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:
2026-08-29 13:49:20 +02:00
co-authored by Claude Opus 5
parent d9eb8faffd
commit 3bb68cff69
8 changed files with 556 additions and 41 deletions
+79 -4
View File
@@ -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();