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

commit2d96db783331db3e419ebbda7d24c0d8b03e8fe9
parentd8ef070b52
authorLucas Galante <[email protected]>
date2026-06-05 20:13
feat: support SidePanel tiling, window border blur, 3D shading, and rounded corners

 Makefile                                |  14 ++
 protocol/river-window-management-v1.xml |  20 +++
 src/borders.rs                          |  21 ++-
 src/config.rs                           |  32 +++-
 src/decorations.rs                      | 292 +++++++++++++++++++++++++++++---
 src/ipc.rs                              |   2 +-
 src/state.rs                            |   1 +
 src/status.rs                           |   1 +
 src/types.rs                            |  19 ++-
 src/wayland.rs                          |  99 +++++++++++
 src/wm.rs                               |  59 ++++++-
 11 files changed, 526 insertions(+), 34 deletions(-)

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..48eb940
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+.PHONY: build install run clean
+
+build:
+	cargo build --release
+
+install: build
+	mkdir -p ~/.local/bin
+	install -m 755 target/release/ccec ~/.local/bin/ccec
+
+run:
+	cargo run
+
+clean:
+	cargo clean
diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 60040b2..cbc1015 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -1139,6 +1139,16 @@
       </description>
       <arg name="circular" type="uint" summary="1 if circular, 0 otherwise"/>
     </request>
+
+    <request name="set_blur" since="4">
+      <description summary="set whether backdrop blur is enabled">
+        Set whether backdrop blur should be enabled for this window.
+        1 to enable, 0 to disable.
+        This request modifies rendering state and may only be made as part of a
+        render sequence, see the river_window_manager_v1 description.
+      </description>
+      <arg name="blur" type="uint" summary="1 to enable, 0 to disable"/>
+    </request>
   </interface>
 
   <interface name="river_decoration_v1" version="4">
@@ -1195,6 +1205,16 @@
         render sequence, see the river_window_manager_v1 description.
       </description>
     </request>
+
+    <request name="set_blur" since="4">
+      <description summary="set whether backdrop blur is enabled">
+        Set whether backdrop blur should be enabled for this decoration.
+        1 to enable, 0 to disable.
+        This request modifies rendering state and may only be made as part of a
+        render sequence, see the river_window_manager_v1 description.
+      </description>
+      <arg name="blur" type="uint" summary="1 to enable, 0 to disable"/>
+    </request>
   </interface>
 
   <interface name="river_shell_surface_v1" version="4">
diff --git a/src/borders.rs b/src/borders.rs
index 5ab2e32..ec5cf72 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -112,7 +112,8 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
             let r = blend_channel(state.layout.background_r, state.layout.border_r, factor);
             let g = blend_channel(state.layout.background_g, state.layout.border_g, factor);
             let b = blend_channel(state.layout.background_b, state.layout.border_b, factor);
-            (r, g, b, ALPHA)
+            let a = blend_channel(state.layout.background_a, state.layout.border_a, factor);
+            (r, g, b, a)
         };
 
         let mut width = if state.expose_visual_active && win.tiling_mode != TilingMode::Popup {
@@ -124,6 +125,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
                 TilingMode::Grid => state.layout.grid_border_width,
                 TilingMode::Floating => state.layout.floating_border_width,
                 TilingMode::Popup => state.layout.border_width,
+                TilingMode::SidePanel => state.layout.cascade_border_width,
             }
         };
 
@@ -133,6 +135,21 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
             width = 0;
         }
 
