Files
DarkRoom/ui/dr-ui/ui/app.slint
T
dtourolle 402dcdc24c Move the library grid's cells and selection onto their own Slint global
AppWindow carried the grid's loaded window of cells, the keyboard cursor,
drag and drop, the held-row long-press state, columns and cell size, the
scroll and viewport bookkeeping, the photo roll's pick and centre-request,
and the local-only/reorder/collection-filing gestures that act on a
selection, as properties and callbacks on the root component. That state now
lives in the `Library` global declared in library.slint, next to the structs
(LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other
components already share; Rust reaches it through
window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the
root, the same change collections.slint's `Collections` global made for the
sidebar.

library-visible stays on AppWindow: it is computed from active-page and
active-view, the shell's own routing state, which a global cannot read.
Everything else still prefixed library- — the timeline, the filter bar,
ratings and flags, keywording, and the routes and status lines — stays on
the window for now and moves in the commits that follow.
2026-09-20 21:42:48 +02:00

3120 lines
167 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 { Develop, ViewMode } from "session.slint";
import { AdjustPanel, Adjustments, ComposePanel, Framing, GroupStrip, ParamRow, Transfer, TransferPanel } from "adjust.slint";
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, Masking, SubjectRow } from "masks.slint";
import { Repair, SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
import { HistoryPanel, HistoryRow, Steps } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { MergePage, MergeFrameRow } from "merge.slint";
import { Capture, StatusBar, InfoPanel } from "develop.slint";
import { ToolRail } from "toolrail.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip, MergeFrameRow }
export { GestureRow }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
// The develop view's globals, re-exported so Rust can reach them: a global is
// hooked through `window.global::<Adjustments>()` and only what this document
// exports appears in the generated API. `session.slint` says why the develop
// panels take their wiring this way and why there is one global per family.
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
export { Collections }
export { Library }
/// Which top-level screen is on display. Exactly one at a time — replacing
/// the `show-launch` / `show-library` / `show-identity` booleans, with
/// develop as "none of the above" wired into every conditional that drew any
/// of the other three. Those conditionals compared four and five booleans at
/// once to rule out two screens being true together; comparing one value to
/// a variant cannot be wrong in that way.
///
/// `develop` is listed first, and so is the type's default: it is what the
/// old booleans meant when every one of them was false, which is the state
/// files named on the command line stay in permanently — there is no
/// library behind them to switch to — and the state the window briefly
/// holds at construction, before `lib.rs::run` chooses.
export enum View {
develop,
library,
identity,
launch,
}
/// Which page, if any, is drawn over the current `View`. `settings`,
/// `import` and `merge` are reachable from more than one `View` — Settings
/// from the library and from develop, Import and Merge from the library —
/// and are drawn outermost so the view underneath is torn down rather than
/// nested inside the one it is about to change. `none` is the ordinary case,
/// and the type's default.
///
/// Kept apart from `View` rather than folded into it as three more variants:
/// closing a page has to return to whichever view was already current, and
/// today that works because the view's own property is left untouched while
/// a page sits over it. Three more `View` variants would need a second field
/// remembering what to return to; a separate, single-valued `Page` needs
/// nothing to remember.
export enum Page {
none,
settings,
import,
merge,
}
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 <string> inference-backend: "detecting…";
in property <string> inference-detail: "";
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";
/// TRACES: NFR-OPS-1
/// The diagnostics bundle's preview and the last save's outcome — see
/// `SettingsPage`, which is the only reader.
in property <string> diagnostics-preview;
in property <string> diagnostics-result;
callback diagnostics-prepare();
callback diagnostics-save();
callback diagnostics-discard();
// Current image, for the status strip and empty state.
in property <string> filename: "";
in property <int> index: 0;
in property <int> total: 0;
in property <string> load-error: "";
/// TRACES: FR-CULL-3
/// The focus marks themselves, and whether they describe *this* frame.
/// Rust's, because they come from a compute pass — see `peaking.slint` for
/// why `focus-overlay-ready` is a separate question from
/// `Peaking.showing`, and for the three things the photographer chose.
in property <image> focus-overlay;
in property <bool> focus-overlay-ready: false;
// --- 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;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
in-out property <float> crop-x: 0.0;
in-out property <float> crop-y: 0.0;
in-out property <float> crop-w: 1.0;
in-out property <float> crop-h: 1.0;
/// Enter a mode, or `photo` to leave whichever one is current.
callback mode-picked(ViewMode);
/// A dragged crop rect in fractions of the frame, 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);
// --- before and after (FR-DEV-7) ---
/// TRACES: FR-DEV-7
/// Whether the canvas is currently showing the unedited original.
///
/// Rust owns it, and the ownership is the point: this is *not* a history
/// position and must never become one. Held down, the canvas renders the
/// photograph with the adjustments taken off and the framing left on; let
/// go, the edit comes back. Nothing is recorded, so a photographer can
/// check whether they have overcooked a frame without paying two steps of
/// undo for the look.
in property <bool> showing-original: false;
/// The comparison is being held, or has been let go.
///
/// Both edges through one callback rather than a press and a release, so
/// the two cannot get out of step and strand the view on the original.
callback compare-original(bool);
// --- sampling a neutral off the photograph (FR-DEV-3) ---
/// TRACES: FR-DEV-3
/// Shorthand for the condition the canvas tests, written once.
///
/// Which operation is armed lives in `Adjustments`, with the panel whose
/// headings arm it. It is held in the interface rather than in Rust,
/// unlike the view mode beside it, and the difference is real: entering a
/// mode has side effects on the session and this has none. Arming changes
/// what the next click on the photograph means and nothing else — no
/// parameter moves, no history step is taken, and the session has nothing
/// to hear about until a point is picked. It is one-shot: the click that
/// samples disarms it.
property <bool> sampling: Adjustments.sampling-op >= 0;
/// A neutral was picked, in fractions of the visible image.
///
/// The operation is not passed: which one a sampled colour belongs to is
/// settled by what the chain declared, and this file has no business
/// knowing. See `dr_pipeline::neutral`.
callback neutral-picked(float, float);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
/// TRACES: FR-UI-4
/// Toggle between fitting the frame and inspecting it at 1:1, anchored on
/// a point given in fractions of the visible image.
///
/// A negative coordinate means "wherever I was last looking" — the last
/// inspected point, or the centre of the frame if there has not been one.
/// A sentinel rather than a second callback, for the reason `swatch-hue`
/// is one: a control that has no point of its own still has to be able to
/// ask, and every value in `0..1` is a real place on the photograph.
///
/// Rust owns which of the two states it is, for the same reason it owns
/// the view mode: 1:1 is not a number this file can compute — it depends
/// on the file's own resolution against the viewport's — and the point is
/// remembered across photographs, which is state no screen holds.
callback inspect-toggled(float, float);
// --- active view (FR-UI-8) ---
//
// The four top-level screens share one property; see `View`. Rust is the
// only writer — nothing in this file assigns to it — so `in` is enough.
in property <View> active-view: View.develop;
// --- 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> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <bool> launch-endpoint-is-library: false;
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.
/// Whether the grid is actually on screen — the condition the `if` below
/// is written with, hoisted so Rust can read the same answer.
///
/// **`active-view == View.library` is not enough on its own.** The view
/// stays `library` while Settings, Import, Merge or People replaces the
/// window as a page over it. The grid subtree is torn down in all four
/// cases, and a Flickable being destroyed passes its viewport through zero
/// on the way out — which reaches `on_library_scrolled` as a scroll to the
/// top of the library and overwrote the position the user was at. That is
/// the "I open Settings and come back to the beginning" report.
///
/// So the guard has to be this, and the `if` has to be spelled from it, or
/// the two can drift apart again.
out property <bool> library-visible:
root.active-page == Page.none && root.active-view == View.library;
// --- 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;
/// The catalog on this device is being opened and checked, on a worker,
/// before the scan starts (`library_ui::open_catalog_soon`).
///
/// Separate from `library-scanning` because they are different answers to
/// the same question: this one is reading a file that is already here, and
/// that one is walking a tree over the network. Both are true at once for
/// the first moment of a launch, and only the more specific of them is
/// worth putting on an empty grid.
in property <bool> library-opening: 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: "";
/// 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-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();
// The question a damaged catalog asks. Same shape as the prompt above and
// for the same reason: an empty title is what closes it, and every word in
// it is composed in Rust, which is the only side that knows what SQLite
// said and which backups exist.
in property <string> recovery-title: "";
in property <string> recovery-detail: "";
in property <string> recovery-diagnosis: "";
in property <string> recovery-restore-label: "";
in property <bool> recovery-can-restore: false;
in property <string> recovery-rebuild-label: "";
in property <bool> recovery-busy: false;
callback recovery-restore();
callback recovery-rebuild();
callback recovery-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 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 — see `View`.
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 <bool> identity-coverage-read-only: 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;
// The grouping dials Regroup turns. Percent and a face count, both stored
// in the device settings file beside the cache budgets.
in property <float> identity-merge-probability: 80;
in property <int> identity-min-group-size: 2;
callback identity-merge-probability-changed(float);
callback identity-min-group-size-changed(int);
callback identity-preview-grouping();
in property <bool> identity-previewing: false;
in property <string> identity-grouping-preview;
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-reindex();
callback identity-stop-indexing();
callback identity-check-coverage();
callback identity-delete-all();
callback library-thumbnail-all();
/// 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;
callback library-sync-now();
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) ---
//
// State that belongs to the sidebar itself — the tree, the row menu,
// drag and drop between rows, the trash row — has moved to the
// `Collections` global in `collections.slint`. What is left here is
// shared with the library grid, whose Rust wiring still reaches it on
// the window.
/// Which collection scopes the grid. 0 is the whole library.
in property <int> collection-selected: 0;
/// 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. Owned by
/// `run()`'s panel-layout wiring rather than the sidebar's own global,
/// because it seeds from the saved layout before the sidebar exists.
in property <bool> collections-visible: true;
callback collection-select(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);
/// 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);
// --- 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);
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
callback library-burst-toggled(int);
/// One frame of an open burst was named as the one the group folds to, by
/// row (FR-CULL-5).
callback library-burst-representative-chosen(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();
/// TRACES: FR-CULL-8a | FR-CULL-13
/// Whether the grid is narrowed to photographs with nobody blinking.
in property <bool> library-filter-eyes-open: false;
callback library-filter-eyes-open-toggled();
/// Everyone the library knows, for the filter bar's people tray.
/// TRACES: FR-UI-4
/// The gesture reference's rows, read from the generated table.
in property <[GestureRow]> library-gestures;
in property <[PersonChip]> library-people;
callback library-people-listed();
callback library-filter-person-toggled(int);
in property <int> library-filter-min-rating: 0;
in property <bool> library-filter-unjudged: false;
in property <int> library-filter-flag: 0;
/// Images per star count, index 0 unrated, for the filter chips.
in-out property <[int]> library-rating-counts;
callback library-filter-min-rating-changed(int);
callback library-filter-unjudged-changed(bool);
callback library-filter-flag-changed(int);
callback next-image();
callback prev-image();
// --- copying settings between photographs (FR-DEV-6) ---
//
// The clipboard is a window-lifetime thing rather than a view's, which is
// why the grid's half of 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. The develop half is `Transfer`, in `adjust.slint`.
/// 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);
/// TRACES: FR-DEV-6
/// Which kinds of edit a copy, paste or preset carries.
///
/// One set for all three, on the window because all three read it: the
/// clipboard is pasted from develop and from the grid, and a preset is
/// applied from both. A per-action set would be three answers to one
/// question about how this photographer works.
in property <[ScopeKind]> copy-scope-kinds;
in property <bool> copy-scope-empty: false;
callback copy-scope-toggled(string);
/// TRACES: FR-DEV-6
/// Reading Lightroom presets in, and what came of the last attempt.
in property <string> preset-import-report;
callback import-presets(string);
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and outermost among the pages drawn over
// a view: it is reachable from the library and from develop, so guarding
// it with which view was current would make which one you came from
// decide whether it appears. See `Page`.
//
// 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 <Page> active-page: Page.none;
// --- 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.
/// 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;
// TRACES: FR-MRG-1 | FR-MRG-7
// The merge page, on the import page's model: a decision to be
// committed or abandoned, with a running state, over the library.
in property <string> merge-stage: "";
in property <bool> merge-running: false;
in property <float> merge-fraction: 0.0;
in property <string> merge-status: "";
in property <string> merge-error: "";
in property <bool> merge-aligned: false;
in property <bool> merge-complete: false;
in property <string> merge-summary: "";
in property <[MergeFrameRow]> merge-frames;
in property <image> merge-preview;
in property <int> merge-projection-selected: 0;
in property <int> merge-border-selected: 0;
in property <bool> merge-fill-available: false;
in property <string> merge-fill-note: "";
in property <bool> merge-preview-filled: false;
// The fill's knobs (experimental; FR-MRG-4), in working pixels.
in property <float> merge-fill-scale: 2;
in property <float> merge-fill-erosion: 4;
in property <float> merge-fill-coarse: 4;
in property <float> merge-fill-band: 96;
in property <float> merge-fill-mirror: 48;
in property <float> merge-fill-feather: 48;
in property <bool> merge-done: false;
/// Merge the grid's selection: opens the page and starts the alignment.
callback library-merge-selection();
/// The confirmation (FR-MRG-1). Nothing is written before this.
callback merge-confirm();
callback merge-abandon();
callback merge-projection-picked(int);
callback merge-border-picked(int);
/// A fill knob turned: its name and the new value.
callback merge-fill-knob(string, float);
callback merge-close();
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 <[string]> settings-fetch-ahead-labels;
in property <int> settings-fetch-ahead-selected: 0;
callback settings-fetch-ahead-picked(int);
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);
/// 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);
/// TRACES: FR-CAT-13
in property <bool> settings-write-xmp: false;
callback settings-write-xmp-toggled(bool);
in property <int> settings-xmp-conflicts: 0;
callback settings-xmp-reload();
/// TRACES: FR-UI-1
/// The group-navigation preference, and what "Automatic" resolves to here.
in property <[string]> settings-group-nav-labels;
in property <int> settings-group-nav-selected: 0;
in property <string> settings-group-nav-auto-says;
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: "";
/// TRACES: FR-CULL-8
/// Which face detector the indexing pass runs.
in property <[string]> settings-face-detector-labels;
in property <int> settings-face-detector-selected: 0;
callback settings-face-detector-picked(int);
callback settings-group-nav-picked(int);
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;
/// TRACES: FR-UI-1
/// Whether the develop column sits under the photograph rather than beside
/// it (D-N7).
///
/// **Not a layout class.** The class is decided by the window's width and
/// says how much room there is; this is decided by its aspect and says
/// which way round that room is, so a 960-wide portrait window is
/// `expanded` *and* docked while a 1500-wide landscape one is `expanded`
/// and not. Nothing here reads it but the develop view's frame, and
/// nothing remembers it: closing the column in landscape closes the dock
/// in portrait, because it is the same column.
///
/// From Rust for the reason `panel-max-width` above is — the frame reads
/// it to lay itself out, so deriving it from `root.width` in the frame
/// would be a property that both feeds and follows the layout.
in property <bool> column-below: false;
// --- 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;
/// 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.
/// TRACES: FR-DEV-19b
/// A stroke, in fractions of the photograph as it is currently shown.
///
/// These stay on the window rather than moving into `Masking` with the
/// rest of the mask controls: a stroke is a gesture on the *canvas*, and
/// the canvas is this file's. What went into the global is the panel's
/// state — which tool is armed, how big the brush is, what parts a mask
/// has — because that is what a second copy of the column needs its own
/// binding to.
callback paint-began(float, float);
callback paint-moved(float, float);
callback paint-ended();
// Clicking to select a subject and dragging to paint are the same press,
// so exactly one of them is armed at a time. Without this the first dab of
// every stroke would also re-select whatever was under it, and the mask
// being painted would be replaced by the one the click made.
property <bool> region-picking: Develop.local-mode && Masking.segmented && Masking.tool == 0;
property <bool> painting: Develop.local-mode && Masking.tool > 0;
/// 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;
/// 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);
/// 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);
/// 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, 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;
/// The height it has, for the same reason and with the same caveat.
///
/// Reported alongside the width because the develop view's frame is
/// decided by the *aspect* of what the interface actually gets, not of the
/// window (D-N7) — and on Android those differ by the height of the status
/// and navigation bars, which is exactly the axis being measured.
property <length> shell-height:
root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
// One-way: report the size outward, never read layout back into it. Two
// handlers because Slint has no single "geometry changed" hook — the same
// shape as `canvas-resized`'s px-w/px-h below.
changed shell-width => {
root.window-resized(self.shell-width, self.shell-height);
}
changed shell-height => {
root.window-resized(self.shell-width, self.shell-height);
}
// 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.shell-height;
// 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.
//
// The recovery question is asked first because it is drawn
// over everything, the offline prompt included: Back must
// reach the thing the user can actually see.
if (root.recovery-title != "") {
root.recovery-dismiss();
return accept;
}
// The row menu and the membership sheet are drawn over the
// offline prompt, so Back has to reach them first — and the
// menu's own confirmation is a step Back must be able to take
// one at a time, which is why the dismiss handlers in Rust
// back out of a pending delete rather than closing outright.
if (Collections.collection-menu-title != "") {
Collections.collection-menu-dismiss();
return accept;
}
if (Collections.membership-open) {
Collections.membership-dismiss();
return accept;
}
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 no page is open".
// Outermost with the settings page, and before it: both are reachable
// from the library and from develop, so guarding either with which
// view was current would make which view you came from decide whether
// it appears.
if root.active-page == Page.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.active-page == Page.merge: MergePage {
width: 100%;
height: 100%;
stage: root.merge-stage;
running: root.merge-running;
fraction: root.merge-fraction;
status: root.merge-status;
error: root.merge-error;
aligned: root.merge-aligned;
complete: root.merge-complete;
summary: root.merge-summary;
frames: root.merge-frames;
preview: root.merge-preview;
projection-selected: root.merge-projection-selected;
border-selected: root.merge-border-selected;
fill-available: root.merge-fill-available;
fill-note: root.merge-fill-note;
preview-filled: root.merge-preview-filled;
fill-scale: root.merge-fill-scale;
fill-erosion: root.merge-fill-erosion;
fill-coarse: root.merge-fill-coarse;
fill-band: root.merge-fill-band;
fill-mirror: root.merge-fill-mirror;
fill-feather: root.merge-fill-feather;
done: root.merge-done;
merge() => { root.merge-confirm(); }
abandon() => { root.merge-abandon(); }
projection-picked(i) => { root.merge-projection-picked(i); }
border-picked(i) => { root.merge-border-picked(i); }
fill-knob(name, value) => { root.merge-fill-knob(name, value); }
close() => { root.merge-close(); }
}
if root.active-page == Page.settings: SettingsPage {
width: 100%;
height: 100%;
adapter: root.adapter;
backend: root.backend;
inference-backend: root.inference-backend;
inference-detail: root.inference-detail;
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;
fetch-ahead-labels: root.settings-fetch-ahead-labels;
fetch-ahead-selected: root.settings-fetch-ahead-selected;
fetch-ahead-picked(i) => { root.settings-fetch-ahead-picked(i); }
copy-scope-kinds: root.copy-scope-kinds;
copy-scope-empty: root.copy-scope-empty;
copy-scope-toggled(name) => { root.copy-scope-toggled(name); }
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); }
write-xmp: root.settings-write-xmp;
write-xmp-toggled(on) => { root.settings-write-xmp-toggled(on); }
xmp-conflicts: root.settings-xmp-conflicts;
xmp-reload() => { root.settings-xmp-reload(); }
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); }
group-nav-labels: root.settings-group-nav-labels;
group-nav-selected: root.settings-group-nav-selected;
group-nav-auto-says: root.settings-group-nav-auto-says;
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;
group-nav-picked(i) => { root.settings-group-nav-picked(i); }
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;
face-detector-labels: root.settings-face-detector-labels;
face-detector-selected: root.settings-face-detector-selected;
face-detector-picked(i) => { root.settings-face-detector-picked(i); }
library-open: root.library-open;
thumbnail-library() => { root.library-thumbnail-all(); }
index-faces() => { root.identity-index(); }
reindex-faces() => { root.identity-reindex(); }
// TRACES: NFR-OPS-1
diagnostics-preview: root.diagnostics-preview;
diagnostics-result: root.diagnostics-result;
diagnostics-prepare() => { root.diagnostics-prepare(); }
diagnostics-save() => { root.diagnostics-save(); }
diagnostics-discard() => { root.diagnostics-discard(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
// Like the launch screen, this replaces the window rather than
// overlaying it — see `View`.
if root.active-page == Page.none && root.active-view == View.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;
coverage-read-only: root.identity-coverage-read-only;
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;
merge-probability: root.identity-merge-probability;
min-group-size: root.identity-min-group-size;
merge-probability-changed(v) => { root.identity-merge-probability-changed(v); }
min-group-size-changed(n) => { root.identity-min-group-size-changed(n); }
previewing: root.identity-previewing;
grouping-preview: root.identity-grouping-preview;
preview-grouping() => { root.identity-preview-grouping(); }
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.active-page == Page.none && root.active-view == View.launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
account: root.launch-account;
library-root: root.launch-root;
endpoint-is-library: root.launch-endpoint-is-library;
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.library-visible: 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: Collections.collection-rows;
selected-id: root.collection-selected;
total-images: Library.library-total;
error: Collections.collection-error;
// TRACES: FR-NC-6a
// The two actions that were in the library header and
// referred to the tree rather than to the library.
// They read the same window state the header did — one
// source per fact — and call the same handlers.
scanning: root.library-scanning;
scope-pinned: root.library-scope-pinned;
toggle-pin-scope() => { root.library-toggle-pin-scope(); }
change-library() => { root.library-change(); }
select(id) => { root.collection-select(id); }
toggle(id) => { Collections.collection-toggle(id); }
new-collection() => { Collections.collection-new(); }
row-menu(id) => { Collections.collection-menu(id); }
offline-menu(id) => { root.collection-offline-menu(id); }
row-press(id, down) => { Collections.collection-row-press(id, down); }
lifted-id: Collections.collection-lifted;
drag-active(id, on) => { Collections.collection-drag-active(id, on); }
root-drop-allowed: Collections.collection-root-drop-allowed;
dropped-on-root() => { Collections.collection-dropped-on-root(); }
renaming-id: Collections.collection-renaming;
rename-start(id) => { Collections.collection-rename-start(id); }
rename-commit(id, name) => {
Collections.collection-rename-commit(id, name);
}
rename-cancel() => { Collections.collection-rename-cancel(); }
trash-count: Collections.trash-count;
trash-label: Collections.trash-label;
selected-count: Library.library-selected-count;
dropped-on(id) => { Collections.collection-dropped(id); }
collection-drag-payload(id) => { return Collections.collection-drag-payload(id); }
dropped-on-trash() => { Collections.trash-dropped(); }
empty-trash() => { Collections.trash-empty(); }
restore-selected() => { Collections.trash-restore(); }
drag-over(id, over) => { Collections.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
// line but not the library's buttons beside them.
expanded: root.expanded;
cells: Library.library-cells;
total: Library.library-total;
scanning: root.library-scanning;
opening: root.library-opening;
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: Library.library-local-only;
local-count: Library.library-local-count;
toggle-local-only() => { Library.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { Library.library-clear-selection(); }
select-all() => { Library.library-select-all(); }
reorderable: Library.library-reorderable;
reorder-to(row, after) => { Library.library-reorder-to(row, after); }
collection-from-selection(name) => {
Library.library-collection-from-selection(name);
}
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(); }
pin-done: root.library-pin-done;
pin-total: root.library-pin-total;
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: Library.library-selected-count;
scope-label: Collections.collection-scope-label;
settings-armed: Transfer.armed;
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 = Library.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(); }
// TRACES: FR-MRG-1
merge-selection => { root.library-merge-selection(); }
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: Library.library-cell-size;
touched <=> Library.library-touched;
zoom-cells(d) => { Library.library-zoom-cells(d); }
pinch-cells(r) => { Library.library-pinch-cells(r); }
pinch-started() => { Library.library-pinch-started(); }
scroll-to: Library.library-scroll-to;
scroll-token: Library.library-scroll-token;
sync-now() => { root.library-sync-now(); }
columns-changed(n) => {
Library.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.
Library.library-columns-changed();
}
scrolled(i) => { Library.library-scrolled(i); }
viewport-cells-changed(n) => { Library.library-viewport-cells(n); }
cell-clicked(i) => { Library.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
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) => {
Library.library-cell-pressed(i, ctrl, shift);
}
cell-press-ended() => { Library.library-cell-press-ended(); }
select-mode: Library.library-select-mode;
toggle-select-mode() => { Library.library-toggle-select-mode(); }
held-row <=> Library.library-held-row;
// The sidebar's rows, not a second model: the sheet files
// into the same tree the sidebar draws.
collections: Collections.collection-rows;
file-in-collection(id, moves) => {
Library.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: Library.library-cursor;
move-cursor(delta, extend) => {
Library.library-move-cursor(delta, extend);
}
open-cursor() => { Library.library-open-cursor(); }
drag-image: Library.library-drag-image;
drag-payload() => { return Library.library-drag-payload(); }
drag-started(i) => { Library.library-drag-started(i); }
drag-finished() => { Library.library-drag-finished(); }
remove-from-collection() => {
Library.library-remove-from-collection();
}
open-membership() => { Library.library-open-membership(); }
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
filter-eyes-open: root.library-filter-eyes-open;
people: root.library-people;
gestures: root.library-gestures;
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); }
burst-toggled(i) => { root.library-burst-toggled(i); }
burst-representative-chosen(i) => {
root.library-burst-representative-chosen(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() => {
Collections.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(); }
filter-eyes-open-toggled() => { root.library-filter-eyes-open-toggled(); }
people-listed() => { root.library-people-listed(); }
filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
}
}
}
if root.active-page == Page.none && root.active-view == View.develop: 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: Library.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: Steps.can-undo;
can-redo: Steps.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() => { Steps.undo(); }
redo() => { Steps.redo(); }
}
// The develop view's frame: the rail, the photograph, the column.
//
// **A Rectangle rather than the HorizontalLayout this was**, and
// every child below states its own `x`, `y`, `width` and `height`
// instead of being placed by one. Slint cannot turn a layout on its
// side, and D-N7 asks for exactly that: on a tall window the column
// goes under the photograph rather than beside it, and the rail
// stays down the left in both cases.
//
// The alternative was the whole subtree declared twice under two
// `if`s, which is four hundred lines of bindings copied — and a
// conditional child in a layout is the shape that has produced
// binding loops in this file before (see the develop column below,
// and the group strip inside it).
//
// With `column-below` false the three rectangles come out where the
// layout put them, to the pixel: a `HorizontalLayout` has no
// spacing or padding of its own, so the rail took its declared
// width on the left, the column its declared width on the right,
// and the canvas — the only child with `horizontal-stretch: 1` —
// everything in between.
frame := Rectangle {
horizontal-stretch: 1;
vertical-stretch: 1;
// What the column takes when it is beside the photograph, and
// what it takes when it is under it. Exactly one of the two is
// ever non-zero, which is what lets the canvas subtract both
// without asking which case it is in.
//
// Zero is also how the column is put away, on either axis:
// `panel-visible` collapses the dock's height exactly as it
// collapses the column's width, and for the same reason the
// note on the column gives — an `if` here would be a layout
// that depends on a property derived from the window the layout
// then influences.
property <length> column-width: !root.column-below && root.panel-visible
? min(root.panel-max-width, Theme.panel-width)
: 0px;
property <length> dock-height: root.column-below && root.panel-visible
? Theme.dock-height
: 0px;
// **The tools, on the far side from their consequences.**
//
// A fixed rail rather than a row of chips inside the develop
// column, which is where these three used to live. The column
// can be put away — that is the whole point of the toggle in
// the strip above — and putting the tools away with it meant
// the way *out* of crop disappeared along with the way in.
// Hence the "Done Cropping" button floating over the canvas
// below: one control duplicated to paper over the other being
// reachable only sometimes.
//
// The rail does not close, so a tool can always be put down
// where it was picked up. `toolrail.slint` has the rest of the
// reasoning and the table that generates it.
//
// Not conditioned on `total > 0` from out here: the rail
// collapses itself on `enabled`, and an `if` in this layout is
// the shape that has caused binding loops in this file before
// (see the develop column below).
rail := ToolRail {
// Full height down the left in both arrangements — D-N7 is
// explicit that the rail does not move, and a vertical list
// of finger-sized entries wants the height a tall window
// has more of. Its width is its own (`rail-width`, or zero
// when it stands down), which is why the two boxes beside
// it can measure from `rail.width`.
x: 0;
y: 0;
height: parent.height;
enabled: root.total > 0 && root.load-error == "";
mode: Develop.view-mode;
picked(m) => { root.mode-picked(m); }
// The same model and the same callback the strip below
// takes, because only one of the two is ever on screen.
// Routing both to `adjust-tab-picked` means Rust is not
// told which control was pressed and has no reason to
// care — the choice between them is a presentation
// decision, and it stays one.
groups-in-rail: Adjustments.groups-in-rail;
tabs: Adjustments.tabs;
active-tab: Adjustments.active-tab;
group-picked(i) => { Adjustments.tab-picked(i); }
}
// The canvas: compute output composited directly. No CPU
// round-trip anywhere in this path (ARCH §6.1).
canvas-area := Rectangle {
// What is left of the frame: beside the rail, and above or
// beside the column — whichever of the two dimensions above
// is the non-zero one. This is what `horizontal-stretch: 1`
// used to say to the layout.
x: rail.width;
y: 0;
width: parent.width - rail.width - parent.column-width;
height: parent.height - parent.dock-height;
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 && !Masking.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;
}
// TRACES: FR-CULL-3
// The focus marks, over the same fitted rect. See
// `peaking.slint` for why they are a layer over the canvas
// rather than a tint in it.
if root.focus-overlay-ready && root.total > 0: FocusMarks {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
marks: root.focus-overlay;
}
// 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.
// GESTURE: Magnify the photograph by any amount
// where: Develop
// touch: Pinch it with two fingers
// pointer: The scroll wheel over it
// why: Anchored on the fingers' midpoint, and on the
// pointer, so the gesture reads as magnifying
// the picture rather than sliding it about.
// Double-tap is the way to an exact 1:1; this
// is the way to everything in between.
//
// 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: !Develop.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; }
}
// --- the pan/zoom backstop ---------------------------------
//
// **Declared first among the canvas handlers, because in
// Slint that puts it last in line for a press.**
//
// Slint hit-tests siblings `FrontToBack`
// (`i-slint-core`'s `send_mouse_event_to_item`), a
// `TouchArea` answers `GrabMouse` on any press it is
// enabled for, and the first grab aborts the traversal.
// Front means *last declared*. So this — full-canvas, and
// enabled for everything but a crop — took every press in
// the viewport, and the four handlers below it never
// received one: painting a mask, clicking a subject on the
// photograph, placing a repair and sampling a neutral were
// all dead, each of them carrying a comment claiming it sat
// "above the pan/zoom area" because it was written first.
//
// Nothing about the geometry says which wins, so nothing
// about the geometry can be adjusted to fix it. The order
// is the fix, and the rule to keep is: **the general case
// goes at the top of the file and the specific ones after
// it.** `GradientHandles` further down is the other end of
// the same rule, and is why dragging a handle has always
// worked while everything between it and here did not.
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: !Develop.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;
}
}
// GESTURE: Move a magnified photograph about
// where: Develop
// touch: Drag it
// pointer: Drag it
// why: Only once there is something outside the
// viewport to reach, which is why the
// cursor becomes a hand exactly then. The
// view is clamped to the frame: panning
// past the edge would show undefined area
// beside the photograph, and that reads as
// a rendering fault rather than as the end
// of the picture.
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;
}
// TRACES: FR-UI-4
// Fit and 1:1, which is what FR-UI-4 asks a double
// tap for. It used to drop straight to fit, so the
// gesture only ever did half its job — and the half
// it did not do is the one that matters, since
// nothing else in the interface reached 100% at all.
//
// Anchored on the pointer, in fractions of the shown
// image, exactly as the wheel above is: the detail
// being inspected is the one under the finger, and a
// toggle that jumped to the centre would ask for a
// pan afterwards every single time.
double-clicked => {
root.inspect-toggled(
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
);
}
}
// Region picking, declared after the pan/zoom area so a
// click reaches it first — see the note there for why that
// is the way round it is. A separate area rather than a
// branch inside it: 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: !Develop.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,
);
}
}
// GESTURE: Paint a mask by hand
// where: Develop
// touch: Choose Paint or Erase, then drag on the
// photograph
// pointer: Choose Paint or Erase, then drag
// why: A model's mask stops inside a shoulder and
// leaks into the hair, and no single edge
// control fixes two errors that go opposite
// ways. The whole stroke is one step in the
// history, so taking a mark back costs one
// press however long it took to make.
//
// TRACES: FR-DEV-19b
//
// In front of the region picker rather than beside it: the
// two want the same press, and `region-picking` is false
// whenever this is armed so only one of them exists at a
// time.
if root.painting && root.total > 0 && root.load-error == "": paint := TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: crosshair;
enabled: !Develop.cropping;
pointer-event(ev) => {
if ev.kind == PointerEventKind.down {
root.paint-began(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
// A cancelled gesture ends the stroke like a
// release does. The alternative is a stroke left
// open across the next press, which paints a line
// between two places the finger never travelled.
if ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel {
root.paint-ended();
}
}
moved => {
if self.pressed {
root.paint-moved(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
}
}
// 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 Develop.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: !Develop.cropping;
clicked => {
root.spot-placed(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
}
// TRACES: FR-DEV-3 | FR-UI-7
// Sampling a neutral, beside the repair placer above and
// built exactly like it: a click on the fitted image, over
// the pan area so the click reaches it first, and a
// separate handler rather than a branch inside the pan —
// panning wants press-drag-release and this wants a click,
// and interleaving the two is how a drag ends up sampling
// whatever it happened to travel over.
//
// **One click, one sample, one history step.** There is no
// hover preview: previewing would mean a render and a
// solve per pixel the pointer crossed, and if any of them
// were recorded the stack would fill with a hundred
// temperatures nobody chose. The arming state is what says
// the next click will do something instead.
if root.sampling && 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: !Develop.cropping;
clicked => {
root.neutral-picked(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
// One shot. Leaving it armed would make the next
// click on the photograph — to place a mask, to
// pan — a second sample, and the photographer
// would have to remember to put a tool away that
// has already done its job.
Adjustments.sampling-op = -1;
}
}
// --- crop overlay ------------------------------------------
//
// Placed over the fitted image, so the fractions it draws are
// fractions of the picture. See `CropOverlay` in crop.slint.
if Develop.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;
// TRACES: FR-DEV-7
// Letting go of backslash puts the edit back. The
// release has to be handled here and not inferred
// from the next press, or a photographer who holds
// it and then reaches for a slider would be
// adjusting an image they cannot see.
if (event.text == "\\") {
root.compare-original(false);
return accept;
}
return reject;
}
key-pressed(event) => {
root.shift-held = event.modifiers.shift;
// TRACES: FR-DEV-7
// Hold backslash to see the unedited original.
// Rust ignores a repeat that says what it already
// knows, so the key repeating under a long look
// costs nothing.
if (event.text == "\\") {
root.compare-original(true);
return accept;
}
// GESTURE: Take back the last change
// where: Develop
// touch: Tap the step above the current one in
// the History list
// pointer: Click it, or press Undo in the
// History header
// keys: Ctrl+Z
// why: A whole drag is one step, so undo
// takes back a decision rather than a
// frame of a gesture. The list is there
// because arriving six steps back costs
// what arriving from one does.
//
// GESTURE: Do it again after taking it back
// where: Develop
// touch: Tap the step below the current one in
// the History list
// pointer: Click it, or press Redo in the
// History header
// keys: Ctrl+Shift+Z
//
// 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) {
Steps.redo();
} else {
Steps.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 (Develop.repairing && Repair.selected != ""
&& (event.text == Key.Delete
|| event.text == Key.Backspace)) {
Repair.removed(Repair.selected);
return accept;
}
// GESTURE: Copy the settings from this photograph
// where: Develop
// touch: Press Copy in the Settings panel
// pointer: Press Copy in the Settings panel
// keys: Ctrl+C
// why: The panel is the copy that has to
// work: a tablet has no modifier key
// to hold and no menu bar to hang the
// action from. The shortcut is an
// accelerator for a control that is on
// screen either way.
//
// GESTURE: Paste the settings onto this photograph
// where: Develop
// touch: Press Paste in the Settings panel
// pointer: Press Paste in the Settings panel
// keys: Ctrl+V
// why: The button names what would be
// pasted — "3 adjustments", and
// whether the crop is coming with it —
// which the shortcut cannot say. Both
// paste the same scope.
//
// TRACES: FR-DEV-6
// The desktop accelerators for the two buttons in
// the Settings panel, which until now the panel's
// own comment claimed existed and nothing bound.
// Guarded on `settings-armed` for the reason the
// button is: a paste with nothing copied is not a
// clear, it is a keystroke that should do nothing.
if (event.modifiers.control
&& (event.text == "c" || event.text == "C")) {
Transfer.copy();
return accept;
}
if (event.modifiers.control && Transfer.armed
&& (event.text == "v" || event.text == "V")) {
Transfer.paste();
return accept;
}
// GESTURE: Change which group of adjustments is on screen
// where: Develop
// touch: Tap a group in the rail down the left
// pointer: Click a group in the strip above the
// develop column
// keys: [ and ] step through them, wrapping
// round through "everything"
// why: The groups are whatever the operation
// set declares itself to be about, so
// there are as many as the pipeline has
// and no key can be assigned to one of
// them by name. Stepping is the binding
// that survives a node being added.
//
// TRACES: FR-DEV-3a | FR-UI-5
// -1 is "everything" and is part of the cycle
// rather than a way out of it: it is where the
// panel starts, so a photographer stepping round
// the groups arrives back at the whole set instead
// of being trapped in the last one.
if (event.text == "]") {
Adjustments.tab-picked(
(Adjustments.active-tab + 1
>= Adjustments.tabs.length)
? -1
: (Adjustments.active-tab + 1));
return accept;
}
if (event.text == "[") {
Adjustments.tab-picked(
(Adjustments.active-tab - 1 < -1)
? (Adjustments.tabs.length - 1)
: (Adjustments.active-tab - 1));
return accept;
}
// GESTURE: Look at the photograph at 1:1
// where: Develop
// touch: Double-tap the photograph
// pointer: Double-click it, or press the zoom
// readout floating over the canvas
// keys: Z
// why: Noise reduction and capture
// sharpening are judgements about
// single pixels, and a fitted view
// averages several of the file's into
// each one on screen — so the frame
// looks softer than it is and the
// correction goes too far. The point
// and the magnification survive opening
// the next photograph, which is what
// makes checking the same eye across
// forty portraits forty keystrokes
// rather than forty pans.
//
// TRACES: FR-UI-4 | FR-UI-5
// Fit and 1:1 from the keyboard, so inspecting
// forty portraits at the same eye is forty
// keystrokes rather than forty pans. Bare, with
// no modifier: the control-Z case above has
// already returned, so there is nothing left for
// this to shadow.
if (event.text == "z" || event.text == "Z") {
root.inspect-toggled(-1, -1);
return accept;
}
// TRACES: FR-DEV-16
// R puts back the control last moved. Bare, like
// Z: the modifier chords above have all returned.
// Nothing has been moved on this photograph until
// something has, so the key is silent until then
// rather than resetting a control nobody named.
if ((event.text == "r" || event.text == "R")
&& Adjustments.touched-op >= 0) {
Adjustments.param-reset(
Adjustments.touched-op,
Adjustments.touched-param);
return accept;
}
// TRACES: FR-DEV-16
// H hides or shows the selected mask layer — the
// ring at the head of its row, from the keyboard.
// With nothing selected there is nothing to hide,
// and Rust says so by doing nothing.
if (event.text == "h" || event.text == "H") {
Masking.selected-toggled();
return accept;
}
// GESTURE: Move to the next or previous photograph
// where: Develop
// touch: Tap a frame in the roll along the
// foot of the canvas
// pointer: Click a frame in the roll
// keys: Right arrow or space for the next,
// left arrow for the one before
// why: The edit on screen is saved on the
// way out, so stepping through a folder
// is as much a departure as going back
// to the grid and loses nothing.
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.
//
// **"Done" is no longer here because it has to be.** It was:
// the control that entered a mode was pinned inside the
// develop column, the column closes, and stranding someone
// in crop with no visible way out was worse than one
// duplicated control. The tool rail does not close, so that
// reason has gone.
//
// It stays because of where it is. Finishing a crop is a
// decision *about the photograph*, taken while looking at
// the photograph, and a confirmation the width of the
// window away from the thing being confirmed is a
// confirmation you take on trust. The rail is now the
// second way out rather than the first.
if root.total > 0 && root.load-error == "": HorizontalLayout {
// **Clear of the photo roll, or these do nothing.**
//
// The roll's swipe handler consumes every press that
// lands inside it and offers it to its own children
// and to nobody else — so a control drawn underneath
// that band is drawn, is lit, and then ignores the
// pointer entirely, hover included. "Done Cropping"
// sat there and was dead for as long as a library was
// open, which is the whole time anyone is developing
// from one.
//
// Being declared before the roll is not the fault and
// reordering is not the fix: the band would then be
// the thing that could not be reached. They stack
// instead — the controls ride on top of the roll's
// band, at whatever height it currently stands.
property <length> roll-clear:
Library.library-total > 0
? (root.roll-open
? Theme.roll-strip + Theme.roll-reach
: Theme.roll-reach)
: 0px;
// The roll's own travel, so they rise with the strip
// rather than jumping ahead of it.
animate roll-clear { duration: 180ms; easing: ease-out; }
x: 12px;
y: parent.height - self.preferred-height - 12px
- self.roll-clear;
// 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 Develop.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.
// "Composing", matching what the rail entry and
// the panel both call this mode. It said "Done
// Cropping" while the tool said Crop; naming the
// exit after one of the mode's controls, when the
// rail beside it named the mode, would be the same
// control answering to two words.
text: Develop.cropping
? "Done Composing"
: (Develop.repairing ? "Done Repairing" : "Done Masking");
active: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
// TRACES: FR-UI-4 | NFR-A11Y-2
// Zoom is a view state, so its readout doubles as the
// control that clears it — and, when there is nothing
// to clear, as the way in to 1:1.
//
// **The non-gesture half of the double tap.** FR-UI-4
// requires every gesture-driven action to have an
// equivalent that is not a gesture, and a magnifier
// reachable only by double-tapping the picture is a
// feature for people who already know it is there.
// Enabled unconditionally now, because "Fit" on a
// fitted photograph really was dead and this is not.
Button {
text: root.zoomed
? Math.round(root.zoom * 100) + "%"
: "1:1";
active: root.zoomed;
// No point of its own, so it asks for the last
// one: pressing this returns to whatever was
// being looked at rather than to the middle of a
// frame nobody was looking at the middle of.
clicked => { root.inspect-toggled(-1, -1); }
}
// GESTURE: See the photograph before you edited it
// where: Develop
// touch: Press and hold "Before"
// pointer: Press and hold "Before"
// keys: Hold \
// why: Held rather than toggled, and no split
// screen: a split halves the working image
// on the tablet the column was sized for,
// and the comparison photographers describe
// making is a flick back and forth. It
// takes no history step, so checking
// whether a frame is overcooked costs
// nothing to undo afterwards.
//
// TRACES: FR-DEV-7 | FR-DEV-3b | NFR-A11Y-2
// The unedited original, for as long as it is held.
//
// **A hold rather than a split screen.** A split view
// halves the working image, which on the tablet this
// column was sized for leaves neither half worth
// judging; and the comparison a photographer actually
// describes making is a flick back and forth, not two
// pictures side by side. A hold is also the cheaper
// thing to build: one extra render of a graph that is
// already there, against a second canvas that would
// have to be laid out, sized and kept in step.
//
// Press-and-hold is the same gesture with a finger and
// with a mouse, which is what FR-DEV-3b's mapping
// wants — no long-press timer, no mode, and no way to
// be left in it. The keyboard's backslash does the
// same thing on both edges; see the focus scope below.
Button {
text: "Before";
enabled: Develop.enabled;
active: root.showing-original;
accessible-checkable: true;
accessible-checked: root.showing-original;
held(down) => { root.compare-original(down); }
}
}
// 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 Library.library-total > 0: PhotoRoll {
width: 100%;
height: 100%;
cells: Library.library-cells;
current: Library.library-roll-current;
open <=> root.roll-open;
centre-request <=> Library.library-roll-centre;
pick(i) => { Library.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-1
// **One width, stated once, and not measured.**
//
// This column used to size itself from its contents: every
// panel published a `content-width`, declared it as its own
// `min-width`, and the column took the largest. That was an
// answer to a real problem — before it, the width was two
// guessed constants (280px for a tablet, 380px for a
// desktop) and the generated chip row outgrew both, so a
// control ended up somewhere you had to pan to find.
//
// **It solved that by making the photograph pay.** The
// pixels a sidebar takes come out of the picture beside it,
// and a measured sidebar spends them on whatever happens to
// be widest: an axis label gaining a digit made the image
// smaller, and switching tools swapped one set of panels for
// another and shifted the image sideways under the eye
// that was judging it. In an application whose entire job is
// showing you a photograph accurately, the frame around it
// must not move because a caption changed.
//
// So the number is mandated: `panel-width`, in
// `style.yaml`, next to the reasoning and next to the rail
// on the other side, which is fixed for the same reason. A
// panel that wants more than that clips, and the Flickable
// below is what makes the rest of it reachable — the same
// fallback as before, now the first answer rather than the
// last. `AdjustPanel` is the panel the number was chosen
// against; its note says why.
//
// The generated row that started all this is not a problem
// any more either, because it is no longer in the column's
// way: the tools moved to `ToolRail` and what is left is
// group filters, which pan within their own strip without
// anything else having to move.
//
// `panel-max-width` survives, and it is the one thing here
// that is not a size: it is the policy that a column may
// not take the window from the photograph it exists to
// serve, and it bites only on a window narrow enough that
// 360px would. 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.
//
// **Which of those two numbers applies is the only thing
// `column-below` changes here.** The column's contents,
// its collapse and its toggle are the same on both axes —
// it is the same column, moved, and not a second one.
//
// Beside the photograph it is `column-width` wide against
// the frame's right edge and full height. Under it, it is
// `dock-height` tall against the frame's bottom edge and
// runs from the rail to that same right edge. `dock-height`
// rather than a measured height for the reason the note
// above gives about the width: a dock that sizes itself to
// its contents is a photograph that changes height when a
// caption wraps.
x: root.column-below ? rail.width : parent.width - parent.column-width;
y: root.column-below ? parent.height - parent.dock-height : 0;
width: root.column-below ? parent.width - rail.width : parent.column-width;
height: root.column-below ? parent.dock-height : parent.height;
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 {
// Stands down when the rail is carrying the groups, and
// collapses itself to zero height rather than being
// conditioned here — a conditional child in this layout is
// the shape that has caused binding loops in this file
// before. `GroupStrip.live` holds the rule.
GroupStrip { }
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.
//
// **`min-width`, not `preferred-width`, and that is
// what makes a mandated column work at all.**
//
// With the preferred width here, the content laid
// itself out at whatever size it would have liked
// and the column clipped the difference: Paste came
// out sliced down the middle and the panel's reset
// was over the window's edge. That is the same
// fault as above wearing a different hat — the
// content was never told how much room it had, so
// it could not adapt to having less.
//
// The floor is the layout's *minimum* instead. A
// preferred width is what a panel would enjoy; a
// minimum is what it cannot go below, and between
// the two the layout does the work it exists to do
// — rows tighten, stretches give way — so the
// column fits because it was asked to rather than
// by luck.
//
// That does less here than it sounds like, and it
// is worth knowing why: this column's minimum and
// its preferred width are within a few pixels of
// each other, because a `Text` that does not elide
// reports the same for both and most of what is in
// here is text. So `panel-width` still has to be a
// number the contents actually fit in — see its
// note in `style.yaml` for the measurement. What
// this binding buys is that the clipping, when it
// comes, is of a panel that genuinely cannot
// shrink rather than of one that simply was not
// asked to, and that panning is then a real
// fallback rather than a permanent condition.
viewport-width: max(self.width, column.min-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 !Develop.local-mode && !Develop.repairing: InfoPanel { }
if !Develop.local-mode && !Develop.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 { }
Rectangle {
height: 1px;
background: Theme.rule;
}
// TRACES: FR-CULL-3
// Under the histogram, because the two are the same
// kind of thing: instruments that report on the
// photograph rather than change it. Kept in every
// mode for the same reason the histogram is.
FocusPanel { }
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 !Develop.local-mode && !Develop.repairing: ComposePanel { }
if !Develop.local-mode && !Develop.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 !Develop.local-mode && !Develop.repairing: TransferPanel { }
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 Develop.repairing: SpotPanel { }
if Develop.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
if Develop.local-mode: MaskPanel { }
if Develop.local-mode: Rectangle {
height: 1px;
background: Theme.rule;
}
adjust := AdjustPanel { }
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 { }
}
}
}
// The seam between the photograph and the column, on
// whichever side of the column the photograph is.
//
// **`x: 0` and `y: 0` are 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;
y: 0;
width: root.column-below ? parent.width : 1px;
height: root.column-below ? 1px : parent.height;
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 360px 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;
scope-kinds: root.copy-scope-kinds;
scope-empty: root.copy-scope-empty;
scope-toggled(name) => { root.copy-scope-toggled(name); }
import-report: root.preset-import-report;
import-presets(path) => { root.import-presets(path); }
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(); }
}
// TRACES: FR-CAT-7 | FR-UI-4
// The row menu and the membership sheet, above the offline prompt for
// the same reason it sits above the shell: both are opened from a
// 232px sidebar that disappears entirely in the compact layout, so
// neither can be drawn inside the thing that raises it.
//
// The two never coexist — one is about a collection, the other about a
// selection — but they are laid out in the order Back unwinds them,
// which is the order they are checked in above.
MembershipSheet {
width: 100%;
height: 100%;
open: Collections.membership-open;
selected-count: Library.library-selected-count;
rows: Collections.membership-rows;
remove(id) => { Collections.membership-remove(id); }
dismiss() => { Collections.membership-dismiss(); }
}
CollectionMenu {
width: 100%;
height: 100%;
title: Collections.collection-menu-title;
detail: Collections.collection-menu-detail;
nested: Collections.collection-menu-nested;
smart: Collections.collection-menu-smart;
confirming: Collections.collection-menu-confirming;
confirm-detail: Collections.collection-menu-confirm-detail;
delete-label: Collections.collection-menu-delete-label;
pinned: Collections.collection-menu-pinned;
rename() => { Collections.collection-menu-rename(); }
new-child() => { Collections.collection-menu-new-child(); }
promote() => { Collections.collection-menu-promote(); }
offline() => { Collections.collection-menu-offline(); }
delete() => { Collections.collection-menu-delete(); }
dismiss() => { Collections.collection-menu-dismiss(); }
}
// Last, and therefore over everything including the settings page and
// the offline prompt. Not a preference about layering: this is asked
// before the grid exists, and nothing else in the window is about a
// library that can be read.
RecoveryPrompt {
width: 100%;
height: 100%;
title: root.recovery-title;
detail: root.recovery-detail;
diagnosis: root.recovery-diagnosis;
restore-label: root.recovery-restore-label;
can-restore: root.recovery-can-restore;
rebuild-label: root.recovery-rebuild-label;
busy: root.recovery-busy;
restore() => { root.recovery-restore(); }
rebuild() => { root.recovery-rebuild(); }
dismiss() => { root.recovery-dismiss(); }
}
}
}