import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint"; import { LaunchScreen } from "launch.slint"; import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint"; import { CollectionsPanel, CollectionRow } from "collections.slint"; import { SettingsPage } from "settings.slint"; export { LibraryCell, TimelineBar, CollectionRow } // Status strip — surfaces the GPU backend and adapter, which matters during // v0.1 because assumption A1 is exactly "does this compositing path work on // this hardware". Seeing the adapter at a glance makes vendor differences // obvious during the S1/S2 spikes. component StatusBar inherits Rectangle { in property adapter; in property backend; in property layout-class; in property fps; in property filename; in property position; /// Only offered where there is a library to go back to — with files named /// on the command line there is no grid behind this view. in property can-return-to-library: false; /// Whether the develop column is currently shown, for the toggle's label. in property panel-visible: true; callback back-to-library(); callback open-settings(); callback toggle-panel(); // 44px and `surface`, the same bar the library and settings draw. // // It was 28px — the control height — which was a reasonable size for a // readout and the wrong size for the thing it actually is: this strip // carries the only way out of develop, and it was half the height of the // identical control on the two screens either side of it. Three screens // whose headers do not agree read as three applications, and the one place // the user needs to find a way back was the one drawn smallest. // // Controls keep their own 28px and are centred in it, exactly as the // library header centres the same buttons: a `Button` sets a fixed height, // which a HorizontalLayout parks at the top of the row rather than // centring for it. height: 44px; background: Theme.surface; HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap; alignment: start; // Kept instantiated with `visible` rather than wrapped in an `if`: // the strip is inside the layout that `expanded` feeds, and adding a // conditional child here is the shape that has already caused binding // loops in this file (see the panel below). Button { text: "‹ Library"; y: (parent.height - self.height) / 2; visible: root.can-return-to-library; clicked => { root.back-to-library(); } } // The backend is an identifying label, not a state — it says which // path is in use, and it says the same thing whether that path is // fast or slow. The accent it used to carry made every frame look // like an alert; `fps` below is the thing here that can go wrong. Label { text: root.backend; } Value { text: root.filename; compact: true; overflow: elide; } Caption { text: root.position; } Rectangle { horizontal-stretch: 1; } Caption { text: root.layout-class; } // Show or hide the develop column. On a tablet the panel is 280px of a // screen that is mostly photograph, and the whole point of opening an // image is to look at it — so being able to put the instruments away // and bring them back is worth a control of its own. The glyph points // the way the panel will move. Button { text: root.panel-visible ? "Hide panel ›" : "‹ Panel"; active: root.panel-visible; y: (parent.height - self.height) / 2; clicked => { root.toggle-panel(); } } // Reachable from develop as well as from the grid: export defaults are // most likely to be wanted with a finished photograph on screen, which // is exactly where this bar is and the library header is not. Button { text: "Settings"; y: (parent.height - self.height) / 2; clicked => { root.open-settings(); } } // Degraded performance, so this is a caution rather than an active // state: the frame rate has fallen below what the compositing path is // supposed to sustain, which is exactly the assumption A1 exists to // test. `warn` is the one hue left in the palette and this earns it. Caption { text: root.fps + " fps"; warn: root.fps < 55; } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // Capture metadata. Read-only; the adjustment controls live in AdjustPanel, // which is generated from pipeline capabilities rather than written here. // // Flat, like everything else in this column. This was briefly a collapsible // section so that every panel could be put away; the sidebar as a whole now // closes from the status strip instead, which is the control that was actually // wanted, and the per-group lids only stood between the user and the controls. component InfoPanel inherits Rectangle { in property camera; in property exposure; in property dimensions; background: transparent; height: panel.preferred-height; panel := Panel { // Flat: this abuts the adjust panel below it and the rule between // them is drawn by the column that stacks the two. flat: true; width: 100%; PanelHeading { text: "IMAGE"; } Value { text: root.camera == "" ? "—" : root.camera; placeholder: root.camera == ""; wrap: word-wrap; } Label { text: root.exposure; } Caption { text: root.dimensions; } } } export component AppWindow inherits Window { title: "DarkRoom"; 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; // 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: ""; // --- 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; /// Whether the crop overlay is active. While it is, 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-out property crop-mode: false; /// The crop rect in fractions of the frame, mirrored from Rust so the /// overlay draws exactly what the pipeline holds. in-out property crop-x: 0.0; in-out property crop-y: 0.0; in-out property crop-w: 1.0; in-out property crop-h: 1.0; callback crop-mode-toggled(bool); /// A dragged crop rect, in fractions of the frame. callback crop-changed(float, float, float, float); // --- 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); /// Crop, angle, rotation and flips back to neutral, leaving colour alone. callback framing-reset(); /// 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-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); /// 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; in property library-scanning: false; in property library-status: ""; in property library-error: ""; in property library-thumbs-done: 0; in property library-thumbs-total: 0; // --- 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(); 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(); 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; in-out property library-cell-size: 180px; callback library-zoom-cells(int); in property library-scroll-to: 0; in property library-scroll-token: 0; callback library-sync-now(); callback library-columns-changed(); callback library-scrolled(int); callback library-capacity(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); /// 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); // --- 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(); // --- 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); /// 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); 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; 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; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); callback reset-all(); // --- 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; 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); 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-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-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-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-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"; /// 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; /// Show or hide the develop column. callback toggle-panel(); /// 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) { 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". if root.show-settings: SettingsPage { width: 100%; height: 100%; 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; 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; 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; 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); } 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); } close() => { root.settings-close(); } reset-defaults() => { root.settings-reset(); } } // 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-settings && 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; 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); } 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-settings && !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; select(id) => { root.collection-select(id); } toggle(id) => { root.collection-toggle(id); } new-collection() => { root.collection-new(); } row-menu(id) => { root.collection-menu(id); } 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); } 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; cells: root.library-cells; total: root.library-total; scanning: root.library-scanning; scan-status: root.library-status; scan-error: root.library-error; thumbs-done: root.library-thumbs-done; thumbs-total: root.library-thumbs-total; 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(); } collections-visible: root.collections-visible; toggle-collections() => { root.toggle-collections(); } scope-pinned: root.library-scope-pinned; pin-done: root.library-pin-done; pin-total: root.library-pin-total; toggle-pin-scope() => { root.library-toggle-pin-scope(); } 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; 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; cell-size: root.library-cell-size; zoom-cells(d) => { root.library-zoom-cells(d); } 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); } capacity-changed(n) => { root.library-capacity(n); } cell-clicked(i) => { root.library-cell-clicked(i); } rescan() => { root.library-rescan(); } change-library() => { root.library-change(); } open-settings() => { root.settings-open(); } cell-pressed(i, ctrl, shift) => { root.library-cell-pressed(i, ctrl, shift); } 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-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); } 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); } } } } if !root.show-settings && !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; 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; back-to-library() => { root.back-to-library(); } open-settings() => { root.settings-open(); } toggle-panel() => { root.toggle-panel(); } } HorizontalLayout { // 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. // // This matters even at moderate zoom on a HiDPI display: // the canvas is rendered at *logical* size and Slint scales // it up by the device pixel ratio, so the buffer is // resampled on its way to the screen whatever the pipeline // did. image-rendering: root.magnified ? ImageRendering.pixelated : ImageRendering.smooth; visible: root.total > 0 && root.load-error == ""; } // 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.crop-mode; 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; } } 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.crop-mode; 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 ------------------------------------------ // // Four dimmed, desaturated panels around the crop, then the // rect itself with handles. Panels rather than one shape with // a hole: Slint has no cut-out, and four rectangles are exact. if root.crop-mode && root.total > 0 && root.load-error == "": crop-overlay := Rectangle { x: parent.shown-x; y: parent.shown-y; width: parent.shown-w; height: parent.shown-h; property rx: root.crop-x * self.width; property ry: root.crop-y * self.height; property rw: root.crop-w * self.width; property rh: root.crop-h * self.height; // The surround: dimmed and drained of colour, so the crop // reads as the photograph and everything else as context // being discarded. Slint cannot desaturate a live image, // so this is a heavy neutral wash over it — the dimming // carries the separation and the neutrality kills the // colour cue. property veil: #20242aE0; Rectangle { x: 0; y: 0; width: parent.width; height: crop-overlay.ry; background: crop-overlay.veil; } Rectangle { x: 0; y: crop-overlay.ry + crop-overlay.rh; width: parent.width; height: parent.height - crop-overlay.ry - crop-overlay.rh; background: crop-overlay.veil; } Rectangle { x: 0; y: crop-overlay.ry; width: crop-overlay.rx; height: crop-overlay.rh; background: crop-overlay.veil; } Rectangle { x: crop-overlay.rx + crop-overlay.rw; y: crop-overlay.ry; width: parent.width - crop-overlay.rx - crop-overlay.rw; height: crop-overlay.rh; background: crop-overlay.veil; } // The rect, its thirds, and the handles. Rectangle { x: parent.rx; y: parent.ry; width: parent.rw; height: parent.rh; border-width: 1px; border-color: #ffffffCC; // Rule of thirds, the reason a crop overlay is worth // drawing at all rather than typing numbers. Rectangle { x: parent.width / 3; y: 0; width: 1px; height: parent.height; background: #ffffff44; } Rectangle { x: parent.width * 2 / 3; y: 0; width: 1px; height: parent.height; background: #ffffff44; } Rectangle { x: 0; y: parent.height / 3; width: parent.width; height: 1px; background: #ffffff44; } Rectangle { x: 0; y: parent.height * 2 / 3; width: parent.width; height: 1px; background: #ffffff44; } // Drag the whole rect. move-area := TouchArea { width: 100%; height: 100%; mouse-cursor: MouseCursor.move; property start-x; property start-y; pointer-event(ev) => { if (ev.kind == PointerEventKind.down) { self.start-x = root.crop-x; self.start-y = root.crop-y; } } moved => { if (self.pressed) { root.crop-changed( self.start-x + (self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px), self.start-y + (self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px), root.crop-w, root.crop-h, ); } } } } // Corner handles. Each drags one corner while the opposite // stays put, which is the only behaviour that lets a crop // be shaped rather than merely moved. for corner in [ { hx: 0.0, hy: 0.0 }, { hx: 1.0, hy: 0.0 }, { hx: 0.0, hy: 1.0 }, { hx: 1.0, hy: 1.0 }, ]: Rectangle { property size: 18px; x: parent.rx + corner.hx * parent.rw - self.size / 2; y: parent.ry + corner.hy * parent.rh - self.size / 2; width: self.size; height: self.size; Rectangle { width: 12px; height: 12px; background: #ffffff; border-radius: 2px; } TouchArea { width: 100%; height: 100%; mouse-cursor: (corner.hx == corner.hy) ? MouseCursor.nwse-resize : MouseCursor.nesw-resize; property ox; property oy; property ow; property oh; pointer-event(ev) => { if (ev.kind == PointerEventKind.down) { self.ox = root.crop-x; self.oy = root.crop-y; self.ow = root.crop-w; self.oh = root.crop-h; } } // Movement as a fraction of the frame, live while // the handle is held. property dx: (self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px); property dy: (self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px); moved => { if (!self.pressed) { return; } // Dragging a left/top handle moves the origin // and shrinks the extent by the same amount; // a right/bottom handle moves only the extent. // Rust clamps the result, so an over-drag // slides rather than inverting. root.crop-changed( corner.hx == 0 ? self.ox + self.dx : self.ox, corner.hy == 0 ? self.oy + self.dy : self.oy, corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx, corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy, ); } } } } // 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(); } key-pressed(event) => { 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. // // A crop exit is still reachable from here while cropping — // the panel may be collapsed on a narrow window, and stranding // the user in a mode with no visible way out is worse than one // duplicated control. if root.total > 0 && root.load-error == "": HorizontalLayout { x: 12px; y: parent.height - self.preferred-height - 12px; spacing: 6px; if root.crop-mode: Button { text: "Done"; active: true; clicked => { root.crop-mode-toggled(false); } } // 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(); } } } // 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. Rectangle { width: root.panel-visible ? 280px : 0px; visible: root.panel-visible; background: Theme.surface; VerticalLayout { InfoPanel { camera: root.camera; exposure: root.exposure; dimensions: root.dimensions; } 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). GeometryPanel { enabled: root.adjust-enabled; crop-mode: root.crop-mode; angle: root.straighten; max-straighten: root.max-straighten; flip-h: root.flip-h; flip-v: root.flip-v; modified: root.framing-modified; crop-toggled(on) => { root.crop-mode-toggled(on); } 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-reset => { root.straighten-changed(0); } reset => { root.framing-reset(); } } Rectangle { height: 1px; background: Theme.rule; } AdjustPanel { vertical-stretch: 1; rows: root.adjust-rows; enabled: root.adjust-enabled; curve-samples: root.curve-samples; 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(); } } } Rectangle { width: 1px; background: Theme.rule; } } } } } }