gardesk/gardisplay / 2bbd7d5

Browse files

initial gardisplay structure with basic monitor view

Authored by espadonne
SHA
2bbd7d51c8dc7e6761d0353c0552e353ba753708
Tree
2f651a3

12 changed files

StatusFile+-
A .gitignore 6 0
A Cargo.toml 9 0
A README.md 28 0
A gardisplay-ipc/Cargo.toml 8 0
A gardisplay-ipc/src/lib.rs 71 0
A gardisplay/Cargo.toml 26 0
A gardisplay/src/app.rs 209 0
A gardisplay/src/config/mod.rs 49 0
A gardisplay/src/config/types.rs 163 0
A gardisplay/src/main.rs 35 0
A gardisplay/src/ui/mod.rs 16 0
A gardisplay/src/ui/monitor_view.rs 249 0
.gitignoreadded
@@ -0,0 +1,6 @@
1
+docs/
2
+.vscode/
3
+.fackr/
4
+CLAUDE.md
5
+target/
6
+Cargo.lock
Cargo.tomladded
@@ -0,0 +1,9 @@
1
+[workspace]
2
+resolver = "2"
3
+members = ["gardisplay", "gardisplay-ipc"]
4
+
5
+[workspace.package]
6
+version = "0.1.0"
7
+edition = "2024"
8
+license = "MIT"
9
+repository = "https://github.com/gardesk/gardisplay"
README.mdadded
@@ -0,0 +1,28 @@
1
+# gardisplay
2
+
3
+Display/monitor manager for the gardesk desktop suite.
4
+
5
+Manages monitor layouts, resolutions, refresh rates, scaling, rotation, and display effects (gamma, night mode, color profiles) with a draggable UI built on gartk.
6
+
7
+## Components
8
+
9
+- **gardisplay** - GUI application with visual monitor layout editor
10
+- **gardisplayd** - Daemon for config persistence and effect scheduling
11
+- **gardisplayctl** - CLI for scripting and automation
12
+- **gardisplay-ipc** - Shared IPC message types
13
+
14
+## Building
15
+
16
+```bash
17
+cargo build --release
18
+```
19
+
20
+## Configuration
21
+
22
+```
23
+~/.config/gardisplay/config.toml
24
+```
25
+
26
+## License
27
+
28
+MIT
gardisplay-ipc/Cargo.tomladded
@@ -0,0 +1,8 @@
1
+[package]
2
+name = "gardisplay-ipc"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+
7
+[dependencies]
8
+serde = { version = "1.0", features = ["derive"] }
gardisplay-ipc/src/lib.rsadded
@@ -0,0 +1,71 @@
1
+//! IPC types for gardisplay daemon communication.
2
+
3
+use serde::{Deserialize, Serialize};
4
+
5
+/// Request from client to daemon.
6
+#[derive(Debug, Clone, Serialize, Deserialize)]
7
+pub enum Request {
8
+    /// Re-detect connected monitors.
9
+    Detect,
10
+    /// Apply a profile (uses default if None).
11
+    Apply { profile: Option<String> },
12
+    /// List available profile names.
13
+    ListProfiles,
14
+    /// Get current profile name.
15
+    GetProfile,
16
+    /// Switch to a named profile.
17
+    SetProfile { name: String },
18
+    /// Set brightness (0.0 - 1.0).
19
+    SetBrightness { value: f64 },
20
+    /// Set gamma (0.5 - 2.0).
21
+    SetGamma { value: f64 },
22
+    /// Control night mode.
23
+    NightMode { action: NightModeAction },
24
+    /// Get full current state.
25
+    GetState,
26
+}
27
+
28
+/// Night mode control action.
29
+#[derive(Debug, Clone, Serialize, Deserialize)]
30
+pub enum NightModeAction {
31
+    On,
32
+    Off,
33
+    Toggle,
34
+}
35
+
36
+/// Response from daemon to client.
37
+#[derive(Debug, Clone, Serialize, Deserialize)]
38
+pub enum Response {
39
+    Ok,
40
+    Error { message: String },
41
+    Profile { name: String },
42
+    Profiles { names: Vec<String> },
43
+    State(DisplayState),
44
+}
45
+
46
+/// Current display state.
47
+#[derive(Debug, Clone, Serialize, Deserialize)]
48
+pub struct DisplayState {
49
+    pub current_profile: String,
50
+    pub monitors: Vec<MonitorInfo>,
51
+    pub brightness: f64,
52
+    pub gamma: f64,
53
+    pub night_mode_active: bool,
54
+}
55
+
56
+/// Monitor information for IPC.
57
+#[derive(Debug, Clone, Serialize, Deserialize)]
58
+pub struct MonitorInfo {
59
+    pub name: String,
60
+    pub x: i32,
61
+    pub y: i32,
62
+    pub width: u32,
63
+    pub height: u32,
64
+    pub primary: bool,
65
+}
66
+
67
+/// Event sent to gar window manager.
68
+#[derive(Debug, Clone, Serialize, Deserialize)]
69
+pub enum Event {
70
+    LayoutChanged { monitors: Vec<MonitorInfo> },
71
+}
gardisplay/Cargo.tomladded
@@ -0,0 +1,26 @@
1
+[package]
2
+name = "gardisplay"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+
7
+[[bin]]
8
+name = "gardisplay"
9
+path = "src/main.rs"
10
+
11
+[dependencies]
12
+gardisplay-ipc = { path = "../gardisplay-ipc" }
13
+gartk-core = { path = "../../gartk/gartk-core" }
14
+gartk-x11 = { path = "../../gartk/gartk-x11" }
15
+gartk-render = { path = "../../gartk/gartk-render" }
16
+
17
+x11rb = { version = "0.13", features = ["randr"] }
18
+serde = { version = "1.0", features = ["derive"] }
19
+serde_json = "1.0"
20
+toml = "0.8"
21
+thiserror = "2.0"
22
+anyhow = "1.0"
23
+tracing = "0.1"
24
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
25
+clap = { version = "4.5", features = ["derive"] }
26
+dirs = "6.0"
gardisplay/src/app.rsadded
@@ -0,0 +1,209 @@
1
+//! Main application state and event loop.
2
+
3
+use anyhow::Result;
4
+use gartk_core::{InputEvent, Key, Rect, Size, Theme};
5
+use gartk_render::{copy_surface_to_window, Renderer};
6
+use gartk_x11::{
7
+    detect_monitors, primary_monitor, Connection, EventLoop, EventLoopConfig, Window, WindowConfig,
8
+};
9
+use x11rb::protocol::xproto::ConnectionExt;
10
+
11
+use crate::config::Config;
12
+use crate::ui::{EventResult, MonitorView};
13
+
14
+/// Window dimensions.
15
+const WINDOW_WIDTH: u32 = 800;
16
+const WINDOW_HEIGHT: u32 = 600;
17
+
18
+/// Main application.
19
+pub struct App {
20
+    conn: Connection,
21
+    window: Window,
22
+    renderer: Renderer,
23
+    theme: Theme,
24
+    gc: u32,
25
+    config: Config,
26
+    monitor_view: MonitorView,
27
+}
28
+
29
+impl App {
30
+    /// Create a new application instance.
31
+    pub fn new(config: Config) -> Result<Self> {
32
+        // Connect to X11
33
+        let conn = Connection::connect(None)?;
34
+        tracing::info!("connected to X11 display");
35
+
36
+        // Get monitor for positioning
37
+        let monitor = primary_monitor(&conn).unwrap_or_else(|_| {
38
+            tracing::warn!("could not get primary monitor, using defaults");
39
+            gartk_x11::Monitor {
40
+                name: "default".to_string(),
41
+                rect: Rect::new(0, 0, 1920, 1080),
42
+                primary: true,
43
+                width_mm: 0,
44
+                height_mm: 0,
45
+            }
46
+        });
47
+
48
+        // Center window on monitor
49
+        let x = monitor.rect.x + (monitor.rect.width as i32 - WINDOW_WIDTH as i32) / 2;
50
+        let y = monitor.rect.y + (monitor.rect.height as i32 - WINDOW_HEIGHT as i32) / 2;
51
+
52
+        // Create window
53
+        let window = Window::create(
54
+            conn.clone(),
55
+            WindowConfig::new()
56
+                .title("gardisplay")
57
+                .class("gardisplay")
58
+                .position(x, y)
59
+                .size(WINDOW_WIDTH, WINDOW_HEIGHT),
60
+        )?;
61
+        window.map()?;
62
+        tracing::info!(
63
+            "created window {}x{} at ({}, {})",
64
+            WINDOW_WIDTH,
65
+            WINDOW_HEIGHT,
66
+            x,
67
+            y
68
+        );
69
+
70
+        // Create graphics context for blitting
71
+        let gc = conn.generate_id()?;
72
+        conn.inner()
73
+            .create_gc(gc, window.id(), &Default::default())?;
74
+
75
+        // Create renderer with theme
76
+        let theme = Theme::dark();
77
+        let renderer = Renderer::with_theme(WINDOW_WIDTH, WINDOW_HEIGHT, theme.clone())?;
78
+
79
+        // Create monitor view
80
+        let view_rect = Rect::new(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT - 100); // Leave room for controls
81
+        let mut monitor_view = MonitorView::new(view_rect);
82
+
83
+        // Detect monitors
84
+        let monitors = detect_monitors(&conn)?;
85
+        tracing::info!("detected {} monitors", monitors.len());
86
+        for m in &monitors {
87
+            tracing::debug!(
88
+                "  {} {}x{} at ({}, {}) {}",
89
+                m.name,
90
+                m.rect.width,
91
+                m.rect.height,
92
+                m.rect.x,
93
+                m.rect.y,
94
+                if m.primary { "(primary)" } else { "" }
95
+            );
96
+        }
97
+        monitor_view.set_monitors(monitors);
98
+
99
+        Ok(Self {
100
+            conn,
101
+            window,
102
+            renderer,
103
+            theme,
104
+            gc,
105
+            config,
106
+            monitor_view,
107
+        })
108
+    }
109
+
110
+    /// Run the application event loop.
111
+    pub fn run(&mut self) -> Result<()> {
112
+        // Initial render
113
+        self.render()?;
114
+
115
+        let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?;
116
+
117
+        event_loop.run(|loop_state, event| {
118
+            match self.handle_event(&event) {
119
+                EventResult::Quit => return Ok(false),
120
+                EventResult::Redraw => {
121
+                    loop_state.request_redraw();
122
+                }
123
+                EventResult::None => {}
124
+            }
125
+
126
+            // Render on idle if needed
127
+            if matches!(event, InputEvent::Idle) && loop_state.needs_redraw() {
128
+                if let Err(e) = self.render() {
129
+                    tracing::error!("render error: {}", e);
130
+                }
131
+                loop_state.redraw_done();
132
+            }
133
+
134
+            Ok(true)
135
+        })?;
136
+
137
+        tracing::info!("exiting");
138
+        Ok(())
139
+    }
140
+
141
+    /// Handle an input event.
142
+    fn handle_event(&mut self, event: &InputEvent) -> EventResult {
143
+        match event {
144
+            InputEvent::CloseRequested => EventResult::Quit,
145
+            InputEvent::Key(e) if e.pressed && e.key == Key::Escape => EventResult::Quit,
146
+            InputEvent::Key(e) if e.pressed && e.key == Key::Char('q') => EventResult::Quit,
147
+            InputEvent::Resize { width, height } => {
148
+                self.handle_resize(Size::new(*width, *height));
149
+                EventResult::Redraw
150
+            }
151
+            InputEvent::Expose => EventResult::Redraw,
152
+            _ => self.monitor_view.handle_event(event),
153
+        }
154
+    }
155
+
156
+    /// Handle window resize.
157
+    fn handle_resize(&mut self, size: Size) {
158
+        tracing::debug!("resize to {}x{}", size.width, size.height);
159
+
160
+        // Resize renderer
161
+        if let Err(e) = self.renderer.resize(size.width, size.height) {
162
+            tracing::error!("failed to resize renderer: {}", e);
163
+            return;
164
+        }
165
+
166
+        // Update monitor view rect
167
+        let view_rect = Rect::new(0, 0, size.width, size.height.saturating_sub(100));
168
+        self.monitor_view.set_view_rect(view_rect);
169
+    }
170
+
171
+    /// Render the application.
172
+    fn render(&mut self) -> Result<()> {
173
+        // Clear background
174
+        self.renderer.clear()?;
175
+
176
+        // Render monitor view
177
+        self.monitor_view.render(&mut self.renderer, &self.theme)?;
178
+
179
+        // Render bottom controls area
180
+        let size = self.renderer.size();
181
+        let controls_y = size.height.saturating_sub(100) as i32;
182
+        let controls_rect = Rect::new(0, controls_y, size.width, 100);
183
+        self.renderer
184
+            .fill_rect(controls_rect, self.theme.background)?;
185
+
186
+        // Separator line
187
+        self.renderer.line(
188
+            0.0,
189
+            controls_y as f64,
190
+            size.width as f64,
191
+            controls_y as f64,
192
+            self.theme.border,
193
+            1.0,
194
+        )?;
195
+
196
+        // Title in controls area
197
+        self.renderer.text_default(
198
+            "gardisplay - Drag monitors to arrange",
199
+            10.0,
200
+            (controls_y + 20) as f64,
201
+            self.theme.foreground,
202
+        )?;
203
+
204
+        // Blit to window
205
+        copy_surface_to_window(self.renderer.surface_mut(), &self.window, self.gc, 0, 0)?;
206
+
207
+        Ok(())
208
+    }
209
+}
gardisplay/src/config/mod.rsadded
@@ -0,0 +1,49 @@
1
+//! Configuration loading and types.
2
+
3
+mod types;
4
+
5
+pub use types::*;
6
+
7
+use std::path::PathBuf;
8
+
9
+/// Load configuration from file or default location.
10
+pub fn load_config(path: Option<&str>) -> anyhow::Result<Config> {
11
+    let path = match path {
12
+        Some(p) => PathBuf::from(p),
13
+        None => config_path()?,
14
+    };
15
+
16
+    if path.exists() {
17
+        tracing::info!("loading config from {}", path.display());
18
+        let content = std::fs::read_to_string(&path)?;
19
+        let config: Config = toml::from_str(&content)?;
20
+        Ok(config)
21
+    } else {
22
+        tracing::info!("no config file found, using defaults");
23
+        Ok(Config::default())
24
+    }
25
+}
26
+
27
+/// Get default config file path.
28
+pub fn config_path() -> anyhow::Result<PathBuf> {
29
+    let config_dir = dirs::config_dir()
30
+        .ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?;
31
+    Ok(config_dir.join("gardisplay").join("config.toml"))
32
+}
33
+
34
+impl Config {
35
+    /// Save configuration to file.
36
+    pub fn save(&self) -> anyhow::Result<()> {
37
+        let path = config_path()?;
38
+
39
+        if let Some(parent) = path.parent() {
40
+            std::fs::create_dir_all(parent)?;
41
+        }
42
+
43
+        let content = toml::to_string_pretty(self)?;
44
+        std::fs::write(&path, content)?;
45
+
46
+        tracing::info!("saved config to {}", path.display());
47
+        Ok(())
48
+    }
49
+}
gardisplay/src/config/types.rsadded
@@ -0,0 +1,163 @@
1
+//! Configuration types.
2
+
3
+use serde::{Deserialize, Serialize};
4
+use std::collections::HashMap;
5
+
6
+/// Main configuration.
7
+#[derive(Debug, Clone, Serialize, Deserialize)]
8
+pub struct Config {
9
+    #[serde(default)]
10
+    pub general: GeneralConfig,
11
+    #[serde(default)]
12
+    pub profiles: HashMap<String, Profile>,
13
+    #[serde(default)]
14
+    pub effects: EffectsConfig,
15
+    #[serde(default)]
16
+    pub night_mode: NightModeConfig,
17
+}
18
+
19
+impl Default for Config {
20
+    fn default() -> Self {
21
+        Self {
22
+            general: GeneralConfig::default(),
23
+            profiles: HashMap::new(),
24
+            effects: EffectsConfig::default(),
25
+            night_mode: NightModeConfig::default(),
26
+        }
27
+    }
28
+}
29
+
30
+/// General settings.
31
+#[derive(Debug, Clone, Serialize, Deserialize)]
32
+pub struct GeneralConfig {
33
+    #[serde(default = "default_profile_name")]
34
+    pub default_profile: String,
35
+}
36
+
37
+fn default_profile_name() -> String {
38
+    "default".to_string()
39
+}
40
+
41
+impl Default for GeneralConfig {
42
+    fn default() -> Self {
43
+        Self {
44
+            default_profile: default_profile_name(),
45
+        }
46
+    }
47
+}
48
+
49
+/// A named monitor profile.
50
+#[derive(Debug, Clone, Serialize, Deserialize)]
51
+pub struct Profile {
52
+    pub primary: Option<String>,
53
+    #[serde(default)]
54
+    pub monitors: Vec<MonitorConfig>,
55
+}
56
+
57
+/// Per-monitor configuration.
58
+#[derive(Debug, Clone, Serialize, Deserialize)]
59
+pub struct MonitorConfig {
60
+    pub name: String,
61
+    #[serde(default = "default_true")]
62
+    pub enabled: bool,
63
+    #[serde(default)]
64
+    pub x: i32,
65
+    #[serde(default)]
66
+    pub y: i32,
67
+    pub width: u32,
68
+    pub height: u32,
69
+    #[serde(default = "default_refresh")]
70
+    pub refresh: f64,
71
+    #[serde(default = "default_scale")]
72
+    pub scale: f64,
73
+    #[serde(default)]
74
+    pub rotation: u32,
75
+}
76
+
77
+fn default_true() -> bool {
78
+    true
79
+}
80
+
81
+fn default_refresh() -> f64 {
82
+    60.0
83
+}
84
+
85
+fn default_scale() -> f64 {
86
+    1.0
87
+}
88
+
89
+/// Display effects settings.
90
+#[derive(Debug, Clone, Serialize, Deserialize)]
91
+pub struct EffectsConfig {
92
+    #[serde(default = "default_brightness")]
93
+    pub brightness: f64,
94
+    #[serde(default = "default_gamma")]
95
+    pub gamma: f64,
96
+}
97
+
98
+fn default_brightness() -> f64 {
99
+    1.0
100
+}
101
+
102
+fn default_gamma() -> f64 {
103
+    1.0
104
+}
105
+
106
+impl Default for EffectsConfig {
107
+    fn default() -> Self {
108
+        Self {
109
+            brightness: default_brightness(),
110
+            gamma: default_gamma(),
111
+        }
112
+    }
113
+}
114
+
115
+/// Night mode settings.
116
+#[derive(Debug, Clone, Serialize, Deserialize)]
117
+pub struct NightModeConfig {
118
+    #[serde(default)]
119
+    pub enabled: bool,
120
+    #[serde(default = "default_schedule")]
121
+    pub schedule: String,
122
+    #[serde(default = "default_start_time")]
123
+    pub start_time: String,
124
+    #[serde(default = "default_end_time")]
125
+    pub end_time: String,
126
+    #[serde(default = "default_temperature")]
127
+    pub temperature: u32,
128
+    #[serde(default = "default_transition")]
129
+    pub transition_minutes: u32,
130
+}
131
+
132
+fn default_schedule() -> String {
133
+    "sunset".to_string()
134
+}
135
+
136
+fn default_start_time() -> String {
137
+    "20:00".to_string()
138
+}
139
+
140
+fn default_end_time() -> String {
141
+    "06:00".to_string()
142
+}
143
+
144
+fn default_temperature() -> u32 {
145
+    4500
146
+}
147
+
148
+fn default_transition() -> u32 {
149
+    30
150
+}
151
+
152
+impl Default for NightModeConfig {
153
+    fn default() -> Self {
154
+        Self {
155
+            enabled: false,
156
+            schedule: default_schedule(),
157
+            start_time: default_start_time(),
158
+            end_time: default_end_time(),
159
+            temperature: default_temperature(),
160
+            transition_minutes: default_transition(),
161
+        }
162
+    }
163
+}
gardisplay/src/main.rsadded
@@ -0,0 +1,35 @@
1
+//! gardisplay - Display/monitor manager for gardesk.
2
+
3
+mod app;
4
+mod config;
5
+mod ui;
6
+
7
+use clap::Parser;
8
+use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
9
+
10
+#[derive(Parser)]
11
+#[command(name = "gardisplay")]
12
+#[command(about = "Display/monitor manager for gardesk")]
13
+struct Args {
14
+    /// Configuration file path
15
+    #[arg(short, long)]
16
+    config: Option<String>,
17
+}
18
+
19
+fn main() -> anyhow::Result<()> {
20
+    let args = Args::parse();
21
+
22
+    tracing_subscriber::registry()
23
+        .with(
24
+            EnvFilter::try_from_default_env()
25
+                .unwrap_or_else(|_| EnvFilter::new("info,gardisplay=debug")),
26
+        )
27
+        .with(tracing_subscriber::fmt::layer())
28
+        .init();
29
+
30
+    tracing::info!("starting gardisplay");
31
+
32
+    let config = config::load_config(args.config.as_deref())?;
33
+    let mut app = app::App::new(config)?;
34
+    app.run()
35
+}
gardisplay/src/ui/mod.rsadded
@@ -0,0 +1,16 @@
1
+//! UI components for gardisplay.
2
+
3
+mod monitor_view;
4
+
5
+pub use monitor_view::MonitorView;
6
+
7
+/// Result of handling an event.
8
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+pub enum EventResult {
10
+    /// No action needed.
11
+    None,
12
+    /// Redraw the UI.
13
+    Redraw,
14
+    /// Quit the application.
15
+    Quit,
16
+}
gardisplay/src/ui/monitor_view.rsadded
@@ -0,0 +1,249 @@
1
+//! Monitor layout view - displays monitors as draggable rectangles.
2
+
3
+use gartk_core::{Color, InputEvent, Point, Rect, Theme};
4
+use gartk_render::Renderer;
5
+use gartk_x11::Monitor;
6
+
7
+use super::EventResult;
8
+
9
+/// Visual representation of a monitor in the layout.
10
+#[derive(Debug, Clone)]
11
+pub struct MonitorState {
12
+    /// Monitor info from X11.
13
+    pub info: Monitor,
14
+    /// Scaled rectangle for display.
15
+    pub scaled_rect: Rect,
16
+}
17
+
18
+/// View showing all monitors as rectangles.
19
+pub struct MonitorView {
20
+    monitors: Vec<MonitorState>,
21
+    selected: Option<usize>,
22
+    hovered: Option<usize>,
23
+    primary_name: Option<String>,
24
+    view_rect: Rect,
25
+    scale: f64,
26
+    offset: Point,
27
+}
28
+
29
+impl MonitorView {
30
+    /// Create a new monitor view.
31
+    pub fn new(view_rect: Rect) -> Self {
32
+        Self {
33
+            monitors: Vec::new(),
34
+            selected: None,
35
+            hovered: None,
36
+            primary_name: None,
37
+            view_rect,
38
+            scale: 1.0,
39
+            offset: Point::new(0, 0),
40
+        }
41
+    }
42
+
43
+    /// Update with detected monitors.
44
+    pub fn set_monitors(&mut self, monitors: Vec<Monitor>) {
45
+        // Find primary
46
+        self.primary_name = monitors.iter().find(|m| m.primary).map(|m| m.name.clone());
47
+
48
+        // Calculate scale to fit all monitors in view
49
+        self.calculate_layout(&monitors);
50
+
51
+        // Create monitor states
52
+        self.monitors = monitors
53
+            .into_iter()
54
+            .map(|info| {
55
+                let scaled_rect = self.scale_rect(&info.rect);
56
+                MonitorState { info, scaled_rect }
57
+            })
58
+            .collect();
59
+
60
+        tracing::debug!(
61
+            "set {} monitors, scale={:.3}, offset=({}, {})",
62
+            self.monitors.len(),
63
+            self.scale,
64
+            self.offset.x,
65
+            self.offset.y
66
+        );
67
+    }
68
+
69
+    /// Calculate scale and offset to fit monitors in view.
70
+    fn calculate_layout(&mut self, monitors: &[Monitor]) {
71
+        if monitors.is_empty() {
72
+            self.scale = 1.0;
73
+            self.offset = Point::new(0, 0);
74
+            return;
75
+        }
76
+
77
+        // Find bounding box of all monitors
78
+        let mut min_x = i32::MAX;
79
+        let mut min_y = i32::MAX;
80
+        let mut max_x = i32::MIN;
81
+        let mut max_y = i32::MIN;
82
+
83
+        for m in monitors {
84
+            min_x = min_x.min(m.rect.x);
85
+            min_y = min_y.min(m.rect.y);
86
+            max_x = max_x.max(m.rect.x + m.rect.width as i32);
87
+            max_y = max_y.max(m.rect.y + m.rect.height as i32);
88
+        }
89
+
90
+        let total_width = (max_x - min_x) as f64;
91
+        let total_height = (max_y - min_y) as f64;
92
+
93
+        // Add padding
94
+        let padding = 40.0;
95
+        let available_width = self.view_rect.width as f64 - padding * 2.0;
96
+        let available_height = self.view_rect.height as f64 - padding * 2.0;
97
+
98
+        // Calculate scale to fit
99
+        let scale_x = available_width / total_width;
100
+        let scale_y = available_height / total_height;
101
+        self.scale = scale_x.min(scale_y).min(0.15); // Cap at 15% to keep it reasonable
102
+
103
+        // Calculate offset to center
104
+        let scaled_width = total_width * self.scale;
105
+        let scaled_height = total_height * self.scale;
106
+
107
+        self.offset = Point::new(
108
+            self.view_rect.x + ((self.view_rect.width as f64 - scaled_width) / 2.0) as i32
109
+                - (min_x as f64 * self.scale) as i32,
110
+            self.view_rect.y + ((self.view_rect.height as f64 - scaled_height) / 2.0) as i32
111
+                - (min_y as f64 * self.scale) as i32,
112
+        );
113
+    }
114
+
115
+    /// Scale a monitor rect to view coordinates.
116
+    fn scale_rect(&self, rect: &Rect) -> Rect {
117
+        Rect::new(
118
+            self.offset.x + (rect.x as f64 * self.scale) as i32,
119
+            self.offset.y + (rect.y as f64 * self.scale) as i32,
120
+            (rect.width as f64 * self.scale) as u32,
121
+            (rect.height as f64 * self.scale) as u32,
122
+        )
123
+    }
124
+
125
+    /// Find monitor at a point.
126
+    fn monitor_at_point(&self, point: Point) -> Option<usize> {
127
+        for (i, state) in self.monitors.iter().enumerate().rev() {
128
+            if state.scaled_rect.contains_point(point) {
129
+                return Some(i);
130
+            }
131
+        }
132
+        None
133
+    }
134
+
135
+    /// Handle an input event.
136
+    pub fn handle_event(&mut self, event: &InputEvent) -> EventResult {
137
+        match event {
138
+            InputEvent::MouseMove(e) => {
139
+                let new_hovered = self.monitor_at_point(e.position);
140
+                if new_hovered != self.hovered {
141
+                    self.hovered = new_hovered;
142
+                    return EventResult::Redraw;
143
+                }
144
+            }
145
+            InputEvent::MousePress(e) => {
146
+                if let Some(index) = self.monitor_at_point(e.position) {
147
+                    self.selected = Some(index);
148
+                    return EventResult::Redraw;
149
+                } else if self.selected.is_some() {
150
+                    self.selected = None;
151
+                    return EventResult::Redraw;
152
+                }
153
+            }
154
+            _ => {}
155
+        }
156
+        EventResult::None
157
+    }
158
+
159
+    /// Render the monitor view.
160
+    pub fn render(&self, renderer: &mut Renderer, theme: &Theme) -> anyhow::Result<()> {
161
+        // Background
162
+        renderer.fill_rect(self.view_rect, theme.background)?;
163
+
164
+        // Render each monitor
165
+        for (i, state) in self.monitors.iter().enumerate() {
166
+            self.render_monitor(renderer, theme, i, state)?;
167
+        }
168
+
169
+        Ok(())
170
+    }
171
+
172
+    /// Render a single monitor.
173
+    fn render_monitor(
174
+        &self,
175
+        renderer: &Renderer,
176
+        theme: &Theme,
177
+        index: usize,
178
+        state: &MonitorState,
179
+    ) -> anyhow::Result<()> {
180
+        let rect = state.scaled_rect;
181
+        let is_primary = self
182
+            .primary_name
183
+            .as_ref()
184
+            .is_some_and(|name| name == &state.info.name);
185
+
186
+        // Determine colors based on state
187
+        let (bg_color, border_color, border_width) = if Some(index) == self.selected {
188
+            (
189
+                theme.item_selected_background,
190
+                theme.selection_background,
191
+                3.0,
192
+            )
193
+        } else if Some(index) == self.hovered {
194
+            (theme.item_hover_background, theme.border, 2.0)
195
+        } else {
196
+            (theme.item_background, theme.border, 1.0)
197
+        };
198
+
199
+        // Background
200
+        renderer.fill_rounded_rect(rect, 8.0, bg_color)?;
201
+
202
+        // Border (thicker for primary)
203
+        let actual_border_width = if is_primary {
204
+            border_width + 1.0
205
+        } else {
206
+            border_width
207
+        };
208
+        let actual_border_color = if is_primary {
209
+            Color::new(0.3, 0.6, 1.0, 1.0) // Blue accent for primary
210
+        } else {
211
+            border_color
212
+        };
213
+        renderer.stroke_rounded_rect(rect, 8.0, actual_border_color, actual_border_width)?;
214
+
215
+        // Monitor name
216
+        let text_x = (rect.x + 10) as f64;
217
+        let text_y = (rect.y + 10) as f64;
218
+        renderer.text_default(&state.info.name, text_x, text_y, theme.foreground)?;
219
+
220
+        // Resolution
221
+        let res_text = format!("{}x{}", state.info.rect.width, state.info.rect.height);
222
+        renderer.text_default(&res_text, text_x, text_y + 20.0, theme.item_description)?;
223
+
224
+        // Primary indicator
225
+        if is_primary {
226
+            renderer.text_default("(primary)", text_x, text_y + 40.0, theme.selection_background)?;
227
+        }
228
+
229
+        Ok(())
230
+    }
231
+
232
+    /// Get the selected monitor index.
233
+    pub fn selected(&self) -> Option<usize> {
234
+        self.selected
235
+    }
236
+
237
+    /// Get monitors.
238
+    pub fn monitors(&self) -> &[MonitorState] {
239
+        &self.monitors
240
+    }
241
+
242
+    /// Update view rect (e.g., on resize).
243
+    pub fn set_view_rect(&mut self, rect: Rect) {
244
+        self.view_rect = rect;
245
+        // Recalculate layout
246
+        let monitors: Vec<Monitor> = self.monitors.iter().map(|s| s.info.clone()).collect();
247
+        self.set_monitors(monitors);
248
+    }
249
+}