+        let has_titlebar = !win.closed
+            && !win.minimized
+            && win.app_id.as_deref() != Some("clear-status-interface")
+            && !win.app_id.as_deref().map_or(false, |aid| aid.contains("noborder"))
+            && win.tiling_mode != TilingMode::Popup
+            && win.tiling_mode != TilingMode::Fullscreen
+            && !win.circular;
+
+        let edges = if has_titlebar {
+            // Disable compositor-drawn borders entirely; all borders are drawn by client decorations.
+            0b0000u32
+        } else {
+            all_edges
+        };
+
         eprintln!(
             "[borders]   -> r=#{:08x} g=#{:08x} b=#{:08x} a=#{:08x} width={}",
             r, g, b, a, width,
@@ -140,7 +157,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
 
         results.push(WindowBorders {
             window_idx: idx,
-            edges: all_edges,
+            edges,
             width,
             r,
             g,
diff --git a/src/config.rs b/src/config.rs
index f73ea2b..b3abb45 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -80,6 +80,10 @@ pub struct LayoutConfig {
     pub transition_duration: i64,
     #[serde(default = "default_grid_gap")]
     pub grid_gap: i64,
+    #[serde(default = "default_border_blur")]
+    pub border_blur: bool,
+    #[serde(default = "default_window_blur")]
+    pub window_blur: bool,
 }
 
 impl Default for LayoutConfig {
@@ -102,6 +106,8 @@ impl Default for LayoutConfig {
             border_font_size: default_border_font_size(),
             transition_duration: default_transition_duration(),
             grid_gap: default_grid_gap(),
+            border_blur: default_border_blur(),
+            window_blur: default_window_blur(),
         }
     }
 }
@@ -110,6 +116,14 @@ fn default_grid_gap() -> i64 {
     18
 }
 
+fn default_border_blur() -> bool {
+    false
+}
+
+fn default_window_blur() -> bool {
+    false
+}
+
 fn default_gap() -> i64 {
     48
 }
@@ -309,6 +323,8 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
     state.layout.border_font_size = config.layout.border_font_size as i32;
     state.layout.transition_duration = config.layout.transition_duration as i32;
     state.layout.grid_gap = config.layout.grid_gap as i32;
+    state.layout.border_blur = config.layout.border_blur;
+    state.layout.window_blur = config.layout.window_blur;
     if let Some((r, g, b, a)) = parse_hex_color(&config.layout.border_color) {
         state.layout.border_r = r;
         state.layout.border_g = g;
@@ -328,7 +344,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
         let mods = parse_modifiers(&kb.mods);
         let keysym = parse_keysym(&kb.key);
         let action = parse_action(&kb.action);
-        let command = if action == crate::types::Action::Spawn {
+        let command = if action == crate::types::Action::Spawn || action == crate::types::Action::Toggle {
             kb.command.clone()
         } else {
             None
@@ -557,7 +573,7 @@ fn expand_env_vars(s: &str) -> String {
 }
 
 /// Extract the program name (first word, basename) from a command string
-fn extract_program_name(cmd: &str) -> String {
+pub fn extract_program_name(cmd: &str) -> String {
     let cmd = cmd.trim_start();
     let first_word: String = cmd.chars().take_while(|c| !c.is_whitespace()).collect();
     if let Some(slash) = first_word.rfind('/') {
@@ -686,16 +702,16 @@ mod tests {
 
     #[test]
     fn test_expand_env_vars_simple() {
-        std::env::set_var("CCEC_TEST_VAR", "hello");
-        assert_eq!(expand_env_vars("$CCEC_TEST_VAR"), "hello");
-        std::env::remove_var("CCEC_TEST_VAR");
+        std::env::set_var("CCEC_TEST_VAR_SIMPLE", "hello");
+        assert_eq!(expand_env_vars("$CCEC_TEST_VAR_SIMPLE"), "hello");
+        std::env::remove_var("CCEC_TEST_VAR_SIMPLE");
     }
 
     #[test]
     fn test_expand_env_vars_braces() {
-        std::env::set_var("CCEC_TEST_VAR", "world");
-        assert_eq!(expand_env_vars("${CCEC_TEST_VAR}!"), "world!");
-        std::env::remove_var("CCEC_TEST_VAR");
+        std::env::set_var("CCEC_TEST_VAR_BRACES", "world");
+        assert_eq!(expand_env_vars("${CCEC_TEST_VAR_BRACES}!"), "world!");
+        std::env::remove_var("CCEC_TEST_VAR_BRACES");
     }
 
     #[test]
diff --git a/src/decorations.rs b/src/decorations.rs
index 1360b14..d77ac08 100644
--- a/src/decorations.rs
+++ b/src/decorations.rs
@@ -200,7 +200,70 @@ fn create_memfd(size: usize) -> Option<RawFd> {
     Some(fd)
 }
 
-fn update_transparent_decoration(
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum CornerType {
+    TopLeft,
+    TopRight,
+    BottomLeft,
+    BottomRight,
+}
+
+fn round_corner(
+    buffer_slice: &mut [u32],
+    dec_width: i32,
+    dec_height: i32,
+    cx: i32,
+    cy: i32,
+    r: i32,
+    corner: CornerType,
+) {
+    if r <= 0 {
+        return;
+    }
+    let r_f = r as f32;
+
+    let (x_range, y_range) = match corner {
+        CornerType::TopLeft => (0..cx, 0..cy),
+        CornerType::TopRight => (cx..dec_width, 0..cy),
+        CornerType::BottomLeft => (0..cx, cy..dec_height),
+        CornerType::BottomRight => (cx..dec_width, cy..dec_height),
+    };
+
+    for py in y_range {
+        if py < 0 || py >= dec_height {
+            continue;
+        }
+        let row_offset = (py * dec_width) as usize;
+        for px in x_range.clone() {
+            if px < 0 || px >= dec_width {
+                continue;
+            }
+
+            let dx = (px - cx) as f32;
+            let dy = (py - cy) as f32;
+            let dist = (dx * dx + dy * dy).sqrt();
+
+            if dist > r_f + 0.5 {
+                buffer_slice[row_offset + px as usize] = 0x00000000;
+            } else if dist > r_f - 0.5 {
+                let alpha_scale = r_f + 0.5 - dist; // value between 0.0 and 1.0
+                let index = row_offset + px as usize;
+                let pixel = buffer_slice[index];
+                let a = ((pixel >> 24) & 0xFF) as f32 * alpha_scale;
+                let r_val = ((pixel >> 16) & 0xFF) as f32 * alpha_scale;
+                let g = ((pixel >> 8) & 0xFF) as f32 * alpha_scale;
+                let b = (pixel & 0xFF) as f32 * alpha_scale;
+
+                buffer_slice[index] = ((a as u32) << 24)
+                    | ((r_val as u32) << 16)
+                    | ((g as u32) << 8)
+                    | (b as u32);
+            }
+        }
+    }
+}
+
+fn update_border_decoration(
     _wid: u64,
     dec_opt: &mut Option<WindowDecoration>,
     compositor: &wayland_client::protocol::wl_compositor::WlCompositor,
@@ -212,6 +275,14 @@ fn update_transparent_decoration(
     logical_width: i32,
     logical_height: i32,
     scale: i32,
+    border_blur: bool,
+    bg_color: u32,
+    rect_x: i32,
+    rect_y: i32,
+    rect_w: i32,
+    rect_h: i32,
+    border_width: i32,
+    round_bottom: bool,
 ) {
     let dec_width = logical_width * scale;
     let dec_height = logical_height * scale;
@@ -239,6 +310,7 @@ fn update_transparent_decoration(
 
     let dec = dec_opt.as_mut().unwrap();
     dec.decoration.set_offset(offset_x, offset_y);
+    dec.decoration.set_blur(if border_blur { 1 } else { 0 });
 
     if needs_new_buffer {
         if !dec.mapped_data.is_null() {
@@ -276,14 +348,6 @@ fn update_transparent_decoration(
                     (),
                 );
 
-                // Clear to fully transparent
-                let buffer_slice = unsafe {
-                    std::slice::from_raw_parts_mut(mapped_data, (dec_width * dec_height) as usize)
-                };
-                for pixel in buffer_slice.iter_mut() {
-                    *pixel = 0x00000000;
-                }
-
                 dec.pool = Some(pool);
                 dec.buffer = Some(buffer);
                 dec.width = dec_width;
@@ -292,18 +356,121 @@ fn update_transparent_decoration(
                 dec.mapped_size = size;
                 dec.surface.set_buffer_scale(scale);
             } else {
-                eprintln!("[decorations] failed to mmap transparent decoration buffer");
+                eprintln!("[decorations] failed to mmap border decoration buffer");
                 unsafe { libc::close(fd); }
                 return;
             }
             unsafe { libc::close(fd); }
         } else {
-            eprintln!("[decorations] failed to create memfd for transparent decoration");
+            eprintln!("[decorations] failed to create memfd for border decoration");
             return;
         }
     }
 
     if let Some(ref buffer) = dec.buffer {
+        if !dec.mapped_data.is_null() {
+            let buffer_slice = unsafe {
+                std::slice::from_raw_parts_mut(dec.mapped_data, (dec_width * dec_height) as usize)
+            };
+
+            // Clear to fully transparent
+            for pixel in buffer_slice.iter_mut() {
+                *pixel = 0x00000000;
+            }
+
+            // Draw visual border color with 3D cylindrical shading and mitred joints
+            let rx_start = rect_x * scale;
+            let ry_start = rect_y * scale;
+            let rx_end = (rect_x + rect_w) * scale;
+            let ry_end = (rect_y + rect_h) * scale;
+
+            let border_w_scaled = border_width * scale;
+            let win_w_scaled = (rect_w - 2 * border_width) * scale;
+            let grab_w_scaled = logical_height * scale;
+
+            for py in ry_start..ry_end {
+                if py >= 0 && py < dec_height {
+                    let row_offset = (py * dec_width) as usize;
+                    for px in rx_start..rx_end {
+                        if px >= 0 && px < dec_width {
+                            let s = if rect_w > rect_h {
+                                // Horizontal border (bottom)
+                                let logical_px = px - grab_w_scaled;
+                                let logical_py = py;
+
+                                if logical_px < 0 && logical_py < -logical_px {
+                                    // Left border region (outer is at t = 0)
+                                    let t_val = ((logical_px + border_w_scaled) as f32) / (border_w_scaled as f32 - 1.0).max(1.0);
+                                    if t_val < 0.5 {
+                                        1.0 + 0.45 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                                    } else {
+                                        1.0
+                                    }
+                                } else if logical_px > win_w_scaled && logical_py < (logical_px - win_w_scaled) {
+                                    // Right border region (outer is at t = 0)
+                                    let t_val = 1.0 - ((logical_px - win_w_scaled) as f32) / (border_w_scaled as f32 - 1.0).max(1.0);
+                                    if t_val < 0.5 {
+                                        1.0 - 0.50 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                                    } else {
+                                        1.0
+                                    }
+                                } else {
+                                    // Bottom border region (outer is at t = 0)
+                                    let t_val = 1.0 - (logical_py as f32) / (border_w_scaled as f32 - 1.0).max(1.0);
+                                    if t_val < 0.5 {
+                                        1.0 - 0.50 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                                    } else {
+                                        1.0
+                                    }
+                                }
+                            } else {
+                                // Vertical border (left/right)
+                                let t_val = ((px - rx_start) as f32) / (border_w_scaled as f32 - 1.0).max(1.0);
+                                if offset_x < 0 {
+                                    // Left border: outer is at t = 0 (highlight)
+                                    if t_val < 0.5 {
+                                        1.0 + 0.45 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                                    } else {
+                                        1.0
+                                    }
+                                } else {
+                                    // Right border: outer is at t = 1 (shadow)
+                                    if t_val > 0.5 {
+                                        1.0 - 0.50 * (2.0 * t_val - 1.0) * (2.0 * t_val - 1.0)
+                                    } else {
+                                        1.0
+                                    }
+                                }
+                            };
+
+                            let a = ((bg_color >> 24) & 0xFF) as f32;
+                            let r_val = (((bg_color >> 16) & 0xFF) as f32 * s).round().min(255.0) as u32;
+                            let g = (((bg_color >> 8) & 0xFF) as f32 * s).round().min(255.0) as u32;
+                            let b = ((bg_color & 0xFF) as f32 * s).round().min(255.0) as u32;
+
+                            buffer_slice[row_offset + px as usize] = ((a as u32) << 24)
+                                | (r_val << 16)
+                                | (g << 8)
+                                | b;
+                        }
+                    }
+                }
+            }
+
+            if round_bottom {
+                let r_val = (16.min(border_width)) * scale;
+                let cy = ry_end - r_val;
+                
+                // Bottom Left
+                let cx_l = rx_start + r_val;
+                round_corner(buffer_slice, dec_width, dec_height, cx_l, cy, r_val, CornerType::BottomLeft);
+
+                // Bottom Right
+                let cx_r = rx_end - r_val;
+                round_corner(buffer_slice, dec_width, dec_height, cx_r, cy, r_val, CornerType::BottomRight);
+            }
+        }
+
         dec.surface.attach(Some(buffer), 0, 0);
         dec.surface.damage(0, 0, dec_width, dec_height);
         dec.surface.commit();
@@ -438,6 +605,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                     crate::types::TilingMode::Grid => "G",
                     crate::types::TilingMode::Fullscreen => "S",
                     crate::types::TilingMode::Popup => "P",
+                    crate::types::TilingMode::SidePanel => "SP",
                 }
             };
             let title_with_idx = if is_minimized {
@@ -462,7 +630,15 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
             let border_g = (g & 0xFF) as u8;
             let border_b = (b & 0xFF) as u8;
             let border_a = (a & 0xFF) as u8;
-            let bg_color = ((border_a as u32) << 24) | ((border_r as u32) << 16) | ((border_g as u32) << 8) | (border_b as u32);
+
+            let r_premult = ((border_r as u32 * border_a as u32) / 255) as u8;
+            let g_premult = ((border_g as u32 * border_a as u32) / 255) as u8;
+            let b_premult = ((border_b as u32 * border_a as u32) / 255) as u8;
+
+            let bg_color = ((border_a as u32) << 24)
+                | ((r_premult as u32) << 16)
+                | ((g_premult as u32) << 8)
+                | (b_premult as u32);
 
             // Border width is mode-specific
             let border_width = if is_minimized {
@@ -476,6 +652,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                     crate::types::TilingMode::Grid => state.wm.layout.grid_border_width,
                     crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
                     crate::types::TilingMode::Popup => 0,
+                    crate::types::TilingMode::SidePanel => state.wm.layout.cascade_border_width,
                 }
             };
             DecorateInfo {
@@ -522,7 +699,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 border_width.max(1)
             };
 
-            update_transparent_decoration(
+            update_border_decoration(
                 wid,
                 &mut wp.dec_left,
                 compositor,
@@ -534,9 +711,17 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 grab_w,
                 win_height,
                 scale,
+                state.wm.layout.border_blur,
+                bg_color,
+                grab_w - border_width,
+                0,
+                border_width,
+                win_height,
+                border_width,
+                false,
             );
 
-            update_transparent_decoration(
+            update_border_decoration(
                 wid,
                 &mut wp.dec_right,
                 compositor,
@@ -548,9 +733,17 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 grab_w,
                 win_height,
                 scale,
+                state.wm.layout.border_blur,
+                bg_color,
+                0,
+                0,
+                border_width,
+                win_height,
+                border_width,
+                false,
             );
 
-            update_transparent_decoration(
+            update_border_decoration(
                 wid,
                 &mut wp.dec_bottom,
                 compositor,
@@ -562,6 +755,14 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 win_width + 2 * grab_w,
                 grab_w,
                 scale,
+                state.wm.layout.border_blur,
+                bg_color,
+                grab_w - border_width,
+                0,
+                win_width + 2 * border_width,
+                border_width,
+                border_width,
+                true,
             );
         }
 
@@ -613,6 +814,8 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
             dec.decoration.set_offset(-border_width, -logical_height);
         }
 
+        dec.decoration.set_blur(if state.wm.layout.border_blur { 1 } else { 0 });
+
         if needs_new_buffer {
             eprintln!(
                 "[decorations] allocating buffer {}x{} for window {}",
@@ -680,9 +883,53 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 std::slice::from_raw_parts_mut(dec.mapped_data, (dec_width * dec_height) as usize)
             };
 
-            // Clear background
-            for pixel in buffer_slice.iter_mut() {
-                *pixel = bg_color;
+            // Clear background with 3D cylindrical shading and mitred corners (outer edge only)
+            let border_w_scaled = border_width * scale;
+            let titlebar_h_scaled = logical_height * scale;
+            let win_w_scaled = (if is_minimized { 160 - 2 * border_width } else { win_width }) * scale;
+
+            for py in 0..dec_height {
+                let row_offset = (py * dec_width) as usize;
+                for px in 0..dec_width {
+                    // Convert to coordinates relative to client area (0, 0)
+                    let logical_px = px - border_w_scaled;
+                    let logical_py = py - titlebar_h_scaled;
+
+                    // Determine which border region this pixel belongs to, using the exact diagonal slope:
+                    let slope = (titlebar_h_scaled as f32) / (border_w_scaled as f32).max(1.0);
+                    let s = if logical_px < 0 && logical_py > (logical_px as f32 * slope) as i32 {
+                        // Left border region (outer is at t = 0)
+                        let t_val = ((logical_px + border_w_scaled) as f32) / (border_w_scaled as f32 - 1.0).max(1.0);
+                        if t_val < 0.5 {
+                            1.0 + 0.45 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                        } else {
+                            1.0
+                        }
+                    } else if logical_px > win_w_scaled && logical_py > (-((logical_px - win_w_scaled) as f32 * slope)) as i32 {
+                        // Right border region (outer is at t = 0)
+                        let t_val = 1.0 - ((logical_px - win_w_scaled) as f32) / (border_w_scaled as f32 - 1.0).max(1.0);
+                        if t_val < 0.5 {
+                            1.0 - 0.50 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                        } else {
+                            1.0
+                        }
+                    } else {
+                        // Top border region (outer is at t = 0)
+                        let t_val = ((logical_py + titlebar_h_scaled) as f32) / (titlebar_h_scaled as f32 - 1.0).max(1.0);
+                        if t_val < 0.5 {
+                            1.0 + 0.45 * (1.0 - 2.0 * t_val) * (1.0 - 2.0 * t_val)
+                        } else {
+                            1.0
+                        }
+                    };
+
+                    let a = ((bg_color >> 24) & 0xFF) as f32;
+                    let r_val = (((bg_color >> 16) & 0xFF) as f32 * s).round().min(255.0) as u32;
+                    let g = (((bg_color >> 8) & 0xFF) as f32 * s).round().min(255.0) as u32;
+                    let b = ((bg_color & 0xFF) as f32 * s).round().min(255.0) as u32;
+
+                    buffer_slice[row_offset + px as usize] = ((a as u32) << 24) | (r_val << 16) | (g << 8) | b;
+                }
             }
 
             // Draw window title text
@@ -709,7 +956,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 });
                 let baseline_y = (dec_height as f32 + line_metrics.ascent + line_metrics.descent) / 2.0;
 
-                let mut text_x = 8.0f32 * scale as f32; // Margin from left
+                let mut text_x = 24.0f32 * scale as f32; // Margin from left
                 for c in title.chars() {
                     let (metrics, bitmap) = font.rasterize(c, font_size);
                     if text_x + metrics.xmin as f32 + metrics.width as f32 > dec_width as f32 {
@@ -761,7 +1008,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 
                 // Vertically center the text inside the titlebar
                 let text_y = (dec_height - font_h) / 2;
-                let mut text_x = 8 * scale; // Margin from left
+                let mut text_x = 24 * scale; // Margin from left
 
                 for c in title.chars() {
                     if text_x + 8 * drawing_scale * scale > dec_width {
@@ -772,6 +1019,11 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 }
             }
 
+            // Apply corner rounding to top-left and top-right of titlebar
+            let r_top = 16 * scale;
+            round_corner(buffer_slice, dec_width, dec_height, r_top, r_top, r_top, CornerType::TopLeft);
+            round_corner(buffer_slice, dec_width, dec_height, dec_width - r_top, r_top, r_top, CornerType::TopRight);
+
             // Commit surface rendering
             if !is_minimized {
                 dec.decoration.sync_next_commit();
diff --git a/src/ipc.rs b/src/ipc.rs
index 982403f..442dc51 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -837,7 +837,7 @@ fn handle_bind_command(rest: &str, state: &mut WindowManager) {
     };
 
     let action = parse_action(action_str);
-    let command = if action == Action::Spawn {
+    let command = if action == Action::Spawn || action == Action::Toggle {
         command
     } else {
         None
diff --git a/src/state.rs b/src/state.rs
index ba244af..33be399 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -236,6 +236,7 @@ fn parse_tiling_mode_str(s: &str) -> TilingMode {
         "Fullscreen" => TilingMode::Fullscreen,
         "Floating" => TilingMode::Floating,
         "Popup" => TilingMode::Popup,
+        "Side Panel" | "SidePanel" | "side-panel" => TilingMode::SidePanel,
         _ => TilingMode::Cascade,
     }
 }
diff --git a/src/status.rs b/src/status.rs
index b6599b4..6bf54d7 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -95,5 +95,6 @@ fn tiling_mode_str(mode: TilingMode) -> &'static str {
         TilingMode::Grid => "Grid",
         TilingMode::Fullscreen => "Fullscreen",
         TilingMode::Popup => "Popup",
+        TilingMode::SidePanel => "Side Panel",
     }
 }
diff --git a/src/types.rs b/src/types.rs
index 6b2a585..3a70e00 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -12,6 +12,7 @@ pub enum TilingMode {
     Grid,
     Fullscreen,
     Popup,
+    SidePanel,
 }
 
 impl TilingMode {
@@ -22,6 +23,7 @@ impl TilingMode {
             TilingMode::Grid => "Grid",
             TilingMode::Fullscreen => "Fullscreen",
             TilingMode::Popup => "Popup",
+            TilingMode::SidePanel => "Side Panel",
         }
     }
 }
@@ -31,6 +33,7 @@ impl TilingMode {
 pub enum Action {
     None,
     Spawn,
+    Toggle,
     Close,
     FocusNext,
     FocusPrev,
@@ -85,6 +88,8 @@ pub struct Layout {
     pub border_font_size: i32,
     pub transition_duration: i32,
     pub grid_gap: i32,
+    pub border_blur: bool,
+    pub window_blur: bool,
 }
 
 impl Default for Layout {
@@ -113,6 +118,8 @@ impl Default for Layout {
             border_font_size: 11,
             transition_duration: 300,
             grid_gap: 18,
+            border_blur: false,
+            window_blur: false,
         }
     }
 }
@@ -235,6 +242,7 @@ pub struct Window {
     pub anim_h: Option<f64>,
     pub anim_opacity: Option<f64>,
     pub circular: bool,
+    pub size_hint_applied: bool,
 }
 
 impl Default for Window {
@@ -274,6 +282,7 @@ impl Default for Window {
             anim_h: None,
             anim_opacity: None,
             circular: false,
+            size_hint_applied: false,
         }
     }
 }
@@ -504,6 +513,7 @@ pub fn parse_tiling_mode(s: &str) -> TilingMode {
         "fullscreen" => TilingMode::Fullscreen,
         "floating" => TilingMode::Floating,
         "popup" => TilingMode::Popup,
+        "side-panel" | "side_panel" | "side panel" | "Side Panel" => TilingMode::SidePanel,
         _ => TilingMode::Floating,
     }
 }
@@ -575,7 +585,11 @@ pub fn parse_action(s: &str) -> Action {
                 };
             }
         }
-        Action::None
+        if s == "toggle" {
+            Action::Toggle
+        } else {
+            Action::None
+        }
     } else if s.starts_with("set-tag") {
         let rest = &s[7..];
         let tag_str = rest
@@ -667,6 +681,8 @@ mod tests {
         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("side-panel"), TilingMode::SidePanel);
+        assert_eq!(parse_tiling_mode("Side Panel"), TilingMode::SidePanel);
         assert_eq!(parse_tiling_mode("unknown"), TilingMode::Floating);
     }
 
@@ -685,6 +701,7 @@ mod tests {
         assert_eq!(parse_action("restart"), Action::Restart);
         assert_eq!(parse_action("spawn"), Action::Spawn);
         assert_eq!(parse_action("spawn something"), Action::Spawn);
+        assert_eq!(parse_action("toggle"), Action::Toggle);
         assert_eq!(parse_action("view-1"), Action::View1);
         assert_eq!(parse_action("view-4"), Action::View4);
         assert_eq!(parse_action("toggle-2"), Action::Toggle2);
diff --git a/src/wayland.rs b/src/wayland.rs
index 1c83e74..360cb81 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -388,6 +388,7 @@ impl AppState {
                             crate::types::TilingMode::Grid => state.wm.layout.grid_border_width,
                             crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
                             crate::types::TilingMode::Popup => 0,
+                            crate::types::TilingMode::SidePanel => state.wm.layout.cascade_border_width,
                         }
                     };
                     let grab_w = border_w.max(10);
@@ -1056,6 +1057,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
                     crate::wm::render_borders(state);
                     crate::wm::render_opacity(state);
                     crate::wm::render_circular(state);
+                    crate::wm::render_blur(state);
 
                     // Update and render window title decorations on borders
                     crate::decorations::update_decorations(state, qhandle);
@@ -1441,6 +1443,16 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
                         "[window] id={} (app_id={:?}) dimensions_hint: min={}x{} max={}x{}",
                         wid, window.app_id, min_width, min_height, max_width, max_height
                     );
+
+                    if !window.size_hint_applied && min_width > 32 {
+                        window.width = min_width;
+                        window.height = min_height;
+                        window.size_hint_applied = true;
+                        state.wm.needs_render = true;
+                        if let Some(ref wm) = state.window_manager {
+                            wm.manage_dirty();
+                        }
+                    }
                 }
             }
 
@@ -2239,6 +2251,73 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
                 };
             }
         }
+        Action::Toggle => {
+            if let Some(cmd) = command {
+                let prog_name = crate::config::extract_program_name(cmd);
+                // Look for an open window whose app_id (or title fallback) matches prog_name
+                let open_window_id = state.wm.windows.iter().find(|w| {
+                    if w.closed {
+                        return false;
+                    }
+                    if let Some(ref aid) = w.app_id {
+                        let aid_lower = aid.to_lowercase();
+                        let prog_lower = prog_name.to_lowercase();
+                        aid_lower == prog_lower || aid_lower.contains(&prog_lower) || prog_lower.contains(&aid_lower)
+                    } else if let Some(ref title) = w.title {
+                        let title_lower = title.to_lowercase();
+                        let prog_lower = prog_name.to_lowercase();
+                        title_lower.contains(&prog_lower)
+                    } else {
+                        false
+                    }
+                }).map(|w| w.id);
+
+                if let Some(win_id) = open_window_id {
+                    eprintln!("toggle: close window id={}", win_id);
+                    if let Some(wp) = state.get_window_proxy(win_id) {
+                        wp.river_window.close();
+                    }
+                    // Shift focus to the next visible window if the closed window was focused.
+                    if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+                        if seat.focused_window_id == Some(win_id) {
+                            let visible_ids: Vec<u64> = state
+                                .wm
+                                .windows
+                                .iter()
+                                .filter(|w| {
+                                    (w.tags & state.wm.active_tags) != 0 && !w.closed && !w.minimized && w.id != win_id && w.app_id.as_deref() != Some("clear-status-interface")
+                                })
+                                .map(|w| w.id)
+                                .collect();
+                            seat.focused_window_id = visible_ids.last().copied();
+                        }
+                    }
+                    state.wm.needs_render = true;
+                    state.wm.needs_focus = true;
+                    state.wm.needs_status_update = true;
+                } else {
+                    eprintln!("toggle: spawn command={}", cmd);
+                    use std::os::unix::process::CommandExt;
+                    let _ = unsafe {
+                        std::process::Command::new("sh")
+                            .arg("-c")
+                            .arg(cmd)
+                            .env_remove("WAYLAND_DEBUG")
+                            .stdout(std::process::Stdio::null())
+                            .stderr(std::process::Stdio::null())
+                            .pre_exec(|| {
+                                let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as libc::c_int;
+                                for fd in 3..max_fd {
+                                    libc::close(fd);
+                                }
+                                libc::setsid();
+                                Ok(())
+                            })
+                            .spawn()
+                    };
+                }
+            }
+        }
         Action::Close => {
             // Ask the compositor to close the focused window by calling
             // close() on its River protocol proxy. This matches tinyrwm's
@@ -3843,6 +3922,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
                                                         crate::types::TilingMode::Grid => state.wm.layout.grid_border_width,
                                                         crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
                                                         crate::types::TilingMode::Popup => 0,
+                                                        crate::types::TilingMode::SidePanel => state.wm.layout.cascade_border_width,
                                                     }
                                                 };
                                                 let grab_w = border_w.max(10);
@@ -3928,6 +4008,25 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
                     } else if btn_state == wayland_client::WEnum::Value(wl_pointer::ButtonState::Released) {
                         eprintln!("[pointer] left button released");
                         state.pending_border_drag = None;
+
+                        if let Some(ref op) = state.active_pointer_op {
+                            if op.op_type != PointerOpType::Move {
+                                if let Some(wp) = state.get_window_proxy(op.window_id) {
+                                    wp.river_window.inform_resize_end();
+                                }
+                            }
+                            state.active_pointer_op = None;
+                            for (_sid, sp) in &state.seat_proxies {
+                                sp.river_seat.op_end();
+                            }
+                            for seat in &mut state.wm.seats {
+                                seat.interacted_window_id = None;
+                            }
+                            state.wm.needs_render = true;
+                            if let Some(ref wm) = state.window_manager {
+                                wm.manage_dirty();
+                            }
+                        }
                     }
                 }
 
