Skip to content

Improved mouse support #1481

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 37 additions & 24 deletions prompt_toolkit/input/win32.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from prompt_toolkit.eventloop.win32 import create_win32_event, wait_for_handles
from prompt_toolkit.key_binding.key_processor import KeyPress
from prompt_toolkit.keys import Keys
from prompt_toolkit.mouse_events import MouseEventType
from prompt_toolkit.mouse_events import MouseButton, MouseEventType
from prompt_toolkit.win32_types import (
INPUT_RECORD,
KEY_EVENT_RECORD,
Expand All @@ -50,6 +50,13 @@
"detach_win32_input",
]

# Win32 Constants for MOUSE_EVENT_RECORD.
# See: https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str
FROM_LEFT_1ST_BUTTON_PRESSED = 0x1
RIGHTMOST_BUTTON_PRESSED = 0x2
MOUSE_MOVED = 0x0001
MOUSE_WHEELED = 0x0004


class _Win32InputBase(Input):
"""
Expand Down Expand Up @@ -509,42 +516,48 @@ def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) -> List[KeyPress]:
"""
Handle mouse events. Return a list of KeyPress instances.
"""
FROM_LEFT_1ST_BUTTON_PRESSED = 0x1
MOUSE_MOVED = 0x0001
MOUSE_WHEELED = 0x0004

event_flags = ev.EventFlags
button_state = ev.ButtonState

result = []
event_type: Optional[MouseEventType] = None

# Move events.
if event_flags & MOUSE_MOVED:
if button_state == FROM_LEFT_1ST_BUTTON_PRESSED:
event_type = MouseEventType.MOUSE_DOWN_MOVE
button: MouseButton = MouseButton.NONE

# Scroll events.
elif event_flags & MOUSE_WHEELED:
if event_flags & MOUSE_WHEELED:
if button_state > 0:
event_type = MouseEventType.SCROLL_UP
else:
event_type = MouseEventType.SCROLL_DOWN
else:
# Handle button state for non-scroll events.
if button_state == FROM_LEFT_1ST_BUTTON_PRESSED:
button = MouseButton.LEFT

# Mouse down (left button).
elif button_state == FROM_LEFT_1ST_BUTTON_PRESSED:
event_type = MouseEventType.MOUSE_DOWN
elif button_state == RIGHTMOST_BUTTON_PRESSED:
button = MouseButton.RIGHT

# No key pressed anymore: mouse up.
else:
event_type = MouseEventType.MOUSE_UP
# Move events.
if event_flags & MOUSE_MOVED:
event_type = MouseEventType.MOUSE_MOVE

if event_type is not None:
data = ";".join(
[event_type.value, str(ev.MousePosition.X), str(ev.MousePosition.Y)]
)
result.append(KeyPress(Keys.WindowsMouseEvent, data))
return result
# No key pressed anymore: mouse up.
if event_type is None:
if button_state > 0:
# Some button pressed.
event_type = MouseEventType.MOUSE_DOWN
else:
# No button pressed.
event_type = MouseEventType.MOUSE_UP

data = ";".join(
[
button.value,
event_type.value,
str(ev.MousePosition.X),
str(ev.MousePosition.Y),
]
)
return [KeyPress(Keys.WindowsMouseEvent, data)]


class _Win32Handles:
Expand Down
253 changes: 226 additions & 27 deletions prompt_toolkit/key_binding/bindings/mouse.py

Large diffs are not rendered by default.

40 changes: 36 additions & 4 deletions prompt_toolkit/key_binding/key_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def my_key_binding(event):
kb.add(Keys.A, my_key_binding)
"""
from abc import ABCMeta, abstractmethod, abstractproperty
from inspect import isawaitable
from typing import (
TYPE_CHECKING,
Awaitable,
Expand All @@ -53,12 +54,27 @@ def my_key_binding(event):
from prompt_toolkit.filters import FilterOrBool, Never, to_filter
from prompt_toolkit.keys import KEY_ALIASES, Keys

# Avoid circular imports.
if TYPE_CHECKING:
# Avoid circular imports.
from .key_processor import KeyPressEvent

# The only two return values for a mouse hander (and key bindings) are
# `None` and `NotImplemented`. For the type checker it's best to annotate
# this as `object`. (The consumer never expects a more specific instance:
# checking for NotImplemented can be done using `is NotImplemented`.)
NotImplementedOrNone = object
# Other non-working options are:
# * Optional[Literal[NotImplemented]]
# --> Doesn't work, Literal can't take an Any.
# * None
# --> Doesn't work. We can't assign the result of a function that
# returns `None` to a variable.
# * Any
# --> Works, but too broad.


__all__ = [
"NotImplementedOrNone",
"Binding",
"KeyBindingsBase",
"KeyBindings",
Expand All @@ -68,7 +84,13 @@ def my_key_binding(event):
"GlobalOnlyKeyBindings",
]

KeyHandlerCallable = Callable[["KeyPressEvent"], Union[None, Awaitable[None]]]
# Key bindings can be regular functions or coroutines.
# In both cases, if they return `NotImplemented`, the UI won't be invalidated.
# This is mainly used in case of mouse move events, to prevent excessive
# repainting during mouse move events.
KeyHandlerCallable = Callable[
["KeyPressEvent"], Union["NotImplementedOrNone", Awaitable["NotImplementedOrNone"]]
]


class Binding:
Expand Down Expand Up @@ -102,8 +124,18 @@ def call(self, event: "KeyPressEvent") -> None:
result = self.handler(event)

# If the handler is a coroutine, create an asyncio task.
if result is not None:
event.app.create_background_task(result)
if isawaitable(result):
awaitable = cast(Awaitable["NotImplementedOrNone"], result)

async def bg_task() -> None:
result = await awaitable
if result != NotImplemented:
event.app.invalidate()

event.app.create_background_task(bg_task())

elif result != NotImplemented:
event.app.invalidate()

def __repr__(self) -> str:
return "%s(keys=%r, handler=%r)" % (
Expand Down
8 changes: 0 additions & 8 deletions prompt_toolkit/key_binding/key_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,9 @@ def get_next() -> KeyPress:
else:
return self.input_queue.popleft()

keys_processed = False
is_flush = False

while not_empty():
keys_processed = True

# Process next key.
key_press = get_next()

Expand All @@ -277,16 +274,11 @@ def get_next() -> KeyPress:
# an exception was raised) restart the processor for next time.
self.reset()
self.empty_queue()
app.invalidate()
raise

if not is_flush and not is_cpr:
self.after_key_press.fire()

if keys_processed:
# Invalidate user interface.
app.invalidate()

# Skip timeout if the last key was flush.
if not is_flush:
self._start_timeout()
Expand Down
34 changes: 26 additions & 8 deletions prompt_toolkit/layout/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
if TYPE_CHECKING:
from typing_extensions import Protocol, TypeGuard

NotImplementedOrNone = object
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone


__all__ = [
"AnyContainer",
Expand Down Expand Up @@ -1829,13 +1830,16 @@ def _write_to_screen_at_index(
self.render_info = render_info

# Set mouse handlers.
def mouse_handler(mouse_event: MouseEvent) -> None:
"""Wrapper around the mouse_handler of the `UIControl` that turns
screen coordinates into line coordinates."""
def mouse_handler(mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Wrapper around the mouse_handler of the `UIControl` that turns
screen coordinates into line coordinates.
Returns `NotImplemented` if no UI invalidation should be done.
"""
# Don't handle mouse events outside of the current modal part of
# the UI.
if self not in get_app().layout.walk_through_modal_area():
return
return NotImplemented

# Find row/col position first.
yx_to_rowcol = {v: k for k, v in rowcol_to_yx.items()}
Expand All @@ -1861,6 +1865,8 @@ def mouse_handler(mouse_event: MouseEvent) -> None:
MouseEvent(
position=Point(x=col, y=row),
event_type=mouse_event.event_type,
button=mouse_event.button,
modifiers=mouse_event.modifiers,
)
)
break
Expand All @@ -1871,13 +1877,18 @@ def mouse_handler(mouse_event: MouseEvent) -> None:
# Report (0,0) instead.)
result = self.content.mouse_handler(
MouseEvent(
position=Point(x=0, y=0), event_type=mouse_event.event_type
position=Point(x=0, y=0),
event_type=mouse_event.event_type,
button=mouse_event.button,
modifiers=mouse_event.modifiers,
)
)

# If it returns NotImplemented, handle it here.
if result == NotImplemented:
self._mouse_handler(mouse_event)
result = self._mouse_handler(mouse_event)

return result

mouse_handlers.set_mouse_handler_for_range(
x_min=write_position.xpos + sum(left_margin_widths),
Expand Down Expand Up @@ -2547,15 +2558,22 @@ def do_scroll(
),
)

def _mouse_handler(self, mouse_event: MouseEvent) -> None:
def _mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Mouse handler. Called when the UI control doesn't handle this
particular event.

Return `NotImplemented` if nothing was done as a consequence of this
key binding (no UI invalidate required in that case).
"""
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
self._scroll_down()
return None
elif mouse_event.event_type == MouseEventType.SCROLL_UP:
self._scroll_up()
return None

return NotImplemented

def _scroll_down(self) -> None:
"Scroll window down."
Expand Down
32 changes: 14 additions & 18 deletions prompt_toolkit/layout/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
split_lines,
)
from prompt_toolkit.lexers import Lexer, SimpleLexer
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
from prompt_toolkit.search import SearchState
from prompt_toolkit.selection import SelectionType
from prompt_toolkit.utils import get_cwidth
Expand All @@ -48,23 +48,12 @@
)

if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import KeyBindingsBase
from prompt_toolkit.key_binding.key_bindings import (
KeyBindingsBase,
NotImplementedOrNone,
)
from prompt_toolkit.utils import Event

# The only two return values for a mouse hander are `None` and
# `NotImplemented`. For the type checker it's best to annotate this as
# `object`. (The consumer never expects a more specific instance: checking
# for NotImplemented can be done using `is NotImplemented`.)
NotImplementedOrNone = object
# Other non-working options are:
# * Optional[Literal[NotImplemented]]
# --> Doesn't work, Literal can't take an Any.
# * None
# --> Doesn't work. We can't assign the result of a function that
# returns `None` to a variable.
# * Any
# --> Works, but too broad.


__all__ = [
"BufferControl",
Expand Down Expand Up @@ -858,8 +847,15 @@ def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
buffer.exit_selection()
buffer.cursor_position = index

elif mouse_event.event_type == MouseEventType.MOUSE_DOWN_MOVE:
if buffer.selection_state is None:
elif (
mouse_event.event_type == MouseEventType.MOUSE_MOVE
and mouse_event.button != MouseButton.NONE
):
# Click and drag to highlight a selection
if (
buffer.selection_state is None
and abs(buffer.cursor_position - index) > 0
):
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
buffer.cursor_position = index

Expand Down
6 changes: 4 additions & 2 deletions prompt_toolkit/layout/menus.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@
from .margins import ScrollbarMargin

if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import KeyBindings
from prompt_toolkit.key_binding.key_bindings import (
KeyBindings,
NotImplementedOrNone,
)

NotImplementedOrNone = object

__all__ = [
"CompletionsMenu",
Expand Down
14 changes: 9 additions & 5 deletions prompt_toolkit/layout/mouse_handlers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from collections import defaultdict
from itertools import product
from typing import Callable, DefaultDict, Tuple
from typing import TYPE_CHECKING, Callable, DefaultDict

from prompt_toolkit.mouse_events import MouseEvent

if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone

__all__ = [
"MouseHandler",
"MouseHandlers",
]

MouseHandler = Callable[[MouseEvent], None]

MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"]


class MouseHandlers:
Expand All @@ -18,10 +21,11 @@ class MouseHandlers:
"""

def __init__(self) -> None:
def dummy_callback(mouse_event: MouseEvent) -> None:
def dummy_callback(mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
:param mouse_event: `MouseEvent` instance.
"""
return NotImplemented

# NOTE: Previously, the data structure was a dictionary mapping (x,y)
# to the handlers. This however would be more inefficient when copying
Expand All @@ -38,7 +42,7 @@ def set_mouse_handler_for_range(
x_max: int,
y_min: int,
y_max: int,
handler: Callable[[MouseEvent], None],
handler: Callable[[MouseEvent], "NotImplementedOrNone"],
) -> None:
"""
Set mouse handler for a region.
Expand Down
Loading