Files
DarkRoom/ui/dr-ui/ui/settings.slint
T
dtourolle 6b1aac477d Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 16:20:15 +02:00

1313 lines
62 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 | FR-CULL-10
/// The re-index: every image the chosen detector has not been over at
/// native resolution, detected again with names carried across. The
/// same running state as the pass above — one job, two ways to ask for
/// it — so both buttons go quiet together.
callback reindex-faces();
/// TRACES: FR-CULL-8
/// Which detector the pass finds faces with — docs/dev/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;
/// What runs the neural models and how it was chosen — the two lines
/// `dr_ui::inference::about_lines` produces (docs/dev/inference.md §4).
in property <string> inference-backend;
in property <string> inference-detail;
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(); }
}
}
// TRACES: FR-CULL-8 | FR-CULL-10
// The re-index, under the pass it is the heavier
// form of. Both run the completeness job
// (dr_ui::repairs) and differ in one predicate:
// indexing converges on coverage and leaves a face a
// weaker detector found on a small proxy as found;
// this one detects every such image again so every
// box, landmark, crop and vector is the current
// detector's from the native render, with names,
// suggestions and rejections carried onto the new
// faces. It fetches every original it visits, which
// is why it says so and never starts on its own.
if root.library-open: Rectangle {
height: Theme.gap-sm;
}
if root.library-open: Caption {
text: "Re-indexing detects every face again with "
+ "the chosen detector at full resolution, "
+ "on every photograph it has not yet been "
+ "over — including those an earlier, "
+ "faster pass looked at — and fills in "
+ "whatever else a record is missing on the "
+ "way. Names, suggestions and rejections "
+ "are carried onto the new faces. It "
+ "fetches every original it visits, and "
+ "it can be stopped and resumed.";
wrap: word-wrap;
}
if root.library-open: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: root.face-indexing
? "Indexing faces…"
: "Re-index every face";
enabled: !root.face-indexing && !root.face-model-missing;
clicked => { root.reindex-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;
}
}
// TRACES: FR-INF-1
// The backend the models run on, and why. Beside
// Graphics because it is the same kind of fact: a
// property of this device, chosen by measurement,
// that a bug report about a slow index should quote.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Inference"; }
Value {
text: root.inference-backend;
horizontal-stretch: 1;
overflow: elide;
}
}
if root.inference-detail != "": Caption {
text: root.inference-detail;
wrap: word-wrap;
}
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";
// What an empty field does, not what it was once
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
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); }
}
}
}
}
}
}
}