From e7130ff8914684be46382c5af87daeda569ad4a2 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 16 Aug 2026 23:04:06 +0200 Subject: [PATCH] Give the library header somewhere to put six buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tablet in portrait is 1920 physical pixels at density 400 — 768 logical, which is below the 820 breakpoint, so the library was already taking the compact layout. The compact header simply was not compact: one flat HorizontalLayout holding a menu button, a title, three status readouts and six buttons. Slint's HorizontalLayout has no wrap and no overflow. Given less width than its children want it shrinks each to its minimum and lets the rest run past the edge, so "Change library" arrived as "Change li…", the readouts elided to nothing useful, and the row overflowed anyway. The six actions move into a `HeaderActions` component drawn in one of two places: inline in the header when expanded, and in a disclosure row under it behind a "More" button when compact. One component rather than two copies, because the alternative is six buttons with their visibility rules and callbacks written twice, and the copy that rots is the one behind a disclosure nobody opens while testing. The row closes itself when the window widens, so rotating to landscape does not leave a stray toolbar behind. "More" is a word rather than an ellipsis glyph: "⋯" sitting in a header of elided labels reads as one more truncation, which is the failure this change exists to remove. `LibraryGrid` takes `expanded` to decide, from the window width rather than the device (FR-UI-1) — so a narrow desktop window gets the same treatment. Co-Authored-By: Claude Opus 5 --- ui/dr-ui/ui/app.slint | 5 + ui/dr-ui/ui/library.slint | 211 +++++++++++++++++++++++++++++--------- 2 files changed, 165 insertions(+), 51 deletions(-) diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index a1bf683..bcf896b 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -800,6 +800,11 @@ export component AppWindow inherits Window { LibraryGrid { horizontal-stretch: 1; + // FR-UI-1: the header collapses its actions behind a + // disclosure below the breakpoint. A tablet in portrait is + // 768 logical pixels, which fits the title and the status + // readouts but not six buttons beside them. + expanded: root.expanded; cells: root.library-cells; total: root.library-total; scanning: root.library-scanning; diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 99d7580..7b31592 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -434,7 +434,110 @@ export component FlagMark inherits Rectangle { } } +// The header's action buttons, in one place so they can be drawn in two. +// +// A tablet in portrait is 768 logical pixels wide, and the header wants a +// title, three status readouts and six buttons. Laid out in one row they all +// shrink to their minimum and elide — "Change library" arrives as "Change +// li…" — and the row still overflows its own width. Slint's HorizontalLayout +// has no wrap and no overflow, so the row has to be told what to drop. +// +// Extracted rather than duplicated because the alternative is six buttons +// written twice with their visibility rules and callbacks copied, and the +// copy that gets forgotten is the one behind the disclosure nobody opens +// while testing. +component HeaderActions inherits HorizontalLayout { + in property scope-label; + in property selected-count: 0; + in property scanning: false; + in property syncing: false; + in property scope-pinned: false; + /// Centres each button in a 44px header. Off in the disclosure row, which + /// is sized to its content. + in property centred: true; + in property row-height: 44px; + + callback remove-from-collection(); + callback change-library(); + callback toggle-pin-scope(); + callback sync-now(); + callback rescan(); + callback open-settings(); + + spacing: Theme.gap; + + // Removing from a collection is only meaningful while the grid is scoped + // to one. Offering it unscoped would invite the reading "remove from the + // library", which nothing here does. + if root.scope-label != "" && root.selected-count > 0: Button { + text: "Remove from collection"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.remove-from-collection(); } + } + + if !root.scanning: Button { + text: "Change library"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.change-library(); } + } + + // TRACES: FR-NC-6a + // Keep this collection offline. Only offered when the grid is scoped to + // one: "pin the whole library" is a different and much more expensive + // request, and a button that meant either depending on invisible state + // would be a trap. + if root.scope-label != "": Button { + // No tick on the pinned label: `active` already inverts the button, + // which says the same thing without a symbol inside a string. + text: root.scope-pinned ? "Pinned" : "Pin offline"; + active: root.scope-pinned; + enabled: !root.scanning; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.toggle-pin-scope(); } + } + + Button { + // Shares the finished index so a second device inherits it rather + // than repeating hours of range fetches. + text: root.syncing ? "Syncing…" : "Sync"; + enabled: !root.syncing && !root.scanning; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.sync-now(); } + } + + if !root.scanning: Button { + text: "Rescan"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.rescan(); } + } + + // Last in the row, and unconditional. The buttons before it come and go + // with what the grid is showing; settings is always reachable, and a + // control that moved as its neighbours appeared would be hunted for each + // time. + Button { + text: "Settings"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.open-settings(); } + } +} + export component LibraryGrid inherits Rectangle { + /// TRACES: FR-UI-1 + /// The layout class, from the window width rather than the device. + /// + /// Compact moves the header's actions behind a disclosure; see + /// [`HeaderActions`]. Defaults to expanded so a caller that forgets to + /// pass it gets the desktop layout rather than a hidden toolbar. + in property expanded: true; + /// Whether the compact action row is showing. Local to the grid: it is a + /// disclosure, not a preference, and it should close itself the moment + /// the window is wide enough not to need it. + property actions-open: false; + changed expanded => { + if (root.expanded) { root.actions-open = false; } + } + in property <[LibraryCell]> cells; in property total: 0; in property scanning: false; @@ -749,66 +852,72 @@ export component LibraryGrid inherits Rectangle { overflow: elide; } - // Removing from a collection is only meaningful while the grid - // is scoped to one. Offering it unscoped would invite the - // reading "remove from the library", which nothing here does. - Button { - text: "Remove from collection"; - y: (parent.height - self.height) / 2; - visible: root.scope-label != "" && root.selected-count > 0; - clicked => { root.remove-from-collection(); } + // Expanded: the actions sit in the header as one row. There + // is room, and a disclosure would be a click in front of a + // button that was already visible. + if root.expanded: HeaderActions { + scope-label: root.scope-label; + selected-count: root.selected-count; + scanning: root.scanning; + syncing: root.syncing; + scope-pinned: root.scope-pinned; + remove-from-collection => { root.remove-from-collection(); } + change-library => { root.change-library(); } + toggle-pin-scope => { root.toggle-pin-scope(); } + sync-now => { root.sync-now(); } + rescan => { root.rescan(); } + open-settings => { root.open-settings(); } } - // The header is taller than a control, so these are centred - // in it rather than stretched to fill it. - Button { - text: "Change library"; + // Compact: one button in place of six. Labelled rather than a + // bare glyph, because "⋯" alone in a header of words reads as + // a truncation of the label beside it — which is exactly what + // this change exists to stop happening. + if !root.expanded: Button { + text: root.actions-open ? "Close" : "More"; + active: root.actions-open; y: (parent.height - self.height) / 2; - visible: !root.scanning; - clicked => { root.change-library(); } + clicked => { root.actions-open = !root.actions-open; } } + } + } - // TRACES: FR-NC-6a - // Keep this collection offline. Only offered when the grid is - // scoped to one: "pin the whole library" is a different and - // much more expensive request, and a button that meant either - // depending on invisible state would be a trap. - if root.scope-label != "": Button { - // No tick on the pinned label: `active` already inverts - // the button, which says the same thing without a symbol - // inside a string (see icons.slint). - text: root.scope-pinned ? "Pinned" : "Pin offline"; - active: root.scope-pinned; - enabled: !root.scanning; - y: (parent.height - self.height) / 2; - clicked => { root.toggle-pin-scope(); } - } + // The compact action row, disclosed by "More" above. + // + // A row beneath the header rather than a popup over the grid: a popup + // needs a dismiss rule, an anchor and a decision about what happens + // when the window resizes under it, and all this needs to be is the + // six buttons somewhere they fit. It closes itself when the window + // widens — see `actions-open`. + if !root.expanded && root.actions-open: Rectangle { + height: 44px; + background: Theme.surface; - Button { - // Shares the finished index so a second device inherits it - // rather than repeating hours of range fetches. - text: root.syncing ? "Syncing…" : "Sync"; - enabled: !root.syncing && !root.scanning; - y: (parent.height - self.height) / 2; - clicked => { root.sync-now(); } - } + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + alignment: start; - Button { - text: "Rescan"; - y: (parent.height - self.height) / 2; - visible: !root.scanning; - clicked => { root.rescan(); } + HeaderActions { + centred: true; + scope-label: root.scope-label; + selected-count: root.selected-count; + scanning: root.scanning; + syncing: root.syncing; + scope-pinned: root.scope-pinned; + remove-from-collection => { root.remove-from-collection(); } + change-library => { root.change-library(); } + toggle-pin-scope => { root.toggle-pin-scope(); } + sync-now => { root.sync-now(); } + rescan => { root.rescan(); } + open-settings => { root.open-settings(); } } + } - // Last in the row, and unconditional. The buttons before it - // come and go with what the grid is showing; settings is - // always reachable, and a control that moved as its - // neighbours appeared would be hunted for each time. - Button { - text: "Settings"; - y: (parent.height - self.height) / 2; - clicked => { root.open-settings(); } - } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; } }