2273 lines
114 KiB
Plaintext
2273 lines
114 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||
import { LaunchScreen } from "launch.slint";
|
||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||
import { SettingsPage } from "settings.slint";
|
||
|
||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView }
|
||
export { ViewMode, GradientHandle, HandleRole }
|
||
|
||
// 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;
|
||
/// FR-UI-1: the layout class, so the instrumentation can stand down on a
|
||
/// strip too narrow to carry both it and the controls.
|
||
in property <bool> expanded: true;
|
||
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;
|
||
/// What the export button says. Rust owns it because the answer depends
|
||
/// on settings this component does not see — the format, and whether the
|
||
/// destination is this device or the server.
|
||
in property <string> export-label: "Export";
|
||
in property <bool> export-busy: false;
|
||
/// What the last export did. Sits beside the button rather than in a
|
||
/// dialogue: an export that succeeded needs no acknowledging, and one
|
||
/// that failed needs its reason where the retry is.
|
||
in property <string> export-status;
|
||
/// Whether the edit can be stepped either way (FR-DEV-5).
|
||
in property <bool> can-undo: false;
|
||
in property <bool> can-redo: false;
|
||
|
||
callback back-to-library();
|
||
callback open-settings();
|
||
callback toggle-panel();
|
||
callback export-image();
|
||
callback undo();
|
||
callback redo();
|
||
|
||
// 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;
|
||
|
||
// Scrolls rather than overflowing. A layout given less width than its
|
||
// children need does not shrink them — it overruns the edge and reports
|
||
// the oversized minimum to whatever contains it. Collapsing the
|
||
// instrumentation below buys back about 200 logical pixels, which is
|
||
// enough for a tablet; it is not enough for a phone, and nothing here
|
||
// should be unreachable merely because the window is narrow.
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, strip.preferred-width);
|
||
|
||
strip := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
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(); }
|
||
}
|
||
|
||
Value { text: root.filename; compact: true; overflow: elide; }
|
||
|
||
Caption { text: root.position; }
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
|
||
// --- instrumentation, and only where there is room for it ---------
|
||
//
|
||
// The render backend, the layout class and the frame rate are all
|
||
// developer readouts. They cost about 200 logical pixels of a strip
|
||
// that also carries the only way out of develop, the undo pair, the
|
||
// panel toggle and the export button — and a `HorizontalLayout` given
|
||
// less width than its children's minimums does not shrink them, it
|
||
// runs off the end.
|
||
//
|
||
// A tablet in portrait is 768 logical pixels, so that is exactly what
|
||
// happened: everything from the panel toggle rightwards was pushed off
|
||
// the right-hand edge. The develop column is *also* closed by default
|
||
// below the breakpoint, so the toggle being unreachable meant the
|
||
// column could not be opened at all — and with it went copy and paste,
|
||
// which live in it. Reported as "I have no idea how to copy a setting
|
||
// on Android and apply it to other images", and the answer was that
|
||
// there was no way to.
|
||
//
|
||
// Collapsed to zero width rather than wrapped in an `if`: this strip
|
||
// is inside the layout that `expanded` feeds, and a conditional child
|
||
// here is the shape that has already caused binding loops in this file
|
||
// (see the panel below). A `visible: false` child still takes its slot
|
||
// in a layout, so the width has to go as well.
|
||
Rectangle {
|
||
width: root.expanded ? diagnostics.preferred-width : 0px;
|
||
visible: root.expanded;
|
||
clip: true;
|
||
|
||
diagnostics := HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
|
||
// 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;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
Caption {
|
||
text: root.layout-class;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
// 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;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Undo and redo, in the strip rather than in the develop column: the
|
||
// panel can be put away, and the one control that takes back a
|
||
// mis-drag must not go away with it. Disabled rather than hidden, so
|
||
// the pair keeps its place and the keyboard shortcut has something
|
||
// visible to correspond to.
|
||
Button {
|
||
text: "Undo";
|
||
enabled: root.can-undo;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.undo(); }
|
||
}
|
||
|
||
Button {
|
||
text: "Redo";
|
||
enabled: root.can-redo;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.redo(); }
|
||
}
|
||
|
||
// 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(); }
|
||
}
|
||
|
||
Caption {
|
||
text: root.export-status;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The export itself, beside the settings that shape it. This is the
|
||
// screen where a photograph is finished, so it is the screen where
|
||
// one is asked for — the grid can export a selection later, but a
|
||
// single finished frame is exported from in front of it.
|
||
Button {
|
||
text: root.export-busy ? "Exporting…" : root.export-label;
|
||
enabled: !root.export-busy;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.export-image(); }
|
||
}
|
||
|
||
// 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(); }
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
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";
|
||
// The one raster asset in the UI, and the exception that the header of
|
||
// icons.slint does not cover: everything drawn *inside* the window is a
|
||
// Path because a font might not have the glyph, but the window icon is
|
||
// never drawn by us. It is handed to the window manager, which wants
|
||
// pixels. Pre-shaped with rounded corners and transparent outside them,
|
||
// because desktop window managers composite it as-is rather than masking
|
||
// it the way Android does — the Android launcher icon is a separate,
|
||
// maskable set under apps/darkroom-android/android/res.
|
||
//
|
||
// Where it actually shows up is narrower than it looks: X11 and Windows
|
||
// read it, and Wayland ignores it outright in favour of matching app_id
|
||
// against an installed .desktop file, which this repo does not install.
|
||
// Android ignores it too — there is no title bar to put it in.
|
||
icon: @image-url("app-icon.png");
|
||
background: Theme.ground;
|
||
preferred-width: 1100px;
|
||
preferred-height: 720px;
|
||
min-width: 360px;
|
||
min-height: 320px;
|
||
|
||
// Set from Rust each frame: the compute output, delivered as a texture.
|
||
in property <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: "";
|
||
|
||
/// TRACES: FR-DSP-7
|
||
/// The counted frame. Set only when the render has settled — a histogram
|
||
/// of a draft frame is a histogram of an image nobody is reading.
|
||
in property <HistogramView> histogram;
|
||
|
||
// --- 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;
|
||
|
||
/// TRACES: FR-UI-5
|
||
/// What the develop view is currently doing (see `ViewMode`).
|
||
///
|
||
/// One value rather than a flag per mode, so two modes cannot be on at
|
||
/// once — which, with `crop-mode` a bare bool beside a masking panel that
|
||
/// armed the canvas through two toggles of its own, they could be. Rust
|
||
/// owns it: entering a mode has side effects on the session (crop drops
|
||
/// the zoom, local clears nothing and leaving it clears the selection),
|
||
/// and a property the interface could set directly would let the view
|
||
/// change without them.
|
||
///
|
||
/// In `crop` the canvas shows the *whole* frame — otherwise the area being
|
||
/// cropped away would not be on screen to drag across — and the surround
|
||
/// is greyed.
|
||
in property <ViewMode> view-mode: ViewMode.photo;
|
||
/// Shorthand for the two conditions half this file tests, so a mode
|
||
/// comparison is written once rather than at each of a dozen call sites.
|
||
property <bool> cropping: root.view-mode == ViewMode.crop;
|
||
property <bool> local-mode: root.view-mode == ViewMode.local;
|
||
|
||
/// 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;
|
||
|
||
/// Enter a mode, or `photo` to leave whichever one is current.
|
||
callback mode-picked(ViewMode);
|
||
/// 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();
|
||
|
||
// --- undo and redo (FR-DEV-5) ---
|
||
//
|
||
// Rust owns whether there is anywhere to step, because "anywhere" is a
|
||
// position in a stack of snapshots this side never sees. A whole drag is
|
||
// one step; the coalescing that makes it so lives in `dr-pipeline`.
|
||
in property <bool> can-undo: false;
|
||
in property <bool> can-redo: false;
|
||
callback undo();
|
||
callback redo();
|
||
/// 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;
|
||
// --- background activity (FR-CAT-1, FR-NC-6c) ---
|
||
//
|
||
// Every background job in one register, filled by `activity.rs`. The
|
||
// aggregate drives the bar across the top of the shell; the rows are the
|
||
// list on the settings page, where a stalled transfer can be identified
|
||
// rather than merely felt.
|
||
in property <[ActivityRow]> activity-rows;
|
||
/// Anything at all running. Drives the bar's visibility, so it is a
|
||
/// property rather than `activity-rows.length > 0` — finished rows stay in
|
||
/// the list for a while and must not keep the bar alight.
|
||
in property <bool> activity-busy: false;
|
||
/// Whether the running jobs, taken together, have a denominator. A scan
|
||
/// alone does not; a scan beside a download does.
|
||
in property <bool> activity-determinate: false;
|
||
in property <float> activity-fraction: 0;
|
||
in property <int> activity-running: 0;
|
||
/// Jobs that stopped and are being kept: finished ones as a short history,
|
||
/// failed ones until they are cleared.
|
||
in property <int> activity-kept: 0;
|
||
callback activity-clear-finished();
|
||
|
||
in property <bool> library-scanning: false;
|
||
in property <string> library-status: "";
|
||
in property <string> library-error: "";
|
||
|
||
// --- offline mode (FR-CAT-9) ---
|
||
//
|
||
// The library keeps working from local data when the server cannot be
|
||
// reached: thumbnails come from the shards, and rating and sorting are
|
||
// catalog operations that never needed the network. What stops is opening
|
||
// an original that was never stored locally.
|
||
//
|
||
// `offline-reason` carries the transport's own message ("connection
|
||
// refused", "dns error") because it is usually specific enough to act on,
|
||
// where a bare "offline" leaves the user guessing whether it is their wifi
|
||
// or the server.
|
||
in property <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();
|
||
/// Whether the grid is narrowed to the timeline's visible span.
|
||
in-out property <bool> library-range-active: false;
|
||
callback library-toggle-date-range();
|
||
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();
|
||
|
||
// The question a held collection asks: keep these on the device, or give
|
||
// the disk back. Driven entirely from Rust — the counts and the byte sizes
|
||
// in the labels are catalog reads, and an empty `title` is what closes it.
|
||
in property <string> offline-prompt-title: "";
|
||
in property <string> offline-prompt-detail: "";
|
||
in property <string> offline-prompt-keep-label: "";
|
||
in property <string> offline-prompt-release-label: "";
|
||
in property <bool> offline-prompt-can-keep: false;
|
||
in property <bool> offline-prompt-can-release: false;
|
||
in property <bool> offline-prompt-busy: false;
|
||
callback offline-prompt-keep();
|
||
callback offline-prompt-release();
|
||
callback offline-prompt-dismiss();
|
||
|
||
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;
|
||
/// Whether this session is a touch one, which decides if the rating strip
|
||
/// waits to be hovered or stands open. Seeded from the platform by Rust
|
||
/// and latched by the grid on the first finger it sees.
|
||
in-out property <bool> library-touched: false;
|
||
callback library-zoom-cells(int);
|
||
/// A pinch step on the grid: the ratio since the last update.
|
||
callback library-pinch-cells(float);
|
||
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||
callback library-pinch-started();
|
||
in property <int> library-scroll-to: 0;
|
||
in property <int> library-scroll-token: 0;
|
||
/// Which row of the loaded window is the photograph currently open in
|
||
/// develop, so the roll can mark it. `-1` when it is not in the window.
|
||
in property <int> library-roll-current: -1;
|
||
/// Whether the photo roll is out. Owned here rather than by the roll, so
|
||
/// it survives the develop view being torn down and rebuilt.
|
||
in-out property <bool> roll-open: false;
|
||
/// A photograph was chosen from the roll: the row within the loaded
|
||
/// window, which is what a cell click reports too.
|
||
callback library-roll-pick(int);
|
||
callback library-sync-now();
|
||
callback library-columns-changed();
|
||
callback library-scrolled(int);
|
||
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);
|
||
/// TRACES: FR-NC-6a
|
||
/// Ask about a collection's local copies: the tray on a row, or a long
|
||
/// press anywhere on it.
|
||
callback collection-offline-menu(int);
|
||
/// A row is held or released — the long press is timed in Rust.
|
||
callback collection-row-press(int, bool);
|
||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||
/// File the grid's selection in a collection without dragging it there:
|
||
/// the target's id, and whether to take the images out of the collection
|
||
/// currently being shown.
|
||
callback library-file-in-collection(int, bool);
|
||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||
/// Keywording the grid's selection. The catalog has been searchable by
|
||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||
///
|
||
/// The vocabulary arrives already answered against the selection — each row
|
||
/// says how many of the selected photographs carry that word — because only
|
||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||
/// the selection as a second model that could disagree with the first.
|
||
in property <[KeywordRow]> library-keywords;
|
||
/// The sheet is opening: recompute the rows against the selection as it
|
||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||
/// every arrow key and the sheet is shut for almost all of them.
|
||
callback library-keywords-opened();
|
||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||
/// word typed into the field and a word tapped in the list are one path.
|
||
callback library-assign-keyword(string);
|
||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||
callback library-unassign-keyword(string);
|
||
/// TRACES: FR-UI-2
|
||
/// Whether a tap in the grid selects rather than opens, and the button
|
||
/// that turns it on. The long press does the same thing without it.
|
||
in property <bool> library-select-mode: false;
|
||
callback library-toggle-select-mode();
|
||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||
callback library-cell-press-ended();
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Two taps on a cell: in selection mode, the far end of a range.
|
||
callback library-cell-double-clicked(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);
|
||
/// Payload for dragging a collection onto another to reparent it.
|
||
pure callback collection-drag-payload(int) -> data-transfer;
|
||
|
||
// --- trash (FR-CAT-15) ---
|
||
//
|
||
// Not a collection: a drop here moves the file into a trash folder on the
|
||
// server, where a collection drop only adds a reference.
|
||
in property <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();
|
||
|
||
// The keyboard cursor: where the arrows are in the library, as an image
|
||
// ordinal. Rust owns it — clamping it needs the library's length, and
|
||
// moving it may have to swap the loaded window underneath.
|
||
in property <int> library-cursor: -1;
|
||
callback library-move-cursor(int, bool);
|
||
callback library-open-cursor();
|
||
|
||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
|
||
//
|
||
// Stars and pick/reject, set from the grid and persisted to the catalog
|
||
// and the sidecar. Every image enters unrated, which is a state of its
|
||
// own rather than a zero score.
|
||
/// A star was clicked on a cell: row, then the rating 0..5.
|
||
callback library-cell-rated(int, int);
|
||
/// The trash target was clicked on one cell, by row.
|
||
callback library-cell-trashed(int);
|
||
/// 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 <[string]> adjust-tabs;
|
||
in property <int> adjust-active-tab: -1;
|
||
callback adjust-tab-picked(int);
|
||
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;
|
||
// The curves that widget can plot, named by the core, and which of them
|
||
// `curve-samples` describes. Fewer than two means nothing to choose.
|
||
in property <[string]> curve-channels;
|
||
in property <int> curve-channel;
|
||
callback param-changed(int, int, float);
|
||
callback param-reset(int, int);
|
||
callback curve-reset(int);
|
||
callback curve-channel-picked(int);
|
||
callback reset-all();
|
||
|
||
// --- copying settings between photographs (FR-DEV-6) ---
|
||
//
|
||
// The clipboard is a window-lifetime thing rather than a view's, which is
|
||
// why it lives here and not inside the develop column: a copy is taken in
|
||
// develop and may be pasted onto a selection back in the library grid.
|
||
/// Whether anything has been copied this session.
|
||
in property <bool> settings-armed: false;
|
||
/// What a paste would apply, at the current scope — "3 adjustments".
|
||
in property <string> settings-summary;
|
||
/// Whether the copy holds framing that the current setting is dropping.
|
||
in property <bool> settings-framing-withheld: false;
|
||
callback copy-settings();
|
||
callback paste-settings();
|
||
/// Apply the clipboard to every selected image in the grid.
|
||
callback paste-settings-to-selection();
|
||
|
||
// --- 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 <bool> settings-copy-includes-framing: false;
|
||
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);
|
||
callback settings-copy-includes-framing-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-destination-hint;
|
||
in property <bool> settings-browse-open: false;
|
||
in property <string> settings-browse-path;
|
||
in property <[string]> settings-browse-entries;
|
||
in property <bool> settings-browse-loading: false;
|
||
in property <bool> settings-browse-at-root: true;
|
||
in property <bool> settings-browse-available: false;
|
||
in property <[string]> settings-target-labels;
|
||
in property <int> settings-target-selected: 0;
|
||
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-target-changed(int);
|
||
callback settings-browse-open-picker();
|
||
callback settings-browse-into(string);
|
||
callback settings-browse-up();
|
||
callback settings-browse-confirm();
|
||
callback settings-browse-cancel();
|
||
callback settings-reset();
|
||
|
||
/// Show the settings page. Reads the file first, so a second instance's
|
||
/// writes are picked up rather than overwritten.
|
||
callback settings-open();
|
||
callback settings-close();
|
||
|
||
// FR-UI-1: layout class follows window width, not device type. A narrow
|
||
// desktop window gets the compact layout, exactly as a tablet would.
|
||
//
|
||
// Set from Rust rather than derived from `root.width` here: a property
|
||
// read inside the layout and also feeding it creates a binding loop,
|
||
// which Slint warns about and which can panic at runtime.
|
||
//
|
||
// Nothing below reads `expanded` directly any more. It decides what the
|
||
// two collapsible columns *default* to, and Rust applies that to
|
||
// `panel-visible` and `collections-visible`; binding a column's width to
|
||
// the class itself would mean a panel the user opened snapped shut again
|
||
// on the next resize.
|
||
in property <bool> expanded: true;
|
||
in property <string> layout-class: "expanded";
|
||
|
||
// --- local adjustments (FR-DEV-3) ---------------------------------------
|
||
|
||
/// The false-coloured region map, drawn over the photograph.
|
||
///
|
||
/// An empty image when there is nothing to draw, which is also how the
|
||
/// overlay is hidden — one source of truth rather than a flag that can
|
||
/// disagree with the picture.
|
||
in property <image> region-overlay;
|
||
/// How strongly the overlay is composited. Not 1.0: the point of looking
|
||
/// at it is to judge whether a boundary follows the *photograph*, and an
|
||
/// opaque map hides the thing being judged.
|
||
in property <float> overlay-strength: 0.55;
|
||
in property <bool> overlay-on: false;
|
||
/// Which part of the source-space overlay the view is showing, in overlay
|
||
/// pixels. Without it the overlay stays frame-sized while the photograph
|
||
/// moves under it.
|
||
in property <int> overlay-clip-x;
|
||
in property <int> overlay-clip-y;
|
||
in property <int> overlay-clip-w;
|
||
in property <int> overlay-clip-h;
|
||
/// The straightening angle, so the overlay turns with the frame.
|
||
in property <float> overlay-angle: 0.0;
|
||
|
||
/// Whether a click on the canvas selects a region instead of panning.
|
||
///
|
||
/// **Derived, not toggled.** It used to be a button in the masking panel,
|
||
/// which meant local masking could be open with picking off — a mode
|
||
/// entered and doing nothing. Arming a click is part of what local mode
|
||
/// *is*, so it follows the mode and the existence of something to pick.
|
||
property <bool> region-picking: root.local-mode && root.segmented;
|
||
|
||
/// Whether shift is down, tracked by the develop key scope below.
|
||
///
|
||
/// A `TouchArea`'s `clicked` carries no modifiers, so the state has to be
|
||
/// held between the key event and the click. Private to the window because
|
||
/// nothing outside it has any business setting it.
|
||
property <bool> shift-held: false;
|
||
|
||
in property <[MaskRow]> mask-rows;
|
||
in property <[SubjectRow]> subject-rows;
|
||
in property <bool> segmented: false;
|
||
in property <bool> segmenting: false;
|
||
/// The selected layer's name, already resolved, or "ADJUST" when the
|
||
/// controls are global. Names the adjust panel's heading.
|
||
in property <string> adjust-scope: "ADJUST";
|
||
|
||
/// The selected gradient's handles, in fractions of the shown image.
|
||
/// Empty unless a gradient layer is selected in local mode.
|
||
in property <[GradientHandle]> gradient-handles;
|
||
/// A handle dragged: which one, where the press was and where the pointer
|
||
/// is now, both in fractions of the shown image.
|
||
///
|
||
/// The press is carried rather than a running delta because the geometry
|
||
/// is re-derived from the position it had when the drag began — which is
|
||
/// what stops a drag accumulating rounding error along its length, the
|
||
/// same reason the crop handles capture the rect they started from.
|
||
callback gradient-handle-dragged(HandleRole, float, float, float, float);
|
||
/// A drag finished, so the edit can be recorded as one history step
|
||
/// rather than as one per frame of the gesture.
|
||
callback gradient-handle-released();
|
||
|
||
callback segment-image();
|
||
/// A click on the photograph, in fractions of the shown image, plus
|
||
/// whether it should extend the selection rather than replace it.
|
||
callback region-picked(float, float, bool);
|
||
callback mask-selected(string);
|
||
callback mask-removed(string);
|
||
callback mask-toggled(string, bool);
|
||
callback mask-invert-toggled(string, bool);
|
||
callback mask-opacity-changed(string, float);
|
||
callback mask-feather-changed(string, float);
|
||
callback mask-falloff-picked(string, int);
|
||
callback mask-morphology-picked(string, int);
|
||
callback mask-morph-radius-changed(string, float);
|
||
callback add-gradient-mask(bool);
|
||
callback add-subject-mask(int);
|
||
|
||
/// 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;
|
||
/// The export button's label and busy state, and the line it reports to.
|
||
/// Rust owns all three: what an export is called depends on the format and
|
||
/// on whether the destination is this device or the server, neither of
|
||
/// which the toolbar can see.
|
||
in property <string> export-label: "Export";
|
||
in property <bool> export-busy: false;
|
||
in property <string> export-status;
|
||
/// TRACES: FR-EXP-7
|
||
/// Whether an export would be queued for the server rather than written to
|
||
/// this device. The grid says the same thing as the develop button, but it
|
||
/// composes its own label around a selection count — so it is given the
|
||
/// fact and not the sentence.
|
||
in property <bool> export-to-server: false;
|
||
/// Whether a batch is running. The grid's export button becomes its cancel
|
||
/// button; there is nowhere else a long batch can be stopped from.
|
||
in property <bool> library-exporting: false;
|
||
/// Export the grid's selection in the background (FR-EXP-7).
|
||
callback library-export-selection();
|
||
/// TRACES: NFR-ARCH-3
|
||
callback library-cancel-export();
|
||
/// Show or hide the develop column.
|
||
callback toggle-panel();
|
||
/// Export the image on screen, using the settings as they stand.
|
||
callback export-image();
|
||
/// Show or hide the collections sidebar.
|
||
callback toggle-collections();
|
||
|
||
/// Android's back gesture, or Escape. Returns whether there was anywhere to
|
||
/// go: `false` means this is the top of the stack, and the platform is left
|
||
/// to close the application as it normally would.
|
||
callback back-requested() -> bool;
|
||
|
||
callback canvas-resized(int, int);
|
||
callback window-resized(length);
|
||
|
||
/// The width the interface actually has to lay out in.
|
||
///
|
||
/// Not the window's: on Android the window spans the display and the
|
||
/// left/right insets are given over to system furniture, so choosing the
|
||
/// layout class from `width` would promise the layout space it cannot use.
|
||
property <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) {
|
||
// A dialogue takes "go up one" first, and it is the *only*
|
||
// thing that goes up. Without this, Android's Back button
|
||
// would leave the library from behind an open question — the
|
||
// view changing underneath a modal, which reads as the app
|
||
// having lost its place.
|
||
if (root.offline-prompt-title != "") {
|
||
root.offline-prompt-dismiss();
|
||
return accept;
|
||
}
|
||
return root.back-requested() ? accept : reject;
|
||
}
|
||
return reject;
|
||
}
|
||
|
||
// Settings, over everything. First in the file and first in z-order so the
|
||
// conditions below can be read as "and settings is not open".
|
||
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;
|
||
copy-includes-framing: root.settings-copy-includes-framing;
|
||
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); }
|
||
copy-includes-framing-toggled(on) => {
|
||
root.settings-copy-includes-framing-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;
|
||
destination-hint: root.settings-destination-hint;
|
||
browse-open: root.settings-browse-open;
|
||
browse-path: root.settings-browse-path;
|
||
browse-entries: root.settings-browse-entries;
|
||
browse-loading: root.settings-browse-loading;
|
||
browse-at-root: root.settings-browse-at-root;
|
||
browse-available: root.settings-browse-available;
|
||
target-labels: root.settings-target-labels;
|
||
target-selected: root.settings-target-selected;
|
||
error: root.settings-error;
|
||
|
||
format-picked(i) => { root.settings-format-picked(i); }
|
||
quality-changed(q) => { root.settings-quality-changed(q); }
|
||
colour-picked(i) => { root.settings-colour-picked(i); }
|
||
sizing-picked(i) => { root.settings-sizing-picked(i); }
|
||
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
|
||
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
|
||
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
|
||
template-changed(t) => { root.settings-template-changed(t); }
|
||
collision-picked(i) => { root.settings-collision-picked(i); }
|
||
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
|
||
destination-changed(t) => { root.settings-destination-changed(t); }
|
||
target-picked(i) => { root.settings-target-changed(i); }
|
||
browse-open-picker() => { root.settings-browse-open-picker(); }
|
||
browse-into(n) => { root.settings-browse-into(n); }
|
||
browse-up() => { root.settings-browse-up(); }
|
||
browse-confirm() => { root.settings-browse-confirm(); }
|
||
browse-cancel() => { root.settings-browse-cancel(); }
|
||
|
||
activity-rows: root.activity-rows;
|
||
activity-running: root.activity-running;
|
||
activity-kept: root.activity-kept;
|
||
clear-finished() => { root.activity-clear-finished(); }
|
||
|
||
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); }
|
||
offline-menu(id) => { root.collection-offline-menu(id); }
|
||
row-press(id, down) => { root.collection-row-press(id, down); }
|
||
|
||
renaming-id: root.collection-renaming;
|
||
rename-start(id) => { root.collection-rename-start(id); }
|
||
rename-commit(id, name) => {
|
||
root.collection-rename-commit(id, name);
|
||
}
|
||
rename-cancel() => { root.collection-rename-cancel(); }
|
||
trash-count: root.trash-count;
|
||
trash-label: root.trash-label;
|
||
selected-count: root.library-selected-count;
|
||
|
||
dropped-on(id) => { root.collection-dropped(id); }
|
||
collection-drag-payload(id) => { return root.collection-drag-payload(id); }
|
||
dropped-on-trash() => { root.trash-dropped(); }
|
||
empty-trash() => { root.trash-empty(); }
|
||
restore-selected() => { root.trash-restore(); }
|
||
drag-over(id, over) => { root.collection-drag-over(id, over); }
|
||
}
|
||
}
|
||
|
||
LibraryGrid {
|
||
horizontal-stretch: 1;
|
||
// FR-UI-1: the header collapses its actions behind a
|
||
// disclosure below the breakpoint. A tablet in portrait is
|
||
// 768 logical pixels, which fits the title and the status
|
||
// readouts but not six buttons beside them.
|
||
expanded: root.expanded;
|
||
cells: root.library-cells;
|
||
total: root.library-total;
|
||
scanning: root.library-scanning;
|
||
scan-status: root.library-status;
|
||
scan-error: root.library-error;
|
||
|
||
offline: root.library-offline;
|
||
offline-reason: root.library-offline-reason;
|
||
offline-since: root.library-offline-since;
|
||
retry-connection() => { root.library-retry-connection(); }
|
||
local-only: root.library-local-only;
|
||
local-count: root.library-local-count;
|
||
toggle-local-only() => { root.library-toggle-local-only(); }
|
||
range-active: root.library-range-active;
|
||
toggle-date-range() => { root.library-toggle-date-range(); }
|
||
|
||
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;
|
||
settings-armed: root.settings-armed;
|
||
settings-summary: root.settings-summary;
|
||
paste-settings-to-selection => {
|
||
root.paste-settings-to-selection();
|
||
}
|
||
|
||
// TRACES: FR-EXP-7
|
||
exporting: root.library-exporting;
|
||
export-to-server: root.export-to-server;
|
||
export-selection => { root.library-export-selection(); }
|
||
cancel-export => { root.library-cancel-export(); }
|
||
|
||
sweep-done: root.library-sweep-done;
|
||
sweep-total: root.library-sweep-total;
|
||
|
||
current-bucket: root.library-current-bucket;
|
||
current-bucket-fraction: root.library-current-fraction;
|
||
timeline-anchored: root.library-timeline-anchored;
|
||
|
||
scrub-fraction(f) => { root.library-scrub-fraction(f); }
|
||
timeline-pinch(r) => { root.library-timeline-pinch(r); }
|
||
timeline-pan(d) => { root.library-timeline-pan(d); }
|
||
timeline-zoom(d) => { root.library-timeline-zoom(d); }
|
||
syncing: root.library-syncing;
|
||
requested-cell-size: root.library-cell-size;
|
||
touched <=> root.library-touched;
|
||
zoom-cells(d) => { root.library-zoom-cells(d); }
|
||
pinch-cells(r) => { root.library-pinch-cells(r); }
|
||
pinch-started() => { root.library-pinch-started(); }
|
||
scroll-to: root.library-scroll-to;
|
||
scroll-token: root.library-scroll-token;
|
||
sync-now() => { root.library-sync-now(); }
|
||
columns-changed(n) => {
|
||
root.library-columns = n;
|
||
// Which cell begins a row just changed, and month headings sit on
|
||
// row-leading cells — so they must be recomputed, not just moved.
|
||
root.library-columns-changed();
|
||
}
|
||
scrolled(i) => { root.library-scrolled(i); }
|
||
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);
|
||
}
|
||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
||
select-mode: root.library-select-mode;
|
||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||
// The sidebar's rows, not a second model: the sheet files
|
||
// into the same tree the sidebar draws.
|
||
collections: root.collection-rows;
|
||
file-in-collection(id, moves) => {
|
||
root.library-file-in-collection(id, moves);
|
||
}
|
||
keywords: root.library-keywords;
|
||
keywords-opened() => { root.library-keywords-opened(); }
|
||
assign-keyword(word) => { root.library-assign-keyword(word); }
|
||
unassign-keyword(word) => { root.library-unassign-keyword(word); }
|
||
cursor: root.library-cursor;
|
||
move-cursor(delta, extend) => {
|
||
root.library-move-cursor(delta, extend);
|
||
}
|
||
open-cursor() => { root.library-open-cursor(); }
|
||
drag-image: root.library-drag-image;
|
||
drag-payload() => { return root.library-drag-payload(); }
|
||
drag-started(i) => { root.library-drag-started(i); }
|
||
drag-finished() => { root.library-drag-finished(); }
|
||
remove-from-collection() => {
|
||
root.library-remove-from-collection();
|
||
}
|
||
|
||
filter-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;
|
||
expanded: root.expanded;
|
||
filename: root.filename;
|
||
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
|
||
// Only where a library was opened; command-line files have no
|
||
// grid to return to.
|
||
can-return-to-library: root.library-total > 0;
|
||
panel-visible: root.panel-visible;
|
||
export-label: root.export-label;
|
||
export-busy: root.export-busy;
|
||
export-status: root.export-status;
|
||
can-undo: root.can-undo;
|
||
can-redo: root.can-redo;
|
||
back-to-library() => { root.back-to-library(); }
|
||
open-settings() => { root.settings-open(); }
|
||
toggle-panel() => { root.toggle-panel(); }
|
||
export-image() => { root.export-image(); }
|
||
undo() => { root.undo(); }
|
||
redo() => { root.redo(); }
|
||
}
|
||
|
||
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 == "";
|
||
}
|
||
|
||
// The region map, composited over the photograph at the
|
||
// same fitted rect.
|
||
//
|
||
// Drawn here rather than blended into the render because
|
||
// it is a diagnostic and not an edit: it must not reach
|
||
// the histogram, an export, or the texture the develop
|
||
// pass hands the compositor.
|
||
if root.overlay-on && root.total > 0: Image {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
source: root.region-overlay;
|
||
// The visible part of a source-space picture, which is
|
||
// what makes the overlay follow a zoom, a pan and a
|
||
// crop. The compositor does the crop and the scale;
|
||
// resampling on the CPU would mean rebuilding a
|
||
// megapixel image on every frame of a drag.
|
||
source-clip-x: root.overlay-clip-x;
|
||
source-clip-y: root.overlay-clip-y;
|
||
source-clip-width: root.overlay-clip-w;
|
||
source-clip-height: root.overlay-clip-h;
|
||
transform-rotation: -root.overlay-angle * 1deg;
|
||
image-fit: fill;
|
||
opacity: root.overlay-strength;
|
||
// Nearest-neighbour, always. The map is proxy-sized
|
||
// and its values are *names*: smoothing between region
|
||
// 4 and region 9 invents a colour belonging to
|
||
// neither, and draws a soft edge exactly where the
|
||
// point is to see a hard one.
|
||
image-rendering: ImageRendering.pixelated;
|
||
}
|
||
|
||
// 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.cropping;
|
||
|
||
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; }
|
||
}
|
||
|
||
// Region picking, above the pan/zoom area so a click
|
||
// reaches it first. A separate area rather than a branch
|
||
// inside the one below: panning wants press-drag-release
|
||
// and picking wants a click, and interleaving the two in
|
||
// one handler is how a drag ends up selecting a region the
|
||
// user was only scrolling past.
|
||
if root.region-picking && root.total > 0 && root.load-error == "": pick := TouchArea {
|
||
x: parent.shown-x;
|
||
y: parent.shown-y;
|
||
width: parent.shown-w;
|
||
height: parent.shown-h;
|
||
mouse-cursor: MouseCursor.crosshair;
|
||
enabled: !root.cropping;
|
||
|
||
clicked => {
|
||
root.region-picked(
|
||
self.mouse-x / max(self.width, 1px),
|
||
self.mouse-y / max(self.height, 1px),
|
||
// Shift extends or removes, the modifier every
|
||
// selection tool uses for the same thing.
|
||
root.shift-held,
|
||
);
|
||
}
|
||
}
|
||
|
||
if root.total > 0 && root.load-error == "": TouchArea {
|
||
x: 0; y: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
// A pan is only meaningful once there is something outside
|
||
// the viewport to reach.
|
||
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
|
||
enabled: !root.cropping;
|
||
|
||
property <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.cropping && 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,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- gradient handles ---------------------------------------
|
||
//
|
||
// TRACES: FR-DEV-3 | FR-UI-3
|
||
// A linear or radial mask could be made and then never
|
||
// moved: there was nothing to grab, so a radial sat at the
|
||
// centre of the frame at its default size for ever.
|
||
//
|
||
// **Positioned against `shown-*`, like the crop overlay
|
||
// and the region picker.** The photograph is letterboxed
|
||
// inside this box and Slint does not report the fitted
|
||
// rect, so anything that has to land *on* the picture is
|
||
// placed against the derived one. Rust supplies the
|
||
// positions already mapped through the framing — the same
|
||
// map the shader applies — so a handle follows the mask
|
||
// through a zoom, a pan, a crop and a straightening rather
|
||
// than sitting where the mask used to be.
|
||
//
|
||
// **Drawn small, grabbed large.** The visible dot is 14px
|
||
// because a bigger one would hide the edge it is placed
|
||
// on, and the `TouchArea` is a full touch target, centred
|
||
// on it — `Button` establishes the same split. These are
|
||
// the first controls in the application meant to be
|
||
// dragged on the photograph, and a 12-inch tablet has no
|
||
// hover to reveal them with and no modifier to qualify
|
||
// them by, so what is drawn is all there is (FR-UI-7).
|
||
for handle in root.gradient-handles: Rectangle {
|
||
x: parent.shown-x + handle.x * parent.shown-w - self.width / 2;
|
||
y: parent.shown-y + handle.y * parent.shown-h - self.height / 2;
|
||
width: Theme.touch-target;
|
||
height: Theme.touch-target;
|
||
|
||
// The centre moves the whole mask, so it is filled;
|
||
// the others shape it and are rings. Shape rather than
|
||
// colour, because the handles sit on a photograph and
|
||
// any colour they carried would be read as part of it.
|
||
property <bool> solid: handle.role == HandleRole.centre;
|
||
|
||
Rectangle {
|
||
width: handle.role == HandleRole.rotate ? 12px : 14px;
|
||
height: self.width;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
border-radius: self.width / 2;
|
||
border-width: 2px;
|
||
// White with a dark ring, so the handle is visible
|
||
// against a blown sky and against a black frame —
|
||
// one treatment, both extremes, no theme token
|
||
// because this is drawn over the image and not over
|
||
// the interface.
|
||
border-color: #00000099;
|
||
background: parent.solid ? #ffffff : #ffffff44;
|
||
}
|
||
|
||
drag := TouchArea {
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: parent.solid
|
||
? MouseCursor.move
|
||
: MouseCursor.crosshair;
|
||
|
||
// Where the press landed, in the same fractions the
|
||
// callback reports in. Captured on the way down so
|
||
// the whole gesture is measured from one origin.
|
||
property <float> from-x;
|
||
property <float> from-y;
|
||
|
||
function fraction-x(local-x: length) -> float {
|
||
return (parent.x + local-x - canvas-area.shown-x)
|
||
/ max(canvas-area.shown-w, 1px);
|
||
}
|
||
function fraction-y(local-y: length) -> float {
|
||
return (parent.y + local-y - canvas-area.shown-y)
|
||
/ max(canvas-area.shown-h, 1px);
|
||
}
|
||
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
self.from-x = self.fraction-x(self.pressed-x);
|
||
self.from-y = self.fraction-y(self.pressed-y);
|
||
}
|
||
// One history step per gesture, not per frame.
|
||
if (ev.kind == PointerEventKind.up) {
|
||
root.gradient-handle-released();
|
||
}
|
||
}
|
||
|
||
moved => {
|
||
if (self.pressed) {
|
||
root.gradient-handle-dragged(
|
||
handle.role,
|
||
self.from-x,
|
||
self.from-y,
|
||
self.fraction-x(self.mouse-x),
|
||
self.fraction-y(self.mouse-y),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Arrow keys and space step through the folder.
|
||
//
|
||
// Focused on show rather than waiting for a click, exactly
|
||
// as the grid's judgement scope is: without it the first
|
||
// keystroke after opening an image goes nowhere, and — now
|
||
// that the shell answers the back gesture by bubbling —
|
||
// there would be no focus item for `Key.Back` to bubble
|
||
// *from*, so back would close the application from develop
|
||
// instead of returning to the grid.
|
||
FocusScope {
|
||
init => { self.focus(); }
|
||
|
||
// Shift is state, not an event: the click that needs
|
||
// to know about it arrives through a `TouchArea`,
|
||
// which reports no modifiers of its own. Tracked on
|
||
// both edges so releasing it while the pointer is over
|
||
// the canvas is noticed.
|
||
key-released(event) => {
|
||
root.shift-held = event.modifiers.shift;
|
||
return reject;
|
||
}
|
||
|
||
key-pressed(event) => {
|
||
root.shift-held = event.modifiers.shift;
|
||
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
||
// of the letter, because the logical key that
|
||
// reaches us carries the shift: holding it for
|
||
// redo turns "z" into "Z".
|
||
if (event.modifiers.control
|
||
&& (event.text == "z" || event.text == "Z")) {
|
||
if (event.modifiers.shift) {
|
||
root.redo();
|
||
} else {
|
||
root.undo();
|
||
}
|
||
return accept;
|
||
}
|
||
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 way out of whichever mode is on is still reachable from
|
||
// here — the develop column may be closed on a narrow
|
||
// window, and the strip that enters a mode is pinned inside
|
||
// it. Stranding the user in a mode with no visible way out
|
||
// is worse than one duplicated control, and it is worse
|
||
// still now that there are two modes to be stranded in.
|
||
if root.total > 0 && root.load-error == "": HorizontalLayout {
|
||
x: 12px;
|
||
y: parent.height - self.preferred-height - 12px;
|
||
// Bounded, or the row takes the canvas's whole width
|
||
// and stretches its children across it. With one
|
||
// button that read as an odd-looking "Fit"; with two it
|
||
// reads as a toolbar laid over the photograph, which is
|
||
// the opposite of what a floating control should do.
|
||
width: self.preferred-width;
|
||
spacing: 6px;
|
||
alignment: start;
|
||
|
||
if root.view-mode != ViewMode.photo: Button {
|
||
// Names the mode being left rather than saying
|
||
// "Done", which was unambiguous while there was
|
||
// one mode and would not be with two.
|
||
text: root.cropping ? "Done Cropping" : "Done Masking";
|
||
active: true;
|
||
clicked => { root.mode-picked(ViewMode.photo); }
|
||
}
|
||
|
||
// Zoom is a view state, so its readout doubles as the
|
||
// control that clears it.
|
||
Button {
|
||
text: root.zoomed
|
||
? Math.round(root.zoom * 100) + "%"
|
||
: "Fit";
|
||
active: root.zoomed;
|
||
enabled: root.zoomed;
|
||
clicked => { root.zoom-reset(); }
|
||
}
|
||
}
|
||
|
||
// The photo roll, along the foot of the canvas.
|
||
//
|
||
// Only where a library was opened: with files named on the
|
||
// command line there is no window of photographs to roll
|
||
// through, which is the same condition the "‹ Library"
|
||
// button uses.
|
||
//
|
||
// Last in the canvas, so it draws over the photograph
|
||
// rather than under it.
|
||
if root.library-total > 0: PhotoRoll {
|
||
width: 100%;
|
||
height: 100%;
|
||
cells: root.library-cells;
|
||
current: root.library-roll-current;
|
||
open <=> root.roll-open;
|
||
pick(i) => { root.library-roll-pick(i); }
|
||
}
|
||
|
||
// Report size changes so the render target can be resized to
|
||
// match. Width and height are tracked separately because
|
||
// Slint has no single "geometry changed" hook.
|
||
property <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;
|
||
clip: true;
|
||
|
||
// **The whole column scrolls, histogram included.**
|
||
//
|
||
// Only the sliders used to. Everything above them — the
|
||
// capture metadata, the histogram, the geometry controls,
|
||
// copy and paste — was fixed, so on a 280px column in
|
||
// portrait they took the height the sliders needed and the
|
||
// histogram could not be moved out of the way. It is the
|
||
// instrument the sliders are judged against; being unable
|
||
// to scroll to it, or past it, is the worst of both.
|
||
//
|
||
// `interactive` stands down while a track has a gesture,
|
||
// the same arbitration `AdjustPanel` used to do for its own
|
||
// scroller — see the note on `SliderTrack`'s `claimed`.
|
||
// Without it every attempt to drag a slider would scroll
|
||
// the column instead.
|
||
// Pinned: the strip is how the column is navigated, so it
|
||
// cannot be somewhere you have to navigate to.
|
||
// A layout, because the parent is a Rectangle: two children of one
|
||
// would both sit at its origin and overlap. The strip is pinned by
|
||
// being outside the Flickable rather than by any coordinate.
|
||
VerticalLayout {
|
||
ModeStrip {
|
||
enabled: root.adjust-enabled;
|
||
mode: root.view-mode;
|
||
tabs: root.adjust-tabs;
|
||
active-tab: root.adjust-active-tab;
|
||
picked(i) => { root.adjust-tab-picked(i); }
|
||
mode-picked(m) => { root.mode-picked(m); }
|
||
}
|
||
|
||
Flickable {
|
||
viewport-height: column.preferred-height;
|
||
interactive: !adjust.slider-dragging;
|
||
|
||
// **What the column holds is the mode's answer.**
|
||
//
|
||
// In local mode it is the mask stack and then the
|
||
// selected layer's adjustments, and nothing else:
|
||
// the capture metadata describes the file, copy and
|
||
// paste move a whole edit between photographs, and
|
||
// framing is a decision about the picture's shape —
|
||
// none of the three is a property of a region
|
||
// within it, so all three would be controls in
|
||
// scope of nothing.
|
||
//
|
||
// **Each child carries its own condition** rather
|
||
// than the three being grouped inside one `if`.
|
||
// A nested layout under-reports its height here and
|
||
// the panels below it get drawn on top of each
|
||
// other — invisible in this file and obvious on
|
||
// screen. `masks.slint` carries the same note for
|
||
// the same reason.
|
||
column := VerticalLayout {
|
||
if !root.local-mode: InfoPanel {
|
||
camera: root.camera;
|
||
exposure: root.exposure;
|
||
dimensions: root.dimensions;
|
||
}
|
||
|
||
if !root.local-mode: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Directly under the capture metadata and above every
|
||
// control, because it is the thing the controls are
|
||
// judged against: exposure, blacks and whites are all
|
||
// set by watching this move (FR-DSP-7). An instrument
|
||
// below the sliders it reports on would have the
|
||
// photographer looking away from it to use it.
|
||
//
|
||
// Kept in local mode, where it still reports the
|
||
// whole frame while the sliders edit a layer. That
|
||
// disagreement is real and is N3's to close; taking
|
||
// the instrument away instead would be a worse
|
||
// answer than an honest one that is not yet scoped.
|
||
HistogramPanel {
|
||
data: root.histogram;
|
||
}
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Framing above the colour work, matching how the edit is
|
||
// made rather than how it is applied: the frame is decided
|
||
// by eye first and the pipeline runs it last (see
|
||
// `dr_pipeline::framing` on the coordinate order).
|
||
if !root.local-mode: GeometryPanel {
|
||
enabled: root.adjust-enabled;
|
||
angle: root.straighten;
|
||
max-straighten: root.max-straighten;
|
||
flip-h: root.flip-h;
|
||
flip-v: root.flip-v;
|
||
modified: root.framing-modified;
|
||
|
||
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(); }
|
||
}
|
||
|
||
if !root.local-mode: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Between the framing and the colour work, because it
|
||
// acts on both: a paste is about the whole edit, and
|
||
// burying it under thirty sliders would put the one
|
||
// control that operates on all of them below all of
|
||
// them.
|
||
if !root.local-mode: TransferPanel {
|
||
enabled: root.adjust-enabled;
|
||
armed: root.settings-armed;
|
||
summary: root.settings-summary;
|
||
framing-withheld: root.settings-framing-withheld;
|
||
copy => { root.copy-settings(); }
|
||
paste => { root.paste-settings(); }
|
||
}
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Above the adjust panel, because it decides what
|
||
// those sliders act on. Below it, the photographer
|
||
// would set an exposure and only then discover which
|
||
// scope it landed in.
|
||
//
|
||
// Only in local mode: it is no longer a panel among
|
||
// peers that silently re-points another panel, it is
|
||
// what that mode's column *is*. That also takes one
|
||
// panel out of a scrolling column that had six.
|
||
if root.local-mode: MaskPanel {
|
||
enabled: root.adjust-enabled;
|
||
masks: root.mask-rows;
|
||
subjects: root.subject-rows;
|
||
segmented: root.segmented;
|
||
segmenting: root.segmenting;
|
||
|
||
segment => { root.segment-image(); }
|
||
mask-selected(id) => { root.mask-selected(id); }
|
||
mask-removed(id) => { root.mask-removed(id); }
|
||
mask-toggled(id, on) => { root.mask-toggled(id, on); }
|
||
mask-invert-toggled(id, on) => {
|
||
root.mask-invert-toggled(id, on);
|
||
}
|
||
mask-opacity-changed(id, v) => {
|
||
root.mask-opacity-changed(id, v);
|
||
}
|
||
mask-feather-changed(id, v) => {
|
||
root.mask-feather-changed(id, v);
|
||
}
|
||
mask-falloff-picked(id, i) => {
|
||
root.mask-falloff-picked(id, i);
|
||
}
|
||
mask-morphology-picked(id, i) => {
|
||
root.mask-morphology-picked(id, i);
|
||
}
|
||
mask-morph-radius-changed(id, v) => {
|
||
root.mask-morph-radius-changed(id, v);
|
||
}
|
||
add-gradient(radial) => { root.add-gradient-mask(radial); }
|
||
add-subject(i) => { root.add-subject-mask(i); }
|
||
}
|
||
|
||
if root.local-mode: Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
adjust := AdjustPanel {
|
||
rows: root.adjust-rows;
|
||
enabled: root.adjust-enabled;
|
||
scope: root.adjust-scope;
|
||
curve-samples: root.curve-samples;
|
||
curve-channels: root.curve-channels;
|
||
curve-channel: root.curve-channel;
|
||
curve-channel-picked(i) => {
|
||
root.curve-channel-picked(i);
|
||
}
|
||
param-changed(op, param, value) => {
|
||
root.param-changed(op, param, value);
|
||
}
|
||
param-reset(op, param) => {
|
||
root.param-reset(op, param);
|
||
}
|
||
curve-reset(op) => { root.curve-reset(op); }
|
||
// A section's reset and a curve's reset are the same
|
||
// action — every parameter of one operation back to
|
||
// its default — so they share the one callback rather
|
||
// than duplicating a handler that would have to be
|
||
// kept in step with it.
|
||
op-reset(op) => { root.curve-reset(op); }
|
||
reset-all => { root.reset-all(); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The seam between the photograph and the column.
|
||
//
|
||
// **`x: 0` is load-bearing.** A child of a plain Rectangle
|
||
// that declares a size and no position is *centred* in it,
|
||
// so this hairline was being drawn straight down the middle
|
||
// of the develop column — over the histogram, the geometry
|
||
// controls and every slider below them. Invisible in the
|
||
// source and, at one pixel of `rule` grey, quiet enough on
|
||
// screen to be read as a divider that was meant to be
|
||
// there.
|
||
Rectangle {
|
||
x: 0;
|
||
width: 1px;
|
||
height: 100%;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the load bar -------------------------------------------------
|
||
//
|
||
// Last in the file, so it is last in z-order and no view can cover it.
|
||
//
|
||
// At the top of the *shell* rather than inside a view, because that is
|
||
// the only place that is true of every view: a pin download outlives
|
||
// the grid it was started from, and until this existed it drew nothing
|
||
// at all once the user opened an image. Three pixels, no text and no
|
||
// hit area — it says only "something is running", and the settings
|
||
// page says what (FR-CAT-1, FR-NC-6c).
|
||
if root.activity-busy: ProgressBar {
|
||
x: 0;
|
||
y: 0;
|
||
width: 100%;
|
||
indeterminate: !root.activity-determinate;
|
||
fraction: root.activity-fraction;
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The offline question, over the shell rather than inside the sidebar
|
||
// that raises it. The sidebar is 232px wide and closes entirely in the
|
||
// compact layout, so a dialogue living in there would be unreadable on
|
||
// the device the gesture exists for — and the answer commits gigabytes
|
||
// either way, which deserves the width to say so.
|
||
//
|
||
// Below the load bar, above everything else: a download started from
|
||
// here shows its progress in that bar, and the bar must not be the
|
||
// thing the dialogue covers.
|
||
OfflinePrompt {
|
||
width: 100%;
|
||
height: 100%;
|
||
title: root.offline-prompt-title;
|
||
detail: root.offline-prompt-detail;
|
||
keep-label: root.offline-prompt-keep-label;
|
||
release-label: root.offline-prompt-release-label;
|
||
can-keep: root.offline-prompt-can-keep;
|
||
can-release: root.offline-prompt-can-release;
|
||
busy: root.offline-prompt-busy;
|
||
keep() => { root.offline-prompt-keep(); }
|
||
release() => { root.offline-prompt-release(); }
|
||
dismiss() => { root.offline-prompt-dismiss(); }
|
||
}
|
||
}
|
||
}
|