diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index abc03a9..fd6e005 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -75,7 +75,7 @@ pub struct Input { #[knuffel(child)] pub warp_mouse_to_focus: bool, #[knuffel(child)] - pub focus_follows_mouse: bool, + pub focus_follows_mouse: Option, #[knuffel(child)] pub workspace_auto_back_and_forth: bool, } @@ -289,6 +289,15 @@ pub struct Touch { pub map_to_output: Option, } +#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] +pub struct FocusFollowsMouse { + #[knuffel(property, str)] + pub max_scroll_amount: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Percent(pub f64); + #[derive(Debug, Default, Clone, PartialEq)] pub struct Outputs(pub Vec); @@ -1805,6 +1814,16 @@ where } impl Animation { + pub fn new_off() -> Self { + Self { + off: true, + kind: AnimationKind::Easing(EasingParams { + duration_ms: 0, + curve: AnimationCurve::Linear, + }), + } + } + fn decode_node( node: &knuffel::ast::SpannedNode, ctx: &mut knuffel::decode::Context, @@ -2429,6 +2448,23 @@ impl FromStr for TapButtonMap { } } +impl FromStr for Percent { + type Err = miette::Error; + + fn from_str(s: &str) -> Result { + let Some((value, empty)) = s.split_once('%') else { + return Err(miette!("value must end with '%'")); + }; + + if !empty.is_empty() { + return Err(miette!("trailing characters after '%' are not allowed")); + } + + let value: f64 = value.parse().map_err(|_| miette!("error parsing value"))?; + Ok(Percent(value / 100.)) + } +} + pub fn set_miette_hook() -> Result<(), miette::InstallError> { miette::set_hook(Box::new(|_| Box::new(NarratableReportHandler::new()))) } @@ -2675,7 +2711,9 @@ mod tests { }, disable_power_key_handling: true, warp_mouse_to_focus: true, - focus_follows_mouse: true, + focus_follows_mouse: Some(FocusFollowsMouse { + max_scroll_amount: None, + }), workspace_auto_back_and_forth: true, }, outputs: Outputs(vec![Output { diff --git a/resources/default-config.kdl b/resources/default-config.kdl index a4b1cbd..e795f20 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -44,7 +44,8 @@ input { // warp-mouse-to-focus // Focus windows and outputs automatically when moving the mouse into them. - // focus-follows-mouse + // Setting max-scroll-amount="0%" makes it work only on windows already fully on screen. + // focus-follows-mouse max-scroll-amount="0%" } // You can configure outputs by their name, which you can find diff --git a/src/layout/mod.rs b/src/layout/mod.rs index ab7d7ec..fadde1f 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -947,6 +947,22 @@ impl Layout { } } + pub fn scroll_amount_to_activate(&self, window: &W::Id) -> f64 { + let MonitorSet::Normal { monitors, .. } = &self.monitor_set else { + return 0.; + }; + + for mon in monitors { + for ws in &mon.workspaces { + if ws.has_window(window) { + return ws.scroll_amount_to_activate(window); + } + } + } + + 0. + } + pub fn activate_window(&mut self, window: &W::Id) { let MonitorSet::Normal { monitors, diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index 5044ece..625f4d5 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -660,8 +660,9 @@ impl Workspace { }); } - fn compute_new_view_offset_for_column(&self, current_x: f64, idx: usize) -> f64 { - if self.columns[idx].is_fullscreen { + fn compute_new_view_offset_for_column_fit(&self, current_x: f64, idx: usize) -> f64 { + let col = &self.columns[idx]; + if col.is_fullscreen { return 0.; } @@ -677,7 +678,7 @@ impl Workspace { final_x + self.working_area.loc.x, self.working_area.size.w, new_col_x, - self.columns[idx].width(), + col.width(), self.options.gaps, ); @@ -685,6 +686,71 @@ impl Workspace { new_offset - self.working_area.loc.x } + fn compute_new_view_offset_for_column_centered(&self, current_x: f64, idx: usize) -> f64 { + let col = &self.columns[idx]; + if col.is_fullscreen { + return self.compute_new_view_offset_for_column_fit(current_x, idx); + } + + let width = col.width(); + + // Columns wider than the view are left-aligned (the fit code can deal with that). + if self.working_area.size.w <= width { + return self.compute_new_view_offset_for_column_fit(current_x, idx); + } + + -(self.working_area.size.w - width) / 2. - self.working_area.loc.x + } + + fn compute_new_view_offset_for_column( + &self, + current_x: f64, + idx: usize, + prev_idx: Option, + ) -> f64 { + match self.options.center_focused_column { + CenterFocusedColumn::Always => { + self.compute_new_view_offset_for_column_centered(current_x, idx) + } + CenterFocusedColumn::OnOverflow => { + let Some(prev_idx) = prev_idx else { + return self.compute_new_view_offset_for_column_fit(current_x, idx); + }; + + // Always take the left or right neighbor of the target as the source. + let source_idx = if prev_idx > idx { + min(idx + 1, self.columns.len() - 1) + } else { + idx.saturating_sub(1) + }; + + let source_x = self.column_x(source_idx); + let source_width = self.columns[source_idx].width(); + + let target_x = self.column_x(idx); + let target_width = self.columns[idx].width(); + + let total_width = if source_x < target_x { + // Source is left from target. + target_x - source_x + target_width + } else { + // Source is right from target. + source_x - target_x + source_width + } + self.options.gaps * 2.; + + // If it fits together, do a normal animation, otherwise center the new column. + if total_width <= self.working_area.size.w { + self.compute_new_view_offset_for_column_fit(current_x, idx) + } else { + self.compute_new_view_offset_for_column_centered(current_x, idx) + } + } + CenterFocusedColumn::Never => { + self.compute_new_view_offset_for_column_fit(current_x, idx) + } + } + } + fn animate_view_offset(&mut self, current_x: f64, idx: usize, new_view_offset: f64) { self.animate_view_offset_with_config( current_x, @@ -728,44 +794,24 @@ impl Workspace { ))); } - fn animate_view_offset_to_column_fit( - &mut self, - current_x: f64, - idx: usize, - config: niri_config::Animation, - ) { - let new_view_offset = self.compute_new_view_offset_for_column(current_x, idx); - self.animate_view_offset_with_config(current_x, idx, new_view_offset, config); - } - fn animate_view_offset_to_column_centered( &mut self, current_x: f64, idx: usize, config: niri_config::Animation, ) { - if self.columns.is_empty() { - return; - } - - let col = &self.columns[idx]; - if col.is_fullscreen { - self.animate_view_offset_to_column_fit(current_x, idx, config); - return; - } - - let width = col.width(); - - // If the column is wider than the working area, then on commit it will be shifted to left - // edge alignment by the usual positioning code, so there's no use in trying to center it - // here. - if self.working_area.size.w <= width { - self.animate_view_offset_to_column_fit(current_x, idx, config); - return; - } - - let new_view_offset = -(self.working_area.size.w - width) / 2. - self.working_area.loc.x; + let new_view_offset = self.compute_new_view_offset_for_column_centered(current_x, idx); + self.animate_view_offset_with_config(current_x, idx, new_view_offset, config); + } + fn animate_view_offset_to_column_with_config( + &mut self, + current_x: f64, + idx: usize, + prev_idx: Option, + config: niri_config::Animation, + ) { + let new_view_offset = self.compute_new_view_offset_for_column(current_x, idx, prev_idx); self.animate_view_offset_with_config(current_x, idx, new_view_offset, config); } @@ -783,57 +829,6 @@ impl Workspace { ) } - fn animate_view_offset_to_column_with_config( - &mut self, - current_x: f64, - idx: usize, - prev_idx: Option, - config: niri_config::Animation, - ) { - match self.options.center_focused_column { - CenterFocusedColumn::Always => { - self.animate_view_offset_to_column_centered(current_x, idx, config) - } - CenterFocusedColumn::OnOverflow => { - let Some(prev_idx) = prev_idx else { - self.animate_view_offset_to_column_fit(current_x, idx, config); - return; - }; - - // Always take the left or right neighbor of the target as the source. - let source_idx = if prev_idx > idx { - min(idx + 1, self.columns.len() - 1) - } else { - idx.saturating_sub(1) - }; - - let source_x = self.column_x(source_idx); - let source_width = self.columns[source_idx].width(); - - let target_x = self.column_x(idx); - let target_width = self.columns[idx].width(); - - let total_width = if source_x < target_x { - // Source is left from target. - target_x - source_x + target_width - } else { - // Source is right from target. - source_x - target_x + source_width - } + self.options.gaps * 2.; - - // If it fits together, do a normal animation, otherwise center the new column. - if total_width <= self.working_area.size.w { - self.animate_view_offset_to_column_fit(current_x, idx, config); - } else { - self.animate_view_offset_to_column_centered(current_x, idx, config); - } - } - CenterFocusedColumn::Never => { - self.animate_view_offset_to_column_fit(current_x, idx, config) - } - }; - } - fn activate_column(&mut self, idx: usize) { self.activate_column_with_anim_config( idx, @@ -913,31 +908,24 @@ impl Workspace { is_full_width, true, ); - let width = column.width(); self.data.insert(col_idx, ColumnData::new(&column)); self.columns.insert(col_idx, column); if activate { - // If this is the first window on an empty workspace, skip the animation from whatever - // view_offset was left over. + // If this is the first window on an empty workspace, remove the effect of whatever + // view_offset was left over and skip the animation. if was_empty { - if self.options.center_focused_column == CenterFocusedColumn::Always { - self.view_offset = - -(self.working_area.size.w - width) / 2. - self.working_area.loc.x; - } else { - // Try to make the code produce a left-aligned offset, even in presence of left - // exclusive zones. - self.view_offset = self.compute_new_view_offset_for_column(self.column_x(0), 0); - } + self.view_offset = 0.; self.view_offset_adj = None; + self.view_offset = + self.compute_new_view_offset_for_column(self.view_pos(), col_idx, None); } let prev_offset = (!was_empty).then(|| self.static_view_offset()); - self.activate_column_with_anim_config( - col_idx, - anim_config.unwrap_or(self.options.animations.horizontal_view_movement.0), - ); + let anim_config = + anim_config.unwrap_or(self.options.animations.horizontal_view_movement.0); + self.activate_column_with_anim_config(col_idx, anim_config); self.activate_prev_column_on_removal = prev_offset; } @@ -1054,23 +1042,17 @@ impl Workspace { column.update_config(self.scale.fractional_scale(), self.options.clone()); column.set_view_size(self.view_size, self.working_area); - let width = column.width(); self.data.insert(idx, ColumnData::new(&column)); self.columns.insert(idx, column); if activate { - // If this is the first window on an empty workspace, skip the animation from whatever - // view_offset was left over. + // If this is the first window on an empty workspace, remove the effect of whatever + // view_offset was left over and skip the animation. if was_empty { - if self.options.center_focused_column == CenterFocusedColumn::Always { - self.view_offset = - -(self.working_area.size.w - width) / 2. - self.working_area.loc.x; - } else { - // Try to make the code produce a left-aligned offset, even in presence of left - // exclusive zones. - self.view_offset = self.compute_new_view_offset_for_column(self.column_x(0), 0); - } + self.view_offset = 0.; self.view_offset_adj = None; + self.view_offset = + self.compute_new_view_offset_for_column(self.view_pos(), idx, None); } let prev_offset = (!was_empty).then(|| self.static_view_offset()); @@ -1409,6 +1391,37 @@ impl Workspace { } } + pub fn scroll_amount_to_activate(&self, window: &W::Id) -> f64 { + let column_idx = self + .columns + .iter() + .position(|col| col.contains(window)) + .unwrap(); + + if self.active_column_idx == column_idx { + return 0.; + } + + let current_x = self.view_pos(); + let new_view_offset = self.compute_new_view_offset_for_column( + current_x, + column_idx, + Some(self.active_column_idx), + ); + + // Consider the end of an ongoing animation because that's what compute to fit does too. + let final_x = if let Some(ViewOffsetAdjustment::Animation(anim)) = &self.view_offset_adj { + current_x - self.view_offset + anim.to() + } else { + current_x + }; + + let new_col_x = self.column_x(column_idx); + let from_view_offset = final_x - new_col_x; + + (from_view_offset - new_view_offset).abs() / self.working_area.size.w + } + pub fn activate_window(&mut self, window: &W::Id) { let column_idx = self .columns @@ -1947,6 +1960,10 @@ impl Workspace { } pub fn center_column(&mut self) { + if self.columns.is_empty() { + return; + } + let center_x = self.view_pos(); self.animate_view_offset_to_column_centered( center_x, @@ -1954,10 +1971,8 @@ impl Workspace { self.options.animations.horizontal_view_movement.0, ); - if !self.columns.is_empty() { - let col = &mut self.columns[self.active_column_idx]; - cancel_resize_for_column(&mut self.interactive_resize, col); - } + let col = &mut self.columns[self.active_column_idx]; + cancel_resize_for_column(&mut self.interactive_resize, col); } fn view_pos(&self) -> f64 { diff --git a/src/niri.rs b/src/niri.rs index 08331be..2ff2f01 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -4140,9 +4140,9 @@ impl Niri { } pub fn handle_focus_follows_mouse(&mut self, new_focus: &PointerFocus) { - if !self.config.borrow().input.focus_follows_mouse { + let Some(ffm) = self.config.borrow().input.focus_follows_mouse else { return; - } + }; let pointer = &self.seat.get_pointer().unwrap(); if pointer.is_grabbed() { @@ -4160,6 +4160,12 @@ impl Niri { if let Some(window) = &new_focus.window { if current_focus.window.as_ref() != Some(window) { + if let Some(threshold) = ffm.max_scroll_amount { + if self.layout.scroll_amount_to_activate(window) > threshold.0 { + return; + } + } + self.layout.activate_window(window); } } diff --git a/wiki/Configuration:-Input.md b/wiki/Configuration:-Input.md index 05c6ce9..fd4d0d6 100644 --- a/wiki/Configuration:-Input.md +++ b/wiki/Configuration:-Input.md @@ -68,7 +68,7 @@ input { // disable-power-key-handling // warp-mouse-to-focus - // focus-follows-mouse + // focus-follows-mouse max-scroll-amount="0%" // workspace-auto-back-and-forth } ``` @@ -207,6 +207,24 @@ input { } ``` +Since: 0.1.8 You can optionally set `max-scroll-amount`. +Then, focus-follows-mouse won't focus a window if it will result in the view scrolling more than the set amount. +The value is a percentage of the working area width. + +``` +input { + // Allow focus-follows-mouse when it results in scrolling at most 10% of the screen. + focus-follows-mouse max-scroll-amount="10%" +} +``` + +``` +input { + // Allow focus-follows-mouse only when it will not scroll the view. + focus-follows-mouse max-scroll-amount="0%" +} +``` + #### `workspace-auto-back-and-forth` Normally, switching to the same workspace by index twice will do nothing (since you're already on that workspace). diff --git a/wiki/Configuration:-Key-Bindings.md b/wiki/Configuration:-Key-Bindings.md index 51dcf85..37d7344 100644 --- a/wiki/Configuration:-Key-Bindings.md +++ b/wiki/Configuration:-Key-Bindings.md @@ -50,7 +50,7 @@ For this reason, most of the default keys use the `Mod` modifier. > Here, look at `sym: Left` and `sym: Right`: these are the key names. > I was pressing the left and the right arrow in this example. -Since: 0.1.7 Binds will repeat by default (i.e. holding down a bind will make it trigger repeatedly). +Since: 0.1.8 Binds will repeat by default (i.e. holding down a bind will make it trigger repeatedly). You can disable that for specific binds with `repeat=false`: ```