gardesk/gardisplay / 7e78855

Browse files

add display panel, toggle widget, and cursor changes

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7e7885577d391886aca4784652aca52b73c86578
Parents
980e94b
Tree
a13da29

6 changed files

StatusFile+-
M gardisplay/src/app.rs 128 20
A gardisplay/src/ui/display_panel.rs 434 0
M gardisplay/src/ui/mod.rs 3 1
M gardisplay/src/ui/monitor_view.rs 52 1
M gardisplay/src/ui/widgets/mod.rs 2 0
A gardisplay/src/ui/widgets/toggle.rs 119 0
gardisplay/src/app.rsmodified
@@ -4,19 +4,25 @@ use anyhow::Result;
4
 use gartk_core::{Color, InputEvent, Key, Rect, Size, Theme};
4
 use gartk_core::{Color, InputEvent, Key, Rect, Size, Theme};
5
 use gartk_render::{copy_surface_to_window, Renderer};
5
 use gartk_render::{copy_surface_to_window, Renderer};
6
 use gartk_x11::{
6
 use gartk_x11::{
7
-    detect_monitors, primary_monitor, Connection, EventLoop, EventLoopConfig, Monitor, Window,
7
+    detect_monitors, primary_monitor, Connection, CursorManager, CursorShape, EventLoop,
8
-    WindowConfig,
8
+    EventLoopConfig, Monitor, Window, WindowConfig,
9
 };
9
 };
10
 use x11rb::protocol::xproto::ConnectionExt;
10
 use x11rb::protocol::xproto::ConnectionExt;
11
 
11
 
12
 use crate::config::{Config, MonitorConfig, Profile};
12
 use crate::config::{Config, MonitorConfig, Profile};
13
-use crate::randr::RandrManager;
13
+use crate::randr::{OutputInfo, RandrManager};
14
-use crate::ui::{Button, Dropdown, DropdownAction, EventResult, MonitorView, TextInput};
14
+use crate::ui::{
15
+    Button, DisplayPanel, DisplayPanelResult, Dropdown, DropdownAction, EventResult, MonitorView,
16
+    TextInput,
17
+};
15
 
18
 
16
 /// Window dimensions.
19
 /// Window dimensions.
17
 const WINDOW_WIDTH: u32 = 800;
20
 const WINDOW_WIDTH: u32 = 800;
18
 const WINDOW_HEIGHT: u32 = 600;
21
 const WINDOW_HEIGHT: u32 = 600;
19
 
22
 
23
+/// Footer height in pixels.
24
+const FOOTER_HEIGHT: u32 = 100;
25
+
20
 /// Main application.
26
 /// Main application.
