The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
934 lines
41 KiB
Plaintext
934 lines
41 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { AdjustPanel, 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";
|
||
|
||
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;
|
||
|
||
callback back-to-library();
|
||
|
||
// Tall enough for a Button to sit in without the strip growing: the
|
||
// control height and this strip are both 28px by design.
|
||
height: Theme.control-height;
|
||
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";
|
||
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; }
|
||
|
||
// 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.
|
||
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 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);
|
||
/// 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);
|
||
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;
|
||
|
||
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 <int> library-current-index: -1;
|
||
in property <bool> library-timeline-anchored: false;
|
||
|
||
callback library-scrub(int);
|
||
callback library-timeline-pan(int);
|
||
callback library-timeline-zoom(int);
|
||
in-out property <int> library-columns: 1;
|
||
in property <bool> library-syncing: false;
|
||
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. Collapsed in the compact layout,
|
||
/// where 232px of the window is most of the photograph (FR-UI-1).
|
||
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();
|
||
|
||
// 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.
|
||
in property <bool> expanded: true;
|
||
in property <string> layout-class: "expanded";
|
||
|
||
callback canvas-resized(int, int);
|
||
callback window-resized(length);
|
||
|
||
// One-way: report width outward, never read layout back into it.
|
||
changed width => { root.window-resized(self.width); }
|
||
|
||
// 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-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-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-launch && root.show-library: Rectangle {
|
||
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;
|
||
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-index: root.library-current-index;
|
||
timeline-anchored: root.library-timeline-anchored;
|
||
|
||
scrub(t) => { root.library-scrub(t); }
|
||
timeline-pan(d) => { root.library-timeline-pan(d); }
|
||
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
||
syncing: root.library-syncing;
|
||
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(); }
|
||
|
||
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-launch && !root.show-library: VerticalLayout {
|
||
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;
|
||
back-to-library() => { root.back-to-library(); }
|
||
}
|
||
|
||
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;
|
||
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.
|
||
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.
|
||
FocusScope {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Crop and zoom controls, floating over the canvas so they are
|
||
// reachable whether or not the side panel is showing.
|
||
if root.total > 0 && root.load-error == "": HorizontalLayout {
|
||
x: 12px;
|
||
y: parent.height - self.preferred-height - 12px;
|
||
spacing: 6px;
|
||
|
||
Button {
|
||
text: root.crop-mode ? "Done" : "Crop";
|
||
active: root.crop-mode;
|
||
clicked => { root.crop-mode-toggled(!root.crop-mode); }
|
||
}
|
||
|
||
// 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 in compact mode.
|
||
// A conditional `if` here creates a binding loop — the layout
|
||
// depends on `expanded`, which derives from the window width,
|
||
// which the layout then influences. Slint flags it, and it can
|
||
// panic at runtime.
|
||
Rectangle {
|
||
width: root.expanded ? 280px : 0px;
|
||
visible: root.expanded;
|
||
background: Theme.surface;
|
||
|
||
VerticalLayout {
|
||
InfoPanel {
|
||
camera: root.camera;
|
||
exposure: root.exposure;
|
||
dimensions: root.dimensions;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|