markdown · 7979 bytes Raw Blame History

Sprint 4: Lua Configuration

Goal: User-configurable keybinds, settings, and startup applications via Lua scripting.

Objectives

  • Load and execute Lua configuration file
  • Expose gar API to Lua (keybinds, settings, rules)
  • Support config hot-reload
  • Provide sensible default configuration

Prerequisites

  • Sprint 3 complete (workspaces)

Lua API Design

-- ~/.config/gar/init.lua

-- Settings
gar.set("border_width", 2)
gar.set("border_color_focused", "#5294e2")
gar.set("border_color_unfocused", "#2d2d2d")
gar.set("gap_inner", 5)
gar.set("gap_outer", 10)

-- Keybindings
gar.bind("mod+Return", function() gar.exec("alacritty") end)
gar.bind("mod+d", function() gar.exec("rofi -show drun") end)
gar.bind("mod+shift+q", gar.close_window)
gar.bind("mod+h", function() gar.focus("left") end)
gar.bind("mod+j", function() gar.focus("down") end)
gar.bind("mod+k", function() gar.focus("up") end)
gar.bind("mod+l", function() gar.focus("right") end)

-- Workspace bindings (loop)
for i = 1, 9 do
    gar.bind("mod+" .. i, function() gar.workspace(i) end)
    gar.bind("mod+shift+" .. i, function() gar.move_to_workspace(i) end)
end

-- Window rules
gar.rule({ class = "Firefox" }, { workspace = 2 })
gar.rule({ class = "Spotify" }, { floating = true })
gar.rule({ type = "dialog" }, { floating = true })

-- Startup applications
gar.exec_once("picom")
gar.exec_once("polybar")
gar.exec_once("dunst")

Tasks

4.1 Lua Integration Setup

  • Add mlua dependency
  • Create src/config/mod.rs and src/config/lua.rs
  • Initialize Lua state
  • Create gar global table
  • Handle Lua errors gracefully
use mlua::{Lua, Result as LuaResult};

pub struct LuaConfig {
    lua: Lua,
}

impl LuaConfig {
    pub fn new() -> LuaResult<Self> {
        let lua = Lua::new();

        // Create gar global table
        lua.globals().set("gar", lua.create_table()?)?;

        Ok(Self { lua })
    }
}

4.2 Configuration Loading

  • Find config file (~/.config/gar/init.lua)
  • Fall back to default config if not found
  • Execute Lua file
  • Handle syntax errors with helpful messages
impl LuaConfig {
    pub fn load(&self, wm: &mut WindowManager) -> Result<()> {
        let config_path = dirs::config_dir()
            .map(|p| p.join("gar/init.lua"))
            .filter(|p| p.exists());

        let source = match config_path {
            Some(path) => std::fs::read_to_string(path)?,
            None => include_str!("../../config/default.lua").to_string(),
        };

        self.lua.load(&source).exec()?;
        Ok(())
    }
}

4.3 Settings API

  • Implement gar.set(key, value)
  • Store settings in Rust-side config struct
  • Support: border_width, border colors, gaps
  • Validate values (types, ranges)
// Register gar.set
let set_fn = lua.create_function(|_, (key, value): (String, mlua::Value)| {
    // Send to config channel or store in shared state
    Ok(())
})?;
gar_table.set("set", set_fn)?;

Supported settings:

Setting Type Default Description
border_width int 2 Window border width in pixels
border_color_focused string "#5294e2" Focused window border color
border_color_unfocused string "#2d2d2d" Unfocused window border color
gap_inner int 0 Gap between windows
gap_outer int 0 Gap around screen edge

4.4 Keybind API

  • Implement gar.bind(keyspec, callback)
  • Parse keyspec string ("mod+shift+q")
  • Register callback function
  • Grab keys via X11
// Key specification parser
fn parse_keyspec(spec: &str) -> Result<(Modifiers, Keycode)> {
    // "mod+shift+q" -> (MOD4 | SHIFT, keycode_q)
    let parts: Vec<&str> = spec.to_lowercase().split('+').collect();
    // ...
}

4.5 Built-in Actions

  • gar.focus(direction) - focus in direction
  • gar.swap(direction) - swap with direction
  • gar.resize(direction, amount) - resize split
  • gar.close_window() - close focused window
  • gar.workspace(n) - switch workspace
  • gar.move_to_workspace(n) - move window
  • gar.exec(cmd) - spawn command
  • gar.exec_once(cmd) - spawn only if not running
  • gar.reload() - reload config

