@@ -9,9 +9,9 @@ use gartk_x11::{ |
| 9 | 9 | }; |
| 10 | 10 | use x11rb::protocol::xproto::ConnectionExt; |
| 11 | 11 | |
| 12 | | -use crate::config::{Config, MonitorConfig}; |
| 12 | +use crate::config::{Config, MonitorConfig, Profile}; |
| 13 | 13 | use crate::randr::RandrManager; |
| 14 | | -use crate::ui::{EventResult, MonitorView}; |
| 14 | +use crate::ui::{Button, EventResult, MonitorView}; |
| 15 | 15 | |
| 16 | 16 | /// Window dimensions. |
| 17 | 17 | const WINDOW_WIDTH: u32 = 800; |
@@ -25,13 +25,16 @@ pub struct App { |
| 25 | 25 | renderer: Renderer, |
| 26 | 26 | theme: Theme, |
| 27 | 27 | gc: u32, |
| 28 | | - #[allow(dead_code)] // Used for profile management |
| 29 | 28 | config: Config, |
| 30 | 29 | monitor_view: MonitorView, |
| 31 | 30 | randr: Option<RandrManager>, |
| 32 | 31 | original_monitors: Vec<Monitor>, |
| 33 | 32 | demo_mode: bool, |
| 34 | 33 | status_message: Option<(String, std::time::Instant)>, |
| 34 | + // UI buttons |
| 35 | + btn_apply: Button, |
| 36 | + btn_revert: Button, |
| 37 | + btn_save: Button, |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | impl App { |
@@ -125,6 +128,18 @@ impl App { |
| 125 | 128 | let original_monitors = monitors.clone(); |
| 126 | 129 | monitor_view.set_monitors(monitors); |
| 127 | 130 | |
| 131 | + // Try to load saved profile |
| 132 | + if let Some(profile) = config.profiles.get(&config.general.default_profile) { |
| 133 | + tracing::info!("loading profile '{}'", config.general.default_profile); |
| 134 | + Self::apply_profile_to_view(&mut monitor_view, profile); |
| 135 | + } |
| 136 | + |
| 137 | + // Create UI buttons (positioned in controls area) |
| 138 | + let controls_y = (WINDOW_HEIGHT - 100) as i32; |
| 139 | + let btn_apply = Button::new(WINDOW_WIDTH as i32 - 280, controls_y + 30, 80, 32, "Apply"); |
| 140 | + let btn_revert = Button::new(WINDOW_WIDTH as i32 - 190, controls_y + 30, 80, 32, "Revert"); |
| 141 | + let btn_save = Button::new(WINDOW_WIDTH as i32 - 100, controls_y + 30, 80, 32, "Save"); |
| 142 | + |
| 128 | 143 | Ok(Self { |
| 129 | 144 | conn, |
| 130 | 145 | window, |
@@ -137,9 +152,43 @@ impl App { |
| 137 | 152 | original_monitors, |
| 138 | 153 | demo_mode: demo, |
| 139 | 154 | status_message: None, |
| 155 | + btn_apply, |
| 156 | + btn_revert, |
| 157 | + btn_save, |
| 140 | 158 | }) |
| 141 | 159 | } |
| 142 | 160 | |
| 161 | + /// Apply a saved profile to the monitor view. |
| 162 | + fn apply_profile_to_view(view: &mut MonitorView, profile: &Profile) { |
| 163 | + // Build a map of monitor name -> config |
| 164 | + let config_map: std::collections::HashMap<&str, &MonitorConfig> = profile |
| 165 | + .monitors |
| 166 | + .iter() |
| 167 | + .map(|m| (m.name.as_str(), m)) |
| 168 | + .collect(); |
| 169 | + |
| 170 | + // Update monitor positions from profile |
| 171 | + for state in view.monitors_mut() { |
| 172 | + if let Some(config) = config_map.get(state.info.name.as_str()) { |
| 173 | + state.real_position = gartk_core::Point::new(config.x, config.y); |
| 174 | + tracing::debug!( |
| 175 | + "loaded {} at ({}, {})", |
| 176 | + state.info.name, |
| 177 | + config.x, |
| 178 | + config.y |
| 179 | + ); |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + // Set primary monitor |
| 184 | + if let Some(ref primary) = profile.primary { |
| 185 | + view.set_primary(primary); |
| 186 | + } |
| 187 | + |
| 188 | + // Recalculate scaling |
| 189 | + view.recalculate_layout(); |
| 190 | + } |
| 191 | + |
| 143 | 192 | /// Create demo monitors for UI testing. |
| 144 | 193 | fn demo_monitors() -> Vec<gartk_x11::Monitor> { |
| 145 | 194 | vec![ |
@@ -200,6 +249,26 @@ impl App { |
| 200 | 249 | |
| 201 | 250 | /// Handle an input event. |
| 202 | 251 | fn handle_event(&mut self, event: &InputEvent) -> EventResult { |
| 252 | + // Handle button clicks |
| 253 | + if self.btn_apply.handle_event(event) { |
| 254 | + self.apply_layout(); |
| 255 | + return EventResult::Redraw; |
| 256 | + } |
| 257 | + if self.btn_revert.handle_event(event) { |
| 258 | + self.revert_layout(); |
| 259 | + return EventResult::Redraw; |
| 260 | + } |
| 261 | + if self.btn_save.handle_event(event) { |
| 262 | + self.save_profile(); |
| 263 | + return EventResult::Redraw; |
| 264 | + } |
| 265 | + |
| 266 | + // Check if any button needs redraw from hover |
| 267 | + if matches!(event, InputEvent::MouseMove(_)) { |
| 268 | + // Buttons handle their own hover state |
| 269 | + return EventResult::Redraw; |
| 270 | + } |
| 271 | + |
| 203 | 272 | match event { |
| 204 | 273 | InputEvent::CloseRequested => EventResult::Quit, |
| 205 | 274 | InputEvent::Key(e) if e.pressed && e.key == Key::Escape => EventResult::Quit, |
@@ -222,6 +291,11 @@ impl App { |
| 222 | 291 | self.revert_layout(); |
| 223 | 292 | EventResult::Redraw |
| 224 | 293 | } |
| 294 | + // Save: Ctrl+S |
| 295 | + InputEvent::Key(e) if e.pressed && e.modifiers.ctrl && e.key == Key::Char('s') => { |
| 296 | + self.save_profile(); |
| 297 | + EventResult::Redraw |
| 298 | + } |
| 225 | 299 | InputEvent::Resize { width, height } => { |
| 226 | 300 | self.handle_resize(Size::new(*width, *height)); |
| 227 | 301 | EventResult::Redraw |
@@ -365,6 +439,47 @@ impl App { |
| 365 | 439 | self.status_message = Some((message.to_string(), std::time::Instant::now())); |
| 366 | 440 | } |
| 367 | 441 | |
| 442 | + /// Save current layout as a profile. |
| 443 | + fn save_profile(&mut self) { |
| 444 | + let primary_name = self.monitor_view.primary_name().map(|s| s.to_string()); |
| 445 | + |
| 446 | + // Build profile from current layout |
| 447 | + let monitors: Vec<MonitorConfig> = self |
| 448 | + .monitor_view |
| 449 | + .monitors() |
| 450 | + .iter() |
| 451 | + .map(|state| MonitorConfig { |
| 452 | + name: state.info.name.clone(), |
| 453 | + enabled: true, |
| 454 | + x: state.real_position.x, |
| 455 | + y: state.real_position.y, |
| 456 | + width: state.info.rect.width, |
| 457 | + height: state.info.rect.height, |
| 458 | + refresh: 60.0, |
| 459 | + scale: 1.0, |
| 460 | + rotation: 0, |
| 461 | + }) |
| 462 | + .collect(); |
| 463 | + |
| 464 | + let profile = Profile { |
| 465 | + primary: primary_name, |
| 466 | + monitors, |
| 467 | + }; |
| 468 | + |
| 469 | + // Save to default profile |
| 470 | + self.config |
| 471 | + .profiles |
| 472 | + .insert("default".to_string(), profile); |
| 473 | + |
| 474 | + match self.config.save() { |
| 475 | + Ok(()) => self.set_status("Saved profile"), |
| 476 | + Err(e) => { |
| 477 | + tracing::error!("failed to save profile: {}", e); |
| 478 | + self.set_status("Failed to save profile"); |
| 479 | + } |
| 480 | + } |
| 481 | + } |
| 482 | + |
| 368 | 483 | /// Handle window resize. |
| 369 | 484 | fn handle_resize(&mut self, size: Size) { |
| 370 | 485 | tracing::debug!("resize to {}x{}", size.width, size.height); |
@@ -378,6 +493,12 @@ impl App { |
| 378 | 493 | // Update monitor view rect |
| 379 | 494 | let view_rect = Rect::new(0, 0, size.width, size.height.saturating_sub(100)); |
| 380 | 495 | self.monitor_view.set_view_rect(view_rect); |
| 496 | + |
| 497 | + // Reposition buttons |
| 498 | + let controls_y = size.height.saturating_sub(100) as i32; |
| 499 | + self.btn_apply.set_position(size.width as i32 - 280, controls_y + 30); |
| 500 | + self.btn_revert.set_position(size.width as i32 - 190, controls_y + 30); |
| 501 | + self.btn_save.set_position(size.width as i32 - 100, controls_y + 30); |
| 381 | 502 | } |
| 382 | 503 | |
| 383 | 504 | /// Render the application. |
@@ -405,46 +526,50 @@ impl App { |
| 405 | 526 | 1.0, |
| 406 | 527 | )?; |
| 407 | 528 | |
| 408 | | - // Instructions |
| 529 | + // Left side: Instructions and status |
| 409 | 530 | self.renderer.text_default( |
| 410 | | - "Drag monitors to arrange | Double-click to set primary", |
| 531 | + "Drag monitors to arrange | Double-click: set primary", |
| 411 | 532 | 10.0, |
| 412 | | - (controls_y + 20) as f64, |
| 533 | + (controls_y + 15) as f64, |
| 413 | 534 | self.theme.foreground, |
| 414 | 535 | )?; |
| 415 | 536 | |
| 416 | | - // Keyboard shortcuts |
| 537 | + // Keyboard shortcuts hint |
| 417 | 538 | self.renderer.text_default( |
| 418 | | - "Enter: Apply | Backspace: Revert | Q/Esc: Quit", |
| 539 | + "Ctrl+S: Save | Ctrl+A: Apply | Ctrl+R: Revert", |
| 419 | 540 | 10.0, |
| 420 | | - (controls_y + 40) as f64, |
| 541 | + (controls_y + 35) as f64, |
| 421 | 542 | self.theme.item_description, |
| 422 | 543 | )?; |
| 423 | 544 | |
| 424 | 545 | // Status message (show for 3 seconds) |
| 425 | 546 | if let Some((ref msg, instant)) = self.status_message { |
| 426 | 547 | if instant.elapsed().as_secs() < 3 { |
| 427 | | - // Green for success, theme color otherwise |
| 428 | | - let color = if msg.contains("error") { |
| 548 | + let color = if msg.contains("error") || msg.contains("Failed") { |
| 429 | 549 | Color::new(1.0, 0.4, 0.4, 1.0) // Red |
| 430 | 550 | } else { |
| 431 | 551 | Color::new(0.4, 0.8, 0.4, 1.0) // Green |
| 432 | 552 | }; |
| 433 | 553 | self.renderer |
| 434 | | - .text_default(msg, 10.0, (controls_y + 70) as f64, color)?; |
| 554 | + .text_default(msg, 10.0, (controls_y + 60) as f64, color)?; |
| 435 | 555 | } |
| 436 | 556 | } |
| 437 | 557 | |
| 438 | | - // Dirty indicator |
| 558 | + // Dirty indicator (above buttons) |
| 439 | 559 | if self.monitor_view.is_dirty() { |
| 440 | 560 | self.renderer.text_default( |
| 441 | | - "(unsaved changes)", |
| 442 | | - (size.width - 150) as f64, |
| 443 | | - (controls_y + 20) as f64, |
| 561 | + "* unsaved", |
| 562 | + (size.width - 280) as f64, |
| 563 | + (controls_y + 15) as f64, |
| 444 | 564 | Color::new(1.0, 0.7, 0.3, 1.0), // Orange |
| 445 | 565 | )?; |
| 446 | 566 | } |
| 447 | 567 | |
| 568 | + // Render buttons |
| 569 | + self.btn_apply.render(&self.renderer, &self.theme)?; |
| 570 | + self.btn_revert.render(&self.renderer, &self.theme)?; |
| 571 | + self.btn_save.render(&self.renderer, &self.theme)?; |
| 572 | + |
| 448 | 573 | // Blit to window |
| 449 | 574 | copy_surface_to_window(self.renderer.surface_mut(), &self.window, self.gc, 0, 0)?; |
| 450 | 575 | |