From 75ce50400e1c83b09127123dddca8e59f37bf725 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 22 Jan 2026 13:05:22 +0530 Subject: [PATCH] Remote control: scroll-window: Allow fractional scrolling since we now have pixel scroll --- kitty/fast_data_types.pyi | 3 +++ kitty/rc/scroll_window.py | 35 +++++++++++++++--------------- kitty/screen.c | 40 +++++++++++++++++++++++++++++++++++ kitty/window.py | 7 ++++++ tools/cmd/at/scroll_window.go | 2 +- 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 324355ce7..a38693fab 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1317,6 +1317,9 @@ class Screen: def scroll(self, amt: int, upwards: bool) -> bool: pass + def fractional_scroll(self, amt: float) -> bool: + pass + def scroll_to_next_mark(self, mark: int = 0, backwards: bool = True) -> bool: pass diff --git a/kitty/rc/scroll_window.py b/kitty/rc/scroll_window.py index d33f475ca..1520eebd9 100644 --- a/kitty/rc/scroll_window.py +++ b/kitty/rc/scroll_window.py @@ -16,7 +16,7 @@ class ScrollWindow(RemoteCommand): amount+/list.scroll_amount: The amount to scroll, a two item list with the first item being \ either a number or the keywords, start and end. \ And the second item being either 'p' for pages or 'l' for lines or 'u' - for unscrolling by lines, or 'r' for scrolling ot prompt. + for unscrolling by lines, or 'r' for scrolling to prompt. match/str: The window to scroll ''' @@ -26,7 +26,7 @@ class ScrollWindow(RemoteCommand): ' :italic:`SCROLL_AMOUNT` can be either the keywords :code:`start` or :code:`end` or an' ' argument of the form :italic:`[unit][+-]`. :code:`unit` can be :code:`l` for lines, :code:`p` for pages,' ' :code:`u` for unscroll and :code:`r` for scroll to prompt. If unspecified, :code:`l` is the default.' - ' For example, :code:`30` will scroll down 30 lines, :code:`2p-`' + ' For example, :code:`30.5` will scroll down 30 and a half lines, :code:`2p-`' ' will scroll up 2 pages and :code:`0.5p` will scroll down half page.' ' :code:`3u` will *unscroll* by 3 lines, which means that 3 lines will move from the' ' scrollback buffer onto the top of the screen. :code:`1r-` will scroll to the previous prompt and :code:`1r` to the next prompt.' @@ -52,7 +52,7 @@ def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: Arg prompt = 'r' in amt mult = -1 if amt.endswith('-') and not unscroll else 1 q = float(amt.rstrip('+-plur')) - if not pages and not q.is_integer(): + if (unscroll or prompt) and not q.is_integer(): self.fatal('The number must be an integer') amount = q * mult, 'p' if pages else ('u' if unscroll else ('r' if prompt else 'l')) @@ -64,22 +64,23 @@ def response_from_kitty(self, boss: Boss, window: Window | None, payload_get: Pa for window in self.windows_for_match_payload(boss, window, payload_get): if window: if amt[0] in ('start', 'end'): - getattr(window, {'start': 'scroll_home'}.get(amt[0], 'scroll_end'))() + (window.scroll_home if amt[0] == 'start' else window.scroll_end)() else: amt, unit = amt - if unit == 'u': - window.screen.reverse_scroll(int(abs(amt)), True) - elif unit == 'r': - window.scroll_to_prompt(int(amt)) - else: - unit = 'page' if unit == 'p' else 'line' - if unit == 'page' and not isinstance(amt, int) and not amt.is_integer(): - amt = round(window.screen.lines * amt) - unit = 'line' - direction = 'up' if amt < 0 else 'down' - func = getattr(window, f'scroll_{unit}_{direction}') - for i in range(int(abs(amt))): - func() + match unit: + case 'u': + window.screen.reverse_scroll(int(abs(amt)), True) + case 'r': + window.scroll_to_prompt(int(amt)) + case 'l': + window.scroll_fractional_lines(amt) + case 'p': + if not isinstance(amt, int) and not amt.is_integer(): + amt = round(window.screen.lines * amt) + unit = 'line' + func = window.scroll_page_up if amt < 0 else window.scroll_page_down + for i in range(int(abs(amt))): + func() return None diff --git a/kitty/screen.c b/kitty/screen.c index 8f21ba3b6..461815ab3 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -5066,6 +5066,45 @@ screen_history_scroll(Screen *self, int amt, bool upwards) { return false; } +static bool +screen_fractional_scroll(Screen *self, double amt) { + if (amt == 0) return false; + index_type before_scrolled_by = self->scrolled_by; + double before_pixels = self->pixel_scroll_offset_y; + double integral_part, fractional_part = modf(amt, &integral_part); + int lines = (int)integral_part; + double pixels = fractional_part * self->cell_size.height; + if (amt > 0) { // downwards + pixels = pixels > self->pixel_scroll_offset_y ? pixels - self->pixel_scroll_offset_y : 0; + self->pixel_scroll_offset_y = 0; + self->scrolled_by = self->scrolled_by > (unsigned)lines ? self->scrolled_by - lines : 0; + if (pixels > 0 && self->scrolled_by > 0) { + self->scrolled_by--; self->pixel_scroll_offset_y = self->cell_size.height - pixels; + } + } else { + self->pixel_scroll_offset_y -= pixels; // pixels is negative + if (self->pixel_scroll_offset_y >= self->cell_size.height) { + self->pixel_scroll_offset_y = 0; self->scrolled_by++; + } + self->scrolled_by = MIN(self->scrolled_by - lines, self->historybuf->count); + if (self->scrolled_by >= self->historybuf->count) self->pixel_scroll_offset_y = 0; + } + if (self->scrolled_by != before_scrolled_by || self->pixel_scroll_offset_y != before_pixels) { + dirty_scroll(self); + return true; + } + return false; +} + +static PyObject* +fractional_scroll(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 float"); return NULL; } + return Py_NewRef(screen_fractional_scroll(self, y) ? Py_True : Py_False); +} + static PyObject* scroll(Screen *self, PyObject *args) { int amt, upwards; @@ -5939,6 +5978,7 @@ static PyMethodDef methods[] = { MND(text_for_marked_url, METH_VARARGS) MND(is_rectangle_select, METH_NOARGS) MND(scroll, METH_VARARGS) + MND(fractional_scroll, METH_O) MND(scroll_to_prompt, METH_VARARGS) MND(set_last_visited_prompt, METH_VARARGS) MND(send_escape_code_to_child, METH_VARARGS) diff --git a/kitty/window.py b/kitty/window.py index 692a28770..7dd134e15 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -2234,6 +2234,13 @@ def pass_selection_to_program(self, *args: str) -> None: def clear_selection(self) -> None: self.screen.clear_selection() + 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.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: if self.screen.is_main_linebuf(): diff --git a/tools/cmd/at/scroll_window.go b/tools/cmd/at/scroll_window.go index 01a623438..35e6bcbcb 100644 --- a/tools/cmd/at/scroll_window.go +++ b/tools/cmd/at/scroll_window.go @@ -25,7 +25,7 @@ func parse_scroll_amount(amt string) ([]any, error) { if err != nil { return ans, err } - if !pages && q != float64(int(q)) { + if (unscroll || prompt) && q != float64(int(q)) { return ans, fmt.Errorf("The number must be an integer") } ans[0] = q * mult