Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Update system configuration and interface modules
Cargo.lock | 42 +++++++++++-
Cargo.toml | 1 +
src/borders.rs | 22 ++++---
src/clearctl.rs | 1 +
src/config.rs | 8 +++
src/decorations.rs | 189 +++++++++++++++++++++++++++++++++++++++++++----------
src/input.rs | 130 +++++++++++++++++++++++++++++-------
src/ipc.rs | 139 +++++++++++++++++++++++++++++++++++++++
src/status.rs | 12 ++--
src/types.rs | 8 +++
src/wayland.rs | 15 +++++
src/wm.rs | 89 +++++++++++++++++++++++++
12 files changed, 586 insertions(+), 70 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 8216187..2f3cfd6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -41,6 +47,7 @@ name = "clearwm"
version = "0.1.0"
dependencies = [
"bitflags",
+ "fontdue",
"libc",
"nix",
"serde",
@@ -82,6 +89,33 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "fontdue"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
+dependencies = [
+ "hashbrown 0.15.5",
+ "ttf-parser",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -95,7 +129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown",
+ "hashbrown 0.17.1",
]
[[package]]
@@ -417,6 +451,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+[[package]]
+name = "ttf-parser"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
diff --git a/Cargo.toml b/Cargo.toml
index 89449af..413bd84 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,3 +24,4 @@ libc = "0.2"
bitflags = "2"
tokio = { version = "1.35", features = ["full"] }
serde_json = "1.0"
+fontdue = "0.9.3"
diff --git a/src/borders.rs b/src/borders.rs
index 984d138..4f916c5 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -100,7 +100,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
let visible_mode: Vec<usize> = visible
.iter()
.cloned()
- .filter(|&i| state.windows[i].tiling_mode == win.tiling_mode)
+ .filter(|&i| state.expose_active || state.windows[i].tiling_mode == win.tiling_mode)
.collect();
let n_visible_mode = visible_mode.len();
let pos = visible_mode.iter().position(|&i| i == idx).unwrap_or(0);
@@ -115,14 +115,18 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
(r, g, b, ALPHA)
};
- let mut width = 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::Vsplit => state.layout.vsplit_border_width,
- TilingMode::Hsplit => state.layout.hsplit_border_width,
- TilingMode::Floating => state.layout.floating_border_width,
- TilingMode::Popup => state.layout.border_width,
+ let mut width = if state.expose_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::Vsplit => state.layout.vsplit_border_width,
+ TilingMode::Hsplit => state.layout.hsplit_border_width,
+ TilingMode::Floating => state.layout.floating_border_width,
+ TilingMode::Popup => state.layout.border_width,
+ }
};
if win.app_id.as_deref() == Some("clear-status-interface") {
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 3a6d64f..b57b2f1 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -24,6 +24,7 @@ fn usage(name: &str, to_stderr: bool) {
print(" toggle <1-4>");
print(" close");
print(" focus-next");
+ print(" expose");
print(" windows");
print(" exit");
print(" restart");
diff --git a/src/config.rs b/src/config.rs
index 90a1e79..ccf4f5b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -78,6 +78,8 @@ pub struct LayoutConfig {
pub border_color: String,
#[serde(default = "default_background_color", alias = "low_color")]
pub background_color: String,
+ #[serde(default = "default_border_font_size")]
+ pub border_font_size: i64,
}
impl Default for LayoutConfig {
@@ -99,6 +101,7 @@ impl Default for LayoutConfig {
floating_border_width: default_floating_border_width(),
border_color: default_border_color(),
background_color: default_background_color(),
+ border_font_size: default_border_font_size(),
}
}
}
@@ -151,6 +154,9 @@ fn default_border_color() -> String {
fn default_background_color() -> String {
"#0a1a0e".to_string()
}
+fn default_border_font_size() -> i64 {
+ 11
+}
#[derive(Debug, Deserialize, Default)]
pub struct OutputConfig {
@@ -298,6 +304,7 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
state.layout.vsplit_border_width = config.layout.vsplit_border_width as i32;
state.layout.hsplit_border_width = config.layout.hsplit_border_width as i32;
state.layout.floating_border_width = config.layout.floating_border_width as i32;
+ state.layout.border_font_size = config.layout.border_font_size 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;
@@ -632,6 +639,7 @@ mod tests {
assert_eq!(lc.border_width, 6);
assert_eq!(lc.fullscreen_border_width, 0);
assert_eq!(lc.border_color, "#3e3e3e");
+ assert_eq!(lc.border_font_size, 11);
}
#[test]
diff --git a/src/decorations.rs b/src/decorations.rs
index be8932d..6758623 100644
--- a/src/decorations.rs
+++ b/src/decorations.rs
@@ -9,6 +9,27 @@ use wayland_client::QueueHandle;
use crate::protocol::river_window_management::client::river_decoration_v1::RiverDecorationV1;
use crate::wayland::AppState;
+fn resolve_window_border_font_path() -> Option<String> {
+ use std::io::Read;
+ let mut child = std::process::Command::new("fc-match")
+ .args(&["-f", "%{file}", "window-borders"])
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ .ok()?;
+
+ let mut stdout = child.stdout.take()?;
+ let mut output_str = String::new();
+ let _ = stdout.read_to_string(&mut output_str);
+ let _ = child.wait();
+
+ let path = output_str.trim().to_string();
+ if !path.is_empty() && std::path::Path::new(&path).exists() {
+ return Some(path);
+ }
+ None
+}
+
/// Struct tracking the Wayland decoration resources for a window.
pub struct WindowDecoration {
pub surface: wl_surface::WlSurface,
@@ -181,6 +202,20 @@ fn create_memfd(size: usize) -> Option<RawFd> {
/// Main entry point to create or update decoration surfaces during rendering.
pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
+ // Check if we need to load or reload the font
+ let current_path = resolve_window_border_font_path();
+ if current_path != state.border_font_path {
+ state.border_font_path = current_path.clone();
+ state.border_font = current_path
+ .and_then(|path| std::fs::read(&path).ok())
+ .and_then(|data| fontdue::Font::from_bytes(data, fontdue::FontSettings::default()).ok());
+ if state.border_font.is_some() {
+ eprintln!("[decorations] loaded border font: {:?}", state.border_font_path);
+ } else {
+ eprintln!("[decorations] failed to load border font, falling back to built-in bitmap font");
+ }
+ }
+
let active_tags = state.wm.active_tags;
// We can only create decoration surfaces if the compositor and shm globals are bound
@@ -217,19 +252,31 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
let title = w.title.clone().unwrap_or_else(|| {
w.app_id.clone().unwrap_or_else(|| "Window".to_string())
});
- let mode_idx = 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);
- let indicator = match w.tiling_mode {
- crate::types::TilingMode::Floating => "F",
- crate::types::TilingMode::Cascade => "C",
- crate::types::TilingMode::Grid => "G",
- crate::types::TilingMode::Vsplit => "V",
- crate::types::TilingMode::Hsplit => "H",
- crate::types::TilingMode::Fullscreen => "S",
- crate::types::TilingMode::Popup => "P",
+ let mode_idx = if state.wm.expose_active && w.tiling_mode != crate::types::TilingMode::Popup {
+ 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)
+ } else {
+ 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)
+ };
+ let indicator = if state.wm.expose_active && w.tiling_mode != crate::types::TilingMode::Popup {
+ "EX"
+ } else {
+ match w.tiling_mode {
+ crate::types::TilingMode::Floating => "F",
+ crate::types::TilingMode::Cascade => "C",
+ crate::types::TilingMode::Grid => "G",
+ crate::types::TilingMode::Vsplit => "V",
+ crate::types::TilingMode::Hsplit => "H",
+ crate::types::TilingMode::Fullscreen => "S",
+ crate::types::TilingMode::Popup => "P",
+ }
};
let title_with_idx = format!("[{}{}] {}", indicator, mode_idx, title);
@@ -252,14 +299,18 @@ 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 = 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::Vsplit => state.wm.layout.vsplit_border_width,
- crate::types::TilingMode::Hsplit => state.wm.layout.hsplit_border_width,
- crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
- crate::types::TilingMode::Popup => 0,
+ 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::Vsplit => state.wm.layout.vsplit_border_width,
+ crate::types::TilingMode::Hsplit => state.wm.layout.hsplit_border_width,
+ crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
+ crate::types::TilingMode::Popup => 0,
+ }
};
(w.id, w.width, border_w, title_with_idx, bg_color)
})
@@ -378,20 +429,90 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
}
// Draw window title text
- // Determine font scale (1x if height < 32, 2x if height >= 32)
- let scale = if dec_height >= 32 { 2 } else { 1 };
- let font_h = 8 * scale;
-
- // Vertically center the text inside the titlebar
- let text_y = (dec_height - font_h) / 2;
- let mut text_x = 8; // Margin from left
-
- for c in title.chars() {
- if text_x + 8 * scale > dec_width {
- break; // Out of bounds
+ if let Some(ref font) = state.border_font {
+ let font_size = if state.wm.layout.border_font_size > 0 {
+ state.wm.layout.border_font_size as f32
+ } else {
+ if dec_height >= 32 {
+ 22.0
+ } else if dec_height >= 24 {
+ 16.0
+ } else if dec_height >= 16 {
+ 11.0
+ } else {
+ (dec_height as f32 - 4.0).max(8.0)
+ }
+ };
+
+ let line_metrics = font.horizontal_line_metrics(font_size).unwrap_or(fontdue::LineMetrics {
+ ascent: font_size * 0.8,
+ descent: -font_size * 0.2,
+ line_gap: 0.0,
+ new_line_size: font_size,
+ });
+ let baseline_y = (dec_height as f32 + line_metrics.ascent + line_metrics.descent) / 2.0;
+
+ let mut text_x = 8.0f32; // 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 {
+ break;
+ }
+
+ let x_start = (text_x + metrics.xmin as f32).round() as i32;
+ let y_start = (baseline_y - metrics.ymin as f32 - metrics.height as f32).round() as i32;
+
+ for row in 0..metrics.height {
+ for col in 0..metrics.width {
+ let px = x_start + col as i32;
+ let py = y_start + row as i32;
+
+ if px >= 0 && px < dec_width && py >= 0 && py < dec_height {
+ let alpha_coverage = bitmap[row * metrics.width + col] as u32;
+ if alpha_coverage > 0 {
+ let index = (py * dec_width + px) as usize;
+ let dest_pixel = buffer_slice[index];
+
+ let src_r = (text_color >> 16) & 0xFF;
+ let src_g = (text_color >> 8) & 0xFF;
+ let src_b = text_color & 0xFF;
+ let src_a = (text_color >> 24) & 0xFF;
+
+ let dest_r = (dest_pixel >> 16) & 0xFF;
+ let dest_g = (dest_pixel >> 8) & 0xFF;
+ let dest_b = dest_pixel & 0xFF;
+ let dest_a = (dest_pixel >> 24) & 0xFF;
+
+ let alpha = (alpha_coverage * src_a) / 255;
+
+ let out_r = ((src_r * alpha) + (dest_r * (255 - alpha))) / 255;
+ let out_g = ((src_g * alpha) + (dest_g * (255 - alpha))) / 255;
+ let out_b = ((src_b * alpha) + (dest_b * (255 - alpha))) / 255;
+ let out_a = dest_a + ((255 - dest_a) * alpha) / 255;
+
+ buffer_slice[index] = (out_a << 24) | (out_r << 16) | (out_g << 8) | out_b;
+ }
+ }
+ }
+ }
+ text_x += metrics.advance_width;
+ }
+ } else {
+ // Determine font scale (1x if height < 32, 2x if height >= 32)
+ let scale = if dec_height >= 32 { 2 } else { 1 };
+ let font_h = 8 * scale;
+
+ // Vertically center the text inside the titlebar
+ let text_y = (dec_height - font_h) / 2;
+ let mut text_x = 8; // Margin from left
+
+ for c in title.chars() {
+ if text_x + 8 * scale > dec_width {
+ break; // Out of bounds
+ }
+ draw_char(buffer_slice, dec_width, dec_height, c, text_x, text_y, scale, text_color);
+ text_x += 8 * scale;
}
- draw_char(buffer_slice, dec_width, dec_height, c, text_x, text_y, scale, text_color);
- text_x += 8 * scale;
}
// Commit surface rendering
diff --git a/src/input.rs b/src/input.rs
index 7029458..c0e0d11 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -75,6 +75,8 @@ struct SlotState {
active: bool,
x: f32,
y: f32,
+ raw_x: Option<i32>,
+ raw_y: Option<i32>,
}
fn eviocgabs(abs: u32) -> libc::c_ulong {
@@ -188,8 +190,6 @@ fn write_scroll(file: &mut std::fs::File, dwx: i32, dwy: i32) -> std::io::Result
}
struct TouchTracker {
- last_x: Option<i32>,
- last_y: Option<i32>,
dx: i32,
dy: i32,
finger_down: bool,
@@ -231,13 +231,11 @@ async fn read_device_loop(
let range_x = (max_x - min_x).max(1) as f32;
let range_y = (max_y - min_y).max(1) as f32;
- let mut slots = [SlotState::default(); 5];
+ let mut slots = [SlotState::default(); 16];
let mut current_slot = 0usize;
let mut buf = [0u8; 24];
let mut touch = TouchTracker {
- last_x: None,
- last_y: None,
dx: 0,
dy: 0,
finger_down: false,
@@ -261,30 +259,40 @@ async fn read_device_loop(
} else if event.type_ == EV_ABS {
if event.code == ABS_X || event.code == ABS_MT_POSITION_X {
let val = event.value;
- if let Some(lx) = touch.last_x {
- touch.dx += val - lx;
+ let slot_idx = if event.code == ABS_X { 0 } else { current_slot };
+ let is_primary = slots.iter().position(|s| s.active) == Some(slot_idx);
+ let slot = &mut slots[slot_idx];
+ if is_primary {
+ if let Some(lx) = slot.raw_x {
+ touch.dx += val - lx;
+ }
}
- touch.last_x = Some(val);
- slots[current_slot].x = (val - min_x) as f32 / range_x;
+ slot.raw_x = Some(val);
+ slot.x = (val - min_x) as f32 / range_x;
} else if event.code == ABS_Y || event.code == ABS_MT_POSITION_Y {
let val = event.value;
- if let Some(ly) = touch.last_y {
- touch.dy += val - ly;
+ let slot_idx = if event.code == ABS_Y { 0 } else { current_slot };
+ let is_primary = slots.iter().position(|s| s.active) == Some(slot_idx);
+ let slot = &mut slots[slot_idx];
+ if is_primary {
+ if let Some(ly) = slot.raw_y {
+ touch.dy += val - ly;
+ }
}
- touch.last_y = Some(val);
- slots[current_slot].y = (val - min_y) as f32 / range_y;
+ slot.raw_y = Some(val);
+ slot.y = (val - min_y) as f32 / range_y;
} else if event.code == ABS_MT_TRACKING_ID {
if event.value >= 0 {
- touch.finger_down = true;
slots[current_slot].active = true;
+ touch.finger_down = true;
} else {
- touch.finger_down = false;
- touch.last_x = None;
- touch.last_y = None;
slots[current_slot].active = false;
+ slots[current_slot].raw_x = None;
+ slots[current_slot].raw_y = None;
+ touch.finger_down = slots.iter().any(|s| s.active);
}
} else if event.code == ABS_MT_SLOT {
- current_slot = (event.value as usize).min(4);
+ current_slot = (event.value as usize).min(15);
}
} else if event.type_ == EV_KEY {
if event.code == BTN_TOUCH {
@@ -293,10 +301,10 @@ async fn read_device_loop(
slots[0].active = true;
} else {
touch.finger_down = false;
- touch.last_x = None;
- touch.last_y = None;
for s in &mut slots {
s.active = false;
+ s.raw_x = None;
+ s.raw_y = None;
}
}
}
@@ -322,8 +330,10 @@ async fn read_device_loop(
let _ = tx.send(CoordinatorMsg::PhysicalTrackpadLift { timestamp: now });
touch.dx = 0;
touch.dy = 0;
- touch.last_x = None;
- touch.last_y = None;
+ for s in &mut slots {
+ s.raw_x = None;
+ s.raw_y = None;
+ }
}
}
}
@@ -363,6 +373,9 @@ struct PhysicsState {
tap_to_click: bool,
trackpad_disabled_by_scroll: bool,
+ three_finger_start_x: Option<f32>,
+ three_finger_start_y: Option<f32>,
+ three_finger_gesture_triggered: bool,
}
fn trigger_tap_to_click_ipc(tap: bool, ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
@@ -381,6 +394,38 @@ fn trigger_trackpad_disabled_ipc(disabled: bool, ipc_tx: &std::sync::mpsc::Sende
}
}
+fn trigger_expose_ipc(ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
+ let cmd = "expose".to_string();
+ let _ = ipc_tx.send(cmd);
+ unsafe {
+ libc::write(pipe_write, &1u8 as *const u8 as *const libc::c_void, 1);
+ }
+}
+
+fn trigger_expose_exit_ipc(ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
+ let cmd = "expose-exit".to_string();
+ let _ = ipc_tx.send(cmd);
+ unsafe {
+ libc::write(pipe_write, &1u8 as *const u8 as *const libc::c_void, 1);
+ }
+}
+
+fn trigger_view_next_ipc(ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
+ let cmd = "view-next".to_string();
+ let _ = ipc_tx.send(cmd);
+ unsafe {
+ libc::write(pipe_write, &1u8 as *const u8 as *const libc::c_void, 1);
+ }
+}
+
+fn trigger_view_prev_ipc(ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
+ let cmd = "view-prev".to_string();
+ let _ = ipc_tx.send(cmd);
+ unsafe {
+ libc::write(pipe_write, &1u8 as *const u8 as *const libc::c_void, 1);
+ }
+}
+
pub fn run_input_daemon(
mut event_queue_rx: tokio::sync::mpsc::UnboundedReceiver<(InertialConfig, bool)>,
ipc_tx: std::sync::mpsc::Sender<String>,
@@ -503,6 +548,9 @@ pub fn run_input_daemon(
accum_scroll_y: 0.0,
tap_to_click,
trackpad_disabled_by_scroll: false,
+ three_finger_start_x: None,
+ three_finger_start_y: None,
+ three_finger_gesture_triggered: false,
};
let mut tick_interval = tokio::time::interval(Duration::from_millis(16)); // ~60fps
@@ -610,6 +658,44 @@ pub fn run_input_daemon(
state.trackpad_disabled_by_scroll = false;
trigger_trackpad_disabled_ipc(false, &ipc_tx, pipe_write);
}
+
+ // Detect 3-finger gestures (swipe up, down, left, right)
+ if fingers.len() == 3 {
+ let avg_x = (fingers[0].x + fingers[1].x + fingers[2].x) / 3.0;
+ let avg_y = (fingers[0].y + fingers[1].y + fingers[2].y) / 3.0;
+ if let (Some(start_x), Some(start_y)) = (state.three_finger_start_x, state.three_finger_start_y) {
+ let dy_up = start_y - avg_y; // Y decreases as fingers move up
+ let dy_down = avg_y - start_y; // Y increases as fingers move down
+ let dx_right = avg_x - start_x; // X increases as fingers move right
+ let dx_left = start_x - avg_x; // X decreases as fingers move left
+
+ if dy_up > 0.15 && !state.three_finger_gesture_triggered {
+ state.three_finger_gesture_triggered = true;
+ println!("[input-subsystem] 3-finger swipe up gesture detected. Triggering Expose mode.");
+ trigger_expose_ipc(&ipc_tx, pipe_write);
+ } else if dy_down > 0.15 && !state.three_finger_gesture_triggered {
+ state.three_finger_gesture_triggered = true;
+ println!("[input-subsystem] 3-finger swipe down gesture detected. Triggering Expose exit.");
+ trigger_expose_exit_ipc(&ipc_tx, pipe_write);
+ } else if dx_right > 0.15 && !state.three_finger_gesture_triggered {
+ state.three_finger_gesture_triggered = true;
+ println!("[input-subsystem] 3-finger swipe right gesture detected. Switching to previous tag.");
+ trigger_view_prev_ipc(&ipc_tx, pipe_write);
+ } else if dx_left > 0.15 && !state.three_finger_gesture_triggered {
+ state.three_finger_gesture_triggered = true;
+ println!("[input-subsystem] 3-finger swipe left gesture detected. Switching to next tag.");
+ trigger_view_next_ipc(&ipc_tx, pipe_write);
+ }
+ } else {
+ state.three_finger_start_x = Some(avg_x);
+ state.three_finger_start_y = Some(avg_y);
+ }
+ } else {
+ state.three_finger_start_x = None;
+ state.three_finger_start_y = None;
+ state.three_finger_gesture_triggered = false;
+ }
+
if let Ok(serialized) = serde_json::to_string(&fingers) {
let _ = broadcast_tx.send(serialized);
}
diff --git a/src/ipc.rs b/src/ipc.rs
index 1e3560e..5d4d681 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -75,6 +75,70 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
"reload" => {
crate::restart::wm_restart();
}
+ "expose" => {
+ state.expose_active = !state.expose_active;
+ state.needs_render = true;
+ state.needs_status_update = true;
+ }
+ "expose-exit" => {
+ if state.expose_active {
+ state.expose_active = false;
+ let mut windows_to_focus = Vec::new();
+ for seat in &state.seats {
+ if seat.removed {
+ continue;
+ }
+ if let Some(wid) = seat.hovered_window_id {
+ windows_to_focus.push((seat.id, wid));
+ }
+ }
+ for (seat_id, wid) in windows_to_focus {
+ if let Some(seat) = state.seats.iter_mut().find(|s| s.id == seat_id) {
+ 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;
+ }
+ }
+ "view-next" => {
+ let current_tag_idx = (0..NUM_TAGS).find(|&i| (state.active_tags & (1 << i)) != 0).unwrap_or(0);
+ let next_tag_idx = (current_tag_idx + 1) % NUM_TAGS;
+ state.active_tags = 1 << next_tag_idx;
+
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+ .map(|w| w.id)
+ .collect();
+ seat.focused_window_id = visible_ids.last().copied();
+ }
+ state.needs_render = true;
+ state.needs_focus = true;
+ state.needs_status_update = true;
+ }
+ "view-prev" => {
+ let current_tag_idx = (0..NUM_TAGS).find(|&i| (state.active_tags & (1 << i)) != 0).unwrap_or(0);
+ let prev_tag_idx = (current_tag_idx + NUM_TAGS - 1) % NUM_TAGS;
+ state.active_tags = 1 << prev_tag_idx;
+
+ if let Some(seat) = state.seats.iter_mut().find(|s| !s.removed) {
+ let visible_ids: Vec<u64> = state
+ .windows
+ .iter()
+ .filter(|w| (w.tags & state.active_tags) != 0 && !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+ .map(|w| w.id)
+ .collect();
+ seat.focused_window_id = visible_ids.last().copied();
+ }
+ state.needs_render = true;
+ state.needs_focus = true;
+ state.needs_status_update = true;
+ }
"view" | _ if tok.starts_with("view") => {
let tag = parse_tag_from_command(tok, "view", rest);
if let Some(tag) = tag {
@@ -272,6 +336,14 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
}
}
}
+ "border_font_size" => {
+ if let Ok(value) = value_str.parse::<i32>() {
+ state.layout.border_font_size = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Border font size set to {}px", value));
+ }
+ }
+ }
"fullscreen_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.fullscreen_border_width = value;
@@ -879,4 +951,71 @@ mod tests {
handle_ipc_command("layout border_width 18", &mut state);
assert_eq!(state.layout.border_width, 18);
}
+
+ #[test]
+ fn test_ipc_expose() {
+ let mut state = WindowManager::default();
+ assert!(!state.expose_active);
+ handle_ipc_command("expose", &mut state);
+ assert!(state.expose_active);
+ handle_ipc_command("expose", &mut state);
+ assert!(!state.expose_active);
+ }
+
+ #[test]
+ fn test_ipc_expose_exit() {
+ let mut state = WindowManager::default();
+ state.seats.push(crate::types::Seat {
+ id: 1,
+ hovered_window_id: Some(42),
+ focused_window_id: Some(10),
+ ..Default::default()
+ });
+
+ // 1. When expose is not active, "expose-exit" should do nothing.
+ assert!(!state.expose_active);
+ handle_ipc_command("expose-exit", &mut state);
+ assert!(!state.expose_active);
+ assert_eq!(state.seats[0].focused_window_id, Some(10));
+
+ // 2. When expose is active, "expose-exit" should deactivate it and focus the hovered window.
+ state.expose_active = true;
+ handle_ipc_command("expose-exit", &mut state);
+ assert!(!state.expose_active);
+ assert_eq!(state.seats[0].focused_window_id, Some(42));
+ assert!(state.needs_focus);
+ assert!(state.needs_render);
+ }
+
+ #[test]
+ fn test_ipc_view_next_prev() {
+ let mut state = WindowManager::default();
+ state.active_tags = 1; // Tag 1 (1 << 0)
+
+ // view-next should go: Tag 1 -> Tag 2 -> Tag 3 -> Tag 4 -> Tag 1
+ handle_ipc_command("view-next", &mut state);
+ assert_eq!(state.active_tags, 2); // Tag 2
+
+ handle_ipc_command("view-next", &mut state);
+ assert_eq!(state.active_tags, 4); // Tag 3
+
+ handle_ipc_command("view-next", &mut state);
+ assert_eq!(state.active_tags, 8); // Tag 4
+
+ handle_ipc_command("view-next", &mut state);
+ assert_eq!(state.active_tags, 1); // Tag 1 (wrap around)
+
+ // view-prev should go: Tag 1 -> Tag 4 -> Tag 3 -> Tag 2 -> Tag 1
+ handle_ipc_command("view-prev", &mut state);
+ assert_eq!(state.active_tags, 8); // Tag 4 (wrap around)
+
+ handle_ipc_command("view-prev", &mut state);
+ assert_eq!(state.active_tags, 4); // Tag 3
+
+ handle_ipc_command("view-prev", &mut state);
+ assert_eq!(state.active_tags, 2); // Tag 2
+
+ handle_ipc_command("view-prev", &mut state);
+ assert_eq!(state.active_tags, 1); // Tag 1
+ }
}
diff --git a/src/status.rs b/src/status.rs
index 467d27b..49e3d9b 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -21,10 +21,14 @@ pub fn write_status_files(state: &WindowManager) {
}
// /tmp/clearwm-layout: focused window's tiling mode
- let mode_str = state
- .focused_window()
- .map(|w| tiling_mode_str(w.tiling_mode))
- .unwrap_or("none");
+ let mode_str = if state.expose_active {
+ "Expose"
+ } else {
+ state
+ .focused_window()
+ .map(|w| tiling_mode_str(w.tiling_mode))
+ .unwrap_or("none")
+ };
if let Ok(mut f) = fs::File::create("/tmp/clearwm-layout") {
let _ = writeln!(f, "{}", mode_str);
diff --git a/src/types.rs b/src/types.rs
index a9c5211..bb3fe78 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -57,6 +57,7 @@ pub enum Action {
SetTag2,
SetTag3,
SetTag4,
+ Expose,
}
/// Layout parameters
@@ -84,6 +85,7 @@ pub struct Layout {
pub background_g: u32,
pub background_b: u32,
pub background_a: u32,
+ pub border_font_size: i32,
}
impl Default for Layout {
@@ -111,6 +113,7 @@ impl Default for Layout {
background_g: 0x1A1A1A1Au32,
background_b: 0x0E0E0E0Eu32,
background_a: 0xFFFFFFFFu32,
+ border_font_size: 11,
}
}
}
@@ -343,6 +346,7 @@ pub struct WindowManager {
pub reload_commands: Vec<String>,
pub input_controller: Option<tokio::sync::mpsc::UnboundedSender<(crate::config::InertialConfig, bool)>>,
pub trackpad_disabled: bool,
+ pub expose_active: bool,
}
impl Default for WindowManager {
@@ -387,6 +391,7 @@ impl Default for WindowManager {
reload_commands: Vec::new(),
input_controller: None,
trackpad_disabled: false,
+ expose_active: false,
}
}
}
@@ -561,6 +566,8 @@ pub fn parse_action(s: &str) -> Action {
}
}
Action::None
+ } else if s == "expose" {
+ Action::Expose
} else {
Action::None
}
@@ -651,6 +658,7 @@ mod tests {
assert_eq!(parse_action("view-4"), Action::View4);
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("unknown"), Action::None);
}
diff --git a/src/wayland.rs b/src/wayland.rs
index 6e4085b..e427dad 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -153,6 +153,8 @@ pub struct AppState {
/// The surface the pointer is currently hovering over
pub pointer_hovered_surface: Option<wl_surface::WlSurface>,
pub input_device_names: std::collections::HashMap<u32, String>,
+ pub border_font: Option<fontdue::Font>,
+ pub border_font_path: Option<String>,
}
/// Info tracked for each libinput device discovered via river_libinput_config_v1
@@ -214,6 +216,8 @@ impl AppState {
libinput_devices: Vec::new(),
pointer_hovered_surface: None,
input_device_names: std::collections::HashMap::new(),
+ border_font: None,
+ border_font_path: None,
}
}
@@ -1292,6 +1296,12 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
seat.focused_window_id = Some(wid);
// Move clicked window to front of cascade stack
state.wm.move_window_to_end(wid);
+
+ // Disable expose if it was active
+ if state.wm.expose_active {
+ state.wm.expose_active = false;
+ }
+
state.wm.needs_render = true;
state.wm.needs_focus = true;
state.wm.needs_status_update = true;
@@ -1990,6 +2000,11 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
Action::Restart => {
crate::restart::wm_restart();
}
+ Action::Expose => {
+ state.wm.expose_active = !state.wm.expose_active;
+ state.wm.needs_render = true;
+ state.wm.needs_status_update = true;
+ }
Action::View1 | Action::View2 | Action::View3 | Action::View4 => {
let tag = match action {
Action::View1 => 1,
diff --git a/src/wm.rs b/src/wm.rs
index 6a3218f..917dabd 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -238,6 +238,95 @@ fn compute_tiling(
let cascade_offset = wm.layout.cascade_offset;
let bar_height = wm.layout.bar_height;
+ 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)
+ .collect();
+
+ let n_expose = expose_windows.len() as i32;
+ if n_expose > 0 {
+ let cols = (n_expose as f64).sqrt().ceil() as i32;
+ let rows = (n_expose + cols - 1) / cols;
+ let bw = wm.layout.grid_border_width;
+
+ for (idx, win) in expose_windows.iter().enumerate() {
+ let idx = idx as i32;
+ let row = idx / cols;
+ let col = idx % cols;
+
+ let width = (screen_w - gap_left - gap_right - (cols - 1) * gap) / cols - 2 * bw;
+ let height = (screen_h - bar_height - gap_top - gap_bottom - (rows - 1) * gap) / rows - 2 * bw;
+ let width = if width < 1 { 1 } else { width };
+ let height = if height < 1 { 1 } else { height };
+
+ let x = gap_left + bw + col * (width + 2 * bw + gap);
+ let y = bar_height + gap_top + bw + row * (height + 2 * bw + gap);
+
+ results.push(TileResult {
+ wid: win.id,
+ x,
+ y,
+ w: width,
+ h: height,
+ });
+ }
+ }
+
+ // Still layout clear-status-interface as fullscreen/bar if present
+ if let Some(win) = wm.windows.iter().find(|w| !w.closed && w.app_id.as_deref() == Some("clear-status-interface")) {
+ let (tx, ty, tw, th) = tiling::tile_fullscreen(
+ phys_w,
+ phys_h,
+ gap_top,
+ gap_left,
+ gap_right,
+ gap_bottom,
+ 0,
+ bar_height,
+ );
+ results.push(TileResult {
+ wid: win.id,
+ x: tx + phys_x,
+ y: ty + phys_y,
+ w: tw,
+ h: th,
+ });
+ }
+
+ // Layout any active popup windows normally
+ for win in &wm.windows {
+ if !win.closed && (win.tags & wm.active_tags) != 0 && win.tiling_mode == TilingMode::Popup {
+ let fw = if win.width > 0 {
+ win.width
+ } else if win.hint_min_width > 32 {
+ win.hint_min_width
+ } else {
+ 360
+ };
+ let fh = if win.height > 0 {
+ win.height
+ } else if win.hint_min_height > 32 {
+ win.hint_min_height
+ } else {
+ 100
+ };
+ let fx = screen_w - fw - gap_right;
+ let fy = bar_height + gap_top;
+ results.push(TileResult {
+ wid: win.id,
+ x: fx,
+ y: fy,
+ w: fw,
+ h: fh,
+ });
+ }
+ }
+
+ return results;
+ }
+
// Count windows per tiling mode
let mut n_cascade = 0i32;
let mut n_grid = 0i32;