diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/core/src/monitor.rs | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip | |
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has
diverged with its own separate fixes today. Nothing here is reviewed
or curated yet - this exists purely so none of this work can be lost
to a git operation, disk issue, or worktree cleanup while that
reconciliation happens.
Diffstat (limited to 'crates/core/src/monitor.rs')
| -rw-r--r-- | crates/core/src/monitor.rs | 239 |
1 files changed, 238 insertions, 1 deletions
diff --git a/crates/core/src/monitor.rs b/crates/core/src/monitor.rs index 01f54cb..6ea053f 100644 --- a/crates/core/src/monitor.rs +++ b/crates/core/src/monitor.rs @@ -42,10 +42,247 @@ pub struct Monitor { pub name: String, pub refresh_rate_mhz: u32, pub primary: bool, + /// `true` when this entry is one part of a real output divided by + /// `srd.monitor.split` - not a second `wl_output`, not a second + /// physical connector. A display-arrangement UI reads this to tell a + /// split part apart from a genuinely separate monitor, so it does not + /// offer to move or extend a physical arrangement onto something that + /// is not a real, independent output. `false` for an ordinary, + /// undivided output. + pub split: bool, + /// This output's real scale factor (automatic, from `srdwm_core:: + /// monitor::auto_scale_for`, or an explicit `srd.monitor.scale` + /// override) - `1.0` for an unscaled output. Every other field on + /// this struct (`geometry`, `full_geometry`, `maximize_geometry`) is + /// in *physical* pixels, not the logical points a Wayland client + /// itself sees; a caller that needs to convert between the two (a + /// display-arrangement UI chaining outputs by their reported size, + /// for instance) multiplies logical by this to get physical, or + /// divides physical by this to get logical. Requested directly by the + /// AGS peer session after a real bug (`srd dispatch set output + /// position` and this compositor's own physical-pixel bookkeeping + /// silently disagreeing with a client's logical one at any scale + /// other than `1.0`) traced back to exactly this missing piece of + /// information. + pub scale: f64, } impl Monitor { pub fn new(id: MonitorId, name: impl Into<String>, geometry: Rect) -> Self { - Self { id, name: name.into(), geometry, full_geometry: geometry, maximize_geometry: geometry, refresh_rate_mhz: 60_000, primary: false } + Self { id, name: name.into(), geometry, full_geometry: geometry, maximize_geometry: geometry, refresh_rate_mhz: 60_000, primary: false, split: false, scale: 1.0 } + } +} + +/// A connector a backend has administratively disabled (`srd dispatch set +/// output enabled <name> false`) but that's still physically connected -- +/// purely informational, reported by the backend via `WindowManager:: +/// set_disabled_monitor` for `srd monitors`/the `monitors` subscribe event +/// to list (so a display-settings UI can offer to turn it back on by +/// name), and deliberately never fed into `WindowManager::monitors()` or +/// any real placement/tiling logic, which continues to see only genuinely +/// live outputs exactly as before this existed. Geometry is a last-known +/// snapshot from the moment it was disabled - stale by construction, and +/// meant to be: a caller wanting to reposition it correctly re-queries +/// once it's actually re-enabled, not from this. +#[derive(Debug, Clone)] +pub struct DisabledMonitor { + pub geometry: Rect, + pub full_geometry: Rect, + pub primary: bool, +} + +/// A `srd.monitor.split(name, parts, direction)` config-time request: +/// divide one real output into `parts` equal (within a pixel) logical +/// [`Monitor`] entries, so placement/tiling can treat them as separate +/// screens without any DRM/`wl_output` involvement - see `split_rect`'s +/// own doc comment for the actual division, and the udev platform's +/// `monitors()` for where this turns into real `Monitor` entries. +/// +/// Deliberately just a division of one real output's rectangle for +/// placement purposes, not a second `wl_output` global - a client +/// fullscreening or querying `wl_output.enter`/scale for a specific +/// sub-region still sees it as part of the one real output. See the +/// "different monitors mode in one" plan for why that's an accepted, +/// explicitly-flagged limitation of this first version. +#[derive(Debug, Clone, Copy)] +pub struct MonitorSplit { + pub parts: u32, + /// `false` (the default): side-by-side columns, splitting width. + /// `true`: stacked rows, splitting height. + pub rows: bool, +} + +/// Divides `rect` into `parts` equal (within one pixel) pieces along one +/// axis, returning piece number `index` (`0..parts`). `rows` chooses which +/// axis: stacked rows (splitting height) when `true`, side-by-side columns +/// (splitting width) when `false`. +/// +/// Any remainder from an uneven division is spread one pixel at a time +/// across the first `remainder` pieces, rather than dumped entirely onto +/// the last one - so a 1919px-wide monitor split into 2 columns yields +/// 960/959, not a lopsided 959/960 vs. a naive 959/960-plus-slack-on-one- +/// side that would leave one part visibly wider for no reason tied to the +/// actual pixel count. +/// +/// `index >= parts` or `parts == 0` returns `rect` unchanged - callers +/// are expected to only iterate `0..parts.max(1)`, this is just a safe +/// fallback rather than a panic for a config-driven value. +pub fn split_rect(rect: Rect, index: u32, parts: u32, rows: bool) -> Rect { + if parts <= 1 || index >= parts { + return rect; + } + let total = if rows { rect.height } else { rect.width }; + let other = if rows { rect.width } else { rect.height }; + let base = total / parts; + let remainder = total % parts; + let size_for = |i: u32| base + if i < remainder { 1 } else { 0 }; + let offset: u32 = (0..index).map(size_for).sum(); + let size = size_for(index); + if rows { + Rect::new(rect.x, rect.y + offset as i32, other, size) + } else { + Rect::new(rect.x + offset as i32, rect.y, size, other) + } +} + +/// The pixel density (in real, physical-size terms) srdwm treats as +/// needing no scale correction at all. `92`, close to the classic desktop +/// "96 DPI" constant - lowered from an initial `109` (roughly a 24" +/// 1920x1080 or 27" 2560x1440 monitor) after live testing on a real 1080p +/// monitor at ~78 PPI: `109` produced a `0.71` scale there, reported as +/// too aggressive a shrink; `92` produces `~0.85`, still a real reduction +/// but closer to what actually reads as "more space", not "suddenly tiny +/// text". +const REFERENCE_PPI: f64 = 92.0; + +/// Automatically derives an output scale from real EDID physical size and +/// native resolution, with no monitor name or fixed size bucket involved +/// anywhere - a large panel with low pixel density (a big monitor at the +/// same resolution as a much smaller one, the concrete case this exists +/// for) gets scaled down smoothly in proportion to how far its real PPI +/// falls below [`REFERENCE_PPI`], clamped to `0.5` so a pathologically +/// large/low-res panel doesn't shrink text into illegibility. Deliberately +/// never scales *above* `1.0` on its own - a high-density panel already +/// benefits from more detail, not less, and plenty of people want native +/// crispness there; `srd.monitor.scale` remains the explicit, manual way +/// to opt into upscaling a specific connector. +/// +/// `physical_mm` of `(0, 0)` (no EDID physical-size descriptor at all -- +/// some VMs/adapters report this) returns `1.0` rather than guessing from +/// nothing. +pub fn auto_scale_for(physical_mm: (i32, i32), resolution_px: (i32, i32)) -> f64 { + let (pw, ph) = physical_mm; + if pw <= 0 || ph <= 0 { + return 1.0; + } + let diagonal_mm = ((pw as f64).powi(2) + (ph as f64).powi(2)).sqrt(); + let diagonal_in = diagonal_mm / 25.4; + let (rw, rh) = resolution_px; + let diagonal_px = ((rw as f64).powi(2) + (rh as f64).powi(2)).sqrt(); + let ppi = diagonal_px / diagonal_in; + if ppi >= REFERENCE_PPI { + 1.0 + } else { + (ppi / REFERENCE_PPI).clamp(0.5, 1.0) + } +} + +#[cfg(test)] +mod auto_scale_tests { + use super::*; + + #[test] + fn a_15_inch_1080p_laptop_panel_needs_no_correction() { + // 340mm x 190mm, ~143 PPI - comfortably above the reference, and + // the concrete real-hardware case this must not regress: this + // laptop's own panel was already correct at 1.0. + assert_eq!(auto_scale_for((340, 190), (1920, 1080)), 1.0); + } + + #[test] + fn a_physically_large_1080p_monitor_scales_down() { + // 600mm x 400mm at the same 1920x1080 as the laptop above -- + // ~78 PPI, well under the reference. The concrete case this whole + // function exists for: reported live as "too big, should utilize + // greater real estate" on exactly this monitor. + let s = auto_scale_for((600, 400), (1920, 1080)); + assert!(s < 1.0 && s > 0.5, "expected a real scale-down, got {s}"); + } + + #[test] + fn a_high_density_panel_is_never_auto_upscaled() { + // A small, very high-resolution panel (e.g. a 13" 4K) - far above + // the reference PPI. Must clamp at 1.0, not scale past it. + assert_eq!(auto_scale_for((290, 170), (3840, 2160)), 1.0); + } + + #[test] + fn an_extreme_low_density_panel_clamps_at_half_scale() { + let s = auto_scale_for((2000, 1200), (1024, 768)); + assert_eq!(s, 0.5); + } + + #[test] + fn missing_physical_size_does_not_guess() { + assert_eq!(auto_scale_for((0, 0), (1920, 1080)), 1.0); + } +} + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn single_part_returns_the_whole_rect_unchanged() { + let r = Rect::new(0, 0, 1920, 1080); + assert_eq!(split_rect(r, 0, 1, false), r); + } + + #[test] + fn even_columns_split_width_with_no_gap_or_overlap() { + let r = Rect::new(100, 0, 1920, 1080); + let a = split_rect(r, 0, 2, false); + let b = split_rect(r, 1, 2, false); + assert_eq!(a, Rect::new(100, 0, 960, 1080)); + assert_eq!(b, Rect::new(1060, 0, 960, 1080)); + assert_eq!(a.right(), b.x, "no gap or overlap between adjacent parts"); + } + + #[test] + fn uneven_columns_spread_the_remainder_one_pixel_at_a_time() { + let r = Rect::new(0, 0, 1919, 1080); + let a = split_rect(r, 0, 2, false); + let b = split_rect(r, 1, 2, false); + assert_eq!(a.width, 960); + assert_eq!(b.width, 959); + assert_eq!(a.width + b.width, r.width); + assert_eq!(a.right(), b.x); + } + + #[test] + fn rows_split_height_and_leave_width_untouched() { + let r = Rect::new(0, 50, 1920, 1080); + let a = split_rect(r, 0, 2, true); + let b = split_rect(r, 1, 2, true); + assert_eq!(a, Rect::new(0, 50, 1920, 540)); + assert_eq!(b, Rect::new(0, 590, 1920, 540)); + assert_eq!(a.bottom(), b.y); + } + + #[test] + fn three_parts_covers_the_whole_rect_exactly() { + let r = Rect::new(0, 0, 1000, 500); + let parts: Vec<Rect> = (0..3).map(|i| split_rect(r, i, 3, false)).collect(); + let total_width: u32 = parts.iter().map(|p| p.width).sum(); + assert_eq!(total_width, r.width); + for w in parts.windows(2) { + assert_eq!(w[0].right(), w[1].x); + } + } + + #[test] + fn out_of_range_index_returns_the_whole_rect_unchanged() { + let r = Rect::new(0, 0, 1920, 1080); + assert_eq!(split_rect(r, 5, 2, false), r); } } |