initial skeleton: workspace, x11 composite, event loop, IPC
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
c44f0c46bfc9d16055d4ee78e2689641c4ec0c89- Tree
e79d9e8
c44f0c4
c44f0c46bfc9d16055d4ee78e2689641c4ec0c89e79d9e8| Status | File | + | - |
|---|---|---|---|
| A |
.gitignore
|
23 | 0 |
| A |
Cargo.toml
|
35 | 0 |
| A |
garchomp-ipc/Cargo.toml
|
10 | 0 |
| A |
garchomp-ipc/src/lib.rs
|
65 | 0 |
| A |
garchomp/Cargo.toml
|
19 | 0 |
| A |
garchomp/src/compositor/mod.rs
|
344 | 0 |
| A |
garchomp/src/compositor/window.rs
|
93 | 0 |
| A |
garchomp/src/ipc/mod.rs
|
98 | 0 |
| A |
garchomp/src/main.rs
|
165 | 0 |
| A |
garchomp/src/x11/atoms.rs
|
63 | 0 |
| A |
garchomp/src/x11/composite.rs
|
121 | 0 |
| A |
garchomp/src/x11/connection.rs
|
131 | 0 |
| A |
garchomp/src/x11/mod.rs
|
9 | 0 |
| A |
garchompctl/Cargo.toml
|
12 | 0 |
| A |
garchompctl/src/main.rs
|
98 | 0 |
.gitignoreadded@@ -0,0 +1,23 @@ | ||
| 1 | +# Build artifacts | |
| 2 | +/target/ | |
| 3 | +**/*.rs.bk | |
| 4 | +Cargo.lock | |
| 5 | + | |
| 6 | +# IDE | |
| 7 | +.idea/ | |
| 8 | +.vscode/ | |
| 9 | +*.swp | |
| 10 | +*.swo | |
| 11 | +*~ | |
| 12 | + | |
| 13 | +# Documentation (planning files) | |
| 14 | +/docs/ROADMAP.md | |
| 15 | +/docs/sprints/ | |
| 16 | + | |
| 17 | +# Logs | |
| 18 | +*.log | |
| 19 | +/tmp/ | |
| 20 | + | |
| 21 | +# OS | |
| 22 | +.DS_Store | |
| 23 | +Thumbs.db | |
Cargo.tomladded@@ -0,0 +1,35 @@ | ||
| 1 | +[workspace] | |
| 2 | +resolver = "2" | |
| 3 | +members = ["garchomp", "garchompctl", "garchomp-ipc"] | |
| 4 | + | |
| 5 | +[workspace.package] | |
| 6 | +version = "0.1.0" | |
| 7 | +edition = "2024" | |
| 8 | +authors = ["gardesk contributors"] | |
| 9 | +license = "MIT" | |
| 10 | +repository = "https://github.com/gardesk/garchomp" | |
| 11 | + | |
| 12 | +[workspace.dependencies] | |
| 13 | +# X11 | |
| 14 | +x11rb = { version = "0.13", features = ["allow-unsafe-code", "randr", "composite", "damage", "xfixes", "shape", "render"] } | |
| 15 | + | |
| 16 | +# Async | |
| 17 | +tokio = { version = "1", features = ["full", "signal"] } | |
| 18 | + | |
| 19 | +# Serialization | |
| 20 | +serde = { version = "1", features = ["derive"] } | |
| 21 | +serde_json = "1" | |
| 22 | + | |
| 23 | +# CLI | |
| 24 | +clap = { version = "4", features = ["derive"] } | |
| 25 | + | |
| 26 | +# Logging | |
| 27 | +tracing = "0.1" | |
| 28 | +tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |
| 29 | + | |
| 30 | +# Errors | |
| 31 | +thiserror = "2" | |
| 32 | +anyhow = "1" | |
| 33 | + | |
| 34 | +# Internal | |
| 35 | +garchomp-ipc = { path = "garchomp-ipc" } | |
garchomp-ipc/Cargo.tomladded@@ -0,0 +1,10 @@ | ||
| 1 | +[package] | |
| 2 | +name = "garchomp-ipc" | |
| 3 | +description = "IPC types for garchomp compositor" | |
| 4 | +version.workspace = true | |
| 5 | +edition.workspace = true | |
| 6 | +license.workspace = true | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +serde = { workspace = true } | |
| 10 | +serde_json = { workspace = true } | |
garchomp-ipc/src/lib.rsadded@@ -0,0 +1,65 @@ | ||
| 1 | +//! IPC protocol types for garchomp compositor. | |
| 2 | + | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | + | |
| 5 | +/// Request sent to garchomp. | |
| 6 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 7 | +#[serde(tag = "type", rename_all = "snake_case")] | |
| 8 | +pub enum Request { | |
| 9 | + /// Reload configuration. | |
| 10 | + Reload, | |
| 11 | + /// Toggle an effect. | |
| 12 | + SetEffect { effect: String, enabled: bool }, | |
| 13 | + /// Set blur strength (0-20). | |
| 14 | + SetBlurStrength { strength: u32 }, | |
| 15 | + /// Get window information. | |
| 16 | + GetWindowInfo { window: u32 }, | |
| 17 | + /// List all managed windows. | |
| 18 | + ListWindows, | |
| 19 | + /// Ping for health check. | |
| 20 | + Ping, | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// Response from garchomp. | |
| 24 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 25 | +#[serde(tag = "type", rename_all = "snake_case")] | |
| 26 | +pub enum Response { | |
| 27 | + /// Success with no data. | |
| 28 | + Ok, | |
| 29 | + /// Error response. | |
| 30 | + Error { message: String }, | |
| 31 | + /// Pong response to ping. | |
| 32 | + Pong, | |
| 33 | + /// Window information. | |
| 34 | + WindowInfo(WindowInfo), | |
| 35 | + /// List of windows. | |
| 36 | + WindowList { windows: Vec<WindowInfo> }, | |
| 37 | +} | |
| 38 | + | |
| 39 | +/// Information about a managed window. | |
| 40 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 41 | +pub struct WindowInfo { | |
| 42 | + pub id: u32, | |
| 43 | + pub x: i16, | |
| 44 | + pub y: i16, | |
| 45 | + pub width: u16, | |
| 46 | + pub height: u16, | |
| 47 | + pub mapped: bool, | |
| 48 | + pub override_redirect: bool, | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Events sent from gar WM to garchomp. | |
| 52 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 53 | +#[serde(tag = "type", rename_all = "snake_case")] | |
| 54 | +pub enum GarEvent { | |
| 55 | + /// Workspace changed. | |
| 56 | + WorkspaceChanged { | |
| 57 | + from: usize, | |
| 58 | + to: usize, | |
| 59 | + direction: String, | |
| 60 | + }, | |
| 61 | + /// Focus changed. | |
| 62 | + FocusChanged { old: Option<u32>, new: Option<u32> }, | |
| 63 | + /// Window entered fullscreen. | |
| 64 | + WindowFullscreen { window: u32, fullscreen: bool }, | |
| 65 | +} | |
garchomp/Cargo.tomladded@@ -0,0 +1,19 @@ | ||
| 1 | +[package] | |
| 2 | +name = "garchomp" | |
| 3 | +description = "X11 compositor for gar desktop environment" | |
| 4 | +version.workspace = true | |
| 5 | +edition.workspace = true | |
| 6 | +license.workspace = true | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +garchomp-ipc = { workspace = true } | |
| 10 | +x11rb = { workspace = true } | |
| 11 | +tokio = { workspace = true } | |
| 12 | +serde = { workspace = true } | |
| 13 | +serde_json = { workspace = true } | |
| 14 | +clap = { workspace = true, features = ["env"] } | |
| 15 | +tracing = { workspace = true } | |
| 16 | +tracing-subscriber = { workspace = true } | |
| 17 | +thiserror = { workspace = true } | |
| 18 | +anyhow = { workspace = true } | |
| 19 | +ctrlc = "3" | |
garchomp/src/compositor/mod.rsadded@@ -0,0 +1,344 @@ | ||
| 1 | +//! Core compositor state and event handling. | |
| 2 | + | |
| 3 | +mod window; | |
| 4 | + | |
| 5 | +pub use window::{TrackedWindow, WindowType}; | |
| 6 | + | |
| 7 | +use crate::x11::{CompositeExt, Connection}; | |
| 8 | +use std::collections::HashMap; | |
| 9 | +use thiserror::Error; | |
| 10 | +use x11rb::connection::Connection as _; | |
| 11 | +use x11rb::protocol::damage::{ConnectionExt as DamageConnectionExt, ReportLevel}; | |
| 12 | +use x11rb::protocol::xproto::{ | |
| 13 | + AtomEnum, ChangeWindowAttributesAux, ConfigureNotifyEvent, ConnectionExt, CreateNotifyEvent, | |
| 14 | + DestroyNotifyEvent, EventMask, MapNotifyEvent, PropertyNotifyEvent, | |
| 15 | + UnmapNotifyEvent, Window, | |
| 16 | +}; | |
| 17 | +use x11rb::protocol::Event; | |
| 18 | + | |
| 19 | +#[derive(Error, Debug)] | |
| 20 | +pub enum CompositorError { | |
| 21 | + #[error("X11 connection error: {0}")] | |
| 22 | + Connection(#[from] crate::x11::ConnectionError), | |
| 23 | + | |
| 24 | + #[error("X11 error: {0}")] | |
| 25 | + X11(#[from] x11rb::errors::ConnectionError), | |
| 26 | + | |
| 27 | + #[error("X11 reply error: {0}")] | |
| 28 | + Reply(#[from] x11rb::errors::ReplyError), | |
| 29 | +} | |
| 30 | + | |
| 31 | +pub type Result<T> = std::result::Result<T, CompositorError>; | |
| 32 | + | |
| 33 | +/// The main compositor state. | |
| 34 | +pub struct Compositor { | |
| 35 | + pub conn: Connection, | |
| 36 | + pub overlay: Window, | |
| 37 | + pub windows: HashMap<Window, TrackedWindow>, | |
| 38 | + pub running: bool, | |
| 39 | +} | |
| 40 | + | |
| 41 | +impl Compositor { | |
| 42 | + /// Create a new compositor instance. | |
| 43 | + pub fn new() -> Result<Self> { | |
| 44 | + let conn = Connection::new()?; | |
| 45 | + | |
| 46 | + // Redirect all windows for compositing | |
| 47 | + conn.redirect_subwindows()?; | |
| 48 | + | |
| 49 | + // Get the overlay window | |
| 50 | + let overlay = conn.get_overlay_window()?; | |
| 51 | + conn.configure_overlay(overlay)?; | |
| 52 | + | |
| 53 | + // Subscribe to events on root window | |
| 54 | + let event_mask = EventMask::SUBSTRUCTURE_NOTIFY | |
| 55 | + | EventMask::STRUCTURE_NOTIFY | |
| 56 | + | EventMask::PROPERTY_CHANGE; | |
| 57 | + | |
| 58 | + conn.conn.change_window_attributes( | |
| 59 | + conn.root(), | |
| 60 | + &ChangeWindowAttributesAux::new().event_mask(event_mask), | |
| 61 | + )?; | |
| 62 | + | |
| 63 | + conn.flush()?; | |
| 64 | + | |
| 65 | + let mut compositor = Self { | |
| 66 | + conn, | |
| 67 | + overlay, | |
| 68 | + windows: HashMap::new(), | |
| 69 | + running: true, | |
| 70 | + }; | |
| 71 | + | |
| 72 | + // Scan existing windows | |
| 73 | + compositor.scan_windows()?; | |
| 74 | + | |
| 75 | + Ok(compositor) | |
| 76 | + } | |
| 77 | + | |
| 78 | + /// Scan for existing windows on startup. | |
| 79 | + fn scan_windows(&mut self) -> Result<()> { | |
| 80 | + let tree = self.conn.conn.query_tree(self.conn.root())?.reply()?; | |
| 81 | + | |
| 82 | + // Collect windows to track (avoid borrow issues) | |
| 83 | + let mut to_track = Vec::new(); | |
| 84 | + for window in tree.children { | |
| 85 | + if window == self.overlay { | |
| 86 | + continue; | |
| 87 | + } | |
| 88 | + | |
| 89 | + if let Ok(attrs) = self.conn.conn.get_window_attributes(window) { | |
| 90 | + if let Ok(attrs) = attrs.reply() { | |
| 91 | + if attrs.map_state != x11rb::protocol::xproto::MapState::UNMAPPED { | |
| 92 | + to_track.push(window); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + for window in to_track { | |
| 99 | + self.track_window(window)?; | |
| 100 | + } | |
| 101 | + | |
| 102 | + tracing::info!("Scanned {} existing windows", self.windows.len()); | |
| 103 | + Ok(()) | |
| 104 | + } | |
| 105 | + | |
| 106 | + /// Start tracking a window. | |
| 107 | + fn track_window(&mut self, window: Window) -> Result<()> { | |
| 108 | + if self.windows.contains_key(&window) || window == self.overlay { | |
| 109 | + return Ok(()); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // Get window geometry | |
| 113 | + let geom = self.conn.conn.get_geometry(window)?.reply()?; | |
| 114 | + let attrs = self.conn.conn.get_window_attributes(window)?.reply()?; | |
| 115 | + | |
| 116 | + // Get window pixmap for compositing | |
| 117 | + let pixmap = self.conn.name_window_pixmap(window).ok(); | |
| 118 | + | |
| 119 | + // Create damage tracking for this window | |
| 120 | + let damage = self.conn.generate_id()?; | |
| 121 | + self.conn | |
| 122 | + .conn | |
| 123 | + .damage_create(damage, window, ReportLevel::NON_EMPTY)?; | |
| 124 | + | |
| 125 | + // Subscribe to property changes on the window | |
| 126 | + self.conn.conn.change_window_attributes( | |
| 127 | + window, | |
| 128 | + &ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE), | |
| 129 | + )?; | |
| 130 | + | |
| 131 | + let tracked = TrackedWindow { | |
| 132 | + id: window, | |
| 133 | + pixmap, | |
| 134 | + damage, | |
| 135 | + x: geom.x, | |
| 136 | + y: geom.y, | |
| 137 | + width: geom.width, | |
| 138 | + height: geom.height, | |
| 139 | + border_width: geom.border_width, | |
| 140 | + mapped: true, | |
| 141 | + override_redirect: attrs.override_redirect, | |
| 142 | + window_type: WindowType::Normal, | |
| 143 | + opacity: 1.0, | |
| 144 | + damaged: true, | |
| 145 | + }; | |
| 146 | + | |
| 147 | + tracing::debug!( | |
| 148 | + "Tracking window {:#x}: {}x{}+{}+{} override_redirect={}", | |
| 149 | + window, | |
| 150 | + geom.width, | |
| 151 | + geom.height, | |
| 152 | + geom.x, | |
| 153 | + geom.y, | |
| 154 | + attrs.override_redirect | |
| 155 | + ); | |
| 156 | + | |
| 157 | + self.windows.insert(window, tracked); | |
| 158 | + Ok(()) | |
| 159 | + } | |
| 160 | + | |
| 161 | + /// Stop tracking a window. | |
| 162 | + pub fn untrack_window(&mut self, window: Window) { | |
| 163 | + if let Some(tracked) = self.windows.remove(&window) { | |
| 164 | + // Destroy damage object | |
| 165 | + let _ = self.conn.conn.damage_destroy(tracked.damage); | |
| 166 | + | |
| 167 | + // Free pixmap | |
| 168 | + if let Some(pixmap) = tracked.pixmap { | |
| 169 | + let _ = self.conn.conn.free_pixmap(pixmap); | |
| 170 | + } | |
| 171 | + | |
| 172 | + tracing::debug!("Untracked window {:#x}", window); | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + /// Run the compositor event loop. | |
| 177 | + pub fn run(&mut self) -> Result<()> { | |
| 178 | + tracing::info!("Starting compositor event loop"); | |
| 179 | + | |
| 180 | + while self.running { | |
| 181 | + let event = self.conn.conn.wait_for_event()?; | |
| 182 | + self.handle_event(event)?; | |
| 183 | + } | |
| 184 | + | |
| 185 | + Ok(()) | |
| 186 | + } | |
| 187 | + | |
| 188 | + /// Handle a single X11 event. | |
| 189 | + pub fn handle_event(&mut self, event: Event) -> Result<()> { | |
| 190 | + match event { | |
| 191 | + Event::CreateNotify(e) => self.handle_create(e)?, | |
| 192 | + Event::DestroyNotify(e) => self.handle_destroy(e), | |
| 193 | + Event::MapNotify(e) => self.handle_map(e)?, | |
| 194 | + Event::UnmapNotify(e) => self.handle_unmap(e), | |
| 195 | + Event::ConfigureNotify(e) => self.handle_configure(e)?, | |
| 196 | + Event::PropertyNotify(e) => self.handle_property(e)?, | |
| 197 | + Event::Error(e) => { | |
| 198 | + tracing::warn!("X11 error: {:?}", e); | |
| 199 | + } | |
| 200 | + Event::Unknown(raw) => { | |
| 201 | + // Check for damage events by response type (first byte) | |
| 202 | + if !raw.is_empty() { | |
| 203 | + let response_type = raw[0] & 0x7f; // Mask out the "sent" flag | |
| 204 | + if response_type == self.conn.damage_event_base { | |
| 205 | + if raw.len() >= 8 { | |
| 206 | + let drawable = u32::from_ne_bytes([raw[4], raw[5], raw[6], raw[7]]); | |
| 207 | + if let Some(tracked) = self.windows.get_mut(&drawable) { | |
| 208 | + tracked.damaged = true; | |
| 209 | + let _ = self.conn.conn.damage_subtract(tracked.damage, 0u32, 0u32); | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + } | |
| 214 | + } | |
| 215 | + _ => {} | |
| 216 | + } | |
| 217 | + | |
| 218 | + Ok(()) | |
| 219 | + } | |
| 220 | + | |
| 221 | + fn handle_create(&mut self, event: CreateNotifyEvent) -> Result<()> { | |
| 222 | + tracing::trace!("CreateNotify: window {:#x}", event.window); | |
| 223 | + Ok(()) | |
| 224 | + } | |
| 225 | + | |
| 226 | + fn handle_destroy(&mut self, event: DestroyNotifyEvent) { | |
| 227 | + tracing::trace!("DestroyNotify: window {:#x}", event.window); | |
| 228 | + self.untrack_window(event.window); | |
| 229 | + } | |
| 230 | + | |
| 231 | + fn handle_map(&mut self, event: MapNotifyEvent) -> Result<()> { | |
| 232 | + tracing::trace!("MapNotify: window {:#x}", event.window); | |
| 233 | + | |
| 234 | + if let Some(tracked) = self.windows.get_mut(&event.window) { | |
| 235 | + tracked.mapped = true; | |
| 236 | + tracked.damaged = true; | |
| 237 | + | |
| 238 | + // Get fresh pixmap | |
| 239 | + if let Some(old_pixmap) = tracked.pixmap.take() { | |
| 240 | + let _ = self.conn.conn.free_pixmap(old_pixmap); | |
| 241 | + } | |
| 242 | + tracked.pixmap = self.conn.name_window_pixmap(event.window).ok(); | |
| 243 | + } else { | |
| 244 | + self.track_window(event.window)?; | |
| 245 | + } | |
| 246 | + | |
| 247 | + Ok(()) | |
| 248 | + } | |
| 249 | + | |
| 250 | + fn handle_unmap(&mut self, event: UnmapNotifyEvent) { | |
| 251 | + tracing::trace!("UnmapNotify: window {:#x}", event.window); | |
| 252 | + | |
| 253 | + if let Some(tracked) = self.windows.get_mut(&event.window) { | |
| 254 | + tracked.mapped = false; | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + fn handle_configure(&mut self, event: ConfigureNotifyEvent) -> Result<()> { | |
| 259 | + if let Some(tracked) = self.windows.get_mut(&event.window) { | |
| 260 | + let size_changed = | |
| 261 | + tracked.width != event.width || tracked.height != event.height; | |
| 262 | + | |
| 263 | + tracked.x = event.x; | |
| 264 | + tracked.y = event.y; | |
| 265 | + tracked.width = event.width; | |
| 266 | + tracked.height = event.height; | |
| 267 | + tracked.border_width = event.border_width; | |
| 268 | + tracked.damaged = true; | |
| 269 | + | |
| 270 | + if size_changed { | |
| 271 | + // Need new pixmap for resized window | |
| 272 | + if let Some(old_pixmap) = tracked.pixmap.take() { | |
| 273 | + let _ = self.conn.conn.free_pixmap(old_pixmap); | |
| 274 | + } | |
| 275 | + if tracked.mapped { | |
| 276 | + tracked.pixmap = self.conn.name_window_pixmap(event.window).ok(); | |
| 277 | + } | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + Ok(()) | |
| 282 | + } | |
| 283 | + | |
| 284 | + fn handle_property(&mut self, event: PropertyNotifyEvent) -> Result<()> { | |
| 285 | + // Check for opacity changes | |
| 286 | + if event.atom == self.conn.atoms._NET_WM_WINDOW_OPACITY { | |
| 287 | + let opacity = self.get_window_opacity(event.window); | |
| 288 | + if let Some(tracked) = self.windows.get_mut(&event.window) { | |
| 289 | + tracked.opacity = opacity; | |
| 290 | + tracked.damaged = true; | |
| 291 | + } | |
| 292 | + } | |
| 293 | + | |
| 294 | + Ok(()) | |
| 295 | + } | |
| 296 | + | |
| 297 | + fn get_window_opacity(&self, window: Window) -> f32 { | |
| 298 | + let cookie = match self.conn.conn.get_property( | |
| 299 | + false, | |
| 300 | + window, | |
| 301 | + self.conn.atoms._NET_WM_WINDOW_OPACITY, | |
| 302 | + AtomEnum::CARDINAL, | |
| 303 | + 0, | |
| 304 | + 1, | |
| 305 | + ) { | |
| 306 | + Ok(c) => c, | |
| 307 | + Err(_) => return 1.0, | |
| 308 | + }; | |
| 309 | + | |
| 310 | + match cookie.reply() { | |
| 311 | + Ok(reply) => { | |
| 312 | + if let Some(value) = reply.value32().and_then(|mut v| v.next()) { | |
| 313 | + value as f32 / u32::MAX as f32 | |
| 314 | + } else { | |
| 315 | + 1.0 | |
| 316 | + } | |
| 317 | + } | |
| 318 | + Err(_) => 1.0, | |
| 319 | + } | |
| 320 | + } | |
| 321 | + | |
| 322 | + /// Shutdown the compositor cleanly. | |
| 323 | + pub fn shutdown(&mut self) -> Result<()> { | |
| 324 | + tracing::info!("Shutting down compositor"); | |
| 325 | + | |
| 326 | + // Clean up tracked windows | |
| 327 | + let windows: Vec<Window> = self.windows.keys().copied().collect(); | |
| 328 | + for window in windows { | |
| 329 | + self.untrack_window(window); | |
| 330 | + } | |
| 331 | + | |
| 332 | + // Release overlay | |
| 333 | + self.conn.release_overlay_window()?; | |
| 334 | + self.conn.flush()?; | |
| 335 | + | |
| 336 | + Ok(()) | |
| 337 | + } | |
| 338 | +} | |
| 339 | + | |
| 340 | +impl Drop for Compositor { | |
| 341 | + fn drop(&mut self) { | |
| 342 | + let _ = self.shutdown(); | |
| 343 | + } | |
| 344 | +} | |
garchomp/src/compositor/window.rsadded@@ -0,0 +1,93 @@ | ||
| 1 | +//! Window tracking types. | |
| 2 | + | |
| 3 | +use x11rb::protocol::xproto::{Pixmap, Window}; | |
| 4 | + | |
| 5 | +/// Type of window for compositor effects. | |
| 6 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 7 | +pub enum WindowType { | |
| 8 | + Normal, | |
| 9 | + Desktop, | |
| 10 | + Dock, | |
| 11 | + Toolbar, | |
| 12 | + Menu, | |
| 13 | + Utility, | |
| 14 | + Splash, | |
| 15 | + Dialog, | |
| 16 | + DropdownMenu, | |
| 17 | + PopupMenu, | |
| 18 | + Tooltip, | |
| 19 | + Notification, | |
| 20 | + Combo, | |
| 21 | + Dnd, | |
| 22 | +} | |
| 23 | + | |
| 24 | +impl Default for WindowType { | |
| 25 | + fn default() -> Self { | |
| 26 | + Self::Normal | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// A window being tracked by the compositor. | |
| 31 | +#[derive(Debug)] | |
| 32 | +pub struct TrackedWindow { | |
| 33 | + /// X11 window ID. | |
| 34 | + pub id: Window, | |
| 35 | + /// Pixmap containing window contents. | |
| 36 | + pub pixmap: Option<Pixmap>, | |
| 37 | + /// Damage object for tracking changes. | |
| 38 | + pub damage: u32, | |
| 39 | + /// X position. | |
| 40 | + pub x: i16, | |
| 41 | + /// Y position. | |
| 42 | + pub y: i16, | |
| 43 | + /// Width. | |
| 44 | + pub width: u16, | |
| 45 | + /// Height. | |
| 46 | + pub height: u16, | |
| 47 | + /// Border width. | |
| 48 | + pub border_width: u16, | |
| 49 | + /// Whether the window is mapped (visible). | |
| 50 | + pub mapped: bool, | |
| 51 | + /// Whether this is an override-redirect window (unmanaged). | |
| 52 | + pub override_redirect: bool, | |
| 53 | + /// Window type for effect decisions. | |
| 54 | + pub window_type: WindowType, | |
| 55 | + /// Window opacity (0.0 - 1.0). | |
| 56 | + pub opacity: f32, | |
| 57 | + /// Whether the window has been damaged since last render. | |
| 58 | + pub damaged: bool, | |
| 59 | +} | |
| 60 | + | |
| 61 | +impl TrackedWindow { | |
| 62 | + /// Check if this window should have effects applied. | |
| 63 | + pub fn should_have_effects(&self) -> bool { | |
| 64 | + match self.window_type { | |
| 65 | + WindowType::Desktop | WindowType::Dock => false, | |
| 66 | + WindowType::Tooltip | WindowType::PopupMenu | WindowType::DropdownMenu => false, | |
| 67 | + _ => true, | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Check if this window should have shadows. | |
| 72 | + pub fn should_have_shadow(&self) -> bool { | |
| 73 | + match self.window_type { | |
| 74 | + WindowType::Desktop | WindowType::Dock => false, | |
| 75 | + WindowType::Tooltip | WindowType::Menu => false, | |
| 76 | + _ => !self.override_redirect, | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Check if this window should have blur behind. | |
| 81 | + pub fn should_have_blur(&self) -> bool { | |
| 82 | + self.should_have_effects() && self.opacity < 1.0 | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Check if this window should have rounded corners. | |
| 86 | + pub fn should_have_corners(&self) -> bool { | |
| 87 | + match self.window_type { | |
| 88 | + WindowType::Desktop | WindowType::Dock => false, | |
| 89 | + WindowType::Tooltip | WindowType::Menu => false, | |
| 90 | + _ => true, | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
garchomp/src/ipc/mod.rsadded@@ -0,0 +1,98 @@ | ||
| 1 | +//! IPC server for garchomp control. | |
| 2 | + | |
| 3 | +use garchomp_ipc::{Request, Response}; | |
| 4 | +use std::io::{BufRead, BufReader, Write}; | |
| 5 | +use std::os::unix::net::{UnixListener, UnixStream}; | |
| 6 | +use std::path::PathBuf; | |
| 7 | +use thiserror::Error; | |
| 8 | + | |
| 9 | +#[derive(Error, Debug)] | |
| 10 | +pub enum IpcError { | |
| 11 | + #[error("IO error: {0}")] | |
| 12 | + Io(#[from] std::io::Error), | |
| 13 | + | |
| 14 | + #[error("JSON error: {0}")] | |
| 15 | + Json(#[from] serde_json::Error), | |
| 16 | +} | |
| 17 | + | |
| 18 | +pub type Result<T> = std::result::Result<T, IpcError>; | |
| 19 | + | |
| 20 | +/// IPC server for compositor control. | |
| 21 | +pub struct IpcServer { | |
| 22 | + listener: UnixListener, | |
| 23 | + socket_path: PathBuf, | |
| 24 | +} | |
| 25 | + | |
| 26 | +impl IpcServer { | |
| 27 | + /// Create a new IPC server. | |
| 28 | + pub fn new() -> Result<Self> { | |
| 29 | + let socket_path = Self::socket_path(); | |
| 30 | + | |
| 31 | + // Remove existing socket | |
| 32 | + let _ = std::fs::remove_file(&socket_path); | |
| 33 | + | |
| 34 | + let listener = UnixListener::bind(&socket_path)?; | |
| 35 | + listener.set_nonblocking(true)?; | |
| 36 | + | |
| 37 | + tracing::info!("IPC server listening at {:?}", socket_path); | |
| 38 | + | |
| 39 | + Ok(Self { | |
| 40 | + listener, | |
| 41 | + socket_path, | |
| 42 | + }) | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// Get the socket path. | |
| 46 | + fn socket_path() -> PathBuf { | |
| 47 | + let runtime_dir = | |
| 48 | + std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); | |
| 49 | + PathBuf::from(runtime_dir).join("garchomp.sock") | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Poll for incoming connections (non-blocking). | |
| 53 | + pub fn poll(&mut self) -> Option<ClientRequest> { | |
| 54 | + match self.listener.accept() { | |
| 55 | + Ok((stream, _)) => { | |
| 56 | + if let Ok(request) = Self::read_request(&stream) { | |
| 57 | + Some(ClientRequest { stream, request }) | |
| 58 | + } else { | |
| 59 | + None | |
| 60 | + } | |
| 61 | + } | |
| 62 | + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => None, | |
| 63 | + Err(e) => { | |
| 64 | + tracing::warn!("IPC accept error: {}", e); | |
| 65 | + None | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + fn read_request(stream: &UnixStream) -> Result<Request> { | |
| 71 | + let mut reader = BufReader::new(stream); | |
| 72 | + let mut line = String::new(); | |
| 73 | + reader.read_line(&mut line)?; | |
| 74 | + Ok(serde_json::from_str(&line)?) | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +impl Drop for IpcServer { | |
| 79 | + fn drop(&mut self) { | |
| 80 | + let _ = std::fs::remove_file(&self.socket_path); | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +/// A client request with the stream for responding. | |
| 85 | +pub struct ClientRequest { | |
| 86 | + stream: UnixStream, | |
| 87 | + pub request: Request, | |
| 88 | +} | |
| 89 | + | |
| 90 | +impl ClientRequest { | |
| 91 | + /// Send a response to the client. | |
| 92 | + pub fn respond(mut self, response: Response) -> Result<()> { | |
| 93 | + let mut msg = serde_json::to_string(&response)?; | |
| 94 | + msg.push('\n'); | |
| 95 | + self.stream.write_all(msg.as_bytes())?; | |
| 96 | + Ok(()) | |
| 97 | + } | |
| 98 | +} | |
garchomp/src/main.rsadded@@ -0,0 +1,165 @@ | ||
| 1 | +//! garchomp - X11 compositor for gar desktop environment. | |
| 2 | + | |
| 3 | +mod compositor; | |
| 4 | +mod ipc; | |
| 5 | +mod x11; | |
| 6 | + | |
| 7 | +use anyhow::{Context, Result}; | |
| 8 | +use clap::Parser; | |
| 9 | +use garchomp_ipc::{Request, Response, WindowInfo}; | |
| 10 | +use std::sync::atomic::{AtomicBool, Ordering}; | |
| 11 | +use std::sync::Arc; | |
| 12 | +use tracing_subscriber::EnvFilter; | |
| 13 | +use x11rb::connection::Connection as _; | |
| 14 | + | |
| 15 | +#[derive(Parser)] | |
| 16 | +#[command(name = "garchomp", about = "X11 compositor for gar desktop environment")] | |
| 17 | +struct Cli { | |
| 18 | + /// X11 display to connect to. | |
| 19 | + #[arg(short, long, env = "DISPLAY")] | |
| 20 | + display: Option<String>, | |
| 21 | + | |
| 22 | + /// Path to configuration file. | |
| 23 | + #[arg(short, long, default_value = "~/.config/gar/init.lua")] | |
| 24 | + config: String, | |
| 25 | + | |
| 26 | + /// Increase logging verbosity. | |
| 27 | + #[arg(short, long, action = clap::ArgAction::Count)] | |
| 28 | + verbose: u8, | |
| 29 | + | |
| 30 | + /// Disable HDR support. | |
| 31 | + #[arg(long)] | |
| 32 | + no_hdr: bool, | |
| 33 | +} | |
| 34 | + | |
| 35 | +fn main() -> Result<()> { | |
| 36 | + let cli = Cli::parse(); | |
| 37 | + | |
| 38 | + // Initialize logging | |
| 39 | + init_logging(cli.verbose); | |
| 40 | + | |
| 41 | + tracing::info!("garchomp compositor starting"); | |
| 42 | + | |
| 43 | + // Set up signal handling | |
| 44 | + let running = Arc::new(AtomicBool::new(true)); | |
| 45 | + setup_signal_handlers(running.clone())?; | |
| 46 | + | |
| 47 | + // Create compositor | |
| 48 | + let mut compositor = | |
| 49 | + compositor::Compositor::new().context("Failed to initialize compositor")?; | |
| 50 | + | |
| 51 | + // Create IPC server | |
| 52 | + let mut ipc_server = ipc::IpcServer::new().context("Failed to start IPC server")?; | |
| 53 | + | |
| 54 | + tracing::info!( | |
| 55 | + "Compositor initialized, tracking {} windows", | |
| 56 | + compositor.windows.len() | |
| 57 | + ); | |
| 58 | + | |
| 59 | + // Main event loop | |
| 60 | + while running.load(Ordering::Relaxed) && compositor.running { | |
| 61 | + // Handle IPC requests (non-blocking) | |
| 62 | + while let Some(request) = ipc_server.poll() { | |
| 63 | + handle_ipc_request(&mut compositor, request); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Handle X11 events (non-blocking poll) | |
| 67 | + if let Ok(Some(event)) = compositor.conn.conn.poll_for_event() { | |
| 68 | + if let Err(e) = compositor.handle_event(event) { | |
| 69 | + tracing::error!("Error handling event: {}", e); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + // Small sleep to prevent busy-waiting | |
| 74 | + // TODO: Use proper event-driven approach with poll/select | |
| 75 | + std::thread::sleep(std::time::Duration::from_millis(1)); | |
| 76 | + } | |
| 77 | + | |
| 78 | + tracing::info!("Shutting down"); | |
| 79 | + compositor.shutdown()?; | |
| 80 | + | |
| 81 | + Ok(()) | |
| 82 | +} | |
| 83 | + | |
| 84 | +fn init_logging(verbosity: u8) { | |
| 85 | + let filter = match verbosity { | |
| 86 | + 0 => "garchomp=info", | |
| 87 | + 1 => "garchomp=debug", | |
| 88 | + _ => "garchomp=trace", | |
| 89 | + }; | |
| 90 | + | |
| 91 | + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)); | |
| 92 | + | |
| 93 | + tracing_subscriber::fmt() | |
| 94 | + .with_env_filter(filter) | |
| 95 | + .with_target(false) | |
| 96 | + .init(); | |
| 97 | +} | |
| 98 | + | |
| 99 | +fn setup_signal_handlers(running: Arc<AtomicBool>) -> Result<()> { | |
| 100 | + ctrlc::set_handler(move || { | |
| 101 | + tracing::info!("Received shutdown signal"); | |
| 102 | + running.store(false, Ordering::Relaxed); | |
| 103 | + }) | |
| 104 | + .context("Failed to set signal handler")?; | |
| 105 | + | |
| 106 | + Ok(()) | |
| 107 | +} | |
| 108 | + | |
| 109 | +fn handle_ipc_request(compositor: &mut compositor::Compositor, request: ipc::ClientRequest) { | |
| 110 | + let response = match &request.request { | |
| 111 | + Request::Ping => Response::Pong, | |
| 112 | + Request::Reload => { | |
| 113 | + tracing::info!("Config reload requested"); | |
| 114 | + // TODO: Implement config reload | |
| 115 | + Response::Ok | |
| 116 | + } | |
| 117 | + Request::SetEffect { effect, enabled } => { | |
| 118 | + tracing::info!("Set effect {} = {}", effect, enabled); | |
| 119 | + // TODO: Implement effect toggle | |
| 120 | + Response::Ok | |
| 121 | + } | |
| 122 | + Request::SetBlurStrength { strength } => { | |
| 123 | + tracing::info!("Set blur strength = {}", strength); | |
| 124 | + // TODO: Implement blur strength | |
| 125 | + Response::Ok | |
| 126 | + } | |
| 127 | + Request::GetWindowInfo { window } => { | |
| 128 | + if let Some(tracked) = compositor.windows.get(&(*window as u32)) { | |
| 129 | + Response::WindowInfo(WindowInfo { | |
| 130 | + id: tracked.id, | |
| 131 | + x: tracked.x, | |
| 132 | + y: tracked.y, | |
| 133 | + width: tracked.width, | |
| 134 | + height: tracked.height, | |
| 135 | + mapped: tracked.mapped, | |
| 136 | + override_redirect: tracked.override_redirect, | |
| 137 | + }) | |
| 138 | + } else { | |
| 139 | + Response::Error { | |
| 140 | + message: "Window not found".into(), | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + Request::ListWindows => { | |
| 145 | + let windows: Vec<WindowInfo> = compositor | |
| 146 | + .windows | |
| 147 | + .values() | |
| 148 | + .map(|w| WindowInfo { | |
| 149 | + id: w.id, | |
| 150 | + x: w.x, | |
| 151 | + y: w.y, | |
| 152 | + width: w.width, | |
| 153 | + height: w.height, | |
| 154 | + mapped: w.mapped, | |
| 155 | + override_redirect: w.override_redirect, | |
| 156 | + }) | |
| 157 | + .collect(); | |
| 158 | + Response::WindowList { windows } | |
| 159 | + } | |
| 160 | + }; | |
| 161 | + | |
| 162 | + if let Err(e) = request.respond(response) { | |
| 163 | + tracing::warn!("Failed to send IPC response: {}", e); | |
| 164 | + } | |
| 165 | +} | |
garchomp/src/x11/atoms.rsadded@@ -0,0 +1,63 @@ | ||
| 1 | +//! X11 atom definitions for EWMH and compositor protocols. | |
| 2 | + | |
| 3 | +use x11rb::atom_manager; | |
| 4 | + | |
| 5 | +atom_manager! { | |
| 6 | + /// Atoms used by garchomp. | |
| 7 | + pub Atoms: AtomsCookie { | |
| 8 | + // ICCCM | |
| 9 | + WM_PROTOCOLS, | |
| 10 | + WM_DELETE_WINDOW, | |
| 11 | + WM_STATE, | |
| 12 | + WM_TRANSIENT_FOR, | |
| 13 | + | |
| 14 | + // EWMH - Window types | |
| 15 | + _NET_WM_WINDOW_TYPE, | |
| 16 | + _NET_WM_WINDOW_TYPE_DESKTOP, | |
| 17 | + _NET_WM_WINDOW_TYPE_DOCK, | |
| 18 | + _NET_WM_WINDOW_TYPE_TOOLBAR, | |
| 19 | + _NET_WM_WINDOW_TYPE_MENU, | |
| 20 | + _NET_WM_WINDOW_TYPE_UTILITY, | |
| 21 | + _NET_WM_WINDOW_TYPE_SPLASH, | |
| 22 | + _NET_WM_WINDOW_TYPE_DIALOG, | |
| 23 | + _NET_WM_WINDOW_TYPE_DROPDOWN_MENU, | |
| 24 | + _NET_WM_WINDOW_TYPE_POPUP_MENU, | |
| 25 | + _NET_WM_WINDOW_TYPE_TOOLTIP, | |
| 26 | + _NET_WM_WINDOW_TYPE_NOTIFICATION, | |
| 27 | + _NET_WM_WINDOW_TYPE_COMBO, | |
| 28 | + _NET_WM_WINDOW_TYPE_DND, | |
| 29 | + _NET_WM_WINDOW_TYPE_NORMAL, | |
| 30 | + | |
| 31 | + // EWMH - Window state | |
| 32 | + _NET_WM_STATE, | |
| 33 | + _NET_WM_STATE_MODAL, | |
| 34 | + _NET_WM_STATE_STICKY, | |
| 35 | + _NET_WM_STATE_MAXIMIZED_VERT, | |
| 36 | + _NET_WM_STATE_MAXIMIZED_HORZ, | |
| 37 | + _NET_WM_STATE_SHADED, | |
| 38 | + _NET_WM_STATE_SKIP_TASKBAR, | |
| 39 | + _NET_WM_STATE_SKIP_PAGER, | |
| 40 | + _NET_WM_STATE_HIDDEN, | |
| 41 | + _NET_WM_STATE_FULLSCREEN, | |
| 42 | + _NET_WM_STATE_ABOVE, | |
| 43 | + _NET_WM_STATE_BELOW, | |
| 44 | + _NET_WM_STATE_DEMANDS_ATTENTION, | |
| 45 | + _NET_WM_STATE_FOCUSED, | |
| 46 | + | |
| 47 | + // EWMH - Active window | |
| 48 | + _NET_ACTIVE_WINDOW, | |
| 49 | + _NET_CLIENT_LIST, | |
| 50 | + _NET_CLIENT_LIST_STACKING, | |
| 51 | + | |
| 52 | + // Compositor bypass | |
| 53 | + _NET_WM_BYPASS_COMPOSITOR, | |
| 54 | + | |
| 55 | + // Opacity | |
| 56 | + _NET_WM_WINDOW_OPACITY, | |
| 57 | + | |
| 58 | + // Compositor-specific | |
| 59 | + _GARCHOMP_COLORSPACE, | |
| 60 | + _GARCHOMP_MAX_LUMINANCE, | |
| 61 | + _GARCHOMP_MIN_LUMINANCE, | |
| 62 | + } | |
| 63 | +} | |
garchomp/src/x11/composite.rsadded@@ -0,0 +1,121 @@ | ||
| 1 | +//! X11 Composite extension operations. | |
| 2 | + | |
| 3 | +use super::connection::{Connection, Result}; | |
| 4 | +use x11rb::protocol::composite::{ConnectionExt as _, Redirect}; | |
| 5 | +use x11rb::protocol::xfixes::ConnectionExt as XfixesConnectionExt; | |
| 6 | +use x11rb::protocol::xproto::{ | |
| 7 | + AtomEnum, ConfigureWindowAux, ConnectionExt as _, Pixmap, StackMode, Window, | |
| 8 | +}; | |
| 9 | + | |
| 10 | +/// Extension trait for Composite operations on Connection. | |
| 11 | +pub trait CompositeExt { | |
| 12 | + /// Redirect all subwindows of root for compositing. | |
| 13 | + fn redirect_subwindows(&self) -> Result<()>; | |
| 14 | + | |
| 15 | + /// Get the composite overlay window. | |
| 16 | + fn get_overlay_window(&self) -> Result<Window>; | |
| 17 | + | |
| 18 | + /// Release the overlay window. | |
| 19 | + fn release_overlay_window(&self) -> Result<()>; | |
| 20 | + | |
| 21 | + /// Get a pixmap for a window's contents. | |
| 22 | + fn name_window_pixmap(&self, window: Window) -> Result<Pixmap>; | |
| 23 | + | |
| 24 | + /// Unredirect a window (for fullscreen bypass). | |
| 25 | + fn unredirect_window(&self, window: Window) -> Result<()>; | |
| 26 | + | |
| 27 | + /// Re-redirect a window. | |
| 28 | + fn redirect_window(&self, window: Window) -> Result<()>; | |
| 29 | + | |
| 30 | + /// Configure the overlay window to cover the screen. | |
| 31 | + fn configure_overlay(&self, overlay: Window) -> Result<()>; | |
| 32 | + | |
| 33 | + /// Check if a window wants to bypass the compositor. | |
| 34 | + fn wants_bypass(&self, window: Window) -> Result<bool>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl CompositeExt for Connection { | |
| 38 | + fn redirect_subwindows(&self) -> Result<()> { | |
| 39 | + self.conn | |
| 40 | + .composite_redirect_subwindows(self.root(), Redirect::AUTOMATIC)?; | |
| 41 | + tracing::debug!("Redirected subwindows for compositing"); | |
| 42 | + Ok(()) | |
| 43 | + } | |
| 44 | + | |
| 45 | + fn get_overlay_window(&self) -> Result<Window> { | |
| 46 | + let reply = self.conn.composite_get_overlay_window(self.root())?.reply()?; | |
| 47 | + tracing::debug!("Got overlay window: {:#x}", reply.overlay_win); | |
| 48 | + Ok(reply.overlay_win) | |
| 49 | + } | |
| 50 | + | |
| 51 | + fn release_overlay_window(&self) -> Result<()> { | |
| 52 | + self.conn.composite_release_overlay_window(self.root())?; | |
| 53 | + tracing::debug!("Released overlay window"); | |
| 54 | + Ok(()) | |
| 55 | + } | |
| 56 | + | |
| 57 | + fn name_window_pixmap(&self, window: Window) -> Result<Pixmap> { | |
| 58 | + let pixmap = self.generate_id()?; | |
| 59 | + self.conn.composite_name_window_pixmap(window, pixmap)?; | |
| 60 | + Ok(pixmap) | |
| 61 | + } | |
| 62 | + | |
| 63 | + fn unredirect_window(&self, window: Window) -> Result<()> { | |
| 64 | + self.conn | |
| 65 | + .composite_unredirect_window(window, Redirect::AUTOMATIC)?; | |
| 66 | + tracing::debug!("Unredirected window {:#x}", window); | |
| 67 | + Ok(()) | |
| 68 | + } | |
| 69 | + | |
| 70 | + fn redirect_window(&self, window: Window) -> Result<()> { | |
| 71 | + self.conn | |
| 72 | + .composite_redirect_window(window, Redirect::AUTOMATIC)?; | |
| 73 | + tracing::debug!("Redirected window {:#x}", window); | |
| 74 | + Ok(()) | |
| 75 | + } | |
| 76 | + | |
| 77 | + fn configure_overlay(&self, overlay: Window) -> Result<()> { | |
| 78 | + let screen = self.screen(); | |
| 79 | + | |
| 80 | + // Make overlay cover the entire screen | |
| 81 | + let aux = ConfigureWindowAux::new() | |
| 82 | + .x(0) | |
| 83 | + .y(0) | |
| 84 | + .width(screen.width_in_pixels as u32) | |
| 85 | + .height(screen.height_in_pixels as u32) | |
| 86 | + .stack_mode(StackMode::ABOVE); | |
| 87 | + | |
| 88 | + self.conn.configure_window(overlay, &aux)?; | |
| 89 | + | |
| 90 | + // Allow input to pass through to windows below | |
| 91 | + let region = self.generate_id()?; | |
| 92 | + self.conn.xfixes_create_region(region, &[])?; | |
| 93 | + self.conn.xfixes_set_window_shape_region(overlay, x11rb::protocol::shape::SK::INPUT, 0, 0, region)?; | |
| 94 | + self.conn.xfixes_destroy_region(region)?; | |
| 95 | + | |
| 96 | + tracing::debug!( | |
| 97 | + "Configured overlay: {}x{}", | |
| 98 | + screen.width_in_pixels, | |
| 99 | + screen.height_in_pixels | |
| 100 | + ); | |
| 101 | + Ok(()) | |
| 102 | + } | |
| 103 | + | |
| 104 | + fn wants_bypass(&self, window: Window) -> Result<bool> { | |
| 105 | + let reply = self.conn.get_property( | |
| 106 | + false, | |
| 107 | + window, | |
| 108 | + self.atoms._NET_WM_BYPASS_COMPOSITOR, | |
| 109 | + AtomEnum::CARDINAL, | |
| 110 | + 0, | |
| 111 | + 1, | |
| 112 | + )?.reply()?; | |
| 113 | + | |
| 114 | + if let Some(value) = reply.value32().and_then(|mut v| v.next()) { | |
| 115 | + // 1 = bypass requested, 2 = bypass when fullscreen | |
| 116 | + Ok(value >= 1) | |
| 117 | + } else { | |
| 118 | + Ok(false) | |
| 119 | + } | |
| 120 | + } | |
| 121 | +} | |
garchomp/src/x11/connection.rsadded@@ -0,0 +1,131 @@ | ||
| 1 | +//! X11 connection wrapper with extension support. | |
| 2 | + | |
| 3 | +use super::Atoms; | |
| 4 | +use thiserror::Error; | |
| 5 | +use x11rb::connection::{Connection as X11Connection, RequestConnection}; | |
| 6 | +use x11rb::protocol::composite::ConnectionExt as CompositeConnectionExt; | |
| 7 | +use x11rb::protocol::damage::ConnectionExt as DamageConnectionExt; | |
| 8 | +use x11rb::protocol::xfixes::ConnectionExt as XfixesConnectionExt; | |
| 9 | +use x11rb::protocol::xproto::{Screen, Window}; | |
| 10 | +use x11rb::rust_connection::RustConnection; | |
| 11 | + | |
| 12 | +#[derive(Error, Debug)] | |
| 13 | +pub enum ConnectionError { | |
| 14 | + #[error("failed to connect to X11 display")] | |
| 15 | + Connect(#[from] x11rb::errors::ConnectError), | |
| 16 | + | |
| 17 | + #[error("X11 connection error: {0}")] | |
| 18 | + Connection(#[from] x11rb::errors::ConnectionError), | |
| 19 | + | |
| 20 | + #[error("X11 reply error: {0}")] | |
| 21 | + Reply(#[from] x11rb::errors::ReplyError), | |
| 22 | + | |
| 23 | + #[error("extension '{name}' not available (required version: {required})")] | |
| 24 | + ExtensionMissing { name: &'static str, required: &'static str }, | |
| 25 | + | |
| 26 | + #[error("failed to intern atoms")] | |
| 27 | + Atoms(#[from] x11rb::errors::ReplyOrIdError), | |
| 28 | +} | |
| 29 | + | |
| 30 | +pub type Result<T> = std::result::Result<T, ConnectionError>; | |
| 31 | + | |
| 32 | +/// X11 connection with compositor extensions. | |
| 33 | +pub struct Connection { | |
| 34 | + pub conn: RustConnection, | |
| 35 | + pub screen_num: usize, | |
| 36 | + pub atoms: Atoms, | |
| 37 | + pub composite_opcode: u8, | |
| 38 | + pub damage_opcode: u8, | |
| 39 | + pub damage_event_base: u8, | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Connection { | |
| 43 | + /// Connect to the X11 display and initialize extensions. | |
| 44 | + pub fn new() -> Result<Self> { | |
| 45 | + let (conn, screen_num) = x11rb::connect(None)?; | |
| 46 | + | |
| 47 | + // Query Composite extension | |
| 48 | + let composite = conn | |
| 49 | + .composite_query_version(0, 4)? | |
| 50 | + .reply()?; | |
| 51 | + tracing::info!( | |
| 52 | + "Composite extension: {}.{}", | |
| 53 | + composite.major_version, | |
| 54 | + composite.minor_version | |
| 55 | + ); | |
| 56 | + if composite.major_version == 0 && composite.minor_version < 4 { | |
| 57 | + return Err(ConnectionError::ExtensionMissing { | |
| 58 | + name: "Composite", | |
| 59 | + required: "0.4", | |
| 60 | + }); | |
| 61 | + } | |
| 62 | + | |
| 63 | + // Query Damage extension | |
| 64 | + let damage = conn | |
| 65 | + .damage_query_version(1, 1)? | |
| 66 | + .reply()?; | |
| 67 | + tracing::info!( | |
| 68 | + "Damage extension: {}.{}", | |
| 69 | + damage.major_version, | |
| 70 | + damage.minor_version | |
| 71 | + ); | |
| 72 | + | |
| 73 | + // Query XFixes extension | |
| 74 | + let xfixes = conn | |
| 75 | + .xfixes_query_version(5, 0)? | |
| 76 | + .reply()?; | |
| 77 | + tracing::info!( | |
| 78 | + "XFixes extension: {}.{}", | |
| 79 | + xfixes.major_version, | |
| 80 | + xfixes.minor_version | |
| 81 | + ); | |
| 82 | + | |
| 83 | + // Get extension opcodes for event handling | |
| 84 | + let composite_ext = conn | |
| 85 | + .extension_information(x11rb::protocol::composite::X11_EXTENSION_NAME)? | |
| 86 | + .ok_or(ConnectionError::ExtensionMissing { | |
| 87 | + name: "Composite", | |
| 88 | + required: "0.4", | |
| 89 | + })?; | |
| 90 | + | |
| 91 | + let damage_ext = conn | |
| 92 | + .extension_information(x11rb::protocol::damage::X11_EXTENSION_NAME)? | |
| 93 | + .ok_or(ConnectionError::ExtensionMissing { | |
| 94 | + name: "Damage", | |
| 95 | + required: "1.1", | |
| 96 | + })?; | |
| 97 | + | |
| 98 | + // Intern atoms | |
| 99 | + let atoms = Atoms::new(&conn)?.reply()?; | |
| 100 | + | |
| 101 | + Ok(Self { | |
| 102 | + conn, | |
| 103 | + screen_num, | |
| 104 | + atoms, | |
| 105 | + composite_opcode: composite_ext.major_opcode, | |
| 106 | + damage_opcode: damage_ext.major_opcode, | |
| 107 | + damage_event_base: damage_ext.first_event, | |
| 108 | + }) | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Get the default screen. | |
| 112 | + pub fn screen(&self) -> &Screen { | |
| 113 | + &self.conn.setup().roots[self.screen_num] | |
| 114 | + } | |
| 115 | + | |
| 116 | + /// Get the root window. | |
| 117 | + pub fn root(&self) -> Window { | |
| 118 | + self.screen().root | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Flush pending requests to the server. | |
| 122 | + pub fn flush(&self) -> Result<()> { | |
| 123 | + self.conn.flush()?; | |
| 124 | + Ok(()) | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Generate a new X11 ID. | |
| 128 | + pub fn generate_id(&self) -> Result<u32> { | |
| 129 | + Ok(self.conn.generate_id()?) | |
| 130 | + } | |
| 131 | +} | |
garchomp/src/x11/mod.rsadded@@ -0,0 +1,9 @@ | ||
| 1 | +//! X11 protocol layer for garchomp compositor. | |
| 2 | + | |
| 3 | +mod atoms; | |
| 4 | +mod composite; | |
| 5 | +mod connection; | |
| 6 | + | |
| 7 | +pub use atoms::Atoms; | |
| 8 | +pub use composite::CompositeExt; | |
| 9 | +pub use connection::{Connection, ConnectionError}; | |
garchompctl/Cargo.tomladded@@ -0,0 +1,12 @@ | ||
| 1 | +[package] | |
| 2 | +name = "garchompctl" | |
| 3 | +description = "Control utility for garchomp compositor" | |
| 4 | +version.workspace = true | |
| 5 | +edition.workspace = true | |
| 6 | +license.workspace = true | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +garchomp-ipc = { workspace = true } | |
| 10 | +clap = { workspace = true } | |
| 11 | +serde_json = { workspace = true } | |
| 12 | +anyhow = { workspace = true } | |
garchompctl/src/main.rsadded@@ -0,0 +1,98 @@ | ||
| 1 | +//! garchompctl - Control utility for garchomp compositor. | |
| 2 | + | |
| 3 | +use anyhow::{Context, Result}; | |
| 4 | +use clap::{Parser, Subcommand}; | |
| 5 | +use garchomp_ipc::{Request, Response}; | |
| 6 | +use std::io::{BufRead, BufReader, Write}; | |
| 7 | +use std::os::unix::net::UnixStream; | |
| 8 | + | |
| 9 | +#[derive(Parser)] | |
| 10 | +#[command(name = "garchompctl", about = "Control garchomp compositor")] | |
| 11 | +struct Cli { | |
| 12 | + #[command(subcommand)] | |
| 13 | + command: Commands, | |
| 14 | +} | |
| 15 | + | |
| 16 | +#[derive(Subcommand)] | |
| 17 | +enum Commands { | |
| 18 | + /// Reload configuration. | |
| 19 | + Reload, | |
| 20 | + /// Enable an effect. | |
| 21 | + Enable { effect: String }, | |
| 22 | + /// Disable an effect. | |
| 23 | + Disable { effect: String }, | |
| 24 | + /// Set blur strength (0-20). | |
| 25 | + Blur { strength: u32 }, | |
| 26 | + /// List managed windows. | |
| 27 | + Windows, | |
| 28 | + /// Ping the compositor. | |
| 29 | + Ping, | |
| 30 | +} | |
| 31 | + | |
| 32 | +fn main() -> Result<()> { | |
| 33 | + let cli = Cli::parse(); | |
| 34 | + | |
| 35 | + let request = match cli.command { | |
| 36 | + Commands::Reload => Request::Reload, | |
| 37 | + Commands::Enable { effect } => Request::SetEffect { | |
| 38 | + effect, | |
| 39 | + enabled: true, | |
| 40 | + }, | |
| 41 | + Commands::Disable { effect } => Request::SetEffect { | |
| 42 | + effect, | |
| 43 | + enabled: false, | |
| 44 | + }, | |
| 45 | + Commands::Blur { strength } => Request::SetBlurStrength { strength }, | |
| 46 | + Commands::Windows => Request::ListWindows, | |
| 47 | + Commands::Ping => Request::Ping, | |
| 48 | + }; | |
| 49 | + | |
| 50 | + let response = send_request(&request)?; | |
| 51 | + | |
| 52 | + match response { | |
| 53 | + Response::Ok => println!("OK"), | |
| 54 | + Response::Pong => println!("pong"), | |
| 55 | + Response::Error { message } => { | |
| 56 | + eprintln!("Error: {}", message); | |
| 57 | + std::process::exit(1); | |
| 58 | + } | |
| 59 | + Response::WindowInfo(info) => { | |
| 60 | + println!("{}", serde_json::to_string_pretty(&info)?); | |
| 61 | + } | |
| 62 | + Response::WindowList { windows } => { | |
| 63 | + for win in windows { | |
| 64 | + println!( | |
| 65 | + "{:#010x} {}x{}+{}+{} {}", | |
| 66 | + win.id, | |
| 67 | + win.width, | |
| 68 | + win.height, | |
| 69 | + win.x, | |
| 70 | + win.y, | |
| 71 | + if win.mapped { "mapped" } else { "unmapped" } | |
| 72 | + ); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + Ok(()) | |
| 78 | +} | |
| 79 | + | |
| 80 | +fn send_request(request: &Request) -> Result<Response> { | |
| 81 | + let socket_path = format!( | |
| 82 | + "{}/garchomp.sock", | |
| 83 | + std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()) | |
| 84 | + ); | |
| 85 | + | |
| 86 | + let mut stream = | |
| 87 | + UnixStream::connect(&socket_path).context("Failed to connect to garchomp socket")?; | |
| 88 | + | |
| 89 | + let mut msg = serde_json::to_string(request)?; | |
| 90 | + msg.push('\n'); | |
| 91 | + stream.write_all(msg.as_bytes())?; | |
| 92 | + | |
| 93 | + let mut reader = BufReader::new(stream); | |
| 94 | + let mut line = String::new(); | |
| 95 | + reader.read_line(&mut line)?; | |
| 96 | + | |
| 97 | + serde_json::from_str(&line).context("Failed to parse response") | |
| 98 | +} | |