Files
DarkRoom/ui/dr-ui/ui/settings.slint
T
dtourolleandClaude Opus 5 6a97fdf6f9 Put the adjustment groups in the rail where a finger is driving
Reported from the tablet: the tool rail is very useful there, and the same
interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's
central assumption failing in use, and the interesting part is which half of
it failed.

D-N2 was right that platform is the wrong axis and width is the wrong axis: a
tablet in landscape wants what a desktop wants, and a desktop window dragged
narrow wants what a small screen wants. `apply_layout_class` still decides the
layout class from the window and nothing here changes that. What D-N2 got
wrong is the sentence "touch changes hit regions, not layout" — it identified
input as the real difference between the targets and then assumed that
difference could never reach the layout.

Two controls answer one question — which group of adjustments am I looking at
— and neither is better in general. A horizontal strip above the column is one
gesture to a target the eye has already found, and it pans when the operation
set is rich, so a group can sit off the end with nothing saying so: a pointer
user tolerates that, a finger user never discovers it. The same list down the
rail is every entry visible at once, each finger-sized, on the edge of the
screen the hand is already holding, and it costs no width because the rail is
already there.

So `ToolRail` grows a second section, and `GroupStrip` stands down when it
does. The two are never both on screen, which is why they can share
`adjust-tab-picked`: Rust is not told which was pressed and has no reason to
want to. Mode and group stay independent axes as N1 requires — one entry lit
in each section, and choosing a group while a tool is held still filters
without putting the tool down.

They stay drawn differently, which N1 also required. The tools fill with
`active-dim` and invert their ink; the groups take a bar down the leading
edge — the strip's underline turned ninety degrees — so a lit entry says which
kind of state it is without the reader having to remember which section it was
in. The rule between the sections is the second signal.

The rail scrolls now. Its own note argued against a Flickable because "this
list is four entries written in this file"; with the groups in it the list
comes from the operation set, which is exactly the "something the user's data
decides" that note excluded this control from.

The axis is input, and it is a preference because the automatic answer is a
guess that cannot be made reliable. Neither platform can be asked what the
user is holding: an Android tablet in a keyboard case is being driven like a
desktop, and a touchscreen laptop is whichever its owner says.
`dr_plat::is_touch_first` reports the usual case per platform, and
`GroupNavigation` lets it be overridden. Settings names what Automatic
resolves to on this device rather than leaving it to be found by pressing.

D-N6 records the reversal beside the decision it reverses, including the half
that still stands and the question it opens: whether Local is a mode at all,
or a scope that would collapse the two sections into one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:14:03 +02:00

1068 lines
49 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();
// --- 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-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: 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);
// --- 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 && root.face-coverage != "": Caption {
text: root.face-coverage;
wrap: word-wrap;
}
if root.library-open && root.face-model-missing: Caption {
text: "No face model is 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); }
}
}
}
// --- 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;
}
}
}
// --- 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"; }
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); }
}
}
}
}
}
}
}