From c26e1675f767a8324da580b17b4ad67656228665 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 14:11:38 +0100 Subject: [PATCH 1/4] Add show cursor boolean --- src/textual/widgets/_text_area.py | 102 ++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 18 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 5b89494ddd..420298af35 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -172,7 +172,7 @@ class TextArea(ScrollView): &.-read-only .text-area--cursor { background: $warning-darken-1; } - } + } } """ @@ -373,6 +373,15 @@ class TextArea(ScrollView): The document can still be edited programmatically via the API. """ + show_cursor: Reactive[bool] = reactive(True) + """Show the cursor in read only mode? + + If `False`, the cursor will be visible when `read_only==True`. + If `True`, the cursor will not be visible when `read_only==True`, and the TextArea may be + scroll like other containers. + + """ + compact: reactive[bool] = reactive(False, toggle_class="-textual-compact") """Enable compact display?""" @@ -423,6 +432,7 @@ def __init__( soft_wrap: bool = True, tab_behavior: Literal["focus", "indent"] = "focus", read_only: bool = False, + show_cursor: bool = True, show_line_numbers: bool = False, line_number_start: int = 1, max_checkpoints: int = 50, @@ -443,6 +453,7 @@ def __init__( soft_wrap: Enable soft wrapping. tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. read_only: Enable read-only mode. This prevents edits using the keyboard. + show_cursor: Show the cursor in read only mode (no effect otherwise). show_line_numbers: Show line numbers on the left edge. line_number_start: What line number to start on. max_checkpoints: The maximum number of undo history checkpoints to retain. @@ -509,6 +520,7 @@ def __init__( self.set_reactive(TextArea.soft_wrap, soft_wrap) self.set_reactive(TextArea.read_only, read_only) + self.set_reactive(TextArea.show_cursor, show_cursor) self.set_reactive(TextArea.show_line_numbers, show_line_numbers) self.set_reactive(TextArea.line_number_start, line_number_start) self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line) @@ -542,6 +554,7 @@ def code_editor( soft_wrap: bool = False, tab_behavior: Literal["focus", "indent"] = "indent", read_only: bool = False, + show_cursor: bool = True, show_line_numbers: bool = True, line_number_start: int = 1, max_checkpoints: int = 50, @@ -564,6 +577,8 @@ def code_editor( theme: The theme to use. soft_wrap: Enable soft wrapping. tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + read_only: Enable read-only mode. This prevents edits using the keyboard. + show_cursor: Show the cursor in read only mode (no effect otherwise). show_line_numbers: Show line numbers on the left edge. line_number_start: What line number to start on. name: The name of the `TextArea` widget. @@ -581,6 +596,7 @@ def code_editor( soft_wrap=soft_wrap, tab_behavior=tab_behavior, read_only=read_only, + show_cursor=show_cursor, show_line_numbers=show_line_numbers, line_number_start=line_number_start, max_checkpoints=max_checkpoints, @@ -1101,6 +1117,24 @@ def _refresh_size(self) -> None: width, height = self.document.get_size(self.indent_width) self.virtual_size = Size(width + self.gutter_width + 1, height) + @property + def _draw_cursor(self) -> bool: + """Draw the cursor?""" + if self.read_only: + # If we are in read only mode, we don't want the cursor to blink + return self.show_cursor and self.has_focus + draw_cursor = ( + self.has_focus + and not self.cursor_blink + or (self.cursor_blink and self._cursor_visible) + ) + return draw_cursor + + @property + def _has_cursor(self) -> bool: + """Is there a usable cursor?""" + return not (self.read_only and not self.show_cursor) + def get_line(self, line_index: int) -> Text: """Retrieve the line at the given line index. @@ -1158,6 +1192,7 @@ def render_line(self, y: int) -> Strip: self.soft_wrap, self.show_line_numbers, self.read_only, + self.show_cursor, ) if (cached_line := self._line_cache.get(cache_key)) is not None: return cached_line @@ -1213,12 +1248,13 @@ def _render_line(self, y: int) -> Strip: selection_top_row, selection_top_column = selection_top selection_bottom_row, selection_bottom_column = selection_bottom - cursor_line_style = theme.cursor_line_style if theme else None - if ( - cursor_line_style - and cursor_row == line_index - and self.highlight_cursor_line - ): + highlight_cursor_line = self.highlight_cursor_line and self._has_cursor + cursor_line_style = ( + theme.cursor_line_style if (theme and highlight_cursor_line) else None + ) + has_cursor = self._has_cursor + + if has_cursor and cursor_line_style and cursor_row == line_index: line.stylize(cursor_line_style) # Selection styling @@ -1271,16 +1307,14 @@ def _render_line(self, y: int) -> Strip: matching_bracket = self._matching_bracket_location match_cursor_bracket = self.match_cursor_bracket draw_matched_brackets = ( - match_cursor_bracket and matching_bracket is not None and start == end + has_cursor + and match_cursor_bracket + and matching_bracket is not None + and start == end ) if cursor_row == line_index: - draw_cursor = ( - self.has_focus - and not self.cursor_blink - or (self.cursor_blink and self._cursor_visible) - and not self.read_only - ) + draw_cursor = self._draw_cursor if draw_matched_brackets: matching_bracket_style = theme.bracket_matching_style if theme else None if matching_bracket_style: @@ -1312,7 +1346,7 @@ def _render_line(self, y: int) -> Strip: # Build the gutter text for this line gutter_width = self.gutter_width if self.show_line_numbers: - if cursor_row == line_index and self.highlight_cursor_line: + if cursor_row == line_index and highlight_cursor_line: gutter_style = theme.cursor_line_gutter_style else: gutter_style = theme.gutter_style @@ -1565,8 +1599,6 @@ def _redo_batch(self, edits: Sequence[Edit]) -> None: async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" self._restart_blink() - if self.read_only: - return key = event.key insert_values = { @@ -1584,6 +1616,9 @@ async def _on_key(self, event: events.Key) -> None: insert_values["tab"] = " " * self._find_columns_to_next_tab_stop() if event.is_printable or key in insert_values: + if self.read_only: + self.app.bell() + return event.stop() event.prevent_default() insert = insert_values.get(key, event.character) @@ -1775,6 +1810,8 @@ def scroll_cursor_visible( Returns: The offset that was scrolled to bring the cursor into view. """ + if not self._has_cursor: + return Offset(0, 0) self._recompute_cursor_offset() x, y = self._cursor_offset @@ -1804,6 +1841,8 @@ def move_cursor( so that we jump back to the same width the next time we move to a row that is wide enough. """ + if not self._has_cursor: + return if select: start, _end = self.selection self.selection = Selection(start, location) @@ -1948,6 +1987,9 @@ def action_cursor_left(self, select: bool = False) -> None: Args: select: If True, select the text while moving. """ + if not self._has_cursor: + self.scroll_left() + return target = ( self.get_cursor_left_location() if select or self.selection.is_empty @@ -1973,6 +2015,9 @@ def action_cursor_right(self, select: bool = False) -> None: Args: select: If True, select the text while moving. """ + if not self._has_cursor: + self.scroll_right() + return target = ( self.get_cursor_right_location() if select or self.selection.is_empty @@ -1994,6 +2039,9 @@ def action_cursor_down(self, select: bool = False) -> None: Args: select: If True, select the text while moving. """ + if not self._has_cursor: + self.scroll_down() + return target = self.get_cursor_down_location() self.move_cursor(target, record_width=False, select=select) @@ -2011,6 +2059,9 @@ def action_cursor_up(self, select: bool = False) -> None: Args: select: If True, select the text while moving. """ + if not self._has_cursor: + self.scroll_up() + return target = self.get_cursor_up_location() self.move_cursor(target, record_width=False, select=select) @@ -2024,6 +2075,9 @@ def get_cursor_up_location(self) -> Location: def action_cursor_line_end(self, select: bool = False) -> None: """Move the cursor to the end of the line.""" + if not self._has_cursor: + self.scroll_x = self.max_scroll_x + return location = self.get_cursor_line_end_location() self.move_cursor(location, select=select) @@ -2037,6 +2091,9 @@ def get_cursor_line_end_location(self) -> Location: def action_cursor_line_start(self, select: bool = False) -> None: """Move the cursor to the start of the line.""" + if not self._has_cursor: + self.scroll_x = 0 + return target = self.get_cursor_line_start_location(smart_home=True) self.move_cursor(target, select=select) @@ -2061,6 +2118,8 @@ def action_cursor_word_left(self, select: bool = False) -> None: Args: select: Whether to select while moving the cursor. """ + if not self.show_cursor: + return if self.cursor_at_start_of_text: return target = self.get_cursor_word_left_location() @@ -2086,7 +2145,8 @@ def get_cursor_word_left_location(self) -> Location: def action_cursor_word_right(self, select: bool = False) -> None: """Move the cursor right by a single word, skipping leading whitespace.""" - + if not self.show_cursor: + return if self.cursor_at_end_of_text: return @@ -2121,6 +2181,9 @@ def get_cursor_word_right_location(self) -> Location: def action_cursor_page_up(self) -> None: """Move the cursor and scroll up one page.""" + if not self.show_cursor: + self.scroll_page_up() + return height = self.content_size.height _, cursor_location = self.selection target = self.navigator.get_location_at_y_offset( @@ -2132,6 +2195,9 @@ def action_cursor_page_up(self) -> None: def action_cursor_page_down(self) -> None: """Move the cursor and scroll down one page.""" + if not self.show_cursor: + self.scroll_page_down() + return height = self.content_size.height _, cursor_location = self.selection target = self.navigator.get_location_at_y_offset( From 1998ed4fa18fef4776e87bf72e1e6965b95dc407 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 14:16:48 +0100 Subject: [PATCH 2/4] Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00acfeaa27..2c31dec2e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added textual.getters https://github.com/Textualize/textual/pull/5930 +- Added a `show_cursor` boolean to TextArea https://github.com/Textualize/textual/pull/5934 ### Changed - Potential breaking change: Changed default `query_one` and `query_exactly_one` search to breadth first https://github.com/Textualize/textual/pull/5930 +- Cursor is now visible by default when in read only mode (restoring pre 3.6.0 behavior) https://github.com/Textualize/textual/pull/5934 ## [3.6.0] - 2025-07-06 From c97cc1988cdb55fc0d7e5df868ef9f65c66042b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 16:42:01 +0100 Subject: [PATCH 3/4] snapshots --- src/textual/widgets/_text_area.py | 18 +- .../test_read_only_textarea.svg | 154 ++++++++++++++++++ .../test_read_only_textarea_no_cursor.svg | 154 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 36 ++++ 4 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea.svg create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea_no_cursor.svg diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 420298af35..170429f3a0 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1598,8 +1598,12 @@ def _redo_batch(self, edits: Sequence[Edit]) -> None: async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" + self._restart_blink() + if self.read_only: + return + key = event.key insert_values = { "enter": "\n", @@ -1616,9 +1620,6 @@ async def _on_key(self, event: events.Key) -> None: insert_values["tab"] = " " * self._find_columns_to_next_tab_stop() if event.is_printable or key in insert_values: - if self.read_only: - self.app.bell() - return event.stop() event.prevent_default() insert = insert_values.get(key, event.character) @@ -2076,7 +2077,7 @@ def get_cursor_up_location(self) -> Location: def action_cursor_line_end(self, select: bool = False) -> None: """Move the cursor to the end of the line.""" if not self._has_cursor: - self.scroll_x = self.max_scroll_x + self.scroll_end() return location = self.get_cursor_line_end_location() self.move_cursor(location, select=select) @@ -2092,7 +2093,7 @@ def get_cursor_line_end_location(self) -> Location: def action_cursor_line_start(self, select: bool = False) -> None: """Move the cursor to the start of the line.""" if not self._has_cursor: - self.scroll_x = 0 + self.scroll_home() return target = self.get_cursor_line_start_location(smart_home=True) self.move_cursor(target, select=select) @@ -2355,6 +2356,9 @@ def action_delete_left(self) -> None: If there's a selection, then the selected range is deleted.""" + if self.read_only: + return + selection = self.selection start, end = selection @@ -2367,6 +2371,8 @@ def action_delete_right(self) -> None: """Deletes the character to the right of the cursor and keeps the cursor at the same location. If there's a selection, then the selected range is deleted.""" + if self.read_only: + return selection = self.selection start, end = selection @@ -2378,6 +2384,8 @@ def action_delete_right(self) -> None: def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" + if self.read_only: + return self._delete_cursor_line() def _delete_cursor_line(self) -> EditResult | None: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea.svg new file mode 100644 index 0000000000..4174de47de --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +line 1                                                                     +line 2                                                                     +line 3                                                                     +line 4                                                                     +line 5                                                                    ▁▁ +line 6                                                                     +line 7                                                                     +line 8                                                                     +line 9                                                                     +line 10                                                                    +line 11                                                                    +line 12                                                                    +line 13                                                                    +line 14                                                                    +line 15                                                                    +line 16                                                                    +line 17                                                                    +line 18                                                                    +line 19                                                                    +line 20                                                                    +line 21                                                                    +line 22                                                                    +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea_no_cursor.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea_no_cursor.svg new file mode 100644 index 0000000000..0db7071461 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_read_only_textarea_no_cursor.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +line 2                                                                    ▇▇ +line 3                                                                     +line 4                                                                     +line 5                                                                     +line 6                                                                     +line 7                                                                     +line 8                                                                     +line 9                                                                     +line 10                                                                    +line 11                                                                    +line 12                                                                    +line 13                                                                    +line 14                                                                    +line 15                                                                    +line 16                                                                    +line 17                                                                    +line 18                                                                    +line 19                                                                    +line 20                                                                    +line 21                                                                    +line 22                                                                    +line 23                                                                    +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 603a8903a7..bc68089cec 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4281,3 +4281,39 @@ def on_mount(self) -> None: self.query_one(TextArea).move_cursor((0, 2)) assert snap_compare(TextAreaLine()) + + +def test_read_only_textarea(snap_compare): + """A read only textarea. + + Should have a cursor by default. You should see the cursor on the second line (line 2) + + """ + + class TextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea( + "\n".join(f"line {n + 1}" for n in range(100)), read_only=True + ) + + assert snap_compare(TextAreaApp(), press=["down"]) + + +def test_read_only_textarea_no_cursor(snap_compare): + """A read only textarea with a hidden cursor. + + You should see a text area with no visible cursor. + The user has pressed down, which will move the view down one line (*not* the cursor as there isn't a cursor here). + Line 2 should be at the top of the view. + + """ + + class TextAreaApp(App): + def compose(self) -> ComposeResult: + yield TextArea( + "\n".join(f"line {n + 1}" for n in range(100)), + read_only=True, + show_cursor=False, + ) + + assert snap_compare(TextAreaApp(), press=["down"]) From 0c781541fd6ea6709a1ede5d8c90717b37261621 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Jul 2025 16:47:31 +0100 Subject: [PATCH 4/4] fix docstring --- src/textual/widgets/_text_area.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 170429f3a0..338d9765bd 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -376,8 +376,8 @@ class TextArea(ScrollView): show_cursor: Reactive[bool] = reactive(True) """Show the cursor in read only mode? - If `False`, the cursor will be visible when `read_only==True`. - If `True`, the cursor will not be visible when `read_only==True`, and the TextArea may be + If `True`, the cursor will be visible when `read_only==True`. + If `False`, the cursor will be hidden when `read_only==True`, and the TextArea will scroll like other containers. """