21
 pub struct App {
27
 pub struct App {
22
     #[allow(dead_code)] // Connection kept alive for X11 resources
28
     #[allow(dead_code)] // Connection kept alive for X11 resources
@@ -31,6 +37,13 @@ pub struct App {
31
     original_monitors: Vec<Monitor>,
37
     original_monitors: Vec<Monitor>,
32
     demo_mode: bool,
38
     demo_mode: bool,
33
     status_message: Option<(String, std::time::Instant)>,
39
     status_message: Option<(String, std::time::Instant)>,
40
+    // Display panel
41
+    display_panel: DisplayPanel,
42
+    randr_outputs: Vec<OutputInfo>,
43
+    last_selected: Option<usize>,
44
+    // Cursor management
45
+    cursor_manager: CursorManager,
46
+    current_cursor: CursorShape,
34
     // UI widgets
47
     // UI widgets
35
     current_profile: String,
48
     current_profile: String,
36
     dropdown_profiles: Dropdown,
49
     dropdown_profiles: Dropdown,
@@ -91,19 +104,34 @@ impl App {
91
         let theme = Theme::dark();
104
         let theme = Theme::dark();
92
         let renderer = Renderer::with_theme(WINDOW_WIDTH, WINDOW_HEIGHT, theme.clone())?;
105
         let renderer = Renderer::with_theme(WINDOW_WIDTH, WINDOW_HEIGHT, theme.clone())?;
93
 
106
 
94
-        // Create monitor view
107
+        // Create cursor manager
95
-        let view_rect = Rect::new(0, 0, WINDOW_WIDTH, WINDOW_HEIGHT - 100); // Leave room for controls
108
+        let cursor_manager = CursorManager::new(conn.clone())?;
109
+
110
+        // Calculate layout: monitor view (2/3) + display panel (1/3) + footer
111
+        let view_area = WINDOW_HEIGHT - FOOTER_HEIGHT;
112
+        let panel_height = view_area / 3;
113
+        let monitor_view_height = view_area - panel_height;
114
+
115
+        // Create monitor view (top 2/3)
116
+        let view_rect = Rect::new(0, 0, WINDOW_WIDTH, monitor_view_height);
96
         let mut monitor_view = MonitorView::new(view_rect);
117
         let mut monitor_view = MonitorView::new(view_rect);
97
 
118
 
119
+        // Create display panel (bottom 1/3 of view area)
120
+        let panel_rect = Rect::new(0, monitor_view_height as i32, WINDOW_WIDTH, panel_height);
121
+        let display_panel = DisplayPanel::new(panel_rect);
122
+
98
         // Create RandR manager (only in non-demo mode)
123
         // Create RandR manager (only in non-demo mode)
99
-        let randr = if demo {
124
+        let (randr, randr_outputs) = if demo {
100
-            None
125
+            (None, Vec::new())
101
         } else {
126
         } else {
102
             match RandrManager::new(conn.clone()) {
127
             match RandrManager::new(conn.clone()) {
103
-                Ok(r) => Some(r),
128
+                Ok(r) => {
129
+                    let outputs = r.get_outputs().unwrap_or_default();
130
+                    (Some(r), outputs)
131
+                }
104
                 Err(e) => {
132
                 Err(e) => {
105
                     tracing::warn!("failed to create RandR manager: {}", e);
133
                     tracing::warn!("failed to create RandR manager: {}", e);
106
-                    None
134
+                    (None, Vec::new())
107
                 }
135
                 }
108
             }
136
             }
109
         };
137
         };
@@ -139,7 +167,7 @@ impl App {
139
         }
167
         }
140
 
168
 
141
         // Create UI widgets (positioned in controls area)
169
         // Create UI widgets (positioned in controls area)
142
-        let controls_y = (WINDOW_HEIGHT - 100) as i32;
170
+        let controls_y = (WINDOW_HEIGHT - FOOTER_HEIGHT) as i32;
143
 
171
 
144
         // Buttons on the left
172
         // Buttons on the left
145
         let btn_apply = Button::new(10, controls_y + 60, 70, 32, "Apply");
173
         let btn_apply = Button::new(10, controls_y + 60, 70, 32, "Apply");
@@ -170,6 +198,11 @@ impl App {
170
             original_monitors,
198
             original_monitors,
171
             demo_mode: demo,
199
             demo_mode: demo,
172
             status_message: None,
200
             status_message: None,
201
+            display_panel,
202
+            randr_outputs,
203
+            last_selected: None,
204
+            cursor_manager,
205
+            current_cursor: CursorShape::Default,
173
             current_profile,
206
             current_profile,
174
             dropdown_profiles,
207
             dropdown_profiles,
175
             btn_apply,
208
             btn_apply,
@@ -334,7 +367,7 @@ impl App {
334
         if self.btn_save_as.handle_event(event) {
367
         if self.btn_save_as.handle_event(event) {
335
             // Show save-as input (appears above the dropdown)
368
             // Show save-as input (appears above the dropdown)
336
             let size = self.renderer.size();
369
             let size = self.renderer.size();
337
-            let controls_y = size.height.saturating_sub(100) as i32;
370
+            let controls_y = size.height.saturating_sub(FOOTER_HEIGHT) as i32;
338
             let mut input = TextInput::new(size.width as i32 - 260, controls_y + 25, 150, 32);
371
             let mut input = TextInput::new(size.width as i32 - 260, controls_y + 25, 150, 32);
339
             input.set_placeholder("Profile name");
372
             input.set_placeholder("Profile name");
340
             input.set_active(true);
373
             input.set_active(true);
@@ -342,6 +375,26 @@ impl App {
342
             return EventResult::Redraw;
375
             return EventResult::Redraw;
343
         }
376
         }
344
 
377
 
378
+        // Handle display panel events
379
+        match self.display_panel.handle_event(event) {
380
+            DisplayPanelResult::ConfigChanged(config) => {
381
+                if let Some(name) = self.display_panel.selected_output() {
382
+                    self.monitor_view.update_monitor_config(
383
+                        name,
384
+                        config.width,
385
+                        config.height,
386
+                        config.refresh,
387
+                        config.rotation,
388
+                        config.scale,
389
+                        config.enabled,
390
+                    );
391
+                }
392
+                return EventResult::Redraw;
393
+            }
394
+            DisplayPanelResult::Redraw => return EventResult::Redraw,
395
+            DisplayPanelResult::None => {}
396
+        }
397
+
345
         match event {
398
         match event {
346
             InputEvent::CloseRequested => EventResult::Quit,
399
             InputEvent::CloseRequested => EventResult::Quit,
347
             InputEvent::Key(e) if e.pressed && e.key == Key::Escape => EventResult::Quit,
400
             InputEvent::Key(e) if e.pressed && e.key == Key::Escape => EventResult::Quit,
@@ -374,7 +427,50 @@ impl App {
374
                 EventResult::Redraw
427
                 EventResult::Redraw
375
             }
428
             }
376
             InputEvent::Expose => EventResult::Redraw,
429
             InputEvent::Expose => EventResult::Redraw,
377
-            _ => self.monitor_view.handle_event(event),
430
+            _ => {
431
+                let result = self.monitor_view.handle_event(event);
432
+                // Check if selection changed and sync with display panel
433
+                let current_selected = self.monitor_view.selected();
434
+                if current_selected != self.last_selected {
435
+                    self.last_selected = current_selected;
436
+                    self.sync_panel_selection();
437
+                }
438
+                // Update cursor based on hover/drag state
439
+                self.update_cursor();
440
+                result
441
+            }
442
+        }
443
+    }
444
+
445
+    /// Update the cursor based on monitor view state.
446
+    fn update_cursor(&mut self) {
447
+        let shape = self.monitor_view.cursor_shape();
448
+        if shape != self.current_cursor {
449
+            self.current_cursor = shape;
450
+            if let Err(e) = self.cursor_manager.set_window_cursor(self.window.id(), shape) {
451
+                tracing::debug!("failed to set cursor: {}", e);
452
+            }
453
+        }
454
+    }
455
+
456
+    /// Sync the display panel with the selected monitor.
457
+    fn sync_panel_selection(&mut self) {
458
+        if let Some(state) = self.monitor_view.selected_monitor() {
459
+            // Find matching RandR output
460
+            let output = self.randr_outputs.iter().find(|o| o.name == state.info.name);
461
+
462
+            self.display_panel.set_selected_monitor(
463
+                output,
464
+                state.info.rect.width,
465
+                state.info.rect.height,
466
+                state.refresh,
467
+                state.rotation,
468
+                state.scale,
469
+                state.enabled,
470
+            );
471
+        } else {
472
+            self.display_panel
473
+                .set_selected_monitor(None, 0, 0, 60.0, 0, 1.0, true);
378
         }
474
         }
379
     }
475
     }
380
 
476
 
@@ -687,12 +783,21 @@ impl App {
687
             return;
783
             return;
688
         }
784
         }
689
 
785
 
690
-        // Update monitor view rect
786
+        // Calculate new layout
691
-        let view_rect = Rect::new(0, 0, size.width, size.height.saturating_sub(100));
787
+        let view_area = size.height.saturating_sub(FOOTER_HEIGHT);
788
+        let panel_height = view_area / 3;
789
+        let monitor_view_height = view_area - panel_height;
790
+
791
+        // Update monitor view rect (top 2/3)
792
+        let view_rect = Rect::new(0, 0, size.width, monitor_view_height);
692
         self.monitor_view.set_view_rect(view_rect);
793
         self.monitor_view.set_view_rect(view_rect);
693
 
794
 
694
-        // Reposition widgets
795
+        // Update display panel rect (bottom 1/3 of view area)
695
-        let controls_y = size.height.saturating_sub(100) as i32;
796
+        let panel_rect = Rect::new(0, monitor_view_height as i32, size.width, panel_height);
797
+        self.display_panel.set_rect(panel_rect);
798
+
799
+        // Reposition footer widgets
800
+        let controls_y = size.height.saturating_sub(FOOTER_HEIGHT) as i32;
696
 
801
 
697
         // Buttons on the left
802
         // Buttons on the left
698
         self.btn_apply.set_position(10, controls_y + 60);
803
         self.btn_apply.set_position(10, controls_y + 60);
@@ -717,10 +822,13 @@ impl App {
717
         // Render monitor view
822
         // Render monitor view
718
         self.monitor_view.render(&mut self.renderer, &self.theme)?;
823
         self.monitor_view.render(&mut self.renderer, &self.theme)?;
719
 
824
 
720
-        // Render bottom controls area
825
+        // Render display panel
826
+        self.display_panel.render(&self.renderer, &self.theme)?;
827
+
828
+        // Render bottom controls area (footer)
721
         let size = self.renderer.size();
829
         let size = self.renderer.size();
722
-        let controls_y = size.height.saturating_sub(100) as i32;
830
+        let controls_y = size.height.saturating_sub(FOOTER_HEIGHT) as i32;
723
-        let controls_rect = Rect::new(0, controls_y, size.width, 100);
831
+        let controls_rect = Rect::new(0, controls_y, size.width, FOOTER_HEIGHT);
724
         self.renderer
832
         self.renderer
725
             .fill_rect(controls_rect, self.theme.background)?;
833
             .fill_rect(controls_rect, self.theme.background)?;
726
 
834
 
gardisplay/src/ui/display_panel.rsadded
@@ -0,0 +1,434 @@
1
+//! Display settings panel for selected monitor.
2
+
3
+use std::collections::HashSet;
4
+
5
+use gartk_core::{InputEvent, Rect, Theme};
6
+use gartk_render::{Renderer, TextAlign, TextStyle};
7
+
8
+use crate::randr::{ModeInfo, OutputInfo};
9
+use crate::ui::widgets::{Dropdown, Toggle};
10
+
11
+/// Configuration values from the display panel.
12
+#[derive(Debug, Clone)]
13
+pub struct DisplayPanelConfig {
14
+    pub width: u32,
15
+    pub height: u32,
16
+    pub refresh: f64,
17
+    pub rotation: u32,
18
+    pub scale: f64,
19
+    pub enabled: bool,
20
+}
21
+
22
+/// Result of handling a panel event.
23
+#[derive(Debug)]
24
+pub enum DisplayPanelResult {
25
+    /// No action needed.
26
+    None,
27
+    /// Redraw the UI.
28
+    Redraw,
29
+    /// Configuration changed.
30
+    ConfigChanged(DisplayPanelConfig),
31
+}
32
+
33
+/// Display settings panel for the selected monitor.
34
+pub struct DisplayPanel {
35
+    rect: Rect,
36
+    // Widgets
37
+    resolution_dropdown: Dropdown,
38
+    refresh_dropdown: Dropdown,
39
+    rotation_dropdown: Dropdown,
40
+    scale_dropdown: Dropdown,
41
+    enabled_toggle: Toggle,
42
+    // State
43
+    selected_output: Option<String>,
44
+    available_modes: Vec<ModeInfo>,
45
+    // Current values
46
+    current_width: u32,
47
+    current_height: u32,
48
+    current_refresh: f64,
49
+    current_rotation: u32,
50
+    current_scale: f64,
51
+    current_enabled: bool,
52
+}
53
+
54
+impl DisplayPanel {
55
+    /// Create a new display panel.
56
+    pub fn new(rect: Rect) -> Self {
57
+        // Calculate widget positions based on panel rect
58
+        let row1_y = rect.y + 40;
59
+        let row2_y = rect.y + 80;
60
+        let col1_x = rect.x + 100;
61
+        let col2_x = rect.x + (rect.width as i32 / 2) + 80;
62
+        let dropdown_width = 120;
63
+        let dropdown_height = 28;
64
+
65
+        let resolution_dropdown = Dropdown::new(col1_x, row1_y, dropdown_width, dropdown_height);
66
+        let refresh_dropdown = Dropdown::new(col2_x, row1_y, 80, dropdown_height);
67
+        let rotation_dropdown = Dropdown::new(col1_x, row2_y, 80, dropdown_height);
68
+        let scale_dropdown = Dropdown::new(col2_x, row2_y, 80, dropdown_height);
69
+        let enabled_toggle = Toggle::new(
70
+            rect.x + rect.width as i32 - 150,
71
+            rect.y + 8,
72
+            140,
73
+            28,
74
+            "Enabled",
75
+        );
76
+
77
+        Self {
78
+            rect,
79
+            resolution_dropdown,
80
+            refresh_dropdown,
81
+            rotation_dropdown,
82
+            scale_dropdown,
83
+            enabled_toggle,
84
+            selected_output: None,
85
+            available_modes: Vec::new(),
86
+            current_width: 0,
87
+            current_height: 0,
88
+            current_refresh: 60.0,
89
+            current_rotation: 0,
90
+            current_scale: 1.0,
91
+            current_enabled: true,
92
+        }
93
+    }
94
+
95
+    /// Update panel to show settings for the selected monitor.
96
+    pub fn set_selected_monitor(
97
+        &mut self,
98
+        output: Option<&OutputInfo>,
99
+        width: u32,
100
+        height: u32,
101
+        refresh: f64,
102
+        rotation: u32,
103
+        scale: f64,
104
+        enabled: bool,
105
+    ) {
106
+        if let Some(output) = output {
107
+            self.selected_output = Some(output.name.clone());
108
+            self.available_modes = output.modes.clone();
109
+
110
+            // Populate resolution dropdown with unique resolutions
111
+            let resolutions: Vec<String> = output
112
+                .modes
113
+                .iter()
114
+                .map(|m| format!("{}x{}", m.width, m.height))
115
+                .collect::<HashSet<_>>()
116
+                .into_iter()
117
+                .collect();
118
+            let mut sorted_resolutions: Vec<String> = resolutions;
119
+            sorted_resolutions.sort_by(|a, b| {
120
+                // Sort by resolution (width * height) descending
121
+                let parse_res = |s: &str| -> u64 {
122
+                    let parts: Vec<&str> = s.split('x').collect();
123
+                    if parts.len() == 2 {
124
+                        parts[0].parse::<u64>().unwrap_or(0)
125
+                            * parts[1].parse::<u64>().unwrap_or(0)
126
+                    } else {
127
+                        0
128
+                    }
129
+                };
130
+                parse_res(b).cmp(&parse_res(a))
131
+            });
132
+            self.resolution_dropdown.set_items(sorted_resolutions);
133
+
134
+            // Set current resolution
135
+            let current_res = format!("{}x{}", width, height);
136
+            self.resolution_dropdown.set_selected_by_name(&current_res);
137
+            self.current_width = width;
138
+            self.current_height = height;
139
+
140
+            // Populate refresh rates for current resolution
141
+            self.update_refresh_dropdown(width, height);
142
+
143
+            // Set current refresh
144
+            let current_refresh_str = format!("{:.0}Hz", refresh);
145
+            self.refresh_dropdown.set_selected_by_name(&current_refresh_str);
146
+            self.current_refresh = refresh;
147
+
148
+            // Rotation options
149
+            self.rotation_dropdown
150
+                .set_items(vec!["0".to_string(), "90".to_string(), "180".to_string(), "270".to_string()]);
151
+            self.rotation_dropdown
152
+                .set_selected_by_name(&rotation.to_string());
153
+            self.current_rotation = rotation;
154
+
155
+            // Scale options
156
+            self.scale_dropdown.set_items(vec![
157
+                "1x".to_string(),
158
+                "1.25x".to_string(),
159
+                "1.5x".to_string(),
160
+                "1.75x".to_string(),
161
+                "2x".to_string(),
162
+            ]);
163
+            let scale_str = format!("{}x", scale);
164
+            self.scale_dropdown.set_selected_by_name(&scale_str);
165
+            self.current_scale = scale;
166
+
167
+            // Enable toggle
168
+            self.enabled_toggle.set_value(enabled);
169
+            self.current_enabled = enabled;
170
+        } else {
171
+            self.selected_output = None;
172
+            self.available_modes.clear();
173
+            self.resolution_dropdown.set_items(Vec::new());
174
+            self.refresh_dropdown.set_items(Vec::new());
175
+        }
176
+    }
177
+
178
+    /// Update refresh rate dropdown for the given resolution.
179
+    fn update_refresh_dropdown(&mut self, width: u32, height: u32) {
180
+        let refreshes: Vec<String> = self
181
+            .available_modes
182
+            .iter()
183
+            .filter(|m| m.width as u32 == width && m.height as u32 == height)
184
+            .map(|m| format!("{:.0}Hz", m.refresh))
185
+            .collect::<HashSet<_>>()
186
+            .into_iter()
187
+            .collect();
188
+        let mut sorted_refreshes: Vec<String> = refreshes;
189
+        sorted_refreshes.sort_by(|a, b| {
190
+            let parse_hz = |s: &str| -> f64 {
191
+                s.trim_end_matches("Hz").parse::<f64>().unwrap_or(0.0)
192
+            };
193
+            parse_hz(b).partial_cmp(&parse_hz(a)).unwrap_or(std::cmp::Ordering::Equal)
194
+        });
195
+        self.refresh_dropdown.set_items(sorted_refreshes);
196
+    }
197
+
198
+    /// Get the current configuration.
199
+    pub fn get_config(&self) -> Option<DisplayPanelConfig> {
200
+        if self.selected_output.is_some() {
201
+            Some(DisplayPanelConfig {
202
+                width: self.current_width,
203
+                height: self.current_height,
204
+                refresh: self.current_refresh,
205
+                rotation: self.current_rotation,
206
+                scale: self.current_scale,
207
+                enabled: self.current_enabled,
208
+            })
209
+        } else {
210
+            None
211
+        }
212
+    }
213
+
214
+    /// Get the selected output name.
215
+    pub fn selected_output(&self) -> Option<&str> {
216
+        self.selected_output.as_deref()
217
+    }
218
+
219
+    /// Set the panel rect.
220
+    pub fn set_rect(&mut self, rect: Rect) {
221
+        self.rect = rect;
222
+
223
+        // Recalculate widget positions
224
+        let row1_y = rect.y + 40;
225
+        let row2_y = rect.y + 80;
226
+        let col1_x = rect.x + 100;
227
+        let col2_x = rect.x + (rect.width as i32 / 2) + 80;
228
+
229
+        self.resolution_dropdown.set_position(col1_x, row1_y);
230
+        self.refresh_dropdown.set_position(col2_x, row1_y);
231
+        self.rotation_dropdown.set_position(col1_x, row2_y);
232
+        self.scale_dropdown.set_position(col2_x, row2_y);
233
+        self.enabled_toggle
234
+            .set_position(rect.x + rect.width as i32 - 150, rect.y + 8);
235
+    }
236
+
237
+    /// Handle an input event.
238
+    pub fn handle_event(&mut self, event: &InputEvent) -> DisplayPanelResult {
239
+        // Only handle events if we have a selected output
240
+        if self.selected_output.is_none() {
241
+            return DisplayPanelResult::None;
242
+        }
243
+
244
+        let mut config_changed = false;
245
+
246
+        // Handle enable toggle
247
+        if self.enabled_toggle.handle_event(event) {
248
+            self.current_enabled = self.enabled_toggle.value();
249
+            config_changed = true;
250
+        }
251
+
252
+        // Handle resolution dropdown
253
+        if let Some(action) = self.resolution_dropdown.handle_event(event) {
254
+            if let crate::ui::widgets::DropdownAction::Select(_) = action {
255
+                if let Some(res) = self.resolution_dropdown.selected_item() {
256
+                    // Parse resolution
257
+                    let parts: Vec<&str> = res.split('x').collect();
258
+                    if parts.len() == 2 {
259
+                        if let (Ok(w), Ok(h)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
260
+                            self.current_width = w;
261
+                            self.current_height = h;
262
+                            // Update refresh rates for new resolution
263
+                            self.update_refresh_dropdown(w, h);
264
+                            // Select first available refresh rate
265
+                            if let Some(first_refresh) = self.refresh_dropdown.selected_item() {
266
+                                let hz_str = first_refresh.trim_end_matches("Hz");
267
+                                if let Ok(hz) = hz_str.parse::<f64>() {
268
+                                    self.current_refresh = hz;
269
+                                }
270
+                            }
271
+                            config_changed = true;
272
+                        }
273
+                    }
274
+                }
275
+            }
276
+            return if config_changed {
277
+                DisplayPanelResult::ConfigChanged(self.get_config().unwrap())
278
+            } else {
279
+                DisplayPanelResult::Redraw
280
+            };
281
+        }
282
+
283
+        // Handle refresh dropdown
284
+        if let Some(action) = self.refresh_dropdown.handle_event(event) {
285
+            if let crate::ui::widgets::DropdownAction::Select(_) = action {
286
+                if let Some(refresh) = self.refresh_dropdown.selected_item() {
287
+                    let hz_str = refresh.trim_end_matches("Hz");
288
+                    if let Ok(hz) = hz_str.parse::<f64>() {
289
+                        self.current_refresh = hz;
290
+                        config_changed = true;
291
+                    }
292
+                }
293
+            }
294
+            return if config_changed {
295
+                DisplayPanelResult::ConfigChanged(self.get_config().unwrap())
296
+            } else {
297
+                DisplayPanelResult::Redraw
298
+            };
299
+        }
300
+
301
+        // Handle rotation dropdown
302
+        if let Some(action) = self.rotation_dropdown.handle_event(event) {
303
+            if let crate::ui::widgets::DropdownAction::Select(_) = action {
304
+                if let Some(rot) = self.rotation_dropdown.selected_item() {
305
+                    if let Ok(r) = rot.parse::<u32>() {
306
+                        self.current_rotation = r;
307
+                        config_changed = true;
308
+                    }
309
+                }
310
+            }
311
+            return if config_changed {
312
+                DisplayPanelResult::ConfigChanged(self.get_config().unwrap())
313
+            } else {
314
+                DisplayPanelResult::Redraw
315
+            };
316
+        }
317
+
318
+        // Handle scale dropdown
319
+        if let Some(action) = self.scale_dropdown.handle_event(event) {
320
+            if let crate::ui::widgets::DropdownAction::Select(_) = action {
321
+                if let Some(scale) = self.scale_dropdown.selected_item() {
322
+                    let scale_str = scale.trim_end_matches('x');
323
+                    if let Ok(s) = scale_str.parse::<f64>() {
324
+                        self.current_scale = s;
325
+                        config_changed = true;
326
+                    }
327
+                }
328
+            }
329
+            return if config_changed {
330
+                DisplayPanelResult::ConfigChanged(self.get_config().unwrap())
331
+            } else {
332
+                DisplayPanelResult::Redraw
333
+            };
334
+        }
335
+
336
+        // Check dropdown expand/collapse state changes
337
+        if self.resolution_dropdown.is_expanded()
338
+            || self.refresh_dropdown.is_expanded()
339
+            || self.rotation_dropdown.is_expanded()
340
+            || self.scale_dropdown.is_expanded()
341
+        {
342
+            return DisplayPanelResult::Redraw;
343
+        }
344
+
345
+        if config_changed {
346
+            DisplayPanelResult::ConfigChanged(self.get_config().unwrap())
347
+        } else {
348
+            DisplayPanelResult::None
349
+        }
350
+    }
351
+
352
+    /// Render the panel.
353
+    pub fn render(&self, renderer: &Renderer, theme: &Theme) -> anyhow::Result<()> {
354
+        // Panel background
355
+        renderer.fill_rect(self.rect, theme.background)?;
356
+
357
+        // Top border
358
+        renderer.line(
359
+            self.rect.x as f64,
360
+            self.rect.y as f64,
361
+            (self.rect.x + self.rect.width as i32) as f64,
362
+            self.rect.y as f64,
363
+            theme.border,
364
+            1.0,
365
+        )?;
366
+
367
+        let label_style = TextStyle::new()
368
+            .font_family(&theme.font_family)
369
+            .font_size(theme.font_size)
370
+            .color(theme.item_description)
371
+            .align(TextAlign::Left);
372
+
373
+        let title_style = TextStyle::new()
374
+            .font_family(&theme.font_family)
375
+            .font_size(theme.font_size + 2.0)
376
+            .color(theme.foreground)
377
+            .align(TextAlign::Left);
378
+
379
+        if let Some(ref name) = self.selected_output {
380
+            // Title: Monitor name
381
+            let title_rect = Rect::new(self.rect.x + 10, self.rect.y + 8, 200, 28);
382
+            renderer.text_in_rect(name, title_rect, &title_style)?;
383
+
384
+            // Enable toggle
385
+            self.enabled_toggle.render(renderer, theme)?;
386
+
387
+            // Row 1: Resolution and Refresh
388
+            let row1_y = self.rect.y + 45;
389
+            renderer.text_in_rect(
390
+                "Resolution:",
391
+                Rect::new(self.rect.x + 10, row1_y, 90, 28),
392
+                &label_style,
393
+            )?;
394
+            self.resolution_dropdown.render(renderer, theme)?;
395
+
396
+            renderer.text_in_rect(
397
+                "Refresh:",
398
+                Rect::new(self.rect.x + (self.rect.width as i32 / 2) + 10, row1_y, 70, 28),
399
+                &label_style,
400
+            )?;
401
+            self.refresh_dropdown.render(renderer, theme)?;
402
+
403
+            // Row 2: Rotation and Scale
404
+            let row2_y = self.rect.y + 85;
405
+            renderer.text_in_rect(
406
+                "Rotation:",
407
+                Rect::new(self.rect.x + 10, row2_y, 90, 28),
408
+                &label_style,
409
+            )?;
410
+            self.rotation_dropdown.render(renderer, theme)?;
411
+
412
+            renderer.text_in_rect(
413
+                "Scale:",
414
+                Rect::new(self.rect.x + (self.rect.width as i32 / 2) + 10, row2_y, 70, 28),
415
+                &label_style,
416
+            )?;
417
+            self.scale_dropdown.render(renderer, theme)?;
418
+        } else {
419
+            // No monitor selected
420
+            let hint_style = TextStyle::new()
421
+                .font_family(&theme.font_family)
422
+                .font_size(theme.font_size)
423
+                .color(theme.item_description)
424
+                .align(TextAlign::Center);
425
+            renderer.text_in_rect(
426
+                "Select a monitor to configure",
427
+                self.rect,
428
+                &hint_style,
429
+            )?;
430
+        }
431
+
432
+        Ok(())
433
+    }
434
+}
gardisplay/src/ui/mod.rsmodified
@@ -1,10 +1,12 @@
1
 //! UI components for gardisplay.
1
 //! UI components for gardisplay.
2
 
2
 
3
+mod display_panel;
3
 mod monitor_view;
4
 mod monitor_view;
4
 pub mod widgets;
5
 pub mod widgets;
5
 
6
 
7
+pub use display_panel::{DisplayPanel, DisplayPanelConfig, DisplayPanelResult};
6
 pub use monitor_view::MonitorView;
8
 pub use monitor_view::MonitorView;
7
-pub use widgets::{Button, Dropdown, DropdownAction, TextInput};
9
+pub use widgets::{Button, Dropdown, DropdownAction, TextInput, Toggle};
8
 
10
 
9
 /// Result of handling an event.
11
 /// Result of handling an event.
10
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
gardisplay/src/ui/monitor_view.rsmodified
@@ -2,7 +2,7 @@
2
 
2
 
3
 use gartk_core::{Color, InputEvent, MouseButton, Point, Rect, Theme};
3
 use gartk_core::{Color, InputEvent, MouseButton, Point, Rect, Theme};
4
 use gartk_render::Renderer;
4
 use gartk_render::Renderer;
5
-use gartk_x11::Monitor;
5
+use gartk_x11::{CursorShape, Monitor};
6
 use std::time::Instant;
6
 use std::time::Instant;
7
 
7
 
8
 use super::EventResult;
8
 use super::EventResult;
@@ -22,6 +22,14 @@ pub struct MonitorState {
22
     pub scaled_rect: Rect,
22
     pub scaled_rect: Rect,
23
     /// Real-world position (in actual pixels, updated after drag).
23
     /// Real-world position (in actual pixels, updated after drag).
24
     pub real_position: Point,
24
     pub real_position: Point,
25
+    /// Whether the monitor is enabled.
26
+    pub enabled: bool,
27
+    /// Refresh rate in Hz.
28
+    pub refresh: f64,
29
+    /// Rotation in degrees (0, 90, 180, 270).
30
+    pub rotation: u32,
31
+    /// Scale factor.
32
+    pub scale: f64,
25
 }
33
 }
26
 
34
 
27
 /// State for an active drag operation.
35
 /// State for an active drag operation.
@@ -96,6 +104,10 @@ impl MonitorView {
96
                     info,
104
                     info,
97
                     scaled_rect,
105
                     scaled_rect,
98
                     real_position,
106
                     real_position,
107
+                    enabled: true,
108
+                    refresh: 60.0, // Default, will be updated from RandR
109
+                    rotation: 0,
110
+                    scale: 1.0,
99
                 }
111
                 }
100
             })