4.6 Window Rules

  • Implement gar.rule(match, actions)
  • Match by: class, instance, title, type
  • Actions: workspace, floating, border, size, position
  • Apply rules on window creation
pub struct WindowRule {
    pub match_class: Option<Regex>,
    pub match_instance: Option<Regex>,
    pub match_title: Option<Regex>,
    pub match_type: Option<WindowType>,

    pub workspace: Option<WorkspaceId>,
    pub floating: Option<bool>,
    pub border_width: Option<u32>,
    // ...
}

4.7 Config Reload

  • Implement gar.reload() function
  • Bind to Mod+Shift+R by default
  • Clear existing keybinds
  • Re-execute config file
  • Apply new settings immediately
  • Report errors without crashing

4.8 Default Configuration

  • Create config/default.lua
  • Include sensible defaults for all settings
  • Include essential keybinds (focus, close, workspaces)
  • Document all options in comments

Default Configuration

-- config/default.lua
-- Default gar configuration

-- Appearance
gar.set("border_width", 2)
gar.set("border_color_focused", "#5294e2")
gar.set("border_color_unfocused", "#2d2d2d")
gar.set("gap_inner", 0)
gar.set("gap_outer", 0)

-- Mod key (mod = Super/Win key)
local mod = "mod"

-- Core keybindings
gar.bind(mod .. "+Return", function() gar.exec("xterm") end)
gar.bind(mod .. "+shift+q", gar.close_window)
gar.bind(mod .. "+shift+r", gar.reload)
gar.bind(mod .. "+shift+e", gar.exit)

-- Focus navigation
gar.bind(mod .. "+h", function() gar.focus("left") end)
gar.bind(mod .. "+j", function() gar.focus("down") end)
gar.bind(mod .. "+k", function() gar.focus("up") end)
gar.bind(mod .. "+l", function() gar.focus("right") end)

-- Window movement
gar.bind(mod .. "+shift+h", function() gar.swap("left") end)
gar.bind(mod .. "+shift+j", function() gar.swap("down") end)
gar.bind(mod .. "+shift+k", function() gar.swap("up") end)
gar.bind(mod .. "+shift+l", function() gar.swap("right") end)

-- Resize
gar.bind(mod .. "+ctrl+h", function() gar.resize("left", 0.05) end)
gar.bind(mod .. "+ctrl+j", function() gar.resize("down", 0.05) end)
gar.bind(mod .. "+ctrl+k", function() gar.resize("up", 0.05) end)
gar.bind(mod .. "+ctrl+l", function() gar.resize("right", 0.05) end)
gar.bind(mod .. "+e", gar.equalize)

-- Workspaces
for i = 1, 9 do
    gar.bind(mod .. "+" .. i, function() gar.workspace(i) end)
    gar.bind(mod .. "+shift+" .. i, function() gar.move_to_workspace(i) end)
end
gar.bind(mod .. "+0", function() gar.workspace(10) end)
gar.bind(mod .. "+shift+0", function() gar.move_to_workspace(10) end)

-- Window rules
gar.rule({ type = "dialog" }, { floating = true })
gar.rule({ type = "splash" }, { floating = true })

Acceptance Criteria

  1. Config loads from ~/.config/gar/init.lua
  2. Falls back to default if no config exists
  3. All keybinds configurable via Lua
  4. Settings (borders, gaps) apply immediately
  5. Window rules work for class/type matching
  6. Mod+Shift+R reloads config without restart
  7. Syntax errors reported clearly, don't crash WM

Testing Strategy

# Create test config
mkdir -p ~/.config/gar
cat > ~/.config/gar/init.lua << 'EOF'
gar.set("border_width", 4)
gar.set("border_color_focused", "#ff0000")
gar.bind("mod+t", function() print("test") end)
EOF

# Start gar, verify:
# - 4px borders
# - Red focus color
# - Mod+t prints to log

# Test reload
# Modify config, Mod+Shift+R, verify changes apply

Notes

  • Lua 5.4 via mlua with vendored feature (no system dependency)
  • Consider sandboxing Lua (disable os.execute, io.*, etc.)
  • exec_once needs to track spawned processes
  • Error messages should include line numbers
View source
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