gardesk/gar / edaf501

Browse files

Add visual effects config infrastructure (disabled)

- Add gradient border, animation, and shader config options
- Add frame gradient rendering code (currently disabled)
- Fix frame destruction to unmap before destroy
- Gradient borders disabled due to lifecycle issues
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
edaf5018a5990cda92e7a70b71f8501a02111eee
Parents
c5843dc
Tree
b7e0519

10 changed files

StatusFile+-
M Cargo.lock 1 0
M Cargo.toml 1 0
M gar/Cargo.toml 1 0
M gar/config/default.lua 37 0
A gar/config/shaders/dim-unfocused.glsl 15 0
A gar/config/shaders/focused-glow.glsl 15 0
M gar/src/config/lua.rs 135 1
M gar/src/config/mod.rs 143 5
M gar/src/core/mod.rs 12 4
M gar/src/x11/frame.rs 350 6
Cargo.lockmodified
@@ -205,6 +205,7 @@ name = "gar"
205205
 version = "0.1.0"
206206
 dependencies = [
207207
  "dirs",
208
+ "libc",
208209
  "mlua",
209210
  "serde",
210211
  "serde_json",
Cargo.tomlmodified
@@ -21,3 +21,4 @@ serde_json = "1.0"
2121
 mlua = { version = "0.10", features = ["lua54", "vendored"] }
2222
 dirs = "6.0"
2323
 clap = { version = "4.5", features = ["derive"] }
24
+libc = "0.2"
gar/Cargo.tomlmodified
@@ -19,3 +19,4 @@ serde.workspace = true
1919
 serde_json.workspace = true
2020
 mlua.workspace = true
2121
 dirs.workspace = true
22
+libc.workspace = true
gar/config/default.luamodified
@@ -35,6 +35,43 @@ gar.set("gap_outer", 8)
3535
 -- gar.set("fade_enabled", true)
3636
 -- gar.set("fade_delta", 10)
3737
 
38
+-- Title bars (disabled by default)
39
+-- gar.set("titlebar_enabled", true)
40
+-- gar.set("titlebar_height", 20)
41
+-- gar.set("titlebar_color_focused", "#3d3d3d")
42
+-- gar.set("titlebar_color_unfocused", "#2d2d2d")
43
+-- gar.set("titlebar_text_color", "#ffffff")
44
+
45
+-- Border gradients (requires frame windows, disabled by default)
46
+-- gar.set("border_gradient_enabled", true)
47
+-- gar.set("border_gradient_start_focused", "#5294e2")
48
+-- gar.set("border_gradient_end_focused", "#1a5fb4")
49
+-- gar.set("border_gradient_start_unfocused", "#3d3d3d")
50
+-- gar.set("border_gradient_end_unfocused", "#1d1d1d")
51
+-- gar.set("border_gradient_direction", "vertical")  -- "vertical", "horizontal", "diagonal"
52
+
53
+-- Animations (picom v12+)
54
+-- open options: "slide-in", "fly-in", "appear", "none"
55
+-- close options: "slide-out", "fly-out", "disappear", "none"
56
+-- gar.set("animation_open", "fly-in")
57
+-- gar.set("animation_close", "fly-out")
58
+-- gar.set("animation_duration", 0.2)      -- seconds
59
+
60
+-- Custom GLSL shader (picom, requires GLX backend)
61
+-- gar.set("picom_shader", "~/.config/gar/shaders/focused-glow.glsl")
62
+
63
+-- Per-window picom rules (examples)
64
+-- gar.picom_rule({
65
+--     match = "class_g = 'Firefox'",
66
+--     corner_radius = 8,
67
+--     opacity = 0.95,
68
+-- })
69
+-- gar.picom_rule({
70
+--     match = "class_g = 'Alacritty'",
71
+--     blur_background = true,
72
+--     opacity = 0.9,
73
+-- })
74
+
3875
 -- Behavior
3976
 gar.set("follow_window_on_move", true)  -- Follow window when using Mod+Shift+number
4077
 
gar/config/shaders/dim-unfocused.glsladded
@@ -0,0 +1,15 @@
1
+// Dimmed unfocused window shader for gar + picom
2
+// Applies a subtle dimming effect to unfocused windows
3
+#version 330
4
+
5
+uniform sampler2D tex;
6
+uniform float opacity;
7
+in vec2 texcoord;
8
+out vec4 fragColor;
9
+
10
+void main() {
11
+    vec4 color = texture(tex, texcoord);
12
+    // Dim by 15%
13
+    color.rgb *= 0.85;
14
+    fragColor = color * opacity;
15
+}
gar/config/shaders/focused-glow.glsladded
@@ -0,0 +1,15 @@
1
+// Focused window glow shader for gar + picom
2
+// Applies a subtle brightness boost to focused windows
3
+#version 330
4
+
5
+uniform sampler2D tex;
6
+uniform float opacity;
7
+in vec2 texcoord;
8
+out vec4 fragColor;
9
+
10
+void main() {
11
+    vec4 color = texture(tex, texcoord);
12
+    // Subtle brightness boost for focused windows
13
+    color.rgb *= 1.05;
14
+    fragColor = color * opacity;
15
+}
gar/src/config/lua.rsmodified
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex};
55
 use mlua::{Function, Lua, Result as LuaResult, Table, Value};
