debounce ffm

Josh Robson Chase created

Change summary

assets/settings/default.json                |  5 +
crates/settings_content/src/workspace.rs    |  9 +++
crates/settings_ui/src/page_data.rs         | 39 +++++++++++++++-
crates/workspace/src/focus_follows_mouse.rs | 52 +++++++++++++++++++++-
crates/workspace/src/pane.rs                |  6 +-
crates/workspace/src/workspace.rs           |  4 
crates/workspace/src/workspace_settings.rs  | 25 +++++++++-
7 files changed, 122 insertions(+), 18 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -226,7 +226,10 @@
   //    "on_typing_and_movement"
   "hide_mouse": "on_typing_and_movement",
   // Determines whether the focused panel follows the mouse location.
-  "focus_follows_mouse": false,
+  "focus_follows_mouse": {
+    "enabled": false,
+    "debounce_ms": 250,
+  },
   // Determines how snippets are sorted relative to other completion items.
   //
   // 1. Place snippets at the top of the completion list:

crates/settings_content/src/workspace.rs 🔗

@@ -124,7 +124,7 @@ pub struct WorkspaceSettingsContent {
     pub window_decorations: Option<WindowDecorations>,
     /// Whether the focused panel follows the mouse location
     /// Default: false
-    pub focus_follows_mouse: Option<bool>,
+    pub focus_follows_mouse: Option<FocusFollowsMouse>,
 }
 
 #[with_fallible_options]
@@ -923,3 +923,10 @@ impl DocumentSymbols {
         self == &Self::On
     }
 }
+
+#[with_fallible_options]
+#[derive(Copy, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
+pub struct FocusFollowsMouse {
+    pub enabled: Option<bool>,
+    pub debounce_ms: Option<u64>,
+}

crates/settings_ui/src/page_data.rs 🔗

@@ -4121,7 +4121,7 @@ fn window_and_layout_page() -> SettingsPage {
         ]
     }
 
