Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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::*;