AppWindow carried the grid's loaded window of cells, the keyboard cursor, drag and drop, the held-row long-press state, columns and cell size, the scroll and viewport bookkeeping, the photo roll's pick and centre-request, and the local-only/reorder/collection-filing gestures that act on a selection, as properties and callbacks on the root component. That state now lives in the `Library` global declared in library.slint, next to the structs (LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other components already share; Rust reaches it through window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the root, the same change collections.slint's `Collections` global made for the sidebar. library-visible stays on AppWindow: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. Everything else still prefixed library- — the timeline, the filter bar, ratings and flags, keywording, and the routes and status lines — stays on the window for now and moves in the commits that follow.
3120 lines
167 KiB
Plaintext
3120 lines
167 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { Develop, ViewMode } from "session.slint";
|
||
import { AdjustPanel, Adjustments, ComposePanel, Framing, GroupStrip, ParamRow, Transfer, TransferPanel } from "adjust.slint";
|
||
import { CategoryRow, GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, Masking, SubjectRow } from "masks.slint";
|
||
import { Repair, SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
|
||
import { CropOverlay } from "crop.slint";
|
||
import { HistoryPanel, HistoryRow, Steps } from "history.slint";
|
||
import { LaunchScreen } from "launch.slint";
|
||
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip, Library } from "library.slint";
|
||
import { GestureRow } from "gestures.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
|
||
MembershipSheet, MembershipRow } from "collections.slint";
|
||
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
|
||
import { RecoveryPrompt } from "recovery.slint";
|
||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
|
||
import { SettingsPage } from "settings.slint";
|
||
import { ImportPage } from "import.slint";
|
||
import { MergePage, MergeFrameRow } from "merge.slint";
|
||
import { Capture, StatusBar, InfoPanel } from "develop.slint";
|
||
import { ToolRail } from "toolrail.slint";
|
||
|
||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip, MergeFrameRow }
|
||
export { GestureRow }
|
||
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||
|
||
// The develop view's globals, re-exported so Rust can reach them: a global is
|
||
// hooked through `window.global::<Adjustments>()` and only what this document
|
||
// exports appears in the generated API. `session.slint` says why the develop
|
||
// panels take their wiring this way and why there is one global per family.
|
||
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||
export { Collections }
|
||
export { Library }
|
||
|
||
/// Which top-level screen is on display. Exactly one at a time — replacing
|
||
/// the `show-launch` / `show-library` / `show-identity` booleans, with
|
||
/// develop as "none of the above" wired into every conditional that drew any
|
||
/// of the other three. Those conditionals compared four and five booleans at
|
||
/// once to rule out two screens being true together; comparing one value to
|
||
/// a variant cannot be wrong in that way.
|
||
///
|
||
/// `develop` is listed first, and so is the type's default: it is what the
|
||
/// old booleans meant when every one of them was false, which is the state
|
||
/// files named on the command line stay in permanently — there is no
|
||
/// library behind them to switch to — and the state the window briefly
|
||
/// holds at construction, before `lib.rs::run` chooses.
|
||
export enum View {
|
||
develop,
|
||
library,
|
||
identity,
|
||
launch,
|
||
}
|
||
|
||
/// Which page, if any, is drawn over the current `View`. `settings`,
|
||
/// `import` and `merge` are reachable from more than one `View` — Settings
|
||
/// from the library and from develop, Import and Merge from the library —
|
||
/// and are drawn outermost so the view underneath is torn down rather than
|
||
/// nested inside the one it is about to change. `none` is the ordinary case,
|
||
/// and the type's default.
|
||
///
|
||
/// Kept apart from `View` rather than folded into it as three more variants:
|
||
/// closing a page has to return to whichever view was already current, and
|
||
/// today that works because the view's own property is left untouched while
|
||
/// a page sits over it. Three more `View` variants would need a second field
|
||
/// remembering what to return to; a separate, single-valued `Page` needs
|
||
/// nothing to remember.
|
||
export enum Page {
|
||
none,
|
||
settings,
|
||
import,
|
||
merge,
|
||
}
|
||
|
||
export component AppWindow inherits Window {
|
||
title: "DarkRoom";
|
||
// The one raster asset in the UI, and the exception that the header of
|
||
// icons.slint does not cover: everything drawn *inside* the window is a
|
||
// Path because a font might not have the glyph, but the window icon is
|
||
// never drawn by us. It is handed to the window manager, which wants
|
||
// pixels. Pre-shaped with rounded corners and transparent outside them,
|
||
// because desktop window managers composite it as-is rather than masking
|
||
// it the way Android does — the Android launcher icon is a separate,
|
||
// maskable set under apps/darkroom-android/android/res.
|
||
//
|
||
// Where it actually shows up is narrower than it looks: X11 and Windows
|
||
// read it, and Wayland ignores it outright in favour of matching app_id
|
||
// against an installed .desktop file, which this repo does not install.
|
||
// Android ignores it too — there is no title bar to put it in.
|
||
icon: @image-url("app-icon.png");
|
||
background: Theme.ground;
|
||
preferred-width: 1100px;
|
||
preferred-height: 720px;
|
||
min-width: 360px;
|
||
min-height: 320px;
|
||
|
||
// Set from Rust each frame: the compute output, delivered as a texture.
|
||
in property <image> canvas;
|
||
in property <string> adapter: "detecting…";
|
||
in property <string> backend: "—";
|
||
in property <string> inference-backend: "detecting…";
|
||
in property <string> inference-detail: "";
|
||
in property <int> fps: 0;
|
||
/// TRACES: FR-DSP-8
|
||
/// The display showing the canvas and the colour transform it is getting.
|
||
/// Shown under ABOUT in Settings; see `settings.slint` for why the second
|
||
/// one is a sentence rather than a name.
|
||
in property <string> display-name: "detecting…";
|
||
in property <string> display-colour: "detecting…";
|
||
in property <string> display-others: "";
|
||
/// TRACES: FR-UI-2
|
||
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
||
/// the binary it is part of.
|
||
in property <string> app-version: "unknown";
|
||
/// TRACES: NFR-OPS-1
|
||
/// The diagnostics bundle's preview and the last save's outcome — see
|
||
/// `SettingsPage`, which is the only reader.
|
||
in property <string> diagnostics-preview;
|
||
in property <string> diagnostics-result;
|
||
callback diagnostics-prepare();
|
||
callback diagnostics-save();
|
||
callback diagnostics-discard();
|
||
|
||
// Current image, for the status strip and empty state.
|
||
in property <string> filename: "";
|
||
in property <int> index: 0;
|
||
in property <int> total: 0;
|
||
in property <string> load-error: "";
|
||
|
||
/// TRACES: FR-CULL-3
|
||
/// The focus marks themselves, and whether they describe *this* frame.
|
||
/// Rust's, because they come from a compute pass — see `peaking.slint` for
|
||
/// why `focus-overlay-ready` is a separate question from
|
||
/// `Peaking.showing`, and for the three things the photographer chose.
|
||
in property <image> focus-overlay;
|
||
in property <bool> focus-overlay-ready: false;
|
||
|
||
// --- zoom, pan and crop (FR-DEV-4) ---
|
||
//
|
||
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
||
// pipeline renders at, never what the file becomes. Rust owns the actual
|
||
// rect; these carry only what the interface has to draw.
|
||
in property <float> zoom: 1.0;
|
||
in property <bool> zoomed: false;
|
||
|
||
/// Whether one source pixel now covers more than one screen pixel.
|
||
///
|
||
/// Drives the canvas's filtering, and nothing else. Rust decides it rather
|
||
/// than the `zoom` property above, because the two are not the same
|
||
/// question: whether the image is magnified depends on the source's
|
||
/// resolution against the viewport's, which Slint does not know.
|
||
in property <bool> magnified: false;
|
||
|
||
/// The crop rect in fractions of the frame, mirrored from Rust so the
|
||
/// overlay draws exactly what the pipeline holds.
|
||
in-out property <float> crop-x: 0.0;
|
||
in-out property <float> crop-y: 0.0;
|
||
in-out property <float> crop-w: 1.0;
|
||
in-out property <float> crop-h: 1.0;
|
||
|
||
/// Enter a mode, or `photo` to leave whichever one is current.
|
||
callback mode-picked(ViewMode);
|
||
/// A dragged crop rect in fractions of the frame, then the corner the drag
|
||
/// is holding as a `0`/`1` pair — or `-1, -1` for a move, which keeps the
|
||
/// shape it has. See `CropOverlay` in crop.slint for why the corner has to
|
||
/// travel with the rect.
|
||
callback crop-changed(float, float, float, float, float, float);
|
||
|
||
// --- before and after (FR-DEV-7) ---
|
||
|
||
/// TRACES: FR-DEV-7
|
||
/// Whether the canvas is currently showing the unedited original.
|
||
///
|
||
/// Rust owns it, and the ownership is the point: this is *not* a history
|
||
/// position and must never become one. Held down, the canvas renders the
|
||
/// photograph with the adjustments taken off and the framing left on; let
|
||
/// go, the edit comes back. Nothing is recorded, so a photographer can
|
||
/// check whether they have overcooked a frame without paying two steps of
|
||
/// undo for the look.
|
||
in property <bool> showing-original: false;
|
||
/// The comparison is being held, or has been let go.
|
||
///
|
||
/// Both edges through one callback rather than a press and a release, so
|
||
/// the two cannot get out of step and strand the view on the original.
|
||
callback compare-original(bool);
|
||
|
||
// --- sampling a neutral off the photograph (FR-DEV-3) ---
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Shorthand for the condition the canvas tests, written once.
|
||
///
|
||
/// Which operation is armed lives in `Adjustments`, with the panel whose
|
||
/// headings arm it. It is held in the interface rather than in Rust,
|
||
/// unlike the view mode beside it, and the difference is real: entering a
|
||
/// mode has side effects on the session and this has none. Arming changes
|
||
/// what the next click on the photograph means and nothing else — no
|
||
/// parameter moves, no history step is taken, and the session has nothing
|
||
/// to hear about until a point is picked. It is one-shot: the click that
|
||
/// samples disarms it.
|
||
property <bool> sampling: Adjustments.sampling-op >= 0;
|
||
/// A neutral was picked, in fractions of the visible image.
|
||
///
|
||
/// The operation is not passed: which one a sampled colour belongs to is
|
||
/// settled by what the chain declared, and this file has no business
|
||
/// knowing. See `dr_pipeline::neutral`.
|
||
callback neutral-picked(float, float);
|
||
|
||
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
|
||
callback zoom-at(float, float, float);
|
||
callback pan-by(float, float);
|
||
/// TRACES: FR-UI-4
|
||
/// Toggle between fitting the frame and inspecting it at 1:1, anchored on
|
||
/// a point given in fractions of the visible image.
|
||
///
|
||
/// A negative coordinate means "wherever I was last looking" — the last
|
||
/// inspected point, or the centre of the frame if there has not been one.
|
||
/// A sentinel rather than a second callback, for the reason `swatch-hue`
|
||
/// is one: a control that has no point of its own still has to be able to
|
||
/// ask, and every value in `0..1` is a real place on the photograph.
|
||
///
|
||
/// Rust owns which of the two states it is, for the same reason it owns
|
||
/// the view mode: 1:1 is not a number this file can compute — it depends
|
||
/// on the file's own resolution against the viewport's — and the point is
|
||
/// remembered across photographs, which is state no screen holds.
|
||
callback inspect-toggled(float, float);
|
||
|
||
// --- active view (FR-UI-8) ---
|
||
//
|
||
// The four top-level screens share one property; see `View`. Rust is the
|
||
// only writer — nothing in this file assigns to it — so `in` is enough.
|
||
in property <View> active-view: View.develop;
|
||
|
||
// --- launch screen (FR-NC-1, FR-NC-4) ---
|
||
//
|
||
// The app opens here when no library is configured, and returns here to
|
||
// sign out or switch account.
|
||
in property <bool> launch-signed-in: false;
|
||
in property <string> launch-account: "";
|
||
in property <string> launch-root: "";
|
||
in property <bool> launch-endpoint-is-library: false;
|
||
in property <string> launch-server: "";
|
||
in property <string> launch-folder: "";
|
||
in property <bool> launch-busy: false;
|
||
in property <string> launch-status: "";
|
||
in property <string> launch-error: "";
|
||
in property <string> launch-login-url: "";
|
||
in property <bool> launch-can-remember: true;
|
||
in-out property <[string]> launch-format-labels;
|
||
in-out property <[bool]> launch-format-checked;
|
||
|
||
callback launch-sign-in(string);
|
||
/// The path of a folder library — no account, no credential.
|
||
callback launch-use-folder(string);
|
||
/// server, username, app password
|
||
callback launch-sign-in-direct(string, string, string);
|
||
callback launch-sign-out();
|
||
callback launch-choose-folder();
|
||
callback launch-open-library();
|
||
callback launch-format-toggled(int, bool);
|
||
callback launch-copy-url();
|
||
|
||
in property <bool> launch-browsing: false;
|
||
in property <string> launch-browse-path: "";
|
||
in property <bool> launch-browse-loading: false;
|
||
in-out property <[string]> launch-browse-entries;
|
||
|
||
callback launch-browse-into(string);
|
||
callback launch-browse-up();
|
||
callback launch-browse-confirm();
|
||
callback launch-browse-cancel();
|
||
|
||
// --- library grid (FR-CAT-4) ---
|
||
//
|
||
// Shown after a library is opened, before an image is chosen. Cells are a
|
||
// window over the catalog, not the whole of it.
|
||
/// Whether the grid is actually on screen — the condition the `if` below
|
||
/// is written with, hoisted so Rust can read the same answer.
|
||
///
|
||
/// **`active-view == View.library` is not enough on its own.** The view
|
||
/// stays `library` while Settings, Import, Merge or People replaces the
|
||
/// window as a page over it. The grid subtree is torn down in all four
|
||
/// cases, and a Flickable being destroyed passes its viewport through zero
|
||
/// on the way out — which reaches `on_library_scrolled` as a scroll to the
|
||
/// top of the library and overwrote the position the user was at. That is
|
||
/// the "I open Settings and come back to the beginning" report.
|
||
///
|
||
/// So the guard has to be this, and the `if` has to be spelled from it, or
|
||
/// the two can drift apart again.
|
||
out property <bool> library-visible:
|
||
root.active-page == Page.none && root.active-view == View.library;
|
||
// --- background activity (FR-CAT-1, FR-NC-6c) ---
|
||
//
|
||
// Every background job in one register, filled by `activity.rs`. The
|
||
// aggregate drives the bar across the top of the shell; the rows are the
|
||
// list on the settings page, where a stalled transfer can be identified
|
||
// rather than merely felt.
|
||
in property <[ActivityRow]> activity-rows;
|
||
/// Anything at all running. Drives the bar's visibility, so it is a
|
||
/// property rather than `activity-rows.length > 0` — finished rows stay in
|
||
/// the list for a while and must not keep the bar alight.
|
||
in property <bool> activity-busy: false;
|
||
/// Whether the running jobs, taken together, have a denominator. A scan
|
||
/// alone does not; a scan beside a download does.
|
||
in property <bool> activity-determinate: false;
|
||
in property <float> activity-fraction: 0;
|
||
in property <int> activity-running: 0;
|
||
/// Jobs that stopped and are being kept: finished ones as a short history,
|
||
/// failed ones until they are cleared.
|
||
in property <int> activity-kept: 0;
|
||
callback activity-clear-finished();
|
||
|
||
in property <bool> library-scanning: false;
|
||
/// The catalog on this device is being opened and checked, on a worker,
|
||
/// before the scan starts (`library_ui::open_catalog_soon`).
|
||
///
|
||
/// Separate from `library-scanning` because they are different answers to
|
||
/// the same question: this one is reading a file that is already here, and
|
||
/// that one is walking a tree over the network. Both are true at once for
|
||
/// the first moment of a launch, and only the more specific of them is
|
||
/// worth putting on an empty grid.
|
||
in property <bool> library-opening: false;
|
||
in property <string> library-status: "";
|
||
in property <string> library-error: "";
|
||
|
||
// --- offline mode (FR-CAT-9) ---
|
||
//
|
||
// The library keeps working from local data when the server cannot be
|
||
// reached: thumbnails come from the shards, and rating and sorting are
|
||
// catalog operations that never needed the network. What stops is opening
|
||
// an original that was never stored locally.
|
||
//
|
||
// `offline-reason` carries the transport's own message ("connection
|
||
// refused", "dns error") because it is usually specific enough to act on,
|
||
// where a bare "offline" leaves the user guessing whether it is their wifi
|
||
// or the server.
|
||
in property <bool> library-offline: false;
|
||
in property <string> library-offline-reason: "";
|
||
in property <string> library-offline-since: "";
|
||
/// Whether the grid is narrowed to the timeline's visible span.
|
||
in-out property <bool> library-range-active: false;
|
||
callback library-toggle-date-range();
|
||
/// TRACES: FR-CAT-6
|
||
in property <string> library-range-from;
|
||
in property <string> library-range-to;
|
||
in property <bool> library-range-invalid: false;
|
||
callback library-range-edited(string, string);
|
||
/// The same range as a band on the capture-time axis, and the ends of it
|
||
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
|
||
/// conversion both ways, so the band lands where the finger left it.
|
||
in property <float> library-range-from-fraction: -1;
|
||
in property <float> library-range-to-fraction: -1;
|
||
callback library-timeline-range-changed(float, float);
|
||
callback library-retry-connection();
|
||
|
||
// --- pinning a collection offline (FR-NC-6a) ---
|
||
in property <bool> library-scope-pinned: false;
|
||
in property <int> library-pin-done: 0;
|
||
in property <int> library-pin-total: 0;
|
||
callback library-toggle-pin-scope();
|
||
|
||
// The question a held collection asks: keep these on the device, or give
|
||
// the disk back. Driven entirely from Rust — the counts and the byte sizes
|
||
// in the labels are catalog reads, and an empty `title` is what closes it.
|
||
in property <string> offline-prompt-title: "";
|
||
in property <string> offline-prompt-detail: "";
|
||
in property <string> offline-prompt-keep-label: "";
|
||
in property <string> offline-prompt-release-label: "";
|
||
in property <bool> offline-prompt-can-keep: false;
|
||
in property <bool> offline-prompt-can-release: false;
|
||
in property <bool> offline-prompt-busy: false;
|
||
callback offline-prompt-keep();
|
||
callback offline-prompt-release();
|
||
callback offline-prompt-dismiss();
|
||
|
||
// The question a damaged catalog asks. Same shape as the prompt above and
|
||
// for the same reason: an empty title is what closes it, and every word in
|
||
// it is composed in Rust, which is the only side that knows what SQLite
|
||
// said and which backups exist.
|
||
in property <string> recovery-title: "";
|
||
in property <string> recovery-detail: "";
|
||
in property <string> recovery-diagnosis: "";
|
||
in property <string> recovery-restore-label: "";
|
||
in property <bool> recovery-can-restore: false;
|
||
in property <string> recovery-rebuild-label: "";
|
||
in property <bool> recovery-busy: false;
|
||
callback recovery-restore();
|
||
callback recovery-rebuild();
|
||
callback recovery-dismiss();
|
||
|
||
in property <string> library-root-label: "";
|
||
in-out property <[TimelineBar]> library-timeline;
|
||
in property <string> library-timeline-label: "";
|
||
in property <string> library-window-label: "";
|
||
in property <int> library-offset: 0;
|
||
|
||
in property <int> library-sweep-done: 0;
|
||
in property <int> library-sweep-total: 0;
|
||
|
||
in property <int> library-current-bucket: 0;
|
||
in property <float> library-current-fraction: -1;
|
||
in property <bool> library-timeline-anchored: false;
|
||
|
||
callback library-scrub-fraction(float);
|
||
callback library-timeline-pinch(float);
|
||
callback library-timeline-pan(float);
|
||
callback library-timeline-zoom(int);
|
||
in property <bool> library-syncing: false;
|
||
/// TRACES: FR-CAT-3 | FR-NC-3
|
||
/// The whole-library thumbnail pass, launched from the settings page.
|
||
/// `library-open` gates the button on there being a library to sweep —
|
||
/// the page is reachable from the launch screen too.
|
||
in property <bool> library-thumbnailing: false;
|
||
in property <bool> library-open: false;
|
||
|
||
// --- people and faces (FR-CULL-8 … FR-CULL-12) ---
|
||
//
|
||
// A third top-level screen rather than a panel: naming a cluster and
|
||
// pulling a stranger out of it are tasks with their own rhythm, and they
|
||
// need the whole window. Gated the same way the other screens are, so
|
||
// exactly one is ever up — see `View`.
|
||
in property <[IdentityPerson]> identity-people;
|
||
in property <[IdentityFace]> identity-faces;
|
||
in property <int> identity-selected: -1;
|
||
in property <string> identity-selected-name;
|
||
in property <int> identity-unassigned: 0;
|
||
in property <bool> identity-calibrated: false;
|
||
in property <bool> identity-indexing: false;
|
||
in property <string> identity-indexing-status;
|
||
in property <bool> identity-model-missing: false;
|
||
in property <string> identity-back-label: "‹ Library";
|
||
in property <string> identity-coverage;
|
||
in property <bool> identity-coverage-complete: false;
|
||
in property <bool> identity-coverage-read-only: false;
|
||
in property <int> identity-picked: 0;
|
||
/// The namesake merge the Identity screen is offering. See IdentityScreen.
|
||
in property <string> identity-merge-offer-name;
|
||
in property <int> identity-merge-offer-faces: 0;
|
||
/// See IdentityScreen.name-revision.
|
||
in property <int> identity-name-revision: 0;
|
||
|
||
callback identity-open();
|
||
callback identity-close();
|
||
callback identity-person-picked(int);
|
||
callback identity-rename(string);
|
||
callback identity-merge-accept();
|
||
callback identity-merge-decline();
|
||
callback identity-name-edited(string);
|
||
callback identity-confirm-face(int);
|
||
callback identity-reject-face(int);
|
||
callback identity-toggle-pick(int);
|
||
callback identity-confirm-all();
|
||
callback identity-split-picked();
|
||
callback identity-recluster();
|
||
in property <bool> identity-regrouping: false;
|
||
in property <string> identity-regroup-status;
|
||
// The grouping dials Regroup turns. Percent and a face count, both stored
|
||
// in the device settings file beside the cache budgets.
|
||
in property <float> identity-merge-probability: 80;
|
||
in property <int> identity-min-group-size: 2;
|
||
callback identity-merge-probability-changed(float);
|
||
callback identity-min-group-size-changed(int);
|
||
callback identity-preview-grouping();
|
||
in property <bool> identity-previewing: false;
|
||
in property <string> identity-grouping-preview;
|
||
in property <int> identity-ignored-count: 0;
|
||
in property <bool> identity-show-ignored: false;
|
||
in property <bool> identity-selected-ignored: false;
|
||
callback identity-toggle-show-ignored();
|
||
callback identity-ignore-person(int, bool);
|
||
callback identity-show-photos(int, bool);
|
||
callback identity-index();
|
||
callback identity-reindex();
|
||
callback identity-stop-indexing();
|
||
callback identity-check-coverage();
|
||
callback identity-delete-all();
|
||
callback library-thumbnail-all();
|
||
/// Whether the photo roll is out. Owned here rather than by the roll, so
|
||
/// it survives the develop view being torn down and rebuilt.
|
||
in-out property <bool> roll-open: false;
|
||
callback library-sync-now();
|
||
callback library-rescan();
|
||
/// Grid → launch screen, to pick a different folder or account.
|
||
callback library-change();
|
||
/// Develop → grid.
|
||
callback back-to-library();
|
||
|
||
// --- collections (FR-CAT-7) ---
|
||
//
|
||
// State that belongs to the sidebar itself — the tree, the row menu,
|
||
// drag and drop between rows, the trash row — has moved to the
|
||
// `Collections` global in `collections.slint`. What is left here is
|
||
// shared with the library grid, whose Rust wiring still reaches it on
|
||
// the window.
|
||
/// Which collection scopes the grid. 0 is the whole library.
|
||
in property <int> collection-selected: 0;
|
||
/// Whether the sidebar is shown at all. Closed by default in the compact
|
||
/// layout, where 232px of the window is most of the photograph (FR-UI-1),
|
||
/// and openable from the grid header either way — see `panel-visible` for
|
||
/// how a default and a user's answer to it are kept apart. Owned by
|
||
/// `run()`'s panel-layout wiring rather than the sidebar's own global,
|
||
/// because it seeds from the saved layout before the sidebar exists.
|
||
in property <bool> collections-visible: true;
|
||
callback collection-select(int);
|
||
/// TRACES: FR-NC-6a
|
||
/// Ask about a collection's local copies: the tray on a row, or a long
|
||
/// press anywhere on it.
|
||
callback collection-offline-menu(int);
|
||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||
/// Keywording the grid's selection. The catalog has been searchable by
|
||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||
///
|
||
/// The vocabulary arrives already answered against the selection — each row
|
||
/// says how many of the selected photographs carry that word — because only
|
||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||
/// the selection as a second model that could disagree with the first.
|
||
in property <[KeywordRow]> library-keywords;
|
||
/// The sheet is opening: recompute the rows against the selection as it
|
||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||
/// every arrow key and the sheet is shut for almost all of them.
|
||
callback library-keywords-opened();
|
||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||
/// word typed into the field and a word tapped in the list are one path.
|
||
callback library-assign-keyword(string);
|
||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||
callback library-unassign-keyword(string);
|
||
|
||
|
||
|
||
|
||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
|
||
//
|
||
// Stars and pick/reject, set from the grid and persisted to the catalog
|
||
// and the sidecar. Every image enters unrated, which is a state of its
|
||
// own rather than a zero score.
|
||
/// A star was clicked on a cell: row, then the rating 0..5.
|
||
callback library-cell-rated(int, int);
|
||
/// The trash target was clicked on one cell, by row.
|
||
callback library-cell-trashed(int);
|
||
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
|
||
callback library-burst-toggled(int);
|
||
/// One frame of an open burst was named as the one the group folds to, by
|
||
/// row (FR-CULL-5).
|
||
callback library-burst-representative-chosen(int);
|
||
/// Move the grid selection to the trash — the `Delete` key.
|
||
callback library-trash-selection();
|
||
/// A judgement key was pressed, applying to the whole selection. One of
|
||
/// the two arguments is -1, saying which axis was *not* meant.
|
||
callback library-judged(int, int);
|
||
|
||
/// Whose photographs the grid is narrowed to, one entry each.
|
||
in property <[PersonChip]> library-filter-people;
|
||
/// Whether those people are an intersection rather than a union.
|
||
in property <bool> library-filter-people-all: false;
|
||
callback library-filter-person-cleared(int);
|
||
callback library-filter-people-mode-toggled();
|
||
/// TRACES: FR-CULL-8a | FR-CULL-13
|
||
/// Whether the grid is narrowed to photographs with nobody blinking.
|
||
in property <bool> library-filter-eyes-open: false;
|
||
callback library-filter-eyes-open-toggled();
|
||
/// Everyone the library knows, for the filter bar's people tray.
|
||
/// TRACES: FR-UI-4
|
||
/// The gesture reference's rows, read from the generated table.
|
||
in property <[GestureRow]> library-gestures;
|
||
in property <[PersonChip]> library-people;
|
||
callback library-people-listed();
|
||
callback library-filter-person-toggled(int);
|
||
in property <int> library-filter-min-rating: 0;
|
||
in property <bool> library-filter-unjudged: false;
|
||
in property <int> library-filter-flag: 0;
|
||
/// Images per star count, index 0 unrated, for the filter chips.
|
||
in-out property <[int]> library-rating-counts;
|
||
|
||
callback library-filter-min-rating-changed(int);
|
||
callback library-filter-unjudged-changed(bool);
|
||
callback library-filter-flag-changed(int);
|
||
|
||
callback next-image();
|
||
callback prev-image();
|
||
|
||
// --- copying settings between photographs (FR-DEV-6) ---
|
||
//
|
||
// The clipboard is a window-lifetime thing rather than a view's, which is
|
||
// why the grid's half of it lives here and not inside the develop column:
|
||
// a copy is taken in develop and may be pasted onto a selection back in
|
||
// the library grid. The develop half is `Transfer`, in `adjust.slint`.
|
||
/// Apply the clipboard to every selected image in the grid.
|
||
callback paste-settings-to-selection();
|
||
|
||
// --- named presets (FR-DEV-6) ---
|
||
//
|
||
// The saved half of the same requirement. On the window rather than in a
|
||
// view for the reason the clipboard is: a preset is saved in develop,
|
||
// where there is an edit to capture, and applied most often in the grid,
|
||
// where there is a selection to apply it to.
|
||
in-out property <bool> presets-open: false;
|
||
in property <[string]> preset-names;
|
||
/// Whether there is an edit in hand to save, set by whichever view opened
|
||
/// the sheet. `in-out` because that is where the answer is known.
|
||
in-out property <bool> preset-can-save: false;
|
||
/// What applying would act on: 0 is the open photograph, higher is that
|
||
/// many selected. Set at open, and read back by Rust when one is picked —
|
||
/// so the action matches the count the user read on the way in.
|
||
in-out property <int> preset-apply-count: 0;
|
||
/// What saving would capture, from the routine the clipboard summary uses.
|
||
in property <string> preset-capture-summary;
|
||
/// Why the last name was refused, cleared by the next keystroke.
|
||
in property <string> preset-name-error;
|
||
callback save-preset(string);
|
||
callback apply-preset(string);
|
||
callback rename-preset(string, string);
|
||
callback delete-preset(string);
|
||
callback preset-name-edited(string);
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Which kinds of edit a copy, paste or preset carries.
|
||
///
|
||
/// One set for all three, on the window because all three read it: the
|
||
/// clipboard is pasted from develop and from the grid, and a preset is
|
||
/// applied from both. A per-action set would be three answers to one
|
||
/// question about how this photographer works.
|
||
in property <[ScopeKind]> copy-scope-kinds;
|
||
in property <bool> copy-scope-empty: false;
|
||
callback copy-scope-toggled(string);
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Reading Lightroom presets in, and what came of the last attempt.
|
||
in property <string> preset-import-report;
|
||
callback import-presets(string);
|
||
|
||
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
||
//
|
||
// A page rather than an overlay, and outermost among the pages drawn over
|
||
// a view: it is reachable from the library and from develop, so guarding
|
||
// it with which view was current would make which one you came from
|
||
// decide whether it appears. See `Page`.
|
||
//
|
||
// Every control saves on change (see `settings_ui.rs`), so there is no
|
||
// dirty state here and nothing to confirm on the way out.
|
||
in-out property <Page> active-page: Page.none;
|
||
|
||
// --- import (FR-CAT-10, FR-CAT-11, FR-NC-7a) -------------------------
|
||
//
|
||
// A page like Settings, and outermost with it: an import is reachable from
|
||
// the library, and it writes into the library, so it must not be nested
|
||
// inside the view it is about to change under.
|
||
//
|
||
// Unlike Settings, this page holds a *decision* rather than a set of saved
|
||
// preferences, which is why it carries `import-can-start` and a running
|
||
// state: everything here exists to be committed or abandoned.
|
||
/// Whether this platform can import at all — see `dr_plat::imports_supported`.
|
||
in property <bool> import-supported: false;
|
||
|
||
in property <[string]> import-volume-labels;
|
||
in property <[string]> import-volume-details;
|
||
in property <int> import-volume-selected: -1;
|
||
in property <string> import-card-path: "";
|
||
in property <bool> import-card-looks-right: false;
|
||
in property <bool> import-surveying: false;
|
||
in property <string> import-survey-summary: "";
|
||
in property <string> import-upload-target: "";
|
||
in property <string> import-folder-template: "";
|
||
in property <string> import-template-preview: "";
|
||
in property <[string]> import-mode-labels;
|
||
in property <int> import-mode-selected: 0;
|
||
in property <bool> import-mode-is-move: false;
|
||
in property <[string]> import-duplicate-labels;
|
||
in property <int> import-duplicate-selected: 0;
|
||
in property <bool> import-verify: true;
|
||
in property <bool> import-backup-on: false;
|
||
in property <string> import-backup-path: "";
|
||
in property <bool> import-running: false;
|
||
in property <float> import-fraction: 0.0;
|
||
in property <string> import-status: "";
|
||
in property <string> import-error: "";
|
||
in property <bool> import-can-start: false;
|
||
|
||
// TRACES: FR-MRG-1 | FR-MRG-7
|
||
// The merge page, on the import page's model: a decision to be
|
||
// committed or abandoned, with a running state, over the library.
|
||
in property <string> merge-stage: "";
|
||
in property <bool> merge-running: false;
|
||
in property <float> merge-fraction: 0.0;
|
||
in property <string> merge-status: "";
|
||
in property <string> merge-error: "";
|
||
in property <bool> merge-aligned: false;
|
||
in property <bool> merge-complete: false;
|
||
in property <string> merge-summary: "";
|
||
in property <[MergeFrameRow]> merge-frames;
|
||
in property <image> merge-preview;
|
||
in property <int> merge-projection-selected: 0;
|
||
in property <int> merge-border-selected: 0;
|
||
in property <bool> merge-fill-available: false;
|
||
in property <string> merge-fill-note: "";
|
||
in property <bool> merge-preview-filled: false;
|
||
// The fill's knobs (experimental; FR-MRG-4), in working pixels.
|
||
in property <float> merge-fill-scale: 2;
|
||
in property <float> merge-fill-erosion: 4;
|
||
in property <float> merge-fill-coarse: 4;
|
||
in property <float> merge-fill-band: 96;
|
||
in property <float> merge-fill-mirror: 48;
|
||
in property <float> merge-fill-feather: 48;
|
||
in property <bool> merge-done: false;
|
||
/// Merge the grid's selection: opens the page and starts the alignment.
|
||
callback library-merge-selection();
|
||
/// The confirmation (FR-MRG-1). Nothing is written before this.
|
||
callback merge-confirm();
|
||
callback merge-abandon();
|
||
callback merge-projection-picked(int);
|
||
callback merge-border-picked(int);
|
||
/// A fill knob turned: its name and the new value.
|
||
callback merge-fill-knob(string, float);
|
||
callback merge-close();
|
||
|
||
callback import-open();
|
||
callback import-close();
|
||
callback import-volume-picked(int);
|
||
callback import-card-path-changed(string);
|
||
callback import-refresh-volumes();
|
||
callback import-template-changed(string);
|
||
callback import-mode-picked(int);
|
||
callback import-duplicate-picked(int);
|
||
callback import-verify-toggled(bool);
|
||
callback import-backup-toggled(bool);
|
||
callback import-backup-path-changed(string);
|
||
callback import-start();
|
||
callback import-stop();
|
||
|
||
in property <string> settings-original-budget: "";
|
||
in property <bool> settings-original-unlimited: false;
|
||
in property <string> settings-thumbnail-budget: "";
|
||
in property <bool> settings-thumbnail-unlimited: false;
|
||
in property <bool> settings-keep-opened: true;
|
||
in property <[string]> settings-fetch-ahead-labels;
|
||
in property <int> settings-fetch-ahead-selected: 0;
|
||
callback settings-fetch-ahead-picked(int);
|
||
in property <string> settings-cache-usage: "";
|
||
|
||
callback settings-original-budget-changed(string);
|
||
callback settings-original-unlimited-toggled(bool);
|
||
callback settings-thumbnail-budget-changed(string);
|
||
callback settings-thumbnail-unlimited-toggled(bool);
|
||
callback settings-keep-opened-toggled(bool);
|
||
/// TRACES: FR-CAT-6
|
||
in property <[string]> settings-timeline-bar-labels;
|
||
in property <int> settings-timeline-bars-selected: 0;
|
||
callback settings-timeline-bars-picked(int);
|
||
/// TRACES: FR-CAT-13
|
||
in property <bool> settings-write-xmp: false;
|
||
callback settings-write-xmp-toggled(bool);
|
||
in property <int> settings-xmp-conflicts: 0;
|
||
callback settings-xmp-reload();
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// The group-navigation preference, and what "Automatic" resolves to here.
|
||
in property <[string]> settings-group-nav-labels;
|
||
in property <int> settings-group-nav-selected: 0;
|
||
in property <string> settings-group-nav-auto-says;
|
||
in property <[string]> settings-format-labels;
|
||
in property <int> settings-format-selected: 0;
|
||
in property <int> settings-quality: 90;
|
||
in property <bool> settings-quality-enabled: true;
|
||
in property <[string]> settings-colour-labels;
|
||
in property <int> settings-colour-selected: 0;
|
||
in property <[string]> settings-sizing-labels;
|
||
in property <int> settings-sizing-selected: 0;
|
||
in property <int> settings-sizing-value: 0;
|
||
in property <bool> settings-sizing-has-value: false;
|
||
in property <string> settings-sizing-unit: "px";
|
||
in property <string> settings-sizing-value-label: "Size value";
|
||
in property <int> settings-sizing-height: 0;
|
||
in property <bool> settings-sizing-has-height: false;
|
||
in property <[string]> settings-screen-labels;
|
||
in property <int> settings-screen-selected: -1;
|
||
in property <bool> settings-allow-upscaling: false;
|
||
in property <[string]> settings-sharpening-labels;
|
||
in property <int> settings-sharpening-selected: 0;
|
||
in property <string> settings-filename-template: "";
|
||
in property <[string]> settings-collision-labels;
|
||
in property <int> settings-collision-selected: 0;
|
||
in property <bool> settings-strip-location: true;
|
||
in property <string> settings-destination: "";
|
||
in property <string> settings-destination-hint;
|
||
in property <bool> settings-browse-open: false;
|
||
in property <string> settings-browse-path;
|
||
in property <[string]> settings-browse-entries;
|
||
in property <bool> settings-browse-loading: false;
|
||
in property <bool> settings-browse-at-root: true;
|
||
in property <bool> settings-browse-available: false;
|
||
in property <[string]> settings-target-labels;
|
||
in property <int> settings-target-selected: 0;
|
||
in property <string> settings-error: "";
|
||
|
||
/// TRACES: FR-CULL-8
|
||
/// Which face detector the indexing pass runs.
|
||
in property <[string]> settings-face-detector-labels;
|
||
in property <int> settings-face-detector-selected: 0;
|
||
callback settings-face-detector-picked(int);
|
||
|
||
callback settings-group-nav-picked(int);
|
||
callback settings-format-picked(int);
|
||
callback settings-quality-changed(int);
|
||
callback settings-colour-picked(int);
|
||
callback settings-sizing-picked(int);
|
||
callback settings-sizing-value-changed(string);
|
||
callback settings-sizing-height-changed(string);
|
||
callback settings-screen-picked(int);
|
||
callback settings-upscaling-toggled(bool);
|
||
callback settings-sharpening-picked(int);
|
||
callback settings-template-changed(string);
|
||
callback settings-collision-picked(int);
|
||
callback settings-strip-location-toggled(bool);
|
||
callback settings-destination-changed(string);
|
||
callback settings-target-changed(int);
|
||
callback settings-browse-open-picker();
|
||
callback settings-browse-into(string);
|
||
callback settings-browse-up();
|
||
callback settings-browse-confirm();
|
||
callback settings-browse-cancel();
|
||
callback settings-reset();
|
||
|
||
/// Show the settings page. Reads the file first, so a second instance's
|
||
/// writes are picked up rather than overwritten.
|
||
callback settings-open();
|
||
callback settings-close();
|
||
|
||
// FR-UI-1: layout class follows window width, not device type. A narrow
|
||
// desktop window gets the compact layout, exactly as a tablet would.
|
||
//
|
||
// Set from Rust rather than derived from `root.width` here: a property
|
||
// read inside the layout and also feeding it creates a binding loop,
|
||
// which Slint warns about and which can panic at runtime.
|
||
//
|
||
// Nothing below reads `expanded` directly any more. It decides what the
|
||
// two collapsible columns *default* to, and Rust applies that to
|
||
// `panel-visible` and `collections-visible`; binding a column's width to
|
||
// the class itself would mean a panel the user opened snapped shut again
|
||
// on the next resize.
|
||
in property <bool> expanded: true;
|
||
in property <string> layout-class: "expanded";
|
||
|
||
/// TRACES: FR-UI-2
|
||
/// The most the develop column may take, in logical pixels.
|
||
///
|
||
/// Set from Rust beside `layout-class` and for the same reason: the column
|
||
/// sizes itself to its contents, and a ceiling read from `root.width`
|
||
/// inside the layout that `root.width` depends on is the binding loop the
|
||
/// note above describes.
|
||
///
|
||
/// A ceiling and not a width. The column asks for what it needs and this
|
||
/// only says when the answer has stopped being reasonable — a photograph
|
||
/// with a third of a window left to show itself in is not a develop view.
|
||
/// The default is generous because the first frame is drawn before any
|
||
/// resize has been reported, and a column briefly too wide is a far
|
||
/// smaller fault than one briefly clipped.
|
||
in property <length> panel-max-width: 520px;
|
||
|
||
/// TRACES: FR-UI-1
|
||
/// Whether the develop column sits under the photograph rather than beside
|
||
/// it (D-N7).
|
||
///
|
||
/// **Not a layout class.** The class is decided by the window's width and
|
||
/// says how much room there is; this is decided by its aspect and says
|
||
/// which way round that room is, so a 960-wide portrait window is
|
||
/// `expanded` *and* docked while a 1500-wide landscape one is `expanded`
|
||
/// and not. Nothing here reads it but the develop view's frame, and
|
||
/// nothing remembers it: closing the column in landscape closes the dock
|
||
/// in portrait, because it is the same column.
|
||
///
|
||
/// From Rust for the reason `panel-max-width` above is — the frame reads
|
||
/// it to lay itself out, so deriving it from `root.width` in the frame
|
||
/// would be a property that both feeds and follows the layout.
|
||
in property <bool> column-below: false;
|
||
|
||
// --- local adjustments (FR-DEV-3) ---------------------------------------
|
||
|
||
/// The false-coloured region map, drawn over the photograph.
|
||
///
|
||
/// An empty image when there is nothing to draw, which is also how the
|
||
/// overlay is hidden — one source of truth rather than a flag that can
|
||
/// disagree with the picture.
|
||
in property <image> region-overlay;
|
||
/// How strongly the overlay is composited. Not 1.0: the point of looking
|
||
/// at it is to judge whether a boundary follows the *photograph*, and an
|
||
/// opaque map hides the thing being judged.
|
||
in property <float> overlay-strength: 0.55;
|
||
in property <bool> overlay-on: false;
|
||
/// Which part of the source-space overlay the view is showing, in overlay
|
||
/// pixels. Without it the overlay stays frame-sized while the photograph
|
||
/// moves under it.
|
||
in property <int> overlay-clip-x;
|
||
in property <int> overlay-clip-y;
|
||
in property <int> overlay-clip-w;
|
||
in property <int> overlay-clip-h;
|
||
/// The straightening angle, so the overlay turns with the frame.
|
||
in property <float> overlay-angle: 0.0;
|
||
|
||
/// Whether a click on the canvas selects a region instead of panning.
|
||
///
|
||
/// **Derived, not toggled.** It used to be a button in the masking panel,
|
||
/// which meant local masking could be open with picking off — a mode
|
||
/// entered and doing nothing. Arming a click is part of what local mode
|
||
/// *is*, so it follows the mode and the existence of something to pick.
|
||
/// TRACES: FR-DEV-19b
|
||
/// A stroke, in fractions of the photograph as it is currently shown.
|
||
///
|
||
/// These stay on the window rather than moving into `Masking` with the
|
||
/// rest of the mask controls: a stroke is a gesture on the *canvas*, and
|
||
/// the canvas is this file's. What went into the global is the panel's
|
||
/// state — which tool is armed, how big the brush is, what parts a mask
|
||
/// has — because that is what a second copy of the column needs its own
|
||
/// binding to.
|
||
callback paint-began(float, float);
|
||
callback paint-moved(float, float);
|
||
callback paint-ended();
|
||
|
||
// Clicking to select a subject and dragging to paint are the same press,
|
||
// so exactly one of them is armed at a time. Without this the first dab of
|
||
// every stroke would also re-select whatever was under it, and the mask
|
||
// being painted would be replaced by the one the click made.
|
||
property <bool> region-picking: Develop.local-mode && Masking.segmented && Masking.tool == 0;
|
||
property <bool> painting: Develop.local-mode && Masking.tool > 0;
|
||
|
||
/// Whether shift is down, tracked by the develop key scope below.
|
||
///
|
||
/// A `TouchArea`'s `clicked` carries no modifiers, so the state has to be
|
||
/// held between the key event and the click. Private to the window because
|
||
/// nothing outside it has any business setting it.
|
||
property <bool> shift-held: false;
|
||
|
||
/// The selected gradient's handles, in fractions of the shown image.
|
||
/// Empty unless a gradient layer is selected in local mode.
|
||
in property <[GradientHandle]> gradient-handles;
|
||
/// A handle dragged: which one, where the press was and where the pointer
|
||
/// is now, both in fractions of the shown image.
|
||
///
|
||
/// The press is carried rather than a running delta because the geometry
|
||
/// is re-derived from the position it had when the drag began — which is
|
||
/// what stops a drag accumulating rounding error along its length, the
|
||
/// same reason the crop handles capture the rect they started from.
|
||
callback gradient-handle-dragged(HandleRole, float, float, float, float);
|
||
/// A drag finished, so the edit can be recorded as one history step
|
||
/// rather than as one per frame of the gesture.
|
||
callback gradient-handle-released();
|
||
|
||
// --- repairs (FR-DEV-8) -------------------------------------------------
|
||
|
||
/// Every repair on the photograph, as circles in fractions of the shown
|
||
/// image. Rust maps them through the framing, so they follow a crop, a
|
||
/// zoom, a pan and a rotation without anything here knowing about any of
|
||
/// those.
|
||
in property <[SpotHandle]> spot-handles;
|
||
/// A click on the photograph in repair mode, in fractions of the shown
|
||
/// image: cover what is here.
|
||
callback spot-placed(float, float);
|
||
/// A repair's circle dragged: which repair, which half of it, where the
|
||
/// press landed and where the pointer is now.
|
||
///
|
||
/// The press is carried rather than a running delta for the reason the
|
||
/// gradient handles carry it — the geometry is re-derived from where it
|
||
/// stood when the gesture began, so a drag cannot accumulate rounding
|
||
/// error along its length.
|
||
callback spot-handle-dragged(string, SpotRole, float, float, float, float);
|
||
/// A drag finished, so it can be one history step rather than one a frame.
|
||
callback spot-handle-released();
|
||
/// A repair chosen, so the column describes it.
|
||
callback spot-selected(string);
|
||
/// A click on the photograph, in fractions of the shown image, plus
|
||
/// whether it should extend the selection rather than replace it.
|
||
callback region-picked(float, float, bool);
|
||
|
||
/// Whether the develop column — image info, geometry, adjust — is shown.
|
||
///
|
||
/// Distinct from `expanded`, which is the *layout class* and only supplies
|
||
/// this its default: a panel the user closed on a wide window stays closed,
|
||
/// and one they opened on a narrow one stays open. Rust owns both for the
|
||
/// reason given above — a property read inside the layout and also feeding
|
||
/// it is a binding loop.
|
||
in property <bool> panel-visible: true;
|
||
/// The export button's label and busy state, and the line it reports to.
|
||
/// Rust owns all three: what an export is called depends on the format and
|
||
/// on whether the destination is this device or the server, neither of
|
||
/// which the toolbar can see.
|
||
in property <string> export-label: "Export";
|
||
in property <bool> export-busy: false;
|
||
in property <string> export-status;
|
||
/// TRACES: FR-EXP-7
|
||
/// Whether an export would be queued for the server rather than written to
|
||
/// this device. The grid says the same thing as the develop button, but it
|
||
/// composes its own label around a selection count — so it is given the
|
||
/// fact and not the sentence.
|
||
in property <bool> export-to-server: false;
|
||
/// Whether a batch is running. The grid's export button becomes its cancel
|
||
/// button; there is nowhere else a long batch can be stopped from.
|
||
in property <bool> library-exporting: false;
|
||
/// Export the grid's selection in the background (FR-EXP-7).
|
||
callback library-export-selection();
|
||
/// TRACES: NFR-ARCH-3
|
||
callback library-cancel-export();
|
||
/// Show or hide the develop column.
|
||
callback toggle-panel();
|
||
/// Export the image on screen, using the settings as they stand.
|
||
callback export-image();
|
||
/// Show or hide the collections sidebar.
|
||
callback toggle-collections();
|
||
|
||
/// Android's back gesture, or Escape. Returns whether there was anywhere to
|
||
/// go: `false` means this is the top of the stack, and the platform is left
|
||
/// to close the application as it normally would.
|
||
callback back-requested() -> bool;
|
||
|
||
callback canvas-resized(int, int);
|
||
callback window-resized(length, length);
|
||
|
||
/// The width the interface actually has to lay out in.
|
||
///
|
||
/// Not the window's: on Android the window spans the display and the
|
||
/// left/right insets are given over to system furniture, so choosing the
|
||
/// layout class from `width` would promise the layout space it cannot use.
|
||
property <length> shell-width:
|
||
root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||
|
||
/// The height it has, for the same reason and with the same caveat.
|
||
///
|
||
/// Reported alongside the width because the develop view's frame is
|
||
/// decided by the *aspect* of what the interface actually gets, not of the
|
||
/// window (D-N7) — and on Android those differ by the height of the status
|
||
/// and navigation bars, which is exactly the axis being measured.
|
||
property <length> shell-height:
|
||
root.height - root.safe-area-insets.top - root.safe-area-insets.bottom;
|
||
|
||
// One-way: report the size outward, never read layout back into it. Two
|
||
// handlers because Slint has no single "geometry changed" hook — the same
|
||
// shape as `canvas-resized`'s px-w/px-h below.
|
||
changed shell-width => {
|
||
root.window-resized(self.shell-width, self.shell-height);
|
||
}
|
||
changed shell-height => {
|
||
root.window-resized(self.shell-width, self.shell-height);
|
||
}
|
||
|
||
// Everything the interface draws lives inside this, for two reasons that
|
||
// happen to want the same element.
|
||
//
|
||
// **The safe area.** From target SDK 35 Android draws applications behind
|
||
// the status and navigation bars and offers no way out, so the window's
|
||
// own coordinates include the strips those bars occupy — which is why the
|
||
// develop status strip came out underneath the clock and the wifi icon.
|
||
// `safe-area-insets` is what the platform reports as covered; the shell is
|
||
// placed inside it and only the window background paints behind the bars.
|
||
// Every inset is zero on the desktop, so nothing about that layout moves.
|
||
//
|
||
// **The back gesture.** Android's back button and back swipe arrive as
|
||
// `Key.Back`, delivered to whatever holds focus and then bubbled up its
|
||
// ancestors — so the one handler that answers for the whole application
|
||
// has to be above all four views rather than beside them. Accepting the
|
||
// key tells the platform we consumed it; rejecting it lets Android do what
|
||
// it does by default and close the activity, which is exactly what should
|
||
// happen at the top of the stack.
|
||
shell := FocusScope {
|
||
x: root.safe-area-insets.left;
|
||
y: root.safe-area-insets.top;
|
||
width: root.shell-width;
|
||
height: root.shell-height;
|
||
|
||
// Deliberately does *not* focus itself.
|
||
//
|
||
// A key needs a focus item to bubble from, so each of the four views
|
||
// below focuses a scope of its own on show — the grid's judgement keys,
|
||
// develop's arrow keys, and a zero-sized holder on the two form pages
|
||
// that would otherwise focus nothing. Claiming focus here as a fallback
|
||
// would race with those: Slint does not promise whether a parent's
|
||
// `init` runs before or after its children's, so on the wrong ordering
|
||
// this would take focus back off the grid and silently break culling.
|
||
|
||
key-pressed(event) => {
|
||
// Escape alongside Back: the same "go up one" on a keyboard, and
|
||
// the inner scopes that use it for something more specific — a
|
||
// rename in the sidebar — see it first and accept it there.
|
||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||
// A dialogue takes "go up one" first, and it is the *only*
|
||
// thing that goes up. Without this, Android's Back button
|
||
// would leave the library from behind an open question — the
|
||
// view changing underneath a modal, which reads as the app
|
||
// having lost its place.
|
||
//
|
||
// The recovery question is asked first because it is drawn
|
||
// over everything, the offline prompt included: Back must
|
||
// reach the thing the user can actually see.
|
||
if (root.recovery-title != "") {
|
||
root.recovery-dismiss();
|
||
return accept;
|
||
}
|
||
// The row menu and the membership sheet are drawn over the
|
||
// offline prompt, so Back has to reach them first — and the
|
||
// menu's own confirmation is a step Back must be able to take
|
||
// one at a time, which is why the dismiss handlers in Rust
|
||
// back out of a pending delete rather than closing outright.
|
||
if (Collections.collection-menu-title != "") {
|
||
Collections.collection-menu-dismiss();
|
||
return accept;
|
||
}
|
||
if (Collections.membership-open) {
|
||
Collections.membership-dismiss();
|
||
return accept;
|
||
}
|
||
if (root.offline-prompt-title != "") {
|
||
root.offline-prompt-dismiss();
|
||
return accept;
|
||
}
|
||
return root.back-requested() ? accept : reject;
|
||
}
|
||
return reject;
|
||
}
|
||
|
||
// Settings, over everything. First in the file and first in z-order so the
|
||
// conditions below can be read as "and no page is open".
|
||
// Outermost with the settings page, and before it: both are reachable
|
||
// from the library and from develop, so guarding either with which
|
||
// view was current would make which view you came from decide whether
|
||
// it appears.
|
||
if root.active-page == Page.import: ImportPage {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
volume-labels: root.import-volume-labels;
|
||
volume-details: root.import-volume-details;
|
||
volume-selected: root.import-volume-selected;
|
||
card-path: root.import-card-path;
|
||
card-looks-right: root.import-card-looks-right;
|
||
surveying: root.import-surveying;
|
||
survey-summary: root.import-survey-summary;
|
||
upload-target: root.import-upload-target;
|
||
folder-template: root.import-folder-template;
|
||
template-preview: root.import-template-preview;
|
||
mode-labels: root.import-mode-labels;
|
||
mode-selected: root.import-mode-selected;
|
||
mode-is-move: root.import-mode-is-move;
|
||
duplicate-labels: root.import-duplicate-labels;
|
||
duplicate-selected: root.import-duplicate-selected;
|
||
verify: root.import-verify;
|
||
backup-on: root.import-backup-on;
|
||
backup-path: root.import-backup-path;
|
||
running: root.import-running;
|
||
fraction: root.import-fraction;
|
||
status: root.import-status;
|
||
error: root.import-error;
|
||
can-start: root.import-can-start;
|
||
|
||
volume-picked(i) => { root.import-volume-picked(i); }
|
||
card-path-changed(t) => { root.import-card-path-changed(t); }
|
||
refresh-volumes() => { root.import-refresh-volumes(); }
|
||
template-changed(t) => { root.import-template-changed(t); }
|
||
mode-picked(i) => { root.import-mode-picked(i); }
|
||
duplicate-picked(i) => { root.import-duplicate-picked(i); }
|
||
verify-toggled(on) => { root.import-verify-toggled(on); }
|
||
backup-toggled(on) => { root.import-backup-toggled(on); }
|
||
backup-path-changed(t) => { root.import-backup-path-changed(t); }
|
||
begin() => { root.import-start(); }
|
||
stop() => { root.import-stop(); }
|
||
close() => { root.import-close(); }
|
||
}
|
||
|
||
if root.active-page == Page.merge: MergePage {
|
||
width: 100%;
|
||
height: 100%;
|
||
stage: root.merge-stage;
|
||
running: root.merge-running;
|
||
fraction: root.merge-fraction;
|
||
status: root.merge-status;
|
||
error: root.merge-error;
|
||
aligned: root.merge-aligned;
|
||
complete: root.merge-complete;
|
||
summary: root.merge-summary;
|
||
frames: root.merge-frames;
|
||
preview: root.merge-preview;
|
||
projection-selected: root.merge-projection-selected;
|
||
border-selected: root.merge-border-selected;
|
||
fill-available: root.merge-fill-available;
|
||
fill-note: root.merge-fill-note;
|
||
preview-filled: root.merge-preview-filled;
|
||
fill-scale: root.merge-fill-scale;
|
||
fill-erosion: root.merge-fill-erosion;
|
||
fill-coarse: root.merge-fill-coarse;
|
||
fill-band: root.merge-fill-band;
|
||
fill-mirror: root.merge-fill-mirror;
|
||
fill-feather: root.merge-fill-feather;
|
||
done: root.merge-done;
|
||
merge() => { root.merge-confirm(); }
|
||
abandon() => { root.merge-abandon(); }
|
||
projection-picked(i) => { root.merge-projection-picked(i); }
|
||
border-picked(i) => { root.merge-border-picked(i); }
|
||
fill-knob(name, value) => { root.merge-fill-knob(name, value); }
|
||
close() => { root.merge-close(); }
|
||
}
|
||
|
||
if root.active-page == Page.settings: SettingsPage {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
adapter: root.adapter;
|
||
backend: root.backend;
|
||
inference-backend: root.inference-backend;
|
||
inference-detail: root.inference-detail;
|
||
fps: root.fps;
|
||
layout-class: root.layout-class;
|
||
app-version: root.app-version;
|
||
display-name: root.display-name;
|
||
display-colour: root.display-colour;
|
||
display-others: root.display-others;
|
||
|
||
original-budget: root.settings-original-budget;
|
||
original-unlimited: root.settings-original-unlimited;
|
||
thumbnail-budget: root.settings-thumbnail-budget;
|
||
thumbnail-unlimited: root.settings-thumbnail-unlimited;
|
||
keep-opened: root.settings-keep-opened;
|
||
fetch-ahead-labels: root.settings-fetch-ahead-labels;
|
||
fetch-ahead-selected: root.settings-fetch-ahead-selected;
|
||
fetch-ahead-picked(i) => { root.settings-fetch-ahead-picked(i); }
|
||
copy-scope-kinds: root.copy-scope-kinds;
|
||
copy-scope-empty: root.copy-scope-empty;
|
||
copy-scope-toggled(name) => { root.copy-scope-toggled(name); }
|
||
timeline-bar-labels: root.settings-timeline-bar-labels;
|
||
timeline-bars-selected: root.settings-timeline-bars-selected;
|
||
timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); }
|
||
write-xmp: root.settings-write-xmp;
|
||
write-xmp-toggled(on) => { root.settings-write-xmp-toggled(on); }
|
||
xmp-conflicts: root.settings-xmp-conflicts;
|
||
xmp-reload() => { root.settings-xmp-reload(); }
|
||
cache-usage: root.settings-cache-usage;
|
||
|
||
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
|
||
original-unlimited-toggled(on) => {
|
||
root.settings-original-unlimited-toggled(on);
|
||
}
|
||
thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); }
|
||
thumbnail-unlimited-toggled(on) => {
|
||
root.settings-thumbnail-unlimited-toggled(on);
|
||
}
|
||
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
|
||
|
||
group-nav-labels: root.settings-group-nav-labels;
|
||
group-nav-selected: root.settings-group-nav-selected;
|
||
group-nav-auto-says: root.settings-group-nav-auto-says;
|
||
format-labels: root.settings-format-labels;
|
||
format-selected: root.settings-format-selected;
|
||
quality: root.settings-quality;
|
||
quality-enabled: root.settings-quality-enabled;
|
||
colour-labels: root.settings-colour-labels;
|
||
colour-selected: root.settings-colour-selected;
|
||
sizing-labels: root.settings-sizing-labels;
|
||
sizing-selected: root.settings-sizing-selected;
|
||
sizing-value: root.settings-sizing-value;
|
||
sizing-has-value: root.settings-sizing-has-value;
|
||
sizing-unit: root.settings-sizing-unit;
|
||
sizing-value-label: root.settings-sizing-value-label;
|
||
sizing-height: root.settings-sizing-height;
|
||
sizing-has-height: root.settings-sizing-has-height;
|
||
screen-labels: root.settings-screen-labels;
|
||
screen-selected: root.settings-screen-selected;
|
||
allow-upscaling: root.settings-allow-upscaling;
|
||
sharpening-labels: root.settings-sharpening-labels;
|
||
sharpening-selected: root.settings-sharpening-selected;
|
||
filename-template: root.settings-filename-template;
|
||
collision-labels: root.settings-collision-labels;
|
||
collision-selected: root.settings-collision-selected;
|
||
strip-location: root.settings-strip-location;
|
||
destination: root.settings-destination;
|
||
destination-hint: root.settings-destination-hint;
|
||
browse-open: root.settings-browse-open;
|
||
browse-path: root.settings-browse-path;
|
||
browse-entries: root.settings-browse-entries;
|
||
browse-loading: root.settings-browse-loading;
|
||
browse-at-root: root.settings-browse-at-root;
|
||
browse-available: root.settings-browse-available;
|
||
target-labels: root.settings-target-labels;
|
||
target-selected: root.settings-target-selected;
|
||
error: root.settings-error;
|
||
|
||
group-nav-picked(i) => { root.settings-group-nav-picked(i); }
|
||
format-picked(i) => { root.settings-format-picked(i); }
|
||
quality-changed(q) => { root.settings-quality-changed(q); }
|
||
colour-picked(i) => { root.settings-colour-picked(i); }
|
||
sizing-picked(i) => { root.settings-sizing-picked(i); }
|
||
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
|
||
sizing-height-changed(t) => { root.settings-sizing-height-changed(t); }
|
||
screen-picked(i) => { root.settings-screen-picked(i); }
|
||
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
|
||
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
|
||
template-changed(t) => { root.settings-template-changed(t); }
|
||
collision-picked(i) => { root.settings-collision-picked(i); }
|
||
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
|
||
destination-changed(t) => { root.settings-destination-changed(t); }
|
||
target-picked(i) => { root.settings-target-changed(i); }
|
||
browse-open-picker() => { root.settings-browse-open-picker(); }
|
||
browse-into(n) => { root.settings-browse-into(n); }
|
||
browse-up() => { root.settings-browse-up(); }
|
||
browse-confirm() => { root.settings-browse-confirm(); }
|
||
browse-cancel() => { root.settings-browse-cancel(); }
|
||
|
||
activity-rows: root.activity-rows;
|
||
activity-running: root.activity-running;
|
||
activity-kept: root.activity-kept;
|
||
clear-finished() => { root.activity-clear-finished(); }
|
||
thumbnailing: root.library-thumbnailing;
|
||
// The face pass reuses the Identity Manager's own state rather
|
||
// than duplicating it: one job, startable from either place, and
|
||
// a button that cannot disagree with the screen about whether it
|
||
// is running.
|
||
face-indexing: root.identity-indexing;
|
||
face-coverage: root.identity-coverage;
|
||
face-model-missing: root.identity-model-missing;
|
||
face-detector-labels: root.settings-face-detector-labels;
|
||
face-detector-selected: root.settings-face-detector-selected;
|
||
face-detector-picked(i) => { root.settings-face-detector-picked(i); }
|
||
library-open: root.library-open;
|
||
thumbnail-library() => { root.library-thumbnail-all(); }
|
||
index-faces() => { root.identity-index(); }
|
||
reindex-faces() => { root.identity-reindex(); }
|
||
|
||
// TRACES: NFR-OPS-1
|
||
diagnostics-preview: root.diagnostics-preview;
|
||
diagnostics-result: root.diagnostics-result;
|
||
diagnostics-prepare() => { root.diagnostics-prepare(); }
|
||
diagnostics-save() => { root.diagnostics-save(); }
|
||
diagnostics-discard() => { root.diagnostics-discard(); }
|
||
|
||
close() => { root.settings-close(); }
|
||
reset-defaults() => { root.settings-reset(); }
|
||
}
|
||
|
||
// Like the launch screen, this replaces the window rather than
|
||
// overlaying it — see `View`.
|
||
if root.active-page == Page.none && root.active-view == View.identity: IdentityScreen {
|
||
width: 100%;
|
||
height: 100%;
|
||
people: root.identity-people;
|
||
faces: root.identity-faces;
|
||
selected-person: root.identity-selected;
|
||
selected-name: root.identity-selected-name;
|
||
unassigned: root.identity-unassigned;
|
||
calibrated: root.identity-calibrated;
|
||
indexing: root.identity-indexing;
|
||
indexing-status: root.identity-indexing-status;
|
||
model-missing: root.identity-model-missing;
|
||
picked-count: root.identity-picked;
|
||
back-label: root.identity-back-label;
|
||
coverage: root.identity-coverage;
|
||
coverage-complete: root.identity-coverage-complete;
|
||
coverage-read-only: root.identity-coverage-read-only;
|
||
merge-offer-name: root.identity-merge-offer-name;
|
||
merge-offer-faces: root.identity-merge-offer-faces;
|
||
name-revision: root.identity-name-revision;
|
||
|
||
person-picked(id) => { root.identity-person-picked(id); }
|
||
rename(name) => { root.identity-rename(name); }
|
||
merge-accept() => { root.identity-merge-accept(); }
|
||
name-edited(t) => { root.identity-name-edited(t); }
|
||
merge-decline() => { root.identity-merge-decline(); }
|
||
confirm-face(id) => { root.identity-confirm-face(id); }
|
||
reject-face(id) => { root.identity-reject-face(id); }
|
||
toggle-pick(id) => { root.identity-toggle-pick(id); }
|
||
confirm-all() => { root.identity-confirm-all(); }
|
||
split-picked() => { root.identity-split-picked(); }
|
||
compact: !root.expanded;
|
||
photos-filtered: root.library-filter-people.length > 0;
|
||
regrouping: root.identity-regrouping;
|
||
regroup-status: root.identity-regroup-status;
|
||
merge-probability: root.identity-merge-probability;
|
||
min-group-size: root.identity-min-group-size;
|
||
merge-probability-changed(v) => { root.identity-merge-probability-changed(v); }
|
||
min-group-size-changed(n) => { root.identity-min-group-size-changed(n); }
|
||
previewing: root.identity-previewing;
|
||
grouping-preview: root.identity-grouping-preview;
|
||
preview-grouping() => { root.identity-preview-grouping(); }
|
||
ignored-count: root.identity-ignored-count;
|
||
show-ignored: root.identity-show-ignored;
|
||
selected-ignored: root.identity-selected-ignored;
|
||
toggle-show-ignored() => { root.identity-toggle-show-ignored(); }
|
||
ignore-person(id, on) => { root.identity-ignore-person(id, on); }
|
||
show-photos(id, add) => { root.identity-show-photos(id, add); }
|
||
recluster() => { root.identity-recluster(); }
|
||
index-faces() => { root.identity-index(); }
|
||
stop-indexing() => { root.identity-stop-indexing(); }
|
||
check-coverage() => { root.identity-check-coverage(); }
|
||
delete-all-face-data() => { root.identity-delete-all(); }
|
||
close() => { root.identity-close(); }
|
||
}
|
||
|
||
// The launch screen replaces the whole window rather than overlaying it:
|
||
// there is no library to look at until an account is configured.
|
||
if root.active-page == Page.none && root.active-view == View.launch: LaunchScreen {
|
||
width: 100%;
|
||
height: 100%;
|
||
signed-in: root.launch-signed-in;
|
||
account: root.launch-account;
|
||
library-root: root.launch-root;
|
||
endpoint-is-library: root.launch-endpoint-is-library;
|
||
server-url: root.launch-server;
|
||
folder-path: root.launch-folder;
|
||
busy: root.launch-busy;
|
||
status: root.launch-status;
|
||
error: root.launch-error;
|
||
login-url: root.launch-login-url;
|
||
can-remember: root.launch-can-remember;
|
||
format-labels: root.launch-format-labels;
|
||
format-checked: root.launch-format-checked;
|
||
|
||
sign-in(server) => { root.launch-sign-in(server); }
|
||
use-folder(path) => { root.launch-use-folder(path); }
|
||
sign-in-direct(server, user, pw) => {
|
||
root.launch-sign-in-direct(server, user, pw);
|
||
}
|
||
sign-out() => { root.launch-sign-out(); }
|
||
choose-folder() => { root.launch-choose-folder(); }
|
||
open-library() => { root.launch-open-library(); }
|
||
format-toggled(i, on) => { root.launch-format-toggled(i, on); }
|
||
copy-login-url() => { root.launch-copy-url(); }
|
||
|
||
browsing: root.launch-browsing;
|
||
browse-path: root.launch-browse-path;
|
||
browse-loading: root.launch-browse-loading;
|
||
browse-entries: root.launch-browse-entries;
|
||
|
||
browse-into(name) => { root.launch-browse-into(name); }
|
||
browse-up() => { root.launch-browse-up(); }
|
||
browse-confirm() => { root.launch-browse-confirm(); }
|
||
browse-cancel() => { root.launch-browse-cancel(); }
|
||
}
|
||
|
||
// The library view sits between the launch screen and develop: a library
|
||
// has been opened but no image chosen yet. The collections sidebar and the
|
||
// grid are siblings here rather than the sidebar living inside the grid,
|
||
// because the drag that connects them has to be owned above both.
|
||
if root.library-visible: Rectangle {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: Theme.ground;
|
||
|
||
HorizontalLayout {
|
||
// Always instantiated, width collapsed to zero when hidden — the
|
||
// same reasoning as the adjust panel below: an `if` here depends on
|
||
// the layout class, which derives from the window width, which the
|
||
// layout then influences. Slint flags that loop and it can panic.
|
||
Rectangle {
|
||
width: root.collections-visible ? 232px : 0px;
|
||
visible: root.collections-visible;
|
||
horizontal-stretch: 0;
|
||
|
||
CollectionsPanel {
|
||
width: 100%;
|
||
height: 100%;
|
||
rows: Collections.collection-rows;
|
||
selected-id: root.collection-selected;
|
||
total-images: Library.library-total;
|
||
error: Collections.collection-error;
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The two actions that were in the library header and
|
||
// referred to the tree rather than to the library.
|
||
// They read the same window state the header did — one
|
||
// source per fact — and call the same handlers.
|
||
scanning: root.library-scanning;
|
||
scope-pinned: root.library-scope-pinned;
|
||
toggle-pin-scope() => { root.library-toggle-pin-scope(); }
|
||
change-library() => { root.library-change(); }
|
||
|
||
select(id) => { root.collection-select(id); }
|
||
toggle(id) => { Collections.collection-toggle(id); }
|
||
new-collection() => { Collections.collection-new(); }
|
||
row-menu(id) => { Collections.collection-menu(id); }
|
||
offline-menu(id) => { root.collection-offline-menu(id); }
|
||
row-press(id, down) => { Collections.collection-row-press(id, down); }
|
||
lifted-id: Collections.collection-lifted;
|
||
drag-active(id, on) => { Collections.collection-drag-active(id, on); }
|
||
root-drop-allowed: Collections.collection-root-drop-allowed;
|
||
dropped-on-root() => { Collections.collection-dropped-on-root(); }
|
||
|
||
renaming-id: Collections.collection-renaming;
|
||
rename-start(id) => { Collections.collection-rename-start(id); }
|
||
rename-commit(id, name) => {
|
||
Collections.collection-rename-commit(id, name);
|
||
}
|
||
rename-cancel() => { Collections.collection-rename-cancel(); }
|
||
trash-count: Collections.trash-count;
|
||
trash-label: Collections.trash-label;
|
||
selected-count: Library.library-selected-count;
|
||
|
||
dropped-on(id) => { Collections.collection-dropped(id); }
|
||
collection-drag-payload(id) => { return Collections.collection-drag-payload(id); }
|
||
dropped-on-trash() => { Collections.trash-dropped(); }
|
||
empty-trash() => { Collections.trash-empty(); }
|
||
restore-selected() => { Collections.trash-restore(); }
|
||
drag-over(id, over) => { Collections.collection-drag-over(id, over); }
|
||
}
|
||
}
|
||
|
||
LibraryGrid {
|
||
horizontal-stretch: 1;
|
||
// FR-UI-1: the header collapses its actions behind a
|
||
// disclosure below the breakpoint. A tablet in portrait is
|
||
// 768 logical pixels, which fits the title and the status
|
||
// line but not the library's buttons beside them.
|
||
expanded: root.expanded;
|
||
cells: Library.library-cells;
|
||
total: Library.library-total;
|
||
scanning: root.library-scanning;
|
||
opening: root.library-opening;
|
||
scan-status: root.library-status;
|
||
scan-error: root.library-error;
|
||
|
||
offline: root.library-offline;
|
||
offline-reason: root.library-offline-reason;
|
||
offline-since: root.library-offline-since;
|
||
retry-connection() => { root.library-retry-connection(); }
|
||
local-only: Library.library-local-only;
|
||
local-count: Library.library-local-count;
|
||
toggle-local-only() => { Library.library-toggle-local-only(); }
|
||
range-active: root.library-range-active;
|
||
toggle-date-range() => { root.library-toggle-date-range(); }
|
||
clear-selection() => { Library.library-clear-selection(); }
|
||
select-all() => { Library.library-select-all(); }
|
||
reorderable: Library.library-reorderable;
|
||
reorder-to(row, after) => { Library.library-reorder-to(row, after); }
|
||
collection-from-selection(name) => {
|
||
Library.library-collection-from-selection(name);
|
||
}
|
||
range-from: root.library-range-from;
|
||
range-to: root.library-range-to;
|
||
range-invalid: root.library-range-invalid;
|
||
range-edited(a, b) => { root.library-range-edited(a, b); }
|
||
range-from-fraction: root.library-range-from-fraction;
|
||
range-to-fraction: root.library-range-to-fraction;
|
||
timeline-range-changed(a, b) => {
|
||
root.library-timeline-range-changed(a, b);
|
||
}
|
||
|
||
collections-visible: root.collections-visible;
|
||
toggle-collections() => { root.toggle-collections(); }
|
||
|
||
pin-done: root.library-pin-done;
|
||
pin-total: root.library-pin-total;
|
||
root-label: root.library-root-label;
|
||
timeline: root.library-timeline;
|
||
timeline-label: root.library-timeline-label;
|
||
window-label: root.library-window-label;
|
||
offset: root.library-offset;
|
||
selected-count: Library.library-selected-count;
|
||
scope-label: Collections.collection-scope-label;
|
||
settings-armed: Transfer.armed;
|
||
paste-settings-to-selection => {
|
||
root.paste-settings-to-selection();
|
||
}
|
||
// TRACES: FR-DEV-6
|
||
// Opened with the selection's size, which is both what the
|
||
// sheet says it would act on and what the apply handler
|
||
// reads back to decide it means the batch.
|
||
open-presets => {
|
||
root.preset-apply-count = Library.library-selected-count;
|
||
root.preset-can-save = false;
|
||
root.presets-open = true;
|
||
}
|
||
|
||
// TRACES: FR-EXP-7
|
||
exporting: root.library-exporting;
|
||
export-to-server: root.export-to-server;
|
||
export-selection => { root.library-export-selection(); }
|
||
cancel-export => { root.library-cancel-export(); }
|
||
// TRACES: FR-MRG-1
|
||
merge-selection => { root.library-merge-selection(); }
|
||
|
||
sweep-done: root.library-sweep-done;
|
||
sweep-total: root.library-sweep-total;
|
||
|
||
current-bucket: root.library-current-bucket;
|
||
current-bucket-fraction: root.library-current-fraction;
|
||
timeline-anchored: root.library-timeline-anchored;
|
||
|
||
scrub-fraction(f) => { root.library-scrub-fraction(f); }
|
||
timeline-pinch(r) => { root.library-timeline-pinch(r); }
|
||
timeline-pan(d) => { root.library-timeline-pan(d); }
|
||
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
||
syncing: root.library-syncing;
|
||
requested-cell-size: Library.library-cell-size;
|
||
touched <=> Library.library-touched;
|
||
zoom-cells(d) => { Library.library-zoom-cells(d); }
|
||
pinch-cells(r) => { Library.library-pinch-cells(r); }
|
||
pinch-started() => { Library.library-pinch-started(); }
|
||
scroll-to: Library.library-scroll-to;
|
||
scroll-token: Library.library-scroll-token;
|
||
sync-now() => { root.library-sync-now(); }
|
||
columns-changed(n) => {
|
||
Library.library-columns = n;
|
||
// Which cell begins a row just changed, and month headings sit on
|
||
// row-leading cells — so they must be recomputed, not just moved.
|
||
Library.library-columns-changed();
|
||
}
|
||
scrolled(i) => { Library.library-scrolled(i); }
|
||
viewport-cells-changed(n) => { Library.library-viewport-cells(n); }
|
||
cell-clicked(i) => { Library.library-cell-clicked(i); }
|
||
rescan() => { root.library-rescan(); }
|
||
can-import: root.import-supported;
|
||
open-import() => { root.import-open(); }
|
||
open-settings() => { root.settings-open(); }
|
||
open-people() => { root.identity-open(); }
|
||
|
||
cell-pressed(i, ctrl, shift) => {
|
||
Library.library-cell-pressed(i, ctrl, shift);
|
||
}
|
||
cell-press-ended() => { Library.library-cell-press-ended(); }
|
||
select-mode: Library.library-select-mode;
|
||
toggle-select-mode() => { Library.library-toggle-select-mode(); }
|
||
held-row <=> Library.library-held-row;
|
||
// The sidebar's rows, not a second model: the sheet files
|
||
// into the same tree the sidebar draws.
|
||
collections: Collections.collection-rows;
|
||
file-in-collection(id, moves) => {
|
||
Library.library-file-in-collection(id, moves);
|
||
}
|
||
keywords: root.library-keywords;
|
||
keywords-opened() => { root.library-keywords-opened(); }
|
||
assign-keyword(word) => { root.library-assign-keyword(word); }
|
||
unassign-keyword(word) => { root.library-unassign-keyword(word); }
|
||
cursor: Library.library-cursor;
|
||
move-cursor(delta, extend) => {
|
||
Library.library-move-cursor(delta, extend);
|
||
}
|
||
open-cursor() => { Library.library-open-cursor(); }
|
||
drag-image: Library.library-drag-image;
|
||
drag-payload() => { return Library.library-drag-payload(); }
|
||
drag-started(i) => { Library.library-drag-started(i); }
|
||
drag-finished() => { Library.library-drag-finished(); }
|
||
remove-from-collection() => {
|
||
Library.library-remove-from-collection();
|
||
}
|
||
open-membership() => { Library.library-open-membership(); }
|
||
|
||
filter-people: root.library-filter-people;
|
||
filter-people-all: root.library-filter-people-all;
|
||
filter-eyes-open: root.library-filter-eyes-open;
|
||
people: root.library-people;
|
||
gestures: root.library-gestures;
|
||
filter-min-rating: root.library-filter-min-rating;
|
||
filter-unjudged: root.library-filter-unjudged;
|
||
filter-flag: root.library-filter-flag;
|
||
rating-counts: root.library-rating-counts;
|
||
|
||
cell-rated(i, n) => { root.library-cell-rated(i, n); }
|
||
cell-trashed(i) => { root.library-cell-trashed(i); }
|
||
burst-toggled(i) => { root.library-burst-toggled(i); }
|
||
burst-representative-chosen(i) => {
|
||
root.library-burst-representative-chosen(i);
|
||
}
|
||
trash-selection() => { root.library-trash-selection(); }
|
||
// Derived from the sidebar's own selection rather than
|
||
// mirrored in a second property: `-1` is already the sentinel
|
||
// for the trash row (see collections.slint), and two sources
|
||
// for one fact is how they come to disagree.
|
||
viewing-trash: root.collection-selected == -1;
|
||
judged(rating, flag) => { root.library-judged(rating, flag); }
|
||
// `F2` renames whatever the grid is scoped to. Answered from
|
||
// the sidebar's selection rather than a second property, for
|
||
// the same reason `viewing-trash` above is: one fact, one
|
||
// source. Ids at or below 0 are "all photographs" and the
|
||
// trash, neither of which has a name to change — Rust drops
|
||
// those rather than the binding trying to reason about it.
|
||
rename-scope() => {
|
||
Collections.collection-rename-start(root.collection-selected);
|
||
}
|
||
filter-min-rating-changed(n) => {
|
||
root.library-filter-min-rating-changed(n);
|
||
}
|
||
filter-unjudged-toggled(on) => {
|
||
root.library-filter-unjudged-changed(on);
|
||
}
|
||
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
|
||
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
|
||
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
|
||
filter-eyes-open-toggled() => { root.library-filter-eyes-open-toggled(); }
|
||
people-listed() => { root.library-people-listed(); }
|
||
filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
|
||
}
|
||
}
|
||
}
|
||
|
||
if root.active-page == Page.none && root.active-view == View.develop: VerticalLayout {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
StatusBar {
|
||
adapter: root.adapter;
|
||
backend: root.backend;
|
||
layout-class: root.layout-class;
|
||
fps: root.fps;
|
||
expanded: root.expanded;
|
||
filename: root.filename;
|
||
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
|
||
// Only where a library was opened; command-line files have no
|
||
// grid to return to.
|
||
can-return-to-library: Library.library-total > 0;
|
||
panel-visible: root.panel-visible;
|
||
export-label: root.export-label;
|
||
export-busy: root.export-busy;
|
||
export-status: root.export-status;
|
||
can-undo: Steps.can-undo;
|
||
can-redo: Steps.can-redo;
|
||
back-to-library() => { root.back-to-library(); }
|
||
open-people() => { root.identity-open(); }
|
||
open-settings() => { root.settings-open(); }
|
||
toggle-panel() => { root.toggle-panel(); }
|
||
export-image() => { root.export-image(); }
|
||
undo() => { Steps.undo(); }
|
||
redo() => { Steps.redo(); }
|
||
}
|
||
|
||
// The develop view's frame: the rail, the photograph, the column.
|
||
//
|
||
// **A Rectangle rather than the HorizontalLayout this was**, and
|
||
// every child below states its own `x`, `y`, `width` and `height`
|
||
// instead of being placed by one. Slint cannot turn a layout on its
|
||
// side, and D-N7 asks for exactly that: on a tall window the column
|
||
// goes under the photograph rather than beside it, and the rail
|
||
// stays down the left in both cases.
|
||
//
|
||
// The alternative was the whole subtree declared twice under two
|
||
// `if`s, which is four hundred lines of bindings copied — and a
|
||
// conditional child in a layout is the shape that has produced
|
||
// binding loops in this file before (see the develop column below,
|
||
// and the group strip inside it).
|
||
//
|
||
// With `column-below` false the three rectangles come out where the
|
||
// layout put them, to the pixel: a `HorizontalLayout` has no
|
||
// spacing or padding of its own, so the rail took its declared
|
||
// width on the left, the column its declared width on the right,
|
||
// and the canvas — the only child with `horizontal-stretch: 1` —
|
||
// everything in between.
|
||
frame := Rectangle {
|
||
horizontal-stretch: 1;
|
||
vertical-stretch: 1;
|
||
|
||
// What the column takes when it is beside the photograph, and
|
||
// what it takes when it is under it. Exactly one of the two is
|
||
// ever non-zero, which is what lets the canvas subtract both
|
||
// without asking which case it is in.
|
||
//
|
||
// Zero is also how the column is put away, on either axis:
|
||
// `panel-visible` collapses the dock's height exactly as it
|
||
// collapses the column's width, and for the same reason the
|
||
// note on the column gives — an `if` here would be a layout
|
||
// that depends on a property derived from the window the layout
|
||
// then influences.
|
||
property <length> column-width: !root.column-below && root.panel-visible
|
||
? min(root.panel-max-width, Theme.panel-width)
|
||
: 0px;
|
||
property <length> dock-height: root.column-below && root.panel-visible
|
||
? Theme.dock-height
|
||
: 0px;
|
||
|
||
// **The tools, on the far side from their consequences.**
|
||
//
|
||
// A fixed rail rather than a row of chips inside the develop
|
||
// column, which is where these three used to live. The column
|
||
// can be put away — that is the whole point of the toggle in
|
||
// the strip above — and putting the tools away with it meant
|
||
// the way *out* of crop disappeared along with the way in.
|
||
// Hence the "Done Cropping" button floating over the canvas
|
||
// below: one control duplicated to paper over the other being
|
||
// reachable only sometimes.
|
||
//
|
||
// The rail does not close, so a tool can always be put down
|
||
// where it was picked up. `toolrail.slint` has the rest of the
|
||
// reasoning and the table that generates it.
|
||
//
|
||
// Not conditioned on `total > 0` from out here: the rail
|
||
// collapses itself on `enabled`, and an `if` in this layout is
|
||
// the shape that has caused binding loops in this file before
|
||
// (see the develop column below).
|
||
rail := ToolRail {
|
||
// Full height down the left in both arrangements — D-N7 is
|
||
// explicit that the rail does not move, and a vertical list
|
||
// of finger-sized entries wants the height a tall window
|
||
// has more of. Its width is its own (`rail-width`, or zero
|
||
// when it stands down), which is why the two boxes beside
|
||
// it can measure from `rail.width`.
|
||
x: 0;
|
||
y: 0;
|
||
height: parent.height;
|
||
enabled: root.total > 0 && root.load-error == "";
|
||
mode: Develop.view-mode;
|
||
picked(m) => { root.mode-picked(m); }
|
||
|
||
// The same model and the same callback the strip below
|
||
// takes, because only one of the two is ever on screen.
|
||
// Routing both to `adjust-tab-picked` means Rust is not
|
||
// told which control was pressed and has no reason to
|
||
// care — the choice between them is a presentation
|
||
// decision, and it stays one.
|
||
groups-in-rail: Adjustments.groups-in-rail;
|
||
tabs: Adjustments.tabs;
|
||
active-tab: Adjustments.active-tab;
|
||
group-picked(i) => { Adjustments.tab-picked(i); }
|
||
}
|
||
|
||
// The canvas: compute output composited directly. No CPU
|
||
// round-trip anywhere in this path (ARCH §6.1).
|
||
canvas-area := Rectangle {
|
||
// What is left of the frame: beside the rail, and above or
|
||
// beside the column — whichever of the two dimensions above
|
||
// is the non-zero one. This is what `horizontal-stretch: 1`
|
||
// used to say to the layout.
|
||
x: rail.width;
|
||
y: 0;
|
||
width: parent.width - rail.width - parent.column-width;
|
||
height: parent.height - parent.dock-height;
|
||
background: Theme.ground;
|
||
clip: true;
|
||
|
||
canvas-image := Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: root.canvas;
|
||
image-fit: contain;
|
||
// Past 1:1 there is no detail left to reconstruct, so
|
||
// smoothing only invents values between real pixels — and
|
||
// inspecting focus or noise is the whole reason to zoom in
|
||
// that far. Below 1:1 it stays smooth, where filtering is
|
||
// what keeps the image from aliasing.
|
||
//
|
||
// TRACES: FR-DSP-8
|
||
// The canvas is rendered at the *physical* pixel size of
|
||
// the box this image occupies, so `contain` presents it
|
||
// 1:1 and neither filter is reached at all until the
|
||
// photographer zooms. That is the point: on a fractionally
|
||
// scaled desktop the buffer used to be a logical-sized one
|
||
// that the compositor stretched, and no choice of filter
|
||
// recovers detail that was never rendered.
|
||
image-rendering: root.magnified
|
||
? ImageRendering.pixelated
|
||
: ImageRendering.smooth;
|
||
visible: root.total > 0 && root.load-error == "";
|
||
}
|
||
|
||
// The region map, composited over the photograph at the
|
||
// same fitted rect.
|
||
//
|
||
// Drawn here rather than blended into the render because
|
||
// it is a diagnostic and not an edit: it must not reach
|
||
// the histogram, an export, or the texture the develop
|
||
// pass hands the compositor.
|
||
if root.overlay-on && !Masking.overlay-hidden && root.total > 0: Image {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
source: root.region-overlay;
|
||
// The visible part of a source-space picture, which is
|
||
// what makes the overlay follow a zoom, a pan and a
|
||
// crop. The compositor does the crop and the scale;
|
||
// resampling on the CPU would mean rebuilding a
|
||
// megapixel image on every frame of a drag.
|
||
source-clip-x: root.overlay-clip-x;
|
||
source-clip-y: root.overlay-clip-y;
|
||
source-clip-width: root.overlay-clip-w;
|
||
source-clip-height: root.overlay-clip-h;
|
||
transform-rotation: -root.overlay-angle * 1deg;
|
||
image-fit: fill;
|
||
opacity: root.overlay-strength;
|
||
// Nearest-neighbour, always. The map is proxy-sized
|
||
// and its values are *names*: smoothing between region
|
||
// 4 and region 9 invents a colour belonging to
|
||
// neither, and draws a soft edge exactly where the
|
||
// point is to see a hard one.
|
||
image-rendering: ImageRendering.pixelated;
|
||
}
|
||
|
||
// TRACES: FR-CULL-3
|
||
// The focus marks, over the same fitted rect. See
|
||
// `peaking.slint` for why they are a layer over the canvas
|
||
// rather than a tint in it.
|
||
if root.focus-overlay-ready && root.total > 0: FocusMarks {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
marks: root.focus-overlay;
|
||
}
|
||
|
||
// Where the photograph actually sits inside this box.
|
||
//
|
||
// `image-fit: contain` letterboxes, and Slint does not report
|
||
// the fitted rect, so it is derived from the source's aspect.
|
||
// The overlay is placed against *this*, not against the whole
|
||
// area — otherwise the crop rect drifts off the picture on any
|
||
// window whose shape differs from the image's.
|
||
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
|
||
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
|
||
property <float> box-w: self.width / 1px;
|
||
property <float> box-h: self.height / 1px;
|
||
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
|
||
property <length> shown-w: self.src-w * self.fit-scale * 1px;
|
||
property <length> shown-h: self.src-h * self.fit-scale * 1px;
|
||
property <length> shown-x: (self.width - self.shown-w) / 2;
|
||
property <length> shown-y: (self.height - self.shown-h) / 2;
|
||
|
||
// Empty and error states say what happened rather than
|
||
// showing a blank canvas.
|
||
if root.total == 0 || root.load-error != "": EmptyState {
|
||
headline: root.load-error != "" ? "Could not load image" : "No images";
|
||
detail: root.load-error != "" ? root.load-error
|
||
: "Pass a folder or file on the command line.";
|
||
}
|
||
|
||
// --- zoom and pan ------------------------------------------
|
||
//
|
||
// Below the crop overlay in z-order so that, in crop mode, the
|
||
// handles take the drag instead. Scroll still zooms either way.
|
||
|
||
// GESTURE: Magnify the photograph by any amount
|
||
// where: Develop
|
||
// touch: Pinch it with two fingers
|
||
// pointer: The scroll wheel over it
|
||
// why: Anchored on the fingers' midpoint, and on the
|
||
// pointer, so the gesture reads as magnifying
|
||
// the picture rather than sliding it about.
|
||
// Double-tap is the way to an exact 1:1; this
|
||
// is the way to everything in between.
|
||
//
|
||
// TRACES: FR-UI-4
|
||
// Pinch to zoom. The wheel gesture below is the desktop's
|
||
// answer and there is no wheel on a tablet, so without this
|
||
// the only way to magnify a photograph on the device the
|
||
// panel was sized for is a control that does not exist.
|
||
//
|
||
// `scale` is cumulative from 1.0 across the whole gesture,
|
||
// so the ratio since the last update is what maps onto a
|
||
// zoom step — the same reasoning as the timeline's pinch,
|
||
// and for the same reason: applying the running total on
|
||
// every update squares it and shoots straight to the limit.
|
||
//
|
||
// Anchored on the midpoint between the fingers, which is
|
||
// what makes a pinch feel like it is moving the picture
|
||
// rather than the viewport.
|
||
if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler {
|
||
x: 0; y: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
enabled: !Develop.cropping;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => { self.last-scale = 1.0; }
|
||
updated => {
|
||
root.zoom-at(
|
||
self.scale / max(0.01, self.last-scale),
|
||
(self.center.x - canvas-area.shown-x)
|
||
/ max(canvas-area.shown-w, 1px),
|
||
(self.center.y - canvas-area.shown-y)
|
||
/ max(canvas-area.shown-h, 1px),
|
||
);
|
||
self.last-scale = self.scale;
|
||
}
|
||
ended => { self.last-scale = 1.0; }
|
||
cancelled => { self.last-scale = 1.0; }
|
||
}
|
||
|
||
// --- the pan/zoom backstop ---------------------------------
|
||
//
|
||
// **Declared first among the canvas handlers, because in
|
||
// Slint that puts it last in line for a press.**
|
||
//
|
||
// Slint hit-tests siblings `FrontToBack`
|
||
// (`i-slint-core`'s `send_mouse_event_to_item`), a
|
||
// `TouchArea` answers `GrabMouse` on any press it is
|
||
// enabled for, and the first grab aborts the traversal.
|
||
// Front means *last declared*. So this — full-canvas, and
|
||
// enabled for everything but a crop — took every press in
|
||
// the viewport, and the four handlers below it never
|
||
// received one: painting a mask, clicking a subject on the
|
||
// photograph, placing a repair and sampling a neutral were
|
||
// all dead, each of them carrying a comment claiming it sat
|
||
// "above the pan/zoom area" because it was written first.
|
||
//
|
||
// Nothing about the geometry says which wins, so nothing
|
||
// about the geometry can be adjusted to fix it. The order
|
||
// is the fix, and the rule to keep is: **the general case
|
||
// goes at the top of the file and the specific ones after
|
||
// it.** `GradientHandles` further down is the other end of
|
||
// the same rule, and is why dragging a handle has always
|
||
// worked while everything between it and here did not.
|
||
if root.total > 0 && root.load-error == "": TouchArea {
|
||
x: 0; y: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
// A pan is only meaningful once there is something outside
|
||
// the viewport to reach.
|
||
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
|
||
enabled: !Develop.cropping;
|
||
|
||
property <length> last-x;
|
||
property <length> last-y;
|
||
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
self.last-x = self.mouse-x;
|
||
self.last-y = self.mouse-y;
|
||
}
|
||
}
|
||
|
||
// GESTURE: Move a magnified photograph about
|
||
// where: Develop
|
||
// touch: Drag it
|
||
// pointer: Drag it
|
||
// why: Only once there is something outside the
|
||
// viewport to reach, which is why the
|
||
// cursor becomes a hand exactly then. The
|
||
// view is clamped to the frame: panning
|
||
// past the edge would show undefined area
|
||
// beside the photograph, and that reads as
|
||
// a rendering fault rather than as the end
|
||
// of the picture.
|
||
moved => {
|
||
if (self.pressed && root.zoomed) {
|
||
// Fractions of the *visible* area, which is what
|
||
// the session's pan expects. Negated: dragging
|
||
// right moves the image right, so the window onto
|
||
// it moves left.
|
||
root.pan-by(
|
||
-(self.mouse-x - self.last-x) / max(parent.shown-w, 1px),
|
||
-(self.mouse-y - self.last-y) / max(parent.shown-h, 1px),
|
||
);
|
||
self.last-x = self.mouse-x;
|
||
self.last-y = self.mouse-y;
|
||
}
|
||
}
|
||
|
||
scroll-event(ev) => {
|
||
if (ev.delta-y == 0) {
|
||
return reject;
|
||
}
|
||
// Anchored on the pointer, in fractions of the shown
|
||
// image, so whatever is under the cursor stays there.
|
||
root.zoom-at(
|
||
ev.delta-y > 0 ? 1.15 : 1.0 / 1.15,
|
||
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
|
||
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
|
||
);
|
||
return accept;
|
||
}
|
||
|
||
// TRACES: FR-UI-4
|
||
// Fit and 1:1, which is what FR-UI-4 asks a double
|
||
// tap for. It used to drop straight to fit, so the
|
||
// gesture only ever did half its job — and the half
|
||
// it did not do is the one that matters, since
|
||
// nothing else in the interface reached 100% at all.
|
||
//
|
||
// Anchored on the pointer, in fractions of the shown
|
||
// image, exactly as the wheel above is: the detail
|
||
// being inspected is the one under the finger, and a
|
||
// toggle that jumped to the centre would ask for a
|
||
// pan afterwards every single time.
|
||
double-clicked => {
|
||
root.inspect-toggled(
|
||
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
|
||
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Region picking, declared after the pan/zoom area so a
|
||
// click reaches it first — see the note there for why that
|
||
// is the way round it is. A separate area rather than a
|
||
// branch inside it: panning wants press-drag-release and
|
||
// picking wants a click, and interleaving the two in one
|
||
// handler is how a drag ends up selecting a region the
|
||
// user was only scrolling past.
|
||
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
mouse-cursor: MouseCursor.crosshair;
|
||
enabled: !Develop.cropping;
|
||
|
||
clicked => {
|
||
root.region-picked(
|
||
self.mouse-x / max(self.width, 1px),
|
||
self.mouse-y / max(self.height, 1px),
|
||
// Shift extends or removes, the modifier every
|
||
// selection tool uses for the same thing.
|
||
root.shift-held,
|
||
);
|
||
}
|
||
}
|
||
|
||
// GESTURE: Paint a mask by hand
|
||
// where: Develop
|
||
// touch: Choose Paint or Erase, then drag on the
|
||
// photograph
|
||
// pointer: Choose Paint or Erase, then drag
|
||
// why: A model's mask stops inside a shoulder and
|
||
// leaks into the hair, and no single edge
|
||
// control fixes two errors that go opposite
|
||
// ways. The whole stroke is one step in the
|
||
// history, so taking a mark back costs one
|
||
// press however long it took to make.
|
||
//
|
||
// TRACES: FR-DEV-19b
|
||
//
|
||
// In front of the region picker rather than beside it: the
|
||
// two want the same press, and `region-picking` is false
|
||
// whenever this is armed so only one of them exists at a
|
||
// time.
|
||
if root.painting && root.total > 0 && root.load-error == "": paint := TouchArea {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
mouse-cursor: crosshair;
|
||
enabled: !Develop.cropping;
|
||
|
||
pointer-event(ev) => {
|
||
if ev.kind == PointerEventKind.down {
|
||
root.paint-began(
|
||
self.mouse-x / max(self.width, 1px),
|
||
self.mouse-y / max(self.height, 1px),
|
||
);
|
||
}
|
||
// A cancelled gesture ends the stroke like a
|
||
// release does. The alternative is a stroke left
|
||
// open across the next press, which paints a line
|
||
// between two places the finger never travelled.
|
||
if ev.kind == PointerEventKind.up
|
||
|| ev.kind == PointerEventKind.cancel {
|
||
root.paint-ended();
|
||
}
|
||
}
|
||
|
||
moved => {
|
||
if self.pressed {
|
||
root.paint-moved(
|
||
self.mouse-x / max(self.width, 1px),
|
||
self.mouse-y / max(self.height, 1px),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-DEV-8
|
||
// Placing a repair, beside the region picker and for the
|
||
// same reasons: a click and a pan want different handlers,
|
||
// and this one has to reach the click first.
|
||
//
|
||
// Below the circles declared further down, so a press that
|
||
// lands on an existing repair takes hold of it instead of
|
||
// making another one on top.
|
||
if Develop.repairing && root.total > 0 && root.load-error == "": TouchArea {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
mouse-cursor: MouseCursor.crosshair;
|
||
enabled: !Develop.cropping;
|
||
|
||
clicked => {
|
||
root.spot-placed(
|
||
self.mouse-x / max(self.width, 1px),
|
||
self.mouse-y / max(self.height, 1px),
|
||
);
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-DEV-3 | FR-UI-7
|
||
// Sampling a neutral, beside the repair placer above and
|
||
// built exactly like it: a click on the fitted image, over
|
||
// the pan area so the click reaches it first, and a
|
||
// separate handler rather than a branch inside the pan —
|
||
// panning wants press-drag-release and this wants a click,
|
||
// and interleaving the two is how a drag ends up sampling
|
||
// whatever it happened to travel over.
|
||
//
|
||
// **One click, one sample, one history step.** There is no
|
||
// hover preview: previewing would mean a render and a
|
||
// solve per pixel the pointer crossed, and if any of them
|
||
// were recorded the stack would fill with a hundred
|
||
// temperatures nobody chose. The arming state is what says
|
||
// the next click will do something instead.
|
||
if root.sampling && root.total > 0 && root.load-error == "": TouchArea {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
mouse-cursor: MouseCursor.crosshair;
|
||
enabled: !Develop.cropping;
|
||
|
||
clicked => {
|
||
root.neutral-picked(
|
||
self.mouse-x / max(self.width, 1px),
|
||
self.mouse-y / max(self.height, 1px),
|
||
);
|
||
// One shot. Leaving it armed would make the next
|
||
// click on the photograph — to place a mask, to
|
||
// pan — a second sample, and the photographer
|
||
// would have to remember to put a tool away that
|
||
// has already done its job.
|
||
Adjustments.sampling-op = -1;
|
||
}
|
||
}
|
||
|
||
|
||
// --- crop overlay ------------------------------------------
|
||
//
|
||
// Placed over the fitted image, so the fractions it draws are
|
||
// fractions of the picture. See `CropOverlay` in crop.slint.
|
||
if Develop.cropping && root.total > 0 && root.load-error == "": CropOverlay {
|
||
x: canvas-area.shown-x;
|
||
y: canvas-area.shown-y;
|
||
width: canvas-area.shown-w;
|
||
height: canvas-area.shown-h;
|
||
|
||
crop-x: root.crop-x;
|
||
crop-y: root.crop-y;
|
||
crop-w: root.crop-w;
|
||
crop-h: root.crop-h;
|
||
|
||
crop-changed(x, y, w, h, hx, hy) => {
|
||
root.crop-changed(x, y, w, h, hx, hy);
|
||
}
|
||
}
|
||
|
||
// --- gradient handles ---------------------------------------
|
||
//
|
||
// TRACES: FR-DEV-3 | FR-UI-3
|
||
// Handed the fitted image rect for the same reason the crop
|
||
// overlay and the repairs are: the photograph is letterboxed
|
||
// inside this box, Slint does not report the fitted rect, and
|
||
// anything that has to land *on* the picture is placed against
|
||
// the derived one. See `GradientHandles` in masks.slint.
|
||
GradientHandles {
|
||
x: canvas-area.shown-x;
|
||
y: canvas-area.shown-y;
|
||
width: canvas-area.shown-w;
|
||
height: canvas-area.shown-h;
|
||
|
||
handles: root.gradient-handles;
|
||
|
||
dragged(role, fx, fy, tx, ty) => {
|
||
root.gradient-handle-dragged(role, fx, fy, tx, ty);
|
||
}
|
||
released() => { root.gradient-handle-released(); }
|
||
}
|
||
|
||
// --- repairs (FR-DEV-8) -------------------------------------
|
||
//
|
||
// Every position in here is a fraction of the fitted image
|
||
// rect, which is why the overlay is handed that rect rather
|
||
// than the canvas box. See `SpotHandles` in spots.slint for
|
||
// what is drawn and why it is drawn at true size.
|
||
SpotHandles {
|
||
x: canvas-area.shown-x;
|
||
y: canvas-area.shown-y;
|
||
width: canvas-area.shown-w;
|
||
height: canvas-area.shown-h;
|
||
|
||
handles: root.spot-handles;
|
||
|
||
selected(id) => { root.spot-selected(id); }
|
||
dragged(id, role, fx, fy, tx, ty) => {
|
||
root.spot-handle-dragged(id, role, fx, fy, tx, ty);
|
||
}
|
||
released() => { root.spot-handle-released(); }
|
||
}
|
||
|
||
// Arrow keys and space step through the folder.
|
||
//
|
||
// Focused on show rather than waiting for a click, exactly
|
||
// as the grid's judgement scope is: without it the first
|
||
// keystroke after opening an image goes nowhere, and — now
|
||
// that the shell answers the back gesture by bubbling —
|
||
// there would be no focus item for `Key.Back` to bubble
|
||
// *from*, so back would close the application from develop
|
||
// instead of returning to the grid.
|
||
FocusScope {
|
||
init => { self.focus(); }
|
||
|
||
// Shift is state, not an event: the click that needs
|
||
// to know about it arrives through a `TouchArea`,
|
||
// which reports no modifiers of its own. Tracked on
|
||
// both edges so releasing it while the pointer is over
|
||
// the canvas is noticed.
|
||
key-released(event) => {
|
||
root.shift-held = event.modifiers.shift;
|
||
// TRACES: FR-DEV-7
|
||
// Letting go of backslash puts the edit back. The
|
||
// release has to be handled here and not inferred
|
||
// from the next press, or a photographer who holds
|
||
// it and then reaches for a slider would be
|
||
// adjusting an image they cannot see.
|
||
if (event.text == "\\") {
|
||
root.compare-original(false);
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
|
||
key-pressed(event) => {
|
||
root.shift-held = event.modifiers.shift;
|
||
// TRACES: FR-DEV-7
|
||
// Hold backslash to see the unedited original.
|
||
// Rust ignores a repeat that says what it already
|
||
// knows, so the key repeating under a long look
|
||
// costs nothing.
|
||
if (event.text == "\\") {
|
||
root.compare-original(true);
|
||
return accept;
|
||
}
|
||
// GESTURE: Take back the last change
|
||
// where: Develop
|
||
// touch: Tap the step above the current one in
|
||
// the History list
|
||
// pointer: Click it, or press Undo in the
|
||
// History header
|
||
// keys: Ctrl+Z
|
||
// why: A whole drag is one step, so undo
|
||
// takes back a decision rather than a
|
||
// frame of a gesture. The list is there
|
||
// because arriving six steps back costs
|
||
// what arriving from one does.
|
||
//
|
||
// GESTURE: Do it again after taking it back
|
||
// where: Develop
|
||
// touch: Tap the step below the current one in
|
||
// the History list
|
||
// pointer: Click it, or press Redo in the
|
||
// History header
|
||
// keys: Ctrl+Shift+Z
|
||
//
|
||
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
||
// of the letter, because the logical key that
|
||
// reaches us carries the shift: holding it for
|
||
// redo turns "z" into "Z".
|
||
if (event.modifiers.control
|
||
&& (event.text == "z" || event.text == "Z")) {
|
||
if (event.modifiers.shift) {
|
||
Steps.redo();
|
||
} else {
|
||
Steps.undo();
|
||
}
|
||
return accept;
|
||
}
|
||
// TRACES: FR-DEV-8
|
||
// Delete removes the selected repair, and only in
|
||
// repair mode: the same key in the grid judges
|
||
// photographs, and a key that means two things
|
||
// depending on a mode the user has forgotten they
|
||
// are in is how work disappears.
|
||
if (Develop.repairing && Repair.selected != ""
|
||
&& (event.text == Key.Delete
|
||
|| event.text == Key.Backspace)) {
|
||
Repair.removed(Repair.selected);
|
||
return accept;
|
||
}
|
||
// GESTURE: Copy the settings from this photograph
|
||
// where: Develop
|
||
// touch: Press Copy in the Settings panel
|
||
// pointer: Press Copy in the Settings panel
|
||
// keys: Ctrl+C
|
||
// why: The panel is the copy that has to
|
||
// work: a tablet has no modifier key
|
||
// to hold and no menu bar to hang the
|
||
// action from. The shortcut is an
|
||
// accelerator for a control that is on
|
||
// screen either way.
|
||
//
|
||
// GESTURE: Paste the settings onto this photograph
|
||
// where: Develop
|
||
// touch: Press Paste in the Settings panel
|
||
// pointer: Press Paste in the Settings panel
|
||
// keys: Ctrl+V
|
||
// why: The button names what would be
|
||
// pasted — "3 adjustments", and
|
||
// whether the crop is coming with it —
|
||
// which the shortcut cannot say. Both
|
||
// paste the same scope.
|
||
//
|
||
// TRACES: FR-DEV-6
|
||
// The desktop accelerators for the two buttons in
|
||
// the Settings panel, which until now the panel's
|
||
// own comment claimed existed and nothing bound.
|
||
// Guarded on `settings-armed` for the reason the
|
||
// button is: a paste with nothing copied is not a
|
||
// clear, it is a keystroke that should do nothing.
|
||
if (event.modifiers.control
|
||
&& (event.text == "c" || event.text == "C")) {
|
||
Transfer.copy();
|
||
return accept;
|
||
}
|
||
if (event.modifiers.control && Transfer.armed
|
||
&& (event.text == "v" || event.text == "V")) {
|
||
Transfer.paste();
|
||
return accept;
|
||
}
|
||
// GESTURE: Change which group of adjustments is on screen
|
||
// where: Develop
|
||
// touch: Tap a group in the rail down the left
|
||
// pointer: Click a group in the strip above the
|
||
// develop column
|
||
// keys: [ and ] step through them, wrapping
|
||
// round through "everything"
|
||
// why: The groups are whatever the operation
|
||
// set declares itself to be about, so
|
||
// there are as many as the pipeline has
|
||
// and no key can be assigned to one of
|
||
// them by name. Stepping is the binding
|
||
// that survives a node being added.
|
||
//
|
||
// TRACES: FR-DEV-3a | FR-UI-5
|
||
// -1 is "everything" and is part of the cycle
|
||
// rather than a way out of it: it is where the
|
||
// panel starts, so a photographer stepping round
|
||
// the groups arrives back at the whole set instead
|
||
// of being trapped in the last one.
|
||
if (event.text == "]") {
|
||
Adjustments.tab-picked(
|
||
(Adjustments.active-tab + 1
|
||
>= Adjustments.tabs.length)
|
||
? -1
|
||
: (Adjustments.active-tab + 1));
|
||
return accept;
|
||
}
|
||
if (event.text == "[") {
|
||
Adjustments.tab-picked(
|
||
(Adjustments.active-tab - 1 < -1)
|
||
? (Adjustments.tabs.length - 1)
|
||
: (Adjustments.active-tab - 1));
|
||
return accept;
|
||
}
|
||
// GESTURE: Look at the photograph at 1:1
|
||
// where: Develop
|
||
// touch: Double-tap the photograph
|
||
// pointer: Double-click it, or press the zoom
|
||
// readout floating over the canvas
|
||
// keys: Z
|
||
// why: Noise reduction and capture
|
||
// sharpening are judgements about
|
||
// single pixels, and a fitted view
|
||
// averages several of the file's into
|
||
// each one on screen — so the frame
|
||
// looks softer than it is and the
|
||
// correction goes too far. The point
|
||
// and the magnification survive opening
|
||
// the next photograph, which is what
|
||
// makes checking the same eye across
|
||
// forty portraits forty keystrokes
|
||
// rather than forty pans.
|
||
//
|
||
// TRACES: FR-UI-4 | FR-UI-5
|
||
// Fit and 1:1 from the keyboard, so inspecting
|
||
// forty portraits at the same eye is forty
|
||
// keystrokes rather than forty pans. Bare, with
|
||
// no modifier: the control-Z case above has
|
||
// already returned, so there is nothing left for
|
||
// this to shadow.
|
||
if (event.text == "z" || event.text == "Z") {
|
||
root.inspect-toggled(-1, -1);
|
||
return accept;
|
||
}
|
||
// TRACES: FR-DEV-16
|
||
// R puts back the control last moved. Bare, like
|
||
// Z: the modifier chords above have all returned.
|
||
// Nothing has been moved on this photograph until
|
||
// something has, so the key is silent until then
|
||
// rather than resetting a control nobody named.
|
||
if ((event.text == "r" || event.text == "R")
|
||
&& Adjustments.touched-op >= 0) {
|
||
Adjustments.param-reset(
|
||
Adjustments.touched-op,
|
||
Adjustments.touched-param);
|
||
return accept;
|
||
}
|
||
// TRACES: FR-DEV-16
|
||
// H hides or shows the selected mask layer — the
|
||
// ring at the head of its row, from the keyboard.
|
||
// With nothing selected there is nothing to hide,
|
||
// and Rust says so by doing nothing.
|
||
if (event.text == "h" || event.text == "H") {
|
||
Masking.selected-toggled();
|
||
return accept;
|
||
}
|
||
// GESTURE: Move to the next or previous photograph
|
||
// where: Develop
|
||
// touch: Tap a frame in the roll along the
|
||
// foot of the canvas
|
||
// pointer: Click a frame in the roll
|
||
// keys: Right arrow or space for the next,
|
||
// left arrow for the one before
|
||
// why: The edit on screen is saved on the
|
||
// way out, so stepping through a folder
|
||
// is as much a departure as going back
|
||
// to the grid and loses nothing.
|
||
if (event.text == Key.RightArrow || event.text == " ") {
|
||
root.next-image();
|
||
return accept;
|
||
}
|
||
if (event.text == Key.LeftArrow) {
|
||
root.prev-image();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// Zoom controls, floating over the canvas.
|
||
//
|
||
// Zoom stays here rather than moving to the sidebar with the
|
||
// crop: it is a property of *looking*, it is driven by the
|
||
// scroll wheel on the image itself, and its readout belongs
|
||
// beside what it is reporting on. Crop moved to the panel
|
||
// because it is an edit, and edits live with the other edits.
|
||
//
|
||
// **"Done" is no longer here because it has to be.** It was:
|
||
// the control that entered a mode was pinned inside the
|
||
// develop column, the column closes, and stranding someone
|
||
// in crop with no visible way out was worse than one
|
||
// duplicated control. The tool rail does not close, so that
|
||
// reason has gone.
|
||
//
|
||
// It stays because of where it is. Finishing a crop is a
|
||
// decision *about the photograph*, taken while looking at
|
||
// the photograph, and a confirmation the width of the
|
||
// window away from the thing being confirmed is a
|
||
// confirmation you take on trust. The rail is now the
|
||
// second way out rather than the first.
|
||
if root.total > 0 && root.load-error == "": HorizontalLayout {
|
||
// **Clear of the photo roll, or these do nothing.**
|
||
//
|
||
// The roll's swipe handler consumes every press that
|
||
// lands inside it and offers it to its own children
|
||
// and to nobody else — so a control drawn underneath
|
||
// that band is drawn, is lit, and then ignores the
|
||
// pointer entirely, hover included. "Done Cropping"
|
||
// sat there and was dead for as long as a library was
|
||
// open, which is the whole time anyone is developing
|
||
// from one.
|
||
//
|
||
// Being declared before the roll is not the fault and
|
||
// reordering is not the fix: the band would then be
|
||
// the thing that could not be reached. They stack
|
||
// instead — the controls ride on top of the roll's
|
||
// band, at whatever height it currently stands.
|
||
property <length> roll-clear:
|
||
Library.library-total > 0
|
||
? (root.roll-open
|
||
? Theme.roll-strip + Theme.roll-reach
|
||
: Theme.roll-reach)
|
||
: 0px;
|
||
// The roll's own travel, so they rise with the strip
|
||
// rather than jumping ahead of it.
|
||
animate roll-clear { duration: 180ms; easing: ease-out; }
|
||
|
||
x: 12px;
|
||
y: parent.height - self.preferred-height - 12px
|
||
- self.roll-clear;
|
||
// Bounded, or the row takes the canvas's whole width
|
||
// and stretches its children across it. With one
|
||
// button that read as an odd-looking "Fit"; with two it
|
||
// reads as a toolbar laid over the photograph, which is
|
||
// the opposite of what a floating control should do.
|
||
width: self.preferred-width;
|
||
spacing: 6px;
|
||
alignment: start;
|
||
|
||
if Develop.view-mode != ViewMode.photo: Button {
|
||
// Names the mode being left rather than saying
|
||
// "Done", which was unambiguous while there was
|
||
// one mode and would not be with two.
|
||
// "Composing", matching what the rail entry and
|
||
// the panel both call this mode. It said "Done
|
||
// Cropping" while the tool said Crop; naming the
|
||
// exit after one of the mode's controls, when the
|
||
// rail beside it named the mode, would be the same
|
||
// control answering to two words.
|
||
text: Develop.cropping
|
||
? "Done Composing"
|
||
: (Develop.repairing ? "Done Repairing" : "Done Masking");
|
||
active: true;
|
||
clicked => { root.mode-picked(ViewMode.photo); }
|
||
}
|
||
|
||
// TRACES: FR-UI-4 | NFR-A11Y-2
|
||
// Zoom is a view state, so its readout doubles as the
|
||
// control that clears it — and, when there is nothing
|
||
// to clear, as the way in to 1:1.
|
||
//
|
||
// **The non-gesture half of the double tap.** FR-UI-4
|
||
// requires every gesture-driven action to have an
|
||
// equivalent that is not a gesture, and a magnifier
|
||
// reachable only by double-tapping the picture is a
|
||
// feature for people who already know it is there.
|
||
// Enabled unconditionally now, because "Fit" on a
|
||
// fitted photograph really was dead and this is not.
|
||
Button {
|
||
text: root.zoomed
|
||
? Math.round(root.zoom * 100) + "%"
|
||
: "1:1";
|
||
active: root.zoomed;
|
||
// No point of its own, so it asks for the last
|
||
// one: pressing this returns to whatever was
|
||
// being looked at rather than to the middle of a
|
||
// frame nobody was looking at the middle of.
|
||
clicked => { root.inspect-toggled(-1, -1); }
|
||
}
|
||
|
||
// GESTURE: See the photograph before you edited it
|
||
// where: Develop
|
||
// touch: Press and hold "Before"
|
||
// pointer: Press and hold "Before"
|
||
// keys: Hold \
|
||
// why: Held rather than toggled, and no split
|
||
// screen: a split halves the working image
|
||
// on the tablet the column was sized for,
|
||
// and the comparison photographers describe
|
||
// making is a flick back and forth. It
|
||
// takes no history step, so checking
|
||
// whether a frame is overcooked costs
|
||
// nothing to undo afterwards.
|
||
//
|
||
// TRACES: FR-DEV-7 | FR-DEV-3b | NFR-A11Y-2
|
||
// The unedited original, for as long as it is held.
|
||
//
|
||
// **A hold rather than a split screen.** A split view
|
||
// halves the working image, which on the tablet this
|
||
// column was sized for leaves neither half worth
|
||
// judging; and the comparison a photographer actually
|
||
// describes making is a flick back and forth, not two
|
||
// pictures side by side. A hold is also the cheaper
|
||
// thing to build: one extra render of a graph that is
|
||
// already there, against a second canvas that would
|
||
// have to be laid out, sized and kept in step.
|
||
//
|
||
// Press-and-hold is the same gesture with a finger and
|
||
// with a mouse, which is what FR-DEV-3b's mapping
|
||
// wants — no long-press timer, no mode, and no way to
|
||
// be left in it. The keyboard's backslash does the
|
||
// same thing on both edges; see the focus scope below.
|
||
Button {
|
||
text: "Before";
|
||
enabled: Develop.enabled;
|
||
active: root.showing-original;
|
||
accessible-checkable: true;
|
||
accessible-checked: root.showing-original;
|
||
held(down) => { root.compare-original(down); }
|
||
}
|
||
}
|
||
|
||
// The photo roll, along the foot of the canvas.
|
||
//
|
||
// Only where a library was opened: with files named on the
|
||
// command line there is no window of photographs to roll
|
||
// through, which is the same condition the "‹ Library"
|
||
// button uses.
|
||
//
|
||
// Last in the canvas, so it draws over the photograph
|
||
// rather than under it.
|
||
if Library.library-total > 0: PhotoRoll {
|
||
width: 100%;
|
||
height: 100%;
|
||
cells: Library.library-cells;
|
||
current: Library.library-roll-current;
|
||
open <=> root.roll-open;
|
||
centre-request <=> Library.library-roll-centre;
|
||
pick(i) => { Library.library-roll-pick(i); }
|
||
}
|
||
|
||
// Report size changes so the render target can be resized to
|
||
// match. Width and height are tracked separately because
|
||
// Slint has no single "geometry changed" hook.
|
||
property <int> px-w: Math.round(self.width / 1px);
|
||
property <int> px-h: Math.round(self.height / 1px);
|
||
changed px-w => { root.canvas-resized(self.px-w, self.px-h); }
|
||
changed px-h => { root.canvas-resized(self.px-w, self.px-h); }
|
||
}
|
||
|
||
// Always instantiated, width collapsed to zero when closed. A
|
||
// conditional `if` here creates a binding loop — the layout
|
||
// depends on `panel-visible`, which derives from the window
|
||
// width, which the layout then influences. Slint flags it, and
|
||
// it can panic at runtime.
|
||
develop-column := Rectangle {
|
||
// TRACES: FR-UI-1
|
||
// **One width, stated once, and not measured.**
|
||
//
|
||
// This column used to size itself from its contents: every
|
||
// panel published a `content-width`, declared it as its own
|
||
// `min-width`, and the column took the largest. That was an
|
||
// answer to a real problem — before it, the width was two
|
||
// guessed constants (280px for a tablet, 380px for a
|
||
// desktop) and the generated chip row outgrew both, so a
|
||
// control ended up somewhere you had to pan to find.
|
||
//
|
||
// **It solved that by making the photograph pay.** The
|
||
// pixels a sidebar takes come out of the picture beside it,
|
||
// and a measured sidebar spends them on whatever happens to
|
||
// be widest: an axis label gaining a digit made the image
|
||
// smaller, and switching tools swapped one set of panels for
|
||
// another and shifted the image sideways under the eye
|
||
// that was judging it. In an application whose entire job is
|
||
// showing you a photograph accurately, the frame around it
|
||
// must not move because a caption changed.
|
||
//
|
||
// So the number is mandated: `panel-width`, in
|
||
// `style.yaml`, next to the reasoning and next to the rail
|
||
// on the other side, which is fixed for the same reason. A
|
||
// panel that wants more than that clips, and the Flickable
|
||
// below is what makes the rest of it reachable — the same
|
||
// fallback as before, now the first answer rather than the
|
||
// last. `AdjustPanel` is the panel the number was chosen
|
||
// against; its note says why.
|
||
//
|
||
// The generated row that started all this is not a problem
|
||
// any more either, because it is no longer in the column's
|
||
// way: the tools moved to `ToolRail` and what is left is
|
||
// group filters, which pan within their own strip without
|
||
// anything else having to move.
|
||
//
|
||
// `panel-max-width` survives, and it is the one thing here
|
||
// that is not a size: it is the policy that a column may
|
||
// not take the window from the photograph it exists to
|
||
// serve, and it bites only on a window narrow enough that
|
||
// 360px would. It comes from Rust rather than from
|
||
// `root.width` because reading the window width inside the
|
||
// layout that sets it is the binding loop the comment on
|
||
// `expanded` above describes; Rust already measures the
|
||
// window for `layout-class`, so this is one more thing said
|
||
// in the same breath.
|
||
//
|
||
// **Which of those two numbers applies is the only thing
|
||
// `column-below` changes here.** The column's contents,
|
||
// its collapse and its toggle are the same on both axes —
|
||
// it is the same column, moved, and not a second one.
|
||
//
|
||
// Beside the photograph it is `column-width` wide against
|
||
// the frame's right edge and full height. Under it, it is
|
||
// `dock-height` tall against the frame's bottom edge and
|
||
// runs from the rail to that same right edge. `dock-height`
|
||
// rather than a measured height for the reason the note
|
||
// above gives about the width: a dock that sizes itself to
|
||
// its contents is a photograph that changes height when a
|
||
// caption wraps.
|
||
x: root.column-below ? rail.width : parent.width - parent.column-width;
|
||
y: root.column-below ? parent.height - parent.dock-height : 0;
|
||
width: root.column-below ? parent.width - rail.width : parent.column-width;
|
||
height: root.column-below ? parent.dock-height : parent.height;
|
||
visible: root.panel-visible;
|
||
background: Theme.surface;
|
||
clip: true;
|
||
|
||
// **The whole column scrolls, histogram included.**
|
||
//
|
||
// Only the sliders used to. Everything above them — the
|
||
// capture metadata, the histogram, the geometry controls,
|
||
// copy and paste — was fixed, so on a 280px column in
|
||
// portrait they took the height the sliders needed and the
|
||
// histogram could not be moved out of the way. It is the
|
||
// instrument the sliders are judged against; being unable
|
||
// to scroll to it, or past it, is the worst of both.
|
||
//
|
||
// `interactive` stands down while a track has a gesture,
|
||
// the same arbitration `AdjustPanel` used to do for its own
|
||
// scroller — see the note on `SliderTrack`'s `claimed`.
|
||
// Without it every attempt to drag a slider would scroll
|
||
// the column instead.
|
||
// Pinned: the strip is how the column is navigated, so it
|
||
// cannot be somewhere you have to navigate to.
|
||
// A layout, because the parent is a Rectangle: two children of one
|
||
// would both sit at its origin and overlap. The strip is pinned by
|
||
// being outside the Flickable rather than by any coordinate.
|
||
VerticalLayout {
|
||
// Stands down when the rail is carrying the groups, and
|
||
// collapses itself to zero height rather than being
|
||
// conditioned here — a conditional child in this layout is
|
||
// the shape that has caused binding loops in this file
|
||
// before. `GroupStrip.live` holds the rule.
|
||
GroupStrip { }
|
||
|
||
Flickable {
|
||
viewport-height: column.preferred-height;
|
||
// **And the width, or the column is drawn centred
|
||
// over its own edges.**
|
||
//
|
||
// Unset, a Flickable's viewport takes its content's
|
||
// preferred width, and a viewport wider than the
|
||
// Flickable is *centred* in it — the same rule the
|
||
// note on the seam's `x: 0` below records. So a
|
||
// column narrower than what it held did not clip
|
||
// the right-hand side and keep the left, it lost
|
||
// half of each: "HISTOGRAM" read "ISTOGRAM",
|
||
// "Straighten" read "aighten", and Paste ran off
|
||
// the far side. It looked like a rendering fault
|
||
// and it was an alignment one.
|
||
//
|
||
// **`min-width`, not `preferred-width`, and that is
|
||
// what makes a mandated column work at all.**
|
||
//
|
||
// With the preferred width here, the content laid
|
||
// itself out at whatever size it would have liked
|
||
// and the column clipped the difference: Paste came
|
||
// out sliced down the middle and the panel's reset
|
||
// was over the window's edge. That is the same
|
||
// fault as above wearing a different hat — the
|
||
// content was never told how much room it had, so
|
||
// it could not adapt to having less.
|
||
//
|
||
// The floor is the layout's *minimum* instead. A
|
||
// preferred width is what a panel would enjoy; a
|
||
// minimum is what it cannot go below, and between
|
||
// the two the layout does the work it exists to do
|
||
// — rows tighten, stretches give way — so the
|
||
// column fits because it was asked to rather than
|
||
// by luck.
|
||
//
|
||
// That does less here than it sounds like, and it
|
||
// is worth knowing why: this column's minimum and
|
||
// its preferred width are within a few pixels of
|
||
// each other, because a `Text` that does not elide
|
||
// reports the same for both and most of what is in
|
||
// here is text. So `panel-width` still has to be a
|
||
// number the contents actually fit in — see its
|
||
// note in `style.yaml` for the measurement. What
|
||
// this binding buys is that the clipping, when it
|
||
// comes, is of a panel that genuinely cannot
|
||
// shrink rather than of one that simply was not
|
||
// asked to, and that panning is then a real
|
||
// fallback rather than a permanent condition.
|
||
viewport-width: max(self.width, column.min-width);
|
||
interactive: !adjust.slider-dragging;
|
||
|
||
// **What the column holds is the mode's answer.**
|
||
//
|
||
// In local mode it is the mask stack and then the
|
||
// selected layer's adjustments, and nothing else:
|
||
// the capture metadata describes the file, copy and
|
||
// paste move a whole edit between photographs, and
|
||
// framing is a decision about the picture's shape —
|
||
// none of the three is a property of a region
|
||
// within it, so all three would be controls in
|
||
// scope of nothing.
|
||
//
|
||
// **Each child carries its own condition** rather
|
||
// than the three being grouped inside one `if`.
|
||
// A nested layout under-reports its height here and
|
||
// the panels below it get drawn on top of each
|
||
// other — invisible in this file and obvious on
|
||
// screen. `masks.slint` carries the same note for
|
||
// the same reason.
|
||
column := VerticalLayout {
|
||
if !Develop.local-mode && !Develop.repairing: InfoPanel { }
|
||
|
||
if !Develop.local-mode && !Develop.repairing: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Directly under the capture metadata and above every
|
||
// control, because it is the thing the controls are
|
||
// judged against: exposure, blacks and whites are all
|
||
// set by watching this move (FR-DSP-7). An instrument
|
||
// below the sliders it reports on would have the
|
||
// photographer looking away from it to use it.
|
||
//
|
||
// Kept in local mode, where it still reports the
|
||
// whole frame while the sliders edit a layer. That
|
||
// disagreement is real and is N3's to close; taking
|
||
// the instrument away instead would be a worse
|
||
// answer than an honest one that is not yet scoped.
|
||
HistogramPanel { }
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// TRACES: FR-CULL-3
|
||
// Under the histogram, because the two are the same
|
||
// kind of thing: instruments that report on the
|
||
// photograph rather than change it. Kept in every
|
||
// mode for the same reason the histogram is.
|
||
FocusPanel { }
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Framing above the colour work, matching how the edit is
|
||
// made rather than how it is applied: the frame is decided
|
||
// by eye first and the pipeline runs it last (see
|
||
// `dr_pipeline::framing` on the coordinate order).
|
||
if !Develop.local-mode && !Develop.repairing: ComposePanel { }
|
||
|
||
if !Develop.local-mode && !Develop.repairing: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Between the framing and the colour work, because it
|
||
// acts on both: a paste is about the whole edit, and
|
||
// burying it under thirty sliders would put the one
|
||
// control that operates on all of them below all of
|
||
// them.
|
||
if !Develop.local-mode && !Develop.repairing: TransferPanel { }
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Above the adjust panel, because it decides what
|
||
// those sliders act on. Below it, the photographer
|
||
// would set an exposure and only then discover which
|
||
// scope it landed in.
|
||
//
|
||
// Only in local mode: it is no longer a panel among
|
||
// peers that silently re-points another panel, it is
|
||
// what that mode's column *is*. That also takes one
|
||
// panel out of a scrolling column that had six.
|
||
// TRACES: FR-DEV-8
|
||
// What the column *is* in repair mode, on the same
|
||
// terms as the mask panel: not a panel among peers
|
||
// that quietly re-points the sliders below it, but
|
||
// the whole of what this mode has to say.
|
||
if Develop.repairing: SpotPanel { }
|
||
|
||
if Develop.repairing: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
if Develop.local-mode: MaskPanel { }
|
||
|
||
if Develop.local-mode: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
adjust := AdjustPanel { }
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Last in the column, and the length is why. The
|
||
// stack runs sixty-four deep, so anywhere else it
|
||
// would push the controls it is a record of below
|
||
// the fold. Its own rows run newest-first, which
|
||
// puts the steps worth reaching immediately under
|
||
// the heading rather than at the far end of them.
|
||
//
|
||
// Present in local mode as well. The mask work
|
||
// *is* history — adding a layer, moving a
|
||
// gradient and feathering an edge are all steps —
|
||
// and a list that emptied when the photographer
|
||
// entered the mode that produces the most steps
|
||
// would be a list they stopped trusting.
|
||
HistoryPanel { }
|
||
}
|
||
}
|
||
}
|
||
|
||
// The seam between the photograph and the column, on
|
||
// whichever side of the column the photograph is.
|
||
//
|
||
// **`x: 0` and `y: 0` are load-bearing.** A child of a
|
||
// plain Rectangle that declares a size and no position is
|
||
// *centred* in it, so this hairline was being drawn
|
||
// straight down the middle of the develop column — over the
|
||
// histogram, the geometry controls and every slider below
|
||
// them. Invisible in the source and, at one pixel of `rule`
|
||
// grey, quiet enough on screen to be read as a divider that
|
||
// was meant to be there.
|
||
Rectangle {
|
||
x: 0;
|
||
y: 0;
|
||
width: root.column-below ? parent.width : 1px;
|
||
height: root.column-below ? 1px : parent.height;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the load bar -------------------------------------------------
|
||
//
|
||
// Last in the file, so it is last in z-order and no view can cover it.
|
||
//
|
||
// At the top of the *shell* rather than inside a view, because that is
|
||
// the only place that is true of every view: a pin download outlives
|
||
// the grid it was started from, and until this existed it drew nothing
|
||
// at all once the user opened an image. Three pixels, no text and no
|
||
// hit area — it says only "something is running", and the settings
|
||
// page says what (FR-CAT-1, FR-NC-6c).
|
||
if root.activity-busy: ProgressBar {
|
||
x: 0;
|
||
y: 0;
|
||
width: 100%;
|
||
indeterminate: !root.activity-determinate;
|
||
fraction: root.activity-fraction;
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The offline question, over the shell rather than inside the sidebar
|
||
// that raises it. The sidebar is 232px wide and closes entirely in the
|
||
// compact layout, so a dialogue living in there would be unreadable on
|
||
// the device the gesture exists for — and the answer commits gigabytes
|
||
// either way, which deserves the width to say so.
|
||
//
|
||
// Below the load bar, above everything else: a download started from
|
||
// here shows its progress in that bar, and the bar must not be the
|
||
// thing the dialogue covers.
|
||
// TRACES: FR-DEV-6
|
||
// Over the shell rather than inside a view, because both views open
|
||
// it — and because the develop column is 360px wide, which is not
|
||
// enough to list presets and rename one in.
|
||
if root.presets-open: PresetSheet {
|
||
width: 100%;
|
||
height: 100%;
|
||
names: root.preset-names;
|
||
can-save: root.preset-can-save;
|
||
apply-count: root.preset-apply-count;
|
||
capture-summary: root.preset-capture-summary;
|
||
name-error: root.preset-name-error;
|
||
scope-kinds: root.copy-scope-kinds;
|
||
scope-empty: root.copy-scope-empty;
|
||
scope-toggled(name) => { root.copy-scope-toggled(name); }
|
||
import-report: root.preset-import-report;
|
||
import-presets(path) => { root.import-presets(path); }
|
||
save(name) => { root.save-preset(name); }
|
||
apply(name) => { root.apply-preset(name); }
|
||
rename(from, to) => { root.rename-preset(from, to); }
|
||
remove(name) => { root.delete-preset(name); }
|
||
name-edited(text) => { root.preset-name-edited(text); }
|
||
dismiss => { root.presets-open = false; }
|
||
}
|
||
|
||
OfflinePrompt {
|
||
width: 100%;
|
||
height: 100%;
|
||
title: root.offline-prompt-title;
|
||
detail: root.offline-prompt-detail;
|
||
keep-label: root.offline-prompt-keep-label;
|
||
release-label: root.offline-prompt-release-label;
|
||
can-keep: root.offline-prompt-can-keep;
|
||
can-release: root.offline-prompt-can-release;
|
||
busy: root.offline-prompt-busy;
|
||
keep() => { root.offline-prompt-keep(); }
|
||
release() => { root.offline-prompt-release(); }
|
||
dismiss() => { root.offline-prompt-dismiss(); }
|
||
}
|
||
|
||
// TRACES: FR-CAT-7 | FR-UI-4
|
||
// The row menu and the membership sheet, above the offline prompt for
|
||
// the same reason it sits above the shell: both are opened from a
|
||
// 232px sidebar that disappears entirely in the compact layout, so
|
||
// neither can be drawn inside the thing that raises it.
|
||
//
|
||
// The two never coexist — one is about a collection, the other about a
|
||
// selection — but they are laid out in the order Back unwinds them,
|
||
// which is the order they are checked in above.
|
||
MembershipSheet {
|
||
width: 100%;
|
||
height: 100%;
|
||
open: Collections.membership-open;
|
||
selected-count: Library.library-selected-count;
|
||
rows: Collections.membership-rows;
|
||
remove(id) => { Collections.membership-remove(id); }
|
||
dismiss() => { Collections.membership-dismiss(); }
|
||
}
|
||
|
||
CollectionMenu {
|
||
width: 100%;
|
||
height: 100%;
|
||
title: Collections.collection-menu-title;
|
||
detail: Collections.collection-menu-detail;
|
||
nested: Collections.collection-menu-nested;
|
||
smart: Collections.collection-menu-smart;
|
||
confirming: Collections.collection-menu-confirming;
|
||
confirm-detail: Collections.collection-menu-confirm-detail;
|
||
delete-label: Collections.collection-menu-delete-label;
|
||
pinned: Collections.collection-menu-pinned;
|
||
rename() => { Collections.collection-menu-rename(); }
|
||
new-child() => { Collections.collection-menu-new-child(); }
|
||
promote() => { Collections.collection-menu-promote(); }
|
||
offline() => { Collections.collection-menu-offline(); }
|
||
delete() => { Collections.collection-menu-delete(); }
|
||
dismiss() => { Collections.collection-menu-dismiss(); }
|
||
}
|
||
|
||
// Last, and therefore over everything including the settings page and
|
||
// the offline prompt. Not a preference about layering: this is asked
|
||
// before the grid exists, and nothing else in the window is about a
|
||
// library that can be read.
|
||
RecoveryPrompt {
|
||
width: 100%;
|
||
height: 100%;
|
||
title: root.recovery-title;
|
||
detail: root.recovery-detail;
|
||
diagnosis: root.recovery-diagnosis;
|
||
restore-label: root.recovery-restore-label;
|
||
can-restore: root.recovery-can-restore;
|
||
rebuild-label: root.recovery-rebuild-label;
|
||
busy: root.recovery-busy;
|
||
restore() => { root.recovery-restore(); }
|
||
rebuild() => { root.recovery-rebuild(); }
|
||
dismiss() => { root.recovery-dismiss(); }
|
||
}
|
||
}
|
||
}
|