gar Public
Comparing changes
Choose two branches to see what's changed or to start a new pull request.
Able to merge.
These branches can be automatically merged.
24 commits
11 files changed
2 contributors
Commits on trunk
.gitignoremodified@@ -1,3 +1,4 @@ | ||
| 1 | 1 | docs/ |
| 2 | 2 | target/ |
| 3 | 3 | CLAUDE.md |
| 4 | +flake.lock | |
examples/init.luamodified@@ -16,7 +16,11 @@ gar.exec_once("garclip daemon --foreground") | ||
| 16 | 16 | gar.exec_once("gartray daemon") |
| 17 | 17 | |
| 18 | 18 | -- Polkit authentication agent (needed for power actions via D-Bus) |
| 19 | -gar.exec_once("/usr/libexec/kf6/polkit-kde-authentication-agent-1") | |
| 19 | +gar.exec_once("garcard daemon") | |
| 20 | + | |
| 21 | +-- External fallback authentication agents (optional): | |
| 22 | +-- gar.exec_once("/usr/libexec/kf6/polkit-kde-authentication-agent-1") | |
| 23 | +-- gar.exec_once("/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1") | |
| 20 | 24 | |
| 21 | 25 | -- Uncomment the ones you want: |
| 22 | 26 | -- gar.exec_once("picom") -- Compositor (for transparency/shadows) |
gar-session.shmodified@@ -52,17 +52,9 @@ systemctl --user start gar-session.target | ||
| 52 | 52 | |
| 53 | 53 | # ═══════════════════════════════════════════════════════════════════ |
| 54 | 54 | |
| 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 | |
| 66 | 58 | |
| 67 | 59 | # Set log level |
| 68 | 60 | export GAR_LOG=info |
gar/config/picom.confmodified@@ -19,7 +19,6 @@ vsync = true; | ||
| 19 | 19 | use-ewmh-active-win = true; |
| 20 | 20 | |
| 21 | 21 | # GLX backend optimizations |
| 22 | -glx-no-stencil = true; | |
| 23 | 22 | glx-no-rebind-pixmap = true; |
| 24 | 23 | |
| 25 | 24 | # ============================================================================= |
gar/src/config/lua.rsmodified@@ -383,11 +383,42 @@ impl LuaConfig { | ||
| 383 | 383 | state.config.mouse_follows_focus = v; |
| 384 | 384 | } |
| 385 | 385 | } |
| 386 | + "focus_follows_mouse" => { | |
| 387 | + if let Value::Boolean(v) = value { | |
| 388 | + state.config.focus_follows_mouse = v; | |
| 389 | + } | |
| 390 | + } | |
| 386 | 391 | "bar_height" => { |
| 387 | 392 | if let Value::Integer(v) = value { |
| 388 | 393 | state.config.bar_height = v as u32; |
| 389 | 394 | } |
| 390 | 395 | } |
| 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 | + } | |
| 391 | 422 | // Compositor visual settings (picom) |
| 392 | 423 | "corner_radius" => { |
| 393 | 424 | if let Value::Integer(v) = value { |
@@ -781,13 +812,13 @@ impl LuaConfig { | ||
| 781 | 812 | rule.opacity = Some(op); |
| 782 | 813 | } |
| 783 | 814 | |
| 784 | - // Optional: shadow | |
| 785 | - if let Ok(shadow) = table.get::<bool>("shadow") { | |
| 815 | + // Optional: shadow (use Option<bool> so nil doesn't become false) | |
| 816 | + if let Ok(Some(shadow)) = table.get::<Option<bool>>("shadow") { | |
| 786 | 817 | rule.shadow = Some(shadow); |
| 787 | 818 | } |
| 788 | 819 | |
| 789 | - // Optional: blur_background | |
| 790 | - if let Ok(blur) = table.get::<bool>("blur_background") { | |
| 820 | + // Optional: blur_background (use Option<bool> so nil doesn't become false) | |
| 821 | + if let Ok(Some(blur)) = table.get::<Option<bool>>("blur_background") { | |
| 791 | 822 | rule.blur_background = Some(blur); |
| 792 | 823 | } |
| 793 | 824 | |
gar/src/config/mod.rsmodified@@ -31,6 +31,7 @@ pub struct Config { | ||
| 31 | 31 | // Behavior settings |
| 32 | 32 | pub follow_window_on_move: bool, |
| 33 | 33 | pub mouse_follows_focus: bool, |
| 34 | + pub focus_follows_mouse: bool, | |
| 34 | 35 | // Manual bar/panel reserved space (overrides struts) |
| 35 | 36 | pub bar_height: u32, |
| 36 | 37 | // garbar integration: spawn garbar automatically if gar.bar is configured |
@@ -43,6 +44,10 @@ pub struct Config { | ||
| 43 | 44 | // Screen timeout/DPMS settings |
| 44 | 45 | pub screen_timeout_enabled: bool, |
| 45 | 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, | |
| 46 | 51 | // Compositor visual settings (picom) |
| 47 | 52 | // These are stored for reference and potential dynamic picom config generation |
| 48 | 53 | pub corner_radius: u32, |
@@ -79,23 +84,26 @@ impl Config { | ||
| 79 | 84 | /// Generate picom.conf content from current config settings. |
| 80 | 85 | pub fn generate_picom_config(&self) -> String { |
| 81 | 86 | 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 | + | |
| 82 | 99 | format!( |
| 83 | 100 | r#"# Blur |
| 84 | 101 | blur-method = "{}"; |
| 85 | 102 | blur-strength = {}; |
| 86 | 103 | blur-background = true; |
| 87 | 104 | blur-background-frame = false; |
| 88 | -blur-kern = "3x3box"; | |
| 89 | - | |
| 90 | -blur-background-exclude = [ | |
| 91 | - "window_type = 'dock'", | |
| 92 | - "window_type = 'desktop'", | |
| 93 | - "window_type = 'menu'", | |
| 94 | - "window_type = 'dropdown_menu'", | |
| 95 | - "window_type = 'popup_menu'", | |
| 96 | - "_NET_WM_BYPASS_COMPOSITOR = 1" | |
| 97 | -];"#, | |
| 98 | - self.blur_method, self.blur_strength | |
| 105 | +blur-kern = "3x3box";"#, | |
| 106 | + blur_method, blur_strength | |
| 99 | 107 | ) |
| 100 | 108 | } else { |
| 101 | 109 | "# Blur disabled".to_string() |
@@ -108,18 +116,7 @@ shadow = true; | ||
| 108 | 116 | shadow-radius = {}; |
| 109 | 117 | shadow-opacity = {:.2}; |
| 110 | 118 | shadow-offset-x = {}; |
| 111 | -shadow-offset-y = {}; | |
| 112 | - | |
| 113 | -shadow-exclude = [ | |
| 114 | - "window_type = 'dock'", | |
| 115 | - "window_type = 'desktop'", | |
| 116 | - "window_type = 'menu'", | |
| 117 | - "window_type = 'dropdown_menu'", | |
| 118 | - "window_type = 'popup_menu'", | |
| 119 | - "window_type = 'tooltip'", | |
| 120 | - "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'", | |
| 121 | - "_NET_WM_BYPASS_COMPOSITOR = 1" | |
| 122 | -];"#, | |
| 119 | +shadow-offset-y = {};"#, | |
| 123 | 120 | self.shadow_radius, |
| 124 | 121 | self.shadow_opacity, |
| 125 | 122 | self.shadow_offset_x, |
@@ -137,13 +134,7 @@ fade-in-step = 0.028; | ||
| 137 | 134 | fade-out-step = 0.03; |
| 138 | 135 | fade-delta = {}; |
| 139 | 136 | |
| 140 | -no-fading-destroyed-argb = true; | |
| 141 | - | |
| 142 | -fade-exclude = [ | |
| 143 | - "window_type = 'menu'", | |
| 144 | - "window_type = 'dropdown_menu'", | |
| 145 | - "window_type = 'popup_menu'" | |
| 146 | -];"#, | |
| 137 | +no-fading-destroyed-argb = true;"#, | |
| 147 | 138 | self.fade_delta |
| 148 | 139 | ) |
| 149 | 140 | } else { |
@@ -213,42 +204,105 @@ animations = ({{ | ||
| 213 | 204 | "# No custom shader".to_string() |
| 214 | 205 | }; |
| 215 | 206 | |
| 216 | - // Per-window rules section | |
| 217 | - let rules_section = if !self.picom_rules.is_empty() { | |
| 218 | - let mut rules = String::from("# Per-window Rules\nrules = (\n"); | |
| 219 | - for rule in &self.picom_rules { | |
| 220 | - rules.push_str(&format!(" {{\n match = \"{}\";\n", rule.match_expr)); | |
| 221 | - if let Some(cr) = rule.corner_radius { | |
| 222 | - rules.push_str(&format!(" corner-radius = {};\n", cr)); | |
| 223 | - } | |
| 224 | - if let Some(opacity) = rule.opacity { | |
| 225 | - rules.push_str(&format!(" opacity = {:.2};\n", opacity)); | |
| 226 | - } | |
| 227 | - if let Some(shadow) = rule.shadow { | |
| 228 | - rules.push_str(&format!(" shadow = {};\n", shadow)); | |
| 229 | - } | |
| 230 | - if let Some(blur) = rule.blur_background { | |
| 231 | - rules.push_str(&format!(" blur-background = {};\n", blur)); | |
| 232 | - } | |
| 233 | - if let Some(ref shader) = rule.shader { | |
| 234 | - let expanded = if shader.starts_with("~/") { | |
| 235 | - if let Some(home) = dirs::home_dir() { | |
| 236 | - home.join(&shader[2..]).to_string_lossy().to_string() | |
| 237 | - } else { | |
| 238 | - shader.clone() | |
| 239 | - } | |
| 207 | + // Unified rules section (picom v13 format) | |
| 208 | + // Replaces deprecated: shadow-exclude, fade-exclude, blur-background-exclude, | |
| 209 | + // rounded-corners-exclude, and wintypes blocks | |
| 210 | + let mut rules = String::from("# Rules (picom v13 format)\nrules = (\n"); | |
| 211 | + | |
| 212 | + // Built-in window type rules | |
| 213 | + rules.push_str(r#" { | |
| 214 | + match = "window_type = 'dock'"; | |
| 215 | + shadow = false; | |
| 216 | + corner-radius = 0; | |
| 217 | + blur-background = false; | |
| 218 | + clip-shadow-above = true; | |
| 219 | + }, | |
| 220 | + { | |
| 221 | + match = "window_type = 'desktop'"; | |
| 222 | + shadow = false; | |
| 223 | + corner-radius = 0; | |
| 224 | + blur-background = false; | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + match = "window_type = 'tooltip'"; | |
| 228 | + shadow = false; | |
| 229 | + corner-radius = 0; | |
| 230 | + blur-background = false; | |
| 231 | + fade = true; | |
| 232 | + opacity = 0.95; | |
| 233 | + focus = true; | |
| 234 | + }, | |
| 235 | + { | |
| 236 | + match = "window_type = 'menu'"; | |
| 237 | + shadow = false; | |
| 238 | + corner-radius = 0; | |
| 239 | + blur-background = false; | |
| 240 | + fade = false; | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + match = "window_type = 'dropdown_menu'"; | |
| 244 | + shadow = false; | |
| 245 | + corner-radius = 0; | |
| 246 | + blur-background = false; | |
| 247 | + fade = false; | |
| 248 | + opacity = 0.95; | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + match = "window_type = 'popup_menu'"; | |
| 252 | + shadow = false; | |
| 253 | + corner-radius = 0; | |
| 254 | + blur-background = false; | |
| 255 | + fade = false; | |
| 256 | + opacity = 0.95; | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + match = "window_type = 'dnd'"; | |
| 260 | + shadow = false; | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + match = "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'"; | |
| 264 | + corner-radius = 0; | |
| 265 | + shadow = false; | |
| 266 | + }, | |
| 267 | + { | |
| 268 | + match = "_NET_WM_BYPASS_COMPOSITOR = 1"; | |
| 269 | + shadow = false; | |
| 270 | + blur-background = false; | |
| 271 | + }, | |
| 272 | +"#); | |
| 273 | + | |
| 274 | + // User custom picom rules | |
| 275 | + for rule in &self.picom_rules { | |
| 276 | + rules.push_str(&format!(" {{\n match = \"{}\";\n", rule.match_expr)); | |
| 277 | + if let Some(cr) = rule.corner_radius { | |
| 278 | + rules.push_str(&format!(" corner-radius = {};\n", cr)); | |
| 279 | + } | |
| 280 | + if let Some(opacity) = rule.opacity { | |
| 281 | + rules.push_str(&format!(" opacity = {:.2};\n", opacity)); | |
| 282 | + } | |
| 283 | + if let Some(shadow) = rule.shadow { | |
| 284 | + rules.push_str(&format!(" shadow = {};\n", shadow)); | |
| 285 | + } | |
| 286 | + if let Some(blur) = rule.blur_background { | |
| 287 | + rules.push_str(&format!(" blur-background = {};\n", blur)); | |
| 288 | + } | |
| 289 | + if let Some(ref shader) = rule.shader { | |
| 290 | + let expanded = if shader.starts_with("~/") { | |
| 291 | + if let Some(home) = dirs::home_dir() { | |
| 292 | + home.join(&shader[2..]).to_string_lossy().to_string() | |
| 240 | 293 | } else { |
| 241 | 294 | shader.clone() |
| 242 | - }; | |
| 243 | - rules.push_str(&format!(" shader = \"{}\";\n", expanded)); | |
| 244 | - } | |
| 245 | - rules.push_str(" },\n"); | |
| 295 | + } | |
| 296 | + } else { | |
| 297 | + shader.clone() | |
| 298 | + }; | |
| 299 | + rules.push_str(&format!(" shader = \"{}\";\n", expanded)); | |
| 246 | 300 | } |
| 247 | - rules.push_str(");"); | |
| 248 | - rules | |
| 249 | - } else { | |
| 250 | - "# No per-window rules".to_string() | |
| 251 | - }; | |
| 301 | + rules.push_str(" },\n"); | |
| 302 | + } | |
| 303 | + | |
| 304 | + rules.push_str(");"); | |
| 305 | + let rules_section = rules; | |
| 252 | 306 | |
| 253 | 307 | format!( |
| 254 | 308 | r#"# picom.conf - Auto-generated by gar window manager |
@@ -256,24 +310,13 @@ animations = ({{ | ||
| 256 | 310 | # Edit ~/.config/gar/init.lua instead and reload with Mod+Shift+R |
| 257 | 311 | |
| 258 | 312 | # Backend Configuration |
| 259 | -backend = "glx"; | |
| 313 | +backend = "{}"; | |
| 260 | 314 | vsync = true; |
| 261 | 315 | use-ewmh-active-win = true; |
| 262 | -glx-no-stencil = true; | |
| 263 | 316 | |
| 264 | 317 | # Rounded Corners |
| 265 | 318 | corner-radius = {}; |
| 266 | 319 | |
| 267 | -rounded-corners-exclude = [ | |
| 268 | - "window_type = 'dock'", | |
| 269 | - "window_type = 'desktop'", | |
| 270 | - "window_type = 'tooltip'", | |
| 271 | - "window_type = 'menu'", | |
| 272 | - "window_type = 'dropdown_menu'", | |
| 273 | - "window_type = 'popup_menu'", | |
| 274 | - "_NET_WM_STATE *= '_NET_WM_STATE_FULLSCREEN'" | |
| 275 | -]; | |
| 276 | - | |
| 277 | 320 | {} |
| 278 | 321 | |
| 279 | 322 | {} |
@@ -287,34 +330,8 @@ rounded-corners-exclude = [ | ||
| 287 | 330 | {} |
| 288 | 331 | |
| 289 | 332 | {} |
| 290 | - | |
| 291 | -# Window Type Settings | |
| 292 | -wintypes: | |
| 293 | -{{ | |
| 294 | - tooltip = {{ | |
| 295 | - fade = true; | |
| 296 | - shadow = false; | |
| 297 | - opacity = 0.95; | |
| 298 | - focus = true; | |
| 299 | - blur-background = false; | |
| 300 | - }}; | |
| 301 | - dock = {{ | |
| 302 | - shadow = false; | |
| 303 | - clip-shadow-above = true; | |
| 304 | - }}; | |
| 305 | - dnd = {{ | |
| 306 | - shadow = false; | |
| 307 | - }}; | |
| 308 | - popup_menu = {{ | |
| 309 | - opacity = 0.95; | |
| 310 | - shadow = false; | |
| 311 | - }}; | |
| 312 | - dropdown_menu = {{ | |
| 313 | - opacity = 0.95; | |
| 314 | - shadow = false; | |
| 315 | - }}; | |
| 316 | -}}; | |
| 317 | 333 | "#, |
| 334 | + self.picom_backend, | |
| 318 | 335 | self.corner_radius, |
| 319 | 336 | blur_section, |
| 320 | 337 | shadow_section, |
@@ -326,8 +343,15 @@ wintypes: | ||
| 326 | 343 | ) |
| 327 | 344 | } |
| 328 | 345 | |
| 329 | - /// Write picom config to ~/.config/gar/picom.conf and signal picom to reload. | |
| 346 | + /// Write picom config to ~/.config/gar/picom.conf and optionally restart picom. | |
| 347 | + /// Only writes/restarts if compositor is set to "picom". | |
| 330 | 348 | 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 | + | |
| 331 | 355 | let config_dir = dirs::config_dir() |
| 332 | 356 | .ok_or_else(|| std::io::Error::new( |
| 333 | 357 | std::io::ErrorKind::NotFound, |
@@ -350,63 +374,110 @@ wintypes: | ||
| 350 | 374 | Ok(()) |
| 351 | 375 | } |
| 352 | 376 | |
| 353 | - /// Apply screen timeout/DPMS settings using xset | |
| 354 | - pub fn apply_screen_timeout(&self) { | |
| 377 | + /// Start the configured compositor. | |
| 378 | + /// Called on gar startup to launch the appropriate compositor. | |
| 379 | + pub fn start_compositor(&self) { | |
| 355 | 380 | use std::process::Command; |
| 356 | 381 | |
| 357 | - if self.screen_timeout_enabled { | |
| 358 | - // Enable DPMS and set timeout | |
| 359 | - let timeout = self.screen_timeout_seconds.to_string(); | |
| 360 | - match Command::new("xset") | |
| 361 | - .args(["dpms", &timeout, &timeout, &timeout]) | |
| 362 | - .status() | |
| 363 | - { | |
| 364 | - Ok(status) if status.success() => { | |
| 365 | - tracing::info!("Set DPMS timeout to {} seconds", self.screen_timeout_seconds); | |
| 382 | + match self.compositor.as_str() { | |
| 383 | + "picom" => { | |
| 384 | + // Generate picom config first | |
| 385 | + if let Err(e) = self.write_picom_config() { | |
| 386 | + tracing::warn!("Failed to write picom config: {}", e); | |
| 387 | + } | |
| 388 | + // picom will be started by write_picom_config -> reload_picom | |
| 389 | + } | |
| 390 | + "garchomp" => { | |
| 391 | + // Kill any existing compositor first (use -f for NixOS wrappers) | |
| 392 | + let _ = Command::new("pkill").args(["-f", "picom"]).status(); | |
| 393 | + let _ = Command::new("pkill").args(["-f", "garchomp"]).status(); | |
| 394 | + | |
| 395 | + std::thread::sleep(std::time::Duration::from_millis(100)); | |
| 396 | + | |
| 397 | + // Start garchomp | |
| 398 | + match Command::new("garchomp").spawn() { | |
| 399 | + Ok(_) => { | |
| 400 | + tracing::info!("Started garchomp compositor"); | |
| 401 | + } | |
| 402 | + Err(e) => { | |
| 403 | + tracing::error!("Failed to start garchomp: {}", e); | |
| 404 | + // Fall back to picom | |
| 405 | + tracing::info!("Falling back to picom"); | |
| 406 | + Self::reload_picom(); | |
| 407 | + } | |
| 408 | + } | |
| 409 | + } | |
| 410 | + "none" => { | |
| 411 | + tracing::info!("Compositor disabled (compositor=none)"); | |
| 412 | + // Kill any running compositor (use -f for NixOS wrappers) | |
| 413 | + let _ = Command::new("pkill").args(["-f", "picom"]).status(); | |
| 414 | + let _ = Command::new("pkill").args(["-f", "garchomp"]).status(); | |
| 415 | + } | |
| 416 | + _ => { | |
| 417 | + tracing::warn!("Unknown compositor '{}', defaulting to picom", self.compositor); | |
| 418 | + if let Err(e) = self.write_picom_config() { | |
| 419 | + tracing::warn!("Failed to write picom config: {}", e); | |
| 366 | 420 | } |
| 367 | - Ok(_) => tracing::warn!("xset dpms command failed"), | |
| 368 | - Err(e) => tracing::warn!("Failed to run xset: {}", e), | |
| 369 | 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 | + } | |
| 370 | 455 | |
| 371 | - // Enable screen saver with same timeout | |
| 456 | + if self.screen_timeout_enabled { | |
| 457 | + // Use X11 screen saver for software blanking (safe, no hardware power changes) | |
| 458 | + let timeout = self.screen_timeout_seconds.to_string(); | |
| 372 | 459 | match Command::new("xset") |
| 373 | 460 | .args(["s", &timeout, &timeout]) |
| 374 | 461 | .status() |
| 375 | 462 | { |
| 376 | 463 | Ok(status) if status.success() => { |
| 377 | - tracing::debug!("Set screen saver timeout to {} seconds", self.screen_timeout_seconds); | |
| 464 | + tracing::info!( | |
| 465 | + "Screen blanking enabled via X11 screen saver ({} seconds)", | |
| 466 | + self.screen_timeout_seconds | |
| 467 | + ); | |
| 378 | 468 | } |
| 379 | 469 | Ok(_) => tracing::warn!("xset s command failed"), |
| 380 | 470 | Err(e) => tracing::warn!("Failed to run xset: {}", e), |
| 381 | 471 | } |
| 382 | - | |
| 383 | - // Make sure DPMS is enabled | |
| 384 | - match Command::new("xset").args(["+dpms"]).status() { | |
| 385 | - Ok(_) => {} | |
| 386 | - Err(e) => tracing::warn!("Failed to enable DPMS: {}", e), | |
| 387 | - } | |
| 388 | 472 | } else { |
| 389 | - // Disable DPMS and screen saver | |
| 390 | - match Command::new("xset").args(["dpms", "0", "0", "0"]).status() { | |
| 391 | - Ok(status) if status.success() => { | |
| 392 | - tracing::info!("Disabled DPMS timeout"); | |
| 393 | - } | |
| 394 | - Ok(_) => tracing::warn!("xset dpms 0 command failed"), | |
| 395 | - Err(e) => tracing::warn!("Failed to run xset: {}", e), | |
| 396 | - } | |
| 397 | - | |
| 473 | + // Disable screen saver blanking too | |
| 398 | 474 | match Command::new("xset").args(["s", "off"]).status() { |
| 399 | 475 | Ok(status) if status.success() => { |
| 400 | - tracing::debug!("Disabled screen saver"); | |
| 476 | + tracing::info!("Screen blanking disabled"); | |
| 401 | 477 | } |
| 402 | 478 | Ok(_) => tracing::warn!("xset s off command failed"), |
| 403 | 479 | Err(e) => tracing::warn!("Failed to run xset: {}", e), |
| 404 | 480 | } |
| 405 | - | |
| 406 | - match Command::new("xset").args(["-dpms"]).status() { | |
| 407 | - Ok(_) => {} | |
| 408 | - Err(e) => tracing::warn!("Failed to disable DPMS: {}", e), | |
| 409 | - } | |
| 410 | 481 | } |
| 411 | 482 | } |
| 412 | 483 | |
@@ -474,6 +545,8 @@ impl Default for Config { | ||
| 474 | 545 | follow_window_on_move: false, |
| 475 | 546 | // Behavior: warp mouse pointer to center of focused window |
| 476 | 547 | mouse_follows_focus: false, |
| 548 | + // Behavior: focus window when mouse enters it | |
| 549 | + focus_follows_mouse: true, | |
| 477 | 550 | // Manual bar height (0 = use struts from dock windows) |
| 478 | 551 | bar_height: 0, |
| 479 | 552 | // garbar not enabled by default (enabled when gar.bar table is set) |
@@ -482,9 +555,14 @@ impl Default for Config { | ||
| 482 | 555 | notification_enabled: false, |
| 483 | 556 | // Monitor order: empty = sort by X position |
| 484 | 557 | monitor_order: Vec::new(), |
| 485 | - // Screen timeout: enabled by default with 10 minute timeout | |
| 486 | - screen_timeout_enabled: true, | |
| 558 | + // Screen timeout: disabled by default - X11 screen saver blanking triggers | |
| 559 | + // Xid 79 GPU crashes on NVIDIA with multi-monitor HDMI setups | |
| 560 | + screen_timeout_enabled: false, | |
| 487 | 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(), | |
| 488 | 566 | // Compositor settings (picom) - matching picom.conf defaults |
| 489 | 567 | corner_radius: 12, |
| 490 | 568 | blur_enabled: true, |
gar/src/core/mod.rsmodified@@ -59,7 +59,7 @@ pub struct WindowManager { | ||
| 59 | 59 | |
| 60 | 60 | impl WindowManager { |
| 61 | 61 | pub fn new(conn: Connection) -> Result<Self> { |
| 62 | - let workspaces: Vec<Workspace> = (1..=10) | |
| 62 | + let mut workspaces: Vec<Workspace> = (1..=10) | |
| 63 | 63 | .map(|i| Workspace::new(i, i.to_string())) |
| 64 | 64 | .collect(); |
| 65 | 65 | |
@@ -75,10 +75,8 @@ impl WindowManager { | ||
| 75 | 75 | // Get config values from Lua state |
| 76 | 76 | let config = lua_state.lock().unwrap().config.clone(); |
| 77 | 77 | |
| 78 | - // Generate picom config from settings | |
| 79 | - if let Err(e) = config.write_picom_config() { | |
| 80 | - tracing::warn!("Failed to generate picom config: {}", e); | |
| 81 | - } | |
| 78 | + // Start the configured compositor (picom, garchomp, or none) | |
| 79 | + config.start_compositor(); | |
| 82 | 80 | |
| 83 | 81 | // Apply screen timeout/DPMS settings |
| 84 | 82 | config.apply_screen_timeout(); |
@@ -133,6 +131,9 @@ impl WindowManager { | ||
| 133 | 131 | for (i, monitor) in monitors.iter_mut().enumerate() { |
| 134 | 132 | monitor.workspaces = vec![i]; // Just track initial workspace |
| 135 | 133 | 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 | + } | |
| 136 | 137 | tracing::debug!("Monitor '{}' starts with workspace {}", monitor.name, i + 1); |
| 137 | 138 | } |
| 138 | 139 | |
@@ -294,6 +295,9 @@ impl WindowManager { | ||
| 294 | 295 | pub fn refresh_monitors(&mut self) -> Result<()> { |
| 295 | 296 | tracing::info!("Refreshing monitor configuration"); |
| 296 | 297 | |
| 298 | + // Update cached screen dimensions (may have changed due to rotation) | |
| 299 | + self.conn.update_screen_size(); | |
| 300 | + | |
| 297 | 301 | let mut new_monitors = self.conn.detect_monitors().unwrap_or_else(|e| { |
| 298 | 302 | tracing::warn!("Failed to detect monitors: {}, keeping current", e); |
| 299 | 303 | return self.monitors.clone(); |
@@ -330,6 +334,9 @@ impl WindowManager { | ||
| 330 | 334 | monitor.workspaces = vec![first_free]; |
| 331 | 335 | used_workspaces.insert(first_free); |
| 332 | 336 | } |
| 337 | + if monitor.active_workspace < self.workspaces.len() { | |
| 338 | + self.workspaces[monitor.active_workspace].last_monitor = Some(i); | |
| 339 | + } | |
| 333 | 340 | tracing::info!("Monitor '{}' showing workspace {}", monitor.name, monitor.active_workspace + 1); |
| 334 | 341 | } |
| 335 | 342 | |
@@ -825,6 +832,11 @@ impl WindowManager { | ||
| 825 | 832 | // Update borders for all windows on visible workspaces |
| 826 | 833 | for ws_idx in visible_ws { |
| 827 | 834 | 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 | + | |
| 828 | 840 | // Check if window is urgent (and not focused - focused clears urgency) |
| 829 | 841 | let is_urgent = self.windows.get(&window) |
| 830 | 842 | .map(|w| w.urgent && Some(window) != focused) |
@@ -904,19 +916,42 @@ impl WindowManager { | ||
| 904 | 916 | fs_window, screen |
| 905 | 917 | ); |
| 906 | 918 | |
| 907 | - // Configure fullscreen window to cover entire monitor (no gaps, no borders) | |
| 908 | - self.conn.configure_window( | |
| 909 | - fs_window, | |
| 910 | - screen.x, | |
| 911 | - screen.y, | |
| 912 | - screen.width, | |
| 913 | - screen.height, | |
| 914 | - 0, // No border for fullscreen | |
| 915 | - )?; | |
| 919 | + if let Some(frame) = self.frames.frame_for_client(fs_window) { | |
| 920 | + // Window has a frame — position frame at monitor origin with no border, | |
| 921 | + // and configure client to fill the entire frame. | |
| 922 | + let frame_aux = ConfigureWindowAux::new() | |
| 923 | + .x(screen.x as i32) | |
| 924 | + .y(screen.y as i32) | |
| 925 | + .width(screen.width as u32) | |
| 926 | + .height(screen.height as u32) | |
| 927 | + .border_width(0); | |
| 928 | + self.conn.conn.configure_window(frame, &frame_aux)?; | |
| 929 | + | |
| 930 | + let client_aux = ConfigureWindowAux::new() | |
| 931 | + .x(0) | |
| 932 | + .y(0) | |
| 933 | + .width(screen.width as u32) | |
| 934 | + .height(screen.height as u32) | |
| 935 | + .border_width(0); | |
| 936 | + self.conn.conn.configure_window(fs_window, &client_aux)?; | |
| 937 | + | |
| 938 | + // Raise frame above everything | |
| 939 | + let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE); | |
| 940 | + self.conn.conn.configure_window(frame, &aux)?; | |
| 941 | + } else { | |
| 942 | + // No frame — configure client directly | |
| 943 | + self.conn.configure_window( | |
| 944 | + fs_window, | |
| 945 | + screen.x, | |
| 946 | + screen.y, | |
| 947 | + screen.width, | |
| 948 | + screen.height, | |
| 949 | + 0, | |
| 950 | + )?; | |
| 916 | 951 | |
| 917 | - // Raise fullscreen window above everything | |
| 918 | - let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE); | |
| 919 | - self.conn.conn.configure_window(fs_window, &aux)?; | |
| 952 | + let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE); | |
| 953 | + self.conn.conn.configure_window(fs_window, &aux)?; | |
| 954 | + } | |
| 920 | 955 | |
| 921 | 956 | // Skip normal layout for this workspace - fullscreen window covers everything |
| 922 | 957 | continue; |
@@ -976,6 +1011,12 @@ impl WindowManager { | ||
| 976 | 1011 | border_width as u16, |
| 977 | 1012 | )?; |
| 978 | 1013 | |
| 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 | + | |
| 979 | 1020 | tracing::debug!( |
| 980 | 1021 | "apply_layout: TILED+FRAME window={} at ({}, {}) size {}x{} (titlebar: {})", |
| 981 | 1022 | window, gapped_x, gapped_y, final_width.max(1), final_height.max(1), titlebar_height |
@@ -994,6 +1035,10 @@ impl WindowManager { | ||
| 994 | 1035 | final_height.max(1), |
| 995 | 1036 | border_width, |
| 996 | 1037 | )?; |
| 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)?; | |
| 997 | 1042 | } |
| 998 | 1043 | |
| 999 | 1044 | // Store the actual geometry for pointer warping |
@@ -1036,18 +1081,17 @@ impl WindowManager { | ||
| 1036 | 1081 | if let Some(frame) = self.frames.frame_for_client(window_id) { |
| 1037 | 1082 | let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE); |
| 1038 | 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) | |
| 1087 | + ); | |
| 1088 | + } else { | |
| 1089 | + tracing::warn!( | |
| 1090 | + "apply_layout: FLOATING window={} has_frame=true but no frame found!", | |
| 1091 | + window_id | |
| 1092 | + ); | |
| 1039 | 1093 | } |
| 1040 | - | |
| 1041 | - tracing::debug!( | |
| 1042 | - "apply_layout: FLOATING+FRAME window={} at ({}, {}) size {}x{} (raising)", | |
| 1043 | - window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1) | |
| 1044 | - ); | |
| 1045 | 1094 | } else { |
| 1046 | - tracing::debug!( | |
| 1047 | - "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)", | |
| 1048 | - window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1) | |
| 1049 | - ); | |
| 1050 | - | |
| 1051 | 1095 | // Configure geometry |
| 1052 | 1096 | self.conn.configure_window( |
| 1053 | 1097 | window_id, |
@@ -1061,6 +1105,11 @@ impl WindowManager { | ||
| 1061 | 1105 | // Raise to top of stack (each subsequent window goes above the previous) |
| 1062 | 1106 | let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE); |
| 1063 | 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 | + ); | |
| 1064 | 1113 | } |
| 1065 | 1114 | |
| 1066 | 1115 | // Store the actual geometry for pointer warping |
gar/src/core/workspace.rsmodified@@ -11,6 +11,8 @@ pub struct Workspace { | ||
| 11 | 11 | pub floating: Vec<XWindow>, |
| 12 | 12 | pub focused: Option<XWindow>, |
| 13 | 13 | pub visible: bool, |
| 14 | + /// Last monitor this workspace was displayed on (for focus-back behavior) | |
| 15 | + pub last_monitor: Option<usize>, | |
| 14 | 16 | } |
| 15 | 17 | |
| 16 | 18 | impl Workspace { |
@@ -22,6 +24,7 @@ impl Workspace { | ||
| 22 | 24 | floating: Vec::new(), |
| 23 | 25 | focused: None, |
| 24 | 26 | visible: id == 1, |
| 27 | + last_monitor: None, | |
| 25 | 28 | } |
| 26 | 29 | } |
| 27 | 30 | |
gar/src/x11/connection.rsmodified@@ -65,6 +65,7 @@ pub struct Connection { | ||
| 65 | 65 | pub net_wm_state: Atom, |
| 66 | 66 | pub net_wm_state_modal: Atom, |
| 67 | 67 | pub net_wm_state_fullscreen: Atom, |
| 68 | + pub net_wm_state_above: Atom, | |
| 68 | 69 | // EWMH atoms for workspaces |
| 69 | 70 | pub net_supported: Atom, |
| 70 | 71 | pub net_supporting_wm_check: Atom, |
@@ -133,6 +134,7 @@ impl Connection { | ||
| 133 | 134 | let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom; |
| 134 | 135 | let net_wm_state_modal = conn.intern_atom(false, b"_NET_WM_STATE_MODAL")?.reply()?.atom; |
| 135 | 136 | 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; | |
| 136 | 138 | |
| 137 | 139 | // Intern EWMH atoms for workspaces and WM identification |
| 138 | 140 | let net_supported = conn.intern_atom(false, b"_NET_SUPPORTED")?.reply()?.atom; |
@@ -199,6 +201,7 @@ impl Connection { | ||
| 199 | 201 | net_wm_state, |
| 200 | 202 | net_wm_state_modal, |
| 201 | 203 | net_wm_state_fullscreen, |
| 204 | + net_wm_state_above, | |
| 202 | 205 | net_supported, |
| 203 | 206 | net_supporting_wm_check, |
| 204 | 207 | net_client_list, |
@@ -241,7 +244,9 @@ impl Connection { | ||
| 241 | 244 | EventMask::SUBSTRUCTURE_REDIRECT |
| 242 | 245 | | EventMask::SUBSTRUCTURE_NOTIFY |
| 243 | 246 | | EventMask::STRUCTURE_NOTIFY |
| 244 | - | EventMask::PROPERTY_CHANGE, | |
| 247 | + | EventMask::PROPERTY_CHANGE | |
| 248 | + | EventMask::ENTER_WINDOW | |
| 249 | + | EventMask::POINTER_MOTION, | |
| 245 | 250 | ) |
| 246 | 251 | .background_pixel(self.screen().black_pixel) |
| 247 | 252 | .cursor(self.cursor_normal); |
@@ -769,7 +774,7 @@ impl Connection { | ||
| 769 | 774 | } |
| 770 | 775 | } |
| 771 | 776 | |
| 772 | - // 3. Check _NET_WM_STATE for modal windows | |
| 777 | + // 3. Check _NET_WM_STATE for modal or above windows | |
| 773 | 778 | if let Ok(cookie) = self.conn.get_property( |
| 774 | 779 | false, |
| 775 | 780 | window, |
@@ -786,6 +791,10 @@ impl Connection { | ||
| 786 | 791 | tracing::debug!("Window {} is modal, should float", window); |
| 787 | 792 | return true; |
| 788 | 793 | } |
| 794 | + if atom == self.net_wm_state_above { | |
| 795 | + tracing::debug!("Window {} has ABOVE state, should float", window); | |
| 796 | + return true; | |
| 797 | + } | |
| 789 | 798 | } |
| 790 | 799 | } |
| 791 | 800 | } |
@@ -1067,6 +1076,8 @@ impl Connection { | ||
| 1067 | 1076 | self.net_close_window, |
| 1068 | 1077 | self.net_wm_state, |
| 1069 | 1078 | self.net_wm_state_fullscreen, |
| 1079 | + self.net_wm_state_modal, | |
| 1080 | + self.net_wm_state_above, | |
| 1070 | 1081 | self.net_wm_name, |
| 1071 | 1082 | ]; |
| 1072 | 1083 | self.conn.change_property32( |
@@ -1327,6 +1338,38 @@ impl Connection { | ||
| 1327 | 1338 | )?; |
| 1328 | 1339 | Ok(()) |
| 1329 | 1340 | } |
| 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 | + } | |
| 1330 | 1373 | } |
| 1331 | 1374 | |
| 1332 | 1375 | impl std::ops::Deref for Connection { |
gar/src/x11/events.rsmodified@@ -574,8 +574,15 @@ impl WindowManager { | ||
| 574 | 574 | Event::EnterNotify(e) => { |
| 575 | 575 | self.handle_enter_notify(e)?; |
| 576 | 576 | } |
| 577 | - Event::RandrScreenChangeNotify(_) => { | |
| 578 | - tracing::info!("RandR screen change detected, refreshing monitors"); | |
| 577 | + Event::RandrScreenChangeNotify(e) => { | |
| 578 | + tracing::info!( | |
| 579 | + "RandR screen change: {}x{} -> {}x{}", | |
| 580 | + self.conn.screen_width, self.conn.screen_height, | |
| 581 | + e.width, e.height | |
| 582 | + ); | |
| 583 | + // Update cached screen dimensions from the event | |
| 584 | + self.conn.screen_width = e.width; | |
| 585 | + self.conn.screen_height = e.height; | |
| 579 | 586 | self.refresh_monitors()?; |
| 580 | 587 | self.broadcast_i3_output_event(); |
| 581 | 588 | } |
@@ -693,9 +700,10 @@ impl WindowManager { | ||
| 693 | 700 | // Flush to ensure ConfigureWindow requests are processed before we query geometry |
| 694 | 701 | self.conn.flush()?; |
| 695 | 702 | |
| 696 | - // Focus the new window if on a visible workspace | |
| 703 | + // Focus and raise the new window if on a visible workspace | |
| 697 | 704 | if target_visible { |
| 698 | 705 | self.set_focus(window, true)?; |
| 706 | + self.raise_window(window)?; | |
| 699 | 707 | } |
| 700 | 708 | |
| 701 | 709 | Ok(()) |
@@ -1058,8 +1066,11 @@ impl WindowManager { | ||
| 1058 | 1066 | self.conn.flush()?; |
| 1059 | 1067 | return Ok(()); |
| 1060 | 1068 | } |
| 1061 | - // Not on edge - if this is a focused floating window, replay the click | |
| 1069 | + // Not on edge - if this is a focused floating window, raise it and replay the click | |
| 1070 | + // This ensures clicking on a floating window that somehow ended up behind | |
| 1071 | + // other windows will bring it to the front | |
| 1062 | 1072 | if self.focused_window == Some(window) { |
| 1073 | + self.raise_window(window)?; | |
| 1063 | 1074 | self.conn.conn.allow_events( |
| 1064 | 1075 | x11rb::protocol::xproto::Allow::REPLAY_POINTER, |
| 1065 | 1076 | x11rb::CURRENT_TIME, |
@@ -1201,13 +1212,30 @@ impl WindowManager { | ||
| 1201 | 1212 | // Check for tiled edge hover (for cursor feedback) |
| 1202 | 1213 | self.update_tiled_edge_cursor(window, event.root_x, event.root_y)?; |
| 1203 | 1214 | } |
| 1204 | - } else if self.tiled_edge_cursor.is_some() { | |
| 1205 | - // Moving to unmanaged window or root - clear tiled edge cursor | |
| 1206 | - let (old_w1, old_w2, _) = self.tiled_edge_cursor.unwrap(); | |
| 1207 | - self.conn.clear_window_cursor(old_w1)?; | |
| 1208 | - self.conn.clear_window_cursor(old_w2)?; | |
| 1209 | - self.conn.flush()?; | |
| 1210 | - self.tiled_edge_cursor = None; | |
| 1215 | + } else { | |
| 1216 | + if self.tiled_edge_cursor.is_some() { | |
| 1217 | + // Moving to unmanaged window or root - clear tiled edge cursor | |
| 1218 | + let (old_w1, old_w2, _) = self.tiled_edge_cursor.unwrap(); | |
| 1219 | + self.conn.clear_window_cursor(old_w1)?; | |
| 1220 | + self.conn.clear_window_cursor(old_w2)?; | |
| 1221 | + self.conn.flush()?; | |
| 1222 | + self.tiled_edge_cursor = None; | |
| 1223 | + } | |
| 1224 | + // Pointer is on root/empty desktop — check for cross-monitor movement | |
| 1225 | + let monitor_idx = self.monitor_idx_at_point(event.root_x, event.root_y); | |
| 1226 | + if monitor_idx != self.focused_monitor { | |
| 1227 | + let old_workspace_idx = self.focused_workspace; | |
| 1228 | + self.focused_monitor = monitor_idx; | |
| 1229 | + let workspace_idx = self.monitors[monitor_idx].active_workspace; | |
| 1230 | + self.focused_workspace = workspace_idx; | |
| 1231 | + self.focused_window = None; | |
| 1232 | + self.conn.set_active_window(None)?; | |
| 1233 | + self.conn.set_current_desktop(workspace_idx as u32)?; | |
| 1234 | + if workspace_idx != old_workspace_idx { | |
| 1235 | + self.broadcast_i3_workspace_event("focus", workspace_idx, Some(old_workspace_idx)); | |
| 1236 | + } | |
| 1237 | + self.conn.flush()?; | |
| 1238 | + } | |
| 1211 | 1239 | } |
| 1212 | 1240 | return Ok(()); |
| 1213 | 1241 | } |
@@ -1685,8 +1713,24 @@ impl WindowManager { | ||
| 1685 | 1713 | return Ok(()); |
| 1686 | 1714 | } |
| 1687 | 1715 | |
| 1688 | - // Only focus windows we manage | |
| 1716 | + // For unmanaged windows (root window / empty desktop areas), | |
| 1717 | + // check if the pointer crossed to a different monitor and update | |
| 1718 | + // focused workspace accordingly so garbar underline tracks correctly. | |
| 1689 | 1719 | 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 | + } | |
| 1690 | 1734 | return Ok(()); |
| 1691 | 1735 | } |
| 1692 | 1736 | |
@@ -1697,10 +1741,18 @@ impl WindowManager { | ||
| 1697 | 1741 | |
| 1698 | 1742 | tracing::debug!("Focus follows mouse: focusing window {}", window); |
| 1699 | 1743 | |
| 1744 | + let old_workspace_idx = self.focused_workspace; | |
| 1745 | + | |
| 1700 | 1746 | // Focus the new window (no warp - mouse enter) |
| 1701 | 1747 | // set_focus handles grab/ungrab for old and new windows |
| 1702 | 1748 | self.set_focus(window, false)?; |
| 1703 | 1749 | |
| 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 | + | |
| 1704 | 1756 | // Raise floating windows on focus |
| 1705 | 1757 | if self.is_floating(window) { |
| 1706 | 1758 | self.raise_window(window)?; |
@@ -1776,6 +1828,19 @@ impl WindowManager { | ||
| 1776 | 1828 | _ => {} |
| 1777 | 1829 | } |
| 1778 | 1830 | } |
| 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 | + } | |
| 1779 | 1844 | } else { |
| 1780 | 1845 | tracing::trace!("Unhandled ClientMessage type: {}", msg_type); |
| 1781 | 1846 | } |
@@ -2162,12 +2227,16 @@ impl WindowManager { | ||
| 2162 | 2227 | }; |
| 2163 | 2228 | |
| 2164 | 2229 | tracing::info!("Moving focus from monitor {} to {}", self.focused_monitor, target_idx); |
| 2230 | + let old_workspace_idx = self.focused_workspace; | |
| 2165 | 2231 | self.focused_monitor = target_idx; |
| 2166 | 2232 | |
| 2167 | 2233 | // Focus the active workspace on that monitor |
| 2168 | 2234 | let workspace_idx = self.monitors[target_idx].active_workspace; |
| 2169 | 2235 | self.focused_workspace = workspace_idx; |
| 2170 | 2236 | |
| 2237 | + // Update EWMH | |
| 2238 | + self.conn.set_current_desktop(workspace_idx as u32)?; | |
| 2239 | + | |
| 2171 | 2240 | // Focus a window on that workspace if any, or just warp to monitor center |
| 2172 | 2241 | if let Some(window) = self.workspaces[workspace_idx].focused |
| 2173 | 2242 | .or_else(|| self.workspaces[workspace_idx].floating.last().copied()) |
@@ -2178,10 +2247,16 @@ impl WindowManager { | ||
| 2178 | 2247 | } else { |
| 2179 | 2248 | // No windows on target monitor - clear focus and warp to monitor center |
| 2180 | 2249 | self.focused_window = None; |
| 2250 | + self.conn.set_active_window(None)?; | |
| 2181 | 2251 | self.warp_to_monitor(target_idx)?; |
| 2182 | 2252 | tracing::debug!("No windows on monitor {}, warped to center", target_idx); |
| 2183 | 2253 | } |
| 2184 | 2254 | |
| 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 | + | |
| 2185 | 2260 | self.conn.flush()?; |
| 2186 | 2261 | Ok(()) |
| 2187 | 2262 | } |
@@ -2250,6 +2325,14 @@ impl WindowManager { | ||
| 2250 | 2325 | Ok(()) |
| 2251 | 2326 | } |
| 2252 | 2327 | |
| 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 | + | |
| 2253 | 2336 | /// Execute an i3-compatible command (from IPC RUN_COMMAND). |
| 2254 | 2337 | /// Returns true if the command was executed successfully. |
| 2255 | 2338 | fn execute_i3_command(&mut self, cmd: &str) -> bool { |
@@ -2334,6 +2417,7 @@ impl WindowManager { | ||
| 2334 | 2417 | // Focus the monitor that has this workspace |
| 2335 | 2418 | self.focused_monitor = monitor_idx; |
| 2336 | 2419 | self.focused_workspace = idx; |
| 2420 | + self.workspaces[idx].last_monitor = Some(monitor_idx); | |
| 2337 | 2421 | |
| 2338 | 2422 | // Update EWMH |
| 2339 | 2423 | self.conn.set_current_desktop(idx as u32)?; |
@@ -2358,13 +2442,16 @@ impl WindowManager { | ||
| 2358 | 2442 | self.conn.set_active_window(None)?; |
| 2359 | 2443 | } |
| 2360 | 2444 | } else { |
| 2361 | - // Workspace not visible - show it on current monitor (i3 behavior) | |
| 2362 | - let current_monitor = self.focused_monitor; | |
| 2363 | - let old_ws = self.monitors[current_monitor].active_workspace; | |
| 2445 | + // Workspace not visible — show it on the monitor it was last on, | |
| 2446 | + // falling back to the current monitor if it was never shown. | |
| 2447 | + let target_monitor = self.workspaces[idx].last_monitor | |
| 2448 | + .filter(|&m| m < self.monitors.len()) | |
| 2449 | + .unwrap_or(self.focused_monitor); | |
| 2450 | + let old_ws = self.monitors[target_monitor].active_workspace; | |
| 2364 | 2451 | |
| 2365 | 2452 | tracing::info!( |
| 2366 | 2453 | "Switching monitor {} from workspace {} to {}", |
| 2367 | - current_monitor, old_ws + 1, idx + 1 | |
| 2454 | + target_monitor, old_ws + 1, idx + 1 | |
| 2368 | 2455 | ); |
| 2369 | 2456 | |
| 2370 | 2457 | // Hide windows on old workspace |
@@ -2381,7 +2468,9 @@ impl WindowManager { | ||
| 2381 | 2468 | } |
| 2382 | 2469 | |
| 2383 | 2470 | // Update monitor's active workspace |
| 2384 | - self.monitors[current_monitor].active_workspace = idx; | |
| 2471 | + self.monitors[target_monitor].active_workspace = idx; | |
| 2472 | + self.workspaces[idx].last_monitor = Some(target_monitor); | |
| 2473 | + self.focused_monitor = target_monitor; | |
| 2385 | 2474 | self.focused_workspace = idx; |
| 2386 | 2475 | |
| 2387 | 2476 | // Update EWMH |
@@ -2409,7 +2498,7 @@ impl WindowManager { | ||
| 2409 | 2498 | self.focused_window = None; |
| 2410 | 2499 | self.conn.set_active_window(None)?; |
| 2411 | 2500 | if warp_pointer { |
| 2412 | - let monitor_geom = self.monitors[current_monitor].geometry; | |
| 2501 | + let monitor_geom = self.monitors[target_monitor].geometry; | |
| 2413 | 2502 | let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2); |
| 2414 | 2503 | let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2); |
| 2415 | 2504 | self.conn.warp_pointer(center_x, center_y)?; |
@@ -2690,11 +2779,14 @@ impl WindowManager { | ||
| 2690 | 2779 | } |
| 2691 | 2780 | self.garnotify_process = None; |
| 2692 | 2781 | |
| 2693 | - // Kill picom to prevent compositor effects from bleeding into the greeter | |
| 2694 | - tracing::info!("Killing picom..."); | |
| 2782 | + // Kill compositor to prevent overlay from bleeding into the greeter | |
| 2783 | + tracing::info!("Killing compositor..."); | |
| 2784 | + // Use -f to match against full command line (needed for NixOS wrappers) | |
| 2785 | + let _ = std::process::Command::new("pkill") | |
| 2786 | + .args(["-f", "garchomp"]) | |
| 2787 | + .status(); | |
| 2695 | 2788 | let _ = std::process::Command::new("pkill") |
| 2696 | - .arg("-x") | |
| 2697 | - .arg("picom") | |
| 2789 | + .args(["-f", "picom"]) | |
| 2698 | 2790 | .status(); |
| 2699 | 2791 | |
| 2700 | 2792 | // Signal systemd that graphical session has ended |
@@ -2805,6 +2897,18 @@ impl WindowManager { | ||
| 2805 | 2897 | Err(e) => Response::error(e.to_string()), |
| 2806 | 2898 | } |
| 2807 | 2899 | } |
| 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 | + } | |
| 2808 | 2912 | "reload" => { |
| 2809 | 2913 | match self.reload_config() { |
| 2810 | 2914 | Ok(_) => Response::success(None), |
@@ -2983,9 +3087,15 @@ impl WindowManager { | ||
| 2983 | 3087 | // Update stacking order in workspace's floating list |
| 2984 | 3088 | self.current_workspace_mut().raise_floating(window); |
| 2985 | 3089 | |
| 2986 | - // Raise in X11 | |
| 3090 | + // Raise in X11 - if window has a frame, raise the frame instead | |
| 2987 | 3091 | let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE); |
| 2988 | - self.conn.conn.configure_window(window, &aux)?; | |
| 3092 | + if let Some(frame) = self.frames.frame_for_client(window) { | |
| 3093 | + tracing::info!("raise_window: window={} -> raising frame={}", window, frame); | |
| 3094 | + self.conn.conn.configure_window(frame, &aux)?; | |
| 3095 | + } else { | |
| 3096 | + tracing::info!("raise_window: window={} (no frame)", window); | |
| 3097 | + self.conn.conn.configure_window(window, &aux)?; | |
| 3098 | + } | |
| 2989 | 3099 | self.conn.flush()?; |
| 2990 | 3100 | Ok(()) |
| 2991 | 3101 | } |
@@ -3023,12 +3133,16 @@ impl WindowManager { | ||
| 3023 | 3133 | } |
| 3024 | 3134 | |
| 3025 | 3135 | tracing::info!("Focusing monitor {}: '{}'", target_idx, self.monitors[target_idx].name); |
| 3136 | + let old_workspace_idx = self.focused_workspace; | |
| 3026 | 3137 | self.focused_monitor = target_idx; |
| 3027 | 3138 | |
| 3028 | 3139 | // Focus the active workspace on that monitor |
| 3029 | 3140 | let workspace_idx = self.monitors[target_idx].active_workspace; |
| 3030 | 3141 | self.focused_workspace = workspace_idx; |
| 3031 | 3142 | |
| 3143 | + // Update EWMH | |
| 3144 | + self.conn.set_current_desktop(workspace_idx as u32)?; | |
| 3145 | + | |
| 3032 | 3146 | // Focus a window on that workspace if any, or warp to monitor center |
| 3033 | 3147 | if let Some(window) = self.workspaces[workspace_idx].focused |
| 3034 | 3148 | .or_else(|| self.workspaces[workspace_idx].floating.last().copied()) |
@@ -3037,11 +3151,17 @@ impl WindowManager { | ||
| 3037 | 3151 | // set_focus handles grab/ungrab for old and new windows |
| 3038 | 3152 | self.set_focus(window, true)?; |
| 3039 | 3153 | } else { |
| 3040 | - // No windows - warp to monitor center | |
| 3154 | + // No windows - clear EWMH active window and warp to monitor center | |
| 3041 | 3155 | self.focused_window = None; |
| 3156 | + self.conn.set_active_window(None)?; | |
| 3042 | 3157 | self.warp_to_monitor(target_idx)?; |
| 3043 | 3158 | } |
| 3044 | 3159 | |
| 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 | + | |
| 3045 | 3165 | self.conn.flush()?; |
| 3046 | 3166 | Ok(()) |
| 3047 | 3167 | } |
@@ -3088,12 +3208,18 @@ impl WindowManager { | ||
| 3088 | 3208 | window, target_idx, self.monitors[target_idx].name, target_workspace + 1); |
| 3089 | 3209 | |
| 3090 | 3210 | // Remove from current workspace |
| 3211 | + let source_ws = self.focused_workspace; | |
| 3091 | 3212 | if is_floating { |
| 3092 | 3213 | self.current_workspace_mut().remove_floating(window); |
| 3093 | 3214 | } else { |
| 3094 | 3215 | self.current_workspace_mut().tree.remove(window); |
| 3095 | 3216 | } |
| 3096 | 3217 | |
| 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 | + | |
| 3097 | 3223 | // Update window's workspace |
| 3098 | 3224 | if let Some(win) = self.windows.get_mut(&window) { |
| 3099 | 3225 | win.workspace = target_workspace; |
@@ -3112,16 +3238,25 @@ impl WindowManager { | ||
| 3112 | 3238 | self.conn.set_window_desktop(window, target_workspace as u32)?; |
| 3113 | 3239 | |
| 3114 | 3240 | // Focus follows window to new monitor |
| 3241 | + let old_workspace_idx = self.focused_workspace; | |
| 3115 | 3242 | self.focused_monitor = target_idx; |
| 3116 | 3243 | self.focused_workspace = target_workspace; |
| 3117 | 3244 | self.workspaces[target_workspace].focused = Some(window); |
| 3118 | 3245 | |
| 3246 | + // Update EWMH | |
| 3247 | + self.conn.set_current_desktop(target_workspace as u32)?; | |
| 3248 | + | |
| 3119 | 3249 | // Apply layouts on both monitors |
| 3120 | 3250 | self.apply_layout()?; |
| 3121 | 3251 | |
| 3122 | 3252 | // Set X11 focus on the moved window (updates focused_window, button grabs, EWMH) |
| 3123 | 3253 | self.set_focus(window, true)?; |
| 3124 | 3254 | |
| 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 | + | |
| 3125 | 3260 | self.conn.flush()?; |
| 3126 | 3261 | Ok(()) |
| 3127 | 3262 | } |
garctl/src/main.rsmodified@@ -49,6 +49,8 @@ enum Command { | ||
| 49 | 49 | ToggleFloating, |
| 50 | 50 | /// Equalize split ratios |
| 51 | 51 | Equalize, |
| 52 | + /// Refresh window layout (re-apply without changing ratios) | |
| 53 | + RefreshLayout, | |
| 52 | 54 | /// Reload configuration |
| 53 | 55 | Reload, |
| 54 | 56 | /// Exit gar |
@@ -129,6 +131,9 @@ fn main() { | ||
| 129 | 131 | Command::Equalize => { |
| 130 | 132 | json!({ "command": "equalize", "args": {} }) |
| 131 | 133 | } |
| 134 | + Command::RefreshLayout => { | |
| 135 | + json!({ "command": "refresh_layout", "args": {} }) | |
| 136 | + } | |
| 132 | 137 | Command::Reload => { |
| 133 | 138 | json!({ "command": "reload", "args": {} }) |
| 134 | 139 | } |