Skip to content
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

Implement double/triple/quadruple click in TextArea #5405

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
12 changes: 7 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased


### Fixed

- Fixed `Pilot.click` not working with `times` parameter https://github.com/Textualize/textual/pull/5398

### Added

- Double clicking on a word in `TextArea` now selects the word https://github.com/Textualize/textual/pull/5405
- Triple clicking in `TextArea` now selects the clicked line (or paragraph if wrapping is enabled) https://github.com/Textualize/textual/pull/5405
- Quadruple clicking in `TextArea` now selects the entire document without scrolling the cursor into view https://github.com/Textualize/textual/pull/5405
- Added `TextArea.cursor_scroll_disabled` context manager to temporarily disable the automatic scrolling of the cursor into view https://github.com/Textualize/textual/pull/5405
- Added `from_app_focus` to `Focus` event to indicate if a widget is being focused because the app itself has regained focus or not https://github.com/Textualize/textual/pull/5379
- - Added `Select.type_to_search` which allows you to type to move the cursor to a matching option https://github.com/Textualize/textual/pull/5403

Expand All @@ -23,6 +22,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Updated `TextArea` and `Input` behavior when there is a selection and the user presses left or right https://github.com/Textualize/textual/pull/5400
- Footer can now be scrolled horizontally without holding `shift` https://github.com/Textualize/textual/pull/5404

### Fixed

- Fixed `Pilot.click` not working with `times` parameter https://github.com/Textualize/textual/pull/5398

## [1.0.0] - 2024-12-12

Expand Down
93 changes: 77 additions & 16 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@
import dataclasses
import re
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple
from typing import (
TYPE_CHECKING,
ClassVar,
Generator,
Iterable,
Optional,
Sequence,
Tuple,
)

