gardesk/garnotify / b7e80b7

Browse files

feat(config): add Lua config integration for gar ecosystem

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b7e80b7cab1bed37a153444c7bf227982e424899
Parents
695a6eb
Tree
364f213

4 changed files

StatusFile+-
M Cargo.toml 3 0
M garnotify/Cargo.toml 3 0
A garnotify/src/config/lua.rs 321 0
R garnotify/src/config.rsgarnotify/src/config/mod.rs 0 0
Cargo.tomlmodified
@@ -50,6 +50,9 @@ dirs = "5.0"
50
 shellexpand = "3.0"
50
 shellexpand = "3.0"
51
 regex = "1.10"
51
 regex = "1.10"
52
 
52
 
53
+# Lua config
54
+mlua = { version = "0.10", features = ["lua54", "vendored", "serialize"] }
55
+
53
 # Icon loading
56
 # Icon loading
54
 image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
57
 image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
55
 resvg = "0.44"
58
 resvg = "0.44"
garnotify/Cargo.tomlmodified
@@ -47,6 +47,9 @@ dirs = { workspace = true }
47
 shellexpand = { workspace = true }
47
 shellexpand = { workspace = true }
48
 regex = { workspace = true }
48
 regex = { workspace = true }
49
 
49
 
50
+# Lua config
51
+mlua = { workspace = true }
52
+
50
 # Icon loading
53
 # Icon loading
51
 image = { workspace = true }
54
 image = { workspace = true }
52
 resvg = { workspace = true }
55
 resvg = { workspace = true }
