FR-DSP-8 asks for a fallback that is *defined*, and a reason that reaches the code but never the screen satisfies half of it. The three readouts are now built by a free function over the survey rather than written straight into the window, so a test can assert that "sRGB assumed" arrives with the reason attached, that an approximated profile says "nearest to" rather than claiming the space, and that the second monitor is described on the page read from the first — which is the display the requirement is actually about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
412 lines
17 KiB
Rust
412 lines
17 KiB
Rust
//! TRACES: FR-DSP-8
|
||
//! Following the canvas from one display to the next.
|
||
//!
|
||
//! `dr_plat::display` establishes what each display *is*. This decides which
|
||
//! of them is showing the canvas right now, keeps the develop session's output
|
||
//! space pointed at it, and renders at that display's physical pixel size
|
||
//! rather than its logical one.
|
||
//!
|
||
//! # Why a poll and not a callback
|
||
//!
|
||
//! Slint reports a window's size and its scale factor and does not report that
|
||
//! it has moved — there is no `on_moved`, on any backend. What there is, is
|
||
//! `Window::position()`, so this samples it. A sample is three integers
|
||
//! compared against three integers and happens twice a second; a monitor is
|
||
//! changed at human speed, and nothing here runs on the frame path.
|
||
//!
|
||
//! The re-survey is what costs something — a Wayland roundtrip pair, or an X11
|
||
//! property fetch — so it is deliberately *not* done every tick. It runs when
|
||
//! the geometry actually changed, which is also the moment a monitor could
|
||
//! have been plugged in or unplugged: both of those move or rescale the window
|
||
//! on every desktop that does anything sensible.
|
||
|
||
use std::cell::{Cell, RefCell};
|
||
use std::rc::Rc;
|
||
|
||
use dr_plat::DisplaySurvey;
|
||
use slint::ComponentHandle;
|
||
|
||
use crate::AppWindow;
|
||
|
||
/// How often the window's geometry is sampled.
|
||
const POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||
|
||
/// The render-target size for a canvas that occupies `logical` logical pixels
|
||
/// on a display scaled by `scale`.
|
||
///
|
||
/// # FR-DSP-8's scaling clause, in one multiplication
|
||
///
|
||
/// "Fractional and mixed DPI scaling are handled without resampling artefacts
|
||
/// in the canvas." The canvas is a `wgpu::Texture` handed straight to the
|
||
/// compositor (ARCH §6.1), which draws it into a box measured in *logical*
|
||
/// pixels. Render 1600 logical pixels wide onto a display at 1.25 and the
|
||
/// compositor has 1600 samples to fill 2000 device pixels, so every frame is
|
||
/// resampled up by a quarter — a softness that reads like a bad demosaic
|
||
/// rather than like a scaling bug, which is exactly why the requirement calls
|
||
/// it out and why it is worth an explicit test.
|
||
///
|
||
/// Rendering the physical count instead makes the presentation 1:1. It costs
|
||
/// what the extra pixels cost — 56% more work at 1.25 — and that cost is the
|
||
/// requirement: the alternative is not cheaper, it is blurrier.
|
||
///
|
||
/// A `scale` of zero or worse is clamped rather than trusted. It arrives from
|
||
/// the windowing system, and a zero would collapse the render target to one
|
||
/// pixel and blank the canvas.
|
||
pub(crate) fn physical(logical: (u32, u32), scale: f32) -> (u32, u32) {
|
||
let scale = if scale.is_finite() && scale > 0.01 {
|
||
scale
|
||
} else {
|
||
1.0
|
||
};
|
||
let convert = |v: u32| ((v as f32) * scale).round().max(1.0) as u32;
|
||
(convert(logical.0), convert(logical.1))
|
||
}
|
||
|
||
/// The geometry the window had when the display was last resolved.
|
||
///
|
||
/// Position *and* scale factor, because either changing can mean a different
|
||
/// display: dragging across a seam moves the window, and a compositor that
|
||
/// rescales in place — a fractional-scaling change, or a monitor unplugged
|
||
/// from under a window — changes only the second.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
struct Geometry {
|
||
x: i32,
|
||
y: i32,
|
||
scale_milli: u32,
|
||
}
|
||
|
||
impl Geometry {
|
||
fn of(window: &AppWindow) -> Self {
|
||
let position = window.window().position();
|
||
Self {
|
||
x: position.x,
|
||
y: position.y,
|
||
// Compared as thousandths rather than as a float: this is an
|
||
// equality test running twice a second, and a scale factor that
|
||
// differs in its last bit must not read as a display change and
|
||
// provoke a re-survey on every tick.
|
||
scale_milli: (window.window().scale_factor().max(0.0) * 1000.0) as u32,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Everything the window needs to keep the canvas on the right display.
|
||
pub(crate) struct DisplayWatch {
|
||
survey: RefCell<DisplaySurvey>,
|
||
/// Which entry of the survey is showing the canvas.
|
||
current: Cell<usize>,
|
||
seen: Cell<Option<Geometry>>,
|
||
/// The canvas's size in *logical* pixels, as Slint last reported it.
|
||
///
|
||
/// Kept so that a scale-factor change can re-derive the physical size
|
||
/// without waiting for a resize that may never come: moving a window
|
||
/// between a 1× and a 2× display changes what to render and not how large
|
||
/// the box is.
|
||
logical_canvas: Cell<(u32, u32)>,
|
||
}
|
||
|
||
impl DisplayWatch {
|
||
/// Ask the platform once, at startup.
|
||
pub(crate) fn probe() -> Rc<Self> {
|
||
let survey = DisplaySurvey::probe();
|
||
log::info!(
|
||
"display server: {}, {} display(s)",
|
||
survey.server.label(),
|
||
survey.displays.len()
|
||
);
|
||
for display in &survey.displays {
|
||
log::info!(" {}: {}", display.name, display.profile.describe());
|
||
}
|
||
Rc::new(Self {
|
||
survey: RefCell::new(survey),
|
||
current: Cell::new(0),
|
||
seen: Cell::new(None),
|
||
logical_canvas: Cell::new((1024, 768)),
|
||
})
|
||
}
|
||
|
||
/// The output space the canvas should be encoded into right now.
|
||
pub(crate) fn space(&self) -> dr_types::ColourSpace {
|
||
self.survey.borrow().profile(self.current.get()).space
|
||
}
|
||
|
||
/// Record the canvas's logical size and return the size to render at.
|
||
pub(crate) fn canvas_resized(&self, window: &AppWindow, logical: (u32, u32)) -> (u32, u32) {
|
||
self.logical_canvas.set(logical);
|
||
physical(logical, window.window().scale_factor())
|
||
}
|
||
|
||
/// The size the canvas should be rendered at, from the last known logical
|
||
/// size and the current scale factor.
|
||
fn canvas_physical(&self, window: &AppWindow) -> (u32, u32) {
|
||
physical(self.logical_canvas.get(), window.window().scale_factor())
|
||
}
|
||
|
||
/// Push the About-page readouts.
|
||
fn publish(&self, window: &AppWindow) {
|
||
let Some(readouts) = readouts(&self.survey.borrow(), self.current.get()) else {
|
||
return;
|
||
};
|
||
window.set_display_name(readouts.name.into());
|
||
window.set_display_colour(readouts.colour.into());
|
||
window.set_display_others(readouts.others.into());
|
||
}
|
||
|
||
/// Re-ask the platform and re-resolve which display is showing the canvas.
|
||
///
|
||
/// Returns whether the output space changed, which is what decides a
|
||
/// redraw. Nothing is pushed into the develop session from here: the
|
||
/// session is asked for its space on the way into every render (see
|
||
/// `render_now` in the crate root), so "the canvas is composed for the
|
||
/// display showing it" is structural rather than something a callback has
|
||
/// to remember to do. A session opened while the window sits on the second
|
||
/// monitor is then right on its first frame, without this having to know
|
||
/// that a photograph was opened at all.
|
||
fn resolve(&self, window: &AppWindow) -> bool {
|
||
let before = self.space();
|
||
*self.survey.borrow_mut() = DisplaySurvey::probe();
|
||
|
||
// The window's *centre*, in the display server's own screen
|
||
// coordinates. A window straddling a seam is showing more of itself on
|
||
// one side, and its centre says which — where its top-left corner
|
||
// would flip the transform as soon as one pixel crossed.
|
||
let position = window.window().position();
|
||
let size = window.window().size();
|
||
let centre_x = position.x.saturating_add_unsigned(size.width / 2);
|
||
let centre_y = position.y.saturating_add_unsigned(size.height / 2);
|
||
|
||
let index = self.survey.borrow().containing(centre_x, centre_y);
|
||
self.current.set(index);
|
||
self.publish(window);
|
||
self.space() != before
|
||
}
|
||
}
|
||
|
||
/// The three strings the About page shows about the session's colour path.
|
||
pub(crate) struct Readouts {
|
||
pub name: String,
|
||
pub colour: String,
|
||
pub others: String,
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8
|
||
/// Turn a survey into what the About page says.
|
||
///
|
||
/// A free function over the survey rather than a method that writes into the
|
||
/// window, because the requirement's visibility clause is the part worth
|
||
/// asserting: FR-DSP-8 asks for a fallback that is *defined*, and a reason
|
||
/// that reaches the code but never the screen satisfies half of that. This is
|
||
/// where a test can hold the strings.
|
||
///
|
||
/// `None` only for a survey with no displays at all, which `probe` does not
|
||
/// produce.
|
||
pub(crate) fn readouts(survey: &DisplaySurvey, index: usize) -> Option<Readouts> {
|
||
// Clamped rather than indexed: a monitor unplugged between the survey and
|
||
// this call leaves an index pointing past the end, and the page must
|
||
// describe *a* display rather than nothing.
|
||
let index = if index < survey.displays.len() {
|
||
index
|
||
} else {
|
||
0
|
||
};
|
||
let here = survey.displays.get(index)?;
|
||
|
||
// The other monitors, listed rather than hidden. FR-DSP-8 is a
|
||
// multi-monitor requirement and the failure it names — the second display
|
||
// showing wrong colours — is invisible from the first, so a page that
|
||
// described only the display it was being read on would report nothing
|
||
// about the case that matters.
|
||
let others: Vec<String> = survey
|
||
.displays
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, _)| *i != index)
|
||
.map(|(_, d)| format!("{}: {}", d.name, d.profile.describe()))
|
||
.collect();
|
||
|
||
Some(Readouts {
|
||
name: format!("{} ({})", here.name, survey.server.label()),
|
||
colour: here.profile.describe(),
|
||
others: others.join(" · "),
|
||
})
|
||
}
|
||
|
||
/// Start following the window's display, and answer once immediately.
|
||
///
|
||
/// `redraw` is the window's ordinary re-render, so a display change costs
|
||
/// exactly one recomposition and one dispatch — the property the fused design
|
||
/// was always going to give us here (`compose_with_framing`'s output space is
|
||
/// a parameter, and enters the structure hash).
|
||
pub(crate) fn attach(
|
||
window: &AppWindow,
|
||
watch: &Rc<DisplayWatch>,
|
||
viewport: &Rc<RefCell<(u32, u32)>>,
|
||
redraw: Rc<dyn Fn(&AppWindow)>,
|
||
) {
|
||
// The first answer, before any frame is drawn. Without it the canvas's
|
||
// first render is sRGB on every desktop and corrects itself half a second
|
||
// later, which on a wide-gamut panel is a visible flash of the wrong
|
||
// colour on every image opened.
|
||
watch.seen.set(Some(Geometry::of(window)));
|
||
watch.resolve(window);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let watch = watch.clone();
|
||
let viewport = viewport.clone();
|
||
timer.start(slint::TimerMode::Repeated, POLL, move || {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
let now = Geometry::of(&window);
|
||
if watch.seen.get() == Some(now) {
|
||
return;
|
||
}
|
||
watch.seen.set(Some(now));
|
||
|
||
// The physical size follows the scale factor, so a move onto a
|
||
// differently-scaled display changes what to render as well as what
|
||
// to render it into (FR-DSP-8's two halves arriving together).
|
||
let size = watch.canvas_physical(&window);
|
||
let resized = *viewport.borrow() != size;
|
||
if resized {
|
||
*viewport.borrow_mut() = size;
|
||
}
|
||
|
||
if watch.resolve(&window) || resized {
|
||
redraw(&window);
|
||
}
|
||
});
|
||
// The timer stops when it is dropped, and it must outlive this function.
|
||
// Leaked deliberately rather than threaded through the window's state:
|
||
// it lives exactly as long as the process, and there is nothing that
|
||
// could sensibly stop it.
|
||
std::mem::forget(timer);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use dr_plat::{Bounds, DisplayInfo, DisplayProfile, DisplayServer, FallbackReason};
|
||
|
||
fn two_monitors() -> DisplaySurvey {
|
||
DisplaySurvey {
|
||
server: DisplayServer::X11,
|
||
displays: vec![
|
||
DisplayInfo {
|
||
name: "eDP-1".to_string(),
|
||
bounds: Some(Bounds {
|
||
x: 0,
|
||
y: 0,
|
||
width: 1920,
|
||
height: 1200,
|
||
}),
|
||
profile: DisplayProfile::fallback(FallbackReason::NoWaylandProtocol),
|
||
},
|
||
DisplayInfo {
|
||
name: "DP-2".to_string(),
|
||
bounds: Some(Bounds {
|
||
x: 1920,
|
||
y: 0,
|
||
width: 2560,
|
||
height: 1440,
|
||
}),
|
||
profile: DisplayProfile {
|
||
space: dr_types::ColourSpace::DisplayP3,
|
||
source: dr_plat::ProfileSource::X11RootProperty(
|
||
"_ICC_PROFILE_1".to_string(),
|
||
),
|
||
described_as: Some("EIZO CG279X".to_string()),
|
||
approximated: true,
|
||
},
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8
|
||
/// The fallback is defined *and visible*, which is what the requirement
|
||
/// asks for and the half that is easy to leave out.
|
||
#[test]
|
||
fn the_about_page_says_which_acquisition_path_the_session_is_on() {
|
||
let survey = two_monitors();
|
||
let shown = readouts(&survey, 0).expect("a display");
|
||
|
||
assert!(shown.name.contains("eDP-1"), "{}", shown.name);
|
||
assert!(shown.name.contains("X11"), "{}", shown.name);
|
||
// A photographer being shown sRGB because the compositor would not say
|
||
// otherwise must be able to find that out rather than wonder.
|
||
assert!(shown.colour.contains("sRGB"), "{}", shown.colour);
|
||
assert!(shown.colour.contains("assumed"), "{}", shown.colour);
|
||
assert!(
|
||
shown.colour.contains("colour management"),
|
||
"the reason for the fallback did not reach the page: {}",
|
||
shown.colour
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8
|
||
/// The second monitor is described on the page read on the first.
|
||
///
|
||
/// The requirement's whole subject is the display the reader is *not*
|
||
/// looking at: "showing wrong colours on the second display is a
|
||
/// correctness defect". A page that listed only the current one would say
|
||
/// nothing about the case it exists for.
|
||
#[test]
|
||
fn the_other_monitor_is_named_on_the_page_read_from_this_one() {
|
||
let survey = two_monitors();
|
||
let shown = readouts(&survey, 0).expect("a display");
|
||
assert!(shown.others.contains("DP-2"), "{}", shown.others);
|
||
assert!(shown.others.contains("Display P3"), "{}", shown.others);
|
||
// Approximated, and saying so. An honest approximation the user can
|
||
// see is the whole trade made in `dr_plat::display`.
|
||
assert!(shown.others.contains("nearest to"), "{}", shown.others);
|
||
assert!(shown.others.contains("EIZO CG279X"), "{}", shown.others);
|
||
|
||
// And from the second monitor's point of view, the first is the other.
|
||
let shown = readouts(&survey, 1).expect("a display");
|
||
assert!(shown.colour.contains("Display P3"), "{}", shown.colour);
|
||
assert!(shown.others.contains("eDP-1"), "{}", shown.others);
|
||
}
|
||
|
||
/// A single-monitor desktop leaves the row empty rather than repeating
|
||
/// itself, which is what makes the row conditional in `settings.slint`.
|
||
#[test]
|
||
fn one_display_has_no_others_to_list() {
|
||
let survey = DisplaySurvey::assumed_srgb(FallbackReason::NoWaylandProtocol);
|
||
let shown = readouts(&survey, 0).expect("a display");
|
||
assert!(shown.others.is_empty(), "{}", shown.others);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8
|
||
/// Fractional scaling renders more pixels, not the same pixels stretched.
|
||
#[test]
|
||
fn a_fractionally_scaled_canvas_is_rendered_at_its_physical_size() {
|
||
// GNOME's fractional steps, against a canvas of a plausible size. The
|
||
// failure being guarded is silent: at 1.25 the compositor is handed
|
||
// 1600 samples for 2000 device pixels and the photograph goes soft.
|
||
assert_eq!(physical((1600, 1000), 1.25), (2000, 1250));
|
||
assert_eq!(physical((1600, 1000), 1.5), (2400, 1500));
|
||
assert_eq!(physical((1600, 1000), 1.75), (2800, 1750));
|
||
assert_eq!(physical((1600, 1000), 2.0), (3200, 2000));
|
||
}
|
||
|
||
#[test]
|
||
fn an_unscaled_display_renders_exactly_what_it_is_asked_for() {
|
||
// The other half of the same requirement: 1:1 must stay 1:1 rather
|
||
// than acquiring a rounding error that resamples every frame by a
|
||
// fraction of a pixel.
|
||
assert_eq!(physical((1923, 1081), 1.0), (1923, 1081));
|
||
}
|
||
|
||
#[test]
|
||
fn a_nonsense_scale_factor_does_not_blank_the_canvas() {
|
||
// It comes from the windowing system, and a zero would collapse the
|
||
// render target to a single pixel with no error anywhere.
|
||
assert_eq!(physical((800, 600), 0.0), (800, 600));
|
||
assert_eq!(physical((800, 600), f32::NAN), (800, 600));
|
||
assert_eq!(physical((800, 600), -2.0), (800, 600));
|
||
// And a canvas that has been collapsed to nothing by a dragged
|
||
// splitter still has to produce a renderable target.
|
||
assert_eq!(physical((0, 0), 2.0), (1, 1));
|
||
}
|
||
}
|