kitty/kitty_tests/mouse.py
Francis Lavoie 86c5e13816
Wayland: do not ignore the scroll wheel after reversing direction
A high resolution wheel delivers one physical detent as several VALUE120
events, so pending_scroll_pixels accumulates fractions of a line and carries
the remainder to the next event. Detents do not reliably total 120 units, so
that carry is load bearing: it is what lets an undersized detent still scroll
a line.

It breaks down when the scroll direction changes, because the residual then
has the wrong sign and the first detent of the new direction is spent
cancelling it rather than scrolling. Measured on a Logitech MX Master 3 under
GNOME/Wayland, against a terminal program that had requested mouse events,
47% of detents that reversed direction scrolled nothing, against 2% of
detents continuing in the same direction.

Discard the residual when the direction changes, and leave the carry alone
otherwise, so scrolling in a single direction is unaffected.

The accumulator state also moves from Screen to OSWindow, since scroll events
are delivered to an OS Window rather than to a particular terminal window.
2026-08-02 15:47:31 -04:00

466 lines
15 KiB
Python

#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from functools import partial
from kitty.fast_data_types import (
GLFW_MOD_ALT,
GLFW_MOD_CONTROL,
GLFW_MOUSE_BUTTON_LEFT,
GLFW_MOUSE_BUTTON_RIGHT,
MOUSE_SELECTION_WORD_AND_LINE_FROM_POINT,
create_mock_window,
mock_mouse_selection,
send_mock_mouse_event_to_window,
)
from . import BaseTest
def send_mouse_event(
window,
button=-1,
modifiers=0,
is_release=False,
x=0.0,
y=0,
clear_click_queue=False,
):
ix = int(x)
in_left_half_of_cell = x - ix < 0.5
send_mock_mouse_event_to_window(
window, button, modifiers, is_release, ix, y, clear_click_queue, in_left_half_of_cell
)
class TestMouse(BaseTest):
def test_mouse_selection(self):
s = self.create_screen(
options=dict(
rectangle_select_modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL
)
)
w = create_mock_window(s)
ev = partial(send_mouse_event, w)
def mouse_selection(code: int) -> None:
mock_mouse_selection(w, s.callbacks.current_mouse_button, code)
s.callbacks.mouse_selection = mouse_selection
def sel():
return ''.join(s.text_for_selection())
def init():
s.reset()
s.draw('pqrst')
s.draw('uvwxy')
s.draw('ABCDE')
s.draw('FGHIJ')
s.draw('KLMNO')
s.draw('12345')
s.draw('67890')
s.draw('abcde')
s.draw('fghij')
s.draw('klmno')
def press(x=0, y=0, modifiers=0, button=GLFW_MOUSE_BUTTON_LEFT):
ev(button, x=x, y=y, modifiers=modifiers)
def release(x=0, y=0, button=GLFW_MOUSE_BUTTON_LEFT):
ev(
button,
x=x,
y=y,
is_release=True,
clear_click_queue=True
)
def move(x=0, y=0, button=-1, q=None):
ev(x=x, y=y, button=button)
if q is not None:
sl = sel()
from kitty.window import as_text
self.ae(sl, q, f'{sl!r} != {q!r} after movement to x={x} y={y}. Screen contents: {as_text(s)!r}')
def multi_click(x=0, y=0, count=2, modifiers=0):
clear_click_queue = True
while count > 0:
count -= 1
ev(GLFW_MOUSE_BUTTON_LEFT, x=x, y=y, modifiers=modifiers, clear_click_queue=clear_click_queue)
clear_click_queue = False
def scroll(x=0, y=0, up=True):
move(x=x, y=y, button=-2 if up else -3)
# Single line click, move, release test
init()
press()
move(x=3.6, q='1234')
release(x=3.6)
self.ae(sel(), '1234')
press(x=4), release(x=0.6)
self.ae(sel(), '234')
# multi line movement
init()
press(x=2, y=2)
move(x=2, y=1, q='890ab')
move(x=2.6, y=1, q='90ab')
move(y=1, q='67890ab')
move(x=4, y=1, q='0ab')
move(x=4.6, y=1, q='ab')
move(q='1234567890ab')
move(x=2, y=3, q='cdefg')
move(y=3, q='cde')
move(x=0.6, y=3, q='cdef')
move(x=2.6, y=3, q='cdefgh')
move(x=4.6, y=3, q='cdefghij')
# Single cell select
init()
press(), release(1)
self.ae(sel(), '1')
press(3), release(2)
self.ae(sel(), '3')
# Multi-line click release
init()
press(1, 1), release(3.6, 2)
self.ae(sel(), '7890abcd')
press(1.6, 1), release(3, 2)
self.ae(sel(), '890abc')
press(3.6, 4), release(2, 2)
self.ae(sel(), 'cdefghijklmn')
press(3, 4), release(2.6, 2)
self.ae(sel(), 'defghijklm')
# Word select with drag
s.reset()
s.draw('ab cd')
s.draw(' f gh')
s.draw(' stuv')
s.draw('X Y')
multi_click(x=1.4)
self.ae(sel(), 'ab')
move(2.6)
self.ae(sel(), 'ab ')
move(3.6)
self.ae(sel(), 'ab cd')
move(2.6)
self.ae(sel(), 'ab ')
release(3.6, 1)
self.ae(sel(), 'ab cd f gh')
multi_click(x=1, y=2)
self.ae(sel(), 'stuvX')
release()
multi_click(x=3.6)
self.ae(sel(), 'cd')
move(0.2)
release()
self.ae(sel(), 'ab cd')
multi_click(x=4.4)
self.ae(sel(), 'cd')
move(x=4.4, y=1)
self.ae(sel(), 'cd f gh')
move(x=4.4, y=0)
self.ae(sel(), 'cd')
release()
multi_click(x=4.4, y=1)
self.ae(sel(), 'gh')
move(x=4.4, y=0)
self.ae(sel(), 'cd f gh')
move(x=4.4, y=1)
self.ae(sel(), 'gh')
release()
multi_click(x=4.4)
self.ae(sel(), 'cd')
move()
self.ae(sel(), 'ab cd')
move(x=1, y=1)
self.ae(sel(), 'ab cd f')
move()
self.ae(sel(), 'ab cd')
release()
multi_click(x=1.4)
self.ae(sel(), 'ab')
move(x=4.4)
self.ae(sel(), 'ab cd')
move(x=4.4, y=1)
self.ae(sel(), 'ab cd f gh')
move(x=4.4)
self.ae(sel(), 'ab cd')
# Line select with drag
s.reset()
s.draw('1 2 3')
s.linefeed(), s.carriage_return()
s.draw('4 5 6')
s.linefeed(), s.carriage_return()
s.draw('7 8 9X')
multi_click(x=1, count=3)
self.ae(sel(), str(s.line(0)))
move(y=1)
self.ae(sel(), '1 2 3\n4 5 6')
move(y=2)
self.ae(sel(), '1 2 3\n4 5 6\n7 8 9X')
move(y=1)
self.ae(sel(), '1 2 3\n4 5 6')
move()
self.ae(sel(), str(s.line(0)))
release()
multi_click(y=1, count=3)
self.ae(sel(), '4 5 6')
move(y=0)
self.ae(sel(), '1 2 3\n4 5 6')
move(y=1)
self.ae(sel(), '4 5 6')
move(y=2)
self.ae(sel(), '4 5 6\n7 8 9X')
release()
s.reset()
s.draw(' 123')
s.linefeed(), s.carriage_return()
s.draw(' 456')
s.linefeed(), s.carriage_return()
multi_click(x=1, count=3)
self.ae(sel(), '123')
move(x=2, y=1)
self.ae(sel(), '123\n 456')
release()
press(x=2, y=1, button=GLFW_MOUSE_BUTTON_RIGHT)
release(x=2, y=1, button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), '123\n 456')
press(button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), ' 123\n 456')
release(button=GLFW_MOUSE_BUTTON_RIGHT)
# line select for wrapped lines in scrollback
s.reset()
s.draw('ABCDE12345')
s.linefeed(), s.carriage_return()
s.draw(('X' * s.columns) * (s.lines-1))
multi_click(x=1, count=3)
self.ae(sel(), 'ABCDE12345')
s.reset()
s.draw('ABCDE12345')
s.linefeed(), s.carriage_return()
s.draw('678')
s.linefeed(), s.carriage_return()
s.draw(('X' * s.columns) * (s.lines-2))
multi_click(x=1, y=1, count=3)
self.ae(sel(), '678')
press(x=2, button=GLFW_MOUSE_BUTTON_RIGHT)
release(x=2, button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), 'ABCDE12345\n678')
# line select for wrapped lines below viewport
s.reset()
s.scroll(100, False)
# draw enough content to enable scrolling: fill lines-1 visual lines, then draw a long wrapped line
s.draw(('X' * s.columns) * (s.lines - 1))
s.linefeed(), s.carriage_return()
s.draw('ABCDE12345') # wraps to 2 visual lines, second one pushed below viewport
# scroll up by 1 so top visual line of the wrapped text is visible but bottom wraps off-screen
s.scroll(1, True)
multi_click(x=1, y=s.lines - 1, count=3)
self.ae(sel(), 'ABCDE12345')
# extending selection to a line that wraps below viewport
s.reset()
s.scroll(100, False)
s.draw(('X' * s.columns) * (s.lines - 2))
s.linefeed(), s.carriage_return()
s.draw('678')
s.linefeed(), s.carriage_return()
s.draw('ABCDE12345')
s.scroll(1, True)
multi_click(x=1, y=3, count=3)
self.ae(sel(), '678')
press(x=2, y=s.lines - 1, button=GLFW_MOUSE_BUTTON_RIGHT)
release(x=2, y=s.lines - 1, button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), '678\nABCDE12345')
s.scroll(100, False)
# word select for wrapped words in scrollback
s.reset()
s.draw('abcde12345')
s.linefeed(), s.carriage_return()
s.draw(('X' * s.columns) * (s.lines-1))
multi_click(x=1)
self.ae(sel(), 'abcde12345')
# word select for wrapped words below viewport
s.reset()
s.scroll(100, False)
s.draw(('X' * s.columns) * (s.lines - 1))
s.linefeed(), s.carriage_return()
s.draw('abcde12345')
s.scroll(1, True)
multi_click(x=1, y=s.lines - 1)
self.ae(sel(), 'abcde12345')
s.scroll(100, False)
# Rectangle select
init()
press(x=1, y=1, modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL)
move(x=3.6, y=3)
self.ae(sel(), '789bcdghi')
release(x=3, y=3)
self.ae(sel(), '78bcgh')
press(x=3.6, y=1, modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL)
self.ae(sel(), '')
move(x=1, y=3)
self.ae(sel(), '789bcdghi')
release(x=1.6)
self.ae(sel(), '3489')
# scrolling
init()
press(x=1.6)
scroll(x=1)
self.ae(sel(), 'LMNO12')
scroll(x=1)
self.ae(sel(), 'GHIJKLMNO12')
scroll(x=1, up=False)
self.ae(sel(), 'LMNO12')
scroll(x=2.6, up=False)
self.ae(sel(), '3')
release()
# fractional scrolling
init()
s.fractional_scroll(-0.5)
press()
move(x=3.6, q='1234')
release(x=3.6)
self.ae(sel(), '1234')
# extending selections
init()
press()
move(x=3.6, q='1234')
release(x=3.6)
self.ae(sel(), '1234')
press(x=1, y=1, button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), '123456')
move(x=2, y=1, q='1234567')
release(x=3, y=1, button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), '12345678')
init()
press(y=2)
move(x=3.6, y=2, q='abcd')
press(x=3, y=0, button=GLFW_MOUSE_BUTTON_RIGHT)
self.ae(sel(), '4567890abcd')
# blank line select
s.reset()
s.draw('abcde')
s.linefeed(), s.carriage_return()
s.linefeed(), s.carriage_return()
s.draw('12345')
press(x=0, y=0)
move(x=2, y=2, q='abcde\n\n12')
# Line from begin select (alt+triple click): selects from column 0
# (including any leading whitespace), unlike normal triple click which strips it
s.reset()
s.draw(' 123')
s.linefeed(), s.carriage_return()
s.draw(' 456')
s.linefeed(), s.carriage_return()
multi_click(x=1, count=3, modifiers=GLFW_MOD_ALT)
self.ae(sel(), ' 123')
multi_click(x=1, count=3)
self.ae(sel(), '123')
multi_click(x=1, count=3, modifiers=GLFW_MOD_ALT)
move(y=1)
self.ae(sel(), ' 123\n 456')
release()
# Line from point select (ctrl+alt+triple click): selects from click
# position to end of line; cannot start before the first non-blank char
s.reset()
s.draw(' 123')
s.linefeed(), s.carriage_return()
s.draw(' 456')
s.linefeed(), s.carriage_return()
multi_click(x=2, count=3, modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL)
self.ae(sel(), '23')
# click before first non-blank: selection starts at first non-blank
multi_click(x=0, count=3, modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL)
self.ae(sel(), '123')
# click past last non-blank char: no selection created
multi_click(x=4, count=3, modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL)
self.ae(sel(), '')
# drag to next line
multi_click(x=2, count=3, modifiers=GLFW_MOD_ALT | GLFW_MOD_CONTROL)
move(y=1)
self.ae(sel(), '23\n 456')
release()
# Word and line from point: selects the word at the click point and
# extends the selection to the end of the line
sel_word_and_line = MOUSE_SELECTION_WORD_AND_LINE_FROM_POINT
s.reset()
s.draw('ab cd')
press(x=0)
mock_mouse_selection(w, GLFW_MOUSE_BUTTON_LEFT, sel_word_and_line)
self.ae(sel(), 'ab cd')
press(x=3)
mock_mouse_selection(w, GLFW_MOUSE_BUTTON_LEFT, sel_word_and_line)
self.ae(sel(), 'cd')
# click on space: no word, selects from space to end of line
press(x=2)
mock_mouse_selection(w, GLFW_MOUSE_BUTTON_LEFT, sel_word_and_line)
self.ae(sel(), ' cd')
# click past last non-blank char: no selection created
press(x=4)
mock_mouse_selection(w, GLFW_MOUSE_BUTTON_LEFT, sel_word_and_line)
self.ae(sel(), '')
# Double-click on whitespace: no word found, no selection created
s.reset()
s.draw('ab cd')
multi_click(x=2)
self.ae(sel(), '')
multi_click(x=2.4)
self.ae(sel(), '')
def test_v120_scroll_direction_reversal(self):
# A high resolution wheel delivers one physical detent as several
# VALUE120 events, and the fractional remainder is carried between them.
# Detents do not reliably total 120 units, so that carry is what lets an
# undersized detent still scroll a line. But when the direction changes
# the residual has the wrong sign, and the first detent of the new
# direction gets spent cancelling it instead of scrolling.
# The fragment sequences below were captured from a Logitech MX Master 3.
from kitty.fast_data_types import test_scale_scroll
self.set_options() # scale_scroll reads wheel_scroll_multiplier
cell = 40
def run(*v120):
# scale_scroll is exercised in mouse tracking mode, which reduces
# wheel_scroll_multiplier to its sign, so the result does not depend
# on the configured multiplier.
return test_scale_scroll([float(v) for v in v120], cell)
down = (-48, -32, -24, -24) # 128 units
up = (48, 32, 24, 24)
# Five detents down, then one up. Every detent must scroll, including
# the one that reverses direction. Before the residual was discarded on
# a reversal, that last detent scrolled nothing.
res = run(*(down * 5), *up)
self.ae(-5, sum(res[:20]), f'detents before the reversal: {res}')
self.ae(1, sum(res[20:]), f'detent that reversed direction: {res}')
# Repeated reversals keep working.
res = run(*(down * 2), *(up * 2), *(down * 2))
self.ae([-2, 2, -2], [sum(res[i:i + 8]) for i in range(0, 24, 8)])
# Scrolling in one direction is unaffected: the carry is retained, so a
# run of undersized detents still scrolls about one line each.
self.ae(-9, sum(run(*((-48, -24, -24, -16) * 10))))
# A fragment too small to be a detent does not scroll.
self.ae(0, sum(run(8)))