import { Theme } from "theme.slint"; import { Develop, ViewMode } from "session.slint"; import { AdjustPanel, Adjustments, ComposePanel, Framing, GroupStrip, ParamRow, Transfer } 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, GestureSheet } from "gestures.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, WaitingState, ProgressBar, ActivityRow, Scrolling, ScrollBar } 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 { CopySheet, PresetOrigin, PresetRow, PresetSheet, ScopeChips, ScopeKind } from "presets.slint"; import { ExportOptions, ExportSheet } from "export.slint"; import { Albums, AlbumSheet, AlbumRow } from "albums.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 { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint"; import { Capture, ProfileOffer, StatusBar, InfoPanel } from "develop.slint"; import { ToolRail } from "toolrail.slint"; import { Keys } from "keys.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::()` 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, ProfileOffer, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps } export { Collections } export { Library } export { ExportOptions } export { Albums, AlbumRow } export { Pickers } from "pickers.slint"; export { Duplicates, DuplicateRow, DuplicateCopy } export { Scrolling } /// 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, /// TRACES: FR-CAT-11a /// The review of duplicate originals. duplicates, } 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 canvas; /// TRACES: FR-DSP-4 /// Whether `canvas` is a draft: half resolution, rendered mid-gesture. in property canvas-draft; /// The last draft frame, kept for the crossfade into the sharp one. /// /// Set only on draft frames, so on the frame that settles it still holds /// the draft that frame replaces. No render is spent on it: it is the /// texture the draft was already drawn into, and `AdjustPass` ping-pongs /// between two targets, so the sharp frame is written into the *other* /// one (and at a different size, which reallocates rather than /// overwrites). The fade is the compositor blending two textures it /// already holds. in property canvas-previous; in property adapter: "detecting…"; in property backend: "—"; in property inference-backend: "detecting…"; in property inference-detail: ""; in property 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 display-name: "detecting…"; in property display-colour: "detecting…"; in property 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 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 diagnostics-preview; in property diagnostics-result; callback diagnostics-prepare(); callback diagnostics-save(); callback diagnostics-discard(); // Current image, for the status strip and empty state. in property filename: ""; in property index: 0; in property total: 0; in property load-error: ""; /// TRACES: FR-NC-6a /// A photograph has been asked for and is not on the canvas yet — it is /// being read from the cache, or downloaded, or decoded. The previous /// frame is gone from view by then; the grid's thumbnail stands in. in property load-pending: false; /// And it is not on this device: what has arrived, as a sentence /// ("12.4 of 38.0 MB"). Empty while it is only being read from disk, which /// is too quick to be worth a headline. /// /// Its own property rather than a message in `load-error`, which is where /// it used to live — and so the view headed a download that was going /// fine with "Could not load image", on every step, cached or not. in property load-waiting: ""; /// How much of it, 0..1, or below zero while the size is not known yet. in property load-fraction: -1; /// The grid's thumbnail of the photograph being fetched, shown dimmed /// behind the bar, so stepping along the roll moves from picture to /// picture rather than through an empty frame. in property load-preview; in property has-load-preview: false; /// A photograph is on the canvas and is the one named: not an empty /// folder, not a failure, not a download still coming in. Everything that /// acts on the picture is gated on this. property has-photo: root.total > 0 && root.load-error == "" && !root.load-pending; /// 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 focus-overlay; in property 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 zoom: 1.0; in property zoomed: false; /// Whether one source pixel now covers one screen pixel or more — 1:1 or /// closer, in physical pixels. /// /// 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 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 crop-x: 0.0; in-out property crop-y: 0.0; in-out property crop-w: 1.0; in-out property 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 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 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 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 launch-signed-in: false; in property launch-account: ""; in property launch-root: ""; in property launch-endpoint-is-library: false; in property launch-server: ""; in property launch-folder: ""; in property launch-busy: false; in property launch-status: ""; in property launch-error: ""; in property launch-login-url: ""; in property 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); callback launch-browse-local-folder(); /// 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 launch-browsing: false; in property launch-browse-path: ""; in property 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(); callback launch-browse-make(string); // --- 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 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 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 activity-determinate: false; in property activity-fraction: 0; in property activity-running: 0; /// Jobs that stopped and are being kept: finished ones as a short history, /// failed ones until they are cleared. in property activity-kept: 0; callback activity-clear-finished(); // 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 offline-prompt-title: ""; in property offline-prompt-detail: ""; in property offline-prompt-keep-label: ""; in property offline-prompt-release-label: ""; in property offline-prompt-can-keep: false; in property offline-prompt-can-release: false; in property 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 recovery-title: ""; in property recovery-detail: ""; in property recovery-diagnosis: ""; in property recovery-restore-label: ""; in property recovery-can-restore: false; in property recovery-rebuild-label: ""; in property recovery-busy: false; callback recovery-restore(); callback recovery-rebuild(); callback recovery-dismiss(); // --- 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 identity-selected: -1; in property identity-selected-name; in property identity-unassigned: 0; in property identity-calibrated: false; in property identity-indexing: false; in property identity-indexing-status; in property identity-model-missing: false; in property identity-back-label: "‹ Library"; in property identity-coverage; in property identity-coverage-complete: false; in property identity-coverage-read-only: false; in property identity-picked: 0; /// The namesake merge the Identity screen is offering. See IdentityScreen. in property identity-merge-offer-name; in property identity-merge-offer-faces: 0; /// See IdentityScreen.name-revision. in property identity-name-revision: 0; callback identity-open(); callback identity-close(); /// TRACES: FR-UI-5 /// Up or down the people rail by one, from the keyboard. callback identity-person-stepped(int); 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 identity-regrouping: false; in property 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 identity-merge-probability: 80; in property identity-min-group-size: 2; callback identity-merge-probability-changed(float); callback identity-min-group-size-changed(int); callback identity-preview-grouping(); in property identity-previewing: false; in property identity-grouping-preview; in property identity-ignored-count: 0; in property identity-show-ignored: false; in property 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(); /// 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 roll-open: false; /// 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 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 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); 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 presets-open: false; in property <[PresetRow]> preset-rows; /// 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 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 preset-apply-count: 0; /// What saving would capture, from the routine the clipboard summary uses. in property preset-capture-summary; /// Why the last name was refused, cleared by the next keystroke. in property 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); /// A category folder in the preset menu or sheet was opened or closed. callback toggle-preset-folder(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 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 preset-import-report; callback import-presets(string); callback browse-presets(bool); // --- 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 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 import-supported: false; in property <[string]> import-volume-labels; in property <[string]> import-volume-details; in property import-volume-selected: -1; in property import-card-path: ""; in property import-card-looks-right: false; in property import-needs-access: false; in property import-surveying: false; in property import-survey-summary: ""; in property import-upload-target: ""; in property import-folder-template: ""; in property import-template-preview: ""; in property <[string]> import-mode-labels; in property import-mode-selected: 0; in property import-mode-is-move: false; in property <[string]> import-duplicate-labels; in property import-duplicate-selected: 0; in property import-verify: true; in property import-backup-on: false; in property import-backup-path: ""; in property import-running: false; in property import-fraction: 0.0; in property import-status: ""; in property import-error: ""; in property 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 merge-stage: ""; in property merge-running: false; in property merge-fraction: 0.0; in property merge-status: ""; in property merge-error: ""; in property merge-aligned: false; in property merge-complete: false; in property merge-summary: ""; in property <[MergeFrameRow]> merge-frames; in property merge-preview; in property merge-projection-selected: 0; in property merge-border-selected: 0; in property merge-fill-available: false; in property merge-fill-note: ""; in property merge-preview-filled: false; // The fill's knobs (experimental; FR-MRG-4), in working pixels. in property merge-fill-scale: 2; in property merge-fill-erosion: 4; in property merge-fill-coarse: 4; in property merge-fill-band: 96; in property merge-fill-mirror: 48; in property merge-fill-feather: 48; in property merge-done: false; /// The confirmation (FR-MRG-1). Nothing is written before this. callback merge-confirm(); callback merge-abandon(); callback merge-projection-picked(int); callback merge-frame-toggled(int, bool); 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-choose-card(); callback import-refresh-volumes(); callback import-grant-access(); 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-choose-backup(); callback import-start(); callback import-stop(); in property settings-original-budget: ""; in property settings-original-unlimited: false; in property settings-thumbnail-budget: ""; in property settings-thumbnail-unlimited: false; in property settings-keep-opened: true; in property <[string]> settings-fetch-ahead-labels; in property settings-fetch-ahead-selected: 0; callback settings-fetch-ahead-picked(int); in property 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 settings-timeline-bars-selected: 0; callback settings-timeline-bars-picked(int); /// TRACES: FR-CAT-13 in property settings-write-xmp: false; callback settings-write-xmp-toggled(bool); in property 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 settings-group-nav-selected: 0; in property settings-group-nav-auto-says; in property settings-error: ""; /// TRACES: FR-CULL-8 /// Which face detector the indexing pass runs. in property <[string]> settings-face-detector-labels; in property settings-face-detector-selected: 0; callback settings-face-detector-picked(int); callback settings-group-nav-picked(int); 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(); /// TRACES: FR-EXP-7 | FR-UI-5 /// The export sheet: the export defaults on their own with an Export /// button, over develop or the grid. Ctrl+E. Opened through Rust, which /// reads the settings file first for the reason `settings-open` does. in-out property export-sheet-open: false; callback export-options-open(); /// TRACES: FR-DEV-6 | FR-UI-5 /// The copy sheet: which kinds of edit a copy carries, and Copy. Ctrl+Shift+C. in-out property copy-sheet-open: false; /// TRACES: FR-UI-4 | FR-UI-5 /// The gesture reference, "Controls and shortcuts": Help, or F1. Over /// the shell rather than inside the grid, because develop — where most /// of the keys are — has to be able to open it too. in-out property help-open: false; /// TRACES: FR-EXP-7 | FR-UI-5 /// Whether the export sheet's button would do anything — the develop /// view's photograph, or the grid's selection. One definition for the /// button and for Enter, so the key cannot export what the greyed button /// would not. property export-sheet-can-export: root.active-view == View.develop ? !root.export-busy && root.total > 0 : !Library.library-exporting && Library.library-selected-count > 0; /// What the export sheet's button does. function export-from-sheet() { root.export-sheet-open = false; if (root.active-view == View.develop) { root.export-image(); } else { Library.library-export-selection(); } } /// TRACES: FR-DEV-16 | FR-DEV-20 /// The framing slider moved last, nudged a hundredth of its travel — the /// step its own increment takes — and committed, because one keystroke is /// a whole gesture and the refit of the crop hangs off the end of one. function framing-nudged(direction: int) { if (Framing.touched == 1) { Framing.angle-changed(clamp( Framing.angle + direction * Framing.max-straighten / 50, -Framing.max-straighten, Framing.max-straighten)); Framing.angle-committed(Framing.angle); } else if (Framing.touched == 2) { Framing.keystone-v-changed(clamp( Framing.keystone-v + direction * Framing.max-keystone / 50, -Framing.max-keystone, Framing.max-keystone)); Framing.keystone-committed(); } else if (Framing.touched == 3) { Framing.keystone-h-changed(clamp( Framing.keystone-h + direction * Framing.max-keystone / 50, -Framing.max-keystone, Framing.max-keystone)); Framing.keystone-committed(); } } /// TRACES: FR-DEV-16 | FR-DEV-20 /// The framing slider moved last, back to level — what its own reset does. function framing-reset-touched() { if (Framing.touched == 1) { Framing.angle-changed(0); } else if (Framing.touched == 2) { Framing.keystone-v-changed(0); Framing.keystone-committed(); } else if (Framing.touched == 3) { Framing.keystone-h-changed(0); Framing.keystone-committed(); } } /// TRACES: FR-UI-5 /// One photograph along, from the keyboard. /// /// `next-image` and `prev-image` walk the files given on the command /// line, and a photograph opened from a library leaves that list empty — /// so with a library open the arrows, space, A and D did nothing at all, /// and only a click on the roll moved on. With a library the step is the /// roll's own: open the neighbouring frame in grid order. By ordinal in /// Rust, not by the roll's row, because the roll holds only the loaded /// window and a row past its end picked nothing. function step-photo(delta: int) { if (Library.library-total > 0) { Library.library-roll-step(delta); } else if (delta > 0) { root.next-image(); } else { root.prev-image(); } } // 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 expanded: true; in property 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 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 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 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 overlay-strength: 0.55; in property 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 overlay-clip-x; in property overlay-clip-y; in property overlay-clip-w; in property overlay-clip-h; /// The straightening angle, so the overlay turns with the frame. in property 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 region-picking: Develop.local-mode && Masking.segmented && Masking.tool == 0; property 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 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 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 export-label: "Export"; in property export-busy: false; in property 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 export-album; /// 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 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 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. // GESTURE: Close what is open, or go back a step // where: Everywhere // touch: The system Back gesture, or the Back button // pointer: The Close or Back button on whatever is open // keys: `Escape`, or `Back` where the device has one // why: One key for "up one", innermost first: a question // before the sheet under it, a sheet before the view, a // view before the library. Nothing is left behind a // dialogue that the key walked straight past. // // GESTURE: Do what a sheet offers // where: Everywhere // touch: Press its button — Export, or Copy // pointer: Press its button — Export, or Copy // keys: `Enter`, on the export and copy sheets // // KEYMAP: Everywhere 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 (Keys.chord(event) == "Back" || Keys.chord(event) == "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 help sheet and the two shortcut sheets, which are drawn // over everything but the recovery question. if (root.help-open) { root.help-open = false; return accept; } if (root.export-sheet-open) { root.export-sheet-open = false; return accept; } if (root.copy-sheet-open) { root.copy-sheet-open = false; 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; } // TRACES: FR-UI-5 | FR-EXP-7 | FR-DEV-6 // Enter does what the sheet's own button does. A text field in // the sheet that has focus takes Enter first, so typing a folder // name and pressing it does not export from under the typing. if (Keys.chord(event) == "Enter") { if (root.export-sheet-open && root.export-sheet-can-export) { root.export-from-sheet(); return accept; } if (root.copy-sheet-open && root.has-photo) { root.copy-sheet-open = false; Transfer.copy(); return accept; } } 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; needs-access: root.import-needs-access; 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); } choose-card => { root.import-choose-card(); } refresh-volumes() => { root.import-refresh-volumes(); } grant-access() => { root.import-grant-access(); } 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); } choose-backup => { root.import-choose-backup(); } 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); } frame-toggled(i, on) => { root.merge-frame-toggled(i, on); } 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.duplicates: DuplicatesPage { width: 100%; height: 100%; } 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; error: root.settings-error; group-nav-picked(i) => { root.settings-group-nav-picked(i); } activity-rows: root.activity-rows; activity-running: root.activity-running; activity-kept: root.activity-kept; clear-finished() => { root.activity-clear-finished(); } thumbnailing: Library.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: Library.library-open; thumbnail-library() => { Library.library-thumbnail-all(); } index-faces() => { root.identity-index(); } reindex-faces() => { root.identity-reindex(); } duplicate-groups: Duplicates.group-count; find-duplicates() => { Duplicates.open(); } // 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(); } open-manual() => { Library.library-open-manual(""); } 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: Library.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(); } person-stepped(d) => { root.identity-person-stepped(d); } } // 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); } browse-local-folder => { root.launch-browse-local-folder(); } 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(); } browse-make(name) => { root.launch-browse-make(name); } } // 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-whole-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: Library.library-scanning; scope-pinned: Library.library-scope-pinned; toggle-pin-scope() => { Library.library-toggle-pin-scope(); } change-library() => { Library.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(); } // The name field took the keyboard; when a key // finished the name, the grid gets it back. keys-released() => { library-grid.take-keys(); } 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); } } } library-grid := 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; total-slots: Library.library-total-slots; scanning: Library.library-scanning; opening: Library.library-opening; scan-status: Library.library-status; scan-error: Library.library-error; offline: Library.library-offline; offline-reason: Library.library-offline-reason; offline-since: Library.library-offline-since; retry-connection() => { Library.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: Library.library-range-active; toggle-date-range() => { Library.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: Library.library-range-from; range-to: Library.library-range-to; range-invalid: Library.library-range-invalid; range-edited(a, b) => { Library.library-range-edited(a, b); } range-from-fraction: Library.library-range-from-fraction; range-to-fraction: Library.library-range-to-fraction; timeline-range-changed(a, b) => { Library.library-timeline-range-changed(a, b); } collections-visible: root.collections-visible; toggle-collections() => { root.toggle-collections(); } pin-done: Library.library-pin-done; pin-total: Library.library-pin-total; root-label: Library.library-root-label; timeline: Library.library-timeline; timeline-label: Library.library-timeline-label; window-label: Library.library-window-label; offset: Library.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: Library.library-exporting; export-album: root.export-album; export-selection => { Library.library-export-selection(); } cancel-export => { Library.library-cancel-export(); } // TRACES: FR-MRG-1 merge-selection => { Library.library-merge-selection(); } sweep-done: Library.library-sweep-done; sweep-total: Library.library-sweep-total; current-bucket: Library.library-current-bucket; current-bucket-fraction: Library.library-current-fraction; timeline-anchored: Library.library-timeline-anchored; scrub-fraction(f) => { Library.library-scrub-fraction(f); } timeline-pinch(r) => { Library.library-timeline-pinch(r); } timeline-pan(d) => { Library.library-timeline-pan(d); } timeline-zoom(d) => { Library.library-timeline-zoom(d); } syncing: Library.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() => { Library.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() => { Library.library-rescan(); } can-import: root.import-supported; open-import() => { root.import-open(); } open-settings() => { root.settings-open(); } open-export-options() => { root.export-options-open(); } sheet-open: root.export-sheet-open || root.help-open; open-help() => { root.help-open = true; } 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: Library.library-keywords; keywords-opened() => { Library.library-keywords-opened(); } assign-keyword(word) => { Library.library-assign-keyword(word); } unassign-keyword(word) => { Library.library-unassign-keyword(word); } cursor: Library.library-cursor; cursor-slot: Library.library-cursor-slot; move-cursor(delta, extend) => { Library.library-move-cursor(delta, extend); } move-cursor-rows(rows, extend) => { Library.library-move-cursor-rows(rows, 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: Library.library-filter-people; filter-people-all: Library.library-filter-people-all; filter-eyes-open: Library.library-filter-eyes-open; people: Library.library-people; filter-min-rating: Library.library-filter-min-rating; filter-max-rating: Library.library-filter-max-rating; filter-rating-range(low, high) => { Library.library-filter-rating-range(low, high); } filter-unjudged: Library.library-filter-unjudged; filter-flag: Library.library-filter-flag; rating-counts: Library.library-rating-counts; cell-rated(i, n) => { Library.library-cell-rated(i, n); } cell-trashed(i) => { Library.library-cell-trashed(i); } burst-toggled(i) => { Library.library-burst-toggled(i); } burst-representative-chosen(i) => { Library.library-burst-representative-chosen(i); } trash-selection() => { Library.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, at) => { Library.library-judged(rating, flag, at); } labelled(code, at) => { Library.library-labelled(code, at); } label-chosen(code) => { Library.library-label-chosen(code); } filter-label: Library.library-filter-label; label-counts: Library.library-label-counts; filter-label-changed(code) => { Library.library-filter-label-changed(code); } // `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) => { Library.library-filter-min-rating-changed(n); } filter-unjudged-toggled(on) => { Library.library-filter-unjudged-changed(on); } filter-flag-changed(f) => { Library.library-filter-flag-changed(f); } filter-person-cleared(id) => { Library.library-filter-person-cleared(id); } filter-people-mode-toggled() => { Library.library-filter-people-mode-toggled(); } filter-eyes-open-toggled() => { Library.library-filter-eyes-open-toggled(); } people-listed() => { Library.library-people-listed(); } filter-person-toggled(id) => { Library.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; // TRACES: FR-CAT-5 // The open photograph's label, read from its grid cell so the // bar and the grid cannot disagree; -1 with no library. colour-label: Library.library-total > 0 && Library.library-roll-current >= 0 ? Library.library-cells[Library.library-roll-current].label : -1; label-picked(code) => { Library.library-cell-labelled(Library.library-roll-current, code, false); } // TRACES: FR-UI-5 | FR-CULL-4 // The open photograph's stars and flag, read from its cell as // the label is; -1 with no library to hold them. rating: Library.library-total > 0 && Library.library-roll-current >= 0 ? Library.library-cells[Library.library-roll-current].rating : -1; flag: Library.library-total > 0 && Library.library-roll-current >= 0 ? Library.library-cells[Library.library-roll-current].flag : 0; rated(n) => { Library.library-cell-rated(Library.library-roll-current, n); } flagged(f) => { Library.library-cell-flagged(Library.library-roll-current, f); } 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(); } open-help() => { root.help-open = true; } 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 column-width: !root.column-below && root.panel-visible ? min(root.panel-max-width, Theme.panel-width) : 0px; property 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.has-photo; 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); } // TRACES: FR-DEV-6 // The rail's presets menu applies to the photograph in // front of it, so the count the apply handler reads is // set to say so — the grid may have left its own there. presets: root.preset-rows; preset-picked(name) => { root.preset-apply-count = 0; root.apply-preset(name); } manage-presets => { Transfer.open-presets(); } folder-toggled(key) => { root.toggle-preset-folder(key); } } // 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 { x: parent.fit-x; y: parent.fit-y; width: parent.fit-w; height: parent.fit-h; source: root.canvas; image-fit: contain; // TRACES: FR-UI-4 // From 1:1 on there is no detail left to reconstruct, so // smoothing only invents values between real pixels — and // inspecting focus or noise is the whole reason to zoom in // that far. Below 1:1 it stays smooth, where filtering is // what keeps the image from aliasing. // // This is the only enlargement there is. Past 1:1 the // pipeline renders the visible region at the source's own // resolution (`render::render_size`) and `contain` // stretches it to the box, so the blocks on screen are the // file's pixels and not ones the pipeline interpolated. // // TRACES: FR-DSP-8 // Below 1:1 the canvas is rendered at the *physical* pixel // size of the box this image occupies, so `contain` // presents it 1:1 and the filter barely matters. 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.has-photo; } // TRACES: FR-DSP-4 // **The draft, fading out over the sharp frame that // replaced it**, so settling reads as the picture coming // into focus rather than a swap. // // Hidden while drafting — it would be the same picture // twice — and snapped to opaque there, so the fade always // starts from the draft. Only the leaving transition is // animated: a new gesture shows its draft at once. Past // the fade it is transparent and hidden, so a settled // canvas composites exactly one image, as before. // // It fades into whatever sharp frame follows a draft. That // is the settle in every case but one: holding a // comparison within the settle delay of a drag renders the // original straight away, and the last draft of the edit // fades out over it rather than vanishing. previous-frame := Image { x: canvas-image.x; y: canvas-image.y; width: canvas-image.width; height: canvas-image.height; source: root.canvas-previous; image-fit: contain; image-rendering: canvas-image.image-rendering; opacity: 0; visible: canvas-image.visible && !root.canvas-draft && self.opacity > 0; states [ drafting when root.canvas-draft: { opacity: 1; out { animate opacity { duration: 150ms; easing: ease-out; } } } ] } // 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.has-photo: 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.has-photo: 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. // // **While composing, the box is inset and stops above the // photo roll.** A crop handle straddles the picture's edge, // half of it outside, and a photograph fitted to the full // box puts that half under this area's clip — and, along // the bottom, under the roll's band, whose swipe handler // takes every press inside it (see `PhotoRoll`). The bottom // corners were drawn there, lit up, and could not be // grabbed. Fitting the picture clear of both is the only // fix: the handler has to be where it is, and moving the // handles inward would leave them off the corner they // move. property roll-band: Library.library-total > 0 ? (root.roll-open ? Theme.roll-strip + Theme.roll-reach : Theme.roll-reach) : 0px; // The roll's own travel, so what stands on it rises with // the strip rather than jumping ahead of it. animate roll-band { duration: 180ms; easing: ease-out; } // // The floating "Done Composing" row stands on that band, // and is declared after the crop overlay so it takes a // press first: a photograph filling the width put the // bottom-left handle under it. So the reserve is the band, // the row's 12px margin, and the row's hit height — a // `Button`'s press area is the touch target, not its ink. // Every other side keeps half a handle and a finger's // slop clear of this area's clip. property fit-inset: Develop.cropping ? 16px : 0px; property fit-below: Develop.cropping ? self.roll-band + 12px + Theme.touch-target : 0px; property fit-x: self.fit-inset; property fit-y: self.fit-inset; property fit-w: max(1px, self.width - 2 * self.fit-inset); property fit-h: max(1px, self.height - 2 * self.fit-inset - self.fit-below); property src-w: root.canvas.width > 0 ? root.canvas.width : 1; property src-h: root.canvas.height > 0 ? root.canvas.height : 1; property box-w: self.fit-w / 1px; property box-h: self.fit-h / 1px; property fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h); property shown-w: self.src-w * self.fit-scale * 1px; property shown-h: self.src-h * self.fit-scale * 1px; property shown-x: self.fit-x + (self.fit-w - self.shown-w) / 2; property shown-y: self.fit-y + (self.fit-h - 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."; } // TRACES: FR-NC-6a // Waiting for an original. Not an empty state and not an // error: the photograph is known, and the grid's thumbnail // of it stands in so a step along the roll lands on *this* // frame at once — with a bar, once it turns out to be a // download, saying how long until it can be worked on. if root.total > 0 && root.load-error == "" && root.load-pending: WaitingState { headline: "Not on this device yet"; detail: root.load-waiting; fraction: root.load-fraction; what: "Downloading " + root.filename; preview: root.load-preview; has-preview: root.has-load-preview; } // --- 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 // keys: `Ctrl+=` or `Ctrl+Plus` in, `Ctrl+-` out, // about the middle of the view // 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. Past // 1:1 the pixels are shown as they are, square // and unsmoothed; below it, filtered. // manual: looking-closer // // 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.has-photo: ScaleRotateGestureHandler { x: 0; y: 0; width: 100%; height: 100%; enabled: !Develop.cropping; property 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.has-photo: 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 last-x; property 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 // keys: `Shift+Left`, `Shift+Right`, `Shift+Up` // and `Shift+Down`, a fifth of the view // at a time // 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. // manual: looking-closer 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.has-photo: 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. // manual: local-adjustments // // 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.has-photo: 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.has-photo: 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.has-photo: 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.has-photo: 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); } crop-released(x, y, w, h) => { Framing.crop-released(x, y, w, h); } } // --- 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. // KEYMAP: Develop 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 (Keys.chord(event) == "\\") { root.compare-original(false); return accept; } return reject; } // KEYMAP: Develop key-pressed(event) => { root.shift-held = event.modifiers.shift; // A sheet is up over the photograph, and these // keys would act on what it covers: A stepping to // the next frame behind the export form. Declined // rather than swallowed, so Escape still reaches // the shell and closes the sheet. if (root.export-sheet-open || root.copy-sheet-open || root.help-open) { return reject; } // GESTURE: Open this list // where: Develop // touch: Press "?" in the top bar, beside // Settings, and Done to put it away // pointer: Press "?" in the top bar, beside // Settings, and Done to put it away // keys: `F1`; the key that closes any sheet // puts it away // why: Most of the keys are develop's, and a // reference that could only be opened // from the grid had to be looked up // before opening the photograph they // were wanted for. // // TRACES: FR-UI-4 | FR-UI-5 // The same sheet the grid's Help opens, drawn by // the shell; the book already starts on Develop. if (Keys.chord(event) == "F1") { root.help-open = true; return accept; } // 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 (Keys.chord(event) == "\\") { 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. // manual: history-snapshots-presets // // 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`, or `Ctrl+Y` // manual: history-snapshots-presets // // Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5), and Ctrl+Y for // hands that learned redo on Windows. if (Keys.chord(event) == "Ctrl+Z") { Steps.undo(); return accept; } if (Keys.chord(event) == "Ctrl+Shift+Z" || Keys.chord(event) == "Ctrl+Y") { Steps.redo(); return accept; } // GESTURE: Remove a repair // where: Develop // touch: Tap it, then Delete Repair // pointer: Click it, then Delete Repair // keys: `Delete` or `Backspace`, while // repairing // // 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 != "" && (Keys.chord(event) == "Delete" || Keys.chord(event) == "Backspace")) { Repair.removed(Repair.selected); return accept; } // GESTURE: Copy the settings from this photograph // where: Develop // touch: Press Copy in the top bar // pointer: Press Copy in the top bar // keys: `Ctrl+C` // why: The button 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. // manual: copying-settings // // GESTURE: Paste the settings onto this photograph // where: Develop // touch: Press Paste in the top bar // pointer: Press Paste in the top bar // 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. // manual: copying-settings // // TRACES: FR-DEV-6 // The desktop accelerators for the Copy and Paste // buttons in the top bar. // 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. // GESTURE: Choose which kinds of edit a copy carries // where: Develop // touch: Open Presets and toggle the kinds // pointer: Open Presets and toggle the kinds // keys: `Ctrl+Shift+C`, which offers Copy // beside them // why: Lightroom's Copy Settings. Pasting a // look across a shoot usually means // leaving each frame's crop and // rotation alone, and that is a choice // to make at the moment of copying. // manual: copying-settings // if (Keys.chord(event) == "Ctrl+Shift+C") { root.copy-sheet-open = true; return accept; } if (Keys.chord(event) == "Ctrl+C") { Transfer.copy(); return accept; } if (Transfer.armed && Keys.chord(event) == "Ctrl+V") { Transfer.paste(); return accept; } // GESTURE: Export this photograph as the last one was // where: Develop // touch: Press Export in the top bar // pointer: Press Export in the top bar // keys: `Ctrl+Shift+E` // why: Every export runs on the defaults in // Settings, so "as the last one was" is // what the button already does. The // chord is Lightroom's and darktable's, // kept so hands that learned it there // need not learn it again. // manual: export // // GESTURE: Choose how to export, then export // where: Develop // touch: Open Settings, then Export defaults // pointer: Open Settings, then Export defaults // keys: `Ctrl+E` // why: The export sheet is the export // defaults alone with an Export button. // What is chosen there is kept, so it // is also what the next Ctrl+Shift+E // uses. // manual: export // // TRACES: FR-UI-5 | FR-EXP-7 // Rust turns away a second export while one is // running, so the chord needs no busy guard of its // own. if (Keys.chord(event) == "Ctrl+Shift+E") { root.export-image(); return accept; } if (Keys.chord(event) == "Ctrl+E") { root.export-options-open(); return accept; } // TRACES: FR-UI-4 | FR-DEV-16 // Magnification from the keyboard: in and out about // the middle of the view, the fit and the 1:1 by // name. Photoshop's and Lightroom's chords. Not while // composing, which drops the zoom on the way in so // the crop handles stay on screen; the wheel and the // pinch stand down there too. if (!Develop.cropping) { if (Keys.chord(event) == "Ctrl+=" || Keys.chord(event) == "Ctrl+Plus") { root.zoom-at(1.25, 0.5, 0.5); return accept; } if (Keys.chord(event) == "Ctrl+-") { root.zoom-at(0.8, 0.5, 0.5); return accept; } // The toggle goes out to fit from any zoom, so fit is // the toggle when zoomed and nothing when not; and // 1:1 is the toggle from fit — twice from a zoom // that is not 1:1, once out and once back in. if (Keys.chord(event) == "Ctrl+0") { if (root.zoomed) { root.inspect-toggled(-1, -1); } return accept; } if (Keys.chord(event) == "Ctrl+1") { if (root.zoomed) { root.inspect-toggled(-1, -1); } root.inspect-toggled(-1, -1); return accept; } } // TRACES: FR-UI-4 | FR-DEV-16 // Shift and an arrow move a magnified view by a fifth // of itself — see "Move a magnified photograph about" // above. Only while magnified, as the drag is: at fit // there is nothing outside the view to move to. if (root.zoomed) { if (Keys.chord(event) == "Shift+Left") { root.pan-by(-0.2, 0); return accept; } if (Keys.chord(event) == "Shift+Right") { root.pan-by(0.2, 0); return accept; } if (Keys.chord(event) == "Shift+Up") { root.pan-by(0, -0.2); return accept; } if (Keys.chord(event) == "Shift+Down") { root.pan-by(0, 0.2); return accept; } } // GESTURE: Keep a crop that leaves a mask outside // where: Develop // touch: Press "Keep crop" on the notice, or "Undo // crop" to take it back // pointer: Press "Keep crop" on the notice, or "Undo // crop" to take it back // keys: `Enter` keeps it; `Ctrl+Z` takes the crop // back, like any other step // // TRACES: FR-DEV-17 | FR-DEV-16 // Only while the notice is up: Enter means nothing // else here, and a key that dismissed a notice nobody // could see would be a key that did nothing visible. if (Framing.hidden-masks > 0 && Keys.chord(event) == "Enter") { Framing.hidden-masks-kept(); return accept; } // GESTURE: Go back to the grid // where: Develop // touch: Press "‹ Library" in the top bar // pointer: Press "‹ Library" in the top bar // keys: `G` // why: Lightroom's key for the grid. Escape gets // there too, but a step at a time — out of a // mode, then out of a zoom — where this goes // straight back. // // TRACES: FR-DEV-16 // Only where there is a grid: files named on the // command line have none, which is also when the bar // hides its button. if (Library.library-total > 0 && Keys.chord(event) == "G") { root.back-to-library(); return accept; } // TRACES: FR-DEV-19a | FR-DEV-16 // J turns the selected part's join chip — see "Change // how a part joins its mask" in masks.slint. // The chip's own cycle, on the part the brush and // the edge controls point at. Rust resolves which that // is, and does nothing on a base part, which joins // nothing. if (Develop.local-mode && Keys.chord(event) == "J") { Masking.selected-part-join-cycled(); return accept; } // GESTURE: Nudge the control last moved // where: Develop // touch: Drag its track // pointer: Drag its track // keys: `=` or `Plus` up and `-` down, a hundredth // of its travel at a time; hold for more // why: Lightroom's keys for the selected slider. // There is no focus ring on a slider here, so // "selected" is the last one moved — the same // control `R` puts back — which covers the // framing sliders, perspective included, as // well as the adjustments. // // TRACES: FR-DEV-16 | FR-DEV-20 // A framing slider moved last takes the key; Rust // works out an adjustment's step from its row, where // the range is. if (Keys.chord(event) == "=" || Keys.chord(event) == "Plus" || Keys.chord(event) == "-") { if (Framing.touched > 0) { root.framing-nudged(Keys.chord(event) == "-" ? -1 : 1); } else if (Adjustments.touched-op >= 0) { Adjustments.param-nudged( Adjustments.touched-op, Adjustments.touched-param, Keys.chord(event) == "-" ? -1 : 1); } 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. // manual: developing-a-photograph // // 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 (Keys.chord(event) == "]") { Adjustments.tab-picked( (Adjustments.active-tab + 1 >= Adjustments.tabs.length) ? -1 : (Adjustments.active-tab + 1)); return accept; } if (Keys.chord(event) == "[") { 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` goes in and back out; `Ctrl+1` goes to // 1:1 and `Ctrl+0` back to the whole frame // 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. From 1:1 on // the photograph is drawn as its own // pixels, each a hard-edged square, // rather than smoothed into a blur. // manual: looking-closer // // 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. if (Keys.chord(event) == "Z") { root.inspect-toggled(-1, -1); return accept; } // TRACES: FR-DEV-16 // R puts back the control last moved. 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 (Keys.chord(event) == "R" && Framing.touched > 0) { root.framing-reset-touched(); return accept; } if (Keys.chord(event) == "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 (Keys.chord(event) == "H") { Masking.selected-toggled(); return accept; } // GESTURE: Rate this photograph // where: Develop // touch: Tap a star in the top bar // pointer: Click a star in the top bar // keys: `0`–`5` // // GESTURE: Pick or reject this photograph // where: Develop // touch: Press Pick or Reject in the top bar; again // to take the flag off // pointer: Press Pick or Reject in the top bar; again // to take the flag off // keys: `P` picks, `X` rejects and `U` takes the // flag off // why: The grid's keys, on the photograph that is // open (FR-UI-5, 2026-09-19). Judging here does // not move on to the next frame: that belongs // to culling, and in develop the photograph in // front of you is the one being worked on. // // TRACES: FR-UI-5 | FR-CULL-4 | FR-DEV-16 // Only with a library, like the labels below: a file // named on the command line has no catalog to hold a // rating. if (Library.library-total > 0 && Library.library-roll-current >= 0) { if (Keys.chord(event) == "0") { Library.library-cell-rated(Library.library-roll-current, 0); return accept; } if (Keys.chord(event) == "1") { Library.library-cell-rated(Library.library-roll-current, 1); return accept; } if (Keys.chord(event) == "2") { Library.library-cell-rated(Library.library-roll-current, 2); return accept; } if (Keys.chord(event) == "3") { Library.library-cell-rated(Library.library-roll-current, 3); return accept; } if (Keys.chord(event) == "4") { Library.library-cell-rated(Library.library-roll-current, 4); return accept; } if (Keys.chord(event) == "5") { Library.library-cell-rated(Library.library-roll-current, 5); return accept; } if (Keys.chord(event) == "P") { Library.library-cell-flagged(Library.library-roll-current, 1); return accept; } if (Keys.chord(event) == "X") { Library.library-cell-flagged(Library.library-roll-current, 2); return accept; } if (Keys.chord(event) == "U") { Library.library-cell-flagged(Library.library-roll-current, 0); return accept; } } // GESTURE: Give this photograph a colour label // where: Develop // touch: Tap Label in the top bar, then a // colour // pointer: Click Label in the top bar, then a // colour // keys: `6` red, `7` yellow, `8` green, `9` blue; the // same key again takes it off // why: The grid's keys, on the photograph // that is open, so labelling while // stepping through a folder is one // hand's work. The bar names the label // in words beside its mark. // manual: rating-and-flagging // // TRACES: FR-CAT-5 | FR-UI-5 | NFR-A11Y-3 // Only with a library: a file named on the command // line has no catalog to hold a label. if (Library.library-total > 0 && Library.library-roll-current >= 0) { if (Keys.chord(event) == "6") { Library.library-cell-labelled(Library.library-roll-current, 1, true); return accept; } if (Keys.chord(event) == "7") { Library.library-cell-labelled(Library.library-roll-current, 2, true); return accept; } if (Keys.chord(event) == "8") { Library.library-cell-labelled(Library.library-roll-current, 3, true); return accept; } if (Keys.chord(event) == "9") { Library.library-cell-labelled(Library.library-roll-current, 4, true); 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`, `D` or `Space` for the // next; `Left` or `A` 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. // A and D as well as the arrows, so the left hand // steps along the roll while the right stays on // the mouse. // manual: moving-between-photographs if (Keys.chord(event) == "Right" || Keys.chord(event) == "Space" || Keys.chord(event) == "D") { root.step-photo(1); return accept; } if (Keys.chord(event) == "Left" || Keys.chord(event) == "A") { root.step-photo(-1); 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.has-photo: 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. // The canvas's measure of the band, animated there // with the roll's own travel so these rise with the // strip rather than jumping ahead of it. property roll-clear: canvas-area.roll-band; 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. // manual: light // // 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); } } // --- a crop that stranded a mask (FR-DEV-17) ---------------- // // TRACES: FR-DEV-17 // A notice, not a dialog. The crop is already applied and // nothing waits on an answer: the photograph can go on // being edited with this up, and the notice goes the // moment the history moves off the crop it is about — // Rust clears it — so ignoring it is keeping the crop. // // "Undo crop" is the ordinary undo, not a second route // back. The crop is one step, so taking it back is that // step, and the notice leaves with it rather than needing // a step of its own. // // At the top of the canvas and last in it, so it draws // over the photograph and clear of the roll's band at the // foot, which takes every press that lands in it. if Framing.hidden-masks > 0 && root.total > 0: Rectangle { x: (parent.width - self.width) / 2; y: Theme.gap; width: min(parent.width - 2 * Theme.gap, notice-row.preferred-width); height: notice-row.preferred-height; background: Theme.surface; border-radius: 6px; border-width: 1px; border-color: Theme.rule; // Swallow presses on the notice's own background, so // a click that misses a button does not pan or paint // the photograph underneath. TouchArea { } notice-row := HorizontalLayout { padding: Theme.gap; spacing: Theme.gap; Caption { text: (Framing.hidden-masks == 1 ? "This crop leaves 1 mask outside the frame: " : "This crop leaves " + Framing.hidden-masks + " masks outside the frame: ") + Framing.hidden-mask-names; warn: true; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } Button { text: "Undo crop"; clicked => { Steps.undo(); } } Button { text: "Keep crop"; primary: true; clicked => { Framing.hidden-masks-kept(); } } } } // 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 px-w: Math.round(self.width / 1px); property 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 { } Rectangle { vertical-stretch: 1; column-flick := Flickable { width: parent.width; height: parent.height; 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 { } 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 column's bar, on a desktop: the sliders and the history run far // past the window, and without it a mouse with no wheel had no way to // see how far, or to get there. Over the column's right-hand edge // rather than beside it, so the mandated width is not paid for twice. ScrollBar { x: parent.width - self.width; y: 0px; viewport-y <=> column-flick.viewport-y; viewport-height: column-flick.viewport-height; visible-height: column-flick.height; } } } // 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%; rows: root.preset-rows; 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); } browse-presets(file) => { root.browse-presets(file); } 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; } folder-toggled(key) => { root.toggle-preset-folder(key); } } // TRACES: FR-EXP-7 | FR-UI-5 // Over the shell for the preset sheet's reasons: both views open it, // and the develop column is too narrow for the form. What Export does // depends on which view is underneath — the open photograph, or the // grid's selection — so the label and the action are chosen here. if root.export-sheet-open: ExportSheet { width: 100%; height: 100%; error: root.settings-error; action-label: root.active-view == View.develop ? root.export-label : (root.export-album != "" ? "Export " + Library.library-selected-count + " to " + root.export-album : "Export " + Library.library-selected-count); can-export: root.export-sheet-can-export; export => { root.export-from-sheet(); } dismiss => { root.export-sheet-open = false; } } // TRACES: FR-DEV-6 | FR-UI-5 if root.copy-sheet-open: CopySheet { width: 100%; height: 100%; scope-kinds: root.copy-scope-kinds; scope-empty: root.copy-scope-empty; summary: Transfer.summary; can-copy: root.has-photo; scope-toggled(name) => { root.copy-scope-toggled(name); } copy => { root.copy-sheet-open = false; Transfer.copy(); } dismiss => { root.copy-sheet-open = false; } } // TRACES: FR-UI-2 | FR-UI-4 // The gesture reference. Above the other sheets, because it is the one // a user opens *because* another one confused them; over the shell for // their reason — the grid and develop both open it. if root.help-open: GestureSheet { width: 100%; height: 100%; rows: Library.library-gestures; close => { root.help-open = false; } open-manual(anchor) => { Library.library-open-manual(anchor); } } 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(); } } // TRACES: FR-EXP-10 // Above the export sheet, which opens it for "New album…", and above // the sidebar that opens it otherwise — which the compact layout // hides, so it cannot be drawn inside it. if Albums.sheet-open: AlbumSheet { width: 100%; height: 100%; } // 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(); } } } }