Separate pointer focus from scrolling window reveal

This commit is contained in:
Samuel Onoja committed 2026-09-28 10:36:19 +01:00
1 parent 2b0ec94ab7
commit 442b950bcd
5 files changed
+201 -37

No files matched your search

+17 -6
View File
@@ -543,11 +543,16 @@ impl Ferese {
if let Some(window) = self.window_under_visual(position) { if let Some(window) = self.window_under_visual(position) {
let focused = self.window_ids.get(&window).copied(); let focused = self.window_ids.get(&window).copied();
if let Some(focused) = focused if let Some(focused) = focused {
&& let Err(error) = self.workspaces.focus_window(focused) let result = if raise {
{ self.workspaces.focus_window(focused)
tracing::error!(%error, ?focused, "failed to update workspace focus"); } else {
return; 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; self.focused_window = focused;
if raise { 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( fn focus_window_under_pointer(
+84 -2
View File
@@ -119,7 +119,14 @@ impl WorkspaceLayout {
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> { pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
match self { 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), Self::Tree(layout) => layout.activate_window(window),
} }
} }
@@ -607,6 +614,18 @@ impl WorkspaceSet {
} }
pub fn focus_window(&mut self, window: WindowId) -> Result<(), WorkspaceError> { 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) { if self.workspace_for_window(window) != Some(self.active) {
return Err(WorkspaceError::InvalidState( return Err(WorkspaceError::InvalidState(
"focused window is not on the active workspace", "focused window is not on the active workspace",
@@ -623,7 +642,13 @@ impl WorkspaceSet {
} }
if self.placement(window) == Some(WindowPlacement::Tiled) { 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); self.active_mut().last_focused = Some(window);
@@ -1418,6 +1443,63 @@ mod tests {
assert!(workspaces.validate().is_ok()); 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] #[test]
fn opening_a_focused_float_preserves_tiled_geometry_and_scroll() { fn opening_a_focused_float_preserves_tiled_geometry_and_scroll() {
let mut workspaces = WorkspaceSet::new( let mut workspaces = WorkspaceSet::new(
+98 -27
View File
@@ -78,7 +78,7 @@ pub struct ScrollingLayout {
neighbor_context: f64, neighbor_context: f64,
default_width: ColumnWidth, default_width: ColumnWidth,
focus_strategy: ViewportFocusStrategy, focus_strategy: ViewportFocusStrategy,
reveal_pending: bool, reveal_pending: Option<WindowId>,
width_cycle_pending: bool, width_cycle_pending: bool,
last_viewport_width: Option<f64>, last_viewport_width: Option<f64>,
} }
@@ -92,7 +92,7 @@ impl Default for ScrollingLayout {
neighbor_context: 48.0, neighbor_context: 48.0,
default_width: ColumnWidth::default(), default_width: ColumnWidth::default(),
focus_strategy: ViewportFocusStrategy::Minimal, focus_strategy: ViewportFocusStrategy::Minimal,
reveal_pending: false, reveal_pending: None,
width_cycle_pending: false, width_cycle_pending: false,
last_viewport_width: None, last_viewport_width: None,
} }
@@ -113,7 +113,7 @@ impl ScrollingLayout {
pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) { pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) {
self.focus_strategy = strategy; self.focus_strategy = strategy;
self.reveal_pending = true; self.reveal_pending = self.active_window();
} }
pub fn default_width(&self) -> ColumnWidth { pub fn default_width(&self) -> ColumnWidth {
@@ -163,7 +163,7 @@ impl ScrollingLayout {
self.columns self.columns
.insert(index, Column::new(window, self.default_width)); .insert(index, Column::new(window, self.default_width));
self.active_column = Some(index); 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()); debug_assert!(self.validate().is_ok());
Ok(()) Ok(())
@@ -191,9 +191,12 @@ impl ScrollingLayout {
column.active = column.active.min(column.windows.len() - 1); column.active = column.active.min(column.windows.len() - 1);
column.normalize_heights(); column.normalize_heights();
} }
self.reveal_pending |= (removed_active_column if self.reveal_pending == Some(window)
|| self.focus_strategy == ViewportFocusStrategy::Paged) || removed_active_column
&& !self.columns.is_empty(); || self.focus_strategy == ViewportFocusStrategy::Paged
{
self.reveal_pending = self.active_window();
}
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
Ok(()) Ok(())
@@ -206,7 +209,27 @@ impl ScrollingLayout {
let changed_column = self.active_column != Some(column); let changed_column = self.active_column != Some(column);
self.active_column = Some(column); self.active_column = Some(column);
self.columns[column].active = index; 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(()) Ok(())
} }
@@ -231,7 +254,7 @@ impl ScrollingLayout {
column.active = insertion; column.active = insertion;
column.normalize_heights(); column.normalize_heights();
self.active_column = Some(target_column); self.active_column = Some(target_column);
self.reveal_pending = true; self.reveal_pending = self.active_window();
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
Ok(()) Ok(())
@@ -251,7 +274,7 @@ impl ScrollingLayout {
self.columns self.columns
.insert(insertion, Column::new(window, self.default_width)); .insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion); self.active_column = Some(insertion);
self.reveal_pending = true; self.reveal_pending = self.active_window();
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
Ok(()) Ok(())
@@ -266,7 +289,7 @@ impl ScrollingLayout {
.window_location(window) .window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?; .ok_or(LayoutError::UnknownWindow(window))?;
self.columns[column].width = normalized_width(width); self.columns[column].width = normalized_width(width);
self.reveal_pending = true; self.reveal_pending = self.active_window();
Ok(()) Ok(())
} }
@@ -480,12 +503,16 @@ impl ScrollingLayout {
if self.width_cycle_pending { if self.width_cycle_pending {
self.retarget_after_width_cycle(&column_positions, viewport_width); self.retarget_after_width_cycle(&column_positions, viewport_width);
self.width_cycle_pending = false; self.width_cycle_pending = false;
self.reveal_pending = false; self.reveal_pending = None;
} else if self.reveal_pending } else {
|| (viewport_resized && self.focus_strategy == ViewportFocusStrategy::Paged) let reveal = self.reveal_pending.take().or_else(|| {
{ (viewport_resized && self.focus_strategy == ViewportFocusStrategy::Paged)
self.reveal_active_column(&column_positions, viewport_width); .then(|| self.active_window())
self.reveal_pending = false; .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(); let mut result = LayoutResult::default();
@@ -518,14 +545,11 @@ impl ScrollingLayout {
Ok(result) Ok(result)
} }
fn reveal_active_column(&mut self, positions: &[(f64, f64)], viewport_width: f64) { fn reveal_column(&mut self, positions: &[(f64, f64)], viewport_width: f64, active: usize) {
if let Some(start) = self.paged_viewport(positions, viewport_width) { if let Some(start) = self.paged_viewport(positions, viewport_width, Some(active)) {
self.viewport_x = start; self.viewport_x = start;
return; return;
} }
let Some(active) = self.active_column else {
return;
};
let (start, width) = positions[active]; let (start, width) = positions[active];
let end = start + width; let end = start + width;
if self.focus_strategy == ViewportFocusStrategy::Center { if self.focus_strategy == ViewportFocusStrategy::Center {
@@ -555,11 +579,16 @@ impl ScrollingLayout {
} }
} }
fn paged_viewport(&self, positions: &[(f64, f64)], viewport_width: f64) -> Option<f64> { fn paged_viewport(
&self,
positions: &[(f64, f64)],
viewport_width: f64,
active: Option<usize>,
) -> Option<f64> {
if self.focus_strategy != ViewportFocusStrategy::Paged { if self.focus_strategy != ViewportFocusStrategy::Paged {
return None; return None;
} }
let active = self.active_column?; let active = active?;
// Pack actual allocated widths, including gaps and client constraints. // Pack actual allocated widths, including gaps and client constraints.
// Page boundaries are independent of focus direction. An oversized // Page boundaries are independent of focus direction. An oversized
// column occupies its own page rather than overlapping its neighbors. // 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) { 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; self.viewport_x = start;
return; return;
} }
@@ -617,7 +646,7 @@ impl ScrollingLayout {
if self.columns[source].windows.len() == 1 { if self.columns[source].windows.len() == 1 {
self.columns.swap(source, destination); self.columns.swap(source, destination);
self.active_column = Some(destination); self.active_column = Some(destination);
self.reveal_pending = true; self.reveal_pending = self.active_window();
return Ok(()); return Ok(());
} }
@@ -631,7 +660,7 @@ impl ScrollingLayout {
self.columns self.columns
.insert(insertion, Column::new(window, self.default_width)); .insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion); self.active_column = Some(insertion);
self.reveal_pending = true; self.reveal_pending = self.active_window();
Ok(()) 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] #[test]
fn focus_reveal_scrolls_only_as_far_as_needed() { fn focus_reveal_scrolls_only_as_far_as_needed() {
let mut layout = ScrollingLayout::default(); let mut layout = ScrollingLayout::default();
+1 -1
View File
@@ -203,7 +203,7 @@ theme {
| `input` key | Type | Default | Meaning | | `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_layout` | nonempty string | `"us"` | XKB layout |
| `xkb_variant` | string | `""` | XKB variant | | `xkb_variant` | string | `""` | XKB variant |
| `xkb_options` | string array | `[]` | XKB options | | `xkb_options` | string array | `[]` | XKB options |
+1 -1
View File
@@ -917,7 +917,7 @@ pub fn fields(page: Page) -> Vec<Field> {
toggle( toggle(
"input.focus_follows_mouse", "input.focus_follows_mouse",
"Focus follows pointer", "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, false,
), ),
toggle( toggle(