From 8c1184dddad76234081ae07464c96bd2602a611b Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 24 Sep 2026 11:01:19 +0100 Subject: [PATCH] feat: add smart tiling navigation --- compositor/src/input.rs | 45 +++++- compositor/src/state.rs | 89 ++++++++--- crates/ferese-layout/src/lib.rs | 255 ++++++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 22 deletions(-) diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 7233ec7..d7ab14a 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1,10 +1,10 @@ use smithay::{ backend::input::{ AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, - KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, + KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, }, input::{ - keyboard::FilterResult, + keyboard::{FilterResult, keysyms}, pointer::{AxisFrame, ButtonEvent, MotionEvent, PointerHandle, RelativeMotionEvent}, }, reexports::wayland_server::protocol::wl_surface::WlSurface, @@ -13,19 +13,54 @@ use smithay::{ }; use crate::Ferese; +use ferese_layout::Direction; impl Ferese { pub fn process_input_event(&mut self, event: InputEvent) { match event { InputEvent::Keyboard { event, .. } => { let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); + let keycode = event.key_code(); + let state = event.state(); + keyboard.input::<(), _>( self, - event.key_code(), - event.state(), + keycode, + state, SERIAL_COUNTER.next_serial(), Event::time(&event) as u32, - |_, _, _| FilterResult::Forward, + |data, modifiers, keysym| { + if state == KeyState::Released && data.intercepted_keys.remove(&keycode) { + return FilterResult::Intercept(()); + } + + let direction = if modifiers.logo + && !modifiers.ctrl + && !modifiers.alt + && !modifiers.shift + { + match keysym.modified_sym().raw() { + keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left), + keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down), + keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up), + keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right), + _ => None, + } + } else { + None + }; + + if let Some(direction) = direction { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + data.focus_direction(direction); + } + + FilterResult::Intercept(()) + } else { + FilterResult::Forward + } + }, ); } InputEvent::PointerMotionAbsolute { event, .. } => { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index b462e52..6b6b662 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -1,6 +1,12 @@ -use std::{collections::HashMap, error::Error, ffi::OsString, sync::Arc, time::Instant}; +use std::{ + collections::{HashMap, HashSet}, + error::Error, + ffi::OsString, + sync::Arc, + time::Instant, +}; -use ferese_layout::{Axis, LayoutTree, Rect, WindowId}; +use ferese_layout::{Axis, Direction, GapConfig, LayoutTree, Rect, WindowId}; use smithay::{ desktop::{PopupManager, Space, Window, WindowSurfaceType}, @@ -40,6 +46,7 @@ pub struct Ferese { pub layout: LayoutTree, pub window_ids: HashMap, pub focused_window: Option, + pub intercepted_keys: HashSet, next_window_id: u64, pub popups: PopupManager, pub seat: Seat, @@ -94,6 +101,7 @@ impl Ferese { layout: LayoutTree::default(), window_ids: HashMap::new(), focused_window: None, + intercepted_keys: HashSet::new(), next_window_id: 1, popups: PopupManager::default(), seat, @@ -160,10 +168,12 @@ impl Ferese { let id = WindowId(self.next_window_id); self.next_window_id += 1; - if let Err(error) = self - .layout - .insert(id, self.focused_window, Axis::Horizontal, 0.5) - { + let axis = self + .output_bounds() + .and_then(|bounds| self.layout.automatic_axis(self.focused_window, bounds).ok()) + .unwrap_or(Axis::Horizontal); + + if let Err(error) = self.layout.insert(id, self.focused_window, axis, 0.5) { tracing::error!(%error, ?id, "failed to insert window into layout"); return; } @@ -195,19 +205,10 @@ impl Ferese { } pub fn relayout(&mut self) { - let Some(output) = self.space.outputs().next() else { + let Some(bounds) = self.output_bounds() else { return; }; - let Some(output_geometry) = self.space.output_geometry(output) else { - return; - }; - let bounds = Rect::new( - output_geometry.loc.x as f64, - output_geometry.loc.y as f64, - output_geometry.size.w as f64, - output_geometry.size.h as f64, - ); - let geometry = match self.layout.geometry(bounds) { + let geometry = match self.layout.geometry_with_gaps(bounds, GapConfig::default()) { Ok(geometry) => geometry, Err(error) => { tracing::error!(%error, "failed to compute tiled geometry"); @@ -234,6 +235,60 @@ impl Ferese { } } } + + pub fn focus_direction(&mut self, direction: Direction) { + let Some(current) = self.focused_window else { + return; + }; + let Some(bounds) = self.output_bounds() else { + return; + }; + let Ok(Some(next)) = self.layout.directional_neighbor(current, direction, bounds) else { + return; + }; + let Some(window) = self + .window_ids + .iter() + .find_map(|(window, id)| (*id == next).then(|| window.clone())) + else { + return; + }; + let Some(surface) = window + .toplevel() + .map(|toplevel| toplevel.wl_surface().clone()) + else { + return; + }; + + self.focused_window = Some(next); + self.space.raise_element(&window, true); + self.seat + .get_keyboard() + .expect("seat has a keyboard") + .set_focus( + self, + Some(surface), + smithay::utils::SERIAL_COUNTER.next_serial(), + ); + + for window in self.space.elements() { + if let Some(toplevel) = window.toplevel() { + toplevel.send_pending_configure(); + } + } + } + + fn output_bounds(&self) -> Option { + let output = self.space.outputs().next()?; + let geometry = self.space.output_geometry(output)?; + + Some(Rect::new( + geometry.loc.x as f64, + geometry.loc.y as f64, + geometry.size.w as f64, + geometry.size.h as f64, + )) + } } #[derive(Default)] diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index e0bd904..af469b8 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -17,6 +17,14 @@ pub enum Axis { Vertical, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + #[derive(Clone, Debug, PartialEq)] pub enum Node { Window(WindowId), @@ -49,6 +57,27 @@ impl Rect { height, } } + + fn center(self) -> (f64, f64) { + (self.x + self.width / 2.0, self.y + self.height / 2.0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GapConfig { + pub inner: f64, + pub outer: f64, + pub smart: bool, +} + +impl Default for GapConfig { + fn default() -> Self { + Self { + inner: 10.0, + outer: 10.0, + smart: true, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -185,6 +214,134 @@ impl LayoutTree { Ok(geometry) } + pub fn geometry_with_gaps( + &self, + bounds: Rect, + gaps: GapConfig, + ) -> Result, LayoutError> { + let mut geometry = self.geometry(bounds)?; + let outer = if gaps.smart && geometry.len() == 1 { + 0.0 + } else { + valid_gap(gaps.outer) + }; + let half_inner = valid_gap(gaps.inner) / 2.0; + let right = bounds.x + bounds.width; + let bottom = bounds.y + bounds.height; + + for rect in geometry.values_mut() { + let left_inset = if nearly_equal(rect.x, bounds.x) { + outer + } else { + half_inner + }; + let top_inset = if nearly_equal(rect.y, bounds.y) { + outer + } else { + half_inner + }; + let right_inset = if nearly_equal(rect.x + rect.width, right) { + outer + } else { + half_inner + }; + let bottom_inset = if nearly_equal(rect.y + rect.height, bottom) { + outer + } else { + half_inner + }; + + rect.x += left_inset; + rect.y += top_inset; + rect.width = (rect.width - left_inset - right_inset).max(1.0); + rect.height = (rect.height - top_inset - bottom_inset).max(1.0); + } + + Ok(geometry) + } + + pub fn automatic_axis( + &self, + focused: Option, + bounds: Rect, + ) -> Result { + let Some(root) = self.root else { + return Ok(Axis::Horizontal); + }; + let target = match focused { + Some(window) => window, + None => match self.nodes.get(&self.first_window(root)?) { + Some(Node::Window(window)) => *window, + _ => return Err(LayoutError::InvalidTree("leaf is not a window")), + }, + }; + let rect = self + .geometry(bounds)? + .remove(&target) + .ok_or(LayoutError::UnknownWindow(target))?; + + Ok(if rect.width > rect.height { + Axis::Horizontal + } else { + Axis::Vertical + }) + } + + pub fn directional_neighbor( + &self, + window: WindowId, + direction: Direction, + bounds: Rect, + ) -> Result, LayoutError> { + let geometry = self.geometry(bounds)?; + let target = geometry + .get(&window) + .copied() + .ok_or(LayoutError::UnknownWindow(window))?; + let (target_x, target_y) = target.center(); + + Ok(geometry + .into_iter() + .filter(|(candidate, _)| *candidate != window) + .filter_map(|(candidate, rect)| { + let (candidate_x, candidate_y) = rect.center(); + let (primary, secondary, aligned) = match direction { + Direction::Left if candidate_x < target_x => ( + target_x - candidate_x, + (target_y - candidate_y).abs(), + intervals_overlap(target.y, target.height, rect.y, rect.height), + ), + Direction::Right if candidate_x > target_x => ( + candidate_x - target_x, + (target_y - candidate_y).abs(), + intervals_overlap(target.y, target.height, rect.y, rect.height), + ), + Direction::Up if candidate_y < target_y => ( + target_y - candidate_y, + (target_x - candidate_x).abs(), + intervals_overlap(target.x, target.width, rect.x, rect.width), + ), + Direction::Down if candidate_y > target_y => ( + candidate_y - target_y, + (target_x - candidate_x).abs(), + intervals_overlap(target.x, target.width, rect.x, rect.width), + ), + _ => return None, + }; + + Some((candidate, primary, secondary, aligned)) + }) + .min_by(|left, right| { + right + .3 + .cmp(&left.3) + .then_with(|| left.1.total_cmp(&right.1)) + .then_with(|| left.2.total_cmp(&right.2)) + .then_with(|| left.0.0.cmp(&right.0.0)) + }) + .map(|(candidate, _, _, _)| candidate)) + } + pub fn validate(&self) -> Result<(), LayoutError> { let Some(root) = self.root else { return if self.nodes.is_empty() && self.parents.is_empty() && self.windows.is_empty() { @@ -351,6 +508,23 @@ fn normalized_ratio(ratio: f64) -> f64 { } } +fn valid_gap(gap: f64) -> f64 { + if gap.is_finite() { gap.max(0.0) } else { 0.0 } +} + +fn nearly_equal(left: f64, right: f64) -> bool { + (left - right).abs() < f64::EPSILON * left.abs().max(right.abs()).max(1.0) +} + +fn intervals_overlap( + first_start: f64, + first_size: f64, + second_start: f64, + second_size: f64, +) -> bool { + first_start < second_start + second_size && second_start < first_start + first_size +} + fn split_rect(bounds: Rect, axis: Axis, ratio: f64) -> (Rect, Rect) { match axis { Axis::Horizontal => { @@ -456,4 +630,85 @@ mod tests { assert_eq!(normalized_ratio(f64::NAN), 0.5); assert_eq!(normalized_ratio(f64::INFINITY), 0.5); } + + #[test] + fn automatic_axis_follows_focused_tile_shape() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + + let wide = Rect::new(0.0, 0.0, 1_920.0, 1_080.0); + assert_eq!( + tree.automatic_axis(Some(WindowId(1)), wide).unwrap(), + Axis::Horizontal + ); + + tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5) + .unwrap(); + assert_eq!( + tree.automatic_axis(Some(WindowId(1)), wide).unwrap(), + Axis::Vertical + ); + } + + #[test] + fn gaps_preserve_outer_and_shared_spacing() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5) + .unwrap(); + + let geometry = tree + .geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default()) + .unwrap(); + let first = geometry[&WindowId(1)]; + let second = geometry[&WindowId(2)]; + + assert_eq!(first.x, 10.0); + assert_eq!(second.x + second.width, 90.0); + assert_eq!(second.x - (first.x + first.width), 10.0); + } + + #[test] + fn smart_gaps_remove_outer_gap_for_one_window() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + + let bounds = Rect::new(0.0, 0.0, 100.0, 80.0); + let geometry = tree + .geometry_with_gaps(bounds, GapConfig::default()) + .unwrap(); + + assert_eq!(geometry[&WindowId(1)], bounds); + } + + #[test] + fn directional_neighbor_prefers_primary_distance() { + let mut tree = LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5) + .unwrap(); + tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5) + .unwrap(); + tree.insert(WindowId(3), Some(WindowId(2)), Axis::Vertical, 0.5) + .unwrap(); + + let bounds = Rect::new(0.0, 0.0, 100.0, 100.0); + assert_eq!( + tree.directional_neighbor(WindowId(1), Direction::Right, bounds) + .unwrap(), + Some(WindowId(2)) + ); + assert_eq!( + tree.directional_neighbor(WindowId(2), Direction::Down, bounds) + .unwrap(), + Some(WindowId(3)) + ); + assert_eq!( + tree.directional_neighbor(WindowId(1), Direction::Left, bounds) + .unwrap(), + None + ); + } }