Build and test / Desktop (Linux) (push) Successful in 20m28s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 13m45s
Build and test / android-image (push) Successful in 13m47s
Build and test / Android (aarch64) (push) Failing after 9m16s
Two complaints from a tablet, with one cause between them. **Some sliders dragged and others only answered a tap.** They were not the same control. `ParamSlider` read its geometry from a `ParamRow` for the generated panel and `PlainSlider` took plain numbers for the straighten angle, each with its own track, handle, hit area and gesture rules written out separately — and a comment arguing the duplication was safe, because "a slider that dragged differently depending on which panel it sat in would be a worse inconsistency than the duplication". That is exactly what happened. The touch arbitration fixed in the previous commit went into `ParamSlider` and `CurveEditor`; `PlainSlider` kept the old code, so two sliders in the same sidebar behaved differently and which one you got depended on where you were dragging. The duplication failed to survive its first change. There is now one `SliderTrack`, owning the track, the hit area, the claim test, the hover arbitration, click-to-jump and double-click reset. The two wrappers differ only in where their numbers and labels come from. **Nothing in the develop column collapses any more.** Every group was a `Section` with a disclosure triangle, including five operations that carry a single parameter — so the lid was most of the row, wrapping one slider whose own label repeated the heading word for word. A control behind a lid is one the user does not know the pipeline has. `GroupHeading` keeps what the section was actually for: the name, the dot that says something inside differs from its default, and the reset. The reset is now permanently visible rather than appearing on hover, because a hover-only control is one no finger can reach. IMAGE goes back to flat as well. The column as a whole still closes, from the status strip — which is the control that was wanted, at the level it makes sense at. This costs no vertical space: `Section` defaulted to expanded and nothing ever set it otherwise, so the panel was already unfolded and the lids were overhead with no saving behind them. Verified with cargo test -p dr-ui (192), clippy at -D warnings, and an arm64-v8a release build installed on a tablet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1376 lines
65 KiB
Plaintext
1376 lines
65 KiB
Plaintext
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 <string> adapter;
|
||
in property <string> backend;
|
||
in property <string> layout-class;
|
||
in property <int> fps;
|
||
in property <string> filename;
|
||
in property <string> 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 <bool> can-return-to-library: false;
|
||
/// Whether the develop column is currently shown, for the toggle's label.
|
||
in property <bool> 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 <string> camera;
|
||
in property <string> exposure;
|
||
in property <string> 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 <image> canvas;
|
||
in property <string> adapter: "detecting…";
|
||
in property <string> backend: "—";
|
||
in property <int> fps: 0;
|
||
|
||
// Current image, for the status strip and empty state.
|
||
in property <string> filename: "";
|
||
in property <string> camera: "";
|
||
in property <string> exposure: "";
|
||
in property <string> dimensions: "";
|
||
in property <int> index: 0;
|
||
in property <int> total: 0;
|
||
in property <string> 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 <float> zoom: 1.0;
|
||
in property <bool> zoomed: false;
|
||
|
||
/// Whether one source pixel now covers more than one screen pixel.
|
||
///
|
||
/// Drives the canvas's filtering, and nothing else. Rust decides it rather
|
||
/// than the `zoom` property above, because the two are not the same
|
||
/// question: whether the image is magnified depends on the source's
|
||
/// resolution against the viewport's, which Slint does not know.
|
||
in property <bool> magnified: false;
|
||
|
||
/// 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 <bool> 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 <float> crop-x: 0.0;
|
||
in-out property <float> crop-y: 0.0;
|
||
in-out property <float> crop-w: 1.0;
|
||
in-out property <float> crop-h: 1.0;
|
||
|
||
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 <float> straighten: 0.0;
|
||
/// The straighten slider's travel either way, from the descriptor.
|
||
in property <float> max-straighten: 45.0;
|
||
in property <bool> flip-h: false;
|
||
in property <bool> flip-v: false;
|
||
/// Any framing edit is in effect — what lights the section's dot and
|
||
/// enables its reset. Distinct from the crop rect being non-full: a
|
||
/// rotation or a flip is an edit with the crop still full.
|
||
in property <bool> framing-modified: false;
|
||
|
||
/// Quarter turns, positive clockwise.
|
||
callback rotate-quarters(int);
|
||
callback flip-h-toggled();
|
||
callback flip-v-toggled();
|
||
callback straighten-changed(float);
|
||
/// 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 <bool> show-launch: false;
|
||
in property <bool> launch-signed-in: false;
|
||
in property <string> launch-account: "";
|
||
in property <string> launch-root: "";
|
||
in property <string> launch-server: "";
|
||
in property <bool> launch-busy: false;
|
||
in property <string> launch-status: "";
|
||
in property <string> launch-error: "";
|
||
in property <string> launch-login-url: "";
|
||
in property <bool> launch-can-remember: true;
|
||
in-out property <[string]> launch-format-labels;
|
||
in-out property <[bool]> launch-format-checked;
|
||
|
||
callback launch-sign-in(string);
|
||
/// server, username, app password
|
||
callback launch-sign-in-direct(string, string, string);
|
||
callback launch-sign-out();
|
||
callback launch-choose-folder();
|
||
callback launch-open-library();
|
||
callback launch-format-toggled(int, bool);
|
||
callback launch-copy-url();
|
||
|
||
in property <bool> launch-browsing: false;
|
||
in property <string> launch-browse-path: "";
|
||
in property <bool> launch-browse-loading: false;
|
||
in-out property <[string]> launch-browse-entries;
|
||
|
||
callback launch-browse-into(string);
|
||
callback launch-browse-up();
|
||
callback launch-browse-confirm();
|
||
callback launch-browse-cancel();
|
||
|
||
// --- library grid (FR-CAT-4) ---
|
||
//
|
||
// Shown after a library is opened, before an image is chosen. Cells are a
|
||
// window over the catalog, not the whole of it.
|
||
in property <bool> show-library: false;
|
||
in-out property <[LibraryCell]> library-cells;
|
||
in property <int> library-total: 0;
|
||
in property <bool> library-scanning: false;
|
||
in property <string> library-status: "";
|
||
in property <string> library-error: "";
|
||
in property <int> library-thumbs-done: 0;
|
||
in property <int> 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 <bool> library-offline: false;
|
||
in property <string> library-offline-reason: "";
|
||
in property <string> library-offline-since: "";
|
||
// Narrows the grid to images whose RAW is stored locally — the ones that
|
||
// can actually be opened while offline. Off by default: the catalog is the
|
||
// library, and hiding most of it the moment a connection drops would read
|
||
// as data loss rather than as a filter.
|
||
in-out property <bool> library-local-only: false;
|
||
in property <int> library-local-count: 0;
|
||
callback library-toggle-local-only();
|
||
callback library-retry-connection();
|
||
|
||
// --- pinning a collection offline (FR-NC-6a) ---
|
||
in property <bool> library-scope-pinned: false;
|
||
in property <int> library-pin-done: 0;
|
||
in property <int> library-pin-total: 0;
|
||
callback library-toggle-pin-scope();
|
||
|
||
in property <string> library-root-label: "";
|
||
in-out property <[TimelineBar]> library-timeline;
|
||
in property <string> library-timeline-label: "";
|
||
in property <string> library-window-label: "";
|
||
in property <int> library-offset: 0;
|
||
|
||
in property <int> library-sweep-done: 0;
|
||
in property <int> library-sweep-total: 0;
|
||
|
||
in property <int> library-current-bucket: 0;
|
||
in property <float> library-current-fraction: -1;
|
||
in property <bool> library-timeline-anchored: false;
|
||
|
||
callback library-scrub-fraction(float);
|
||
callback library-timeline-pinch(float);
|
||
callback library-timeline-pan(float);
|
||
callback library-timeline-zoom(int);
|
||
in-out property <int> library-columns: 1;
|
||
in property <bool> library-syncing: false;
|
||
in-out property <length> library-cell-size: 180px;
|
||
callback library-zoom-cells(int);
|
||
in property <int> library-scroll-to: 0;
|
||
in property <int> 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 <int> collection-selected: 0;
|
||
in property <string> collection-scope-label: "";
|
||
in property <string> collection-error: "";
|
||
/// Whether the sidebar is shown at all. Closed by default in the compact
|
||
/// layout, where 232px of the window is most of the photograph (FR-UI-1),
|
||
/// and openable from the grid header either way — see `panel-visible` for
|
||
/// how a default and a user's answer to it are kept apart.
|
||
in property <bool> collections-visible: true;
|
||
|
||
/// Which collection is being renamed in the sidebar, by id. 0 is none.
|
||
in property <int> collection-renaming: 0;
|
||
|
||
callback collection-select(int);
|
||
callback collection-toggle(int);
|
||
callback collection-new();
|
||
callback collection-menu(int);
|
||
/// 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 <int> trash-count: 0;
|
||
in property <string> trash-label: "";
|
||
/// A soft delete: move the dragged images to the trash folder.
|
||
callback trash-dropped();
|
||
/// A hard delete: permanently remove everything in the trash.
|
||
callback trash-empty();
|
||
/// Put the selected trashed images back where they came from.
|
||
callback trash-restore();
|
||
|
||
// --- drag and drop ---
|
||
//
|
||
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
|
||
// click-versus-drag threshold, arbitration against the grid's Flickable,
|
||
// and the image drawn under the cursor. So there is no badge to position
|
||
// and no hover state to mirror here — only the payload, which Rust builds
|
||
// from the selection, and the count the header shows.
|
||
/// The drag payload, built by Rust from the current selection.
|
||
pure callback library-drag-payload() -> data-transfer;
|
||
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
|
||
/// stack where several images are being dragged. Composited in Rust.
|
||
in property <image> library-drag-image;
|
||
/// A drag began on a cell, so an unselected one can be promoted before the
|
||
/// payload is read.
|
||
callback library-drag-started(int);
|
||
callback library-drag-finished();
|
||
in property <int> library-selected-count: 0;
|
||
|
||
callback library-cell-pressed(int, bool, bool);
|
||
callback library-remove-from-collection();
|
||
|
||
// --- 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 <int> library-filter-min-rating: 0;
|
||
in property <bool> library-filter-unjudged: false;
|
||
in property <int> library-filter-flag: 0;
|
||
/// Images per star count, index 0 unrated, for the filter chips.
|
||
in-out property <[int]> library-rating-counts;
|
||
|
||
callback library-filter-min-rating-changed(int);
|
||
callback library-filter-unjudged-changed(bool);
|
||
callback library-filter-flag-changed(int);
|
||
|
||
callback next-image();
|
||
callback prev-image();
|
||
|
||
// Adjustment controls, generated from what the pipeline reports it can
|
||
// do. This window knows the *shape* of a control, never which operations
|
||
// exist (FR-DEV-3a).
|
||
in property <[ParamRow]> adjust-rows;
|
||
in property <bool> adjust-enabled: false;
|
||
// The tone curve's sampled shape, evaluated by the core so the drawn
|
||
// line and the applied one cannot disagree.
|
||
in property <[float]> curve-samples;
|
||
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 <bool> show-settings: false;
|
||
|
||
in property <string> settings-original-budget: "";
|
||
in property <bool> settings-original-unlimited: false;
|
||
in property <string> settings-thumbnail-budget: "";
|
||
in property <bool> settings-thumbnail-unlimited: false;
|
||
in property <bool> settings-keep-opened: true;
|
||
in property <string> settings-cache-usage: "";
|
||
|
||
callback settings-original-budget-changed(string);
|
||
callback settings-original-unlimited-toggled(bool);
|
||
callback settings-thumbnail-budget-changed(string);
|
||
callback settings-thumbnail-unlimited-toggled(bool);
|
||
callback settings-keep-opened-toggled(bool);
|
||
|
||
in property <[string]> settings-format-labels;
|
||
in property <int> settings-format-selected: 0;
|
||
in property <int> settings-quality: 90;
|
||
in property <bool> settings-quality-enabled: true;
|
||
in property <[string]> settings-colour-labels;
|
||
in property <int> settings-colour-selected: 0;
|
||
in property <[string]> settings-sizing-labels;
|
||
in property <int> settings-sizing-selected: 0;
|
||
in property <int> settings-sizing-value: 0;
|
||
in property <bool> settings-sizing-has-value: false;
|
||
in property <string> settings-sizing-unit: "px";
|
||
in property <bool> settings-allow-upscaling: false;
|
||
in property <[string]> settings-sharpening-labels;
|
||
in property <int> settings-sharpening-selected: 0;
|
||
in property <string> settings-filename-template: "";
|
||
in property <[string]> settings-collision-labels;
|
||
in property <int> settings-collision-selected: 0;
|
||
in property <bool> settings-strip-location: true;
|
||
in property <string> settings-destination: "";
|
||
in property <string> settings-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 <bool> expanded: true;
|
||
in property <string> 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 <bool> 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 <length> shell-width:
|
||
root.width - root.safe-area-insets.left - root.safe-area-insets.right;
|
||
|
||
// One-way: report width outward, never read layout back into it.
|
||
changed shell-width => { root.window-resized(self.shell-width); }
|
||
|
||
// Everything the interface draws lives inside this, for two reasons that
|
||
// happen to want the same element.
|
||
//
|
||
// **The safe area.** From target SDK 35 Android draws applications behind
|
||
// the status and navigation bars and offers no way out, so the window's
|
||
// own coordinates include the strips those bars occupy — which is why the
|
||
// develop status strip came out underneath the clock and the wifi icon.
|
||
// `safe-area-insets` is what the platform reports as covered; the shell is
|
||
// placed inside it and only the window background paints behind the bars.
|
||
// Every inset is zero on the desktop, so nothing about that layout moves.
|
||
//
|
||
// **The back gesture.** Android's back button and back swipe arrive as
|
||
// `Key.Back`, delivered to whatever holds focus and then bubbled up its
|
||
// ancestors — so the one handler that answers for the whole application
|
||
// has to be above all four views rather than beside them. Accepting the
|
||
// key tells the platform we consumed it; rejecting it lets Android do what
|
||
// it does by default and close the activity, which is exactly what should
|
||
// happen at the top of the stack.
|
||
shell := FocusScope {
|
||
x: root.safe-area-insets.left;
|
||
y: root.safe-area-insets.top;
|
||
width: root.shell-width;
|
||
height: root.height - root.safe-area-insets.top
|
||
- root.safe-area-insets.bottom;
|
||
|
||
// Deliberately does *not* focus itself.
|
||
//
|
||
// A key needs a focus item to bubble from, so each of the four views
|
||
// below focuses a scope of its own on show — the grid's judgement keys,
|
||
// develop's arrow keys, and a zero-sized holder on the two form pages
|
||
// that would otherwise focus nothing. Claiming focus here as a fallback
|
||
// would race with those: Slint does not promise whether a parent's
|
||
// `init` runs before or after its children's, so on the wrong ordering
|
||
// this would take focus back off the grid and silently break culling.
|
||
|
||
key-pressed(event) => {
|
||
// Escape alongside Back: the same "go up one" on a keyboard, and
|
||
// the inner scopes that use it for something more specific — a
|
||
// rename in the sidebar — see it first and accept it there.
|
||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||
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 <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
|
||
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
|
||
property <float> box-w: self.width / 1px;
|
||
property <float> box-h: self.height / 1px;
|
||
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
|
||
property <length> shown-w: self.src-w * self.fit-scale * 1px;
|
||
property <length> shown-h: self.src-h * self.fit-scale * 1px;
|
||
property <length> shown-x: (self.width - self.shown-w) / 2;
|
||
property <length> shown-y: (self.height - self.shown-h) / 2;
|
||
|
||
// Empty and error states say what happened rather than
|
||
// showing a blank canvas.
|
||
if root.total == 0 || root.load-error != "": EmptyState {
|
||
headline: root.load-error != "" ? "Could not load image" : "No images";
|
||
detail: root.load-error != "" ? root.load-error
|
||
: "Pass a folder or file on the command line.";
|
||
}
|
||
|
||
// --- zoom and pan ------------------------------------------
|
||
//
|
||
// Below the crop overlay in z-order so that, in crop mode, the
|
||
// handles take the drag instead. Scroll still zooms either way.
|
||
|
||
// TRACES: FR-UI-4
|
||
// Pinch to zoom. The wheel gesture below is the desktop's
|
||
// answer and there is no wheel on a tablet, so without this
|
||
// the only way to magnify a photograph on the device the
|
||
// panel was sized for is a control that does not exist.
|
||
//
|
||
// `scale` is cumulative from 1.0 across the whole gesture,
|
||
// so the ratio since the last update is what maps onto a
|
||
// zoom step — the same reasoning as the timeline's pinch,
|
||
// and for the same reason: applying the running total on
|
||
// every update squares it and shoots straight to the limit.
|
||
//
|
||
// Anchored on the midpoint between the fingers, which is
|
||
// what makes a pinch feel like it is moving the picture
|
||
// rather than the viewport.
|
||
if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler {
|
||
x: 0; y: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
enabled: !root.crop-mode;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => { self.last-scale = 1.0; }
|
||
updated => {
|
||
root.zoom-at(
|
||
self.scale / max(0.01, self.last-scale),
|
||
(self.center.x - canvas-area.shown-x)
|
||
/ max(canvas-area.shown-w, 1px),
|
||
(self.center.y - canvas-area.shown-y)
|
||
/ max(canvas-area.shown-h, 1px),
|
||
);
|
||
self.last-scale = self.scale;
|
||
}
|
||
ended => { self.last-scale = 1.0; }
|
||
cancelled => { self.last-scale = 1.0; }
|
||
}
|
||
|
||
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 <length> last-x;
|
||
property <length> last-y;
|
||
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
self.last-x = self.mouse-x;
|
||
self.last-y = self.mouse-y;
|
||
}
|
||
}
|
||
|
||
moved => {
|
||
if (self.pressed && root.zoomed) {
|
||
// Fractions of the *visible* area, which is what
|
||
// the session's pan expects. Negated: dragging
|
||
// right moves the image right, so the window onto
|
||
// it moves left.
|
||
root.pan-by(
|
||
-(self.mouse-x - self.last-x) / max(parent.shown-w, 1px),
|
||
-(self.mouse-y - self.last-y) / max(parent.shown-h, 1px),
|
||
);
|
||
self.last-x = self.mouse-x;
|
||
self.last-y = self.mouse-y;
|
||
}
|
||
}
|
||
|
||
scroll-event(ev) => {
|
||
if (ev.delta-y == 0) {
|
||
return reject;
|
||
}
|
||
// Anchored on the pointer, in fractions of the shown
|
||
// image, so whatever is under the cursor stays there.
|
||
root.zoom-at(
|
||
ev.delta-y > 0 ? 1.15 : 1.0 / 1.15,
|
||
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
|
||
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
|
||
);
|
||
return accept;
|
||
}
|
||
|
||
double-clicked => { root.zoom-reset(); }
|
||
}
|
||
|
||
// --- crop overlay ------------------------------------------
|
||
//
|
||
// 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 <length> rx: root.crop-x * self.width;
|
||
property <length> ry: root.crop-y * self.height;
|
||
property <length> rw: root.crop-w * self.width;
|
||
property <length> 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 <brush> 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 <float> start-x;
|
||
property <float> 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 <length> 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 <float> ox;
|
||
property <float> oy;
|
||
property <float> ow;
|
||
property <float> 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 <float> dx:
|
||
(self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px);
|
||
property <float> 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 <int> px-w: Math.round(self.width / 1px);
|
||
property <int> px-h: Math.round(self.height / 1px);
|
||
changed px-w => { root.canvas-resized(self.px-w, self.px-h); }
|
||
changed px-h => { root.canvas-resized(self.px-w, self.px-h); }
|
||
}
|
||
|
||
// Always instantiated, width collapsed to zero when closed. A
|
||
// conditional `if` here creates a binding loop — the layout
|
||
// depends on `panel-visible`, which derives from the window
|
||
// width, which the layout then influences. Slint flags it, and
|
||
// it can panic at runtime.
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|