diff --git a/CHANGELOG.md b/CHANGELOG.md index 8330fc60b2..3bb51c397f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +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` 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 diff --git a/src/textual/content.py b/src/textual/content.py index def09fa5b7..8c65440c06 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -869,6 +869,26 @@ 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. + + 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. + 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/__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. + + + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2b3eade06c..89a37a204d 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4626,3 +4626,31 @@ def compose(self) -> ComposeResult: yield Header() assert snap_compare(HeaderApp()) + + +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. +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)