kitty/kitty_tests/mouse.py
copilot-swe-agent[bot] 18fb31b416
Fix double-click word selection not extending beyond viewport edges
Add continue_word_upwards_scrollback() and continue_word_downwards_offscreen()
to extend word selection beyond viewport boundaries, mirroring the line
selection fix. Add tests for word selection wrapping into scrollback and
below viewport.

Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/58191e45-d925-4996-b0d5-8b1bd4baa8d5

Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com>
2026-04-07 09:49:50 +00:00

427 lines
13 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(), '')