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 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(); /// 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 thumbnailing: false; in property 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 face-indexing: false; in property face-coverage; /// No model on disk, so the pass cannot run at all. in property 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 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 duplicate-groups: 0; callback find-duplicates(); // --- 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-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 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 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 group-nav-auto-says; in property <[ScopeKind]> copy-scope-kinds; in property 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 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 adapter; in property 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 inference-backend; in property inference-detail; in property fps; in property layout-class; in property 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 diagnostics-preview; in property 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 display-name; in property 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 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 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 write-xmp: false; callback write-xmp-toggled(bool); in property 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 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 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; } } } }