From 827b4b9e025293cfefae5e4d57249e7321911a2b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 21 Apr 2026 20:05:00 +0530 Subject: [PATCH] Option to have focus_follows_mouse only on drops Fixes #9896 --- docs/changelog.rst | 2 ++ kitty/glfw.c | 3 +++ kitty/mouse.c | 2 +- kitty/options/definition.py | 5 +++-- kitty/options/parse.py | 7 ++++++- kitty/options/to-c-generated.h | 2 +- kitty/options/to-c.h | 16 ++++++++++++++++ kitty/options/types.py | 3 ++- kitty/state.h | 4 +++- 9 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 97ef657bf..f313940c4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -255,6 +255,8 @@ Detailed list of changes - diff kitten: Keep the current (topmost) filename visible when scrolling, controlled by a new option :opt:`kitten-diff.sticky_header` (:pull:`9891`) +- Add an option to :opt:`focus_follows_mouse` to only switch focus on drops rather than movement (:pull:`9896`) + 0.46.2 [2026-03-21] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/kitty/glfw.c b/kitty/glfw.c index 6584c3c01..a3e0d77a7 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -843,6 +843,9 @@ on_drop(GLFWwindow *window, GLFWDropEvent *ev) { ev->from_self ? Py_True : Py_False, Py_True); break; case GLFW_DROP_DROP: + if (w && OPT(focus_follows_mouse).on_drop) { + call_boss(set_active_window, "KO", w->id, Py_True); + } Py_CLEAR(global_state.drop_dest.data); global_state.drop_dest.drop_has_happened = true; global_state.drop_dest.client_window_data_request = 0; diff --git a/kitty/mouse.c b/kitty/mouse.c index 048dfa908..b6eda9d9f 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -196,7 +196,7 @@ set_currently_hovered_window(id_type window_id, int modifiers) { debug("Sent mouse leave event to window: %llu\n", left_window->id); } } - if (window_id && OPT(focus_follows_mouse) && global_state.callback_os_window && global_state.callback_os_window->num_tabs) { + if (window_id && OPT(focus_follows_mouse).on_cross && global_state.callback_os_window && global_state.callback_os_window->num_tabs) { Tab *t = global_state.callback_os_window->tabs + global_state.callback_os_window->active_tab; for (unsigned i = 0; i < t->num_windows; i++) { if (t->windows[i].id == window_id) { diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 8898809c4..66545faf4 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -855,12 +855,13 @@ fallback to 0.5. ) opt('focus_follows_mouse', 'no', - option_type='to_bool', ctype='bool', + choices=('no', 'n', 'false', 'y', 'yes', 'true', 'drop'), ctype='!focus_follows_mouse', long_text=''' Set the active window to the window under the mouse when the mouse crosses into a different window. The active window does not change while the mouse moves around within a single window, so an accidental mouse bump will not -undo a keyboard-driven window switch. +undo a keyboard-driven window switch. Setting it to :code:`drop` means +focus will only be changed when a drag and drop happens in a window. On macOS, this will also cause the OS Window under the mouse to be focused automatically when the mouse enters it, as long as the kitty application is the active application. ''' diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 662601a2b..cf2870707 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1012,7 +1012,12 @@ class Parser: ans["filter_notification"][k] = v def focus_follows_mouse(self, val: str, ans: dict[str, typing.Any]) -> None: - ans['focus_follows_mouse'] = to_bool(val) + val = val.lower() + if val not in self.choices_for_focus_follows_mouse: + raise ValueError(f"The value {val} is not a valid choice for focus_follows_mouse") + ans["focus_follows_mouse"] = val + + choices_for_focus_follows_mouse = frozenset(('no', 'n', 'false', 'y', 'yes', 'true', 'drop')) def font_family(self, val: str, ans: dict[str, typing.Any]) -> None: ans['font_family'] = parse_font_spec(val) diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index 5d67018a2..eb149fb61 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -683,7 +683,7 @@ convert_from_opts_click_interval(PyObject *py_opts, Options *opts) { static void convert_from_python_focus_follows_mouse(PyObject *val, Options *opts) { - opts->focus_follows_mouse = PyObject_IsTrue(val); + focus_follows_mouse(val, opts); } static void diff --git a/kitty/options/to-c.h b/kitty/options/to-c.h index dc9f13d14..84ec7475c 100644 --- a/kitty/options/to-c.h +++ b/kitty/options/to-c.h @@ -558,6 +558,22 @@ tab_bar_style(PyObject *val, Options *opts) { opts->tab_bar_hidden = PyUnicode_CompareWithASCIIString(val, "hidden") == 0 ? true: false; } +static inline void +focus_follows_mouse(PyObject *val, Options *opts) { + zero_at_ptr(&opts->focus_follows_mouse); + const char *q = PyUnicode_AsUTF8(val); + switch(q[0]) { + case 'y': case 't': + opts->focus_follows_mouse.on_cross = true; + opts->focus_follows_mouse.on_drop = true; + break; + case 'd': + opts->focus_follows_mouse.on_drop = true; + break; + } +} + + static inline void tab_bar_margin_height(PyObject *val, Options *opts) { if (!PyTuple_Check(val) || PyTuple_GET_SIZE(val) != 2) { diff --git a/kitty/options/types.py b/kitty/options/types.py index 100981472..bbe05b840 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -22,6 +22,7 @@ choices_for_allow_cloning = typing.Literal['yes', 'y', 'true', 'no', 'n', 'false choices_for_allow_remote_control = typing.Literal['password', 'socket-only', 'socket', 'no', 'n', 'false', 'yes', 'y', 'true'] choices_for_background_image_layout = typing.Literal['mirror-tiled', 'scaled', 'tiled', 'clamped', 'centered', 'cscaled'] choices_for_default_pointer_shape = typing.Literal['arrow', 'beam', 'text', 'pointer', 'hand', 'help', 'wait', 'progress', 'crosshair', 'cell', 'vertical-text', 'move', 'e-resize', 'ne-resize', 'nw-resize', 'n-resize', 'se-resize', 'sw-resize', 's-resize', 'w-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'zoom-in', 'zoom-out', 'alias', 'copy', 'not-allowed', 'no-drop', 'grab', 'grabbing'] +choices_for_focus_follows_mouse = typing.Literal['no', 'n', 'false', 'y', 'yes', 'true', 'drop'] choices_for_linux_display_server = typing.Literal['auto', 'wayland', 'x11'] choices_for_macos_colorspace = typing.Literal['srgb', 'default', 'displayp3'] choices_for_macos_show_window_title_in = typing.Literal['all', 'menubar', 'none', 'window'] @@ -578,7 +579,7 @@ class Options: enable_audio_bell: bool = True enabled_layouts: list[str] = ['fat', 'grid', 'horizontal', 'splits', 'stack', 'tall', 'vertical'] file_transfer_confirmation_bypass: str = '' - focus_follows_mouse: bool = False + focus_follows_mouse: choices_for_focus_follows_mouse = 'no' font_family: FontSpec = FontSpec(family=None, style=None, postscript_name=None, full_name=None, system='monospace', axes=(), variable_name=None, features=(), created_from_string='monospace') font_size: float = 11.0 force_ltr: bool = False diff --git a/kitty/state.h b/kitty/state.h index 9712a84b0..551bc9811 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -74,7 +74,9 @@ typedef struct Options { color_type url_color, background, foreground, active_border_color, inactive_border_color, bell_border_color, tab_bar_background, tab_bar_margin_color, window_title_bar_active_foreground, window_title_bar_active_background, window_title_bar_inactive_foreground, window_title_bar_inactive_background; monotonic_t repaint_delay, input_delay; - bool focus_follows_mouse; + struct { + bool on_cross, on_drop; + } focus_follows_mouse; unsigned int hide_window_decorations; bool macos_hide_from_tasks, macos_quit_when_last_window_closed, macos_window_resizable, macos_traditional_fullscreen, macos_fullscreen_ignore_safe_area_insets; unsigned int macos_option_as_alt;