The range could be turned on with a finger and not aimed with one. Its two ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to name days already drawn on the axis a thumb away; and the chip that seeds them takes its span from the timeline's zoom and pan, which are a wheel and a middle button. A touch screen has neither, so on Android the filter was a switch with no aim. The band is now on the timeline. Two ends with grips, dragged along the bars, released to filter — the histogram was already how a period is found, and this makes it how a period is stated. Both ends snap to whole days, which is what the typed fields mean, what `show_range` reads back out, and a floor under a range dragged shut. The fields stay for what dragging cannot do: name an exact day, and say in words what the range is. For that to work the axis had to stop following the range. Redrawn to the band, it moved the ground under the very handles doing the narrowing, and there was nothing outside the range left to widen back into. While there: a fixed number of equal bins instead of calendar buckets. Between one calendar unit and the next the bar count is free to wander by a factor of twelve, so zooming in halved it two steps out of three — the same picture drawn wider until it jumped back to fine. Equal bins also include the empty ones, so a bar's position on the track and the date under it are finally the same quantity; before, a library with gaps drew a February six months wide and the marker, the band and a click all pointed somewhere else. The count is a setting, 32 or 64, because the right answer is a question about the screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
856 lines
39 KiB
Plaintext
856 lines
39 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";
|
||
|
||
// 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();
|
||
|
||
// --- 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
|
||
/// Whether copying settings carries the crop and rotation with it.
|
||
in property <bool> copy-includes-framing: false;
|
||
/// 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;
|
||
|
||
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);
|
||
callback copy-includes-framing-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";
|
||
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 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(); }
|
||
}
|
||
}
|
||
|
||
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"; }
|
||
|
||
Check {
|
||
label: "Copy crop and rotation with settings";
|
||
// Says what the *off* state does, because off is
|
||
// the default and is the behaviour that needs
|
||
// explaining: a user who has never opened this
|
||
// page should still be able to predict what a
|
||
// paste did to their crops.
|
||
hint: "Off, pasting settings changes only colour and "
|
||
+ "tone, and each photograph keeps its own "
|
||
+ "composition. On, the crop, straightening, "
|
||
+ "rotation and flips travel too — which "
|
||
+ "re-frames every image pasted onto.";
|
||
checked: root.copy-includes-framing;
|
||
toggled(on) => { root.copy-includes-framing-toggled(on); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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: "Size value";
|
||
text: root.sizing-value;
|
||
unit: root.sizing-unit;
|
||
field-width: 90px;
|
||
accepted(t) => { root.sizing-value-changed(t); }
|
||
}
|
||
|
||
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; }
|
||
}
|
||
|
||
Caption {
|
||
text: "Graphics and frame rate 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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|