//! 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, /// Which entry of the survey is showing the canvas. current: Cell, seen: Cell>, /// 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 { 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 { // 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 = 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, viewport: &Rc>, redraw: Rc, ) { // 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)); } }