66
 use x11rb::protocol::xproto::ModMask;
77
 
8
-use super::Config;
8
+use super::{Config, PicomRule};
99
 
1010
 /// Actions that can be triggered by keybinds
1111
 #[derive(Debug, Clone)]
@@ -188,6 +188,9 @@ impl LuaConfig {
188188
         // gar.rule(match, actions)
189189
         self.register_rule(&gar)?;
190190
 
191
+        // gar.picom_rule(table) - per-window picom rules
192
+        self.register_picom_rule(&gar)?;
193
+
191194
         // Built-in action functions
192195
         self.register_actions(&gar)?;
193196
 
@@ -362,6 +365,90 @@ impl LuaConfig {
362365
                         state.config.fade_delta = v as u32;
363366
                     }
364367
                 }
368
+                // Border gradient settings
369
+                "border_gradient_enabled" => {
370
+                    if let Value::Boolean(v) = value {
371
+                        state.config.border_gradient_enabled = v;
372
+                    }
373
+                }
374
+                "border_gradient_start_focused" => {
375
+                    if let Value::String(s) = value {
376
+                        if let Ok(str_val) = s.to_str() {
377
+                            if let Some(color) = parse_color(&str_val) {
378
+                                state.config.border_gradient_start_focused = color;
379
+                            }
380
+                        }
381
+                    }
382
+                }
383
+                "border_gradient_end_focused" => {
384
+                    if let Value::String(s) = value {
385
+                        if let Ok(str_val) = s.to_str() {
386
+                            if let Some(color) = parse_color(&str_val) {
387
+                                state.config.border_gradient_end_focused = color;
388
+                            }
389
+                        }
390
+                    }
391
+                }
392
+                "border_gradient_start_unfocused" => {
393
+                    if let Value::String(s) = value {
394
+                        if let Ok(str_val) = s.to_str() {
395
+                            if let Some(color) = parse_color(&str_val) {
396
+                                state.config.border_gradient_start_unfocused = color;
397
+                            }
398
+                        }
399
+                    }
400
+                }
401
+                "border_gradient_end_unfocused" => {
402
+                    if let Value::String(s) = value {
403
+                        if let Ok(str_val) = s.to_str() {
404
+                            if let Some(color) = parse_color(&str_val) {
405
+                                state.config.border_gradient_end_unfocused = color;
406
+                            }
407
+                        }
408
+                    }
409
+                }
410
+                "border_gradient_direction" => {
411
+                    if let Value::String(s) = value {
412
+                        if let Ok(str_val) = s.to_str() {
413
+                            state.config.border_gradient_direction = str_val.to_string();
414
+                        }
415
+                    }
416
+                }
417
+                // Animation settings
418
+                "animation_open" => {
419
+                    if let Value::String(s) = value {
420
+                        if let Ok(str_val) = s.to_str() {
421
+                            state.config.animation_open = str_val.to_string();
422
+                        }
423
+                    }
424
+                }
425
+                "animation_close" => {
426
+                    if let Value::String(s) = value {
427
+                        if let Ok(str_val) = s.to_str() {
428
+                            state.config.animation_close = str_val.to_string();
429
+                        }
430
+                    }
431
+                }
432
+                "animation_duration" => {
433
+                    if let Value::Number(v) = value {
434
+                        state.config.animation_duration = v;
435
+                    }
436
+                }
437
+                "animation_curve" => {
438
+                    if let Value::String(s) = value {
439
+                        if let Ok(str_val) = s.to_str() {
440
+                            state.config.animation_curve = str_val.to_string();
441
+                        }
442
+                    }
443
+                }
444
+                // Shader settings
445
+                "picom_shader" => {
446
+                    if let Value::String(s) = value {
447
+                        if let Ok(str_val) = s.to_str() {
448
+                            state.config.picom_shader = Some(str_val.to_string());
449
+                        }
450
+                    }
451
+                }
365452
                 _ => {
366453
                     tracing::warn!("Unknown config key: {}", key);
367454
                 }