garnotify/src/config/lua.rsadded
@@ -0,0 +1,321 @@
1
+//! Lua configuration loader for gar ecosystem integration
2
+//!
3
+//! Reads `gar.notification` table from `~/.config/gar/init.lua`
4
+
5
+use anyhow::{anyhow, Result};
6
+use mlua::{Lua, Table, Value};
7
+use std::path::Path;
8
+use tracing::{debug, info, warn};
9
+
10
+use super::{
11
+    AnimationConfig, AppearanceConfig, ColorConfig, Config, GeneralConfig,
12
+    GeometryConfig, HistoryConfig, TimeoutConfig,
13
+};
14
+
15
+/// Convert mlua Error to anyhow Error
16
+fn lua_err(e: mlua::Error) -> anyhow::Error {
17
+    anyhow!("Lua error: {}", e)
18
+}
19
+
20
+/// Setup stub functions for gar's Lua API
21
+/// These no-ops allow garnotify to execute gar's init.lua without errors
22
+fn setup_gar_stubs(lua: &Lua, gar: &Table) -> Result<()> {
23
+    // gar.set(key, value) - configuration setter
24
+    let set_fn = lua
25
+        .create_function(|_, (_key, _value): (String, Value)| Ok(()))
26
+        .map_err(lua_err)?;
27
+    gar.set("set", set_fn).map_err(lua_err)?;
28
+
29
+    // gar.bind(keyspec, callback) - keybinding
30
+    let bind_fn = lua
31
+        .create_function(|_, (_keyspec, _callback): (String, Value)| Ok(()))
32
+        .map_err(lua_err)?;
33
+    gar.set("bind", bind_fn).map_err(lua_err)?;
34
+
35
+    // gar.exec(cmd) - execute command
36
+    let exec_fn = lua
37
+        .create_function(|_, _cmd: String| Ok(()))
38
+        .map_err(lua_err)?;
39
+    gar.set("exec", exec_fn).map_err(lua_err)?;
40
+
41
+    // gar.exec_once(cmd) - execute command once at startup
42
+    let exec_once_fn = lua
43
+        .create_function(|_, _cmd: String| Ok(()))
44
+        .map_err(lua_err)?;
45
+    gar.set("exec_once", exec_once_fn).map_err(lua_err)?;
46
+
47
+    // gar.rule(match, actions) - window rules
48
+    let rule_fn = lua
49
+        .create_function(|_, (_match_table, _actions_table): (Value, Value)| Ok(()))
50
+        .map_err(lua_err)?;
51
+    gar.set("rule", rule_fn).map_err(lua_err)?;
52
+
53
+    // gar.picom_rule(config) - picom window rules
54
+    let picom_rule_fn = lua
55
+        .create_function(|_, _config: Value| Ok(()))
56
+        .map_err(lua_err)?;
57
+    gar.set("picom_rule", picom_rule_fn).map_err(lua_err)?;
58
+
59
+    // Window management stubs
60
+    for name in &[
61
+        "focus",
62
+        "swap",
63
+        "close",
64
+        "toggle_floating",
65
+        "equalize",
66
+        "reload",
67
+        "exit",
68
+        "focus_monitor",
69
+        "move_to_monitor",
70
+    ] {
71
+        let stub = lua.create_function(|_, _: Value| Ok(())).map_err(lua_err)?;
72
+        gar.set(*name, stub).map_err(lua_err)?;
73
+    }
74
+
75
+    // Workspace stubs
76
+    let workspace_fn = lua
77
+        .create_function(|_, _n: Value| Ok(()))
78
+        .map_err(lua_err)?;
79
+    gar.set("workspace", workspace_fn).map_err(lua_err)?;
80
+
81
+    let workspace_next_fn = lua.create_function(|_, ()| Ok(())).map_err(lua_err)?;
82
+    gar.set("workspace_next", workspace_next_fn)
83
+        .map_err(lua_err)?;
84
+
85
+    let workspace_prev_fn = lua.create_function(|_, ()| Ok(())).map_err(lua_err)?;
86
+    gar.set("workspace_prev", workspace_prev_fn)
87
+        .map_err(lua_err)?;
88
+
89
+    let move_fn = lua
90
+        .create_function(|_, _n: Value| Ok(()))
91
+        .map_err(lua_err)?;
92
+    gar.set("move", move_fn).map_err(lua_err)?;
93
+
94
+    let move_to_workspace_fn = lua
95
+        .create_function(|_, _n: Value| Ok(()))
96
+        .map_err(lua_err)?;
97
+    gar.set("move_to_workspace", move_to_workspace_fn)
98
+        .map_err(lua_err)?;
99
+
100
+    let resize_fn = lua
101
+        .create_function(|_, (_direction, _amount): (Value, Value)| Ok(()))
102
+        .map_err(lua_err)?;
103
+    gar.set("resize", resize_fn).map_err(lua_err)?;
104
+
105
+    Ok(())
106
+}
107
+
108
+/// Load configuration from gar's init.lua file
109
+pub fn load_from_lua<P: AsRef<Path>>(path: P) -> Result<Option<Config>> {
110
+    let path = path.as_ref();
111
+
112
+    if !path.exists() {
113
+        debug!("Lua config file not found: {}", path.display());
114
+        return Ok(None);
115
+    }
116
+
117
+    info!("Loading config from {}", path.display());
118
+
119
+    let lua = Lua::new();
120
+    let content = std::fs::read_to_string(path)?;
121
+
122
+    // Create the gar global table with stub functions
123
+    let gar = lua.create_table().map_err(lua_err)?;
124
+    setup_gar_stubs(&lua, &gar)?;
125
+    lua.globals().set("gar", gar).map_err(lua_err)?;
126
+
127
+    // Execute the config file
128
+    lua.load(&content)
129
+        .set_name(path.to_string_lossy())
130
+        .exec()
131
+        .map_err(|e| anyhow!("Failed to execute {}: {}", path.display(), e))?;
132
+
133
+    // Try to get gar.notification
134
+    let gar: Table = lua.globals().get("gar").map_err(lua_err)?;
135
+    let notification_value: Value = gar.get("notification").map_err(lua_err)?;
136
+
137
+    match notification_value {
138
+        Value::Table(table) => {
139
+            let config = parse_notification_config(&table)?;
140
+            Ok(Some(config))
141
+        }
142
+        Value::Nil => {
143
+            debug!("No gar.notification table found in config");
144
+            Ok(None)
145
+        }
146
+        _ => {
147
+            warn!("gar.notification is not a table");
148
+            Ok(None)
149
+        }
150
+    }
151
+}
152
+
153
+/// Parse the gar.notification table into Config
154
+fn parse_notification_config(table: &Table) -> Result<Config> {
155
+    let mut config = Config::default();
156
+
157
+    // General settings
158
+    if let Ok(monitor) = table.get::<String>("monitor") {
159
+        config.general.monitor = monitor;
160
+    }
161
+    if let Ok(follow_mouse) = table.get::<bool>("follow_mouse") {
162
+        config.general.follow_mouse = follow_mouse;
163
+    }
164
+
165
+    // Geometry settings
166
+    if let Ok(position) = table.get::<String>("position") {
167
+        config.geometry.position = position;
168
+    }
169
+    if let Ok(width) = table.get::<u32>("width") {
170
+        config.geometry.width = width;
171
+    }
172
+    if let Ok(max_height) = table.get::<u32>("max_height") {
173
+        config.geometry.max_height = max_height;
174
+    }
175
+    if let Ok(offset_x) = table.get::<i32>("offset_x") {
176
+        config.geometry.offset_x = offset_x;
177
+    }
178
+    if let Ok(offset_y) = table.get::<i32>("offset_y") {
179
+        config.geometry.offset_y = offset_y;
180
+    }
181
+    if let Ok(gap) = table.get::<i32>("gap") {
182
+        config.geometry.gap = gap;
183
+    }
184
+    if let Ok(max_visible) = table.get::<u32>("max_visible") {
185
+        config.geometry.max_visible = max_visible;
186
+    }
187
+
188
+    // Timeout settings
189
+    if let Ok(timeout) = table.get::<i32>("timeout") {
190
+        // Single timeout applies to normal urgency
191
+        config.timeouts.normal = timeout;
192
+    }
193
+    if let Ok(timeouts) = table.get::<Table>("timeouts") {
194
+        if let Ok(low) = timeouts.get::<i32>("low") {
195
+            config.timeouts.low = low;
196
+        }
197
+        if let Ok(normal) = timeouts.get::<i32>("normal") {
198
+            config.timeouts.normal = normal;
199
+        }
200
+        if let Ok(critical) = timeouts.get::<i32>("critical") {
201
+            config.timeouts.critical = critical;
202
+        }
203
+    }
204
+
205
+    // Appearance settings
206
+    if let Ok(font) = table.get::<String>("font") {
207
+        config.appearance.font = font;
208
+    }
209
+    if let Ok(title_font) = table.get::<String>("title_font") {
210
+        config.appearance.title_font = title_font;
211
+    }
212
+    if let Ok(icon_size) = table.get::<u32>("icon_size") {
213
+        config.appearance.icon_size = icon_size;
214
+    }
215
+    if let Ok(padding) = table.get::<u32>("padding") {
216
+        config.appearance.padding = padding;
217
+    }
218
+    if let Ok(corner_radius) = table.get::<u32>("corner_radius") {
219
+        config.appearance.corner_radius = corner_radius;
220
+    }
221
+    if let Ok(border_width) = table.get::<u32>("border_width") {
222
+        config.appearance.border_width = border_width;
223
+    }
224
+
225
+    // Colors - can be flat or nested
226
+    if let Ok(background) = table.get::<String>("background") {
227
+        config.appearance.colors.background = background.clone();
228
+        config.appearance.colors.low_background = background.clone();
229
+    }
230
+    if let Ok(foreground) = table.get::<String>("foreground") {
231
+        config.appearance.colors.foreground = foreground.clone();
232
+        config.appearance.colors.low_foreground = foreground.clone();
233
+    }
234
+    if let Ok(border) = table.get::<String>("border") {
235
+        config.appearance.colors.border = border.clone();
236
+        config.appearance.colors.low_border = border.clone();
237
+    }
238
+
239
+    // Nested colors table
240
+    if let Ok(colors) = table.get::<Table>("colors") {
241
+        parse_colors(&colors, &mut config.appearance.colors);
242
+    }
243
+
244
+    // Animation settings
245
+    if let Ok(animations) = table.get::<Table>("animation") {
246
+        if let Ok(enabled) = animations.get::<bool>("enabled") {
247
+            config.animation.enabled = enabled;
248
+        }
249
+        if let Ok(fade_in) = animations.get::<u32>("fade_in") {
250
+            config.animation.fade_in = fade_in;
251
+        }
252
+        if let Ok(fade_out) = animations.get::<u32>("fade_out") {
253
+            config.animation.fade_out = fade_out;
254
+        }
255
+        if let Ok(slide) = animations.get::<String>("slide") {
256
+            config.animation.slide = slide;
257
+        }
258
+        if let Ok(slide_distance) = animations.get::<u32>("slide_distance") {
259
+            config.animation.slide_distance = slide_distance;
260
+        }
261
+    }
262
+
263
+    // History settings
264
+    if let Ok(history) = table.get::<Table>("history") {
265
+        if let Ok(max_length) = history.get::<usize>("max_length") {
266
+            config.history.max_length = max_length;
267
+        }
268
+        if let Ok(persist) = history.get::<bool>("persist") {
269
+            config.history.persist = persist;
270
+        }
271
+    }
272
+
273
+    debug!(
274
+        "Parsed notification config: position={}, width={}",
275
+        config.geometry.position, config.geometry.width
276
+    );
277
+    Ok(config)
278
+}
279
+
280
+/// Parse colors table
281
+fn parse_colors(table: &Table, colors: &mut ColorConfig) {
282
+    if let Ok(bg) = table.get::<String>("background") {
283
+        colors.background = bg;
284
+    }
285
+    if let Ok(fg) = table.get::<String>("foreground") {
286
+        colors.foreground = fg;
287
+    }
288
+    if let Ok(border) = table.get::<String>("border") {
289
+        colors.border = border;
290
+    }
291
+
292
+    // Low urgency
293
+    if let Ok(low_bg) = table.get::<String>("low_background") {
294
+        colors.low_background = low_bg;
295
+    }
296
+    if let Ok(low_fg) = table.get::<String>("low_foreground") {
297
+        colors.low_foreground = low_fg;
298
+    }
299
+    if let Ok(low_border) = table.get::<String>("low_border") {
300
+        colors.low_border = low_border;
301
+    }
302
+
303
+    // Critical urgency
304
+    if let Ok(crit_bg) = table.get::<String>("critical_background") {
305
+        colors.critical_background = crit_bg;
306
+    }
307
+    if let Ok(crit_fg) = table.get::<String>("critical_foreground") {
308
+        colors.critical_foreground = crit_fg;
309
+    }
310
+    if let Ok(crit_border) = table.get::<String>("critical_border") {
311
+        colors.critical_border = crit_border;
312
+    }
313
+}
314
+
315
+/// Get the path to gar's init.lua
316
+pub fn gar_config_path() -> std::path::PathBuf {
317
+    dirs::config_dir()
318
+        .unwrap_or_else(|| std::path::PathBuf::from("~/.config"))
319
+        .join("gar")
320
+        .join("init.lua")
321
+}
garnotify/src/config.rs → garnotify/src/config/mod.rsrenamed (80% similarity)
@@ -1,8 +1,11 @@
1
 //! Configuration loading and management for garnotify
