Temporarily track sprint docs for laptop transfer
- SHA
edf725ec24728981e045517f34c61f507c709cbb- Parents
-
32aaf43 - Tree
2ae0740
edf725e
edf725ec24728981e045517f34c61f507c709cbb32aaf43
2ae0740| Status | File | + | - |
|---|---|---|---|
| A |
docs/sprints/sprint-0-setup.md
|
135 | 0 |
| A |
docs/sprints/sprint-1-basic-wm.md
|
198 | 0 |
| A |
docs/sprints/sprint-2-smart-split.md
|
220 | 0 |
| A |
docs/sprints/sprint-3-workspaces.md
|
241 | 0 |
| A |
docs/sprints/sprint-4-lua-config.md
|
279 | 0 |
| A |
docs/sprints/sprint-5-floating.md
|
271 | 0 |
| A |
docs/sprints/sprint-6-ipc.md
|
348 | 0 |
| A |
docs/sprints/sprint-7-multimonitor.md
|
282 | 0 |
| A |
docs/sprints/sprint-8-ewmh.md
|
427 | 0 |
| A |
docs/sprints/sprint-9-mvp-polish.md
|
334 | 0 |
docs/sprints/sprint-0-setup.mdadded@@ -0,0 +1,135 @@ | ||
| 1 | +# Sprint 0: Project Setup | |
| 2 | + | |
| 3 | +**Goal:** Buildable skeleton that connects to X server and appears in the display manager greeter. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Initialize Rust project with proper workspace structure | |
| 8 | +- Establish logging and error handling patterns | |
| 9 | +- Connect to X server and become a window manager | |
| 10 | +- Create a stable event loop | |
| 11 | +- Make gar selectable from the login greeter | |
| 12 | + | |
| 13 | +## Tasks | |
| 14 | + | |
| 15 | +### 0.1 Initialize Cargo Project | |
| 16 | +- [ ] Create `Cargo.toml` with workspace members (gar, garctl) | |
| 17 | +- [ ] Set up `src/main.rs` entry point | |
| 18 | +- [ ] Set up `src/lib.rs` for library code | |
| 19 | +- [ ] Create module structure skeleton | |
| 20 | + | |
| 21 | +**Dependencies to add:** | |
| 22 | +```toml | |
| 23 | +[dependencies] | |
| 24 | +x11rb = { version = "0.13", features = ["allow-unsafe-code"] } | |
| 25 | +tracing = "0.1" | |
| 26 | +tracing-subscriber = "0.3" | |
| 27 | +thiserror = "1.0" | |
| 28 | +``` | |
| 29 | + | |
| 30 | +### 0.2 Logging Infrastructure | |
| 31 | +- [ ] Initialize tracing subscriber in main | |
| 32 | +- [ ] Set up log levels (RUST_LOG environment variable) | |
| 33 | +- [ ] Add structured logging for key events | |
| 34 | +- [ ] Log to file for debugging sessions | |
| 35 | + | |
| 36 | +### 0.3 X Server Connection | |
| 37 | +- [ ] Create `src/x11/mod.rs` module | |
| 38 | +- [ ] Create `src/x11/connection.rs` | |
| 39 | +- [ ] Connect to X server using `x11rb::connect()` | |
| 40 | +- [ ] Get root window and screen info | |
| 41 | +- [ ] Handle connection errors gracefully | |
| 42 | + | |
| 43 | +**Key code pattern:** | |
| 44 | +```rust | |
| 45 | +use x11rb::connection::Connection; | |
| 46 | +use x11rb::rust_connection::RustConnection; | |
| 47 | + | |
| 48 | +pub fn connect() -> Result<(RustConnection, usize), Error> { | |
| 49 | + let (conn, screen_num) = x11rb::connect(None)?; | |
| 50 | + Ok((conn, screen_num)) | |
| 51 | +} | |
| 52 | +``` | |
| 53 | + | |
| 54 | +### 0.4 Become Window Manager | |
| 55 | +- [ ] Request SubstructureRedirect on root window | |
| 56 | +- [ ] Handle "another WM running" error gracefully | |
| 57 | +- [ ] Set up necessary event masks on root window | |
| 58 | + | |
| 59 | +**Critical X11 concept:** Only ONE client can have SubstructureRedirect on the root window. This is how X11 ensures only one window manager runs. | |
| 60 | + | |
| 61 | +```rust | |
| 62 | +let change = ChangeWindowAttributesAux::new() | |
| 63 | + .event_mask(EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY); | |
| 64 | +conn.change_window_attributes(root, &change)?.check()?; | |
| 65 | +``` | |
| 66 | + | |
| 67 | +### 0.5 Event Loop | |
| 68 | +- [ ] Create `src/x11/events.rs` | |
| 69 | +- [ ] Implement basic event loop structure | |
| 70 | +- [ ] Handle ConfigureRequest (pass through for now) | |
| 71 | +- [ ] Handle MapRequest (map windows for now) | |
| 72 | +- [ ] Log all received events | |
| 73 | +- [ ] Clean shutdown on SIGTERM/SIGINT | |
| 74 | + | |
| 75 | +**Event loop pattern:** | |
| 76 | +```rust | |
| 77 | +loop { | |
| 78 | + let event = conn.wait_for_event()?; | |
| 79 | + match event { | |
| 80 | + Event::ConfigureRequest(e) => handle_configure_request(&conn, e)?, | |
| 81 | + Event::MapRequest(e) => handle_map_request(&conn, e)?, | |
| 82 | + _ => tracing::trace!("Unhandled event: {:?}", event), | |
| 83 | + } | |
| 84 | +} | |
| 85 | +``` | |
| 86 | + | |
| 87 | +### 0.6 Desktop Entry | |
| 88 | +- [ ] Create `gar.desktop` file | |
| 89 | +- [ ] Install to `/usr/share/xsessions/` (or document install location) | |
| 90 | +- [ ] Test selection from GDM/LightDM/SDDM | |
| 91 | + | |
| 92 | +**Desktop entry content:** | |
| 93 | +```ini | |
| 94 | +[Desktop Entry] | |
| 95 | +Name=gar | |
| 96 | +Comment=Tiling window manager with smart splits | |
| 97 | +Exec=gar | |
| 98 | +Type=XSession | |
| 99 | +``` | |
| 100 | + | |
| 101 | +## Acceptance Criteria | |
| 102 | + | |
| 103 | +1. `cargo build` succeeds without errors | |
| 104 | +2. `cargo run` connects to X server (or fails gracefully if one isn't available) | |
| 105 | +3. Running in a nested X session (Xephyr) shows gar as the WM | |
| 106 | +4. `gar.desktop` can be created and would appear in a greeter | |
| 107 | +5. Existing windows are not lost (ConfigureRequest/MapRequest passthrough) | |
| 108 | +6. Logs show event activity | |
| 109 | + | |
| 110 | +## Testing Strategy | |
| 111 | + | |
| 112 | +**Manual testing with Xephyr:** | |
| 113 | +```bash | |
| 114 | +# Terminal 1: Start nested X server | |
| 115 | +Xephyr -br -ac -noreset -screen 1280x720 :1 | |
| 116 | + | |
| 117 | +# Terminal 2: Run gar in nested session | |
| 118 | +DISPLAY=:1 cargo run | |
| 119 | + | |
| 120 | +# Terminal 3: Launch apps in nested session | |
| 121 | +DISPLAY=:1 xterm | |
| 122 | +``` | |
| 123 | + | |
| 124 | +## Notes | |
| 125 | + | |
| 126 | +- Keep this sprint minimal - just enough to have a working foundation | |
| 127 | +- Don't implement actual window management yet (Sprint 1) | |
| 128 | +- Focus on correctness over features | |
| 129 | +- Document any X11 quirks encountered | |
| 130 | + | |
| 131 | +## Resources | |
| 132 | + | |
| 133 | +- [x11rb documentation](https://docs.rs/x11rb/latest/x11rb/) | |
| 134 | +- [X11 Protocol Reference](https://www.x.org/releases/current/doc/xproto/x11protocol.html) | |
| 135 | +- [ICCCM Spec](https://www.x.org/releases/X11R7.6/doc/xorg-docs/specs/ICCCM/icccm.html) | |
docs/sprints/sprint-1-basic-wm.mdadded@@ -0,0 +1,198 @@ | ||
| 1 | +# Sprint 1: Basic Window Management | |
| 2 | + | |
| 3 | +**Goal:** Manage windows in a single workspace with manual tiling using a BSP tree. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Track window lifecycle (create, destroy) | |
| 8 | +- Implement Binary Space Partition tree data structure | |
| 9 | +- Tile windows using the BSP tree | |
| 10 | +- Basic focus handling (click-to-focus) | |
| 11 | +- One hardcoded keybind to test input grabbing | |
| 12 | + | |
| 13 | +## Prerequisites | |
| 14 | + | |
| 15 | +- Sprint 0 complete (X connection, event loop working) | |
| 16 | + | |
| 17 | +## Tasks | |
| 18 | + | |
| 19 | +### 1.1 Window Tracking | |
| 20 | +- [ ] Create `src/core/window.rs` | |
| 21 | +- [ ] Define `Window` struct to track window state | |
| 22 | +- [ ] Create window registry (HashMap<XWindow, Window>) | |
| 23 | +- [ ] Handle MapRequest: add window to registry | |
| 24 | +- [ ] Handle UnmapNotify: remove window from registry | |
| 25 | +- [ ] Handle DestroyNotify: clean up window | |
| 26 | + | |
| 27 | +**Window struct:** | |
| 28 | +```rust | |
| 29 | +pub struct Window { | |
| 30 | + pub id: x11rb::protocol::xproto::Window, | |
| 31 | + pub x: i16, | |
| 32 | + pub y: i16, | |
| 33 | + pub width: u16, | |
| 34 | + pub height: u16, | |
| 35 | + pub mapped: bool, | |
| 36 | +} | |
| 37 | +``` | |
| 38 | + | |
| 39 | +### 1.2 BSP Tree Data Structure | |
| 40 | +- [ ] Create `src/core/tree.rs` | |
| 41 | +- [ ] Implement `SplitDirection` enum (Horizontal, Vertical) | |
| 42 | +- [ ] Implement `Node` enum (Internal, Leaf) | |
| 43 | +- [ ] Implement tree traversal methods | |
| 44 | +- [ ] Implement node insertion (always vertical for now) | |
| 45 | +- [ ] Implement node removal | |
| 46 | +- [ ] Implement geometry calculation from tree | |
| 47 | + | |
| 48 | +**Core tree structure:** | |
| 49 | +```rust | |
| 50 | +#[derive(Debug, Clone, Copy)] | |
| 51 | +pub enum SplitDirection { | |
| 52 | + Horizontal, // Top/Bottom | |
| 53 | + Vertical, // Left/Right | |
| 54 | +} | |
| 55 | + | |
| 56 | +#[derive(Debug)] | |
| 57 | +pub enum Node { | |
| 58 | + Internal { | |
| 59 | + split: SplitDirection, | |
| 60 | + ratio: f32, | |
| 61 | + left: Box<Node>, | |
| 62 | + right: Box<Node>, | |
| 63 | + }, | |
| 64 | + Leaf { | |
| 65 | + window: Option<WindowId>, | |
| 66 | + }, | |
| 67 | +} | |
| 68 | +``` | |
| 69 | + | |
| 70 | +### 1.3 Geometry Calculation | |
| 71 | +- [ ] Calculate window geometries from BSP tree | |
| 72 | +- [ ] Pass root geometry (screen size minus any reserved space) | |
| 73 | +- [ ] Recursively divide space based on splits and ratios | |
| 74 | +- [ ] Return list of (WindowId, Rect) pairs | |
| 75 | + | |
| 76 | +**Algorithm:** | |
| 77 | +```rust | |
| 78 | +impl Node { | |
| 79 | + pub fn calculate_geometries(&self, rect: Rect) -> Vec<(WindowId, Rect)> { | |
| 80 | + match self { | |
| 81 | + Node::Leaf { window: Some(id) } => vec![(*id, rect)], | |
| 82 | + Node::Leaf { window: None } => vec![], | |
| 83 | + Node::Internal { split, ratio, left, right } => { | |
| 84 | + let (left_rect, right_rect) = rect.split(*split, *ratio); | |
| 85 | + let mut result = left.calculate_geometries(left_rect); | |
| 86 | + result.extend(right.calculate_geometries(right_rect)); | |
| 87 | + result | |
| 88 | + } | |
| 89 | + } | |
| 90 | + } | |
| 91 | +} | |
| 92 | +``` | |
| 93 | + | |
| 94 | +### 1.4 Apply Geometries to X11 | |
| 95 | +- [ ] Create function to configure window geometry | |
| 96 | +- [ ] Use `ConfigureWindow` request | |
| 97 | +- [ ] Handle border width in calculations | |
| 98 | +- [ ] Apply all geometries after tree changes | |
| 99 | + | |
| 100 | +```rust | |
| 101 | +fn apply_geometry(conn: &impl Connection, window: Window, rect: Rect) -> Result<()> { | |
| 102 | + let aux = ConfigureWindowAux::new() | |
| 103 | + .x(rect.x as i32) | |
| 104 | + .y(rect.y as i32) | |
| 105 | + .width(rect.width as u32) | |
| 106 | + .height(rect.height as u32); | |
| 107 | + conn.configure_window(window, &aux)?; | |
| 108 | + Ok(()) | |
| 109 | +} | |
| 110 | +``` | |
| 111 | + | |
| 112 | +### 1.5 Window Insertion | |
| 113 | +- [ ] Find the focused leaf node | |
| 114 | +- [ ] Insert new window next to focused window | |
| 115 | +- [ ] If no focused window, insert at root | |
| 116 | +- [ ] Split vertically (for now - smart split is Sprint 2) | |
| 117 | +- [ ] Recalculate and apply all geometries | |
| 118 | + | |
| 119 | +### 1.6 Window Removal | |
| 120 | +- [ ] Find and remove window from tree | |
| 121 | +- [ ] Collapse parent if only one child remains | |
| 122 | +- [ ] Handle removing last window (empty tree) | |
| 123 | +- [ ] Update focus to sibling or parent's other child | |
| 124 | +- [ ] Recalculate and apply geometries | |
| 125 | + | |
| 126 | +### 1.7 Focus Handling | |
| 127 | +- [ ] Track currently focused window | |
| 128 | +- [ ] Handle ButtonPress on windows (click-to-focus) | |
| 129 | +- [ ] Set input focus using `SetInputFocus` | |
| 130 | +- [ ] Visual feedback: different border color for focused window | |
| 131 | +- [ ] Grab button on unfocused windows, ungrab on focused | |
| 132 | + | |
| 133 | +```rust | |
| 134 | +// Set focus | |
| 135 | +conn.set_input_focus(InputFocus::PARENT, window, CURRENT_TIME)?; | |
| 136 | + | |
| 137 | +// Visual feedback | |
| 138 | +let border_color = if focused { 0x5294e2 } else { 0x2d2d2d }; | |
| 139 | +conn.change_window_attributes(window, &ChangeWindowAttributesAux::new() | |
| 140 | + .border_pixel(border_color))?; | |
| 141 | +``` | |
| 142 | + | |
| 143 | +### 1.8 Basic Keyboard Grab | |
| 144 | +- [ ] Grab Mod4+Return (super+enter) globally | |
| 145 | +- [ ] On keypress, spawn terminal (hardcoded: `alacritty` or `xterm`) | |
| 146 | +- [ ] Use `std::process::Command` to spawn | |
| 147 | + | |
| 148 | +```rust | |
| 149 | +// Grab the key | |
| 150 | +conn.grab_key( | |
| 151 | + false, | |
| 152 | + root, | |
| 153 | + ModMask::M4, // Super/Win key | |
| 154 | + keycode_for_return, | |
| 155 | + GrabMode::ASYNC, | |
| 156 | + GrabMode::ASYNC, | |
| 157 | +)?; | |
| 158 | +``` | |
| 159 | + | |
| 160 | +## Acceptance Criteria | |
| 161 | + | |
| 162 | +1. Opening windows tiles them vertically (side by side) | |
| 163 | +2. Closing a window removes it and re-tiles remaining windows | |
| 164 | +3. Clicking a window focuses it (border color changes) | |
| 165 | +4. Mod+Enter spawns a terminal | |
| 166 | +5. Windows properly fill available screen space | |
| 167 | +6. No crashes on rapid window open/close | |
| 168 | + | |
| 169 | +## Testing Strategy | |
| 170 | + | |
| 171 | +```bash | |
| 172 | +# In Xephyr session | |
| 173 | +DISPLAY=:1 cargo run & | |
| 174 | + | |
| 175 | +# Open multiple terminals | |
| 176 | +DISPLAY=:1 xterm & | |
| 177 | +DISPLAY=:1 xterm & | |
| 178 | +DISPLAY=:1 xterm & | |
| 179 | + | |
| 180 | +# Verify: 3 windows tiled vertically | |
| 181 | +# Click each to verify focus changes | |
| 182 | +# Close middle window, verify remaining 2 expand | |
| 183 | +``` | |
| 184 | + | |
| 185 | +## Edge Cases to Handle | |
| 186 | + | |
| 187 | +- First window (empty tree) | |
| 188 | +- Last window closed (empty tree again) | |
| 189 | +- Rapid window creation | |
| 190 | +- Windows that set their own size (override redirect) | |
| 191 | +- Transient windows (handle in later sprint) | |
| 192 | + | |
| 193 | +## Notes | |
| 194 | + | |
| 195 | +- Keep split ratio at 0.5 for now (equal splits) | |
| 196 | +- Vertical-only splits for now (Sprint 2 adds smart splitting) | |
| 197 | +- No gaps yet (Sprint 8) | |
| 198 | +- Focused border color hardcoded (Sprint 4 makes configurable) | |
docs/sprints/sprint-2-smart-split.mdadded@@ -0,0 +1,220 @@ | ||
| 1 | +# Sprint 2: Smart Split + Keyboard Navigation | |
| 2 | + | |
| 3 | +**Goal:** Implement the core differentiating feature (intelligent split direction) and full keyboard-based window navigation. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Automatic split direction based on window dimensions | |
| 8 | +- Full keyboard navigation (vim-style hjkl) | |
| 9 | +- Keyboard-based window operations | |
| 10 | +- This is what makes gar different from i3 | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 1 complete (BSP tree, basic window management) | |
| 15 | + | |
| 16 | +## The Smart Split Algorithm | |
| 17 | + | |
| 18 | +This is the key feature that Hyprland has and i3 lacks: | |
| 19 | + | |
| 20 | +``` | |
| 21 | +When inserting a new window: | |
| 22 | +1. Get dimensions of the target area (width, height) | |
| 23 | +2. If width > height → split Vertical (creates side-by-side windows) | |
| 24 | +3. If height >= width → split Horizontal (creates stacked windows) | |
| 25 | +``` | |
| 26 | + | |
| 27 | +**Visual example:** | |
| 28 | +``` | |
| 29 | +┌─────────────────────────┐ ┌────────────┬────────────┐ | |
| 30 | +│ │ │ │ │ | |
| 31 | +│ Wide container │ --> │ Left │ Right │ | |
| 32 | +│ (width > height) │ │ │ (new) │ | |
| 33 | +│ │ │ │ │ | |
| 34 | +└─────────────────────────┘ └────────────┴────────────┘ | |
| 35 | + Split VERTICAL | |
| 36 | + | |
| 37 | +┌───────────┐ ┌───────────┐ | |
| 38 | +│ │ │ Top │ | |
| 39 | +│ Tall │ ├───────────┤ | |
| 40 | +│ container │ ────────> │ Bottom │ | |
| 41 | +│ (h >= w) │ │ (new) │ | |
| 42 | +│ │ │ │ | |
| 43 | +└───────────┘ └───────────┘ | |
| 44 | + Split HORIZONTAL | |
| 45 | +``` | |
| 46 | + | |
| 47 | +## Tasks | |
| 48 | + | |
| 49 | +### 2.1 Implement Smart Split | |
| 50 | +- [ ] Modify `insert_window` in tree.rs | |
| 51 | +- [ ] Calculate container dimensions before inserting | |
| 52 | +- [ ] Choose split direction based on width vs height | |
| 53 | +- [ ] Add unit tests for split direction logic | |
| 54 | + | |
| 55 | +```rust | |
| 56 | +impl Node { | |
| 57 | + fn determine_split_direction(rect: &Rect) -> SplitDirection { | |
| 58 | + if rect.width > rect.height { | |
| 59 | + SplitDirection::Vertical | |
| 60 | + } else { | |
| 61 | + SplitDirection::Horizontal | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + pub fn insert_window(&mut self, window: WindowId, rect: Rect) { | |
| 66 | + let direction = Self::determine_split_direction(&rect); | |
| 67 | + // ... rest of insertion logic | |
| 68 | + } | |
| 69 | +} | |
| 70 | +``` | |
| 71 | + | |
| 72 | +### 2.2 Keyboard Input Infrastructure | |
| 73 | +- [ ] Create `src/input/mod.rs` and `src/input/keybinds.rs` | |
| 74 | +- [ ] Define keybind action enum | |
| 75 | +- [ ] Create keybind registry | |
| 76 | +- [ ] Grab all configured keys on startup | |
| 77 | +- [ ] Dispatch to actions on KeyPress event | |
| 78 | + | |
| 79 | +```rust | |
| 80 | +pub enum Action { | |
| 81 | + FocusDirection(Direction), | |
| 82 | + SwapDirection(Direction), | |
| 83 | + ResizeDirection(Direction, i32), | |
| 84 | + CloseWindow, | |
| 85 | + Equalize, | |
| 86 | + Exec(String), | |
| 87 | +} | |
| 88 | + | |
| 89 | +pub enum Direction { | |
| 90 | + Left, Right, Up, Down, | |
| 91 | +} | |
| 92 | +``` | |
| 93 | + | |
| 94 | +### 2.3 Focus Navigation | |
| 95 | +- [ ] Implement directional focus (Mod+h/j/k/l) | |
| 96 | +- [ ] Find window in given direction from focused | |
| 97 | +- [ ] Handle edge cases (no window in direction) | |
| 98 | +- [ ] Update focus and visual feedback | |
| 99 | + | |
| 100 | +**Algorithm for finding window in direction:** | |
| 101 | +```rust | |
| 102 | +fn find_window_in_direction( | |
| 103 | + tree: &Node, | |
| 104 | + from: WindowId, | |
| 105 | + direction: Direction, | |
| 106 | +) -> Option<WindowId> { | |
| 107 | + // 1. Get geometry of 'from' window | |
| 108 | + // 2. Get geometries of all windows | |
| 109 | + // 3. Filter to windows in the given direction | |
| 110 | + // 4. Return closest window in that direction | |
| 111 | +} | |
| 112 | +``` | |
| 113 | + | |
| 114 | +### 2.4 Window Closing | |
| 115 | +- [ ] Implement Mod+Shift+Q to close focused window | |
| 116 | +- [ ] Send WM_DELETE_WINDOW if supported (ICCCM) | |
| 117 | +- [ ] Fall back to XKillClient if not | |
| 118 | +- [ ] Remove from tree after close | |
| 119 | + | |
| 120 | +```rust | |
| 121 | +fn close_window(conn: &impl Connection, window: Window) -> Result<()> { | |
| 122 | + // Check if window supports WM_DELETE_WINDOW | |
| 123 | + if supports_delete_window(conn, window)? { | |
| 124 | + send_delete_window_message(conn, window)?; | |
| 125 | + } else { | |
| 126 | + conn.kill_client(window)?; | |
| 127 | + } | |
| 128 | + Ok(()) | |
| 129 | +} | |
| 130 | +``` | |
| 131 | + | |
| 132 | +### 2.5 Resize Splits | |
| 133 | +- [ ] Implement Mod+Ctrl+h/j/k/l to resize | |
| 134 | +- [ ] Find the split that affects the focused window in that direction | |
| 135 | +- [ ] Adjust ratio by fixed increment (e.g., 0.05) | |
| 136 | +- [ ] Clamp ratio between 0.1 and 0.9 | |
| 137 | +- [ ] Recalculate and apply geometries | |
| 138 | + | |
| 139 | +```rust | |
| 140 | +fn resize_in_direction(tree: &mut Node, window: WindowId, dir: Direction, delta: f32) { | |
| 141 | + // Find the nearest ancestor split in the given direction | |
| 142 | + // Adjust its ratio | |
| 143 | +} | |
| 144 | +``` | |
| 145 | + | |
| 146 | +### 2.6 Equalize Splits | |
| 147 | +- [ ] Implement Mod+E to equalize | |
| 148 | +- [ ] Set all split ratios to 0.5 | |
| 149 | +- [ ] Recursive traversal of tree | |
| 150 | +- [ ] Recalculate and apply geometries | |
| 151 | + | |
| 152 | +```rust | |
| 153 | +fn equalize(node: &mut Node) { | |
| 154 | + match node { | |
| 155 | + Node::Internal { ratio, left, right, .. } => { | |
| 156 | + *ratio = 0.5; | |
| 157 | + equalize(left); | |
| 158 | + equalize(right); | |
| 159 | + } | |
| 160 | + Node::Leaf { .. } => {} | |
| 161 | + } | |
| 162 | +} | |
| 163 | +``` | |
| 164 | + | |
| 165 | +### 2.7 Swap Windows | |
| 166 | +- [ ] Implement Mod+Shift+h/j/k/l to swap | |
| 167 | +- [ ] Find window in direction | |
| 168 | +- [ ] Swap the two windows in the tree | |
| 169 | +- [ ] Recalculate and apply geometries | |
| 170 | + | |
| 171 | +```rust | |
| 172 | +fn swap_windows(tree: &mut Node, a: WindowId, b: WindowId) { | |
| 173 | + // Find both leaf nodes | |
| 174 | + // Swap their window IDs | |
| 175 | +} | |
| 176 | +``` | |
| 177 | + | |
| 178 | +## Keybind Summary | |
| 179 | + | |
| 180 | +| Keybind | Action | | |
| 181 | +|---------|--------| | |
| 182 | +| Mod+h/j/k/l | Focus left/down/up/right | | |
| 183 | +| Mod+Shift+h/j/k/l | Swap with left/down/up/right | | |
| 184 | +| Mod+Ctrl+h/j/k/l | Resize split in direction | | |
| 185 | +| Mod+Shift+Q | Close focused window | | |
| 186 | +| Mod+E | Equalize all splits | | |
| 187 | +| Mod+Return | Spawn terminal | | |
| 188 | + | |
| 189 | +## Acceptance Criteria | |
| 190 | + | |
| 191 | +1. New windows split intelligently based on container shape | |
| 192 | +2. All keyboard navigation works as specified | |
| 193 | +3. Window swapping maintains tree structure | |
| 194 | +4. Resize adjusts the correct split | |
| 195 | +5. Equalize makes all windows same size | |
| 196 | +6. Closing windows works cleanly | |
| 197 | + | |
| 198 | +## Testing Strategy | |
| 199 | + | |
| 200 | +```bash | |
| 201 | +# Test smart splits | |
| 202 | +# Open terminal in empty workspace → full screen | |
| 203 | +# Open second terminal → should split vertically (side by side) | |
| 204 | +# Focus right window, open third → right window splits horizontally (stacked) | |
| 205 | + | |
| 206 | +# Test navigation | |
| 207 | +# Mod+h/l should move focus left/right | |
| 208 | +# Mod+j/k should move focus up/down | |
| 209 | + | |
| 210 | +# Test resize | |
| 211 | +# Mod+Ctrl+l should make focused window wider | |
| 212 | +# Mod+E should reset to equal sizes | |
| 213 | +``` | |
| 214 | + | |
| 215 | +## Notes | |
| 216 | + | |
| 217 | +- This sprint establishes the core UX that differentiates gar | |
| 218 | +- Navigation should feel snappy (no perceptible delay) | |
| 219 | +- Consider adding Mod+Arrow keys as alternative to hjkl | |
| 220 | +- Direction finding algorithm is the trickiest part - test thoroughly | |
docs/sprints/sprint-3-workspaces.mdadded@@ -0,0 +1,241 @@ | ||
| 1 | +# Sprint 3: Multiple Workspaces | |
| 2 | + | |
| 3 | +**Goal:** Implement named workspaces with switching and window movement. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Multiple independent workspaces, each with its own BSP tree | |
| 8 | +- Keyboard-based workspace switching | |
| 9 | +- Move windows between workspaces | |
| 10 | +- EWMH workspace properties for status bar integration | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 2 complete (smart split, keyboard navigation) | |
| 15 | + | |
| 16 | +## Tasks | |
| 17 | + | |
| 18 | +### 3.1 Workspace Data Structure | |
| 19 | +- [ ] Create `src/core/workspace.rs` | |
| 20 | +- [ ] Define `Workspace` struct | |
| 21 | +- [ ] Create `WorkspaceManager` to hold all workspaces | |
| 22 | +- [ ] Initialize default workspaces (1-10) | |
| 23 | + | |
| 24 | +```rust | |
| 25 | +pub struct Workspace { | |
| 26 | + pub id: WorkspaceId, | |
| 27 | + pub name: String, | |
| 28 | + pub tree: Node, | |
| 29 | + pub floating: Vec<Window>, | |
| 30 | + pub focused: Option<WindowId>, | |
| 31 | + pub visible: bool, | |
| 32 | +} | |
| 33 | + | |
| 34 | +pub struct WorkspaceManager { | |
| 35 | + workspaces: Vec<Workspace>, | |
| 36 | + active: WorkspaceId, | |
| 37 | +} | |
| 38 | +``` | |
| 39 | + | |
| 40 | +### 3.2 Workspace Initialization | |
| 41 | +- [ ] Create 10 default workspaces (named "1" through "10") | |
| 42 | +- [ ] Set workspace 1 as active on startup | |
| 43 | +- [ ] Existing windows go to workspace 1 | |
| 44 | + | |
| 45 | +```rust | |
| 46 | +impl WorkspaceManager { | |
| 47 | + pub fn new() -> Self { | |
| 48 | + let workspaces = (1..=10) | |
| 49 | + .map(|i| Workspace { | |
| 50 | + id: WorkspaceId(i), | |
| 51 | + name: i.to_string(), | |
| 52 | + tree: Node::empty(), | |
| 53 | + floating: Vec::new(), | |
| 54 | + focused: None, | |
| 55 | + visible: i == 1, | |
| 56 | + }) | |
| 57 | + .collect(); | |
| 58 | + Self { workspaces, active: WorkspaceId(1) } | |
| 59 | + } | |
| 60 | +} | |
| 61 | +``` | |
| 62 | + | |
| 63 | +### 3.3 Workspace Switching | |
| 64 | +- [ ] Implement Mod+1 through Mod+9 (and Mod+0 for 10) | |
| 65 | +- [ ] Hide windows on old workspace | |
| 66 | +- [ ] Show windows on new workspace | |
| 67 | +- [ ] Restore focus to previously focused window | |
| 68 | +- [ ] Update EWMH _NET_CURRENT_DESKTOP | |
| 69 | + | |
| 70 | +```rust | |
| 71 | +fn switch_workspace(&mut self, conn: &impl Connection, target: WorkspaceId) -> Result<()> { | |
| 72 | + if target == self.active { | |
| 73 | + return Ok(()); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // Hide current workspace windows | |
| 77 | + for window in self.current_workspace().all_windows() { | |
| 78 | + conn.unmap_window(window)?; | |
| 79 | + } | |
| 80 | + | |
| 81 | + // Show target workspace windows | |
| 82 | + self.active = target; | |
| 83 | + for window in self.current_workspace().all_windows() { | |
| 84 | + conn.map_window(window)?; | |
| 85 | + } | |
| 86 | + | |
| 87 | + // Restore focus | |
| 88 | + if let Some(focused) = self.current_workspace().focused { | |
| 89 | + set_focus(conn, focused)?; | |
| 90 | + } | |
| 91 | + | |
| 92 | + Ok(()) | |
| 93 | +} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +### 3.4 Move Window to Workspace | |
| 97 | +- [ ] Implement Mod+Shift+1 through Mod+Shift+9 | |
| 98 | +- [ ] Remove window from current workspace tree | |
| 99 | +- [ ] Add window to target workspace tree | |
| 100 | +- [ ] If target workspace is not visible, unmap window | |
| 101 | +- [ ] Optionally follow window to new workspace (configurable later) | |
| 102 | + | |
| 103 | +```rust | |
| 104 | +fn move_to_workspace( | |
| 105 | + &mut self, | |
| 106 | + conn: &impl Connection, | |
| 107 | + window: WindowId, | |
| 108 | + target: WorkspaceId, | |
| 109 | +) -> Result<()> { | |
| 110 | + // Remove from current | |
| 111 | + self.current_workspace_mut().tree.remove(window); | |
| 112 | + | |
| 113 | + // Add to target | |
| 114 | + self.workspace_mut(target).tree.insert(window); | |
| 115 | + | |
| 116 | + // Hide if target not visible | |
| 117 | + if target != self.active { | |
| 118 | + conn.unmap_window(window)?; | |
| 119 | + } | |
| 120 | + | |
| 121 | + // Retile both workspaces | |
| 122 | + self.apply_layout(conn, self.active)?; | |
| 123 | + self.apply_layout(conn, target)?; | |
| 124 | + | |
| 125 | + Ok(()) | |
| 126 | +} | |
| 127 | +``` | |
| 128 | + | |
| 129 | +### 3.5 Per-Workspace Trees | |
| 130 | +- [ ] Modify window insertion to use active workspace | |
| 131 | +- [ ] Modify window removal to find correct workspace | |
| 132 | +- [ ] Ensure tree operations target correct workspace | |
| 133 | +- [ ] Handle window destroy when on non-active workspace | |
| 134 | + | |
| 135 | +### 3.6 EWMH Workspace Support | |
| 136 | +- [ ] Set `_NET_NUMBER_OF_DESKTOPS` on root | |
| 137 | +- [ ] Set `_NET_DESKTOP_NAMES` with workspace names | |
| 138 | +- [ ] Set `_NET_CURRENT_DESKTOP` when switching | |
| 139 | +- [ ] Set `_NET_WM_DESKTOP` on each window | |
| 140 | +- [ ] Handle `_NET_CURRENT_DESKTOP` client messages (pagers) | |
| 141 | + | |
| 142 | +```rust | |
| 143 | +fn set_ewmh_workspace_hints(conn: &impl Connection, manager: &WorkspaceManager) -> Result<()> { | |
| 144 | + let root = get_root(conn); | |
| 145 | + | |
| 146 | + // Number of desktops | |
| 147 | + conn.change_property32( | |
| 148 | + PropMode::REPLACE, | |
| 149 | + root, | |
| 150 | + atoms._NET_NUMBER_OF_DESKTOPS, | |
| 151 | + AtomEnum::CARDINAL, | |
| 152 | + &[manager.workspaces.len() as u32], | |
| 153 | + )?; | |
| 154 | + | |
| 155 | + // Desktop names | |
| 156 | + let names: Vec<u8> = manager.workspaces | |
| 157 | + .iter() | |
| 158 | + .flat_map(|ws| ws.name.bytes().chain(std::iter::once(0))) | |
| 159 | + .collect(); | |
| 160 | + conn.change_property8( | |
| 161 | + PropMode::REPLACE, | |
| 162 | + root, | |
| 163 | + atoms._NET_DESKTOP_NAMES, | |
| 164 | + atoms.UTF8_STRING, | |
| 165 | + &names, | |
| 166 | + )?; | |
| 167 | + | |
| 168 | + // Current desktop | |
| 169 | + conn.change_property32( | |
| 170 | + PropMode::REPLACE, | |
| 171 | + root, | |
| 172 | + atoms._NET_CURRENT_DESKTOP, | |
| 173 | + AtomEnum::CARDINAL, | |
| 174 | + &[manager.active.0 as u32 - 1], // 0-indexed for EWMH | |
| 175 | + )?; | |
| 176 | + | |
| 177 | + Ok(()) | |
| 178 | +} | |
| 179 | +``` | |
| 180 | + | |
| 181 | +### 3.7 Handle Existing Windows on Startup | |
| 182 | +- [ ] Query existing windows on WM start | |
| 183 | +- [ ] Check `_NET_WM_DESKTOP` for previous workspace assignment | |
| 184 | +- [ ] Place windows in appropriate workspaces | |
| 185 | +- [ ] Map windows in active workspace only | |
| 186 | + | |
| 187 | +## Keybind Summary | |
| 188 | + | |
| 189 | +| Keybind | Action | | |
| 190 | +|---------|--------| | |
| 191 | +| Mod+1-9 | Switch to workspace 1-9 | | |
| 192 | +| Mod+0 | Switch to workspace 10 | | |
| 193 | +| Mod+Shift+1-9 | Move window to workspace 1-9 | | |
| 194 | +| Mod+Shift+0 | Move window to workspace 10 | | |
| 195 | + | |
| 196 | +## Acceptance Criteria | |
| 197 | + | |
| 198 | +1. Can switch between 10 workspaces | |
| 199 | +2. Each workspace maintains its own window layout | |
| 200 | +3. Moving window to another workspace works | |
| 201 | +4. Status bars (polybar) can display workspace info | |
| 202 | +5. Windows hidden/shown correctly on switch | |
| 203 | +6. Focus restored correctly when switching back | |
| 204 | + | |
| 205 | +## Testing Strategy | |
| 206 | + | |
| 207 | +```bash | |
| 208 | +# Test workspace switching | |
| 209 | +# Mod+1 - should be on workspace 1 | |
| 210 | +# Open xterm | |
| 211 | +# Mod+2 - switch to workspace 2 (empty) | |
| 212 | +# Open xterm - should be alone | |
| 213 | +# Mod+1 - back to workspace 1, original xterm visible | |
| 214 | + | |
| 215 | +# Test window movement | |
| 216 | +# On workspace 1 with xterm | |
| 217 | +# Mod+Shift+2 - moves xterm to workspace 2 | |
| 218 | +# Mod+2 - verify xterm is there | |
| 219 | + | |
| 220 | +# Test polybar integration | |
| 221 | +polybar example # Should show workspace indicators | |
| 222 | +``` | |
| 223 | + | |
| 224 | +## Status Bar Integration | |
| 225 | + | |
| 226 | +For polybar, create an i3-compatible module (we'll implement IPC in Sprint 6): | |
| 227 | + | |
| 228 | +```ini | |
| 229 | +[module/workspaces] | |
| 230 | +type = internal/xworkspaces | |
| 231 | +label-active = %name% | |
| 232 | +label-occupied = %name% | |
| 233 | +label-empty = %name% | |
| 234 | +``` | |
| 235 | + | |
| 236 | +## Notes | |
| 237 | + | |
| 238 | +- 0-indexed for EWMH, 1-indexed for user display | |
| 239 | +- Consider urgent workspace highlighting (Sprint 8) | |
| 240 | +- Named workspaces beyond 1-10 come with Lua config (Sprint 4) | |
| 241 | +- Multi-monitor workspace assignment comes in Sprint 7 | |
docs/sprints/sprint-4-lua-config.mdadded@@ -0,0 +1,279 @@ | ||
| 1 | +# Sprint 4: Lua Configuration | |
| 2 | + | |
| 3 | +**Goal:** User-configurable keybinds, settings, and startup applications via Lua scripting. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Load and execute Lua configuration file | |
| 8 | +- Expose gar API to Lua (keybinds, settings, rules) | |
| 9 | +- Support config hot-reload | |
| 10 | +- Provide sensible default configuration | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 3 complete (workspaces) | |
| 15 | + | |
| 16 | +## Lua API Design | |
| 17 | + | |
| 18 | +```lua | |
| 19 | +-- ~/.config/gar/init.lua | |
| 20 | + | |
| 21 | +-- Settings | |
| 22 | +gar.set("border_width", 2) | |
| 23 | +gar.set("border_color_focused", "#5294e2") | |
| 24 | +gar.set("border_color_unfocused", "#2d2d2d") | |
| 25 | +gar.set("gap_inner", 5) | |
| 26 | +gar.set("gap_outer", 10) | |
| 27 | + | |
| 28 | +-- Keybindings | |
| 29 | +gar.bind("mod+Return", function() gar.exec("alacritty") end) | |
| 30 | +gar.bind("mod+d", function() gar.exec("rofi -show drun") end) | |
| 31 | +gar.bind("mod+shift+q", gar.close_window) | |
| 32 | +gar.bind("mod+h", function() gar.focus("left") end) | |
| 33 | +gar.bind("mod+j", function() gar.focus("down") end) | |
| 34 | +gar.bind("mod+k", function() gar.focus("up") end) | |
| 35 | +gar.bind("mod+l", function() gar.focus("right") end) | |
| 36 | + | |
| 37 | +-- Workspace bindings (loop) | |
| 38 | +for i = 1, 9 do | |
| 39 | + gar.bind("mod+" .. i, function() gar.workspace(i) end) | |
| 40 | + gar.bind("mod+shift+" .. i, function() gar.move_to_workspace(i) end) | |
| 41 | +end | |
| 42 | + | |
| 43 | +-- Window rules | |
| 44 | +gar.rule({ class = "Firefox" }, { workspace = 2 }) | |
| 45 | +gar.rule({ class = "Spotify" }, { floating = true }) | |
| 46 | +gar.rule({ type = "dialog" }, { floating = true }) | |
| 47 | + | |
| 48 | +-- Startup applications | |
| 49 | +gar.exec_once("picom") | |
| 50 | +gar.exec_once("polybar") | |
| 51 | +gar.exec_once("dunst") | |
| 52 | +``` | |
| 53 | + | |
| 54 | +## Tasks | |
| 55 | + | |
| 56 | +### 4.1 Lua Integration Setup | |
| 57 | +- [ ] Add `mlua` dependency | |
| 58 | +- [ ] Create `src/config/mod.rs` and `src/config/lua.rs` | |
| 59 | +- [ ] Initialize Lua state | |
| 60 | +- [ ] Create `gar` global table | |
| 61 | +- [ ] Handle Lua errors gracefully | |
| 62 | + | |
| 63 | +```rust | |
| 64 | +use mlua::{Lua, Result as LuaResult}; | |
| 65 | + | |
| 66 | +pub struct LuaConfig { | |
| 67 | + lua: Lua, | |
| 68 | +} | |
| 69 | + | |
| 70 | +impl LuaConfig { | |
| 71 | + pub fn new() -> LuaResult<Self> { | |
| 72 | + let lua = Lua::new(); | |
| 73 | + | |
| 74 | + // Create gar global table | |
| 75 | + lua.globals().set("gar", lua.create_table()?)?; | |
| 76 | + | |
| 77 | + Ok(Self { lua }) | |
| 78 | + } | |
| 79 | +} | |
| 80 | +``` | |
| 81 | + | |
| 82 | +### 4.2 Configuration Loading | |
| 83 | +- [ ] Find config file (`~/.config/gar/init.lua`) | |
| 84 | +- [ ] Fall back to default config if not found | |
| 85 | +- [ ] Execute Lua file | |
| 86 | +- [ ] Handle syntax errors with helpful messages | |
| 87 | + | |
| 88 | +```rust | |
| 89 | +impl LuaConfig { | |
| 90 | + pub fn load(&self, wm: &mut WindowManager) -> Result<()> { | |
| 91 | + let config_path = dirs::config_dir() | |
| 92 | + .map(|p| p.join("gar/init.lua")) | |
| 93 | + .filter(|p| p.exists()); | |
| 94 | + | |
| 95 | + let source = match config_path { | |
| 96 | + Some(path) => std::fs::read_to_string(path)?, | |
| 97 | + None => include_str!("../../config/default.lua").to_string(), | |
| 98 | + }; | |
| 99 | + | |
| 100 | + self.lua.load(&source).exec()?; | |
| 101 | + Ok(()) | |
| 102 | + } | |
| 103 | +} | |
| 104 | +``` | |
| 105 | + | |
| 106 | +### 4.3 Settings API | |
| 107 | +- [ ] Implement `gar.set(key, value)` | |
| 108 | +- [ ] Store settings in Rust-side config struct | |
| 109 | +- [ ] Support: border_width, border colors, gaps | |
| 110 | +- [ ] Validate values (types, ranges) | |
| 111 | + | |
| 112 | +```rust | |
| 113 | +// Register gar.set | |
| 114 | +let set_fn = lua.create_function(|_, (key, value): (String, mlua::Value)| { | |
| 115 | + // Send to config channel or store in shared state | |
| 116 | + Ok(()) | |
| 117 | +})?; | |
| 118 | +gar_table.set("set", set_fn)?; | |
| 119 | +``` | |
| 120 | + | |
| 121 | +**Supported settings:** | |
| 122 | +| Setting | Type | Default | Description | | |
| 123 | +|---------|------|---------|-------------| | |
| 124 | +| border_width | int | 2 | Window border width in pixels | | |
| 125 | +| border_color_focused | string | "#5294e2" | Focused window border color | | |
| 126 | +| border_color_unfocused | string | "#2d2d2d" | Unfocused window border color | | |
| 127 | +| gap_inner | int | 0 | Gap between windows | | |
| 128 | +| gap_outer | int | 0 | Gap around screen edge | | |
| 129 | + | |
| 130 | +### 4.4 Keybind API | |
| 131 | +- [ ] Implement `gar.bind(keyspec, callback)` | |
| 132 | +- [ ] Parse keyspec string ("mod+shift+q") | |
| 133 | +- [ ] Register callback function | |
| 134 | +- [ ] Grab keys via X11 | |
| 135 | + | |
| 136 | +```rust | |
| 137 | +// Key specification parser | |
| 138 | +fn parse_keyspec(spec: &str) -> Result<(Modifiers, Keycode)> { | |
| 139 | + // "mod+shift+q" -> (MOD4 | SHIFT, keycode_q) | |
| 140 | + let parts: Vec<&str> = spec.to_lowercase().split('+').collect(); | |
| 141 | + // ... | |
| 142 | +} | |
| 143 | +``` | |
| 144 | + | |
| 145 | +### 4.5 Built-in Actions | |
| 146 | +- [ ] `gar.focus(direction)` - focus in direction | |
| 147 | +- [ ] `gar.swap(direction)` - swap with direction | |
| 148 | +- [ ] `gar.resize(direction, amount)` - resize split | |
| 149 | +- [ ] `gar.close_window()` - close focused window | |
| 150 | +- [ ] `gar.workspace(n)` - switch workspace | |
| 151 | +- [ ] `gar.move_to_workspace(n)` - move window | |
| 152 | +- [ ] `gar.exec(cmd)` - spawn command | |
| 153 | +- [ ] `gar.exec_once(cmd)` - spawn only if not running | |
| 154 | +- [ ] `gar.reload()` - reload config | |
| 155 | + | |
| 156 | +### 4.6 Window Rules | |
| 157 | +- [ ] Implement `gar.rule(match, actions)` | |
| 158 | +- [ ] Match by: class, instance, title, type | |
| 159 | +- [ ] Actions: workspace, floating, border, size, position | |
| 160 | +- [ ] Apply rules on window creation | |
| 161 | + | |
| 162 | +```rust | |
| 163 | +pub struct WindowRule { | |
| 164 | + pub match_class: Option<Regex>, | |
| 165 | + pub match_instance: Option<Regex>, | |
| 166 | + pub match_title: Option<Regex>, | |
| 167 | + pub match_type: Option<WindowType>, | |
| 168 | + | |
| 169 | + pub workspace: Option<WorkspaceId>, | |
| 170 | + pub floating: Option<bool>, | |
| 171 | + pub border_width: Option<u32>, | |
| 172 | + // ... | |
| 173 | +} | |
| 174 | +``` | |
| 175 | + | |
| 176 | +### 4.7 Config Reload | |
| 177 | +- [ ] Implement `gar.reload()` function | |
| 178 | +- [ ] Bind to Mod+Shift+R by default | |
| 179 | +- [ ] Clear existing keybinds | |
| 180 | +- [ ] Re-execute config file | |
| 181 | +- [ ] Apply new settings immediately | |
| 182 | +- [ ] Report errors without crashing | |
| 183 | + | |
| 184 | +### 4.8 Default Configuration | |
| 185 | +- [ ] Create `config/default.lua` | |
| 186 | +- [ ] Include sensible defaults for all settings | |
| 187 | +- [ ] Include essential keybinds (focus, close, workspaces) | |
| 188 | +- [ ] Document all options in comments | |
| 189 | + | |
| 190 | +## Default Configuration | |
| 191 | + | |
| 192 | +```lua | |
| 193 | +-- config/default.lua | |
| 194 | +-- Default gar configuration | |
| 195 | + | |
| 196 | +-- Appearance | |
| 197 | +gar.set("border_width", 2) | |
| 198 | +gar.set("border_color_focused", "#5294e2") | |
| 199 | +gar.set("border_color_unfocused", "#2d2d2d") | |
| 200 | +gar.set("gap_inner", 0) | |
| 201 | +gar.set("gap_outer", 0) | |
| 202 | + | |
| 203 | +-- Mod key (mod = Super/Win key) | |
| 204 | +local mod = "mod" | |
| 205 | + | |
| 206 | +-- Core keybindings | |
| 207 | +gar.bind(mod .. "+Return", function() gar.exec("xterm") end) | |
| 208 | +gar.bind(mod .. "+shift+q", gar.close_window) | |
| 209 | +gar.bind(mod .. "+shift+r", gar.reload) | |
| 210 | +gar.bind(mod .. "+shift+e", gar.exit) | |
| 211 | + | |
| 212 | +-- Focus navigation | |
| 213 | +gar.bind(mod .. "+h", function() gar.focus("left") end) | |
| 214 | +gar.bind(mod .. "+j", function() gar.focus("down") end) | |
| 215 | +gar.bind(mod .. "+k", function() gar.focus("up") end) | |
| 216 | +gar.bind(mod .. "+l", function() gar.focus("right") end) | |
| 217 | + | |
| 218 | +-- Window movement | |
| 219 | +gar.bind(mod .. "+shift+h", function() gar.swap("left") end) | |
| 220 | +gar.bind(mod .. "+shift+j", function() gar.swap("down") end) | |
| 221 | +gar.bind(mod .. "+shift+k", function() gar.swap("up") end) | |
| 222 | +gar.bind(mod .. "+shift+l", function() gar.swap("right") end) | |
| 223 | + | |
| 224 | +-- Resize | |
| 225 | +gar.bind(mod .. "+ctrl+h", function() gar.resize("left", 0.05) end) | |
| 226 | +gar.bind(mod .. "+ctrl+j", function() gar.resize("down", 0.05) end) | |
| 227 | +gar.bind(mod .. "+ctrl+k", function() gar.resize("up", 0.05) end) | |
| 228 | +gar.bind(mod .. "+ctrl+l", function() gar.resize("right", 0.05) end) | |
| 229 | +gar.bind(mod .. "+e", gar.equalize) | |
| 230 | + | |
| 231 | +-- Workspaces | |
| 232 | +for i = 1, 9 do | |
| 233 | + gar.bind(mod .. "+" .. i, function() gar.workspace(i) end) | |
| 234 | + gar.bind(mod .. "+shift+" .. i, function() gar.move_to_workspace(i) end) | |
| 235 | +end | |
| 236 | +gar.bind(mod .. "+0", function() gar.workspace(10) end) | |
| 237 | +gar.bind(mod .. "+shift+0", function() gar.move_to_workspace(10) end) | |
| 238 | + | |
| 239 | +-- Window rules | |
| 240 | +gar.rule({ type = "dialog" }, { floating = true }) | |
| 241 | +gar.rule({ type = "splash" }, { floating = true }) | |
| 242 | +``` | |
| 243 | + | |
| 244 | +## Acceptance Criteria | |
| 245 | + | |
| 246 | +1. Config loads from `~/.config/gar/init.lua` | |
| 247 | +2. Falls back to default if no config exists | |
| 248 | +3. All keybinds configurable via Lua | |
| 249 | +4. Settings (borders, gaps) apply immediately | |
| 250 | +5. Window rules work for class/type matching | |
| 251 | +6. Mod+Shift+R reloads config without restart | |
| 252 | +7. Syntax errors reported clearly, don't crash WM | |
| 253 | + | |
| 254 | +## Testing Strategy | |
| 255 | + | |
| 256 | +```bash | |
| 257 | +# Create test config | |
| 258 | +mkdir -p ~/.config/gar | |
| 259 | +cat > ~/.config/gar/init.lua << 'EOF' | |
| 260 | +gar.set("border_width", 4) | |
| 261 | +gar.set("border_color_focused", "#ff0000") | |
| 262 | +gar.bind("mod+t", function() print("test") end) | |
| 263 | +EOF | |
| 264 | + | |
| 265 | +# Start gar, verify: | |
| 266 | +# - 4px borders | |
| 267 | +# - Red focus color | |
| 268 | +# - Mod+t prints to log | |
| 269 | + | |
| 270 | +# Test reload | |
| 271 | +# Modify config, Mod+Shift+R, verify changes apply | |
| 272 | +``` | |
| 273 | + | |
| 274 | +## Notes | |
| 275 | + | |
| 276 | +- Lua 5.4 via mlua with vendored feature (no system dependency) | |
| 277 | +- Consider sandboxing Lua (disable os.execute, io.*, etc.) | |
| 278 | +- exec_once needs to track spawned processes | |
| 279 | +- Error messages should include line numbers | |
docs/sprints/sprint-5-floating.mdadded@@ -0,0 +1,271 @@ | ||
| 1 | +# Sprint 5: Floating Windows | |
| 2 | + | |
| 3 | +**Goal:** Full floating window support with mouse interaction. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Toggle windows between tiled and floating | |
| 8 | +- Mouse-based move and resize for floating windows | |
| 9 | +- Automatic floating for dialogs and certain window types | |
| 10 | +- Proper stacking order (floating above tiled) | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 4 complete (Lua configuration) | |
| 15 | + | |
| 16 | +## Tasks | |
| 17 | + | |
| 18 | +### 5.1 Floating Window State | |
| 19 | +- [ ] Add floating window list to Workspace | |
| 20 | +- [ ] Track floating window geometry separately | |
| 21 | +- [ ] Floating windows don't participate in BSP tree | |
| 22 | +- [ ] Track stacking order for floating windows | |
| 23 | + | |
| 24 | +```rust | |
| 25 | +pub struct Workspace { | |
| 26 | + pub tree: Node, // Tiled windows | |
| 27 | + pub floating: Vec<FloatingWindow>, // Floating windows | |
| 28 | + pub focused: Option<WindowId>, | |
| 29 | + // ... | |
| 30 | +} | |
| 31 | + | |
| 32 | +pub struct FloatingWindow { | |
| 33 | + pub id: WindowId, | |
| 34 | + pub geometry: Rect, | |
| 35 | + pub z_order: u32, | |
| 36 | +} | |
| 37 | +``` | |
| 38 | + | |
| 39 | +### 5.2 Toggle Floating | |
| 40 | +- [ ] Implement Mod+Shift+Space to toggle | |
| 41 | +- [ ] When floating: remove from tree, add to floating list | |
| 42 | +- [ ] When tiling: remove from floating list, insert to tree | |
| 43 | +- [ ] Preserve position when floating | |
| 44 | +- [ ] Use smart split when returning to tiled | |
| 45 | + | |
| 46 | +```rust | |
| 47 | +fn toggle_floating(&mut self, window: WindowId) { | |
| 48 | + if self.is_floating(window) { | |
| 49 | + // Move to tiled | |
| 50 | + let float_win = self.floating.remove(/* ... */); | |
| 51 | + self.tree.insert(window); | |
| 52 | + } else { | |
| 53 | + // Move to floating | |
| 54 | + let geometry = self.get_window_geometry(window); | |
| 55 | + self.tree.remove(window); | |
| 56 | + self.floating.push(FloatingWindow { | |
| 57 | + id: window, | |
| 58 | + geometry, | |
| 59 | + z_order: self.next_z_order(), | |
| 60 | + }); | |
| 61 | + } | |
| 62 | +} | |
| 63 | +``` | |
| 64 | + | |
| 65 | +### 5.3 Mouse Move (Mod+LeftClick) | |
| 66 | +- [ ] Grab Mod+Button1 on all windows | |
| 67 | +- [ ] On press: record start position | |
| 68 | +- [ ] On motion: update window position | |
| 69 | +- [ ] On release: finalize position | |
| 70 | +- [ ] Only works for floating windows | |
| 71 | + | |
| 72 | +```rust | |
| 73 | +fn handle_button_press(&mut self, event: ButtonPressEvent) { | |
| 74 | + if event.detail == 1 && event.state.contains(ModMask::M4) { | |
| 75 | + // Start move operation | |
| 76 | + self.drag_state = Some(DragState::Move { | |
| 77 | + window: event.child, | |
| 78 | + start_x: event.root_x, | |
| 79 | + start_y: event.root_y, | |
| 80 | + start_geometry: self.get_geometry(event.child), | |
| 81 | + }); | |
| 82 | + self.conn.grab_pointer(/* ... */)?; | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +fn handle_motion(&mut self, event: MotionNotifyEvent) { | |
| 87 | + if let Some(DragState::Move { window, start_x, start_y, start_geometry }) = &self.drag_state { | |
| 88 | + let dx = event.root_x - start_x; | |
| 89 | + let dy = event.root_y - start_y; | |
| 90 | + let new_x = start_geometry.x + dx; | |
| 91 | + let new_y = start_geometry.y + dy; | |
| 92 | + self.configure_window(window, new_x, new_y, None, None)?; | |
| 93 | + } | |
| 94 | +} | |
| 95 | +``` | |
| 96 | + | |
| 97 | +### 5.4 Mouse Resize (Mod+RightClick) | |
| 98 | +- [ ] Grab Mod+Button3 on all windows | |
| 99 | +- [ ] Determine resize corner based on click position | |
| 100 | +- [ ] On motion: adjust size (and position for top/left edges) | |
| 101 | +- [ ] Enforce minimum window size | |
| 102 | +- [ ] Only works for floating windows | |
| 103 | + | |
| 104 | +```rust | |
| 105 | +fn determine_resize_edge(window_geometry: Rect, click_x: i16, click_y: i16) -> ResizeEdge { | |
| 106 | + let center_x = window_geometry.x + window_geometry.width as i16 / 2; | |
| 107 | + let center_y = window_geometry.y + window_geometry.height as i16 / 2; | |
| 108 | + | |
| 109 | + match (click_x < center_x, click_y < center_y) { | |
| 110 | + (true, true) => ResizeEdge::TopLeft, | |
| 111 | + (false, true) => ResizeEdge::TopRight, | |
| 112 | + (true, false) => ResizeEdge::BottomLeft, | |
| 113 | + (false, false) => ResizeEdge::BottomRight, | |
| 114 | + } | |
| 115 | +} | |
| 116 | +``` | |
| 117 | + | |
| 118 | +### 5.5 Auto-Float Rules | |
| 119 | +- [ ] Float `_NET_WM_WINDOW_TYPE_DIALOG` | |
| 120 | +- [ ] Float `_NET_WM_WINDOW_TYPE_SPLASH` | |
| 121 | +- [ ] Float `_NET_WM_WINDOW_TYPE_TOOLTIP` | |
| 122 | +- [ ] Float `_NET_WM_WINDOW_TYPE_UTILITY` | |
| 123 | +- [ ] Float windows with WM_TRANSIENT_FOR set | |
| 124 | +- [ ] Respect size hints for floating position | |
| 125 | + | |
| 126 | +```rust | |
| 127 | +fn should_float(conn: &impl Connection, window: Window) -> Result<bool> { | |
| 128 | + let window_type = get_window_type(conn, window)?; | |
| 129 | + | |
| 130 | + let float_types = [ | |
| 131 | + atoms._NET_WM_WINDOW_TYPE_DIALOG, | |
| 132 | + atoms._NET_WM_WINDOW_TYPE_SPLASH, | |
| 133 | + atoms._NET_WM_WINDOW_TYPE_TOOLTIP, | |
| 134 | + atoms._NET_WM_WINDOW_TYPE_UTILITY, | |
| 135 | + atoms._NET_WM_WINDOW_TYPE_POPUP_MENU, | |
| 136 | + ]; | |
| 137 | + | |
| 138 | + if float_types.contains(&window_type) { | |
| 139 | + return Ok(true); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // Check transient | |
| 143 | + if get_transient_for(conn, window)?.is_some() { | |
| 144 | + return Ok(true); | |
| 145 | + } | |
| 146 | + | |
| 147 | + Ok(false) | |
| 148 | +} | |
| 149 | +``` | |
| 150 | + | |
| 151 | +### 5.6 Stacking Order | |
| 152 | +- [ ] Floating windows above tiled | |
| 153 | +- [ ] Most recently focused floating on top | |
| 154 | +- [ ] Use `ConfigureWindow` with `stack_mode` and `sibling` | |
| 155 | +- [ ] Maintain stacking order on focus changes | |
| 156 | + | |
| 157 | +```rust | |
| 158 | +fn raise_window(&self, conn: &impl Connection, window: Window) -> Result<()> { | |
| 159 | + conn.configure_window( | |
| 160 | + window, | |
| 161 | + &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE), | |
| 162 | + )?; | |
| 163 | + Ok(()) | |
| 164 | +} | |
| 165 | + | |
| 166 | +fn restack_floating(&self, conn: &impl Connection) -> Result<()> { | |
| 167 | + // Stack in z_order, lowest first | |
| 168 | + let mut sorted: Vec<_> = self.floating.iter().collect(); | |
| 169 | + sorted.sort_by_key(|w| w.z_order); | |
| 170 | + | |
| 171 | + for (i, window) in sorted.iter().enumerate() { | |
| 172 | + if i > 0 { | |
| 173 | + conn.configure_window( | |
| 174 | + window.id, | |
| 175 | + &ConfigureWindowAux::new() | |
| 176 | + .stack_mode(StackMode::ABOVE) | |
| 177 | + .sibling(sorted[i - 1].id), | |
| 178 | + )?; | |
| 179 | + } | |
| 180 | + } | |
| 181 | + Ok(()) | |
| 182 | +} | |
| 183 | +``` | |
| 184 | + | |
| 185 | +### 5.7 Floating Focus | |
| 186 | +- [ ] Tab cycles through floating windows | |
| 187 | +- [ ] Focus follows click for floating | |
| 188 | +- [ ] Raise on focus | |
| 189 | +- [ ] Handle focus between tiled and floating | |
| 190 | + | |
| 191 | +```rust | |
| 192 | +fn focus_next_floating(&mut self) { | |
| 193 | + if self.floating.is_empty() { | |
| 194 | + return; | |
| 195 | + } | |
| 196 | + | |
| 197 | + let current_idx = self.floating | |
| 198 | + .iter() | |
| 199 | + .position(|w| Some(w.id) == self.focused) | |
| 200 | + .unwrap_or(0); | |
| 201 | + | |
| 202 | + let next_idx = (current_idx + 1) % self.floating.len(); | |
| 203 | + let next_window = self.floating[next_idx].id; | |
| 204 | + | |
| 205 | + self.set_focus(next_window); | |
| 206 | + self.raise_window(next_window); | |
| 207 | +} | |
| 208 | +``` | |
| 209 | + | |
| 210 | +### 5.8 Lua Integration | |
| 211 | +- [ ] Add `floating` property to window rules | |
| 212 | +- [ ] Add `gar.toggle_floating()` function | |
| 213 | +- [ ] Add `gar.float_geometry(x, y, w, h)` for rules | |
| 214 | + | |
| 215 | +```lua | |
| 216 | +-- Float Firefox PiP | |
| 217 | +gar.rule( | |
| 218 | + { class = "firefox", title = "Picture-in-Picture" }, | |
| 219 | + { floating = true, geometry = { x = 100, y = 100, width = 400, height = 300 } } | |
| 220 | +) | |
| 221 | + | |
| 222 | +-- Keybind | |
| 223 | +gar.bind("mod+shift+space", gar.toggle_floating) | |
| 224 | +``` | |
| 225 | + | |
| 226 | +## Keybind Summary | |
| 227 | + | |
| 228 | +| Keybind | Action | | |
| 229 | +|---------|--------| | |
| 230 | +| Mod+Shift+Space | Toggle floating | | |
| 231 | +| Mod+LeftClick drag | Move floating window | | |
| 232 | +| Mod+RightClick drag | Resize floating window | | |
| 233 | + | |
| 234 | +## Acceptance Criteria | |
| 235 | + | |
| 236 | +1. Mod+Shift+Space toggles between tiled/floating | |
| 237 | +2. Can drag floating windows with Mod+LeftClick | |
| 238 | +3. Can resize floating windows with Mod+RightClick | |
| 239 | +4. Dialogs automatically float | |
| 240 | +5. Floating windows stay above tiled | |
| 241 | +6. Focus works correctly between tiled/floating | |
| 242 | + | |
| 243 | +## Testing Strategy | |
| 244 | + | |
| 245 | +```bash | |
| 246 | +# Test toggle | |
| 247 | +# Open xterm, verify tiled | |
| 248 | +# Mod+Shift+Space, verify floating (doesn't affect layout) | |
| 249 | +# Mod+Shift+Space again, verify back in tile | |
| 250 | + | |
| 251 | +# Test mouse | |
| 252 | +# Float a window | |
| 253 | +# Mod+LeftClick drag - should move | |
| 254 | +# Mod+RightClick drag - should resize | |
| 255 | + | |
| 256 | +# Test auto-float | |
| 257 | +# Open a dialog (e.g., Firefox preferences) | |
| 258 | +# Should automatically float | |
| 259 | + | |
| 260 | +# Test stacking | |
| 261 | +# Have tiled windows | |
| 262 | +# Float one | |
| 263 | +# Floating should be on top | |
| 264 | +``` | |
| 265 | + | |
| 266 | +## Notes | |
| 267 | + | |
| 268 | +- Consider minimum size constraints | |
| 269 | +- Handle fullscreen windows (Sprint 8) | |
| 270 | +- Floating geometry should be remembered when toggling | |
| 271 | +- Resize should work from any edge (not just corner) | |
docs/sprints/sprint-6-ipc.mdadded@@ -0,0 +1,348 @@ | ||
| 1 | +# Sprint 6: IPC System | |
| 2 | + | |
| 3 | +**Goal:** External control via Unix socket with JSON protocol. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Unix socket server for IPC | |
| 8 | +- JSON-based command/response protocol | |
| 9 | +- Command-line tool (`garctl`) for interaction | |
| 10 | +- Event subscription for external tools | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 5 complete (floating windows) | |
| 15 | + | |
| 16 | +## Protocol Design | |
| 17 | + | |
| 18 | +### Message Format | |
| 19 | + | |
| 20 | +```json | |
| 21 | +// Command (client -> server) | |
| 22 | +{ | |
| 23 | + "type": "command", | |
| 24 | + "command": "focus", | |
| 25 | + "args": { "direction": "left" } | |
| 26 | +} | |
| 27 | + | |
| 28 | +// Response (server -> client) | |
| 29 | +{ | |
| 30 | + "type": "response", | |
| 31 | + "success": true, | |
| 32 | + "data": { /* optional result */ } | |
| 33 | +} | |
| 34 | + | |
| 35 | +// Error response | |
| 36 | +{ | |
| 37 | + "type": "response", | |
| 38 | + "success": false, | |
| 39 | + "error": "Window not found" | |
| 40 | +} | |
| 41 | + | |
| 42 | +// Event (server -> subscribed clients) | |
| 43 | +{ | |
| 44 | + "type": "event", | |
| 45 | + "event": "window_focus", | |
| 46 | + "data": { "window_id": 12345, "title": "Terminal" } | |
| 47 | +} | |
| 48 | +``` | |
| 49 | + | |
| 50 | +### Commands | |
| 51 | + | |
| 52 | +| Command | Args | Description | | |
| 53 | +|---------|------|-------------| | |
| 54 | +| focus | direction | Focus window in direction | | |
| 55 | +| swap | direction | Swap with window in direction | | |
| 56 | +| resize | direction, amount | Resize split | | |
| 57 | +| close | - | Close focused window | | |
| 58 | +| workspace | number | Switch to workspace | | |
| 59 | +| move_to_workspace | number | Move window to workspace | | |
| 60 | +| toggle_floating | - | Toggle floating state | | |
| 61 | +| reload | - | Reload configuration | | |
| 62 | +| exit | - | Exit gar | | |
| 63 | +| get_tree | - | Get window tree | | |
| 64 | +| get_workspaces | - | Get workspace info | | |
| 65 | +| get_focused | - | Get focused window | | |
| 66 | +| subscribe | events[] | Subscribe to events | | |
| 67 | + | |
| 68 | +### Events | |
| 69 | + | |
| 70 | +| Event | Data | Description | | |
| 71 | +|-------|------|-------------| | |
| 72 | +| window_new | window info | New window created | | |
| 73 | +| window_close | window_id | Window closed | | |
| 74 | +| window_focus | window info | Focus changed | | |
| 75 | +| window_move | window_id, workspace | Window moved | | |
| 76 | +| workspace_focus | workspace info | Workspace changed | | |
| 77 | +| mode | mode name | Mode changed (later) | | |
| 78 | + | |
| 79 | +## Tasks | |
| 80 | + | |
| 81 | +### 6.1 Socket Server Setup | |
| 82 | +- [ ] Create `src/ipc/mod.rs`, `server.rs`, `protocol.rs` | |
| 83 | +- [ ] Add tokio dependency for async I/O | |
| 84 | +- [ ] Create Unix socket at `$XDG_RUNTIME_DIR/gar.sock` | |
| 85 | +- [ ] Handle multiple concurrent clients | |
| 86 | +- [ ] Clean up socket on exit | |
| 87 | + | |
| 88 | +```rust | |
| 89 | +use tokio::net::{UnixListener, UnixStream}; | |
| 90 | + | |
| 91 | +pub struct IpcServer { | |
| 92 | + listener: UnixListener, | |
| 93 | + clients: Vec<Client>, | |
| 94 | +} | |
| 95 | + | |
| 96 | +impl IpcServer { | |
| 97 | + pub async fn new() -> Result<Self> { | |
| 98 | + let path = std::env::var("XDG_RUNTIME_DIR") | |
| 99 | + .map(|dir| format!("{}/gar.sock", dir)) | |
| 100 | + .unwrap_or_else(|_| "/tmp/gar.sock".to_string()); | |
| 101 | + | |
| 102 | + // Remove existing socket | |
| 103 | + let _ = std::fs::remove_file(&path); | |
| 104 | + | |
| 105 | + let listener = UnixListener::bind(&path)?; | |
| 106 | + Ok(Self { listener, clients: Vec::new() }) | |
| 107 | + } | |
| 108 | +} | |
| 109 | +``` | |
| 110 | + | |
| 111 | +### 6.2 Protocol Types | |
| 112 | +- [ ] Define message types with serde | |
| 113 | +- [ ] Implement JSON serialization | |
| 114 | +- [ ] Handle malformed messages gracefully | |
| 115 | + | |
| 116 | +```rust | |
| 117 | +use serde::{Deserialize, Serialize}; | |
| 118 | + | |
| 119 | +#[derive(Debug, Deserialize)] | |
| 120 | +#[serde(tag = "type")] | |
| 121 | +pub enum Request { | |
| 122 | + #[serde(rename = "command")] | |
| 123 | + Command { command: String, args: serde_json::Value }, | |
| 124 | +} | |
| 125 | + | |
| 126 | +#[derive(Debug, Serialize)] | |
| 127 | +#[serde(tag = "type")] | |
| 128 | +pub enum Response { | |
| 129 | + #[serde(rename = "response")] | |
| 130 | + Success { success: bool, data: Option<serde_json::Value> }, | |
| 131 | + #[serde(rename = "response")] | |
| 132 | + Error { success: bool, error: String }, | |
| 133 | +} | |
| 134 | + | |
| 135 | +#[derive(Debug, Serialize)] | |
| 136 | +#[serde(tag = "type")] | |
| 137 | +pub enum Event { | |
| 138 | + #[serde(rename = "event")] | |
| 139 | + Event { event: String, data: serde_json::Value }, | |
| 140 | +} | |
| 141 | +``` | |
| 142 | + | |
| 143 | +### 6.3 Command Dispatch | |
| 144 | +- [ ] Parse incoming commands | |
| 145 | +- [ ] Map to window manager actions | |
| 146 | +- [ ] Execute and return result | |
| 147 | +- [ ] Handle unknown commands | |
| 148 | + | |
| 149 | +```rust | |
| 150 | +impl IpcServer { | |
| 151 | + fn dispatch(&self, wm: &mut WindowManager, cmd: &str, args: Value) -> Response { | |
| 152 | + match cmd { | |
| 153 | + "focus" => { | |
| 154 | + let direction = args["direction"].as_str().unwrap(); | |
| 155 | + wm.focus_direction(direction.parse()?)?; | |
| 156 | + Response::success(None) | |
| 157 | + } | |
| 158 | + "get_tree" => { | |
| 159 | + let tree = wm.get_tree_json(); | |
| 160 | + Response::success(Some(tree)) | |
| 161 | + } | |
| 162 | + _ => Response::error(format!("Unknown command: {}", cmd)), | |
| 163 | + } | |
| 164 | + } | |
| 165 | +} | |
| 166 | +``` | |
| 167 | + | |
| 168 | +### 6.4 Query Commands | |
| 169 | +- [ ] `get_tree` - return workspace trees as JSON | |
| 170 | +- [ ] `get_workspaces` - return workspace list | |
| 171 | +- [ ] `get_focused` - return focused window info | |
| 172 | +- [ ] `get_outputs` - return monitor info (for multi-monitor) | |
| 173 | + | |
| 174 | +```rust | |
| 175 | +fn get_tree_json(&self) -> Value { | |
| 176 | + json!({ | |
| 177 | + "workspaces": self.workspaces.iter().map(|ws| { | |
| 178 | + json!({ | |
| 179 | + "name": ws.name, | |
| 180 | + "focused": ws.focused, | |
| 181 | + "nodes": tree_to_json(&ws.tree), | |
| 182 | + }) | |
| 183 | + }).collect::<Vec<_>>() | |
| 184 | + }) | |
| 185 | +} | |
| 186 | +``` | |
| 187 | + | |
| 188 | +### 6.5 Event Subscription | |
| 189 | +- [ ] Track subscribed clients per event type | |
| 190 | +- [ ] Broadcast events to subscribers | |
| 191 | +- [ ] Handle client disconnection | |
| 192 | +- [ ] Implement subscription command | |
| 193 | + | |
| 194 | +```rust | |
| 195 | +struct Client { | |
| 196 | + stream: UnixStream, | |
| 197 | + subscriptions: HashSet<String>, | |
| 198 | +} | |
| 199 | + | |
| 200 | +impl IpcServer { | |
| 201 | + fn broadcast_event(&mut self, event: &str, data: Value) { | |
| 202 | + let msg = Event { event: event.into(), data }; | |
| 203 | + let json = serde_json::to_string(&msg).unwrap(); | |
| 204 | + | |
| 205 | + self.clients.retain(|client| { | |
| 206 | + if client.subscriptions.contains(event) { | |
| 207 | + client.stream.try_write(json.as_bytes()).is_ok() | |
| 208 | + } else { | |
| 209 | + true | |
| 210 | + } | |
| 211 | + }); | |
| 212 | + } | |
| 213 | +} | |
| 214 | +``` | |
| 215 | + | |
| 216 | +### 6.6 Integration with Event Loop | |
| 217 | +- [ ] Run IPC server alongside X event loop | |
| 218 | +- [ ] Use tokio runtime or poll-based approach | |
| 219 | +- [ ] Handle commands without blocking X events | |
| 220 | +- [ ] Thread-safe communication with WM state | |
| 221 | + | |
| 222 | +```rust | |
| 223 | +// Option 1: Tokio with channels | |
| 224 | +fn main() { | |
| 225 | + let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(100); | |
| 226 | + let (event_tx, event_rx) = tokio::sync::broadcast::channel(100); | |
| 227 | + | |
| 228 | + // Spawn IPC server task | |
| 229 | + tokio::spawn(async move { | |
| 230 | + ipc_server.run(cmd_tx, event_rx).await; | |
| 231 | + }); | |
| 232 | + | |
| 233 | + // X event loop | |
| 234 | + loop { | |
| 235 | + // Check for IPC commands | |
| 236 | + while let Ok(cmd) = cmd_rx.try_recv() { | |
| 237 | + handle_ipc_command(cmd); | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Handle X events | |
| 241 | + let event = conn.wait_for_event()?; | |
| 242 | + handle_x_event(event); | |
| 243 | + | |
| 244 | + // Broadcast events | |
| 245 | + event_tx.send(/* ... */); | |
| 246 | + } | |
| 247 | +} | |
| 248 | +``` | |
| 249 | + | |
| 250 | +### 6.7 garctl CLI Tool | |
| 251 | +- [ ] Create `garctl/src/main.rs` | |
| 252 | +- [ ] Connect to gar socket | |
| 253 | +- [ ] Send commands from CLI args | |
| 254 | +- [ ] Print responses | |
| 255 | +- [ ] Support event monitoring mode | |
| 256 | + | |
| 257 | +```rust | |
| 258 | +// garctl/src/main.rs | |
| 259 | +use clap::Parser; | |
| 260 | + | |
| 261 | +#[derive(Parser)] | |
| 262 | +struct Cli { | |
| 263 | + #[clap(subcommand)] | |
| 264 | + command: Command, | |
| 265 | +} | |
| 266 | + | |
| 267 | +#[derive(Parser)] | |
| 268 | +enum Command { | |
| 269 | + Focus { direction: String }, | |
| 270 | + Workspace { number: u32 }, | |
| 271 | + GetTree, | |
| 272 | + Subscribe { events: Vec<String> }, | |
| 273 | + // ... | |
| 274 | +} | |
| 275 | + | |
| 276 | +fn main() -> Result<()> { | |
| 277 | + let cli = Cli::parse(); | |
| 278 | + let mut socket = UnixStream::connect(get_socket_path())?; | |
| 279 | + | |
| 280 | + let request = match cli.command { | |
| 281 | + Command::Focus { direction } => { | |
| 282 | + json!({ "type": "command", "command": "focus", "args": { "direction": direction } }) | |
| 283 | + } | |
| 284 | + // ... | |
| 285 | + }; | |
| 286 | + | |
| 287 | + socket.write_all(serde_json::to_string(&request)?.as_bytes())?; | |
| 288 | + | |
| 289 | + let mut response = String::new(); | |
| 290 | + socket.read_to_string(&mut response)?; | |
| 291 | + println!("{}", response); | |
| 292 | + | |
| 293 | + Ok(()) | |
| 294 | +} | |
| 295 | +``` | |
| 296 | + | |
| 297 | +## garctl Usage | |
| 298 | + | |
| 299 | +```bash | |
| 300 | +# Focus direction | |
| 301 | +garctl focus left | |
| 302 | +garctl focus right | |
| 303 | + | |
| 304 | +# Workspaces | |
| 305 | +garctl workspace 2 | |
| 306 | +garctl move-to-workspace 3 | |
| 307 | + | |
| 308 | +# Queries | |
| 309 | +garctl get-tree | |
| 310 | +garctl get-workspaces | |
| 311 | +garctl get-focused | |
| 312 | + | |
| 313 | +# Events (stays open, prints events) | |
| 314 | +garctl subscribe window_focus workspace_focus | |
| 315 | +``` | |
| 316 | + | |
| 317 | +## Acceptance Criteria | |
| 318 | + | |
| 319 | +1. Socket created at `$XDG_RUNTIME_DIR/gar.sock` | |
| 320 | +2. `garctl` can send commands and receive responses | |
| 321 | +3. All WM actions available via IPC | |
| 322 | +4. Query commands return proper JSON | |
| 323 | +5. Event subscription works for focus/workspace changes | |
| 324 | +6. Multiple concurrent clients supported | |
| 325 | + | |
| 326 | +## Testing Strategy | |
| 327 | + | |
| 328 | +```bash | |
| 329 | +# Start gar | |
| 330 | +DISPLAY=:1 cargo run | |
| 331 | + | |
| 332 | +# Test command | |
| 333 | +garctl focus left # Should return success | |
| 334 | + | |
| 335 | +# Test query | |
| 336 | +garctl get-tree | jq . # Should show tree structure | |
| 337 | + | |
| 338 | +# Test events | |
| 339 | +garctl subscribe window_focus & | |
| 340 | +# Open/focus windows, events should print | |
| 341 | +``` | |
| 342 | + | |
| 343 | +## Notes | |
| 344 | + | |
| 345 | +- Socket permissions should be user-only (0600) | |
| 346 | +- Consider i3-compatible message format for polybar compatibility | |
| 347 | +- Add timeout for client reads to prevent blocking | |
| 348 | +- Log all IPC activity for debugging | |
docs/sprints/sprint-7-multimonitor.mdadded@@ -0,0 +1,282 @@ | ||
| 1 | +# Sprint 7: Multi-Monitor Support | |
| 2 | + | |
| 3 | +**Goal:** Proper multi-head support with per-monitor workspaces. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Detect monitors via RandR extension | |
| 8 | +- Assign workspaces to monitors | |
| 9 | +- Navigate and move windows between monitors | |
| 10 | +- Handle monitor hotplug (connect/disconnect) | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 6 complete (IPC system) | |
| 15 | + | |
| 16 | +## Monitor Model | |
| 17 | + | |
| 18 | +``` | |
| 19 | +┌─────────────────────────────────────────────────────────────────────┐ | |
| 20 | +│ WorkspaceManager │ | |
| 21 | +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ | |
| 22 | +│ │ Monitor 0 │ │ Monitor 1 │ │ Monitor 2 │ │ | |
| 23 | +│ │ "eDP-1" │ │ "HDMI-1" │ │ "DP-1" │ │ | |
| 24 | +│ │ │ │ │ │ │ │ | |
| 25 | +│ │ WS: 1,2,3 │ │ WS: 4,5,6 │ │ WS: 7,8,9 │ │ | |
| 26 | +│ │ Active: 1 │ │ Active: 4 │ │ Active: 7 │ │ | |
| 27 | +│ └──────────────┘ └──────────────┘ └──────────────┘ │ | |
| 28 | +└─────────────────────────────────────────────────────────────────────┘ | |
| 29 | +``` | |
| 30 | + | |
| 31 | +## Tasks | |
| 32 | + | |
| 33 | +### 7.1 Monitor Detection | |
| 34 | +- [ ] Create `src/core/monitor.rs` | |
| 35 | +- [ ] Query RandR for connected outputs | |
| 36 | +- [ ] Get monitor geometry (position, size) | |
| 37 | +- [ ] Identify primary monitor | |
| 38 | +- [ ] Handle disabled/mirrored outputs | |
| 39 | + | |
| 40 | +```rust | |
| 41 | +use x11rb::protocol::randr; | |
| 42 | + | |
| 43 | +pub struct Monitor { | |
| 44 | + pub name: String, | |
| 45 | + pub output: randr::Output, | |
| 46 | + pub geometry: Rect, | |
| 47 | + pub primary: bool, | |
| 48 | + pub workspaces: Vec<WorkspaceId>, | |
| 49 | + pub active_workspace: WorkspaceId, | |
| 50 | +} | |
| 51 | + | |
| 52 | +fn detect_monitors(conn: &impl Connection, root: Window) -> Result<Vec<Monitor>> { | |
| 53 | + let resources = conn.randr_get_screen_resources(root)?.reply()?; | |
| 54 | + let mut monitors = Vec::new(); | |
| 55 | + | |
| 56 | + for output in resources.outputs { | |
| 57 | + let info = conn.randr_get_output_info(output, 0)?.reply()?; | |
| 58 | + | |
| 59 | + if info.connection != randr::Connection::CONNECTED { | |
| 60 | + continue; | |
| 61 | + } | |
| 62 | + | |
| 63 | + if let Some(crtc) = info.crtc { | |
| 64 | + let crtc_info = conn.randr_get_crtc_info(crtc, 0)?.reply()?; | |
| 65 | + monitors.push(Monitor { | |
| 66 | + name: String::from_utf8_lossy(&info.name).to_string(), | |
| 67 | + output, | |
| 68 | + geometry: Rect { | |
| 69 | + x: crtc_info.x, | |
| 70 | + y: crtc_info.y, | |
| 71 | + width: crtc_info.width, | |
| 72 | + height: crtc_info.height, | |
| 73 | + }, | |
| 74 | + primary: false, // Set later | |
| 75 | + workspaces: Vec::new(), | |
| 76 | + active_workspace: WorkspaceId(0), | |
| 77 | + }); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + Ok(monitors) | |
| 82 | +} | |
| 83 | +``` | |
| 84 | + | |
| 85 | +### 7.2 Workspace-Monitor Assignment | |
| 86 | +- [ ] Each monitor has a set of workspaces | |
| 87 | +- [ ] Default: distribute workspaces evenly | |
| 88 | +- [ ] Lua API: `gar.assign_workspace(ws, monitor)` | |
| 89 | +- [ ] Workspace shows on its assigned monitor | |
| 90 | + | |
| 91 | +```rust | |
| 92 | +impl WorkspaceManager { | |
| 93 | + fn assign_workspaces_to_monitors(&mut self) { | |
| 94 | + let ws_per_monitor = self.workspaces.len() / self.monitors.len(); | |
| 95 | + | |
| 96 | + for (i, monitor) in self.monitors.iter_mut().enumerate() { | |
| 97 | + let start = i * ws_per_monitor + 1; | |
| 98 | + let end = if i == self.monitors.len() - 1 { | |
| 99 | + self.workspaces.len() | |
| 100 | + } else { | |
| 101 | + start + ws_per_monitor | |
| 102 | + }; | |
| 103 | + | |
| 104 | + monitor.workspaces = (start..=end).map(WorkspaceId).collect(); | |
| 105 | + monitor.active_workspace = WorkspaceId(start); | |
| 106 | + } | |
| 107 | + } | |
| 108 | +} | |
| 109 | +``` | |
| 110 | + | |
| 111 | +### 7.3 Focus Across Monitors | |
| 112 | +- [ ] Implement Mod+comma/period to focus monitor | |
| 113 | +- [ ] Track focused monitor | |
| 114 | +- [ ] Focus follows workspace on switch | |
| 115 | +- [ ] Handle case when monitor has no windows | |
| 116 | + | |
| 117 | +```rust | |
| 118 | +fn focus_monitor(&mut self, direction: Direction) { | |
| 119 | + let current_monitor = self.focused_monitor(); | |
| 120 | + let target = self.monitor_in_direction(current_monitor, direction)?; | |
| 121 | + | |
| 122 | + // Focus the active workspace on target monitor | |
| 123 | + let workspace = self.monitors[target].active_workspace; | |
| 124 | + if let Some(window) = self.workspaces[workspace].focused { | |
| 125 | + self.set_focus(window); | |
| 126 | + } | |
| 127 | + | |
| 128 | + self.focused_monitor = target; | |
| 129 | +} | |
| 130 | +``` | |
| 131 | + | |
| 132 | +### 7.4 Move Window to Monitor | |
| 133 | +- [ ] Implement Mod+Shift+comma/period | |
| 134 | +- [ ] Remove from current workspace | |
| 135 | +- [ ] Add to target monitor's active workspace | |
| 136 | +- [ ] Optionally follow window | |
| 137 | + | |
| 138 | +```rust | |
| 139 | +fn move_to_monitor(&mut self, direction: Direction) { | |
| 140 | + let window = self.focused_window()?; | |
| 141 | + let target_monitor = self.monitor_in_direction(self.focused_monitor, direction)?; | |
| 142 | + let target_workspace = self.monitors[target_monitor].active_workspace; | |
| 143 | + | |
| 144 | + // Move window | |
| 145 | + self.current_workspace_mut().tree.remove(window); | |
| 146 | + self.workspace_mut(target_workspace).tree.insert(window); | |
| 147 | + | |
| 148 | + // Update geometries | |
| 149 | + self.apply_layouts()?; | |
| 150 | +} | |
| 151 | +``` | |
| 152 | + | |
| 153 | +### 7.5 Per-Monitor Workspace Switching | |
| 154 | +- [ ] Mod+N switches workspace on focused monitor | |
| 155 | +- [ ] Only switches within monitor's workspace set | |
| 156 | +- [ ] Or switches to workspace and focuses its monitor | |
| 157 | +- [ ] Configurable behavior via Lua | |
| 158 | + | |
| 159 | +```rust | |
| 160 | +fn switch_workspace(&mut self, workspace: WorkspaceId) { | |
| 161 | + // Find which monitor this workspace belongs to | |
| 162 | + let target_monitor = self.monitors.iter() | |
| 163 | + .position(|m| m.workspaces.contains(&workspace)); | |
| 164 | + | |
| 165 | + match target_monitor { | |
| 166 | + Some(monitor_idx) => { | |
| 167 | + // Switch workspace on that monitor | |
| 168 | + self.monitors[monitor_idx].active_workspace = workspace; | |
| 169 | + | |
| 170 | + // Focus that monitor | |
| 171 | + self.focused_monitor = monitor_idx; | |
| 172 | + | |
| 173 | + // Update visibility | |
| 174 | + self.update_workspace_visibility(); | |
| 175 | + } | |
| 176 | + None => { | |
| 177 | + tracing::warn!("Workspace {:?} not assigned to any monitor", workspace); | |
| 178 | + } | |
| 179 | + } | |
| 180 | +} | |
| 181 | +``` | |
| 182 | + | |
| 183 | +### 7.6 Monitor Hotplug | |
| 184 | +- [ ] Subscribe to RandR events | |
| 185 | +- [ ] Handle ScreenChangeNotify | |
| 186 | +- [ ] Detect added/removed monitors | |
| 187 | +- [ ] Reassign workspaces as needed | |
| 188 | +- [ ] Move windows from disconnected monitors | |
| 189 | + | |
| 190 | +```rust | |
| 191 | +fn handle_randr_event(&mut self, event: randr::ScreenChangeNotifyEvent) { | |
| 192 | + let new_monitors = detect_monitors(&self.conn, self.root)?; | |
| 193 | + | |
| 194 | + // Find removed monitors | |
| 195 | + for old in &self.monitors { | |
| 196 | + if !new_monitors.iter().any(|m| m.name == old.name) { | |
| 197 | + // Monitor disconnected - move its workspaces/windows | |
| 198 | + self.handle_monitor_removed(old); | |
| 199 | + } | |
| 200 | + } | |
| 201 | + | |
| 202 | + // Find added monitors | |
| 203 | + for new in &new_monitors { | |
| 204 | + if !self.monitors.iter().any(|m| m.name == new.name) { | |
| 205 | + // Monitor connected - assign workspaces | |
| 206 | + self.handle_monitor_added(new); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + self.monitors = new_monitors; | |
| 211 | + self.apply_all_layouts()?; | |
| 212 | +} | |
| 213 | +``` | |
| 214 | + | |
| 215 | +### 7.7 EWMH Multi-Monitor | |
| 216 | +- [ ] Set `_NET_DESKTOP_GEOMETRY` (combined size) | |
| 217 | +- [ ] Set `_NET_DESKTOP_VIEWPORT` per workspace | |
| 218 | +- [ ] Set `_NET_WORKAREA` per monitor | |
| 219 | +- [ ] Update on monitor changes | |
| 220 | + | |
| 221 | +### 7.8 Lua Configuration | |
| 222 | +- [ ] `gar.assign_workspace(ws, monitor_name)` | |
| 223 | +- [ ] `gar.focus_monitor(direction)` action | |
| 224 | +- [ ] `gar.move_to_monitor(direction)` action | |
| 225 | +- [ ] Configure focus-follows-workspace behavior | |
| 226 | + | |
| 227 | +```lua | |
| 228 | +-- Assign specific workspaces to monitors | |
| 229 | +gar.assign_workspace(1, "eDP-1") | |
| 230 | +gar.assign_workspace(2, "eDP-1") | |
| 231 | +gar.assign_workspace(3, "HDMI-1") | |
| 232 | +gar.assign_workspace(4, "HDMI-1") | |
| 233 | + | |
| 234 | +-- Keybinds | |
| 235 | +gar.bind("mod+comma", function() gar.focus_monitor("prev") end) | |
| 236 | +gar.bind("mod+period", function() gar.focus_monitor("next") end) | |
| 237 | +gar.bind("mod+shift+comma", function() gar.move_to_monitor("prev") end) | |
| 238 | +gar.bind("mod+shift+period", function() gar.move_to_monitor("next") end) | |
| 239 | +``` | |
| 240 | + | |
| 241 | +## Keybind Summary | |
| 242 | + | |
| 243 | +| Keybind | Action | | |
| 244 | +|---------|--------| | |
| 245 | +| Mod+comma | Focus previous monitor | | |
| 246 | +| Mod+period | Focus next monitor | | |
| 247 | +| Mod+Shift+comma | Move window to previous monitor | | |
| 248 | +| Mod+Shift+period | Move window to next monitor | | |
| 249 | + | |
| 250 | +## Acceptance Criteria | |
| 251 | + | |
| 252 | +1. Multiple monitors detected correctly | |
| 253 | +2. Each monitor shows its own workspaces | |
| 254 | +3. Mod+comma/period moves focus between monitors | |
| 255 | +4. Mod+Shift+comma/period moves windows between monitors | |
| 256 | +5. Workspace switch on any monitor works | |
| 257 | +6. Hotplug works (monitor disconnect/reconnect) | |
| 258 | +7. Windows from disconnected monitor move to remaining | |
| 259 | + | |
| 260 | +## Testing Strategy | |
| 261 | + | |
| 262 | +```bash | |
| 263 | +# With multiple monitors (or Xephyr instances) | |
| 264 | +xrandr --listmonitors # Verify detection | |
| 265 | + | |
| 266 | +# Test focus | |
| 267 | +# Mod+period - focus should move to other monitor | |
| 268 | +# Mod+comma - focus should move back | |
| 269 | + | |
| 270 | +# Test move | |
| 271 | +# Mod+Shift+period - window should move to other monitor | |
| 272 | + | |
| 273 | +# Test hotplug (if possible) | |
| 274 | +# Disconnect monitor, verify workspaces/windows migrate | |
| 275 | +``` | |
| 276 | + | |
| 277 | +## Notes | |
| 278 | + | |
| 279 | +- Consider "primary" monitor concept for default workspace | |
| 280 | +- Polybar needs per-monitor instances | |
| 281 | +- Handle Xinerama fallback for older setups? | |
| 282 | +- Monitor arrangement (which is "left"/"right") comes from xrandr | |
docs/sprints/sprint-8-ewmh.mdadded@@ -0,0 +1,427 @@ | ||
| 1 | +# Sprint 8: EWMH Compliance + Polish | |
| 2 | + | |
| 3 | +**Goal:** Full EWMH compliance for ecosystem integration, plus visual polish. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Complete EWMH atom support for tools like polybar, rofi, dunst | |
| 8 | +- Proper ICCCM compliance | |
| 9 | +- Visual features: borders, gaps, optional title bars | |
| 10 | +- Urgency hints | |
| 11 | + | |
| 12 | +## Prerequisites | |
| 13 | + | |
| 14 | +- Sprint 7 complete (multi-monitor) | |
| 15 | + | |
| 16 | +## EWMH Compliance | |
| 17 | + | |
| 18 | +### Required Atoms | |
| 19 | + | |
| 20 | +| Atom | Purpose | Implementation | | |
| 21 | +|------|---------|----------------| | |
| 22 | +| `_NET_SUPPORTED` | List of supported atoms | Set on root | | |
| 23 | +| `_NET_SUPPORTING_WM_CHECK` | WM identification | Child window | | |
| 24 | +| `_NET_CLIENT_LIST` | All managed windows | Update on changes | | |
| 25 | +| `_NET_CLIENT_LIST_STACKING` | Windows in stacking order | Update on changes | | |
| 26 | +| `_NET_NUMBER_OF_DESKTOPS` | Workspace count | Set on init | | |
| 27 | +| `_NET_DESKTOP_GEOMETRY` | Virtual desktop size | Set on init | | |
| 28 | +| `_NET_DESKTOP_VIEWPORT` | Viewport position | Per workspace | | |
| 29 | +| `_NET_CURRENT_DESKTOP` | Active workspace | Update on switch | | |
| 30 | +| `_NET_DESKTOP_NAMES` | Workspace names | Set on init | | |
| 31 | +| `_NET_ACTIVE_WINDOW` | Focused window | Update on focus | | |
| 32 | +| `_NET_WORKAREA` | Usable screen area | Per monitor | | |
| 33 | +| `_NET_WM_NAME` | WM name | "gar" | | |
| 34 | +| `_NET_WM_DESKTOP` | Window's workspace | Per window | | |
| 35 | +| `_NET_WM_STATE` | Window states | Per window | | |
| 36 | +| `_NET_WM_WINDOW_TYPE` | Window type | Read, respect | | |
| 37 | + | |
| 38 | +## Tasks | |
| 39 | + | |
| 40 | +### 8.1 EWMH Setup | |
| 41 | +- [ ] Create `src/x11/ewmh.rs` | |
| 42 | +- [ ] Intern all EWMH atoms on startup | |
| 43 | +- [ ] Create supporting WM check window | |
| 44 | +- [ ] Set `_NET_SUPPORTED` with all supported atoms | |
| 45 | +- [ ] Set WM name | |
| 46 | + | |
| 47 | +```rust | |
| 48 | +pub struct EwmhAtoms { | |
| 49 | + pub _NET_SUPPORTED: Atom, | |
| 50 | + pub _NET_SUPPORTING_WM_CHECK: Atom, | |
| 51 | + pub _NET_CLIENT_LIST: Atom, | |
| 52 | + pub _NET_CLIENT_LIST_STACKING: Atom, | |
| 53 | + pub _NET_NUMBER_OF_DESKTOPS: Atom, | |
| 54 | + pub _NET_CURRENT_DESKTOP: Atom, | |
| 55 | + pub _NET_DESKTOP_NAMES: Atom, | |
| 56 | + pub _NET_ACTIVE_WINDOW: Atom, | |
| 57 | + pub _NET_WM_NAME: Atom, | |
| 58 | + pub _NET_WM_DESKTOP: Atom, | |
| 59 | + pub _NET_WM_STATE: Atom, | |
| 60 | + pub _NET_WM_STATE_FULLSCREEN: Atom, | |
| 61 | + pub _NET_WM_STATE_HIDDEN: Atom, | |
| 62 | + pub _NET_WM_STATE_DEMANDS_ATTENTION: Atom, | |
| 63 | + pub _NET_WM_WINDOW_TYPE: Atom, | |
| 64 | + pub _NET_WM_WINDOW_TYPE_DIALOG: Atom, | |
| 65 | + // ... more | |
| 66 | +} | |
| 67 | + | |
| 68 | +fn setup_ewmh(conn: &impl Connection, root: Window, atoms: &EwmhAtoms) -> Result<()> { | |
| 69 | + // Create check window | |
| 70 | + let check_window = conn.generate_id()?; | |
| 71 | + conn.create_window( | |
| 72 | + 0, check_window, root, | |
| 73 | + -1, -1, 1, 1, 0, | |
| 74 | + WindowClass::INPUT_OUTPUT, | |
| 75 | + 0, | |
| 76 | + &CreateWindowAux::new(), | |
| 77 | + )?; | |
| 78 | + | |
| 79 | + // Set supporting WM check | |
| 80 | + conn.change_property32( | |
| 81 | + PropMode::REPLACE, root, | |
| 82 | + atoms._NET_SUPPORTING_WM_CHECK, | |
| 83 | + AtomEnum::WINDOW, | |
| 84 | + &[check_window], | |
| 85 | + )?; | |
| 86 | + conn.change_property32( | |
| 87 | + PropMode::REPLACE, check_window, | |
| 88 | + atoms._NET_SUPPORTING_WM_CHECK, | |
| 89 | + AtomEnum::WINDOW, | |
| 90 | + &[check_window], | |
| 91 | + )?; | |
| 92 | + | |
| 93 | + // Set WM name | |
| 94 | + conn.change_property8( | |
| 95 | + PropMode::REPLACE, check_window, | |
| 96 | + atoms._NET_WM_NAME, | |
| 97 | + atoms.UTF8_STRING, | |
| 98 | + b"gar", | |
| 99 | + )?; | |
| 100 | + | |
| 101 | + // Set supported atoms | |
| 102 | + let supported = vec![ | |
| 103 | + atoms._NET_SUPPORTED, | |
| 104 | + atoms._NET_SUPPORTING_WM_CHECK, | |
| 105 | + // ... all supported atoms | |
| 106 | + ]; | |
| 107 | + conn.change_property32( | |
| 108 | + PropMode::REPLACE, root, | |
| 109 | + atoms._NET_SUPPORTED, | |
| 110 | + AtomEnum::ATOM, | |
| 111 | + &supported, | |
| 112 | + )?; | |
| 113 | + | |
| 114 | + Ok(()) | |
| 115 | +} | |
| 116 | +``` | |
| 117 | + | |
| 118 | +### 8.2 Client List Maintenance | |
| 119 | +- [ ] Update `_NET_CLIENT_LIST` on window add/remove | |
| 120 | +- [ ] Update `_NET_CLIENT_LIST_STACKING` on stacking changes | |
| 121 | +- [ ] Update `_NET_ACTIVE_WINDOW` on focus change | |
| 122 | + | |
| 123 | +```rust | |
| 124 | +fn update_client_list(&self, conn: &impl Connection) -> Result<()> { | |
| 125 | + let windows: Vec<u32> = self.all_windows() | |
| 126 | + .iter() | |
| 127 | + .map(|w| w.id) | |
| 128 | + .collect(); | |
| 129 | + | |
| 130 | + conn.change_property32( | |
| 131 | + PropMode::REPLACE, | |
| 132 | + self.root, | |
| 133 | + self.atoms._NET_CLIENT_LIST, | |
| 134 | + AtomEnum::WINDOW, | |
| 135 | + &windows, | |
| 136 | + )?; | |
| 137 | + | |
| 138 | + Ok(()) | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +### 8.3 Handle Client Messages | |
| 143 | +- [ ] Handle `_NET_ACTIVE_WINDOW` (focus requests from apps) | |
| 144 | +- [ ] Handle `_NET_WM_STATE` changes (fullscreen, etc.) | |
| 145 | +- [ ] Handle `_NET_CURRENT_DESKTOP` (pager workspace switch) | |
| 146 | +- [ ] Handle `_NET_CLOSE_WINDOW` (close requests) | |
| 147 | + | |
| 148 | +```rust | |
| 149 | +fn handle_client_message(&mut self, event: ClientMessageEvent) -> Result<()> { | |
| 150 | + let atom = event.type_; | |
| 151 | + | |
| 152 | + if atom == self.atoms._NET_ACTIVE_WINDOW { | |
| 153 | + // Application requesting focus | |
| 154 | + let window = event.window; | |
| 155 | + self.focus_window(window)?; | |
| 156 | + } else if atom == self.atoms._NET_WM_STATE { | |
| 157 | + // State change request (e.g., fullscreen) | |
| 158 | + let action = event.data.as_data32()[0]; | |
| 159 | + let prop = event.data.as_data32()[1]; | |
| 160 | + self.handle_state_change(event.window, action, prop)?; | |
| 161 | + } else if atom == self.atoms._NET_CURRENT_DESKTOP { | |
| 162 | + // Workspace switch request | |
| 163 | + let desktop = event.data.as_data32()[0] as usize; | |
| 164 | + self.switch_workspace(WorkspaceId(desktop + 1))?; | |
| 165 | + } | |
| 166 | + | |
| 167 | + Ok(()) | |
| 168 | +} | |
| 169 | +``` | |
| 170 | + | |
| 171 | +### 8.4 Window State Management | |
| 172 | +- [ ] Track window states (fullscreen, hidden, urgent) | |
| 173 | +- [ ] Update `_NET_WM_STATE` property | |
| 174 | +- [ ] Implement fullscreen toggle (Mod+F) | |
| 175 | +- [ ] Handle fullscreen requests from applications | |
| 176 | + | |
| 177 | +```rust | |
| 178 | +fn set_fullscreen(&mut self, window: WindowId, fullscreen: bool) -> Result<()> { | |
| 179 | + let win = self.get_window_mut(window)?; | |
| 180 | + | |
| 181 | + if fullscreen { | |
| 182 | + // Save current geometry | |
| 183 | + win.saved_geometry = Some(win.geometry); | |
| 184 | + // Set to monitor size | |
| 185 | + let monitor = self.get_monitor_for_window(window); | |
| 186 | + win.geometry = monitor.geometry; | |
| 187 | + win.states.insert(WindowState::Fullscreen); | |
| 188 | + } else { | |
| 189 | + // Restore geometry | |
| 190 | + if let Some(saved) = win.saved_geometry.take() { | |
| 191 | + win.geometry = saved; | |
| 192 | + } | |
| 193 | + win.states.remove(&WindowState::Fullscreen); | |
| 194 | + } | |
| 195 | + | |
| 196 | + // Update X property | |
| 197 | + self.update_wm_state(window)?; | |
| 198 | + self.apply_geometry(window)?; | |
| 199 | + | |
| 200 | + Ok(()) | |
| 201 | +} | |
| 202 | +``` | |
| 203 | + | |
| 204 | +### 8.5 ICCCM Compliance | |
| 205 | +- [ ] Create `src/x11/icccm.rs` | |
| 206 | +- [ ] Handle `WM_HINTS` (urgency, input, etc.) | |
| 207 | +- [ ] Handle `WM_NORMAL_HINTS` (size hints) | |
| 208 | +- [ ] Handle `WM_PROTOCOLS` (delete window, take focus) | |
| 209 | +- [ ] Handle `WM_TRANSIENT_FOR` | |
| 210 | + | |
| 211 | +```rust | |
| 212 | +fn get_wm_hints(conn: &impl Connection, window: Window) -> Result<Option<WmHints>> { | |
| 213 | + // Read WM_HINTS property | |
| 214 | + // Parse into struct | |
| 215 | +} | |
| 216 | + | |
| 217 | +fn get_size_hints(conn: &impl Connection, window: Window) -> Result<Option<SizeHints>> { | |
| 218 | + // Read WM_NORMAL_HINTS | |
| 219 | + // Extract min/max size, aspect ratio, etc. | |
| 220 | +} | |
| 221 | +``` | |
| 222 | + | |
| 223 | +### 8.6 Border Colors | |
| 224 | +- [ ] Configurable border colors via Lua | |
| 225 | +- [ ] Different colors: focused, unfocused, urgent | |
| 226 | +- [ ] Apply colors on focus change | |
| 227 | +- [ ] Flash urgent windows | |
| 228 | + | |
| 229 | +```rust | |
| 230 | +fn update_border_color(&self, conn: &impl Connection, window: WindowId) -> Result<()> { | |
| 231 | + let win = self.get_window(window)?; | |
| 232 | + let color = if win.urgent { | |
| 233 | + self.config.border_color_urgent | |
| 234 | + } else if Some(window) == self.focused { | |
| 235 | + self.config.border_color_focused | |
| 236 | + } else { | |
| 237 | + self.config.border_color_unfocused | |
| 238 | + }; | |
| 239 | + | |
| 240 | + conn.change_window_attributes( | |
| 241 | + window, | |
| 242 | + &ChangeWindowAttributesAux::new().border_pixel(color), | |
| 243 | + )?; | |
| 244 | + | |
| 245 | + Ok(()) | |
| 246 | +} | |
| 247 | +``` | |
| 248 | + | |
| 249 | +### 8.7 Gaps | |
| 250 | +- [ ] Inner gaps (between windows) | |
| 251 | +- [ ] Outer gaps (screen edge) | |
| 252 | +- [ ] Configurable via Lua | |
| 253 | +- [ ] Apply in geometry calculation | |
| 254 | + | |
| 255 | +```rust | |
| 256 | +fn calculate_geometries_with_gaps(&self, rect: Rect, gaps: &GapConfig) -> Vec<(WindowId, Rect)> { | |
| 257 | + // Shrink rect by outer gaps | |
| 258 | + let work_area = Rect { | |
| 259 | + x: rect.x + gaps.outer as i16, | |
| 260 | + y: rect.y + gaps.outer as i16, | |
| 261 | + width: rect.width - 2 * gaps.outer, | |
| 262 | + height: rect.height - 2 * gaps.outer, | |
| 263 | + }; | |
| 264 | + | |
| 265 | + // Calculate from tree | |
| 266 | + let mut geometries = self.tree.calculate_geometries(work_area); | |
| 267 | + | |
| 268 | + // Apply inner gaps | |
| 269 | + for (_, geom) in &mut geometries { | |
| 270 | + geom.x += gaps.inner as i16 / 2; | |
| 271 | + geom.y += gaps.inner as i16 / 2; | |
| 272 | + geom.width -= gaps.inner; | |
| 273 | + geom.height -= gaps.inner; | |
| 274 | + } | |
| 275 | + | |
| 276 | + geometries | |
| 277 | +} | |
| 278 | +``` | |
| 279 | + | |
| 280 | +### 8.8 Optional Title Bars | |
| 281 | +- [ ] Create title bar windows (reparenting) | |
| 282 | +- [ ] Draw window title | |
| 283 | +- [ ] Handle title bar clicks (focus, move) | |
| 284 | +- [ ] Configurable per window class | |
| 285 | +- [ ] Double-click to toggle maximize | |
| 286 | + | |
| 287 | +```rust | |
| 288 | +fn create_frame(&mut self, conn: &impl Connection, window: WindowId) -> Result<Frame> { | |
| 289 | + let title_height = if self.should_have_titlebar(window) { | |
| 290 | + self.config.titlebar_height | |
| 291 | + } else { | |
| 292 | + 0 | |
| 293 | + }; | |
| 294 | + | |
| 295 | + // Create frame window | |
| 296 | + let frame = conn.generate_id()?; | |
| 297 | + conn.create_window(/* ... */)?; | |
| 298 | + | |
| 299 | + // Reparent client into frame | |
| 300 | + conn.reparent_window(window, frame, 0, title_height)?; | |
| 301 | + | |
| 302 | + Ok(Frame { id: frame, client: window, title_height }) | |
| 303 | +} | |
| 304 | +``` | |
| 305 | + | |
| 306 | +### 8.9 Urgency Hints | |
| 307 | +- [ ] Read `WM_HINTS` urgency flag | |
| 308 | +- [ ] Read `_NET_WM_STATE_DEMANDS_ATTENTION` | |
| 309 | +- [ ] Set urgent border color | |
| 310 | +- [ ] Indicate urgent workspace (for status bars) | |
| 311 | +- [ ] Clear urgency on focus | |
| 312 | + | |
| 313 | +```rust | |
| 314 | +fn handle_urgency(&mut self, window: WindowId) -> Result<()> { | |
| 315 | + let hints = get_wm_hints(&self.conn, window)?; | |
| 316 | + | |
| 317 | + if hints.map(|h| h.urgent).unwrap_or(false) { | |
| 318 | + let win = self.get_window_mut(window)?; | |
| 319 | + win.urgent = true; | |
| 320 | + | |
| 321 | + // Update workspace urgency | |
| 322 | + let ws = self.get_workspace_for_window(window)?; | |
| 323 | + self.workspaces[ws].urgent = true; | |
| 324 | + | |
| 325 | + // Update EWMH | |
| 326 | + self.set_wm_state(window, StateAction::Add, self.atoms._NET_WM_STATE_DEMANDS_ATTENTION)?; | |
| 327 | + | |
| 328 | + // Update border | |
| 329 | + self.update_border_color(window)?; | |
| 330 | + } | |
| 331 | + | |
| 332 | + Ok(()) | |
| 333 | +} | |
| 334 | +``` | |
| 335 | + | |
| 336 | +### 8.10 External Compositor Integration | |
| 337 | + | |
| 338 | +**Goal:** Support external compositors (picom) for proper screen repainting and visual effects. | |
| 339 | + | |
| 340 | +#### Background | |
| 341 | + | |
| 342 | +Like i3, gar is NOT a compositing window manager. We rely on external compositors (picom, compton, xcompmgr) for: | |
| 343 | +- Proper screen repainting when windows close | |
| 344 | +- Transparency and visual effects | |
| 345 | +- Vsync and tear-free rendering | |
| 346 | + | |
| 347 | +Without a compositor, X11 does not automatically repaint exposed areas when windows close, leading to visual artifacts (old window pixels remaining on screen). | |
| 348 | + | |
| 349 | +#### Tasks | |
| 350 | +- [ ] Add picom launch to `gar-session.sh` (before gar starts) | |
| 351 | +- [ ] Add picom launch to `start-gar.sh` xinitrc | |
| 352 | +- [ ] Keep `clear_root_area()` as fallback for compositor-less setups | |
| 353 | +- [ ] Add `_NET_WM_BYPASS_COMPOSITOR` atom (for fullscreen apps to request un-redirection) | |
| 354 | + | |
| 355 | +#### Session Script Changes | |
| 356 | +```bash | |
| 357 | +# Launch compositor before WM | |
| 358 | +picom -b --use-ewmh-active-win & | |
| 359 | +sleep 0.1 # Brief pause to let compositor initialize | |
| 360 | +``` | |
| 361 | + | |
| 362 | +#### Recommended Picom Flags | |
| 363 | +- `-b` or `--daemon`: Run as background daemon | |
| 364 | +- `--use-ewmh-active-win`: Use `_NET_ACTIVE_WINDOW` for focus (more reliable with tiling WMs) | |
| 365 | +- `--backend glx`: Better for screen tearing prevention (optional) | |
| 366 | + | |
| 367 | +#### EWMH Atoms for Compositors | |
| 368 | +| Atom | Purpose | Who Sets It | | |
| 369 | +|------|---------|-------------| | |
| 370 | +| `_NET_WM_BYPASS_COMPOSITOR` | Un-redirect fullscreen windows | Apps (games, videos) | | |
| 371 | +| `_NET_ACTIVE_WINDOW` | Currently focused window | WM (gar) | | |
| 372 | +| `_NET_WM_OPACITY` | Window opacity hint | Apps or user tools | | |
| 373 | + | |
| 374 | +#### Fallback Behavior | |
| 375 | +The `clear_root_area()` function remains as a fallback for users who don't have picom installed. When a compositor is running, it handles repainting automatically and `clear_root_area()` becomes a no-op in effect. | |
| 376 | + | |
| 377 | +#### Acceptance Criteria | |
| 378 | +1. Windows close without visual artifacts when picom is running | |
| 379 | +2. No screen tearing during window operations | |
| 380 | +3. Fallback still works when picom is not installed (uses `clear_root_area()`) | |
| 381 | +4. Fullscreen apps (games, videos) can bypass compositor via `_NET_WM_BYPASS_COMPOSITOR` | |
| 382 | + | |
| 383 | +--- | |
| 384 | + | |
| 385 | +## Keybind Summary | |
| 386 | + | |
| 387 | +| Keybind | Action | | |
| 388 | +|---------|--------| | |
| 389 | +| Mod+F | Toggle fullscreen | | |
| 390 | + | |
| 391 | +## Acceptance Criteria | |
| 392 | + | |
| 393 | +1. Polybar shows workspaces correctly | |
| 394 | +2. Rofi can list and switch windows | |
| 395 | +3. Dunst notifications work properly | |
| 396 | +4. Applications can request fullscreen | |
| 397 | +5. Borders and gaps configurable and working | |
| 398 | +6. Urgent windows highlighted | |
| 399 | +7. No errors from EWMH-compliant applications | |
| 400 | + | |
| 401 | +## Testing Strategy | |
| 402 | + | |
| 403 | +```bash | |
| 404 | +# Polybar | |
| 405 | +# Install polybar, configure xworkspaces module | |
| 406 | +# Workspaces should display, clicking should switch | |
| 407 | + | |
| 408 | +# Rofi | |
| 409 | +rofi -show window # Should list all windows | |
| 410 | +# Selecting window should focus it | |
| 411 | + | |
| 412 | +# Fullscreen | |
| 413 | +# Open Firefox, press F11 | |
| 414 | +# Should go fullscreen correctly | |
| 415 | +# Press F11 again, should restore | |
| 416 | + | |
| 417 | +# Urgency | |
| 418 | +# Set urgent hint on a window | |
| 419 | +# Border should change, workspace should indicate | |
| 420 | +``` | |
| 421 | + | |
| 422 | +## Notes | |
| 423 | + | |
| 424 | +- EWMH spec: https://specifications.freedesktop.org/wm-spec/latest/ | |
| 425 | +- ICCCM spec: https://tronche.com/gui/x/icccm/ | |
| 426 | +- Title bars add complexity - consider making opt-in | |
| 427 | +- Test with various applications (Firefox, Chromium, mpv, etc.) | |
docs/sprints/sprint-9-mvp-polish.mdadded@@ -0,0 +1,334 @@ | ||
| 1 | +# Sprint 9: MVP Polish + Testing | |
| 2 | + | |
| 3 | +**Goal:** Stable, documented MVP ready for release. | |
| 4 | + | |
| 5 | +## Objectives | |
| 6 | + | |
| 7 | +- Robust error handling throughout | |
| 8 | +- Crash recovery (restart in place) | |
| 9 | +- Documentation (man pages, examples) | |
| 10 | +- Integration testing | |
| 11 | +- Installation packaging | |
| 12 | + | |
| 13 | +## Prerequisites | |
| 14 | + | |
| 15 | +- Sprint 8 complete (EWMH, polish) | |
| 16 | + | |
| 17 | +## Tasks | |
| 18 | + | |
| 19 | +### 9.1 Error Handling Audit | |
| 20 | +- [ ] Review all `unwrap()` and `expect()` calls | |
| 21 | +- [ ] Replace with proper error handling | |
| 22 | +- [ ] Add context to errors with `thiserror` | |
| 23 | +- [ ] Log errors appropriately | |
| 24 | +- [ ] Never crash on X errors (use error handler) | |
| 25 | + | |
| 26 | +```rust | |
| 27 | +// Bad | |
| 28 | +let window = self.get_window(id).unwrap(); | |
| 29 | + | |
| 30 | +// Good | |
| 31 | +let window = self.get_window(id) | |
| 32 | + .ok_or_else(|| Error::WindowNotFound(id))?; | |
| 33 | + | |
| 34 | +// With context | |
| 35 | +let tree = std::fs::read_to_string(&config_path) | |
| 36 | + .map_err(|e| Error::ConfigLoad { path: config_path.clone(), source: e })?; | |
| 37 | +``` | |
| 38 | + | |
| 39 | +### 9.2 X Error Handling | |
| 40 | +- [ ] Set up X error handler | |
| 41 | +- [ ] Log X errors without crashing | |
| 42 | +- [ ] Handle "window destroyed" race conditions | |
| 43 | +- [ ] Recover from non-fatal errors | |
| 44 | + | |
| 45 | +```rust | |
| 46 | +fn setup_error_handler(conn: &impl Connection) { | |
| 47 | + // X11 error handling is synchronous in x11rb | |
| 48 | + // Wrap operations that might fail due to window destruction | |
| 49 | +} | |
| 50 | + | |
| 51 | +fn safe_configure_window( | |
| 52 | + conn: &impl Connection, | |
| 53 | + window: Window, | |
| 54 | + aux: &ConfigureWindowAux, | |
| 55 | +) -> Result<()> { | |
| 56 | + match conn.configure_window(window, aux)?.check() { | |
| 57 | + Ok(_) => Ok(()), | |
| 58 | + Err(e) if is_window_error(&e) => { | |
| 59 | + tracing::debug!("Window {} no longer exists", window); | |
| 60 | + Ok(()) // Not a fatal error | |
| 61 | + } | |
| 62 | + Err(e) => Err(e.into()), | |
| 63 | + } | |
| 64 | +} | |
| 65 | +``` | |
| 66 | + | |
| 67 | +### 9.3 Crash Recovery | |
| 68 | +- [ ] Implement `--replace` flag | |
| 69 | +- [ ] Save state before exit/crash | |
| 70 | +- [ ] Restore state on restart | |
| 71 | +- [ ] Handle SIGTERM/SIGINT gracefully | |
| 72 | +- [ ] Support restart-in-place (Mod+Shift+R alternative) | |
| 73 | + | |
| 74 | +```rust | |
| 75 | +fn save_state(&self, path: &Path) -> Result<()> { | |
| 76 | + let state = SavedState { | |
| 77 | + workspaces: self.workspaces.iter().map(|ws| { | |
| 78 | + SavedWorkspace { | |
| 79 | + name: ws.name.clone(), | |
| 80 | + windows: ws.all_windows(), | |
| 81 | + } | |
| 82 | + }).collect(), | |
| 83 | + focused: self.focused, | |
| 84 | + }; | |
| 85 | + let json = serde_json::to_string(&state)?; | |
| 86 | + std::fs::write(path, json)?; | |
| 87 | + Ok(()) | |
| 88 | +} | |
| 89 | + | |
| 90 | +fn restore_state(&mut self, path: &Path) -> Result<()> { | |
| 91 | + let json = std::fs::read_to_string(path)?; | |
| 92 | + let state: SavedState = serde_json::from_str(&json)?; | |
| 93 | + // Restore workspaces and focus | |
| 94 | + Ok(()) | |
| 95 | +} | |
| 96 | + | |
| 97 | +fn handle_signal(sig: i32) { | |
| 98 | + match sig { | |
| 99 | + SIGTERM | SIGINT => { | |
| 100 | + // Save state and exit cleanly | |
| 101 | + save_state(&state_path).ok(); | |
| 102 | + std::process::exit(0); | |
| 103 | + } | |
| 104 | + SIGUSR1 => { | |
| 105 | + // Restart in place | |
| 106 | + save_state(&state_path).ok(); | |
| 107 | + exec_self(); | |
| 108 | + } | |
| 109 | + _ => {} | |
| 110 | + } | |
| 111 | +} | |
| 112 | +``` | |
| 113 | + | |
| 114 | +### 9.4 Man Pages | |
| 115 | +- [ ] Create `docs/gar.1` (main man page) | |
| 116 | +- [ ] Create `docs/garctl.1` (CLI tool) | |
| 117 | +- [ ] Create `docs/gar-lua.5` (configuration) | |
| 118 | +- [ ] Document all keybinds and options | |
| 119 | + | |
| 120 | +```man | |
| 121 | +.TH GAR 1 "2024" "gar" "User Commands" | |
| 122 | +.SH NAME | |
| 123 | +gar \- tiling window manager with smart splits | |
| 124 | +.SH SYNOPSIS | |
| 125 | +.B gar | |
| 126 | +[\fIOPTIONS\fR] | |
| 127 | +.SH DESCRIPTION | |
| 128 | +.B gar | |
| 129 | +is an X11 tiling window manager that automatically determines | |
| 130 | +the optimal split direction when creating new windows. | |
| 131 | +.SH OPTIONS | |
| 132 | +.TP | |
| 133 | +.B \-c, \-\-config FILE | |
| 134 | +Use alternate configuration file | |
| 135 | +.TP | |
| 136 | +.B \-\-replace | |
| 137 | +Replace currently running window manager | |
| 138 | +.TP | |
| 139 | +.B \-v, \-\-version | |
| 140 | +Print version and exit | |
| 141 | +.SH FILES | |
| 142 | +.TP | |
| 143 | +.I ~/.config/gar/init.lua | |
| 144 | +Default configuration file | |
| 145 | +.TP | |
| 146 | +.I $XDG_RUNTIME_DIR/gar.sock | |
| 147 | +IPC socket | |
| 148 | +.SH SEE ALSO | |
| 149 | +.BR garctl (1), | |
| 150 | +.BR gar-lua (5) | |
| 151 | +``` | |
| 152 | + | |
| 153 | +### 9.5 Example Configurations | |
| 154 | +- [ ] Create `examples/minimal.lua` | |
| 155 | +- [ ] Create `examples/i3-like.lua` | |
| 156 | +- [ ] Create `examples/gaps-and-borders.lua` | |
| 157 | +- [ ] Create `examples/polybar-integration.lua` | |
| 158 | +- [ ] Document each example | |
| 159 | + | |
| 160 | +### 9.6 Integration Tests | |
| 161 | +- [ ] Set up test infrastructure with Xvfb | |
| 162 | +- [ ] Test window creation/destruction | |
| 163 | +- [ ] Test workspace switching | |
| 164 | +- [ ] Test keybind execution | |
| 165 | +- [ ] Test IPC commands | |
| 166 | +- [ ] Test config loading | |
| 167 | + | |
| 168 | +```rust | |
| 169 | +// tests/integration.rs | |
| 170 | +use std::process::Command; | |
| 171 | + | |
| 172 | +fn setup_xvfb() -> XvfbGuard { | |
| 173 | + // Start Xvfb on :99 | |
| 174 | + // Return guard that kills on drop | |
| 175 | +} | |
| 176 | + | |
| 177 | +#[test] | |
| 178 | +fn test_window_tiling() { | |
| 179 | + let _xvfb = setup_xvfb(); | |
| 180 | + | |
| 181 | + // Start gar | |
| 182 | + let mut gar = Command::new("cargo") | |
| 183 | + .args(["run", "--"]) | |
| 184 | + .env("DISPLAY", ":99") | |
| 185 | + .spawn() | |
| 186 | + .unwrap(); | |
| 187 | + | |
| 188 | + // Wait for startup | |
| 189 | + std::thread::sleep(Duration::from_millis(500)); | |
| 190 | + | |
| 191 | + // Open windows | |
| 192 | + Command::new("xterm") | |
| 193 | + .env("DISPLAY", ":99") | |
| 194 | + .spawn() | |
| 195 | + .unwrap(); | |
| 196 | + | |
| 197 | + // Verify via IPC | |
| 198 | + let output = Command::new("garctl") | |
| 199 | + .args(["get-tree"]) | |
| 200 | + .env("DISPLAY", ":99") | |
| 201 | + .output() | |
| 202 | + .unwrap(); | |
| 203 | + | |
| 204 | + let tree: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); | |
| 205 | + // Assert tree structure | |
| 206 | + | |
| 207 | + gar.kill().unwrap(); | |
| 208 | +} | |
| 209 | +``` | |
| 210 | + | |
| 211 | +### 9.7 Installation Script | |
| 212 | +- [ ] Create `install.sh` for manual install | |
| 213 | +- [ ] Create Makefile with install target | |
| 214 | +- [ ] Document dependencies | |
| 215 | +- [ ] Test on fresh system | |
| 216 | + | |
| 217 | +```bash | |
| 218 | +#!/bin/bash | |
| 219 | +# install.sh | |
| 220 | + | |
| 221 | +set -e | |
| 222 | + | |
| 223 | +PREFIX=${PREFIX:-/usr/local} | |
| 224 | + | |
| 225 | +cargo build --release | |
| 226 | + | |
| 227 | +install -Dm755 target/release/gar "$PREFIX/bin/gar" | |
| 228 | +install -Dm755 target/release/garctl "$PREFIX/bin/garctl" | |
| 229 | +install -Dm644 gar.desktop "$PREFIX/share/xsessions/gar.desktop" | |
| 230 | +install -Dm644 docs/gar.1 "$PREFIX/share/man/man1/gar.1" | |
| 231 | +install -Dm644 docs/garctl.1 "$PREFIX/share/man/man1/garctl.1" | |
| 232 | + | |
| 233 | +echo "Installation complete!" | |
| 234 | +``` | |
| 235 | + | |
| 236 | +### 9.8 AUR Package (optional) | |
| 237 | +- [ ] Create PKGBUILD | |
| 238 | +- [ ] Test in clean chroot | |
| 239 | +- [ ] Submit to AUR | |
| 240 | + | |
| 241 | +```bash | |
| 242 | +# PKGBUILD | |
| 243 | +pkgname=gar | |
| 244 | +pkgver=0.1.0 | |
| 245 | +pkgrel=1 | |
| 246 | +pkgdesc="Tiling window manager with smart splits" | |
| 247 | +arch=('x86_64') | |
| 248 | +url="https://github.com/youruser/gar" | |
| 249 | +license=('MIT') | |
| 250 | +depends=('libxcb' 'lua') | |
| 251 | +makedepends=('rust' 'cargo') | |
| 252 | + | |
| 253 | +build() { | |
| 254 | + cd "$srcdir/$pkgname-$pkgver" | |
| 255 | + cargo build --release | |
| 256 | +} | |
| 257 | + | |
| 258 | +package() { | |
| 259 | + cd "$srcdir/$pkgname-$pkgver" | |
| 260 | + install -Dm755 target/release/gar "$pkgdir/usr/bin/gar" | |
| 261 | + install -Dm755 target/release/garctl "$pkgdir/usr/bin/garctl" | |
| 262 | + install -Dm644 gar.desktop "$pkgdir/usr/share/xsessions/gar.desktop" | |
| 263 | +} | |
| 264 | +``` | |
| 265 | + | |
| 266 | +### 9.9 README and Documentation | |
| 267 | +- [ ] Update README with features, screenshots | |
| 268 | +- [ ] Add CONTRIBUTING.md | |
| 269 | +- [ ] Add LICENSE file | |
| 270 | +- [ ] Document known issues/limitations | |
| 271 | + | |
| 272 | +### 9.10 Final Testing Checklist | |
| 273 | +- [ ] Fresh install works | |
| 274 | +- [ ] All documented keybinds work | |
| 275 | +- [ ] Polybar integration works | |
| 276 | +- [ ] Rofi integration works | |
| 277 | +- [ ] Multi-monitor works | |
| 278 | +- [ ] Config reload works | |
| 279 | +- [ ] No memory leaks (valgrind) | |
| 280 | +- [ ] No crash on stress test | |
| 281 | + | |
| 282 | +## Acceptance Criteria | |
| 283 | + | |
| 284 | +1. No panics in release build under normal use | |
| 285 | +2. Man pages installed and accessible | |
| 286 | +3. Example configs provided and documented | |
| 287 | +4. Integration tests pass | |
| 288 | +5. Install script works on fresh Arch/Ubuntu | |
| 289 | +6. README provides clear getting started guide | |
| 290 | + | |
| 291 | +## Testing Strategy | |
| 292 | + | |
| 293 | +```bash | |
| 294 | +# Full test suite | |
| 295 | +cargo test | |
| 296 | + | |
| 297 | +# Integration tests (requires Xvfb) | |
| 298 | +cargo test --test integration | |
| 299 | + | |
| 300 | +# Memory check | |
| 301 | +valgrind --leak-check=full target/release/gar | |
| 302 | + | |
| 303 | +# Stress test | |
| 304 | +for i in {1..100}; do | |
| 305 | + DISPLAY=:1 xterm & | |
| 306 | +done | |
| 307 | +# Then close all, verify no issues | |
| 308 | +``` | |
| 309 | + | |
| 310 | +## MVP Feature Summary | |
| 311 | + | |
| 312 | +After Sprint 9, gar will have: | |
| 313 | + | |
| 314 | +- Smart split detection (the core feature) | |
| 315 | +- Full keyboard navigation | |
| 316 | +- 10 workspaces | |
| 317 | +- Multi-monitor support | |
| 318 | +- Floating window support | |
| 319 | +- Lua configuration | |
| 320 | +- IPC system with garctl | |
| 321 | +- EWMH compliance | |
| 322 | +- Configurable borders and gaps | |
| 323 | +- Documentation and examples | |
| 324 | + | |
| 325 | +## Post-MVP Ideas (Future Sprints) | |
| 326 | + | |
| 327 | +- Scratchpad windows | |
| 328 | +- Tabbed/stacked layouts | |
| 329 | +- Animations (fade, slide) | |
| 330 | +- Built-in bar (optional) | |
| 331 | +- Session save/restore | |
| 332 | +- Marks (like vim marks) | |
| 333 | +- Modes (resize mode, move mode) | |
| 334 | +- More layout algorithms | |