Files
DarkRoom/ui/dr-ui/ui/app.slint
T
dtourolleandClaude Opus 5 5a8327824f Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.

The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.

Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.

Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.

Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.

The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:07:14 +02:00

2489 lines
129 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, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
import { HistoryPanel, HistoryRow } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } 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 { PresetSheet } from "presets.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
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-DSP-8
/// The display showing the canvas and the colour transform it is getting.
/// Shown under ABOUT in Settings; see `settings.slint` for why the second
/// one is a sentence rather than a name.
in property <string> display-name: "detecting…";
in property <string> display-colour: "detecting…";
in property <string> display-others: "";
/// 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;
/// TRACES: FR-DEV-8
property <bool> repairing: root.view-mode == ViewMode.spots;
/// 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;
/// TRACES: FR-DEV-3
/// The ratios the crop may be locked to, and which one is chosen.
///
/// Named by Rust rather than listed here: the set is `CropAspect::CHOICES`
/// and the labels come off it, so a ratio added there appears without this
/// file changing.
in property <[string]> crop-aspects;
in property <int> crop-aspect: 0;
/// Whether the chosen ratio is standing on its short edge.
in property <bool> crop-portrait: false;
/// Whether the chosen ratio has a portrait form at all — `Free` and the
/// square do not, and the switch says so rather than vanishing.
in property <bool> crop-aspect-turnable: false;
/// Enter a mode, or `photo` to leave whichever one is current.
callback mode-picked(ViewMode);
/// A dragged crop rect in fractions of the frame, then the corner the drag
/// is holding as a `0`/`1` pair — or `-1, -1` for a move, which keeps the
/// shape it has. See `CropOverlay` in crop.slint for why the corner has to
/// travel with the rect.
callback crop-changed(float, float, float, float, float, float);
/// Lock the crop to one of `crop-aspects`, by index.
callback crop-aspect-picked(int);
/// Stand the chosen ratio on its other edge.
callback crop-portrait-toggled();
// --- 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);
/// TRACES: FR-DEV-3
/// The straightening gesture is over and this is the angle to keep.
///
/// Reported separately from the value because the auto-crop that follows a
/// straighten has to happen once, at the end — see
/// `DevelopSession::auto_crop_to_angle`.
callback straighten-committed(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();
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> history-rows;
/// What undo would take back, resolved. Empty when there is nowhere to go.
in property <string> undo-label: "";
/// A row's own `index`.
callback history-picked(int);
/// 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 <string> launch-folder: "";
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);
/// The path of a folder library — no account, no credential.
callback launch-use-folder(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);
/// The same range as a band on the capture-time axis, and the ends of it
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
/// conversion both ways, so the band lands where the finger left it.
in property <float> library-range-from-fraction: -1;
in property <float> library-range-to-fraction: -1;
callback library-timeline-range-changed(float, float);
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;
// --- people and faces (FR-CULL-8 … FR-CULL-12) ---
//
// A third top-level screen rather than a panel: naming a cluster and
// pulling a stranger out of it are tasks with their own rhythm, and they
// need the whole window. Gated the same way the other screens are, so
// exactly one is ever up.
in property <bool> show-identity: false;
in property <[IdentityPerson]> identity-people;
in property <[IdentityFace]> identity-faces;
in property <int> identity-selected: -1;
in property <string> identity-selected-name;
in property <int> identity-unassigned: 0;
in property <bool> identity-calibrated: false;
in property <bool> identity-indexing: false;
in property <string> identity-indexing-status;
in property <bool> identity-model-missing: false;
in property <string> identity-back-label: "‹ Library";
in property <string> identity-coverage;
in property <bool> identity-coverage-complete: false;
in property <int> identity-picked: 0;
/// The namesake merge the Identity screen is offering. See IdentityScreen.
in property <string> identity-merge-offer-name;
in property <int> identity-merge-offer-faces: 0;
/// See IdentityScreen.name-revision.
in property <int> identity-name-revision: 0;
callback identity-open();
callback identity-close();
callback identity-person-picked(int);
callback identity-rename(string);
callback identity-merge-accept();
callback identity-merge-decline();
callback identity-name-edited(string);
callback identity-confirm-face(int);
callback identity-reject-face(int);
callback identity-toggle-pick(int);
callback identity-confirm-all();
callback identity-split-picked();
callback identity-recluster();
in property <bool> identity-regrouping: false;
in property <string> identity-regroup-status;
in property <int> identity-ignored-count: 0;
in property <bool> identity-show-ignored: false;
in property <bool> identity-selected-ignored: false;
callback identity-toggle-show-ignored();
callback identity-ignore-person(int, bool);
callback identity-show-photos(int, bool);
callback identity-index();
callback identity-stop-indexing();
callback identity-check-coverage();
callback identity-delete-all();
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);
/// How many cells the grid's viewport shows at once. Rust sizes the
/// loaded window from it — see `library_ui::window_move`.
callback library-viewport-cells(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);
/// Whose photographs the grid is narrowed to, one entry each.
in property <[PersonChip]> library-filter-people;
/// Whether those people are an intersection rather than a union.
in property <bool> library-filter-people-all: false;
callback library-filter-person-cleared(int);
callback library-filter-people-mode-toggled();
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;
// TRACES: FR-DEV-3f
// The installed film stocks, "None" first, and which one this photograph
// is on. Resolved names: the core publishes ids, and a stock's name is a
// product name that does not translate.
in property <[string]> film-stocks;
in property <int> film-selected: 0;
in property <bool> film-can-print: false;
in property <bool> film-print: false;
callback film-picked(int);
callback film-print-toggled(bool);
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();
// --- named presets (FR-DEV-6) ---
//
// The saved half of the same requirement. On the window rather than in a
// view for the reason the clipboard is: a preset is saved in develop,
// where there is an edit to capture, and applied most often in the grid,
// where there is a selection to apply it to.
in-out property <bool> presets-open: false;
in property <[string]> preset-names;
/// Whether there is an edit in hand to save, set by whichever view opened
/// the sheet. `in-out` because that is where the answer is known.
in-out property <bool> preset-can-save: false;
/// What applying would act on: 0 is the open photograph, higher is that
/// many selected. Set at open, and read back by Rust when one is picked —
/// so the action matches the count the user read on the way in.
in-out property <int> preset-apply-count: 0;
/// What saving would capture, from the routine the clipboard summary uses.
in property <string> preset-capture-summary;
/// Why the last name was refused, cleared by the next keystroke.
in property <string> preset-name-error;
callback save-preset(string);
callback apply-preset(string);
callback rename-preset(string, string);
callback delete-preset(string);
callback preset-name-edited(string);
/// Raised as the sheet opens over an open photograph, so Rust can say what
/// saving would capture. A property refreshed on every slider drag would
/// be recomputing a string nobody is looking at.
callback presets-opened();
// --- 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);
/// TRACES: FR-CAT-6
in property <[string]> settings-timeline-bar-labels;
in property <int> settings-timeline-bars-selected: 0;
callback settings-timeline-bars-picked(int);
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 <string> settings-sizing-value-label: "Size value";
in property <int> settings-sizing-height: 0;
in property <bool> settings-sizing-has-height: false;
in property <[string]> settings-screen-labels;
in property <int> settings-screen-selected: -1;
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-sizing-height-changed(string);
callback settings-screen-picked(int);
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";
/// TRACES: FR-UI-2
/// The most the develop column may take, in logical pixels.
///
/// Set from Rust beside `layout-class` and for the same reason: the column
/// sizes itself to its contents, and a ceiling read from `root.width`
/// inside the layout that `root.width` depends on is the binding loop the
/// note above describes.
///
/// A ceiling and not a width. The column asks for what it needs and this
/// only says when the answer has stopped being reasonable — a photograph
/// with a third of a window left to show itself in is not a develop view.
/// The default is generous because the first frame is drawn before any
/// resize has been reported, and a column briefly too wide is a far
/// smaller fault than one briefly clipped.
in property <length> panel-max-width: 520px;
// --- 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();
// --- repairs (FR-DEV-8) -------------------------------------------------
/// Every repair on the photograph, as circles in fractions of the shown
/// image. Rust maps them through the framing, so they follow a crop, a
/// zoom, a pan and a rotation without anything here knowing about any of
/// those.
in property <[SpotHandle]> spot-handles;
/// A click on the photograph in repair mode, in fractions of the shown
/// image: cover what is here.
callback spot-placed(float, float);
/// A repair's circle dragged: which repair, which half of it, where the
/// press landed and where the pointer is now.
///
/// The press is carried rather than a running delta for the reason the
/// gradient handles carry it — the geometry is re-derived from where it
/// stood when the gesture began, so a drag cannot accumulate rounding
/// error along its length.
callback spot-handle-dragged(string, SpotRole, float, float, float, float);
/// A drag finished, so it can be one history step rather than one a frame.
callback spot-handle-released();
/// A repair chosen, so the column describes it.
callback spot-selected(string);
/// Take the selected repair off the photograph.
callback spot-removed(string);
/// Which repair the column is describing, or "" for none.
in-out property <string> selected-spot: "";
/// The selected repair's settings, and how many repairs there are.
///
/// Pushed as scalars rather than read off `spot-handles`, because the
/// circles carry what is *drawn* — a radius already mapped through the
/// framing into fractions of the shown image — and the panel edits what is
/// *stored*. Deriving one from the other would mean a slider that moved
/// differently at different zoom levels.
in property <int> spot-count: 0;
in property <float> spot-radius: 0.012;
in property <float> spot-feather: 0.35;
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone.
in property <int> spot-mode: 0;
callback spot-radius-changed(float);
callback spot-feather-changed(float);
callback spot-opacity-changed(float);
callback spot-mode-picked(int);
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;
display-name: root.display-name;
display-colour: root.display-colour;
display-others: root.display-others;
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;
timeline-bar-labels: root.settings-timeline-bar-labels;
timeline-bars-selected: root.settings-timeline-bars-selected;
timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); }
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;
sizing-value-label: root.settings-sizing-value-label;
sizing-height: root.settings-sizing-height;
sizing-has-height: root.settings-sizing-has-height;
screen-labels: root.settings-screen-labels;
screen-selected: root.settings-screen-selected;
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); }
sizing-height-changed(t) => { root.settings-sizing-height-changed(t); }
screen-picked(i) => { root.settings-screen-picked(i); }
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;
// The face pass reuses the Identity Manager's own state rather
// than duplicating it: one job, startable from either place, and
// a button that cannot disagree with the screen about whether it
// is running.
face-indexing: root.identity-indexing;
face-coverage: root.identity-coverage;
face-model-missing: root.identity-model-missing;
library-open: root.library-open;
thumbnail-library() => { root.library-thumbnail-all(); }
index-faces() => { root.identity-index(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
// Like the launch screen, this replaces the window rather than
// overlaying it — see `show-identity`.
if !root.show-import && !root.show-settings && root.show-identity: IdentityScreen {
width: 100%;
height: 100%;
people: root.identity-people;
faces: root.identity-faces;
selected-person: root.identity-selected;
selected-name: root.identity-selected-name;
unassigned: root.identity-unassigned;
calibrated: root.identity-calibrated;
indexing: root.identity-indexing;
indexing-status: root.identity-indexing-status;
model-missing: root.identity-model-missing;
picked-count: root.identity-picked;
back-label: root.identity-back-label;
coverage: root.identity-coverage;
coverage-complete: root.identity-coverage-complete;
merge-offer-name: root.identity-merge-offer-name;
merge-offer-faces: root.identity-merge-offer-faces;
name-revision: root.identity-name-revision;
person-picked(id) => { root.identity-person-picked(id); }
rename(name) => { root.identity-rename(name); }
merge-accept() => { root.identity-merge-accept(); }
name-edited(t) => { root.identity-name-edited(t); }
merge-decline() => { root.identity-merge-decline(); }
confirm-face(id) => { root.identity-confirm-face(id); }
reject-face(id) => { root.identity-reject-face(id); }
toggle-pick(id) => { root.identity-toggle-pick(id); }
confirm-all() => { root.identity-confirm-all(); }
split-picked() => { root.identity-split-picked(); }
compact: !root.expanded;
photos-filtered: root.library-filter-people.length > 0;
regrouping: root.identity-regrouping;
regroup-status: root.identity-regroup-status;
ignored-count: root.identity-ignored-count;
show-ignored: root.identity-show-ignored;
selected-ignored: root.identity-selected-ignored;
toggle-show-ignored() => { root.identity-toggle-show-ignored(); }
ignore-person(id, on) => { root.identity-ignore-person(id, on); }
show-photos(id, add) => { root.identity-show-photos(id, add); }
recluster() => { root.identity-recluster(); }
index-faces() => { root.identity-index(); }
stop-indexing() => { root.identity-stop-indexing(); }
check-coverage() => { root.identity-check-coverage(); }
delete-all-face-data() => { root.identity-delete-all(); }
close() => { root.identity-close(); }
}
// 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-identity && 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;
folder-path: root.launch-folder;
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); }
use-folder(path) => { root.launch-use-folder(path); }
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-identity && !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); }
range-from-fraction: root.library-range-from-fraction;
range-to-fraction: root.library-range-to-fraction;
timeline-range-changed(a, b) => {
root.library-timeline-range-changed(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-DEV-6
// Opened with the selection's size, which is both what the
// sheet says it would act on and what the apply handler
// reads back to decide it means the batch.
open-presets => {
root.preset-apply-count = root.library-selected-count;
root.preset-can-save = false;
root.presets-open = true;
}
// 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); }
viewport-cells-changed(n) => { root.library-viewport-cells(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(); }
open-people() => { root.identity-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-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
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); }
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
}
}
}
if !root.show-import && !root.show-settings && !root.show-identity && !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-people() => { root.identity-open(); }
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.
//
// TRACES: FR-DSP-8
// The canvas is rendered at the *physical* pixel size of
// the box this image occupies, so `contain` presents it
// 1:1 and neither filter is reached at all until the
// photographer zooms. That is the point: on a fractionally
// scaled desktop the buffer used to be a logical-sized one
// that the compositor stretched, and no choice of filter
// recovers detail that was never rendered.
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,
);
}
}
// TRACES: FR-DEV-8
// Placing a repair, beside the region picker and for the
// same reasons: a click and a pan want different handlers,
// and this one has to reach the click first.
//
// Below the circles declared further down, so a press that
// lands on an existing repair takes hold of it instead of
// making another one on top.
if root.repairing && root.total > 0 && root.load-error == "": 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.spot-placed(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
}
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 ------------------------------------------
//
// Placed over the fitted image, so the fractions it draws are
// fractions of the picture. See `CropOverlay` in crop.slint.
if root.cropping && root.total > 0 && root.load-error == "": CropOverlay {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
height: canvas-area.shown-h;
crop-x: root.crop-x;
crop-y: root.crop-y;
crop-w: root.crop-w;
crop-h: root.crop-h;
crop-changed(x, y, w, h, hx, hy) => {
root.crop-changed(x, y, w, h, hx, hy);
}
}
// --- gradient handles ---------------------------------------
//
// TRACES: FR-DEV-3 | FR-UI-3
// Handed the fitted image rect for the same reason the crop
// overlay and the repairs are: the photograph is letterboxed
// inside this box, Slint does not report the fitted rect, and
// anything that has to land *on* the picture is placed against
// the derived one. See `GradientHandles` in masks.slint.
GradientHandles {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
height: canvas-area.shown-h;
handles: root.gradient-handles;
dragged(role, fx, fy, tx, ty) => {
root.gradient-handle-dragged(role, fx, fy, tx, ty);
}
released() => { root.gradient-handle-released(); }
}
// --- repairs (FR-DEV-8) -------------------------------------
//
// Every position in here is a fraction of the fitted image
// rect, which is why the overlay is handed that rect rather
// than the canvas box. See `SpotHandles` in spots.slint for
// what is drawn and why it is drawn at true size.
SpotHandles {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
height: canvas-area.shown-h;
handles: root.spot-handles;
selected(id) => { root.spot-selected(id); }
dragged(id, role, fx, fy, tx, ty) => {
root.spot-handle-dragged(id, role, fx, fy, tx, ty);
}
released() => { root.spot-handle-released(); }
}
// 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;
}
// TRACES: FR-DEV-8
// Delete removes the selected repair, and only in
// repair mode: the same key in the grid judges
// photographs, and a key that means two things
// depending on a mode the user has forgotten they
// are in is how work disappears.
if (root.repairing && root.selected-spot != ""
&& (event.text == Key.Delete
|| event.text == Key.Backspace)) {
root.spot-removed(root.selected-spot);
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"
: (root.repairing ? "Done Repairing" : "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.
develop-column := Rectangle {
// TRACES: FR-UI-2
// **As wide as what it holds.**
//
// It was 280px, chosen for a tablet where the column is a
// large fraction of the screen and every pixel of it is
// taken from the photograph. That was too narrow on a
// desktop, so a second number — 380px — was added for the
// expanded class. Both were guesses at how much room the
// widest row in this column needs, and a guess is exactly
// what cannot be right here: the mode strip is two modes, a
// separator, "All", and one chip per attribute the
// operation set *declares*, so the row is generated and no
// constant in this file can track it. When the guess came
// up short the strip scrolled sideways, and a control you
// have to pan to reach is one you do not know is there.
//
// **So the column asks instead.** Every panel that can
// appear in it publishes a `content-width` — how wide it
// has to be before it clips itself — and also declares that
// as its `min-width`. The `min-width` is what makes this
// aggregate on its own: `column` below is a layout, so it
// already reports the largest minimum among its children,
// and it does so for the panels that come and go with the
// mode as well, which sit inside `if`s and cannot be named
// from out here. Grep `content-width` in this directory to
// see every panel that has a say.
//
// The mode strip is named explicitly only because it is
// pinned *outside* that layout, so nothing else would
// measure it.
//
// There is no floor. A floor is another guess, and the
// panels state their own minimums now. The one thing left
// over the measurement is `panel-max-width` — not a size
// but a policy, that a column may not take the window from
// the photograph it exists to serve — and past that the
// Flickables inside are the fallback, as they have always
// been. It comes from Rust rather than from `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 measures the window for
// `layout-class`, so this is one more thing said in the
// same breath.
property <length> content-width: max(
develop-strip.content-width,
column.preferred-width,
);
width: root.panel-visible
? min(root.panel-max-width, self.content-width)
: 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 {
develop-strip := 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;
// **And the width, or the column is drawn centred
// over its own edges.**
//
// Unset, a Flickable's viewport takes its content's
// preferred width, and a viewport wider than the
// Flickable is *centred* in it — the same rule the
// note on the seam's `x: 0` below records. So a
// column narrower than what it held did not clip
// the right-hand side and keep the left, it lost
// half of each: "HISTOGRAM" read "ISTOGRAM",
// "Straighten" read "aighten", and Paste ran off
// the far side. It looked like a rendering fault
// and it was an alignment one.
//
// Floored at the Flickable's own width so the
// ordinary case — a column sized to fit, which is
// now every case the cap does not bite — puts the
// content at x: 0 and stretches it, rather than
// leaving it preferred-width and adrift.
viewport-width: max(self.width, column.preferred-width);
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 && !root.repairing: InfoPanel {
camera: root.camera;
exposure: root.exposure;
dimensions: root.dimensions;
}
if !root.local-mode && !root.repairing: 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 && !root.repairing: 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;
// The ratio chips belong to the crop tool, so
// they appear with it. Shown at all times they
// would be a control for a gesture that is not
// on screen; hidden behind a lid they would be
// a feature nobody finds.
cropping: root.cropping;
aspects: root.crop-aspects;
aspect: root.crop-aspect;
portrait: root.crop-portrait;
aspect-turnable: root.crop-aspect-turnable;
aspect-picked(i) => { root.crop-aspect-picked(i); }
portrait-toggled => { root.crop-portrait-toggled(); }
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-committed(v) => {
root.straighten-committed(v);
}
angle-reset => { root.straighten-changed(0); }
reset => { root.framing-reset(); }
}
if !root.local-mode && !root.repairing: 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 && !root.repairing: 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(); }
// Opened with no count, which is what tells
// the apply handler this means the open
// photograph rather than a selection.
open-presets => {
root.preset-apply-count = 0;
root.preset-can-save = root.adjust-enabled;
root.presets-opened();
root.presets-open = true;
}
}
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.
// TRACES: FR-DEV-8
// What the column *is* in repair mode, on the same
// terms as the mask panel: not a panel among peers
// that quietly re-points the sliders below it, but
// the whole of what this mode has to say.
if root.repairing: SpotPanel {
enabled: root.adjust-enabled;
has-selection: root.selected-spot != "";
count: root.spot-count;
radius: root.spot-radius;
feather: root.spot-feather;
spot-opacity: root.spot-opacity;
mode: root.spot-mode;
radius-changed(v) => { root.spot-radius-changed(v); }
feather-changed(v) => { root.spot-feather-changed(v); }
opacity-changed(v) => { root.spot-opacity-changed(v); }
mode-picked(i) => { root.spot-mode-picked(i); }
removed => { root.spot-removed(root.selected-spot); }
}
if root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
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(); }
// TRACES: FR-DEV-3f
film-stocks: root.film-stocks;
film-selected: root.film-selected;
film-can-print: root.film-can-print;
film-print: root.film-print;
film-picked(i) => { root.film-picked(i); }
film-print-toggled(on) => { root.film-print-toggled(on); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Last in the column, and the length is why. The
// stack runs sixty-four deep, so anywhere else it
// would push the controls it is a record of below
// the fold. Its own rows run newest-first, which
// puts the steps worth reaching immediately under
// the heading rather than at the far end of them.
//
// Present in local mode as well. The mask work
// *is* history — adding a layer, moving a
// gradient and feathering an edge are all steps —
// and a list that emptied when the photographer
// entered the mode that produces the most steps
// would be a list they stopped trusting.
HistoryPanel {
rows: root.history-rows;
enabled: root.adjust-enabled;
can-undo: root.can-undo;
can-redo: root.can-redo;
undo-label: root.undo-label;
undo => { root.undo(); }
redo => { root.redo(); }
picked(i) => { root.history-picked(i); }
}
}
}
}
// 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.
// TRACES: FR-DEV-6
// Over the shell rather than inside a view, because both views open
// it — and because the develop column is 320px wide, which is not
// enough to list presets and rename one in.
if root.presets-open: PresetSheet {
width: 100%;
height: 100%;
names: root.preset-names;
can-save: root.preset-can-save;
apply-count: root.preset-apply-count;
capture-summary: root.preset-capture-summary;
name-error: root.preset-name-error;
save(name) => { root.save-preset(name); }
apply(name) => { root.apply-preset(name); }
rename(from, to) => { root.rename-preset(from, to); }
remove(name) => { root.delete-preset(name); }
name-edited(text) => { root.preset-name-edited(text); }
dismiss => { root.presets-open = false; }
}
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(); }
}
}
}