// 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, Field } 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, } /// TRACES: FR-DEV-5 /// One named snapshot of the open edit. export struct SnapshotRow { /// Routing back to the core. Opaque to this file. id: string, name: string, /// The canvas is showing this one instead of the edit, while held. comparing: bool, } /// TRACES: FR-DEV-5 | FR-DEV-7 /// A snapshot: press it to go back to it, hold the eye beside it to look. component SnapshotItem inherits Rectangle { in property data; in property enabled: true; callback restored(); /// Both edges, like "Before": down shows the snapshot, up shows the edit. callback compared(bool); callback removed(); height: Theme.row-height; background: root.data.comparing ? Theme.selected : (touch.has-hover ? Theme.hover : transparent); // Behind the two buttons, so a press on either does its own job and a // press anywhere else on the row is the restore. 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.restored(); } } HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Label { text: root.data.name; emphasised: root.data.comparing || touch.has-hover; horizontal-stretch: 1; overflow: elide; vertical-alignment: center; } // GESTURE: See the photograph as a snapshot had it // where: Develop // touch: Press and hold the eye beside the snapshot // pointer: Press and hold the eye beside the snapshot // why: The same hold as "Before", against a point the // photographer chose rather than the file: "the version // I liked twenty minutes ago" is how a choice between two // treatments is actually made. It takes no history step // and changes nothing; letting go puts the edit back. // manual: history-snapshots-presets // // TRACES: FR-DEV-7 eye := TouchArea { width: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; pointer-event(event) => { if event.kind == PointerEventKind.down && event.button == PointerEventButton.left { root.compared(true); } if event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel { root.compared(false); } } Label { text: "◐"; emphasised: eye.has-hover || root.data.comparing; horizontal-alignment: center; vertical-alignment: center; } } cut := TouchArea { width: Theme.touch-target; mouse-cursor: pointer; enabled: root.enabled; clicked => { root.removed(); } Label { text: "×"; emphasised: cut.has-hover; horizontal-alignment: center; vertical-alignment: center; } } } } 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); /// TRACES: FR-DEV-5 /// The named snapshots of the open edit, oldest first. Rust owns the /// list; the panel only names, restores, deletes and holds them. in property <[SnapshotRow]> snapshots; /// The name typed for the next one. Empty is allowed: Rust numbers it. callback snapshot-taken(string); callback snapshot-restored(string); callback snapshot-removed(string); /// TRACES: FR-DEV-7 /// `true` on the way down, `false` on the way up. callback snapshot-compared(string, bool); } 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; } // TRACES: FR-DEV-5 // Snapshots above the steps: a snapshot is the state worth keeping // out of a run of steps, so it sits where the steps lead to. // // GESTURE: Keep the photograph as it is now, under a name // where: Develop // touch: Type a name in the History panel and press Snapshot // pointer: Type a name in the History panel and press Snapshot // why: The history is forgotten with the sitting, on purpose; // a snapshot is the photographer saying this one should // not be. It is written into the sidecar as a version // of the edit, so it survives a restart and reaches the // other device. Pressing a snapshot puts the photograph // back to it, as one step that undo takes back whole. // manual: history-snapshots-presets if Develop.enabled: HorizontalLayout { spacing: Theme.gap-sm; name := Field { label: "Snapshot name"; placeholder: "Name this state"; horizontal-stretch: 1; } Button { text: "Snapshot"; clicked => { Steps.snapshot-taken(name.text); name.text = ""; } } } for snapshot in Steps.snapshots: SnapshotItem { data: snapshot; enabled: Develop.enabled; restored => { Steps.snapshot-restored(snapshot.id); } compared(down) => { Steps.snapshot-compared(snapshot.id, down); } removed => { Steps.snapshot-removed(snapshot.id); } } 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); } } } }