gardesk/gar / edf725e

Browse files

Temporarily track sprint docs for laptop transfer

Authored by espadonne
SHA
edf725ec24728981e045517f34c61f507c709cbb
Parents
32aaf43
Tree
2ae0740

10 changed files

StatusFile+-
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