1
 //! Configuration loading and management for garnotify
2
 
2
 
3
+pub mod lua;
4
+
3
 use anyhow::{Context, Result};
5
 use anyhow::{Context, Result};
4
 use serde::{Deserialize, Serialize};
6
 use serde::{Deserialize, Serialize};
5
 use std::path::PathBuf;
7
 use std::path::PathBuf;
8
+use tracing::{debug, info, warn};
6
 
9
 
7
 use crate::rules::Rule;
10
 use crate::rules::Rule;
8
 
11
 
@@ -251,19 +254,57 @@ impl Default for HistoryConfig {
251
 }
254
 }
252
 
255
 
253
 /// Load configuration from file
256
 /// Load configuration from file
257
+///
258
+/// Priority:
259
+/// 1. Explicit path (if provided)
260
+/// 2. gar's Lua config: ~/.config/gar/init.lua (gar.notification table)
261
+/// 3. TOML config: ~/.config/garnotify/config.toml
262
+/// 4. Defaults
254
 pub fn load(path: Option<&str>) -> Result<Config> {
263
 pub fn load(path: Option<&str>) -> Result<Config> {
255
-    let config_path = path.map(PathBuf::from).unwrap_or_else(config_path);
264
+    // If explicit path provided, use it
256
-
265
+    if let Some(p) = path {
257
-    if config_path.exists() {
266
+        let config_path = PathBuf::from(p);
258
-        let content = std::fs::read_to_string(&config_path)
267
+        if config_path.exists() {
259
-            .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
268
+            return load_toml(&config_path);
269
+        }
270
+    }
260
 
271
 
261
-        let config: Config = toml::from_str(&content)
272
+    // Try gar's Lua config first (gar ecosystem integration)
262
-            .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
273
+    let lua_path = lua::gar_config_path();
274
+    if lua_path.exists() {
275
+        match lua::load_from_lua(&lua_path) {
276
+            Ok(Some(config)) => {
277
+                info!("Loaded config from gar's init.lua");
278
+                return Ok(config);
279
+            }
280
+            Ok(None) => {
281
+                debug!("No gar.notification table in init.lua");
282
+            }
283
+            Err(e) => {
284
+                warn!("Failed to load Lua config: {}", e);
285
+            }
286
+        }
287
+    }
263
 
288
 
264
-        Ok(config)
289
+    // Try TOML config
265
-    } else {
290
+    let toml_path = config_path();
266
-        // Config file doesn't exist, use defaults
291
+    if toml_path.exists() {
267
-        Ok(Config::default())
292
+        return load_toml(&toml_path);
268
     }
293
     }
294
+
295
+    // Use defaults
296
+    debug!("Using default configuration");
297
+    Ok(Config::default())
298
+}
299
+
300
+/// Load config from TOML file
301
+fn load_toml(path: &PathBuf) -> Result<Config> {
302
+    let content = std::fs::read_to_string(path)
303
+        .with_context(|| format!("Failed to read config file: {}", path.display()))?;
304
+
305
+    let config: Config = toml::from_str(&content)
306
+        .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
307
+
308
+    info!("Loaded config from {}", path.display());
309
+    Ok(config)
269
 }
310
 }