diff --git a/docs/changelog.rst b/docs/changelog.rst index 37d54238d..e31fe3a76 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -170,6 +170,12 @@ Detailed list of changes - A new option :opt:`palette_generate` to automatically generate the 256 color palette from the first 16 colors (:pull:`9426`) +- :ac:`scroll_line_up` and :ac:`scroll_line_down` now support an optional + ``smooth`` argument that performs smooth animated scrolling, timed to complete + within the platform's keyboard repeat interval. The default key mappings use + this argument. Releasing the key or triggering any other scroll action + immediately finishes the animation. + - For builtin key mappings automatically :ref:`fallback ` to matching the US-PC layout key when the pressed key has no matches and is a non-English character (:pull:`9671`) - Allow drag and drop of windows to re-arrange them, move them to another diff --git a/kitty/boss.py b/kitty/boss.py index 3cb2d550c..829832432 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1647,6 +1647,12 @@ class Boss: def dispatch_possible_special_key(self, ev: KeyEvent) -> bool: return self.mappings.dispatch_possible_special_key(ev) + def on_shortcut_key_release(self, ev: KeyEvent) -> bool: + window = self.active_window + if window is not None: + window.finish_scroll_animation() + return False + def cancel_current_visual_select(self) -> None: if self.current_visual_select: self.current_visual_select.cancel() diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index e91cf91c2..9f9bd8fd5 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1326,6 +1326,9 @@ class Screen: def scroll(self, amt: int, upwards: bool) -> bool: pass + def scroll_to_absolute(self, amt: float) -> None: + pass + def fractional_scroll(self, amt: float) -> bool: pass @@ -1629,6 +1632,10 @@ def get_click_interval() -> float: pass +def glfw_get_keyboard_repeat_interval() -> float: + pass + + def send_data_to_peer(peer_id: int, data: Union[str, bytes], is_async_response: bool = False) -> None: pass diff --git a/kitty/glfw.c b/kitty/glfw.c index b776d29bd..c5462c359 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -2743,6 +2743,15 @@ get_click_interval(PyObject *self UNUSED, PyObject *args UNUSED) { return PyFloat_FromDouble(monotonic_t_to_s_double(OPT(click_interval))); } +static PyObject* +glfw_get_keyboard_repeat_interval(PyObject *self UNUSED, PyObject *args UNUSED) { +#define DEFAULT_KEYBOARD_REPEAT_INTERVAL_MS 30ll + monotonic_t interval = ms_to_monotonic_t(DEFAULT_KEYBOARD_REPEAT_INTERVAL_MS); + glfwGetKeyboardRepeatDelay(NULL, &interval); + return PyFloat_FromDouble(monotonic_t_to_s_double(interval)); +#undef DEFAULT_KEYBOARD_REPEAT_INTERVAL_MS +} + id_type add_main_loop_timer(monotonic_t interval, bool repeats, timer_callback_fun callback, void *callback_data, timer_callback_fun free_callback) { return glfwAddTimer(interval, repeats, callback, callback_data, free_callback); @@ -3031,6 +3040,7 @@ static PyMethodDef module_methods[] = { METHODB(x11_display, METH_NOARGS), METHODB(wayland_compositor_data, METH_NOARGS), METHODB(get_click_interval, METH_NOARGS), + METHODB(glfw_get_keyboard_repeat_interval, METH_NOARGS), METHODB(is_layer_shell_supported, METH_NOARGS), METHODB(x11_window_id, METH_O), METHODB(strip_csi, METH_O), diff --git a/kitty/keys.c b/kitty/keys.c index ed9d00343..ac3d63175 100644 --- a/kitty/keys.c +++ b/kitty/keys.c @@ -292,7 +292,8 @@ on_key_input(const GLFWkeyevent *ev) { screen = w->render_data.screen; } else if (w->last_special_key_pressed == key) { w->last_special_key_pressed = 0; - debug("ignoring release event for previous press that was handled as shortcut\n"); + dispatch_key_event(on_shortcut_key_release); + debug("dispatched release event for shortcut key\n"); return; } if (w->buffered_keys.enabled) { diff --git a/kitty/mouse.c b/kitty/mouse.c index 732c0fb0c..95db938a4 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -20,6 +20,14 @@ static MouseShape mouse_cursor_shape = TEXT_POINTER; typedef enum MouseActions { PRESS, RELEASE, DRAG, MOVE, LEAVE } MouseAction; #define debug debug_input +static void +finish_scroll_animation(Screen *screen) { + if (screen->callbacks != Py_None) { + PyObject *ret = PyObject_CallMethod(screen->callbacks, "finish_scroll_animation", NULL); + if (ret == NULL) PyErr_Print(); else Py_DECREF(ret); + } +} + // Encoding of mouse events {{{ #define SHIFT_INDICATOR (1 << 2) #define ALT_INDICATOR (1 << 3) @@ -340,6 +348,7 @@ static bool do_drag_scroll(Window *w, bool upwards) { Screen *screen = w->render_data.screen; if (screen->linebuf == screen->main_linebuf) { + finish_scroll_animation(screen); screen_history_scroll(screen, SCROLL_LINE, upwards); update_drag(w); if (mouse_cursor_shape != DEFAULT_POINTER) { @@ -506,6 +515,7 @@ handle_scrollbar_track_click(Window *w, double mouse_y) { if (!w) return; Screen *screen = w->render_data.screen; if (!validate_scrollbar_state(w)) return; + finish_scroll_animation(screen); if (OPT(scrollbar_jump_on_click)) { ScrollbarGeometry geom = calculate_scrollbar_geometry(w); @@ -563,6 +573,7 @@ static void handle_scrollbar_drag(Window *w, double mouse_y) { if (!w || !w->scrollbar.is_dragging || !validate_scrollbar_state(w)) return; Screen *screen = w->render_data.screen; + finish_scroll_animation(screen); ScrollbarGeometry geom = calculate_scrollbar_geometry(w); double scrollbar_height = geom.bottom - geom.top; double mouse_pane_fraction = (mouse_y - geom.top) / scrollbar_height; @@ -1486,6 +1497,7 @@ scroll_event(const GLFWScrollEvent *ev) { case GLFW_MOMENTUM_PHASE_MAY_BEGIN: break; } + finish_scroll_animation(screen); if (ev->y_offset != 0.0) { if (screen->modes.mouse_tracking_mode == NO_TRACKING && pixel_scroll_enabled_for_screen(screen) && (ev->offset_type == GLFW_SCROLL_OFFEST_HIGHRES || ev->offset_type == GLFW_SCROLL_OFFEST_V120)) { double delta_pixels; diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 453aa1906..e5f31fc00 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -2901,32 +2901,32 @@ egr() # }}} agr('shortcuts.scrolling', 'Scrolling') map('Scroll line up', - 'scroll_line_up kitty_mod+up scroll_line_up', + 'scroll_line_up kitty_mod+up scroll_line_up smooth', ) map('Scroll line up', - 'scroll_line_up --allow-fallback=shifted,ascii kitty_mod+k scroll_line_up', + 'scroll_line_up --allow-fallback=shifted,ascii kitty_mod+k scroll_line_up smooth', ) map('Scroll line up', - 'scroll_line_up opt+cmd+page_up scroll_line_up', + 'scroll_line_up opt+cmd+page_up scroll_line_up smooth', only='macos', ) map('Scroll line up', - 'scroll_line_up cmd+up scroll_line_up', + 'scroll_line_up cmd+up scroll_line_up smooth', only='macos', ) map('Scroll line down', - 'scroll_line_down kitty_mod+down scroll_line_down', + 'scroll_line_down kitty_mod+down scroll_line_down smooth', ) map('Scroll line down', - 'scroll_line_down --allow-fallback=shifted,ascii kitty_mod+j scroll_line_down', + 'scroll_line_down --allow-fallback=shifted,ascii kitty_mod+j scroll_line_down smooth', ) map('Scroll line down', - 'scroll_line_down opt+cmd+page_down scroll_line_down', + 'scroll_line_down opt+cmd+page_down scroll_line_down smooth', only='macos', ) map('Scroll line down', - 'scroll_line_down cmd+down scroll_line_down', + 'scroll_line_down cmd+down scroll_line_down smooth', only='macos', ) diff --git a/kitty/options/types.py b/kitty/options/types.py index b95a0fc9d..e0b87943d 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -870,13 +870,13 @@ defaults.map = [ # pass_selection_to_program KeyDefinition(trigger=SingleKey(mods=256, key=111), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='pass_selection_to_program'), # scroll_line_up - KeyDefinition(trigger=SingleKey(mods=256, key=57352), definition='scroll_line_up'), + KeyDefinition(trigger=SingleKey(mods=256, key=57352), definition='scroll_line_up smooth'), # scroll_line_up - KeyDefinition(trigger=SingleKey(mods=256, key=107), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='scroll_line_up'), + KeyDefinition(trigger=SingleKey(mods=256, key=107), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='scroll_line_up smooth'), # scroll_line_down - KeyDefinition(trigger=SingleKey(mods=256, key=57353), definition='scroll_line_down'), + KeyDefinition(trigger=SingleKey(mods=256, key=57353), definition='scroll_line_down smooth'), # scroll_line_down - KeyDefinition(trigger=SingleKey(mods=256, key=106), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='scroll_line_down'), + KeyDefinition(trigger=SingleKey(mods=256, key=106), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='scroll_line_down smooth'), # scroll_page_up KeyDefinition(trigger=SingleKey(mods=256, key=57354), definition='scroll_page_up'), # scroll_page_down @@ -1022,10 +1022,10 @@ defaults.map = [ if is_macos: defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=99), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='copy_or_noop')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=118), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback=(KeyFallbackType.shifted, KeyFallbackType.alternate)), definition='paste_from_clipboard')) - defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=57354), definition='scroll_line_up')) - defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57352), definition='scroll_line_up')) - defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=57355), definition='scroll_line_down')) - defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57353), definition='scroll_line_down')) + defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=57354), definition='scroll_line_up smooth')) + defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57352), definition='scroll_line_up smooth')) + defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=57355), definition='scroll_line_down smooth')) + defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57353), definition='scroll_line_down smooth')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57354), definition='scroll_page_up')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57355), definition='scroll_page_down')) defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57356), definition='scroll_home')) diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 0fc010a12..fd686967a 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -88,6 +88,11 @@ def parse_send_text_bytes(text: str) -> bytes: return defines.expand_ansi_c_escapes(text).encode('utf-8') +@func_with_args('scroll_line_up', 'scroll_line_down') +def scroll_line_updown(func: str, rest: str) -> FuncArgsType: + return func, [rest.strip().lower() == 'smooth'] + + @func_with_args('scroll_prompt_to_top') def scroll_prompt_to_top(func: str, rest: str) -> FuncArgsType: return func, [to_bool(rest) if rest else False] diff --git a/kitty/screen.c b/kitty/screen.c index fddd40c66..5ae68eee5 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -5149,6 +5149,16 @@ fractional_scroll(Screen *self, PyObject *amt) { return Py_NewRef(screen_fractional_scroll(self, y) ? Py_True : Py_False); } +static PyObject* +scroll_to_absolute(Screen *self, PyObject *amt) { + double y; + if (PyFloat_Check(amt)) y = PyFloat_AS_DOUBLE(amt); + else if (PyLong_Check(amt)) y = PyLong_AsDouble(amt); + else { PyErr_SetString(PyExc_TypeError, "amt must be a number"); return NULL; } + screen_history_scroll_to_absolute(self, y); + Py_RETURN_NONE; +} + static PyObject* scroll(Screen *self, PyObject *args) { int amt, upwards; @@ -6055,6 +6065,7 @@ static PyMethodDef methods[] = { MND(text_for_marked_url, METH_VARARGS) MND(is_rectangle_select, METH_NOARGS) MND(scroll, METH_VARARGS) + MND(scroll_to_absolute, METH_O) MND(fractional_scroll, METH_O) MND(scroll_to_prompt, METH_VARARGS) MND(set_last_visited_prompt, METH_VARARGS) diff --git a/kitty/window.py b/kitty/window.py index b0a21ba31..9c493cf5b 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -71,6 +71,7 @@ from .fast_data_types import ( get_mouse_data_for_window, get_options, get_window_logo_settings_if_not_default, + glfw_get_keyboard_repeat_interval, is_css_pointer_name_valid, is_modifier_key, last_focused_os_window_id, @@ -80,6 +81,7 @@ from .fast_data_types import ( move_cursor_to_mouse_if_in_prompt, pointer_name_to_css_name, pt_to_px, + remove_timer, replace_c0_codes_except_nl_space_tab, set_redirect_keys_to_overlay, set_window_logo, @@ -121,6 +123,20 @@ from .utils import ( MatchPatternType = Union[Pattern[str], tuple[Pattern[str], Optional[Pattern[str]]]] +# Target ~60 fps for scroll animation ticks +_SCROLL_ANIMATION_FRAME_INTERVAL: float = 1.0 / 60.0 + + +class ScrollAnimation: + __slots__ = ('timer', 'start', 'duration', 'total', 'start_scrolled_by') + + def __init__(self) -> None: + self.timer: int = 0 + self.start: float = 0. + self.duration: float = 0. + self.total: float = 0. + self.start_scrolled_by: int = 0 + if TYPE_CHECKING: from kittens.tui.handler import OpenUrlHandler @@ -759,6 +775,7 @@ class Window: self.screen.copy_colors_from(copy_colors_from.screen) self.remote_control_passwords = remote_control_passwords self.allow_remote_control = allow_remote_control + self._scroll_animation = ScrollAnimation() def remote_control_allowed(self, pcmd: dict[str, Any], extra_data: dict[str, Any]) -> bool: if not self.allow_remote_control: @@ -2388,27 +2405,89 @@ class Window: def scroll_fractional_lines(self, amt: float) -> bool | None: ' Scroll fractionally, negative values are up and positive values are down ' if self.screen.is_main_linebuf(): + self.finish_scroll_animation() self.screen.fractional_scroll(amt) return None return True - @ac('sc', 'Scroll up by one line when in main screen. To scroll by different amounts, you can map the remote_control scroll-window action.') - def scroll_line_up(self) -> bool | None: + def _scroll_animation_tick(self, timer_id: int | None) -> None: + a = self._scroll_animation + if not a.timer: + return + now = monotonic() + elapsed = now - a.start + progress = min(1.0, elapsed / a.duration) if a.duration > 0 else 1.0 + if progress >= 1.0: + # Ensure we land exactly on a line boundary with pixel_scroll_offset_y = 0 + self.screen.scroll_to_absolute(max(0, a.start_scrolled_by - a.total)) + a.timer = 0 + else: + # Use absolute positioning to avoid pixel rounding errors from incremental fractional scrolls + self.screen.scroll_to_absolute(max(0.0, a.start_scrolled_by - a.total * progress)) + + def finish_scroll_animation(self) -> None: + ' Finish any in-progress scroll animation immediately ' + a = self._scroll_animation + if a.timer: + remove_timer(a.timer) + a.timer = 0 + # Scroll to the exact integer target line, ensuring pixel_scroll_offset_y = 0 + self.screen.scroll_to_absolute(max(0, a.start_scrolled_by - a.total)) + + def _start_scroll_animation(self, lines: float) -> None: + ' Start a smooth scroll animation for the given number of lines (negative=up, positive=down) ' + self.finish_scroll_animation() + if not self.screen.is_main_linebuf(): + return + duration = glfw_get_keyboard_repeat_interval() + if duration <= 0: + self.screen.fractional_scroll(lines) + return + a = self._scroll_animation + a.start = monotonic() + a.duration = duration + a.total = lines + a.start_scrolled_by = self.screen.scrolled_by + a.timer = add_timer(self._scroll_animation_tick, min(_SCROLL_ANIMATION_FRAME_INTERVAL, duration / 2), True) + + @ac('sc', ''' + Scroll up by one line when in main screen. To scroll by different amounts, you can map the remote_control + scroll-window action. Pass the ``smooth`` argument to have the scrolling be animated over the keyboard + repeat interval. For example:: + + map kitty_mod+up scroll_line_up smooth + ''') + def scroll_line_up(self, smooth: bool = False) -> bool | None: if self.screen.is_main_linebuf(): - self.screen.scroll(SCROLL_LINE, True) + if smooth: + self._start_scroll_animation(-1.0) + else: + self.finish_scroll_animation() + self.screen.scroll(SCROLL_LINE, True) return None return True - @ac('sc', 'Scroll down by one line when in main screen. To scroll by different amounts, you can map the remote_control scroll-window action.') - def scroll_line_down(self) -> bool | None: + @ac('sc', ''' + Scroll down by one line when in main screen. To scroll by different amounts, you can map the remote_control + scroll-window action. Pass the ``smooth`` argument to have the scrolling be animated over the keyboard + repeat interval. For example:: + + map kitty_mod+down scroll_line_down smooth + ''') + def scroll_line_down(self, smooth: bool = False) -> bool | None: if self.screen.is_main_linebuf(): - self.screen.scroll(SCROLL_LINE, False) + if smooth: + self._start_scroll_animation(1.0) + else: + self.finish_scroll_animation() + self.screen.scroll(SCROLL_LINE, False) return None return True @ac('sc', 'Scroll up by one page when in main screen. To scroll by different amounts, you can map the remote_control scroll-window action.') def scroll_page_up(self) -> bool | None: if self.screen.is_main_linebuf(): + self.finish_scroll_animation() self.screen.scroll(SCROLL_PAGE, True) return None return True @@ -2416,6 +2495,7 @@ class Window: @ac('sc', 'Scroll down by one page when in main screen. To scroll by different amounts, you can map the remote_control scroll-window action.') def scroll_page_down(self) -> bool | None: if self.screen.is_main_linebuf(): + self.finish_scroll_animation() self.screen.scroll(SCROLL_PAGE, False) return None return True @@ -2423,6 +2503,7 @@ class Window: @ac('sc', 'Scroll to the top of the scrollback buffer when in main screen') def scroll_home(self) -> bool | None: if self.screen.is_main_linebuf(): + self.finish_scroll_animation() self.screen.scroll(SCROLL_FULL, True) return None return True @@ -2430,6 +2511,7 @@ class Window: @ac('sc', 'Scroll to the bottom of the scrollback buffer when in main screen') def scroll_end(self) -> bool | None: if self.screen.is_main_linebuf(): + self.finish_scroll_animation() self.screen.scroll(SCROLL_FULL, False) return None return True @@ -2455,6 +2537,7 @@ class Window: ''') def scroll_to_prompt(self, num_of_prompts: int = -1, scroll_offset: int = 0) -> bool | None: if self.screen.is_main_linebuf(): + self.finish_scroll_animation() self.screen.scroll_to_prompt(num_of_prompts, scroll_offset) return None return True diff --git a/kitty_tests/__init__.py b/kitty_tests/__init__.py index cc1db836b..7e1da4b2c 100644 --- a/kitty_tests/__init__.py +++ b/kitty_tests/__init__.py @@ -153,6 +153,9 @@ class Callbacks: def on_activity_since_last_focus(self) -> None: pass + def finish_scroll_animation(self) -> None: + pass + def on_mouse_event(self, event): ev = MouseEvent(**event) opts = get_options()