Implement smooth animated scrolling for scroll_line_up and scroll_line_down

Fixes #9784
This commit is contained in:
copilot-swe-agent[bot]
2026-03-31 11:23:28 +00:00
committed by Kovid Goyal
parent 50cc4f6630
commit 35ca3a178d
12 changed files with 167 additions and 23 deletions

View File

@@ -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 <mapping-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

View File

@@ -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()

View File

@@ -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

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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',
)

16
kitty/options/types.py generated
View File

@@ -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'))

View File

@@ -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]

View File

@@ -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)

View File

@@ -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

View File

@@ -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()