From 5edcab02234d035a18f78a46e5ee8481e596e339 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 17 Dec 2024 17:03:43 +0000 Subject: [PATCH 1/4] Implement double/triple/quadruple click in TextArea --- src/textual/widgets/_text_area.py | 86 +++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index b011d8d8ae..d186ad1da6 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -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 @@ -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) @@ -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 @@ -1309,6 +1328,12 @@ 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]: + 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. @@ -1612,6 +1637,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: + 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: @@ -1735,6 +1771,16 @@ 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_left_location(self.navigator.get_location_right(location)) + right = self.get_word_right_location(location) + self.selection = Selection(left, right) + self.record_cursor_width() + def select_line(self, index: int) -> None: """Select all the text in the specified line. @@ -1952,17 +1998,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.""" @@ -1979,25 +2029,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.""" From cfe93cb49d73bfacc02a952b2e08737554243e7c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 17 Dec 2024 17:18:03 +0000 Subject: [PATCH 2/4] Add a docstring --- src/textual/widgets/_text_area.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index d186ad1da6..4cb234e077 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1330,6 +1330,11 @@ def matching_bracket_location(self) -> Location | None: @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 From 6ce0ff026eaea2b1f1c5597e37fdfded6c6c62e9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 17 Dec 2024 17:21:35 +0000 Subject: [PATCH 3/4] Update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ff52f8ca..6d197ccdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### 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 + ## [1.0.0] - 2024-12-12 ### Added From 68101a01163a392536d46ea4d7efc2f8823eb011 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Dec 2024 15:17:39 +0000 Subject: [PATCH 4/4] Fix click chain support --- src/textual/widgets/_text_area.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 4b3d75c1b0..aecd788132 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1781,9 +1781,11 @@ def select_word(self, location: Location) -> None: # 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_left_location(self.navigator.get_location_right(location)) - right = self.get_word_right_location(location) - self.selection = Selection(left, 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: