Files
DarkRoom/ui/dr-ui/ui/app.slint
T
dtourolleandClaude Opus 5 b0b6dd559a
Build and test / Desktop (Linux) (push) Failing after 2m37s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Failing after 6s
Show what is selected, and let a collection be made of it
Three things a selection needed and did not have.

**Seeing it.** The count existed — "12 selected" — in the header row, which
scrolls sideways. On a tablet it sat past the right-hand edge along with every
button beside it, so a selection was something you could make and then not
see. A selection you cannot see is one you act on by accident.

**Putting it down.** The only way to clear one was "Done", which also leaves
select mode — so after filing forty photographs the next forty began by
re-entering a mode the user had not meant to leave. Clearing is now its own
action and keeps the mode.

**Filing it somewhere new.** Making a collection of a selection took four
steps: create one, find it in the tree, select the photographs again because
creating it changed the scope, then add them. It is one press, which is how a
selection is usually meant — it is gathered *because* it is going somewhere.

The new collection is created at the top level rather than inside the current
scope, unlike the tree's "+". A selection can be gathered from anywhere,
including across collections, so filing it under whichever one happens to be
open would put it somewhere its contents did not come from. It opens straight
into its name field, for the reason `collection_new` already does: the
placeholder name is nobody's choice, and making the user find the rename
afterwards is asking them to finish a job we started.

All of it on its own strip beside the date range's, appearing only while there
is a selection — the third control this session that was invisible for being
put in a row that scrolls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:57:35 +02:00

