Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Update system configuration and interface modules
protocol/clear-inspector-v1.xml | 6 +-
protocol/river-window-management-v1.xml | 9 +
src/borders.rs | 4 +-
src/clearctl.rs | 4 +-
src/config.rs | 11 +
src/decorations.rs | 117 +++++++--
src/input.rs | 86 ++++++-
src/inspector_cli.rs | 251 ++++++++++++++++++-
src/ipc.rs | 180 +++++++++++++-
src/state.rs | 16 +-
src/status.rs | 3 +-
src/status_server.rs | 33 ++-
src/tiling.rs | 16 +-
src/types.rs | 15 ++
src/wayland.rs | 208 +++++++++++++++-
src/wm.rs | 422 ++++++++++++++++++++++++++++++--
16 files changed, 1287 insertions(+), 94 deletions(-)
diff --git a/protocol/clear-inspector-v1.xml b/protocol/clear-inspector-v1.xml
index b321965..4dca611 100644
--- a/protocol/clear-inspector-v1.xml
+++ b/protocol/clear-inspector-v1.xml
@@ -27,7 +27,8 @@
The state is serialized as a JSON string containing widget layout and state data.
</description>
<arg name="surface" type="object" interface="wl_surface"/>
- <arg name="state" type="string" summary="JSON serialized state of the application"/>
+ <arg name="fd" type="fd" summary="JSON state file descriptor"/>
+ <arg name="len" type="uint" summary="JSON state length in bytes"/>
</request>
<request name="get_inspected_surfaces">
@@ -42,7 +43,8 @@
<arg name="y" type="int" summary="absolute screen Y coordinate"/>
<arg name="width" type="int" summary="surface width"/>
<arg name="height" type="int" summary="surface height"/>
- <arg name="state" type="string" summary="JSON state of the application"/>
+ <arg name="fd" type="fd" summary="JSON state file descriptor"/>
+ <arg name="len" type="uint" summary="JSON state length in bytes"/>
</event>
<event name="inspected_surface_done">
diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 32d6db3..60040b2 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -1130,6 +1130,15 @@
</description>
<arg name="opacity" type="uint" summary="opacity value from 0 to 0xffffffff"/>
</request>
+
+ <request name="set_circular" since="4">
+ <description summary="set whether the window is circular">
+ Set whether the window is circular (1 for circular, 0 for rectangular).
+ 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="circular" type="uint" summary="1 if circular, 0 otherwise"/>
+ </request>
</interface>
<interface name="river_decoration_v1" version="4">
diff --git a/src/borders.rs b/src/borders.rs
index 53d0657..5ab2e32 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -127,7 +127,9 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
}
};
- if win.app_id.as_deref() == Some("clear-status-interface") {
+ if win.app_id.as_deref() == Some("clear-status-interface")
+ || win.app_id.as_deref().map_or(false, |aid| aid.contains("noborder"))
+ {
width = 0;
}
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 4129218..6adcabb 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -31,11 +31,13 @@ fn usage(name: &str, to_stderr: bool) {
print(&format!("usage: {} <command> [args...]", name));
print("");
print("commands:");
- print(" layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
+ print(" layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|grid_gap|bar_height|border_width|fullscreen_border_width|border_color> <value>");
print(" view <1-4>");
print(" toggle <1-4>");
print(" close");
+ print(" minimize");
print(" focus-next");
+ print(" focus-window <app_id|title>");
print(" expose");
print(" windows");
print(" exit");
diff --git a/src/config.rs b/src/config.rs
index 1d6690b..f73ea2b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -78,6 +78,8 @@ pub struct LayoutConfig {
pub border_font_size: i64,
#[serde(default = "default_transition_duration")]
pub transition_duration: i64,
+ #[serde(default = "default_grid_gap")]
+ pub grid_gap: i64,
}
impl Default for LayoutConfig {
@@ -99,10 +101,15 @@ impl Default for LayoutConfig {
background_color: default_background_color(),
border_font_size: default_border_font_size(),
transition_duration: default_transition_duration(),
+ grid_gap: default_grid_gap(),
}
}
}
+fn default_grid_gap() -> i64 {
+ 18
+}
+
fn default_gap() -> i64 {
48
}
@@ -255,6 +262,7 @@ pub struct ModeRuleConfig {
pub title: Option<String>,
pub single: Option<bool>,
pub tag: Option<i64>,
+ pub circular: Option<bool>,
}
#[derive(Debug, Deserialize)]
@@ -300,6 +308,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
state.layout.floating_border_width = config.layout.floating_border_width as i32;
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;
if let Some((r, g, b, a)) = parse_hex_color(&config.layout.border_color) {
state.layout.border_r = r;
state.layout.border_g = g;
@@ -356,6 +365,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
title_pattern: mr.title.clone(),
single_instance: mr.single.unwrap_or(false),
tag,
+ circular: mr.circular.unwrap_or(false),
});
}
@@ -671,6 +681,7 @@ mod tests {
assert_eq!(lc.fullscreen_border_width, 0);
assert_eq!(lc.border_color, "#3e3e3e");
assert_eq!(lc.border_font_size, 11);
+ assert_eq!(lc.grid_gap, 18);
}
#[test]
diff --git a/src/decorations.rs b/src/decorations.rs
index ec48de7..1360b14 100644
--- a/src/decorations.rs
+++ b/src/decorations.rs
@@ -338,8 +338,15 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
// or side borders for windows that do not have them (Popup, Fullscreen).
for (wid, wp) in &mut state.window_proxies {
if let Some(w) = state.wm.windows.iter().find(|win| win.id == *wid) {
- let should_not_decorate = w.closed || w.app_id.as_deref() == Some("clear-status-interface") || w.tiling_mode == crate::types::TilingMode::Popup || w.tiling_mode == crate::types::TilingMode::Fullscreen;
- let needs_sides = !should_not_decorate;
+ let is_minimized = w.minimized;
+ let should_not_decorate = w.closed
+ || w.app_id.as_deref() == Some("clear-status-interface")
+ || w.app_id.as_deref().map_or(false, |aid| aid.contains("noborder"))
+ || w.tiling_mode == crate::types::TilingMode::Popup
+ || w.tiling_mode == crate::types::TilingMode::Fullscreen
+ || w.circular;
+ let needs_sides = !should_not_decorate && !is_minimized;
+ eprintln!("[decorations] window {} minimized={} needs_sides={} dec_right_exists={}", w.id, is_minimized, needs_sides, wp.dec_right.is_some());
if should_not_decorate {
if let Some(dec) = wp.decoration.take() {
@@ -365,36 +372,64 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
}
}
+ struct DecorateInfo {
+ id: u64,
+ win_width: i32,
+ win_height: i32,
+ border_width: i32,
+ title: String,
+ bg_color: u32,
+ tiling_mode: crate::types::TilingMode,
+ is_minimized: bool,
+ minimized_idx: Option<usize>,
+ win_y: i32,
+ }
+
// Calculate dynamic border colors
let border_colors = crate::borders::compute_border_colors(&state.wm);
let text_color = 0xFFE0E0E0u32;
// Collect window IDs to modify so we don't violate the borrow checker
- let windows_to_decorate: Vec<(u64, i32, i32, i32, String, u32, crate::types::TilingMode)> = state
+ let windows_to_decorate: Vec<DecorateInfo> = state
.wm
.windows
.iter()
.enumerate()
- .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("clear-status-interface") && w.tiling_mode != crate::types::TilingMode::Popup && w.tiling_mode != crate::types::TilingMode::Fullscreen)
+ .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("clear-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;
+ let minimized_idx = if is_minimized {
+ state.wm.windows
+ .iter()
+ .filter(|win| !win.closed && win.minimized && (win.tags & active_tags) != 0 && win.app_id.as_deref() != Some("clear-status-interface"))
+ .position(|win| win.id == w.id)
+ } else {
+ None
+ };
let title = w.title.clone().unwrap_or_else(|| {
w.app_id.clone().unwrap_or_else(|| "Window".to_string())
});
let mode_idx = if state.wm.expose_visual_active && w.tiling_mode != crate::types::TilingMode::Popup {
- state.wm.windows
+ let list: Vec<_> = state.wm.windows
.iter()
.filter(|win| !win.closed && win.app_id.as_deref() != Some("clear-status-interface") && win.tiling_mode != crate::types::TilingMode::Popup && (win.tags & active_tags) != 0)
- .position(|win| win.id == w.id)
- .unwrap_or(0)
+ .collect();
+ let len = list.len();
+ let pos = list.iter().position(|win| win.id == w.id).unwrap_or(0);
+ if len > 0 { len - 1 - pos } else { 0 }
} else {
- state.wm.windows
+ let list: Vec<_> = state.wm.windows
.iter()
.filter(|win| !win.closed && win.app_id.as_deref() != Some("clear-status-interface") && (win.tags & active_tags) != 0 && win.tiling_mode == w.tiling_mode)
- .position(|win| win.id == w.id)
- .unwrap_or(0)
+ .collect();
+ let len = list.len();
+ let pos = list.iter().position(|win| win.id == w.id).unwrap_or(0);
+ if len > 0 { len - 1 - pos } else { 0 }
};
- let indicator = if state.wm.expose_visual_active && w.tiling_mode != crate::types::TilingMode::Popup {
+ let indicator = if is_minimized {
+ "M"
+ } else if state.wm.expose_visual_active && w.tiling_mode != crate::types::TilingMode::Popup {
"EX"
} else {
match w.tiling_mode {
@@ -405,7 +440,11 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
crate::types::TilingMode::Popup => "P",
}
};
- let title_with_idx = format!("[{}{}] {}", indicator, mode_idx, title);
+ let title_with_idx = if is_minimized {
+ format!("[{}] {}", indicator, title)
+ } else {
+ format!("[{}{}] {}", indicator, mode_idx, title)
+ };
// Find matching computed border color for this window
let bc = border_colors.iter().find(|b| b.window_idx == idx);
@@ -426,7 +465,9 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
let bg_color = ((border_a as u32) << 24) | ((border_r as u32) << 16) | ((border_g as u32) << 8) | (border_b as u32);
// Border width is mode-specific
- let border_w = if state.wm.expose_visual_active && w.tiling_mode != crate::types::TilingMode::Popup {
+ let border_width = if is_minimized {
+ state.wm.layout.grid_border_width
+ } else if state.wm.expose_visual_active && w.tiling_mode != crate::types::TilingMode::Popup {
state.wm.layout.grid_border_width
} else {
match w.tiling_mode {
@@ -437,11 +478,33 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
crate::types::TilingMode::Popup => 0,
}
};
- (w.id, w.width, w.height, border_w, title_with_idx, bg_color, w.tiling_mode)
+ DecorateInfo {
+ id: w.id,
+ win_width: w.width,
+ win_height: w.height,
+ border_width,
+ title: title_with_idx,
+ bg_color,
+ tiling_mode: w.tiling_mode,
+ is_minimized,
+ minimized_idx,
+ win_y: w.y,
+ }
})
.collect();
- for (wid, win_width, win_height, border_width, title, bg_color, tiling_mode) in windows_to_decorate {
+ for info in windows_to_decorate {
+ let wid = info.id;
+ let win_width = info.win_width;
+ let win_height = info.win_height;
+ let border_width = info.border_width;
+ let title = info.title;
+ let bg_color = info.bg_color;
+ let tiling_mode = info.tiling_mode;
+ let is_minimized = info.is_minimized;
+ let minimized_idx = info.minimized_idx;
+ let win_y = info.win_y;
+
// Find or create decoration proxy
let wp_idx = match state.window_proxies.iter().position(|(id, _)| *id == wid) {
Some(idx) => idx,
@@ -452,7 +515,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
let scale = (state.wm.output_scale.round() as i32).max(1);
- if tiling_mode != crate::types::TilingMode::Popup && tiling_mode != crate::types::TilingMode::Fullscreen {
+ if tiling_mode != crate::types::TilingMode::Popup && tiling_mode != crate::types::TilingMode::Fullscreen && !is_minimized {
let grab_w = if tiling_mode == crate::types::TilingMode::Floating {
border_width.max(10)
} else {
@@ -505,7 +568,11 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
// Determine titlebar dimensions:
// Height equals border_width (or 16 if border_width is too small to display font)
let logical_height = std::cmp::max(border_width, 16);
- let logical_width = win_width + 2 * border_width;
+ let logical_width = if is_minimized {
+ 160
+ } else {
+ win_width + 2 * border_width
+ };
let dec_height = logical_height * scale;
let dec_width = logical_width * scale;
@@ -534,7 +601,17 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
let dec = wp.decoration.as_mut().unwrap();
// Position decoration on top of the window top border
- dec.decoration.set_offset(-border_width, -logical_height);
+ if is_minimized {
+ if let Some(idx) = minimized_idx {
+ let bar_height = state.wm.layout.bar_height;
+ let gap_top = state.wm.layout.gap_top;
+ let bubble_gap = 8;
+ let bubble_y = bar_height + gap_top + idx as i32 * (logical_height + bubble_gap);
+ dec.decoration.set_offset(0, bubble_y - win_y);
+ }
+ } else {
+ dec.decoration.set_offset(-border_width, -logical_height);
+ }
if needs_new_buffer {
eprintln!(
@@ -696,7 +773,9 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
}
// Commit surface rendering
- dec.decoration.sync_next_commit();
+ if !is_minimized {
+ dec.decoration.sync_next_commit();
+ }
if let Some(ref wl_buf) = dec.buffer {
dec.surface.attach(Some(wl_buf), 0, 0);
}
diff --git a/src/input.rs b/src/input.rs
index 4d4656d..0e85dd8 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -83,6 +83,28 @@ struct InputEvent {
value: i32,
}
+const UI_SET_ABSBIT: libc::c_ulong = 0x40045567;
+const UI_ABS_SETUP: libc::c_ulong = 1075598596; // 0x40185568
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+struct InputAbsinfo {
+ value: i32,
+ minimum: i32,
+ maximum: i32,
+ fuzz: i32,
+ flat: i32,
+ resolution: i32,
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+struct UinputAbsSetup {
+ code: u16,
+ _padding: u16,
+ absinfo: InputAbsinfo,
+}
+
#[derive(Debug, Clone, serde::Serialize)]
pub struct FingerState {
pub slot: usize,
@@ -152,6 +174,47 @@ fn setup_uinput() -> std::io::Result<std::fs::File> {
if libc::ioctl(fd, UI_SET_RELBIT, REL_HWHEEL as libc::c_int) < 0 {
return Err(std::io::Error::last_os_error());
}
+ if libc::ioctl(fd, UI_SET_EVBIT, EV_ABS as libc::c_int) < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ if libc::ioctl(fd, UI_SET_ABSBIT, ABS_X as libc::c_int) < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let abs_x_setup = UinputAbsSetup {
+ code: ABS_X,
+ _padding: 0,
+ absinfo: InputAbsinfo {
+ value: 0,
+ minimum: 0,
+ maximum: 1920,
+ fuzz: 0,
+ flat: 0,
+ resolution: 1,
+ },
+ };
+ if libc::ioctl(fd, UI_ABS_SETUP, &abs_x_setup as *const UinputAbsSetup as *const libc::c_void) < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+
+ if libc::ioctl(fd, UI_SET_ABSBIT, ABS_Y as libc::c_int) < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let abs_y_setup = UinputAbsSetup {
+ code: ABS_Y,
+ _padding: 0,
+ absinfo: InputAbsinfo {
+ value: 0,
+ minimum: 0,
+ maximum: 1200,
+ fuzz: 0,
+ flat: 0,
+ resolution: 1,
+ },
+ };
+ if libc::ioctl(fd, UI_ABS_SETUP, &abs_y_setup as *const UinputAbsSetup as *const libc::c_void) < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+
if libc::ioctl(fd, UI_SET_EVBIT, EV_KEY as libc::c_int) < 0 {
return Err(std::io::Error::last_os_error());
}
@@ -212,13 +275,10 @@ fn write_raw_event(file: &mut std::fs::File, type_: u16, code: u16, value: i32)
Ok(())
}
-fn write_mouse_move(file: &mut std::fs::File, dx: i32, dy: i32) -> std::io::Result<()> {
- if dx != 0 {
- write_raw_event(file, EV_REL, REL_X, dx)?;
- }
- if dy != 0 {
- write_raw_event(file, EV_REL, REL_Y, dy)?;
- }
+
+fn write_mouse_absolute(file: &mut std::fs::File, x: i32, y: i32) -> std::io::Result<()> {
+ write_raw_event(file, EV_ABS, ABS_X, x)?;
+ write_raw_event(file, EV_ABS, ABS_Y, y)?;
write_raw_event(file, EV_SYN, SYN_REPORT, 0)?;
Ok(())
}
@@ -619,8 +679,10 @@ pub fn run_input_daemon(
}
}
InputDaemonMsg::SimulateMove { dx, dy } => {
- let _ = write_mouse_move(&mut uinput_file, dx, dy);
update_pointer_coords(dx, dy);
+ let px = POINTER_X.load(Ordering::SeqCst);
+ let py = POINTER_Y.load(Ordering::SeqCst);
+ let _ = write_mouse_absolute(&mut uinput_file, px, py);
}
InputDaemonMsg::SimulateButton { button, press } => {
let val = if press { 1 } else { 0 };
@@ -834,8 +896,10 @@ pub fn run_input_daemon(
state.accum_y -= steps_y as f32;
if steps_x != 0 || steps_y != 0 {
- let _ = write_mouse_move(&mut uinput_file, steps_x, steps_y);
update_pointer_coords(steps_x, steps_y);
+ let px = POINTER_X.load(Ordering::SeqCst);
+ let py = POINTER_Y.load(Ordering::SeqCst);
+ let _ = write_mouse_absolute(&mut uinput_file, px, py);
}
}
}
@@ -860,8 +924,10 @@ pub fn run_input_daemon(
state.accum_trackpad_y -= steps_y as f32;
if steps_x != 0 || steps_y != 0 {
- let _ = write_mouse_move(&mut uinput_file, steps_x, steps_y);
update_pointer_coords(steps_x, steps_y);
+ let px = POINTER_X.load(Ordering::SeqCst);
+ let py = POINTER_Y.load(Ordering::SeqCst);
+ let _ = write_mouse_absolute(&mut uinput_file, px, py);
}
}
}
diff --git a/src/inspector_cli.rs b/src/inspector_cli.rs
index 47bb034..8c62ae6 100644
--- a/src/inspector_cli.rs
+++ b/src/inspector_cli.rs
@@ -35,6 +35,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for InspectorState {
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global { name, interface, version } = event {
+ println!("Debug Global: interface='{}', version={}, name={}", interface, version, name);
if interface == "zclear_inspector_v1" {
state.inspector = Some(registry.bind::<ZclearInspectorV1, _, _>(name, version, qh, ()));
}
@@ -52,7 +53,13 @@ impl Dispatch<ZclearInspectorV1, ()> for InspectorState {
_qh: &QueueHandle<Self>,
) {
match event {
- zclear_inspector_v1::Event::InspectedSurface { title, app_id, x, y, width, height, state: surface_state } => {
+ zclear_inspector_v1::Event::InspectedSurface { title, app_id, x, y, width, height, fd, len } => {
+ let mut surface_state = String::new();
+ if len > 0 {
+ use std::io::Read;
+ let file = std::fs::File::from(fd);
+ let _ = file.take(len as u64).read_to_string(&mut surface_state);
+ }
state.surfaces.push(InspectedSurface {
title,
app_id,
@@ -70,7 +77,166 @@ impl Dispatch<ZclearInspectorV1, ()> for InspectorState {
}
}
+fn get_socket_path() -> String {
+ match std::env::var("WAYLAND_DISPLAY") {
+ Ok(display) => format!("/tmp/ccec-{}.sock", display),
+ Err(_) => "/tmp/ccec.sock".to_string(),
+ }
+}
+
+fn send_ipc_commands(commands: &[String]) -> Result<Vec<String>, std::io::Error> {
+ use std::io::{Read, Write};
+ use std::os::unix::net::UnixStream;
+ let mut stream = UnixStream::connect(get_socket_path())?;
+
+ let mut combined_cmd = String::new();
+ for cmd in commands {
+ combined_cmd.push_str(cmd);
+ }
+
+ stream.write_all(combined_cmd.as_bytes())?;
+
+ let mut buf = [0u8; 4096];
+ let mut response = String::new();
+ loop {
+ match stream.read(&mut buf) {
+ Ok(0) => break,
+ Ok(n) => {
+ response.push_str(&String::from_utf8_lossy(&buf[..n]));
+ }
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
+ Err(e) => return Err(e),
+ }
+ }
+
+ let replies = response.lines().map(|line| format!("{}\n", line)).collect();
+ Ok(replies)
+}
+
+fn find_widget_recursive(val: &Value, target_type: &str, target_label: Option<&str>) -> Option<(f64, f64, f64, f64)> {
+ if let Some(obj) = val.as_object() {
+ let type_match = obj.get("type")
+ .and_then(|t| t.as_str())
+ .map(|t| t == target_type)
+ .unwrap_or(false);
+
+ let label_match = match target_label {
+ Some(lbl) => obj.get("label")
+ .and_then(|l| l.as_str())
+ .map(|l| l.eq_ignore_ascii_case(lbl) || l.to_lowercase().contains(&lbl.to_lowercase()))
+ .unwrap_or(false),
+ None => true,
+ };
+
+ if type_match && label_match {
+ if let Some(rect_arr) = obj.get("rect").and_then(|r| r.as_array()) {
+ if rect_arr.len() == 4 {
+ let rx = rect_arr[0].as_f64().unwrap_or(0.0);
+ let ry = rect_arr[1].as_f64().unwrap_or(0.0);
+ let rw = rect_arr[2].as_f64().unwrap_or(0.0);
+ let rh = rect_arr[3].as_f64().unwrap_or(0.0);
+ return Some((rx, ry, rw, rh));
+ }
+ }
+ }
+
+ // Search children
+ if let Some(children) = obj.get("children").and_then(|c| c.as_array()) {
+ for child in children {
+ if let Some(coords) = find_widget_recursive(child, target_type, target_label) {
+ return Some(coords);
+ }
+ }
+ }
+ } else if let Some(arr) = val.as_array() {
+ for item in arr {
+ if let Some(coords) = find_widget_recursive(item, target_type, target_label) {
+ return Some(coords);
+ }
+ }
+ }
+ None
+}
+
+fn print_usage(bin_name: &str) {
+ println!("Usage:");
+ println!(" {} List all inspected surfaces and their widget trees", bin_name);
+ println!(" {} click-widget --app-id <app_id> --type <widget_type> [--label <widget_label>]", bin_name);
+ println!();
+ println!("Options:");
+ println!(" -a, --app-id <app_id> The Wayland application ID (e.g. clear-design-interface)");
+ println!(" -t, --type <widget_type> The widget type (e.g. MenuItem, Button)");
+ println!(" -l, --label <widget_label> Optional widget label to filter by");
+}
+
fn main() {
+ let args: Vec<String> = std::env::args().collect();
+ let bin_name = args.get(0).map(|s| s.as_str()).unwrap_or("clear-inspector");
+
+ let mut app_id = None;
+ let mut widget_type = None;
+ let mut widget_label = None;
+ let mut is_click_widget = false;
+
+ if args.len() > 1 {
+ if args[1] == "click-widget" {
+ is_click_widget = true;
+ let mut i = 2;
+ while i < args.len() {
+ match args[i].as_str() {
+ "--app-id" | "-a" => {
+ if i + 1 < args.len() {
+ app_id = Some(args[i+1].clone());
+ i += 2;
+ } else {
+ eprintln!("Error: missing value for --app-id");
+ std::process::exit(1);
+ }
+ }
+ "--type" | "-t" => {
+ if i + 1 < args.len() {
+ widget_type = Some(args[i+1].clone());
+ i += 2;
+ } else {
+ eprintln!("Error: missing value for --type");
+ std::process::exit(1);
+ }
+ }
+ "--label" | "-l" => {
+ if i + 1 < args.len() {
+ widget_label = Some(args[i+1].clone());
+ i += 2;
+ } else {
+ eprintln!("Error: missing value for --label");
+ std::process::exit(1);
+ }
+ }
+ "-h" | "--help" | "help" => {
+ print_usage(bin_name);
+ std::process::exit(0);
+ }
+ _ => {
+ eprintln!("Error: unknown argument '{}'", args[i]);
+ print_usage(bin_name);
+ std::process::exit(1);
+ }
+ }
+ }
+ if app_id.is_none() || widget_type.is_none() {
+ eprintln!("Error: --app-id and --type are required for click-widget command");
+ print_usage(bin_name);
+ std::process::exit(1);
+ }
+ } else if args[1] == "-h" || args[1] == "--help" || args[1] == "help" {
+ print_usage(bin_name);
+ std::process::exit(0);
+ } else {
+ eprintln!("Error: unknown command '{}'", args[1]);
+ print_usage(bin_name);
+ std::process::exit(1);
+ }
+ }
+
let conn = match Connection::connect_to_env() {
Ok(c) => c,
Err(e) => {
@@ -79,8 +245,8 @@ fn main() {
}
};
- let (_globals, mut event_queue) = registry_queue_init(&conn).unwrap();
- let _qh = event_queue.handle();
+ let (globals, mut event_queue) = registry_queue_init(&conn).unwrap();
+ let qh = event_queue.handle();
let mut state = InspectorState {
inspector: None,
@@ -88,13 +254,10 @@ fn main() {
surfaces: Vec::new(),
};
- // Populate registry globals
- event_queue.roundtrip(&mut state).unwrap();
-
- let inspector = match state.inspector.take() {
- Some(ins) => ins,
- None => {
- eprintln!("Error: zclear_inspector_v1 global protocol not found on Wayland registry.");
+ let inspector = match globals.bind::<ZclearInspectorV1, _, _>(&qh, 1..=1, ()) {
+ Ok(ins) => ins,
+ Err(e) => {
+ eprintln!("Error: zclear_inspector_v1 global protocol not found on Wayland registry: {:?}", e);
eprintln!("Ensure clear-river is running and supports the inspector protocol.");
std::process::exit(1);
}
@@ -108,6 +271,74 @@ fn main() {
event_queue.blocking_dispatch(&mut state).unwrap();
}
+ if is_click_widget {
+ let target_app_id = app_id.as_deref().unwrap();
+ let target_type = widget_type.as_deref().unwrap();
+ let matched_surface = state.surfaces.iter().find(|s| s.app_id.eq_ignore_ascii_case(target_app_id));
+ if let Some(surface) = matched_surface {
+ if let Ok(json_val) = serde_json::from_str::<Value>(&surface.state) {
+ let mut max_logical_w = 0.0;
+ if let Some(arr) = json_val.as_array() {
+ for item in arr {
+ if let Some(obj) = item.as_object() {
+ if let Some(rect_arr) = obj.get("rect").and_then(|r| r.as_array()) {
+ if rect_arr.len() == 4 {
+ let rx_val = rect_arr[0].as_f64().unwrap_or(0.0);
+ let rw_val = rect_arr[2].as_f64().unwrap_or(0.0);
+ if rx_val == 0.0 && rw_val > max_logical_w {
+ max_logical_w = rw_val;
+ }
+ }
+ }
+ }
+ }
+ }
+ let scale = if max_logical_w > 0.0 {
+ (surface.width as f64) / max_logical_w
+ } else {
+ 1.0
+ };
+
+ if let Some((rx, ry, rw, rh)) = find_widget_recursive(&json_val, target_type, widget_label.as_deref()) {
+ let target_x = (surface.x as f64 + (rx + rw / 2.0) * scale) as i32;
+ let target_y = (surface.y as f64 + (ry + rh / 2.0) * scale) as i32;
+ println!("Found matching widget! Coordinate local bounds: [{}, {}, {}, {}], global target: ({}, {}), scale: {}", rx, ry, rw, rh, target_x, target_y, scale);
+
+ let commands = vec![
+ format!("focus-window {}\n", target_app_id),
+ format!("pointer-move-to {} {}\n", target_x, target_y),
+ format!("pointer-click left\n"),
+ ];
+ match send_ipc_commands(&commands) {
+ Ok(replies) => {
+ for r in replies {
+ print!("{}", r);
+ }
+ }
+ Err(e) => {
+ eprintln!("Error sending IPC commands to ccec socket: {:?}", e);
+ std::process::exit(1);
+ }
+ }
+ } else {
+ eprintln!("Error: Widget of type '{}'{} not found in surface '{}'",
+ target_type,
+ widget_label.as_ref().map(|l| format!(" with label '{}'", l)).unwrap_or_default(),
+ target_app_id
+ );
+ std::process::exit(1);
+ }
+ } else {
+ eprintln!("Error: Failed to parse widget state JSON for surface '{}'", target_app_id);
+ std::process::exit(1);
+ }
+ } else {
+ eprintln!("Error: Surface with app_id '{}' not found", target_app_id);
+ std::process::exit(1);
+ }
+ return;
+ }
+
// Print results
println!("Found {} active inspected surface(s):", state.surfaces.len());
println!("{}", "=".repeat(60));
diff --git a/src/ipc.rs b/src/ipc.rs
index 5cd0b12..8580dca 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -78,6 +78,32 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
}
state.needs_render = true;
}
+ "minimize" => {
+ if let Some(seat) = state.seats.first() {
+ if let Some(focused_id) = seat.focused_window_id {
+ if let Some(window) = state.get_window_mut(focused_id) {
+ window.minimized = true;
+ }
+ // Shift focus to the next visible window
+ let active_tags = state.active_tags;
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & active_tags) != 0 && !w.closed && !w.minimized && w.app_id.as_deref() != Some("clear-status-interface"))
+ .map(|w| w.id)
+ .collect();
+ let next_id = visible_ids.last().copied();
+ for s in &mut state.seats {
+ if !s.removed {
+ s.focused_window_id = next_id;
+ }
+ }
+ }
+ }
+ state.needs_render = true;
+ state.needs_focus = true;
+ state.needs_status_update = true;
+ }
"focus-next" => {
// Focus the next visible window (wrapping) and move it to the
// front of the cascade stack (end of windows vector).
@@ -87,7 +113,7 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
let visible_ids: Vec<u64> = state
.windows
.iter()
- .filter(|w| (w.tags & active_tags) != 0 && !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+ .filter(|w| (w.tags & active_tags) != 0 && !w.closed && !w.minimized && w.app_id.as_deref() != Some("clear-status-interface"))
.map(|w| w.id)
.collect();
if visible_ids.len() > 1 {
@@ -106,6 +132,34 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
state.needs_focus = true;
state.needs_status_update = true;
}
+ "focus-prev" => {
+ // Focus the previous visible window (wrapping) and move it to the
+ // front of the cascade stack (end of windows vector).
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ let focused_id = seat.focused_window_id;
+ let active_tags = state.active_tags;
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & active_tags) != 0 && !w.closed && !w.minimized && w.app_id.as_deref() != Some("clear-status-interface"))
+ .map(|w| w.id)
+ .collect();
+ if visible_ids.len() > 1 {
+ if let Some(fid) = focused_id {
+ if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
+ let prev_idx = if idx == 0 { visible_ids.len() - 1 } else { idx - 1 };
+ let prev_id = visible_ids[prev_idx];
+ seat.focused_window_id = Some(prev_id);
+ // Move newly focused window to front of cascade stack
+ state.move_window_to_end(prev_id);
+ }
+ }
+ }
+ }
+ state.needs_render = true;
+ state.needs_focus = true;
+ state.needs_status_update = true;
+ }
"exit" => {
// Signal exit request
}
@@ -113,7 +167,7 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
crate::restart::wm_restart();
}
"reload" => {
- crate::restart::wm_restart();
+ crate::restart::wm_reload(state);
}
"expose" => {
let active = !state.expose_active;
@@ -254,6 +308,9 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
"set-mode" => {
handle_set_mode_command(rest, state);
}
+ "apply-mode-sharing" => {
+ handle_apply_mode_sharing_command(rest, state);
+ }
"bind" => {
handle_bind_command(rest, state);
}
@@ -288,6 +345,50 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) -> String {
}
}
}
+ "focus-window" => {
+ let app_id_or_title = rest.trim();
+ if !app_id_or_title.is_empty() {
+ let mut found_id = None;
+ for win in &state.windows {
+ if !win.closed && !win.minimized {
+ if let Some(ref app_id) = win.app_id {
+ if app_id.eq_ignore_ascii_case(app_id_or_title) {
+ found_id = Some(win.id);
+ break;
+ }
+ }
+ }
+ }
+ if found_id.is_none() {
+ for win in &state.windows {
+ if !win.closed && !win.minimized {
+ if let Some(ref title) = win.title {
+ if title.to_lowercase().contains(&app_id_or_title.to_lowercase()) {
+ found_id = Some(win.id);
+ break;
+ }
+ }
+ }
+ }
+ }
+ if let Some(wid) = found_id {
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ seat.focused_window_id = Some(wid);
+ state.move_window_to_end(wid);
+ state.needs_focus = true;
+ state.needs_render = true;
+ state.needs_status_update = true;
+ reply = format!("ok focused window {}\n", wid);
+ } else {
+ reply = "error: no active seat\n".to_string();
+ }
+ } else {
+ reply = "error: window not found\n".to_string();
+ }
+ } else {
+ reply = "error: usage: focus-window <app_id|title>\n".to_string();
+ }
+ }
"pointer-location" => {
let px = crate::input::POINTER_X.load(std::sync::atomic::Ordering::SeqCst);
let py = crate::input::POINTER_Y.load(std::sync::atomic::Ordering::SeqCst);
@@ -511,6 +612,14 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
}
}
}
+ "grid_gap" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.grid_gap = value;
+ if state.notifications_enable {
+ crate::config::show_notification("ccec", &format!("Grid gap set to {}px", value));
+ }
+ }
+ }
"grid_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.grid_border_width = value;
@@ -593,6 +702,7 @@ fn handle_mode_command(rest: &str, state: &mut WindowManager) {
title_pattern,
single_instance,
tag,
+ circular: false,
});
}
@@ -616,6 +726,38 @@ fn handle_set_mode_command(rest: &str, state: &mut WindowManager) {
}
}
+/// Handle "apply-mode-sharing <mode>" command — apply <mode> to all windows sharing a tiling mode with the focused window.
+fn handle_apply_mode_sharing_command(rest: &str, state: &mut WindowManager) {
+ let mode_str = rest.trim();
+ if mode_str.is_empty() {
+ return;
+ }
+ let new_mode = parse_tiling_mode(mode_str);
+
+ // Find the focused window's current tiling mode
+ let old_mode = if let Some(window) = state.focused_window() {
+ window.tiling_mode
+ } else {
+ return;
+ };
+
+ let notifications_enable = state.notifications_enable;
+
+ // Iterate over all windows and update tiling mode for matching windows
+ for window in &mut state.windows {
+ if !window.closed && window.tiling_mode == old_mode {
+ window.tiling_mode = new_mode;
+ window.mode_locked = true;
+ }
+ }
+
+ if notifications_enable {
+ crate::config::show_notification("ccec", &format!("Applied tiling mode {} to all windows sharing mode {}", new_mode.as_str(), old_mode.as_str()));
+ }
+ state.needs_render = true;
+ state.needs_status_update = true;
+}
+
/// Handle "bind <mods> <key> <action> [command]" command
fn handle_bind_command(rest: &str, state: &mut WindowManager) {
// Format: bind <mods> <key> <action> [command...]
@@ -1023,6 +1165,15 @@ mod tests {
assert_eq!(state.layout.gap, 18);
}
+ #[test]
+ fn test_ipc_layout_grid_gap() {
+ let mut state = WindowManager::default();
+ assert_eq!(state.layout.grid_gap, 18);
+
+ handle_ipc_command("layout grid_gap 24", &mut state);
+ assert_eq!(state.layout.grid_gap, 24);
+ }
+
#[test]
fn test_ipc_layout_gap_sides() {
let mut state = WindowManager::default();
@@ -1217,6 +1368,31 @@ mod tests {
assert_eq!(state.active_tags, 1); // Tag 1
}
+ #[test]
+ fn test_ipc_focus_next_prev() {
+ let mut state = WindowManager::default();
+ // Setup 3 windows
+ state.windows.push(crate::types::Window { id: 1, tags: 1, ..Default::default() });
+ state.windows.push(crate::types::Window { id: 2, tags: 1, ..Default::default() });
+ state.windows.push(crate::types::Window { id: 3, tags: 1, ..Default::default() });
+ state.seats.push(crate::types::Seat { id: 1, focused_window_id: Some(1), ..Default::default() });
+
+ handle_ipc_command("focus-next", &mut state);
+ assert_eq!(state.seats[0].focused_window_id, Some(2));
+
+ handle_ipc_command("focus-next", &mut state);
+ assert_eq!(state.seats[0].focused_window_id, Some(1));
+
+ handle_ipc_command("focus-next", &mut state);
+ assert_eq!(state.seats[0].focused_window_id, Some(3));
+
+ handle_ipc_command("focus-prev", &mut state);
+ assert_eq!(state.seats[0].focused_window_id, Some(1));
+
+ handle_ipc_command("focus-prev", &mut state);
+ assert_eq!(state.seats[0].focused_window_id, Some(3));
+ }
+
#[test]
fn test_ipc_pointer_and_keys() {
let mut state = WindowManager::default();
diff --git a/src/state.rs b/src/state.rs
index 8e41a94..ba244af 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -58,6 +58,7 @@ pub struct PersistentWindow {
pub tags: u32,
pub tiling_mode: TilingMode,
pub mode_locked: bool,
+ pub minimized: bool,
}
/// Percent-encode spaces, tabs, newlines, and percent signs in a string.
@@ -137,8 +138,8 @@ pub fn write_state(wm: &WindowManager) {
let mode_str = win.tiling_mode.as_str();
let _ = writeln!(
f,
- "window\t{}\t{}\t{}\t{}\t{}\t{}",
- ident, app_id, title, win.tags, mode_str, win.mode_locked
+ "window\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
+ ident, app_id, title, win.tags, mode_str, win.mode_locked, win.minimized
);
}
@@ -204,6 +205,7 @@ pub fn read_state() -> Option<PersistentState> {
let tags = parts[3].parse::<u32>().unwrap_or(1);
let tiling_mode = parse_tiling_mode_str(parts[4]);
let mode_locked = parts[5] == "true";
+ let minimized = parts.get(6).map(|&s| s == "true").unwrap_or(false);
windows.push(PersistentWindow {
identifier,
@@ -212,6 +214,7 @@ pub fn read_state() -> Option<PersistentState> {
tags,
tiling_mode,
mode_locked,
+ minimized,
});
}
continue;
@@ -309,6 +312,7 @@ pub fn apply_state(wm: &mut WindowManager, state: &PersistentState) {
win.tiling_mode = pw.tiling_mode;
win.mode_locked = true;
}
+ win.minimized = pw.minimized;
}
// Post-restore normalization: Ensure blank steam_proton helper windows are untagged (tags = 0)
@@ -395,8 +399,8 @@ mod tests {
let title = w.title.as_deref().map(pct_encode).unwrap_or("-".to_string());
let _ = writeln!(
f,
- "window\t{}\t{}\t{}\t{}\t{}\t{}",
- ident, app_id, title, w.tags, w.tiling_mode.as_str(), w.mode_locked
+ "window\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
+ ident, app_id, title, w.tags, w.tiling_mode.as_str(), w.mode_locked, w.minimized
);
}
}
@@ -435,7 +439,8 @@ mod tests {
let tags = parts[3].parse::<u32>().unwrap_or(1);
let tiling_mode = parse_tiling_mode_str(parts[4]);
let mode_locked = parts[5] == "true";
- windows.push(PersistentWindow { identifier, app_id, title, tags, tiling_mode, mode_locked });
+ let minimized = parts.get(6).map(|&s| s == "true").unwrap_or(false);
+ windows.push(PersistentWindow { identifier, app_id, title, tags, tiling_mode, mode_locked, minimized });
}
}
}
@@ -461,6 +466,7 @@ mod tests {
assert_eq!(pw.tags, 0b100);
assert_eq!(pw.tiling_mode, TilingMode::Fullscreen);
assert!(pw.mode_locked);
+ assert!(!pw.minimized);
// Clean up
let _ = fs::remove_file(&path);
diff --git a/src/status.rs b/src/status.rs
index 121eab2..b6599b4 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -55,7 +55,7 @@ pub fn write_status_files(state: &WindowManager) {
};
let _ = writeln!(
f,
- "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={} has_parent={}",
+ "window app_id={} title={} mode={} decoration={} presentation={} tags={} x={} y={} w={} h={} has_parent={} minimized={}",
win.app_id.as_deref().unwrap_or("(null)"),
win.title.as_deref().unwrap_or("(null)"),
mode_str,
@@ -63,6 +63,7 @@ pub fn write_status_files(state: &WindowManager) {
presentation_str,
win.tags, win.x, win.y, win.width, win.height,
win.has_parent,
+ win.minimized,
);
}
diff --git a/src/status_server.rs b/src/status_server.rs
index 0a69de9..8b7fc67 100644
--- a/src/status_server.rs
+++ b/src/status_server.rs
@@ -268,15 +268,40 @@ pub fn build_status_update(wm: &crate::types::WindowManager) -> StatusUpdate {
}
}
+fn read_status_normal_color_from_config() -> String {
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/lsgalante".to_string());
+ let path = format!("{}/.config/ccec/config.toml", home);
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ for line in content.lines() {
+ let trimmed = line.trim();
+ if let Some(rest) = trimmed.strip_prefix("status_normal_color") {
+ let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+ let hex = rest.trim_end_matches('"').trim();
+ if hex.starts_with('#') {
+ return hex.to_string();
+ } else if !hex.is_empty() {
+ return format!("#{}", hex);
+ }
+ }
+ }
+ }
+ "#ccccd8".to_string()
+}
+
/// Render tag state as a JSON string with pango markup, matching the format
/// produced by the old ccec-tags.sh script.
///
/// Colors:
-/// - Active + Focused: bright (#a8c0d8)
+/// - Active + Focused: bright (dynamic normal color, defaults to #ccccd8)
/// - Focused only: dim (#666666)
/// - Active only: medium (#888888)
/// - Neither: dark (#444444)
fn render_tags_json(active: u32, focused: u32, num_tags: u32) -> String {
+ let normal_color = read_status_normal_color_from_config();
+ render_tags_json_with_color(active, focused, num_tags, &normal_color)
+}
+
+fn render_tags_json_with_color(active: u32, focused: u32, num_tags: u32, normal_color: &str) -> String {
let mut text = String::new();
for i in 0..num_tags {
let bit = 1u32 << i;
@@ -286,7 +311,7 @@ fn render_tags_json(active: u32, focused: u32, num_tags: u32) -> String {
let is_focused = (focused & bit) != 0;
let color = if is_focused && is_active {
- "#a8c0d8"
+ normal_color
} else if is_focused {
"#666666"
} else if is_active {
@@ -310,7 +335,7 @@ mod tests {
#[test]
fn test_render_tags_json_single_tag() {
- let json = render_tags_json(1, 1, 4);
+ let json = render_tags_json_with_color(1, 1, 4, "#a8c0d8");
// Tag 1 should be active+focused (#a8c0d8), tags 2-4 should be dark (#444444)
assert!(json.contains("#a8c0d8"), "tag 1 should be bright: {}", json);
assert!(
@@ -323,7 +348,7 @@ mod tests {
#[test]
fn test_render_tags_json_no_focus() {
- let json = render_tags_json(1, 0, 4);
+ let json = render_tags_json_with_color(1, 0, 4, "#a8c0d8");
// Tag 1 is active but not focused → #888888
assert!(
json.contains("#888888"),
diff --git a/src/tiling.rs b/src/tiling.rs
index 6a5297b..c66f0c5 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -39,8 +39,9 @@ pub fn tile_cascade(
let height = screen_h - bar_height - gap_top - gap_bottom - (dec_h + bw) - cascade_offset * (n_cascade - 1);
let width = if width < 1 { 1 } else { width };
let height = if height < 1 { 1 } else { height };
- let x = gap_left + bw + idx * cascade_offset;
- let y = bar_height + gap_top + dec_h + idx * cascade_offset;
+ let pos_idx = n_cascade - 1 - idx;
+ let x = gap_left + bw + pos_idx * cascade_offset;
+ let y = bar_height + gap_top + dec_h + pos_idx * cascade_offset;
(x, y, width, height)
}
@@ -149,10 +150,15 @@ mod tests {
#[test]
fn test_tile_cascade_multiple() {
- // 3 cascade windows, idx=2 (the back one)
+ // 3 cascade windows, idx=2 (the back one, highest index, should be up/left)
let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 18, 18, 18, 18, 32, 28, 3, 2);
- assert_eq!(x, 36 + 2 * 32); // gap_left + bw + idx * offset
- assert_eq!(y, 64 + 2 * 32); // bar + gap_top + bw + idx * offset
+ assert_eq!(x, 36); // gap_left + bw
+ assert_eq!(y, 64); // bar + gap_top + bw
+
+ // 3 cascade windows, idx=0 (the focused one, lowest index, should be down/right)
+ let (x, y, _w, _h) = tile_cascade(1920, 1080, 18, 18, 18, 18, 18, 18, 32, 28, 3, 0);
+ assert_eq!(x, 36 + 2 * 32); // gap_left + bw + 2 * offset
+ assert_eq!(y, 64 + 2 * 32); // bar + gap_top + bw + 2 * offset
}
#[test]
diff --git a/src/types.rs b/src/types.rs
index b906622..0d89512 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -33,6 +33,7 @@ pub enum Action {
Spawn,
Close,
FocusNext,
+ FocusPrev,
Move,
Resize,
Exit,
@@ -54,6 +55,7 @@ pub enum Action {
SetTag3,
SetTag4,
Expose,
+ Minimize,
}
/// Layout parameters
@@ -81,6 +83,7 @@ pub struct Layout {
pub background_a: u32,
pub border_font_size: i32,
pub transition_duration: i32,
+ pub grid_gap: i32,
}
impl Default for Layout {
@@ -108,6 +111,7 @@ impl Default for Layout {
background_a: 0xFFFFFFFFu32,
border_font_size: 11,
transition_duration: 300,
+ grid_gap: 18,
}
}
}
@@ -120,6 +124,7 @@ pub struct ModeRule {
pub title_pattern: Option<String>,
pub single_instance: bool,
pub tag: i32,
+ pub circular: bool,
}
/// A pending keyboard binding waiting to be applied to seats
@@ -214,6 +219,7 @@ pub struct Window {
pub fullscreen_requested: bool,
pub maximize_requested: bool,
pub minimize_requested: bool,
+ pub minimized: bool,
pub tiling_mode: TilingMode,
pub mode_locked: bool,
/// Whether we've queued an xprop check for XWayland parent detection.
@@ -227,6 +233,7 @@ pub struct Window {
pub anim_w: Option<f64>,
pub anim_h: Option<f64>,
pub anim_opacity: Option<f64>,
+ pub circular: bool,
}
impl Default for Window {
@@ -255,6 +262,7 @@ impl Default for Window {
fullscreen_requested: false,
maximize_requested: false,
minimize_requested: false,
+ minimized: false,
tiling_mode: TilingMode::Floating,
mode_locked: false,
needs_xprop_check: false,
@@ -264,6 +272,7 @@ impl Default for Window {
anim_w: None,
anim_h: None,
anim_opacity: None,
+ circular: false,
}
}
}
@@ -506,6 +515,8 @@ pub fn parse_action(s: &str) -> Action {
Action::Exit
} else if s == "focus-next" {
Action::FocusNext
+ } else if s == "focus-prev" {
+ Action::FocusPrev
} else if s == "move" {
Action::Move
} else if s == "resize" {
@@ -520,6 +531,8 @@ pub fn parse_action(s: &str) -> Action {
Action::Restart
} else if s == "fullscreen" {
Action::Fullscreen
+ } else if s == "minimize" {
+ Action::Minimize
} else if s.starts_with("spawn")
&& (s.len() == 5 || s.as_bytes()[5] == b' ' || s.as_bytes()[5] == b'-')
{
@@ -659,6 +672,7 @@ mod tests {
assert_eq!(parse_action("close"), Action::Close);
assert_eq!(parse_action("exit"), Action::Exit);
assert_eq!(parse_action("focus-next"), Action::FocusNext);
+ assert_eq!(parse_action("focus-prev"), Action::FocusPrev);
assert_eq!(parse_action("move"), Action::Move);
assert_eq!(parse_action("resize"), Action::Resize);
assert_eq!(parse_action("layout-next"), Action::LayoutNext);
@@ -671,6 +685,7 @@ mod tests {
assert_eq!(parse_action("toggle-2"), Action::Toggle2);
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("unknown"), Action::None);
}
diff --git a/src/wayland.rs b/src/wayland.rs
index 0018c61..67380e4 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -1055,6 +1055,7 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
// and must happen during ManageStart.
crate::wm::render_borders(state);
crate::wm::render_opacity(state);
+ crate::wm::render_circular(state);
// Update and render window title decorations on borders
crate::decorations::update_decorations(state, qhandle);
@@ -1519,13 +1520,39 @@ impl Dispatch<RiverWindowV1, ()> for AppState {
}
river_window_v1::Event::MinimizeRequested { .. } => {
+ let mut minimized_id = None;
if let Some(window) = state.wm.get_window_mut(wid) {
window.minimize_requested = true;
+ window.minimized = true;
+ minimized_id = Some(wid);
eprintln!(
"[window] id={} (app_id={:?}) requested minimize",
wid, window.app_id
);
}
+
+ if let Some(wid) = minimized_id {
+ // Shift focus to the next visible window if the minimized window was focused
+ 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(|w| (w.tags & active_tags) != 0 && !w.closed && !w.minimized && w.id != wid && 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;
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
}
river_window_v1::Event::ShowWindowMenuRequested { x, y } => {
@@ -1588,21 +1615,27 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
wid,
target_app_id
);
+ let already_focused = seat.focused_window_id == Some(wid);
seat.focused_window_id = Some(wid);
// Move clicked window to front of cascade stack
- state.wm.move_window_to_end(wid);
+ let moved = state.wm.move_window_to_end(wid);
// Disable expose if it was active
+ let mut expose_changed = false;
if state.wm.expose_active {
crate::wm::set_expose_active(&mut state.wm, false);
- if let Some(ref wm) = state.window_manager {
- wm.manage_dirty();
- }
+ expose_changed = true;
}
state.wm.needs_render = true;
state.wm.needs_focus = true;
state.wm.needs_status_update = true;
+
+ if !already_focused || moved || expose_changed {
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
+ }
}
}
}
@@ -1613,6 +1646,21 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
if let Some(wid) = state.window_id_for_proxy(&river_window) {
if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
seat.hovered_window_id = Some(wid);
+
+ if state.wm.expose_active {
+ let already_focused = seat.focused_window_id == Some(wid);
+ seat.focused_window_id = Some(wid);
+
+ state.wm.needs_focus = true;
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+
+ if !already_focused {
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
+ }
+ }
}
}
}
@@ -2131,7 +2179,7 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
.windows
.iter()
.filter(|w| {
- (w.tags & state.wm.active_tags) != 0 && !w.closed && w.id != focused_id && w.app_id.as_deref() != Some("clear-status-interface")
+ (w.tags & state.wm.active_tags) != 0 && !w.closed && !w.minimized && w.id != focused_id && w.app_id.as_deref() != Some("clear-status-interface")
})
.map(|w| w.id)
.collect();
@@ -2145,6 +2193,33 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
state.wm.needs_focus = true;
state.wm.needs_status_update = true;
}
+ Action::Minimize => {
+ // Get the focused window ID on this seat
+ let focused_id = state.wm.seats.iter().find(|s| s.id == seat_id).and_then(|s| s.focused_window_id);
+ if let Some(focused_id) = focused_id {
+ if let Some(w) = state.wm.get_window_mut(focused_id) {
+ w.minimized = true;
+ }
+ // Shift focus to the next visible window
+ let active_tags = state.wm.active_tags;
+ let visible_ids: Vec<u64> = state.wm
+ .windows
+ .iter()
+ .filter(|w| (w.tags & active_tags) != 0 && !w.closed && !w.minimized && w.id != focused_id && w.app_id.as_deref() != Some("clear-status-interface"))
+ .map(|w| w.id)
+ .collect();
+ let next_id = visible_ids.last().copied();
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == seat_id) {
+ seat.focused_window_id = next_id;
+ }
+ }
+ 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();
+ }
+ }
Action::FocusNext => {
// Focus the next visible window (wrapping) of the same tiling mode,
// and move it to the front of the cascade stack (end of windows vector).
@@ -2160,6 +2235,7 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
.filter(|w| {
(w.tags & state.wm.active_tags) != 0
&& !w.closed
+ && !w.minimized
&& w.app_id.as_deref() != Some("clear-status-interface")
&& (focused_mode.is_none() || Some(w.tiling_mode) == focused_mode)
})
@@ -2188,6 +2264,50 @@ fn execute_action(state: &mut AppState, seat_id: u64, action: &crate::types::Act
}
}
}
+ Action::FocusPrev => {
+ // Focus the previous visible window (wrapping) of the same tiling mode,
+ // and move it to the front of the cascade stack (end of windows vector).
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
+ let focused_id = seat.focused_window_id;
+ let focused_mode = focused_id.and_then(|fid| {
+ state.wm.windows.iter().find(|w| w.id == fid).map(|w| w.tiling_mode)
+ });
+ let visible_ids: Vec<u64> = state
+ .wm
+ .windows
+ .iter()
+ .filter(|w| {
+ (w.tags & state.wm.active_tags) != 0
+ && !w.closed
+ && !w.minimized
+ && w.app_id.as_deref() != Some("clear-status-interface")
+ && (focused_mode.is_none() || Some(w.tiling_mode) == focused_mode)
+ })
+ .map(|w| w.id)
+ .collect();
+ if let Some(fid) = focused_id {
+ if let Some(idx) = visible_ids.iter().position(|id| *id == fid) {
+ let prev_idx = if idx == 0 { visible_ids.len() - 1 } else { idx - 1 };
+ let prev_id = visible_ids[prev_idx];
+ seat.focused_window_id = Some(prev_id);
+ // Move the newly focused window to the end of the
+ // windows vector so it gets the front cascade position
+ // (rightmost/bottommost) and brightest border.
+ state.wm.move_window_to_end(prev_id);
+ state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
+ }
+ } else if !visible_ids.is_empty() {
+ let prev_id = visible_ids[visible_ids.len() - 1];
+ seat.focused_window_id = Some(prev_id);
+ state.wm.move_window_to_end(prev_id);
+ state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
+ }
+ }
+ }
Action::Move | Action::Resize => {
let op_type = if *action == Action::Move {
PointerOpType::Move
@@ -3373,7 +3493,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
match event {
wl_pointer::Event::Enter { serial, surface, surface_x, surface_y } => {
eprintln!("[pointer] enter surface={:?} x={} y={}", surface, surface_x, surface_y);
- state.pointer_hovered_surface = Some(surface);
+ state.pointer_hovered_surface = Some(surface.clone());
state.last_pointer_surface_x = surface_x;
state.last_pointer_surface_y = surface_y;
@@ -3384,6 +3504,59 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
}
Self::update_cursor_shape_for_surface(state, proxy);
+
+ if state.wm.expose_active {
+ let current_surface = &surface;
+ let matched_window = state.window_proxies.iter().find_map(|(id, proxy)| {
+ if let Some(dec) = &proxy.decoration {
+ if &dec.surface == current_surface {
+ return Some(*id);
+ }
+ }
+ if let Some(dec) = &proxy.dec_left {
+ if &dec.surface == current_surface {
+ return Some(*id);
+ }
+ }
+ if let Some(dec) = &proxy.dec_right {
+ if &dec.surface == current_surface {
+ return Some(*id);
+ }
+ }
+ if let Some(dec) = &proxy.dec_bottom {
+ if &dec.surface == current_surface {
+ return Some(*id);
+ }
+ }
+ None
+ });
+
+ if let Some(wid) = matched_window {
+ let seat_id = state.seat_proxies.iter().find_map(|(sid, sp)| {
+ if sp.wl_pointer.as_ref() == Some(proxy) {
+ Some(*sid)
+ } else {
+ None
+ }
+ });
+
+ if let Some(sid) = seat_id {
+ if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+ let already_focused = seat.focused_window_id == Some(wid);
+ seat.focused_window_id = Some(wid);
+ state.wm.needs_focus = true;
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+
+ if !already_focused {
+ if let Some(ref wm) = state.window_manager {
+ wm.manage_dirty();
+ }
+ }
+ }
+ }
+ }
+ }
}
wl_pointer::Event::Leave { .. } => {
eprintln!("[pointer] leave");
@@ -3486,12 +3659,33 @@ impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
if let Some(sid) = seat_id {
if let Some(seat) = state.wm.seats.iter_mut().find(|s| s.id == sid) {
+ let already_focused = seat.focused_window_id == Some(wid);
seat.focused_window_id = Some(wid);
- state.wm.move_window_to_end(wid);
+ let moved = state.wm.move_window_to_end(wid);
+ let mut expose_changed = false;
if state.wm.expose_active {
crate::wm::set_expose_active(&mut state.wm, false);
+ expose_changed = true;
}
+
+ let mut unminimized = false;
+ if let Some(w) = state.wm.get_window_mut(wid) {
+ if w.minimized {
+ w.minimized = false;
+ w.minimize_requested = false;
+ unminimized = true;
+ }
}
+
+ state.wm.needs_focus = true;
+ 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();
+ }
+ }
+ }
// Determine the specific PointerOpType based on coordinates on the matched surface
let op_type = if let Some(w) = state.wm.windows.iter().find(|win| win.id == wid) {
let border_w = if state.wm.expose_active && w.tiling_mode != crate::types::TilingMode::Popup {
diff --git a/src/wm.rs b/src/wm.rs
index aa28e18..61c7eb9 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -9,7 +9,7 @@ use crate::borders::compute_border_colors;
use crate::protocol::river_window_management::client::river_node_v1::RiverNodeV1;
use crate::protocol::river_window_management::client::river_window_v1::Edges;
use crate::tiling;
-use crate::types::{TilingMode, Window, WindowManager, NUM_TAGS};
+use crate::types::{TilingMode, Window, WindowManager, NUM_TAGS, ModeRule};
use crate::wayland::AppState;
use wayland_client::QueueHandle;
@@ -74,12 +74,68 @@ pub fn get_mode_for_window(wm: &WindowManager, win: &Window) -> Option<TilingMod
Some(wm.global_layout)
}
+fn matches_mode_rule(mode_rules: &[ModeRule], win: &Window) -> 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 {
+ return true;
+ }
+ }
+ false
+}
+
+fn get_circular_for_window(mode_rules: &[ModeRule], win: &Window) -> 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 {
+ return rule.circular;
+ }
+ }
+ false
+}
+
/// 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) {
// Enforce that clear-status-interface is assigned all tags so that it is always visible
// and that blank steam_proton helper windows are always untagged so they remain hidden.
for win in &mut wm.windows {
+ win.circular = get_circular_for_window(&wm.mode_rules, win);
+
if win.app_id.as_deref() == Some("clear-status-interface") {
win.tags = u32::MAX;
}
@@ -94,6 +150,38 @@ pub fn assign_window_modes(wm: &mut WindowManager) {
}
}
+ // Have newly spawned windows adopt the window mode of the focused window, if there is one.
+ let focused_mode = wm.focused_window().map(|w| w.tiling_mode);
+ if let Some(f_mode) = focused_mode {
+ let mode_rules = &wm.mode_rules;
+ for win in &mut wm.windows {
+ if win.is_new
+ && win.app_id.as_deref() != Some("clear-status-interface")
+ && !win.has_parent
+ && !matches_mode_rule(mode_rules, win)
+ {
+ // Determine what normal fallback mode would be
+ let mut normal_fallback = wm.global_layout;
+ for tag_bit in 0..crate::types::NUM_TAGS {
+ let tag_mask = 1u32 << tag_bit;
+ if (win.tags & tag_mask) != 0 && wm.has_tag_layout[tag_bit] {
+ normal_fallback = wm.tag_layouts[tag_bit];
+ break;
+ }
+ }
+
+ if f_mode != normal_fallback {
+ win.mode_locked = true;
+ }
+ win.tiling_mode = f_mode;
+ eprintln!(
+ "[mode] new window {} (app_id={:?}) inherits focus mode {:?}",
+ win.id, win.app_id, f_mode
+ );
+ }
+ }
+ }
+
// Collect assignments first (borrow checker: can't borrow wm mutably while iterating mode_rules)
let assignments: Vec<(u64, TilingMode)> = wm
.windows
@@ -251,10 +339,19 @@ fn compute_tiling(
if wm.expose_active {
let mut results = Vec::new();
// Collect all active non-status-bar, non-popup windows on current tags
- let expose_windows: Vec<&crate::types::Window> = wm.windows.iter()
- .filter(|w| !w.closed && (w.tags & wm.active_tags) != 0 && w.app_id.as_deref() != Some("clear-status-interface") && w.tiling_mode != TilingMode::Popup)
+ let mut expose_windows: Vec<&crate::types::Window> = wm.windows.iter()
+ .filter(|w| !w.closed && !w.minimized && (w.tags & wm.active_tags) != 0 && w.app_id.as_deref() != Some("clear-status-interface") && w.tiling_mode != TilingMode::Popup)
.collect();
+ // Sort expose_windows by current visual location (y first, then x)
+ expose_windows.sort_by(|a, b| {
+ if a.y != b.y {
+ a.y.cmp(&b.y)
+ } else {
+ a.x.cmp(&b.x)
+ }
+ });
+
let n_expose = expose_windows.len() as i32;
if n_expose > 0 {
let cols = (n_expose as f64).sqrt().ceil() as i32;
@@ -338,19 +435,33 @@ fn compute_tiling(
return results;
}
- // Count windows per tiling mode
- let mut n_cascade = 0i32;
- let mut n_grid = 0i32;
- for win in &wm.windows {
- if (win.tags & wm.active_tags) == 0 || win.closed {
- continue;
- }
- match win.tiling_mode {
- TilingMode::Cascade => n_cascade += 1,
- TilingMode::Grid => n_grid += 1,
- _ => {}
- }
- }
+ // Collect and sort grid windows by ID to ensure stable tiling layout positions
+ let mut grid_windows: Vec<&crate::types::Window> = wm
+ .windows
+ .iter()
+ .filter(|w| {
+ (w.tags & wm.active_tags) != 0
+ && !w.closed
+ && !w.minimized
+ && w.tiling_mode == TilingMode::Grid
+ })
+ .collect();
+ grid_windows.sort_by_key(|w| w.id);
+ let n_grid = grid_windows.len() as i32;
+
+ // Collect and reverse cascade windows (focused window gets index 0, next 1, etc.)
+ let mut cascade_windows: Vec<&crate::types::Window> = wm
+ .windows
+ .iter()
+ .filter(|w| {
+ (w.tags & wm.active_tags) != 0
+ && !w.closed
+ && !w.minimized
+ && w.tiling_mode == TilingMode::Cascade
+ })
+ .collect();
+ cascade_windows.reverse();
+ let n_cascade = cascade_windows.len() as i32;
// Check for fullscreen window — prefer the focused window so FocusNext
// cycles visible windows when fullscreen is used as a layout mode.
@@ -361,24 +472,22 @@ fn compute_tiling(
.and_then(|s| s.focused_window_id)
.filter(|&fid| {
wm.get_window(fid).map_or(false, |w| {
- (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen && w.app_id.as_deref() != Some("clear-status-interface")
+ (w.tags & wm.active_tags) != 0 && !w.closed && !w.minimized && w.tiling_mode == TilingMode::Fullscreen && w.app_id.as_deref() != Some("clear-status-interface")
})
})
.or_else(|| {
// Fallback: first fullscreen window if no focused window qualifies
wm.windows.iter().find(|w| {
- (w.tags & wm.active_tags) != 0 && !w.closed && w.tiling_mode == TilingMode::Fullscreen && w.app_id.as_deref() != Some("clear-status-interface")
+ (w.tags & wm.active_tags) != 0 && !w.closed && !w.minimized && w.tiling_mode == TilingMode::Fullscreen && w.app_id.as_deref() != Some("clear-status-interface")
}).map(|w| w.id)
});
// Compute tiling
let mut results = Vec::new();
- let mut idx_cascade = 0i32;
- let mut idx_grid = 0i32;
let mut idx_floating = 0i32;
for win in &wm.windows {
- if (win.tags & wm.active_tags) == 0 || win.closed {
+ if (win.tags & wm.active_tags) == 0 || win.closed || win.minimized {
continue;
}
@@ -409,6 +518,10 @@ fn compute_tiling(
}
}
TilingMode::Cascade => {
+ let idx = cascade_windows
+ .iter()
+ .position(|w| w.id == wid)
+ .unwrap_or(0) as i32;
let (x, y, w, h) = tiling::tile_cascade(
screen_w,
screen_h,
@@ -421,15 +534,28 @@ fn compute_tiling(
cascade_offset,
bar_height,
n_cascade,
- idx_cascade,
+ idx,
);
- idx_cascade += 1;
(x, y, w, h)
}
TilingMode::Grid => {
- let (x, y, w, h) =
- tiling::tile_grid(screen_w, screen_h, gap, gap_top, gap_left, gap_right, gap_bottom, wm.layout.grid_border_width, bar_height, n_grid, idx_grid);
- idx_grid += 1;
+ let idx = grid_windows
+ .iter()
+ .position(|w| w.id == wid)
+ .unwrap_or(0) as i32;
+ let (x, y, w, h) = tiling::tile_grid(
+ screen_w,
+ screen_h,
+ wm.layout.grid_gap,
+ gap_top,
+ gap_left,
+ gap_right,
+ gap_bottom,
+ wm.layout.grid_border_width,
+ bar_height,
+ n_grid,
+ idx,
+ );
(x, y, w, h)
}
@@ -491,6 +617,29 @@ fn compute_tiling(
results.push(TileResult { wid, x, y, w, h });
}
+ // Layout minimized windows as bubbles stacked at the right edge
+ let minimized_windows: Vec<&crate::types::Window> = wm.windows.iter()
+ .filter(|w| !w.closed && w.minimized && (w.tags & wm.active_tags) != 0 && w.app_id.as_deref() != Some("clear-status-interface"))
+ .collect();
+
+ let bubble_width = 160;
+ let border_w = wm.layout.grid_border_width;
+
+ for (_idx, win) in minimized_windows.iter().enumerate() {
+ let wid = win.id;
+ let x = screen_w - wm.layout.gap_right - bubble_width - border_w;
+ let y = screen_h - 2;
+ let w = if win.width > 0 { win.width } else { bubble_width };
+ let h = if win.height > 0 { win.height } else { 100 };
+ results.push(TileResult {
+ wid,
+ x,
+ y,
+ w,
+ h,
+ });
+ }
+
results
}
@@ -526,7 +675,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;
+ let should_animate = (is_cascade || is_exposed || was_animating) && !win.minimized;
if should_animate {
let curr_x = win.anim_x.unwrap_or(win.x as f64);
@@ -754,3 +903,222 @@ 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 {
+ if win.closed {
+ continue;
+ }
+ if let Some(wp) = state.get_window_proxy(win.id) {
+ let val = if win.circular { 1 } else { 0 };
+ wp.river_window.set_circular(val);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::{Window, Seat, WindowManager, TilingMode, ModeRule};
+
+ #[test]
+ fn test_assign_window_modes_inherit_focus_mode_locked() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Cascade;
+ wm.tag_layouts[0] = TilingMode::Cascade;
+
+ // Spawn a focused window that is in Grid mode
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Grid,
+ is_new: false,
+ ..Default::default()
+ });
+ wm.seats.push(Seat {
+ id: 1,
+ focused_window_id: Some(1),
+ ..Default::default()
+ });
+
+ // Spawn a new window
+ wm.windows.push(Window {
+ id: 2,
+ is_new: true,
+ app_id: Some("kitty".to_string()),
+ ..Default::default()
+ });
+
+ assign_window_modes(&mut wm);
+
+ // Window 2 should inherit Grid mode and be locked because Grid != Cascade
+ let win2 = wm.get_window(2).unwrap();
+ assert_eq!(win2.tiling_mode, TilingMode::Grid);
+ assert!(win2.mode_locked);
+ }
+
+ #[test]
+ fn test_assign_window_modes_inherit_focus_mode_not_locked() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Grid;
+ wm.tag_layouts[0] = TilingMode::Grid;
+
+ // Spawn a focused window that is in Grid mode
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Grid,
+ is_new: false,
+ ..Default::default()
+ });
+ wm.seats.push(Seat {
+ id: 1,
+ focused_window_id: Some(1),
+ ..Default::default()
+ });
+
+ // Spawn a new window
+ wm.windows.push(Window {
+ id: 2,
+ is_new: true,
+ app_id: Some("kitty".to_string()),
+ ..Default::default()
+ });
+
+ assign_window_modes(&mut wm);
+
+ // Window 2 should inherit Grid mode but NOT be locked because Grid == Grid (normal fallback)
+ let win2 = wm.get_window(2).unwrap();
+ assert_eq!(win2.tiling_mode, TilingMode::Grid);
+ assert!(!win2.mode_locked);
+ }
+
+ #[test]
+ fn test_assign_window_modes_inherit_focus_has_parent() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Grid;
+ wm.tag_layouts[0] = TilingMode::Grid;
+
+ // Spawn a focused window that is in Grid mode
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Grid,
+ is_new: false,
+ ..Default::default()
+ });
+ wm.seats.push(Seat {
+ id: 1,
+ focused_window_id: Some(1),
+ ..Default::default()
+ });
+
+ // Spawn a new parented window
+ wm.windows.push(Window {
+ id: 2,
+ is_new: true,
+ app_id: Some("kitty".to_string()),
+ has_parent: true,
+ ..Default::default()
+ });
+
+ assign_window_modes(&mut wm);
+
+ // Window 2 should get Floating mode (parent fallback) and not inherit Grid
+ let win2 = wm.get_window(2).unwrap();
+ assert_eq!(win2.tiling_mode, TilingMode::Floating);
+ }
+
+ #[test]
+ fn test_assign_window_modes_inherit_focus_matches_rule() {
+ let mut wm = WindowManager::default();
+ wm.global_layout = TilingMode::Cascade;
+ wm.tag_layouts[0] = TilingMode::Cascade;
+ wm.mode_rules.push(ModeRule {
+ mode: TilingMode::Fullscreen,
+ app_id_pattern: "firefox".to_string(),
+ title_pattern: None,
+ single_instance: false,
+ tag: 0,
+ circular: false,
+ });
+
+ // Spawn a focused window that is in Grid mode
+ wm.windows.push(Window {
+ id: 1,
+ tiling_mode: TilingMode::Grid,
+ is_new: false,
+ ..Default::default()
+ });
+ wm.seats.push(Seat {
+ id: 1,
+ focused_window_id: Some(1),
+ ..Default::default()
+ });
+
+ // Spawn a new firefox window matching the mode rule
+ wm.windows.push(Window {
+ id: 2,
+ is_new: true,
+ app_id: Some("firefox".to_string()),
+ ..Default::default()
+ });
+
+ assign_window_modes(&mut wm);
+
+ // Window 2 should get Fullscreen mode from rule and not inherit Grid
+ let win2 = wm.get_window(2).unwrap();
+ assert_eq!(win2.tiling_mode, TilingMode::Fullscreen);
+ }
+
+ #[test]
+ fn test_expose_mode_sorting() {
+ let mut wm = WindowManager::default();
+ wm.expose_active = true;
+ wm.active_tags = 1;
+
+ // Push windows in unordered spatial positions, representing focus ordering
+ wm.windows.push(Window {
+ id: 10,
+ x: 1000,
+ y: 500,
+ tags: 1,
+ ..Default::default()
+ });
+ wm.windows.push(Window {
+ id: 20,
+ x: 0,
+ y: 500,
+ tags: 1,
+ ..Default::default()
+ });
+ wm.windows.push(Window {
+ id: 30,
+ x: 1000,
+ y: 0,
+ tags: 1,
+ ..Default::default()
+ });
+ wm.windows.push(Window {
+ id: 40,
+ x: 0,
+ y: 0,
+ tags: 1,
+ ..Default::default()
+ });
+
+ // Compute tiling in expose mode
+ let results = compute_tiling(&wm, 1920, 1080, 1920, 1080, 0, 0);
+
+ // Expected sorted order:
+ // 1. (0, 0) -> id 40
+ // 2. (1000, 0) -> id 30
+ // 3. (0, 500) -> id 20
+ // 4. (1000, 500) -> id 10
+ assert_eq!(results.len(), 4);
+ assert_eq!(results[0].wid, 40);
+ assert_eq!(results[1].wid, 30);
+ assert_eq!(results[2].wid, 20);
+ assert_eq!(results[3].wid, 10);
+ }
+}
+