Comparing changes

Choose two branches to see what's changed or to start a new pull request.

base: v0.3.1
Choose a base ref
v0.3.1 trunk default
compare: trunk
Choose a head ref
trunk default
Create pull request
Able to merge. These branches can be automatically merged.
24 commits 11 files changed 2 contributors

Commits on trunk

.gitignoremodified
@@ -1,3 +1,4 @@
11
 docs/
22
 target/
33
 CLAUDE.md
4
+flake.lock
examples/init.luamodified
@@ -16,7 +16,11 @@ gar.exec_once("garclip daemon --foreground")
1616
 gar.exec_once("gartray daemon")
1717
 
1818
 -- Polkit authentication agent (needed for power actions via D-Bus)
19
-gar.exec_once("/usr/libexec/kf6/polkit-kde-authentication-agent-1")
19
+gar.exec_once("garcard daemon")
20
+
21
+-- External fallback authentication agents (optional):
22
+-- gar.exec_once("/usr/libexec/kf6/polkit-kde-authentication-agent-1")
23
+-- gar.exec_once("/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1")
2024
 
2125
 -- Uncomment the ones you want:
2226
 -- gar.exec_once("picom")                      -- Compositor (for transparency/shadows)
gar-session.shmodified
@@ -52,17 +52,9 @@ systemctl --user start gar-session.target
5252
 
5353
 # ═══════════════════════════════════════════════════════════════════
5454
 
55
-# Launch compositor before WM (for proper screen repainting)
56
-# gar generates picom.conf on startup and signals picom to reload
57
-if command -v picom &> /dev/null; then
58
-    if [[ -f ~/.config/gar/picom.conf ]]; then
59
-        picom -b --config ~/.config/gar/picom.conf &
60
-    else
61
-        # First run: start with GLX backend, gar will generate config and signal reload
62
-        picom -b --backend glx &
63
-    fi
64
-    sleep 0.1
65
-fi
55
+# Compositor is now managed by gar itself via start_compositor()
56
+# based on the "compositor" setting in init.lua ("picom", "garchomp", or "none")
57
+# Do NOT start picom here - it causes dual-compositor conflicts when garchomp is selected
6658
 
6759
 # Set log level
6860
 export GAR_LOG=info
gar/config/picom.confmodified
@@ -19,7 +19,6 @@ vsync = true;
1919
 use-ewmh-active-win = true;
2020
 
2121
 # GLX backend optimizations
22
-glx-no-stencil = true;
2322
 glx-no-rebind-pixmap = true;
2423
 
2524
 # =============================================================================
gar/src/config/lua.rsmodified
@@ -383,11 +383,42 @@ impl LuaConfig {
383383
                         state.config.mouse_follows_focus = v;
384384
                     }
385385
                 }
386
+                "focus_follows_mouse" => {
387
+                    if let Value::Boolean(v) = value {
388
+                        state.config.focus_follows_mouse = v;
389
+                    }
390
+                }
386391
                 "bar_height" => {
387392
                     if let Value::Integer(v) = value {
388393
                         state.config.bar_height = v as u32;
389394
                     }
390395
                 }
396
+                // Compositor selection: "picom", "garchomp", or "none"
397
+                "compositor" => {
398
+                    if let Value::String(s) = value {
399
+                        if let Ok(str_val) = s.to_str() {
400
+                            let comp = str_val.to_lowercase();
401
+                            if comp == "picom" || comp == "garchomp" || comp == "none" {
402
+                                state.config.compositor = comp;
403
+                            } else {
404
+                                tracing::warn!("Unknown compositor '{}', using 'picom'", str_val);
405
+                            }
406
+                        }
407
+                    }
408
+                }
409
+                // Picom backend: "glx" or "xrender"
410
+                "compositor_backend" | "picom_backend" => {
411
+                    if let Value::String(s) = value {
412
+                        if let Ok(str_val) = s.to_str() {
413
+                            let backend = str_val.to_lowercase();
414
+                            if backend == "glx" || backend == "xrender" {
415
+                                state.config.picom_backend = backend;
416
+                            } else {
417
+                                tracing::warn!("Unknown compositor backend '{}', using 'glx'", str_val);
418
+                            }
419
+                        }
420
+                    }
421
+                }
391422
                 // Compositor visual settings (picom)
