From 02fd2451cb66df4f4b4b43cb8aeb85f949e32918 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 16:20:24 +0100 Subject: [PATCH 1/6] long placeholder --- CHANGELOG.md | 1 + src/textual/content.py | 17 +++++++++++++ src/textual/widgets/_text_area.py | 34 +++++++++++++------------- tests/snapshot_tests/test_snapshots.py | 24 ++++++++++++++++++ tests/test_content.py | 16 ++++++++++++ 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8330fc60b2..394127760c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Screen.size` https://github.com/Textualize/textual/pull/6105 - Added `compact` to Binding.Group https://github.com/Textualize/textual/pull/6132 - Added `Screen.get_hover_widgets_at` https://github.com/Textualize/textual/pull/6132 +- Added `Content.wrap` ### Fixed diff --git a/src/textual/content.py b/src/textual/content.py index def09fa5b7..05e19b9db2 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -869,6 +869,23 @@ def iter_content() -> Iterable[Content]: return Content("".join(text), spans, total_cell_length) + def wrap( + self, width: int, align: TextAlign = "left", overflow: TextOverflow = "fold" + ) -> list[Content]: + """Wrap text so that it fits within the given dimensions. + + Args: + width: Maximum width of the line (in cells). + align: Alignment of lines. + overflow: Overflow of lines (what happens when the text doesn't fit). + + Returns: + A list of Content objects, one per line. + """ + lines = self._wrap_and_format(width, align, overflow) + content_lines = [line.content for line in lines] + return content_lines + def get_style_at_offset(self, offset: int) -> Style: """Get the style of a character at give offset. diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 880efc2fb7..82856067bb 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1206,24 +1206,24 @@ def render_line(self, y: int) -> Strip: Returns: A rendered line. """ - if y == 0 and not self.text and self.placeholder: - style = self.get_visual_style("text-area--placeholder") - content = ( - Content(self.placeholder) - if isinstance(self.placeholder, str) - else self.placeholder - ) - content = content.stylize(style) - if self._draw_cursor: - theme = self._theme - cursor_style = theme.cursor_style if theme else None - if cursor_style: - content = content.stylize( - ContentStyle.from_rich_style(cursor_style), 0, 1 - ) - return Strip( - content.render_segments(self.visual_style), content.cell_length + + if not self.text and self.placeholder: + placeholder_lines = Content.from_text(self.placeholder).wrap( + self.content_size.width ) + if y < len(placeholder_lines): + style = self.get_visual_style("text-area--placeholder") + content = placeholder_lines[y].stylize(style) + if self._draw_cursor and y == 0: + theme = self._theme + cursor_style = theme.cursor_style if theme else None + if cursor_style: + content = content.stylize( + ContentStyle.from_rich_style(cursor_style), 0, 1 + ) + return Strip( + content.render_segments(self.visual_style), content.cell_length + ) scroll_x, scroll_y = self.scroll_offset absolute_y = scroll_y + y diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2b3eade06c..9dc5e95bf2 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4626,3 +4626,27 @@ def compose(self) -> ComposeResult: yield Header() assert snap_compare(HeaderApp()) + + +def test_long_textarea_placeholder(snap_compare) -> None: + + TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + class PlaceholderApp(App): + + CSS = """ + TextArea { + width: 50%; + } + """ + + def compose(self) -> ComposeResult: + yield TextArea(placeholder=TEXT) + + assert snap_compare(PlaceholderApp()) diff --git a/tests/test_content.py b/tests/test_content.py index ec28058386..5020db8f90 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -349,3 +349,19 @@ def test_add_spans() -> None: Span(7, 9, style="blue"), ] assert content.spans == expected + + +def test_wrap() -> None: + content = Content.from_markup("[green]Hello, [b]World, One two three[/b]") + wrapped = content.wrap(6) + print(wrapped) + expected = [ + Content("Hello,", spans=[Span(0, 6, style="green")]), + Content("World,", spans=[Span(0, 6, style="green"), Span(0, 6, style="b")]), + Content("One", spans=[Span(0, 3, style="green"), Span(0, 3, style="b")]), + Content("two", spans=[Span(0, 3, style="green"), Span(0, 3, style="b")]), + Content("three", spans=[Span(0, 5, style="green"), Span(0, 5, style="b")]), + ] + assert len(wrapped) == len(expected) + for line1, line2 in zip(wrapped, expected): + assert line1.is_same(line2) From ce15ea1fa25516fc4f291995af53eca0b776119c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 16:21:52 +0100 Subject: [PATCH 2/6] changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 394127760c..3bb51c397f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,12 +24,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Screen.size` https://github.com/Textualize/textual/pull/6105 - Added `compact` to Binding.Group https://github.com/Textualize/textual/pull/6132 - Added `Screen.get_hover_widgets_at` https://github.com/Textualize/textual/pull/6132 -- Added `Content.wrap` +- Added `Content.wrap` https://github.com/Textualize/textual/pull/6138 ### Fixed - Fixed issue where Segments with a style of `None` aren't rendered https://github.com/Textualize/textual/pull/6109 - Fixed visual glitches and crash when changing `DataTable.header_height` https://github.com/Textualize/textual/pull/6128 +- Fixed TextArea.placeholder not handling multi-lines https://github.com/Textualize/textual/pull/6138 ## [6.1.0] - 2025-08-01 From 57ad67665268117e928ab224d403464724143298 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 16:23:39 +0100 Subject: [PATCH 3/6] api --- src/textual/content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/content.py b/src/textual/content.py index 05e19b9db2..e3481b06a8 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -870,7 +870,7 @@ def iter_content() -> Iterable[Content]: return Content("".join(text), spans, total_cell_length) def wrap( - self, width: int, align: TextAlign = "left", overflow: TextOverflow = "fold" + self, width: int, *, align: TextAlign = "left", overflow: TextOverflow = "fold" ) -> list[Content]: """Wrap text so that it fits within the given dimensions. From 015bc11d426833ca48d84e216e953a739f6c2851 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 16:26:38 +0100 Subject: [PATCH 4/6] docstring --- tests/snapshot_tests/test_snapshots.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 9dc5e95bf2..89a37a204d 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4629,6 +4629,10 @@ def compose(self) -> ComposeResult: def test_long_textarea_placeholder(snap_compare) -> None: + """Test multi-line placeholders are wrapped and rendered. + + You should see a TextArea at 50% width, with several lines of wrapped text. + """ TEXT = """I must not fear. Fear is the mind-killer. From aef15a6b8603291a375f3cc8f005eeb5141bd0c1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 16:32:14 +0100 Subject: [PATCH 5/6] snapshot --- .../test_long_textarea_placeholder.svg | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_long_textarea_placeholder.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_long_textarea_placeholder.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_long_textarea_placeholder.svg new file mode 100644 index 0000000000..3ae66c87f2 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_long_textarea_placeholder.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PlaceholderApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings +total obliteration. +I will face my fear. +I will permit it to pass over me and +through me. +And when it has gone past, I will +turn the inner eye to see its path. +Where the fear has gone there will +be nothing. Only I will remain. + + + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + From e81138caddaa4355498a5867b2d63c224e241c74 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 16:34:03 +0100 Subject: [PATCH 6/6] docstrings --- src/textual/content.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/content.py b/src/textual/content.py index e3481b06a8..8c65440c06 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -874,6 +874,9 @@ def wrap( ) -> list[Content]: """Wrap text so that it fits within the given dimensions. + Note that Textual will automatically wrap Content in widgets. + This method is only required if you need some additional processing to lines. + Args: width: Maximum width of the line (in cells). align: Alignment of lines.