Files
DarkRoom/ui/dr-ui/ui/app.slint
T
dtourolleandClaude Opus 5 c4ddcbe0f7 Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting
coefficients from a lens name, a focal length and an aperture — with no
dependents anywhere in the workspace. The three corrections it feeds now
exist in the graph, so this connects the two and finishes the chain.

The coefficient structs stay duplicated. `dr-pipeline` is organised around
having no dependencies so its codegen is testable without a device or a
database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of
profile data. Neither crate can convert to the other, so the conversion goes
above both, in `develop.rs`, which is the only place that sees them together.

Both traits grow the same defaulted door. The optical corrections do not sit
on the same side of the fetch — distortion and CA rewrite coordinates and are
`Warp`s, vignetting applies a gain to the pixel already there and is an
ordinary node — and fanning a profile out by which trait each happens to
implement would make the caller reason about that distinction. Each correction
takes its own share of the whole profile instead, and `set_lens_profile` walks
both lists identically.

The lookup happens in `set_source_metadata` rather than in its caller, because
that is the one place a session is told which file it came from. Doing it
there makes it unforgettable, in the shape `FilmRebake` already uses for the
other derived thing — and, more to the point, makes *clearing* unforgettable:
a session that opened a second photograph while still holding the first one's
profile would correct it for the wrong optics, invisibly, in a way that looks
exactly like the lens.

It needs the whole shot and not just a name. Distortion is interpolated across
a zoom's focal range and vignetting depends strongly on aperture — a fast
prime can be two stops down in the corners wide open and clean by f/8 — so a
lookup missing either returns coefficients measured for a shot nobody took.
Missing any of the three refuses rather than guesses.

A profile is derived, not persisted: it comes from the file's EXIF and a
database, so it is not a parameter, not in the sidecar and not undoable. What
is an edit is the manual trim beside it, which each correction composes with
the measurement — so a photographer can lean on it, override it, or work
without one.

`InfoPanel` gains a lens line, and it distinguishes three cases rather than
two. `dr-lens` states the rule it exists for: an automatic correction that
silently did nothing is worse than one the user can see is unavailable. A
session with no header draws nothing, a header naming no lens reads "Lens not
recorded", and a lens the database has never heard of reads "· no profile".
Collapsing the last two would send somebody hunting for a profile that was
never missing — which, for third-party and adapted glass, is the ordinary case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:11:32 +02:00

