faces.md §12.3 measured what the cheapest detector costs: the small faces in every group shot, and a dog embedded a dozen times. Which trade is right depends on the machine doing the sweep — a desktop left overnight and a tablet on a battery want different answers — so the detector is now a per-device setting, Fast / Balanced / Thorough on the settings page beside the indexing button, persisted with the rest of the settings file. A detector is half of a model id. Every face, marker, shard and calibration is keyed on faces.model_id precisely so that a model change is a new id and a re-index rather than a silent change under existing data, and a detector change is a model change: it decides which faces exist and where the landmarks that align them land. So each choice names its own pipeline. 500M keeps the bare "w600k_mbf" every existing library was written under, so an upgrade disturbs nothing; the others are qualified. Choosing one restarts coverage from zero under the new id, the sweep re-detects, confirmed names carry across by box overlap, and the sync shards are keyed by the same id so a peer on another setting neither adopts nor pollutes them. The library controller carries the id into the sync the same way it carries the cache budget, because the sync starts from places that have no settings in reach. All three shape-fixed exports ship — APK, Arch, Flatpak — since a tablet has no other way to obtain the one it was not installed with; the APK grows by twenty megabytes for the choice.
1094 lines
51 KiB
Plaintext
1094 lines
51 KiB
Plaintext
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-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: 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); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|