An audit of every action by view against the keys the handlers bind left develop without zoom, pan, fit or a way back to the grid, the grid without select-none, thumbnail size or keywording, People with no key at all, and the export and copy sheets without Enter. It also found the reverse gap FR-UI-5 forbids: pick and reject had no route but P, X and U, and the 2026-09-19 amendment's judging in develop had not been built. Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid a mask. 0-5, P, X and U rate and flag the open photograph without moving on, with stars and Pick/Reject in the top bar as the pointer and touch route. = and - nudge the control last moved by a hundredth of its travel; the framing sliders, perspective included, now count as "last moved", so R puts them back as well. J turns the selected mask part's join chip. Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives pick and reject a pointer and touch route. Sidebar: Enter commits a collection's name, and Enter or Escape hands the keyboard back to the grid, where it used to go nowhere until something was clicked. People: Up and Down walk the rail, F2 puts the name field under the keys, and Escape or Back now leave the screen the way its back button does instead of doing nothing. Sheets: Enter does what the export or copy sheet's button does. The choices follow Lightroom where it has one. No new key steals typing: the grid's and People's keys live on focus holders that are not ancestors of any text field, and the sheets' Enter comes after a focused field has had it. Every binding is tagged beside its handler, and the gate added in the previous commit holds the two to each other.
392 lines
17 KiB
Plaintext
392 lines
17 KiB
Plaintext
// The develop view's own chrome: the strip across the top of the window and
|
||
// the capture-metadata block in the column beside the canvas.
|
||
//
|
||
// Split out of `app.slint`, where they sat above `AppWindow` and were read as
|
||
// part of the application shell. They are not: neither is instantiated
|
||
// anywhere but the develop view, and the shell's job — choosing which of the
|
||
// five screens is up — is easier to read without two unrelated components in
|
||
// front of it.
|
||
|
||
import { Theme } from "theme.slint";
|
||
import { Develop } from "session.slint";
|
||
import { Transfer } from "adjust.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel } from "widgets.slint";
|
||
import { Labels, LabelMark, LabelPicker } from "labels.slint";
|
||
import { StarStrip } from "library.slint";
|
||
|
||
// Status strip — surfaces the GPU backend and adapter, which matters during
|
||
// v0.1 because assumption A1 is exactly "does this compositing path work on
|
||
// this hardware". Seeing the adapter at a glance makes vendor differences
|
||
// obvious during the S1/S2 spikes.
|
||
export component StatusBar inherits Rectangle {
|
||
in property <string> adapter;
|
||
in property <string> backend;
|
||
in property <string> layout-class;
|
||
in property <int> fps;
|
||
/// FR-UI-1: the layout class, so the instrumentation can stand down on a
|
||
/// strip too narrow to carry both it and the controls.
|
||
in property <bool> expanded: true;
|
||
in property <string> filename;
|
||
in property <string> position;
|
||
/// Only offered where there is a library to go back to — with files named
|
||
/// on the command line there is no grid behind this view.
|
||
in property <bool> can-return-to-library: false;
|
||
/// Whether the develop column is currently shown, for the toggle's label.
|
||
in property <bool> panel-visible: true;
|
||
/// What the export button says. Rust owns it because the answer depends
|
||
/// on settings this component does not see — the format, and whether the
|
||
/// destination is this device or the server.
|
||
in property <string> export-label: "Export";
|
||
in property <bool> export-busy: false;
|
||
/// What the last export did. Sits beside the button rather than in a
|
||
/// dialogue: an export that succeeded needs no acknowledging, and one
|
||
/// that failed needs its reason where the retry is.
|
||
in property <string> export-status;
|
||
/// Whether the edit can be stepped either way (FR-DEV-5).
|
||
in property <bool> can-undo: false;
|
||
in property <bool> can-redo: false;
|
||
/// TRACES: FR-CAT-5
|
||
/// The open photograph's colour label code, 0 for none, -1 where there
|
||
/// is no library to hold one (files named on the command line).
|
||
in property <int> colour-label: -1;
|
||
/// Whether the label choices are open in the strip.
|
||
property <bool> picking-label: false;
|
||
callback label-picked(int);
|
||
/// TRACES: FR-UI-5 | FR-CULL-4
|
||
/// The open photograph's stars, or -1 where there is no library to hold
|
||
/// them, and its flag: 0 none, 1 pick, 2 reject.
|
||
in property <int> rating: -1;
|
||
in property <int> flag: 0;
|
||
callback rated(int);
|
||
callback flagged(int);
|
||
|
||
callback back-to-library();
|
||
callback open-people();
|
||
callback open-settings();
|
||
callback toggle-panel();
|
||
callback export-image();
|
||
callback undo();
|
||
callback redo();
|
||
|
||
// 44px and `surface`, the same bar the library and settings draw.
|
||
//
|
||
// It was 28px — the control height — which was a reasonable size for a
|
||
// readout and the wrong size for the thing it actually is: this strip
|
||
// carries the only way out of develop, and it was half the height of the
|
||
// identical control on the two screens either side of it. Three screens
|
||
// whose headers do not agree read as three applications, and the one place
|
||
// the user needs to find a way back was the one drawn smallest.
|
||
//
|
||
// Controls keep their own 28px and are centred in it, exactly as the
|
||
// library header centres the same buttons: a `Button` sets a fixed height,
|
||
// which a HorizontalLayout parks at the top of the row rather than
|
||
// centring for it.
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
// Scrolls rather than overflowing. A layout given less width than its
|
||
// children need does not shrink them — it overruns the edge and reports
|
||
// the oversized minimum to whatever contains it. Collapsing the
|
||
// instrumentation below buys back about 200 logical pixels, which is
|
||
// enough for a tablet; it is not enough for a phone, and nothing here
|
||
// should be unreachable merely because the window is narrow.
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, strip.preferred-width);
|
||
|
||
strip := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
alignment: start;
|
||
|
||
// Kept instantiated with `visible` rather than wrapped in an `if`:
|
||
// the strip is inside the layout that `expanded` feeds, and adding a
|
||
// conditional child here is the shape that has already caused binding
|
||
// loops in this file (see the panel below).
|
||
Button {
|
||
text: "‹ Library";
|
||
y: (parent.height - self.height) / 2;
|
||
visible: root.can-return-to-library;
|
||
clicked => { root.back-to-library(); }
|
||
}
|
||
|
||
// The third screen, reachable from the second. Beside the way back
|
||
// because it is the same kind of move — leaving this photograph for
|
||
// somewhere else in the library — and because a mode you can only
|
||
// reach from one of the other two is not a peer of them, whatever the
|
||
// navigation model says.
|
||
Button {
|
||
text: "Identity";
|
||
y: (parent.height - self.height) / 2;
|
||
visible: root.can-return-to-library;
|
||
clicked => { root.open-people(); }
|
||
}
|
||
|
||
Value { text: root.filename; compact: true; overflow: elide; }
|
||
|
||
Caption { text: root.position; }
|
||
|
||
// TRACES: FR-CAT-5 | FR-UI-4 | NFR-A11Y-3
|
||
// The label, shown and set here. The button says the label's name
|
||
// beside its mark, so the strip reads "Label: Green" in words; with
|
||
// none it offers "Label". Pressing it opens the six choices in the
|
||
// strip, which close on the one chosen.
|
||
Button {
|
||
text: root.colour-label > 0 ? "Label: " + Labels.names[root.colour-label] : "Label";
|
||
active: root.picking-label;
|
||
visible: root.colour-label >= 0;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.picking-label = !root.picking-label; }
|
||
}
|
||
|
||
if root.colour-label > 0: LabelMark {
|
||
code: root.colour-label;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
if root.picking-label && root.colour-label >= 0: LabelPicker {
|
||
current: root.colour-label;
|
||
chosen(code) => {
|
||
root.label-picked(code);
|
||
root.picking-label = false;
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-UI-5 | FR-CULL-4 | NFR-A11Y-3
|
||
// The judgement, shown and set on the photograph that is open — the
|
||
// pointer and touch half of the rating and flag keys, which a tablet
|
||
// has no number row for (FR-UI-5, amended 2026-09-19). The grid's own
|
||
// stars, so a rating looks the same in both places; the star already
|
||
// set clears it, as there.
|
||
StarStrip {
|
||
rating: max(0, root.rating);
|
||
show-empty: true;
|
||
can-trash: false;
|
||
visible: root.rating >= 0;
|
||
y: (parent.height - self.height) / 2;
|
||
rate(n) => { root.rated(n); }
|
||
}
|
||
|
||
// Pressed again, a flag comes off: two buttons that each toggle,
|
||
// rather than a third for "none" that is only ever pressed to undo
|
||
// one of the other two.
|
||
Button {
|
||
text: root.flag == 1 ? "Picked" : "Pick";
|
||
active: root.flag == 1;
|
||
visible: root.rating >= 0;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.flagged(root.flag == 1 ? 0 : 1); }
|
||
}
|
||
|
||
Button {
|
||
text: root.flag == 2 ? "Rejected" : "Reject";
|
||
active: root.flag == 2;
|
||
visible: root.rating >= 0;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.flagged(root.flag == 2 ? 0 : 2); }
|
||
}
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
|
||
// --- instrumentation lives in Settings now ------------------------
|
||
//
|
||
// The render backend, the layout class and the frame rate are
|
||
// developer readouts. They cost about 200 logical pixels of a strip
|
||
// that also carries the only way out of develop, the undo pair, the
|
||
// panel toggle and the export button — and a `HorizontalLayout` given
|
||
// less width than its children's minimums does not shrink them, it
|
||
// runs off the end.
|
||
//
|
||
// A tablet in portrait is 768 logical pixels and was already below the
|
||
// breakpoint that hid them, so it kept its header. Landscape is 1200
|
||
// and is not, so the readouts came back and the header ran off the
|
||
// right-hand edge there instead — a breakpoint that hides a problem at
|
||
// one size and not another is a workaround, not a fix.
|
||
//
|
||
// The backend, the frame rate and the layout class used to live here.
|
||
// They are diagnostics — read once when something looks wrong, and
|
||
// never again — and as permanent furniture in a 44px strip they cost
|
||
// three slots that the controls beside them actually needed. On a
|
||
// tablet in landscape the header ran off the right-hand edge, and this
|
||
// was a third of the reason.
|
||
//
|
||
// They are in Settings under ABOUT now, next to the version, which is
|
||
// where someone goes when they have a bug to report rather than a
|
||
// photograph to edit.
|
||
|
||
// Undo and redo, in the strip rather than in the develop column: the
|
||
// panel can be put away, and the one control that takes back a
|
||
// mis-drag must not go away with it. Disabled rather than hidden, so
|
||
// the pair keeps its place and the keyboard shortcut has something
|
||
// visible to correspond to.
|
||
Button {
|
||
text: "Undo";
|
||
enabled: root.can-undo;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.undo(); }
|
||
}
|
||
|
||
Button {
|
||
text: "Redo";
|
||
enabled: root.can-redo;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.redo(); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-6
|
||
// Copy and paste, in the strip beside undo for the reason undo is
|
||
// here: they act on the whole edit, not on any one panel, and the
|
||
// column can be put away. They sat in the column under a "SETTINGS"
|
||
// heading, which read as application settings and hid them behind a
|
||
// scroll and behind the panel toggle.
|
||
//
|
||
// The paste button names what would be pasted — "3 adjustments", and
|
||
// whether the crop is staying behind — because "Paste" alone asks the
|
||
// user to remember what they copied.
|
||
Button {
|
||
text: "Copy";
|
||
enabled: Develop.enabled;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { Transfer.copy(); }
|
||
}
|
||
|
||
// Enabled on `armed` rather than on a non-empty summary, so pasting a
|
||
// neutral copy — which clears this image — stays available.
|
||
Button {
|
||
text: !Transfer.armed ? "Paste"
|
||
: "Paste " + Transfer.summary
|
||
+ (Transfer.framing-withheld ? ", no crop" : "");
|
||
enabled: Develop.enabled && Transfer.armed;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { Transfer.paste(); }
|
||
}
|
||
|
||
// The saved half: this edit, kept under a name.
|
||
Button {
|
||
text: "Presets…";
|
||
enabled: Develop.enabled;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { Transfer.open-presets(); }
|
||
}
|
||
|
||
// Show or hide the develop column. On a tablet the panel is 280px of a
|
||
// screen that is mostly photograph, and the whole point of opening an
|
||
// image is to look at it — so being able to put the instruments away
|
||
// and bring them back is worth a control of its own. The glyph points
|
||
// the way the panel will move.
|
||
Button {
|
||
text: root.panel-visible ? "Hide panel ›" : "‹ Panel";
|
||
active: root.panel-visible;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.toggle-panel(); }
|
||
}
|
||
|
||
Caption {
|
||
text: root.export-status;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The export itself, beside the settings that shape it. This is the
|
||
// screen where a photograph is finished, so it is the screen where
|
||
// one is asked for — the grid can export a selection later, but a
|
||
// single finished frame is exported from in front of it.
|
||
Button {
|
||
text: root.export-busy ? "Exporting…" : root.export-label;
|
||
enabled: !root.export-busy;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.export-image(); }
|
||
}
|
||
|
||
// Reachable from develop as well as from the grid: export defaults are
|
||
// most likely to be wanted with a finished photograph on screen, which
|
||
// is exactly where this bar is and the library header is not.
|
||
Button {
|
||
text: "Settings";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.open-settings(); }
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
/// What the file says about how the photograph was taken.
|
||
///
|
||
/// A global rather than four properties on the panel, for the reason
|
||
/// `session.slint` gives at length: the panel is drawn twice — once in the
|
||
/// develop column and once in the portrait dock — and four lines of forwarding
|
||
/// copied into the second composition is four lines that can fall behind the
|
||
/// first without anything failing.
|
||
///
|
||
/// Nothing here is a control. It is the one panel in the column that only
|
||
/// reports, which is why this global carries no callback at all.
|
||
export global Capture {
|
||
in property <string> camera;
|
||
/// TRACES: FR-DEV-3
|
||
/// The lens, and whether it matched a correction profile.
|
||
///
|
||
/// Here rather than beside the optical sliders, and the reason is what the
|
||
/// line is *for*. `dr-lens` states the rule — an automatic correction that
|
||
/// silently did nothing is worse than one the user can see is unavailable
|
||
/// — and the fact it reports is a fact about the file: which lens took
|
||
/// this photograph, and whether the database has heard of it. That is the
|
||
/// same kind of thing as the body and the exposure, and it wants reading
|
||
/// once on opening rather than hunting for under a group filter.
|
||
in property <string> lens;
|
||
in property <string> exposure;
|
||
in property <string> dimensions;
|
||
}
|
||
|
||
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
||
// which is generated from pipeline capabilities rather than written here.
|
||
//
|
||
// Flat, like everything else in this column. This was briefly a collapsible
|
||
// section so that every panel could be put away; the sidebar as a whole now
|
||
// closes from the status strip instead, which is the control that was actually
|
||
// wanted, and the per-group lids only stood between the user and the controls.
|
||
export component InfoPanel inherits Rectangle {
|
||
background: transparent;
|
||
height: panel.preferred-height;
|
||
|
||
panel := Panel {
|
||
// Flat: this abuts the adjust panel below it and the rule between
|
||
// them is drawn by the column that stacks the two.
|
||
flat: true;
|
||
width: 100%;
|
||
|
||
PanelHeading { text: "IMAGE"; }
|
||
|
||
Value {
|
||
text: Capture.camera == "" ? "—" : Capture.camera;
|
||
placeholder: Capture.camera == "";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Label { text: Capture.exposure; }
|
||
|
||
// Dim, like the dimensions below it: this is something the file says,
|
||
// not something the photographer chose. An empty string draws nothing
|
||
// — a session built from pixels with no header has no lens to report,
|
||
// and an empty row is honest where "Unknown" would be noise.
|
||
Caption {
|
||
text: Capture.lens;
|
||
visible: Capture.lens != "";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Caption { text: Capture.dimensions; }
|
||
}
|
||
}
|