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; } }