feat(slint): Local Library folder management — compact table + per-folder sync/delete

- Replace the folder list with a compact table (sticky header: FOLDER / LAST SCAN /
  STATUS + actions, internal scroll).
- Per-row sync (scans only that folder) and per-row delete (removes the DB entry +
  indexed tracks, never the files); delete no longer relies on rfd/zenity (which
  fails-closed on zenity-less systems) — direct removal + a toast.
- Fix the row event order so the per-row buttons receive their clicks instead of
  toggling selection.
- Fix mis-centered button glyphs (Stop, change-path, scan, tag-editor search): an
  inner layout filled its parent so the centering collapsed to x:0 — use explicit
  padding.

cc
This commit is contained in:
vicrodh committed 2026-06-09 01:44:48 -06:00
1 parent 41c5261613
commit f508758d32
3 files changed
+164 -60

No files matched your search

@@ -177,7 +177,8 @@ export component TagEditorModal inherits Rectangle {
border-color: Theme.border-subtle;
background: search-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
search-row := HorizontalLayout {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
padding-left: 13px;
padding-right: 13px;
spacing: 6px;
if TagEditorState.remote_searching: VerticalLayout {
alignment: center;
@@ -132,7 +132,8 @@ export component LibFolderEditModal inherits Rectangle {
border-color: Theme.border-subtle;
background: change-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
change-row := HorizontalLayout {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
padding-left: 11px;
padding-right: 11px;
spacing: 6px;
VerticalLayout {
alignment: center;
@@ -309,7 +310,8 @@ export component LibFolderEditModal inherits Rectangle {
border-color: Theme.border-subtle;
background: scan-ta.has-hover && self.can-scan ? Theme.surface-hover : Theme.surface-elevated;
scan-row := HorizontalLayout {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
padding-left: 13px;
padding-right: 13px;
spacing: 7px;
VerticalLayout {
alignment: center;
@@ -83,79 +83,160 @@ component IconBtn inherits Rectangle {
}
}
// One folder row.
// Shared column geometry for the folder table (header + rows MUST match).
// checkbox 20 · folder (stretch) · last-scan 120 · status 84 · actions 76.
component FolderTableHeader inherits Rectangle {
height: 26px;
HorizontalLayout {
padding-left: 10px;
padding-right: 12px;
spacing: 12px;
Rectangle { width: 20px; }
Text {
text: @tr("FOLDER");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("LAST SCAN");
width: 120px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("STATUS");
width: 84px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Rectangle { width: 76px; }
}
}
// One folder row — a compact table row aligned to FolderTableHeader's columns.
component FolderRow inherits Rectangle {
in property <string> display-name;
in property <string> last-scan-label;
in property <bool> enabled-folder;
in property <bool> is-network;
in property <bool> accessible;
in property <bool> selected;
in property <bool> scanning;
callback toggle();
height: 48px;
callback sync();
callback remove();
height: 40px;
border-radius: Radius.sm;
background: root.selected
? Theme.surface-elevated
: (ta.has-hover ? Theme.surface-hover : transparent);
// Declared FIRST so it sits BENEATH the per-row IconBtns: their own
// TouchAreas (on top) take their clicks (sync/remove), while clicks on the
// empty row area fall through to here to toggle selection. (Was last —
// i.e. on top — which swallowed the button clicks into a select-toggle.)
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.toggle(); }
}
HorizontalLayout {
padding-left: 10px;
padding-right: 12px;
spacing: 12px;
// Select.
VerticalLayout {
width: 20px;
alignment: center;
QbzCheckbox {
checked: root.selected;
toggled => { root.toggle(); }
}
}
VerticalLayout {
alignment: center;
QbzIcon {
source: root.is-network
? @image-url("../assets/icons/network.svg")
: @image-url("../assets/icons/hard-drive.svg");
width: 18px;
height: 18px;
tint: root.is-network
? (root.accessible ? #3fae6a : #e0564f)
: Theme.text-secondary;
}
}
VerticalLayout {
alignment: center;
// FOLDER: type glyph + name.
HorizontalLayout {
horizontal-stretch: 1;
Text {
text: root.display-name;
color: root.enabled-folder ? Theme.text-primary : Theme.text-muted;
font-size: Typography.body;
overflow: elide;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.is-network
? @image-url("../assets/icons/network.svg")
: @image-url("../assets/icons/hard-drive.svg");
width: 16px;
height: 16px;
tint: root.is-network
? (root.accessible ? #3fae6a : #e0564f)
: Theme.text-secondary;
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.display-name;
color: root.enabled-folder ? Theme.text-primary : Theme.text-muted;
font-size: Typography.body;
overflow: elide;
}
}
}
// Square status badge (ADR-008: plain text, not a pill).
if !root.enabled-folder || (root.is-network && !root.accessible): VerticalLayout {
// LAST SCAN.
VerticalLayout {
width: 120px;
alignment: center;
Rectangle {
width: badge-text.preferred-width + 16px;
height: 22px;
border-radius: Radius.sm;
background: Theme.surface-elevated;
border-width: 1px;
border-color: !root.enabled-folder ? Theme.border-subtle : #e0564f;
badge-text := Text {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
text: !root.enabled-folder ? @tr("Disabled") : @tr("Unavailable");
color: !root.enabled-folder ? Theme.text-muted : #e0564f;
font-size: Typography.legal;
font-weight: Typography.medium;
Text {
text: root.last-scan-label;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
// STATUS.
VerticalLayout {
width: 84px;
alignment: center;
Text {
text: !root.enabled-folder
? @tr("Disabled")
: ((root.is-network && !root.accessible) ? @tr("Unavailable") : @tr("Active"));
color: (root.is-network && !root.accessible && root.enabled-folder)
? #e0564f
: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.medium;
overflow: elide;
}
}
// ACTIONS: per-folder sync + delete (recycled IconBtns).
HorizontalLayout {
width: 76px;
spacing: 4px;
alignment: end;
VerticalLayout {
alignment: center;
IconBtn {
icon: @image-url("../assets/icons/refresh-cw.svg");
enabled: root.enabled-folder && !root.scanning;
clicked => { root.sync(); }
}
}
VerticalLayout {
alignment: center;
IconBtn {
icon: @image-url("../assets/icons/trash-2.svg");
clicked => { root.remove(); }
}
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.toggle(); }
}
}
@@ -223,19 +304,33 @@ export component LocalLibrarySettings inherits VerticalLayout {
color: Theme.text-muted;
font-size: Typography.body;
}
if LibraryFoldersState.folders.length > 0: Rectangle {
height: Math.min(LibraryFoldersState.folders.length * 50px, 380px);
ScrollView {
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 2px;
for f in LibraryFoldersState.folders: FolderRow {
display-name: f.display-name;
enabled-folder: f.enabled;
is-network: f.is-network;
accessible: f.accessible;
selected: f.selected;
toggle => { LibraryManageActions.toggle-folder-select(f.id); }
// Compact table: sticky header + a scrolling body (internal scroll, capped
// height) aligned to the same columns. Rows recycle the sync/delete buttons.
if LibraryFoldersState.folders.length > 0: VerticalLayout {
spacing: 0px;
FolderTableHeader { }
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle {
height: Math.min(LibraryFoldersState.folders.length * 41px, 360px);
ScrollView {
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 1px;
for f in LibraryFoldersState.folders: FolderRow {
display-name: f.display-name;
last-scan-label: f.last-scan-label;
enabled-folder: f.enabled;
is-network: f.is-network;
accessible: f.accessible;
selected: f.selected;
scanning: LibraryScanState.scanning;
toggle => { LibraryManageActions.toggle-folder-select(f.id); }
sync => { LibraryManageActions.scan-folder(f.id); }
remove => { LibraryManageActions.remove-folder(f.id); }
}
}
}
}
@@ -259,14 +354,20 @@ export component LocalLibrarySettings inherits VerticalLayout {
}
// Stop scan.
Rectangle {
width: stop-row.preferred-width + 22px;
// Size to content. The inner HorizontalLayout fills this
// Rectangle, so the old `x: (parent-self)/2` centering
// collapsed to 0 and the glyph touched the border — use
// explicit padding on the row instead (same fix as the
// album VersionPicker).
width: stop-row.preferred-width;
height: 30px;
border-radius: Radius.sm;
border-width: 1px;
border-color: #e0564f;
background: stop-ta.has-hover ? #e0564f22 : transparent;
stop-row := HorizontalLayout {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
padding-left: 12px;
padding-right: 12px;
spacing: 6px;
VerticalLayout {
alignment: center;