@@ -530,6 +617,53 @@ impl LuaConfig {
530617
         gar.set("rule", rule_fn)
531618
     }
532619
 
620
+    fn register_picom_rule(&self, gar: &Table) -> LuaResult<()> {
621
+        let state = Arc::clone(&self.state);
622
+        // gar.picom_rule({ match = "...", corner_radius = 8, opacity = 0.9, ... })
623
+        let picom_rule_fn = self.lua.create_function(move |_, table: Table| {
624
+            let mut rule = PicomRule::default();
625
+
626
+            // Required: match expression
627
+            if let Ok(match_expr) = table.get::<String>("match") {
628
+                rule.match_expr = match_expr;
629
+            } else {
630
+                tracing::warn!("picom_rule: missing 'match' field");
631
+                return Ok(());
632
+            }
633
+
634
+            // Optional: corner_radius
635
+            if let Ok(cr) = table.get::<u32>("corner_radius") {
636
+                rule.corner_radius = Some(cr);
637
+            }
638
+
639
+            // Optional: opacity
640
+            if let Ok(op) = table.get::<f64>("opacity") {
641
+                rule.opacity = Some(op);
642
+            }
643
+
644
+            // Optional: shadow
645
+            if let Ok(shadow) = table.get::<bool>("shadow") {
646
+                rule.shadow = Some(shadow);
647
+            }
648
+
649
+            // Optional: blur_background
650
+            if let Ok(blur) = table.get::<bool>("blur_background") {
651
+                rule.blur_background = Some(blur);
652
+            }
653
+
654
+            // Optional: shader
655
+            if let Ok(shader) = table.get::<String>("shader") {
656
+                rule.shader = Some(shader);
657
+            }
658
+
659
+            tracing::debug!("Registered picom rule: {:?}", rule);
660
+            let mut state = state.lock().unwrap();
661
+            state.config.picom_rules.push(rule);
662
+            Ok(())
663
+        })?;
664
+        gar.set("picom_rule", picom_rule_fn)
665
+    }
666
+
533667
     fn register_actions(&self, gar: &Table) -> LuaResult<()> {
534668
         // gar.close_window - returns a table that bind() recognizes
535669
         let close_window = self.lua.create_table()?;
gar/src/config/mod.rsmodified
@@ -2,6 +2,17 @@ mod lua;
22
 
33
 pub use lua::{Action, Keybind, LuaConfig, LuaState, RuleActions, WindowMatch, WindowRule};
44
 
5
+/// A per-window picom rule for customizing compositor effects per application
6
+#[derive(Debug, Clone, Default)]
7
+pub struct PicomRule {
8
+    pub match_expr: String,
9
+    pub corner_radius: Option<u32>,
10
+    pub opacity: Option<f64>,
11
+    pub shadow: Option<bool>,
12
+    pub blur_background: Option<bool>,
13
+    pub shader: Option<String>,
14
+}
15
+
516
 #[derive(Debug, Clone)]
617
 pub struct Config {
718
     pub border_width: u32,
@@ -36,6 +47,21 @@ pub struct Config {
3647
     pub opacity_unfocused: f64,
3748
     pub fade_enabled: bool,
3849
     pub fade_delta: u32,
50
+    // Border gradient settings
51
+    pub border_gradient_enabled: bool,
52
+    pub border_gradient_start_focused: u32,
53
+    pub border_gradient_end_focused: u32,
54
+    pub border_gradient_start_unfocused: u32,
55
+    pub border_gradient_end_unfocused: u32,
56
+    pub border_gradient_direction: String,
57
+    // Animation settings
58
+    pub animation_open: String,
59
+    pub animation_close: String,
60
+    pub animation_duration: f64,
61
+    pub animation_curve: String,
62
+    // Shader and per-window rules
63
+    pub picom_shader: Option<String>,
64
+    pub picom_rules: Vec<PicomRule>,
3965
 }
4066
 
4167
 impl Config {
@@ -56,7 +82,7 @@ blur-background-exclude = [
5682
     "window_type = 'menu'",
5783
     "window_type = 'dropdown_menu'",
5884
     "window_type = 'popup_menu'",
59
-    "_NET_WM_BYPASS_COMPOSITOR@:32c = 1"
85
+    "_NET_WM_BYPASS_COMPOSITOR = 1"
6086
 ];"#,
6187
                 self.blur_method, self.blur_strength
6288
             )
@@ -80,8 +106,8 @@ shadow-exclude = [
80106
     "window_type = 'dropdown_menu'",
81107
     "window_type = 'popup_menu'",
82108
     "window_type = 'tooltip'",
83
-    "_NET_WM_STATE@:32a *= '_NET_WM_STATE_FULLSCREEN'",
84
-    "_NET_WM_BYPASS_COMPOSITOR@:32c = 1"
109
+    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'",
110
+    "_NET_WM_BYPASS_COMPOSITOR = 1"
85111
 ];"#,
86112
                 self.shadow_radius,
87113
                 self.shadow_opacity,
@@ -125,6 +151,94 @@ frame-opacity = 1.0;"#,
125151
             "# Focus opacity: all windows fully opaque".to_string()
126152
         };
127153
 
154
+        // Animation section - only generate if using valid picom v12 presets
155
+        // Valid presets: slide-in, slide-out, fly-in, fly-out, appear, disappear
156
+        let valid_presets = ["slide-in", "slide-out", "fly-in", "fly-out", "appear", "disappear"];
157
+        let open_valid = valid_presets.contains(&self.animation_open.as_str());
158
+        let close_valid = valid_presets.contains(&self.animation_close.as_str());
159
+
160
+        let animation_section = if open_valid || close_valid {
161
+            let open_preset = if open_valid { &self.animation_open } else { "appear" };
162
+            let close_preset = if close_valid { &self.animation_close } else { "disappear" };
163
+            format!(
164
+                r#"# Animations
165
+animations = ({{
166
+    triggers = ["open", "show"];
167
+    preset = "{}";
168
+    duration = {:.2};
169
+}},
170
+{{
171
+    triggers = ["close", "hide"];
172
+    preset = "{}";
173
+    duration = {:.2};
174
+}},
175
+{{
176
+    triggers = ["geometry"];
177
+    preset = "geometry-change";
178
+    duration = {:.2};
179
+}});"#,
180
+                open_preset, self.animation_duration,
181
+                close_preset, self.animation_duration,
182
+                self.animation_duration * 0.5
183
+            )
184
+        } else {
185
+            "# Animations disabled".to_string()
186
+        };
187
+
188
+        // Global shader section
189
+        let shader_section = if let Some(ref shader) = self.picom_shader {
190
+            // Expand ~ to home directory
191
+            let expanded = if shader.starts_with("~/") {
192
+                if let Some(home) = dirs::home_dir() {
193
+                    home.join(&shader[2..]).to_string_lossy().to_string()
194
+                } else {
195
+                    shader.clone()
196
+                }
197
+            } else {
198
+                shader.clone()
199
+            };
200
+            format!("# Custom Shader\nwindow-shader-fg = \"{}\";", expanded)
201
+        } else {
202
+            "# No custom shader".to_string()
203
+        };
204
+
205
+        // Per-window rules section
206
+        let rules_section = if !self.picom_rules.is_empty() {
207
+            let mut rules = String::from("# Per-window Rules\nrules = (\n");
208
+            for rule in &self.picom_rules {
209
+                rules.push_str(&format!("    {{\n        match = \"{}\";\n", rule.match_expr));
210
+                if let Some(cr) = rule.corner_radius {
211
+                    rules.push_str(&format!("        corner-radius = {};\n", cr));
212
+                }
213
+                if let Some(opacity) = rule.opacity {
214
+                    rules.push_str(&format!("        opacity = {:.2};\n", opacity));
215
+                }
216
+                if let Some(shadow) = rule.shadow {
217
+                    rules.push_str(&format!("        shadow = {};\n", shadow));
218
+                }
219
+                if let Some(blur) = rule.blur_background {
220
+                    rules.push_str(&format!("        blur-background = {};\n", blur));
221
+                }
222
+                if let Some(ref shader) = rule.shader {
223
+                    let expanded = if shader.starts_with("~/") {
224
+                        if let Some(home) = dirs::home_dir() {
225
+                            home.join(&shader[2..]).to_string_lossy().to_string()
226
+                        } else {
227
+                            shader.clone()
228
+                        }
229
+                    } else {
230
+                        shader.clone()
231
+                    };
232
+                    rules.push_str(&format!("        shader = \"{}\";\n", expanded));
233
+                }
234
+                rules.push_str("    },\n");
235
+            }
236
+            rules.push_str(");");
237
+            rules
238
+        } else {
239
+            "# No per-window rules".to_string()
240
+        };
241
+
128242
         format!(
129243
             r#"# picom.conf - Auto-generated by gar window manager
130244
 # DO NOT EDIT MANUALLY - changes will be overwritten on reload
@@ -147,7 +261,7 @@ rounded-corners-exclude = [
147261
     "window_type = 'menu'",
148262
     "window_type = 'dropdown_menu'",
149263
     "window_type = 'popup_menu'",
150
-    "_NET_WM_STATE@:32a *= '_NET_WM_STATE_FULLSCREEN'"
264
+    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'"
151265
 ];
152266
 
153267
 {}
@@ -158,6 +272,12 @@ rounded-corners-exclude = [
158272
 
159273
 {}
160274
 
275
+{}
276
+
277
+{}
278
+
279
+{}
280
+
161281
 # Window Type Settings
162282
 wintypes:
163283
 {{
@@ -189,7 +309,10 @@ wintypes:
189309
             blur_section,
190310
             shadow_section,
191311
             fade_section,
192
-            opacity_section
312
+            opacity_section,
313
+            animation_section,
314
+            shader_section,
315
+            rules_section
193316
         )
194317
     }
195318
 
@@ -296,6 +419,21 @@ impl Default for Config {
296419
             opacity_unfocused: 1.0, // No unfocused dimming by default
297420
             fade_enabled: true,
298421
             fade_delta: 10,
422
+            // Border gradients disabled by default
423
+            border_gradient_enabled: false,
424
+            border_gradient_start_focused: 0x5294e2,
425
+            border_gradient_end_focused: 0x1a5fb4,
426
+            border_gradient_start_unfocused: 0x3d3d3d,
427
+            border_gradient_end_unfocused: 0x1d1d1d,
428
+            border_gradient_direction: "vertical".to_string(),
429
+            // Animations disabled by default
430
+            animation_open: "none".to_string(),
431
+            animation_close: "none".to_string(),
432
+            animation_duration: 0.2,
433
+            animation_curve: "ease-out".to_string(),
434
+            // No global shader by default
435
+            picom_shader: None,
436
+            picom_rules: Vec::new(),
299437
         }
300438
     }
301439
 }
gar/src/core/mod.rsmodified
@@ -364,8 +364,12 @@ impl WindowManager {
364364
 
365365
     /// Create a frame for a window if title bars are enabled.
366366
     /// Returns the frame window ID if created.
367
+    /// NOTE: Gradient borders via frames are disabled due to lifecycle issues.
368
+    /// Gradient support requires a different approach (compositor shaders or similar).
367369
     pub fn create_frame_for_window(&mut self, window: XWindow) -> Option<XWindow> {
368
-        if !self.config.titlebar_enabled {
370
+        let needs_titlebar = self.config.titlebar_enabled;
371
+
372
+        if !needs_titlebar {
369373
             return None;
370374
         }
371375
 
@@ -374,6 +378,7 @@ impl WindowManager {
374378
 
375379
         // Create frame with initial geometry (will be updated by apply_layout)
376380
         let screen = self.screen_rect();
381
+
377382
         let frame = match self.frames.create_frame(
378383
             &self.conn.conn,
379384
             self.conn.root,
@@ -643,16 +648,19 @@ impl WindowManager {
643648
                     .map(|w| w.urgent && Some(window) != focused)
644649
                     .unwrap_or(false);
645650
 
651
+                let is_focused = Some(window) == focused;
652
+
646653
                 let color = if is_urgent {
647654
                     urgent_color
648
-                } else if Some(window) == focused {
655
+                } else if is_focused {
649656
                     focused_color
650657
                 } else {
651658
                     unfocused_color
652659
                 };
653660
 
654
-                // If window has a frame, set border on the frame instead
655
-                if self.windows.get(&window).and_then(|w| w.frame).is_some() {
661
+                // If window has a frame, handle border on the frame
662
+                // NOTE: Gradient borders disabled - using solid colors only
663
+                if let Some(_frame) = self.windows.get(&window).and_then(|w| w.frame) {
656664
                     self.frames.set_frame_border(&self.conn.conn, window, color)?;
657665
                 } else {
658666
                     self.conn.set_border(window, border_width, color)?;
gar/src/x11/frame.rsmodified
@@ -1,7 +1,7 @@
1
-//! Frame window management for title bars.
1
+//! Frame window management for title bars and gradient borders.
22
 //!
3
-//! When title bars are enabled, client windows are reparented into frame windows.
4
-//! The frame handles the title bar drawing and the client sits below it.
3
+//! When title bars or gradient borders are enabled, client windows are reparented into frame windows.
4
+//! The frame handles the title bar drawing, gradient border rendering, and the client sits below/inside it.
55
 
66
 use std::collections::HashMap;
77
 
@@ -130,11 +130,15 @@ impl FrameManager {
130130
         if let Some(frame) = self.client_to_frame.remove(&client) {
131131
             self.frame_to_client.remove(&frame);
132132
 
133
-            // Reparent client back to root
134
-            conn.reparent_window(client, root, 0, 0)?;
133
+            // Unmap frame first to prevent visual flash of empty frame background
134
+            let _ = conn.unmap_window(frame);
135
+
136
+            // Reparent client back to root (may fail if client was already destroyed)
137
+            // Ignore errors since the client might already be gone
138
+            let _ = conn.reparent_window(client, root, 0, 0);
135139
 
136140
             // Destroy the frame window
137
-            conn.destroy_window(frame)?;
141
+            let _ = conn.destroy_window(frame);
138142
 
139143
             tracing::debug!("Destroyed frame {} for client {}", frame, client);
140144
         }
@@ -281,6 +285,346 @@ impl FrameManager {
281285
     pub fn all_frames(&self) -> impl Iterator<Item = Window> + '_ {
282286
         self.frame_to_client.keys().copied()
283287
     }
288
+
289
+    /// Draw a gradient border around the frame.
290
+    /// This draws the border area of the frame with a color gradient.
291
+    /// The `direction` can be "vertical", "horizontal", or "diagonal".
292
+    pub fn draw_gradient_border<C: X11Connection>(
293
+        &mut self,
294
+        conn: &C,
295
+        client: Window,
296
+        width: u16,
297
+        height: u16,
298
+        border_width: u16,
299
+        start_color: u32,
300
+        end_color: u32,
301
+        direction: &str,
302
+    ) -> Result<(), Error> {
303
+        let Some(&frame) = self.client_to_frame.get(&client) else {
304
+            return Ok(());
305
+        };
306
+
307
+        if border_width == 0 {
308
+            return Ok(());
309
+        }
310
+
311
+        // Ensure we have a GC
312
+        let gc = match self.gc {
313
+            Some(gc) => gc,
314
+            None => {
315
+                let gc = conn.generate_id()?;
316
+                let aux = CreateGCAux::new().foreground(start_color);
317
+                conn.create_gc(gc, frame, &aux)?;
318
+                self.gc = Some(gc);
319
+                gc
320
+            }
321
+        };
322
+
323
+        // Number of gradient steps for smooth appearance
324
+        let steps: u16 = 32.min(border_width * 2);
325
+
326
+        match direction {
327
+            "vertical" => {
328
+                // Draw gradient from top to bottom across the border areas
329
+                // We draw along the sides
330
+                let step_height = height / steps;
331
+                for i in 0..steps {
332
+                    let t = i as f32 / (steps - 1).max(1) as f32;
333
+                    let color = interpolate_color(start_color, end_color, t);
334
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
335
+
336
+                    let y = (i * step_height) as i16;
337
+                    let seg_height = step_height.min(height.saturating_sub(i * step_height));
338
+
339
+                    // Left border strip
340
+                    let left_rect = Rectangle {
341
+                        x: 0,
342
+                        y,
343
+                        width: border_width,
344
+                        height: seg_height,
345
+                    };
346
+                    // Right border strip
347
+                    let right_rect = Rectangle {
348
+                        x: (width - border_width) as i16,
349
+                        y,
350
+                        width: border_width,
351
+                        height: seg_height,
352
+                    };
353
+                    conn.poly_fill_rectangle(frame, gc, &[left_rect, right_rect])?;
354
+                }
355
+
356
+                // Top and bottom borders (full width gradient)
357
+                let step_width = width / steps;
358
+                for i in 0..steps {
359
+                    let t = i as f32 / (steps - 1).max(1) as f32;
360
+                    let color = interpolate_color(start_color, end_color, t);
361
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
362
+
363
+                    let x = (i * step_width) as i16;
364
+                    let seg_width = step_width.min(width.saturating_sub(i * step_width));
365
+
366
+                    // Top border strip
367
+                    let top_rect = Rectangle {
368
+                        x,
369
+                        y: 0,
370
+                        width: seg_width,
371
+                        height: border_width,
372
+                    };
373
+                    // Bottom border strip
374
+                    let bottom_rect = Rectangle {
375
+                        x,
376
+                        y: (height - border_width) as i16,
377
+                        width: seg_width,
378
+                        height: border_width,
379
+                    };
380
+                    conn.poly_fill_rectangle(frame, gc, &[top_rect, bottom_rect])?;
381
+                }
382
+            }
383
+            "horizontal" => {
384
+                // Draw gradient from left to right across all border areas
385
+                let step_width = width / steps;
386
+                for i in 0..steps {
387
+                    let t = i as f32 / (steps - 1).max(1) as f32;
388
+                    let color = interpolate_color(start_color, end_color, t);
389
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
390
+
391
+                    let x = (i * step_width) as i16;
392
+                    let seg_width = step_width.min(width.saturating_sub(i * step_width));
393
+
394
+                    // Top border
395
+                    let top_rect = Rectangle {
396
+                        x,
397
+                        y: 0,
398
+                        width: seg_width,
399
+                        height: border_width,
400
+                    };
401
+                    // Bottom border
402
+                    let bottom_rect = Rectangle {
403
+                        x,
404
+                        y: (height - border_width) as i16,
405
+                        width: seg_width,
406
+                        height: border_width,
407
+                    };
408
+                    conn.poly_fill_rectangle(frame, gc, &[top_rect, bottom_rect])?;
409
+                }
410
+
411
+                // Side borders
412
+                let step_height = height / steps;
413
+                for i in 0..steps {
414
+                    let t = i as f32 / (steps - 1).max(1) as f32;
415
+                    let color = interpolate_color(start_color, end_color, t);
416
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
417
+
418
+                    let y = (i * step_height) as i16;
419
+                    let seg_height = step_height.min(height.saturating_sub(i * step_height));
420
+
421
+                    // Left border
422
+                    let left_rect = Rectangle {
423
+                        x: 0,
424
+                        y,
425
+                        width: border_width,
426
+                        height: seg_height,
427
+                    };
428
+                    // Right border
429
+                    let right_rect = Rectangle {
430
+                        x: (width - border_width) as i16,
431
+                        y,
432
+                        width: border_width,
433
+                        height: seg_height,
434
+                    };
435
+                    conn.poly_fill_rectangle(frame, gc, &[left_rect, right_rect])?;
436
+                }
437
+            }
438
+            "diagonal" | _ => {
439
+                // Draw a diagonal gradient (top-left to bottom-right)
440
+                // We approximate by using the sum of x+y position
441
+                let max_dist = (width + height) as f32;
442
+
443
+                // Draw borders with diagonal gradient
444
+                // Top border
445
+                for x in 0..width {
446
+                    let t = x as f32 / max_dist;
447
+                    let color = interpolate_color(start_color, end_color, t);
448
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
449
+                    let rect = Rectangle {
450
+                        x: x as i16,
451
+                        y: 0,
452
+                        width: 1,
453
+                        height: border_width,
454
+                    };
455
+                    conn.poly_fill_rectangle(frame, gc, &[rect])?;
456
+                }
457
+                // Bottom border
458
+                for x in 0..width {
459
+                    let t = (x as f32 + (height - border_width) as f32) / max_dist;
460
+                    let color = interpolate_color(start_color, end_color, t.min(1.0));
461
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
462
+                    let rect = Rectangle {
463
+                        x: x as i16,
464
+                        y: (height - border_width) as i16,
465
+                        width: 1,
466
+                        height: border_width,
467
+                    };
468
+                    conn.poly_fill_rectangle(frame, gc, &[rect])?;
469
+                }
470
+                // Left border
471
+                for y in border_width..(height - border_width) {
472
+                    let t = y as f32 / max_dist;
473
+                    let color = interpolate_color(start_color, end_color, t);
474
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
475
+                    let rect = Rectangle {
476
+                        x: 0,
477
+                        y: y as i16,
478
+                        width: border_width,
479
+                        height: 1,
480
+                    };
481
+                    conn.poly_fill_rectangle(frame, gc, &[rect])?;
482
+                }
483
+                // Right border
484
+                for y in border_width..(height - border_width) {
485
+                    let t = ((width - border_width) as f32 + y as f32) / max_dist;
486
+                    let color = interpolate_color(start_color, end_color, t.min(1.0));
487
+                    conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(color))?;
488
+                    let rect = Rectangle {
489
+                        x: (width - border_width) as i16,
490
+                        y: y as i16,
491
+                        width: border_width,
492
+                        height: 1,
493
+                    };
494
+                    conn.poly_fill_rectangle(frame, gc, &[rect])?;
495
+                }
496
+            }
497
+        }
498
+
499
+        Ok(())
500
+    }
501
+
502
+    /// Create a border frame (frame without titlebar, just for gradient borders).
503
+    /// The frame acts as a border container with the client centered inside.
504
+    pub fn create_border_frame<C: X11Connection>(
505
+        &mut self,
506
+        conn: &C,
507
+        root: Window,
508
+        client: Window,
509
+        x: i16,
510
+        y: i16,
511
+        width: u16,
512
+        height: u16,
513
+        border_width: u16,
514
+        bg_color: u32,
515
+    ) -> Result<Window, Error> {
516
+        // Frame size includes the border on all sides
517
+        let frame_width = width + border_width * 2;
518
+        let frame_height = height + border_width * 2;
519
+
520
+        // Generate a new window ID for the frame
521
+        let frame = conn.generate_id()?;
522
+
523
+        // Create the frame window with no X11 border (we draw our own)
524
+        let aux = CreateWindowAux::new()
525
+            .event_mask(
526
+                EventMask::SUBSTRUCTURE_REDIRECT
527
+                    | EventMask::SUBSTRUCTURE_NOTIFY
528
+                    | EventMask::BUTTON_PRESS
529
+                    | EventMask::BUTTON_RELEASE
530
+                    | EventMask::ENTER_WINDOW
531
+                    | EventMask::EXPOSURE,
532
+            )
533
+            .background_pixel(bg_color)
534
+            .border_pixel(0);
535
+
536
+        conn.create_window(
537
+            COPY_DEPTH_FROM_PARENT,
538
+            frame,
539
+            root,
540
+            x,
541
+            y,
542
+            frame_width,
543
+            frame_height,
544
+            0, // No X11 border - we draw gradients ourselves
545
+            WindowClass::INPUT_OUTPUT,
546
+            0, // CopyFromParent visual
547
+            &aux,
548
+        )?;
549
+
550
+        // Reparent the client window into the frame, inset by border_width
551
+        conn.reparent_window(client, frame, border_width as i16, border_width as i16)?;
552
+
553
+        // Configure the client to fill the center of the frame
554
+        let client_aux = ConfigureWindowAux::new()
555
+            .x(border_width as i32)
556
+            .y(border_width as i32)
557
+            .width(width as u32)
558
+            .height(height as u32)
559
+            .border_width(0);
560
+        conn.configure_window(client, &client_aux)?;
561
+
562
+        // Track the mapping
563
+        self.client_to_frame.insert(client, frame);
564
+        self.frame_to_client.insert(frame, client);
565
+
566
+        tracing::debug!(
567
+            "Created border frame {} for client {} at ({}, {}) size {}x{} (border: {})",
568
+            frame, client, x, y, frame_width, frame_height, border_width
569
+        );
570
+
571
+        Ok(frame)
572
+    }
573
+
574
+    /// Configure a border frame's geometry.
575
+    pub fn configure_border_frame<C: X11Connection>(
576
+        &self,
577
+        conn: &C,
578
+        client: Window,
579
+        x: i16,
580
+        y: i16,
581
+        width: u16,
582
+        height: u16,
583
+        border_width: u16,
584
+    ) -> Result<(), Error> {
585
+        if let Some(&frame) = self.client_to_frame.get(&client) {
586
+            let frame_width = width + border_width * 2;
587
+            let frame_height = height + border_width * 2;
588
+
589
+            // Configure frame position and size
590
+            let frame_aux = ConfigureWindowAux::new()
591
+                .x(x as i32)
592
+                .y(y as i32)
593
+                .width(frame_width as u32)
594
+                .height(frame_height as u32)
595
+                .border_width(0);
596
+            conn.configure_window(frame, &frame_aux)?;
597
+
598
+            // Configure client within frame
599
+            let client_aux = ConfigureWindowAux::new()
600
+                .x(border_width as i32)
601
+                .y(border_width as i32)
602
+                .width(width as u32)
603
+                .height(height as u32);
604
+            conn.configure_window(client, &client_aux)?;
605
+        }
606
+        Ok(())
607
+    }
608
+}
609
+
610
+/// Interpolate between two colors.
611
+/// t should be in range 0.0 to 1.0.
612
+fn interpolate_color(c1: u32, c2: u32, t: f32) -> u32 {
613
+    let t = t.clamp(0.0, 1.0);
614
+
615
+    let r1 = ((c1 >> 16) & 0xFF) as f32;
616
+    let g1 = ((c1 >> 8) & 0xFF) as f32;
617
+    let b1 = (c1 & 0xFF) as f32;
618
+
619
+    let r2 = ((c2 >> 16) & 0xFF) as f32;
620
+    let g2 = ((c2 >> 8) & 0xFF) as f32;
621
+    let b2 = (c2 & 0xFF) as f32;
622
+
623
+    let r = (r1 + (r2 - r1) * t) as u32;
624
+    let g = (g1 + (g2 - g1) * t) as u32;
625
+    let b = (b1 + (b2 - b1) * t) as u32;
626
+
627
+    (r << 16) | (g << 8) | b
284628
 }
285629
 
286630
 impl Default for FrameManager {