112
             })
101
             .collect();
113
             .collect();
@@ -707,12 +719,40 @@ impl MonitorView {
707
         self.selected
719
         self.selected
708
     }
720
     }
709
 
721
 
722
+    /// Get the selected monitor state.
723
+    pub fn selected_monitor(&self) -> Option<&MonitorState> {
724
+        self.selected.and_then(|idx| self.monitors.get(idx))
725
+    }
726
+
710
     /// Get monitors.
727
     /// Get monitors.
711
     #[allow(dead_code)] // Used in Sprint 3 for RandR application
728
     #[allow(dead_code)] // Used in Sprint 3 for RandR application
712
     pub fn monitors(&self) -> &[MonitorState] {
729
     pub fn monitors(&self) -> &[MonitorState] {
713
         &self.monitors
730
         &self.monitors
714
     }
731
     }
715
 
732
 
733
+    /// Update a monitor's configuration by name.
734
+    pub fn update_monitor_config(
735
+        &mut self,
736
+        name: &str,
737
+        width: u32,
738
+        height: u32,
739
+        refresh: f64,
740
+        rotation: u32,
741
+        scale: f64,
742
+        enabled: bool,
743
+    ) {
744
+        if let Some(state) = self.monitors.iter_mut().find(|m| m.info.name == name) {
745
+            state.info.rect.width = width;
746
+            state.info.rect.height = height;
747
+            state.refresh = refresh;
748
+            state.rotation = rotation;
749
+            state.scale = scale;
750
+            state.enabled = enabled;
751
+            self.dirty = true;
752
+            self.recalculate_layout();
753
+        }
754
+    }
755
+
716
     /// Get mutable monitors.
