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 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 activity-running: 0; /// Stopped jobs still listed: recent successes, and failures held until /// they are read. in property activity-kept: 0; callback clear-finished(); // --- cache --------------------------------------------------------- in-out property original-budget; in property original-unlimited: false; in-out property thumbnail-budget; in property thumbnail-unlimited: false; in property keep-opened: true; /// TRACES: FR-DEV-6 /// Whether copying settings carries the crop and rotation with it. in property copy-includes-framing: false; /// What the cache currently holds. Empty hides the line. in property cache-usage; 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); // --- export -------------------------------------------------------- in property <[string]> format-labels; in property format-selected: 0; in property quality: 90; in property quality-enabled: true; in property <[string]> colour-labels; in property colour-selected: 0; in property <[string]> sizing-labels; in property sizing-selected: 0; in-out property sizing-value: 0; in property sizing-has-value: false; in property sizing-unit: "px"; in property allow-upscaling: false; in property <[string]> sharpening-labels; in property sharpening-selected: 0; in-out property filename-template; in property <[string]> collision-labels; in property collision-selected: 0; in property strip-location: true; in-out property 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 destination-hint; in property <[string]> target-labels; in property 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 browse-open: false; in property browse-path; in property <[string]> browse-entries; in property browse-loading: false; /// At the library root, so there is nowhere up to go. in property 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 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 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 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; } 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); } } } } // --- 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); } } } } // --- 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); } } } } } } } }