The page is several screens long, and nothing said so until something had been scrolled. Same wrapping as the develop column; the bar sits at the window's right edge, clear of the capped 680px form.
1048 lines
51 KiB
Plaintext
1048 lines
51 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow, ScrollBar } from "widgets.slint";
|
||
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
|
||
import { ExportImageOptions, ExportFileOptions } from "export.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);
|
||
/// TRACES: FR-CAT-11a
|
||
/// How many groups of duplicate originals the catalog holds, and the way
|
||
/// into their review. Here beside the other whole-library passes, and on
|
||
/// the sidebar under the trash, where the copies it moves will go.
|
||
in property <int> duplicate-groups: 0;
|
||
callback find-duplicates();
|
||
|
||
// --- 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-UI-4
|
||
/// Open the manual that came with the application (`dr_ui::manual`).
|
||
callback open-manual();
|
||
|
||
/// 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();
|
||
|
||
// The export defaults are drawn from `ExportOptions` (export.slint),
|
||
// which the export sheet shares; nothing about them is declared here.
|
||
|
||
/// 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; }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
vertical-stretch: 1;
|
||
page-flick := Flickable {
|
||
width: parent.width;
|
||
height: parent.height;
|
||
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(); }
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-CAT-11a
|
||
// Duplicate originals: the same file catalogued in
|
||
// several folders. Opens the review; nothing moves
|
||
// from here.
|
||
if root.library-open: Rectangle {
|
||
height: Theme.gap-sm;
|
||
}
|
||
|
||
if root.library-open: Caption {
|
||
text: root.duplicate-groups > 0
|
||
? "Duplicate originals: " + root.duplicate-groups
|
||
+ (root.duplicate-groups == 1 ? " file is" : " files are")
|
||
+ " in the library more than once. The review "
|
||
+ "checks each group is the same file, keeps one "
|
||
+ "copy with every rating, keyword and collection "
|
||
+ "merged onto it, and moves the rest to the trash."
|
||
: "Duplicate originals: none. The same file in two "
|
||
+ "folders would be listed here.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// GESTURE: Review duplicate originals
|
||
// where: Settings
|
||
// touch: Tap "Review duplicate originals"
|
||
// pointer: Click "Review duplicate originals"
|
||
// why: Beside the other whole-library passes, because
|
||
// it is one; the sidebar offers the same page
|
||
// under the trash.
|
||
// manual: duplicate-originals
|
||
if root.library-open && root.duplicate-groups > 0: Rectangle {
|
||
height: Theme.control-height;
|
||
|
||
Button {
|
||
x: 0;
|
||
text: "Review duplicate originals";
|
||
clicked => { root.find-duplicates(); }
|
||
}
|
||
}
|
||
|
||
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 every export uses. Ctrl+E shows these on their own, "
|
||
+ "beside an Export button.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
ExportImageOptions {}
|
||
}
|
||
}
|
||
|
||
// --- 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; }
|
||
}
|
||
|
||
// The manual, beside the version because both answer
|
||
// "what is this application": one says which, the
|
||
// other what it does. Installed with it, so it opens
|
||
// with no network.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Manual"; vertical-alignment: center; }
|
||
Rectangle {
|
||
horizontal-stretch: 1;
|
||
height: Theme.control-height;
|
||
Button {
|
||
x: 0;
|
||
text: "Open the manual";
|
||
clicked => { root.open-manual(); }
|
||
}
|
||
}
|
||
}
|
||
|
||
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(); }
|
||
}
|
||
}
|
||
|
||
ExportFileOptions {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A bar for a pointer: the page is several screens long, and the bar is
|
||
// what says so before anything has been scrolled.
|
||
ScrollBar {
|
||
x: parent.width - self.width;
|
||
y: 0px;
|
||
viewport-y <=> page-flick.viewport-y;
|
||
viewport-height: page-flick.viewport-height;
|
||
visible-height: page-flick.height;
|
||
}
|
||
}
|
||
}
|
||
}
|