⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/desktop/wayland/
output.rs

1//! Wayland Output Management (wl_output v4)
2//!
3//! Manages display outputs for multi-monitor support and HiDPI scaling.
4//! Each output represents a physical or virtual display with its own
5//! resolution, position, scale factor, and mode list.
6//!
7//! ## Multi-Monitor Layout
8//!
9//! Outputs are arranged in a global compositor coordinate space. Each
10//! output has an (x, y) position representing its top-left corner in
11//! this space. Adjacent outputs share edges (e.g., a right-side monitor
12//! has x = left_monitor.width). The compositor maps surfaces to outputs
13//! based on surface position overlap.
14//!
15//! ## HiDPI Scaling
16//!
17//! Each output has an integer scale factor (1 = normal, 2 = HiDPI/Retina).
18//! Surfaces rendered for a scaled output should produce pixels at
19//! `scale * logical_size`. The compositor handles downscaling when
20//! displaying on lower-scale outputs.
21//!
22//! ## Hotplug
23//!
24//! Outputs can be added and removed at runtime. When an output is added,
25//! a `wl_output` global is advertised to all connected clients. When
26//! removed, the global is withdrawn and any surfaces on that output
27//! should be moved to the primary output.
28
29#![allow(dead_code)]
30
31use alloc::{collections::BTreeMap, string::String, vec::Vec};
32use core::sync::atomic::{AtomicU32, Ordering};
33
34use crate::error::KernelError;
35
36// ---------------------------------------------------------------------------
37// Output transform
38// ---------------------------------------------------------------------------
39
40/// Output transform applied to the output's content.
41///
42/// Matches the Wayland wl_output.transform enum.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[repr(u32)]
45pub enum OutputTransform {
46    /// No transform
47    Normal = 0,
48    /// 90 degrees counter-clockwise
49    Rotate90 = 1,
50    /// 180 degrees
51    Rotate180 = 2,
52    /// 270 degrees counter-clockwise (90 clockwise)
53    Rotate270 = 3,
54    /// Horizontal flip
55    Flipped = 4,
56    /// Flip + 90 degrees counter-clockwise
57    FlippedRotate90 = 5,
58    /// Flip + 180 degrees
59    FlippedRotate180 = 6,
60    /// Flip + 270 degrees counter-clockwise
61    FlippedRotate270 = 7,
62}
63
64impl OutputTransform {
65    /// Create from Wayland protocol value.
66    pub fn from_wl(value: u32) -> Self {
67        match value {
68            0 => Self::Normal,
69            1 => Self::Rotate90,
70            2 => Self::Rotate180,
71            3 => Self::Rotate270,
72            4 => Self::Flipped,
73            5 => Self::FlippedRotate90,
74            6 => Self::FlippedRotate180,
75            7 => Self::FlippedRotate270,
76            _ => Self::Normal,
77        }
78    }
79
80    /// Returns true if the transform includes a 90 or 270 degree rotation,
81    /// which swaps width and height.
82    pub fn swaps_dimensions(self) -> bool {
83        matches!(
84            self,
85            Self::Rotate90 | Self::Rotate270 | Self::FlippedRotate90 | Self::FlippedRotate270
86        )
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Subpixel layout
92// ---------------------------------------------------------------------------
93
94/// Subpixel geometry of the display panel.
95///
96/// Used by font renderers for sub-pixel anti-aliasing.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[repr(u32)]
99pub enum SubpixelLayout {
100    /// Unknown or no subpixel information
101    Unknown = 0,
102    /// No subpixel rendering (e.g., CRT or projector)
103    None = 1,
104    /// Horizontal RGB subpixels (most common LCD)
105    HorizontalRgb = 2,
106    /// Horizontal BGR subpixels
107    HorizontalBgr = 3,
108    /// Vertical RGB subpixels
109    VerticalRgb = 4,
110    /// Vertical BGR subpixels
111    VerticalBgr = 5,
112}
113
114impl SubpixelLayout {
115    /// Create from Wayland protocol value.
116    pub fn from_wl(value: u32) -> Self {
117        match value {
118            0 => Self::Unknown,
119            1 => Self::None,
120            2 => Self::HorizontalRgb,
121            3 => Self::HorizontalBgr,
122            4 => Self::VerticalRgb,
123            5 => Self::VerticalBgr,
124            _ => Self::Unknown,
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Output mode
131// ---------------------------------------------------------------------------
132
133/// A display mode supported by an output.
134///
135/// Each output can support multiple modes (resolutions and refresh rates).
136/// Exactly one mode should be marked as `current`, and one as `preferred`.
137#[derive(Debug, Clone)]
138pub struct OutputMode {
139    /// Horizontal resolution in pixels
140    pub width: u32,
141    /// Vertical resolution in pixels
142    pub height: u32,
143    /// Refresh rate in millihertz (e.g., 60000 = 60Hz)
144    pub refresh_mhz: u32,
145    /// Whether this is the preferred (native) mode
146    pub preferred: bool,
147    /// Whether this is the currently active mode
148    pub current: bool,
149}
150
151impl OutputMode {
152    /// Create a new output mode.
153    pub fn new(width: u32, height: u32, refresh_mhz: u32) -> Self {
154        Self {
155            width,
156            height,
157            refresh_mhz,
158            preferred: false,
159            current: false,
160        }
161    }
162
163    /// Create a mode marked as both current and preferred.
164    pub fn new_current_preferred(width: u32, height: u32, refresh_mhz: u32) -> Self {
165        Self {
166            width,
167            height,
168            refresh_mhz,
169            preferred: true,
170            current: true,
171        }
172    }
173
174    /// Get the Wayland mode flags (bitmask).
175    pub fn wl_flags(&self) -> u32 {
176        let mut flags = 0u32;
177        if self.current {
178            flags |= 0x1; // WL_OUTPUT_MODE_CURRENT
179        }
180        if self.preferred {
181            flags |= 0x2; // WL_OUTPUT_MODE_PREFERRED
182        }
183        flags
184    }
185
186    /// Pixel count for this mode.
187    pub fn pixel_count(&self) -> u64 {
188        self.width as u64 * self.height as u64
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Output
194// ---------------------------------------------------------------------------
195
196/// A display output (physical monitor or virtual display).
197pub struct Output {
198    /// Unique output ID
199    pub id: u32,
200    /// Human-readable output name (e.g., "HDMI-A-1", "eDP-1")
201    pub name: String,
202    /// Description of the output (e.g., "Dell U2720Q")
203    pub description: String,
204    /// Manufacturer name
205    pub make: String,
206    /// Model name
207    pub model: String,
208    /// Position in global compositor coordinate space
209    pub x: i32,
210    pub y: i32,
211    /// Physical width in millimeters (0 = unknown)
212    pub physical_width_mm: u32,
213    /// Physical height in millimeters (0 = unknown)
214    pub physical_height_mm: u32,
215    /// Subpixel layout
216    pub subpixel: SubpixelLayout,
217    /// Applied transform (rotation/flip)
218    pub transform: OutputTransform,
219    /// Integer scale factor (1 = normal, 2 = HiDPI)
220    pub scale: u32,
221    /// Supported display modes
222    pub modes: Vec<OutputMode>,
223    /// Whether this output is enabled
224    pub enabled: bool,
225}
226
227impl Output {
228    /// Create a new output with default settings.
229    pub fn new(id: u32, name: &str) -> Self {
230        Self {
231            id,
232            name: String::from(name),
233            description: String::new(),
234            make: String::from("VeridianOS"),
235            model: String::from("Virtual Display"),
236            x: 0,
237            y: 0,
238            physical_width_mm: 0,
239            physical_height_mm: 0,
240            subpixel: SubpixelLayout::Unknown,
241            transform: OutputTransform::Normal,
242            scale: 1,
243            modes: Vec::new(),
244            enabled: true,
245        }
246    }
247
248    /// Create a virtual output with a single mode.
249    pub fn new_virtual(id: u32, name: &str, width: u32, height: u32) -> Self {
250        let mut output = Self::new(id, name);
251        output
252            .modes
253            .push(OutputMode::new_current_preferred(width, height, 60000));
254        output
255    }
256
257    /// Get the current mode (if any).
258    pub fn current_mode(&self) -> Option<&OutputMode> {
259        self.modes.iter().find(|m| m.current)
260    }
261
262    /// Get the logical width (after transform and scale).
263    pub fn logical_width(&self) -> u32 {
264        let mode = match self.current_mode() {
265            Some(m) => m,
266            None => return 0,
267        };
268        let (w, h) = if self.transform.swaps_dimensions() {
269            (mode.height, mode.width)
270        } else {
271            (mode.width, mode.height)
272        };
273        let _ = h; // suppress unused warning
274        w / self.scale
275    }
276
277    /// Get the logical height (after transform and scale).
278    pub fn logical_height(&self) -> u32 {
279        let mode = match self.current_mode() {
280            Some(m) => m,
281            None => return 0,
282        };
283        let (w, h) = if self.transform.swaps_dimensions() {
284            (mode.height, mode.width)
285        } else {
286            (mode.width, mode.height)
287        };
288        let _ = w; // suppress unused warning
289        h / self.scale
290    }
291
292    /// Get the physical pixel width (current mode, no scaling).
293    pub fn pixel_width(&self) -> u32 {
294        self.current_mode().map(|m| m.width).unwrap_or(0)
295    }
296
297    /// Get the physical pixel height (current mode, no scaling).
298    pub fn pixel_height(&self) -> u32 {
299        self.current_mode().map(|m| m.height).unwrap_or(0)
300    }
301
302    /// Calculate DPI from physical dimensions and pixel resolution.
303    ///
304    /// Returns (dpi_x, dpi_y) or (0, 0) if physical dimensions are unknown.
305    pub fn dpi(&self) -> (u32, u32) {
306        if self.physical_width_mm == 0 || self.physical_height_mm == 0 {
307            return (0, 0);
308        }
309        let mode = match self.current_mode() {
310            Some(m) => m,
311            None => return (0, 0),
312        };
313        // DPI = pixels / (mm / 25.4)
314        let dpi_x = (mode.width * 254) / (self.physical_width_mm * 10);
315        let dpi_y = (mode.height * 254) / (self.physical_height_mm * 10);
316        (dpi_x, dpi_y)
317    }
318
319    /// Check if a point (in global compositor coordinates) falls within
320    /// this output's logical area.
321    pub fn contains_point(&self, px: i32, py: i32) -> bool {
322        let w = self.logical_width() as i32;
323        let h = self.logical_height() as i32;
324        px >= self.x && px < self.x + w && py >= self.y && py < self.y + h
325    }
326
327    /// Get the bounding rectangle (x, y, width, height) in global coords.
328    pub fn bounds(&self) -> (i32, i32, u32, u32) {
329        (self.x, self.y, self.logical_width(), self.logical_height())
330    }
331
332    /// Set the active mode by index. Marks all other modes as non-current.
333    pub fn set_current_mode(&mut self, index: usize) -> Result<(), KernelError> {
334        if index >= self.modes.len() {
335            return Err(KernelError::InvalidArgument {
336                name: "mode_index",
337                value: "out of range",
338            });
339        }
340        for mode in self.modes.iter_mut() {
341            mode.current = false;
342        }
343        self.modes[index].current = true;
344        Ok(())
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Output manager
350// ---------------------------------------------------------------------------
351
352/// Manages all display outputs and their configuration.
353///
354/// The output manager tracks the global coordinate layout, handles hotplug
355/// events, and provides queries for surface-to-output mapping.
356pub struct OutputManager {
357    /// All outputs keyed by ID
358    outputs: BTreeMap<u32, Output>,
359    /// Next output ID
360    next_id: AtomicU32,
361    /// ID of the primary output (receives new windows by default)
362    primary_output: Option<u32>,
363}
364
365impl OutputManager {
366    /// Create a new output manager with no outputs.
367    pub fn new() -> Self {
368        Self {
369            outputs: BTreeMap::new(),
370            next_id: AtomicU32::new(1),
371            primary_output: None,
372        }
373    }
374
375    /// Create an output manager with a single virtual output matching the
376    /// framebuffer dimensions.
377    pub fn new_with_framebuffer(width: u32, height: u32) -> Self {
378        let mut manager = Self::new();
379        let id = manager.next_id.fetch_add(1, Ordering::Relaxed);
380        let output = Output::new_virtual(id, "FBCON-1", width, height);
381        manager.outputs.insert(id, output);
382        manager.primary_output = Some(id);
383        manager
384    }
385
386    /// Add a new output to the manager. Returns the assigned output ID.
387    pub fn add_output(&mut self, mut output: Output) -> u32 {
388        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
389        output.id = id;
390
391        // If this is the first output, make it primary
392        if self.primary_output.is_none() {
393            self.primary_output = Some(id);
394        }
395
396        self.outputs.insert(id, output);
397        id
398    }
399
400    /// Remove an output by ID.
401    ///
402    /// If the removed output was primary, the next available output becomes
403    /// primary. Returns the removed output if it existed.
404    pub fn remove_output(&mut self, id: u32) -> Option<Output> {
405        let output = self.outputs.remove(&id);
406
407        // Update primary if we removed it
408        if self.primary_output == Some(id) {
409            self.primary_output = self.outputs.keys().next().copied();
410        }
411
412        output
413    }
414
415    /// Get a reference to an output by ID.
416    pub fn get_output(&self, id: u32) -> Option<&Output> {
417        self.outputs.get(&id)
418    }
419
420    /// Get a mutable reference to an output by ID.
421    pub fn get_output_mut(&mut self, id: u32) -> Option<&mut Output> {
422        self.outputs.get_mut(&id)
423    }
424
425    /// Get the primary output.
426    pub fn get_primary(&self) -> Option<&Output> {
427        self.primary_output.and_then(|id| self.outputs.get(&id))
428    }
429
430    /// Get the primary output ID.
431    pub fn get_primary_id(&self) -> Option<u32> {
432        self.primary_output
433    }
434
435    /// Set the primary output.
436    pub fn set_primary(&mut self, id: u32) -> Result<(), KernelError> {
437        if !self.outputs.contains_key(&id) {
438            return Err(KernelError::NotFound {
439                resource: "output",
440                id: id as u64,
441            });
442        }
443        self.primary_output = Some(id);
444        Ok(())
445    }
446
447    /// Get all outputs as a list of references.
448    pub fn get_all_outputs(&self) -> Vec<&Output> {
449        self.outputs.values().collect()
450    }
451
452    /// Get the number of active outputs.
453    pub fn output_count(&self) -> usize {
454        self.outputs.len()
455    }
456
457    /// Calculate the total bounding rectangle across all outputs.
458    ///
459    /// Returns (min_x, min_y, total_width, total_height) in global coords.
460    pub fn get_total_area(&self) -> (i32, i32, u32, u32) {
461        if self.outputs.is_empty() {
462            return (0, 0, 0, 0);
463        }
464
465        let mut min_x = i32::MAX;
466        let mut min_y = i32::MAX;
467        let mut max_x = i32::MIN;
468        let mut max_y = i32::MIN;
469
470        for output in self.outputs.values() {
471            if !output.enabled {
472                continue;
473            }
474            let (ox, oy, ow, oh) = output.bounds();
475            if ox < min_x {
476                min_x = ox;
477            }
478            if oy < min_y {
479                min_y = oy;
480            }
481            let right = ox + ow as i32;
482            let bottom = oy + oh as i32;
483            if right > max_x {
484                max_x = right;
485            }
486            if bottom > max_y {
487                max_y = bottom;
488            }
489        }
490
491        if min_x == i32::MAX {
492            return (0, 0, 0, 0);
493        }
494
495        (min_x, min_y, (max_x - min_x) as u32, (max_y - min_y) as u32)
496    }
497
498    /// Find the output at a given point in global coordinates.
499    ///
500    /// Returns the output ID if found.
501    pub fn get_output_at_point(&self, x: i32, y: i32) -> Option<u32> {
502        for output in self.outputs.values() {
503            if output.enabled && output.contains_point(x, y) {
504                return Some(output.id);
505            }
506        }
507        None
508    }
509
510    /// Set the scale factor for an output.
511    pub fn set_scale(&mut self, id: u32, scale: u32) -> Result<(), KernelError> {
512        let output = self.outputs.get_mut(&id).ok_or(KernelError::NotFound {
513            resource: "output",
514            id: id as u64,
515        })?;
516
517        if scale == 0 {
518            return Err(KernelError::InvalidArgument {
519                name: "scale",
520                value: "must be >= 1",
521            });
522        }
523
524        output.scale = scale;
525        Ok(())
526    }
527
528    /// Set the position of an output in global coordinates.
529    pub fn set_position(&mut self, id: u32, x: i32, y: i32) -> Result<(), KernelError> {
530        let output = self.outputs.get_mut(&id).ok_or(KernelError::NotFound {
531            resource: "output",
532            id: id as u64,
533        })?;
534        output.x = x;
535        output.y = y;
536        Ok(())
537    }
538
539    /// Set the transform for an output.
540    pub fn set_transform(
541        &mut self,
542        id: u32,
543        transform: OutputTransform,
544    ) -> Result<(), KernelError> {
545        let output = self.outputs.get_mut(&id).ok_or(KernelError::NotFound {
546            resource: "output",
547            id: id as u64,
548        })?;
549        output.transform = transform;
550        Ok(())
551    }
552
553    /// Handle a hotplug event: add a new output to the right of existing
554    /// outputs.
555    ///
556    /// Returns the assigned output ID.
557    pub fn handle_hotplug(&mut self, output: Output) -> u32 {
558        // Calculate position: to the right of all existing outputs
559        let (_, _, total_w, _) = self.get_total_area();
560        let mut new_output = output;
561        new_output.x = total_w as i32;
562        new_output.y = 0;
563        self.add_output(new_output)
564    }
565
566    /// Arrange outputs side by side (left to right) in the order they
567    /// were added.
568    pub fn arrange_horizontal(&mut self) {
569        let mut x_offset = 0i32;
570        let ids: Vec<u32> = self.outputs.keys().copied().collect();
571        for id in ids {
572            if let Some(output) = self.outputs.get_mut(&id) {
573                if !output.enabled {
574                    continue;
575                }
576                output.x = x_offset;
577                output.y = 0;
578                x_offset += output.logical_width() as i32;
579            }
580        }
581    }
582
583    /// Arrange outputs vertically (top to bottom).
584    pub fn arrange_vertical(&mut self) {
585        let mut y_offset = 0i32;
586        let ids: Vec<u32> = self.outputs.keys().copied().collect();
587        for id in ids {
588            if let Some(output) = self.outputs.get_mut(&id) {
589                if !output.enabled {
590                    continue;
591                }
592                output.x = 0;
593                output.y = y_offset;
594                y_offset += output.logical_height() as i32;
595            }
596        }
597    }
598
599    /// Enable or disable an output.
600    pub fn set_enabled(&mut self, id: u32, enabled: bool) -> Result<(), KernelError> {
601        let output = self.outputs.get_mut(&id).ok_or(KernelError::NotFound {
602            resource: "output",
603            id: id as u64,
604        })?;
605        output.enabled = enabled;
606
607        // If we disabled the primary, pick a new one
608        if !enabled && self.primary_output == Some(id) {
609            self.primary_output = self
610                .outputs
611                .values()
612                .find(|o| o.enabled && o.id != id)
613                .map(|o| o.id);
614        }
615        Ok(())
616    }
617
618    /// Get the effective scale for a point in global coordinates.
619    ///
620    /// Returns the scale factor of the output containing the point,
621    /// or 1 if no output contains the point.
622    pub fn scale_at_point(&self, x: i32, y: i32) -> u32 {
623        self.get_output_at_point(x, y)
624            .and_then(|id| self.outputs.get(&id))
625            .map(|o| o.scale)
626            .unwrap_or(1)
627    }
628
629    /// Get all outputs that overlap with a given rectangle.
630    pub fn outputs_for_rect(&self, x: i32, y: i32, w: u32, h: u32) -> Vec<u32> {
631        let rect_right = x + w as i32;
632        let rect_bottom = y + h as i32;
633        let mut result = Vec::new();
634
635        for output in self.outputs.values() {
636            if !output.enabled {
637                continue;
638            }
639            let (ox, oy, ow, oh) = output.bounds();
640            let out_right = ox + ow as i32;
641            let out_bottom = oy + oh as i32;
642
643            // Standard rectangle overlap test
644            if x < out_right && rect_right > ox && y < out_bottom && rect_bottom > oy {
645                result.push(output.id);
646            }
647        }
648        result
649    }
650}
651
652impl Default for OutputManager {
653    fn default() -> Self {
654        Self::new()
655    }
656}
657
658// ---------------------------------------------------------------------------
659// Tests
660// ---------------------------------------------------------------------------
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn test_output_manager_basic() {
668        let mut mgr = OutputManager::new();
669        assert_eq!(mgr.output_count(), 0);
670
671        let output = Output::new_virtual(0, "TEST-1", 1920, 1080);
672        let id = mgr.add_output(output);
673        assert_eq!(mgr.output_count(), 1);
674        assert_eq!(mgr.get_primary_id(), Some(id));
675    }
676
677    #[test]
678    fn test_output_manager_with_framebuffer() {
679        let mgr = OutputManager::new_with_framebuffer(1280, 800);
680        assert_eq!(mgr.output_count(), 1);
681        let primary = mgr.get_primary().unwrap();
682        assert_eq!(primary.pixel_width(), 1280);
683        assert_eq!(primary.pixel_height(), 800);
684    }
685
686    #[test]
687    fn test_total_area_multi_output() {
688        let mut mgr = OutputManager::new();
689        let out1 = Output::new_virtual(0, "LEFT", 1920, 1080);
690        mgr.add_output(out1);
691        let mut out2 = Output::new_virtual(0, "RIGHT", 1920, 1080);
692        out2.x = 1920;
693        mgr.add_output(out2);
694
695        let (x, y, w, h) = mgr.get_total_area();
696        assert_eq!(x, 0);
697        assert_eq!(y, 0);
698        assert_eq!(w, 3840);
699        assert_eq!(h, 1080);
700    }
701
702    #[test]
703    fn test_output_at_point() {
704        let mut mgr = OutputManager::new();
705        let out1 = Output::new_virtual(0, "LEFT", 1920, 1080);
706        let id1 = mgr.add_output(out1);
707        let mut out2 = Output::new_virtual(0, "RIGHT", 1920, 1080);
708        out2.x = 1920;
709        let id2 = mgr.add_output(out2);
710
711        assert_eq!(mgr.get_output_at_point(100, 100), Some(id1));
712        assert_eq!(mgr.get_output_at_point(2000, 100), Some(id2));
713        assert_eq!(mgr.get_output_at_point(5000, 100), None);
714    }
715
716    #[test]
717    fn test_hidpi_scale() {
718        let mut mgr = OutputManager::new();
719        let output = Output::new_virtual(0, "HIDPI", 3840, 2160);
720        let id = mgr.add_output(output);
721        mgr.set_scale(id, 2).unwrap();
722
723        let out = mgr.get_output(id).unwrap();
724        assert_eq!(out.logical_width(), 1920);
725        assert_eq!(out.logical_height(), 1080);
726        assert_eq!(out.pixel_width(), 3840);
727        assert_eq!(out.pixel_height(), 2160);
728    }
729
730    #[test]
731    fn test_output_transform_swap() {
732        assert!(!OutputTransform::Normal.swaps_dimensions());
733        assert!(OutputTransform::Rotate90.swaps_dimensions());
734        assert!(!OutputTransform::Rotate180.swaps_dimensions());
735        assert!(OutputTransform::Rotate270.swaps_dimensions());
736    }
737
738    #[test]
739    fn test_remove_primary() {
740        let mut mgr = OutputManager::new();
741        let out1 = Output::new_virtual(0, "A", 1920, 1080);
742        let id1 = mgr.add_output(out1);
743        let out2 = Output::new_virtual(0, "B", 1920, 1080);
744        let id2 = mgr.add_output(out2);
745
746        assert_eq!(mgr.get_primary_id(), Some(id1));
747        mgr.remove_output(id1);
748        assert_eq!(mgr.get_primary_id(), Some(id2));
749    }
750
751    #[test]
752    fn test_outputs_for_rect() {
753        let mut mgr = OutputManager::new();
754        let out1 = Output::new_virtual(0, "LEFT", 1920, 1080);
755        let id1 = mgr.add_output(out1);
756        let mut out2 = Output::new_virtual(0, "RIGHT", 1920, 1080);
757        out2.x = 1920;
758        let id2 = mgr.add_output(out2);
759
760        // Rect spanning both outputs
761        let ids = mgr.outputs_for_rect(1800, 0, 240, 100);
762        assert!(ids.contains(&id1));
763        assert!(ids.contains(&id2));
764
765        // Rect on left only
766        let ids = mgr.outputs_for_rect(0, 0, 100, 100);
767        assert!(ids.contains(&id1));
768        assert!(!ids.contains(&id2));
769    }
770
771    #[test]
772    fn test_output_dpi() {
773        let mut output = Output::new_virtual(1, "TEST", 3840, 2160);
774        output.physical_width_mm = 600; // ~24 inches wide
775        output.physical_height_mm = 340;
776
777        let (dpi_x, dpi_y) = output.dpi();
778        // 3840 / (600/25.4) = 3840 / 23.6 = ~162 DPI
779        assert!(dpi_x > 150 && dpi_x < 170);
780        assert!(dpi_y > 150 && dpi_y < 170);
781    }
782}