diff --git a/src/wm.rs b/src/wm.rs
index 61c7eb9..1908264 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -482,6 +482,24 @@ fn compute_tiling(
             }).map(|w| w.id)
         });
 
+    // Check if there is a visible SidePanel window on the active tag
+    let side_panel_win = wm.windows.iter().find(|w| {
+        (w.tags & wm.active_tags) != 0
+            && !w.closed
+            && !w.minimized
+            && w.tiling_mode == TilingMode::SidePanel
+    });
+
+    let shift_x = if let Some(panel_win) = side_panel_win {
+        if panel_win.hint_min_width > 32 {
+            panel_win.hint_min_width
+        } else {
+            360
+        }
+    } else {
+        0
+    };
+
     // Compute tiling
     let mut results = Vec::new();
     let mut idx_floating = 0i32;
@@ -495,6 +513,16 @@ fn compute_tiling(
         let mode = win.tiling_mode;
 
         let (x, y, w, h) = match mode {
+            TilingMode::SidePanel => {
+                let target_w = if win.hint_min_width > 32 {
+                    win.hint_min_width
+                } else {
+                    360
+                };
+                let bw = wm.layout.cascade_border_width;
+                let dec_h = std::cmp::max(bw, 16);
+                (phys_x + bw, bar_height + phys_y + dec_h, target_w - bw * 2, screen_h - bar_height - (dec_h + bw))
+            }
             TilingMode::Fullscreen => {
                 if fullscreen_id == Some(wid) || win.app_id.as_deref() == Some("clear-status-interface") {
                     let border_w = if win.app_id.as_deref() == Some("clear-status-interface") {
@@ -614,7 +642,12 @@ fn compute_tiling(
             }
         };
 
-        results.push(TileResult { wid, x, y, w, h });
+        let final_x = if mode != TilingMode::SidePanel && win.app_id.as_deref() != Some("clear-status-interface") {
+            x + shift_x
+        } else {
+            x
+        };
+        results.push(TileResult { wid, x: final_x, y, w, h });
     }
 
     // Layout minimized windows as bubbles stacked at the right edge
@@ -665,6 +698,14 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
     let gap_top = state.wm.layout.gap_top;
     let bar_height = state.wm.layout.bar_height;
 
+    let side_panel_win = state.wm.windows.iter().find(|w| {
+        (w.tags & state.wm.active_tags) != 0
+            && !w.closed
+            && !w.minimized
+            && w.tiling_mode == TilingMode::SidePanel
+    });
+    let is_side_panel_present = side_panel_win.is_some();
+
     for tr in results {
         let mut final_x = tr.x;
         let mut final_y = tr.y;
@@ -675,7 +716,7 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
             let is_cascade = win.tiling_mode == TilingMode::Cascade;
             let is_exposed = expose_active && win.tiling_mode != TilingMode::Popup && win.app_id.as_deref() != Some("clear-status-interface");
             let was_animating = win.anim_x.is_some() || win.anim_y.is_some() || win.anim_w.is_some() || win.anim_h.is_some() || win.anim_opacity.is_some();
-            let should_animate = (is_cascade || is_exposed || was_animating) && !win.minimized;
+            let should_animate = (is_cascade || is_exposed || is_side_panel_present || win.tiling_mode == TilingMode::SidePanel || was_animating) && !win.minimized;
 
             if should_animate {
                 let curr_x = win.anim_x.unwrap_or(win.x as f64);
@@ -918,6 +959,20 @@ pub fn render_circular(state: &mut AppState) {
     }
 }
 
+/// Apply window backdrop blur.
+/// This modifies rendering state and is called during RenderStart.
+pub fn render_blur(state: &mut AppState) {
+    for win in &state.wm.windows {
+        if win.closed {
+            continue;
+        }
+        if let Some(wp) = state.get_window_proxy(win.id) {
+            let val = if state.wm.layout.window_blur { 1 } else { 0 };
+            wp.river_window.set_blur(val);
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;