From 442b950bcd070cb44ab8d1da7f3e311529619b39 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 28 Sep 2026 10:36:19 +0100 Subject: [PATCH] Separate pointer focus from scrolling window reveal --- compositor/src/input.rs | 23 +++-- crates/ferese-core/src/workspace.rs | 86 +++++++++++++++++- crates/ferese-layout/src/scrolling.rs | 125 ++++++++++++++++++++------ docs/configuration.md | 2 +- shell/ferese-settings/src/schema.rs | 2 +- 5 files changed, 201 insertions(+), 37 deletions(-) diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 599307a..b64c651 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -543,11 +543,16 @@ impl Ferese { if let Some(window) = self.window_under_visual(position) { let focused = self.window_ids.get(&window).copied(); - if let Some(focused) = focused - && let Err(error) = self.workspaces.focus_window(focused) - { - tracing::error!(%error, ?focused, "failed to update workspace focus"); - return; + if let Some(focused) = focused { + let result = if raise { + self.workspaces.focus_window(focused) + } else { + self.workspaces.focus_window_without_reveal(focused) + }; + if let Err(error) = result { + tracing::error!(%error, ?focused, "failed to update workspace focus"); + return; + } } self.focused_window = focused; if raise { @@ -576,7 +581,13 @@ impl Ferese { } }); - self.relayout(); + if raise { + self.relayout(); + } else { + // Focus, dimming and the bar title update without moving the layout. + self.send_shell_snapshots(); + crate::backends::direct::render_all(self); + } } fn focus_window_under_pointer( diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index d9968f2..25de53c 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -119,7 +119,14 @@ impl WorkspaceLayout { pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> { match self { - Self::Scrolling(layout) => layout.focus(window), + Self::Scrolling(layout) => layout.focus_and_reveal(window), + Self::Tree(layout) => layout.activate_window(window), + } + } + + pub fn activate_window_without_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> { + match self { + Self::Scrolling(layout) => layout.focus_without_reveal(window), Self::Tree(layout) => layout.activate_window(window), } } @@ -607,6 +614,18 @@ impl WorkspaceSet { } pub fn focus_window(&mut self, window: WindowId) -> Result<(), WorkspaceError> { + self.focus_window_with_reveal(window, true) + } + + pub fn focus_window_without_reveal(&mut self, window: WindowId) -> Result<(), WorkspaceError> { + self.focus_window_with_reveal(window, false) + } + + fn focus_window_with_reveal( + &mut self, + window: WindowId, + reveal: bool, + ) -> Result<(), WorkspaceError> { if self.workspace_for_window(window) != Some(self.active) { return Err(WorkspaceError::InvalidState( "focused window is not on the active workspace", @@ -623,7 +642,13 @@ impl WorkspaceSet { } if self.placement(window) == Some(WindowPlacement::Tiled) { - self.active_mut().layout.activate_window(window)?; + if reveal { + self.active_mut().layout.activate_window(window)?; + } else { + self.active_mut() + .layout + .activate_window_without_reveal(window)?; + } } self.active_mut().last_focused = Some(window); @@ -1418,6 +1443,63 @@ mod tests { assert!(workspaces.validate().is_ok()); } + #[test] + fn hover_focus_preserves_geometry_and_click_reveals_the_hovered_window() { + for strategy in [ + ViewportFocusStrategy::Minimal, + ViewportFocusStrategy::Center, + ViewportFocusStrategy::Paged, + ] { + let mut workspaces = WorkspaceSet::new( + LayoutMode::Scrolling, + ColumnWidth::Proportion(0.5), + strategy, + ); + for id in 1..=3 { + workspaces + .insert_window(WindowId(id), Axis::Horizontal, 0.5) + .unwrap(); + } + let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + workspaces.focus_window(WindowId(2)).unwrap(); + let before = workspaces + .active_mut() + .layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(2))) + .unwrap(); + let scroll = workspaces.active().layout.viewport_x(); + // Rapid right/middle/left hover must never scroll or skip a focus target, + // even if a later client commit causes another layout calculation. + for id in [3, 2, 1, 2, 3] { + let id = WindowId(id); + workspaces.focus_window_without_reveal(id).unwrap(); + assert_eq!(workspaces.active().last_focused, Some(id)); + let after = workspaces + .active_mut() + .layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(id)) + .unwrap(); + assert_eq!(before.geometry, after.geometry, "{strategy:?}"); + assert_eq!(scroll, workspaces.active().layout.viewport_x()); + } + // Clicking the already-hovered window still requests a reveal. + workspaces.focus_window(WindowId(3)).unwrap(); + let clicked = workspaces + .active_mut() + .layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(3))) + .unwrap(); + let rect = clicked.geometry[&WindowId(3)]; + assert!(rect.x >= 0.0 && rect.x + rect.width <= bounds.width); + assert_ne!(scroll, workspaces.active().layout.viewport_x()); + } + } + #[test] fn opening_a_focused_float_preserves_tiled_geometry_and_scroll() { let mut workspaces = WorkspaceSet::new( diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 92c1292..423df48 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -78,7 +78,7 @@ pub struct ScrollingLayout { neighbor_context: f64, default_width: ColumnWidth, focus_strategy: ViewportFocusStrategy, - reveal_pending: bool, + reveal_pending: Option, width_cycle_pending: bool, last_viewport_width: Option, } @@ -92,7 +92,7 @@ impl Default for ScrollingLayout { neighbor_context: 48.0, default_width: ColumnWidth::default(), focus_strategy: ViewportFocusStrategy::Minimal, - reveal_pending: false, + reveal_pending: None, width_cycle_pending: false, last_viewport_width: None, } @@ -113,7 +113,7 @@ impl ScrollingLayout { pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) { self.focus_strategy = strategy; - self.reveal_pending = true; + self.reveal_pending = self.active_window(); } pub fn default_width(&self) -> ColumnWidth { @@ -163,7 +163,7 @@ impl ScrollingLayout { self.columns .insert(index, Column::new(window, self.default_width)); self.active_column = Some(index); - self.reveal_pending = self.columns.len() > 1; + self.reveal_pending = (self.columns.len() > 1).then_some(window); debug_assert!(self.validate().is_ok()); Ok(()) @@ -191,9 +191,12 @@ impl ScrollingLayout { column.active = column.active.min(column.windows.len() - 1); column.normalize_heights(); } - self.reveal_pending |= (removed_active_column - || self.focus_strategy == ViewportFocusStrategy::Paged) - && !self.columns.is_empty(); + if self.reveal_pending == Some(window) + || removed_active_column + || self.focus_strategy == ViewportFocusStrategy::Paged + { + self.reveal_pending = self.active_window(); + } debug_assert!(self.validate().is_ok()); Ok(()) @@ -206,7 +209,27 @@ impl ScrollingLayout { let changed_column = self.active_column != Some(column); self.active_column = Some(column); self.columns[column].active = index; - self.reveal_pending |= changed_column; + if changed_column { + self.reveal_pending = Some(window); + } + Ok(()) + } + + /// Hover changes the active window without requesting viewport movement. + /// Keep any earlier explicit reveal attached to its original window. + pub fn focus_without_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> { + let (column, index) = self + .window_location(window) + .ok_or(LayoutError::UnknownWindow(window))?; + self.active_column = Some(column); + self.columns[column].active = index; + Ok(()) + } + + /// An explicit selection must reveal even a window already focused by hover. + pub fn focus_and_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> { + self.focus_without_reveal(window)?; + self.reveal_pending = Some(window); Ok(()) } @@ -231,7 +254,7 @@ impl ScrollingLayout { column.active = insertion; column.normalize_heights(); self.active_column = Some(target_column); - self.reveal_pending = true; + self.reveal_pending = self.active_window(); debug_assert!(self.validate().is_ok()); Ok(()) @@ -251,7 +274,7 @@ impl ScrollingLayout { self.columns .insert(insertion, Column::new(window, self.default_width)); self.active_column = Some(insertion); - self.reveal_pending = true; + self.reveal_pending = self.active_window(); debug_assert!(self.validate().is_ok()); Ok(()) @@ -266,7 +289,7 @@ impl ScrollingLayout { .window_location(window) .ok_or(LayoutError::UnknownWindow(window))?; self.columns[column].width = normalized_width(width); - self.reveal_pending = true; + self.reveal_pending = self.active_window(); Ok(()) } @@ -480,12 +503,16 @@ impl ScrollingLayout { if self.width_cycle_pending { self.retarget_after_width_cycle(&column_positions, viewport_width); self.width_cycle_pending = false; - self.reveal_pending = false; - } else if self.reveal_pending - || (viewport_resized && self.focus_strategy == ViewportFocusStrategy::Paged) - { - self.reveal_active_column(&column_positions, viewport_width); - self.reveal_pending = false; + self.reveal_pending = None; + } else { + let reveal = self.reveal_pending.take().or_else(|| { + (viewport_resized && self.focus_strategy == ViewportFocusStrategy::Paged) + .then(|| self.active_window()) + .flatten() + }); + if let Some((column, _)) = reveal.and_then(|window| self.window_location(window)) { + self.reveal_column(&column_positions, viewport_width, column); + } } let mut result = LayoutResult::default(); @@ -518,14 +545,11 @@ impl ScrollingLayout { Ok(result) } - fn reveal_active_column(&mut self, positions: &[(f64, f64)], viewport_width: f64) { - if let Some(start) = self.paged_viewport(positions, viewport_width) { + fn reveal_column(&mut self, positions: &[(f64, f64)], viewport_width: f64, active: usize) { + if let Some(start) = self.paged_viewport(positions, viewport_width, Some(active)) { self.viewport_x = start; return; } - let Some(active) = self.active_column else { - return; - }; let (start, width) = positions[active]; let end = start + width; if self.focus_strategy == ViewportFocusStrategy::Center { @@ -555,11 +579,16 @@ impl ScrollingLayout { } } - fn paged_viewport(&self, positions: &[(f64, f64)], viewport_width: f64) -> Option { + fn paged_viewport( + &self, + positions: &[(f64, f64)], + viewport_width: f64, + active: Option, + ) -> Option { if self.focus_strategy != ViewportFocusStrategy::Paged { return None; } - let active = self.active_column?; + let active = active?; // Pack actual allocated widths, including gaps and client constraints. // Page boundaries are independent of focus direction. An oversized // column occupies its own page rather than overlapping its neighbors. @@ -582,7 +611,7 @@ impl ScrollingLayout { } fn retarget_after_width_cycle(&mut self, positions: &[(f64, f64)], viewport_width: f64) { - if let Some(start) = self.paged_viewport(positions, viewport_width) { + if let Some(start) = self.paged_viewport(positions, viewport_width, self.active_column) { self.viewport_x = start; return; } @@ -617,7 +646,7 @@ impl ScrollingLayout { if self.columns[source].windows.len() == 1 { self.columns.swap(source, destination); self.active_column = Some(destination); - self.reveal_pending = true; + self.reveal_pending = self.active_window(); return Ok(()); } @@ -631,7 +660,7 @@ impl ScrollingLayout { self.columns .insert(insertion, Column::new(window, self.default_width)); self.active_column = Some(insertion); - self.reveal_pending = true; + self.reveal_pending = self.active_window(); Ok(()) } @@ -804,6 +833,48 @@ mod tests { ); } + #[test] + fn hover_does_not_replace_a_pending_explicit_reveal() { + for strategy in [ + ViewportFocusStrategy::Minimal, + ViewportFocusStrategy::Center, + ViewportFocusStrategy::Paged, + ] { + let mut layout = ScrollingLayout::default(); + layout.set_focus_strategy(strategy); + for id in 1..=3 { + layout.insert(window(id), None).unwrap(); + } + let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + for target in [3, 1] { + layout.focus_and_reveal(window(target)).unwrap(); + let mut expected = layout.clone(); + expected + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(target))) + .unwrap(); + layout.focus_without_reveal(window(2)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2))) + .unwrap(); + assert_eq!(layout.active_window(), Some(window(2))); + assert_eq!(layout.viewport_x(), expected.viewport_x(), "{strategy:?}"); + } + layout.focus_and_reveal(window(3)).unwrap(); + layout.focus_without_reveal(window(2)).unwrap(); + layout.remove(window(3)).unwrap(); + assert!( + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2))) + .is_ok() + ); + } + } + #[test] fn focus_reveal_scrolls_only_as_far_as_needed() { let mut layout = ScrollingLayout::default(); diff --git a/docs/configuration.md b/docs/configuration.md index d9b714a..89f9f46 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -203,7 +203,7 @@ theme { | `input` key | Type | Default | Meaning | | --- | --- | --- | --- | -| `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer without raising them; click to raise | +| `focus_follows_mouse` | boolean | `false` | Focus visible windows under the pointer without raising them or scrolling; click or use keyboard focus to reveal a window | | `xkb_layout` | nonempty string | `"us"` | XKB layout | | `xkb_variant` | string | `""` | XKB variant | | `xkb_options` | string array | `[]` | XKB options | diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 1663cad..7aff3ea 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -917,7 +917,7 @@ pub fn fields(page: Page) -> Vec { toggle( "input.focus_follows_mouse", "Focus follows pointer", - "Focus windows as the pointer moves over them.", + "Focus visible windows without raising or scrolling. Click to bring them into view.", false, ), toggle(