// The collections sidebar: a hierarchy of collections, and a drop target for // images dragged out of the grid. // // TRACES: FR-CAT-7 | FR-UI-3 | FR-UI-5 // // # Why the tree is flat here // // Slint has no recursive component instantiation, so a `for` cannot nest itself // to arbitrary depth. The tree arrives from Rust already flattened, each row // carrying its own `depth` — indentation is drawn from that integer. The // ordering rules (siblings by name, children after their parent) live in // `dr_catalog::collections::tree`, which is where the data model already is. // // # Drag and drop // // Each row is a `DropArea`, and the grid's cells are `DragArea`s. Slint owns the // gesture: pointer capture, the threshold that separates a click from a drag, // arbitration against the grid's `Flickable`, the image under the cursor, and // delivery of the payload to whichever row the pointer is actually over. // // This replaced a hand-rolled version that tracked presses through `TouchArea` // and is worth recording, because the failure was not obvious: an interactive // `Flickable` claims any drag beginning inside it for scrolling and *cancels* // the child TouchArea's press, so the gesture could never leave the grid. The // hand-rolled version also had to decide the drop target from the last row // hovered, since a captured pointer is invisible to everything else — which // meant a tree rebuilt mid-drag could redirect the drop. `DropArea` hit-tests // the release itself, so neither problem exists. // // `can-drop` runs while the cursor moves and decides whether this row will // accept — a saved filter refuses there, so the cursor says no *before* the // release rather than the drop being silently discarded after it. import { Theme } from "theme.slint"; import { Button } from "widgets.slint"; // One row of the collection tree. export struct CollectionRow { id: int, name: string, // 0 for a top-level collection. Indentation is drawn from this. depth: int, // Direct members. A parent shows this and its descendants' total // separately: an empty set of full children must not read as full. direct-count: int, // Distinct images including descendants'. deep-count: int, has-children: bool, expanded: bool, // A saved filter. Cannot be dropped onto — its membership is its selector. smart: bool, } // A collection row: disclosure arrow, name, count, and a drop highlight. component TreeRow inherits Rectangle { in property entry; in property selected; /// Whether a drag hovering here could land. A saved filter's membership is /// computed from its selector, so it refuses — and says so on hover. in property drop-allowed: true; /// Whether this row is being renamed, in which case its name is drawn as an /// editable field rather than as text. in property renaming: false; callback clicked(); callback toggle(); /// The pointer is dwelling here mid-drag. What springs a collapsed parent /// open, so a child can be reached without ending the drag. callback drag-over(bool); /// Images were dropped on this row. callback dropped(); callback context-menu(); /// Start renaming this row — a double-click on the name. callback rename-requested(); /// The new name, committed with Enter or by clicking away. callback rename-committed(string); /// Renaming abandoned with Escape; the old name stands. callback rename-cancelled(); height: Theme.row-height; // `drop.has-drag` is only true once `can-drop` has accepted, so a refused // row never lights up as though it would take the images. background: drop.has-drag ? Theme.selected : (root.selected ? Theme.surface-raised : (touch.has-hover ? Theme.hover : transparent)); border-radius: Theme.radius-sm; // A drop target is outlined as well as filled: on a dark ground a fill // change alone is easy to miss mid-drag, when the user is watching the // thumbnail under the cursor rather than the row. border-width: drop.has-drag ? 1px : 0px; border-color: Theme.selected-ring; // Behind the content, so the row's own TouchArea still gets ordinary // clicks. A DropArea only takes part in a drag; it does not block presses. drop := DropArea { width: 100%; height: 100%; can-drop(ev) => { // Refused here rather than after the release: the cursor shows // "no" while the user can still aim somewhere else. if (!root.drop-allowed) { return DragAction.none; } return DragAction.copy; } dropped(ev) => { root.dropped(); return DragAction.copy; } // Dwelling over a collapsed parent springs it open. Reported rather // than acted on here, because the dwell timer and the collapse state // live in Rust with the rest of the tree. changed has-drag => { root.drag-over(self.has-drag); } } HorizontalLayout { // Indentation from depth. The guide rail below sits in this space. padding-left: Theme.gap-sm + root.entry.depth * Theme.indent; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; // Disclosure arrow, or an equal blank so names stay aligned between // rows that have children and rows that do not. Rectangle { width: 14px; if root.entry.has-children: Text { text: root.entry.expanded ? "▾" : "▸"; color: Theme.ink-faint; font-size: Theme.text-sm; horizontal-alignment: center; vertical-alignment: center; } // Its own hit area: toggling open must not also select, or every // expand reloads the grid. if root.entry.has-children: TouchArea { clicked => { root.toggle(); } } } // Smart collections read differently from manual ones — the icon is // the only cue that its contents are computed, and that dropping // images on it will be refused. Text { text: root.entry.smart ? "◈" : "▤"; color: root.entry.smart ? Theme.active-dim : Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } // The name, or the field that is replacing it while this row is being // renamed. Two branches of one `if` rather than a TextInput styled to // look like text at rest: a live TextInput would swallow the clicks // that select the collection and the presses that begin a drag. if !root.renaming: Text { text: root.entry.name; color: root.selected ? Theme.ink : Theme.ink-dim; font-size: Theme.text; font-weight: root.selected ? 600 : 400; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } if root.renaming: Rectangle { horizontal-stretch: 1; background: Theme.ground; border-radius: Theme.radius-sm; border-width: 1px; border-color: Theme.active; edit := TextInput { // Seeded once, when the field appears. Bound two-way to the // row's name it would rewrite the model on every keystroke, // and an edit abandoned with Escape could not be undone. text: root.entry.name; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; single-line: true; // Inset by hand for the same reason `Field` does it: inside a // layout the input stops scrolling its own content once the // text outgrows the box. x: Theme.gap-sm; width: parent.width - 2 * Theme.gap-sm; height: 100%; // Focus and a full selection on appearance, so the placeholder // name a new collection arrives with is replaced by typing // rather than having to be cleared first. init => { self.focus(); self.select-all(); } accepted => { root.rename-committed(self.text); } key-pressed(event) => { // Escape abandons. Handled here rather than on a // surrounding FocusScope, which would never see the key — // the input has focus and consumes it. if (event.text == Key.Escape) { root.rename-cancelled(); return accept; } return reject; } // Clicking away commits rather than discarding: the text is // visible on screen and the user typed it, so throwing it out // for want of an Enter is the surprising choice. changed has-focus => { if (!self.has-focus) { root.rename-committed(self.text); } } } } // The count. A parent shows its deep total, since its own direct // membership is usually zero and "0" beside a full subtree reads as // broken. The distinction is spelled out in the tooltip-less way // available here: parentheses mean "including children". Text { text: root.entry.has-children && root.entry.deep-count != root.entry.direct-count ? "(" + root.entry.deep-count + ")" : (root.entry.direct-count > 0 ? root.entry.direct-count : "") + ""; color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } touch := TouchArea { // Disabled while the field is up, so a click landing on the row rather // than inside the input does not re-select the collection out from // under the edit in progress. enabled: !root.renaming; clicked => { root.clicked(); } // Double-click renames — the gesture a file manager or a Lightroom // panel uses for the same thing, so it needs no discovering. double-clicked => { root.rename-requested(); } pointer-event(ev) => { if (ev.kind == PointerEventKind.down && ev.button == PointerEventButton.right) { root.context-menu(); } } } } export component CollectionsPanel inherits Rectangle { in property <[CollectionRow]> rows; // Which collection scopes the grid. 0 means the whole library. in property selected-id: 0; in property total-images: 0; in property error: ""; callback select(int); callback toggle(int); /// Images were dropped on a collection, by its id. Slint hit-tests the /// release itself, so this is the collection actually under the pointer — /// not the last one hovered. callback dropped-on(int); /// A drag is dwelling over a collection, or has left it. Drives the /// spring-loaded expansion, which is timed in Rust. callback drag-over(int, bool); callback new-collection(); // Right-click on a row: rename, delete, new child. callback row-menu(int); /// Which collection is being renamed, by id. 0 is none. /// /// Driven from Rust rather than held here, because a rename that fails — /// or one begun by *creating* a collection, which happens before this /// panel has the new row — has to be started and ended from that side. in property renaming-id: 0; /// Renaming began on a row: its id. callback rename-start(int); /// A rename was committed: the id, and the new name. callback rename-commit(int, string); /// Renaming was abandoned. callback rename-cancel(); // --- trash (FR-CAT-15) --- // // `selected-id == -1` is the trash being viewed. A sentinel rather than a // separate bool because the sidebar has exactly one selection, and two // flags could disagree about what the grid is showing. /// How many images are in the trash. in property trash-count: 0; /// The count and the bytes it holds, already formatted — "12 · 340 MB". /// Formatted in Rust because Slint has no byte-size formatting and the /// arithmetic would be unreadable inline. in property trash-label: ""; /// How many grid cells are selected, so the trash view can offer to restore /// them. Read here as well as in the library panel because restoring is a /// selection action and the button has to say what it will act on. in property selected-count: 0; /// Images were dropped on the trash: a soft delete. callback dropped-on-trash(); /// Permanently delete everything in the trash. callback empty-trash(); /// Put the selected images back where they came from. callback restore-selected(); width: 232px; background: Theme.surface; VerticalLayout { padding: Theme.gap-sm; spacing: Theme.gap-sm; // --- header ------------------------------------------------------- HorizontalLayout { height: 26px; spacing: Theme.gap-sm; Text { text: "COLLECTIONS"; color: Theme.ink-faint; font-size: Theme.text-sm; font-weight: 700; letter-spacing: 1.2px; vertical-alignment: center; horizontal-stretch: 1; } // New collection. A glyph rather than a word: the header is 232px // wide and the label would crowd out the title. Rectangle { width: 22px; height: 22px; y: (parent.height - self.height) / 2; background: add-touch.pressed ? Theme.pressed : (add-touch.has-hover ? Theme.hover : transparent); border-radius: Theme.radius-sm; Text { text: "+"; color: Theme.ink-dim; font-size: Theme.text-lg; horizontal-alignment: center; vertical-alignment: center; } add-touch := TouchArea { clicked => { root.new-collection(); } } } } // --- the whole library -------------------------------------------- // // Always first and never nested: it is how the user gets back to an // unscoped grid, and burying it inside the tree would make "show me // everything" a thing you have to find. Rectangle { height: Theme.row-height; background: root.selected-id == 0 ? Theme.surface-raised : (all-touch.has-hover ? Theme.hover : transparent); border-radius: Theme.radius-sm; HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Text { text: "All photographs"; color: root.selected-id == 0 ? Theme.ink : Theme.ink-dim; font-size: Theme.text; font-weight: root.selected-id == 0 ? 600 : 400; vertical-alignment: center; horizontal-stretch: 1; } Text { text: root.total-images > 0 ? root.total-images : ""; color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } all-touch := TouchArea { clicked => { root.select(0); } // Dropping onto "All photographs" would mean nothing — an // image is already in the library — so this reports no hover // during a drag and stays inert. } } Rectangle { height: 1px; background: Theme.rule; } // --- the tree ----------------------------------------------------- Flickable { vertical-stretch: 1; viewport-height: root.rows.length * (Theme.row-height + 2px); for row[i] in root.rows: TreeRow { y: i * (Theme.row-height + 2px); width: parent.width; entry: row; selected: row.id == root.selected-id; // A saved filter's membership is computed, so a drop cannot // land there. Refused in `can-drop`, so the cursor says no // before the release rather than after. drop-allowed: !row.smart; renaming: row.id == root.renaming-id; clicked => { root.select(row.id); } toggle => { root.toggle(row.id); } context-menu => { root.row-menu(row.id); } dropped => { root.dropped-on(row.id); } drag-over(over) => { root.drag-over(row.id, over); } rename-requested => { root.rename-start(row.id); } rename-committed(name) => { root.rename-commit(row.id, name); } rename-cancelled => { root.rename-cancel(); } } // Empty state. A blank panel gives no hint that collections exist // at all, let alone that images can be dragged into them. if root.rows.length == 0: VerticalLayout { alignment: center; spacing: Theme.gap-sm; padding: Theme.gap; Text { text: "No collections yet"; color: Theme.ink-dim; font-size: Theme.text-sm; horizontal-alignment: center; } Text { text: "Press + to make one, then drag photographs onto it."; color: Theme.ink-faint; font-size: Theme.text-sm; horizontal-alignment: center; wrap: word-wrap; } } } // --- trash -------------------------------------------------------- // // TRACES: FR-CAT-15 // Below the tree and separated from it, because it is not a collection: // dropping here *moves the file* into a trash folder on the server, // where every collection above merely references. A destination that // changes the library has no business sitting in the same list as ones // that do not. Rectangle { height: 1px; background: Theme.rule; } Rectangle { height: Theme.row-height; background: trash-drop.has-drag ? Theme.selected : (root.selected-id == -1 ? Theme.surface-raised : (trash-touch.has-hover ? Theme.hover : transparent)); border-radius: Theme.radius-sm; border-width: trash-drop.has-drag ? 1px : 0px; border-color: Theme.warn-ink; trash-drop := DropArea { width: 100%; height: 100%; // Deliberately `move`, where a collection drop is `copy`: this // one really does take the photograph out of the library, and // the cursor should say so. can-drop(ev) => { return DragAction.move; } dropped(ev) => { root.dropped-on-trash(); return DragAction.move; } } HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Text { text: "🗑"; color: root.trash-count > 0 ? Theme.warn-ink : Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } Text { text: "Trash"; color: root.selected-id == -1 ? Theme.ink : Theme.ink-dim; font-size: Theme.text; font-weight: root.selected-id == -1 ? 600 : 400; vertical-alignment: center; horizontal-stretch: 1; } // The size, not just the count: "empty trash" is destructive and // what it frees is what tells the user whether they meant it. Text { text: root.trash-count > 0 ? root.trash-label : ""; color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } trash-touch := TouchArea { clicked => { root.select(-1); } } } // Restore sits *above* Empty, and is the only one of the two that names // a number. The recoverable action should be the one the hand reaches // first, and the destructive one should not be what a user finds when // they open the trash looking for a way back. if root.selected-id == -1 && root.selected-count > 0: Button { text: root.selected-count == 1 ? "Restore 1 image" : "Restore " + root.selected-count + " images"; clicked => { root.restore-selected(); } } // Emptying is offered only while the trash is being *looked at*, so it // cannot be hit in passing. It is the one irreversible action in this // panel and it should take a deliberate visit to reach. if root.selected-id == -1 && root.trash-count > 0: Button { text: "Empty trash"; clicked => { root.empty-trash(); } } // --- error -------------------------------------------------------- // // A refused drop or a failed rename says so here rather than only in // the log: the gesture succeeded from the user's point of view, so // silence would read as data loss. if root.error != "": Text { text: root.error; color: Theme.warn-ink; font-size: Theme.text-sm; wrap: word-wrap; } } // Right edge, separating the panel from the grid. Rectangle { x: parent.width - 1px; width: 1px; background: Theme.rule; } }