392423
                 "corner_radius" => {
393424
                     if let Value::Integer(v) = value {
@@ -781,13 +812,13 @@ impl LuaConfig {
781812
                 rule.opacity = Some(op);
782813
             }
783814
 
784
-            // Optional: shadow
785
-            if let Ok(shadow) = table.get::<bool>("shadow") {
815
+            // Optional: shadow (use Option<bool> so nil doesn't become false)
816
+            if let Ok(Some(shadow)) = table.get::<Option<bool>>("shadow") {
786817
                 rule.shadow = Some(shadow);
787818
             }
788819
 
789
-            // Optional: blur_background
790
-            if let Ok(blur) = table.get::<bool>("blur_background") {
820
+            // Optional: blur_background (use Option<bool> so nil doesn't become false)
821
+            if let Ok(Some(blur)) = table.get::<Option<bool>>("blur_background") {
791822
                 rule.blur_background = Some(blur);
792823
             }
793824
 
gar/src/config/mod.rsmodified
@@ -31,6 +31,7 @@ pub struct Config {
3131
     // Behavior settings
3232
     pub follow_window_on_move: bool,
3333
     pub mouse_follows_focus: bool,
34
+    pub focus_follows_mouse: bool,
3435
     // Manual bar/panel reserved space (overrides struts)
3536
     pub bar_height: u32,
3637
     // garbar integration: spawn garbar automatically if gar.bar is configured
@@ -43,6 +44,10 @@ pub struct Config {
4344
     // Screen timeout/DPMS settings
4445
     pub screen_timeout_enabled: bool,
4546
     pub screen_timeout_seconds: u32,
47
+    // Compositor selection: "picom" (default), "garchomp", or "none"
48
+    pub compositor: String,
49
+    // Picom backend: "glx" (GPU) or "xrender" (CPU, safer on NVIDIA)
50
+    pub picom_backend: String,
4651
     // Compositor visual settings (picom)
4752
     // These are stored for reference and potential dynamic picom config generation
4853
     pub corner_radius: u32,
@@ -79,23 +84,26 @@ impl Config {
7984
     /// Generate picom.conf content from current config settings.
8085
     pub fn generate_picom_config(&self) -> String {
8186
         let blur_section = if self.blur_enabled {
87
+            // dual_kawase requires GLX backend; fall back to kernel blur on xrender
88
+            let (blur_method, blur_strength) = if self.picom_backend == "xrender"
89
+                && self.blur_method == "dual_kawase"
90
+            {
91
+                tracing::info!(
92
+                    "Switching blur from dual_kawase to kernel (xrender backend doesn't support dual_kawase)"
93
+                );
94
+                ("kernel".to_string(), self.blur_strength)
95
+            } else {
96
+                (self.blur_method.clone(), self.blur_strength)
97
+            };
98
+
8299
             format!(
83100
                 r#"# Blur
84101
 blur-method = "{}";
85102
 blur-strength = {};
86103
 blur-background = true;
87104
 blur-background-frame = false;
88
-blur-kern = "3x3box";
89
-
90
-blur-background-exclude = [
91
-    "window_type = 'dock'",
92
-    "window_type = 'desktop'",
93
-    "window_type = 'menu'",
94
-    "window_type = 'dropdown_menu'",
95
-    "window_type = 'popup_menu'",
96
-    "_NET_WM_BYPASS_COMPOSITOR = 1"
97
-];"#,
98
-                self.blur_method, self.blur_strength
105
+blur-kern = "3x3box";"#,
106
+                blur_method, blur_strength
99107
             )
100108
         } else {
101109
             "# Blur disabled".to_string()
@@ -108,18 +116,7 @@ shadow = true;
108116
 shadow-radius = {};
109117
 shadow-opacity = {:.2};
110118
 shadow-offset-x = {};
111
-shadow-offset-y = {};
112
-
113
-shadow-exclude = [
114
-    "window_type = 'dock'",
115
-    "window_type = 'desktop'",
116
-    "window_type = 'menu'",
117
-    "window_type = 'dropdown_menu'",
118
-    "window_type = 'popup_menu'",
119
-    "window_type = 'tooltip'",
120
-    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'",
121
-    "_NET_WM_BYPASS_COMPOSITOR = 1"
122
-];"#,
119
+shadow-offset-y = {};"#,
123120
                 self.shadow_radius,
124121
                 self.shadow_opacity,
125122
                 self.shadow_offset_x,
@@ -137,13 +134,7 @@ fade-in-step = 0.028;
137134
 fade-out-step = 0.03;
138135
 fade-delta = {};
139136
 
140
-no-fading-destroyed-argb = true;
141
-
142
-fade-exclude = [
143
-    "window_type = 'menu'",
144
-    "window_type = 'dropdown_menu'",
145
-    "window_type = 'popup_menu'"
146
-];"#,
137
+no-fading-destroyed-argb = true;"#,
147138
                 self.fade_delta
148139
             )
149140
         } else {
@@ -213,42 +204,105 @@ animations = ({{
213204
             "# No custom shader".to_string()
214205
         };
215206
 
216
-        // Per-window rules section
217
-        let rules_section = if !self.picom_rules.is_empty() {
218
-            let mut rules = String::from("# Per-window Rules\nrules = (\n");
219
-            for rule in &self.picom_rules {
220
-                rules.push_str(&format!("    {{\n        match = \"{}\";\n", rule.match_expr));
221
-                if let Some(cr) = rule.corner_radius {
222
-                    rules.push_str(&format!("        corner-radius = {};\n", cr));
223
-                }
224
-                if let Some(opacity) = rule.opacity {
225
-                    rules.push_str(&format!("        opacity = {:.2};\n", opacity));
226
-                }
227
-                if let Some(shadow) = rule.shadow {
228
-                    rules.push_str(&format!("        shadow = {};\n", shadow));
229
-                }
230
-                if let Some(blur) = rule.blur_background {
231
-                    rules.push_str(&format!("        blur-background = {};\n", blur));
232
-                }
233
-                if let Some(ref shader) = rule.shader {
234
-                    let expanded = if shader.starts_with("~/") {
235
-                        if let Some(home) = dirs::home_dir() {
236
-                            home.join(&shader[2..]).to_string_lossy().to_string()
237
-                        } else {
238
-                            shader.clone()
239
-                        }
207
+        // Unified rules section (picom v13 format)
208
+        // Replaces deprecated: shadow-exclude, fade-exclude, blur-background-exclude,
209
+        // rounded-corners-exclude, and wintypes blocks
210
+        let mut rules = String::from("# Rules (picom v13 format)\nrules = (\n");
211
+
212
+        // Built-in window type rules
213
+        rules.push_str(r#"    {
214
+        match = "window_type = 'dock'";
215
+        shadow = false;
216
+        corner-radius = 0;
217
+        blur-background = false;
218
+        clip-shadow-above = true;
219
+    },
220
+    {
221
+        match = "window_type = 'desktop'";
222
+        shadow = false;
223
+        corner-radius = 0;
224
+        blur-background = false;
225
+    },
226
+    {
227
+        match = "window_type = 'tooltip'";
228
+        shadow = false;
229
+        corner-radius = 0;
230
+        blur-background = false;
231
+        fade = true;
232
+        opacity = 0.95;
233
+        focus = true;
234
+    },
235
+    {
236
+        match = "window_type = 'menu'";
237
+        shadow = false;
238
+        corner-radius = 0;
239
+        blur-background = false;
240
+        fade = false;
241
+    },
242
+    {
243
+        match = "window_type = 'dropdown_menu'";
244
+        shadow = false;
245
+        corner-radius = 0;
246
+        blur-background = false;
247
+        fade = false;
248
+        opacity = 0.95;
249
+    },
250
+    {
251
+        match = "window_type = 'popup_menu'";
252
+        shadow = false;
253
+        corner-radius = 0;
254
+        blur-background = false;
255
+        fade = false;
256
+        opacity = 0.95;
257
+    },
258
+    {
259
+        match = "window_type = 'dnd'";
260
+        shadow = false;
261
+    },
262
+    {
263
+        match = "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'";
264
+        corner-radius = 0;
265
+        shadow = false;
266
+    },
267
+    {
268
+        match = "_NET_WM_BYPASS_COMPOSITOR = 1";
269
+        shadow = false;
270
+        blur-background = false;
271
+    },
272
+"#);
273
+
274
+        // User custom picom rules
275
+        for rule in &self.picom_rules {
276
+            rules.push_str(&format!("    {{\n        match = \"{}\";\n", rule.match_expr));
277
+            if let Some(cr) = rule.corner_radius {
278
+                rules.push_str(&format!("        corner-radius = {};\n", cr));
279
+            }
280
+            if let Some(opacity) = rule.opacity {
281
+                rules.push_str(&format!("        opacity = {:.2};\n", opacity));
282
+            }
283
+            if let Some(shadow) = rule.shadow {
284
+                rules.push_str(&format!("        shadow = {};\n", shadow));
285
+            }
286
+            if let Some(blur) = rule.blur_background {
287
+                rules.push_str(&format!("        blur-background = {};\n", blur));
288
+            }
289
+            if let Some(ref shader) = rule.shader {
290
+                let expanded = if shader.starts_with("~/") {
291
+                    if let Some(home) = dirs::home_dir() {
292
+                        home.join(&shader[2..]).to_string_lossy().to_string()
240293
                     } else {
241294
                         shader.clone()
242
-                    };
243
-                    rules.push_str(&format!("        shader = \"{}\";\n", expanded));
244
-                }
245
-                rules.push_str("    },\n");
295
+                    }
296
+                } else {
297
+                    shader.clone()
298
+                };
299
+                rules.push_str(&format!("        shader = \"{}\";\n", expanded));
246300
             }
247
-            rules.push_str(");");
248
-            rules
249
-        } else {
250
-            "# No per-window rules".to_string()
251
-        };
301
+            rules.push_str("    },\n");
302
+        }
303
+
304
+        rules.push_str(");");
305
+        let rules_section = rules;
252306
 
253307
         format!(
254308
             r#"# picom.conf - Auto-generated by gar window manager
@@ -256,24 +310,13 @@ animations = ({{
256310
 # Edit ~/.config/gar/init.lua instead and reload with Mod+Shift+R
257311
 
258312
 # Backend Configuration
259
-backend = "glx";
313
+backend = "{}";
260314
 vsync = true;
261315
 use-ewmh-active-win = true;
262
-glx-no-stencil = true;
263316
 
264317
 # Rounded Corners
265318
 corner-radius = {};
266319
 
267
-rounded-corners-exclude = [
268
-    "window_type = 'dock'",
269
-    "window_type = 'desktop'",
270
-    "window_type = 'tooltip'",
271
-    "window_type = 'menu'",
272
-    "window_type = 'dropdown_menu'",
273
-    "window_type = 'popup_menu'",
274
-    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'"
275
-];
276
-
277320
 {}
278321
 
279322
 {}
@@ -287,34 +330,8 @@ rounded-corners-exclude = [
287330
 {}
288331
 
289332
 {}
290
-
291
-# Window Type Settings
292
-wintypes:
293
-{{
294
-    tooltip = {{
295
-        fade = true;
296
-        shadow = false;
297
-        opacity = 0.95;
298
-        focus = true;
299
-        blur-background = false;
300
-    }};
301
-    dock = {{
302
-        shadow = false;
303
-        clip-shadow-above = true;
304
-    }};
305
-    dnd = {{
306
-        shadow = false;
307
-    }};
308
-    popup_menu = {{
309
-        opacity = 0.95;
310
-        shadow = false;
311
-    }};
312
-    dropdown_menu = {{
313
-        opacity = 0.95;
314
-        shadow = false;
315
-    }};
316
-}};
317333
 "#,
334
+            self.picom_backend,
318335
             self.corner_radius,
319336
             blur_section,
320337
             shadow_section,
@@ -326,8 +343,15 @@ wintypes:
326343
         )
327344
     }
328345
 
329
-    /// Write picom config to ~/.config/gar/picom.conf and signal picom to reload.
346
+    /// Write picom config to ~/.config/gar/picom.conf and optionally restart picom.
347
+    /// Only writes/restarts if compositor is set to "picom".
330348
     pub fn write_picom_config(&self) -> std::io::Result<()> {
349
+        // Only generate picom config if using picom
350
+        if self.compositor != "picom" {
351
+            tracing::debug!("Skipping picom config (compositor={})", self.compositor);
352
+            return Ok(());
353
+        }
354
+
331355
         let config_dir = dirs::config_dir()
332356
             .ok_or_else(|| std::io::Error::new(
333357
                 std::io::ErrorKind::NotFound,
@@ -350,63 +374,110 @@ wintypes:
350374
         Ok(())
351375
     }
352376
 
353
-    /// Apply screen timeout/DPMS settings using xset
354
-    pub fn apply_screen_timeout(&self) {
377
+    /// Start the configured compositor.
378
+    /// Called on gar startup to launch the appropriate compositor.
379
+    pub fn start_compositor(&self) {
355380
         use std::process::Command;
356381
 
357
-        if self.screen_timeout_enabled {
358
-            // Enable DPMS and set timeout
359
-            let timeout = self.screen_timeout_seconds.to_string();
360
-            match Command::new("xset")
361
-                .args(["dpms", &timeout, &timeout, &timeout])
362
-                .status()
363
-            {
364
-                Ok(status) if status.success() => {
365
-                    tracing::info!("Set DPMS timeout to {} seconds", self.screen_timeout_seconds);
382
+        match self.compositor.as_str() {
383
+            "picom" => {
384
+                // Generate picom config first
385
+                if let Err(e) = self.write_picom_config() {
386
+                    tracing::warn!("Failed to write picom config: {}", e);
387
+                }
388
+                // picom will be started by write_picom_config -> reload_picom
389
+            }
390
+            "garchomp" => {
391
+                // Kill any existing compositor first (use -f for NixOS wrappers)
392
+                let _ = Command::new("pkill").args(["-f", "picom"]).status();
393
+                let _ = Command::new("pkill").args(["-f", "garchomp"]).status();
394
+
395
+                std::thread::sleep(std::time::Duration::from_millis(100));
396
+
397
+                // Start garchomp
398
+                match Command::new("garchomp").spawn() {
399
+                    Ok(_) => {
400
+                        tracing::info!("Started garchomp compositor");
401
+                    }
402
+                    Err(e) => {
403
+                        tracing::error!("Failed to start garchomp: {}", e);
404
+                        // Fall back to picom
405
+                        tracing::info!("Falling back to picom");
406
+                        Self::reload_picom();
407
+                    }
408
+                }
409
+            }
410
+            "none" => {
411
+                tracing::info!("Compositor disabled (compositor=none)");
412
+                // Kill any running compositor (use -f for NixOS wrappers)
413
+                let _ = Command::new("pkill").args(["-f", "picom"]).status();
414
+                let _ = Command::new("pkill").args(["-f", "garchomp"]).status();
415
+            }
416
+            _ => {
417
+                tracing::warn!("Unknown compositor '{}', defaulting to picom", self.compositor);
418
+                if let Err(e) = self.write_picom_config() {
419
+                    tracing::warn!("Failed to write picom config: {}", e);
366420
                 }
367
-                Ok(_) => tracing::warn!("xset dpms command failed"),
368
-                Err(e) => tracing::warn!("Failed to run xset: {}", e),
369421
             }
422
+        }
423
+    }
424
+
425
+    /// Stop any running compositor.
426
+    pub fn stop_compositor() {
427
+        use std::process::Command;
428
+        // Use -f to match full command line (needed for NixOS wrappers)
429
+        let _ = Command::new("pkill").args(["-f", "picom"]).status();
430
+        let _ = Command::new("pkill").args(["-f", "garchomp"]).status();
431
+    }
432
+
433
+    /// Apply screen timeout settings using xset.
434
+    ///
435
+    /// Always disables hardware DPMS (causes Xid 79 GPU crashes on NVIDIA with
436
+    /// multi-monitor HDMI setups). Uses the X11 screen saver extension for software
437
+    /// blanking instead, which draws black without hardware power state changes.
438
+    pub fn apply_screen_timeout(&self) {
439
+        use std::process::Command;
440
+
441
+        // Always disable hardware DPMS - it sends power state changes to monitors
442
+        // via the NVIDIA driver, which can crash the GPU (Xid 79) when HDMI displays
443
+        // have unreliable EDID links
444
+        match Command::new("xset").args(["dpms", "0", "0", "0"]).status() {
445
+            Ok(status) if status.success() => {
446
+                tracing::debug!("Disabled hardware DPMS timeouts");
447
+            }
448
+            Ok(_) => tracing::warn!("xset dpms 0 command failed"),
449
+            Err(e) => tracing::warn!("Failed to run xset: {}", e),
450
+        }
451
+        match Command::new("xset").args(["-dpms"]).status() {
452
+            Ok(_) => {}
453
+            Err(e) => tracing::warn!("Failed to disable DPMS: {}", e),
454
+        }
370455
 
371
-            // Enable screen saver with same timeout
456
+        if self.screen_timeout_enabled {
457
+            // Use X11 screen saver for software blanking (safe, no hardware power changes)
458
+            let timeout = self.screen_timeout_seconds.to_string();
372459
             match Command::new("xset")
373460
                 .args(["s", &timeout, &timeout])
374461
                 .status()
375462
             {
376463
                 Ok(status) if status.success() => {
377
-                    tracing::debug!("Set screen saver timeout to {} seconds", self.screen_timeout_seconds);
464
+                    tracing::info!(
465
+                        "Screen blanking enabled via X11 screen saver ({} seconds)",
466
+                        self.screen_timeout_seconds
467
+                    );
378468
                 }
379469
                 Ok(_) => tracing::warn!("xset s command failed"),
380470
                 Err(e) => tracing::warn!("Failed to run xset: {}", e),
381471
             }
382
-
383
-            // Make sure DPMS is enabled
384
-            match Command::new("xset").args(["+dpms"]).status() {
385
-                Ok(_) => {}
386
-                Err(e) => tracing::warn!("Failed to enable DPMS: {}", e),
387
-            }
388472
         } else {
389
-            // Disable DPMS and screen saver
390
-            match Command::new("xset").args(["dpms", "0", "0", "0"]).status() {
391
-                Ok(status) if status.success() => {
392
-                    tracing::info!("Disabled DPMS timeout");
393
-                }
394
-                Ok(_) => tracing::warn!("xset dpms 0 command failed"),
395
-                Err(e) => tracing::warn!("Failed to run xset: {}", e),
396
-            }
397
-
473
+            // Disable screen saver blanking too
398474
             match Command::new("xset").args(["s", "off"]).status() {
399475
                 Ok(status) if status.success() => {
400
-                    tracing::debug!("Disabled screen saver");
476
+                    tracing::info!("Screen blanking disabled");
401477
                 }
402478
                 Ok(_) => tracing::warn!("xset s off command failed"),
403479
                 Err(e) => tracing::warn!("Failed to run xset: {}", e),
404480
             }
405
-
406
-            match Command::new("xset").args(["-dpms"]).status() {
407
-                Ok(_) => {}
408
-                Err(e) => tracing::warn!("Failed to disable DPMS: {}", e),
409
-            }
410481
         }
411482
     }
412483
 
@@ -474,6 +545,8 @@ impl Default for Config {
474545
             follow_window_on_move: false,
475546
             // Behavior: warp mouse pointer to center of focused window
476547
             mouse_follows_focus: false,
548
+            // Behavior: focus window when mouse enters it
549
+            focus_follows_mouse: true,
477550
             // Manual bar height (0 = use struts from dock windows)
478551
             bar_height: 0,
479552
             // garbar not enabled by default (enabled when gar.bar table is set)
@@ -482,9 +555,14 @@ impl Default for Config {
482555
             notification_enabled: false,
483556
             // Monitor order: empty = sort by X position
484557
             monitor_order: Vec::new(),
485
-            // Screen timeout: enabled by default with 10 minute timeout
486
-            screen_timeout_enabled: true,
558
+            // Screen timeout: disabled by default - X11 screen saver blanking triggers
559
+            // Xid 79 GPU crashes on NVIDIA with multi-monitor HDMI setups
560
+            screen_timeout_enabled: false,
487561
             screen_timeout_seconds: 600,
562
+            // Compositor selection: "picom" (default), "garchomp", or "none"
563
+            compositor: "picom".to_string(),
564
+            // Picom backend: "glx" (GPU) or "xrender" (CPU, safer on NVIDIA)
565
+            picom_backend: "glx".to_string(),
488566
             // Compositor settings (picom) - matching picom.conf defaults
489567
             corner_radius: 12,
490568
             blur_enabled: true,
gar/src/core/mod.rsmodified
@@ -59,7 +59,7 @@ pub struct WindowManager {
5959
 
6060
 impl WindowManager {
6161
     pub fn new(conn: Connection) -> Result<Self> {
62
-        let workspaces: Vec<Workspace> = (1..=10)
62
+        let mut workspaces: Vec<Workspace> = (1..=10)
6363
             .map(|i| Workspace::new(i, i.to_string()))
6464
             .collect();
6565
 
@@ -75,10 +75,8 @@ impl WindowManager {
7575
         // Get config values from Lua state
7676
         let config = lua_state.lock().unwrap().config.clone();
7777
 
78
-        // Generate picom config from settings
79
-        if let Err(e) = config.write_picom_config() {
80
-            tracing::warn!("Failed to generate picom config: {}", e);
81
-        }
78
+        // Start the configured compositor (picom, garchomp, or none)
79
+        config.start_compositor();
8280
 
8381
         // Apply screen timeout/DPMS settings
8482
         config.apply_screen_timeout();
@@ -133,6 +131,9 @@ impl WindowManager {
133131
         for (i, monitor) in monitors.iter_mut().enumerate() {
134132
             monitor.workspaces = vec![i]; // Just track initial workspace
135133
             monitor.active_workspace = i; // Monitor 0 shows ws 0, monitor 1 shows ws 1, etc.
134
+            if i < workspaces.len() {
135
+                workspaces[i].last_monitor = Some(i);
136
+            }
136137
             tracing::debug!("Monitor '{}' starts with workspace {}", monitor.name, i + 1);
137138
         }
138139
 
@@ -294,6 +295,9 @@ impl WindowManager {
294295
     pub fn refresh_monitors(&mut self) -> Result<()> {
295296
         tracing::info!("Refreshing monitor configuration");
296297
 
298
+        // Update cached screen dimensions (may have changed due to rotation)
299
+        self.conn.update_screen_size();
300
+
297301
         let mut new_monitors = self.conn.detect_monitors().unwrap_or_else(|e| {
298302
             tracing::warn!("Failed to detect monitors: {}, keeping current", e);
299303
             return self.monitors.clone();
@@ -330,6 +334,9 @@ impl WindowManager {
330334
                 monitor.workspaces = vec![first_free];
331335
                 used_workspaces.insert(first_free);
332336
             }
337
+            if monitor.active_workspace < self.workspaces.len() {
338
+                self.workspaces[monitor.active_workspace].last_monitor = Some(i);
339
+            }
333340
             tracing::info!("Monitor '{}' showing workspace {}", monitor.name, monitor.active_workspace + 1);
334341
         }
335342
 
@@ -825,6 +832,11 @@ impl WindowManager {
825832
         // Update borders for all windows on visible workspaces
826833
         for ws_idx in visible_ws {
827834
             for window in self.workspaces[ws_idx].all_windows() {
835
+                // Fullscreen windows have no borders
836
+                if self.windows.get(&window).map(|w| w.fullscreen).unwrap_or(false) {
837
+                    continue;
838
+                }
839
+
828840
                 // Check if window is urgent (and not focused - focused clears urgency)
829841
                 let is_urgent = self.windows.get(&window)
830842
                     .map(|w| w.urgent && Some(window) != focused)
@@ -904,19 +916,42 @@ impl WindowManager {
904916
                     fs_window, screen
905917
                 );
906918
 
907
-                // Configure fullscreen window to cover entire monitor (no gaps, no borders)
908
-                self.conn.configure_window(
909
-                    fs_window,
910
-                    screen.x,
911
-                    screen.y,
912
-                    screen.width,
913
-                    screen.height,
914
-                    0, // No border for fullscreen
915
-                )?;
919
+                if let Some(frame) = self.frames.frame_for_client(fs_window) {
920
+                    // Window has a frame — position frame at monitor origin with no border,
921
+                    // and configure client to fill the entire frame.
922
+                    let frame_aux = ConfigureWindowAux::new()
923
+                        .x(screen.x as i32)
924
+                        .y(screen.y as i32)
925
+                        .width(screen.width as u32)
926
+                        .height(screen.height as u32)
927
+                        .border_width(0);
928
+                    self.conn.conn.configure_window(frame, &frame_aux)?;
929
+
930
+                    let client_aux = ConfigureWindowAux::new()
931
+                        .x(0)
932
+                        .y(0)
933
+                        .width(screen.width as u32)
934
+                        .height(screen.height as u32)
935
+                        .border_width(0);
936
+                    self.conn.conn.configure_window(fs_window, &client_aux)?;
937
+
938
+                    // Raise frame above everything
939
+                    let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
940
+                    self.conn.conn.configure_window(frame, &aux)?;
941
+                } else {
942
+                    // No frame — configure client directly
943
+                    self.conn.configure_window(
944
+                        fs_window,
945
+                        screen.x,
946
+                        screen.y,
947
+                        screen.width,
948
+                        screen.height,
949
+                        0,
950
+                    )?;
916951
 
917
-                // Raise fullscreen window above everything
918
-                let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
919
-                self.conn.conn.configure_window(fs_window, &aux)?;
952
+                    let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
953
+                    self.conn.conn.configure_window(fs_window, &aux)?;
954
+                }
920955
 
921956
                 // Skip normal layout for this workspace - fullscreen window covers everything
922957
                 continue;
@@ -976,6 +1011,12 @@ impl WindowManager {
9761011
                         border_width as u16,
9771012
                     )?;
9781013
 
1014
+                    // Ensure tiled frame is below floating windows by stacking at bottom
1015
+                    if let Some(frame) = self.frames.frame_for_client(*window) {
1016
+                        let aux = ConfigureWindowAux::new().stack_mode(StackMode::BELOW);
1017
+                        self.conn.conn.configure_window(frame, &aux)?;
1018
+                    }
1019
+
9791020
                     tracing::debug!(
9801021
                         "apply_layout: TILED+FRAME window={} at ({}, {}) size {}x{} (titlebar: {})",
9811022
                         window, gapped_x, gapped_y, final_width.max(1), final_height.max(1), titlebar_height
@@ -994,6 +1035,10 @@ impl WindowManager {
9941035
                         final_height.max(1),
9951036
                         border_width,
9961037
                     )?;
1038
+
1039
+                    // Ensure tiled window is below floating windows
1040
+                    let aux = ConfigureWindowAux::new().stack_mode(StackMode::BELOW);
1041
+                    self.conn.conn.configure_window(*window, &aux)?;
9971042
                 }
9981043
 
9991044
                 // Store the actual geometry for pointer warping
@@ -1036,18 +1081,17 @@ impl WindowManager {
10361081
                     if let Some(frame) = self.frames.frame_for_client(window_id) {
10371082
                         let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
10381083
                         self.conn.conn.configure_window(frame, &aux)?;
1084
+                        tracing::info!(
1085
+                            "apply_layout: FLOATING+FRAME window={} frame={} raised ABOVE (at ({}, {}) size {}x{})",
1086
+                            window_id, frame, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
1087
+                        );
1088
+                    } else {
1089
+                        tracing::warn!(
1090
+                            "apply_layout: FLOATING window={} has_frame=true but no frame found!",
1091
+                            window_id
1092
+                        );
10391093
                     }
1040
-
1041
-                    tracing::debug!(
1042
-                        "apply_layout: FLOATING+FRAME window={} at ({}, {}) size {}x{} (raising)",
1043
-                        window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
1044
-                    );
10451094
                 } else {
1046
-                    tracing::debug!(
1047
-                        "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
1048
-                        window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
1049
-                    );
1050
-
10511095
                     // Configure geometry
10521096
                     self.conn.configure_window(
10531097
                         window_id,
@@ -1061,6 +1105,11 @@ impl WindowManager {
10611105
                     // Raise to top of stack (each subsequent window goes above the previous)
10621106
                     let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
10631107
                     self.conn.conn.configure_window(window_id, &aux)?;
1108
+
1109
+                    tracing::info!(
1110
+                        "apply_layout: FLOATING window={} raised ABOVE (at ({}, {}) size {}x{})",
1111
+                        window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
1112
+                    );
10641113
                 }
10651114
 
10661115
                 // Store the actual geometry for pointer warping
gar/src/core/workspace.rsmodified
@@ -11,6 +11,8 @@ pub struct Workspace {
1111
     pub floating: Vec<XWindow>,
1212
     pub focused: Option<XWindow>,
1313
     pub visible: bool,
14
+    /// Last monitor this workspace was displayed on (for focus-back behavior)
15
+    pub last_monitor: Option<usize>,
1416
 }
1517
 
1618
 impl Workspace {
@@ -22,6 +24,7 @@ impl Workspace {
2224
             floating: Vec::new(),
2325
             focused: None,
2426
             visible: id == 1,
27
+            last_monitor: None,
2528
         }
2629
     }
2730
 
gar/src/x11/connection.rsmodified
@@ -65,6 +65,7 @@ pub struct Connection {
6565
     pub net_wm_state: Atom,
6666
     pub net_wm_state_modal: Atom,
6767
     pub net_wm_state_fullscreen: Atom,
68
+    pub net_wm_state_above: Atom,
6869
     // EWMH atoms for workspaces
6970
     pub net_supported: Atom,
7071
     pub net_supporting_wm_check: Atom,
@@ -133,6 +134,7 @@ impl Connection {
133134
         let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
134135
         let net_wm_state_modal = conn.intern_atom(false, b"_NET_WM_STATE_MODAL")?.reply()?.atom;
135136
         let net_wm_state_fullscreen = conn.intern_atom(false, b"_NET_WM_STATE_FULLSCREEN")?.reply()?.atom;
137
+        let net_wm_state_above = conn.intern_atom(false, b"_NET_WM_STATE_ABOVE")?.reply()?.atom;
136138
 
137139
         // Intern EWMH atoms for workspaces and WM identification
138140
         let net_supported = conn.intern_atom(false, b"_NET_SUPPORTED")?.reply()?.atom;
@@ -199,6 +201,7 @@ impl Connection {
199201
             net_wm_state,
200202
             net_wm_state_modal,
201203
             net_wm_state_fullscreen,
204
+            net_wm_state_above,
202205
             net_supported,
203206
             net_supporting_wm_check,
204207
             net_client_list,
@@ -241,7 +244,9 @@ impl Connection {
241244
                 EventMask::SUBSTRUCTURE_REDIRECT
242245
                     | EventMask::SUBSTRUCTURE_NOTIFY
243246
                     | EventMask::STRUCTURE_NOTIFY
244
-                    | EventMask::PROPERTY_CHANGE,
247
+                    | EventMask::PROPERTY_CHANGE
248
+                    | EventMask::ENTER_WINDOW
249
+                    | EventMask::POINTER_MOTION,
245250
             )
246251
             .background_pixel(self.screen().black_pixel)
247252
             .cursor(self.cursor_normal);
@@ -769,7 +774,7 @@ impl Connection {
769774
             }
770775
         }
771776
 
772
-        // 3. Check _NET_WM_STATE for modal windows
777
+        // 3. Check _NET_WM_STATE for modal or above windows
773778
         if let Ok(cookie) = self.conn.get_property(
774779
             false,
775780
             window,
@@ -786,6 +791,10 @@ impl Connection {
786791
                             tracing::debug!("Window {} is modal, should float", window);
787792
                             return true;
788793
                         }
794
+                        if atom == self.net_wm_state_above {
795
+                            tracing::debug!("Window {} has ABOVE state, should float", window);
796
+                            return true;
797
+                        }
789798
                     }
790799
                 }
791800
             }
@@ -1067,6 +1076,8 @@ impl Connection {
10671076
             self.net_close_window,
10681077
             self.net_wm_state,
10691078
             self.net_wm_state_fullscreen,
1079
+            self.net_wm_state_modal,
1080
+            self.net_wm_state_above,
10701081
             self.net_wm_name,
10711082
         ];
10721083
         self.conn.change_property32(
@@ -1327,6 +1338,38 @@ impl Connection {
13271338
         )?;
13281339
         Ok(())
13291340
     }
1341
+
1342
+    /// Update cached screen dimensions using RandR query.
1343
+    /// Called after RandR events when we don't have the new size from the event.
1344
+    pub fn update_screen_size(&mut self) {
1345
+        use x11rb::protocol::randr::ConnectionExt as RandrExt;
1346
+
1347
+        // Query current screen size from RandR
1348
+        if let Ok(reply) = self.conn.randr_get_screen_info(self.root) {
1349
+            if let Ok(info) = reply.reply() {
1350
+                // Get the size from the current rotation/size index
1351
+                if let Some(size) = info.sizes.get(info.size_id as usize) {
1352
+                    let (new_w, new_h) = if info.rotation.contains(x11rb::protocol::randr::Rotation::ROTATE90)
1353
+                        || info.rotation.contains(x11rb::protocol::randr::Rotation::ROTATE270)
1354
+                    {
1355
+                        // Rotated 90 or 270 - swap dimensions
1356
+                        (size.height, size.width)
1357
+                    } else {
1358
+                        (size.width, size.height)
1359
+                    };
1360
+
1361
+                    if new_w != self.screen_width || new_h != self.screen_height {
1362
+                        tracing::info!(
1363
+                            "Screen size updated: {}x{} -> {}x{}",
1364
+                            self.screen_width, self.screen_height, new_w, new_h
1365
+                        );
1366
+                        self.screen_width = new_w;
1367
+                        self.screen_height = new_h;
1368
+                    }
1369
+                }
1370
+            }
1371
+        }
1372
+    }
13301373
 }
13311374
 
13321375
 impl std::ops::Deref for Connection {
gar/src/x11/events.rsmodified
@@ -574,8 +574,15 @@ impl WindowManager {
574574
             Event::EnterNotify(e) => {
575575
                 self.handle_enter_notify(e)?;
576576
             }
577
-            Event::RandrScreenChangeNotify(_) => {
578
-                tracing::info!("RandR screen change detected, refreshing monitors");
577
+            Event::RandrScreenChangeNotify(e) => {
578
+                tracing::info!(
579
+                    "RandR screen change: {}x{} -> {}x{}",
580
+                    self.conn.screen_width, self.conn.screen_height,
581
+                    e.width, e.height
582
+                );
583
+                // Update cached screen dimensions from the event
584
+                self.conn.screen_width = e.width;
585
+                self.conn.screen_height = e.height;
579586
                 self.refresh_monitors()?;
580587
                 self.broadcast_i3_output_event();
581588
             }
@@ -693,9 +700,10 @@ impl WindowManager {
693700
         // Flush to ensure ConfigureWindow requests are processed before we query geometry
694701
         self.conn.flush()?;
695702
 
696
-        // Focus the new window if on a visible workspace
703
+        // Focus and raise the new window if on a visible workspace
697704
         if target_visible {
698705
             self.set_focus(window, true)?;
706
+            self.raise_window(window)?;
699707
         }
700708
 
701709
         Ok(())
@@ -1058,8 +1066,11 @@ impl WindowManager {
10581066
                 self.conn.flush()?;
10591067
                 return Ok(());
10601068
             }
1061
-            // Not on edge - if this is a focused floating window, replay the click
1069
+            // Not on edge - if this is a focused floating window, raise it and replay the click
1070
+            // This ensures clicking on a floating window that somehow ended up behind
1071
+            // other windows will bring it to the front
10621072
             if self.focused_window == Some(window) {
1073
+                self.raise_window(window)?;
10631074
                 self.conn.conn.allow_events(
10641075
                     x11rb::protocol::xproto::Allow::REPLAY_POINTER,
10651076
                     x11rb::CURRENT_TIME,
@@ -1201,13 +1212,30 @@ impl WindowManager {
12011212
                     // Check for tiled edge hover (for cursor feedback)
12021213
                     self.update_tiled_edge_cursor(window, event.root_x, event.root_y)?;
12031214
                 }
1204
-            } else if self.tiled_edge_cursor.is_some() {
1205
-                // Moving to unmanaged window or root - clear tiled edge cursor
1206
-                let (old_w1, old_w2, _) = self.tiled_edge_cursor.unwrap();
1207
-                self.conn.clear_window_cursor(old_w1)?;
1208
-                self.conn.clear_window_cursor(old_w2)?;
1209
-                self.conn.flush()?;
1210
-                self.tiled_edge_cursor = None;
1215
+            } else {
1216
+                if self.tiled_edge_cursor.is_some() {
1217
+                    // Moving to unmanaged window or root - clear tiled edge cursor
1218
+                    let (old_w1, old_w2, _) = self.tiled_edge_cursor.unwrap();
1219
+                    self.conn.clear_window_cursor(old_w1)?;
1220
+                    self.conn.clear_window_cursor(old_w2)?;
1221
+                    self.conn.flush()?;
1222
+                    self.tiled_edge_cursor = None;
1223
+                }
1224
+                // Pointer is on root/empty desktop — check for cross-monitor movement
1225
+                let monitor_idx = self.monitor_idx_at_point(event.root_x, event.root_y);
1226
+                if monitor_idx != self.focused_monitor {
1227
+                    let old_workspace_idx = self.focused_workspace;
1228
+                    self.focused_monitor = monitor_idx;
1229
+                    let workspace_idx = self.monitors[monitor_idx].active_workspace;
1230
+                    self.focused_workspace = workspace_idx;
1231
+                    self.focused_window = None;
1232
+                    self.conn.set_active_window(None)?;
1233
+                    self.conn.set_current_desktop(workspace_idx as u32)?;
1234
+                    if workspace_idx != old_workspace_idx {
1235
+                        self.broadcast_i3_workspace_event("focus", workspace_idx, Some(old_workspace_idx));
1236
+                    }
1237
+                    self.conn.flush()?;
1238
+                }
12111239
             }
12121240
             return Ok(());
12131241
         }
@@ -1685,8 +1713,24 @@ impl WindowManager {
16851713
             return Ok(());
16861714
         }
16871715
 
1688
-        // Only focus windows we manage
1716
+        // For unmanaged windows (root window / empty desktop areas),
1717
+        // check if the pointer crossed to a different monitor and update
1718
+        // focused workspace accordingly so garbar underline tracks correctly.
16891719
         if !self.windows.contains_key(&window) {
1720
+            let monitor_idx = self.monitor_idx_at_point(event.root_x, event.root_y);
1721
+            if monitor_idx != self.focused_monitor {
1722
+                let old_workspace_idx = self.focused_workspace;
1723
+                self.focused_monitor = monitor_idx;
1724
+                let workspace_idx = self.monitors[monitor_idx].active_workspace;
1725
+                self.focused_workspace = workspace_idx;
1726
+                self.focused_window = None;
1727
+                self.conn.set_active_window(None)?;
1728
+                self.conn.set_current_desktop(workspace_idx as u32)?;
1729
+                if workspace_idx != old_workspace_idx {
1730
+                    self.broadcast_i3_workspace_event("focus", workspace_idx, Some(old_workspace_idx));
1731
+                }
1732
+                self.conn.flush()?;
1733
+            }
16901734
             return Ok(());
16911735
         }
16921736
 
@@ -1697,10 +1741,18 @@ impl WindowManager {
16971741
 
16981742
         tracing::debug!("Focus follows mouse: focusing window {}", window);
16991743
 
1744
+        let old_workspace_idx = self.focused_workspace;
1745
+
17001746
         // Focus the new window (no warp - mouse enter)
17011747
         // set_focus handles grab/ungrab for old and new windows
17021748
         self.set_focus(window, false)?;
17031749
 
1750
+        // If workspace changed (cross-monitor mouse move), update EWMH and notify garbar
1751
+        if self.focused_workspace != old_workspace_idx {
1752
+            self.conn.set_current_desktop(self.focused_workspace as u32)?;
1753
+            self.broadcast_i3_workspace_event("focus", self.focused_workspace, Some(old_workspace_idx));
1754
+        }
1755
+
17041756
         // Raise floating windows on focus
17051757
         if self.is_floating(window) {
17061758
             self.raise_window(window)?;
@@ -1776,6 +1828,19 @@ impl WindowManager {
17761828
                     _ => {}
17771829
                 }
17781830
             }
1831
+            // Handle ABOVE state changes - raise window when requested
1832
+            else if property == self.conn.net_wm_state_above {
1833
+                match action {
1834
+                    1 | 2 => {
1835
+                        // Add or toggle ABOVE - raise the window
1836
+                        tracing::info!("Window {} requesting ABOVE state, raising", window);
1837
+                        if self.windows.contains_key(&window) {
1838
+                            self.raise_window(window)?;
1839
+                        }
1840
+                    }
1841
+                    _ => {}
1842
+                }
1843
+            }
17791844
         } else {
17801845
             tracing::trace!("Unhandled ClientMessage type: {}", msg_type);
17811846
         }
@@ -2162,12 +2227,16 @@ impl WindowManager {
21622227
         };
21632228
 
21642229
         tracing::info!("Moving focus from monitor {} to {}", self.focused_monitor, target_idx);
2230
+        let old_workspace_idx = self.focused_workspace;
21652231
         self.focused_monitor = target_idx;
21662232
 
21672233
         // Focus the active workspace on that monitor
21682234
         let workspace_idx = self.monitors[target_idx].active_workspace;
21692235
         self.focused_workspace = workspace_idx;
21702236
 
2237
+        // Update EWMH
2238
+        self.conn.set_current_desktop(workspace_idx as u32)?;
2239
+
21712240
         // Focus a window on that workspace if any, or just warp to monitor center
21722241
         if let Some(window) = self.workspaces[workspace_idx].focused
21732242
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
@@ -2178,10 +2247,16 @@ impl WindowManager {
21782247
         } else {
21792248
             // No windows on target monitor - clear focus and warp to monitor center
21802249
             self.focused_window = None;
2250
+            self.conn.set_active_window(None)?;
21812251
             self.warp_to_monitor(target_idx)?;
21822252
             tracing::debug!("No windows on monitor {}, warped to center", target_idx);
21832253
         }
21842254
 
2255
+        // Broadcast i3 workspace event so garbar updates
2256
+        if workspace_idx != old_workspace_idx {
2257
+            self.broadcast_i3_workspace_event("focus", workspace_idx, Some(old_workspace_idx));
2258
+        }
2259
+
21852260
         self.conn.flush()?;
21862261
         Ok(())
21872262
     }
@@ -2250,6 +2325,14 @@ impl WindowManager {
22502325
         Ok(())
22512326
     }
22522327
 
2328
+    /// Force refresh layout - just re-apply layout.
2329
+    /// Note: GTK apps may not fully re-render at new scale without restart.
2330
+    fn force_refresh_layout(&mut self) -> Result<()> {
2331
+        self.apply_layout()?;
2332
+        tracing::info!("Layout refreshed");
2333
+        Ok(())
2334
+    }
2335
+
22532336
     /// Execute an i3-compatible command (from IPC RUN_COMMAND).
22542337
     /// Returns true if the command was executed successfully.
22552338
     fn execute_i3_command(&mut self, cmd: &str) -> bool {
@@ -2334,6 +2417,7 @@ impl WindowManager {
23342417
             // Focus the monitor that has this workspace
23352418
             self.focused_monitor = monitor_idx;
23362419
             self.focused_workspace = idx;
2420
+            self.workspaces[idx].last_monitor = Some(monitor_idx);
23372421
 
23382422
             // Update EWMH
23392423
             self.conn.set_current_desktop(idx as u32)?;
@@ -2358,13 +2442,16 @@ impl WindowManager {
23582442
                 self.conn.set_active_window(None)?;
23592443
             }
23602444
         } else {
2361
-            // Workspace not visible - show it on current monitor (i3 behavior)
2362
-            let current_monitor = self.focused_monitor;
2363
-            let old_ws = self.monitors[current_monitor].active_workspace;
2445
+            // Workspace not visible — show it on the monitor it was last on,
2446
+            // falling back to the current monitor if it was never shown.
2447
+            let target_monitor = self.workspaces[idx].last_monitor
2448
+                .filter(|&m| m < self.monitors.len())
2449
+                .unwrap_or(self.focused_monitor);
2450
+            let old_ws = self.monitors[target_monitor].active_workspace;
23642451
 
23652452
             tracing::info!(
23662453
                 "Switching monitor {} from workspace {} to {}",
2367
-                current_monitor, old_ws + 1, idx + 1
2454
+                target_monitor, old_ws + 1, idx + 1
23682455
             );
23692456
 
23702457
             // Hide windows on old workspace
@@ -2381,7 +2468,9 @@ impl WindowManager {
23812468
             }
23822469
 
23832470
             // Update monitor's active workspace
2384
-            self.monitors[current_monitor].active_workspace = idx;
2471
+            self.monitors[target_monitor].active_workspace = idx;
2472
+            self.workspaces[idx].last_monitor = Some(target_monitor);
2473
+            self.focused_monitor = target_monitor;
23852474
             self.focused_workspace = idx;
23862475
 
23872476
             // Update EWMH
@@ -2409,7 +2498,7 @@ impl WindowManager {
24092498
                 self.focused_window = None;
24102499
                 self.conn.set_active_window(None)?;
24112500
                 if warp_pointer {
2412
-                    let monitor_geom = self.monitors[current_monitor].geometry;
2501
+                    let monitor_geom = self.monitors[target_monitor].geometry;
24132502
                     let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
24142503
                     let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
24152504
                     self.conn.warp_pointer(center_x, center_y)?;
@@ -2690,11 +2779,14 @@ impl WindowManager {
26902779
         }
26912780
         self.garnotify_process = None;
26922781
 
2693
-        // Kill picom to prevent compositor effects from bleeding into the greeter
2694
-        tracing::info!("Killing picom...");
2782
+        // Kill compositor to prevent overlay from bleeding into the greeter
2783
+        tracing::info!("Killing compositor...");
2784
+        // Use -f to match against full command line (needed for NixOS wrappers)
2785
+        let _ = std::process::Command::new("pkill")
2786
+            .args(["-f", "garchomp"])
2787
+            .status();
26952788
         let _ = std::process::Command::new("pkill")
2696
-            .arg("-x")
2697
-            .arg("picom")
2789
+            .args(["-f", "picom"])
26982790
             .status();
26992791
 
27002792
         // Signal systemd that graphical session has ended
@@ -2805,6 +2897,18 @@ impl WindowManager {
28052897
                     Err(e) => Response::error(e.to_string()),
28062898
                 }
28072899
             }
2900
+            "refresh_layout" => {
2901
+                // Re-apply layout to all windows without changing ratios.
2902
+                // Useful after display scaling changes when GTK apps resize internally.
2903
+                // Two-step approach: first shrink windows, then expand - forces GTK to re-layout.
2904
+                match self.force_refresh_layout() {
2905
+                    Ok(_) => {
2906
+                        tracing::info!("Layout force-refreshed");
2907
+                        Response::success(None)
2908
+                    }
2909
+                    Err(e) => Response::error(e.to_string()),
2910
+                }
2911
+            }
28082912
             "reload" => {
28092913
                 match self.reload_config() {
28102914
                     Ok(_) => Response::success(None),
@@ -2983,9 +3087,15 @@ impl WindowManager {
29833087
         // Update stacking order in workspace's floating list
29843088
         self.current_workspace_mut().raise_floating(window);
29853089
 
2986
-        // Raise in X11
3090
+        // Raise in X11 - if window has a frame, raise the frame instead
29873091
         let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
2988
-        self.conn.conn.configure_window(window, &aux)?;
3092
+        if let Some(frame) = self.frames.frame_for_client(window) {
3093
+            tracing::info!("raise_window: window={} -> raising frame={}", window, frame);
3094
+            self.conn.conn.configure_window(frame, &aux)?;
3095
+        } else {
3096
+            tracing::info!("raise_window: window={} (no frame)", window);
3097
+            self.conn.conn.configure_window(window, &aux)?;
3098
+        }
29893099
         self.conn.flush()?;
29903100
         Ok(())
29913101
     }
@@ -3023,12 +3133,16 @@ impl WindowManager {
30233133
         }
30243134
 
30253135
         tracing::info!("Focusing monitor {}: '{}'", target_idx, self.monitors[target_idx].name);
3136
+        let old_workspace_idx = self.focused_workspace;
30263137
         self.focused_monitor = target_idx;
30273138
 
30283139
         // Focus the active workspace on that monitor
30293140
         let workspace_idx = self.monitors[target_idx].active_workspace;
30303141
         self.focused_workspace = workspace_idx;
30313142
 
3143
+        // Update EWMH
3144
+        self.conn.set_current_desktop(workspace_idx as u32)?;
3145
+
30323146
         // Focus a window on that workspace if any, or warp to monitor center
30333147
         if let Some(window) = self.workspaces[workspace_idx].focused
30343148
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
@@ -3037,11 +3151,17 @@ impl WindowManager {
30373151
             // set_focus handles grab/ungrab for old and new windows
30383152
             self.set_focus(window, true)?;
30393153
         } else {
3040
-            // No windows - warp to monitor center
3154
+            // No windows - clear EWMH active window and warp to monitor center
30413155
             self.focused_window = None;
3156
+            self.conn.set_active_window(None)?;
30423157
             self.warp_to_monitor(target_idx)?;
30433158
         }
30443159
 
3160
+        // Broadcast i3 workspace event so garbar updates
3161
+        if workspace_idx != old_workspace_idx {
3162
+            self.broadcast_i3_workspace_event("focus", workspace_idx, Some(old_workspace_idx));
3163
+        }
3164
+
30453165
         self.conn.flush()?;
30463166
         Ok(())
30473167
     }
@@ -3088,12 +3208,18 @@ impl WindowManager {
30883208
             window, target_idx, self.monitors[target_idx].name, target_workspace + 1);
30893209
 
30903210
         // Remove from current workspace
3211
+        let source_ws = self.focused_workspace;
30913212
         if is_floating {
30923213
             self.current_workspace_mut().remove_floating(window);
30933214
         } else {
30943215
             self.current_workspace_mut().tree.remove(window);
30953216
         }
30963217
 
3218
+        // Update focus on source workspace so it doesn't point to the moved window
3219
+        let new_focus_on_source = self.workspaces[source_ws].tree.first_window()
3220
+            .or_else(|| self.workspaces[source_ws].floating.last().copied());
3221
+        self.workspaces[source_ws].focused = new_focus_on_source;
3222
+
30973223
         // Update window's workspace
30983224
         if let Some(win) = self.windows.get_mut(&window) {
30993225
             win.workspace = target_workspace;
@@ -3112,16 +3238,25 @@ impl WindowManager {
31123238
         self.conn.set_window_desktop(window, target_workspace as u32)?;
31133239
 
31143240
         // Focus follows window to new monitor
3241
+        let old_workspace_idx = self.focused_workspace;
31153242
         self.focused_monitor = target_idx;
31163243
         self.focused_workspace = target_workspace;
31173244
         self.workspaces[target_workspace].focused = Some(window);
31183245
 
3246
+        // Update EWMH
3247
+        self.conn.set_current_desktop(target_workspace as u32)?;
3248
+
31193249
         // Apply layouts on both monitors
31203250
         self.apply_layout()?;
31213251
 
31223252
         // Set X11 focus on the moved window (updates focused_window, button grabs, EWMH)
31233253
         self.set_focus(window, true)?;
31243254
 
3255
+        // Broadcast i3 workspace event so garbar updates
3256
+        if target_workspace != old_workspace_idx {
3257
+            self.broadcast_i3_workspace_event("focus", target_workspace, Some(old_workspace_idx));
3258
+        }
3259
+
31253260
         self.conn.flush()?;
31263261
         Ok(())
31273262
     }
garctl/src/main.rsmodified
@@ -49,6 +49,8 @@ enum Command {
4949
     ToggleFloating,
5050
     /// Equalize split ratios
5151
     Equalize,
52
+    /// Refresh window layout (re-apply without changing ratios)
53
+    RefreshLayout,
5254
     /// Reload configuration
5355
     Reload,
5456
     /// Exit gar
@@ -129,6 +131,9 @@ fn main() {
129131
         Command::Equalize => {
130132
             json!({ "command": "equalize", "args": {} })
131133
         }
134
+        Command::RefreshLayout => {
135
+            json!({ "command": "refresh_layout", "args": {} })
136
+        }
132137
         Command::Reload => {
133138
             json!({ "command": "reload", "args": {} })
134139
         }