from rich.console import RenderableType
from rich.style import Style
Expand Down Expand Up @@ -485,6 +494,15 @@ def __init__(
reactive is set as a string, the watcher will update this attribute to the
corresponding `TextAreaTheme` object."""

self._scroll_cursor_visible = True
"""When the cursor is moved in any way, it will scroll into view by default.

This flag can be used to switch that behavior off.

Don't set this directly, use the `disable_scroll_cursor_visible` context manager
instead.
"""

self.set_reactive(TextArea.soft_wrap, soft_wrap)
self.set_reactive(TextArea.read_only, read_only)
self.set_reactive(TextArea.show_line_numbers, show_line_numbers)
Expand Down Expand Up @@ -643,7 +661,8 @@ def _watch_selection(

cursor_location = selection.end

self.scroll_cursor_visible()
if self._scroll_cursor_visible:
self.scroll_cursor_visible()

cursor_row, cursor_column = cursor_location

Expand Down Expand Up @@ -1309,6 +1328,17 @@ def matching_bracket_location(self) -> Location | None:
"""The location of the matching bracket, if there is one."""
return self._matching_bracket_location

@contextmanager
def cursor_scroll_disabled(self) -> Generator[None, None, None]:
"""Temporarily disable the automatic scrolling of the cursor into view.

By default, the cursor will always scroll into view when it's moved, unless
the code which performs that movement is called inside this context manager.
"""
self._scroll_cursor_visible = False
yield
self._scroll_cursor_visible = True

def get_text_range(self, start: Location, end: Location) -> str:
"""Get the text between a start and end location.

Expand Down Expand Up @@ -1612,6 +1642,17 @@ async def _on_hide(self, event: events.Hide) -> None:
"""Finalize the selection that has been made using the mouse when the widget is hidden."""
self._end_mouse_selection()

async def on_click(self, event: events.Click) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this shouldn't use modulus. It doesn't look like vscode wraps the click chain in this way.

Copy link
Member Author

@darrenburns darrenburns Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Experimented with this. For now wrapping works better for us for reasons™.

Without the modulus here, when we get to chain == 5 and above, the selection goes back to being a cursor and it appears the click chaining stops working. I think the wrapping behaviour feels better than this, although I think the end-goal should be replicating VSCode's approach of doing nothing on the additional clicks and keeping the whole document selected, but that's just a bit tricky for now...

I think VSCode does chaining on the "mouse down" event. This would let us do a better implementation of this and remove a subtle flicker (between chain 2 and 3, the selection disappears briefly as we set the location in the mouse down handler, then the click handler fires later and selects the line). While we only have it on the click event, it's very difficult to replicate their behaviour exactly.

chain = event.chain
if chain % 4 == 0:
with self.cursor_scroll_disabled():
self.select_all()
elif chain % 3 == 0:
cursor_row, _ = self.cursor_location
self.select_line(cursor_row)
elif chain % 2 == 0:
self.select_word(self.cursor_location)

async def _on_paste(self, event: events.Paste) -> None:
"""When a paste occurs, insert the text from the paste event into the document."""
if self.read_only:
Expand Down Expand Up @@ -1735,6 +1776,18 @@ def move_cursor_relative(
target = clamp_visitable((current_row + rows, current_column + columns))
self.move_cursor(target, select, center, record_width)

def select_word(self, location: Location) -> None:
"""Select the word at the given location."""
# Search for the start and end of a word from the current location.
# If we want the search to be inclusive of the current location, so start
# the search for the left boundary from one character to the right.
left = self.get_word_right_location(
self.get_word_left_location(self.navigator.get_location_right(location))
)
right = self.get_word_left_location(self.get_word_right_location(location))
self.selection = Selection(*sorted((left, right)))
self.record_cursor_width()

def select_line(self, index: int) -> None:
"""Select all the text in the specified line.

Expand Down Expand Up @@ -1964,17 +2017,21 @@ def get_cursor_word_left_location(self) -> Location:
Returns:
The location the cursor will jump on "jump word left".
"""
cursor_row, cursor_column = self.cursor_location
if cursor_row > 0 and cursor_column == 0:
return self.get_word_left_location(self.cursor_location)

def get_word_left_location(self, start: Location) -> Location:
"""Get the location of the start of the word at the given location."""
start_row, start_column = start
if start_row > 0 and start_column == 0:
# Going to the previous row
return cursor_row - 1, len(self.document[cursor_row - 1])
return start_row - 1, len(self.document[start_row - 1])

# Staying on the same row
line = self.document[cursor_row][:cursor_column]
line = self.document[start_row][:start_column]
search_string = line.rstrip()
matches = list(re.finditer(self._word_pattern, search_string))
cursor_column = matches[-1].start() if matches else 0
return cursor_row, cursor_column
start_column = matches[-1].start() if matches else 0
return start_row, start_column

def action_cursor_word_right(self, select: bool = False) -> None:
"""Move the cursor right by a single word, skipping leading whitespace."""
Expand All @@ -1991,25 +2048,29 @@ def get_cursor_word_right_location(self) -> Location:
Returns:
The location the cursor will jump on "jump word right".
"""
cursor_row, cursor_column = self.selection.end
line = self.document[cursor_row]
if cursor_row < self.document.line_count - 1 and cursor_column == len(line):
return self.get_word_right_location(self.cursor_location)

def get_word_right_location(self, start: Location) -> Location:
"""Get the location of the end of the word at the given location."""
start_row, start_column = start
line = self.document[start_row]
if start_row < self.document.line_count - 1 and start_column == len(line):
# Moving to the line below
return cursor_row + 1, 0
return start_row + 1, 0

# Staying on the same line
search_string = line[cursor_column:]
search_string = line[start_column:]
pre_strip_length = len(search_string)
search_string = search_string.lstrip()
strip_offset = pre_strip_length - len(search_string)

matches = list(re.finditer(self._word_pattern, search_string))
if matches:
cursor_column += matches[0].start() + strip_offset
start_column += matches[0].start() + strip_offset
else:
cursor_column = len(line)
start_column = len(line)

return cursor_row, cursor_column
return start_row, start_column

def action_cursor_page_up(self) -> None:
"""Move the cursor and scroll up one page."""
Expand Down
Loading