756
     /// Get mutable monitors.
717
     pub fn monitors_mut(&mut self) -> &mut [MonitorState] {
757
     pub fn monitors_mut(&mut self) -> &mut [MonitorState] {
718
         &mut self.monitors
758
         &mut self.monitors
@@ -792,4 +832,15 @@ impl MonitorView {
792
             );
832
             );
793
         }
833
         }
794
     }
834
     }
835
+
836
+    /// Get the cursor shape based on current state.
837
+    pub fn cursor_shape(&self) -> CursorShape {
838
+        if self.dragging.is_some() {
839
+            CursorShape::Move // Grabbing - four-arrow move cursor
840
+        } else if self.hovered.is_some() {
841
+            CursorShape::Pointer // Grab - hand cursor
842
+        } else {
843
+            CursorShape::Default
844
+        }
845
+    }
795
 }
846
 }
gardisplay/src/ui/widgets/mod.rsmodified
@@ -3,7 +3,9 @@
3
 mod button;
3
 mod button;
4
 mod dropdown;
4
 mod dropdown;
5
 mod text_input;
5
 mod text_input;
6
+mod toggle;
6
 
7
 
7
 pub use button::Button;
8
 pub use button::Button;
8
 pub use dropdown::{Dropdown, DropdownAction};
9
 pub use dropdown::{Dropdown, DropdownAction};
