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
mluadependency - Create
src/config/mod.rsandsrc/config/lua.rs - Initialize Lua state
- Create
garglobal 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
- Config loads from
~/.config/gar/init.lua - Falls back to default if no config exists
- All keybinds configurable via Lua
- Settings (borders, gaps) apply immediately
- Window rules work for class/type matching
- Mod+Shift+R reloads config without restart
- 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 |