-    fn layout_section() -> [SettingsPageItem; 5] {
+    fn layout_section() -> [SettingsPageItem; 6] {
         [
             SettingsPageItem::SectionHeader("Layout"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -4189,12 +4189,43 @@ fn window_and_layout_page() -> SettingsPage {
                 title: "Focus Follows Mouse",
                 description: "Whether to change focus to a pane when the mouse hovers over it.",
                 field: Box::new(SettingField {
-                    json_path: Some("focus_follows_mouse"),
+                    json_path: Some("focus_follows_mouse.enabled"),
                     pick: |settings_content| {
-                        settings_content.workspace.focus_follows_mouse.as_ref()
+                        settings_content
+                            .workspace
+                            .focus_follows_mouse
+                            .as_ref()
+                            .and_then(|s| s.enabled.as_ref())
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .workspace
+                            .focus_follows_mouse
+                            .get_or_insert_default()
+                            .enabled = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Focus Follows Mouse Debounce ms",
+                description: "Amount of time to wait before changing focus.",
+                field: Box::new(SettingField {
+                    json_path: Some("focus_follows_mouse.debounce_ms"),
+                    pick: |settings_content| {
+                        settings_content
+                            .workspace
+                            .focus_follows_mouse
+                            .as_ref()
+                            .and_then(|s| s.debounce_ms.as_ref())
                     },
                     write: |settings_content, value| {
-                        settings_content.workspace.focus_follows_mouse = value;
+                        settings_content
+                            .workspace
+                            .focus_follows_mouse
+                            .get_or_insert_default()
+                            .debounce_ms = value;
                     },
                 }),
                 metadata: None,

crates/workspace/src/focus_follows_mouse.rs 🔗

@@ -1,11 +1,55 @@
-use gpui::{Context, Focusable, StatefulInteractiveElement};
+use std::sync::LazyLock;
+
+use gpui::{
+    AnyWindowHandle, AppContext, Context, FocusHandle, Focusable, StatefulInteractiveElement, Task,
+};
+use parking_lot::Mutex;
+
+use crate::workspace_settings;
+
+#[derive(Default)]
+struct FfmState {
+    // The window and element to be focused
+    handles: Option<(AnyWindowHandle, FocusHandle)>,
+    // The debounced task which will do the focusing
+    debounce_task: Option<Task<()>>,
+}
+
+// Global focus-follows-mouse state.
+static FFM_STATE: LazyLock<Mutex<FfmState>> = LazyLock::new(Default::default);
 
 pub trait FocusFollowsMouse<E: Focusable>: StatefulInteractiveElement {
-    fn focus_follows_mouse(self, enabled: bool, cx: &Context<E>) -> Self {
-        if enabled {
+    fn focus_follows_mouse(
+        self,
+        settings: workspace_settings::FocusFollowsMouse,
+        cx: &Context<E>,
+    ) -> Self {
+        if settings.enabled {
             self.on_hover(cx.listener(move |this, enter, window, cx| {
                 if *enter {
-                    window.focus(&this.focus_handle(cx), cx);
+                    let window_handle = window.window_handle();
+                    let focus_handle = this.focus_handle(cx);
+
+                    let mut state = FFM_STATE.lock();
+
+                    // Set the window/element to be focused to the most recent hovered element.
+                    state.handles.replace((window_handle, focus_handle));
+
+                    // Start a task to focus the most recent target after the debounce period
+                    state
+                        .debounce_task
+                        .replace(cx.spawn(async move |_this, cx| {
+                            cx.background_executor().timer(settings.debounce).await;
+
+                            let mut state = FFM_STATE.lock();
+                            let Some((window, focus)) = state.handles.take() else {
+                                return;
+                            };
+
+                            let _ = cx.update_window(window, move |_view, window, cx| {
+                                window.focus(&focus, cx);
+                            });
+                        }));
                 }
             }))
         } else {

crates/workspace/src/pane.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible,
     SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
     WorkspaceItemBuilder, ZoomIn, ZoomOut,
-    focus_follows_mouse::FocusFollowsMouse,
+    focus_follows_mouse::FocusFollowsMouse as _,
     invalid_item_view::InvalidItemView,
     item::{
         ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings,
@@ -12,7 +12,7 @@ use crate::{
     move_item,
     notifications::NotifyResultExt,
     toolbar::Toolbar,
-    workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
+    workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings},
 };
 use anyhow::Result;
 use collections::{BTreeSet, HashMap, HashSet, VecDeque};
@@ -414,7 +414,7 @@ pub struct Pane {
     pinned_tab_count: usize,
     diagnostics: HashMap<ProjectPath, DiagnosticSeverity>,
     zoom_out_on_close: bool,
-    focus_follows_mouse: bool,
+    focus_follows_mouse: FocusFollowsMouse,
     diagnostic_summary_update: Task<()>,
     /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here.
     pub project_item_restoration_data: HashMap<ProjectItemKind, Box<dyn Any + Send>>,

crates/workspace/src/workspace.rs 🔗

@@ -144,8 +144,8 @@ use util::{
 };
 use uuid::Uuid;
 pub use workspace_settings::{
-    AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings,
-    WorkspaceSettings,
+    AutosaveSetting, BottomDockLayout, FocusFollowsMouse, RestoreOnStartupBehavior,
+    StatusBarSettings, TabBarSettings, WorkspaceSettings,
 };
 use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode};
 

crates/workspace/src/workspace_settings.rs 🔗

@@ -1,4 +1,4 @@
-use std::num::NonZeroUsize;
+use std::{num::NonZeroUsize, time::Duration};
 
 use crate::DockPosition;
 use collections::HashMap;
@@ -35,7 +35,13 @@ pub struct WorkspaceSettings {
     pub use_system_window_tabs: bool,
     pub zoomed_padding: bool,
     pub window_decorations: settings::WindowDecorations,
-    pub focus_follows_mouse: bool,
+    pub focus_follows_mouse: FocusFollowsMouse,
+}
+
+#[derive(Copy, Clone, Deserialize)]
+pub struct FocusFollowsMouse {
+    pub enabled: bool,
+    pub debounce: Duration,
 }
 
 #[derive(Copy, Clone, PartialEq, Debug, Default)]
@@ -114,7 +120,20 @@ impl Settings for WorkspaceSettings {
             use_system_window_tabs: workspace.use_system_window_tabs.unwrap(),
             zoomed_padding: workspace.zoomed_padding.unwrap(),
             window_decorations: workspace.window_decorations.unwrap(),
-            focus_follows_mouse: workspace.focus_follows_mouse.unwrap(),
+            focus_follows_mouse: FocusFollowsMouse {
+                enabled: workspace
+                    .focus_follows_mouse
+                    .unwrap()
+                    .enabled
+                    .unwrap_or(false),
+                debounce: Duration::from_millis(
+                    workspace
+                        .focus_follows_mouse
+                        .unwrap()
+                        .debounce_ms
+                        .unwrap_or(250),
+                ),
+            },
         }
     }
 }