initial gardisplay structure with basic monitor view
- SHA
2bbd7d51c8dc7e6761d0353c0552e353ba753708- Tree
2f651a3
2bbd7d5
2bbd7d51c8dc7e6761d0353c0552e353ba7537082f651a3| Status | File | + | - |
|---|---|---|---|
| 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 | +} | |