Export at an exact resolution, and offer the panels worth naming
Export sizing could bound an image but not fix it. Long edge, short edge and percentage all preserve the aspect ratio by letting one dimension fall where it may, which is right for most work and useless against a display that accepts one resolution and rejects everything else — a television's art mode, a digital frame, a wallpaper slot. FR-EXP-3 has always listed both halves of the answer, and this adds them. **Fit box** scales to fit inside a width and height, so nothing is thrown away and the result is smaller than the box on one axis unless the crop already matches it. **Fill box** scales to cover the box and cuts the overhang off the middle, so the file is exactly the pixels asked for. Fill is the only mode in the file that discards image data, so two things about it are worth stating. The overhang comes off symmetrically: the crop tool is where a photographer decides which part of a frame survives, and this stage having an opinion of its own would fight it. And locking the crop to the same ratio leaves nothing here to cut, which is the workflow the two features are meant to be used in. With upscaling off and a source too small to cover, a fill box keeps its *shape* rather than falling back to the source's: exporting a 3:2 file where 16:9 was asked for is silently wrong in exactly the way the mode exists to prevent, so the box shrinks instead. The existing rule — clamp, never fail — is otherwise unchanged. Four panel sizes are offered as buttons beside the fields. Getting 3840 x 2160 by typing four digits twice is a step at which the mistake is discovered after the upload rather than before it. They fill in the numbers and nothing else, in particular not the fit/fill choice: both are legitimate against a screen, and guessing would discard the edges of a photograph for a user who wanted them. The list is panels rather than platforms, because a screen has one exact pixel count for ever where "what a photo site wants" would rot in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ use std::rc::Rc;
|
||||
use dr_types::settings::budget;
|
||||
use dr_types::{
|
||||
CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, LibrarySettings, OutputSharpening,
|
||||
Settings, SizingMode,
|
||||
ScreenSize, Settings, SizingMode,
|
||||
};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
@@ -203,6 +203,36 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// TRACES: FR-EXP-3
|
||||
// The box modes take two numbers, so the field beside the chips gets a
|
||||
// partner and both stop being "the size value" — one is the width and one
|
||||
// is the height, and a page that called either of them "Size value" would
|
||||
// leave the user guessing which axis they were editing.
|
||||
window.set_settings_sizing_value_label(
|
||||
if s.export.sizing.height().is_some() {
|
||||
"Width"
|
||||
} else {
|
||||
"Size value"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_settings_sizing_height(s.export.sizing.height().unwrap_or(0) as i32);
|
||||
window.set_settings_sizing_has_height(s.export.sizing.height().is_some());
|
||||
// TRACES: FR-EXP-3
|
||||
// The panels, offered as buttons. Lit only on an exact match, and -1 —
|
||||
// no chip lit — where the user has typed their own numbers: a preset that
|
||||
// stayed selected while the fields said something else would be the page
|
||||
// claiming the export is 4K when it is not.
|
||||
window.set_settings_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
|
||||
window.set_settings_screen_selected(
|
||||
ScreenSize::ALL
|
||||
.iter()
|
||||
.position(|p| {
|
||||
s.export.sizing.value() == Some(p.width)
|
||||
&& s.export.sizing.height() == Some(p.height)
|
||||
})
|
||||
.map_or(-1, |i| i as i32),
|
||||
);
|
||||
window.set_settings_allow_upscaling(s.export.allow_upscaling);
|
||||
|
||||
window.set_settings_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
|
||||
@@ -469,9 +499,21 @@ pub fn wire<F, G>(
|
||||
// between two sized modes: switching long edge to short edge
|
||||
// at 900px means 900 on the other axis, not back to 1600.
|
||||
ctl.edit(|s| {
|
||||
s.export.sizing = match s.export.sizing.value() {
|
||||
Some(v) => mode.with_value(v),
|
||||
None => mode,
|
||||
let old = s.export.sizing;
|
||||
s.export.sizing = match (old.value(), old.height(), mode.height()) {
|
||||
// Fit to fill and back: the same box, resolved
|
||||
// differently. This is the switch people actually
|
||||
// flip, and re-typing 3840 x 2160 to flip it would be
|
||||
// absurd.
|
||||
(Some(w), Some(h), Some(_)) => mode.with_box(w, h),
|
||||
// Into a box from a single-number mode: the box opens
|
||||
// at its own size. "2048" is a long edge, not a width
|
||||
// and a height, and pairing it with whatever happened
|
||||
// to be in the other field makes a box nobody asked
|
||||
// for.
|
||||
(_, None, Some(_)) => mode,
|
||||
(Some(v), _, None) => mode.with_value(v),
|
||||
(None, _, _) => mode,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -492,6 +534,39 @@ pub fn wire<F, G>(
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = controller.clone();
|
||||
window.on_settings_sizing_height_changed(move |text| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
match text.trim().parse::<u32>() {
|
||||
Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_height(v)),
|
||||
_ => log::debug!("ignoring an unusable export height: {text:?}"),
|
||||
}
|
||||
render(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
// TRACES: FR-EXP-3
|
||||
// A panel's size, filled into both fields at once.
|
||||
//
|
||||
// It sets the numbers and nothing else — not the fit/fill choice. Both
|
||||
// are legitimate against a screen: fill for a display that accepts one
|
||||
// resolution and rejects everything else, fit for one that letterboxes
|
||||
// politely, and guessing would silently discard the edges of a
|
||||
// photograph for a user who wanted them.
|
||||
let weak = window.as_weak();
|
||||
let ctl = controller.clone();
|
||||
window.on_settings_screen_picked(move |i| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(p) = ScreenSize::ALL.get(i.max(0) as usize) {
|
||||
ctl.edit(|s| s.export.sizing = s.export.sizing.with_box(p.width, p.height));
|
||||
}
|
||||
render(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = controller.clone();
|
||||
|
||||
@@ -731,6 +731,11 @@ export component AppWindow inherits Window {
|
||||
in property <int> settings-sizing-value: 0;
|
||||
in property <bool> settings-sizing-has-value: false;
|
||||
in property <string> settings-sizing-unit: "px";
|
||||
in property <string> settings-sizing-value-label: "Size value";
|
||||
in property <int> settings-sizing-height: 0;
|
||||
in property <bool> settings-sizing-has-height: false;
|
||||
in property <[string]> settings-screen-labels;
|
||||
in property <int> settings-screen-selected: -1;
|
||||
in property <bool> settings-allow-upscaling: false;
|
||||
in property <[string]> settings-sharpening-labels;
|
||||
in property <int> settings-sharpening-selected: 0;
|
||||
@@ -755,6 +760,8 @@ export component AppWindow inherits Window {
|
||||
callback settings-colour-picked(int);
|
||||
callback settings-sizing-picked(int);
|
||||
callback settings-sizing-value-changed(string);
|
||||
callback settings-sizing-height-changed(string);
|
||||
callback settings-screen-picked(int);
|
||||
callback settings-upscaling-toggled(bool);
|
||||
callback settings-sharpening-picked(int);
|
||||
callback settings-template-changed(string);
|
||||
@@ -1143,6 +1150,11 @@ in property <bool> panel-visible: true;
|
||||
sizing-value: root.settings-sizing-value;
|
||||
sizing-has-value: root.settings-sizing-has-value;
|
||||
sizing-unit: root.settings-sizing-unit;
|
||||
sizing-value-label: root.settings-sizing-value-label;
|
||||
sizing-height: root.settings-sizing-height;
|
||||
sizing-has-height: root.settings-sizing-has-height;
|
||||
screen-labels: root.settings-screen-labels;
|
||||
screen-selected: root.settings-screen-selected;
|
||||
allow-upscaling: root.settings-allow-upscaling;
|
||||
sharpening-labels: root.settings-sharpening-labels;
|
||||
sharpening-selected: root.settings-sharpening-selected;
|
||||
@@ -1167,6 +1179,8 @@ in property <bool> panel-visible: true;
|
||||
colour-picked(i) => { root.settings-colour-picked(i); }
|
||||
sizing-picked(i) => { root.settings-sizing-picked(i); }
|
||||
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
|
||||
sizing-height-changed(t) => { root.settings-sizing-height-changed(t); }
|
||||
screen-picked(i) => { root.settings-screen-picked(i); }
|
||||
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
|
||||
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
|
||||
template-changed(t) => { root.settings-template-changed(t); }
|
||||
|
||||
@@ -165,6 +165,16 @@ export component SettingsPage inherits Rectangle {
|
||||
in-out property <int> sizing-value: 0;
|
||||
in property <bool> sizing-has-value: false;
|
||||
in property <string> 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 <string> sizing-value-label: "Size value";
|
||||
in-out property <int> sizing-height: 0;
|
||||
in property <bool> 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 <int> screen-selected: -1;
|
||||
in property <bool> allow-upscaling: false;
|
||||
in property <[string]> sharpening-labels;
|
||||
in property <int> sharpening-selected: 0;
|
||||
@@ -205,6 +215,8 @@ export component SettingsPage inherits Rectangle {
|
||||
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);
|
||||
@@ -653,13 +665,42 @@ export component SettingsPage inherits Rectangle {
|
||||
// "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";
|
||||
label: root.sizing-value-label;
|
||||
text: root.sizing-value;
|
||||
unit: root.sizing-unit;
|
||||
field-width: 90px;
|
||||
accepted(t) => { root.sizing-value-changed(t); }
|
||||
}
|
||||
|
||||
// TRACES: FR-EXP-3
|
||||
// The second axis, for the box modes only.
|
||||
if root.sizing-has-height: TextRow {
|
||||
label: "Height";
|
||||
text: root.sizing-height;
|
||||
unit: "px";
|
||||
field-width: 90px;
|
||||
accepted(t) => { root.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 root.sizing-has-height: Segmented {
|
||||
label: "Screen";
|
||||
hint: "fills in both numbers";
|
||||
options: root.screen-labels;
|
||||
selected: root.screen-selected;
|
||||
picked(i) => { root.screen-picked(i); }
|
||||
}
|
||||
|
||||
Check {
|
||||
label: "Allow upscaling";
|
||||
hint: "Off, a request larger than the source exports "
|
||||
|
||||
Reference in New Issue
Block a user