9
 pub use text_input::TextInput;
10
 pub use text_input::TextInput;
11
+pub use toggle::Toggle;
gardisplay/src/ui/widgets/toggle.rsadded
@@ -0,0 +1,119 @@
1
+//! Toggle switch widget.
2
+
3
+use gartk_core::{Color, InputEvent, MouseButton, Point, Rect, Theme};
4
+use gartk_render::{Renderer, TextAlign, TextStyle};
5
+
6
+/// A toggle switch (on/off).
7
+pub struct Toggle {
8
+    rect: Rect,
9
+    label: String,
10
+    value: bool,
11
+    hovered: bool,
12
+}
13
+
14
+impl Toggle {
15
+    /// Create a new toggle.
16
+    pub fn new(x: i32, y: i32, width: u32, height: u32, label: &str) -> Self {
17
+        Self {
18
+            rect: Rect::new(x, y, width, height),
19
+            label: label.to_string(),
20
+            value: true,
21
+            hovered: false,
22
+        }
23
+    }
24
+
25
+    /// Set toggle position.
26
+    pub fn set_position(&mut self, x: i32, y: i32) {
27
+        self.rect.x = x;
28
+        self.rect.y = y;
29
+    }
30
+
31
+    /// Get current value.
32
+    pub fn value(&self) -> bool {
33
+        self.value
34
+    }
35
+
36
+    /// Set toggle value.
37
+    pub fn set_value(&mut self, value: bool) {
38
+        self.value = value;
39
+    }
40
+
41
+    /// Check if a point is inside the toggle.
42
+    fn contains(&self, point: Point) -> bool {
43
+        self.rect.contains_point(point)
44
+    }
45
+
46
+    /// Handle input event. Returns true if toggled.
47
+    pub fn handle_event(&mut self, event: &InputEvent) -> bool {
48
+        match event {
49
+            InputEvent::MouseMove(e) => {
50
+                self.hovered = self.contains(e.position);
51
+                false
52
+            }
53
+            InputEvent::MousePress(e) if e.button == Some(MouseButton::Left) => {
54
+                if self.contains(e.position) {
55
+                    self.value = !self.value;
56
+                    return true;
57
+                }
58
+                false
59
+            }
60
+            _ => false,
61
+        }
62
+    }
63
+
64
+    /// Render the toggle.
65
+    pub fn render(&self, renderer: &Renderer, theme: &Theme) -> anyhow::Result<()> {
66
+        // Layout: [Label] [==O] or [Label] [O==]
67
+        let track_width = 40u32;
68
+        let track_height = 20u32;
69
+        let knob_radius = 8.0;
70
+
71
+        // Label on the left
72
+        let label_rect = Rect::new(
73
+            self.rect.x,
74
+            self.rect.y,
75
+            self.rect.width.saturating_sub(track_width + 8),
76
+            self.rect.height,
77
+        );
78
+        let style = TextStyle::new()
79
+            .font_family(&theme.font_family)
80
+            .font_size(theme.font_size)
81
+            .color(theme.foreground)
82
+            .align(TextAlign::Left);
83
+        renderer.text_in_rect(&self.label, label_rect, &style)?;
84
+
85
+        // Track on the right
86
+        let track_x = self.rect.x + self.rect.width as i32 - track_width as i32;
87
+        let track_y = self.rect.y + (self.rect.height as i32 - track_height as i32) / 2;
88
+        let track_rect = Rect::new(track_x, track_y, track_width, track_height);
89
+
90
+        // Track color based on value
91
+        let track_color = if self.value {
92
+            Color::new(0.2, 0.7, 0.4, 1.0) // Green when on
93
+        } else {
94
+            theme.item_background
95
+        };
96
+
97
+        renderer.fill_rounded_rect(track_rect, (track_height / 2) as f64, track_color)?;
98
+
99
+        // Border
100
+        let border_color = if self.hovered {
101
+            theme.selection_background
102
+        } else {
103
+            theme.border
104
+        };
105
+        renderer.stroke_rounded_rect(track_rect, (track_height / 2) as f64, border_color, 1.0)?;
106
+
107
+        // Knob
108
+        let knob_x = if self.value {
109
+            track_x as f64 + track_width as f64 - knob_radius - 4.0
110
+        } else {
111
+            track_x as f64 + knob_radius + 4.0
112
+        };
113
+        let knob_y = track_y as f64 + track_height as f64 / 2.0;
114
+
115
+        renderer.fill_circle(knob_x, knob_y, knob_radius, theme.foreground)?;
116
+
117
+        Ok(())
118
+    }
119
+}