// TRACES: FR-EXP-7 | FR-UI-5 // The export defaults, drawn wherever an export is chosen. // // Two places show them: the settings page, among everything else, and the // export sheet Ctrl+E opens, alone with an Export button beneath. They are // one set of values — every export runs on the defaults, there is no // per-export copy — so the state is a global Rust writes once, and the two // panels below read it, rather than forty properties threaded separately // through each surface and bound to agree. import { Theme } from "theme.slint"; import { Button, Label, Value, Caption } from "widgets.slint"; import { Segmented, TextRow, Check, SliderRow } from "controls.slint"; export global ExportOptions { // --- the image --------------------------------------------------- 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"; /// "Size value" for the modes that take one number, "Width" for the box /// modes, where a second field sits below it. in property sizing-value-label: "Size value"; in-out property sizing-height: 0; in property sizing-has-height: false; /// The panels offered as buttons, and which one the numbers match — `-1` /// where they match none, so a preset never stays lit over fields that /// have since been typed over. in property <[string]> screen-labels; in property screen-selected: -1; in property allow-upscaling: false; in property <[string]> sharpening-labels; in property sharpening-selected: 0; // --- the file ---------------------------------------------------- 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 sizing-height-changed(string); callback screen-picked(int); callback upscaling-toggled(bool); callback sharpening-picked(int); callback template-changed(string); callback collision-picked(int); callback strip-location-toggled(bool); callback destination-changed(string); callback target-picked(int); callback browse-open-picker(); callback browse-into(string); callback browse-up(); callback browse-confirm(); callback browse-cancel(); } // What the image becomes: format, quality, colour, size, sharpening. The // body of a panel, not a panel — each surface draws its own frame and // heading around it. export component ExportImageOptions inherits VerticalLayout { spacing: Theme.gap; Segmented { label: "Format"; options: ExportOptions.format-labels; selected: ExportOptions.format-selected; picked(i) => { ExportOptions.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: ExportOptions.quality-enabled ? "1 to 100" : "the chosen format is lossless"; value: ExportOptions.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: ExportOptions.quality-enabled; changed(v) => { ExportOptions.quality-changed(v); } } Segmented { label: "Colour space"; hint: "profile embedded on export"; options: ExportOptions.colour-labels; selected: ExportOptions.colour-selected; picked(i) => { ExportOptions.colour-picked(i); } } Segmented { label: "Size"; options: ExportOptions.sizing-labels; selected: ExportOptions.sizing-selected; picked(i) => { ExportOptions.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 ExportOptions.sizing-has-value: TextRow { label: ExportOptions.sizing-value-label; text: ExportOptions.sizing-value; unit: ExportOptions.sizing-unit; field-width: 90px; accepted(t) => { ExportOptions.sizing-value-changed(t); } } // TRACES: FR-EXP-3 // The second axis, for the box modes only. if ExportOptions.sizing-has-height: TextRow { label: "Height"; text: ExportOptions.sizing-height; unit: "px high"; field-width: 90px; accepted(t) => { ExportOptions.sizing-height-changed(t); } } // TRACES: FR-EXP-3 // Panel sizes as buttons, because the numbers are the // whole difficulty: a television's art mode accepts one // resolution and rejects everything else, and getting // it by typing four digits twice is a step at which a // photographer discovers they were wrong only after the // upload. // // Alongside the fields rather than instead of them — // the presets are a shortcut to a pair of numbers, not // a replacement for being able to say any pair. if ExportOptions.sizing-has-height: Segmented { label: "Screen"; hint: "fills in both numbers"; options: ExportOptions.screen-labels; selected: ExportOptions.screen-selected; picked(i) => { ExportOptions.screen-picked(i); } } Check { label: "Allow upscaling"; hint: "Off, a request larger than the source exports " + "at source size rather than failing."; checked: ExportOptions.allow-upscaling; toggled(on) => { ExportOptions.upscaling-toggled(on); } } Segmented { label: "Output sharpening"; hint: "scaled by the resize factor"; options: ExportOptions.sharpening-labels; selected: ExportOptions.sharpening-selected; picked(i) => { ExportOptions.sharpening-picked(i); } } } // What the file is called, where it lands, and what leaves with it. export component ExportFileOptions inherits VerticalLayout { spacing: Theme.gap; TextRow { label: "Filename template"; hint: "{name} {seq} {date} {dimensions} {preset}"; text <=> ExportOptions.filename-template; field-width: 260px; placeholder: "{name}"; accepted(t) => { ExportOptions.template-changed(t); } } Segmented { label: "If the file exists"; options: ExportOptions.collision-labels; selected: ExportOptions.collision-selected; picked(i) => { ExportOptions.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: ExportOptions.target-labels; selected: ExportOptions.target-selected; picked(i) => { ExportOptions.target-picked(i); } } TextRow { label: "Destination"; // What an empty field does, not what it was once // going to do: nothing asks, and an export with // no folder is refused and says so in the header. hint: "a folder on this device; exports are refused until one is set"; text <=> ExportOptions.destination; field-width: 320px; placeholder: ExportOptions.destination-hint; accepted(t) => { ExportOptions.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 ExportOptions.browse-available && !ExportOptions.browse-open: HorizontalLayout { alignment: start; Button { text: "Choose folder…"; clicked => { ExportOptions.browse-open-picker(); } } } if ExportOptions.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: !ExportOptions.browse-at-root; clicked => { ExportOptions.browse-up(); } } Value { text: ExportOptions.browse-path; overflow: elide; horizontal-stretch: 1; vertical-alignment: center; } Caption { text: ExportOptions.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 ExportOptions.browse-entries.length == 0 && !ExportOptions.browse-loading: Caption { text: "No folders here. " + "Use this one, or go up."; } for name in ExportOptions.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 => { ExportOptions.browse-into(name); } } } } } } HorizontalLayout { spacing: Theme.gap-sm; alignment: end; Button { text: "Cancel"; clicked => { ExportOptions.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 => { ExportOptions.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: ExportOptions.strip-location; toggled(on) => { ExportOptions.strip-location-toggled(on); } } } // TRACES: FR-EXP-7 | FR-UI-5 // The export defaults on their own, over whatever is open, with the button // that uses them — what Ctrl+E opens, and the nearest thing this application // has to another editor's export dialogue. // // Every change here is saved as it is made, exactly as on the settings page, // because they are the same values: closing without exporting keeps them, and // the next Ctrl+Shift+E runs on what was chosen here. export component ExportSheet inherits Rectangle { /// What pressing Export would do — "Export JPEG", "Export 40 to the /// library" — from whichever view opened the sheet. in property action-label: "Export"; /// False when there is nothing to export, or an export is already running. in property can-export: true; /// A save that failed, as the settings page reports it. in property error; callback export(); callback dismiss(); background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the card's // own controls sit above it. TouchArea { clicked => { root.dismiss(); } } Rectangle { // The settings column's width, so the panels lay out as they do there. width: min(680px, parent.width - 2 * Theme.gap-lg); height: min(body.preferred-height + footer.preferred-height + 3 * Theme.gap-lg, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap-lg; // The options scroll and the buttons do not: on a short window the // form is longer than the card, and an Export button scrolled out // of sight is a sheet with no way to do what it is for. Flickable { vertical-stretch: 1; viewport-height: body.preferred-height; body := VerticalLayout { width: 100%; spacing: Theme.gap; alignment: start; Text { text: "Export"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; } if root.error != "": Caption { text: root.error; warn: true; wrap: word-wrap; } ExportImageOptions {} ExportFileOptions {} } } footer := HorizontalLayout { spacing: Theme.gap-sm; alignment: end; Button { text: "Close"; clicked => { root.dismiss(); } } Button { text: root.action-label; primary: true; enabled: root.can-export; clicked => { root.export(); } } } } } }