Files
DarkRoom/ui/dr-ui/ui/settings.slint
T
dtourolle 78cb00634e
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 0s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / windows-image (push) Canceled after 0s
🐳 Windows image / Build and push (push) Canceled after 0s
Build and test / Windows (x86_64, cross) (push) Canceled after 0s
Build and test / Layer separation (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 16m26s
Traceability / Requirement traces (push) Canceled after 0s
Fetch the photographs around the open one ahead of the step to them
Walking the photo roll was one download per frame: every step showed
"Downloading…" over an empty canvas while tens of megabytes came down,
and moving between a pair of near-identical frames paid that a dozen
times. Now, once the opened photograph has landed, the ones around it
are fetched into the originals cache while it is being looked at, so
the next step is a disk read.

A single worker serves the latest wish only, closest first and working
outwards — next, previous, next-but-one, previous-but-one… — one file
at a time. Each open replaces the wish, so a fast walk never leaves a
trail of stale downloads competing with the one being waited on. A
process-wide in-flight registry makes a click on a photograph that is
still being fetched ahead wait for that transfer and read it from disk,
rather than start a second download of the same file.

How far each side is a setting under STORAGE — Off, 2, 5, 10 or 20,
defaulting to 5 — and it is moot while "keep originals after opening"
is off, since a fetch the cache would discard on arrival is transfer
for nothing. Nothing is fetched ahead while offline. The transfers show
in the activity list while they run and are removed when they end.
2026-09-19 10:36:50 +02:00

1237 lines
57 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
import { ScopeChips, ScopeKind } from "presets.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
//
// A full-window page rather than a modal dialogue. Two reasons, and the second
// is the load-bearing one. A modal has to be dismissed to check anything it
// refers to, and these settings refer to the library constantly — how full the
// cache is, where exports land. And every control here saves on change
// (see `settings_ui.rs`), so there is no OK/Cancel pair for a modal to host;
// a dialogue frame with only a close button is a window pretending to be a
// decision.
//
// It replaces the view rather than overlaying it because the app already
// switches views this way — launch, library, develop — and an overlay would be
// a fourth mechanism for the same job.
// One background job: what it is, how far along, and what it last said.
//
// A row rather than a `Value`/`Caption` pair written out at the call site,
// because the list is built by a `for` and every row has to answer the same
// three questions in the same order — otherwise a failure reads as a different
// kind of thing from the job it happened to.
//
// The bar is drawn only while the job runs. A stopped job's fraction is either
// 1 (it finished, and a full bar is noise) or frozen part-way (it failed, and
// a bar sitting at 60% invites the reading "still going").
component ActivityItem inherits VerticalLayout {
in property <ActivityRow> job;
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label {
text: root.job.title;
body: true;
// Faded once it is over: the list is ordered running-first, and
// this is what makes that ordering visible at a glance rather than
// something the reader has to work out from the text.
opacity: root.job.running ? 1.0 : 0.6;
}
Caption {
text: root.job.detail;
// A failure is the one thing here worth a colour (NFR-A11Y-3 is
// satisfied by the words, which say what failed; the hue only
// finds them faster).
warn: root.job.failed;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
if root.job.running: ProgressBar {
indeterminate: !root.job.determinate;
fraction: root.job.fraction;
}
}
export component SettingsPage inherits Rectangle {
// --- background activity --------------------------------------------
in property <[ActivityRow]> activity-rows;
in property <int> activity-running: 0;
/// Stopped jobs still listed: recent successes, and failures held until
/// they are read.
in property <int> activity-kept: 0;
callback clear-finished();
/// TRACES: FR-CAT-3 | FR-NC-3
/// Whether the whole-library thumbnail pass is running, and whether there
/// is a library for it to run over. Without the second, the button would
/// be offered on a settings page reached before signing in, where pressing
/// it could do nothing at all.
in property <bool> thumbnailing: false;
in property <bool> library-open: false;
callback thumbnail-library();
/// TRACES: FR-CULL-8
/// The face-indexing pass, and what a coverage check last found.
///
/// Beside the thumbnail sweep because it is the same kind of thing: a job
/// that runs for an hour, is asked for once, and reports into the list
/// above. It is also *downstream* of that sweep — face detection reads the
/// proxies it builds — so the two belong in the same place, in that order.
in property <bool> face-indexing: false;
in property <string> face-coverage;
/// No model on disk, so the pass cannot run at all.
in property <bool> face-model-missing: false;
callback index-faces();
/// TRACES: FR-CULL-8
/// Which detector the pass finds faces with — docs/faces.md §12.3 for
/// what each costs and finds. The choice is a model change: coverage is
/// counted per pipeline, so picking another one starts from nothing.
in property <[string]> face-detector-labels;
in property <int> face-detector-selected: 0;
callback face-detector-picked(int);
// --- cache ---------------------------------------------------------
in-out property <string> original-budget;
in property <bool> original-unlimited: false;
in-out property <string> thumbnail-budget;
in property <bool> thumbnail-unlimited: false;
in property <bool> keep-opened: true;
/// TRACES: FR-NC-6a | FR-UI-4
/// How many photographs each side of the open one are fetched ahead.
in property <[string]> fetch-ahead-labels;
in property <int> fetch-ahead-selected: 0;
callback fetch-ahead-picked(int);
/// TRACES: FR-DEV-6
/// Which kinds of edit a copy or preset carries. Supersedes the boolean
/// above, which named only the one kind anybody wanted to exclude.
/// TRACES: FR-UI-1
/// Where the develop view's adjustment groups are chosen from.
in property <[string]> group-nav-labels;
in property <int> group-nav-selected: 0;
/// What "Automatic" resolves to on this device, so the caption can say it
/// rather than leaving the photographer to press it and find out.
in property <string> group-nav-auto-says;
in property <[ScopeKind]> copy-scope-kinds;
in property <bool> copy-scope-empty: false;
callback group-nav-picked(int);
callback copy-scope-toggled(string);
/// What the cache currently holds. Empty hides the line.
in property <string> cache-usage;
/// TRACES: FR-UI-2
/// What is drawing, and how fast. These used to sit in the window's top
/// strip, where they were three items of permanent furniture answering a
/// question almost nobody asks twice — and on a tablet in landscape they
/// were part of why the header ran off the screen.
///
/// They are diagnostics, so they belong where a person goes to look
/// something up, not where they are looked at all day.
in property <string> adapter;
in property <string> backend;
in property <int> fps;
in property <string> layout-class;
in property <string> app-version;
/// TRACES: NFR-OPS-1
/// The diagnostics bundle, in its two states. `diagnostics-preview` is
/// empty until the user asks for one; while it is not, the panel shows
/// what would be written and offers the write. `diagnostics-result` is
/// what the last save said — a path, or why it failed.
in property <string> diagnostics-preview;
in property <string> diagnostics-result;
callback diagnostics-prepare();
callback diagnostics-save();
callback diagnostics-discard();
/// TRACES: FR-DSP-8
/// The display showing the canvas, and the colour it is being given.
///
/// These are the same kind of thing as the two above — a fact about the
/// session, not a setting — with one difference that earns them their own
/// row rather than a line in a log. FR-DSP-8's fallback is *defined* to be
/// sRGB where the display server will not say otherwise, and a photographer
/// being shown sRGB because their compositor has no colour-management
/// protocol has no other way to find that out. `display-colour` says which
/// of the acquisition paths this session is on, in words.
in property <string> display-name;
in property <string> display-colour;
/// The *other* monitors, where there are any. Empty on a single-display
/// desktop, which is why the row below is conditional: FR-DSP-8 is about
/// the second display, and the failure it names is invisible from the
/// first, so this page has to be readable about a monitor the reader is
/// not currently looking at.
in property <string> display-others;
callback original-budget-changed(string);
callback original-unlimited-toggled(bool);
callback thumbnail-budget-changed(string);
callback thumbnail-unlimited-toggled(bool);
callback keep-opened-toggled(bool);
// --- library -------------------------------------------------------
/// TRACES: FR-CAT-6
/// The bar counts the capture-time axis can be cut into, and which one is
/// chosen. Labels from Rust, like every other choice row: the numbers are
/// the ones the settings record offers, and a list written again here
/// would be a second place for them to go stale.
in property <[string]> timeline-bar-labels;
in property <int> timeline-bars-selected: 0;
callback timeline-bars-picked(int);
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether judgements also go to the `.xmp` beside the original, and how
/// many sidecars the last scan found disagreeing with the catalog.
in property <bool> write-xmp: false;
callback write-xmp-toggled(bool);
in property <int> xmp-conflicts: 0;
callback xmp-reload();
// --- export --------------------------------------------------------
in property <[string]> format-labels;
in property <int> format-selected: 0;
in property <int> quality: 90;
in property <bool> quality-enabled: true;
in property <[string]> colour-labels;
in property <int> colour-selected: 0;
in property <[string]> sizing-labels;
in property <int> sizing-selected: 0;
in-out property <int> sizing-value: 0;
in property <bool> sizing-has-value: false;
in property <string> sizing-unit: "px";
/// "Size value" for the modes that take one number, "Width" for the box
/// modes, where a second field sits below it.
in property <string> sizing-value-label: "Size value";
in-out property <int> sizing-height: 0;
in property <bool> sizing-has-height: false;
/// The panels offered as buttons, and which one the numbers match — `-1`
/// where they match none, so a preset never stays lit over fields that
/// have since been typed over.
in property <[string]> screen-labels;
in property <int> screen-selected: -1;
in property <bool> allow-upscaling: false;
in property <[string]> sharpening-labels;
in property <int> sharpening-selected: 0;
in-out property <string> filename-template;
in property <[string]> collision-labels;
in property <int> collision-selected: 0;
in property <bool> strip-location: true;
in-out property <string> destination;
// What the destination field means depends on this, so the placeholder
// comes from Rust alongside it rather than being written twice here.
in property <string> destination-hint;
in property <[string]> target-labels;
in property <int> target-selected: 0;
// --- the remote folder picker ---------------------------------------
//
// The same navigation the launch screen uses to choose a library root,
// driven by the same `FolderBrowser` model in Rust. A folder on the
// server is not something anyone can be expected to type from memory.
in property <bool> browse-open: false;
in property <string> browse-path;
in property <[string]> browse-entries;
in property <bool> browse-loading: false;
/// At the library root, so there is nowhere up to go.
in property <bool> browse-at-root: true;
/// Whether the destination is one that can be walked.
///
/// A boolean from Rust rather than a test on `target-selected`. The index
/// was hardcoded to 1, which was Remote's position while both targets were
/// offered — and the moment Android's list narrowed to Remote alone, that
/// index became 0 and the button vanished on the one platform where it is
/// the *only* way to set a destination. An index into a list whose length
/// varies is not a fact about the target.
in property <bool> browse-available: false;
callback format-picked(int);
callback quality-changed(int);
callback colour-picked(int);
callback sizing-picked(int);
callback sizing-value-changed(string);
callback sizing-height-changed(string);
callback screen-picked(int);
callback upscaling-toggled(bool);
callback sharpening-picked(int);
callback template-changed(string);
callback collision-picked(int);
callback strip-location-toggled(bool);
callback destination-changed(string);
callback target-picked(int);
callback browse-open-picker();
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
/// A save failed. The page's whole contract is that what it shows is
/// stored, so this cannot be swallowed.
in property <string> error;
callback close();
callback reset-defaults();
background: Theme.ground;
// Somewhere for a key to start from.
//
// Slint delivers a key to the focused item and bubbles it up the ancestors;
// with nothing focused there is no chain at all and the event is dropped.
// This page has only text fields, none of which claims focus on show — so
// without this holder Android's back gesture would find no focus item here
// and close the application instead of closing the page. Zero height, so it
// takes no room in the column, and it handles nothing itself: the shell
// above answers `Key.Back`.
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
// --- header ----------------------------------------------------
//
// 44px and `surface`, matching the library's header exactly: this is
// the same kind of bar in the same place, and a page that drew its own
// height would read as a different application.
Rectangle {
height: 44px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Button {
text: "‹ Back";
y: (parent.height - self.height) / 2;
clicked => { root.close(); }
}
Value { text: "Settings"; }
Rectangle { horizontal-stretch: 1; }
Caption {
// Says where the file is, because a settings page that
// saves silently gives the user nothing to point a backup
// or a support question at.
text: "Saved automatically";
vertical-alignment: center;
}
Button {
text: "Reset to defaults";
y: (parent.height - self.height) / 2;
clicked => { root.reset-defaults(); }
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// A failed write, above the content: it applies to everything below
// and the user needs it before they keep editing into a file that is
// not being written.
if root.error != "": Rectangle {
height: 32px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Caption { text: root.error; warn: true; overflow: elide; }
}
}
Flickable {
vertical-stretch: 1;
viewport-height: content.preferred-height;
content := VerticalLayout {
width: 100%;
padding: Theme.gap-lg;
spacing: Theme.gap-lg;
alignment: start;
// The column is capped rather than filling the window. A
// settings form stretched across a 2560px display puts its
// label at one edge and its control at the other; 680px is
// about 90 characters of `text`, which is a readable measure.
// `min` so a narrow window still uses what it has (FR-UI-1).
property <length> column: min(root.width - 2 * Theme.gap-lg, 680px);
// --- background activity ---------------------------------
//
// First on the page, and the only panel here that is not a
// preference. It earns the position by being the one thing
// that changes while the page is open: a transfer the user
// came here to check on is answered before they have scrolled.
Rectangle {
width: content.column;
height: activity.preferred-height;
activity := Panel {
width: 100%;
HorizontalLayout {
spacing: Theme.gap;
PanelHeading { text: "BACKGROUND ACTIVITY"; }
// The count beside the heading, because the list
// below carries stopped jobs too and "how many are
// running" should not have to be counted by eye.
Caption {
text: root.activity-running > 0
? root.activity-running + " running" : "";
horizontal-alignment: right;
horizontal-stretch: 1;
}
}
Caption {
text: "Scans, transfers and syncs running behind the "
+ "interface. Everything here survives leaving "
+ "this page; nothing here needs it open.";
wrap: word-wrap;
}
Rectangle { height: Theme.gap-sm; }
// Says nothing is running, rather than showing an empty
// box: a list that is empty because the work finished
// and one that is empty because the register is broken
// look identical otherwise.
if root.activity-rows.length == 0: Caption {
text: "Nothing running.";
}
for row in root.activity-rows: ActivityItem {
job: row;
}
// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7
// The one job here that is started rather than
// observed, and it belongs on this page rather than
// in the header: it runs for an hour, it is asked for
// once, and its progress appears in the list directly
// above.
if root.library-open: Rectangle {
height: Theme.gap-sm;
}
if root.library-open: Caption {
text: "Thumbnails are built for photographs as you "
+ "browse them. This builds the rest in one "
+ "pass and sends them to the server, so "
+ "your other devices show a full library "
+ "without downloading anything themselves.";
wrap: word-wrap;
}
if root.library-open: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: root.thumbnailing
? "Thumbnailing…"
: "Thumbnail the whole library";
enabled: !root.thumbnailing;
clicked => { root.thumbnail-library(); }
}
}
// TRACES: FR-CULL-8
// Face indexing, directly under the sweep that feeds
// it. Detection runs on the proxies the pass above
// builds, so a library that has not been thumbnailed
// has nothing here to index — which is why the
// coverage line says how many images are waiting on a
// proxy rather than only how many are left.
if root.library-open: Rectangle {
height: Theme.gap-sm;
}
if root.library-open: Caption {
text: "Face indexing looks for people across the "
+ "whole library, fetching each photograph it "
+ "has not seen. It runs once per library and "
+ "syncs, so your other devices never repeat "
+ "the work.";
wrap: word-wrap;
}
if root.library-open: Segmented {
label: "Face detector";
options: root.face-detector-labels;
selected: root.face-detector-selected;
enabled: !root.face-indexing;
picked(i) => { root.face-detector-picked(i); }
}
if root.library-open: Caption {
text: "Fast misses the small faces in a group and "
+ "mistakes the odd dog for one. Balanced "
+ "finds a seventh more for almost the same "
+ "time. Thorough finds the most and takes "
+ "three times as long. Changing it indexes "
+ "the library again; names you have "
+ "confirmed are kept.";
wrap: word-wrap;
}
if root.library-open && root.face-coverage != "": Caption {
text: root.face-coverage;
wrap: word-wrap;
}
if root.library-open && root.face-model-missing: Caption {
text: "The chosen detector is not installed, so this cannot run.";
wrap: word-wrap;
}
if root.library-open: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: root.face-indexing
? "Indexing faces…"
: "Index faces in the whole library";
enabled: !root.face-indexing && !root.face-model-missing;
clicked => { root.index-faces(); }
}
}
if root.activity-kept > 0: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: "Clear finished";
clicked => { root.clear-finished(); }
}
}
}
}
// --- storage ---------------------------------------------
Rectangle {
width: content.column;
height: storage.preferred-height;
storage := Panel {
width: 100%;
PanelHeading { text: "STORAGE"; }
Caption {
text: "How much of this device's disk DarkRoom may use. "
+ "These are per-device and never travel with the library.";
wrap: word-wrap;
}
// What is actually held, before what is allowed:
// a ceiling means nothing without the current figure
// to judge it against.
if root.cache-usage != "": Value {
text: root.cache-usage;
compact: true;
}
Rectangle { height: Theme.gap-sm; }
TextRow {
label: "Cached originals";
hint: "evicted oldest-first when full";
text <=> root.original-budget;
enabled: !root.original-unlimited;
placeholder: "8.0 GB";
accepted(t) => { root.original-budget-changed(t); }
}
Check {
label: "No limit on cached originals";
hint: "Nothing is ever evicted for space. "
+ "Pinned photographs are kept regardless.";
checked: root.original-unlimited;
toggled(on) => { root.original-unlimited-toggled(on); }
}
Rectangle { height: Theme.gap-sm; }
TextRow {
label: "Thumbnails and previews";
hint: "what the grid draws from";
text <=> root.thumbnail-budget;
enabled: !root.thumbnail-unlimited;
placeholder: "2.0 GB";
accepted(t) => { root.thumbnail-budget-changed(t); }
}
Check {
label: "No limit on thumbnails";
checked: root.thumbnail-unlimited;
toggled(on) => { root.thumbnail-unlimited-toggled(on); }
}
Rectangle { height: Theme.gap-sm; }
Check {
label: "Keep originals after opening them";
hint: "The file was downloaded anyway, so keeping it "
+ "costs no bandwidth and saves the transfer next time.";
checked: root.keep-opened;
toggled(on) => { root.keep-opened-toggled(on); }
}
Rectangle { height: Theme.gap-sm; }
// TRACES: FR-NC-6a | FR-UI-4
Segmented {
label: "Fetch ahead";
hint: "photographs each side of the open one, "
+ "downloaded while you look at it so the "
+ "next step is instant — closest first";
enabled: root.keep-opened;
options: root.fetch-ahead-labels;
selected: root.fetch-ahead-selected;
picked(i) => { root.fetch-ahead-picked(i); }
}
}
}
// --- develop (FR-DEV-6) ----------------------------------
Rectangle {
width: content.column;
height: develop-panel.preferred-height;
develop-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "DEVELOP"; }
// TRACES: FR-UI-1 | FR-UI-7
// Where the adjustment groups are chosen from.
//
// A preference rather than a fixed rule because the
// automatic answer is a guess and cannot be otherwise.
// Neither platform can be asked what the user is
// actually holding: an Android tablet in a keyboard
// case is being driven like a desktop, and a
// touchscreen laptop is whichever its owner says. The
// guess is right often enough to be the default and
// wrong often enough to need a way out.
VerticalLayout {
spacing: 4px;
Segmented {
label: "Adjustment groups";
options: root.group-nav-labels;
selected: root.group-nav-selected;
picked(i) => { root.group-nav-picked(i); }
}
Caption {
text: "Down the tool rail, or in a strip above "
+ "the panel. Automatic follows how this "
+ "device is driven — here, "
+ root.group-nav-auto-says + ".";
wrap: word-wrap;
}
}
// TRACES: FR-DEV-6
// Which kinds of edit a copy, a paste or a preset
// carries.
//
// This replaced a single "copy crop and rotation"
// checkbox. That question was the right one to ask
// first — geometry is the kind whose accidental
// travel destroys work — but it was the only question
// a boolean could ask, and "match the colour but not
// the sharpening" had no way to be said.
//
// Chips rather than six checkboxes: these are one
// question with six answers, and a column of ticks
// reads as six unrelated preferences.
VerticalLayout {
spacing: 4px;
Label { text: "Copying carries"; }
ScopeChips {
kinds: root.copy-scope-kinds;
columns: 6;
toggled(name) => { root.copy-scope-toggled(name); }
}
Caption {
text: root.copy-scope-empty
? "Nothing selected — a paste would change nothing."
: "Geometry is off by default: a crop is a decision "
+ "about one photograph's composition, and carrying "
+ "it re-frames every image pasted onto.";
warn: root.copy-scope-empty;
}
}
}
}
// --- library ---------------------------------------------
Rectangle {
width: content.column;
height: library-panel.preferred-height;
library-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "LIBRARY"; }
Segmented {
label: "Timeline detail";
// Says what the choice is *about*, since neither
// number means anything on its own: it is how
// finely the capture-time axis beside the grid is
// divided, and the honest trade is precision
// against being able to hit a bar with a thumb.
hint: "bars on the capture-time axis — fewer are "
+ "easier to hit with a finger, more show "
+ "finer structure";
options: root.timeline-bar-labels;
selected: root.timeline-bars-selected;
picked(i) => { root.timeline-bars-picked(i); }
}
}
}
// --- export ----------------------------------------------
Rectangle {
width: content.column;
height: export-panel.preferred-height;
export-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "EXPORT DEFAULTS"; }
Caption {
text: "What an export starts from. Every one of these "
+ "is still changeable per export.";
wrap: word-wrap;
}
Segmented {
label: "Format";
options: root.format-labels;
selected: root.format-selected;
picked(i) => { root.format-picked(i); }
}
// A bounded number, so it gets the control for one.
//
// This was a free-text field: the range lived in the
// hint and was enforced nowhere, and `to-float()`
// answers 0 for anything unparseable — so a typo saved
// a quality of 0 and the page then showed the 0 back as
// though it had been asked for. The track carries the
// range and the box refuses what it cannot read.
SliderRow {
label: "Quality";
// Says why it is greyed rather than leaving the
// user to work out that PNG has no quality.
hint: root.quality-enabled ? "1 to 100"
: "the chosen format is lossless";
value: root.quality;
// No meaningful neutral: quality has a sensible
// default but not a *zero*, and a default marker
// partway along a track reads as one.
default-value: 1;
minimum: 1;
maximum: 100;
enabled: root.quality-enabled;
changed(v) => { root.quality-changed(v); }
}
Segmented {
label: "Colour space";
hint: "profile embedded on export";
options: root.colour-labels;
selected: root.colour-selected;
picked(i) => { root.colour-picked(i); }
}
Segmented {
label: "Size";
options: root.sizing-labels;
selected: root.sizing-selected;
picked(i) => { root.sizing-picked(i); }
}
// Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels".
if root.sizing-has-value: TextRow {
label: root.sizing-value-label;
text: root.sizing-value;
unit: root.sizing-unit;
field-width: 90px;
accepted(t) => { root.sizing-value-changed(t); }
}
// TRACES: FR-EXP-3
// The second axis, for the box modes only.
if root.sizing-has-height: TextRow {
label: "Height";
text: root.sizing-height;
unit: "px high";
field-width: 90px;
accepted(t) => { root.sizing-height-changed(t); }
}
// TRACES: FR-EXP-3
// Panel sizes as buttons, because the numbers are the
// whole difficulty: a television's art mode accepts one
// resolution and rejects everything else, and getting
// it by typing four digits twice is a step at which a
// photographer discovers they were wrong only after the
// upload.
//
// Alongside the fields rather than instead of them —
// the presets are a shortcut to a pair of numbers, not
// a replacement for being able to say any pair.
if root.sizing-has-height: Segmented {
label: "Screen";
hint: "fills in both numbers";
options: root.screen-labels;
selected: root.screen-selected;
picked(i) => { root.screen-picked(i); }
}
Check {
label: "Allow upscaling";
hint: "Off, a request larger than the source exports "
+ "at source size rather than failing.";
checked: root.allow-upscaling;
toggled(on) => { root.upscaling-toggled(on); }
}
Segmented {
label: "Output sharpening";
hint: "scaled by the resize factor";
options: root.sharpening-labels;
selected: root.sharpening-selected;
picked(i) => { root.sharpening-picked(i); }
}
}
}
// --- about and diagnostics -------------------------------
Rectangle {
width: content.column;
height: about-panel.preferred-height;
about-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "ABOUT"; }
// The version first, because it is the one line anyone
// is ever asked to quote. A bug report that names a
// version names a commit.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Version"; }
Value { text: root.app-version; horizontal-stretch: 1; overflow: elide; }
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Graphics"; }
Value {
text: root.adapter + " (" + root.backend + ")";
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Frame rate"; }
// Still earns the warning hue here. It is the
// number that says whether the zero-copy path is
// holding up, which is the assumption the whole
// display design rests on.
// `Caption` rather than `Value` here alone: it is
// the one that carries `warn`, and the frame rate
// is the one reading on this page that can be bad
// news rather than merely a fact.
Caption { text: root.fps + " fps"; warn: root.fps < 55; horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Layout"; }
Value { text: root.layout-class; horizontal-stretch: 1; }
}
// TRACES: FR-DSP-8
// Which display, and what colour it is being sent.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Display"; }
Value {
text: root.display-name;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Display colour"; }
Value {
text: root.display-colour;
horizontal-stretch: 1;
// Wraps rather than elides: this is the one
// value on the page that is a sentence, and
// eliding it would cut off the half that says
// *why* — which is the half FR-DSP-8 asks for.
wrap: word-wrap;
}
}
if root.display-others != "": HorizontalLayout {
spacing: Theme.gap;
Label { text: "Other displays"; }
Value {
text: root.display-others;
horizontal-stretch: 1;
wrap: word-wrap;
}
}
Caption {
text: "Graphics, frame rate and display colour "
+ "describe this session, not a setting — they "
+ "are here so a bug report can quote them.";
wrap: word-wrap;
}
}
}
// --- diagnostics -----------------------------------------
//
// TRACES: NFR-OPS-1
// Two presses, never one. The first gathers and shows; the
// second writes. The requirement asks for a preview-and-
// consent step before anything leaves the device, and a
// single button that gathered and saved would be the step
// skipped under the name of convenience. Nothing is sent by
// either press — the file is for the user to attach.
Rectangle {
width: content.column;
height: diagnostics.preferred-height;
diagnostics := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "DIAGNOSTICS"; }
Caption {
text: "The log, any crash records, and the facts "
+ "above, as one text file to attach to a bug "
+ "report. Credentials and file paths are "
+ "removed first, and you see what is in it "
+ "before anything is written.";
wrap: word-wrap;
}
if root.diagnostics-preview == "": Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: "Show what a bundle would contain";
clicked => { root.diagnostics-prepare(); }
}
}
if root.diagnostics-preview != "": Value {
text: root.diagnostics-preview;
wrap: word-wrap;
}
if root.diagnostics-preview != "": HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Button {
text: "Save the bundle";
clicked => { root.diagnostics-save(); }
}
Button {
text: "Don't";
clicked => { root.diagnostics-discard(); }
}
}
if root.diagnostics-result != "": Caption {
text: root.diagnostics-result;
wrap: word-wrap;
}
}
}
// --- naming and destination ------------------------------
//
// Its own panel rather than more of the export one: format and
// size describe the image, these describe the file. The export
// panel was long enough that the boundary was worth drawing.
Rectangle {
width: content.column;
height: naming.preferred-height;
naming := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "FILES AND METADATA"; }
// TRACES: FR-CAT-13 | NFR-R4
// Reading is not a choice — a sidecar another editor
// wrote is taken in regardless, since reading changes
// nothing in the folder. Writing beside somebody's
// originals is, and it starts off.
Check {
label: "Write ratings, labels and keywords to XMP sidecars";
hint: "Beside the originals, as Lightroom and darktable "
+ "do, so other applications see them. Only those "
+ "fields are written; everything else in an "
+ "existing sidecar is left exactly as it was.";
checked: root.write-xmp;
toggled(on) => { root.write-xmp-toggled(on); }
}
// The offered reload. Shown only while there is
// something to offer: a disagreement between an
// `.xmp` changed elsewhere and what this catalog
// holds, which the automatic pull records rather
// than resolves.
if root.xmp-conflicts > 0: Caption {
text: root.xmp-conflicts
+ (root.xmp-conflicts == 1
? " XMP sidecar disagrees"
: " XMP sidecars disagree")
+ " with this catalog about a rating, label or "
+ "caption. The catalog's values stand until you "
+ "say otherwise.";
wrap: word-wrap;
}
if root.xmp-conflicts > 0: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: "Take the sidecars' values";
clicked => { root.xmp-reload(); }
}
}
TextRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> root.filename-template;
field-width: 260px;
placeholder: "{name}";
accepted(t) => { root.template-changed(t); }
}
Segmented {
label: "If the file exists";
options: root.collision-labels;
selected: root.collision-selected;
picked(i) => { root.collision-picked(i); }
}
// Where the file lands, before what it is called: on
// Android the answer decides whether an export needs
// the Storage Access Framework at all, and on any
// platform a server destination is reached over a
// network that may not be there.
Segmented {
label: "Export to";
options: root.target-labels;
selected: root.target-selected;
picked(i) => { root.target-picked(i); }
}
TextRow {
label: "Destination";
hint: "empty asks each time";
text <=> root.destination;
field-width: 320px;
placeholder: root.destination-hint;
accepted(t) => { root.destination-changed(t); }
}
// Offered only for a server destination. A folder on
// this device is chosen by the platform's own dialogue
// or typed; a folder on the server can only be found
// by walking it, and expecting anyone to recall the
// exact spelling of a path three levels down is how a
// destination silently becomes a new folder at the
// root.
if root.browse-available && !root.browse-open: HorizontalLayout {
alignment: start;
Button {
text: "Choose folder…";
clicked => { root.browse-open-picker(); }
}
}
if root.browse-open: Rectangle {
background: Theme.ground;
border-radius: Theme.radius;
height: picker.preferred-height + 2 * Theme.gap;
picker := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "↑ Up";
// Disabled rather than hidden at the
// root: a control that vanishes moves
// everything beside it, and the row
// would jump as the user navigates.
enabled: !root.browse-at-root;
clicked => { root.browse-up(); }
}
Value {
text: root.browse-path;
overflow: elide;
horizontal-stretch: 1;
vertical-alignment: center;
}
Caption {
text: root.browse-loading ? "Listing…" : "";
vertical-alignment: center;
}
}
// A fixed height rather than one that grows
// with the listing: a folder with sixty
// children would otherwise push the rest of
// the settings page off the bottom.
Rectangle {
height: 180px;
background: Theme.surface;
border-radius: Theme.radius;
Flickable {
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
viewport-height: folders.preferred-height;
folders := VerticalLayout {
width: 100%;
spacing: 2px;
alignment: start;
if root.browse-entries.length == 0
&& !root.browse-loading: Caption {
text: "No folders here. "
+ "Use this one, or go up.";
}
for name in root.browse-entries: Rectangle {
height: 32px;
background: touch.has-hover
? Theme.surface-raised
: transparent;
border-radius: Theme.radius;
Label {
x: Theme.gap-sm;
text: name;
vertical-alignment: center;
overflow: elide;
width: parent.width - 2 * Theme.gap-sm;
}
touch := TouchArea {
clicked => { root.browse-into(name); }
}
}
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { root.browse-cancel(); }
}
// Confirms the folder currently *shown*,
// not one selected in the list — the same
// rule the library picker follows, so
// "use this one" means the same thing in
// both places.
Button {
text: "Use this folder";
active: true;
clicked => { root.browse-confirm(); }
}
}
}
}
Check {
label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a "
+ "published photograph cannot be recalled.";
checked: root.strip-location;
toggled(on) => { root.strip-location-toggled(on); }
}
}
}
}
}
}
}