// TRACES: FR-DEV-5 | FR-DEV-7 // The steps this photograph has been through, and the way back to any of them. // // **Nothing here names an operation, and nothing here names a step.** The rows // arrive already resolved — Rust turns each step's localisation key into a // display string against the UI's catalogue — so an operation added to the // pipeline as a YAML declaration appears in this list under a sensible name // with no change to this file, on the same terms it appears in the panel // (FR-DEV-3c). // // **Why a list and not just the two buttons.** Undo answers "take back the // last thing", which is the question a photographer asks about the mistake // they have just noticed. It is the wrong instrument for the one they notice // six adjustments later: eight presses, each changing the picture, with no way // to see how far back the mistake was without passing through it. A step is a // whole state in `dr-pipeline`, so arriving at one from six away costs what // arriving from one does — which is what makes a row worth making clickable // rather than decorative. import { Theme } from "theme.slint"; import { Develop } from "session.slint"; import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint"; // One step, flattened for Slint's model system. export struct HistoryRow { // Routing back to the core: this step's position in the stack. **Not** its // position in this list, which runs the other way — see `history-rows` in // `develop.rs` for why the two are deliberately different numbers. index: int, // Resolved in Rust against the UI's catalogue; the core deals in keys. label: string, // The state the photograph is in right now. Exactly one row carries it. current: bool, // A step the photographer has stepped back *out* of, still reachable by // redo. Listed rather than hidden — redo would otherwise arrive somewhere // this panel never mentioned — but drawn as the branch it is. undone: bool, } component StepRow inherits Rectangle { in property data; in property enabled: true; callback picked(); height: Theme.row-height; background: root.data.current ? Theme.selected : (touch.has-hover ? Theme.hover : transparent); touch := TouchArea { width: 100%; height: max(parent.height, Theme.touch-target); y: (parent.height - self.height) / 2; enabled: root.enabled; mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default; clicked => { root.picked(); } } HorizontalLayout { // Small, because the panel around this already pads. The row's // background is the highlight for the current step, so it wants to be // a band the width of the column rather than a chip inset from it. padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; // The mark for where the photograph stands. A filled bar against the // leading edge rather than a tick beside the name: the eye finds one // edge down a column of forty rows, and a glyph in the text column // would have to be read. Rectangle { width: 2px; height: parent.height; background: root.data.current ? Theme.active : transparent; } Label { text: root.data.label; emphasised: root.data.current || touch.has-hover; horizontal-stretch: 1; overflow: elide; // Dimmed rather than removed: this step is a future the // photographer stepped out of, and it is still where redo goes. opacity: root.data.undone ? 0.45 : 1.0; } } } /// TRACES: FR-DEV-5 /// The edit as a stack of steps, and the two ways of moving through it. /// /// A global rather than five properties and three callbacks on the panel — see /// `session.slint` for the argument. The status strip and the keyboard reach /// the same two actions; before this all three spellings went through the /// window root and had to be kept in step by hand. /// /// Rust owns whether there is anywhere to step, because "anywhere" is a /// position in a stack of snapshots this side never sees. A whole drag is one /// step; the coalescing that makes it so lives in `dr-pipeline`. export global Steps { /// Every step, newest first. Rust owns the order and stamps each row with /// its own position in the stack, so nothing here does arithmetic to turn /// a row back into a step. in property <[HistoryRow]> rows; in property can-undo: false; in property can-redo: false; /// What undo would take back, already resolved. Empty when there is /// nowhere to go. in property undo-label: ""; callback undo(); callback redo(); /// A row's own `index`, not its position in `rows`. callback picked(int); } export component HistoryPanel inherits Rectangle { background: Theme.surface; // **Flat, deliberately** — `MaskPanel` carries the long version of this // and it applies here unchanged: a nested layout under-reports its height, // so what follows it gets drawn on top of what came before, which is // invisible in the source and obvious the moment anyone opens the panel. // Every element is a direct child of the one layout that measures them, // and the ones that come and go carry their own condition rather than // being grouped inside a wrapper. // // No explicit height either, for the same reason `AdjustPanel` and // `MaskPanel` declare none: the row count changes as the photographer // works, and a height pinned to a layout's preferred size is one more // thing that has to keep up with a repeater. layout := VerticalLayout { padding: Theme.gap; spacing: Theme.gap-sm; alignment: start; // A heading, not a collapsible. `ComposePanel` carries the argument // and it holds here: the list is last in the column, so what a lid // would save is scrolling past nothing. HorizontalLayout { PanelHeading { text: "HISTORY"; } Rectangle { horizontal-stretch: 1; } if Develop.enabled && Steps.rows.length > 1: Value { text: Steps.rows.length - 1 + (Steps.rows.length == 2 ? " step" : " steps"); } } if !Develop.enabled: Caption { text: "No image"; } // The pair, here as well as in the status strip. The strip's copy is // the one that survives the column being put away; this one sits // against the list that says what it will do, which is the pairing // that makes either of them legible. if Develop.enabled: HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Undo"; enabled: Steps.can-undo; horizontal-stretch: 1; clicked => { Steps.undo(); } } Button { text: "Redo"; enabled: Steps.can-redo; horizontal-stretch: 1; clicked => { Steps.redo(); } } } // What the left-hand button would take back, spelled out. // // On a caption rather than on the button, because `Button` sizes to // its label and does not elide: "Undo Highlights & Shadows" is most of // a 280px column on its own, and two of those would lever the column // open — `MaskPanel` has the same note about an unwrapped sentence. // // Undo only. Redo's destination is the row directly above the mark in // the list below, where it can be seen; the step undo takes back is // the one the photographer is standing on and stopped tracking after a // run of small adjustments, which is the moment they are least willing // to press a button to find out. if Develop.enabled && Steps.can-undo: Caption { text: "Undo: " + Steps.undo-label; overflow: elide; } if Develop.enabled && Steps.rows.length > 0: Rectangle { height: 1px; background: Theme.rule; } // Newest first. The list is consulted to take back something just // done rather than browsed from the beginning — the order // `dr_catalog::trash` settled on for the same question — and it keeps // the end being worked at against the heading rather than sixty-four // rows below it. for row in Steps.rows: StepRow { data: row; enabled: Develop.enabled; picked => { Steps.picked(row.index); } } } }