2803 lines
146 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Theme } from "theme.slint";
import { AdjustPanel, ComposePanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
import { HistoryPanel, HistoryRow } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { FocusMarks, FocusPanel } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
import { ToolRail } from "toolrail.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { GestureRow }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
export component AppWindow inherits Window {
title: "DarkRoom";
// The one raster asset in the UI, and the exception that the header of
// icons.slint does not cover: everything drawn *inside* the window is a
// Path because a font might not have the glyph, but the window icon is
// never drawn by us. It is handed to the window manager, which wants
// pixels. Pre-shaped with rounded corners and transparent outside them,
// because desktop window managers composite it as-is rather than masking
// it the way Android does — the Android launcher icon is a separate,
// maskable set under apps/darkroom-android/android/res.
//
// Where it actually shows up is narrower than it looks: X11 and Windows
// read it, and Wayland ignores it outright in favour of matching app_id
// against an installed .desktop file, which this repo does not install.
// Android ignores it too — there is no title bar to put it in.
icon: @image-url("app-icon.png");
background: Theme.ground;
preferred-width: 1100px;
preferred-height: 720px;
min-width: 360px;
min-height: 320px;
// Set from Rust each frame: the compute output, delivered as a texture.
in property <image> canvas;
in property <string> adapter: "detecting…";
in property <string> backend: "—";
in property <int> fps: 0;
/// TRACES: FR-DSP-8
/// The display showing the canvas and the colour transform it is getting.
/// Shown under ABOUT in Settings; see `settings.slint` for why the second
/// one is a sentence rather than a name.
in property <string> display-name: "detecting…";
in property <string> display-colour: "detecting…";
in property <string> display-others: "";
/// TRACES: FR-UI-2
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
/// the binary it is part of.
in property <string> app-version: "unknown";
// Current image, for the status strip and empty state.
in property <string> filename: "";
in property <string> camera: "";
/// TRACES: FR-DEV-3
/// The lens the file names, and whether a correction profile was found for
/// it. Composed in Rust because the sentence depends on a database lookup
/// this file cannot see, and because "not recorded" and "no profile" are
/// different facts that must not be collapsed into one.
in property <string> lens: "";
in property <string> exposure: "";
in property <string> dimensions: "";
in property <int> index: 0;
in property <int> total: 0;
in property <string> load-error: "";
/// TRACES: FR-DSP-7
/// The counted frame. Set only when the render has settled — a histogram
/// of a draft frame is a histogram of an image nobody is reading.
in property <HistogramView> histogram;
/// The same photograph counted in the raw domain — the demosaiced
/// scene-linear texture, before white balance, the camera matrix or any
/// curve, on an axis of stops below sensor saturation.
///
/// Beside the display histogram rather than instead of it: the two answer
/// different questions and the panel offers both. Unlike the one above it
/// this is a property of the *file* and not of the render, so it survives
/// a crop, a zoom and every slider — see `raw_histogram.rs`.
in property <HistogramView> raw-histogram;
/// TRACES: FR-CULL-3
/// Focus peaking: the marks, whether they describe *this* frame, and the
/// three things the photographer chose. All of them are Rust's, because
/// the marks come from a compute pass — see `peaking.slint` for why
/// `focus-overlay-ready` is a separate question from `peaking-on`.
in property <image> focus-overlay;
in property <bool> focus-overlay-ready: false;
in property <bool> peaking-on: false;
in property <bool> peaking-available: true;
in property <int> peaking-sensitivity: 1;
in property <int> peaking-colour: 0;
callback peaking-toggled(bool);
callback peaking-sensitivity-picked(int);
callback peaking-colour-picked(int);
// --- zoom, pan and crop (FR-DEV-4) ---
//
// Zoom is a *viewing* state, not an edit: it changes the resolution the
// pipeline renders at, never what the file becomes. Rust owns the actual
// rect; these carry only what the interface has to draw.
in property <float> zoom: 1.0;
in property <bool> zoomed: false;
/// Whether one source pixel now covers more than one screen pixel.
///
/// Drives the canvas's filtering, and nothing else. Rust decides it rather
/// than the `zoom` property above, because the two are not the same
/// question: whether the image is magnified depends on the source's
/// resolution against the viewport's, which Slint does not know.
in property <bool> magnified: false;
/// TRACES: FR-UI-5
/// What the develop view is currently doing (see `ViewMode`).
///
/// One value rather than a flag per mode, so two modes cannot be on at
/// once — which, with `crop-mode` a bare bool beside a masking panel that
/// armed the canvas through two toggles of its own, they could be. Rust
/// owns it: entering a mode has side effects on the session (crop drops
/// the zoom, local clears nothing and leaving it clears the selection),
/// and a property the interface could set directly would let the view
/// change without them.
///
/// In `crop` the canvas shows the *whole* frame — otherwise the area being
/// cropped away would not be on screen to drag across — and the surround
/// is greyed.
in property <ViewMode> view-mode: ViewMode.photo;
/// Shorthand for the two conditions half this file tests, so a mode
/// comparison is written once rather than at each of a dozen call sites.
property <bool> cropping: root.view-mode == ViewMode.crop;
property <bool> local-mode: root.view-mode == ViewMode.local;
/// TRACES: FR-DEV-8
property <bool> repairing: root.view-mode == ViewMode.spots;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
in-out property <float> crop-x: 0.0;
in-out property <float> crop-y: 0.0;
in-out property <float> crop-w: 1.0;
in-out property <float> crop-h: 1.0;
/// TRACES: FR-DEV-3
/// The ratios the crop may be locked to, and which one is chosen.
///
/// Named by Rust rather than listed here: the set is `CropAspect::CHOICES`
/// and the labels come off it, so a ratio added there appears without this
/// file changing.
in property <[string]> crop-aspects;
in property <int> crop-aspect: 0;
/// Whether the chosen ratio is standing on its short edge.
in property <bool> crop-portrait: false;
/// Whether the chosen ratio has a portrait form at all — `Free` and the
/// square do not, and the switch says so rather than vanishing.
in property <bool> crop-aspect-turnable: false;
/// Enter a mode, or `photo` to leave whichever one is current.
callback mode-picked(ViewMode);
/// A dragged crop rect in fractions of the frame, then the corner the drag
/// is holding as a `0`/`1` pair — or `-1, -1` for a move, which keeps the
/// shape it has. See `CropOverlay` in crop.slint for why the corner has to
/// travel with the rect.
callback crop-changed(float, float, float, float, float, float);
/// Lock the crop to one of `crop-aspects`, by index.
callback crop-aspect-picked(int);
/// Stand the chosen ratio on its other edge.
callback crop-portrait-toggled();
// --- rotation, flips and straightening (FR-DEV-3) ---
//
// Mirrored from the session rather than held here, for the same reason the
// crop rect is: Rust clamps and wraps these, so the panel must show what
// was actually applied and not what the click asked for.
in property <float> straighten: 0.0;
/// The straighten slider's travel either way, from the descriptor.
in property <float> max-straighten: 45.0;
in property <bool> flip-h: false;
in property <bool> flip-v: false;
/// Any framing edit is in effect — what lights the section's dot and
/// enables its reset. Distinct from the crop rect being non-full: a
/// rotation or a flip is an edit with the crop still full.
in property <bool> framing-modified: false;
/// Quarter turns, positive clockwise.
callback rotate-quarters(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback straighten-changed(float);
/// TRACES: FR-DEV-3
/// The straightening gesture is over and this is the angle to keep.
///
/// Reported separately from the value because the auto-crop that follows a
/// straighten has to happen once, at the end — see
/// `DevelopSession::auto_crop_to_angle`.
callback straighten-committed(float);
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback framing-reset();
// --- undo and redo (FR-DEV-5) ---
//
// Rust owns whether there is anywhere to step, because "anywhere" is a
// position in a stack of snapshots this side never sees. A whole drag is
// one step; the coalescing that makes it so lives in `dr-pipeline`.
in property <bool> can-undo: false;
in property <bool> can-redo: false;
callback undo();
callback redo();
/// Every step, newest first. Rust owns the order and stamps each row with
/// its own position in the stack, so nothing here does arithmetic to turn
/// a row back into a step.
in property <[HistoryRow]> history-rows;
/// What undo would take back, resolved. Empty when there is nowhere to go.
in property <string> undo-label: "";
/// A row's own `index`.
callback history-picked(int);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
callback zoom-reset();
// --- launch screen (FR-NC-1, FR-NC-4) ---
//
// The app opens here when no library is configured, and returns here to
// sign out or switch account.
in property <bool> show-launch: false;
in property <bool> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <string> launch-server: "";
in property <string> launch-folder: "";
in property <bool> launch-busy: false;
in property <string> launch-status: "";
in property <string> launch-error: "";
in property <string> launch-login-url: "";
in property <bool> launch-can-remember: true;
in-out property <[string]> launch-format-labels;
in-out property <[bool]> launch-format-checked;
callback launch-sign-in(string);
/// The path of a folder library — no account, no credential.
callback launch-use-folder(string);
/// server, username, app password
callback launch-sign-in-direct(string, string, string);
callback launch-sign-out();
callback launch-choose-folder();
callback launch-open-library();
callback launch-format-toggled(int, bool);
callback launch-copy-url();
in property <bool> launch-browsing: false;
in property <string> launch-browse-path: "";
in property <bool> launch-browse-loading: false;
in-out property <[string]> launch-browse-entries;
callback launch-browse-into(string);
callback launch-browse-up();
callback launch-browse-confirm();
callback launch-browse-cancel();
// --- library grid (FR-CAT-4) ---
//
// Shown after a library is opened, before an image is chosen. Cells are a
// window over the catalog, not the whole of it.
in property <bool> show-library: false;
/// Whether the grid is actually on screen — the condition the `if` below
/// is written with, hoisted so Rust can read the same answer.
///
/// **`show-library` is not that answer.** It says "the library rather than
/// develop", and stays true while Settings, Import, People or the launch
/// screen replaces the window. 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.show-import && !root.show-settings && !root.show-identity
&& !root.show-launch && root.show-library;
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
// --- background activity (FR-CAT-1, FR-NC-6c) ---
//
// Every background job in one register, filled by `activity.rs`. The
// aggregate drives the bar across the top of the shell; the rows are the
// list on the settings page, where a stalled transfer can be identified
// rather than merely felt.
in property <[ActivityRow]> activity-rows;
/// Anything at all running. Drives the bar's visibility, so it is a
/// property rather than `activity-rows.length > 0` — finished rows stay in
/// the list for a while and must not keep the bar alight.
in property <bool> activity-busy: false;
/// Whether the running jobs, taken together, have a denominator. A scan
/// alone does not; a scan beside a download does.
in property <bool> activity-determinate: false;
in property <float> activity-fraction: 0;
in property <int> activity-running: 0;
/// Jobs that stopped and are being kept: finished ones as a short history,
/// failed ones until they are cleared.
in property <int> activity-kept: 0;
callback activity-clear-finished();
in property <bool> library-scanning: false;
/// 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: "";
// Narrows the grid to images whose RAW is stored locally — the ones that
// can actually be opened while offline. Off by default: the catalog is the
// library, and hiding most of it the moment a connection drops would read
// as data loss rather than as a filter.
in-out property <bool> library-local-only: false;
in property <int> library-local-count: 0;
callback library-toggle-local-only();
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-select-all();
/// TRACES: FR-CAT-7
/// Whether the grid is showing something with a manual order to change —
/// a single manual collection, not a set and not a saved filter.
in property <bool> library-reorderable: false;
/// Move the selection beside the photograph at this row of the loaded
/// window: after it where the drop landed on the cell's trailing half,
/// before it otherwise.
callback library-reorder-to(int, bool);
callback library-collection-from-selection(string);
/// 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-out property <int> library-columns: 1;
in property <bool> library-syncing: false;
/// TRACES: FR-CAT-3 | FR-NC-3
/// The whole-library thumbnail pass, launched from the settings page.
/// `library-open` gates the button on there being a library to sweep —
/// the page is reachable from the launch screen too.
in property <bool> library-thumbnailing: false;
in property <bool> library-open: false;
// --- people and faces (FR-CULL-8 … FR-CULL-12) ---
//
// A third top-level screen rather than a panel: naming a cluster and
// pulling a stranger out of it are tasks with their own rhythm, and they
// need the whole window. Gated the same way the other screens are, so
// exactly one is ever up.
in property <bool> show-identity: false;
in property <[IdentityPerson]> identity-people;
in property <[IdentityFace]> identity-faces;
in property <int> identity-selected: -1;
in property <string> identity-selected-name;
in property <int> identity-unassigned: 0;
in property <bool> identity-calibrated: false;
in property <bool> identity-indexing: false;
in property <string> identity-indexing-status;
in property <bool> identity-model-missing: false;
in property <string> identity-back-label: "‹ Library";
in property <string> identity-coverage;
in property <bool> identity-coverage-complete: false;
in property <int> identity-picked: 0;
/// The namesake merge the Identity screen is offering. See IdentityScreen.
in property <string> identity-merge-offer-name;
in property <int> identity-merge-offer-faces: 0;
/// See IdentityScreen.name-revision.
in property <int> identity-name-revision: 0;
callback identity-open();
callback identity-close();
callback identity-person-picked(int);
callback identity-rename(string);
callback identity-merge-accept();
callback identity-merge-decline();
callback identity-name-edited(string);
callback identity-confirm-face(int);
callback identity-reject-face(int);
callback identity-toggle-pick(int);
callback identity-confirm-all();
callback identity-split-picked();
callback identity-recluster();
in property <bool> identity-regrouping: false;
in property <string> identity-regroup-status;
// 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-stop-indexing();
callback identity-check-coverage();
callback identity-delete-all();
callback library-thumbnail-all();
in-out property <length> library-cell-size: 180px;
/// Whether this session is a touch one, which decides if the rating strip
/// waits to be hovered or stands open. Seeded from the platform by Rust
/// and latched by the grid on the first finger it sees.
in-out property <bool> library-touched: false;
callback library-zoom-cells(int);
/// A pinch step on the grid: the ratio since the last update.
callback library-pinch-cells(float);
/// A pinch on the grid has begun, so the press that opened it was not one.
callback library-pinch-started();
in property <int> library-scroll-to: 0;
in property <int> library-scroll-token: 0;
/// Which row of the loaded window is the photograph currently open in
/// develop, so the roll can mark it. `-1` when it is not in the window.
in property <int> library-roll-current: -1;
/// Whether the photo roll is out. Owned here rather than by the roll, so
/// it survives the develop view being torn down and rebuilt.
in-out property <bool> roll-open: false;
/// Centre the roll on the open photograph the next time the strip settles.
///
/// Raised by Rust when a develop session begins — a photograph opened from
/// the grid — and cleared by the strip itself once honoured. Owned here for
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
/// overlay screen covers it, and a request raised before that must survive
/// it. See `PhotoRoll::centre-request`.
in-out property <bool> library-roll-centre: false;
/// A photograph was chosen from the roll: the row within the loaded
/// window, which is what a cell click reports too.
callback library-roll-pick(int);
callback library-sync-now();
callback library-columns-changed();
callback library-scrolled(int);
/// How many cells the grid's viewport shows at once. Rust sizes the
/// loaded window from it — see `library_ui::window_move`.
callback library-viewport-cells(int);
callback library-cell-clicked(int);
callback library-rescan();
/// Grid → launch screen, to pick a different folder or account.
callback library-change();
/// Develop → grid.
callback back-to-library();
// --- collections (FR-CAT-7) ---
//
// The sidebar's tree, flattened in Rust: Slint cannot instantiate a
// component recursively, so depth arrives as an integer per row.
in-out property <[CollectionRow]> collection-rows;
/// Which collection scopes the grid. 0 is the whole library.
in property <int> collection-selected: 0;
in property <string> collection-scope-label: "";
in property <string> collection-error: "";
/// Whether the sidebar is shown at all. Closed by default in the compact
/// layout, where 232px of the window is most of the photograph (FR-UI-1),
/// and openable from the grid header either way — see `panel-visible` for
/// how a default and a user's answer to it are kept apart.
in property <bool> collections-visible: true;
/// Which collection is being renamed in the sidebar, by id. 0 is none.
in property <int> collection-renaming: 0;
callback collection-select(int);
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
/// TRACES: FR-NC-6a
/// Ask about a collection's local copies: the tray on a row, or a long
/// press anywhere on it.
callback collection-offline-menu(int);
/// A row is held or released — the long press is timed in Rust.
callback collection-row-press(int, bool);
/// TRACES: FR-CAT-7 | FR-UI-4
/// File the grid's selection in a collection without dragging it there:
/// the target's id, and whether to take the images out of the collection
/// currently being shown.
callback library-file-in-collection(int, bool);
/// TRACES: FR-CAT-5 | FR-CAT-6
/// Keywording the grid's selection. The catalog has been searchable by
/// keyword since it existed and there was nowhere to type one; this is it.
///
/// The vocabulary arrives already answered against the selection — each row
/// says how many of the selected photographs carry that word — because only
/// Rust knows what is selected, and a `.slint` file counting it would need
/// the selection as a second model that could disagree with the first.
in property <[KeywordRow]> library-keywords;
/// The sheet is opening: recompute the rows against the selection as it
/// stands now. Pulled rather than pushed, because the selection changes on
/// every arrow key and the sheet is shut for almost all of them.
callback library-keywords-opened();
/// Put a keyword on the selection, creating it if it is new. By name, so a
/// word typed into the field and a word tapped in the list are one path.
callback library-assign-keyword(string);
/// Take a keyword off the selection. Never deletes the keyword itself —
/// it stays in the vocabulary and on every other photograph that carries it.
callback library-unassign-keyword(string);
/// TRACES: FR-UI-2
/// Whether a tap in the grid selects rather than opens, and the button
/// that turns it on. The long press does the same thing without it.
in property <bool> library-select-mode: false;
callback library-toggle-select-mode();
/// TRACES: FR-CAT-7 | FR-UI-4
/// The row a press has held long enough to pick up, or `-1`.
///
/// Set by the same hold that turns on selection mode and cleared by the
/// grid when the press ends — `in-out` because both ends write it. See
/// `held-row` in `library.slint` for what it draws and what it stops.
in-out property <int> library-held-row: -1;
/// A press on a cell ended, so the long-press timer can be cancelled.
callback library-cell-press-ended();
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on a cell: in selection mode, the far end of a range.
/// Renaming started on a row, by id — a double-click, or `F2`.
callback collection-rename-start(int);
/// A rename was committed: the collection's id and the new name.
callback collection-rename-commit(int, string);
/// Renaming was abandoned with Escape.
callback collection-rename-cancel();
/// Row index and collection id under the pointer. The id is what the
/// drop highlight follows: a spring expansion rebuilds the row model
/// mid-drag, and an index would then point at a different collection.
/// A drag is dwelling over a collection, or has left it: id, and whether
/// it is over. Drives the spring-loaded expansion, timed in Rust.
callback collection-drag-over(int, bool);
/// Images were dropped on a collection, by id. Slint hit-tests the release,
/// so this is the collection actually under the pointer.
callback collection-dropped(int);
/// Payload for dragging a collection onto another to reparent it.
pure callback collection-drag-payload(int) -> data-transfer;
// --- trash (FR-CAT-15) ---
//
// Not a collection: a drop here moves the file into a trash folder on the
// server, where a collection drop only adds a reference.
in property <int> trash-count: 0;
in property <string> trash-label: "";
/// A soft delete: move the dragged images to the trash folder.
callback trash-dropped();
/// A hard delete: permanently remove everything in the trash.
callback trash-empty();
/// Put the selected trashed images back where they came from.
callback trash-restore();
// --- drag and drop ---
//
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
// click-versus-drag threshold, arbitration against the grid's Flickable,
// and the image drawn under the cursor. So there is no badge to position
// and no hover state to mirror here — only the payload, which Rust builds
// from the selection, and the count the header shows.
/// The drag payload, built by Rust from the current selection.
pure callback library-drag-payload() -> data-transfer;
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
/// stack where several images are being dragged. Composited in Rust.
in property <image> library-drag-image;
/// A drag began on a cell, so an unselected one can be promoted before the
/// payload is read.
callback library-drag-started(int);
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// The keyboard cursor: where the arrows are in the library, as an image
// ordinal. Rust owns it — clamping it needs the library's length, and
// moving it may have to swap the loaded window underneath.
in property <int> library-cursor: -1;
callback library-move-cursor(int, bool);
callback library-open-cursor();
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
//
// Stars and pick/reject, set from the grid and persisted to the catalog
// and the sidecar. Every image enters unrated, which is a state of its
// own rather than a zero score.
/// A star was clicked on a cell: row, then the rating 0..5.
callback library-cell-rated(int, int);
/// The trash target was clicked on one cell, by row.
callback library-cell-trashed(int);
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
callback library-burst-toggled(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();
/// 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();
// Adjustment controls, generated from what the pipeline reports it can
// do. This window knows the *shape* of a control, never which operations
// exist (FR-DEV-3a).
in property <[ParamRow]> adjust-rows;
// TRACES: FR-DEV-3f
// The installed film stocks, "None" first, and which one this photograph
// is on. Resolved names: the core publishes ids, and a stock's name is a
// product name that does not translate.
in property <[string]> film-stocks;
in property <int> film-selected: 0;
in property <bool> film-can-print: false;
in property <bool> film-print: false;
callback film-picked(int);
callback film-print-toggled(bool);
in property <[string]> adjust-tabs;
in property <int> adjust-active-tab: -1;
callback adjust-tab-picked(int);
in property <bool> adjust-enabled: false;
// The tone curve's sampled shape, evaluated by the core so the drawn
// line and the applied one cannot disagree.
in property <[float]> curve-samples;
// The curves that widget can plot, named by the core, and which of them
// `curve-samples` describes. Fewer than two means nothing to choose.
in property <[string]> curve-channels;
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback curve-channel-picked(int);
callback reset-all();
// --- copying settings between photographs (FR-DEV-6) ---
//
// The clipboard is a window-lifetime thing rather than a view's, which is
// why it lives here and not inside the develop column: a copy is taken in
// develop and may be pasted onto a selection back in the library grid.
/// Whether anything has been copied this session.
in property <bool> settings-armed: false;
/// What a paste would apply, at the current scope — "3 adjustments".
in property <string> settings-summary;
/// Whether the copy holds framing that the current setting is dropping.
in property <bool> settings-framing-withheld: false;
callback copy-settings();
callback paste-settings();
/// Apply the clipboard to every selected image in the grid.
callback paste-settings-to-selection();
// --- named presets (FR-DEV-6) ---
//
// The saved half of the same requirement. On the window rather than in a
// view for the reason the clipboard is: a preset is saved in develop,
// where there is an edit to capture, and applied most often in the grid,
// where there is a selection to apply it to.
in-out property <bool> presets-open: false;
in property <[string]> preset-names;
/// Whether there is an edit in hand to save, set by whichever view opened
/// the sheet. `in-out` because that is where the answer is known.
in-out property <bool> preset-can-save: false;
/// What applying would act on: 0 is the open photograph, higher is that
/// many selected. Set at open, and read back by Rust when one is picked —
/// so the action matches the count the user read on the way in.
in-out property <int> preset-apply-count: 0;
/// What saving would capture, from the routine the clipboard summary uses.
in property <string> preset-capture-summary;
/// Why the last name was refused, cleared by the next keystroke.
in property <string> preset-name-error;
callback save-preset(string);
callback apply-preset(string);
callback rename-preset(string, string);
callback delete-preset(string);
callback preset-name-edited(string);
/// Raised as the sheet opens over an open photograph, so Rust can say what
/// saving would capture. A property refreshed on every slider drag would
/// be recomputing a string nobody is looking at.
callback presets-opened();
/// 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 the outermost of the view conditions
// below: it is reachable from the library and from develop, so guarding it
// with `!show-library` or `!show-launch` would make which one you came from
// decide whether it appears.
//
// Every control saves on change (see `settings_ui.rs`), so there is no
// dirty state here and nothing to confirm on the way out.
in-out property <bool> show-settings: false;
// --- import (FR-CAT-10, FR-CAT-11, FR-NC-7a) -------------------------
//
// A page like Settings, and outermost with it: an import is reachable from
// the library, and it writes into the library, so it must not be nested
// inside the view it is about to change under.
//
// Unlike Settings, this page holds a *decision* rather than a set of saved
// preferences, which is why it carries `import-can-start` and a running
// state: everything here exists to be committed or abandoned.
in-out property <bool> show-import: false;
/// Whether this platform can import at all — see `dr_plat::imports_supported`.
in property <bool> import-supported: false;
in property <[string]> import-volume-labels;
in property <[string]> import-volume-details;
in property <int> import-volume-selected: -1;
in property <string> import-card-path: "";
in property <bool> import-card-looks-right: false;
in property <bool> import-surveying: false;
in property <string> import-survey-summary: "";
in property <string> import-upload-target: "";
in property <string> import-folder-template: "";
in property <string> import-template-preview: "";
in property <[string]> import-mode-labels;
in property <int> import-mode-selected: 0;
in property <bool> import-mode-is-move: false;
in property <[string]> import-duplicate-labels;
in property <int> import-duplicate-selected: 0;
in property <bool> import-verify: true;
in property <bool> import-backup-on: false;
in property <string> import-backup-path: "";
in property <bool> import-running: false;
in property <float> import-fraction: 0.0;
in property <string> import-status: "";
in property <string> import-error: "";
in property <bool> import-can-start: false;
callback import-open();
callback import-close();
callback import-volume-picked(int);
callback import-card-path-changed(string);
callback import-refresh-volumes();
callback import-template-changed(string);
callback import-mode-picked(int);
callback import-duplicate-picked(int);
callback import-verify-toggled(bool);
callback import-backup-toggled(bool);
callback import-backup-path-changed(string);
callback import-start();
callback import-stop();
in property <string> settings-original-budget: "";
in property <bool> settings-original-unlimited: false;
in property <string> settings-thumbnail-budget: "";
in property <bool> settings-thumbnail-unlimited: false;
in property <bool> settings-keep-opened: true;
in property <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);
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
in property <int> settings-quality: 90;
in property <bool> settings-quality-enabled: true;
in property <[string]> settings-colour-labels;
in property <int> settings-colour-selected: 0;
in property <[string]> settings-sizing-labels;
in property <int> settings-sizing-selected: 0;
in property <int> settings-sizing-value: 0;
in property <bool> settings-sizing-has-value: false;
in property <string> settings-sizing-unit: "px";
in property <string> settings-sizing-value-label: "Size value";
in property <int> settings-sizing-height: 0;
in property <bool> settings-sizing-has-height: false;
in property <[string]> settings-screen-labels;
in property <int> settings-screen-selected: -1;
in property <bool> settings-allow-upscaling: false;
in property <[string]> settings-sharpening-labels;
in property <int> settings-sharpening-selected: 0;
in property <string> settings-filename-template: "";
in property <[string]> settings-collision-labels;
in property <int> settings-collision-selected: 0;
in property <bool> settings-strip-location: true;
in property <string> settings-destination: "";
in property <string> settings-destination-hint;
in property <bool> settings-browse-open: false;
in property <string> settings-browse-path;
in property <[string]> settings-browse-entries;
in property <bool> settings-browse-loading: false;
in property <bool> settings-browse-at-root: true;
in property <bool> settings-browse-available: false;
in property <[string]> settings-target-labels;
in property <int> settings-target-selected: 0;
in property <string> settings-error: "";
callback settings-format-picked(int);
callback settings-quality-changed(int);
callback settings-colour-picked(int);
callback settings-sizing-picked(int);
callback settings-sizing-value-changed(string);
callback settings-sizing-height-changed(string);
callback settings-screen-picked(int);
callback settings-upscaling-toggled(bool);
callback settings-sharpening-picked(int);
callback settings-template-changed(string);
callback settings-collision-picked(int);
callback settings-strip-location-toggled(bool);
callback settings-destination-changed(string);
callback settings-target-changed(int);
callback settings-browse-open-picker();
callback settings-browse-into(string);
callback settings-browse-up();
callback settings-browse-confirm();
callback settings-browse-cancel();
callback settings-reset();
/// Show the settings page. Reads the file first, so a second instance's
/// writes are picked up rather than overwritten.
callback settings-open();
callback settings-close();
// FR-UI-1: layout class follows window width, not device type. A narrow
// desktop window gets the compact layout, exactly as a tablet would.
//
// Set from Rust rather than derived from `root.width` here: a property
// read inside the layout and also feeding it creates a binding loop,
// which Slint warns about and which can panic at runtime.
//
// Nothing below reads `expanded` directly any more. It decides what the
// two collapsible columns *default* to, and Rust applies that to
// `panel-visible` and `collections-visible`; binding a column's width to
// the class itself would mean a panel the user opened snapped shut again
// on the next resize.
in property <bool> expanded: true;
in property <string> layout-class: "expanded";
/// TRACES: FR-UI-2
/// The most the develop column may take, in logical pixels.
///
/// Set from Rust beside `layout-class` and for the same reason: the column
/// sizes itself to its contents, and a ceiling read from `root.width`
/// inside the layout that `root.width` depends on is the binding loop the
/// note above describes.
///
/// A ceiling and not a width. The column asks for what it needs and this
/// only says when the answer has stopped being reasonable — a photograph
/// with a third of a window left to show itself in is not a develop view.
/// The default is generous because the first frame is drawn before any
/// resize has been reported, and a column briefly too wide is a far
/// smaller fault than one briefly clipped.
in property <length> panel-max-width: 520px;
// --- local adjustments (FR-DEV-3) ---------------------------------------
/// The false-coloured region map, drawn over the photograph.
///
/// An empty image when there is nothing to draw, which is also how the
/// overlay is hidden — one source of truth rather than a flag that can
/// disagree with the picture.
in property <image> region-overlay;
/// How strongly the overlay is composited. Not 1.0: the point of looking
/// at it is to judge whether a boundary follows the *photograph*, and an
/// opaque map hides the thing being judged.
in property <float> overlay-strength: 0.55;
in property <bool> overlay-on: false;
/// TRACES: FR-DEV-3
/// The photographer's switch for the same overlay, kept separate from
/// `overlay-on` because the two answer different questions: `overlay-on`
/// is "is there a mask to draw", which Rust decides, and this is "do I
/// want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// Which part of the source-space overlay the view is showing, in overlay
/// pixels. Without it the overlay stays frame-sized while the photograph
/// moves under it.
in property <int> overlay-clip-x;
in property <int> overlay-clip-y;
in property <int> overlay-clip-w;
in property <int> overlay-clip-h;
/// The straightening angle, so the overlay turns with the frame.
in property <float> overlay-angle: 0.0;
/// Whether a click on the canvas selects a region instead of panning.
///
/// **Derived, not toggled.** It used to be a button in the masking panel,
/// which meant local masking could be open with picking off — a mode
/// entered and doing nothing. Arming a click is part of what local mode
/// *is*, so it follows the mode and the existence of something to pick.
property <bool> region-picking: root.local-mode && root.segmented;
/// Whether shift is down, tracked by the develop key scope below.
///
/// A `TouchArea`'s `clicked` carries no modifiers, so the state has to be
/// held between the key event and the click. Private to the window because
/// nothing outside it has any business setting it.
property <bool> shift-held: false;
in property <[MaskRow]> mask-rows;
in property <[SubjectRow]> subject-rows;
in property <[CategoryRow]> category-rows;
in property <bool> segmented: false;
in property <bool> segmenting: false;
in property <bool> refining: false;
/// The selected layer's name, already resolved, or "ADJUST" when the
/// controls are global. Names the adjust panel's heading.
in property <string> adjust-scope: "ADJUST";
/// The selected gradient's handles, in fractions of the shown image.
/// Empty unless a gradient layer is selected in local mode.
in property <[GradientHandle]> gradient-handles;
/// A handle dragged: which one, where the press was and where the pointer
/// is now, both in fractions of the shown image.
///
/// The press is carried rather than a running delta because the geometry
/// is re-derived from the position it had when the drag began — which is
/// what stops a drag accumulating rounding error along its length, the
/// same reason the crop handles capture the rect they started from.
callback gradient-handle-dragged(HandleRole, float, float, float, float);
/// A drag finished, so the edit can be recorded as one history step
/// rather than as one per frame of the gesture.
callback gradient-handle-released();
// --- repairs (FR-DEV-8) -------------------------------------------------
/// Every repair on the photograph, as circles in fractions of the shown
/// image. Rust maps them through the framing, so they follow a crop, a
/// zoom, a pan and a rotation without anything here knowing about any of
/// those.
in property <[SpotHandle]> spot-handles;
/// A click on the photograph in repair mode, in fractions of the shown
/// image: cover what is here.
callback spot-placed(float, float);
/// A repair's circle dragged: which repair, which half of it, where the
/// press landed and where the pointer is now.
///
/// The press is carried rather than a running delta for the reason the
/// gradient handles carry it — the geometry is re-derived from where it
/// stood when the gesture began, so a drag cannot accumulate rounding
/// error along its length.
callback spot-handle-dragged(string, SpotRole, float, float, float, float);
/// A drag finished, so it can be one history step rather than one a frame.
callback spot-handle-released();
/// A repair chosen, so the column describes it.
callback spot-selected(string);
/// Take the selected repair off the photograph.
callback spot-removed(string);
/// Which repair the column is describing, or "" for none.
in-out property <string> selected-spot: "";
/// The selected repair's settings, and how many repairs there are.
///
/// Pushed as scalars rather than read off `spot-handles`, because the
/// circles carry what is *drawn* — a radius already mapped through the
/// framing into fractions of the shown image — and the panel edits what is
/// *stored*. Deriving one from the other would mean a slider that moved
/// differently at different zoom levels.
in property <int> spot-count: 0;
in property <float> spot-radius: 0.012;
in property <float> spot-feather: 0.35;
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone.
in property <int> spot-mode: 0;
callback spot-radius-changed(float);
callback spot-feather-changed(float);
callback spot-opacity-changed(float);
callback spot-mode-picked(int);
callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it.
callback region-picked(float, float, bool);
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-refine-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback add-gradient-mask(bool);
callback add-subject-mask(int);
callback add-category-mask(string);
/// Whether the develop column — image info, geometry, adjust — is shown.
///
/// Distinct from `expanded`, which is the *layout class* and only supplies
/// this its default: a panel the user closed on a wide window stays closed,
/// and one they opened on a narrow one stays open. Rust owns both for the
/// reason given above — a property read inside the layout and also feeding
/// it is a binding loop.
in property <bool> panel-visible: true;
/// The export button's label and busy state, and the line it reports to.
/// Rust owns all three: what an export is called depends on the format and
/// on whether the destination is this device or the server, neither of
/// which the toolbar can see.
in property <string> export-label: "Export";
in property <bool> export-busy: false;
in property <string> export-status;
/// TRACES: FR-EXP-7
/// Whether an export would be queued for the server rather than written to
/// this device. The grid says the same thing as the develop button, but it
/// composes its own label around a selection count — so it is given the
/// fact and not the sentence.
in property <bool> export-to-server: false;
/// Whether a batch is running. The grid's export button becomes its cancel
/// button; there is nowhere else a long batch can be stopped from.
in property <bool> library-exporting: false;
/// Export the grid's selection in the background (FR-EXP-7).
callback library-export-selection();
/// TRACES: NFR-ARCH-3
callback library-cancel-export();
/// Show or hide the develop column.
callback toggle-panel();
/// Export the image on screen, using the settings as they stand.
callback export-image();
/// Show or hide the collections sidebar.
callback toggle-collections();
/// Android's back gesture, or Escape. Returns whether there was anywhere to
/// go: `false` means this is the top of the stack, and the platform is left
/// to close the application as it normally would.
callback back-requested() -> bool;
callback canvas-resized(int, int);
callback window-resized(length);
/// The width the interface actually has to lay out in.
///
/// Not the window's: on Android the window spans the display and the
/// left/right insets are given over to system furniture, so choosing the
/// layout class from `width` would promise the layout space it cannot use.
property <length> shell-width:
root.width - root.safe-area-insets.left - root.safe-area-insets.right;
// One-way: report width outward, never read layout back into it.
changed shell-width => { root.window-resized(self.shell-width); }
// Everything the interface draws lives inside this, for two reasons that
// happen to want the same element.
//
// **The safe area.** From target SDK 35 Android draws applications behind
// the status and navigation bars and offers no way out, so the window's
// own coordinates include the strips those bars occupy — which is why the
// develop status strip came out underneath the clock and the wifi icon.
// `safe-area-insets` is what the platform reports as covered; the shell is
// placed inside it and only the window background paints behind the bars.
// Every inset is zero on the desktop, so nothing about that layout moves.
//
// **The back gesture.** Android's back button and back swipe arrive as
// `Key.Back`, delivered to whatever holds focus and then bubbled up its
// ancestors — so the one handler that answers for the whole application
// has to be above all four views rather than beside them. Accepting the
// key tells the platform we consumed it; rejecting it lets Android do what
// it does by default and close the activity, which is exactly what should
// happen at the top of the stack.
shell := FocusScope {
x: root.safe-area-insets.left;
y: root.safe-area-insets.top;
width: root.shell-width;
height: root.height - root.safe-area-insets.top
- root.safe-area-insets.bottom;
// Deliberately does *not* focus itself.
//
// A key needs a focus item to bubble from, so each of the four views
// below focuses a scope of its own on show — the grid's judgement keys,
// develop's arrow keys, and a zero-sized holder on the two form pages
// that would otherwise focus nothing. Claiming focus here as a fallback
// would race with those: Slint does not promise whether a parent's
// `init` runs before or after its children's, so on the wrong ordering
// this would take focus back off the grid and silently break culling.
key-pressed(event) => {
// Escape alongside Back: the same "go up one" on a keyboard, and
// the inner scopes that use it for something more specific — a
// rename in the sidebar — see it first and accept it there.
if (event.text == Key.Back || event.text == Key.Escape) {
// A dialogue takes "go up one" first, and it is the *only*
// thing that goes up. Without this, Android's Back button
// would leave the library from behind an open question — the
// view changing underneath a modal, which reads as the app
// having lost its place.
//
// 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;
}
if (root.offline-prompt-title != "") {
root.offline-prompt-dismiss();
return accept;
}
return root.back-requested() ? accept : reject;
}
return reject;
}
// Settings, over everything. First in the file and first in z-order so the
// conditions below can be read as "and settings is not open".
// Outermost with the settings page, and before it: both are reachable
// from the library and from develop, so guarding either with
// `!show-library` would make which view you came from decide whether
// it appears.
if root.show-import: ImportPage {
width: 100%;
height: 100%;
volume-labels: root.import-volume-labels;
volume-details: root.import-volume-details;
volume-selected: root.import-volume-selected;
card-path: root.import-card-path;
card-looks-right: root.import-card-looks-right;
surveying: root.import-surveying;
survey-summary: root.import-survey-summary;
upload-target: root.import-upload-target;
folder-template: root.import-folder-template;
template-preview: root.import-template-preview;
mode-labels: root.import-mode-labels;
mode-selected: root.import-mode-selected;
mode-is-move: root.import-mode-is-move;
duplicate-labels: root.import-duplicate-labels;
duplicate-selected: root.import-duplicate-selected;
verify: root.import-verify;
backup-on: root.import-backup-on;
backup-path: root.import-backup-path;
running: root.import-running;
fraction: root.import-fraction;
status: root.import-status;
error: root.import-error;
can-start: root.import-can-start;
volume-picked(i) => { root.import-volume-picked(i); }
card-path-changed(t) => { root.import-card-path-changed(t); }
refresh-volumes() => { root.import-refresh-volumes(); }
template-changed(t) => { root.import-template-changed(t); }
mode-picked(i) => { root.import-mode-picked(i); }
duplicate-picked(i) => { root.import-duplicate-picked(i); }
verify-toggled(on) => { root.import-verify-toggled(on); }
backup-toggled(on) => { root.import-backup-toggled(on); }
backup-path-changed(t) => { root.import-backup-path-changed(t); }
begin() => { root.import-start(); }
stop() => { root.import-stop(); }
close() => { root.import-close(); }
}
if !root.show-import && root.show-settings: SettingsPage {
width: 100%;
height: 100%;
adapter: root.adapter;
backend: root.backend;
fps: root.fps;
layout-class: root.layout-class;
app-version: root.app-version;
display-name: root.display-name;
display-colour: root.display-colour;
display-others: root.display-others;
original-budget: root.settings-original-budget;
original-unlimited: root.settings-original-unlimited;
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
copy-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); }
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); }
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
quality: root.settings-quality;
quality-enabled: root.settings-quality-enabled;
colour-labels: root.settings-colour-labels;
colour-selected: root.settings-colour-selected;
sizing-labels: root.settings-sizing-labels;
sizing-selected: root.settings-sizing-selected;
sizing-value: root.settings-sizing-value;
sizing-has-value: root.settings-sizing-has-value;
sizing-unit: root.settings-sizing-unit;
sizing-value-label: root.settings-sizing-value-label;
sizing-height: root.settings-sizing-height;
sizing-has-height: root.settings-sizing-has-height;
screen-labels: root.settings-screen-labels;
screen-selected: root.settings-screen-selected;
allow-upscaling: root.settings-allow-upscaling;
sharpening-labels: root.settings-sharpening-labels;
sharpening-selected: root.settings-sharpening-selected;
filename-template: root.settings-filename-template;
collision-labels: root.settings-collision-labels;
collision-selected: root.settings-collision-selected;
strip-location: root.settings-strip-location;
destination: root.settings-destination;
destination-hint: root.settings-destination-hint;
browse-open: root.settings-browse-open;
browse-path: root.settings-browse-path;
browse-entries: root.settings-browse-entries;
browse-loading: root.settings-browse-loading;
browse-at-root: root.settings-browse-at-root;
browse-available: root.settings-browse-available;
target-labels: root.settings-target-labels;
target-selected: root.settings-target-selected;
error: root.settings-error;
format-picked(i) => { root.settings-format-picked(i); }
quality-changed(q) => { root.settings-quality-changed(q); }
colour-picked(i) => { root.settings-colour-picked(i); }
sizing-picked(i) => { root.settings-sizing-picked(i); }
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
sizing-height-changed(t) => { root.settings-sizing-height-changed(t); }
screen-picked(i) => { root.settings-screen-picked(i); }
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
template-changed(t) => { root.settings-template-changed(t); }
collision-picked(i) => { root.settings-collision-picked(i); }
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
target-picked(i) => { root.settings-target-changed(i); }
browse-open-picker() => { root.settings-browse-open-picker(); }
browse-into(n) => { root.settings-browse-into(n); }
browse-up() => { root.settings-browse-up(); }
browse-confirm() => { root.settings-browse-confirm(); }
browse-cancel() => { root.settings-browse-cancel(); }
activity-rows: root.activity-rows;
activity-running: root.activity-running;
activity-kept: root.activity-kept;
clear-finished() => { root.activity-clear-finished(); }
thumbnailing: root.library-thumbnailing;
// The face pass reuses the Identity Manager's own state rather
// than duplicating it: one job, startable from either place, and
// a button that cannot disagree with the screen about whether it
// is running.
face-indexing: root.identity-indexing;
face-coverage: root.identity-coverage;
face-model-missing: root.identity-model-missing;
library-open: root.library-open;
thumbnail-library() => { root.library-thumbnail-all(); }
index-faces() => { root.identity-index(); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
// Like the launch screen, this replaces the window rather than
// overlaying it — see `show-identity`.
if !root.show-import && !root.show-settings && root.show-identity: IdentityScreen {
width: 100%;
height: 100%;
people: root.identity-people;
faces: root.identity-faces;
selected-person: root.identity-selected;
selected-name: root.identity-selected-name;
unassigned: root.identity-unassigned;
calibrated: root.identity-calibrated;
indexing: root.identity-indexing;
indexing-status: root.identity-indexing-status;
model-missing: root.identity-model-missing;
picked-count: root.identity-picked;
back-label: root.identity-back-label;
coverage: root.identity-coverage;
coverage-complete: root.identity-coverage-complete;
merge-offer-name: root.identity-merge-offer-name;
merge-offer-faces: root.identity-merge-offer-faces;
name-revision: root.identity-name-revision;
person-picked(id) => { root.identity-person-picked(id); }
rename(name) => { root.identity-rename(name); }
merge-accept() => { root.identity-merge-accept(); }
name-edited(t) => { root.identity-name-edited(t); }
merge-decline() => { root.identity-merge-decline(); }
confirm-face(id) => { root.identity-confirm-face(id); }
reject-face(id) => { root.identity-reject-face(id); }
toggle-pick(id) => { root.identity-toggle-pick(id); }
confirm-all() => { root.identity-confirm-all(); }
split-picked() => { root.identity-split-picked(); }
compact: !root.expanded;
photos-filtered: root.library-filter-people.length > 0;
regrouping: root.identity-regrouping;
regroup-status: root.identity-regroup-status;
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.show-import && !root.show-settings && !root.show-identity && root.show-launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
account: root.launch-account;
library-root: root.launch-root;
server-url: root.launch-server;
folder-path: root.launch-folder;
busy: root.launch-busy;
status: root.launch-status;
error: root.launch-error;
login-url: root.launch-login-url;
can-remember: root.launch-can-remember;
format-labels: root.launch-format-labels;
format-checked: root.launch-format-checked;
sign-in(server) => { root.launch-sign-in(server); }
use-folder(path) => { root.launch-use-folder(path); }
sign-in-direct(server, user, pw) => {
root.launch-sign-in-direct(server, user, pw);
}
sign-out() => { root.launch-sign-out(); }
choose-folder() => { root.launch-choose-folder(); }
open-library() => { root.launch-open-library(); }
format-toggled(i, on) => { root.launch-format-toggled(i, on); }
copy-login-url() => { root.launch-copy-url(); }
browsing: root.launch-browsing;
browse-path: root.launch-browse-path;
browse-loading: root.launch-browse-loading;
browse-entries: root.launch-browse-entries;
browse-into(name) => { root.launch-browse-into(name); }
browse-up() => { root.launch-browse-up(); }
browse-confirm() => { root.launch-browse-confirm(); }
browse-cancel() => { root.launch-browse-cancel(); }
}
// The library view sits between the launch screen and develop: a library
// has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both.
if root.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: root.collection-rows;
selected-id: root.collection-selected;
total-images: root.library-total;
error: root.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) => { root.collection-toggle(id); }
new-collection() => { root.collection-new(); }
row-menu(id) => { root.collection-menu(id); }
offline-menu(id) => { root.collection-offline-menu(id); }
row-press(id, down) => { root.collection-row-press(id, down); }
renaming-id: root.collection-renaming;
rename-start(id) => { root.collection-rename-start(id); }
rename-commit(id, name) => {
root.collection-rename-commit(id, name);
}
rename-cancel() => { root.collection-rename-cancel(); }
trash-count: root.trash-count;
trash-label: root.trash-label;
selected-count: root.library-selected-count;
dropped-on(id) => { root.collection-dropped(id); }
collection-drag-payload(id) => { return root.collection-drag-payload(id); }
dropped-on-trash() => { root.trash-dropped(); }
empty-trash() => { root.trash-empty(); }
restore-selected() => { root.trash-restore(); }
drag-over(id, over) => { root.collection-drag-over(id, over); }
}
}
LibraryGrid {
horizontal-stretch: 1;
// FR-UI-1: the header collapses its actions behind a
// disclosure below the breakpoint. A tablet in portrait is
// 768 logical pixels, which fits the title and the status
// line but not the library's buttons beside them.
expanded: root.expanded;
cells: root.library-cells;
total: root.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: root.library-local-only;
local-count: root.library-local-count;
toggle-local-only() => { root.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
select-all() => { root.library-select-all(); }
reorderable: root.library-reorderable;
reorder-to(row, after) => { root.library-reorder-to(row, after); }
collection-from-selection(name) => {
root.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: root.library-selected-count;
scope-label: root.collection-scope-label;
settings-armed: root.settings-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 = root.library-selected-count;
root.preset-can-save = false;
root.presets-open = true;
}
// TRACES: FR-EXP-7
exporting: root.library-exporting;
export-to-server: root.export-to-server;
export-selection => { root.library-export-selection(); }
cancel-export => { root.library-cancel-export(); }
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
current-bucket: root.library-current-bucket;
current-bucket-fraction: root.library-current-fraction;
timeline-anchored: root.library-timeline-anchored;
scrub-fraction(f) => { root.library-scrub-fraction(f); }
timeline-pinch(r) => { root.library-timeline-pinch(r); }
timeline-pan(d) => { root.library-timeline-pan(d); }
timeline-zoom(d) => { root.library-timeline-zoom(d); }
syncing: root.library-syncing;
requested-cell-size: root.library-cell-size;
touched <=> root.library-touched;
zoom-cells(d) => { root.library-zoom-cells(d); }
pinch-cells(r) => { root.library-pinch-cells(r); }
pinch-started() => { root.library-pinch-started(); }
scroll-to: root.library-scroll-to;
scroll-token: root.library-scroll-token;
sync-now() => { root.library-sync-now(); }
columns-changed(n) => {
root.library-columns = n;
// Which cell begins a row just changed, and month headings sit on
// row-leading cells — so they must be recomputed, not just moved.
root.library-columns-changed();
}
scrolled(i) => { root.library-scrolled(i); }
viewport-cells-changed(n) => { root.library-viewport-cells(n); }
cell-clicked(i) => { root.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
can-import: root.import-supported;
open-import() => { root.import-open(); }
open-settings() => { root.settings-open(); }
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
}
cell-press-ended() => { root.library-cell-press-ended(); }
select-mode: root.library-select-mode;
toggle-select-mode() => { root.library-toggle-select-mode(); }
held-row <=> root.library-held-row;
// The sidebar's rows, not a second model: the sheet files
// into the same tree the sidebar draws.
collections: root.collection-rows;
file-in-collection(id, moves) => {
root.library-file-in-collection(id, moves);
}
keywords: root.library-keywords;
keywords-opened() => { root.library-keywords-opened(); }
assign-keyword(word) => { root.library-assign-keyword(word); }
unassign-keyword(word) => { root.library-unassign-keyword(word); }
cursor: root.library-cursor;
move-cursor(delta, extend) => {
root.library-move-cursor(delta, extend);
}
open-cursor() => { root.library-open-cursor(); }
drag-image: root.library-drag-image;
drag-payload() => { return root.library-drag-payload(); }
drag-started(i) => { root.library-drag-started(i); }
drag-finished() => { root.library-drag-finished(); }
remove-from-collection() => {
root.library-remove-from-collection();
}
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
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); }
trash-selection() => { root.library-trash-selection(); }
// Derived from the sidebar's own selection rather than
// mirrored in a second property: `-1` is already the sentinel
// for the trash row (see collections.slint), and two sources
// for one fact is how they come to disagree.
viewing-trash: root.collection-selected == -1;
judged(rating, flag) => { root.library-judged(rating, flag); }
// `F2` renames whatever the grid is scoped to. Answered from
// the sidebar's selection rather than a second property, for
// the same reason `viewing-trash` above is: one fact, one
// source. Ids at or below 0 are "all photographs" and the
// trash, neither of which has a name to change — Rust drops
// those rather than the binding trying to reason about it.
rename-scope() => {
root.collection-rename-start(root.collection-selected);
}
filter-min-rating-changed(n) => {
root.library-filter-min-rating-changed(n);
}
filter-unjudged-toggled(on) => {
root.library-filter-unjudged-changed(on);
}
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
people-listed() => { root.library-people-listed(); }
filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
}
}
}
if !root.show-import && !root.show-settings && !root.show-identity && !root.show-launch && !root.show-library: VerticalLayout {
width: 100%;
height: 100%;
StatusBar {
adapter: root.adapter;
backend: root.backend;
layout-class: root.layout-class;
fps: root.fps;
expanded: root.expanded;
filename: root.filename;
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
// Only where a library was opened; command-line files have no
// grid to return to.
can-return-to-library: root.library-total > 0;
panel-visible: root.panel-visible;
export-label: root.export-label;
export-busy: root.export-busy;
export-status: root.export-status;
can-undo: root.can-undo;
can-redo: root.can-redo;
back-to-library() => { root.back-to-library(); }
open-people() => { root.identity-open(); }
open-settings() => { root.settings-open(); }
toggle-panel() => { root.toggle-panel(); }
export-image() => { root.export-image(); }
undo() => { root.undo(); }
redo() => { root.redo(); }
}
HorizontalLayout {
// **The 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).
ToolRail {
enabled: root.total > 0 && root.load-error == "";
mode: root.view-mode;
picked(m) => { root.mode-picked(m); }
}
// The canvas: compute output composited directly. No CPU
// round-trip anywhere in this path (ARCH §6.1).
canvas-area := Rectangle {
horizontal-stretch: 1;
background: Theme.ground;
clip: true;
canvas-image := Image {
width: 100%;
height: 100%;
source: root.canvas;
image-fit: contain;
// Past 1:1 there is no detail left to reconstruct, so
// smoothing only invents values between real pixels — and
// inspecting focus or noise is the whole reason to zoom in
// that far. Below 1:1 it stays smooth, where filtering is
// what keeps the image from aliasing.
//
// TRACES: FR-DSP-8
// The canvas is rendered at the *physical* pixel size of
// the box this image occupies, so `contain` presents it
// 1:1 and neither filter is reached at all until the
// photographer zooms. That is the point: on a fractionally
// scaled desktop the buffer used to be a logical-sized one
// that the compositor stretched, and no choice of filter
// recovers detail that was never rendered.
image-rendering: root.magnified
? ImageRendering.pixelated
: ImageRendering.smooth;
visible: root.total > 0 && root.load-error == "";
}
// The region map, composited over the photograph at the
// same fitted rect.
//
// Drawn here rather than blended into the render because
// it is a diagnostic and not an edit: it must not reach
// the histogram, an export, or the texture the develop
// pass hands the compositor.
if root.overlay-on && !root.overlay-hidden && root.total > 0: Image {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
source: root.region-overlay;
// The visible part of a source-space picture, which is
// what makes the overlay follow a zoom, a pan and a
// crop. The compositor does the crop and the scale;
// resampling on the CPU would mean rebuilding a
// megapixel image on every frame of a drag.
source-clip-x: root.overlay-clip-x;
source-clip-y: root.overlay-clip-y;
source-clip-width: root.overlay-clip-w;
source-clip-height: root.overlay-clip-h;
transform-rotation: -root.overlay-angle * 1deg;
image-fit: fill;
opacity: root.overlay-strength;
// Nearest-neighbour, always. The map is proxy-sized
// and its values are *names*: smoothing between region
// 4 and region 9 invents a colour belonging to
// neither, and draws a soft edge exactly where the
// point is to see a hard one.
image-rendering: ImageRendering.pixelated;
}
// 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.
// TRACES: FR-UI-4
// Pinch to zoom. The wheel gesture below is the desktop's
// answer and there is no wheel on a tablet, so without this
// the only way to magnify a photograph on the device the
// panel was sized for is a control that does not exist.
//
// `scale` is cumulative from 1.0 across the whole gesture,
// so the ratio since the last update is what maps onto a
// zoom step — the same reasoning as the timeline's pinch,
// and for the same reason: applying the running total on
// every update squares it and shoots straight to the limit.
//
// Anchored on the midpoint between the fingers, which is
// what makes a pinch feel like it is moving the picture
// rather than the viewport.
if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler {
x: 0; y: 0;
width: 100%;
height: 100%;
enabled: !root.cropping;
property <float> last-scale: 1.0;
started => { self.last-scale = 1.0; }
updated => {
root.zoom-at(
self.scale / max(0.01, self.last-scale),
(self.center.x - canvas-area.shown-x)
/ max(canvas-area.shown-w, 1px),
(self.center.y - canvas-area.shown-y)
/ max(canvas-area.shown-h, 1px),
);
self.last-scale = self.scale;
}
ended => { self.last-scale = 1.0; }
cancelled => { self.last-scale = 1.0; }
}
// Region picking, above the pan/zoom area so a click
// reaches it first. A separate area rather than a branch
// inside the one below: panning wants press-drag-release
// and picking wants a click, and interleaving the two in
// one handler is how a drag ends up selecting a region the
// user was only scrolling past.
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
clicked => {
root.region-picked(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
// Shift extends or removes, the modifier every
// selection tool uses for the same thing.
root.shift-held,
);
}
}
// TRACES: FR-DEV-8
// Placing a repair, beside the region picker and for the
// same reasons: a click and a pan want different handlers,
// and this one has to reach the click first.
//
// Below the circles declared further down, so a press that
// lands on an existing repair takes hold of it instead of
// making another one on top.
if root.repairing && root.total > 0 && root.load-error == "": TouchArea {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
mouse-cursor: MouseCursor.crosshair;
enabled: !root.cropping;
clicked => {
root.spot-placed(
self.mouse-x / max(self.width, 1px),
self.mouse-y / max(self.height, 1px),
);
}
}
if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0;
width: 100%;
height: 100%;
// A pan is only meaningful once there is something outside
// the viewport to reach.
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
enabled: !root.cropping;
property <length> last-x;
property <length> last-y;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.last-x = self.mouse-x;
self.last-y = self.mouse-y;
}
}
moved => {
if (self.pressed && root.zoomed) {
// Fractions of the *visible* area, which is what
// the session's pan expects. Negated: dragging
// right moves the image right, so the window onto
// it moves left.
root.pan-by(
-(self.mouse-x - self.last-x) / max(parent.shown-w, 1px),
-(self.mouse-y - self.last-y) / max(parent.shown-h, 1px),
);
self.last-x = self.mouse-x;
self.last-y = self.mouse-y;
}
}
scroll-event(ev) => {
if (ev.delta-y == 0) {
return reject;
}
// Anchored on the pointer, in fractions of the shown
// image, so whatever is under the cursor stays there.
root.zoom-at(
ev.delta-y > 0 ? 1.15 : 1.0 / 1.15,
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
);
return accept;
}
double-clicked => { root.zoom-reset(); }
}
// --- crop overlay ------------------------------------------
//
// Placed over the fitted image, so the fractions it draws are
// fractions of the picture. See `CropOverlay` in crop.slint.
if root.cropping && root.total > 0 && root.load-error == "": CropOverlay {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
height: canvas-area.shown-h;
crop-x: root.crop-x;
crop-y: root.crop-y;
crop-w: root.crop-w;
crop-h: root.crop-h;
crop-changed(x, y, w, h, hx, hy) => {
root.crop-changed(x, y, w, h, hx, hy);
}
}
// --- gradient handles ---------------------------------------
//
// TRACES: FR-DEV-3 | FR-UI-3
// Handed the fitted image rect for the same reason the crop
// overlay and the repairs are: the photograph is letterboxed
// inside this box, Slint does not report the fitted rect, and
// anything that has to land *on* the picture is placed against
// the derived one. See `GradientHandles` in masks.slint.
GradientHandles {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
height: canvas-area.shown-h;
handles: root.gradient-handles;
dragged(role, fx, fy, tx, ty) => {
root.gradient-handle-dragged(role, fx, fy, tx, ty);
}
released() => { root.gradient-handle-released(); }
}
// --- repairs (FR-DEV-8) -------------------------------------
//
// Every position in here is a fraction of the fitted image
// rect, which is why the overlay is handed that rect rather
// than the canvas box. See `SpotHandles` in spots.slint for
// what is drawn and why it is drawn at true size.
SpotHandles {
x: canvas-area.shown-x;
y: canvas-area.shown-y;
width: canvas-area.shown-w;
height: canvas-area.shown-h;
handles: root.spot-handles;
selected(id) => { root.spot-selected(id); }
dragged(id, role, fx, fy, tx, ty) => {
root.spot-handle-dragged(id, role, fx, fy, tx, ty);
}
released() => { root.spot-handle-released(); }
}
// Arrow keys and space step through the folder.
//
// Focused on show rather than waiting for a click, exactly
// as the grid's judgement scope is: without it the first
// keystroke after opening an image goes nowhere, and — now
// that the shell answers the back gesture by bubbling —
// there would be no focus item for `Key.Back` to bubble
// *from*, so back would close the application from develop
// instead of returning to the grid.
FocusScope {
init => { self.focus(); }
// Shift is state, not an event: the click that needs
// to know about it arrives through a `TouchArea`,
// which reports no modifiers of its own. Tracked on
// both edges so releasing it while the pointer is over
// the canvas is noticed.
key-released(event) => {
root.shift-held = event.modifiers.shift;
return reject;
}
key-pressed(event) => {
root.shift-held = event.modifiers.shift;
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
// of the letter, because the logical key that
// reaches us carries the shift: holding it for
// redo turns "z" into "Z".
if (event.modifiers.control
&& (event.text == "z" || event.text == "Z")) {
if (event.modifiers.shift) {
root.redo();
} else {
root.undo();
}
return accept;
}
// TRACES: FR-DEV-8
// Delete removes the selected repair, and only in
// repair mode: the same key in the grid judges
// photographs, and a key that means two things
// depending on a mode the user has forgotten they
// are in is how work disappears.
if (root.repairing && root.selected-spot != ""
&& (event.text == Key.Delete
|| event.text == Key.Backspace)) {
root.spot-removed(root.selected-spot);
return accept;
}
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;
}
if (event.text == Key.LeftArrow) {
root.prev-image();
return accept;
}
return reject;
}
}
// Zoom controls, floating over the canvas.
//
// Zoom stays here rather than moving to the sidebar with the
// crop: it is a property of *looking*, it is driven by the
// scroll wheel on the image itself, and its readout belongs
// beside what it is reporting on. Crop moved to the panel
// because it is an edit, and edits live with the other edits.
//
// **"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:
root.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 root.view-mode != ViewMode.photo: Button {
// Names the mode being left rather than saying
// "Done", which was unambiguous while there was
// one mode and would not be with two.
text: root.cropping
? "Done Cropping"
: (root.repairing ? "Done Repairing" : "Done Masking");
active: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
// Zoom is a view state, so its readout doubles as the
// control that clears it.
Button {
text: root.zoomed
? Math.round(root.zoom * 100) + "%"
: "Fit";
active: root.zoomed;
enabled: root.zoomed;
clicked => { root.zoom-reset(); }
}
}
// The photo roll, along the foot of the canvas.
//
// Only where a library was opened: with files named on the
// command line there is no window of photographs to roll
// through, which is the same condition the "‹ Library"
// button uses.
//
// Last in the canvas, so it draws over the photograph
// rather than under it.
if root.library-total > 0: PhotoRoll {
width: 100%;
height: 100%;
cells: root.library-cells;
current: root.library-roll-current;
open <=> root.roll-open;
centre-request <=> root.library-roll-centre;
pick(i) => { root.library-roll-pick(i); }
}
// Report size changes so the render target can be resized to
// match. Width and height are tracked separately because
// Slint has no single "geometry changed" hook.
property <int> px-w: Math.round(self.width / 1px);
property <int> px-h: Math.round(self.height / 1px);
changed px-w => { root.canvas-resized(self.px-w, self.px-h); }
changed px-h => { root.canvas-resized(self.px-w, self.px-h); }
}
// Always instantiated, width collapsed to zero when closed. A
// conditional `if` here creates a binding loop — the layout
// depends on `panel-visible`, which derives from the window
// width, which the layout then influences. Slint flags it, and
// it can panic at runtime.
develop-column := Rectangle {
// TRACES: FR-UI-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.
width: root.panel-visible
? min(root.panel-max-width, Theme.panel-width)
: 0px;
visible: root.panel-visible;
background: Theme.surface;
clip: true;
// **The whole column scrolls, histogram included.**
//
// Only the sliders used to. Everything above them — the
// capture metadata, the histogram, the geometry controls,
// copy and paste — was fixed, so on a 280px column in
// portrait they took the height the sliders needed and the
// histogram could not be moved out of the way. It is the
// instrument the sliders are judged against; being unable
// to scroll to it, or past it, is the worst of both.
//
// `interactive` stands down while a track has a gesture,
// the same arbitration `AdjustPanel` used to do for its own
// scroller — see the note on `SliderTrack`'s `claimed`.
// Without it every attempt to drag a slider would scroll
// the column instead.
// Pinned: the strip is how the column is navigated, so it
// cannot be somewhere you have to navigate to.
// A layout, because the parent is a Rectangle: two children of one
// would both sit at its origin and overlap. The strip is pinned by
// being outside the Flickable rather than by any coordinate.
VerticalLayout {
GroupStrip {
enabled: root.adjust-enabled;
tabs: root.adjust-tabs;
active-tab: root.adjust-active-tab;
picked(i) => { root.adjust-tab-picked(i); }
}
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 !root.local-mode && !root.repairing: InfoPanel {
camera: root.camera;
lens: root.lens;
exposure: root.exposure;
dimensions: root.dimensions;
}
if !root.local-mode && !root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
// Directly under the capture metadata and above every
// control, because it is the thing the controls are
// judged against: exposure, blacks and whites are all
// set by watching this move (FR-DSP-7). An instrument
// below the sliders it reports on would have the
// photographer looking away from it to use it.
//
// Kept in local mode, where it still reports the
// whole frame while the sliders edit a layer. That
// disagreement is real and is N3's to close; taking
// the instrument away instead would be a worse
// answer than an honest one that is not yet scoped.
HistogramPanel {
data: root.histogram;
raw-data: root.raw-histogram;
}
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 {
available: root.peaking-available;
showing: root.peaking-on;
sensitivity: root.peaking-sensitivity;
colour: root.peaking-colour;
toggled(v) => { root.peaking-toggled(v); }
sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); }
colour-picked(i) => { root.peaking-colour-picked(i); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Framing above the colour work, matching how the edit is
// made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see
// `dr_pipeline::framing` on the coordinate order).
if !root.local-mode && !root.repairing: ComposePanel {
enabled: root.adjust-enabled;
angle: root.straighten;
max-straighten: root.max-straighten;
flip-h: root.flip-h;
flip-v: root.flip-v;
modified: root.framing-modified;
// The ratio chips belong to the crop tool, so
// they appear with it. Shown at all times they
// would be a control for a gesture that is not
// on screen; hidden behind a lid they would be
// a feature nobody finds.
cropping: root.cropping;
aspects: root.crop-aspects;
aspect: root.crop-aspect;
portrait: root.crop-portrait;
aspect-turnable: root.crop-aspect-turnable;
aspect-picked(i) => { root.crop-aspect-picked(i); }
portrait-toggled => { root.crop-portrait-toggled(); }
rotate(turns) => { root.rotate-quarters(turns); }
flip-h-toggled => { root.flip-h-toggled(); }
flip-v-toggled => { root.flip-v-toggled(); }
angle-changed(v) => { root.straighten-changed(v); }
angle-committed(v) => {
root.straighten-committed(v);
}
angle-reset => { root.straighten-changed(0); }
reset => { root.framing-reset(); }
}
if !root.local-mode && !root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
// Between the framing and the colour work, because it
// acts on both: a paste is about the whole edit, and
// burying it under thirty sliders would put the one
// control that operates on all of them below all of
// them.
if !root.local-mode && !root.repairing: TransferPanel {
enabled: root.adjust-enabled;
armed: root.settings-armed;
summary: root.settings-summary;
framing-withheld: root.settings-framing-withheld;
copy => { root.copy-settings(); }
paste => { root.paste-settings(); }
// Opened with no count, which is what tells
// the apply handler this means the open
// photograph rather than a selection.
open-presets => {
root.preset-apply-count = 0;
root.preset-can-save = root.adjust-enabled;
root.presets-opened();
root.presets-open = true;
}
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Above the adjust panel, because it decides what
// those sliders act on. Below it, the photographer
// would set an exposure and only then discover which
// scope it landed in.
//
// Only in local mode: it is no longer a panel among
// peers that silently re-points another panel, it is
// what that mode's column *is*. That also takes one
// panel out of a scrolling column that had six.
// TRACES: FR-DEV-8
// What the column *is* in repair mode, on the same
// terms as the mask panel: not a panel among peers
// that quietly re-points the sliders below it, but
// the whole of what this mode has to say.
if root.repairing: SpotPanel {
enabled: root.adjust-enabled;
has-selection: root.selected-spot != "";
count: root.spot-count;
radius: root.spot-radius;
feather: root.spot-feather;
spot-opacity: root.spot-opacity;
mode: root.spot-mode;
radius-changed(v) => { root.spot-radius-changed(v); }
feather-changed(v) => { root.spot-feather-changed(v); }
opacity-changed(v) => { root.spot-opacity-changed(v); }
mode-picked(i) => { root.spot-mode-picked(i); }
removed => { root.spot-removed(root.selected-spot); }
}
if root.repairing: Rectangle {
height: 1px;
background: Theme.rule;
}
if root.local-mode: MaskPanel {
enabled: root.adjust-enabled;
masks: root.mask-rows;
subjects: root.subject-rows;
categories: root.category-rows;
segmented: root.segmented;
segmenting: root.segmenting;
refining: root.refining;
// Two-way: the panel is where it is toggled and
// the canvas is what obeys it, so neither owns
// the value alone.
overlay-hidden <=> root.overlay-hidden;
segment(fine) => { root.segment-image(fine); }
mask-selected(id, extend) => { root.mask-selected(id, extend); }
mask-removed(id) => { root.mask-removed(id); }
mask-refined(id) => { root.mask-refined(id); }
mask-toggled(id, on) => { root.mask-toggled(id, on); }
mask-invert-toggled(id, on) => {
root.mask-invert-toggled(id, on);
}
mask-opacity-changed(id, v) => {
root.mask-opacity-changed(id, v);
}
mask-refine-changed(id, v) => {
root.mask-refine-changed(id, v);
}
mask-feather-changed(id, v) => {
root.mask-feather-changed(id, v);
}
mask-falloff-picked(id, i) => {
root.mask-falloff-picked(id, i);
}
mask-morphology-picked(id, i) => {
root.mask-morphology-picked(id, i);
}
mask-morph-radius-changed(id, v) => {
root.mask-morph-radius-changed(id, v);
}
add-gradient(radial) => { root.add-gradient-mask(radial); }
add-subject(i) => { root.add-subject-mask(i); }
add-category(n) => { root.add-category-mask(n); }
}
if root.local-mode: Rectangle {
height: 1px;
background: Theme.rule;
}
adjust := AdjustPanel {
rows: root.adjust-rows;
enabled: root.adjust-enabled;
scope: root.adjust-scope;
curve-samples: root.curve-samples;
curve-channels: root.curve-channels;
curve-channel: root.curve-channel;
curve-channel-picked(i) => {
root.curve-channel-picked(i);
}
param-changed(op, param, value) => {
root.param-changed(op, param, value);
}
param-reset(op, param) => {
root.param-reset(op, param);
}
curve-reset(op) => { root.curve-reset(op); }
// A section's reset and a curve's reset are the same
// action — every parameter of one operation back to
// its default — so they share the one callback rather
// than duplicating a handler that would have to be
// kept in step with it.
op-reset(op) => { root.curve-reset(op); }
reset-all => { root.reset-all(); }
// TRACES: FR-DEV-3f
film-stocks: root.film-stocks;
film-selected: root.film-selected;
film-can-print: root.film-can-print;
film-print: root.film-print;
film-picked(i) => { root.film-picked(i); }
film-print-toggled(on) => { root.film-print-toggled(on); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Last in the column, and the length is why. The
// stack runs sixty-four deep, so anywhere else it
// would push the controls it is a record of below
// the fold. Its own rows run newest-first, which
// puts the steps worth reaching immediately under
// the heading rather than at the far end of them.
//
// Present in local mode as well. The mask work
// *is* history — adding a layer, moving a
// gradient and feathering an edge are all steps —
// and a list that emptied when the photographer
// entered the mode that produces the most steps
// would be a list they stopped trusting.
HistoryPanel {
rows: root.history-rows;
enabled: root.adjust-enabled;
can-undo: root.can-undo;
can-redo: root.can-redo;
undo-label: root.undo-label;
undo => { root.undo(); }
redo => { root.redo(); }
picked(i) => { root.history-picked(i); }
}
}
}
}
// The seam between the photograph and the column.
//
// **`x: 0` is load-bearing.** A child of a plain Rectangle
// that declares a size and no position is *centred* in it,
// so this hairline was being drawn straight down the middle
// of the develop column — over the histogram, the geometry
// controls and every slider below them. Invisible in the
// source and, at one pixel of `rule` grey, quiet enough on
// screen to be read as a divider that was meant to be
// there.
Rectangle {
x: 0;
width: 1px;
height: 100%;
background: Theme.rule;
}
}
}
}
// --- the load bar -------------------------------------------------
//
// Last in the file, so it is last in z-order and no view can cover it.
//
// At the top of the *shell* rather than inside a view, because that is
// the only place that is true of every view: a pin download outlives
// the grid it was started from, and until this existed it drew nothing
// at all once the user opened an image. Three pixels, no text and no
// hit area — it says only "something is running", and the settings
// page says what (FR-CAT-1, FR-NC-6c).
if root.activity-busy: ProgressBar {
x: 0;
y: 0;
width: 100%;
indeterminate: !root.activity-determinate;
fraction: root.activity-fraction;
}
// TRACES: FR-NC-6a
// The offline question, over the shell rather than inside the sidebar
// that raises it. The sidebar is 232px wide and closes entirely in the
// compact layout, so a dialogue living in there would be unreadable on
// the device the gesture exists for — and the answer commits gigabytes
// either way, which deserves the width to say so.
//
// Below the load bar, above everything else: a download started from
// here shows its progress in that bar, and the bar must not be the
// thing the dialogue covers.
// TRACES: FR-DEV-6
// Over the shell rather than inside a view, because both views open
// it — and because the develop column is 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(); }
}
// 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(); }
}
}
}