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