Comparing changes

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

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

Commits on trunk

.gitignoremodified
@@ -1,3 +1,4 @@
11
 docs/
22
 target/
33
 CLAUDE.md
4
+flake.lock
Cargo.lockmodified
@@ -202,7 +202,7 @@ checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
202202
 
203203
 [[package]]
204204
 name = "gar"
205
-version = "0.2.0"
205
+version = "0.3.0"
206206
 dependencies = [
207207
  "dirs",
208208
  "libc",
@@ -217,7 +217,7 @@ dependencies = [
217217
 
218218
 [[package]]
219219
 name = "garctl"
220
-version = "0.2.0"
220
+version = "0.3.0"
221221
 dependencies = [
222222
  "clap",
223223
  "serde",
examples/init.luamodified
@@ -12,6 +12,16 @@ gar.exec_once("garlock daemon")
1212
 -- Clipboard manager daemon
1313
 gar.exec_once("garclip daemon --foreground")
1414
 
15
+-- System tray with quick settings panel
16
+gar.exec_once("gartray daemon")
17
+
18
+-- Polkit authentication agent (needed for power actions via D-Bus)
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")
24
+
1525
 -- Uncomment the ones you want:
1626
 -- gar.exec_once("picom")                      -- Compositor (for transparency/shadows)
1727
 -- gar.exec_once("dunst")                      -- Notification daemon
@@ -102,7 +112,7 @@ gar.bar = {
102112
     -- Module layout
103113
     modules_left = { "workspaces", "window_title" },
104114
     modules_center = {},
105
-    modules_right = { "almanta", "filesystem", "memory", "cpu", "battery", "wlan", "volume", "datetime" },
115
+    modules_right = { "almanta", "filesystem", "memory", "cpu", "battery", "wlan", "volume", "tray", "datetime", "quick_settings" },
106116
 
107117
     -- Module configurations
108118
     modules = {
@@ -218,11 +228,101 @@ gar.set("follow_window_on_move", true) -- Follow window when using Mod+Shift+nu
218228
 -- Use "mod" for real X session, "alt" for nested testing (Xephyr)
219229
 local mod = "mod"
220230
 
221
--- Terminal
231
+-- Terminal (fallback)
222232
 gar.bind(mod .. "+Return", function()
223233
     gar.exec("alacritty || kitty || foot || xterm")
224234
 end)
225235
 
236
+-- garterm with fish shell
237
+gar.bind(mod .. "+shift+Return", function()
238
+    gar.exec("garterm")
239
+end)
240
+
241
+-- CPR-Music dev workspace via session
242
+gar.bind(mod .. "+alt+Return", function()
243
+    gar.exec("garterm")
244
+    gar.exec("sleep 0.3 && gartermctl load-session cpr-music")
245
+end)
246
+
247
+--------------------------------------------------------------------------------
248
+-- GARTERM CONFIGURATION
249
+--------------------------------------------------------------------------------
250
+-- garterm reads this table for shell, font, colors, sessions, and keybinds
251
+
252
+gar.terminal = {
253
+    -- Default shell
254
+    shell = "/usr/bin/fish",
255
+
256
+    -- Font settings
257
+    font = {
258
+        family = "JetBrainsMono Nerd Font",
259
+        size = 20.0,
260
+    },
261
+
262
+    -- Color scheme
263
+    colors = {
264
+        preset = "catpuccin-mocha",
265
+    },
266
+
267
+    -- Tab bar configuration
268
+    tab_bar = {
269
+        height = 24,                          -- Tab bar height in pixels
270
+        position = "top",                     -- "top" or "bottom"
271
+        show_single_tab = false,              -- Show tab bar even with one tab
272
+        max_tab_width = 200,                  -- Maximum width per tab in pixels
273
+        tab_padding = 16,                     -- Horizontal padding inside each tab
274
+        shorten_paths = true,                 -- Shorten paths: ~/P/a/src style
275
+
276
+        -- Colors as {R, G, B, A} with values 0.0 to 1.0
277
+        background = {0.08, 0.08, 0.12, 1.0}, -- Tab bar background
278
+        active_bg = {0.15, 0.15, 0.20, 1.0},  -- Active tab background
279
+        inactive_bg = {0.10, 0.10, 0.14, 1.0},-- Inactive tab background
280
+        active_fg = {1.0, 1.0, 1.0, 1.0},     -- Active tab text color
281
+        inactive_fg = {0.7, 0.7, 0.7, 1.0},   -- Inactive tab text color
282
+    },
283
+
284
+    -- Session definitions (load with gartermctl load-session <name>)
285
+    sessions = {
286
+        -- CPR-Music development workspace
287
+        ["cpr-music"] = {
288
+            tabs = {
289
+                {
290
+                    title = "Frontend",
291
+                    cwd = "~/GithubOrgs/espadonne/CPR-Music",
292
+                    cmd = "npm run dev",
293
+                },
294
+                {
295
+                    title = "Backend",
296
+                    cwd = "~/GithubOrgs/mfwolffe/CPR-Music-Backend",
297
+                    cmd = "source .venv/bin/activate.fish && python manage.py runserver",
298
+                },
299
+                {
300
+                    title = "Editor",
301
+                    cwd = "~/GithubOrgs/espadonne/CPR-Music",
302
+                    cmd = "fackr",
303
+                },
304
+            },
305
+        },
306
+    },
307
+
308
+    -- Keybinds within garterm (Lua function callbacks)
309
+    keybinds = {
310
+        -- Quick session loading
311
+        ["alt+m"] = { action = "load_session", session = "cpr-music"},
312
+
313
+        -- Open fackr in current pane
314
+        ["alt+e"] = function()
315
+            gar.terminal.send_text(nil, "fackr \n")
316
+        end,
317
+
318
+        -- Open fackr in new tab
319
+        ["alt+shift+e"] = function()
320
+            gar.terminal.new_tab({})
321
+            gar.terminal.send_text(nil, "fackr \n")
322
+        end,
323
+    },
324
+}
325
+
226326
 -- Close window
227327
 gar.bind(mod .. "+q", gar.close_window)
228328
 
gar-session.shmodified
@@ -2,10 +2,10 @@
22
 # gar session wrapper - sets up environment before starting gar
33
 # This script is typically installed to /usr/local/share/gar/gar-session.sh
44
 
5
-# Find gar binary - check common locations
5
+# Find gar binary - check common locations (user local first for dev overrides)
66
 GAR_BIN="${GAR_BIN:-}"
77
 if [ -z "$GAR_BIN" ]; then
8
-    for path in /usr/local/bin/gar /usr/bin/gar "$HOME/.local/bin/gar"; do
8
+    for path in "$HOME/.local/bin/gar" /usr/local/bin/gar /usr/bin/gar; do
99
         if [ -x "$path" ]; then
1010
             GAR_BIN="$path"
1111
             break
@@ -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
@@ -1,4 +1,5 @@
11
 use std::collections::HashSet;
2
+use std::os::unix::process::CommandExt;
23
 use std::path::PathBuf;
34
 use std::sync::{Arc, Mutex};
45
 
@@ -68,6 +69,8 @@ pub struct LuaState {
6869
     pub callbacks: Vec<mlua::RegistryKey>,
6970
     pub rules: Vec<WindowRule>,
7071
     pub exec_once_cmds: HashSet<String>,
72
+    /// PIDs of processes spawned via gar.exec()/gar.exec_once()
73
+    pub spawned_pids: Vec<u32>,
7174
 }
7275
 
7376
 impl Default for LuaState {
@@ -78,6 +81,26 @@ impl Default for LuaState {
7881
             callbacks: Vec::new(),
7982
             rules: Vec::new(),
8083
             exec_once_cmds: HashSet::new(),
84
+            spawned_pids: Vec::new(),
85
+        }
86
+    }
87
+}
88
+
89
+impl LuaState {
90
+    /// Kill all processes spawned via gar.exec()/gar.exec_once()
91
+    /// Called on gar shutdown to clean up child processes
92
+    pub fn kill_spawned_children(&self) {
93
+        tracing::info!("Killing {} spawned child processes", self.spawned_pids.len());
94
+        for &pid in &self.spawned_pids {
95
+            // Check if process still exists before trying to kill
96
+            // SAFETY: Sending signal 0 just checks if process exists
97
+            let exists = unsafe { libc::kill(pid as i32, 0) == 0 };
98
+            if exists {
99
+                // Kill the entire process group (negative PID) to get children too
100
+                // This handles cases like "sh -c garterm" where sh spawns garterm
101
+                tracing::debug!("Sending SIGTERM to process group {}", pid);
102
+                unsafe { libc::kill(-(pid as i32), libc::SIGTERM); }
103
+            }
81104
         }
82105
     }
83106
 }
@@ -117,11 +140,15 @@ impl LuaConfig {
117140
         // Check if gar.bar table exists - enables garbar integration
118141
         self.check_bar_config()?;
119142
 
143
+        // Check if gar.notification table exists - enables garnotify integration
144
+        self.check_notification_config()?;
145
+
120146
         let state = self.state.lock().unwrap();
121147
         tracing::info!(
122
-            "Config loaded: {} keybinds registered, bar_enabled={}",
148
+            "Config loaded: {} keybinds registered, bar_enabled={}, notification_enabled={}",
123149
             state.keybinds.len(),
124
-            state.config.bar_enabled
150
+            state.config.bar_enabled,
151
+            state.config.notification_enabled
125152
         );
126153
 
127154
         Ok(())
@@ -156,6 +183,28 @@ impl LuaConfig {
156183
         Ok(())
157184
     }
158185
 
186
+    /// Check if gar.notification table is configured, enabling garnotify integration
187
+    fn check_notification_config(&self) -> LuaResult<()> {
188
+        let globals = self.lua.globals();
189
+        let gar: Table = globals.get("gar")?;
190
+
191
+        // Check if gar.notification exists and is a table
192
+        match gar.get::<Table>("notification") {
193
+            Ok(_) => {
194
+                // gar.notification exists! Enable garnotify integration
195
+                let mut state = self.state.lock().unwrap();
196
+                state.config.notification_enabled = true;
197
+                tracing::info!("garnotify integration enabled");
198
+            }
199
+            Err(_) => {
200
+                // gar.notification not set, garnotify won't be auto-spawned
201
+                tracing::debug!("gar.notification not configured, garnotify integration disabled");
202
+            }
203
+        }
204
+
205
+        Ok(())
206
+    }
207
+
159208
     /// Reload configuration (clears existing keybinds and rules)
160209
     pub fn reload(&self) -> LuaResult<()> {
161210
         {
@@ -334,11 +383,42 @@ impl LuaConfig {
334383
                         state.config.mouse_follows_focus = v;
335384
                     }
336385
                 }
386
+                "focus_follows_mouse" => {
387
+                    if let Value::Boolean(v) = value {
388
+                        state.config.focus_follows_mouse = v;
389
+                    }
390
+                }
337391
                 "bar_height" => {
338392
                     if let Value::Integer(v) = value {
339393
                         state.config.bar_height = v as u32;
340394
                     }
341395
                 }
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
+                }
342422
                 // Compositor visual settings (picom)
343423
                 "corner_radius" => {
344424
                     if let Value::Integer(v) = value {
@@ -619,33 +699,54 @@ impl LuaConfig {
619699
     }
620700
 
621701
     fn register_exec(&self, gar: &Table) -> LuaResult<()> {
622
-        let exec_fn = self.lua.create_function(|_, cmd: String| {
702
+        // gar.exec(cmd) - spawn a command, track PID for cleanup on exit
703
+        let state = Arc::clone(&self.state);
704
+        let exec_fn = self.lua.create_function(move |_, cmd: String| {
623705
             tracing::debug!("exec: {}", cmd);
624
-            std::process::Command::new("sh")
706
+            // process_group(0) makes the child its own process group leader
707
+            // so we can kill the entire group (including grandchildren) on exit
708
+            if let Ok(child) = std::process::Command::new("sh")
625709
                 .arg("-c")
626710
                 .arg(&cmd)
711
+                .process_group(0)
627712
                 .spawn()
628
-                .ok();
713
+            {
714
+                let pid = child.id();
715
+                tracing::debug!("exec: spawned PID {}", pid);
716
+                if let Ok(mut state) = state.lock() {
717
+                    state.spawned_pids.push(pid);
718
+                }
719
+            }
629720
             Ok(())
630721
         })?;
631722
         gar.set("exec", exec_fn)?;
632723
 
633724
         // gar.exec_once(cmd) - only run if not already run this session
634
-        let state = Arc::clone(&self.state);
725
+        let state_once = Arc::clone(&self.state);
635726
         let exec_once_fn = self.lua.create_function(move |_, cmd: String| {
636
-            let mut state = state.lock().unwrap();
637
-            if state.exec_once_cmds.contains(&cmd) {
638
-                tracing::debug!("exec_once: skipping already-run command: {}", cmd);
639
-                return Ok(());
727
+            {
728
+                let state = state_once.lock().unwrap();
729
+                if state.exec_once_cmds.contains(&cmd) {
730
+                    tracing::debug!("exec_once: skipping already-run command: {}", cmd);
731
+                    return Ok(());
732
+                }
640733
             }
641734
             tracing::info!("exec_once: {}", cmd);
642
-            state.exec_once_cmds.insert(cmd.clone());
643
-            drop(state); // Release lock before spawning
644
-            std::process::Command::new("sh")
735
+            // process_group(0) makes the child its own process group leader
736
+            // so we can kill the entire group (including grandchildren) on exit
737
+            if let Ok(child) = std::process::Command::new("sh")
645738
                 .arg("-c")
646739
                 .arg(&cmd)
740
+                .process_group(0)
647741
                 .spawn()
648
-                .ok();
742
+            {
743
+                let pid = child.id();
744
+                tracing::debug!("exec_once: spawned PID {}", pid);
745
+                if let Ok(mut state) = state_once.lock() {
746
+                    state.exec_once_cmds.insert(cmd);
747
+                    state.spawned_pids.push(pid);
748
+                }
749
+            }
649750
             Ok(())
650751
         })?;
651752
         gar.set("exec_once", exec_once_fn)
@@ -711,13 +812,13 @@ impl LuaConfig {
711812
                 rule.opacity = Some(op);
712813
             }
713814
 
714
-            // Optional: shadow
715
-            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") {
716817
                 rule.shadow = Some(shadow);
717818
             }
718819
 
719
-            // Optional: blur_background
720
-            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") {
721822
                 rule.blur_background = Some(blur);
722823
             }
723824
 
gar/src/config/mod.rsmodified
@@ -31,16 +31,23 @@ 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
3738
     pub bar_enabled: bool,
39
+    // garnotify integration: spawn garnotify automatically if gar.notification is configured
40
+    pub notification_enabled: bool,
3841
     // Monitor ordering: list of monitor names in desired left-to-right order
3942
     // If empty, monitors are sorted by X position (default)
4043
     pub monitor_order: Vec<String>,
4144
     // Screen timeout/DPMS settings
4245
     pub screen_timeout_enabled: bool,
4346
     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,
4451
     // Compositor visual settings (picom)
4552
     // These are stored for reference and potential dynamic picom config generation
4653
     pub corner_radius: u32,
@@ -77,23 +84,26 @@ impl Config {
7784
     /// Generate picom.conf content from current config settings.
7885
     pub fn generate_picom_config(&self) -> String {
7986
         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
+
8099
             format!(
81100
                 r#"# Blur
82101
 blur-method = "{}";
83102
 blur-strength = {};
84103
 blur-background = true;
85104
 blur-background-frame = false;
86
-blur-kern = "3x3box";
87
-
88
-blur-background-exclude = [
89
-    "window_type = 'dock'",
90
-    "window_type = 'desktop'",
91
-    "window_type = 'menu'",
92
-    "window_type = 'dropdown_menu'",
93
-    "window_type = 'popup_menu'",
94
-    "_NET_WM_BYPASS_COMPOSITOR = 1"
95
-];"#,
96
-                self.blur_method, self.blur_strength
105
+blur-kern = "3x3box";"#,
106
+                blur_method, blur_strength
97107
             )
98108
         } else {
99109
             "# Blur disabled".to_string()
@@ -106,18 +116,7 @@ shadow = true;
106116
 shadow-radius = {};
107117
 shadow-opacity = {:.2};
108118
 shadow-offset-x = {};
109
-shadow-offset-y = {};
110
-
111
-shadow-exclude = [
112
-    "window_type = 'dock'",
113
-    "window_type = 'desktop'",
114
-    "window_type = 'menu'",
115
-    "window_type = 'dropdown_menu'",
116
-    "window_type = 'popup_menu'",
117
-    "window_type = 'tooltip'",
118
-    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'",
119
-    "_NET_WM_BYPASS_COMPOSITOR = 1"
120
-];"#,
119
+shadow-offset-y = {};"#,
121120
                 self.shadow_radius,
122121
                 self.shadow_opacity,
123122
                 self.shadow_offset_x,
@@ -135,13 +134,7 @@ fade-in-step = 0.028;
135134
 fade-out-step = 0.03;
136135
 fade-delta = {};
137136
 
138
-no-fading-destroyed-argb = true;
139
-
140
-fade-exclude = [
141
-    "window_type = 'menu'",
142
-    "window_type = 'dropdown_menu'",
143
-    "window_type = 'popup_menu'"
144
-];"#,
137
+no-fading-destroyed-argb = true;"#,
145138
                 self.fade_delta
146139
             )
147140
         } else {
@@ -211,42 +204,105 @@ animations = ({{
211204
             "# No custom shader".to_string()
212205
         };
213206
 
214
-        // Per-window rules section
215
-        let rules_section = if !self.picom_rules.is_empty() {
216
-            let mut rules = String::from("# Per-window Rules\nrules = (\n");
217
-            for rule in &self.picom_rules {
218
-                rules.push_str(&format!("    {{\n        match = \"{}\";\n", rule.match_expr));
219
-                if let Some(cr) = rule.corner_radius {
220
-                    rules.push_str(&format!("        corner-radius = {};\n", cr));
221
-                }
222
-                if let Some(opacity) = rule.opacity {
223
-                    rules.push_str(&format!("        opacity = {:.2};\n", opacity));
224
-                }
225
-                if let Some(shadow) = rule.shadow {
226
-                    rules.push_str(&format!("        shadow = {};\n", shadow));
227
-                }
228
-                if let Some(blur) = rule.blur_background {
229
-                    rules.push_str(&format!("        blur-background = {};\n", blur));
230
-                }
231
-                if let Some(ref shader) = rule.shader {
232
-                    let expanded = if shader.starts_with("~/") {
233
-                        if let Some(home) = dirs::home_dir() {
234
-                            home.join(&shader[2..]).to_string_lossy().to_string()
235
-                        } else {
236
-                            shader.clone()
237
-                        }
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()
238293
                     } else {
239294
                         shader.clone()
240
-                    };
241
-                    rules.push_str(&format!("        shader = \"{}\";\n", expanded));
242
-                }
243
-                rules.push_str("    },\n");
295
+                    }
296
+                } else {
297
+                    shader.clone()
298
+                };
299
+                rules.push_str(&format!("        shader = \"{}\";\n", expanded));
244300
             }
245
-            rules.push_str(");");
246
-            rules
247
-        } else {
248
-            "# No per-window rules".to_string()
249
-        };
301
+            rules.push_str("    },\n");
302
+        }
303
+
304
+        rules.push_str(");");
305
+        let rules_section = rules;
250306
 
251307
         format!(
252308
             r#"# picom.conf - Auto-generated by gar window manager
@@ -254,24 +310,13 @@ animations = ({{
254310
 # Edit ~/.config/gar/init.lua instead and reload with Mod+Shift+R
255311
 
256312
 # Backend Configuration
257
-backend = "glx";
313
+backend = "{}";
258314
 vsync = true;
259315
 use-ewmh-active-win = true;
260
-glx-no-stencil = true;
261316
 
262317
 # Rounded Corners
263318
 corner-radius = {};
264319
 
265
-rounded-corners-exclude = [
266
-    "window_type = 'dock'",
267
-    "window_type = 'desktop'",
268
-    "window_type = 'tooltip'",
269
-    "window_type = 'menu'",
270
-    "window_type = 'dropdown_menu'",
271
-    "window_type = 'popup_menu'",
272
-    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'"
273
-];
274
-
275320
 {}
276321
 
277322
 {}
@@ -285,34 +330,8 @@ rounded-corners-exclude = [
285330
 {}
286331
 
287332
 {}
288
-
289
-# Window Type Settings
290
-wintypes:
291
-{{
292
-    tooltip = {{
293
-        fade = true;
294
-        shadow = false;
295
-        opacity = 0.95;
296
-        focus = true;
297
-        blur-background = false;
298
-    }};
299
-    dock = {{
300
-        shadow = false;
301
-        clip-shadow-above = true;
302
-    }};
303
-    dnd = {{
304
-        shadow = false;
305
-    }};
306
-    popup_menu = {{
307
-        opacity = 0.95;
308
-        shadow = false;
309
-    }};
310
-    dropdown_menu = {{
311
-        opacity = 0.95;
312
-        shadow = false;
313
-    }};
314
-}};
315333
 "#,
334
+            self.picom_backend,
316335
             self.corner_radius,
317336
             blur_section,
318337
             shadow_section,
@@ -324,8 +343,15 @@ wintypes:
324343
         )
325344
     }
326345
 
327
-    /// 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".
328348
     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
+
329355
         let config_dir = dirs::config_dir()
330356
             .ok_or_else(|| std::io::Error::new(
331357
                 std::io::ErrorKind::NotFound,
@@ -348,63 +374,110 @@ wintypes:
348374
         Ok(())
349375
     }
350376
 
351
-    /// Apply screen timeout/DPMS settings using xset
352
-    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) {
353380
         use std::process::Command;
354381
 
355
-        if self.screen_timeout_enabled {
356
-            // Enable DPMS and set timeout
357
-            let timeout = self.screen_timeout_seconds.to_string();
358
-            match Command::new("xset")
359
-                .args(["dpms", &timeout, &timeout, &timeout])
360
-                .status()
361
-            {
362
-                Ok(status) if status.success() => {
363
-                    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);
364420
                 }
365
-                Ok(_) => tracing::warn!("xset dpms command failed"),
366
-                Err(e) => tracing::warn!("Failed to run xset: {}", e),
367421
             }
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
+        }
368455
 
369
-            // 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();
370459
             match Command::new("xset")
371460
                 .args(["s", &timeout, &timeout])
372461
                 .status()
373462
             {
374463
                 Ok(status) if status.success() => {
375
-                    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
+                    );
376468
                 }
377469
                 Ok(_) => tracing::warn!("xset s command failed"),
378470
                 Err(e) => tracing::warn!("Failed to run xset: {}", e),
379471
             }
380
-
381
-            // Make sure DPMS is enabled
382
-            match Command::new("xset").args(["+dpms"]).status() {
383
-                Ok(_) => {}
384
-                Err(e) => tracing::warn!("Failed to enable DPMS: {}", e),
385
-            }
386472
         } else {
387
-            // Disable DPMS and screen saver
388
-            match Command::new("xset").args(["dpms", "0", "0", "0"]).status() {
389
-                Ok(status) if status.success() => {
390
-                    tracing::info!("Disabled DPMS timeout");
391
-                }
392
-                Ok(_) => tracing::warn!("xset dpms 0 command failed"),
393
-                Err(e) => tracing::warn!("Failed to run xset: {}", e),
394
-            }
395
-
473
+            // Disable screen saver blanking too
396474
             match Command::new("xset").args(["s", "off"]).status() {
397475
                 Ok(status) if status.success() => {
398
-                    tracing::debug!("Disabled screen saver");
476
+                    tracing::info!("Screen blanking disabled");
399477
                 }
400478
                 Ok(_) => tracing::warn!("xset s off command failed"),
401479
                 Err(e) => tracing::warn!("Failed to run xset: {}", e),
402480
             }
403
-
404
-            match Command::new("xset").args(["-dpms"]).status() {
405
-                Ok(_) => {}
406
-                Err(e) => tracing::warn!("Failed to disable DPMS: {}", e),
407
-            }
408481
         }
409482
     }
410483
 
@@ -472,15 +545,24 @@ impl Default for Config {
472545
             follow_window_on_move: false,
473546
             // Behavior: warp mouse pointer to center of focused window
474547
             mouse_follows_focus: false,
548
+            // Behavior: focus window when mouse enters it
549
+            focus_follows_mouse: true,
475550
             // Manual bar height (0 = use struts from dock windows)
476551
             bar_height: 0,
477552
             // garbar not enabled by default (enabled when gar.bar table is set)
478553
             bar_enabled: false,
554
+            // garnotify not enabled by default (enabled when gar.notification table is set)
555
+            notification_enabled: false,
479556
             // Monitor order: empty = sort by X position
480557
             monitor_order: Vec::new(),
481
-            // Screen timeout: enabled by default with 10 minute timeout
482
-            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,
483561
             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(),
484566
             // Compositor settings (picom) - matching picom.conf defaults
485567
             corner_radius: 12,
486568
             blur_enabled: true,
gar/src/core/mod.rsmodified
@@ -50,6 +50,8 @@ pub struct WindowManager {
5050
     pub tiled_edge_cursor: Option<(XWindow, XWindow, Direction)>,
5151
     /// garbar child process (managed automatically when gar.bar is configured)
5252
     pub garbar_process: Option<std::process::Child>,
53
+    /// garnotify child process (managed automatically when gar.notification is configured)
54
+    pub garnotify_process: Option<std::process::Child>,
5355
     /// Directional focus memory: (source_window, direction) -> last_target_window
5456
     /// Used to remember which window was focused when navigating in a direction
5557
     pub directional_focus_memory: HashMap<(XWindow, Direction), XWindow>,
@@ -57,7 +59,7 @@ pub struct WindowManager {
5759
 
5860
 impl WindowManager {
5961
     pub fn new(conn: Connection) -> Result<Self> {
60
-        let workspaces: Vec<Workspace> = (1..=10)
62
+        let mut workspaces: Vec<Workspace> = (1..=10)
6163
             .map(|i| Workspace::new(i, i.to_string()))
6264
             .collect();
6365
 
@@ -73,10 +75,8 @@ impl WindowManager {
7375
         // Get config values from Lua state
7476
         let config = lua_state.lock().unwrap().config.clone();
7577
 
76
-        // Generate picom config from settings
77
-        if let Err(e) = config.write_picom_config() {
78
-            tracing::warn!("Failed to generate picom config: {}", e);
79
-        }
78
+        // Start the configured compositor (picom, garchomp, or none)
79
+        config.start_compositor();
8080
 
8181
         // Apply screen timeout/DPMS settings
8282
         config.apply_screen_timeout();
@@ -131,6 +131,9 @@ impl WindowManager {
131131
         for (i, monitor) in monitors.iter_mut().enumerate() {
132132
             monitor.workspaces = vec![i]; // Just track initial workspace
133133
             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
+            }
134137
             tracing::debug!("Monitor '{}' starts with workspace {}", monitor.name, i + 1);
135138
         }
136139
 
@@ -156,6 +159,7 @@ impl WindowManager {
156159
             current_edge_cursor: None,
157160
             tiled_edge_cursor: None,
158161
             garbar_process: None,
162
+            garnotify_process: None,
159163
             directional_focus_memory: HashMap::new(),
160164
         })
161165
     }
@@ -291,6 +295,9 @@ impl WindowManager {
291295
     pub fn refresh_monitors(&mut self) -> Result<()> {
292296
         tracing::info!("Refreshing monitor configuration");
293297
 
298
+        // Update cached screen dimensions (may have changed due to rotation)
299
+        self.conn.update_screen_size();
300
+
294301
         let mut new_monitors = self.conn.detect_monitors().unwrap_or_else(|e| {
295302
             tracing::warn!("Failed to detect monitors: {}, keeping current", e);
296303
             return self.monitors.clone();
@@ -327,6 +334,9 @@ impl WindowManager {
327334
                 monitor.workspaces = vec![first_free];
328335
                 used_workspaces.insert(first_free);
329336
             }
337
+            if monitor.active_workspace < self.workspaces.len() {
338
+                self.workspaces[monitor.active_workspace].last_monitor = Some(i);
339
+            }
330340
             tracing::info!("Monitor '{}' showing workspace {}", monitor.name, monitor.active_workspace + 1);
331341
         }
332342
 
@@ -700,11 +710,22 @@ impl WindowManager {
700710
 
701711
         // Warp pointer to center of focused window (mouse follows focus)
702712
         if warp_pointer {
703
-            if let Err(e) = self.conn.warp_pointer_to_window(window) {
704
-                tracing::warn!("Failed to warp pointer: {}", e);
713
+            // Use stored geometry instead of querying X11 (avoids race with ConfigureWindow)
714
+            if let Some(win) = self.windows.get(&window) {
715
+                let g = &win.current_geometry;
716
+                let center_x = g.x + (g.width as i16 / 2);
717
+                let center_y = g.y + (g.height as i16 / 2);
718
+                if let Err(e) = self.conn.warp_pointer(center_x, center_y) {
719
+                    tracing::warn!("Failed to warp pointer: {}", e);
720
+                }
721
+            } else {
722
+                // Fallback to querying X11 if window not in our map
723
+                if let Err(e) = self.conn.warp_pointer_to_window(window) {
724
+                    tracing::warn!("Failed to warp pointer: {}", e);
725
+                }
705726
             }
706
-            // Record warp time to suppress EnterNotify feedback loop
707727
             self.last_warp = std::time::Instant::now();
728
+            self.conn.flush()?;
708729
         }
709730
 
710731
         Ok(())
@@ -811,6 +832,11 @@ impl WindowManager {
811832
         // Update borders for all windows on visible workspaces
812833
         for ws_idx in visible_ws {
813834
             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
+
814840
                 // Check if window is urgent (and not focused - focused clears urgency)
815841
                 let is_urgent = self.windows.get(&window)
816842
                     .map(|w| w.urgent && Some(window) != focused)
@@ -890,19 +916,42 @@ impl WindowManager {
890916
                     fs_window, screen
891917
                 );
892918
 
893
-                // Configure fullscreen window to cover entire monitor (no gaps, no borders)
894
-                self.conn.configure_window(
895
-                    fs_window,
896
-                    screen.x,
897
-                    screen.y,
898
-                    screen.width,
899
-                    screen.height,
900
-                    0, // No border for fullscreen
901
-                )?;
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
+                    )?;
902951
 
903
-                // Raise fullscreen window above everything
904
-                let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
905
-                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
+                }
906955
 
907956
                 // Skip normal layout for this workspace - fullscreen window covers everything
908957
                 continue;
@@ -962,6 +1011,12 @@ impl WindowManager {
9621011
                         border_width as u16,
9631012
                     )?;
9641013
 
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
+
9651020
                     tracing::debug!(
9661021
                         "apply_layout: TILED+FRAME window={} at ({}, {}) size {}x{} (titlebar: {})",
9671022
                         window, gapped_x, gapped_y, final_width.max(1), final_height.max(1), titlebar_height
@@ -980,6 +1035,15 @@ impl WindowManager {
9801035
                         final_height.max(1),
9811036
                         border_width,
9821037
                     )?;
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)?;
1042
+                }
1043
+
1044
+                // Store the actual geometry for pointer warping
1045
+                if let Some(win) = self.windows.get_mut(window) {
1046
+                    win.current_geometry = Rect::new(gapped_x, gapped_y, final_width.max(1), final_height.max(1));
9831047
                 }
9841048
             }
9851049
 
@@ -988,59 +1052,69 @@ impl WindowManager {
9881052
 
9891053
             for window_id in floating_ids {
9901054
                 // Get the window's floating geometry from our state
991
-                if let Some(win) = self.windows.get(&window_id) {
992
-                    let geom = win.floating_geometry;
993
-                    let adjusted_width = geom.width.saturating_sub(2 * border_width as u16);
994
-                    let adjusted_height = geom.height.saturating_sub(2 * border_width as u16);
995
-
996
-                    let has_frame = win.frame.is_some();
997
-
998
-                    if has_frame && titlebar_enabled {
999
-                        // Configure frame for floating window
1000
-                        let client_height = adjusted_height.saturating_sub(titlebar_height);
1001
-                        self.frames.configure_frame(
1002
-                            &self.conn.conn,
1003
-                            window_id,
1004
-                            geom.x,
1005
-                            geom.y,
1006
-                            adjusted_width.max(1),
1007
-                            client_height.max(1),
1008
-                            titlebar_height,
1009
-                            border_width as u16,
1010
-                        )?;
1011
-
1012
-                        // Raise frame to top of stack
1013
-                        if let Some(frame) = self.frames.frame_for_client(window_id) {
1014
-                            let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
1015
-                            self.conn.conn.configure_window(frame, &aux)?;
1016
-                        }
1017
-
1018
-                        tracing::debug!(
1019
-                            "apply_layout: FLOATING+FRAME window={} at ({}, {}) size {}x{} (raising)",
1020
-                            window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
1055
+                let (geom, has_frame) = match self.windows.get(&window_id) {
1056
+                    Some(win) => (win.floating_geometry, win.frame.is_some()),
1057
+                    None => {
1058
+                        tracing::warn!("apply_layout: floating window {} not in windows map!", window_id);
1059
+                        continue;
1060
+                    }
1061
+                };
1062
+
1063
+                let adjusted_width = geom.width.saturating_sub(2 * border_width as u16);
1064
+                let adjusted_height = geom.height.saturating_sub(2 * border_width as u16);
1065
+
1066
+                if has_frame && titlebar_enabled {
1067
+                    // Configure frame for floating window
1068
+                    let client_height = adjusted_height.saturating_sub(titlebar_height);
1069
+                    self.frames.configure_frame(
1070
+                        &self.conn.conn,
1071
+                        window_id,
1072
+                        geom.x,
1073
+                        geom.y,
1074
+                        adjusted_width.max(1),
1075
+                        client_height.max(1),
1076
+                        titlebar_height,
1077
+                        border_width as u16,
1078
+                    )?;
1079
+
1080
+                    // Raise frame to top of stack
1081
+                    if let Some(frame) = self.frames.frame_for_client(window_id) {
1082
+                        let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
1083
+                        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)
10211087
                         );
10221088
                     } else {
1023
-                        tracing::debug!(
1024
-                            "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
1025
-                            window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
1089
+                        tracing::warn!(
1090
+                            "apply_layout: FLOATING window={} has_frame=true but no frame found!",
1091
+                            window_id
10261092
                         );
1027
-
1028
-                        // Configure geometry
1029
-                        self.conn.configure_window(
1030
-                            window_id,
1031
-                            geom.x,
1032
-                            geom.y,
1033
-                            adjusted_width.max(1),
1034
-                            adjusted_height.max(1),
1035
-                            border_width,
1036
-                        )?;
1037
-
1038
-                        // Raise to top of stack (each subsequent window goes above the previous)
1039
-                        let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
1040
-                        self.conn.conn.configure_window(window_id, &aux)?;
10411093
                     }
10421094
                 } else {
1043
-                    tracing::warn!("apply_layout: floating window {} not in windows map!", window_id);
1095
+                    // Configure geometry
1096
+                    self.conn.configure_window(
1097
+                        window_id,
1098
+                        geom.x,
1099
+                        geom.y,
1100
+                        adjusted_width.max(1),
1101
+                        adjusted_height.max(1),
1102
+                        border_width,
1103
+                    )?;
1104
+
1105
+                    // Raise to top of stack (each subsequent window goes above the previous)
1106
+                    let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
1107
+                    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
+                    );
1113
+                }
1114
+
1115
+                // Store the actual geometry for pointer warping
1116
+                if let Some(win) = self.windows.get_mut(&window_id) {
1117
+                    win.current_geometry = Rect::new(geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1));
10441118
                 }
10451119
             }
10461120
         }
gar/src/core/window.rsmodified
@@ -7,6 +7,8 @@ pub struct Window {
77
     pub id: XWindow,
88
     /// Geometry for floating mode (position and size when floating)
99
     pub floating_geometry: Rect,
10
+    /// Current actual geometry (updated by apply_layout, used for pointer warping)
11
+    pub current_geometry: Rect,
1012
     pub mapped: bool,
1113
     pub focused: bool,
1214
     pub floating: bool,
@@ -30,6 +32,7 @@ impl Window {
3032
         Self {
3133
             id,
3234
             floating_geometry: Rect::new(0, 0, 640, 480),
35
+            current_geometry: Rect::new(0, 0, 640, 480),
3336
             mapped: false,
3437
             focused: false,
3538
             floating: false,
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);
@@ -516,6 +521,11 @@ impl Connection {
516521
         let center_x = (geom.width / 2) as i16;
517522
         let center_y = (geom.height / 2) as i16;
518523
 
524
+        tracing::debug!(
525
+            "Warping pointer to window {}: geom={}x{}+{}+{}, center=({},{})",
526
+            window, geom.width, geom.height, geom.x, geom.y, center_x, center_y
527
+        );
528
+
519529
         self.conn.warp_pointer(
520530
             x11rb::NONE,  // src_window (none = don't check source)
521531
             window,       // dst_window
@@ -764,7 +774,7 @@ impl Connection {
764774
             }
765775
         }
766776
 
767
-        // 3. Check _NET_WM_STATE for modal windows
777
+        // 3. Check _NET_WM_STATE for modal or above windows
768778
         if let Ok(cookie) = self.conn.get_property(
769779
             false,
770780
             window,
@@ -781,6 +791,10 @@ impl Connection {
781791
                             tracing::debug!("Window {} is modal, should float", window);
782792
                             return true;
783793
                         }
794
+                        if atom == self.net_wm_state_above {
795
+                            tracing::debug!("Window {} has ABOVE state, should float", window);
796
+                            return true;
797
+                        }
784798
                     }
785799
                 }
786800
             }
@@ -1062,6 +1076,8 @@ impl Connection {
10621076
             self.net_close_window,
10631077
             self.net_wm_state,
10641078
             self.net_wm_state_fullscreen,
1079
+            self.net_wm_state_modal,
1080
+            self.net_wm_state_above,
10651081
             self.net_wm_name,
10661082
         ];
10671083
         self.conn.change_property32(
@@ -1322,6 +1338,38 @@ impl Connection {
13221338
         )?;
13231339
         Ok(())
13241340
     }
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
+    }
13251373
 }
13261374
 
13271375
 impl std::ops::Deref for Connection {
gar/src/x11/events.rsmodified
@@ -236,6 +236,125 @@ fn reload_garbar(child: &std::process::Child) {
236236
         libc::kill(child.id() as i32, libc::SIGHUP);
237237
     }
238238
 }
239
+
240
+// ============================================================================
241
+// garnotify integration - auto-spawn notification daemon
242
+// ============================================================================
243
+
244
+/// Get garnotify socket path
245
+fn garnotify_socket_path() -> String {
246
+    std::env::var("XDG_RUNTIME_DIR")
247
+        .map(|dir| format!("{}/garnotify.sock", dir))
248
+        .unwrap_or_else(|_| "/tmp/garnotify.sock".to_string())
249
+}
250
+
251
+/// Check if garnotify is healthy (socket exists)
252
+fn is_garnotify_healthy() -> bool {
253
+    std::path::Path::new(&garnotify_socket_path()).exists()
254
+}
255
+
256
+/// Spawn garnotify notification daemon
257
+fn spawn_garnotify() -> Option<std::process::Child> {
258
+    tracing::info!("Spawning garnotify...");
259
+
260
+    // Try to find garnotify in PATH or common locations
261
+    let garnotify_cmd = which_garnotify().unwrap_or_else(|| "garnotify".to_string());
262
+
263
+    // Inherit DISPLAY from current environment
264
+    let x_display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());
265
+
266
+    match Command::new(&garnotify_cmd)
267
+        .arg("daemon")
268
+        .env("DISPLAY", &x_display)
269
+        .spawn()
270
+    {
271
+        Ok(child) => {
272
+            tracing::info!("garnotify started (PID {}), DISPLAY={}", child.id(), x_display);
273
+
274
+            // Wait briefly and verify garnotify becomes healthy
275
+            for attempt in 1..=10 {
276
+                std::thread::sleep(std::time::Duration::from_millis(200));
277
+                if is_garnotify_healthy() {
278
+                    tracing::info!("garnotify socket ready after {}ms", attempt * 200);
279
+                    return Some(child);
280
+                }
281
+            }
282
+
283
+            // Socket never appeared - might still be starting up
284
+            tracing::warn!("garnotify socket not ready after 2s, may still be starting");
285
+            Some(child)
286
+        }
287
+        Err(e) => {
288
+            tracing::error!("Failed to spawn garnotify: {}", e);
289
+            tracing::info!("Hint: Ensure garnotify is installed and in PATH");
290
+            None
291
+        }
292
+    }
293
+}
294
+
295
+/// Find garnotify executable
296
+fn which_garnotify() -> Option<String> {
297
+    // Check if garnotify is in PATH
298
+    if Command::new("which")
299
+        .arg("garnotify")
300
+        .output()
301
+        .map(|o| o.status.success())
302
+        .unwrap_or(false)
303
+    {
304
+        return Some("garnotify".to_string());
305
+    }
306
+
307
+    // Check common cargo install location
308
+    if let Ok(home) = std::env::var("HOME") {
309
+        let cargo_bin = format!("{}/.cargo/bin/garnotify", home);
310
+        if std::path::Path::new(&cargo_bin).exists() {
311
+            return Some(cargo_bin);
312
+        }
313
+    }
314
+
315
+    // Check /usr/local/bin
316
+    if std::path::Path::new("/usr/local/bin/garnotify").exists() {
317
+        return Some("/usr/local/bin/garnotify".to_string());
318
+    }
319
+
320
+    None
321
+}
322
+
323
+/// Stop garnotify gracefully by sending SIGTERM.
324
+fn stop_garnotify(child: &mut std::process::Child) {
325
+    tracing::info!("Stopping garnotify (PID {})...", child.id());
326
+
327
+    // Send SIGTERM for graceful shutdown
328
+    unsafe {
329
+        libc::kill(child.id() as i32, libc::SIGTERM);
330
+    }
331
+
332
+    // Wait briefly for it to exit
333
+    match child.try_wait() {
334
+        Ok(Some(status)) => {
335
+            tracing::info!("garnotify exited with status: {}", status);
336
+        }
337
+        Ok(None) => {
338
+            std::thread::sleep(std::time::Duration::from_millis(100));
339
+            match child.try_wait() {
340
+                Ok(Some(status)) => {
341
+                    tracing::info!("garnotify exited with status: {}", status);
342
+                }
343
+                Ok(None) => {
344
+                    tracing::warn!("garnotify did not exit gracefully, sending SIGKILL");
345
+                    let _ = child.kill();
346
+                }
347
+                Err(e) => {
348
+                    tracing::warn!("Error waiting for garnotify: {}", e);
349
+                }
350
+            }
351
+        }
352
+        Err(e) => {
353
+            tracing::warn!("Error checking garnotify status: {}", e);
354
+        }
355
+    }
356
+}
357
+
239358
 use x11rb::protocol::xproto::{
240359
     ButtonPressEvent, ButtonReleaseEvent, ClientMessageEvent, ConfigureRequestEvent,
241360
     ConfigureWindowAux, ConnectionExt, DestroyNotifyEvent, EnterNotifyEvent, EventMask,
@@ -455,8 +574,15 @@ impl WindowManager {
455574
             Event::EnterNotify(e) => {
456575
                 self.handle_enter_notify(e)?;
457576
             }
458
-            Event::RandrScreenChangeNotify(_) => {
459
-                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;
460586
                 self.refresh_monitors()?;
461587
                 self.broadcast_i3_output_event();
462588
             }
@@ -571,10 +697,13 @@ impl WindowManager {
571697
 
572698
         // Apply layout to all windows
573699
         self.apply_layout()?;
700
+        // Flush to ensure ConfigureWindow requests are processed before we query geometry
701
+        self.conn.flush()?;
574702
 
575
-        // Focus the new window if on a visible workspace
703
+        // Focus and raise the new window if on a visible workspace
576704
         if target_visible {
577705
             self.set_focus(window, true)?;
706
+            self.raise_window(window)?;
578707
         }
579708
 
580709
         Ok(())
@@ -937,8 +1066,11 @@ impl WindowManager {
9371066
                 self.conn.flush()?;
9381067
                 return Ok(());
9391068
             }
940
-            // 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
9411072
             if self.focused_window == Some(window) {
1073
+                self.raise_window(window)?;
9421074
                 self.conn.conn.allow_events(
9431075
                     x11rb::protocol::xproto::Allow::REPLAY_POINTER,
9441076
                     x11rb::CURRENT_TIME,
@@ -1080,13 +1212,30 @@ impl WindowManager {
10801212
                     // Check for tiled edge hover (for cursor feedback)
10811213
                     self.update_tiled_edge_cursor(window, event.root_x, event.root_y)?;
10821214
                 }
1083
-            } else if self.tiled_edge_cursor.is_some() {
1084
-                // Moving to unmanaged window or root - clear tiled edge cursor
1085
-                let (old_w1, old_w2, _) = self.tiled_edge_cursor.unwrap();
1086
-                self.conn.clear_window_cursor(old_w1)?;
1087
-                self.conn.clear_window_cursor(old_w2)?;
1088
-                self.conn.flush()?;
1089
-                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
+                }
10901239
             }
10911240
             return Ok(());
10921241
         }
@@ -1373,31 +1522,21 @@ impl WindowManager {
13731522
         y: i16,
13741523
         geometries: &[(u32, Rect)],
13751524
     ) -> Option<(u32, u32, Direction)> {
1376
-        const EDGE_ZONE: i16 = 32; // Detection zone from window edge (increased for easier grabbing)
1525
+        // Edge zone for resize detection - wide enough to cover gap + some margin
1526
+        // This makes it easy to grab edges: click near the boundary between windows
13771527
         let gap = self.config.gap_inner as i16;
1528
+        let edge_zone = gap + 8; // Gap width plus comfortable margin
13781529
 
13791530
         let left = rect.x;
13801531
         let right = rect.x + rect.width as i16;
13811532
         let top = rect.y;
13821533
         let bottom = rect.y + rect.height as i16;
13831534
 
1384
-        // Calculate distances from each edge
1385
-        let dist_from_left = x - left;
1386
-        let dist_from_right = right - x;
1387
-        let dist_from_top = y - top;
1388
-        let dist_from_bottom = bottom - y;
1389
-
1390
-        debug_log(&format!("EDGE DIST: x={}, y={}, left={}, right={}, top={}, bottom={}", x, y, left, right, top, bottom));
1391
-        debug_log(&format!("EDGE DIST: from_left={}, from_right={}, from_top={}, from_bottom={}, zone={}",
1392
-            dist_from_left, dist_from_right, dist_from_top, dist_from_bottom, EDGE_ZONE));
1393
-
1394
-        // Check each edge
1395
-        let near_left = x >= left && x < left + EDGE_ZONE;
1396
-        let near_right = x > right - EDGE_ZONE && x <= right;
1397
-        let near_top = y >= top && y < top + EDGE_ZONE;
1398
-        let near_bottom = y > bottom - EDGE_ZONE && y <= bottom;
1399
-
1400
-        debug_log(&format!("NEAR EDGES: left={}, right={}, top={}, bottom={}", near_left, near_right, near_top, near_bottom));
1535
+        // Check each edge - trigger if within edge_zone of the window boundary
1536
+        let near_left = x >= left && x < left + edge_zone;
1537
+        let near_right = x > right - edge_zone && x <= right;
1538
+        let near_top = y >= top && y < top + edge_zone;
1539
+        let near_bottom = y > bottom - edge_zone && y <= bottom;
14011540
 
14021541
         // For each edge we're near, look for an adjacent window
14031542
         if near_left {
@@ -1482,7 +1621,12 @@ impl WindowManager {
14821621
         geometries: &[(u32, Rect)],
14831622
     ) -> Option<(u32, u32, Direction)> {
14841623
         let gap = self.config.gap_inner as i16;
1485
-        let tolerance = gap + 8; // Gap width plus some tolerance
1624
+
1625
+        // Log all geometries for debugging
1626
+        for (w, r) in geometries {
1627
+            debug_log(&format!("GAP CHECK GEOM: w={}, x={}, y={}, w={}, h={}, right={}, bottom={}",
1628
+                w, r.x, r.y, r.width, r.height, r.x + r.width as i16, r.y + r.height as i16));
1629
+        }
14861630
 
14871631
         // Check all pairs of windows for horizontal adjacency (vertical split line)
14881632
         for (w1, r1) in geometries {
@@ -1491,14 +1635,22 @@ impl WindowManager {
14911635
                 if w1 == w2 {
14921636
                     continue;
14931637
                 }
1494
-                // Check if w2 is to the right of w1 (within gap distance)
1638
+                // Check if w2 is to the right of w1
14951639
                 let horizontal_gap = r2.x - r1_right;
1496
-                if horizontal_gap >= 0 && horizontal_gap <= tolerance {
1497
-                    // Check if click is in the gap horizontally
1498
-                    if x >= r1_right && x <= r2.x {
1640
+                debug_log(&format!("GAP H CHECK: w1={} right={}, w2={} left={}, gap={}, click_x={}",
1641
+                    w1, r1_right, w2, r2.x, horizontal_gap, x));
1642
+
1643
+                // Allow detection if click is anywhere near the gap area
1644
+                // Gap region is from r1_right to r2.x, but expand by a few pixels for tolerance
1645
+                let gap_left = r1_right - 4;
1646
+                let gap_right = r2.x + 4;
1647
+
1648
+                if horizontal_gap >= 0 && horizontal_gap <= gap + 16 {
1649
+                    if x >= gap_left && x <= gap_right {
14991650
                         // Check vertical overlap at click position
15001651
                         let v_overlap_top = r1.y.max(r2.y);
15011652
                         let v_overlap_bottom = (r1.y + r1.height as i16).min(r2.y + r2.height as i16);
1653
+                        debug_log(&format!("GAP H Y CHECK: y={}, v_top={}, v_bottom={}", y, v_overlap_top, v_overlap_bottom));
15021654
                         if y >= v_overlap_top && y < v_overlap_bottom {
15031655
                             debug_log(&format!("GAP EDGE FOUND H: w1={}, w2={}, gap_x=[{},{}], y_range=[{},{}]",
15041656
                                 w1, w2, r1_right, r2.x, v_overlap_top, v_overlap_bottom));
@@ -1516,11 +1668,15 @@ impl WindowManager {
15161668
                 if w1 == w2 {
15171669
                     continue;
15181670
                 }
1519
-                // Check if w2 is below w1 (within gap distance)
1671
+                // Check if w2 is below w1
15201672
                 let vertical_gap = r2.y - r1_bottom;
1521
-                if vertical_gap >= 0 && vertical_gap <= tolerance {
1522
-                    // Check if click is in the gap vertically
1523
-                    if y >= r1_bottom && y <= r2.y {
1673
+
1674
+                // Allow detection if click is anywhere near the gap area
1675
+                let gap_top = r1_bottom - 4;
1676
+                let gap_bottom = r2.y + 4;
1677
+
1678
+                if vertical_gap >= 0 && vertical_gap <= gap + 16 {
1679
+                    if y >= gap_top && y <= gap_bottom {
15241680
                         // Check horizontal overlap at click position
15251681
                         let h_overlap_left = r1.x.max(r2.x);
15261682
                         let h_overlap_right = (r1.x + r1.width as i16).min(r2.x + r2.width as i16);
@@ -1557,8 +1713,24 @@ impl WindowManager {
15571713
             return Ok(());
15581714
         }
15591715
 
1560
-        // 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.
15611719
         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
+            }
15621734
             return Ok(());
15631735
         }
15641736
 
@@ -1569,10 +1741,18 @@ impl WindowManager {
15691741
 
15701742
         tracing::debug!("Focus follows mouse: focusing window {}", window);
15711743
 
1744
+        let old_workspace_idx = self.focused_workspace;
1745
+
15721746
         // Focus the new window (no warp - mouse enter)
15731747
         // set_focus handles grab/ungrab for old and new windows
15741748
         self.set_focus(window, false)?;
15751749
 
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
+
15761756
         // Raise floating windows on focus
15771757
         if self.is_floating(window) {
15781758
             self.raise_window(window)?;
@@ -1648,6 +1828,19 @@ impl WindowManager {
16481828
                     _ => {}
16491829
                 }
16501830
             }
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
+            }
16511844
         } else {
16521845
             tracing::trace!("Unhandled ClientMessage type: {}", msg_type);
16531846
         }
@@ -1934,43 +2127,50 @@ impl WindowManager {
19342127
             // Check if memory was used (preferred matched target) or default algorithm was used
19352128
             let used_memory = preferred == Some(target);
19362129
 
2130
+            tracing::info!("NAV: {:?} from {} to {}, preferred={:?}, used_memory={}",
2131
+                direction, focused, target, preferred, used_memory);
2132
+
19372133
             // Store the directional focus memory for next time
19382134
             self.directional_focus_memory.insert((focused, direction), target);
2135
+            tracing::info!("NAV: stored ({}, {:?}) -> {}", focused, direction, target);
2136
+
2137
+            // Always store reverse direction if windows are aligned (same row/column)
2138
+            // This ensures "go back" always returns to the window we came from
2139
+            if let (Some((_, from_rect)), Some((_, to_rect))) = (
2140
+                geometries.iter().find(|(w, _)| *w == focused),
2141
+                geometries.iter().find(|(w, _)| *w == target),
2142
+            ) {
2143
+                let overlaps = match direction {
2144
+                    // For Left/Right: store reverse if windows share vertical space (same row)
2145
+                    Direction::Left | Direction::Right => {
2146
+                        let overlap_start = from_rect.y.max(to_rect.y);
2147
+                        let overlap_end = (from_rect.y + from_rect.height as i16)
2148
+                            .min(to_rect.y + to_rect.height as i16);
2149
+                        tracing::info!("NAV: L/R overlap check: from_y={},{} to_y={},{} overlap=[{},{}]",
2150
+                            from_rect.y, from_rect.height, to_rect.y, to_rect.height, overlap_start, overlap_end);
2151
+                        overlap_start < overlap_end
2152
+                    }
2153
+                    // For Up/Down: store reverse if windows share horizontal space (same column)
2154
+                    Direction::Up | Direction::Down => {
2155
+                        let overlap_start = from_rect.x.max(to_rect.x);
2156
+                        let overlap_end = (from_rect.x + from_rect.width as i16)
2157
+                            .min(to_rect.x + to_rect.width as i16);
2158
+                        tracing::info!("NAV: U/D overlap check: from_x={},{} to_x={},{} overlap=[{},{}]",
2159
+                            from_rect.x, from_rect.width, to_rect.x, to_rect.width, overlap_start, overlap_end);
2160
+                        overlap_start < overlap_end
2161
+                    }
2162
+                };
19392163
 
1940
-            // Store reverse direction only if:
1941
-            // 1. Default algorithm was used (not memory-assisted jump that skipped windows)
1942
-            // 2. Windows are aligned (same row/column)
1943
-            if !used_memory {
1944
-                if let (Some((_, from_rect)), Some((_, to_rect))) = (
1945
-                    geometries.iter().find(|(w, _)| *w == focused),
1946
-                    geometries.iter().find(|(w, _)| *w == target),
1947
-                ) {
1948
-                    let dominated = match direction {
1949
-                        // For Left/Right: store reverse if windows share vertical space (same row)
1950
-                        Direction::Left | Direction::Right => {
1951
-                            let overlap_start = from_rect.y.max(to_rect.y);
1952
-                            let overlap_end = (from_rect.y + from_rect.height as i16)
1953
-                                .min(to_rect.y + to_rect.height as i16);
1954
-                            overlap_start < overlap_end
1955
-                        }
1956
-                        // For Up/Down: store reverse if windows share horizontal space (same column)
1957
-                        Direction::Up | Direction::Down => {
1958
-                            let overlap_start = from_rect.x.max(to_rect.x);
1959
-                            let overlap_end = (from_rect.x + from_rect.width as i16)
1960
-                                .min(to_rect.x + to_rect.width as i16);
1961
-                            overlap_start < overlap_end
1962
-                        }
2164
+                tracing::info!("NAV: overlaps={}", overlaps);
2165
+                if overlaps {
2166
+                    let opposite = match direction {
2167
+                        Direction::Left => Direction::Right,
2168
+                        Direction::Right => Direction::Left,
2169
+                        Direction::Up => Direction::Down,
2170
+                        Direction::Down => Direction::Up,
19632171
                     };
1964
-
1965
-                    if dominated {
1966
-                        let opposite = match direction {
1967
-                            Direction::Left => Direction::Right,
1968
-                            Direction::Right => Direction::Left,
1969
-                            Direction::Up => Direction::Down,
1970
-                            Direction::Down => Direction::Up,
1971
-                        };
1972
-                        self.directional_focus_memory.insert((target, opposite), focused);
1973
-                    }
2172
+                    self.directional_focus_memory.insert((target, opposite), focused);
2173
+                    tracing::info!("NAV: stored reverse ({}, {:?}) -> {}", target, opposite, focused);
19742174
                 }
19752175
             }
19762176
 
@@ -2027,12 +2227,16 @@ impl WindowManager {
20272227
         };
20282228
 
20292229
         tracing::info!("Moving focus from monitor {} to {}", self.focused_monitor, target_idx);
2230
+        let old_workspace_idx = self.focused_workspace;
20302231
         self.focused_monitor = target_idx;
20312232
 
20322233
         // Focus the active workspace on that monitor
20332234
         let workspace_idx = self.monitors[target_idx].active_workspace;
20342235
         self.focused_workspace = workspace_idx;
20352236
 
2237
+        // Update EWMH
2238
+        self.conn.set_current_desktop(workspace_idx as u32)?;
2239
+
20362240
         // Focus a window on that workspace if any, or just warp to monitor center
20372241
         if let Some(window) = self.workspaces[workspace_idx].focused
20382242
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
@@ -2043,10 +2247,16 @@ impl WindowManager {
20432247
         } else {
20442248
             // No windows on target monitor - clear focus and warp to monitor center
20452249
             self.focused_window = None;
2250
+            self.conn.set_active_window(None)?;
20462251
             self.warp_to_monitor(target_idx)?;
20472252
             tracing::debug!("No windows on monitor {}, warped to center", target_idx);
20482253
         }
20492254
 
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
+
20502260
         self.conn.flush()?;
20512261
         Ok(())
20522262
     }
@@ -2115,6 +2325,14 @@ impl WindowManager {
21152325
         Ok(())
21162326
     }
21172327
 
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
+
21182336
     /// Execute an i3-compatible command (from IPC RUN_COMMAND).
21192337
     /// Returns true if the command was executed successfully.
21202338
     fn execute_i3_command(&mut self, cmd: &str) -> bool {
@@ -2199,6 +2417,7 @@ impl WindowManager {
21992417
             // Focus the monitor that has this workspace
22002418
             self.focused_monitor = monitor_idx;
22012419
             self.focused_workspace = idx;
2420
+            self.workspaces[idx].last_monitor = Some(monitor_idx);
22022421
 
22032422
             // Update EWMH
22042423
             self.conn.set_current_desktop(idx as u32)?;
@@ -2223,13 +2442,16 @@ impl WindowManager {
22232442
                 self.conn.set_active_window(None)?;
22242443
             }
22252444
         } else {
2226
-            // Workspace not visible - show it on current monitor (i3 behavior)
2227
-            let current_monitor = self.focused_monitor;
2228
-            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;
22292451
 
22302452
             tracing::info!(
22312453
                 "Switching monitor {} from workspace {} to {}",
2232
-                current_monitor, old_ws + 1, idx + 1
2454
+                target_monitor, old_ws + 1, idx + 1
22332455
             );
22342456
 
22352457
             // Hide windows on old workspace
@@ -2246,7 +2468,9 @@ impl WindowManager {
22462468
             }
22472469
 
22482470
             // Update monitor's active workspace
2249
-            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;
22502474
             self.focused_workspace = idx;
22512475
 
22522476
             // Update EWMH
@@ -2274,7 +2498,7 @@ impl WindowManager {
22742498
                 self.focused_window = None;
22752499
                 self.conn.set_active_window(None)?;
22762500
                 if warp_pointer {
2277
-                    let monitor_geom = self.monitors[current_monitor].geometry;
2501
+                    let monitor_geom = self.monitors[target_monitor].geometry;
22782502
                     let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
22792503
                     let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
22802504
                     self.conn.warp_pointer(center_x, center_y)?;
@@ -2503,6 +2727,11 @@ impl WindowManager {
25032727
             self.garbar_process = spawn_garbar();
25042728
         }
25052729
 
2730
+        // Spawn garnotify if gar.notification is configured
2731
+        if self.config.notification_enabled {
2732
+            self.garnotify_process = spawn_garnotify();
2733
+        }
2734
+
25062735
         while self.running {
25072736
             // Handle X11 events (non-blocking poll)
25082737
             while let Some(event) = self.conn.conn.poll_for_event()? {
@@ -2533,17 +2762,31 @@ impl WindowManager {
25332762
         let _ = self.conn.sync();
25342763
         tracing::info!("Windows unmapped and synced");
25352764
 
2765
+        // Kill all processes spawned via gar.exec()/gar.exec_once()
2766
+        if let Ok(state) = self.lua_state.lock() {
2767
+            state.kill_spawned_children();
2768
+        }
2769
+
25362770
         // Stop garbar if it was spawned
25372771
         if let Some(ref mut child) = self.garbar_process {
25382772
             stop_garbar(child);
25392773
         }
25402774
         self.garbar_process = None;
25412775
 
2542
-        // Kill picom to prevent compositor effects from bleeding into the greeter
2543
-        tracing::info!("Killing picom...");
2776
+        // Stop garnotify if it was spawned
2777
+        if let Some(ref mut child) = self.garnotify_process {
2778
+            stop_garnotify(child);
2779
+        }
2780
+        self.garnotify_process = None;
2781
+
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)
25442785
         let _ = std::process::Command::new("pkill")
2545
-            .arg("-x")
2546
-            .arg("picom")
2786
+            .args(["-f", "garchomp"])
2787
+            .status();
2788
+        let _ = std::process::Command::new("pkill")
2789
+            .args(["-f", "picom"])
25472790
             .status();
25482791
 
25492792
         // Signal systemd that graphical session has ended
@@ -2654,6 +2897,18 @@ impl WindowManager {
26542897
                     Err(e) => Response::error(e.to_string()),
26552898
                 }
26562899
             }
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
+            }
26572912
             "reload" => {
26582913
                 match self.reload_config() {
26592914
                     Ok(_) => Response::success(None),
@@ -2832,9 +3087,15 @@ impl WindowManager {
28323087
         // Update stacking order in workspace's floating list
28333088
         self.current_workspace_mut().raise_floating(window);
28343089
 
2835
-        // Raise in X11
3090
+        // Raise in X11 - if window has a frame, raise the frame instead
28363091
         let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
2837
-        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
+        }
28383099
         self.conn.flush()?;
28393100
         Ok(())
28403101
     }
@@ -2872,12 +3133,16 @@ impl WindowManager {
28723133
         }
28733134
 
28743135
         tracing::info!("Focusing monitor {}: '{}'", target_idx, self.monitors[target_idx].name);
3136
+        let old_workspace_idx = self.focused_workspace;
28753137
         self.focused_monitor = target_idx;
28763138
 
28773139
         // Focus the active workspace on that monitor
28783140
         let workspace_idx = self.monitors[target_idx].active_workspace;
28793141
         self.focused_workspace = workspace_idx;
28803142
 
3143
+        // Update EWMH
3144
+        self.conn.set_current_desktop(workspace_idx as u32)?;
3145
+
28813146
         // Focus a window on that workspace if any, or warp to monitor center
28823147
         if let Some(window) = self.workspaces[workspace_idx].focused
28833148
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
@@ -2886,11 +3151,17 @@ impl WindowManager {
28863151
             // set_focus handles grab/ungrab for old and new windows
28873152
             self.set_focus(window, true)?;
28883153
         } else {
2889
-            // No windows - warp to monitor center
3154
+            // No windows - clear EWMH active window and warp to monitor center
28903155
             self.focused_window = None;
3156
+            self.conn.set_active_window(None)?;
28913157
             self.warp_to_monitor(target_idx)?;
28923158
         }
28933159
 
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
+
28943165
         self.conn.flush()?;
28953166
         Ok(())
28963167
     }
@@ -2937,12 +3208,18 @@ impl WindowManager {
29373208
             window, target_idx, self.monitors[target_idx].name, target_workspace + 1);
29383209
 
29393210
         // Remove from current workspace
3211
+        let source_ws = self.focused_workspace;
29403212
         if is_floating {
29413213
             self.current_workspace_mut().remove_floating(window);
29423214
         } else {
29433215
             self.current_workspace_mut().tree.remove(window);
29443216
         }
29453217
 
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
+
29463223
         // Update window's workspace
29473224
         if let Some(win) = self.windows.get_mut(&window) {
29483225
             win.workspace = target_workspace;
@@ -2961,16 +3238,25 @@ impl WindowManager {
29613238
         self.conn.set_window_desktop(window, target_workspace as u32)?;
29623239
 
29633240
         // Focus follows window to new monitor
3241
+        let old_workspace_idx = self.focused_workspace;
29643242
         self.focused_monitor = target_idx;
29653243
         self.focused_workspace = target_workspace;
29663244
         self.workspaces[target_workspace].focused = Some(window);
29673245
 
3246
+        // Update EWMH
3247
+        self.conn.set_current_desktop(target_workspace as u32)?;
3248
+
29683249
         // Apply layouts on both monitors
29693250
         self.apply_layout()?;
29703251
 
29713252
         // Set X11 focus on the moved window (updates focused_window, button grabs, EWMH)
29723253
         self.set_focus(window, true)?;
29733254
 
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
+
29743260
         self.conn.flush()?;
29753261
         Ok(())
29763262
     }
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
         }