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

commit3bcd22e7a818d71f6ce4ff50eed75a654b3ebb69
parent0cf7c541a8
authorLucas Galante <[email protected]>
date2026-05-26 21:19
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;