Comparing changes

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

base: v0.1.0
Choose a base ref
v0.1.0 trunk default
compare: trunk
Choose a head ref
trunk default
Create pull request
Able to merge. These branches can be automatically merged.
60 commits 16 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.1.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.1.0"
220
+version = "0.3.0"
221221
 dependencies = [
222222
  "clap",
223223
  "serde",
Cargo.tomlmodified
@@ -4,7 +4,7 @@ resolver = "2"
44
 default-members = ["gar"]
55
 
66
 [workspace.package]
7
-version = "0.1.0"
7
+version = "0.3.0"
88
 edition = "2024"
99
 authors = ["mfwolffe"]
1010
 license = "MIT"
examples/init.luaadded
@@ -0,0 +1,473 @@
1
+-- gar default configuration
2
+-- Copy to ~/.config/gar/init.lua to customize
3
+
4
+-- Autostart applications (only run once per session)
5
+-- gar.exec_once("random-bg cold")  -- Pick random wallpaper from a themed directory
6
+-- garbg daemon is managed by systemd (garbg.service) with internal X11 retry
7
+gar.exec_once("garbg set ~/Pictures/background/cold/ --random")
8
+
9
+-- Screen locker daemon
10
+gar.exec_once("garlock daemon")
11
+
12
+-- Clipboard manager daemon
13
+gar.exec_once("garclip daemon --foreground")
14
+
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
+
25
+-- Uncomment the ones you want:
26
+-- gar.exec_once("picom")                      -- Compositor (for transparency/shadows)
27
+-- gar.exec_once("dunst")                      -- Notification daemon
28
+-- gar.exec_once("nm-applet")                  -- NetworkManager tray icon
29
+-- gar.exec_once("blueman-applet")             -- Bluetooth tray icon
30
+-- gar.exec_once("xss-lock -- i3lock -c 000000")    -- Auto-lock on suspend
31
+
32
+-- Appearance
33
+gar.set("border_width", 2)
34
+gar.set("border_color_focused", "#5294e2")
35
+gar.set("border_color_unfocused", "#2d2d2d")
36
+gar.set("gap_inner", 12)
37
+gar.set("gap_outer", 16)
38
+
39
+-- Visual Effects (picom compositor)
40
+-- gar auto-generates ~/.config/gar/picom.conf from these settings
41
+-- Changes take effect on reload (Mod+Shift+R) - picom is signaled automatically
42
+gar.set("corner_radius", 18)              -- 0 = square corners
43
+gar.set("blur_enabled", true)
44
+gar.set("blur_method", "dual_kawase")     -- "gaussian", "dual_kawase", "box"
45
+gar.set("blur_strength", 5)               -- 1-20 for dual_kawase
46
+gar.set("shadow_enabled", true)
47
+gar.set("shadow_radius", 12)
48
+gar.set("shadow_opacity", 0.75)
49
+gar.set("shadow_offset_x", -7)
50
+gar.set("shadow_offset_y", -7)
51
+gar.set("opacity_focused", 0.94)
52
+gar.set("opacity_unfocused", 0.6)         -- Set to 0.9 to dim unfocused windows
53
+gar.set("fade_enabled", true)
54
+gar.set("fade_delta", 3)
55
+
56
+-- Title bars (disabled by default)
57
+-- gar.set("titlebar_enabled", true)
58
+-- gar.set("titlebar_height", 20)
59
+-- gar.set("titlebar_color_focused", "#3d3d3d")
60
+-- gar.set("titlebar_color_unfocused", "#2d2d2d")
61
+-- gar.set("titlebar_text_color", "#ffffff")
62
+
63
+-- Border gradients (purple to blue theme)
64
+-- direction options: "vertical", "horizontal", "diagonal"
65
+gar.set("border_gradient_enabled", true)
66
+gar.set("border_gradient_start_focused", "#9b59b6")    -- Purple
67
+gar.set("border_gradient_end_focused", "#3498db")      -- Blue
68
+gar.set("border_gradient_start_unfocused", "#4a235a")  -- Dark purple
69
+gar.set("border_gradient_end_unfocused", "#1a5276")    -- Dark blue
70
+gar.set("border_gradient_direction", "diagonal")
71
+
72
+-- Animations (picom v12+)
73
+-- open options: "slide-in", "fly-in", "appear", "none"
74
+-- close options: "slide-out", "fly-out", "disappear", "none"
75
+gar.set("animation_open", "appear")
76
+gar.set("animation_close", "disappear")
77
+gar.set("animation_duration", 0.15)      -- seconds
78
+
79
+-- Custom GLSL shader (picom, requires GLX backend)
80
+-- Bundled shaders: ~/.config/gar/shaders/focused-glow.glsl, dim-unfocused.glsl
81
+-- gar.set("picom_shader", "~/.config/gar/shaders/focused-glow.glsl")
82
+
83
+-- Per-window picom rules
84
+-- NOTE: When using rules, picom ignores old-style options (inactive-opacity, shadow-exclude, etc.)
85
+-- Only enable rules if you want full control via rules-based config
86
+-- Available rule options: corner_radius, opacity, shadow, blur_background, shader
87
+-- Match syntax: class_g = 'ClassName', class_i = 'instance', name = 'title', window_type = 'type'
88
+-- gar.picom_rule({
89
+--     match = "class_g = 'Alacritty'",
90
+--     blur_background = true,
91
+--     opacity = 0.92,
92
+-- })
93
+-- gar.picom_rule({
94
+--     match = "class_g = 'firefox'",
95
+--     corner_radius = 8,
96
+-- })
97
+
98
+-- garbar status bar (native gar integration)
99
+-- When this table is present, gar automatically spawns and manages garbar
100
+gar.bar = {
101
+    height = 32,
102
+    position = "top",
103
+    background = "#1a1a1a",
104
+    foreground = "#ffffff",
105
+    opacity = 1.0,
106
+    fonts = {
107
+        "JetBrainsMono Nerd Font:size=10",
108
+        "Symbols Nerd Font:size=10",
109
+    },
110
+    padding = { left = 8, right = 16, top = 0, bottom = 0 },
111
+
112
+    -- Module layout
113
+    modules_left = { "workspaces", "window_title" },
114
+    modules_center = {},
115
+    modules_right = { "almanta", "filesystem", "memory", "cpu", "battery", "wlan", "volume", "tray", "datetime", "quick_settings" },
116
+
117
+    -- Module configurations
118
+    modules = {
119
+        workspaces = {
120
+            font_size = 11,
121
+            focused = {
122
+                foreground = "#ffffff",
123
+                background = "transparent",
124
+                underline = { width = 2, color = "#33ccff" },
125
+            },
126
+            unfocused = {
127
+                foreground = "#666666",
128
+                background = "transparent",
129
+            },
130
+            urgent = {
131
+                foreground = "#ffffff",
132
+                background = "#ff5555",
133
+            },
134
+        },
135
+        window_title = {
136
+            max_length = 50,
137
+            empty_text = "Desktop",
138
+        },
139
+        datetime = {
140
+            format = " %a %b %d   %H:%M",
141
+        },
142
+        cpu = {
143
+            format = " {usage}%",
144
+        },
145
+        memory = {
146
+            format = " {percent}%",
147
+        },
148
+        battery = {
149
+            device = "auto",
150
+            format_charging = " {percent}%",
151
+            format_discharging = " {percent}%",
152
+            format_full = " Full",
153
+        },
154
+        -- Script modules (custom commands)
155
+        script = {
156
+            almanta = {
157
+            exec = [[
158
+HOST="espadon@almanta"
159
+SSH_OPTS="-T -o BatchMode=yes -o ConnectTimeout=2 -o ConnectionAttempts=1"
160
+if ! ssh $SSH_OPTS "$HOST" "echo ok" >/dev/null 2>&1; then
161
+  echo "almanta: down"
162
+  exit 0
163
+fi
164
+load=$(ssh $SSH_OPTS "$HOST" "cut -d' ' -f1 /proc/loadavg" 2>/dev/null || echo "?")
165
+mem=$(ssh $SSH_OPTS "$HOST" "free | grep '^Mem:' | awk '{printf \"%.0f\", (\$3/\$2)*100}'" 2>/dev/null || echo "?")
166
+disk=$(ssh $SSH_OPTS "$HOST" "df / | tail -1 | awk '{print \$5}' | tr -d '%'" 2>/dev/null || echo "?")
167
+echo "almanta L:$load M:$mem% D:$disk%"
168
+]],
169
+            interval = 30,
170
+        },
171
+        filesystem = {
172
+            exec = [[df -h / | awk 'NR==2 {print " " $4}']],
173
+            interval = 60,
174
+        },
175
+        wlan = {
176
+            exec = [[
177
+IFACE="wlp1s0f0"
178
+if [ -d "/sys/class/net/$IFACE" ]; then
179
+  STATE=$(cat /sys/class/net/$IFACE/operstate 2>/dev/null)
180
+  if [ "$STATE" = "up" ]; then
181
+    ESSID=$(iwgetid -r 2>/dev/null || echo "")
182
+    IP=$(ip -4 addr show $IFACE 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
183
+    if [ -n "$ESSID" ]; then
184
+      echo " $ESSID $IP"
185
+    else
186
+      echo " connected"
187
+    fi
188
+  else
189
+    echo " offline"
190
+  fi
191
+else
192
+  echo " N/A"
193
+fi
194
+]],
195
+            interval = 5,
196
+        },
197
+        volume = {
198
+            exec = [[
199
+if command -v pamixer >/dev/null 2>&1; then
200
+  if pamixer --get-mute | grep -q true; then
201
+    echo " muted"
202
+  else
203
+    VOL=$(pamixer --get-volume)
204
+    echo " $VOL%"
205
+  fi
206
+elif command -v pactl >/dev/null 2>&1; then
207
+  VOL=$(pactl get-sink-volume @DEFAULT_SINK@ | grep -oP '\d+%' | head -1)
208
+  MUTE=$(pactl get-sink-mute @DEFAULT_SINK@ | grep -oP 'yes|no')
209
+  if [ "$MUTE" = "yes" ]; then
210
+    echo " muted"
211
+  else
212
+    echo " $VOL"
213
+  fi
214
+else
215
+  echo " N/A"
216
+fi
217
+]],
218
+            interval = 1,
219
+        },
220
+        },
221
+    },
222
+}
223
+
224
+-- Behavior
225
+gar.set("follow_window_on_move", true)  -- Follow window when using Mod+Shift+number
226
+
227
+-- Mod key: "mod" = Super/Win, "alt" = Alt
228
+-- Use "mod" for real X session, "alt" for nested testing (Xephyr)
229
+local mod = "mod"
230
+
231
+-- Terminal (fallback)
232
+gar.bind(mod .. "+Return", function()
233
+    gar.exec("alacritty || kitty || foot || xterm")
234
+end)
235
+
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
+
326
+-- Close window
327
+gar.bind(mod .. "+q", gar.close_window)
328
+
329
+-- Reload config
330
+gar.bind(mod .. "+shift+r", gar.reload)
331
+
332
+-- Exit gar (return to display manager)
333
+gar.bind(mod .. "+shift+e", gar.exit)
334
+
335
+-- Focus navigation (arrow keys)
336
+gar.bind(mod .. "+Left", gar.focus("left"))
337
+gar.bind(mod .. "+Right", gar.focus("right"))
338
+gar.bind(mod .. "+Up", gar.focus("up"))
339
+gar.bind(mod .. "+Down", gar.focus("down"))
340
+
341
+-- Focus navigation (vim keys)
342
+gar.bind(mod .. "+h", gar.focus("left"))
343
+gar.bind(mod .. "+l", gar.focus("right"))
344
+gar.bind(mod .. "+k", gar.focus("up"))
345
+gar.bind(mod .. "+j", gar.focus("down"))
346
+
347
+-- Swap windows
348
+gar.bind(mod .. "+shift+Left", gar.swap("left"))
349
+gar.bind(mod .. "+shift+Right", gar.swap("right"))
350
+gar.bind(mod .. "+shift+Up", gar.swap("up"))
351
+gar.bind(mod .. "+shift+Down", gar.swap("down"))
352
+
353
+gar.bind(mod .. "+shift+h", gar.swap("left"))
354
+gar.bind(mod .. "+shift+l", gar.swap("right"))
355
+gar.bind(mod .. "+shift+k", gar.swap("up"))
356
+gar.bind(mod .. "+shift+j", gar.swap("down"))
357
+
358
+-- Resize
359
+gar.bind(mod .. "+ctrl+Left", gar.resize("left", 0.05))
360
+gar.bind(mod .. "+ctrl+Right", gar.resize("right", 0.05))
361
+gar.bind(mod .. "+ctrl+Up", gar.resize("up", 0.05))
362
+gar.bind(mod .. "+ctrl+Down", gar.resize("down", 0.05))
363
+
364
+gar.bind(mod .. "+ctrl+h", gar.resize("left", 0.05))
365
+gar.bind(mod .. "+ctrl+l", gar.resize("right", 0.05))
366
+gar.bind(mod .. "+ctrl+k", gar.resize("up", 0.05))
367
+gar.bind(mod .. "+ctrl+j", gar.resize("down", 0.05))
368
+
369
+-- Equalize splits
370
+gar.bind(mod .. "+e", gar.equalize)
371
+
372
+-- Toggle floating
373
+gar.bind(mod .. "+f", gar.toggle_floating)
374
+
375
+-- Toggle fullscreen
376
+gar.bind(mod .. "+shift+f", gar.toggle_fullscreen)
377
+
378
+-- Cycle through floating windows
379
+gar.bind(mod .. "+grave", gar.cycle_floating)  -- Mod+` (backtick)
380
+
381
+-- Workspaces
382
+for i = 1, 9 do
383
+    gar.bind(mod .. "+" .. i, gar.workspace(i))
384
+    gar.bind(mod .. "+shift+" .. i, gar.move_to_workspace(i))
385
+end
386
+gar.bind(mod .. "+0", gar.workspace(10))
387
+gar.bind(mod .. "+shift+0", gar.move_to_workspace(10))
388
+
389
+-- Multi-monitor (comma/period = prev/next)
390
+gar.bind(mod .. "+comma", gar.focus_monitor("prev"))
391
+gar.bind(mod .. "+period", gar.focus_monitor("next"))
392
+gar.bind(mod .. "+shift+comma", gar.move_to_monitor("prev"))
393
+gar.bind(mod .. "+shift+period", gar.move_to_monitor("next"))
394
+
395
+-- Workspace cycling
396
+gar.bind(mod .. "+Tab", gar.workspace_next())
397
+gar.bind(mod .. "+shift+Tab", gar.workspace_prev())
398
+
399
+-- Launchers (garlaunch)
400
+local garlaunch = os.getenv("HOME") .. "/.cargo/bin/garlaunch"
401
+gar.bind(mod .. "+space", function()
402
+    gar.exec(garlaunch .. " --mode drun 2>/tmp/garlaunch-debug.log")
403
+end)
404
+gar.bind(mod .. "+shift+space", function()
405
+    gar.exec(garlaunch .. " --mode window 2>/tmp/garlaunch-debug.log")
406
+end)
407
+gar.bind(mod .. "+r", function()
408
+    gar.exec(garlaunch .. " --mode run 2>/tmp/garlaunch-debug.log")
409
+end)
410
+
411
+-- dmenu alternative (if rofi not available)
412
+gar.bind(mod .. "+p", function()
413
+    gar.exec("dmenu_run")
414
+end)
415
+
416
+-- Screenshot (requires scrot or maim)
417
+gar.bind("Print", function()
418
+    gar.exec("scrot -e 'mv $f ~/Pictures/' || maim ~/Pictures/screenshot-$(date +%s).png")
419
+end)
420
+gar.bind(mod .. "+Print", function()
421
+    gar.exec("scrot -s -e 'mv $f ~/Pictures/' || maim -s ~/Pictures/screenshot-$(date +%s).png")
422
+end)
423
+
424
+-- Screenshot (garshot)
425
+gar.bind("ctrl+shift+3", function()
426
+    gar.exec("garshot select --annotate")  -- Region selection with annotation editor
427
+end)
428
+gar.bind("ctrl+shift+4", function()
429
+    gar.exec("garshot select")  -- Interactive region selection with blur overlay
430
+end)
431
+gar.bind("ctrl+shift+5", function()
432
+    gar.exec("garshot screen")  -- Full screen capture
433
+end)
434
+gar.bind("ctrl+shift+6", function()
435
+    gar.exec("garshot window")  -- Active window capture
436
+end)
437
+
438
+-- Slideshow mode (Mod+Shift+P to avoid conflict with dmenu on Mod+P)
439
+gar.bind(mod .. "+shift+p", function() gar.exec("garbg set ~/Pictures/background/cold/ --random --interval 2m") end)
440
+
441
+-- Lock screen (requires i3lock, swaylock, or slock)
442
+gar.bind(mod .. "+Escape", function()
443
+    gar.exec("i3lock -c 000000 || swaylock -c 000000 || slock")
444
+end)
445
+
446
+-- Lock screen with garlock (via daemon)
447
+gar.bind(mod .. "+shift+q", function()
448
+    gar.exec("garlock lock")
449
+end)
450
+
451
+-- Volume controls (requires pactl/pamixer)
452
+gar.bind("XF86AudioRaiseVolume", function()
453
+    gar.exec("pactl set-sink-volume @DEFAULT_SINK@ +5% || pamixer -i 5")
454
+end)
455
+gar.bind("XF86AudioLowerVolume", function()
456
+    gar.exec("pactl set-sink-volume @DEFAULT_SINK@ -5% || pamixer -d 5")
457
+end)
458
+gar.bind("XF86AudioMute", function()
459
+    gar.exec("pactl set-sink-mute @DEFAULT_SINK@ toggle || pamixer -t")
460
+end)
461
+
462
+-- Brightness controls (requires brightnessctl or light)
463
+gar.bind("XF86MonBrightnessUp", function()
464
+    gar.exec("brightnessctl set +10% || light -A 10")
465
+end)
466
+gar.bind("XF86MonBrightnessDown", function()
467
+    gar.exec("brightnessctl set 10%- || light -U 10")
468
+end)
469
+
470
+-- Clipboard history (garclip)
471
+gar.bind(mod .. "+shift+v", function()
472
+    gar.exec("garclip-picker")
473
+end)
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.specadded
@@ -0,0 +1,49 @@
1
+Name:           gar
2
+Version:        0.1.0
3
+Release:        1%{?dist}
4
+Summary:        Tiling window manager with smart splits
5
+
6
+License:        MIT
7
+URL:            https://github.com/gardesk/gar
8
+Source0:        %{name}-%{version}.tar.gz
9
+
10
+BuildRequires:  rust >= 1.75
11
+BuildRequires:  cargo
12
+BuildRequires:  libxcb-devel
13
+BuildRequires:  lua-devel
14
+
15
+# Disable debug package
16
+%global debug_package %{nil}
17
+
18
+%description
19
+Gar is a tiling window manager for X11 built in Rust. Features smart window
20
+splits, workspace management, and Lua configuration. Part of the gardesk
21
+desktop environment suite.
22
+
23
+%prep
24
+%autosetup
25
+
26
+%build
27
+export CARGO_TARGET_DIR=target
28
+cargo build --release --workspace
29
+
30
+%install
31
+install -Dm755 target/release/gar %{buildroot}%{_bindir}/gar
32
+install -Dm755 target/release/garctl %{buildroot}%{_bindir}/garctl
33
+install -Dm644 gar.desktop %{buildroot}%{_datadir}/xsessions/gar.desktop
34
+install -Dm755 start-gar.sh %{buildroot}%{_bindir}/start-gar
35
+install -Dm755 gar-session.sh %{buildroot}%{_bindir}/gar-session
36
+
37
+%files
38
+%{_bindir}/gar
39
+%{_bindir}/garctl
40
+%{_bindir}/start-gar
41
+%{_bindir}/gar-session
42
+%{_datadir}/xsessions/gar.desktop
43
+
44
+%changelog
45
+* Fri Jan 17 2025 mfw <espadonne@outlook.com> - 0.1.0-1
46
+- Initial RPM release of gar
47
+- Tiling window manager with smart splits
48
+- Lua configuration support
49
+- Part of gardesk desktop suite
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
         {
@@ -268,6 +317,15 @@ impl LuaConfig {
268317
                         }
269318
                     }
270319
                 }
320
+                "border_color_swap_target" => {
321
+                    if let Value::String(s) = value {
322
+                        if let Ok(str_val) = s.to_str() {
323
+                            if let Some(color) = parse_color(&str_val) {
324
+                                state.config.border_color_swap_target = color;
325
+                            }
326
+                        }
327
+                    }
328
+                }
271329
                 "gap_inner" => {
272330
                     if let Value::Integer(v) = value {
273331
                         state.config.gap_inner = v as u32;
@@ -325,11 +383,42 @@ impl LuaConfig {
325383
                         state.config.mouse_follows_focus = v;
326384
                     }
327385
                 }
386
+                "focus_follows_mouse" => {
387
+                    if let Value::Boolean(v) = value {
388
+                        state.config.focus_follows_mouse = v;
389
+                    }
390
+                }
328391
                 "bar_height" => {
329392
                     if let Value::Integer(v) = value {
330393
                         state.config.bar_height = v as u32;
331394
                     }
332395
                 }
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
+                }
333422
                 // Compositor visual settings (picom)
334423
                 "corner_radius" => {
335424
                     if let Value::Integer(v) = value {
@@ -482,6 +571,34 @@ impl LuaConfig {
482571
                         }
483572
                     }
484573
                 }
574
+                // Monitor ordering: list of monitor names in left-to-right order
575
+                "monitor_order" => {
576
+                    if let Value::Table(t) = value {
577
+                        let mut order = Vec::new();
578
+                        for pair in t.pairs::<i64, String>() {
579
+                            if let Ok((_, name)) = pair {
580
+                                order.push(name);
581
+                            }
582
+                        }
583
+                        if !order.is_empty() {
584
+                            tracing::info!("Monitor order configured: {:?}", order);
585
+                            state.config.monitor_order = order;
586
+                        }
587
+                    }
588
+                }
589
+                // Screen timeout settings
590
+                "screen_timeout_enabled" => {
591
+                    if let Value::Boolean(v) = value {
592
+                        state.config.screen_timeout_enabled = v;
593
+                        tracing::info!("Screen timeout enabled: {}", v);
594
+                    }
595
+                }
596
+                "screen_timeout" => {
597
+                    if let Value::Integer(v) = value {
598
+                        state.config.screen_timeout_seconds = v as u32;
599
+                        tracing::info!("Screen timeout set to {} seconds", v);
600
+                    }
601
+                }
485602
                 _ => {
486603
                     tracing::warn!("Unknown config key: {}", key);
487604
                 }
@@ -582,33 +699,54 @@ impl LuaConfig {
582699
     }
583700
 
584701
     fn register_exec(&self, gar: &Table) -> LuaResult<()> {
585
-        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| {
586705
             tracing::debug!("exec: {}", cmd);
587
-            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")
588709
                 .arg("-c")
589710
                 .arg(&cmd)
711
+                .process_group(0)
590712
                 .spawn()
591
-                .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
+            }
592720
             Ok(())
593721
         })?;
594722
         gar.set("exec", exec_fn)?;
595723
 
596724
         // gar.exec_once(cmd) - only run if not already run this session
597
-        let state = Arc::clone(&self.state);
725
+        let state_once = Arc::clone(&self.state);
598726
         let exec_once_fn = self.lua.create_function(move |_, cmd: String| {
599
-            let mut state = state.lock().unwrap();
600
-            if state.exec_once_cmds.contains(&cmd) {
601
-                tracing::debug!("exec_once: skipping already-run command: {}", cmd);
602
-                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
+                }
603733
             }
604734
             tracing::info!("exec_once: {}", cmd);
605
-            state.exec_once_cmds.insert(cmd.clone());
606
-            drop(state); // Release lock before spawning
607
-            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")
608738
                 .arg("-c")
609739
                 .arg(&cmd)
740
+                .process_group(0)
610741
                 .spawn()
611
-                .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
+            }
612750
             Ok(())
613751
         })?;
614752
         gar.set("exec_once", exec_once_fn)
@@ -674,13 +812,13 @@ impl LuaConfig {
674812
                 rule.opacity = Some(op);
675813
             }
676814
 
677
-            // Optional: shadow
678
-            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") {
679817
                 rule.shadow = Some(shadow);
680818
             }
681819
 
682
-            // Optional: blur_background
683
-            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") {
684822
                 rule.blur_background = Some(blur);
685823
             }
686824
 
gar/src/config/mod.rsmodified
@@ -19,6 +19,7 @@ pub struct Config {
1919
     pub border_color_focused: u32,
2020
     pub border_color_unfocused: u32,
2121
     pub border_color_urgent: u32,
22
+    pub border_color_swap_target: u32,
2223
     pub gap_inner: u32,
2324
     pub gap_outer: u32,
2425
     // Title bar settings
@@ -30,10 +31,23 @@ pub struct Config {
3031
     // Behavior settings
3132
     pub follow_window_on_move: bool,
3233
     pub mouse_follows_focus: bool,
34
+    pub focus_follows_mouse: bool,
3335
     // Manual bar/panel reserved space (overrides struts)
3436
     pub bar_height: u32,
3537
     // garbar integration: spawn garbar automatically if gar.bar is configured
3638
     pub bar_enabled: bool,
39
+    // garnotify integration: spawn garnotify automatically if gar.notification is configured
40
+    pub notification_enabled: bool,
41
+    // Monitor ordering: list of monitor names in desired left-to-right order
42
+    // If empty, monitors are sorted by X position (default)
43
+    pub monitor_order: Vec<String>,
44
+    // Screen timeout/DPMS settings
45
+    pub screen_timeout_enabled: bool,
46
+    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,
3751
     // Compositor visual settings (picom)
3852
     // These are stored for reference and potential dynamic picom config generation
3953
     pub corner_radius: u32,
@@ -70,23 +84,26 @@ impl Config {
7084
     /// Generate picom.conf content from current config settings.
7185
     pub fn generate_picom_config(&self) -> String {
7286
         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
+
7399
             format!(
74100
                 r#"# Blur
75101
 blur-method = "{}";
76102
 blur-strength = {};
77103
 blur-background = true;
78104
 blur-background-frame = false;
79
-blur-kern = "3x3box";
80
-
81
-blur-background-exclude = [
82
-    "window_type = 'dock'",
83
-    "window_type = 'desktop'",
84
-    "window_type = 'menu'",
85
-    "window_type = 'dropdown_menu'",
86
-    "window_type = 'popup_menu'",
87
-    "_NET_WM_BYPASS_COMPOSITOR = 1"
88
-];"#,
89
-                self.blur_method, self.blur_strength
105
+blur-kern = "3x3box";"#,
106
+                blur_method, blur_strength
90107
             )
91108
         } else {
92109
             "# Blur disabled".to_string()
@@ -99,18 +116,7 @@ shadow = true;
99116
 shadow-radius = {};
100117
 shadow-opacity = {:.2};
101118
 shadow-offset-x = {};
102
-shadow-offset-y = {};
103
-
104
-shadow-exclude = [
105
-    "window_type = 'dock'",
106
-    "window_type = 'desktop'",
107
-    "window_type = 'menu'",
108
-    "window_type = 'dropdown_menu'",
109
-    "window_type = 'popup_menu'",
110
-    "window_type = 'tooltip'",
111
-    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'",
112
-    "_NET_WM_BYPASS_COMPOSITOR = 1"
113
-];"#,
119
+shadow-offset-y = {};"#,
114120
                 self.shadow_radius,
115121
                 self.shadow_opacity,
116122
                 self.shadow_offset_x,
@@ -128,13 +134,7 @@ fade-in-step = 0.028;
128134
 fade-out-step = 0.03;
129135
 fade-delta = {};
130136
 
131
-no-fading-destroyed-argb = true;
132
-
133
-fade-exclude = [
134
-    "window_type = 'menu'",
135
-    "window_type = 'dropdown_menu'",
136
-    "window_type = 'popup_menu'"
137
-];"#,
137
+no-fading-destroyed-argb = true;"#,
138138
                 self.fade_delta
139139
             )
140140
         } else {
@@ -204,42 +204,105 @@ animations = ({{
204204
             "# No custom shader".to_string()
205205
         };
206206
 
207
-        // Per-window rules section
208
-        let rules_section = if !self.picom_rules.is_empty() {
209
-            let mut rules = String::from("# Per-window Rules\nrules = (\n");
210
-            for rule in &self.picom_rules {
211
-                rules.push_str(&format!("    {{\n        match = \"{}\";\n", rule.match_expr));
212
-                if let Some(cr) = rule.corner_radius {
213
-                    rules.push_str(&format!("        corner-radius = {};\n", cr));
214
-                }
215
-                if let Some(opacity) = rule.opacity {
216
-                    rules.push_str(&format!("        opacity = {:.2};\n", opacity));
217
-                }
218
-                if let Some(shadow) = rule.shadow {
219
-                    rules.push_str(&format!("        shadow = {};\n", shadow));
220
-                }
221
-                if let Some(blur) = rule.blur_background {
222
-                    rules.push_str(&format!("        blur-background = {};\n", blur));
223
-                }
224
-                if let Some(ref shader) = rule.shader {
225
-                    let expanded = if shader.starts_with("~/") {
226
-                        if let Some(home) = dirs::home_dir() {
227
-                            home.join(&shader[2..]).to_string_lossy().to_string()
228
-                        } else {
229
-                            shader.clone()
230
-                        }
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()
231293
                     } else {
232294
                         shader.clone()
233
-                    };
234
-                    rules.push_str(&format!("        shader = \"{}\";\n", expanded));
235
-                }
236
-                rules.push_str("    },\n");
295
+                    }
296
+                } else {
297
+                    shader.clone()
298
+                };
299
+                rules.push_str(&format!("        shader = \"{}\";\n", expanded));
237300
             }
238
-            rules.push_str(");");
239
-            rules
240
-        } else {
241
-            "# No per-window rules".to_string()
242
-        };
301
+            rules.push_str("    },\n");
302
+        }
303
+
304
+        rules.push_str(");");
305
+        let rules_section = rules;
243306
 
244307
         format!(
245308
             r#"# picom.conf - Auto-generated by gar window manager
@@ -247,24 +310,13 @@ animations = ({{
247310
 # Edit ~/.config/gar/init.lua instead and reload with Mod+Shift+R
248311
 
249312
 # Backend Configuration
250
-backend = "glx";
313
+backend = "{}";
251314
 vsync = true;
252315
 use-ewmh-active-win = true;
253
-glx-no-stencil = true;
254316
 
255317
 # Rounded Corners
256318
 corner-radius = {};
257319
 
258
-rounded-corners-exclude = [
259
-    "window_type = 'dock'",
260
-    "window_type = 'desktop'",
261
-    "window_type = 'tooltip'",
262
-    "window_type = 'menu'",
263
-    "window_type = 'dropdown_menu'",
264
-    "window_type = 'popup_menu'",
265
-    "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'"
266
-];
267
-
268320
 {}
269321
 
270322
 {}
@@ -278,34 +330,8 @@ rounded-corners-exclude = [
278330
 {}
279331
 
280332
 {}
281
-
282
-# Window Type Settings
283
-wintypes:
284
-{{
285
-    tooltip = {{
286
-        fade = true;
287
-        shadow = false;
288
-        opacity = 0.95;
289
-        focus = true;
290
-        blur-background = false;
291
-    }};
292
-    dock = {{
293
-        shadow = false;
294
-        clip-shadow-above = true;
295
-    }};
296
-    dnd = {{
297
-        shadow = false;
298
-    }};
299
-    popup_menu = {{
300
-        opacity = 0.95;
301
-        shadow = false;
302
-    }};
303
-    dropdown_menu = {{
304
-        opacity = 0.95;
305
-        shadow = false;
306
-    }};
307
-}};
308333
 "#,
334
+            self.picom_backend,
309335
             self.corner_radius,
310336
             blur_section,
311337
             shadow_section,
@@ -317,8 +343,15 @@ wintypes:
317343
         )
318344
     }
319345
 
320
-    /// 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".
321348
     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
+
322355
         let config_dir = dirs::config_dir()
323356
             .ok_or_else(|| std::io::Error::new(
324357
                 std::io::ErrorKind::NotFound,
@@ -341,6 +374,113 @@ wintypes:
341374
         Ok(())
342375
     }
343376
 
377
+    /// Start the configured compositor.
378
+    /// Called on gar startup to launch the appropriate compositor.
379
+    pub fn start_compositor(&self) {
380
+        use std::process::Command;
381
+
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);
420
+                }
421
+            }
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
+        }
455
+
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();
459
+            match Command::new("xset")
460
+                .args(["s", &timeout, &timeout])
461
+                .status()
462
+            {
463
+                Ok(status) if status.success() => {
464
+                    tracing::info!(
465
+                        "Screen blanking enabled via X11 screen saver ({} seconds)",
466
+                        self.screen_timeout_seconds
467
+                    );
468
+                }
469
+                Ok(_) => tracing::warn!("xset s command failed"),
470
+                Err(e) => tracing::warn!("Failed to run xset: {}", e),
471
+            }
472
+        } else {
473
+            // Disable screen saver blanking too
474
+            match Command::new("xset").args(["s", "off"]).status() {
475
+                Ok(status) if status.success() => {
476
+                    tracing::info!("Screen blanking disabled");
477
+                }
478
+                Ok(_) => tracing::warn!("xset s off command failed"),
479
+                Err(e) => tracing::warn!("Failed to run xset: {}", e),
480
+            }
481
+        }
482
+    }
483
+
344484
     /// Restart picom to apply new configuration.
345485
     /// Picom doesn't support config reload via signal, so we kill and restart it.
346486
     fn reload_picom() {
@@ -392,6 +532,7 @@ impl Default for Config {
392532
             border_color_focused: 0x5294e2,
393533
             border_color_unfocused: 0x2d2d2d,
394534
             border_color_urgent: 0xff5555, // Red for urgent windows
535
+            border_color_swap_target: 0x00ff00, // Green for drag-swap target
395536
             gap_inner: 0,
396537
             gap_outer: 0,
397538
             // Title bars disabled by default
@@ -404,10 +545,24 @@ impl Default for Config {
404545
             follow_window_on_move: false,
405546
             // Behavior: warp mouse pointer to center of focused window
406547
             mouse_follows_focus: false,
548
+            // Behavior: focus window when mouse enters it
549
+            focus_follows_mouse: true,
407550
             // Manual bar height (0 = use struts from dock windows)
408551
             bar_height: 0,
409552
             // garbar not enabled by default (enabled when gar.bar table is set)
410553
             bar_enabled: false,
554
+            // garnotify not enabled by default (enabled when gar.notification table is set)
555
+            notification_enabled: false,
556
+            // Monitor order: empty = sort by X position
557
+            monitor_order: Vec::new(),
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,
561
+            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(),
411566
             // Compositor settings (picom) - matching picom.conf defaults
412567
             corner_radius: 12,
413568
             blur_enabled: true,
gar/src/core/mod.rsmodified
@@ -45,8 +45,13 @@ pub struct WindowManager {
4545
     pub dock_struts: HashMap<XWindow, Strut>,
4646
     /// Current edge being displayed (for cursor changes on floating window edges)
4747
     pub current_edge_cursor: Option<(XWindow, crate::x11::events::ResizeEdge)>,
48
+    /// Current tiled edge cursor state: (window1, window2, direction)
49
+    /// Tracks which windows have cursor overrides so we can clear them
50
+    pub tiled_edge_cursor: Option<(XWindow, XWindow, Direction)>,
4851
     /// garbar child process (managed automatically when gar.bar is configured)
4952
     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>,
5055
     /// Directional focus memory: (source_window, direction) -> last_target_window
5156
     /// Used to remember which window was focused when navigating in a direction
5257
     pub directional_focus_memory: HashMap<(XWindow, Direction), XWindow>,
@@ -54,7 +59,7 @@ pub struct WindowManager {
5459
 
5560
 impl WindowManager {
5661
     pub fn new(conn: Connection) -> Result<Self> {
57
-        let workspaces: Vec<Workspace> = (1..=10)
62
+        let mut workspaces: Vec<Workspace> = (1..=10)
5863
             .map(|i| Workspace::new(i, i.to_string()))
5964
             .collect();
6065
 
@@ -70,10 +75,11 @@ impl WindowManager {
7075
         // Get config values from Lua state
7176
         let config = lua_state.lock().unwrap().config.clone();
7277
 
73
-        // Generate picom config from settings
74
-        if let Err(e) = config.write_picom_config() {
75
-            tracing::warn!("Failed to generate picom config: {}", e);
76
-        }
78
+        // Start the configured compositor (picom, garchomp, or none)
79
+        config.start_compositor();
80
+
81
+        // Apply screen timeout/DPMS settings
82
+        config.apply_screen_timeout();
7783
 
7884
         // Initialize IPC server (optional - graceful failure)
7985
         let ipc_server = match IpcServer::new() {
@@ -117,11 +123,17 @@ impl WindowManager {
117123
             ));
118124
         }
119125
 
126
+        // Sort monitors by configured order (if set)
127
+        Self::sort_monitors_by_config(&mut monitors, &config.monitor_order);
128
+
120129
         // i3-style: each monitor starts with one workspace (1, 2, 3...)
121130
         // Any workspace can be moved to any monitor dynamically
122131
         for (i, monitor) in monitors.iter_mut().enumerate() {
123132
             monitor.workspaces = vec![i]; // Just track initial workspace
124133
             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
+            }
125137
             tracing::debug!("Monitor '{}' starts with workspace {}", monitor.name, i + 1);
126138
         }
127139
 
@@ -145,7 +157,9 @@ impl WindowManager {
145157
             focus_history: Vec::new(),
146158
             dock_struts: HashMap::new(),
147159
             current_edge_cursor: None,
160
+            tiled_edge_cursor: None,
148161
             garbar_process: None,
162
+            garnotify_process: None,
149163
             directional_focus_memory: HashMap::new(),
150164
         })
151165
     }
@@ -167,6 +181,25 @@ impl WindowManager {
167181
         self.monitors[self.focused_monitor].geometry
168182
     }
169183
 
184
+    /// Get the work area for the focused monitor (accounting for struts and outer gaps).
185
+    pub fn work_area(&self) -> Rect {
186
+        let screen = self.screen_rect();
187
+        let gap_outer = self.config.gap_outer as i16;
188
+
189
+        // Calculate struts for focused monitor
190
+        let (strut_left, strut_right, strut_top, strut_bottom) =
191
+            self.calculate_struts(self.focused_monitor);
192
+
193
+        Rect::new(
194
+            screen.x + gap_outer + strut_left as i16,
195
+            screen.y + gap_outer + strut_top as i16,
196
+            screen.width
197
+                .saturating_sub(2 * gap_outer as u16 + strut_left as u16 + strut_right as u16),
198
+            screen.height
199
+                .saturating_sub(2 * gap_outer as u16 + strut_top as u16 + strut_bottom as u16),
200
+        )
201
+    }
202
+
170203
     /// Get the rectangle for a specific workspace's monitor.
171204
     pub fn workspace_rect(&self, workspace_idx: usize) -> Rect {
172205
         self.monitor_for_workspace(workspace_idx)
@@ -184,10 +217,87 @@ impl WindowManager {
184217
         self.monitors.iter().position(|m| m.active_workspace == workspace_idx)
185218
     }
186219
 
220
+    /// Find the monitor index containing a point (x, y in root window coordinates).
221
+    pub fn monitor_idx_at_point(&self, x: i16, y: i16) -> usize {
222
+        self.monitors
223
+            .iter()
224
+            .position(|m| {
225
+                let g = &m.geometry;
226
+                x >= g.x && x < g.x + g.width as i16 &&
227
+                y >= g.y && y < g.y + g.height as i16
228
+            })
229
+            .unwrap_or(self.focused_monitor) // Fallback to focused monitor
230
+    }
231
+
232
+    /// Get the workspace that should receive new windows (based on mouse position).
233
+    /// This supports spawning windows on the monitor where the mouse is.
234
+    pub fn workspace_for_new_window(&self) -> usize {
235
+        if let Ok((x, y)) = self.conn.get_pointer_position() {
236
+            let monitor_idx = self.monitor_idx_at_point(x, y);
237
+            self.monitors[monitor_idx].active_workspace
238
+        } else {
239
+            self.focused_workspace
240
+        }
241
+    }
242
+
243
+    /// Sort monitors by configured order (from gar.set("monitor_order", {...})).
244
+    /// If config order is empty or doesn't cover all monitors, falls back to X position.
245
+    fn sort_monitors_by_config(monitors: &mut Vec<Monitor>, config_order: &[String]) {
246
+        if config_order.is_empty() {
247
+            // No custom order - sort by X position (default)
248
+            monitors.sort_by_key(|m| m.geometry.x);
249
+            return;
250
+        }
251
+
252
+        monitors.sort_by(|a, b| {
253
+            let pos_a = config_order.iter().position(|n| n == &a.name);
254
+            let pos_b = config_order.iter().position(|n| n == &b.name);
255
+
256
+            match (pos_a, pos_b) {
257
+                (Some(pa), Some(pb)) => pa.cmp(&pb),
258
+                (Some(_), None) => std::cmp::Ordering::Less,
259
+                (None, Some(_)) => std::cmp::Ordering::Greater,
260
+                (None, None) => a.geometry.x.cmp(&b.geometry.x),
261
+            }
262
+        });
263
+
264
+        tracing::info!("Sorted monitors by config order:");
265
+        for (i, m) in monitors.iter().enumerate() {
266
+            tracing::info!(
267
+                "  Monitor {}: '{}' at ({}, {}) size {}x{}",
268
+                i, m.name, m.geometry.x, m.geometry.y, m.geometry.width, m.geometry.height
269
+            );
270
+        }
271
+    }
272
+
273
+    /// Calculate struts (reserved space for panels/docks) for a monitor.
274
+    fn calculate_struts(&self, _monitor_idx: usize) -> (u32, u32, u32, u32) {
275
+        if self.config.bar_height > 0 {
276
+            // Manual bar height overrides struts (assumes bar at top)
277
+            (0, 0, self.config.bar_height, 0)
278
+        } else {
279
+            // Accumulate struts from dock windows
280
+            let mut left: u32 = 0;
281
+            let mut right: u32 = 0;
282
+            let mut top: u32 = 0;
283
+            let mut bottom: u32 = 0;
284
+            for strut in self.dock_struts.values() {
285
+                left = left.max(strut.left);
286
+                right = right.max(strut.right);
287
+                top = top.max(strut.top);
288
+                bottom = bottom.max(strut.bottom);
289
+            }
290
+            (left, right, top, bottom)
291
+        }
292
+    }
293
+
187294
     /// Refresh monitors (called on RandR screen change).
188295
     pub fn refresh_monitors(&mut self) -> Result<()> {
189296
         tracing::info!("Refreshing monitor configuration");
190297
 
298
+        // Update cached screen dimensions (may have changed due to rotation)
299
+        self.conn.update_screen_size();
300
+
191301
         let mut new_monitors = self.conn.detect_monitors().unwrap_or_else(|e| {
192302
             tracing::warn!("Failed to detect monitors: {}, keeping current", e);
193303
             return self.monitors.clone();
@@ -201,6 +311,9 @@ impl WindowManager {
201311
             ));
202312
         }
203313
 
314
+        // Sort monitors by configured order (if set)
315
+        Self::sort_monitors_by_config(&mut new_monitors, &self.config.monitor_order);
316
+
204317
         // Try to preserve workspace assignments from old monitors
205318
         // If we have more monitors now, new ones get next available workspaces
206319
         let old_mon_count = self.monitors.len();
@@ -221,6 +334,9 @@ impl WindowManager {
221334
                 monitor.workspaces = vec![first_free];
222335
                 used_workspaces.insert(first_free);
223336
             }
337
+            if monitor.active_workspace < self.workspaces.len() {
338
+                self.workspaces[monitor.active_workspace].last_monitor = Some(i);
339
+            }
224340
             tracing::info!("Monitor '{}' showing workspace {}", monitor.name, monitor.active_workspace + 1);
225341
         }
226342
 
@@ -313,12 +429,33 @@ impl WindowManager {
313429
 
314430
         tracing::info!("Managing window {} on workspace {} (floating)", window, workspace_idx + 1);
315431
 
316
-        // Calculate floating geometry
432
+        // Get window's requested geometry - respect the window's size/position
317433
         let screen = self.screen_rect();
318
-        let float_width = (screen.width * 4 / 5).max(400);
319
-        let float_height = (screen.height * 4 / 5).max(300);
320
-        let float_x = screen.x + (screen.width as i16 - float_width as i16) / 2;
321
-        let float_y = screen.y + (screen.height as i16 - float_height as i16) / 2;
434
+        let (float_x, float_y, float_width, float_height) =
435
+            if let Ok(Ok(geom)) = self.conn.conn.get_geometry(window).map(|c| c.reply()) {
436
+                // Use window's actual geometry, but ensure it fits on screen
437
+                let w = geom.width.min(screen.width).max(100);
438
+                let h = geom.height.min(screen.height).max(100);
439
+                // Use window's position if valid, otherwise center
440
+                let x = if geom.x >= 0 && (geom.x as u16) < screen.width {
441
+                    geom.x
442
+                } else {
443
+                    screen.x + (screen.width as i16 - w as i16) / 2
444
+                };
445
+                let y = if geom.y >= 0 && (geom.y as u16) < screen.height {
446
+                    geom.y
447
+                } else {
448
+                    screen.y + (screen.height as i16 - h as i16) / 2
449
+                };
450
+                (x, y, w, h)
451
+            } else {
452
+                // Fallback to default centered geometry (80% of screen)
453
+                let w = (screen.width * 4 / 5).max(400);
454
+                let h = (screen.height * 4 / 5).max(300);
455
+                let x = screen.x + (screen.width as i16 - w as i16) / 2;
456
+                let y = screen.y + (screen.height as i16 - h as i16) / 2;
457
+                (x, y, w, h)
458
+            };
322459
 
323460
         // Track the window with floating state
324461
         let mut win = Window::new(window, workspace_idx);
@@ -344,12 +481,33 @@ impl WindowManager {
344481
 
345482
         tracing::info!("Managing window {} (floating)", window);
346483
 
347
-        // Calculate centered floating geometry
484
+        // Get window's requested geometry - respect the window's size/position
348485
         let screen = self.screen_rect();
349
-        let float_width = 640.min(screen.width.saturating_sub(40));
350
-        let float_height = 480.min(screen.height.saturating_sub(40));
351
-        let float_x = screen.x + (screen.width as i16 - float_width as i16) / 2;
352
-        let float_y = screen.y + (screen.height as i16 - float_height as i16) / 2;
486
+        let (float_x, float_y, float_width, float_height) =
487
+            if let Ok(Ok(geom)) = self.conn.conn.get_geometry(window).map(|c| c.reply()) {
488
+                // Use window's actual geometry, but ensure it fits on screen
489
+                let w = geom.width.min(screen.width).max(100);
490
+                let h = geom.height.min(screen.height).max(100);
491
+                // Use window's position if valid, otherwise center
492
+                let x = if geom.x >= 0 && (geom.x as u16) < screen.width {
493
+                    geom.x
494
+                } else {
495
+                    screen.x + (screen.width as i16 - w as i16) / 2
496
+                };
497
+                let y = if geom.y >= 0 && (geom.y as u16) < screen.height {
498
+                    geom.y
499
+                } else {
500
+                    screen.y + (screen.height as i16 - h as i16) / 2
501
+                };
502
+                (x, y, w, h)
503
+            } else {
504
+                // Fallback to default centered geometry
505
+                let w = 640u16.min(screen.width.saturating_sub(40));
506
+                let h = 480u16.min(screen.height.saturating_sub(40));
507
+                let x = screen.x + (screen.width as i16 - w as i16) / 2;
508
+                let y = screen.y + (screen.height as i16 - h as i16) / 2;
509
+                (x, y, w, h)
510
+            };
353511
 
354512
         // Track the window with floating state
355513
         let mut win = Window::new(window, self.focused_workspace);
@@ -516,6 +674,17 @@ impl WindowManager {
516674
             }
517675
         }
518676
 
677
+        // Update focused workspace and monitor based on the window's workspace
678
+        // This is critical for focus-follows-mouse to work correctly with multimonitor
679
+        if let Some(win) = self.windows.get(&window) {
680
+            let ws_idx = win.workspace;
681
+            self.focused_workspace = ws_idx;
682
+            // Update focused_monitor to the monitor displaying this workspace (if visible)
683
+            if let Some(monitor_idx) = self.monitor_idx_for_workspace(ws_idx) {
684
+                self.focused_monitor = monitor_idx;
685
+            }
686
+        }
687
+
519688
         self.focused_window = Some(window);
520689
         self.current_workspace_mut().focused = Some(window);
521690
 
@@ -541,11 +710,22 @@ impl WindowManager {
541710
 
542711
         // Warp pointer to center of focused window (mouse follows focus)
543712
         if warp_pointer {
544
-            if let Err(e) = self.conn.warp_pointer_to_window(window) {
545
-                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
+                }
546726
             }
547
-            // Record warp time to suppress EnterNotify feedback loop
548727
             self.last_warp = std::time::Instant::now();
728
+            self.conn.flush()?;
549729
         }
550730
 
551731
         Ok(())
@@ -652,6 +832,11 @@ impl WindowManager {
652832
         // Update borders for all windows on visible workspaces
653833
         for ws_idx in visible_ws {
654834
             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
+
655840
                 // Check if window is urgent (and not focused - focused clears urgency)
656841
                 let is_urgent = self.windows.get(&window)
657842
                     .map(|w| w.urgent && Some(window) != focused)
@@ -731,19 +916,42 @@ impl WindowManager {
731916
                     fs_window, screen
732917
                 );
733918
 
734
-                // Configure fullscreen window to cover entire monitor (no gaps, no borders)
735
-                self.conn.configure_window(
736
-                    fs_window,
737
-                    screen.x,
738
-                    screen.y,
739
-                    screen.width,
740
-                    screen.height,
741
-                    0, // No border for fullscreen
742
-                )?;
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
+                    )?;
743951
 
744
-                // Raise fullscreen window above everything
745
-                let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
746
-                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
+                }
747955
 
748956
                 // Skip normal layout for this workspace - fullscreen window covers everything
749957
                 continue;
@@ -803,6 +1011,12 @@ impl WindowManager {
8031011
                         border_width as u16,
8041012
                     )?;
8051013
 
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
+
8061020
                     tracing::debug!(
8071021
                         "apply_layout: TILED+FRAME window={} at ({}, {}) size {}x{} (titlebar: {})",
8081022
                         window, gapped_x, gapped_y, final_width.max(1), final_height.max(1), titlebar_height
@@ -821,6 +1035,15 @@ impl WindowManager {
8211035
                         final_height.max(1),
8221036
                         border_width,
8231037
                     )?;
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));
8241047
                 }
8251048
             }
8261049
 
@@ -829,59 +1052,69 @@ impl WindowManager {
8291052
 
8301053
             for window_id in floating_ids {
8311054
                 // Get the window's floating geometry from our state
832
-                if let Some(win) = self.windows.get(&window_id) {
833
-                    let geom = win.floating_geometry;
834
-                    let adjusted_width = geom.width.saturating_sub(2 * border_width as u16);
835
-                    let adjusted_height = geom.height.saturating_sub(2 * border_width as u16);
836
-
837
-                    let has_frame = win.frame.is_some();
838
-
839
-                    if has_frame && titlebar_enabled {
840
-                        // Configure frame for floating window
841
-                        let client_height = adjusted_height.saturating_sub(titlebar_height);
842
-                        self.frames.configure_frame(
843
-                            &self.conn.conn,
844
-                            window_id,
845
-                            geom.x,
846
-                            geom.y,
847
-                            adjusted_width.max(1),
848
-                            client_height.max(1),
849
-                            titlebar_height,
850
-                            border_width as u16,
851
-                        )?;
852
-
853
-                        // Raise frame to top of stack
854
-                        if let Some(frame) = self.frames.frame_for_client(window_id) {
855
-                            let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
856
-                            self.conn.conn.configure_window(frame, &aux)?;
857
-                        }
858
-
859
-                        tracing::debug!(
860
-                            "apply_layout: FLOATING+FRAME window={} at ({}, {}) size {}x{} (raising)",
861
-                            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)
8621087
                         );
8631088
                     } else {
864
-                        tracing::debug!(
865
-                            "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
866
-                            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
8671092
                         );
868
-
869
-                        // Configure geometry
870
-                        self.conn.configure_window(
871
-                            window_id,
872
-                            geom.x,
873
-                            geom.y,
874
-                            adjusted_width.max(1),
875
-                            adjusted_height.max(1),
876
-                            border_width,
877
-                        )?;
878
-
879
-                        // Raise to top of stack (each subsequent window goes above the previous)
880
-                        let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
881
-                        self.conn.conn.configure_window(window_id, &aux)?;
8821093
                     }
8831094
                 } else {
884
-                    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));
8851118
                 }
8861119
             }
8871120
         }
gar/src/core/tree.rsmodified
@@ -14,7 +14,7 @@ pub enum Direction {
1414
     Down,
1515
 }
1616
 
17
-#[derive(Debug)]
17
+#[derive(Debug, Clone)]
1818
 pub enum Node {
1919
     Internal {
2020
         split: SplitDirection,
@@ -256,6 +256,75 @@ impl Node {
256256
         }
257257
     }
258258
 
259
+    /// Get the split ratio affecting a window in the given direction.
260
+    /// Returns None if no such split exists.
261
+    pub fn get_split_ratio(&self, window: XWindow, direction: Direction) -> Option<f32> {
262
+        match self {
263
+            Node::Leaf { .. } => None,
264
+            Node::Internal {
265
+                split,
266
+                ratio,
267
+                left,
268
+                right,
269
+            } => {
270
+                // Check if this split is in the right orientation for the direction
271
+                let dominated = match (split, direction) {
272
+                    (SplitDirection::Vertical, Direction::Left | Direction::Right) => true,
273
+                    (SplitDirection::Horizontal, Direction::Up | Direction::Down) => true,
274
+                    _ => false,
275
+                };
276
+
277
+                if dominated && (left.contains(window) || right.contains(window)) {
278
+                    return Some(*ratio);
279
+                }
280
+
281
+                // Recurse into children
282
+                if left.contains(window) {
283
+                    left.get_split_ratio(window, direction)
284
+                } else if right.contains(window) {
285
+                    right.get_split_ratio(window, direction)
286
+                } else {
287
+                    None
288
+                }
289
+            }
290
+        }
291
+    }
292
+
293
+    /// Set the split ratio affecting a window in the given direction.
294
+    /// Returns true if the ratio was set.
295
+    pub fn set_split_ratio(&mut self, window: XWindow, direction: Direction, new_ratio: f32) -> bool {
296
+        match self {
297
+            Node::Leaf { .. } => false,
298
+            Node::Internal {
299
+                split,
300
+                ratio,
301
+                left,
302
+                right,
303
+            } => {
304
+                // Check if this split is in the right orientation for the direction
305
+                let dominated = match (split, direction) {
306
+                    (SplitDirection::Vertical, Direction::Left | Direction::Right) => true,
307
+                    (SplitDirection::Horizontal, Direction::Up | Direction::Down) => true,
308
+                    _ => false,
309
+                };
310
+
311
+                if dominated && (left.contains(window) || right.contains(window)) {
312
+                    *ratio = new_ratio.clamp(0.1, 0.9);
313
+                    return true;
314
+                }
315
+
316
+                // Recurse into children
317
+                if left.contains(window) {
318
+                    left.set_split_ratio(window, direction, new_ratio)
319
+                } else if right.contains(window) {
320
+                    right.set_split_ratio(window, direction, new_ratio)
321
+                } else {
322
+                    false
323
+                }
324
+            }
325
+        }
326
+    }
327
+
259328
     /// Swap two windows in the tree.
260329
     pub fn swap(&mut self, a: XWindow, b: XWindow) -> bool {
261330
         // Find and swap the windows
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
@@ -2,7 +2,7 @@ use x11rb::connection::Connection as X11Connection;
22
 use x11rb::protocol::xproto::{
33
     Atom, AtomEnum, ButtonIndex, ChangeWindowAttributesAux, ClientMessageData,
44
     ClientMessageEvent, ConfigureWindowAux, ConnectionExt, EventMask, Font, GrabMode, InputFocus,
5
-    ModMask, Screen, Window,
5
+    ModMask, Screen, StackMode, Window,
66
 };
77
 use x11rb::rust_connection::RustConnection;
88
 use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
@@ -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,
@@ -94,6 +95,8 @@ pub struct Connection {
9495
     pub cursor_right: u32,
9596
     pub cursor_top: u32,
9697
     pub cursor_bottom: u32,
98
+    pub cursor_h_double: u32,  // sb_h_double_arrow - horizontal resize
99
+    pub cursor_v_double: u32,  // sb_v_double_arrow - vertical resize
97100
 }
98101
 
99102
 impl Connection {
@@ -131,6 +134,7 @@ impl Connection {
131134
         let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
132135
         let net_wm_state_modal = conn.intern_atom(false, b"_NET_WM_STATE_MODAL")?.reply()?.atom;
133136
         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;
134138
 
135139
         // Intern EWMH atoms for workspaces and WM identification
136140
         let net_supported = conn.intern_atom(false, b"_NET_SUPPORTED")?.reply()?.atom;
@@ -165,6 +169,8 @@ impl Connection {
165169
             cursor_right,
166170
             cursor_top,
167171
             cursor_bottom,
172
+            cursor_h_double,
173
+            cursor_v_double,
168174
         ) = Self::create_cursors(&conn)?;
169175
 
170176
         tracing::info!(
@@ -195,6 +201,7 @@ impl Connection {
195201
             net_wm_state,
196202
             net_wm_state_modal,
197203
             net_wm_state_fullscreen,
204
+            net_wm_state_above,
198205
             net_supported,
199206
             net_supporting_wm_check,
200207
             net_client_list,
@@ -220,6 +227,8 @@ impl Connection {
220227
             cursor_right,
221228
             cursor_top,
222229
             cursor_bottom,
230
+            cursor_h_double,
231
+            cursor_v_double,
223232
         })
224233
     }
225234
 
@@ -235,7 +244,9 @@ impl Connection {
235244
                 EventMask::SUBSTRUCTURE_REDIRECT
236245
                     | EventMask::SUBSTRUCTURE_NOTIFY
237246
                     | EventMask::STRUCTURE_NOTIFY
238
-                    | EventMask::PROPERTY_CHANGE,
247
+                    | EventMask::PROPERTY_CHANGE
248
+                    | EventMask::ENTER_WINDOW
249
+                    | EventMask::POINTER_MOTION,
239250
             )
240251
             .background_pixel(self.screen().black_pixel)
241252
             .cursor(self.cursor_normal);
@@ -286,7 +297,7 @@ impl Connection {
286297
     }
287298
 
288299
     /// Create all cursors used by the window manager.
289
-    fn create_cursors(conn: &RustConnection) -> Result<(u32, u32, u32, u32, u32, u32, u32, u32, u32, u32), Error> {
300
+    fn create_cursors(conn: &RustConnection) -> Result<(u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32), Error> {
290301
         // Open the cursor font
291302
         let font: Font = conn.generate_id()?;
292303
         conn.open_font(font, b"cursor")?;
@@ -320,6 +331,8 @@ impl Connection {
320331
         let cursor_right = create(96)?;        // right_side
321332
         let cursor_top = create(138)?;         // top_side
322333
         let cursor_bottom = create(16)?;       // bottom_side
334
+        let cursor_h_double = create(108)?;    // sb_h_double_arrow
335
+        let cursor_v_double = create(116)?;    // sb_v_double_arrow
323336
 
324337
         // Close font (cursors keep their own references)
325338
         conn.close_font(font)?;
@@ -335,6 +348,8 @@ impl Connection {
335348
             cursor_right,
336349
             cursor_top,
337350
             cursor_bottom,
351
+            cursor_h_double,
352
+            cursor_v_double,
338353
         ))
339354
     }
340355
 
@@ -440,6 +455,34 @@ impl Connection {
440455
         Ok(())
441456
     }
442457
 
458
+    /// Grab Button1 on root without modifiers to catch clicks in gaps between tiled windows.
459
+    pub fn grab_button1_on_root(&self) -> Result<(), Error> {
460
+        let numlock = ModMask::M2;
461
+        let capslock = ModMask::LOCK;
462
+
463
+        // Grab Button1 without mod key (but handle numlock/capslock variants)
464
+        for mods in [
465
+            ModMask::from(0u16),
466
+            numlock,
467
+            capslock,
468
+            numlock | capslock,
469
+        ] {
470
+            self.conn.grab_button(
471
+                false,
472
+                self.root,
473
+                EventMask::BUTTON_PRESS,
474
+                GrabMode::SYNC, // Sync mode so we can replay to client if needed
475
+                GrabMode::ASYNC,
476
+                x11rb::NONE,
477
+                x11rb::NONE,
478
+                ButtonIndex::M1,
479
+                mods,
480
+            )?;
481
+        }
482
+        tracing::debug!("Grabbed Button1 on root for gap edge resize");
483
+        Ok(())
484
+    }
485
+
443486
     /// Set the cursor for a window.
444487
     pub fn set_window_cursor(&self, window: Window, cursor: u32) -> Result<(), Error> {
445488
         tracing::debug!("set_window_cursor: window={} cursor={}", window, cursor);
@@ -456,6 +499,14 @@ impl Connection {
456499
         Ok(())
457500
     }
458501
 
502
+    /// Set the cursor on the root window.
503
+    pub fn set_root_cursor(&self, cursor: u32) -> Result<(), Error> {
504
+        tracing::debug!("set_root_cursor: cursor={}", cursor);
505
+        let change = ChangeWindowAttributesAux::new().cursor(cursor);
506
+        self.conn.change_window_attributes(self.root, &change)?;
507
+        Ok(())
508
+    }
509
+
459510
     /// Set input focus to a window.
460511
     pub fn set_focus(&self, window: Window) -> Result<(), Error> {
461512
         self.conn
@@ -470,6 +521,11 @@ impl Connection {
470521
         let center_x = (geom.width / 2) as i16;
471522
         let center_y = (geom.height / 2) as i16;
472523
 
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
+
473529
         self.conn.warp_pointer(
474530
             x11rb::NONE,  // src_window (none = don't check source)
475531
             window,       // dst_window
@@ -494,6 +550,12 @@ impl Connection {
494550
         Ok(())
495551
     }
496552
 
553
+    /// Get the current mouse pointer position (root window coordinates).
554
+    pub fn get_pointer_position(&self) -> Result<(i16, i16), Error> {
555
+        let reply = self.conn.query_pointer(self.root)?.reply()?;
556
+        Ok((reply.root_x, reply.root_y))
557
+    }
558
+
497559
     /// Set window border width and color.
498560
     pub fn set_border(&self, window: Window, width: u32, color: u32) -> Result<(), Error> {
499561
         let aux = ChangeWindowAttributesAux::new().border_pixel(color);
@@ -542,6 +604,20 @@ impl Connection {
542604
         Ok(())
543605
     }
544606
 
607
+    /// Raise a window to the top of the stacking order.
608
+    pub fn raise_window(&self, window: Window) -> Result<(), Error> {
609
+        let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
610
+        self.conn.configure_window(window, &aux)?;
611
+        Ok(())
612
+    }
613
+
614
+    /// Move a window to a new position without changing size.
615
+    pub fn move_window(&self, window: Window, x: i16, y: i16) -> Result<(), Error> {
616
+        let aux = ConfigureWindowAux::new().x(x as i32).y(y as i32);
617
+        self.conn.configure_window(window, &aux)?;
618
+        Ok(())
619
+    }
620
+
545621
     /// Grab the pointer for drag operations with optional cursor override.
546622
     pub fn grab_pointer(&self, cursor: Option<u32>) -> Result<(), Error> {
547623
         let cursor_id = cursor.unwrap_or(x11rb::NONE);
@@ -698,7 +774,7 @@ impl Connection {
698774
             }
699775
         }
700776
 
701
-        // 3. Check _NET_WM_STATE for modal windows
777
+        // 3. Check _NET_WM_STATE for modal or above windows
702778
         if let Ok(cookie) = self.conn.get_property(
703779
             false,
704780
             window,
@@ -715,6 +791,10 @@ impl Connection {
715791
                             tracing::debug!("Window {} is modal, should float", window);
716792
                             return true;
717793
                         }
794
+                        if atom == self.net_wm_state_above {
795
+                            tracing::debug!("Window {} has ABOVE state, should float", window);
796
+                            return true;
797
+                        }
718798
                     }
719799
                 }
720800
             }
@@ -996,6 +1076,8 @@ impl Connection {
9961076
             self.net_close_window,
9971077
             self.net_wm_state,
9981078
             self.net_wm_state_fullscreen,
1079
+            self.net_wm_state_modal,
1080
+            self.net_wm_state_above,
9991081
             self.net_wm_name,
10001082
         ];
10011083
         self.conn.change_property32(
@@ -1256,6 +1338,38 @@ impl Connection {
12561338
         )?;
12571339
         Ok(())
12581340
     }
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
+    }
12591373
 }
12601374
 
12611375
 impl std::ops::Deref for Connection {
gar/src/x11/events.rsmodified
1662 lines changed — click to load
@@ -1,7 +1,19 @@
11
 use std::process::Command;
2
+use std::io::Write;
23
 
34
 use x11rb::connection::Connection as X11Connection;
45
 
6
+/// Debug logging to file (since RUST_LOG doesn't work with auto-started WM)
7
+fn debug_log(msg: &str) {
8
+    if let Ok(mut f) = std::fs::OpenOptions::new()
9
+        .create(true)
10
+        .append(true)
11
+        .open("/tmp/gar-tiled-resize.log")
12
+    {
13
+        let _ = writeln!(f, "{}", msg);
14
+    }
15
+}
16
+
517
 /// Reap any zombie child processes to prevent accumulation.
618
 /// Called periodically from the event loop.
719
 fn reap_zombies() {
@@ -72,11 +84,45 @@ fn stop_graphical_session() {
7284
     }
7385
 }
7486
 
75
-/// Spawn garbar as a child process.
76
-/// Returns the child process handle if successful.
87
+/// Get garbar socket path
88
+fn garbar_socket_path() -> String {
89
+    std::env::var("XDG_RUNTIME_DIR")
90
+        .map(|dir| format!("{}/garbar.sock", dir))
91
+        .unwrap_or_else(|_| "/tmp/garbar.sock".to_string())
92
+}
93
+
94
+/// Check if garbar is healthy (socket exists)
95
+fn is_garbar_healthy() -> bool {
96
+    std::path::Path::new(&garbar_socket_path()).exists()
97
+}
98
+
99
+/// Kill any stale garbar process that isn't responding
100
+fn cleanup_stale_garbar() {
101
+    let pid_path = std::env::var("XDG_RUNTIME_DIR")
102
+        .map(|dir| format!("{}/garbar.pid", dir))
103
+        .unwrap_or_else(|_| "/tmp/garbar.pid".to_string());
104
+
105
+    if let Ok(pid_str) = std::fs::read_to_string(&pid_path) {
106
+        if let Ok(pid) = pid_str.trim().parse::<i32>() {
107
+            // Check if process exists but socket doesn't (stale process)
108
+            let proc_path = format!("/proc/{}", pid);
109
+            if std::path::Path::new(&proc_path).exists() && !is_garbar_healthy() {
110
+                tracing::warn!("Killing stale garbar process (PID {})", pid);
111
+                unsafe {
112
+                    libc::kill(pid, libc::SIGKILL);
113
+                }
114
+                std::thread::sleep(std::time::Duration::from_millis(100));
115
+            }
116
+        }
117
+    }
118
+}
119
+
77120
 fn spawn_garbar() -> Option<std::process::Child> {
78121
     tracing::info!("Spawning garbar...");
79122
 
123
+    // Clean up any stale garbar process first
124
+    cleanup_stale_garbar();
125
+
80126
     // Try to find garbar in PATH or common locations
81127
     let garbar_cmd = which_garbar().unwrap_or_else(|| "garbar".to_string());
82128
 
@@ -85,13 +131,29 @@ fn spawn_garbar() -> Option<std::process::Child> {
85131
         .map(|dir| format!("{}/gar-i3.sock", dir))
86132
         .unwrap_or_else(|_| "/tmp/gar-i3.sock".to_string());
87133
 
134
+    // Inherit DISPLAY from current environment, fallback to :0
135
+    let x_display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());
136
+
88137
     match Command::new(&garbar_cmd)
89138
         .arg("daemon")
90139
         .env("I3SOCK", &i3sock)
140
+        .env("DISPLAY", &x_display)
91141
         .spawn()
92142
     {
93143
         Ok(child) => {
94
-            tracing::info!("garbar started (PID {}), I3SOCK={}", child.id(), i3sock);
144
+            tracing::info!("garbar started (PID {}), DISPLAY={}, I3SOCK={}", child.id(), x_display, i3sock);
145
+
146
+            // Wait briefly and verify garbar becomes healthy
147
+            for attempt in 1..=10 {
148
+                std::thread::sleep(std::time::Duration::from_millis(200));
149
+                if is_garbar_healthy() {
150
+                    tracing::info!("garbar socket ready after {}ms", attempt * 200);
151
+                    return Some(child);
152
+                }
153
+            }
154
+
155
+            // Socket never appeared - garbar might be stuck
156
+            tracing::warn!("garbar socket not ready after 2s, process may be stuck");
95157
             Some(child)
96158
         }
97159
         Err(e) => {
@@ -174,6 +236,125 @@ fn reload_garbar(child: &std::process::Child) {
174236
         libc::kill(child.id() as i32, libc::SIGHUP);
175237
     }
176238
 }
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
+
177358
 use x11rb::protocol::xproto::{
178359
     ButtonPressEvent, ButtonReleaseEvent, ClientMessageEvent, ConfigureRequestEvent,
179360
     ConfigureWindowAux, ConnectionExt, DestroyNotifyEvent, EnterNotifyEvent, EventMask,
@@ -202,6 +383,36 @@ pub enum DragState {
202383
         start_geometry: Rect,
203384
         edge: ResizeEdge,
204385
     },
386
+    /// Dragging a tiled window to swap with another
387
+    TiledSwap {
388
+        window: u32,
389
+        /// Grab point offset from window origin (for smooth dragging)
390
+        grab_offset_x: i16,
391
+        grab_offset_y: i16,
392
+        /// Original tree state for reverting if cancelled
393
+        original_tree: Node,
394
+        /// Cached geometries of all tiled windows (updated after swaps)
395
+        tiled_geometries: Vec<(u32, Rect)>,
396
+        /// Currently hovered swap target (for visual feedback)
397
+        hover_target: Option<u32>,
398
+        /// Original workspace index
399
+        workspace: usize,
400
+    },
401
+    /// Dragging on the gap between tiled windows to resize
402
+    TiledResize {
403
+        /// Direction of resize (Right = vertical split, Down = horizontal split)
404
+        direction: Direction,
405
+        /// Starting cursor position (x for horizontal, y for vertical)
406
+        start_pos: i16,
407
+        /// Starting ratio of the split
408
+        start_ratio: f32,
409
+        /// Window whose split we're adjusting
410
+        window: u32,
411
+        /// Total size of the split container (for pixel-to-ratio conversion)
412
+        container_size: u16,
413
+        /// Workspace index
414
+        workspace: usize,
415
+    },
205416
 }
206417
 
207418
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -236,6 +447,9 @@ impl WindowManager {
236447
         // Grab Alt+Button1/Button3 on root for floating window move/resize
237448
         self.conn.grab_mod_buttons()?;
238449
 
450
+        // Grab Button1 on root (without mod) for edge resize in gaps between tiled windows
451
+        self.conn.grab_button1_on_root()?;
452
+
239453
         self.conn.flush()?;
240454
         tracing::info!("{} keybinds registered", state.keybinds.len());
241455
         Ok(())
@@ -307,16 +521,12 @@ impl WindowManager {
307521
             let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
308522
 
309523
             // Subscribe to events on the window
310
-            // Floating windows get POINTER_MOTION for edge resize cursor feedback
311
-            let base_events = EventMask::ENTER_WINDOW
524
+            // All windows get POINTER_MOTION for edge resize cursor feedback
525
+            let events = EventMask::ENTER_WINDOW
312526
                 | EventMask::FOCUS_CHANGE
313527
                 | EventMask::PROPERTY_CHANGE
314
-                | EventMask::STRUCTURE_NOTIFY;
315
-            let events = if should_float {
316
-                base_events | EventMask::POINTER_MOTION
317
-            } else {
318
-                base_events
319
-            };
528
+                | EventMask::STRUCTURE_NOTIFY
529
+                | EventMask::POINTER_MOTION;
320530
             self.conn.select_input(window, events)?;
321531
 
322532
             // Grab button for click-to-focus
@@ -351,13 +561,28 @@ impl WindowManager {
351561
             Event::DestroyNotify(e) => self.handle_destroy_notify(e)?,
352562
             Event::ButtonPress(e) => self.handle_button_press(e)?,
353563
             Event::ButtonRelease(e) => self.handle_button_release(e)?,
354
-            Event::MotionNotify(e) => self.handle_motion_notify(e)?,
564
+            Event::MotionNotify(e) => {
565
+                // Log first motion event to confirm events are arriving
566
+                use std::sync::atomic::{AtomicBool, Ordering};
567
+                static FIRST_MOTION: AtomicBool = AtomicBool::new(true);
568
+                if FIRST_MOTION.swap(false, Ordering::Relaxed) {
569
+                    debug_log(&format!("FIRST_MOTION_EVENT: window={}", e.event));
570
+                }
571
+                self.handle_motion_notify(e)?
572
+            }
355573
             Event::KeyPress(e) => self.handle_key_press(e)?,
356574
             Event::EnterNotify(e) => {
357575
                 self.handle_enter_notify(e)?;
358576
             }
359
-            Event::RandrScreenChangeNotify(_) => {
360
-                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;
361586
                 self.refresh_monitors()?;
362587
                 self.broadcast_i3_output_event();
363588
             }
@@ -417,51 +642,53 @@ impl WindowManager {
417642
         // Check window rules first
418643
         let rule_actions = self.check_rules(window);
419644
 
420
-        // Determine target workspace (rule or current)
421
-        let target_workspace = rule_actions.workspace.unwrap_or(self.focused_workspace + 1);
422
-        let target_idx = target_workspace.saturating_sub(1).min(self.workspaces.len() - 1);
645
+        // Determine target workspace: rule > mouse position > focused
646
+        // This makes windows spawn on the monitor where the mouse is
647
+        let target_idx = if let Some(ws) = rule_actions.workspace {
648
+            // Rule specifies workspace (1-indexed)
649
+            ws.saturating_sub(1).min(self.workspaces.len() - 1)
650
+        } else {
651
+            // Use workspace of monitor under mouse pointer
652
+            self.workspace_for_new_window()
653
+        };
423654
 
424655
         // Determine if window should float (rule > ICCCM/EWMH hints)
425656
         let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
426657
 
427658
         // Subscribe to events on the window
428
-        // Floating windows get POINTER_MOTION for edge resize cursor feedback
429
-        let base_events = EventMask::ENTER_WINDOW
659
+        // All windows get POINTER_MOTION for edge resize cursor feedback
660
+        let events = EventMask::ENTER_WINDOW
430661
             | EventMask::FOCUS_CHANGE
431662
             | EventMask::PROPERTY_CHANGE
432
-            | EventMask::STRUCTURE_NOTIFY;
433
-        let events = if should_float {
434
-            base_events | EventMask::POINTER_MOTION
435
-        } else {
436
-            base_events
437
-        };
663
+            | EventMask::STRUCTURE_NOTIFY
664
+            | EventMask::POINTER_MOTION;
438665
         self.conn.select_input(window, events)?;
439666
 
440667
         // Grab button for click-to-focus
441668
         self.conn.grab_button(window)?;
442669
 
443670
         // Manage window on target workspace
444
-        if target_idx != self.focused_workspace {
445
-            // Window goes to a different workspace
446
-            if should_float {
447
-                self.manage_window_floating_on_workspace(window, target_idx);
671
+        let target_visible = self.is_workspace_visible(target_idx);
672
+
673
+        if should_float {
674
+            if target_idx == self.focused_workspace {
675
+                self.manage_window_floating(window);
448676
             } else {
449
-                self.manage_window_on_workspace(window, target_idx);
677
+                self.manage_window_floating_on_workspace(window, target_idx);
450678
             }
451
-            // Create frame if title bars enabled
452
-            self.create_frame_for_window(window);
453
-            // Don't map - it's on another workspace
454679
         } else {
455
-            // Window goes to current workspace
456
-            if should_float {
457
-                self.manage_window_floating(window);
458
-            } else {
680
+            if target_idx == self.focused_workspace {
459681
                 self.manage_window(window);
682
+            } else {
683
+                self.manage_window_on_workspace(window, target_idx);
460684
             }
461
-            // Create frame if title bars enabled
462
-            let frame = self.create_frame_for_window(window);
685
+        }
686
+
687
+        // Create frame if title bars enabled
688
+        let frame = self.create_frame_for_window(window);
463689
 
464
-            // Map the window (and frame if present)
690
+        // Map window if it's on a visible workspace (any monitor)
691
+        if target_visible {
465692
             if frame.is_some() {
466693
                 self.frames.map_frame(&self.conn.conn, window)?;
467694
             }
@@ -470,10 +697,13 @@ impl WindowManager {
470697
 
471698
         // Apply layout to all windows
472699
         self.apply_layout()?;
700
+        // Flush to ensure ConfigureWindow requests are processed before we query geometry
701
+        self.conn.flush()?;
473702
 
474
-        // Focus the new window (only if on current workspace)
475
-        if target_idx == self.focused_workspace {
703
+        // Focus and raise the new window if on a visible workspace
704
+        if target_visible {
476705
             self.set_focus(window, true)?;
706
+            self.raise_window(window)?;
477707
         }
478708
 
479709
         Ok(())
@@ -556,37 +786,63 @@ impl WindowManager {
556786
     fn handle_destroy_notify(&mut self, event: DestroyNotifyEvent) -> Result<()> {
557787
         tracing::debug!("DestroyNotify for window {}", event.window);
558788
 
789
+        // Clean up drag state if the destroyed window was involved in a drag
790
+        if let Some(ref drag) = self.drag_state {
791
+            let drag_window = match drag {
792
+                DragState::Move { window, .. } |
793
+                DragState::Resize { window, .. } |
794
+                DragState::TiledSwap { window, .. } |
795
+                DragState::TiledResize { window, .. } => *window,
796
+            };
797
+            if drag_window == event.window {
798
+                self.drag_state = None;
799
+                let _ = self.conn.ungrab_pointer();
800
+            }
801
+        }
802
+
559803
         // Check if this was a dock window with struts
560804
         if self.dock_struts.remove(&event.window).is_some() {
561805
             tracing::info!("Dock window {} destroyed, removing strut", event.window);
562806
         }
563807
 
808
+        // Check if this window was actually managed before doing layout/focus work
809
+        let was_managed = self.windows.contains_key(&event.window);
810
+
564811
         // Remove from management
565812
         self.unmanage_window(event.window);
566813
 
567
-        // Clear the entire root window to remove any leftover pixels
568
-        // This is needed because X11 without a compositor doesn't automatically repaint
569
-        self.conn.clear_root_area(0, 0, self.conn.screen_width, self.conn.screen_height)?;
814
+        // Only do layout/focus work if the window was actually managed
815
+        // Unmanaged windows (like popup menus, tooltips) shouldn't trigger layout recalc or pointer warps
816
+        if was_managed {
817
+            // Clear the entire root window to remove any leftover pixels
818
+            // This is needed because X11 without a compositor doesn't automatically repaint
819
+            self.conn.clear_root_area(0, 0, self.conn.screen_width, self.conn.screen_height)?;
570820
 
571
-        // Re-apply layout
572
-        self.apply_layout()?;
821
+            // Re-apply layout
822
+            self.apply_layout()?;
573823
 
574
-        // Focus next window or warp to monitor if none left
575
-        if let Some(window) = self.focused_window {
576
-            self.set_focus(window, true)?;
577
-        } else {
578
-            // No windows left, warp to current monitor center
579
-            self.warp_to_monitor(self.focused_monitor)?;
824
+            // Focus next window or warp to monitor if none left
825
+            if let Some(window) = self.focused_window {
826
+                self.set_focus(window, true)?;
827
+            } else {
828
+                // No windows left, warp to current monitor center
829
+                self.warp_to_monitor(self.focused_monitor)?;
830
+            }
580831
         }
581832
 
833
+        // Always flush to ensure any pointer ungrab or other operations are sent
582834
         self.conn.flush()?;
583835
         Ok(())
584836
     }
585837
 
586838
     fn handle_button_press(&mut self, event: ButtonPressEvent) -> Result<()> {
587
-        let window = event.event;
839
+        let event_window = event.event;
588840
         let child = event.child;
589
-        tracing::debug!("ButtonPress on window {}, child {}, button {}", window, child, event.detail);
841
+        // Translate frame window to client window if needed
842
+        let window = self.frames.client_for_frame(event_window).unwrap_or(event_window);
843
+        debug_log(&format!("BUTTON_PRESS_START: event_window={}, window={}, child={}, button={}, state={:?}",
844
+            event_window, window, child, event.detail, event.state));
845
+        tracing::debug!("ButtonPress on window {} (event_window={}), child {}, button {}", window, event_window, child, event.detail);
590846
 
591847
         // If we're already in a drag, ignore additional button presses
592848
         if self.drag_state.is_some() {
@@ -642,6 +898,143 @@ impl WindowManager {
642898
             }
643899
         }
644900
 
901
+        // Mod+Button1 on TILED window = swap drag
902
+        if has_mod && !self.is_floating(target) && self.windows.contains_key(&target) && event.detail == 1 {
903
+            if self.current_workspace().tree.contains(target) {
904
+                let screen = self.screen_rect();
905
+                let tiled_geometries = self.current_workspace()
906
+                    .tree
907
+                    .calculate_geometries(screen);
908
+                let original_tree = self.current_workspace().tree.clone();
909
+
910
+                // Calculate grab offset from window origin for smooth dragging
911
+                let window_geom = tiled_geometries.iter()
912
+                    .find(|(w, _)| *w == target)
913
+                    .map(|(_, r)| *r)
914
+                    .unwrap_or_default();
915
+                let grab_offset_x = event.root_x - window_geom.x;
916
+                let grab_offset_y = event.root_y - window_geom.y;
917
+
918
+                tracing::debug!("Starting tiled swap drag for window {}, offset=({},{})",
919
+                    target, grab_offset_x, grab_offset_y);
920
+                self.drag_state = Some(DragState::TiledSwap {
921
+                    window: target,
922
+                    grab_offset_x,
923
+                    grab_offset_y,
924
+                    original_tree,
925
+                    tiled_geometries,
926
+                    hover_target: None,
927
+                    workspace: self.focused_workspace,
928
+                });
929
+                // Raise the dragged window above others
930
+                self.conn.raise_window(target)?;
931
+                self.conn.grab_pointer(Some(self.conn.cursor_move))?;
932
+                return Ok(());
933
+            }
934
+        }
935
+
936
+        // Check for edge resize on TILED windows - detect edge at click time
937
+        // This works even for apps like alacritty that don't propagate motion events
938
+        let is_managed_window = self.windows.contains_key(&window);
939
+        let is_managed_target = self.windows.contains_key(&target);
940
+        let is_window_float = is_managed_window && self.is_floating(window);
941
+        let is_target_float = is_managed_target && self.is_floating(target);
942
+        debug_log(&format!("TILED GATE: has_mod={}, button={}, window={}, target={}, is_managed_window={}, is_managed_target={}, is_window_float={}, is_target_float={}",
943
+            has_mod, event.detail, window, target, is_managed_window, is_managed_target, is_window_float, is_target_float));
944
+
945
+        // Use target (like tiled swap does) when window is root with child, otherwise window
946
+        let resize_window = if is_managed_target && !is_target_float {
947
+            target
948
+        } else if is_managed_window && !is_window_float {
949
+            window
950
+        } else {
951
+            // Neither is a valid tiled window, skip
952
+            0
953
+        };
954
+
955
+        if !has_mod && event.detail == 1 {
956
+            let work_area = self.work_area();
957
+            let geometries = self.current_workspace().tree.calculate_geometries(work_area);
958
+
959
+            debug_log(&format!("TILED EDGE CHECK: resize_window={}, pos=({},{}), num_geom={}",
960
+                resize_window, event.root_x, event.root_y, geometries.len()));
961
+
962
+            // Try to find edge resize - either from clicked window or from gap click
963
+            let edge_result = if resize_window != 0 {
964
+                // Clicked on a tiled window - check its edges
965
+                if let Some((_, my_rect)) = geometries.iter().find(|(w, _)| *w == resize_window) {
966
+                    let my_rect = *my_rect;
967
+                    debug_log(&format!("TILED EDGE CHECK: my_rect={:?}", my_rect));
968
+                    self.find_tiled_resize_edge(resize_window, &my_rect, event.root_x, event.root_y, &geometries)
969
+                } else {
970
+                    None
971
+                }
972
+            } else {
973
+                // Clicked on root/gap - find any adjacent windows near click position
974
+                debug_log(&format!("GAP CLICK: checking {} geometries for edge near ({},{})",
975
+                    geometries.len(), event.root_x, event.root_y));
976
+                self.find_edge_from_gap(event.root_x, event.root_y, &geometries)
977
+            };
978
+
979
+            if let Some((w1, w2, direction)) = edge_result {
980
+                debug_log(&format!("TILED EDGE FOUND: w1={}, w2={}, dir={:?}", w1, w2, direction));
981
+
982
+                // Calculate container size from both windows
983
+                let rect1 = geometries.iter().find(|(w, _)| *w == w1).map(|(_, r)| r);
984
+                let rect2 = geometries.iter().find(|(w, _)| *w == w2).map(|(_, r)| r);
985
+                let container_size = match (rect1, rect2) {
986
+                    (Some(r1), Some(r2)) => match direction {
987
+                        Direction::Left | Direction::Right => r1.width + r2.width,
988
+                        Direction::Up | Direction::Down => r1.height + r2.height,
989
+                    },
990
+                    _ => match direction {
991
+                        Direction::Left | Direction::Right => work_area.width,
992
+                        Direction::Up | Direction::Down => work_area.height,
993
+                    },
994
+                };
995
+
996
+                // Get current ratio from tree (use w1 which is the "left/top" window)
997
+                let start_ratio = self
998
+                    .current_workspace()
999
+                    .tree
1000
+                    .get_split_ratio(w1, direction)
1001
+                    .unwrap_or(0.5);
1002
+
1003
+                let start_pos = match direction {
1004
+                    Direction::Left | Direction::Right => event.root_x,
1005
+                    Direction::Up | Direction::Down => event.root_y,
1006
+                };
1007
+
1008
+                debug_log(&format!("STARTING TILED RESIZE: w1={}, dir={:?}, ratio={}, container={}", w1, direction, start_ratio, container_size));
1009
+                self.drag_state = Some(DragState::TiledResize {
1010
+                    direction,
1011
+                    start_pos,
1012
+                    start_ratio,
1013
+                    window: w1,
1014
+                    container_size,
1015
+                    workspace: self.focused_workspace,
1016
+                });
1017
+
1018
+                let cursor = match direction {
1019
+                    Direction::Left | Direction::Right => self.conn.cursor_h_double,
1020
+                    Direction::Up | Direction::Down => self.conn.cursor_v_double,
1021
+                };
1022
+                self.conn.grab_pointer(Some(cursor))?;
1023
+                return Ok(());
1024
+            }
1025
+
1026
+            // If we clicked on the gap (root) but didn't find an edge, replay the event
1027
+            if resize_window == 0 {
1028
+                debug_log("GAP CLICK: no edge found, replaying event");
1029
+                self.conn.conn.allow_events(
1030
+                    x11rb::protocol::xproto::Allow::REPLAY_POINTER,
1031
+                    x11rb::CURRENT_TIME,
1032
+                )?;
1033
+                self.conn.flush()?;
1034
+                return Ok(());
1035
+            }
1036
+        }
1037
+
6451038
         // Check for edge resize on floating windows (click on edge without mod key)
6461039
         let is_floating_win = self.is_floating(window);
6471040
         tracing::debug!(
@@ -673,8 +1066,11 @@ impl WindowManager {
6731066
                 self.conn.flush()?;
6741067
                 return Ok(());
6751068
             }
676
-            // 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
6771072
             if self.focused_window == Some(window) {
1073
+                self.raise_window(window)?;
6781074
                 self.conn.conn.allow_events(
6791075
                     x11rb::protocol::xproto::Allow::REPLAY_POINTER,
6801076
                     x11rb::CURRENT_TIME,
@@ -686,6 +1082,12 @@ impl WindowManager {
6861082
 
6871083
         // Only handle if we manage this window
6881084
         if !self.windows.contains_key(&window) {
1085
+            // Replay the click so it passes through to the unmanaged window
1086
+            self.conn.conn.allow_events(
1087
+                x11rb::protocol::xproto::Allow::REPLAY_POINTER,
1088
+                x11rb::CURRENT_TIME,
1089
+            )?;
1090
+            self.conn.flush()?;
6891091
             return Ok(());
6901092
         }
6911093
 
@@ -725,26 +1127,115 @@ impl WindowManager {
7251127
         if let Some(drag) = self.drag_state.take() {
7261128
             tracing::debug!("Ending drag operation");
7271129
             self.conn.ungrab_pointer()?;
728
-            self.conn.flush()?;
7291130
 
730
-            // Restore edge cursor if pointer is still over the dragged floating window
731
-            let window = match drag {
732
-                DragState::Move { window, .. } | DragState::Resize { window, .. } => window,
733
-            };
734
-            if self.is_floating(window) {
735
-                // Use the release event position to update cursor
736
-                self.update_edge_cursor(window, event.root_x, event.root_y)?;
1131
+            match drag {
1132
+                DragState::TiledSwap { window, hover_target, workspace, original_tree, .. } => {
1133
+                    // Restore target border color
1134
+                    if let Some(target) = hover_target {
1135
+                        let color = if self.focused_window == Some(target) {
1136
+                            self.config.border_color_focused
1137
+                        } else {
1138
+                            self.config.border_color_unfocused
1139
+                        };
1140
+                        self.conn.set_border(target, self.config.border_width, color)?;
1141
+                    }
1142
+
1143
+                    // Finalize: snap window to its layout position
1144
+                    if workspace == self.focused_workspace {
1145
+                        // Swaps already happened live during motion
1146
+                        // Just re-apply layout to snap dragged window to final position
1147
+                        self.apply_layout()?;
1148
+                        self.set_focus(window, true)?;
1149
+                    } else {
1150
+                        // Workspace changed during drag - revert to original
1151
+                        self.workspaces[workspace].tree = original_tree;
1152
+                        if workspace == self.focused_workspace {
1153
+                            self.apply_layout()?;
1154
+                        }
1155
+                    }
1156
+                }
1157
+                DragState::Move { window, .. } | DragState::Resize { window, .. } => {
1158
+                    // Restore edge cursor if pointer is still over the dragged floating window
1159
+                    if self.is_floating(window) {
1160
+                        self.update_edge_cursor(window, event.root_x, event.root_y)?;
1161
+                    }
1162
+                }
1163
+                DragState::TiledResize { .. } => {
1164
+                    // Layout already applied during motion
1165
+                    // Reset root cursor to default
1166
+                    self.conn.set_root_cursor(self.conn.cursor_normal)?;
1167
+                    // Allow any frozen pointer events to proceed
1168
+                    self.conn.conn.allow_events(
1169
+                        x11rb::protocol::xproto::Allow::ASYNC_POINTER,
1170
+                        x11rb::CURRENT_TIME,
1171
+                    )?;
1172
+                }
7371173
             }
1174
+
1175
+            self.conn.flush()?;
7381176
         }
7391177
         Ok(())
7401178
     }
7411179
 
7421180
     fn handle_motion_notify(&mut self, event: MotionNotifyEvent) -> Result<()> {
743
-        // If not in a drag, check for edge cursor changes on floating windows
1181
+        // Log first few motion events
1182
+        use std::sync::atomic::{AtomicU32, Ordering};
1183
+        static HANDLER_COUNT: AtomicU32 = AtomicU32::new(0);
1184
+        let count = HANDLER_COUNT.fetch_add(1, Ordering::Relaxed);
1185
+        if count < 5 {
1186
+            debug_log(&format!("MOTION_HANDLER: count={}, event_window={}, drag_state={}",
1187
+                count, event.event, self.drag_state.is_some()));
1188
+        }
1189
+
1190
+        // If not in a drag, check for edge cursor changes
7441191
         if self.drag_state.is_none() {
745
-            let window = event.event;
746
-            if self.windows.contains_key(&window) && self.is_floating(window) {
747
-                self.update_edge_cursor(window, event.root_x, event.root_y)?;
1192
+            let event_window = event.event;
1193
+            // Translate frame window to client window if needed
1194
+            let window = self.frames.client_for_frame(event_window).unwrap_or(event_window);
1195
+
1196
+            let in_windows = self.windows.contains_key(&window);
1197
+            let is_floating = in_windows && self.is_floating(window);
1198
+
1199
+            // Log occasionally (every ~100 events to avoid spam)
1200
+            use std::sync::atomic::{AtomicU32, Ordering};
1201
+            static MOTION_COUNT: AtomicU32 = AtomicU32::new(0);
1202
+            let mc = MOTION_COUNT.fetch_add(1, Ordering::Relaxed);
1203
+            if mc % 100 == 0 {
1204
+                debug_log(&format!("MOTION: event_win={}, client_win={}, in_windows={}, is_floating={}, pos=({},{})",
1205
+                    event_window, window, in_windows, is_floating, event.root_x, event.root_y));
1206
+            }
1207
+
1208
+            if in_windows {
1209
+                if is_floating {
1210
+                    self.update_edge_cursor(window, event.root_x, event.root_y)?;
1211
+                } else {
1212
+                    // Check for tiled edge hover (for cursor feedback)
1213
+                    self.update_tiled_edge_cursor(window, event.root_x, event.root_y)?;
1214
+                }
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
+                }
7481239
             }
7491240
             return Ok(());
7501241
         }
@@ -786,6 +1277,120 @@ impl WindowManager {
7861277
                 let window = *window;
7871278
                 self.set_floating_geometry(window, new_x, new_y, new_w, new_h)?;
7881279
             }
1280
+            DragState::TiledSwap {
1281
+                window,
1282
+                grab_offset_x,
1283
+                grab_offset_y,
1284
+                tiled_geometries,
1285
+                hover_target,
1286
+                workspace,
1287
+                ..
1288
+            } => {
1289
+                // Only process if still on same workspace
1290
+                if *workspace != self.focused_workspace {
1291
+                    return Ok(());
1292
+                }
1293
+
1294
+                let cursor_x = event.root_x;
1295
+                let cursor_y = event.root_y;
1296
+                let dragged = *window;
1297
+                let old_target = *hover_target;
1298
+                let grab_offset_x = *grab_offset_x;
1299
+                let grab_offset_y = *grab_offset_y;
1300
+
1301
+                // Move dragged window to follow cursor using fixed grab offset
1302
+                let new_x = cursor_x - grab_offset_x;
1303
+                let new_y = cursor_y - grab_offset_y;
1304
+                self.conn.move_window(dragged, new_x, new_y)?;
1305
+                self.conn.flush()?;
1306
+
1307
+                // Find window under cursor (excluding dragged window)
1308
+                let new_target = tiled_geometries.iter()
1309
+                    .find(|(w, rect)| {
1310
+                        *w != dragged &&
1311
+                        cursor_x >= rect.x && cursor_x < rect.x + rect.width as i16 &&
1312
+                        cursor_y >= rect.y && cursor_y < rect.y + rect.height as i16
1313
+                    })
1314
+                    .map(|(w, _)| *w);
1315
+
1316
+                // Perform live swap if target changed
1317
+                if new_target != old_target {
1318
+                    // Restore old target border
1319
+                    if let Some(old) = old_target {
1320
+                        let color = if self.focused_window == Some(old) {
1321
+                            self.config.border_color_focused
1322
+                        } else {
1323
+                            self.config.border_color_unfocused
1324
+                        };
1325
+                        self.conn.set_border(old, self.config.border_width, color)?;
1326
+                    }
1327
+
1328
+                    // Perform actual swap if new target exists
1329
+                    if let Some(target) = new_target {
1330
+                        // Swap in tree and relayout (live preview)
1331
+                        self.current_workspace_mut().tree.swap(dragged, target);
1332
+                        self.apply_layout()?;
1333
+
1334
+                        // Re-raise dragged window above others
1335
+                        self.conn.raise_window(dragged)?;
1336
+
1337
+                        // Highlight new target
1338
+                        self.conn.set_border(target, self.config.border_width,
1339
+                            self.config.border_color_swap_target)?;
1340
+
1341
+                        // Update cached geometries after swap
1342
+                        let screen = self.screen_rect();
1343
+                        let new_geometries = self.current_workspace()
1344
+                            .tree
1345
+                            .calculate_geometries(screen);
1346
+                        if let Some(DragState::TiledSwap { tiled_geometries: tg, hover_target: ht, .. }) = &mut self.drag_state {
1347
+                            *tg = new_geometries;
1348
+                            *ht = new_target;
1349
+                        }
1350
+                    } else {
1351
+                        // No new target, just update hover state
1352
+                        if let Some(DragState::TiledSwap { hover_target: ht, .. }) = &mut self.drag_state {
1353
+                            *ht = new_target;
1354
+                        }
1355
+                    }
1356
+                    self.conn.flush()?;
1357
+                }
1358
+            }
1359
+            DragState::TiledResize {
1360
+                direction,
1361
+                start_pos,
1362
+                start_ratio,
1363
+                window,
1364
+                container_size,
1365
+                workspace,
1366
+            } => {
1367
+                if *workspace != self.focused_workspace {
1368
+                    return Ok(());
1369
+                }
1370
+
1371
+                let current_pos = match direction {
1372
+                    Direction::Left | Direction::Right => event.root_x,
1373
+                    Direction::Up | Direction::Down => event.root_y,
1374
+                };
1375
+
1376
+                // Convert pixel delta to ratio delta
1377
+                let pixel_delta = current_pos - *start_pos;
1378
+                let ratio_delta = pixel_delta as f32 / *container_size as f32;
1379
+
1380
+                let new_ratio = (*start_ratio + ratio_delta).clamp(0.1, 0.9);
1381
+
1382
+                debug_log(&format!("TILED RESIZE MOTION: pixel_delta={}, new_ratio={}", pixel_delta, new_ratio));
1383
+
1384
+                // Update tree and apply layout
1385
+                let window = *window;
1386
+                let direction = *direction;
1387
+                let changed = self.current_workspace_mut()
1388
+                    .tree
1389
+                    .set_split_ratio(window, direction, new_ratio);
1390
+                debug_log(&format!("set_split_ratio returned: {}", changed));
1391
+                self.apply_layout()?;
1392
+                self.conn.flush()?;
1393
+            }
7891394
         }
7901395
 
7911396
         Ok(())
@@ -841,6 +1446,253 @@ impl WindowManager {
8411446
         Ok(())
8421447
     }
8431448
 
1449
+    /// Update cursor when hovering near edges of tiled windows.
1450
+    /// Called with the window that received the motion event.
1451
+    fn update_tiled_edge_cursor(&mut self, window: u32, root_x: i16, root_y: i16) -> Result<()> {
1452
+        let work_area = self.work_area();
1453
+        let geometries = self.current_workspace().tree.calculate_geometries(work_area);
1454
+
1455
+        // Find the geometry of the window we're over
1456
+        let my_geometry = geometries.iter().find(|(w, _)| *w == window).map(|(_, r)| r);
1457
+
1458
+        // Log occasionally
1459
+        use std::sync::atomic::{AtomicU32, Ordering};
1460
+        static EDGE_CHECK_COUNT: AtomicU32 = AtomicU32::new(0);
1461
+        let ec = EDGE_CHECK_COUNT.fetch_add(1, Ordering::Relaxed);
1462
+        if ec % 100 == 0 {
1463
+            debug_log(&format!("EDGE_CHECK: window={}, my_geom={:?}, num_geometries={}, pos=({},{})",
1464
+                window, my_geometry, geometries.len(), root_x, root_y));
1465
+        }
1466
+
1467
+        let new_state = if let Some(my_rect) = my_geometry {
1468
+            // Check if we're near an edge of this window that has an adjacent window
1469
+            self.find_tiled_resize_edge(window, my_rect, root_x, root_y, &geometries)
1470
+        } else {
1471
+            None
1472
+        };
1473
+
1474
+        // Check if cursor state changed
1475
+        if new_state == self.tiled_edge_cursor {
1476
+            return Ok(()); // No change
1477
+        }
1478
+
1479
+        // Clear old cursor state (use frame windows if they exist)
1480
+        if let Some((old_w1, old_w2, _)) = self.tiled_edge_cursor {
1481
+            let frame1 = self.frames.frame_for_client(old_w1).unwrap_or(old_w1);
1482
+            let frame2 = self.frames.frame_for_client(old_w2).unwrap_or(old_w2);
1483
+            self.conn.clear_window_cursor(frame1)?;
1484
+            self.conn.clear_window_cursor(frame2)?;
1485
+            // Also clear on client windows in case they don't have frames
1486
+            if frame1 != old_w1 {
1487
+                self.conn.clear_window_cursor(old_w1)?;
1488
+            }
1489
+            if frame2 != old_w2 {
1490
+                self.conn.clear_window_cursor(old_w2)?;
1491
+            }
1492
+        }
1493
+
1494
+        if let Some((w1, w2, dir)) = new_state {
1495
+            debug_log(&format!("EDGE DETECTED: w1={}, w2={}, dir={:?}", w1, w2, dir));
1496
+            // Set resize cursor on both windows sharing the edge (and their frames)
1497
+            let cursor = match dir {
1498
+                Direction::Left | Direction::Right => self.conn.cursor_h_double,
1499
+                Direction::Up | Direction::Down => self.conn.cursor_v_double,
1500
+            };
1501
+            let frame1 = self.frames.frame_for_client(w1).unwrap_or(w1);
1502
+            let frame2 = self.frames.frame_for_client(w2).unwrap_or(w2);
1503
+            self.conn.set_window_cursor(frame1, cursor)?;
1504
+            self.conn.set_window_cursor(frame2, cursor)?;
1505
+            // Also set on client windows
1506
+            self.conn.set_window_cursor(w1, cursor)?;
1507
+            self.conn.set_window_cursor(w2, cursor)?;
1508
+        }
1509
+        self.conn.flush()?;
1510
+
1511
+        self.tiled_edge_cursor = new_state;
1512
+        Ok(())
1513
+    }
1514
+
1515
+    /// Find if cursor is near an edge of the given window that has an adjacent tiled window.
1516
+    /// Returns (this_window, adjacent_window, direction) if on a resizable edge.
1517
+    fn find_tiled_resize_edge(
1518
+        &self,
1519
+        window: u32,
1520
+        rect: &Rect,
1521
+        x: i16,
1522
+        y: i16,
1523
+        geometries: &[(u32, Rect)],
1524
+    ) -> Option<(u32, u32, Direction)> {
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
1527
+        let gap = self.config.gap_inner as i16;
1528
+        let edge_zone = gap + 8; // Gap width plus comfortable margin
1529
+
1530
+        let left = rect.x;
1531
+        let right = rect.x + rect.width as i16;
1532
+        let top = rect.y;
1533
+        let bottom = rect.y + rect.height as i16;
1534
+
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;
1540
+
1541
+        // For each edge we're near, look for an adjacent window
1542
+        if near_left {
1543
+            // Look for window to our left
1544
+            for (other_w, other_r) in geometries {
1545
+                if *other_w == window {
1546
+                    continue;
1547
+                }
1548
+                let other_right = other_r.x + other_r.width as i16;
1549
+                // Check if other window's right edge is adjacent to our left edge
1550
+                if (other_right - left).abs() <= gap + 4 {
1551
+                    // Check vertical overlap
1552
+                    let y_overlap = y >= other_r.y.max(top) && y < (other_r.y + other_r.height as i16).min(bottom);
1553
+                    if y_overlap {
1554
+                        return Some((*other_w, window, Direction::Right));
1555
+                    }
1556
+                }
1557
+            }
1558
+        }
1559
+
1560
+        if near_right {
1561
+            // Look for window to our right
1562
+            for (other_w, other_r) in geometries {
1563
+                if *other_w == window {
1564
+                    continue;
1565
+                }
1566
+                // Check if other window's left edge is adjacent to our right edge
1567
+                if (other_r.x - right).abs() <= gap + 4 {
1568
+                    // Check vertical overlap
1569
+                    let y_overlap = y >= other_r.y.max(top) && y < (other_r.y + other_r.height as i16).min(bottom);
1570
+                    if y_overlap {
1571
+                        return Some((window, *other_w, Direction::Right));
1572
+                    }
1573
+                }
1574
+            }
1575
+        }
1576
+
1577
+        if near_top {
1578
+            // Look for window above us
1579
+            for (other_w, other_r) in geometries {
1580
+                if *other_w == window {
1581
+                    continue;
1582
+                }
1583
+                let other_bottom = other_r.y + other_r.height as i16;
1584
+                // Check if other window's bottom edge is adjacent to our top edge
1585
+                if (other_bottom - top).abs() <= gap + 4 {
1586
+                    // Check horizontal overlap
1587
+                    let x_overlap = x >= other_r.x.max(left) && x < (other_r.x + other_r.width as i16).min(right);
1588
+                    if x_overlap {
1589
+                        return Some((*other_w, window, Direction::Down));
1590
+                    }
1591
+                }
1592
+            }
1593
+        }
1594
+
1595
+        if near_bottom {
1596
+            // Look for window below us
1597
+            for (other_w, other_r) in geometries {
1598
+                if *other_w == window {
1599
+                    continue;
1600
+                }
1601
+                // Check if other window's top edge is adjacent to our bottom edge
1602
+                if (other_r.y - bottom).abs() <= gap + 4 {
1603
+                    // Check horizontal overlap
1604
+                    let x_overlap = x >= other_r.x.max(left) && x < (other_r.x + other_r.width as i16).min(right);
1605
+                    if x_overlap {
1606
+                        return Some((window, *other_w, Direction::Down));
1607
+                    }
1608
+                }
1609
+            }
1610
+        }
1611
+
1612
+        None
1613
+    }
1614
+
1615
+    /// Find a resize edge when clicking in the gap between tiled windows.
1616
+    /// Returns (left/top_window, right/bottom_window, direction) if click is in a gap.
1617
+    fn find_edge_from_gap(
1618
+        &self,
1619
+        x: i16,
1620
+        y: i16,
1621
+        geometries: &[(u32, Rect)],
1622
+    ) -> Option<(u32, u32, Direction)> {
1623
+        let gap = self.config.gap_inner as i16;
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
+        }
1630
+
1631
+        // Check all pairs of windows for horizontal adjacency (vertical split line)
1632
+        for (w1, r1) in geometries {
1633
+            let r1_right = r1.x + r1.width as i16;
1634
+            for (w2, r2) in geometries {
1635
+                if w1 == w2 {
1636
+                    continue;
1637
+                }
1638
+                // Check if w2 is to the right of w1
1639
+                let horizontal_gap = r2.x - r1_right;
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 {
1650
+                        // Check vertical overlap at click position
1651
+                        let v_overlap_top = r1.y.max(r2.y);
1652
+                        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));
1654
+                        if y >= v_overlap_top && y < v_overlap_bottom {
1655
+                            debug_log(&format!("GAP EDGE FOUND H: w1={}, w2={}, gap_x=[{},{}], y_range=[{},{}]",
1656
+                                w1, w2, r1_right, r2.x, v_overlap_top, v_overlap_bottom));
1657
+                            return Some((*w1, *w2, Direction::Right));
1658
+                        }
1659
+                    }
1660
+                }
1661
+            }
1662
+        }
1663
+
1664
+        // Check all pairs of windows for vertical adjacency (horizontal split line)
1665
+        for (w1, r1) in geometries {
1666
+            let r1_bottom = r1.y + r1.height as i16;
1667
+            for (w2, r2) in geometries {
1668
+                if w1 == w2 {
1669
+                    continue;
1670
+                }
1671
+                // Check if w2 is below w1
1672
+                let vertical_gap = r2.y - r1_bottom;
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 {
1680
+                        // Check horizontal overlap at click position
1681
+                        let h_overlap_left = r1.x.max(r2.x);
1682
+                        let h_overlap_right = (r1.x + r1.width as i16).min(r2.x + r2.width as i16);
1683
+                        if x >= h_overlap_left && x < h_overlap_right {
1684
+                            debug_log(&format!("GAP EDGE FOUND V: w1={}, w2={}, gap_y=[{},{}], x_range=[{},{}]",
1685
+                                w1, w2, r1_bottom, r2.y, h_overlap_left, h_overlap_right));
1686
+                            return Some((*w1, *w2, Direction::Down));
1687
+                        }
1688
+                    }
1689
+                }
1690
+            }
1691
+        }
1692
+
1693
+        None
1694
+    }
1695
+
8441696
     fn handle_enter_notify(&mut self, event: EnterNotifyEvent) -> Result<()> {
8451697
         let window = event.event;
8461698
 
@@ -861,8 +1713,24 @@ impl WindowManager {
8611713
             return Ok(());
8621714
         }
8631715
 
864
-        // 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.
8651719
         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
+            }
8661734
             return Ok(());
8671735
         }
8681736
 
@@ -873,10 +1741,18 @@ impl WindowManager {
8731741
 
8741742
         tracing::debug!("Focus follows mouse: focusing window {}", window);
8751743
 
1744
+        let old_workspace_idx = self.focused_workspace;
1745
+
8761746
         // Focus the new window (no warp - mouse enter)
8771747
         // set_focus handles grab/ungrab for old and new windows
8781748
         self.set_focus(window, false)?;
8791749
 
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
+
8801756
         // Raise floating windows on focus
8811757
         if self.is_floating(window) {
8821758
             self.raise_window(window)?;
@@ -952,6 +1828,19 @@ impl WindowManager {
9521828
                     _ => {}
9531829
                 }
9541830
             }
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
+            }
9551844
         } else {
9561845
             tracing::trace!("Unhandled ClientMessage type: {}", msg_type);
9571846
         }
@@ -1211,33 +2100,54 @@ impl WindowManager {
12112100
     }
12122101
 
12132102
     fn focus_direction(&mut self, direction: Direction) -> Result<()> {
2103
+        tracing::debug!(
2104
+            "focus_direction({:?}): focused_monitor={}, monitors={:?}",
2105
+            direction,
2106
+            self.focused_monitor,
2107
+            self.monitors.iter().map(|m| (&m.name, m.geometry.x)).collect::<Vec<_>>()
2108
+        );
2109
+
12142110
         let Some(focused) = self.focused_window else {
12152111
             // No focused window - try to focus adjacent monitor
2112
+            tracing::debug!("No focused window, trying adjacent monitor");
12162113
             return self.focus_adjacent_monitor(direction);
12172114
         };
12182115
 
12192116
         let screen = self.screen_rect();
12202117
         let geometries = self.current_workspace().tree.calculate_geometries(screen);
2118
+        tracing::debug!("Window geometries on workspace: {:?}", geometries);
12212119
 
12222120
         // Look up remembered window for this direction (window memory)
12232121
         let preferred = self.directional_focus_memory.get(&(focused, direction)).copied();
12242122
 
1225
-        if let Some(target) = Node::find_adjacent(&geometries, focused, direction, preferred) {
2123
+        let adjacent = Node::find_adjacent(&geometries, focused, direction, preferred);
2124
+        tracing::debug!("find_adjacent result: {:?}", adjacent);
2125
+
2126
+        if let Some(target) = adjacent {
2127
+            // Check if memory was used (preferred matched target) or default algorithm was used
2128
+            let used_memory = preferred == Some(target);
2129
+
2130
+            tracing::info!("NAV: {:?} from {} to {}, preferred={:?}, used_memory={}",
2131
+                direction, focused, target, preferred, used_memory);
2132
+
12262133
             // Store the directional focus memory for next time
12272134
             self.directional_focus_memory.insert((focused, direction), target);
2135
+            tracing::info!("NAV: stored ({}, {:?}) -> {}", focused, direction, target);
12282136
 
1229
-            // Store reverse direction only if windows are aligned (same row/column)
1230
-            // This enables "back" navigation without breaking natural movement
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
12312139
             if let (Some((_, from_rect)), Some((_, to_rect))) = (
12322140
                 geometries.iter().find(|(w, _)| *w == focused),
12332141
                 geometries.iter().find(|(w, _)| *w == target),
12342142
             ) {
1235
-                let dominated = match direction {
2143
+                let overlaps = match direction {
12362144
                     // For Left/Right: store reverse if windows share vertical space (same row)
12372145
                     Direction::Left | Direction::Right => {
12382146
                         let overlap_start = from_rect.y.max(to_rect.y);
12392147
                         let overlap_end = (from_rect.y + from_rect.height as i16)
12402148
                             .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);
12412151
                         overlap_start < overlap_end
12422152
                     }
12432153
                     // For Up/Down: store reverse if windows share horizontal space (same column)
@@ -1245,11 +2155,14 @@ impl WindowManager {
12452155
                         let overlap_start = from_rect.x.max(to_rect.x);
12462156
                         let overlap_end = (from_rect.x + from_rect.width as i16)
12472157
                             .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);
12482160
                         overlap_start < overlap_end
12492161
                     }
12502162
                 };
12512163
 
1252
-                if dominated {
2164
+                tracing::info!("NAV: overlaps={}", overlaps);
2165
+                if overlaps {
12532166
                     let opposite = match direction {
12542167
                         Direction::Left => Direction::Right,
12552168
                         Direction::Right => Direction::Left,
@@ -1257,6 +2170,7 @@ impl WindowManager {
12572170
                         Direction::Down => Direction::Up,
12582171
                     };
12592172
                     self.directional_focus_memory.insert((target, opposite), focused);
2173
+                    tracing::info!("NAV: stored reverse ({}, {:?}) -> {}", target, opposite, focused);
12602174
                 }
12612175
             }
12622176
 
@@ -1268,6 +2182,7 @@ impl WindowManager {
12682182
             tracing::debug!("Focused {:?} to window {} (preferred: {:?})", direction, target, preferred);
12692183
         } else {
12702184
             // No adjacent window on this workspace - try adjacent monitor
2185
+            tracing::debug!("No adjacent window found, trying adjacent monitor");
12712186
             self.focus_adjacent_monitor(direction)?;
12722187
         }
12732188
 
@@ -1276,7 +2191,13 @@ impl WindowManager {
12762191
 
12772192
     /// Focus the adjacent monitor in the given direction (does NOT wrap at edges)
12782193
     fn focus_adjacent_monitor(&mut self, direction: Direction) -> Result<()> {
2194
+        tracing::debug!(
2195
+            "focus_adjacent_monitor({:?}): focused_monitor={}, num_monitors={}",
2196
+            direction, self.focused_monitor, self.monitors.len()
2197
+        );
2198
+
12792199
         if self.monitors.len() <= 1 {
2200
+            tracing::debug!("Only one monitor, nothing to do");
12802201
             return Ok(());
12812202
         }
12822203
 
@@ -1285,6 +2206,7 @@ impl WindowManager {
12852206
             Direction::Left => {
12862207
                 if self.focused_monitor == 0 {
12872208
                     // At leftmost monitor - do nothing
2209
+                    tracing::debug!("Already at leftmost monitor (index 0), not navigating left");
12882210
                     return Ok(());
12892211
                 }
12902212
                 self.focused_monitor - 1
@@ -1292,21 +2214,29 @@ impl WindowManager {
12922214
             Direction::Right => {
12932215
                 if self.focused_monitor >= self.monitors.len() - 1 {
12942216
                     // At rightmost monitor - do nothing
2217
+                    tracing::debug!("Already at rightmost monitor, not navigating right");
12952218
                     return Ok(());
12962219
                 }
12972220
                 self.focused_monitor + 1
12982221
             }
12992222
             // Up/Down could navigate if monitors are stacked vertically
1300
-            Direction::Up | Direction::Down => return Ok(()),
2223
+            Direction::Up | Direction::Down => {
2224
+                tracing::debug!("Up/Down navigation not supported for horizontal monitor layout");
2225
+                return Ok(());
2226
+            }
13012227
         };
13022228
 
13032229
         tracing::info!("Moving focus from monitor {} to {}", self.focused_monitor, target_idx);
2230
+        let old_workspace_idx = self.focused_workspace;
13042231
         self.focused_monitor = target_idx;
13052232
 
13062233
         // Focus the active workspace on that monitor
13072234
         let workspace_idx = self.monitors[target_idx].active_workspace;
13082235
         self.focused_workspace = workspace_idx;
13092236
 
2237
+        // Update EWMH
2238
+        self.conn.set_current_desktop(workspace_idx as u32)?;
2239
+
13102240
         // Focus a window on that workspace if any, or just warp to monitor center
13112241
         if let Some(window) = self.workspaces[workspace_idx].focused
13122242
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
@@ -1317,10 +2247,16 @@ impl WindowManager {
13172247
         } else {
13182248
             // No windows on target monitor - clear focus and warp to monitor center
13192249
             self.focused_window = None;
2250
+            self.conn.set_active_window(None)?;
13202251
             self.warp_to_monitor(target_idx)?;
13212252
             tracing::debug!("No windows on monitor {}, warped to center", target_idx);
13222253
         }
13232254
 
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
+
13242260
         self.conn.flush()?;
13252261
         Ok(())
13262262
     }
@@ -1389,6 +2325,14 @@ impl WindowManager {
13892325
         Ok(())
13902326
     }
13912327
 
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
+
13922336
     /// Execute an i3-compatible command (from IPC RUN_COMMAND).
13932337
     /// Returns true if the command was executed successfully.
13942338
     fn execute_i3_command(&mut self, cmd: &str) -> bool {
@@ -1473,6 +2417,7 @@ impl WindowManager {
14732417
             // Focus the monitor that has this workspace
14742418
             self.focused_monitor = monitor_idx;
14752419
             self.focused_workspace = idx;
2420
+            self.workspaces[idx].last_monitor = Some(monitor_idx);
14762421
 
14772422
             // Update EWMH
14782423
             self.conn.set_current_desktop(idx as u32)?;
@@ -1497,13 +2442,16 @@ impl WindowManager {
14972442
                 self.conn.set_active_window(None)?;
14982443
             }
14992444
         } else {
1500
-            // Workspace not visible - show it on current monitor (i3 behavior)
1501
-            let current_monitor = self.focused_monitor;
1502
-            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;
15032451
 
15042452
             tracing::info!(
15052453
                 "Switching monitor {} from workspace {} to {}",
1506
-                current_monitor, old_ws + 1, idx + 1
2454
+                target_monitor, old_ws + 1, idx + 1
15072455
             );
15082456
 
15092457
             // Hide windows on old workspace
@@ -1520,7 +2468,9 @@ impl WindowManager {
15202468
             }
15212469
 
15222470
             // Update monitor's active workspace
1523
-            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;
15242474
             self.focused_workspace = idx;
15252475
 
15262476
             // Update EWMH
@@ -1548,7 +2498,7 @@ impl WindowManager {
15482498
                 self.focused_window = None;
15492499
                 self.conn.set_active_window(None)?;
15502500
                 if warp_pointer {
1551
-                    let monitor_geom = self.monitors[current_monitor].geometry;
2501
+                    let monitor_geom = self.monitors[target_monitor].geometry;
15522502
                     let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
15532503
                     let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
15542504
                     self.conn.warp_pointer(center_x, center_y)?;
@@ -1701,6 +2651,9 @@ impl WindowManager {
17012651
             tracing::warn!("Failed to regenerate picom config: {}", e);
17022652
         }
17032653
 
2654
+        // Apply screen timeout/DPMS settings
2655
+        self.config.apply_screen_timeout();
2656
+
17042657
         // Re-register keybinds
17052658
         self.setup_grabs()?;
17062659
 
@@ -1709,28 +2662,36 @@ impl WindowManager {
17092662
 
17102663
         // Handle garbar lifecycle based on new config
17112664
         if self.config.bar_enabled {
1712
-            // Check if garbar is still running
1713
-            let garbar_alive = if let Some(ref mut child) = self.garbar_process {
2665
+            // Check if garbar is still running AND healthy (socket exists)
2666
+            let (garbar_alive, garbar_healthy) = if let Some(ref mut child) = self.garbar_process {
17142667
                 match child.try_wait() {
1715
-                    Ok(None) => true,  // Still running
2668
+                    Ok(None) => (true, is_garbar_healthy()),  // Process running, check socket
17162669
                     Ok(Some(status)) => {
17172670
                         tracing::info!("garbar exited with status {}, will respawn", status);
1718
-                        false
2671
+                        (false, false)
17192672
                     }
17202673
                     Err(e) => {
17212674
                         tracing::warn!("Failed to check garbar status: {}", e);
1722
-                        false
2675
+                        (false, false)
17232676
                     }
17242677
                 }
17252678
             } else {
1726
-                false
2679
+                (false, false)
17272680
             };
17282681
 
1729
-            if garbar_alive {
1730
-                // garbar running, signal it to reload
2682
+            if garbar_alive && garbar_healthy {
2683
+                // garbar running and healthy, signal it to reload
17312684
                 if let Some(ref child) = self.garbar_process {
17322685
                     reload_garbar(child);
17332686
                 }
2687
+            } else if garbar_alive && !garbar_healthy {
2688
+                // garbar process exists but socket doesn't - it's stuck
2689
+                tracing::warn!("garbar process alive but socket missing, restarting...");
2690
+                if let Some(ref mut child) = self.garbar_process {
2691
+                    let _ = child.kill();
2692
+                    let _ = child.wait();
2693
+                }
2694
+                self.garbar_process = spawn_garbar();
17342695
             } else {
17352696
                 // garbar not running, spawn it
17362697
                 self.garbar_process = spawn_garbar();
@@ -1766,6 +2727,11 @@ impl WindowManager {
17662727
             self.garbar_process = spawn_garbar();
17672728
         }
17682729
 
2730
+        // Spawn garnotify if gar.notification is configured
2731
+        if self.config.notification_enabled {
2732
+            self.garnotify_process = spawn_garnotify();
2733
+        }
2734
+
17692735
         while self.running {
17702736
             // Handle X11 events (non-blocking poll)
17712737
             while let Some(event) = self.conn.conn.poll_for_event()? {
@@ -1796,17 +2762,31 @@ impl WindowManager {
17962762
         let _ = self.conn.sync();
17972763
         tracing::info!("Windows unmapped and synced");
17982764
 
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
+
17992770
         // Stop garbar if it was spawned
18002771
         if let Some(ref mut child) = self.garbar_process {
18012772
             stop_garbar(child);
18022773
         }
18032774
         self.garbar_process = None;
18042775
 
1805
-        // Kill picom to prevent compositor effects from bleeding into the greeter
1806
-        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)
2785
+        let _ = std::process::Command::new("pkill")
2786
+            .args(["-f", "garchomp"])
2787
+            .status();
18072788
         let _ = std::process::Command::new("pkill")
1808
-            .arg("-x")
1809
-            .arg("picom")
2789
+            .args(["-f", "picom"])
18102790
             .status();
18112791
 
18122792
         // Signal systemd that graphical session has ended
@@ -1917,6 +2897,18 @@ impl WindowManager {
19172897
                     Err(e) => Response::error(e.to_string()),
19182898
                 }
19192899
             }
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
+            }
19202912
             "reload" => {
19212913
                 match self.reload_config() {
19222914
                     Ok(_) => Response::success(None),
@@ -2050,6 +3042,7 @@ impl WindowManager {
20503042
         }
20513043
     }
20523044
 
3045
+
20533046
     fn get_floating_geometry(&self, window: u32) -> Rect {
20543047
         self.windows
20553048
             .get(&window)
@@ -2094,9 +3087,15 @@ impl WindowManager {
20943087
         // Update stacking order in workspace's floating list
20953088
         self.current_workspace_mut().raise_floating(window);
20963089
 
2097
-        // Raise in X11
3090
+        // Raise in X11 - if window has a frame, raise the frame instead
20983091
         let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
2099
-        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
+        }
21003099
         self.conn.flush()?;
21013100
         Ok(())
21023101
     }
@@ -2134,12 +3133,16 @@ impl WindowManager {
21343133
         }
21353134
 
21363135
         tracing::info!("Focusing monitor {}: '{}'", target_idx, self.monitors[target_idx].name);
3136
+        let old_workspace_idx = self.focused_workspace;
21373137
         self.focused_monitor = target_idx;
21383138
 
21393139
         // Focus the active workspace on that monitor
21403140
         let workspace_idx = self.monitors[target_idx].active_workspace;
21413141
         self.focused_workspace = workspace_idx;
21423142
 
3143
+        // Update EWMH
3144
+        self.conn.set_current_desktop(workspace_idx as u32)?;
3145
+
21433146
         // Focus a window on that workspace if any, or warp to monitor center
21443147
         if let Some(window) = self.workspaces[workspace_idx].focused
21453148
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
@@ -2148,11 +3151,17 @@ impl WindowManager {
21483151
             // set_focus handles grab/ungrab for old and new windows
21493152
             self.set_focus(window, true)?;
21503153
         } else {
2151
-            // No windows - warp to monitor center
3154
+            // No windows - clear EWMH active window and warp to monitor center
21523155
             self.focused_window = None;
3156
+            self.conn.set_active_window(None)?;
21533157
             self.warp_to_monitor(target_idx)?;
21543158
         }
21553159
 
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
+
21563165
         self.conn.flush()?;
21573166
         Ok(())
21583167
     }
@@ -2199,12 +3208,18 @@ impl WindowManager {
21993208
             window, target_idx, self.monitors[target_idx].name, target_workspace + 1);
22003209
 
22013210
         // Remove from current workspace
3211
+        let source_ws = self.focused_workspace;
22023212
         if is_floating {
22033213
             self.current_workspace_mut().remove_floating(window);
22043214
         } else {
22053215
             self.current_workspace_mut().tree.remove(window);
22063216
         }
22073217
 
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
+
22083223
         // Update window's workspace
22093224
         if let Some(win) = self.windows.get_mut(&window) {
22103225
             win.workspace = target_workspace;
@@ -2223,16 +3238,25 @@ impl WindowManager {
22233238
         self.conn.set_window_desktop(window, target_workspace as u32)?;
22243239
 
22253240
         // Focus follows window to new monitor
3241
+        let old_workspace_idx = self.focused_workspace;
22263242
         self.focused_monitor = target_idx;
22273243
         self.focused_workspace = target_workspace;
22283244
         self.workspaces[target_workspace].focused = Some(window);
22293245
 
3246
+        // Update EWMH
3247
+        self.conn.set_current_desktop(target_workspace as u32)?;
3248
+
22303249
         // Apply layouts on both monitors
22313250
         self.apply_layout()?;
22323251
 
22333252
         // Set X11 focus on the moved window (updates focused_window, button grabs, EWMH)
22343253
         self.set_focus(window, true)?;
22353254
 
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
+
22363260
         self.conn.flush()?;
22373261
         Ok(())
22383262
     }
@@ -2291,13 +3315,14 @@ impl WindowManager {
22913315
                 win.floating = false;
22923316
             }
22933317
 
2294
-            // Standard event mask for tiled window
3318
+            // Event mask for tiled window (includes POINTER_MOTION for tiled edge resize)
22953319
             self.conn.select_input(
22963320
                 window,
22973321
                 EventMask::ENTER_WINDOW
22983322
                     | EventMask::FOCUS_CHANGE
22993323
                     | EventMask::PROPERTY_CHANGE
2300
-                    | EventMask::STRUCTURE_NOTIFY,
3324
+                    | EventMask::STRUCTURE_NOTIFY
3325
+                    | EventMask::POINTER_MOTION,
23013326
             )?;
23023327
 
23033328
             // Clear edge cursor state if this window had one
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
         }