2409 lines
121 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView }
export { ViewMode, GradientHandle, HandleRole }
// 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.
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;
callback back-to-library();
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(); }
}
Value { text: root.filename; compact: true; overflow: elide; }
Caption { text: root.position; }
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(); }
}
// 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;
}
}
// 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.
component InfoPanel inherits Rectangle {
in property <string> camera;
in property <string> exposure;
in property <string> dimensions;
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: root.camera == "" ? "—" : root.camera;
placeholder: root.camera == "";
wrap: word-wrap;
}
Label { text: root.exposure; }
Caption { text: root.dimensions; }
}
}
export component AppWindow inherits Window {
title: "DarkRoom";
// The one raster asset in the UI, and the exception that the header of
// icons.slint does not cover: everything drawn *inside* the window is a
// Path because a font might not have the glyph, but the window icon is
// never drawn by us. It is handed to the window manager, which wants
// pixels. Pre-shaped with rounded corners and transparent outside them,
// because desktop window managers composite it as-is rather than masking
// it the way Android does — the Android launcher icon is a separate,
// maskable set under apps/darkroom-android/android/res.
//
// Where it actually shows up is narrower than it looks: X11 and Windows
// read it, and Wayland ignores it outright in favour of matching app_id
// against an installed .desktop file, which this repo does not install.
// Android ignores it too — there is no title bar to put it in.
icon: @image-url("app-icon.png");
background: Theme.ground;
preferred-width: 1100px;
preferred-height: 720px;
min-width: 360px;
min-height: 320px;
// Set from Rust each frame: the compute output, delivered as a texture.
in property <image> canvas;
in property <string> adapter: "detecting…";
in property <string> backend: "—";
in property <int> fps: 0;
/// TRACES: FR-UI-2
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
/// the binary it is part of.
in property <string> app-version: "unknown";
// Current image, for the status strip and empty state.
in property <string> filename: "";
in property <string> camera: "";
in property <string> exposure: "";
in property <string> dimensions: "";
in property <int> index: 0;
in property <int> total: 0;
in property <string> load-error: "";
/// TRACES: FR-DSP-7
/// The counted frame. Set only when the render has settled — a histogram
/// of a draft frame is a histogram of an image nobody is reading.
in property <HistogramView> histogram;
// --- zoom, pan and crop (FR-DEV-4) ---
//
// Zoom is a *viewing* state, not an edit: it changes the resolution the
// pipeline renders at, never what the file becomes. Rust owns the actual
// rect; these carry only what the interface has to draw.
in property <float> zoom: 1.0;
in property <bool> zoomed: false;
/// Whether one source pixel now covers more than one screen pixel.
///
/// Drives the canvas's filtering, and nothing else. Rust decides it rather
/// than the `zoom` property above, because the two are not the same
/// question: whether the image is magnified depends on the source's
/// resolution against the viewport's, which Slint does not know.
in property <bool> magnified: false;
/// TRACES: FR-UI-5
/// What the develop view is currently doing (see `ViewMode`).
///
/// One value rather than a flag per mode, so two modes cannot be on at
/// once — which, with `crop-mode` a bare bool beside a masking panel that
/// armed the canvas through two toggles of its own, they could be. Rust
/// owns it: entering a mode has side effects on the session (crop drops
/// the zoom, local clears nothing and leaving it clears the selection),
/// and a property the interface could set directly would let the view
/// change without them.
///
/// In `crop` the canvas shows the *whole* frame — otherwise the area being
/// cropped away would not be on screen to drag across — and the surround
/// is greyed.
in property <ViewMode> view-mode: ViewMode.photo;
/// Shorthand for the two conditions half this file tests, so a mode
/// comparison is written once rather than at each of a dozen call sites.
property <bool> cropping: root.view-mode == ViewMode.crop;
property <bool> local-mode: root.view-mode == ViewMode.local;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
in-out property <float> crop-x: 0.0;
in-out property <float> crop-y: 0.0;
in-out property <float> crop-w: 1.0;
in-out property <float> crop-h: 1.0;
/// Enter a mode, or `photo` to leave whichever one is current.
callback mode-picked(ViewMode);
/// A dragged crop rect, in fractions of the frame.
callback crop-changed(float, float, float, float);
// --- rotation, flips and straightening (FR-DEV-3) ---
//
// Mirrored from the session rather than held here, for the same reason the
// crop rect is: Rust clamps and wraps these, so the panel must show what
// was actually applied and not what the click asked for.
in property <float> straighten: 0.0;
/// The straighten slider's travel either way, from the descriptor.
in property <float> max-straighten: 45.0;
in property <bool> flip-h: false;
in property <bool> flip-v: false;
/// Any framing edit is in effect — what lights the section's dot and
/// enables its reset. Distinct from the crop rect being non-full: a
/// rotation or a flip is an edit with the crop still full.
in property <bool> framing-modified: false;
/// Quarter turns, positive clockwise.
callback rotate-quarters(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback straighten-changed(float);
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback framing-reset();
// --- undo and redo (FR-DEV-5) ---
//
// 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`.
in property <bool> can-undo: false;
in property <bool> can-redo: false;
callback undo();
callback redo();
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
callback zoom-reset();
// --- launch screen (FR-NC-1, FR-NC-4) ---
//
// The app opens here when no library is configured, and returns here to
// sign out or switch account.
in property <bool> show-launch: false;
in property <bool> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <string> launch-server: "";
in property <bool> launch-busy: false;
in property <string> launch-status: "";
in property <string> launch-error: "";
in property <string> launch-login-url: "";
in property <bool> launch-can-remember: true;
in-out property <[string]> launch-format-labels;
in-out property <[bool]> launch-format-checked;
callback launch-sign-in(string);
/// server, username, app password
callback launch-sign-in-direct(string, string, string);
callback launch-sign-out();
callback launch-choose-folder();
callback launch-open-library();
callback launch-format-toggled(int, bool);
callback launch-copy-url();
in property <bool> launch-browsing: false;
in property <string> launch-browse-path: "";
in property <bool> launch-browse-loading: false;
in-out property <[string]> launch-browse-entries;
callback launch-browse-into(string);
callback launch-browse-up();
callback launch-browse-confirm();
callback launch-browse-cancel();
// --- library grid (FR-CAT-4) ---
//
// Shown after a library is opened, before an image is chosen. Cells are a
// window over the catalog, not the whole of it.
in property <bool> show-library: false;
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
// --- background activity (FR-CAT-1, FR-NC-6c) ---
//
// Every background job in one register, filled by `activity.rs`. The
// aggregate drives the bar across the top of the shell; the rows are the
// list on the settings page, where a stalled transfer can be identified
// rather than merely felt.
in property <[ActivityRow]> activity-rows;
/// Anything at all running. Drives the bar's visibility, so it is a
/// property rather than `activity-rows.length > 0` — finished rows stay in
/// the list for a while and must not keep the bar alight.
in property <bool> activity-busy: false;
/// Whether the running jobs, taken together, have a denominator. A scan
/// alone does not; a scan beside a download does.
in property <bool> activity-determinate: false;
in property <float> activity-fraction: 0;
in property <int> activity-running: 0;
/// Jobs that stopped and are being kept: finished ones as a short history,
/// failed ones until they are cleared.
in property <int> activity-kept: 0;
callback activity-clear-finished();
in property <bool> library-scanning: false;
in property <string> library-status: "";
in property <string> library-error: "";
// --- offline mode (FR-CAT-9) ---
//
// The library keeps working from local data when the server cannot be
// reached: thumbnails come from the shards, and rating and sorting are
// catalog operations that never needed the network. What stops is opening
// an original that was never stored locally.
//
// `offline-reason` carries the transport's own message ("connection
// refused", "dns error") because it is usually specific enough to act on,
// where a bare "offline" leaves the user guessing whether it is their wifi
// or the server.
in property <bool> library-offline: false;
in property <string> library-offline-reason: "";
in property <string> library-offline-since: "";
// Narrows the grid to images whose RAW is stored locally — the ones that
// can actually be opened while offline. Off by default: the catalog is the
// library, and hiding most of it the moment a connection drops would read
// as data loss rather than as a filter.
in-out property <bool> library-local-only: false;
in property <int> library-local-count: 0;
callback library-toggle-local-only();
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-collection-from-selection();
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
in property <bool> library-range-invalid: false;
callback library-range-edited(string, string);
callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) ---
in property <bool> library-scope-pinned: false;
in property <int> library-pin-done: 0;
in property <int> library-pin-total: 0;
callback library-toggle-pin-scope();
// The question a held collection asks: keep these on the device, or give
// the disk back. Driven entirely from Rust — the counts and the byte sizes
// in the labels are catalog reads, and an empty `title` is what closes it.
in property <string> offline-prompt-title: "";
in property <string> offline-prompt-detail: "";
in property <string> offline-prompt-keep-label: "";
in property <string> offline-prompt-release-label: "";
in property <bool> offline-prompt-can-keep: false;
in property <bool> offline-prompt-can-release: false;
in property <bool> offline-prompt-busy: false;
callback offline-prompt-keep();
callback offline-prompt-release();
callback offline-prompt-dismiss();
in property <string> library-root-label: "";
in-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
in property <string> library-window-label: "";
in property <int> library-offset: 0;
in property <int> library-sweep-done: 0;
in property <int> library-sweep-total: 0;
in property <int> library-current-bucket: 0;
in property <float> library-current-fraction: -1;
in property <bool> library-timeline-anchored: false;
callback library-scrub-fraction(float);
callback library-timeline-pinch(float);
callback library-timeline-pan(float);
callback library-timeline-zoom(int);
in-out property <int> library-columns: 1;
in property <bool> library-syncing: false;
/// TRACES: FR-CAT-3 | FR-NC-3
/// The whole-library thumbnail pass, launched from the settings page.
/// `library-open` gates the button on there being a library to sweep —
/// the page is reachable from the launch screen too.
in property <bool> library-thumbnailing: false;
in property <bool> library-open: false;
callback library-thumbnail-all();
in-out property <length> library-cell-size: 180px;
/// Whether this session is a touch one, which decides if the rating strip
/// waits to be hovered or stands open. Seeded from the platform by Rust
/// and latched by the grid on the first finger it sees.
in-out property <bool> library-touched: false;
callback library-zoom-cells(int);
/// A pinch step on the grid: the ratio since the last update.
callback library-pinch-cells(float);
/// A pinch on the grid has begun, so the press that opened it was not one.
callback library-pinch-started();
in property <int> library-scroll-to: 0;
in property <int> library-scroll-token: 0;
/// Which row of the loaded window is the photograph currently open in
/// develop, so the roll can mark it. `-1` when it is not in the window.
in property <int> library-roll-current: -1;
/// Whether the photo roll is out. Owned here rather than by the roll, so
/// it survives the develop view being torn down and rebuilt.
in-out property <bool> roll-open: false;
/// A photograph was chosen from the roll: the row within the loaded
/// window, which is what a cell click reports too.
callback library-roll-pick(int);
callback library-sync-now();
callback library-columns-changed();
callback library-scrolled(int);
callback library-capacity(int);
callback library-cell-clicked(int);
callback library-rescan();
/// Grid → launch screen, to pick a different folder or account.
callback library-change();
/// Develop → grid.
callback back-to-library();
// --- collections (FR-CAT-7) ---
//
// The sidebar's tree, flattened in Rust: Slint cannot instantiate a
// component recursively, so depth arrives as an integer per row.
in-out property <[CollectionRow]> collection-rows;
/// Which collection scopes the grid. 0 is the whole library.
in property <int> collection-selected: 0;
in property <string> collection-scope-label: "";
in property <string> collection-error: "";
/// Whether the sidebar is shown at all. Closed by default in the compact
/// layout, where 232px of the window is most of the photograph (FR-UI-1),
/// and openable from the grid header either way — see `panel-visible` for
/// how a default and a user's answer to it are kept apart.
in property <bool> collections-visible: true;
/// Which collection is being renamed in the sidebar, by id. 0 is none.
in property <int> collection-renaming: 0;
callback collection-select(int);
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
/// TRACES: FR-NC-6a
/// Ask about a collection's local copies: the tray on a row, or a long
/// press anywhere on it.
callback collection-offline-menu(int);
/// A row is held or released — the long press is timed in Rust.
callback collection-row-press(int, bool);
/// TRACES: FR-CAT-7 | FR-UI-4
/// File the grid's selection in a collection without dragging it there:
/// the target's id, and whether to take the images out of the collection
/// currently being shown.
callback library-file-in-collection(int, bool);
/// TRACES: FR-CAT-5 | FR-CAT-6
/// Keywording the grid's selection. The catalog has been searchable by
/// keyword since it existed and there was nowhere to type one; this is it.
///
/// The vocabulary arrives already answered against the selection — each row
/// says how many of the selected photographs carry that word — because only
/// Rust knows what is selected, and a `.slint` file counting it would need
/// the selection as a second model that could disagree with the first.
in property <[KeywordRow]> library-keywords;
/// The sheet is opening: recompute the rows against the selection as it
/// stands now. Pulled rather than pushed, because the selection changes on
/// every arrow key and the sheet is shut for almost all of them.
callback library-keywords-opened();
/// Put a keyword on the selection, creating it if it is new. By name, so a
/// word typed into the field and a word tapped in the list are one path.
callback library-assign-keyword(string);
/// Take a keyword off the selection. Never deletes the keyword itself —
/// it stays in the vocabulary and on every other photograph that carries it.
callback library-unassign-keyword(string);
/// TRACES: FR-UI-2
/// Whether a tap in the grid selects rather than opens, and the button
/// that turns it on. The long press does the same thing without it.
in property <bool> library-select-mode: false;
callback library-toggle-select-mode();
/// A press on a cell ended, so the long-press timer can be cancelled.
callback library-cell-press-ended();
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on a cell: in selection mode, the far end of a range.
callback library-cell-double-clicked(int);
/// Renaming started on a row, by id — a double-click, or `F2`.
callback collection-rename-start(int);
/// A rename was committed: the collection's id and the new name.
callback collection-rename-commit(int, string);
/// Renaming was abandoned with Escape.
callback collection-rename-cancel();
/// Row index and collection id under the pointer. The id is what the
/// drop highlight follows: a spring expansion rebuilds the row model
/// mid-drag, and an index would then point at a different collection.
/// A drag is dwelling over a collection, or has left it: id, and whether
/// it is over. Drives the spring-loaded expansion, timed in Rust.
callback collection-drag-over(int, bool);
/// Images were dropped on a collection, by id. Slint hit-tests the release,
/// so this is the collection actually under the pointer.
callback collection-dropped(int);
/// Payload for dragging a collection onto another to reparent it.
pure callback collection-drag-payload(int) -> data-transfer;
// --- trash (FR-CAT-15) ---
//
// Not a collection: a drop here moves the file into a trash folder on the
// server, where a collection drop only adds a reference.
in property <int> trash-count: 0;
in property <string> trash-label: "";
/// A soft delete: move the dragged images to the trash folder.
callback trash-dropped();
/// A hard delete: permanently remove everything in the trash.
callback trash-empty();
/// Put the selected trashed images back where they came from.
callback trash-restore();
// --- drag and drop ---
//
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
// click-versus-drag threshold, arbitration against the grid's Flickable,
// and the image drawn under the cursor. So there is no badge to position
// and no hover state to mirror here — only the payload, which Rust builds
// from the selection, and the count the header shows.
/// The drag payload, built by Rust from the current selection.
pure callback library-drag-payload() -> data-transfer;
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
/// stack where several images are being dragged. Composited in Rust.
in property <image> library-drag-image;
/// A drag began on a cell, so an unselected one can be promoted before the
/// payload is read.
callback library-drag-started(int);
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// The keyboard cursor: where the arrows are in the library, as an image
// ordinal. Rust owns it — clamping it needs the library's length, and
// moving it may have to swap the loaded window underneath.
in property <int> library-cursor: -1;
callback library-move-cursor(int, bool);
callback library-open-cursor();
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
//
// Stars and pick/reject, set from the grid and persisted to the catalog
// and the sidecar. Every image enters unrated, which is a state of its
// own rather than a zero score.
/// A star was clicked on a cell: row, then the rating 0..5.
callback library-cell-rated(int, int);
/// The trash target was clicked on one cell, by row.
callback library-cell-trashed(int);
/// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of
/// the two arguments is -1, saying which axis was *not* meant.
callback library-judged(int, int);
in property <int> library-filter-min-rating: 0;
in property <bool> library-filter-unjudged: false;
in property <int> library-filter-flag: 0;
/// Images per star count, index 0 unrated, for the filter chips.
in-out property <[int]> library-rating-counts;
callback library-filter-min-rating-changed(int);
callback library-filter-unjudged-changed(bool);
callback library-filter-flag-changed(int);
callback next-image();
callback prev-image();
// Adjustment controls, generated from what the pipeline reports it can
// do. This window knows the *shape* of a control, never which operations
// exist (FR-DEV-3a).
in property <[ParamRow]> adjust-rows;
in property <[string]> adjust-tabs;
in property <int> adjust-active-tab: -1;
callback adjust-tab-picked(int);
in property <bool> adjust-enabled: false;
// The tone curve's sampled shape, evaluated by the core so the drawn
// line and the applied one cannot disagree.
in property <[float]> curve-samples;
// The curves that widget can plot, named by the core, and which of them
// `curve-samples` describes. Fewer than two means nothing to choose.
in property <[string]> curve-channels;
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback curve-channel-picked(int);
callback reset-all();
// --- copying settings between photographs (FR-DEV-6) ---
//
// The clipboard is a window-lifetime thing rather than a view's, which is
// why it lives here and not inside the develop column: a copy is taken in
// develop and may be pasted onto a selection back in the library grid.
/// Whether anything has been copied this session.
in property <bool> settings-armed: false;
/// What a paste would apply, at the current scope — "3 adjustments".
in property <string> settings-summary;
/// Whether the copy holds framing that the current setting is dropping.
in property <bool> settings-framing-withheld: false;
callback copy-settings();
callback paste-settings();
/// Apply the clipboard to every selected image in the grid.
callback paste-settings-to-selection();
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
// below: it is reachable from the library and from develop, so guarding it
// with `!show-library` or `!show-launch` would make which one you came from
// decide whether it appears.
//
// Every control saves on change (see `settings_ui.rs`), so there is no
// dirty state here and nothing to confirm on the way out.
in-out property <bool> show-settings: false;
// --- import (FR-CAT-10, FR-CAT-11, FR-NC-7a) -------------------------
//
// A page like Settings, and outermost with it: an import is reachable from
// the library, and it writes into the library, so it must not be nested
// inside the view it is about to change under.
//
// Unlike Settings, this page holds a *decision* rather than a set of saved
// preferences, which is why it carries `import-can-start` and a running
// state: everything here exists to be committed or abandoned.
in-out property <bool> show-import: false;
/// Whether this platform can import at all — see `dr_plat::imports_supported`.
in property <bool> import-supported: false;
in property <[string]> import-volume-labels;
in property <[string]> import-volume-details;
in property <int> import-volume-selected: -1;
in property <string> import-card-path: "";
in property <bool> import-card-looks-right: false;
in property <bool> import-surveying: false;
in property <string> import-survey-summary: "";
in property <string> import-upload-target: "";
in property <string> import-folder-template: "";
in property <string> import-template-preview: "";
in property <[string]> import-mode-labels;
in property <int> import-mode-selected: 0;
in property <bool> import-mode-is-move: false;
in property <[string]> import-duplicate-labels;
in property <int> import-duplicate-selected: 0;
in property <bool> import-verify: true;
in property <bool> import-backup-on: false;
in property <string> import-backup-path: "";
in property <bool> import-running: false;
in property <float> import-fraction: 0.0;
in property <string> import-status: "";
in property <string> import-error: "";
in property <bool> import-can-start: false;
callback import-open();
callback import-close();
callback import-volume-picked(int);
callback import-card-path-changed(string);
callback import-refresh-volumes();
callback import-template-changed(string);
callback import-mode-picked(int);
callback import-duplicate-picked(int);
callback import-verify-toggled(bool);
callback import-backup-toggled(bool);
callback import-backup-path-changed(string);
callback import-start();
callback import-stop();
in property <string> settings-original-budget: "";
in property <bool> settings-original-unlimited: false;
in property <string> settings-thumbnail-budget: "";
in property <bool> settings-thumbnail-unlimited: false;
in property <bool> settings-keep-opened: true;
in property <bool> settings-copy-includes-framing: false;
in property <string> settings-cache-usage: "";
callback settings-original-budget-changed(string);
callback settings-original-unlimited-toggled(bool);
callback settings-thumbnail-budget-changed(string);
callback settings-thumbnail-unlimited-toggled(bool);
callback settings-keep-opened-toggled(bool);
callback settings-copy-includes-framing-toggled(bool);
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
in property <int> settings-quality: 90;
in property <bool> settings-quality-enabled: true;
in property <[string]> settings-colour-labels;
in property <int> settings-colour-selected: 0;
in property <[string]> settings-sizing-labels;
in property <int> settings-sizing-selected: 0;
in property <int> settings-sizing-value: 0;
in property <bool> settings-sizing-has-value: false;
in property <string> settings-sizing-unit: "px";
in property <bool> settings-allow-upscaling: false;
in property <[string]> settings-sharpening-labels;
in property <int> settings-sharpening-selected: 0;
in property <string> settings-filename-template: "";
in property <[string]> settings-collision-labels;
in property <int> settings-collision-selected: 0;
in property <bool> settings-strip-location: true;
in property <string> settings-destination: "";
in property <string> settings-destination-hint;
in property <bool> settings-browse-open: false;
in property <string> settings-browse-path;
in property <[string]> settings-browse-entries;
in property <bool> settings-browse-loading: false;
in property <bool> settings-browse-at-root: true;
in property <bool> settings-browse-available: false;
in property <[string]> settings-target-labels;
in property <int> settings-target-selected: 0;
in property <string> settings-error: "";
callback settings-format-picked(int);
callback settings-quality-changed(int);
callback settings-colour-picked(int);
callback settings-sizing-picked(int);
callback settings-sizing-value-changed(string);
callback settings-upscaling-toggled(bool);
callback settings-sharpening-picked(int);
callback settings-template-changed(string);
callback settings-collision-picked(int);
callback settings-strip-location-toggled(bool);
callback settings-destination-changed(string);
callback settings-target-changed(int);
callback settings-browse-open-picker();
callback settings-browse-into(string);
callback settings-browse-up();
callback settings-browse-confirm();
callback settings-browse-cancel();
callback settings-reset();
/// Show the settings page. Reads the file first, so a second instance's
/// writes are picked up rather than overwritten.
callback settings-open();
callback settings-close();
// FR-UI-1: layout class follows window width, not device type. A narrow
// desktop window gets the compact layout, exactly as a tablet would.
//
// Set from Rust rather than derived from `root.width` here: a property
// read inside the layout and also feeding it creates a binding loop,
// which Slint warns about and which can panic at runtime.
//
// Nothing below reads `expanded` directly any more. It decides what the
// two collapsible columns *default* to, and Rust applies that to
// `panel-visible` and `collections-visible`; binding a column's width to
// the class itself would mean a panel the user opened snapped shut again
// on the next resize.
in property <bool> expanded: true;
in property <string> layout-class: "expanded";
// --- local adjustments (FR-DEV-3) ---------------------------------------
/// The false-coloured region map, drawn over the photograph.
///
/// An empty image when there is nothing to draw, which is also how the
/// overlay is hidden — one source of truth rather than a flag that can
/// disagree with the picture.
in property <image> region-overlay;
/// How strongly the overlay is composited. Not 1.0: the point of looking
/// at it is to judge whether a boundary follows the *photograph*, and an
/// opaque map hides the thing being judged.
in property <float> overlay-strength: 0.55;
in property <bool> overlay-on: false;
/// TRACES: FR-DEV-3
/// The photographer's switch for the same overlay, kept separate from
/// `overlay-on` because the two answer different questions: `overlay-on`
/// is "is there a mask to draw", which Rust decides, and this is "do I
/// want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// Which part of the source-space overlay the view is showing, in overlay
/// pixels. Without it the overlay stays frame-sized while the photograph
/// moves under it.
in property <int> overlay-clip-x;
in property <int> overlay-clip-y;
in property <int> overlay-clip-w;
in property <int> overlay-clip-h;
/// The straightening angle, so the overlay turns with the frame.
in property <float> overlay-angle: 0.0;
/// Whether a click on the canvas selects a region instead of panning.
///
/// **Derived, not toggled.** It used to be a button in the masking panel,
/// which meant local masking could be open with picking off — a mode
/// entered and doing nothing. Arming a click is part of what local mode
/// *is*, so it follows the mode and the existence of something to pick.
property <bool> region-picking: root.local-mode && root.segmented;
/// Whether shift is down, tracked by the develop key scope below.
///
/// A `TouchArea`'s `clicked` carries no modifiers, so the state has to be
/// held between the key event and the click. Private to the window because
/// nothing outside it has any business setting it.
property <bool> shift-held: false;
in property <[MaskRow]> mask-rows;
in property <[SubjectRow]> subject-rows;
in property <bool> segmented: false;
in property <bool> segmenting: false;
in property <bool> refining: false;
/// The selected layer's name, already resolved, or "ADJUST" when the
/// controls are global. Names the adjust panel's heading.
in property <string> adjust-scope: "ADJUST";
/// The selected gradient's handles, in fractions of the shown image.
/// Empty unless a gradient layer is selected in local mode.
in property <[GradientHandle]> gradient-handles;
/// A handle dragged: which one, where the press was and where the pointer
/// is now, both in fractions of the shown image.
///
/// The press is carried rather than a running delta because the geometry
/// is re-derived from the position it had when the drag began — which is
/// what stops a drag accumulating rounding error along its length, the
/// same reason the crop handles capture the rect they started from.
callback gradient-handle-dragged(HandleRole, float, float, float, float);
/// A drag finished, so the edit can be recorded as one history step
/// rather than as one per frame of the gesture.
callback gradient-handle-released();
callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it.
callback region-picked(float, float, bool);
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback add-gradient-mask(bool);
callback add-subject-mask(int);
/// Whether the develop column — image info, geometry, adjust — is shown.
///
/// Distinct from `expanded`, which is the *layout class* and only supplies
/// this its default: a panel the user closed on a wide window stays closed,
/// and one they opened on a narrow one stays open. Rust owns both for the
/// reason given above — a property read inside the layout and also feeding
/// it is a binding loop.
in property <bool> panel-visible: true;
/// The export button's label and busy state, and the line it reports to.
/// Rust owns all three: what an export is called depends on the format and
/// on whether the destination is this device or the server, neither of
/// which the toolbar can see.
in property <string> export-label: "Export";
in property <bool> export-busy: false;
in property <string> export-status;
/// TRACES: FR-EXP-7
/// Whether an export would be queued for the server rather than written to
/// this device. The grid says the same thing as the develop button, but it
/// composes its own label around a selection count — so it is given the
/// fact and not the sentence.
in property <bool> export-to-server: false;
/// Whether a batch is running. The grid's export button becomes its cancel
/// button; there is nowhere else a long batch can be stopped from.
in property <bool> library-exporting: false;
/// Export the grid's selection in the background (FR-EXP-7).
callback library-export-selection();
/// TRACES: NFR-ARCH-3
callback library-cancel-export();
/// Show or hide the develop column.
callback toggle-panel();
/// Export the image on screen, using the settings as they stand.
callback export-image();
/// Show or hide the collections sidebar.
callback toggle-collections();
/// Android's back gesture, or Escape. Returns whether there was anywhere to
/// go: `false` means this is the top of the stack, and the platform is left
/// to close the application as it normally would.
callback back-requested() -> bool;
callback canvas-resized(int, int);
callback window-resized(length);
/// The width the interface actually has to lay out in.
///
/// Not the window's: on Android the window spans the display and the
/// left/right insets are given over to system furniture, so choosing the
/// layout class from `width` would promise the layout space it cannot use.
property <length> shell-width:
root.width - root.safe-area-insets.left - root.safe-area-insets.right;
// One-way: report width outward, never read layout back into it.
changed shell-width => { root.window-resized(self.shell-width); }
// Everything the interface draws lives inside this, for two reasons that
// happen to want the same element.
//
// **The safe area.** From target SDK 35 Android draws applications behind
// the status and navigation bars and offers no way out, so the window's
// own coordinates include the strips those bars occupy — which is why the
// develop status strip came out underneath the clock and the wifi icon.
// `safe-area-insets` is what the platform reports as covered; the shell is
// placed inside it and only the window background paints behind the bars.
// Every inset is zero on the desktop, so nothing about that layout moves.
//
// **The back gesture.** Android's back button and back swipe arrive as
// `Key.Back`, delivered to whatever holds focus and then bubbled up its
// ancestors — so the one handler that answers for the whole application
// has to be above all four views rather than beside them. Accepting the
// key tells the platform we consumed it; rejecting it lets Android do what
// it does by default and close the activity, which is exactly what should
// happen at the top of the stack.
shell := FocusScope {
x: root.safe-area-insets.left;
y: root.safe-area-insets.top;
width: root.shell-width;
height: root.height - root.safe-area-insets.top
- root.safe-area-insets.bottom;
// Deliberately does *not* focus itself.
//
// A key needs a focus item to bubble from, so each of the four views
// below focuses a scope of its own on show — the grid's judgement keys,
// develop's arrow keys, and a zero-sized holder on the two form pages
// that would otherwise focus nothing. Claiming focus here as a fallback
// would race with those: Slint does not promise whether a parent's
// `init` runs before or after its children's, so on the wrong ordering
// this would take focus back off the grid and silently break culling.
key-pressed(event) => {
// Escape alongside Back: the same "go up one" on a keyboard, and
// the inner scopes that use it for something more specific — a
// rename in the sidebar — see it first and accept it there.
if (event.text == Key.Back || event.text == Key.Escape) {
// A dialogue takes "go up one" first, and it is the *only*
// thing that goes up. Without this, Android's Back button
// would leave the library from behind an open question — the
// view changing underneath a modal, which reads as the app
// having lost its place.
if (root.offline-prompt-title != "") {
root.offline-prompt-dismiss();
return accept;
}
return root.back-requested() ? accept : reject;
}
return reject;
}
// Settings, over everything. First in the file and first in z-order so the
// conditions below can be read as "and settings is not open".
// Outermost with the settings page, and before it: both are reachable
// from the library and from develop, so guarding either with
// `!show-library` would make which view you came from decide whether
// it appears.
if root.show-import: ImportPage {
width: 100%;
height: 100%;
volume-labels: root.import-volume-labels;
volume-details: root.import-volume-details;
volume-selected: root.import-volume-selected;
card-path: root.import-card-path;
card-looks-right: root.import-card-looks-right;
surveying: root.import-surveying;
survey-summary: root.import-survey-summary;
upload-target: root.import-upload-target;
folder-template: root.import-folder-template;
template-preview: root.import-template-preview;
mode-labels: root.import-mode-labels;
mode-selected: root.import-mode-selected;
mode-is-move: root.import-mode-is-move;
duplicate-labels: root.import-duplicate-labels;
duplicate-selected: root.import-duplicate-selected;
verify: root.import-verify;
backup-on: root.import-backup-on;
backup-path: root.import-backup-path;
running: root.import-running;
fraction: root.import-fraction;
status: root.import-status;
error: root.import-error;
can-start: root.import-can-start;
volume-picked(i) => { root.import-volume-picked(i); }
card-path-changed(t) => { root.import-card-path-changed(t); }
refresh-volumes() => { root.import-refresh-volumes(); }
template-changed(t) => { root.import-template-changed(t); }
mode-picked(i) => { root.import-mode-picked(i); }
duplicate-picked(i) => { root.import-duplicate-picked(i); }
verify-toggled(on) => { root.import-verify-toggled(on); }
backup-toggled(on) => { root.import-backup-toggled(on); }
backup-path-changed(t) => { root.import-backup-path-changed(t); }
begin() => { root.import-start(); }
stop() => { root.import-stop(); }
close() => { root.import-close(); }
}
if !root.show-import && root.show-settings: SettingsPage {
width: 100%;
height: 100%;
adapter: root.adapter;
backend: root.backend;
fps: root.fps;
layout-class: root.layout-class;
app-version: root.app-version;
original-budget: root.settings-original-budget;
original-unlimited: root.settings-original-unlimited;
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
copy-includes-framing: root.settings-copy-includes-framing;
cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
original-unlimited-toggled(on) => {
root.settings-original-unlimited-toggled(on);
}
thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); }
thumbnail-unlimited-toggled(on) => {
root.settings-thumbnail-unlimited-toggled(on);
}
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
copy-includes-framing-toggled(on) => {
root.settings-copy-includes-framing-toggled(on);
}
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
quality: root.settings-quality;
quality-enabled: root.settings-quality-enabled;
colour-labels: root.settings-colour-labels;
colour-selected: root.settings-colour-selected;
sizing-labels: root.settings-sizing-labels;
sizing-selected: root.settings-sizing-selected;
sizing-value: root.settings-sizing-value;
sizing-has-value: root.settings-sizing-has-value;
sizing-unit: root.settings-sizing-unit;
allow-upscaling: root.settings-allow-upscaling;
sharpening-labels: root.settings-sharpening-labels;
sharpening-selected: root.settings-sharpening-selected;
filename-template: root.settings-filename-template;
collision-labels: root.settings-collision-labels;
collision-selected: root.settings-collision-selected;
strip-location: root.settings-strip-location;
destination: root.settings-destination;
destination-hint: root.settings-destination-hint;
browse-open: root.settings-browse-open;
browse-path: root.settings-browse-path;
browse-entries: root.settings-browse-entries;
browse-loading: root.settings-browse-loading;
browse-at-root: root.settings-browse-at-root;
browse-available: root.settings-browse-available;
target-labels: root.settings-target-labels;
target-selected: root.settings-target-selected;
error: root.settings-error;
format-picked(i) => { root.settings-format-picked(i); }
quality-changed(q) => { root.settings-quality-changed(q); }
colour-picked(i) => { root.settings-colour-picked(i); }
sizing-picked(i) => { root.settings-sizing-picked(i); }
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
template-changed(t) => { root.settings-template-changed(t); }
collision-picked(i) => { root.settings-collision-picked(i); }
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
target-picked(i) => { root.settings-target-changed(i); }
browse-open-picker() => { root.settings-browse-open-picker(); }
browse-into(n) => { root.settings-browse-into(n); }
browse-up() => { root.settings-browse-up(); }
browse-confirm() => { root.settings-browse-confirm(); }
browse-cancel() => { root.settings-browse-cancel(); }
activity-rows: root.activity-rows;
activity-running: root.activity-running;
activity-kept: root.activity-kept;
clear-finished() => { root.activity-clear-finished(); }
thumbnailing: root.library-thumbnailing;
library-open: root.library-open;
thumbnail-library() => { root.library-thumbnail-all(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
// The launch screen replaces the whole window rather than overlaying it:
// there is no library to look at until an account is configured.
if !root.show-import && !root.show-settings && root.show-launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
account: root.launch-account;
library-root: root.launch-root;
server-url: root.launch-server;
busy: root.launch-busy;
status: root.launch-status;
error: root.launch-error;
login-url: root.launch-login-url;
can-remember: root.launch-can-remember;
format-labels: root.launch-format-labels;
format-checked: root.launch-format-checked;
sign-in(server) => { root.launch-sign-in(server); }
sign-in-direct(server, user, pw) => {
root.launch-sign-in-direct(server, user, pw);
}
sign-out() => { root.launch-sign-out(); }
choose-folder() => { root.launch-choose-folder(); }
open-library() => { root.launch-open-library(); }
format-toggled(i, on) => { root.launch-format-toggled(i, on); }
copy-login-url() => { root.launch-copy-url(); }
browsing: root.launch-browsing;
browse-path: root.launch-browse-path;
browse-loading: root.launch-browse-loading;
browse-entries: root.launch-browse-entries;
browse-into(name) => { root.launch-browse-into(name); }
browse-up() => { root.launch-browse-up(); }
browse-confirm() => { root.launch-browse-confirm(); }
browse-cancel() => { root.launch-browse-cancel(); }
}
// The library view sits between the launch screen and develop: a library
// has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both.
if !root.show-import && !root.show-settings && !root.show-launch && root.show-library: Rectangle {
width: 100%;
height: 100%;
background: Theme.ground;
HorizontalLayout {
// Always instantiated, width collapsed to zero when hidden — the
// same reasoning as the adjust panel below: an `if` here depends on
// the layout class, which derives from the window width, which the
// layout then influences. Slint flags that loop and it can panic.
Rectangle {
width: root.collections-visible ? 232px : 0px;
visible: root.collections-visible;
horizontal-stretch: 0;
CollectionsPanel {
width: 100%;
height: 100%;
rows: root.collection-rows;
selected-id: root.collection-selected;
total-images: root.library-total;
error: root.collection-error;
select(id) => { root.collection-select(id); }
toggle(id) => { root.collection-toggle(id); }
new-collection() => { root.collection-new(); }
row-menu(id) => { root.collection-menu(id); }
offline-menu(id) => { root.collection-offline-menu(id); }
row-press(id, down) => { root.collection-row-press(id, down); }
renaming-id: root.collection-renaming;
rename-start(id) => { root.collection-rename-start(id); }
rename-commit(id, name) => {
root.collection-rename-commit(id, name);
}
rename-cancel() => { root.collection-rename-cancel(); }
trash-count: root.trash-count;
trash-label: root.trash-label;
selected-count: root.library-selected-count;
dropped-on(id) => { root.collection-dropped(id); }
collection-drag-payload(id) => { return root.collection-drag-payload(id); }
dropped-on-trash() => { root.trash-dropped(); }
empty-trash() => { root.trash-empty(); }
restore-selected() => { root.trash-restore(); }
drag-over(id, over) => { root.collection-drag-over(id, over); }
}
}
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;
scan-status: root.library-status;
scan-error: root.library-error;
offline: root.library-offline;
offline-reason: root.library-offline-reason;
offline-since: root.library-offline-since;
retry-connection() => { root.library-retry-connection(); }
local-only: root.library-local-only;
local-count: root.library-local-count;
toggle-local-only() => { root.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
collection-from-selection() => { root.library-collection-from-selection(); }
range-from: root.library-range-from;
range-to: root.library-range-to;
range-invalid: root.library-range-invalid;
range-edited(a, b) => { root.library-range-edited(a, b); }
collections-visible: root.collections-visible;
toggle-collections() => { root.toggle-collections(); }
scope-pinned: root.library-scope-pinned;
pin-done: root.library-pin-done;
pin-total: root.library-pin-total;
toggle-pin-scope() => { root.library-toggle-pin-scope(); }
root-label: root.library-root-label;
timeline: root.library-timeline;
timeline-label: root.library-timeline-label;
window-label: root.library-window-label;
offset: root.library-offset;
selected-count: root.library-selected-count;
scope-label: root.collection-scope-label;
settings-armed: root.settings-armed;
settings-summary: root.settings-summary;
paste-settings-to-selection => {
root.paste-settings-to-selection();
}
// TRACES: FR-EXP-7
exporting: root.library-exporting;
export-to-server: root.export-to-server;
export-selection => { root.library-export-selection(); }
cancel-export => { root.library-cancel-export(); }
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
current-bucket: root.library-current-bucket;
current-bucket-fraction: root.library-current-fraction;
timeline-anchored: root.library-timeline-anchored;
scrub-fraction(f) => { root.library-scrub-fraction(f); }
timeline-pinch(r) => { root.library-timeline-pinch(r); }
timeline-pan(d) => { root.library-timeline-pan(d); }
timeline-zoom(d) => { root.library-timeline-zoom(d); }
syncing: root.library-syncing;
requested-cell-size: root.library-cell-size;
touched <=> root.library-touched;
zoom-cells(d) => { root.library-zoom-cells(d); }
pinch-cells(r) => { root.library-pinch-cells(r); }
pinch-started() => { root.library-pinch-started(); }
scroll-to: root.library-scroll-to;
scroll-token: root.library-scroll-token;
sync-now() => { root.library-sync-now(); }
columns-changed(n) => {
root.library-columns = n;
// Which cell begins a row just changed, and month headings sit on
// row-leading cells — so they must be recomputed, not just moved.
root.library-columns-changed();
}
scrolled(i) => { root.library-scrolled(i); }
capacity-changed(n) => { root.library-capacity(n); }
cell-clicked(i) => { root.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
change-library() => { root.library-change(); }
can-import: root.import-supported;
open-import() => { root.import-open(); }
open-settings() => { root.settings-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
}
cell-press-ended() => { root.library-cell-press-ended(); }
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
select-mode: root.library-select-mode;
toggle-select-mode() => { root.library-toggle-select-mode(); }
// The sidebar's rows, not a second model: the sheet files
// into the same tree the sidebar draws.
collections: root.collection-rows;
file-in-collection(id, moves) => {
root.library-file-in-collection(id, moves);
}
keywords: root.library-keywords;
keywords-opened() => { root.library-keywords-opened(); }
assign-keyword(word) => { root.library-assign-keyword(word); }
unassign-keyword(word) => { root.library-unassign-keyword(word); }
cursor: root.library-cursor;
move-cursor(delta, extend) => {
root.library-move-cursor(delta, extend);
}
open-cursor() => { root.library-open-cursor(); }
drag-image: root.library-drag-image;
drag-payload() => { return root.library-drag-payload(); }
drag-started(i) => { root.library-drag-started(i); }
drag-finished() => { root.library-drag-finished(); }
remove-from-collection() => {
root.library-remove-from-collection();
}
filter-min-rating: root.library-filter-min-rating;
filter-unjudged: root.library-filter-unjudged;
filter-flag: root.library-filter-flag;
rating-counts: root.library-rating-counts;
cell-rated(i, n) => { root.library-cell-rated(i, n); }
cell-trashed(i) => { root.library-cell-trashed(i); }
trash-selection() => { root.library-trash-selection(); }
// Derived from the sidebar's own selection rather than
// mirrored in a second property: `-1` is already the sentinel
// for the trash row (see collections.slint), and two sources
// for one fact is how they come to disagree.
viewing-trash: root.collection-selected == -1;
judged(rating, flag) => { root.library-judged(rating, flag); }
// `F2` renames whatever the grid is scoped to. Answered from
// the sidebar's selection rather than a second property, for
// the same reason `viewing-trash` above is: one fact, one
// source. Ids at or below 0 are "all photographs" and the
// trash, neither of which has a name to change — Rust drops
// those rather than the binding trying to reason about it.
rename-scope() => {
root.collection-rename-start(root.collection-selected);
}
filter-min-rating-changed(n) => {
root.library-filter-min-rating-changed(n);
}
filter-unjudged-toggled(on) => {
root.library-filter-unjudged-changed(on);
}
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
}
}
}
if !root.show-import && !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout {
width: 100%;
height: 100%;
StatusBar {
adapter: root.adapter;
backend: root.backend;
layout-class: root.layout-class;
fps: root.fps;
expanded: root.expanded;
filename: root.filename;
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
// Only where a library was opened; command-line files have no
// grid to return to.
can-return-to-library: root.library-total > 0;
panel-visible: root.panel-visible;
export-label: root.export-label;
export-busy: root.export-busy;
export-status: root.export-status;
can-undo: root.can-undo;
can-redo: root.can-redo;
back-to-library() => { root.back-to-library(); }
open-settings() => { root.settings-open(); }
toggle-panel() => { root.toggle-panel(); }
export-image() => { root.export-image(); }
undo() => { root.undo(); }
redo() => { root.redo(); }
}
HorizontalLayout {
// The canvas: compute output composited directly. No CPU
// round-trip anywhere in this path (ARCH §6.1).
canvas-area := Rectangle {
horizontal-stretch: 1;
background: Theme.ground;
clip: true;
canvas-image := Image {
width: 100%;
height: 100%;
source: root.canvas;
image-fit: contain;
// Past 1:1 there is no detail left to reconstruct, so
// smoothing only invents values between real pixels — and
// inspecting focus or noise is the whole reason to zoom in
// that far. Below 1:1 it stays smooth, where filtering is
// what keeps the image from aliasing.
//
// This matters even at moderate zoom on a HiDPI display:
// the canvas is rendered at *logical* size and Slint scales
// it up by the device pixel ratio, so the buffer is
// resampled on its way to the screen whatever the pipeline
// did.
image-rendering: root.magnified
? ImageRendering.pixelated
: ImageRendering.smooth;
visible: root.total > 0 && root.load-error == "";
}
// The region map, composited over the photograph at the
// same fitted rect.
//
// Drawn here rather than blended into the render because
// it is a diagnostic and not an edit: it must not reach
// the histogram, an export, or the texture the develop
// pass hands the compositor.
if root.overlay-on && !root.overlay-hidden && root.total > 0: Image {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
source: root.region-overlay;
// The visible part of a source-space picture, which is
// what makes the overlay follow a zoom, a pan and a
// crop. The compositor does the crop and the scale;
// resampling on the CPU would mean rebuilding a
// megapixel image on every frame of a drag.
source-clip-x: root.overlay-clip-x;
source-clip-y: root.overlay-clip-y;
source-clip-width: root.overlay-clip-w;
source-clip-height: root.overlay-clip-h;
transform-rotation: -root.overlay-angle * 1deg;
image-fit: fill;
opacity: root.overlay-strength;
// Nearest-neighbour, always. The map is proxy-sized
// and its values are *names*: smoothing between region
// 4 and region 9 invents a colour belonging to
// neither, and draws a soft edge exactly where the
// point is to see a hard one.
image-rendering: ImageRendering.pixelated;
}
// Where the photograph actually sits inside this box.
//
// `image-fit: contain` letterboxes, and Slint does not report
// the fitted rect, so it is derived from the source's aspect.
// The overlay is placed against *this*, not against the whole
// area — otherwise the crop rect drifts off the picture on any
// window whose shape differs from the image's.
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
property <float> box-w: self.width / 1px;
property <float> box-h: self.height / 1px;
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
property <length> shown-w: self.src-w * self.fit-scale * 1px;
property <length> shown-h: self.src-h * self.fit-scale * 1px;
property <length> shown-x: (self.width - self.shown-w) / 2;
property <length> shown-y: (self.height - self.shown-h) / 2;
// Empty and error states say what happened rather than
// showing a blank canvas.
if root.total == 0 || root.load-error != "": EmptyState {
headline: root.load-error != "" ? "Could not load image" : "No images";
detail: root.load-error != "" ? root.load-error
: "Pass a folder or file on the command line.";
}
// --- zoom and pan ------------------------------------------
//
// Below the crop overlay in z-order so that, in crop mode, the
// handles take the drag instead. Scroll still zooms either way.
// TRACES: FR-UI-4
// Pinch to zoom. The wheel gesture below is the desktop's
// answer and there is no wheel on a tablet, so without this
// the only way to magnify a photograph on the device the
// panel was sized for is a control that does not exist.
//
// `scale` is cumulative from 1.0 across the whole gesture,
// so the ratio since the last update is what maps onto a
// zoom step — the same reasoning as the timeline's pinch,
// and for the same reason: applying the running total on
// every update squares it and shoots straight to the limit.
//
// Anchored on the midpoint between the fingers, which is
// what makes a pinch feel like it is moving the picture
// rather than the viewport.
if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler {
x: 0; y: 0;
width: 100%;
height: 100%;
enabled: !root.cropping;
property <float> last-scale: 1.0;
started => { self.last-scale = 1.0; }
updated => {
root.zoom-at(
self.scale / max(0.01, self.last-scale),
(self.center.x - canvas-area.shown-x)
/ max(canvas-area.shown-w, 1px),
(self.center.y - canvas-area.shown-y)
/ max(canvas-area.shown-h, 1px),
);
self.last-scale = self.scale;
}
ended => { self.last-scale = 1.0; }
cancelled => { self.last-scale = 1.0; }
}
// Region picking, above the pan/zoom area so a click
// reaches it first. A separate area rather than a branch
// inside the one below: panning wants press-drag-release
// and picking wants a click, and interleaving the two in
// one handler is how a drag ends up selecting a region the
// user was only scrolling past.
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
clicked => {
root.region-picked(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
// Shift extends or removes, the modifier every
// selection tool uses for the same thing.
root.shift-held,
);
}
}
if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0;
width: 100%;
height: 100%;
// A pan is only meaningful once there is something outside
// the viewport to reach.
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
enabled: !root.cropping;
property <length> last-x;
property <length> last-y;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.last-x = self.mouse-x;
self.last-y = self.mouse-y;
}
}
moved => {
if (self.pressed && root.zoomed) {
// Fractions of the *visible* area, which is what
// the session's pan expects. Negated: dragging
// right moves the image right, so the window onto
// it moves left.
root.pan-by(
-(self.mouse-x - self.last-x) / max(parent.shown-w, 1px),
-(self.mouse-y - self.last-y) / max(parent.shown-h, 1px),
);
self.last-x = self.mouse-x;
self.last-y = self.mouse-y;
}
}
scroll-event(ev) => {
if (ev.delta-y == 0) {
return reject;
}
// Anchored on the pointer, in fractions of the shown
// image, so whatever is under the cursor stays there.
root.zoom-at(
ev.delta-y > 0 ? 1.15 : 1.0 / 1.15,
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
);
return accept;
}
double-clicked => { root.zoom-reset(); }
}
// --- crop overlay ------------------------------------------
//
// Four dimmed, desaturated panels around the crop, then the
// rect itself with handles. Panels rather than one shape with
// a hole: Slint has no cut-out, and four rectangles are exact.
if root.cropping && root.total > 0 && root.load-error == "": crop-overlay := Rectangle {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
property <length> rx: root.crop-x * self.width;
property <length> ry: root.crop-y * self.height;
property <length> rw: root.crop-w * self.width;
property <length> rh: root.crop-h * self.height;
// The surround: dimmed and drained of colour, so the crop
// reads as the photograph and everything else as context
// being discarded. Slint cannot desaturate a live image,
// so this is a heavy neutral wash over it — the dimming
// carries the separation and the neutrality kills the
// colour cue.
property <brush> veil: #20242aE0;
Rectangle {
x: 0;
y: 0;
width: parent.width;
height: crop-overlay.ry;
background: crop-overlay.veil;
}
Rectangle {
x: 0;
y: crop-overlay.ry + crop-overlay.rh;
width: parent.width;
height: parent.height - crop-overlay.ry - crop-overlay.rh;
background: crop-overlay.veil;
}
Rectangle {
x: 0;
y: crop-overlay.ry;
width: crop-overlay.rx;
height: crop-overlay.rh;
background: crop-overlay.veil;
}
Rectangle {
x: crop-overlay.rx + crop-overlay.rw;
y: crop-overlay.ry;
width: parent.width - crop-overlay.rx - crop-overlay.rw;
height: crop-overlay.rh;
background: crop-overlay.veil;
}
// The rect, its thirds, and the handles.
Rectangle {
x: parent.rx;
y: parent.ry;
width: parent.rw;
height: parent.rh;
border-width: 1px;
border-color: #ffffffCC;
// Rule of thirds, the reason a crop overlay is worth
// drawing at all rather than typing numbers.
Rectangle {
x: parent.width / 3;
y: 0; width: 1px; height: parent.height;
background: #ffffff44;
}
Rectangle {
x: parent.width * 2 / 3;
y: 0; width: 1px; height: parent.height;
background: #ffffff44;
}
Rectangle {
x: 0; y: parent.height / 3;
width: parent.width; height: 1px;
background: #ffffff44;
}
Rectangle {
x: 0; y: parent.height * 2 / 3;
width: parent.width; height: 1px;
background: #ffffff44;
}
// Drag the whole rect.
move-area := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: MouseCursor.move;
property <float> start-x;
property <float> start-y;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.start-x = root.crop-x;
self.start-y = root.crop-y;
}
}
moved => {
if (self.pressed) {
root.crop-changed(
self.start-x
+ (self.mouse-x - self.pressed-x)
/ max(crop-overlay.width, 1px),
self.start-y
+ (self.mouse-y - self.pressed-y)
/ max(crop-overlay.height, 1px),
root.crop-w,
root.crop-h,
);
}
}
}
}
// Corner handles. Each drags one corner while the opposite
// stays put, which is the only behaviour that lets a crop
// be shaped rather than merely moved.
for corner in [
{ hx: 0.0, hy: 0.0 },
{ hx: 1.0, hy: 0.0 },
{ hx: 0.0, hy: 1.0 },
{ hx: 1.0, hy: 1.0 },
]: Rectangle {
property <length> size: 18px;
x: parent.rx + corner.hx * parent.rw - self.size / 2;
y: parent.ry + corner.hy * parent.rh - self.size / 2;
width: self.size;
height: self.size;
Rectangle {
width: 12px;
height: 12px;
background: #ffffff;
border-radius: 2px;
}
TouchArea {
width: 100%;
height: 100%;
mouse-cursor: (corner.hx == corner.hy)
? MouseCursor.nwse-resize
: MouseCursor.nesw-resize;
property <float> ox;
property <float> oy;
property <float> ow;
property <float> oh;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.ox = root.crop-x;
self.oy = root.crop-y;
self.ow = root.crop-w;
self.oh = root.crop-h;
}
}
// Movement as a fraction of the frame, live while
// the handle is held.
property <float> dx:
(self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px);
property <float> dy:
(self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px);
moved => {
if (!self.pressed) {
return;
}
// Dragging a left/top handle moves the origin
// and shrinks the extent by the same amount;
// a right/bottom handle moves only the extent.
// Rust clamps the result, so an over-drag
// slides rather than inverting.
root.crop-changed(
corner.hx == 0 ? self.ox + self.dx : self.ox,
corner.hy == 0 ? self.oy + self.dy : self.oy,
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
);
}
}
}
}
// --- gradient handles ---------------------------------------
//
// TRACES: FR-DEV-3 | FR-UI-3
// A linear or radial mask could be made and then never
// moved: there was nothing to grab, so a radial sat at the
// centre of the frame at its default size for ever.
//
// **Positioned against `shown-*`, like the crop overlay
// and the region picker.** The photograph is letterboxed
// inside this box and Slint does not report the fitted
// rect, so anything that has to land *on* the picture is
// placed against the derived one. Rust supplies the
// positions already mapped through the framing — the same
// map the shader applies — so a handle follows the mask
// through a zoom, a pan, a crop and a straightening rather
// than sitting where the mask used to be.
//
// **Drawn small, grabbed large.** The visible dot is 14px
// because a bigger one would hide the edge it is placed
// on, and the `TouchArea` is a full touch target, centred
// on it — `Button` establishes the same split. These are
// the first controls in the application meant to be
// dragged on the photograph, and a 12-inch tablet has no
// hover to reveal them with and no modifier to qualify
// them by, so what is drawn is all there is (FR-UI-7).
for handle in root.gradient-handles: Rectangle {
x: parent.shown-x + handle.x * parent.shown-w - self.width / 2;
y: parent.shown-y + handle.y * parent.shown-h - self.height / 2;
width: Theme.touch-target;
height: Theme.touch-target;
// The centre moves the whole mask, so it is filled;
// the others shape it and are rings. Shape rather than
// colour, because the handles sit on a photograph and
// any colour they carried would be read as part of it.
property <bool> solid: handle.role == HandleRole.centre;
Rectangle {
width: handle.role == HandleRole.rotate ? 12px : 14px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 2px;
// White with a dark ring, so the handle is visible
// against a blown sky and against a black frame —
// one treatment, both extremes, no theme token
// because this is drawn over the image and not over
// the interface.
border-color: #00000099;
background: parent.solid ? #ffffff : #ffffff44;
}
drag := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: parent.solid
? MouseCursor.move
: MouseCursor.crosshair;
// Where the press landed, in the same fractions the
// callback reports in. Captured on the way down so
// the whole gesture is measured from one origin.
property <float> from-x;
property <float> from-y;
function fraction-x(local-x: length) -> float {
return (parent.x + local-x - canvas-area.shown-x)
/ max(canvas-area.shown-w, 1px);
}
function fraction-y(local-y: length) -> float {
return (parent.y + local-y - canvas-area.shown-y)
/ max(canvas-area.shown-h, 1px);
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.from-x = self.fraction-x(self.pressed-x);
self.from-y = self.fraction-y(self.pressed-y);
}
// One history step per gesture, not per frame.
if (ev.kind == PointerEventKind.up) {
root.gradient-handle-released();
}
}
moved => {
if (self.pressed) {
root.gradient-handle-dragged(
handle.role,
self.from-x,
self.from-y,
self.fraction-x(self.mouse-x),
self.fraction-y(self.mouse-y),
);
}
}
}
}
// Arrow keys and space step through the folder.
//
// Focused on show rather than waiting for a click, exactly
// as the grid's judgement scope is: without it the first
// keystroke after opening an image goes nowhere, and — now
// that the shell answers the back gesture by bubbling —
// there would be no focus item for `Key.Back` to bubble
// *from*, so back would close the application from develop
// instead of returning to the grid.
FocusScope {
init => { self.focus(); }
// Shift is state, not an event: the click that needs
// to know about it arrives through a `TouchArea`,
// which reports no modifiers of its own. Tracked on
// both edges so releasing it while the pointer is over
// the canvas is noticed.
key-released(event) => {
root.shift-held = event.modifiers.shift;
return reject;
}
key-pressed(event) => {
root.shift-held = event.modifiers.shift;
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
// of the letter, because the logical key that
// reaches us carries the shift: holding it for
// redo turns "z" into "Z".
if (event.modifiers.control
&& (event.text == "z" || event.text == "Z")) {
if (event.modifiers.shift) {
root.redo();
} else {
root.undo();
}
return accept;
}
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;
}
if (event.text == Key.LeftArrow) {
root.prev-image();
return accept;
}
return reject;
}
}
// Zoom controls, floating over the canvas.
//
// Zoom stays here rather than moving to the sidebar with the
// crop: it is a property of *looking*, it is driven by the
// scroll wheel on the image itself, and its readout belongs
// beside what it is reporting on. Crop moved to the panel
// because it is an edit, and edits live with the other edits.
//
// A way out of whichever mode is on is still reachable from
// here — the develop column may be closed on a narrow
// window, and the strip that enters a mode is pinned inside
// it. Stranding the user in a mode with no visible way out
// is worse than one duplicated control, and it is worse
// still now that there are two modes to be stranded in.
if root.total > 0 && root.load-error == "": HorizontalLayout {
x: 12px;
y: parent.height - self.preferred-height - 12px;
// Bounded, or the row takes the canvas's whole width
// and stretches its children across it. With one
// button that read as an odd-looking "Fit"; with two it
// reads as a toolbar laid over the photograph, which is
// the opposite of what a floating control should do.
width: self.preferred-width;
spacing: 6px;
alignment: start;
if root.view-mode != ViewMode.photo: Button {
// Names the mode being left rather than saying
// "Done", which was unambiguous while there was
// one mode and would not be with two.
text: root.cropping ? "Done Cropping" : "Done Masking";
active: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
// Zoom is a view state, so its readout doubles as the
// control that clears it.
Button {
text: root.zoomed
? Math.round(root.zoom * 100) + "%"
: "Fit";
active: root.zoomed;
enabled: root.zoomed;
clicked => { root.zoom-reset(); }
}
}
// The photo roll, along the foot of the canvas.
//
// Only where a library was opened: with files named on the
// command line there is no window of photographs to roll
// through, which is the same condition the "‹ Library"
// button uses.
//
// Last in the canvas, so it draws over the photograph
// rather than under it.
if root.library-total > 0: PhotoRoll {
width: 100%;
height: 100%;
cells: root.library-cells;
current: root.library-roll-current;
open <=> root.roll-open;
pick(i) => { root.library-roll-pick(i); }
}
// Report size changes so the render target can be resized to
// match. Width and height are tracked separately because
// Slint has no single "geometry changed" hook.
property <int> px-w: Math.round(self.width / 1px);
property <int> px-h: Math.round(self.height / 1px);
changed px-w => { root.canvas-resized(self.px-w, self.px-h); }
changed px-h => { root.canvas-resized(self.px-w, self.px-h); }
}
// Always instantiated, width collapsed to zero when closed. A
// conditional `if` here creates a binding loop — the layout
// depends on `panel-visible`, which derives from the window
// width, which the layout then influences. Slint flags it, and
// it can panic at runtime.
Rectangle {
// TRACES: FR-UI-2
// 280px was chosen for a tablet, where the column is a
// large fraction of the screen and every pixel of it is
// taken from the photograph. On a desktop window it is
// simply too narrow: the mode strip alone is two modes,
// a separator, "All", and one chip per attribute the
// operation set declares, which is more words than 280px
// holds — so it scrolled sideways, and a control you have
// to pan to reach is one you do not know is there.
//
// Driven off `layout-class` rather than `root.width`
// because reading the window width inside the layout that
// sets it is the binding loop the comment on `expanded`
// above describes. Rust already classifies the window; this
// just spends the extra width where there is some.
width: root.panel-visible
? (root.layout-class == "expanded" ? 380px : 280px)
: 0px;
visible: root.panel-visible;
background: Theme.surface;
clip: true;
// **The whole column scrolls, histogram included.**
//
// Only the sliders used to. Everything above them — the
// capture metadata, the histogram, the geometry controls,
// copy and paste — was fixed, so on a 280px column in
// portrait they took the height the sliders needed and the
// histogram could not be moved out of the way. It is the
// instrument the sliders are judged against; being unable
// to scroll to it, or past it, is the worst of both.
//
// `interactive` stands down while a track has a gesture,
// the same arbitration `AdjustPanel` used to do for its own
// scroller — see the note on `SliderTrack`'s `claimed`.
// Without it every attempt to drag a slider would scroll
// the column instead.
// Pinned: the strip is how the column is navigated, so it
// cannot be somewhere you have to navigate to.
// A layout, because the parent is a Rectangle: two children of one
// would both sit at its origin and overlap. The strip is pinned by
// being outside the Flickable rather than by any coordinate.
VerticalLayout {
ModeStrip {
enabled: root.adjust-enabled;
mode: root.view-mode;
tabs: root.adjust-tabs;
active-tab: root.adjust-active-tab;
picked(i) => { root.adjust-tab-picked(i); }
mode-picked(m) => { root.mode-picked(m); }
}
Flickable {
viewport-height: column.preferred-height;
interactive: !adjust.slider-dragging;
// **What the column holds is the mode's answer.**
//
// In local mode it is the mask stack and then the
// selected layer's adjustments, and nothing else:
// the capture metadata describes the file, copy and
// paste move a whole edit between photographs, and
// framing is a decision about the picture's shape —
// none of the three is a property of a region
// within it, so all three would be controls in
// scope of nothing.
//
// **Each child carries its own condition** rather
// than the three being grouped inside one `if`.
// A nested layout under-reports its height here and
// the panels below it get drawn on top of each
// other — invisible in this file and obvious on
// screen. `masks.slint` carries the same note for
// the same reason.
column := VerticalLayout {
if !root.local-mode: InfoPanel {
camera: root.camera;
exposure: root.exposure;
dimensions: root.dimensions;
}
if !root.local-mode: Rectangle {
height: 1px;
background: Theme.rule;
}
// Directly under the capture metadata and above every
// control, because it is the thing the controls are
// judged against: exposure, blacks and whites are all
// set by watching this move (FR-DSP-7). An instrument
// below the sliders it reports on would have the
// photographer looking away from it to use it.
//
// Kept in local mode, where it still reports the
// whole frame while the sliders edit a layer. That
// disagreement is real and is N3's to close; taking
// the instrument away instead would be a worse
// answer than an honest one that is not yet scoped.
HistogramPanel {
data: root.histogram;
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Framing above the colour work, matching how the edit is
// made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see
// `dr_pipeline::framing` on the coordinate order).
if !root.local-mode: GeometryPanel {
enabled: root.adjust-enabled;
angle: root.straighten;
max-straighten: root.max-straighten;
flip-h: root.flip-h;
flip-v: root.flip-v;
modified: root.framing-modified;
rotate(turns) => { root.rotate-quarters(turns); }
flip-h-toggled => { root.flip-h-toggled(); }
flip-v-toggled => { root.flip-v-toggled(); }
angle-changed(v) => { root.straighten-changed(v); }
angle-reset => { root.straighten-changed(0); }
reset => { root.framing-reset(); }
}
if !root.local-mode: Rectangle {
height: 1px;
background: Theme.rule;
}
// Between the framing and the colour work, because it
// acts on both: a paste is about the whole edit, and
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
if !root.local-mode: TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
framing-withheld: root.settings-framing-withheld;
copy => { root.copy-settings(); }
paste => { root.paste-settings(); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Above the adjust panel, because it decides what
// those sliders act on. Below it, the photographer
// would set an exposure and only then discover which
// scope it landed in.
//
// Only in local mode: it is no longer a panel among
// peers that silently re-points another panel, it is
// what that mode's column *is*. That also takes one
// panel out of a scrolling column that had six.
if root.local-mode: MaskPanel {
enabled: root.adjust-enabled;
masks: root.mask-rows;
subjects: root.subject-rows;
segmented: root.segmented;
segmenting: root.segmenting;
refining: root.refining;
// Two-way: the panel is where it is toggled and
// the canvas is what obeys it, so neither owns
// the value alone.
overlay-hidden <=> root.overlay-hidden;
segment(fine) => { root.segment-image(fine); }
mask-selected(id, extend) => { root.mask-selected(id, extend); }
mask-removed(id) => { root.mask-removed(id); }
mask-refined(id) => { root.mask-refined(id); }
mask-toggled(id, on) => { root.mask-toggled(id, on); }
mask-invert-toggled(id, on) => {
root.mask-invert-toggled(id, on);
}
mask-opacity-changed(id, v) => {
root.mask-opacity-changed(id, v);
}
mask-feather-changed(id, v) => {
root.mask-feather-changed(id, v);
}
mask-falloff-picked(id, i) => {
root.mask-falloff-picked(id, i);
}
mask-morphology-picked(id, i) => {
root.mask-morphology-picked(id, i);
}
mask-morph-radius-changed(id, v) => {
root.mask-morph-radius-changed(id, v);
}
add-gradient(radial) => { root.add-gradient-mask(radial); }
add-subject(i) => { root.add-subject-mask(i); }
}
if root.local-mode: Rectangle {
height: 1px;
background: Theme.rule;
}
adjust := AdjustPanel {
rows: root.adjust-rows;
enabled: root.adjust-enabled;
scope: root.adjust-scope;
curve-samples: root.curve-samples;
curve-channels: root.curve-channels;
curve-channel: root.curve-channel;
curve-channel-picked(i) => {
root.curve-channel-picked(i);
}
param-changed(op, param, value) => {
root.param-changed(op, param, value);
}
param-reset(op, param) => {
root.param-reset(op, param);
}
curve-reset(op) => { root.curve-reset(op); }
// A section's reset and a curve's reset are the same
// action — every parameter of one operation back to
// its default — so they share the one callback rather
// than duplicating a handler that would have to be
// kept in step with it.
op-reset(op) => { root.curve-reset(op); }
reset-all => { root.reset-all(); }
}
}
}
}
// The seam between the photograph and the column.
//
// **`x: 0` is load-bearing.** A child of a plain Rectangle
// that declares a size and no position is *centred* in it,
// so this hairline was being drawn straight down the middle
// of the develop column — over the histogram, the geometry
// controls and every slider below them. Invisible in the
// source and, at one pixel of `rule` grey, quiet enough on
// screen to be read as a divider that was meant to be
// there.
Rectangle {
x: 0;
width: 1px;
height: 100%;
background: Theme.rule;
}
}
}
}
// --- the load bar -------------------------------------------------
//
// Last in the file, so it is last in z-order and no view can cover it.
//
// At the top of the *shell* rather than inside a view, because that is
// the only place that is true of every view: a pin download outlives
// the grid it was started from, and until this existed it drew nothing
// at all once the user opened an image. Three pixels, no text and no
// hit area — it says only "something is running", and the settings
// page says what (FR-CAT-1, FR-NC-6c).
if root.activity-busy: ProgressBar {
x: 0;
y: 0;
width: 100%;
indeterminate: !root.activity-determinate;
fraction: root.activity-fraction;
}
// TRACES: FR-NC-6a
// The offline question, over the shell rather than inside the sidebar
// that raises it. The sidebar is 232px wide and closes entirely in the
// compact layout, so a dialogue living in there would be unreadable on
// the device the gesture exists for — and the answer commits gigabytes
// either way, which deserves the width to say so.
//
// Below the load bar, above everything else: a download started from
// here shows its progress in that bar, and the bar must not be the
// thing the dialogue covers.
OfflinePrompt {
width: 100%;
height: 100%;
title: root.offline-prompt-title;
detail: root.offline-prompt-detail;
keep-label: root.offline-prompt-keep-label;
release-label: root.offline-prompt-release-label;
can-keep: root.offline-prompt-can-keep;
can-release: root.offline-prompt-can-release;
busy: root.offline-prompt-busy;
keep() => { root.offline-prompt-keep(); }
release() => { root.offline-prompt-release(); }
dismiss() => { root.offline-prompt-dismiss(); }
}
}
}