git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitdbe7bfc690ee5a8edfccf93f0b10fa4f3e90fd5e
parent594bf3568a
authorLucas Galante <[email protected]>
date2026-05-24 17:04
Update components and system settings

 src/borders.rs     |   6 ++-
 src/clearctl.rs    |  60 +++++++++++++----------
 src/config.rs      |  48 +++++++++++++++++++
 src/decorations.rs |  16 ++++++-
 src/restart.rs     |   4 ++
 src/state.rs       |   1 +
 src/status.rs      |   1 +
 src/types.rs       |   7 +++
 src/wayland.rs     | 136 ++++++++++++++++++++++++++++++++++++++++-------------
 src/wm.rs          |  23 ++++++++-
 10 files changed, 241 insertions(+), 61 deletions(-)

diff --git a/src/borders.rs b/src/borders.rs
index 8c7c69b..984d138 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -84,7 +84,10 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
             state.layout.border_b,
         );
 
-        let (r, g, b, a) = if is_focused {
+        let (r, g, b, a) = if win.tiling_mode == TilingMode::Popup {
+            // Popup windows have a transparent border
+            (0, 0, 0, 0)
+        } else if is_focused {
             // Focused window: pure border color
             (
                 state.layout.border_r,
@@ -119,6 +122,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
             TilingMode::Vsplit => state.layout.vsplit_border_width,
             TilingMode::Hsplit => state.layout.hsplit_border_width,
             TilingMode::Floating => state.layout.floating_border_width,
+            TilingMode::Popup => state.layout.border_width,
         };
 
         if win.app_id.as_deref() == Some("clear-status-interface") {
diff --git a/src/clearctl.rs b/src/clearctl.rs
index eb58a30..3a6d64f 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -8,38 +8,50 @@ use std::process;
 
 const SOCKET_PATH: &str = "/tmp/clearwm.sock";
 
-fn usage(name: &str) {
-    eprintln!("usage: {} <command> [args...]", name);
-    eprintln!();
-    eprintln!("commands:");
-    eprintln!("  layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
-    eprintln!("  view <1-4>");
-    eprintln!("  toggle <1-4>");
-    eprintln!("  close");
-    eprintln!("  focus-next");
-    eprintln!("  windows");
-    eprintln!("  exit");
-    eprintln!("  restart");
-    eprintln!("  reload");
-    eprintln!("  repeat <rate> <delay>");
-    eprintln!("  config-done");
-    eprintln!("  spawn <command>");
-    eprintln!("  notify <title> [body]");
-    eprintln!("  bind <mods> <keysym> <action> [args...]");
-    eprintln!("  pbind <mods> <button> <action>");
-    eprintln!("  retile");
-    eprintln!("  set-tag <1-4>");
-    eprintln!("  mode <cascade|grid|vsplit|hsplit|fullscreen|floating> <app_id> [title]");
-    eprintln!("  tag-layout <1-4> <cascade|grid|vsplit|hsplit|fullscreen|floating>");
+fn usage(name: &str, to_stderr: bool) {
+    let print = |s: &str| {
+        if to_stderr {
+            eprintln!("{}", s);
+        } else {
+            println!("{}", s);
+        }
+    };
+    print(&format!("usage: {} <command> [args...]", name));
+    print("");
+    print("commands:");
+    print("  layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
+    print("  view <1-4>");
+    print("  toggle <1-4>");
+    print("  close");
+    print("  focus-next");
+    print("  windows");
+    print("  exit");
+    print("  restart");
+    print("  reload");
+    print("  repeat <rate> <delay>");
+    print("  config-done");
+    print("  spawn <command>");
+    print("  notify <title> [body]");
+    print("  bind <mods> <keysym> <action> [args...]");
+    print("  pbind <mods> <button> <action>");
+    print("  retile");
+    print("  set-tag <1-4>");
+    print("  mode <cascade|grid|vsplit|hsplit|fullscreen|floating|popup> <app_id> [title]");
+    print("  tag-layout <1-4> <cascade|grid|vsplit|hsplit|fullscreen|floating|popup>");
 }
 
 fn main() {
     let args: Vec<String> = env::args().collect();
     if args.len() < 2 {
-        usage(&args[0]);
+        usage(&args[0], true);
         process::exit(1);
     }
 
+    if args[1] == "--help" || args[1] == "-h" || args[1] == "help" {
+        usage(&args[0], false);
+        return;
+    }
+
     // Special case: "windows" reads the status file directly
     if args[1] == "windows" {
         match fs::read_to_string("/tmp/clearwm-windows") {
diff --git a/src/config.rs b/src/config.rs
index a060828..e6d13e6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -33,6 +33,13 @@ pub struct Config {
     pub tag_layout: Vec<TagLayoutConfig>,
     #[serde(default)]
     pub notifications: NotificationsConfig,
+    #[serde(default)]
+    pub reload: Vec<ReloadEntryConfig>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ReloadEntryConfig {
+    pub exec: String,
 }
 
 #[derive(Debug, Deserialize)]
@@ -380,6 +387,12 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
     // [notifications]
     state.notifications_enable = config.notifications.enable;
 
+    // [[reload]] array
+    state.reload_commands.clear();
+    for entry in &config.reload {
+        state.reload_commands.push(entry.exec.clone());
+    }
+
     // Signal config-done
     state.config_done = true;
     Ok(())
@@ -596,6 +609,12 @@ enable = false
         let config_empty: Config = toml::from_str(toml_str_empty).unwrap();
         assert!(config_empty.notifications.enable);
     }
+
+    #[test]
+    fn test_parse_keysym_favorites() {
+        let sym = parse_keysym("XF86Favorites");
+        assert!(sym != 0, "XF86Favorites keysym must be resolved successfully");
+    }
 }
 
 #[cfg(test)]
@@ -623,4 +642,33 @@ once = true
         assert!(config.startup[1].once);
         assert_eq!(config.env.get("XDG_CURRENT_DESKTOP").unwrap(), "river");
     }
+
+    #[test]
+    fn test_reload_entry_format() {
+        let toml_str = r#"
+[[reload]]
+exec = "pkill clear-input-manager"
+
+[[reload]]
+exec = "echo reloaded"
+"#;
+        let config: Config = toml::from_str(toml_str).expect("TOML parse failed");
+        assert_eq!(config.reload.len(), 2);
+        assert_eq!(config.reload[0].exec, "pkill clear-input-manager");
+        assert_eq!(config.reload[1].exec, "echo reloaded");
+
+        // Also test integration via parse_config (we can write to a temporary file in /tmp or mock it,
+        // but wait! we can write to a temporary file inside the workspace)
+        let temp_path = "/home/lsgalante/Dropbox/Clear/clear-window-manager/scratch_config_test.toml";
+        std::fs::write(temp_path, toml_str).unwrap();
+
+        let mut wm = WindowManager::default();
+        let parse_res = parse_config(temp_path, false, &mut wm);
+        let _ = std::fs::remove_file(temp_path);
+
+        parse_res.unwrap();
+        assert_eq!(wm.reload_commands.len(), 2);
+        assert_eq!(wm.reload_commands[0], "pkill clear-input-manager");
+        assert_eq!(wm.reload_commands[1], "echo reloaded");
+    }
 }
diff --git a/src/decorations.rs b/src/decorations.rs
index a73219a..be8932d 100644
--- a/src/decorations.rs
+++ b/src/decorations.rs
@@ -189,6 +189,18 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
         _ => return,
     };
 
+    // Clean up decorations for windows that should not be decorated
+    for (wid, wp) in &mut state.window_proxies {
+        if let Some(w) = state.wm.windows.iter().find(|win| win.id == *wid) {
+            if w.closed || w.app_id.as_deref() == Some("clear-status-interface") || w.tiling_mode == crate::types::TilingMode::Popup {
+                if let Some(dec) = wp.decoration.take() {
+                    dec.decoration.destroy();
+                    dec.surface.destroy();
+                }
+            }
+        }
+    }
+
     // Calculate dynamic border colors
     let border_colors = crate::borders::compute_border_colors(&state.wm);
     let text_color = 0xFFE0E0E0u32;
@@ -199,7 +211,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
         .windows
         .iter()
         .enumerate()
-        .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+        .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("clear-status-interface") && w.tiling_mode != crate::types::TilingMode::Popup)
         .filter(|(_, w)| (w.tags & active_tags) != 0)
         .map(|(idx, w)| {
             let title = w.title.clone().unwrap_or_else(|| {
@@ -217,6 +229,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 crate::types::TilingMode::Vsplit => "V",
                 crate::types::TilingMode::Hsplit => "H",
                 crate::types::TilingMode::Fullscreen => "S",
+                crate::types::TilingMode::Popup => "P",
             };
             let title_with_idx = format!("[{}{}] {}", indicator, mode_idx, title);
 
@@ -246,6 +259,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 crate::types::TilingMode::Vsplit => state.wm.layout.vsplit_border_width,
                 crate::types::TilingMode::Hsplit => state.wm.layout.hsplit_border_width,
                 crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
+                crate::types::TilingMode::Popup => 0,
             };
             (w.id, w.width, border_w, title_with_idx, bg_color)
         })
diff --git a/src/restart.rs b/src/restart.rs
index dd3a542..3465266 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -217,6 +217,10 @@ pub fn wm_reload(state: &mut WindowManager) {
         if std::path::Path::new(&config_path).exists() {
             match parse_config(&config_path, false, state) {
                 Ok(_) => {
+                    for cmd in &state.reload_commands {
+                        eprintln!("[reload] executing reload command: {}", cmd);
+                        spawn_command_bg(cmd);
+                    }
                     if state.notifications_enable {
                         crate::config::show_notification("clearwm", "Configuration reloaded successfully");
                     }
diff --git a/src/state.rs b/src/state.rs
index e8ccf2a..3188328 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -228,6 +228,7 @@ fn parse_tiling_mode_str(s: &str) -> TilingMode {
         "Hsplit" => TilingMode::Hsplit,
         "Fullscreen" => TilingMode::Fullscreen,
         "Floating" => TilingMode::Floating,
+        "Popup" => TilingMode::Popup,
         _ => TilingMode::Cascade,
     }
 }
diff --git a/src/status.rs b/src/status.rs
index 914b1a9..467d27b 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -90,5 +90,6 @@ fn tiling_mode_str(mode: TilingMode) -> &'static str {
         TilingMode::Vsplit => "Vsplit",
         TilingMode::Hsplit => "Hsplit",
         TilingMode::Fullscreen => "Fullscreen",
+        TilingMode::Popup => "Popup",
     }
 }
diff --git a/src/types.rs b/src/types.rs
index 8ca8617..a67427b 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -13,6 +13,7 @@ pub enum TilingMode {
     Vsplit,
     Hsplit,
     Fullscreen,
+    Popup,
 }
 
 impl TilingMode {
@@ -24,6 +25,7 @@ impl TilingMode {
             TilingMode::Vsplit => "Vsplit",
             TilingMode::Hsplit => "Hsplit",
             TilingMode::Fullscreen => "Fullscreen",
+            TilingMode::Popup => "Popup",
         }
     }
 }
@@ -330,6 +332,8 @@ pub struct WindowManager {
     pub tap_config_applied: bool,
     /// Whether system notifications are enabled
     pub notifications_enable: bool,
+    /// Reload commands to execute on configuration reload
+    pub reload_commands: Vec<String>,
 }
 
 impl Default for WindowManager {
@@ -364,6 +368,7 @@ impl Default for WindowManager {
             tap_to_click: false,
             tap_config_applied: false,
             notifications_enable: true,
+            reload_commands: Vec::new(),
         }
     }
 }
@@ -453,6 +458,7 @@ pub fn parse_tiling_mode(s: &str) -> TilingMode {
         "hsplit" => TilingMode::Hsplit,
         "fullscreen" => TilingMode::Fullscreen,
         "floating" => TilingMode::Floating,
+        "popup" => TilingMode::Popup,
         _ => TilingMode::Floating,
     }
 }
@@ -607,6 +613,7 @@ mod tests {
         assert_eq!(parse_tiling_mode("hsplit"), TilingMode::Hsplit);
         assert_eq!(parse_tiling_mode("fullscreen"), TilingMode::Fullscreen);
         assert_eq!(parse_tiling_mode("floating"), TilingMode::Floating);
+        assert_eq!(parse_tiling_mode("popup"), TilingMode::Popup);
         assert_eq!(parse_tiling_mode("unknown"), TilingMode::Floating);
     }
 
diff --git a/src/wayland.rs b/src/wayland.rs
index 75bb5dc..2b2eb7e 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -150,6 +150,8 @@ pub struct AppState {
     pub libinput_config: Option<RiverLibinputConfigV1>,
     /// Tracked libinput devices with their tap state
     pub libinput_devices: Vec<LibinputDeviceInfo>,
+    /// The surface the pointer is currently hovering over
+    pub pointer_hovered_surface: Option<wl_surface::WlSurface>,
 }
 
 /// Info tracked for each libinput device discovered via river_libinput_config_v1
@@ -202,6 +204,7 @@ impl AppState {
             status_sender: None,
             libinput_config: None,
             libinput_devices: Vec::new(),
+            pointer_hovered_surface: None,
         }
     }
 
@@ -808,41 +811,60 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                     // Update and render window title decorations on borders
                     crate::decorations::update_decorations(state, qhandle);
 
-                    // Raise the focused window to the top of the visual stack.
-                    // place_top() modifies rendering state and must be called
-                    // during a render sequence.
-                    if let Some(seat) = state.wm.seats.iter().find(|s| !s.removed) {
-                        if let Some(focused_id) = seat.focused_window_id {
-                            if let Some(node) = state
-                                .window_nodes
-                                .iter()
-                                .find(|(id, _)| *id == focused_id)
-                                .map(|(_, n)| n)
-                            {
-                                let app_id = state
-                                    .wm
-                                    .get_window(focused_id)
-                                    .and_then(|w| w.app_id.clone());
-                                if app_id.as_deref() != Some("clear-status-interface") {
-                                    eprintln!(
-                                        "[render] place_top for focused window id={} (app_id={:?})",
-                                        focused_id, app_id
-                                    );
-                                    node.place_top();
-                                }
+                    // Raise and stack windows according to z-axis logic:
+                    // 1. clear-status-interface at the absolute bottom (score = 0)
+                    // 2. Unfocused tiled/fullscreen windows (score = 1)
+                    // 3. Focused tiled/fullscreen window (score = 2)
+                    // 4. Unfocused floating windows (score = 3)
+                    // 5. Focused floating window (score = 4)
+                    let active_tags = state.wm.active_tags;
+                    let focused_id = state.wm.seats.iter().find(|s| !s.removed).and_then(|s| s.focused_window_id);
+
+                    let get_window_score = |win: &crate::types::Window| -> i32 {
+                        if win.app_id.as_deref() == Some("clear-status-interface") {
+                            0
+                        } else if win.tiling_mode == crate::types::TilingMode::Popup {
+                            5
+                        } else if win.tiling_mode != crate::types::TilingMode::Floating {
+                            if Some(win.id) == focused_id {
+                                2
+                            } else {
+                                1
+                            }
+                        } else {
+                            if Some(win.id) == focused_id {
+                                4
+                            } else {
+                                3
                             }
                         }
-                    }
+                    };
 
-                    // Enforce that clear-status-interface is placed at the bottom
+                    let mut nodes_to_place: Vec<(i32, usize, u64, Option<String>, &RiverNodeV1)> = Vec::new();
                     for &(wid, ref node) in &state.window_nodes {
-                        if let Some(win) = state.wm.get_window(wid) {
-                            if win.app_id.as_deref() == Some("clear-status-interface") {
-                                node.place_bottom();
+                        if let Some((idx, win)) = state.wm.windows.iter().enumerate().find(|(_, w)| w.id == wid) {
+                            if win.closed {
+                                continue;
+                            }
+                            let visible = (win.tags & active_tags) != 0;
+                            if visible {
+                                let score = get_window_score(win);
+                                nodes_to_place.push((score, idx, win.id, win.app_id.clone(), node));
                             }
                         }
                     }
 
+                    // Sort ascending by score, then by original window list index
+                    nodes_to_place.sort_by_key(|&(score, idx, _, _, _)| (score, idx));
+
+                    for &(score, _, id, ref app_id, node) in &nodes_to_place {
+                        eprintln!(
+                            "[render] placing node id={} (app_id={:?}) at top with score {}",
+                            id, app_id, score
+                        );
+                        node.place_top();
+                    }
+
                     state.wm.needs_render = false;
                 }
 
@@ -1723,15 +1745,23 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
             state.wm.needs_status_update = true;
         }
         Action::FocusNext => {
-            // Focus the next visible window (wrapping) and move it to the
-            // front of the cascade stack (end of windows vector).
+            // Focus the next visible window (wrapping) of the same tiling mode,
+            // and move it to the front of the cascade stack (end of windows vector).
             if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
                 let focused_id = seat.focused_window_id;
+                let focused_mode = focused_id.and_then(|fid| {
+                    state.wm.windows.iter().find(|w| w.id == fid).map(|w| w.tiling_mode)
+                });
                 let visible_ids: Vec<u64> = state
                     .wm
                     .windows
                     .iter()
-                    .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+                    .filter(|w| {
+                        (w.tags & state.wm.active_tags) != 0
+                            && !w.closed
+                            && w.app_id.as_deref() != Some("clear-status-interface")
+                            && (focused_mode.is_none() || Some(w.tiling_mode) == focused_mode)
+                    })
                     .map(|w| w.id)
                     .collect();
                 if let Some(fid) = focused_id {
@@ -1747,9 +1777,15 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
                         state.wm.needs_focus = true;
                         state.wm.needs_status_update = true;
                     }
+                } else if !visible_ids.is_empty() {
+                    let next_id = visible_ids[visible_ids.len() - 1];
+                    seat.focused_window_id = Some(next_id);
+                    state.wm.move_window_to_end(next_id);
+                    state.wm.needs_render = true;
+                    state.wm.needs_focus = true;
+                    state.wm.needs_status_update = true;
                 }
             }
-            // Trigger a manage sequence so focus_window() is called
         }
         Action::Move => {
             // TODO: pointer move
@@ -2729,13 +2765,47 @@ impl Dispatch<wl_seat::WlSeat, ()> for AppState {
 
 impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
     fn event(
-        _state: &mut Self,
+        state: &mut Self,
         _proxy: &wl_pointer::WlPointer,
-        _event: wl_pointer::Event,
+        event: wl_pointer::Event,
         _data: &(),
         _conn: &Connection,
         _qhandle: &QueueHandle<Self>,
     ) {
+        match event {
+            wl_pointer::Event::Enter { surface, .. } => {
+                state.pointer_hovered_surface = Some(surface);
+            }
+            wl_pointer::Event::Leave { .. } => {
+                state.pointer_hovered_surface = None;
+            }
+            wl_pointer::Event::Button { button, state: btn_state, .. } => {
+                // Middle click is button 0x112 (BTN_MIDDLE)
+                if button == 0x112 && btn_state == wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed) {
+                    if let Some(ref current_surface) = state.pointer_hovered_surface {
+                        let matched_window_id = state.window_proxies.iter().find_map(|(id, proxy)| {
+                            if let Some(dec) = &proxy.decoration {
+                                if &dec.surface == current_surface {
+                                    Some(*id)
+                                } else {
+                                    None
+                                }
+                            } else {
+                                None
+                            }
+                        });
+
+                        if let Some(wid) = matched_window_id {
+                            eprintln!("[pointer] Middle click on window {} border, closing window", wid);
+                            if let Some(wp) = state.get_window_proxy(wid) {
+                                wp.river_window.close();
+                            }
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
     }
 }
 
diff --git a/src/wm.rs b/src/wm.rs
index 8467e1f..6a3218f 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -361,14 +361,14 @@ fn compute_tiling(
                 let fbw = wm.layout.floating_border_width;
                 let fw = if win.width > 0 {
                     win.width
-                } else if win.hint_min_width > 0 {
+                } else if win.hint_min_width > 32 {
                     win.hint_min_width
                 } else {
                     screen_w * 2 / 3
                 };
                 let fh = if win.height > 0 {
                     win.height
-                } else if win.hint_min_height > 0 {
+                } else if win.hint_min_height > 32 {
                     win.hint_min_height
                 } else {
                     screen_h * 2 / 3
@@ -386,6 +386,25 @@ fn compute_tiling(
                 idx_floating += 1;
                 (fx, fy, fw, fh)
             }
+            TilingMode::Popup => {
+                let fw = if win.width > 0 {
+                    win.width
+                } else if win.hint_min_width > 32 {
+                    win.hint_min_width
+                } else {
+                    360
+                };
+                let fh = if win.height > 0 {
+                    win.height
+                } else if win.hint_min_height > 32 {
+                    win.hint_min_height
+                } else {
+                    100
+                };
+                let fx = screen_w - fw - gap_right;
+                let fy = bar_height + gap_top;
+                (fx, fy, fw, fh)
+            }
         };
 
         results.push(TileResult { wid, x, y, w, h });