From 13ee28819da2b397810bce910a3a6ea3a4e04a4c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 10:31:00 +0100 Subject: [PATCH 01/13] text area render optimizations --- src/textual/widgets/_text_area.py | 35 +++++++++++++------------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 1e666699d2..0160b78023 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple from rich.console import RenderableType +from rich.segment import Segment from rich.style import Style from rich.text import Text from typing_extensions import Literal @@ -1107,6 +1108,12 @@ def get_line(self, line_index: int) -> Text: line_string = self.document.get_line(line_index) return Text(line_string, end="") + def render_lines(self, crop: Region) -> list[Strip]: + theme = self._theme + if theme: + theme.apply_css(self) + return super().render_lines(crop) + def render_line(self, y: int) -> Strip: """Render a single line of the TextArea. Called by Textual. @@ -1117,8 +1124,6 @@ def render_line(self, y: int) -> Strip: A rendered line. """ theme = self._theme - if theme: - theme.apply_css(self) wrapped_document = self.wrapped_document scroll_x, scroll_y = self.scroll_offset @@ -1263,13 +1268,11 @@ def render_line(self, y: int) -> Strip: gutter_content = ( str(line_index + self.line_number_start) if section_offset == 0 else "" ) - gutter = Text( - f"{gutter_content:>{gutter_width_no_margin}} ", - style=gutter_style or "", - end="", - ) + gutter = [ + Segment(f"{gutter_content:>{gutter_width_no_margin}} ", gutter_style) + ] else: - gutter = Text("", end="") + gutter = [] # TODO: Lets not apply the division each time through render_line. # We should cache sections with the edit counts. @@ -1305,18 +1308,11 @@ def render_line(self, y: int) -> Strip: ) target_width = base_width - self.gutter_width console = self.app.console - gutter_segments = console.render(gutter) - - text_segments = list( - console.render(line, console.options.update_width(target_width)) - ) - - gutter_strip = Strip(gutter_segments, cell_length=gutter_width) - text_strip = Strip(text_segments) - + text_segments = [*line.render(console)] + strip = Strip(gutter + text_segments, cell_length=line.cell_len + gutter_width) # Crop the line to show only the visible part (some may be scrolled out of view) if not self.soft_wrap: - text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) + strip = strip.crop(scroll_x, scroll_x + virtual_width) # Stylize the line the cursor is currently on. if cursor_row == line_index and self.highlight_cursor_line: @@ -1324,8 +1320,7 @@ def render_line(self, y: int) -> Strip: else: line_style = theme.base_style if theme else None - text_strip = text_strip.extend_cell_length(target_width, line_style) - strip = Strip.join([gutter_strip, text_strip]).simplify() + strip = strip.extend_cell_length(target_width, line_style) return strip.apply_style( theme.base_style From a5edaa5557ba16d3997bfe5099f4dcf80bac6df8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 10:33:39 +0100 Subject: [PATCH 02/13] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a294e9a46f..073a3c4039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Changed - Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900 +- Some optimizations to TextArea, which may be noticeable during scrolling ## Added From 8dbf0cbc82f85c2f97c4a4fe7e95daee5fb288ee Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 10:51:16 +0100 Subject: [PATCH 03/13] fix gutter --- src/textual/widgets/_text_area.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 0160b78023..05138031d5 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1308,11 +1308,12 @@ def render_line(self, y: int) -> Strip: ) target_width = base_width - self.gutter_width console = self.app.console - text_segments = [*line.render(console)] - strip = Strip(gutter + text_segments, cell_length=line.cell_len + gutter_width) + # Crop the line to show only the visible part (some may be scrolled out of view) + text_strip = Strip(line.render(console), cell_length=line.cell_len) if not self.soft_wrap: - strip = strip.crop(scroll_x, scroll_x + virtual_width) + text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) + strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip]) # Stylize the line the cursor is currently on. if cursor_row == line_index and self.highlight_cursor_line: From 9438bd32489cc55f5a26b96301952736b882d520 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 11:01:20 +0100 Subject: [PATCH 04/13] simplify --- CHANGELOG.md | 2 +- src/textual/widgets/_text_area.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073a3c4039..41cf23d42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Changed - Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900 -- Some optimizations to TextArea, which may be noticeable during scrolling +- Some optimizations to TextArea, which may be noticeable during scrolling https://github.com/Textualize/textual/pull/5925 ## Added diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 05138031d5..e9c7867d33 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1307,10 +1307,10 @@ def render_line(self, y: int) -> Strip: else max(virtual_width, self.region.size.width) ) target_width = base_width - self.gutter_width - console = self.app.console + # console = self.app.console # Crop the line to show only the visible part (some may be scrolled out of view) - text_strip = Strip(line.render(console), cell_length=line.cell_len) + text_strip = Strip(line.render(self.app.console), cell_length=line.cell_len) if not self.soft_wrap: text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip]) From cd53d200bd8facedbe92f2f9e03cf441f3b69452 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 11:05:20 +0100 Subject: [PATCH 05/13] extend cell length --- 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 e9c7867d33..cddbf22da9 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1313,7 +1313,6 @@ def render_line(self, y: int) -> Strip: text_strip = Strip(line.render(self.app.console), cell_length=line.cell_len) if not self.soft_wrap: text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) - strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip]) # Stylize the line the cursor is currently on. if cursor_row == line_index and self.highlight_cursor_line: @@ -1321,7 +1320,8 @@ def render_line(self, y: int) -> Strip: else: line_style = theme.base_style if theme else None - strip = strip.extend_cell_length(target_width, line_style) + text_strip = text_strip.extend_cell_length(target_width, line_style) + strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip]) return strip.apply_style( theme.base_style From 82b49448a5164ec56ce1ffd73209355c2167f592 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 11:06:52 +0100 Subject: [PATCH 06/13] remove comment --- src/textual/widgets/_text_area.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index cddbf22da9..dcfb32e34d 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1307,7 +1307,6 @@ def render_line(self, y: int) -> Strip: else max(virtual_width, self.region.size.width) ) target_width = base_width - self.gutter_width - # console = self.app.console # Crop the line to show only the visible part (some may be scrolled out of view) text_strip = Strip(line.render(self.app.console), cell_length=line.cell_len) From 5f8cbcd0e1935ef7d7885c08905329be3f20c509 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 11:30:42 +0100 Subject: [PATCH 07/13] restore console.render --- src/textual/widgets/_text_area.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index dcfb32e34d..04252cb056 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1309,7 +1309,8 @@ def render_line(self, y: int) -> Strip: target_width = base_width - self.gutter_width # Crop the line to show only the visible part (some may be scrolled out of view) - text_strip = Strip(line.render(self.app.console), cell_length=line.cell_len) + console = self.app.console + text_strip = Strip(console.render(line), cell_length=line.cell_len) if not self.soft_wrap: text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) From ec0c050c9b7b33739f4096a55008ae0ee5af2491 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 15:00:18 +0100 Subject: [PATCH 08/13] line cache --- src/textual/document/_document.py | 5 +++ src/textual/widgets/_text_area.py | 53 +++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index 47e87eb09b..92d5a12a99 100644 --- a/src/textual/document/_document.py +++ b/src/textual/document/_document.py @@ -466,3 +466,8 @@ def is_empty(self) -> bool: """Return True if the selection has 0 width, i.e. it's just a cursor.""" start, end = self return start == end + + def contains_line(self, y: int) -> bool: + """Check if the given line is within the selection.""" + top, bottom = sorted((self.start[0], self.end[0])) + return y >= top and y <= bottom diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 04252cb056..dc310c14f5 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -16,6 +16,7 @@ from textual._text_area_theme import TextAreaTheme from textual._tree_sitter import TREE_SITTER, get_language +from textual.cache import LRUCache from textual.color import Color from textual.document._document import ( Document, @@ -512,6 +513,8 @@ def __init__( self.set_reactive(TextArea.line_number_start, line_number_start) self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line) + self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024) + self._set_document(text, language) self.language = language @@ -611,6 +614,9 @@ def _get_builtin_highlight_query(language_name: str) -> str: return highlight_query + def notify_style_update(self) -> None: + self._line_cache.clear() + def check_consume_key(self, key: str, character: str | None = None) -> bool: """Check if the widget may consume the given key. @@ -634,6 +640,7 @@ def check_consume_key(self, key: str, character: str | None = None) -> bool: def _build_highlight_map(self) -> None: """Query the tree for ranges to highlights, and update the internal highlights mapping.""" + self._line_cache.clear() highlights = self._highlights highlights.clear() if not self._highlight_query: @@ -838,6 +845,8 @@ def _set_theme(self, theme: str) -> None: if background: self.styles.background = Color.from_rich_color(background) + theme_object.apply_css(self) + @property def available_themes(self) -> set[str]: """A list of the names of the themes available to the `TextArea`. @@ -1108,15 +1117,42 @@ def get_line(self, line_index: int) -> Text: line_string = self.document.get_line(line_index) return Text(line_string, end="") - def render_lines(self, crop: Region) -> list[Strip]: - theme = self._theme - if theme: - theme.apply_css(self) - return super().render_lines(crop) - def render_line(self, y: int) -> Strip: """Render a single line of the TextArea. Called by Textual. + Args: + y: Y Coordinate of line relative to the widget region. + + Returns: + A rendered line. + """ + scroll_x, scroll_y = self.scroll_offset + absolute_y = scroll_y + y + selection = self.selection + cache_key = ( + self.size, + scroll_x, + absolute_y, + ( + selection + if selection.contains_line(absolute_y) + else selection.end[0] == absolute_y + ), + self._cursor_visible, + self.cursor_blink, + self.theme, + self._matching_bracket_location, + self.match_cursor_bracket, + ) + if (cached_line := self._line_cache.get(cache_key)) is not None: + return cached_line + line = self._render_line(y) + self._line_cache[cache_key] = line + return line + + def _render_line(self, y: int) -> Strip: + """Render a single line of the TextArea. Called by Textual. + Args: y: Y Coordinate of line relative to the widget region. @@ -1310,7 +1346,10 @@ def render_line(self, y: int) -> Strip: # Crop the line to show only the visible part (some may be scrolled out of view) console = self.app.console - text_strip = Strip(console.render(line), cell_length=line.cell_len) + text_strip = Strip( + console.render(line, options=console.options.update_width(line.cell_len)), + cell_length=line.cell_len, + ) if not self.soft_wrap: text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) From afae638dcebd347b3295ee10148f47e9f9c662af Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 15:26:49 +0100 Subject: [PATCH 09/13] simplify render --- src/textual/widgets/_text_area.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index dc310c14f5..593701cb7c 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -845,7 +845,10 @@ def _set_theme(self, theme: str) -> None: if background: self.styles.background = Color.from_rich_color(background) - theme_object.apply_css(self) + def apply_theme() -> None: + self._theme.apply_css(self) + + self.call_later(apply_theme) @property def available_themes(self) -> set[str]: @@ -1045,6 +1048,7 @@ def wrap_width(self) -> int: def _rewrap_and_refresh_virtual_size(self) -> None: self.wrapped_document.wrap(self.wrap_width, tab_width=self.indent_width) + self._line_cache.clear() self._refresh_size() @property @@ -1115,7 +1119,7 @@ def get_line(self, line_index: int) -> Text: A `rich.Text` object containing the requested line. """ line_string = self.document.get_line(line_index) - return Text(line_string, end="") + return Text(line_string, end="", no_wrap=True) def render_line(self, y: int) -> Strip: """Render a single line of the TextArea. Called by Textual. @@ -1138,11 +1142,20 @@ def render_line(self, y: int) -> Strip: if selection.contains_line(absolute_y) else selection.end[0] == absolute_y ), - self._cursor_visible, - self.cursor_blink, + ( + selection.end + if ( + self._cursor_visible + and self.cursor_blink + and absolute_y == selection.end[0] + ) + else None + ), self.theme, self._matching_bracket_location, self.match_cursor_bracket, + self.soft_wrap, + self.show_line_numbers, ) if (cached_line := self._line_cache.get(cache_key)) is not None: return cached_line @@ -1214,7 +1227,8 @@ def _render_line(self, y: int) -> Strip: if selection_style: if line_character_count == 0 and line_index != cursor_row: # A simple highlight to show empty lines are included in the selection - line = Text("▌", end="", style=Style(color=selection_style.bgcolor)) + line.plain = "▌" + line.stylize(Style(color=selection_style.bgcolor)) else: if line_index == selection_top_row == selection_bottom_row: # Selection within a single line @@ -1346,10 +1360,7 @@ def _render_line(self, y: int) -> Strip: # Crop the line to show only the visible part (some may be scrolled out of view) console = self.app.console - text_strip = Strip( - console.render(line, options=console.options.update_width(line.cell_len)), - cell_length=line.cell_len, - ) + text_strip = Strip(line.render(console), cell_length=line.cell_len) if not self.soft_wrap: text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width) From f2456799690e123ecb738e60cf7e98a97b7a5097 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 15:32:17 +0100 Subject: [PATCH 10/13] snapshots --- CHANGELOG.md | 2 +- .../test_snapshots/test_compact.svg | 2 +- .../test_input_percentage_width.svg | 118 +++++++++--------- .../test_setting_transparency.svg | 24 ++-- .../test_text_area_alternate_screen.svg | 60 ++++----- ...t_area_selection_rendering[selection0].svg | 56 ++++----- ...t_area_selection_rendering[selection1].svg | 56 ++++----- ...t_area_selection_rendering[selection2].svg | 56 ++++----- ...t_area_selection_rendering[selection3].svg | 56 ++++----- ...t_area_selection_rendering[selection4].svg | 54 ++++---- ...t_area_selection_rendering[selection5].svg | 54 ++++---- 11 files changed, 269 insertions(+), 269 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cf23d42d..01d1734abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Changed - Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900 -- Some optimizations to TextArea, which may be noticeable during scrolling https://github.com/Textualize/textual/pull/5925 +- Some optimizations to TextArea, which may be noticeable during scrolling (note: may break snapshots with a TextArea) https://github.com/Textualize/textual/pull/5925 ## Added diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_compact.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_compact.svg index 34376a69a8..46754508c2 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_compact.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_compact.svg @@ -128,7 +128,7 @@ - + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Bar   Foo world                                    diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_input_percentage_width.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_input_percentage_width.svg index 607ae43be9..f7fc7d756c 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_input_percentage_width.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_input_percentage_width.svg @@ -19,135 +19,135 @@ font-weight: 700; } - .terminal-3778060113-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3778060113-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3778060113-r1 { fill: #121212 } -.terminal-3778060113-r2 { fill: #e0e0e0 } -.terminal-3778060113-r3 { fill: #c5c8c6 } -.terminal-3778060113-r4 { fill: #ff0000 } -.terminal-3778060113-r5 { fill: #e0e0e0;font-weight: bold } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #e0e0e0 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #ff0000 } +.terminal-r5 { fill: #e0e0e0;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputVsTextArea + InputVsTextArea - - - - 01234567890123456789012345678901234567890123456789012345678901234567890123456789 -┌──────────────────────────────────────┐ - - - -└──────────────────────────────────────┘ -┌──────────────────────────────────────┐ - - - - -└──────────────────────────────────────┘ -┌──────────────────────────────────────┐ - - - - -└──────────────────────────────────────┘ -┌──────────────────────────────────────┐ - - Button  - - -└──────────────────────────────────────┘ + + + + 01234567890123456789012345678901234567890123456789012345678901234567890123456789 +┌──────────────────────────────────────┐ + + + +└──────────────────────────────────────┘ +┌──────────────────────────────────────┐ + + + + +└──────────────────────────────────────┘ +┌──────────────────────────────────────┐ + + + + +└──────────────────────────────────────┘ +┌──────────────────────────────────────┐ + + Button  + + +└──────────────────────────────────────┘ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_setting_transparency.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_setting_transparency.svg index 9067444ae6..f4285cfdc5 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_setting_transparency.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_setting_transparency.svg @@ -35,8 +35,8 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #00008b } .terminal-r3 { fill: #0178d4 } -.terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #121212 } +.terminal-r4 { fill: #121212 } +.terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #191919 } @@ -123,30 +123,30 @@ - + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Baseline normal TextArea, not transparent      +Baseline normal TextArea, not transparent      ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ -This TextArea made transparent by adding a     -CSS class                                      +This TextArea made transparent by adding a     +CSS class                                      ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ -This TextArea made transparent by setting      -style with python                              +This TextArea made transparent by setting      +style with python                              ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ -1) This is an OptionList +1) This is an OptionList -2) With a transparent background +2) With a transparent background -3) Made transparent by setting style with -python +3) Made transparent by setting style with +python ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_alternate_screen.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_alternate_screen.svg index a3e7102202..4dd6b583b8 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_alternate_screen.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_alternate_screen.svg @@ -19,78 +19,78 @@ font-weight: 700; } - .terminal-4214779899-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4214779899-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4214779899-r1 { fill: #121212 } -.terminal-4214779899-r2 { fill: #0178d4 } -.terminal-4214779899-r3 { fill: #c5c8c6 } -.terminal-4214779899-r4 { fill: #e0e0e0 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #e0e0e0 } - + - + - + - + - + - + - + - + - + - + - TABug + TABug - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -foo                                          -bar                                          -baz                                          - - - - - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +foo                                          +bar                                          +baz                                          + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection0].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection0].svg index e3cad34a17..025391ca71 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection0].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection0].svg @@ -19,72 +19,72 @@ font-weight: 700; } - .terminal-1738498439-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1738498439-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1738498439-r1 { fill: #121212 } -.terminal-1738498439-r2 { fill: #0178d4 } -.terminal-1738498439-r3 { fill: #c5c8c6 } -.terminal-1738498439-r4 { fill: #f8f8f2 } -.terminal-1738498439-r5 { fill: #65686a } -.terminal-1738498439-r6 { fill: #272822 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #f8f8f2 } +.terminal-r5 { fill: #65686a } +.terminal-r6 { fill: #272822 } - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -I am a line. - -I am another line.         - -I am the final line.       - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I am a line. + +I am another line.         + +I am the final line.       + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection1].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection1].svg index ac8ed8617b..914e6127c0 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection1].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection1].svg @@ -19,72 +19,72 @@ font-weight: 700; } - .terminal-285530678-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-285530678-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-285530678-r1 { fill: #121212 } -.terminal-285530678-r2 { fill: #0178d4 } -.terminal-285530678-r3 { fill: #c5c8c6 } -.terminal-285530678-r4 { fill: #f8f8f2 } -.terminal-285530678-r5 { fill: #272822 } -.terminal-285530678-r6 { fill: #65686a } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #272822 } +.terminal-r5 { fill: #f8f8f2 } +.terminal-r6 { fill: #65686a } - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -I am a line. - -I am another line.         - -I am the final line.       - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I am a line. + +I am another line.         + +I am the final line.       + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection2].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection2].svg index 05f3172b39..d912b7920a 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection2].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection2].svg @@ -19,72 +19,72 @@ font-weight: 700; } - .terminal-1391651187-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1391651187-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1391651187-r1 { fill: #121212 } -.terminal-1391651187-r2 { fill: #0178d4 } -.terminal-1391651187-r3 { fill: #c5c8c6 } -.terminal-1391651187-r4 { fill: #f8f8f2 } -.terminal-1391651187-r5 { fill: #272822 } -.terminal-1391651187-r6 { fill: #65686a } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #272822 } +.terminal-r5 { fill: #f8f8f2 } +.terminal-r6 { fill: #65686a } - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -I am a line. - -I am another line. - -I am the final line.       - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I am a line. + +I am another line. + +I am the final line.       + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection3].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection3].svg index d4660bad04..456199a1db 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection3].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection3].svg @@ -19,72 +19,72 @@ font-weight: 700; } - .terminal-3443945668-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3443945668-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3443945668-r1 { fill: #121212 } -.terminal-3443945668-r2 { fill: #0178d4 } -.terminal-3443945668-r3 { fill: #c5c8c6 } -.terminal-3443945668-r4 { fill: #f8f8f2 } -.terminal-3443945668-r5 { fill: #65686a } -.terminal-3443945668-r6 { fill: #272822 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #f8f8f2 } +.terminal-r5 { fill: #65686a } +.terminal-r6 { fill: #272822 } - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -I am a line. - -I am another line. - -I am the final line. - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I am a line. + +I am another line. + +I am the final line. + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection4].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection4].svg index 3b482cb438..ef5f467e75 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection4].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection4].svg @@ -19,71 +19,71 @@ font-weight: 700; } - .terminal-3984920137-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3984920137-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3984920137-r1 { fill: #121212 } -.terminal-3984920137-r2 { fill: #0178d4 } -.terminal-3984920137-r3 { fill: #c5c8c6 } -.terminal-3984920137-r4 { fill: #f8f8f2 } -.terminal-3984920137-r5 { fill: #272822 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #f8f8f2 } +.terminal-r5 { fill: #272822 } - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -I am a line.               - -I am another line.         - -I am the final line.       - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I am a line.               + +I am another line.         + +I am the final line.       + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection5].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection5].svg index 99915b4179..54a9c7edcb 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection5].svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_selection_rendering[selection5].svg @@ -19,71 +19,71 @@ font-weight: 700; } - .terminal-1348948452-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1348948452-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1348948452-r1 { fill: #121212 } -.terminal-1348948452-r2 { fill: #0178d4 } -.terminal-1348948452-r3 { fill: #c5c8c6 } -.terminal-1348948452-r4 { fill: #f8f8f2 } -.terminal-1348948452-r5 { fill: #272822 } + .terminal-r1 { fill: #121212 } +.terminal-r2 { fill: #0178d4 } +.terminal-r3 { fill: #c5c8c6 } +.terminal-r4 { fill: #f8f8f2 } +.terminal-r5 { fill: #272822 } - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -I am a line.               - -I am another line.         - -I am the final line.       - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I am a line.               + +I am another line.         + +I am the final line.       + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ From 3b66e3a16aaa99a44928296ca70c42baa1098ffb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 16:22:26 +0100 Subject: [PATCH 11/13] pause blink tweak --- CHANGELOG.md | 1 + src/textual/widgets/_text_area.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d1734abb..5525102675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900 - Some optimizations to TextArea, which may be noticeable during scrolling (note: may break snapshots with a TextArea) https://github.com/Textualize/textual/pull/5925 +- Selecting in the TextArea now hides the cursor until you release the mouse https://github.com/Textualize/textual/pull/5925 ## Added diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 593701cb7c..36fe885a35 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1691,7 +1691,7 @@ async def _on_mouse_down(self, event: events.MouseDown) -> None: # Capture the mouse so that if the cursor moves outside the # TextArea widget while selecting, the widget still scrolls. self.capture_mouse() - self._pause_blink(visible=True) + self._pause_blink(visible=False) self.history.checkpoint() async def _on_mouse_move(self, event: events.MouseMove) -> None: From 2af8f90d108ebbab76a9dac53d4d55543e301084 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 16:46:23 +0100 Subject: [PATCH 12/13] apply theme fix --- src/textual/widgets/_text_area.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 36fe885a35..3780499634 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -845,11 +845,6 @@ def _set_theme(self, theme: str) -> None: if background: self.styles.background = Color.from_rich_color(background) - def apply_theme() -> None: - self._theme.apply_css(self) - - self.call_later(apply_theme) - @property def available_themes(self) -> set[str]: """A list of the names of the themes available to the `TextArea`. @@ -1121,6 +1116,12 @@ def get_line(self, line_index: int) -> Text: line_string = self.document.get_line(line_index) return Text(line_string, end="", no_wrap=True) + def render_lines(self, crop: Region) -> list[Strip]: + theme = self._theme + if theme: + theme.apply_css(self) + return super().render_lines(crop) + def render_line(self, y: int) -> Strip: """Render a single line of the TextArea. Called by Textual. From 926770348109c5a113305ea43c5c8cfcf0281579 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 5 Jul 2025 19:45:33 +0100 Subject: [PATCH 13/13] read only cursor --- CHANGELOG.md | 1 + src/textual/widgets/_text_area.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5525102675..2533447e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900 - Some optimizations to TextArea, which may be noticeable during scrolling (note: may break snapshots with a TextArea) https://github.com/Textualize/textual/pull/5925 - Selecting in the TextArea now hides the cursor until you release the mouse https://github.com/Textualize/textual/pull/5925 +- Read only TextAreas will no longer display a cursor https://github.com/Textualize/textual/pull/5925 ## Added diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 3780499634..5b89494ddd 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1157,6 +1157,7 @@ def render_line(self, y: int) -> Strip: self.match_cursor_bracket, self.soft_wrap, self.show_line_numbers, + self.read_only, ) if (cached_line := self._line_cache.get(cache_key)) is not None: return cached_line @@ -1278,6 +1279,7 @@ def _render_line(self, y: int) -> Strip: self.has_focus and not self.cursor_blink or (self.cursor_blink and self._cursor_visible) + and not self.read_only ) if draw_matched_brackets: matching_bracket_style = theme.bracket_matching_style if theme else None