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:
@@ -179,7 +179,15 @@ pub fn export(
|
||||
settings.allow_upscaling,
|
||||
);
|
||||
|
||||
let resized = size::resample(frame, width, height);
|
||||
// TRACES: FR-EXP-3
|
||||
// One mode promises exact dimensions rather than a bound on them, and it
|
||||
// is the only place the fit/fill distinction survives: `target_size` has
|
||||
// already reported what the file will be either way.
|
||||
let resized = if settings.sizing.crops_to_fill() {
|
||||
size::resample_filling(frame, width, height)
|
||||
} else {
|
||||
size::resample(frame, width, height)
|
||||
};
|
||||
|
||||
// Scaled by how much the image actually shrank: a full-size export needs
|
||||
// no compensation, and a thumbnail needs a great deal.
|
||||
|
||||
+202
-6
@@ -25,8 +25,11 @@ const A: f32 = 3.0;
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Resolve the requested sizing against a source, honouring the upscale rule.
|
||||
///
|
||||
/// Aspect is preserved in every mode, so only one dimension is ever the
|
||||
/// requested one.
|
||||
/// Aspect is preserved in every mode. In all but one that means only a single
|
||||
/// dimension is ever the requested one; [`SizingMode::FillBox`] is the
|
||||
/// exception, and it keeps the aspect by *discarding* the overhang rather than
|
||||
/// by settling for a smaller box — see [`resample_filling`], which does the
|
||||
/// discarding.
|
||||
///
|
||||
/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes
|
||||
/// upscaling opt-in, and a batch of mixed frames must not abort because one
|
||||
@@ -45,21 +48,56 @@ pub fn target_size(
|
||||
SizingMode::Original => (src_w, src_h),
|
||||
SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h),
|
||||
SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h),
|
||||
// Fit is a ceiling on both axes, so the smaller factor wins and the
|
||||
// result touches the box on one axis only.
|
||||
SizingMode::FitBox(bw, bh) => {
|
||||
scale_by(src_w, src_h, box_factor(src_w, src_h, bw, bh, f64::min))
|
||||
}
|
||||
// Fill is the box, exactly. The scale that covers it is the larger
|
||||
// factor, and the overhang is taken off in `resample_filling` — this
|
||||
// reports what the file will be, which is the whole reason the mode
|
||||
// exists.
|
||||
SizingMode::FillBox(bw, bh) => (bw.max(1), bh.max(1)),
|
||||
SizingMode::Percentage(p) => {
|
||||
let f = f64::from(p) / 100.0;
|
||||
(
|
||||
((f64::from(src_w) * f).round() as u32).max(1),
|
||||
((f64::from(src_h) * f).round() as u32).max(1),
|
||||
)
|
||||
scale_by(src_w, src_h, f)
|
||||
}
|
||||
};
|
||||
|
||||
if !allow_upscaling && (w > src_w || h > src_h) {
|
||||
// A fill box has to keep its shape even when it cannot keep its size:
|
||||
// the mode's promise is an exact aspect ratio at exact dimensions, and
|
||||
// falling back to the source's own shape would quietly export a 3:2
|
||||
// file where a 16:9 one was asked for. So the *box* is scaled down to
|
||||
// what the source can cover, rather than abandoned.
|
||||
if let SizingMode::FillBox(bw, bh) = sizing {
|
||||
let cover = box_factor(src_w, src_h, bw, bh, f64::max);
|
||||
if cover > 1.0 {
|
||||
return scale_by(bw.max(1), bh.max(1), 1.0 / cover);
|
||||
}
|
||||
}
|
||||
return (src_w, src_h);
|
||||
}
|
||||
(w.max(1), h.max(1))
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// The scale that puts `src` against a `bw × bh` box, `choose` deciding which
|
||||
/// axis governs: `f64::min` fits inside it, `f64::max` covers it.
|
||||
fn box_factor(src_w: u32, src_h: u32, bw: u32, bh: u32, choose: fn(f64, f64) -> f64) -> f64 {
|
||||
let fw = f64::from(bw.max(1)) / f64::from(src_w.max(1));
|
||||
let fh = f64::from(bh.max(1)) / f64::from(src_h.max(1));
|
||||
choose(fw, fh)
|
||||
}
|
||||
|
||||
/// Both axes by one factor, never rounding away to nothing.
|
||||
fn scale_by(w: u32, h: u32, factor: f64) -> (u32, u32) {
|
||||
(
|
||||
((f64::from(w) * factor).round() as u32).max(1),
|
||||
((f64::from(h) * factor).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Scale so that the chosen edge lands on `n`.
|
||||
fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) {
|
||||
let n = n.max(1);
|
||||
@@ -95,6 +133,46 @@ pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
|
||||
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Resample onto exactly `(dst_w, dst_h)`, covering the box and cutting the
|
||||
/// overhang off the middle.
|
||||
///
|
||||
/// The other half of [`SizingMode::FillBox`]. [`resample`] alone would do the
|
||||
/// job by *stretching* the frame onto the box, which is the one outcome a
|
||||
/// photographer would never accept — a 3:2 photograph squeezed onto a 16:9
|
||||
/// panel is visibly wrong in a way no amount of resolution fixes.
|
||||
///
|
||||
/// So the frame is scaled until it covers the box, on whichever axis needs the
|
||||
/// most, and the surplus is taken symmetrically off the other. Centred rather
|
||||
/// than anchored: the crop tool is where a photographer decides *which* part
|
||||
/// of the frame survives, and this stage guessing differently would fight it.
|
||||
/// Locking the crop to the export's ratio leaves nothing here to cut.
|
||||
pub fn resample_filling(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
|
||||
let (dst_w, dst_h) = (dst_w.max(1), dst_h.max(1));
|
||||
|
||||
// Rounded *up*, and floored at the destination: a cover scale that rounds
|
||||
// down leaves the box a pixel short on one axis, and the crop below would
|
||||
// then read past the end of the buffer.
|
||||
let cover = box_factor(frame.width, frame.height, dst_w, dst_h, f64::max);
|
||||
let cw = (((f64::from(frame.width) * cover).ceil()) as u32).max(dst_w);
|
||||
let ch = (((f64::from(frame.height) * cover).ceil()) as u32).max(dst_h);
|
||||
|
||||
let covered = resample(frame, cw, ch);
|
||||
if cw == dst_w && ch == dst_h {
|
||||
return covered;
|
||||
}
|
||||
|
||||
let (x0, y0) = ((cw - dst_w) / 2, (ch - dst_h) / 2);
|
||||
let mut out = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
|
||||
for y in 0..dst_h as usize {
|
||||
let src = ((y + y0 as usize) * cw as usize + x0 as usize) * 4;
|
||||
let dst = y * dst_w as usize * 4;
|
||||
let run = dst_w as usize * 4;
|
||||
out[dst..dst + run].copy_from_slice(&covered[src..src + run]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One separable pass. `horizontal` picks the axis being resampled.
|
||||
fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec<u8> {
|
||||
let (src_len, dst_len) = if horizontal {
|
||||
@@ -219,6 +297,124 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fit_box_stays_inside_the_box_and_keeps_its_shape() {
|
||||
// Fit is a ceiling on both axes, so a 3:2 frame in a 16:9 box comes
|
||||
// back short of the box's width, never past its height.
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::FitBox(3840, 2160), false),
|
||||
(3240, 2160)
|
||||
);
|
||||
// Portrait into the same box: now the height governs nothing and the
|
||||
// width does.
|
||||
assert_eq!(
|
||||
target_size(4000, 6000, SizingMode::FitBox(3840, 2160), false),
|
||||
(1440, 2160)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_box_is_the_box_exactly() {
|
||||
// The whole point of the mode. A display that accepts one resolution
|
||||
// and rejects everything else has to get that resolution whatever the
|
||||
// photograph's own shape is.
|
||||
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (5000, 5000)] {
|
||||
assert_eq!(
|
||||
target_size(w, h, SizingMode::FillBox(3840, 2160), false),
|
||||
(3840, 2160),
|
||||
"{w}x{h} did not fill the box"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_box_too_large_for_the_source_keeps_its_shape_not_the_sources() {
|
||||
// Upscaling off, and the source cannot cover 4K. Falling back to the
|
||||
// source's own size would export a 3:2 file where 16:9 was asked
|
||||
// for — silently wrong in exactly the way the mode exists to prevent.
|
||||
// The box shrinks instead.
|
||||
let (w, h) = target_size(1600, 1200, SizingMode::FillBox(3840, 2160), false);
|
||||
assert!(w <= 1600 && h <= 1200, "upscaled to {w}x{h}");
|
||||
let want = 3840.0 / 2160.0;
|
||||
assert!(
|
||||
((w as f64 / h as f64) / want - 1.0).abs() < 0.01,
|
||||
"{w}x{h} is not the box's shape"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_box_within_the_source_is_honoured_with_upscaling_off() {
|
||||
// The ordinary case: a 24 MP frame has pixels to spare for a 4K panel,
|
||||
// so nothing is being enlarged and the clamp must not fire.
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::FillBox(3840, 2160), false),
|
||||
(3840, 2160)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_frame_comes_back_at_exactly_the_box() {
|
||||
let f = frame(128, 64);
|
||||
// Wider than the source's 2:1, so the crop comes off the width.
|
||||
assert_eq!(resample_filling(&f, 40, 40).len(), 40 * 40 * 4);
|
||||
assert_eq!(resample_filling(&f, 100, 25).len(), 100 * 25 * 4);
|
||||
// Already the box: no work, and no drift.
|
||||
assert_eq!(resample_filling(&f, 128, 64), f.rgba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_crops_rather_than_stretching() {
|
||||
// The property that separates fill from handing the box straight to
|
||||
// `resample`. The test frame ramps red left to right, so a 2:1 source
|
||||
// squeezed into a square would compress that ramp into the full
|
||||
// width — where a centre crop keeps its middle, and therefore starts
|
||||
// and ends well inside the source's own range.
|
||||
let f = frame(128, 128);
|
||||
let square = resample(&f, 64, 64);
|
||||
let filled = resample_filling(&f, 32, 64);
|
||||
|
||||
let left = |b: &[u8]| b[0];
|
||||
let right = |b: &[u8], w: usize| b[(w - 1) * 4];
|
||||
|
||||
assert!(
|
||||
left(&filled) > left(&square),
|
||||
"a centre crop must start further into the ramp"
|
||||
);
|
||||
assert!(
|
||||
right(&filled, 32) < right(&square, 64),
|
||||
"a centre crop must end further from the ramp's end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_takes_the_overhang_evenly_off_both_sides() {
|
||||
// Centred, not anchored: the crop tool is where a photographer decides
|
||||
// which part of the frame survives, and this stage must not have an
|
||||
// opinion of its own.
|
||||
let f = frame(128, 128);
|
||||
let filled = resample_filling(&f, 32, 64);
|
||||
let px = |x: usize| filled[x * 4];
|
||||
// The ramp is horizontal, so a centred crop is symmetric about the
|
||||
// frame's own midpoint: the two ends should sit equally far from it.
|
||||
let mid = i32::from(resample(&f, 128, 128)[64 * 4]);
|
||||
let lo = i32::from(px(0));
|
||||
let hi = i32::from(px(31));
|
||||
assert!(
|
||||
((mid - lo) - (hi - mid)).abs() < 8,
|
||||
"not centred: {lo} .. {mid} .. {hi}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_field_survives_a_fill_unchanged() {
|
||||
// Same guard as the fit path: any deviation means the cover scale and
|
||||
// the crop disagree about where the pixels are.
|
||||
let flat = Frame::new(64, 48, vec![200; 64 * 48 * 4]).unwrap();
|
||||
for byte in resample_filling(&flat, 30, 30) {
|
||||
assert_eq!(byte, 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscaling_is_refused_by_clamping_rather_than_failing() {
|
||||
// FR-EXP-3: opt-in, and a batch must not abort over one small frame.
|
||||
|
||||
@@ -17,7 +17,8 @@ pub use colour::{Chromaticities, Transfer};
|
||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||
pub use settings::{
|
||||
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
||||
ExportTarget, ImportSettings, LibrarySettings, OutputSharpening, Settings, SizingMode,
|
||||
ExportTarget, ImportSettings, LibrarySettings, OutputSharpening, ScreenSize, Settings,
|
||||
SizingMode,
|
||||
};
|
||||
pub use time::{
|
||||
civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil,
|
||||
|
||||
@@ -642,10 +642,75 @@ pub enum SizingMode {
|
||||
LongEdge(u32),
|
||||
/// Pixels on the shorter dimension; aspect preserved.
|
||||
ShortEdge(u32),
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Scaled to fit inside `width × height`; aspect preserved, so the result
|
||||
/// is smaller than the box on one axis unless the crop already matches it.
|
||||
///
|
||||
/// The safe half of the pair: nothing is thrown away and nothing is
|
||||
/// stretched. Wanted where the box is a ceiling — an upload limit, a
|
||||
/// mail attachment, a folder of images that must all fit one envelope.
|
||||
FitBox(u32, u32),
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Scaled to cover `width × height` and cut back to exactly those pixels.
|
||||
///
|
||||
/// The one mode that guarantees the file's dimensions rather than merely
|
||||
/// bounding them, which is what a display that refuses anything else
|
||||
/// needs — a television's art mode, a digital frame, a wallpaper slot. The
|
||||
/// price is the overhang, taken off the middle: a photograph whose crop is
|
||||
/// not already the box's shape loses two strips of it.
|
||||
///
|
||||
/// Pair it with a crop locked to the same ratio and nothing is lost at
|
||||
/// all, which is the workflow the two features are meant to be used in.
|
||||
FillBox(u32, u32),
|
||||
/// A factor of the source, as a percentage.
|
||||
Percentage(u32),
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// A display whose panel size is worth having as a button.
|
||||
///
|
||||
/// These fill in both numbers of a box mode at once. The list is short and
|
||||
/// deliberately made of *panels* rather than of platforms: a screen has one
|
||||
/// exact pixel count for ever, where "what a photo site wants" is a moving
|
||||
/// target that would rot in this file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScreenSize {
|
||||
pub label: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl ScreenSize {
|
||||
/// The panels the settings page offers, in ascending order.
|
||||
///
|
||||
/// 4K UHD is the one that prompted the list. A television art mode — the
|
||||
/// Samsung Frame's among them — accepts one resolution and rejects
|
||||
/// everything else, so "3840 × 2160, exactly" is a thing a photographer
|
||||
/// needs to be able to ask for without counting pixels by hand.
|
||||
pub const ALL: [Self; 4] = [
|
||||
Self {
|
||||
label: "Full HD",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
Self {
|
||||
label: "QHD",
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
},
|
||||
Self {
|
||||
label: "4K UHD",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
},
|
||||
Self {
|
||||
label: "5K",
|
||||
width: 5120,
|
||||
height: 2880,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
impl SizingMode {
|
||||
/// The mode's name, without its value.
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -653,15 +718,33 @@ impl SizingMode {
|
||||
Self::Original => "Original",
|
||||
Self::LongEdge(_) => "Long edge",
|
||||
Self::ShortEdge(_) => "Short edge",
|
||||
Self::FitBox(..) => "Fit box",
|
||||
Self::FillBox(..) => "Fill box",
|
||||
Self::Percentage(_) => "Percentage",
|
||||
}
|
||||
}
|
||||
|
||||
/// The number this mode carries, if it takes one.
|
||||
///
|
||||
/// For a box mode this is the width; [`Self::height`] is the other half.
|
||||
pub fn value(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Original => None,
|
||||
Self::LongEdge(n) | Self::ShortEdge(n) | Self::Percentage(n) => Some(n),
|
||||
Self::FitBox(w, _) | Self::FillBox(w, _) => Some(w),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// The second number, for the modes that take two.
|
||||
///
|
||||
/// Separate from [`Self::value`] rather than folded into it as a pair,
|
||||
/// because the page needs to ask "is there a second field to draw?" and
|
||||
/// every other mode's honest answer is no.
|
||||
pub fn height(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::FitBox(_, h) | Self::FillBox(_, h) => Some(h),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,20 +757,53 @@ impl SizingMode {
|
||||
Self::Original => Self::Original,
|
||||
Self::LongEdge(_) => Self::LongEdge(value),
|
||||
Self::ShortEdge(_) => Self::ShortEdge(value),
|
||||
Self::FitBox(_, h) => Self::FitBox(value, h),
|
||||
Self::FillBox(_, h) => Self::FillBox(value, h),
|
||||
Self::Percentage(_) => Self::Percentage(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// The same mode carrying `height`, or unchanged where it takes only one
|
||||
/// number.
|
||||
pub fn with_height(self, height: u32) -> Self {
|
||||
match self {
|
||||
Self::FitBox(w, _) => Self::FitBox(w, height),
|
||||
Self::FillBox(w, _) => Self::FillBox(w, height),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// The same mode carrying both numbers, for the screen presets.
|
||||
pub fn with_box(self, width: u32, height: u32) -> Self {
|
||||
self.with_value(width).with_height(height)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Whether this mode cuts the overhang away to hit its numbers exactly.
|
||||
///
|
||||
/// The one question the export path has to ask about a mode, and the one
|
||||
/// place the fit/fill distinction means anything: everything else treats
|
||||
/// the resolved size the same way.
|
||||
pub fn crops_to_fill(self) -> bool {
|
||||
matches!(self, Self::FillBox(..))
|
||||
}
|
||||
|
||||
/// The modes in page order, each with a usable starting value.
|
||||
///
|
||||
/// Switching to a sized mode has to land on *something*, and these are
|
||||
/// values a user would plausibly keep: 2048px is a common web long edge,
|
||||
/// and 100% is the identity, so choosing "Percentage" changes nothing
|
||||
/// until a number is typed.
|
||||
pub const CHOICES: [Self; 4] = [
|
||||
pub const CHOICES: [Self; 6] = [
|
||||
Self::Original,
|
||||
Self::LongEdge(2048),
|
||||
Self::ShortEdge(1600),
|
||||
// Both box modes open on 4K UHD, which is the panel the presets exist
|
||||
// for and a size no photographer would call surprising.
|
||||
Self::FitBox(3840, 2160),
|
||||
Self::FillBox(3840, 2160),
|
||||
Self::Percentage(100),
|
||||
];
|
||||
|
||||
@@ -788,6 +904,11 @@ impl Settings {
|
||||
SizingMode::LongEdge(0) => SizingMode::LongEdge(1),
|
||||
SizingMode::ShortEdge(0) => SizingMode::ShortEdge(1),
|
||||
SizingMode::Percentage(0) => SizingMode::Percentage(1),
|
||||
// Either axis of a box, independently: a hand-edited `[3840, 0]`
|
||||
// is a box with no area, and the axis that was written is still
|
||||
// the one the user meant.
|
||||
SizingMode::FitBox(w, h) => SizingMode::FitBox(w.max(1), h.max(1)),
|
||||
SizingMode::FillBox(w, h) => SizingMode::FillBox(w.max(1), h.max(1)),
|
||||
other => other,
|
||||
};
|
||||
|
||||
@@ -867,6 +988,65 @@ pub mod budget {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_screen_preset_is_a_size_a_panel_actually_has() {
|
||||
// Guards a list nobody re-reads against a typo. Each entry is a real
|
||||
// panel: landscape, and an even number of pixels on both axes.
|
||||
for s in ScreenSize::ALL {
|
||||
assert!(s.width > s.height, "{} is not a landscape panel", s.label);
|
||||
assert_eq!(s.width % 2, 0, "{} has an odd width", s.label);
|
||||
assert_eq!(s.height % 2, 0, "{} has an odd height", s.label);
|
||||
assert!(!s.label.is_empty(), "a preset with no name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_mode_carries_both_numbers_and_edits_them_separately() {
|
||||
// What the page needs: two fields beside one mode chip, each editing
|
||||
// its own axis without re-deciding the variant or clearing the other.
|
||||
let mode = SizingMode::FillBox(3840, 2160);
|
||||
assert_eq!(mode.value(), Some(3840));
|
||||
assert_eq!(mode.height(), Some(2160));
|
||||
assert_eq!(mode.with_value(1920), SizingMode::FillBox(1920, 2160));
|
||||
assert_eq!(mode.with_height(1080), SizingMode::FillBox(3840, 1080));
|
||||
assert_eq!(mode.with_box(2560, 1440), SizingMode::FillBox(2560, 1440));
|
||||
// The variant is what decides fit or fill, and setting a number must
|
||||
// never quietly change it.
|
||||
assert!(mode.with_box(2560, 1440).crops_to_fill());
|
||||
assert!(!SizingMode::FitBox(3840, 2160).crops_to_fill());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_box_mode_has_a_second_number() {
|
||||
// The question the page asks to decide whether to draw a height field
|
||||
// at all. A "Percentage" mode answering `Some` would draw one.
|
||||
for mode in [
|
||||
SizingMode::Original,
|
||||
SizingMode::LongEdge(2048),
|
||||
SizingMode::ShortEdge(1600),
|
||||
SizingMode::Percentage(100),
|
||||
] {
|
||||
assert_eq!(mode.height(), None, "{mode:?} claimed a second number");
|
||||
assert_eq!(mode.with_height(99), mode, "{mode:?} took one anyway");
|
||||
assert!(!mode.crops_to_fill(), "{mode:?} should not crop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_with_a_zero_axis_is_nudged_rather_than_reset() {
|
||||
// Same rule as the single-value modes: the user wanted "small", and
|
||||
// silently restoring the default would ignore that. Each axis is
|
||||
// repaired on its own, so the one that was written survives.
|
||||
let mut s = Settings::default();
|
||||
s.export.sizing = SizingMode::FillBox(3840, 0);
|
||||
s.sanitise();
|
||||
assert_eq!(s.export.sizing, SizingMode::FillBox(3840, 1));
|
||||
|
||||
s.export.sizing = SizingMode::FitBox(0, 2160);
|
||||
s.sanitise();
|
||||
assert_eq!(s.export.sizing, SizingMode::FitBox(1, 2160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_round_trip_through_json() {
|
||||
let settings = Settings::default();
|
||||
|
||||
+27
-27
File diff suppressed because one or more lines are too long
@@ -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