Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Refactor client window manager: update Wayland, input, and decorations handling
src/borders.rs | 83 +++++++-----
src/clearctl.rs | 1 +
src/config.rs | 54 ++++++++
src/decorations.rs | 106 ++++++++++++++-
src/input.rs | 4 +
src/ipc.rs | 37 +++++
src/main.rs | 21 ++-
src/types.rs | 34 +++++
src/wayland.rs | 358 +++++++++++++++++++++++++++++++++++-------------
src/wm.rs | 392 ++++++++++++++++++++++++++++++++++++++++++++++-------
start-river.sh | 7 +-
11 files changed, 894 insertions(+), 203 deletions(-)
diff --git a/src/borders.rs b/src/borders.rs
index 6569861..5fbc3f7 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -51,7 +51,6 @@ pub struct WindowBorders {
/// background_color and border_color based on stack depth.
pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
let mut results = Vec::new();
- let all_edges = 0b1111u32;
// Find the focused window ID from the first non-removed seat
let focused_id = state
@@ -86,7 +85,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
);
*/
- let (r, g, b, a) = if win.tiling_mode == TilingMode::Popup {
+ let (r, g, b, mut a) = if win.tiling_mode == TilingMode::Popup {
// Popup windows have a transparent border
(0, 0, 0, 0)
} else if is_focused {
@@ -118,39 +117,15 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
(r, g, b, a)
};
- let mut width = if state.expose_visual_active && win.tiling_mode != TilingMode::Popup {
- state.layout.grid_border_width
- } else {
- match win.tiling_mode {
- TilingMode::Cascade => state.layout.cascade_border_width,
- TilingMode::Fullscreen => state.layout.fullscreen_border_width,
- TilingMode::Grid => state.layout.grid_border_width,
- TilingMode::Floating => state.layout.floating_border_width,
- TilingMode::Popup => 0,
- TilingMode::SidePanel => state.layout.cascade_border_width,
- }
- };
-
- if win.app_id.as_deref() == Some("cce-status-interface")
- || win.app_id.as_deref().map_or(false, |aid| aid.contains("noborder"))
- {
- width = 0;
+ if win.tiling_mode == TilingMode::SidePanel {
+ let opacity_factor = state.layout.side_panel_border_opacity as f64 / 100.0;
+ let base_alpha = (a & 0xFF) as f64 * opacity_factor;
+ let val = base_alpha.round().clamp(0.0, 255.0) as u8;
+ a = val as u32 * 0x01010101;
}
- let has_titlebar = !win.closed
- && !win.minimized
- && win.app_id.as_deref() != Some("cce-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
- };
+ let edges = 0b0000u32;
+ let width = 0;
/*
eprintln!(
@@ -176,6 +151,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::types::{Window, ModeRule};
#[test]
fn test_interp_channel_depth0() {
@@ -190,4 +166,45 @@ mod tests {
let expected = ((0x90 as f64 * 0.80) as u8) as u32 * 0x01010101;
assert_eq!(result, expected);
}
+
+ #[test]
+ fn test_border_edges_with_decorations() {
+ let mut wm = WindowManager::default();
+ wm.active_tags = 1;
+
+ // Window with client decorations OK (should have edges = 0b0000)
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Cascade,
+ tags: 1,
+ ..Default::default()
+ });
+
+ // Window with client decorations disabled via mode rule
+ wm.mode_rules.push(ModeRule {
+ mode: TilingMode::Cascade,
+ app_id_pattern: "no-decorations".to_string(),
+ title_pattern: None,
+ single_instance: false,
+ tag: 0,
+ circular: false,
+ ssd: Some(false), // Disable CSD
+ });
+ wm.windows.push(Window {
+ id: 2,
+ tiling_mode: TilingMode::Cascade,
+ app_id: Some("no-decorations".to_string()),
+ tags: 1,
+ ..Default::default()
+ });
+
+ let results = compute_border_colors(&wm);
+ assert_eq!(results.len(), 2);
+
+ // Window 1: no compositor-drawn borders
+ assert_eq!(results[0].edges, 0);
+
+ // Window 2: no compositor-drawn borders either (all borders are handled in client)
+ assert_eq!(results[1].edges, 0);
+ }
}
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 25bb34e..b40d0b5 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -56,6 +56,7 @@ fn usage(name: &str, to_stderr: bool) {
print(" pointer-location");
print(" pointer-move-to <x> <y>");
print(" pointer-move-by <dx> <dy>");
+ print(" pointer-scroll <dx> <dy>");
print(" pointer-click <button>");
print(" pointer-press <button>");
print(" pointer-release <button>");
diff --git a/src/config.rs b/src/config.rs
index abb41be..a5d118d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -88,6 +88,12 @@ pub struct LayoutConfig {
pub side_panel_behavior: String,
#[serde(default = "default_side_panel_width")]
pub side_panel_width: i64,
+ #[serde(default = "default_side_panel_position")]
+ pub side_panel_position: String,
+ #[serde(default = "default_side_panel_border_gap")]
+ pub side_panel_border_gap: i64,
+ #[serde(default = "default_side_panel_border_opacity")]
+ pub side_panel_border_opacity: i64,
}
impl Default for LayoutConfig {
@@ -114,6 +120,9 @@ impl Default for LayoutConfig {
window_blur: default_window_blur(),
side_panel_behavior: default_side_panel_behavior(),
side_panel_width: default_side_panel_width(),
+ side_panel_position: default_side_panel_position(),
+ side_panel_border_gap: default_side_panel_border_gap(),
+ side_panel_border_opacity: default_side_panel_border_opacity(),
}
}
}
@@ -138,6 +147,18 @@ fn default_side_panel_width() -> i64 {
360
}
+fn default_side_panel_position() -> String {
+ "left".to_string()
+}
+
+fn default_side_panel_border_gap() -> i64 {
+ 0
+}
+
+fn default_side_panel_border_opacity() -> i64 {
+ 100
+}
+
fn default_gap() -> i64 {
48
}
@@ -291,6 +312,7 @@ pub struct ModeRuleConfig {
pub single: Option<bool>,
pub tag: Option<i64>,
pub circular: Option<bool>,
+ pub ssd: Option<bool>,
}
#[derive(Debug, Deserialize)]
@@ -341,6 +363,9 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
state.layout.window_blur = config.layout.window_blur;
state.layout.side_panel_behavior = config.layout.side_panel_behavior.clone();
state.layout.side_panel_width = config.layout.side_panel_width as i32;
+ state.layout.side_panel_position = config.layout.side_panel_position.clone();
+ state.layout.side_panel_border_gap = config.layout.side_panel_border_gap as i32;
+ state.layout.side_panel_border_opacity = config.layout.side_panel_border_opacity as i32;
if let Some((r, g, b, a)) = parse_hex_color(&config.layout.border_color) {
state.layout.border_r = r;
state.layout.border_g = g;
@@ -374,6 +399,31 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
});
}
+ // Register default super+left and super+right bindings for side panel position if not overridden
+ let super_mod = parse_modifiers("super");
+ let left_sym = parse_keysym("Left");
+ let right_sym = parse_keysym("Right");
+
+ let has_super_left = state.pending_bindings.iter().any(|b| b.mods == super_mod && b.keysym == left_sym);
+ if !has_super_left {
+ state.pending_bindings.push(PendingXkbBinding {
+ mods: super_mod,
+ keysym: left_sym,
+ action: crate::types::Action::SidePanelLeft,
+ command: None,
+ });
+ }
+
+ let has_super_right = state.pending_bindings.iter().any(|b| b.mods == super_mod && b.keysym == right_sym);
+ if !has_super_right {
+ state.pending_bindings.push(PendingXkbBinding {
+ mods: super_mod,
+ keysym: right_sym,
+ action: crate::types::Action::SidePanelRight,
+ command: None,
+ });
+ }
+
// [[pointer_bind]] array
for pb in &config.pointer_bind {
let mods = parse_modifiers(&pb.mods);
@@ -398,6 +448,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
single_instance: mr.single.unwrap_or(false),
tag,
circular: mr.circular.unwrap_or(false),
+ ssd: mr.ssd,
});
}
@@ -716,6 +767,9 @@ mod tests {
assert_eq!(lc.grid_gap, 18);
assert_eq!(lc.side_panel_width, 360);
assert_eq!(lc.side_panel_behavior, "inline");
+ assert_eq!(lc.side_panel_position, "left");
+ assert_eq!(lc.side_panel_border_gap, 0);
+ assert_eq!(lc.side_panel_border_opacity, 100);
}
#[test]
diff --git a/src/decorations.rs b/src/decorations.rs
index 929cbff..e7beb5d 100644
--- a/src/decorations.rs
+++ b/src/decorations.rs
@@ -616,7 +616,9 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
.windows
.iter()
.enumerate()
- .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("cce-status-interface") && !w.circular && !w.app_id.as_deref().map_or(false, |aid| aid.contains("noborder")) && (w.minimized || (w.tiling_mode != crate::types::TilingMode::Popup && w.tiling_mode != crate::types::TilingMode::Fullscreen)))
+ .filter(|(_, w)| {
+ !w.closed && w.app_id.as_deref() != Some("cce-status-interface") && !w.circular && !w.app_id.as_deref().map_or(false, |aid| aid.contains("noborder")) && (w.minimized || (w.tiling_mode != crate::types::TilingMode::Popup && w.tiling_mode != crate::types::TilingMode::Fullscreen))
+ })
.filter(|(_, w)| (w.tags & active_tags) != 0)
.map(|(idx, w)| {
let is_minimized = w.minimized;
@@ -711,8 +713,8 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
};
DecorateInfo {
id: w.id,
- win_width: w.width,
- win_height: w.height,
+ win_width: if w.committed_width > 0 { w.committed_width } else { w.width },
+ win_height: if w.committed_height > 0 { w.committed_height } else { w.height },
border_width,
title: title_with_idx,
bg_color,
@@ -1027,7 +1029,74 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
}
}
- buffer_slice[row_offset + px as usize] = ((a as u32) << 24) | (r_val << 16) | (g << 8) | b;
+ let base_pixel_color = ((a as u32) << 24) | (r_val << 16) | (g << 8) | b;
+ let mut final_color = base_pixel_color;
+
+ let lx = px as f32 / scale as f32;
+ let ly = py as f32 / scale as f32;
+
+ if !is_minimized && ly >= 0.0 && ly < logical_height as f32 {
+ if lx >= (logical_width as f32 - 48.0) && lx < (logical_width as f32 - 32.0) {
+ // Minimize button
+ let is_hovered = hover_top.map_or(false, |(hx, hy)| {
+ hx >= (logical_width as f64 - 48.0) && hx < (logical_width as f64 - 32.0)
+ && hy >= 0.0 && hy < logical_height as f64
+ });
+ final_color = if is_hovered {
+ blend_colors(base_pixel_color, 0x30FFFFFF)
+ } else {
+ base_pixel_color
+ };
+
+ let cx = logical_width as f32 - 40.0;
+ let cy = logical_height as f32 / 2.0;
+ if lx >= (cx - 4.0) && lx <= (cx + 4.0) && ly >= (cy + 2.0) && ly < (cy + 3.0) {
+ final_color = text_color;
+ }
+ } else if lx >= (logical_width as f32 - 32.0) && lx < (logical_width as f32 - 16.0) {
+ // Maximize button
+ let is_hovered = hover_top.map_or(false, |(hx, hy)| {
+ hx >= (logical_width as f64 - 32.0) && hx < (logical_width as f64 - 16.0)
+ && hy >= 0.0 && hy < logical_height as f64
+ });
+ final_color = if is_hovered {
+ blend_colors(base_pixel_color, 0x30FFFFFF)
+ } else {
+ base_pixel_color
+ };
+
+ let cx = logical_width as f32 - 24.0;
+ let cy = logical_height as f32 / 2.0;
+ let is_border_x = (lx >= cx - 4.0 && lx < cx - 3.0) || (lx > cx + 3.0 && lx <= cx + 4.0);
+ let is_in_x = lx >= cx - 4.0 && lx <= cx + 4.0;
+ let is_border_y = (ly >= cy - 4.0 && ly < cy - 3.0) || (ly > cy + 3.0 && ly <= cy + 4.0);
+ let is_in_y = ly >= cy - 4.0 && ly <= cy + 4.0;
+ if (is_border_x && is_in_y) || (is_border_y && is_in_x) {
+ final_color = text_color;
+ }
+ } else if lx >= (logical_width as f32 - 16.0) && lx <= logical_width as f32 {
+ // Close button
+ let is_hovered = hover_top.map_or(false, |(hx, hy)| {
+ hx >= (logical_width as f64 - 16.0) && hx <= logical_width as f64
+ && hy >= 0.0 && hy < logical_height as f64
+ });
+ final_color = if is_hovered {
+ blend_colors(base_pixel_color, 0x90E53935)
+ } else {
+ base_pixel_color
+ };
+
+ let cx = logical_width as f32 - 8.0;
+ let cy = logical_height as f32 / 2.0;
+ let dx = (lx - cx).abs();
+ let dy = (ly - cy).abs();
+ if dx <= 4.0 && dy <= 4.0 && (dx - dy).abs() < 1.0 {
+ final_color = text_color;
+ }
+ }
+ }
+
+ buffer_slice[row_offset + px as usize] = final_color;
}
}
@@ -1058,7 +1127,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
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 {
+ if text_x + metrics.xmin as f32 + metrics.width as f32 > dec_width as f32 - 48.0 * scale as f32 {
break;
}
@@ -1110,7 +1179,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
let mut text_x = 24 * scale; // Margin from left
for c in title.chars() {
- if text_x + 8 * drawing_scale * scale > dec_width {
+ if text_x + 8 * drawing_scale * scale > dec_width - 48 * scale {
break; // Out of bounds
}
draw_char(buffer_slice, dec_width, dec_height, c, text_x, text_y, drawing_scale * scale, text_color);
@@ -1180,3 +1249,28 @@ fn is_pixel_in_highlight_region(
_ => false,
}
}
+
+fn blend_colors(bg: u32, fg: u32) -> u32 {
+ let fg_a = (fg >> 24) & 0xFF;
+ if fg_a == 0 {
+ return bg;
+ }
+ if fg_a == 255 {
+ return fg;
+ }
+ let bg_a = (bg >> 24) & 0xFF;
+ let bg_r = (bg >> 16) & 0xFF;
+ let bg_g = (bg >> 8) & 0xFF;
+ let bg_b = bg & 0xFF;
+
+ let fg_r = (fg >> 16) & 0xFF;
+ let fg_g = (fg >> 8) & 0xFF;
+ let fg_b = fg & 0xFF;
+
+ let out_r = ((fg_r * fg_a) + (bg_r * (255 - fg_a))) / 255;
+ let out_g = ((fg_g * fg_a) + (bg_g * (255 - fg_a))) / 255;
+ let out_b = ((fg_b * fg_a) + (bg_b * (255 - fg_a))) / 255;
+ let out_a = bg_a + ((255 - bg_a) * fg_a) / 255;
+
+ (out_a << 24) | (out_r << 16) | (out_g << 8) | out_b
+}
diff --git a/src/input.rs b/src/input.rs
index 1e5aeed..28b2401 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -147,6 +147,7 @@ pub enum InputDaemonMsg {
SimulateKey { keycode: u16, press: bool },
SimulateClick { button: u16 },
SimulateKeyPress { keycode: u16 },
+ SimulateScroll { dx: i32, dy: i32 },
}
#[derive(Debug)]
@@ -797,6 +798,9 @@ pub fn run_input_daemon(
let _ = tx_clone.send(CoordinatorMsg::InternalReleaseKey { keycode });
});
}
+ InputDaemonMsg::SimulateScroll { dx, dy } => {
+ let _ = write_scroll(&mut uinput_mouse_file, dx, dy);
+ }
}
}
CoordinatorMsg::InternalReleaseButton { button } => {
diff --git a/src/ipc.rs b/src/ipc.rs
index 2c35ac5..b09f6df 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -476,6 +476,20 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
reply = "error: usage: pointer-move-by <dx> <dy>\n".to_string();
}
}
+ "pointer-scroll" => {
+ let parts: Vec<&str> = rest.split_whitespace().collect();
+ if parts.len() == 2 {
+ if let (Ok(dx), Ok(dy)) = (parts[0].parse::<i32>(), parts[1].parse::<i32>()) {
+ if let Some(ref controller) = state.input_controller {
+ let _ = controller.send(crate::input::InputDaemonMsg::SimulateScroll { dx, dy });
+ }
+ } else {
+ reply = "error: invalid deltas\n".to_string();
+ }
+ } else {
+ reply = "error: usage: pointer-scroll <dx> <dy>\n".to_string();
+ }
+ }
"pointer-click" => {
let btn = parse_button(rest) as u16;
if btn != 0 {
@@ -722,6 +736,28 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
}
}
}
+ "side_panel_position" | "side-panel-position" => {
+ state.layout.side_panel_position = value_str.to_string();
+ if state.notifications_enable {
+ crate::config::show_notification("cce-client", &format!("Side panel position set to {}", value_str));
+ }
+ }
+ "side_panel_border_gap" | "side-panel-border-gap" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.side_panel_border_gap = value;
+ if state.notifications_enable {
+ crate::config::show_notification("cce-client", &format!("Side panel border gap set to {}px", value));
+ }
+ }
+ }
+ "side_panel_border_opacity" | "side-panel-border-opacity" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.side_panel_border_opacity = value;
+ if state.notifications_enable {
+ crate::config::show_notification("cce-client", &format!("Side panel border opacity set to {}%", value));
+ }
+ }
+ }
_ => {}
}
state.needs_render = true;
@@ -774,6 +810,7 @@ fn handle_mode_command(rest: &str, state: &mut WindowManager) {
single_instance,
tag,
circular: false,
+ ssd: None,
});
}
}
diff --git a/src/main.rs b/src/main.rs
index 6835448..2abc99e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -167,7 +167,6 @@ fn main() {
// Main loop — using poll to block on both Wayland socket and IPC wake-up pipe.
// All work (including spawning) happens inside Dispatch callbacks.
let mut loop_count: u64 = 0;
- let mut last_animation_tick = std::time::Instant::now();
loop {
loop_count += 1;
if loop_count % 10000 == 0 {
@@ -206,11 +205,12 @@ fn main() {
];
let timeout = if state.wm.animating {
- let elapsed = last_animation_tick.elapsed();
- let timeout_duration = if elapsed >= std::time::Duration::from_millis(16) {
+ let elapsed = state.wm.last_animation_tick.elapsed();
+ let target = std::time::Duration::from_millis(32);
+ let timeout_duration = if elapsed >= target {
std::time::Duration::ZERO
} else {
- std::time::Duration::from_millis(16) - elapsed
+ target - elapsed
};
nix::poll::PollTimeout::try_from(timeout_duration).unwrap()
} else {
@@ -252,12 +252,9 @@ fn main() {
// 5. Dispatch read events
let _ = event_queue.dispatch_pending(&mut state);
- // 6. If animating and frame budget elapsed, request next frame
- if state.wm.animating && last_animation_tick.elapsed() >= std::time::Duration::from_millis(16) {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- last_animation_tick = std::time::Instant::now();
- }
+ // 6. If animating and frame budget elapsed, request next frame (fallback only)
+ if state.wm.animating && state.wm.last_animation_tick.elapsed() >= std::time::Duration::from_millis(32) {
+ state.manage_dirty();
}
// 6. Process pending IPC commands from the socket channel
@@ -279,9 +276,7 @@ fn main() {
}
// Force a render sequence if we processed IPC commands
if ipc_commands {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
if !state.wm.tap_config_applied && !state.libinput_devices.is_empty() {
let qh = event_queue.handle();
cce_client::wayland::apply_input_config(&mut state, &qh);
diff --git a/src/types.rs b/src/types.rs
index d115128..a0acb46 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -60,6 +60,8 @@ pub enum Action {
SetTag4,
Expose,
Minimize,
+ SidePanelLeft,
+ SidePanelRight,
}
/// Layout parameters
@@ -92,6 +94,9 @@ pub struct Layout {
pub window_blur: bool,
pub side_panel_behavior: String,
pub side_panel_width: i32,
+ pub side_panel_position: String,
+ pub side_panel_border_gap: i32,
+ pub side_panel_border_opacity: i32,
}
impl Default for Layout {
@@ -124,6 +129,9 @@ impl Default for Layout {
window_blur: false,
side_panel_behavior: "inline".to_string(),
side_panel_width: 360,
+ side_panel_position: "left".to_string(),
+ side_panel_border_gap: 0,
+ side_panel_border_opacity: 100,
}
}
}
@@ -137,6 +145,7 @@ pub struct ModeRule {
pub single_instance: bool,
pub tag: i32,
pub circular: bool,
+ pub ssd: Option<bool>,
}
/// A pending keyboard binding waiting to be applied to seats
@@ -216,6 +225,8 @@ pub struct Window {
pub y: i32,
pub width: i32,
pub height: i32,
+ pub committed_width: i32,
+ pub committed_height: i32,
pub app_id: Option<String>,
pub title: Option<String>,
pub identifier: Option<String>,
@@ -240,6 +251,8 @@ pub struct Window {
pub needs_xprop_check: bool,
/// How many ManageStart cycles we've waited for the xprop result file.
pub xprop_check_attempts: u8,
+ /// How many ManageStart cycles we've waited for window metadata (app_id/title).
+ pub metadata_check_attempts: u8,
pub anim_x: Option<f64>,
pub anim_y: Option<f64>,
pub anim_w: Option<f64>,
@@ -247,6 +260,10 @@ pub struct Window {
pub anim_opacity: Option<f64>,
pub circular: bool,
pub size_hint_applied: bool,
+ pub last_borders: Option<(u32, i32, u32, u32, u32, u32)>,
+ pub last_opacity: Option<u32>,
+ pub last_circular: Option<u32>,
+ pub last_blur: Option<u32>,
}
impl Default for Window {
@@ -260,6 +277,8 @@ impl Default for Window {
y: 0,
width: 0,
height: 0,
+ committed_width: 0,
+ committed_height: 0,
app_id: None,
title: None,
identifier: None,
@@ -280,6 +299,7 @@ impl Default for Window {
mode_locked: false,
needs_xprop_check: false,
xprop_check_attempts: 0,
+ metadata_check_attempts: 0,
anim_x: None,
anim_y: None,
anim_w: None,
@@ -287,6 +307,10 @@ impl Default for Window {
anim_opacity: None,
circular: false,
size_hint_applied: false,
+ last_borders: None,
+ last_opacity: None,
+ last_circular: None,
+ last_blur: None,
}
}
}
@@ -379,6 +403,8 @@ pub struct WindowManager {
pub expose_active: bool,
pub expose_visual_active: bool,
pub animating: bool,
+ pub last_animation_tick: std::time::Instant,
+ pub last_frame_time: std::time::Instant,
}
impl Default for WindowManager {
@@ -429,6 +455,8 @@ impl Default for WindowManager {
expose_active: false,
expose_visual_active: false,
animating: false,
+ last_animation_tick: std::time::Instant::now(),
+ last_frame_time: std::time::Instant::now(),
}
}
}
@@ -614,6 +642,10 @@ pub fn parse_action(s: &str) -> Action {
Action::None
} else if s == "expose" {
Action::Expose
+ } else if s == "side-panel-left" || s == "side_panel_left" {
+ Action::SidePanelLeft
+ } else if s == "side-panel-right" || s == "side_panel_right" {
+ Action::SidePanelRight
} else {
Action::None
}
@@ -712,6 +744,8 @@ mod tests {
assert_eq!(parse_action("set-tag-3"), Action::SetTag3);
assert_eq!(parse_action("expose"), Action::Expose);
assert_eq!(parse_action("minimize"), Action::Minimize);
+ assert_eq!(parse_action("side-panel-left"), Action::SidePanelLeft);
+ assert_eq!(parse_action("side-panel-right"), Action::SidePanelRight);
assert_eq!(parse_action("unknown"), Action::None);
}
diff --git a/src/wayland.rs b/src/wayland.rs
index 75e5e44..1692cd6 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -297,6 +297,13 @@ impl AppState {
id
}
+ pub fn manage_dirty(&mut self) {
+ if let Some(ref wm) = self.window_manager {
+ wm.manage_dirty();
+ self.wm.last_animation_tick = std::time::Instant::now();
+ }
+ }
+
pub fn get_window_proxy(&self, id: u64) -> Option<&WindowProxy> {
self.window_proxies
.iter()
@@ -825,9 +832,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
// If any window gained has_parent, trigger a re-manage so
// assign_window_modes picks it up on the next cycle.
if xprop_parent_changed {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
@@ -927,8 +932,12 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
new_id, app_id
);
}
- // Clear is_new on all windows (only matters once)
+ // Clear is_new on all windows once metadata is resolved, or after a timeout
for window in &mut state.wm.windows {
+ if window.app_id.is_none() && window.title.is_none() && window.metadata_check_attempts < 10 {
+ window.metadata_check_attempts += 1;
+ continue;
+ }
window.is_new = false;
}
}
@@ -1016,9 +1025,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
if has_pending {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
wm_proxy.manage_finish();
@@ -1080,36 +1087,48 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
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 has_fullscreen = state.wm.windows.iter().any(|w| {
+ w.tiling_mode == crate::types::TilingMode::Fullscreen
+ && (w.tags & active_tags) != 0
+ && !w.closed
+ && !w.minimized
+ && w.app_id.as_deref() != Some("cce-status-interface")
+ });
+
let get_window_score = |win: &crate::types::Window| -> i32 {
if win.app_id.as_deref() == Some("cce-status-interface") {
- 0
+ if has_fullscreen {
+ 0
+ } else {
+ 5
+ }
} else if win.tiling_mode == crate::types::TilingMode::Popup {
- 5
+ 10
} else if win.tiling_mode == crate::types::TilingMode::SidePanel {
if state.wm.layout.side_panel_behavior == "above" {
if Some(win.id) == focused_id {
- 4
+ 8
} else {
- 3
+ 6
}
} else {
if Some(win.id) == focused_id {
- 2
+ 4
} else {
- 1
+ 2
}
}
} else if win.tiling_mode != crate::types::TilingMode::Floating {
if Some(win.id) == focused_id {
- 2
+ 4
} else {
- 1
+ 2
}
} else {
if Some(win.id) == focused_id {
- 4
+ 8
} else {
- 3
+ 6
}
}
};
@@ -1145,6 +1164,9 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
}
wm_proxy.render_finish();
+ if state.wm.animating {
+ state.manage_dirty();
+ }
// Flush immediately so River receives render_finish without waiting
// for blocking_dispatch to complete. River has a 3-second unresponsive
// timeout, and if we don't flush promptly, River will kill us.
@@ -1331,6 +1353,8 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
river_window_v1::Event::Dimensions { width, height } => {
if let Some(window) = state.wm.get_window_mut(wid) {
+ window.committed_width = width;
+ window.committed_height = height;
let changed = window.width != width || window.height != height;
if changed {
eprintln!(
@@ -1340,9 +1364,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
window.width = width;
window.height = height;
state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -1371,9 +1393,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
}
if re_eval {
state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
@@ -1408,9 +1428,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
state.wm.needs_render = true;
}
if re_eval {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
@@ -1483,9 +1501,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
if !window.size_hint_applied && min_width > 32 {
window.size_hint_applied = true;
state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -1518,9 +1534,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
}
// Parent status changed: re-assign mode (child windows float)
if window.has_parent != had_parent && !window.mode_locked {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -1532,6 +1546,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
"[window] id={} (app_id={:?}) requested fullscreen",
wid, window.app_id
);
+ state.manage_dirty();
}
}
@@ -1542,6 +1557,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
"[window] id={} (app_id={:?}) requested exit fullscreen",
wid, window.app_id
);
+ state.manage_dirty();
}
}
@@ -1596,9 +1612,7 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
state.wm.needs_render = true;
state.wm.needs_focus = true;
state.wm.needs_status_update = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
river_window_v1::Event::ShowWindowMenuRequested { x, y } => {
@@ -1665,17 +1679,28 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
let mut window_found = false;
let mut needs_render = false;
+ let side_panel_pos = state.wm.layout.side_panel_position.clone();
if let Some(win) = state.wm.get_window_mut(wid) {
if win.tiling_mode != TilingMode::Fullscreen && win.tiling_mode != TilingMode::Popup {
- let is_right_resize_on_side_panel = win.tiling_mode == TilingMode::SidePanel
- && matches!(
- op_type,
- PointerOpType::Resize
- | PointerOpType::ResizeRight
- | PointerOpType::ResizeTopRight
- | PointerOpType::ResizeBottomRight
- );
- if !is_right_resize_on_side_panel && win.tiling_mode != TilingMode::Floating {
+ let is_valid_side_panel_resize = win.tiling_mode == TilingMode::SidePanel
+ && if side_panel_pos == "right" {
+ matches!(
+ op_type,
+ PointerOpType::Resize
+ | PointerOpType::ResizeLeft
+ | PointerOpType::ResizeTopLeft
+ | PointerOpType::ResizeBottomLeft
+ )
+ } else {
+ matches!(
+ op_type,
+ PointerOpType::Resize
+ | PointerOpType::ResizeRight
+ | PointerOpType::ResizeTopRight
+ | PointerOpType::ResizeBottomRight
+ )
+ };
+ if !is_valid_side_panel_resize && win.tiling_mode != TilingMode::Floating {
win.tiling_mode = TilingMode::Floating;
needs_render = true;
}
@@ -1772,9 +1797,7 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
state.wm.needs_status_update = true;
if !already_focused || moved || expose_changed {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -1796,9 +1819,7 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
state.wm.needs_status_update = true;
if !already_focused {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -1843,6 +1864,9 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
let actual_dx = op.start_width - target_width;
win.x = op.start_x + actual_dx;
win.width = target_width;
+ if win.tiling_mode == TilingMode::SidePanel {
+ win.hint_min_width = win.width + bw * 2;
+ }
}
PointerOpType::ResizeBottom => {
win.height = std::cmp::max(50, op.start_height + dy);
@@ -1866,6 +1890,9 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
win.x = op.start_x + actual_dx;
win.width = target_width;
win.height = std::cmp::max(50, op.start_height + dy);
+ if win.tiling_mode == TilingMode::SidePanel {
+ win.hint_min_width = win.width + bw * 2;
+ }
}
PointerOpType::ResizeTopLeft => {
let target_width = std::cmp::max(50, op.start_width - dx);
@@ -1877,6 +1904,9 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
let actual_dy = op.start_height - target_height;
win.y = op.start_y + actual_dy;
win.height = target_height;
+ if win.tiling_mode == TilingMode::SidePanel {
+ win.hint_min_width = win.width + bw * 2;
+ }
}
PointerOpType::ResizeTopRight => {
win.width = std::cmp::max(50, op.start_width + dx);
@@ -1891,16 +1921,12 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
}
}
}
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
river_seat_v1::Event::OpRelease => {
state.pointer_op_release_pending = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
_ => {}
@@ -2189,9 +2215,7 @@ impl Dispatch<RiverPointerBindingV1, BindingUserData> for AppState {
seat.pending_command = data.command.clone();
}
// Trigger a manage sequence so the pending action is processed
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
_ => {}
}
@@ -2232,9 +2256,7 @@ impl Dispatch<RiverXkbBindingV1, BindingUserData> for AppState {
seat.pending_command = data.command.clone();
}
// Trigger a manage sequence so the pending action is processed
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
river_xkb_binding_v1::Event::Released => {}
river_xkb_binding_v1::Event::StopRepeat => {}
@@ -2277,6 +2299,26 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
use crate::types::Action;
match action {
Action::None => {}
+ Action::SidePanelLeft => {
+ let focused = state.wm.focused_window();
+ let is_side_panel = focused.map_or(false, |w| w.tiling_mode == TilingMode::SidePanel);
+ if is_side_panel {
+ state.wm.layout.side_panel_position = "left".to_string();
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+ state.manage_dirty();
+ }
+ }
+ Action::SidePanelRight => {
+ let focused = state.wm.focused_window();
+ let is_side_panel = focused.map_or(false, |w| w.tiling_mode == TilingMode::SidePanel);
+ if is_side_panel {
+ state.wm.layout.side_panel_position = "right".to_string();
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+ state.manage_dirty();
+ }
+ }
Action::Spawn => {
if let Some(cmd) = command {
eprintln!("spawn: {}", cmd);
@@ -2433,9 +2475,7 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
state.wm.needs_render = true;
state.wm.needs_focus = true;
state.wm.needs_status_update = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
Action::FocusNext => {
// Focus the next visible window (wrapping) of the same tiling mode,
@@ -2541,17 +2581,28 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
if let Some(wid) = target_wid {
let mut window_found = false;
let mut needs_render = false;
+ let side_panel_pos = state.wm.layout.side_panel_position.clone();
if let Some(win) = state.wm.get_window_mut(wid) {
if win.tiling_mode != TilingMode::Fullscreen && win.tiling_mode != TilingMode::Popup {
- let is_right_resize_on_side_panel = win.tiling_mode == TilingMode::SidePanel
- && matches!(
- op_type,
- PointerOpType::Resize
- | PointerOpType::ResizeRight
- | PointerOpType::ResizeTopRight
- | PointerOpType::ResizeBottomRight
- );
- if !is_right_resize_on_side_panel && win.tiling_mode != TilingMode::Floating {
+ let is_valid_side_panel_resize = win.tiling_mode == TilingMode::SidePanel
+ && if side_panel_pos == "right" {
+ matches!(
+ op_type,
+ PointerOpType::Resize
+ | PointerOpType::ResizeLeft
+ | PointerOpType::ResizeTopLeft
+ | PointerOpType::ResizeBottomLeft
+ )
+ } else {
+ matches!(
+ op_type,
+ PointerOpType::Resize
+ | PointerOpType::ResizeRight
+ | PointerOpType::ResizeTopRight
+ | PointerOpType::ResizeBottomRight
+ )
+ };
+ if !is_valid_side_panel_resize && win.tiling_mode != TilingMode::Floating {
win.tiling_mode = TilingMode::Floating;
needs_render = true;
}
@@ -3847,9 +3898,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
state.wm.needs_status_update = true;
if !already_focused {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -3890,17 +3939,28 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
// Start the drag! Set the window to Floating and lock it.
let mut needs_render = false;
+ let side_panel_pos = state.wm.layout.side_panel_position.clone();
if let Some(win) = state.wm.get_window_mut(pending.window_id) {
if win.tiling_mode != TilingMode::Fullscreen && win.tiling_mode != TilingMode::Popup {
- let is_right_resize_on_side_panel = win.tiling_mode == TilingMode::SidePanel
- && matches!(
- pending.op_type,
- PointerOpType::Resize
- | PointerOpType::ResizeRight
- | PointerOpType::ResizeTopRight
- | PointerOpType::ResizeBottomRight
- );
- if !is_right_resize_on_side_panel && win.tiling_mode != TilingMode::Floating {
+ let is_valid_side_panel_resize = win.tiling_mode == TilingMode::SidePanel
+ && if side_panel_pos == "right" {
+ matches!(
+ pending.op_type,
+ PointerOpType::Resize
+ | PointerOpType::ResizeLeft
+ | PointerOpType::ResizeTopLeft
+ | PointerOpType::ResizeBottomLeft
+ )
+ } else {
+ matches!(
+ pending.op_type,
+ PointerOpType::Resize
+ | PointerOpType::ResizeRight
+ | PointerOpType::ResizeTopRight
+ | PointerOpType::ResizeBottomRight
+ )
+ };
+ if !is_valid_side_panel_resize && win.tiling_mode != TilingMode::Floating {
win.tiling_mode = TilingMode::Floating;
needs_render = true;
}
@@ -3925,9 +3985,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
} else {
crate::types::Action::Resize
};
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
@@ -3964,6 +4022,120 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
eprintln!("[pointer] matched_window={:?}", matched_window);
if let Some((wid, surface_type)) = matched_window {
+ if let Some(w) = state.wm.windows.iter().find(|win| win.id == wid) {
+ if !w.minimized && surface_type == "top" {
+ let border_w = if state.wm.expose_active && w.tiling_mode != crate::types::TilingMode::Popup {
+ state.wm.layout.grid_border_width
+ } else {
+ match w.tiling_mode {
+ crate::types::TilingMode::Cascade => state.wm.layout.cascade_border_width,
+ crate::types::TilingMode::Fullscreen => state.wm.layout.fullscreen_border_width,
+ 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 logical_width = w.width + 2 * border_w;
+ let logical_height = std::cmp::max(border_w, 16);
+
+ if state.last_pointer_surface_y >= 0.0 && state.last_pointer_surface_y < logical_height as f64 {
+ let click_x = state.last_pointer_surface_x;
+ let mut action_handled = false;
+
+ if click_x >= (logical_width as f64 - 48.0) && click_x < (logical_width as f64 - 32.0) {
+ // Minimize
+ eprintln!("[pointer] Minimize button clicked on window {}", wid);
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ window.minimize_requested = true;
+ window.minimized = true;
+ }
+ // Shift focus
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+ if seat.focused_window_id == Some(wid) {
+ let active_tags = state.wm.active_tags;
+ let visible_ids: Vec<u64> = state.wm
+ .windows
+ .iter()
+ .filter(|win| (win.tags & active_tags) != 0 && !win.closed && !win.minimized && win.id != wid && win.app_id.as_deref() != Some("cce-status-interface"))
+ .map(|win| win.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;
+ state.manage_dirty();
+ action_handled = true;
+ } else if click_x >= (logical_width as f64 - 32.0) && click_x < (logical_width as f64 - 16.0) {
+ // Maximize / Restore
+ eprintln!("[pointer] Maximize button clicked on window {}", wid);
+ let is_fullscreen = state.wm.get_window(wid)
+ .map(|win| win.tiling_mode == TilingMode::Fullscreen)
+ .unwrap_or(false);
+ let resolved_mode = if is_fullscreen {
+ let (app_id, title, tags, mode_locked) = state.wm.get_window(wid)
+ .map(|win| (win.app_id.clone(), win.title.clone(), win.tags, win.mode_locked))
+ .unwrap_or((None, None, 1, false));
+ let temp_win = Window {
+ id: wid,
+ is_new: false,
+ closed: false,
+ tags,
+ app_id,
+ title,
+ tiling_mode: TilingMode::Fullscreen,
+ mode_locked,
+ ..Default::default()
+ };
+ crate::wm::get_mode_for_window(&state.wm, &temp_win).unwrap_or(state.wm.global_layout)
+ } else {
+ TilingMode::Fullscreen
+ };
+ if let Some(window) = state.wm.get_window_mut(wid) {
+ if is_fullscreen {
+ window.tiling_mode = resolved_mode;
+ window.mode_locked = false;
+ } else {
+ window.tiling_mode = TilingMode::Fullscreen;
+ window.mode_locked = true;
+ }
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+ state.manage_dirty();
+ }
+ action_handled = true;
+ } else if click_x >= (logical_width as f64 - 16.0) && click_x <= logical_width as f64 {
+ // Close
+ eprintln!("[pointer] Close button clicked on window {}", wid);
+ if let Some(wp) = state.get_window_proxy(wid) {
+ wp.river_window.close();
+ }
+ action_handled = true;
+ }
+
+ if action_handled {
+ // Focus the window first (if not minimized/closed)
+ if click_x < (logical_width as f64 - 48.0) || click_x >= (logical_width as f64 - 32.0) {
+ if let Some(sid) = state.seat_proxies.iter().find_map(|(sid, sp)| {
+ if sp.wl_pointer.as_ref() == Some(proxy) { Some(*sid) } else { None }
+ }) {
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+ seat.focused_window_id = Some(wid);
+ state.wm.needs_focus = true;
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+ state.manage_dirty();
+ }
+ }
+ }
+ return; // Avoid triggering drag or focus logic below
+ }
+ }
+ }
+ }
+
let window_tiling_mode = state.wm.windows.iter().find(|w| w.id == wid).map(|w| w.tiling_mode);
if let Some(mode) = window_tiling_mode {
if mode != TilingMode::Fullscreen {
@@ -4000,9 +4172,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
state.wm.needs_render = true;
state.wm.needs_status_update = true;
if !already_focused || moved || unminimized || expose_changed {
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
// Determine the specific PointerOpType based on coordinates on the matched surface
@@ -4117,9 +4287,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
seat.interacted_window_id = None;
}
state.wm.needs_render = true;
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ state.manage_dirty();
}
}
}
diff --git a/src/wm.rs b/src/wm.rs
index 23294ae..fbbf0a7 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -32,6 +32,11 @@ pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMod
return None;
}
+ // If the client requested fullscreen, automatically place it in Fullscreen mode.
+ if win.fullscreen_requested {
+ return Some(TilingMode::Fullscreen);
+ }
+
// 0. Windows with a parent (dialogs, file pickers, etc.) always float.
if win.has_parent {
return Some(TilingMode::Floating);
@@ -131,6 +136,36 @@ fn get_circular_for_window(mode_rules: &[ModeRule], win: &Window) -> bool {
false
}
+pub fn get_ssd_override_for_window(mode_rules: &[ModeRule], win: &Window) -> Option<bool> {
+ for rule in mode_rules {
+ let has_app_id = win.app_id.as_deref().map_or(false, |s| !s.is_empty());
+ let match_app = rule.app_id_pattern == "*"
+ || win
+ .app_id
+ .as_deref()
+ .map_or(false, |aid| aid.contains(&rule.app_id_pattern))
+ || (!has_app_id && win.title.as_deref().map_or(false, |t| {
+ let normalize = |s: &str| -> String {
+ s.to_lowercase().replace(|c: char| c == '-' || c == '_', " ")
+ };
+ normalize(t).contains(&normalize(&rule.app_id_pattern))
+ }));
+ let match_title = rule.title_pattern.as_deref() == Some("*")
+ || rule.title_pattern.is_none()
+ || win.title.as_deref().map_or(false, |t| {
+ t.contains(rule.title_pattern.as_deref().unwrap_or(""))
+ });
+
+ if match_app && match_title {
+ if rule.ssd.is_some() {
+ return rule.ssd;
+ }
+ }
+ }
+ None
+}
+
+
/// Assign tiling modes to all windows that aren't mode_locked.
/// Should be called during ManageStart before compute_tiling.
pub fn assign_window_modes(wm: &mut WindowManager) {
@@ -165,6 +200,10 @@ pub fn assign_window_modes(wm: &mut WindowManager) {
&& !win.has_parent
&& !matches_mode_rule(mode_rules, win)
{
+ if win.app_id.is_none() && win.title.is_none() {
+ continue;
+ }
+
// Determine what normal fallback mode would be
let mut normal_fallback = wm.global_layout;
for tag_bit in 0..crate::types::NUM_TAGS {
@@ -505,18 +544,24 @@ fn compute_tiling(
&& w.tiling_mode == TilingMode::SidePanel
});
+ let g = wm.layout.side_panel_border_gap;
let shift_x = if let Some(panel_win) = side_panel_win {
if wm.layout.side_panel_behavior == "above" {
0
- } else if panel_win.hint_min_width > 32 {
- std::cmp::max(wm.layout.side_panel_width, panel_win.hint_min_width)
} else {
- wm.layout.side_panel_width
+ let target_w = if panel_win.hint_min_width > 32 {
+ std::cmp::max(wm.layout.side_panel_width, panel_win.hint_min_width)
+ } else {
+ wm.layout.side_panel_width
+ };
+ target_w + g
}
} else {
0
};
+ let tiled_screen_w = screen_w - shift_x;
+
// Compute tiling
let mut results = Vec::new();
let mut idx_floating = 0i32;
@@ -538,7 +583,14 @@ fn compute_tiling(
};
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))
+ let sp_x = if wm.layout.side_panel_position == "right" {
+ phys_x + screen_w - target_w - g + bw
+ } else {
+ phys_x + g + bw
+ };
+ let sp_y = bar_height + phys_y + dec_h + g;
+ let sp_h = (screen_h - bar_height - (dec_h + bw) - 2 * g).max(1);
+ (sp_x, sp_y, target_w - bw * 2, sp_h)
}
TilingMode::Fullscreen => {
if fullscreen_id == Some(wid) || win.app_id.as_deref() == Some("cce-status-interface") {
@@ -568,7 +620,7 @@ fn compute_tiling(
.position(|w| w.id == wid)
.unwrap_or(0) as i32;
let (x, y, w, h) = tiling::tile_cascade(
- screen_w,
+ tiled_screen_w,
screen_h,
gap,
gap_top,
@@ -589,7 +641,7 @@ fn compute_tiling(
.position(|w| w.id == wid)
.unwrap_or(0) as i32;
let (x, y, w, h) = tiling::tile_grid(
- screen_w,
+ tiled_screen_w,
screen_h,
wm.layout.grid_gap,
gap_top,
@@ -611,30 +663,63 @@ fn compute_tiling(
// Use the window's existing dimensions, or a reasonable
// default if unset.
let fbw = wm.layout.floating_border_width;
- let fw = if win.width > 0 {
+ let mut fw = if win.width > 0 {
win.width
} else if win.hint_min_width > 32 {
win.hint_min_width
} else {
screen_w * 2 / 3
};
- let fh = if win.height > 0 {
+ let mut fh = if win.height > 0 {
win.height
} else if win.hint_min_height > 32 {
win.hint_min_height
} else {
screen_h * 2 / 3
};
- let fx = if win.x != 0 || win.y != 0 {
+
+ // Clamp dimensions to the screen's usable region
+ let max_w = screen_w - gap_left - gap_right;
+ let max_h = screen_h - bar_height - gap_top - gap_bottom;
+ fw = fw.clamp(1, max_w.max(1));
+ fh = fh.clamp(1, max_h.max(1));
+
+ let mut fx = if win.x != 0 || win.y != 0 {
win.x
} else {
- gap_left + fbw + cascade_offset * idx_floating
+ gap_left + fbw + cascade_offset * idx_floating + phys_x
};
- let fy = if win.x != 0 || win.y != 0 {
+ let mut fy = if win.x != 0 || win.y != 0 {
win.y
} else {
- gap_left + fbw + bar_height + gap_top + cascade_offset * idx_floating
+ gap_left + fbw + bar_height + gap_top + cascade_offset * idx_floating + phys_y
+ };
+
+ // Clamp position so the window is fully inside usability limits.
+ // We use the actual committed size (if set) to determine the clamping bounds
+ // so that we correctly clamp windows that refuse to resize to the layout target (e.g. Steam).
+ let actual_fw = if win.committed_width > 0 { win.committed_width } else { fw };
+ let actual_fh = if win.committed_height > 0 { win.committed_height } else { fh };
+
+ let min_x = phys_x + gap_left;
+ let max_x = phys_x + screen_w - gap_right - actual_fw;
+ let min_y = phys_y + bar_height + gap_top;
+ let max_y = phys_y + screen_h - gap_bottom - actual_fh;
+
+ let (clamp_min_x, clamp_max_x) = if max_x >= min_x {
+ (min_x, max_x)
+ } else {
+ (max_x, min_x)
};
+ fx = fx.clamp(clamp_min_x, clamp_max_x);
+
+ let (clamp_min_y, clamp_max_y) = if max_y >= min_y {
+ (min_y, max_y)
+ } else {
+ (max_y, min_y)
+ };
+ fy = fy.clamp(clamp_min_y, clamp_max_y);
+
idx_floating += 1;
(fx, fy, fw, fh)
}
@@ -664,7 +749,11 @@ fn compute_tiling(
&& mode != TilingMode::Popup
&& win.app_id.as_deref() != Some("cce-status-interface")
{
- x + shift_x
+ if wm.layout.side_panel_position == "right" {
+ x
+ } else {
+ x + shift_x
+ }
} else {
x
};
@@ -705,11 +794,31 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
.find(|s| !s.removed)
.and_then(|s| s.focused_window_id);
+ let was_animating = state.wm.animating;
+ let dt = if was_animating {
+ let elapsed = state.wm.last_frame_time.elapsed().as_secs_f64();
+ // Cap dt to 0.1s to avoid huge snapping jumps on massive frame drops/system pauses
+ if elapsed > 0.1 {
+ 0.1
+ } else {
+ elapsed
+ }
+ } else {
+ // Reset last_frame_time to now so that subsequent frames measure the actual elapsed time.
+ state.wm.last_frame_time = std::time::Instant::now();
+ 0.016
+ };
+
+ if was_animating {
+ state.wm.last_frame_time = std::time::Instant::now();
+ }
+
let transition_duration = state.wm.layout.transition_duration;
- let easing = if transition_duration <= 16 {
+ let easing = if transition_duration <= 8 {
1.0
} else {
- 1.0 - 0.01f64.powf(16.0 / transition_duration as f64)
+ let dt_ms = dt * 1000.0;
+ 1.0 - 0.01f64.powf(dt_ms / transition_duration as f64)
};
let expose_active = state.wm.expose_active;
@@ -739,6 +848,8 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
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 || 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);
let curr_y = win.anim_y.unwrap_or(win.y as f64);
@@ -827,6 +938,7 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
win.width = final_w;
win.height = final_h;
}
+
} else {
// If it's a new floating window created during expose mode,
// initialize its position to the default floating position if unset.
@@ -855,10 +967,12 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
// Propose dimensions via river_window_v1
if let Some(wp) = state.get_window_proxy(tr.wid) {
- let is_floating_or_popup = if let Some(win) = state.wm.get_window(tr.wid) {
- (win.tiling_mode == TilingMode::Floating || win.tiling_mode == TilingMode::Popup) && win.width == 0
+ let (is_floating_or_popup, is_tiled) = if let Some(win) = state.wm.get_window(tr.wid) {
+ let is_float = (win.tiling_mode == TilingMode::Floating || win.tiling_mode == TilingMode::Popup) && win.width == 0;
+ let is_tile = win.tiling_mode == TilingMode::Cascade || win.tiling_mode == TilingMode::Grid || win.tiling_mode == TilingMode::SidePanel;
+ (is_float, is_tile)
} else {
- false
+ (false, false)
};
if is_floating_or_popup {
@@ -866,6 +980,13 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
} else {
wp.river_window.propose_dimensions(final_w, final_h);
}
+
+ if is_tiled {
+ wp.river_window.set_tiled(Edges::all());
+ } else {
+ wp.river_window.set_tiled(Edges::empty());
+ }
+
// Tell the client to use server-side decoration.
// Per the River protocol, use_csd is the default when neither
// use_csd nor use_ssd is called. Calling use_ssd here ensures
@@ -880,6 +1001,9 @@ fn apply_tiling(state: &mut AppState, results: &[TileResult]) {
if any_animating {
state.wm.needs_render = true;
}
+ if was_animating && !any_animating {
+ state.wm.needs_status_update = true;
+ }
state.wm.expose_visual_active = state.wm.expose_active;
}
@@ -927,15 +1051,25 @@ fn set_borders(state: &mut AppState) {
let border_colors = compute_border_colors(&state.wm);
for bc in &border_colors {
- let wid = match state.wm.windows.get(bc.window_idx) {
- Some(w) => w.id,
+ let (wid, is_diff) = match state.wm.windows.get_mut(bc.window_idx) {
+ Some(win) => {
+ let current_val = (bc.edges, bc.width, bc.r, bc.g, bc.b, bc.a);
+ if win.last_borders != Some(current_val) {
+ win.last_borders = Some(current_val);
+ (win.id, true)
+ } else {
+ (win.id, false)
+ }
+ }
None => continue,
};
- if let Some(wp) = state.get_window_proxy(wid) {
- let edges = Edges::from_bits_truncate(bc.edges);
- wp.river_window
- .set_borders(edges, bc.width, bc.r, bc.g, bc.b, bc.a);
+ if is_diff {
+ if let Some(wp) = state.get_window_proxy(wid) {
+ let edges = Edges::from_bits_truncate(bc.edges);
+ wp.river_window
+ .set_borders(edges, bc.width, bc.r, bc.g, bc.b, bc.a);
+ }
}
}
}
@@ -948,32 +1082,40 @@ pub fn render_opacity(state: &mut AppState) {
.find(|s| !s.removed)
.and_then(|s| s.focused_window_id);
- for win in &state.wm.windows {
+ let mut updates = Vec::new();
+ for win in &mut state.wm.windows {
if win.closed {
continue;
}
- if let Some(wp) = state.get_window_proxy(win.id) {
- let is_cascade = win.tiling_mode == TilingMode::Cascade;
- let is_exposed = state.wm.expose_visual_active && win.tiling_mode != TilingMode::Popup && win.app_id.as_deref() != Some("cce-status-interface");
- let was_animating = win.anim_opacity.is_some();
- let should_fade = is_cascade || is_exposed || was_animating;
-
- let opacity = if should_fade {
- win.anim_opacity.unwrap_or_else(|| {
- if is_exposed || is_cascade {
- if Some(win.id) == focused_id {
- 1.0
- } else {
- 0.75
- }
- } else {
+ let is_cascade = win.tiling_mode == TilingMode::Cascade;
+ let is_exposed = state.wm.expose_visual_active && win.tiling_mode != TilingMode::Popup && win.app_id.as_deref() != Some("cce-status-interface");
+ let was_animating = win.anim_opacity.is_some();
+ let should_fade = is_cascade || is_exposed || was_animating;
+
+ let opacity = if should_fade {
+ win.anim_opacity.unwrap_or_else(|| {
+ if is_exposed || is_cascade {
+ if Some(win.id) == focused_id {
1.0
+ } else {
+ 0.75
}
- })
- } else {
- 1.0
- };
- let op_u32 = (opacity * u32::MAX as f64).round() as u32;
+ } else {
+ 1.0
+ }
+ })
+ } else {
+ 1.0
+ };
+ let op_u32 = (opacity * u32::MAX as f64).round() as u32;
+ if win.last_opacity != Some(op_u32) {
+ win.last_opacity = Some(op_u32);
+ updates.push((win.id, op_u32));
+ }
+ }
+
+ for (wid, op_u32) in updates {
+ if let Some(wp) = state.get_window_proxy(wid) {
wp.river_window.set_opacity(op_u32);
}
}
@@ -982,12 +1124,20 @@ pub fn render_opacity(state: &mut AppState) {
/// Apply whether windows are circular.
/// This modifies rendering state and is called during RenderStart.
pub fn render_circular(state: &mut AppState) {
- for win in &state.wm.windows {
+ let mut updates = Vec::new();
+ for win in &mut state.wm.windows {
if win.closed {
continue;
}
- if let Some(wp) = state.get_window_proxy(win.id) {
- let val = if win.circular { 1 } else { 0 };
+ let val = if win.circular { 1u32 } else { 0u32 };
+ if win.last_circular != Some(val) {
+ win.last_circular = Some(val);
+ updates.push((win.id, val));
+ }
+ }
+
+ for (wid, val) in updates {
+ if let Some(wp) = state.get_window_proxy(wid) {
wp.river_window.set_circular(val);
}
}
@@ -996,12 +1146,24 @@ 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 {
+ let mut updates = Vec::new();
+ for win in &mut 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 };
+ let val = if state.wm.layout.window_blur && win.app_id.as_deref() != Some("cce-status-interface") {
+ 1u32
+ } else {
+ 0u32
+ };
+ if win.last_blur != Some(val) {
+ win.last_blur = Some(val);
+ updates.push((win.id, val));
+ }
+ }
+
+ for (wid, val) in updates {
+ if let Some(wp) = state.get_window_proxy(wid) {
wp.river_window.set_blur(val);
}
}
@@ -1129,6 +1291,7 @@ mod tests {
single_instance: false,
tag: 0,
circular: false,
+ ssd: None,
});
// Spawn a focused window that is in Grid mode
@@ -1244,5 +1407,132 @@ mod tests {
assert_eq!(results[2].wid, 20);
assert_eq!(results[3].wid, 10);
}
+
+ #[test]
+ fn test_auto_fullscreen() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Cascade;
+ wm.tag_layouts[0] = TilingMode::Cascade;
+
+ wm.windows.push(Window {
+ id: 1,
+ fullscreen_requested: true,
+ is_new: false,
+ ..Default::default()
+ });
+
+ assign_window_modes(&mut wm);
+
+ let win1 = wm.get_window(1).unwrap();
+ assert_eq!(win1.tiling_mode, TilingMode::Fullscreen);
+ }
+
+ #[test]
+ fn test_floating_clamping() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Floating;
+ wm.active_tags = 1;
+ wm.layout.gap_left = 10;
+ wm.layout.gap_right = 10;
+ wm.layout.gap_top = 10;
+ wm.layout.gap_bottom = 10;
+ wm.layout.bar_height = 20;
+
+ // Large window that would overflow screen (1920x1080 screen)
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Floating,
+ x: 26,
+ y: 50,
+ width: 1920,
+ height: 1200,
+ tags: 1,
+ ..Default::default()
+ });
+
+ let results = compute_tiling(&wm, 1920, 1080, 1920, 1080, 0, 0);
+ assert_eq!(results.len(), 1);
+ let res = &results[0];
+
+ // Usable boundaries:
+ // max_w = 1920 - 10 - 10 = 1900
+ // max_h = 1080 - 20 (bar) - 10 - 10 = 1040
+ // width and height must be clamped to max_w and max_h respectively
+ assert_eq!(res.w, 1900);
+ assert_eq!(res.h, 1040);
+
+ // Coordinates:
+ // fx must be clamped to [min_x, max_x]
+ // min_x = 0 + 10 = 10
+ // max_x = 0 + 1920 - 10 - fw = 1910 - 1900 = 10
+ // So fx must be exactly 10!
+ assert_eq!(res.x, 10);
+
+ // fy must be clamped to [min_y, max_y]
+ // min_y = 0 + 20 (bar) + 10 = 30
+ // max_y = 0 + 1080 - 10 - fh = 1070 - 1040 = 30
+ // So fy must be exactly 30!
+ assert_eq!(res.y, 30);
+ }
+
+ #[test]
+ fn test_metadata_race_condition() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Cascade;
+ wm.tag_layouts[0] = TilingMode::Cascade;
+ wm.mode_rules.push(ModeRule {
+ mode: TilingMode::Floating,
+ app_id_pattern: "steam_proton".to_string(),
+ title_pattern: None,
+ single_instance: false,
+ tag: 0,
+ circular: false,
+ ssd: None,
+ });
+
+ // Focus window (Cascade mode)
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Cascade,
+ is_new: false,
+ ..Default::default()
+ });
+ wm.seats.push(Seat {
+ id: 1,
+ focused_window_id: Some(1),
+ ..Default::default()
+ });
+
+ // New window starts blank (is_new = true, app_id = None, title = None)
+ wm.windows.push(Window {
+ id: 2,
+ is_new: true,
+ app_id: None,
+ title: None,
+ ..Default::default()
+ });
+
+ // 1. First assign_window_modes run (blank window): inheritance should be skipped, not locked
+ assign_window_modes(&mut wm);
+ {
+ let win2 = wm.get_window(2).unwrap();
+ assert_eq!(win2.tiling_mode, TilingMode::Cascade);
+ assert!(!win2.mode_locked);
+ }
+
+ // 2. Metadata arrives (app_id = Some("steam_proton"))
+ {
+ let win2 = wm.get_window_mut(2).unwrap();
+ win2.app_id = Some("steam_proton".to_string());
+ }
+
+ // 3. Second assign_window_modes run: rule should match and resolve to Floating
+ assign_window_modes(&mut wm);
+ {
+ let win2 = wm.get_window(2).unwrap();
+ assert_eq!(win2.tiling_mode, TilingMode::Floating);
+ assert!(!win2.mode_locked);
+ }
+ }
}
diff --git a/start-river.sh b/start-river.sh
index bd9dbf8..2a008fa 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -30,11 +30,8 @@ LAUNCH_EOF
fi
chmod +x /tmp/cce-client-launch-river.sh
-echo "Starting river with cce-client..."
if [ "$LOGGING" = true ]; then
- echo "Logs: /tmp/river-cce-client.log + /tmp/cce-client-${WAYLAND_DISPLAY}.log"
+ exec river -c /tmp/cce-client-launch-river.sh 2>/tmp/river-cce-client.log
else
- echo "Logging disabled. Use --logging to enable."
+ exec river -c /tmp/cce-client-launch-river.sh 2>/dev/null
fi
-
-exec river -c /tmp/cce-client-launch-river.sh 2>/tmp/river-cce-client.log