diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb7f1d524..124ba841d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `bar_renderable` to `ProgressBar` widget https://github.com/Textualize/textual/pull/5963 - +- Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048 +- Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048 +- Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index 89ac28df1e..310df9a07d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2768,6 +2768,7 @@ def push_screen( next_screen._push_result_callback(message_pump, callback, future) self._load_screen_css(next_screen) + next_screen._update_auto_focus() self._screen_stack.append(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") diff --git a/src/textual/highlight.py b/src/textual/highlight.py index 7946280004..8a126eba68 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -29,6 +29,7 @@ class HighlightTheme: Token.Keyword.Namespace: "$text-error", Token.Keyword.Type: "bold", Token.Literal.Number: "$text-warning", + Token.Literal.String.Backtick: "$text 60%", Token.Literal.String: "$text-success 90%", Token.Literal.String.Doc: "$text-success 80% italic", Token.Literal.String.Double: "$text-success 90%", diff --git a/src/textual/screen.py b/src/textual/screen.py index 1ece40259a..b1f0f6d5e1 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1346,7 +1346,16 @@ def _on_screen_resume(self) -> None: self.app._refresh_notifications() size = self.app.size - # Only auto-focus when the app has focus (textual-web only) + self._update_auto_focus() + + if self.is_attached: + self._compositor_refresh() + self.app.stylesheet.update(self) + self._refresh_layout(size) + self.refresh() + + def _update_auto_focus(self) -> None: + """Update auto focus.""" if self.app.app_focus: auto_focus = ( self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS @@ -1354,15 +1363,10 @@ def _on_screen_resume(self) -> None: if auto_focus and self.focused is None: for widget in self.query(auto_focus): if widget.focusable: + widget.has_focus = True self.set_focus(widget) break - if self.is_attached: - self._compositor_refresh() - self.app.stylesheet.update(self) - self._refresh_layout(size) - self.refresh() - def _on_screen_suspend(self) -> None: """Screen has suspended.""" if self.app.SUSPENDED_SCREEN_CLASS: diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index a63dca9726..7a40a61fcb 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -324,15 +324,37 @@ def clear_options(self) -> Self: self._option_to_index.clear() self.highlighted = None self.refresh() - self.scroll_to(0, 0, animate=False) + self.scroll_y = 0 self._update_lines() return self + def set_options(self, options: Iterable[OptionListContent]) -> Self: + """Set options, potentially clearing existing options. + + Args: + options: Options to set. + + Returns: + The `OptionList` instance. + """ + self._options.clear() + self._line_cache.clear() + self._option_render_cache.clear() + self._id_to_option.clear() + self._option_to_index.clear() + self.highlighted = None + self.scroll_y = 0 + self.add_options(options) + return self + def add_options(self, new_options: Iterable[OptionListContent]) -> Self: """Add new options. Args: new_options: Content of new options. + + Returns: + The `OptionList` instance. """ new_options = list(new_options) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 75e58df238..cd2f443ab2 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -46,8 +46,8 @@ def __init__( ) self.expand = expand self.shrink = shrink - self._content = content - self._visual: Visual | None = None + self.__content = content + self.__visual: Visual | None = None @property def visual(self) -> Visual: @@ -58,19 +58,19 @@ def visual(self) -> Visual: update with a string, then the visual will be a [Content][textual.content.Content] instance. """ - if self._visual is None: - self._visual = visualize(self, self._content, markup=self._render_markup) - return self._visual + if self.__visual is None: + self.__visual = visualize(self, self.__content, markup=self._render_markup) + return self.__visual @property def content(self) -> VisualType: """The original content set in the constructor.""" - return self._content + return self.__content @content.setter def content(self, content: VisualType) -> None: - self._content = content - self._visual = visualize(self, content, markup=self._render_markup) + self.__content = content + self.__visual = visualize(self, content, markup=self._render_markup) self.clear_cached_dimensions() self.refresh(layout=True) @@ -90,6 +90,6 @@ def update(self, content: VisualType = "", *, layout: bool = True) -> None: layout: Also perform a layout operation (set to `False` if you are certain the size won't change). """ - self._content = content - self._visual = visualize(self, content, markup=self._render_markup) + self.__content = content + self.__visual = visualize(self, content, markup=self._render_markup) self.refresh(layout=layout) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index f3ebd2bfdc..cc8b5b144b 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -18,6 +18,7 @@ from textual._tree_sitter import TREE_SITTER, get_language from textual.cache import LRUCache from textual.color import Color +from textual.content import Content from textual.document._document import ( Document, DocumentBase, @@ -36,6 +37,7 @@ from textual.document._wrapped_document import WrappedDocument from textual.expand_tabs import expand_tabs_inline, expand_text_tabs_from_widths from textual.screen import Screen +from textual.style import Style as ContentStyle if TYPE_CHECKING: from tree_sitter import Language, Query @@ -143,6 +145,14 @@ class TextArea(ScrollView): background: $foreground 30%; } + & .text-area--suggestion { + color: $text-muted; + } + + & .text-area--placeholder { + color: $text 40%; + } + &:focus { border: tall $border; } @@ -183,6 +193,8 @@ class TextArea(ScrollView): "text-area--cursor-line", "text-area--selection", "text-area--matching-bracket", + "text-area--suggestion", + "text-area--placeholder", } """ `TextArea` offers some component classes which can be used to style aspects of the widget. @@ -197,6 +209,8 @@ class TextArea(ScrollView): | `text-area--cursor-line` | Target the line the cursor is on. | | `text-area--selection` | Target the current selection. | | `text-area--matching-bracket` | Target matching brackets. | + | `text-area--suggestion` | Target the text set in the `suggestion` reactive. | + | `text-area--placeholder` | Target the placeholder text. | """ BINDINGS = [ @@ -392,6 +406,12 @@ class TextArea(ScrollView): """Indicates where the cursor is in the blink cycle. If it's currently not visible due to blinking, this is False.""" + suggestion: Reactive[str] = reactive("") + """A suggestion for auto-complete (pressing right will insert it).""" + + placeholder: Reactive[str | Content] = reactive("") + """Text to show when the text area has no content.""" + @dataclass class Changed(Message): """Posted when the content inside the TextArea changes. @@ -443,6 +463,7 @@ def __init__( tooltip: RenderableType | None = None, compact: bool = False, highlight_cursor_line: bool = True, + placeholder: str | Content = "", ) -> None: """Construct a new `TextArea`. @@ -464,6 +485,7 @@ def __init__( tooltip: Optional tooltip. compact: Enable compact style (without borders). highlight_cursor_line: Highlight the line under the cursor. + placeholder: Text to display when there is not content. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -524,6 +546,7 @@ def __init__( 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) + self.set_reactive(TextArea.placeholder, placeholder) self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024) @@ -565,6 +588,7 @@ def code_editor( tooltip: RenderableType | None = None, compact: bool = False, highlight_cursor_line: bool = True, + placeholder: str | Content = "", ) -> TextArea: """Construct a new `TextArea` with sensible defaults for editing code. @@ -607,6 +631,7 @@ def code_editor( tooltip=tooltip, compact=compact, highlight_cursor_line=highlight_cursor_line, + placeholder=placeholder, ) @staticmethod @@ -632,6 +657,7 @@ def _get_builtin_highlight_query(language_name: str) -> str: def notify_style_update(self) -> None: self._line_cache.clear() + super().notify_style_update() def check_consume_key(self, key: str, character: str | None = None) -> bool: """Check if the widget may consume the given key. @@ -1173,6 +1199,25 @@ 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 + ) + scroll_x, scroll_y = self.scroll_offset absolute_y = scroll_y + y selection = self.selection @@ -1201,6 +1246,7 @@ def render_line(self, y: int) -> Strip: self.show_line_numbers, self.read_only, self.show_cursor, + self.suggestion, ) if (cached_line := self._line_cache.get(cache_key)) is not None: return cached_line @@ -1332,6 +1378,16 @@ def _render_line(self, y: int) -> Strip: cursor_column + 1, ) + if self.suggestion and self.has_focus: + suggestion_style = self.get_component_rich_style( + "text-area--suggestion" + ) + line = Text.assemble( + line[:cursor_column], + (self.suggestion, suggestion_style), + line[cursor_column:], + ) + if draw_cursor: cursor_style = theme.cursor_style if theme else None if cursor_style: @@ -1474,6 +1530,7 @@ def edit(self, edit: Edit) -> EditResult: Data relating to the edit that may be useful. The data returned may be different depending on the edit performed. """ + self.suggestion = "" old_gutter_width = self.gutter_width result = edit.do(self) self.history.record(edit) @@ -2030,6 +2087,9 @@ def action_cursor_right(self, select: bool = False) -> None: if not self._has_cursor: self.scroll_right() return + if self.suggestion: + self.insert(self.suggestion) + return target = ( self.get_cursor_right_location() if select or self.selection.is_empty diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py index 03c0c8772f..e4dd40656f 100644 --- a/tests/option_list/test_option_list_create.py +++ b/tests/option_list/test_option_list_create.py @@ -156,3 +156,13 @@ async def test_options_are_available_soon() -> None: option = Option("", id="some_id") option_list = OptionList(option) assert option_list.get_option("some_id") is option + + +async def test_set_options(): + """Test set_options method.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.set_options(["foo", "bar"]) + assert option_list.option_count == 2 + assert option_list.get_option_at_index(0).prompt == "foo" + assert option_list.get_option_at_index(1).prompt == "bar" diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_textarea_placeholder.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_textarea_placeholder.svg new file mode 100644 index 0000000000..e8b52162e3 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_textarea_placeholder.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Your text here + + + + + + + + + + + + + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_textarea_suggestion.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_textarea_suggestion.svg new file mode 100644 index 0000000000..8554cd3c0e --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_textarea_suggestion.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Hello, World! + + + + + + + + + + + + + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index c5a97470f3..54ff172d67 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -3569,7 +3569,6 @@ def test_setting_transparency(snap_compare): Regression test for https://github.com/Textualize/textual/pull/5890""" class TransparentPythonApp(App): - CSS = """ Screen { background: darkblue; @@ -3589,7 +3588,6 @@ class TransparentPythonApp(App): """ def compose(self): - yield TextArea("Baseline normal TextArea, not transparent") text_area2 = TextArea( "This TextArea made transparent by adding a CSS class", @@ -3961,14 +3959,12 @@ def test_enforce_visual(snap_compare): """ class OverflowOption(Option): - def __init__(self) -> None: super().__init__( Text.from_markup(f"Line one\n{'a' * 100}", overflow="ellipsis") ) class OptionListOverflowApp(App[None]): - CSS = """ OptionList { width: 30; @@ -4146,7 +4142,6 @@ def test_breakpoints_horizontal(snap_compare, size): """ class BreakpointApp(App): - HORIZONTAL_BREAKPOINTS = [ (0, "-narrow"), (40, "-normal"), @@ -4174,7 +4169,7 @@ class BreakpointApp(App): def compose(self) -> ComposeResult: with Grid(): for n in range(16): - yield Placeholder(f"Placeholder {n+1}") + yield Placeholder(f"Placeholder {n + 1}") assert snap_compare(BreakpointApp(), terminal_size=size) @@ -4197,7 +4192,6 @@ def test_breakpoints_vertical(snap_compare, size): """ class BreakpointApp(App): - VERTICAL_BREAKPOINTS = [ (0, "-low"), (30, "-middle"), @@ -4225,7 +4219,7 @@ class BreakpointApp(App): def compose(self) -> ComposeResult: with Grid(): for n in range(16): - yield Placeholder(f"Placeholder {n+1}") + yield Placeholder(f"Placeholder {n + 1}") assert snap_compare(BreakpointApp(), terminal_size=size) @@ -4240,10 +4234,8 @@ def test_compact(snap_compare): """ class CompactApp(App): - def compose(self) -> ComposeResult: with Horizontal(): - with Vertical(): yield Button("Foo") yield Input("hello") @@ -4349,7 +4341,6 @@ def test_snapshot_scroll(snap_compare): """ class ScrollKeylineApp(App): - CSS = """\ #my-container { keyline: heavy blue; @@ -4407,7 +4398,7 @@ def test_markdown_append(snap_compare): MD = [ "# Title\n", "\n", - "1. List item 1\n" "2. List item 2\n" "\n" "> There can be only one\n", + "1. List item 1\n2. List item 2\n\n> There can be only one\n", ] class MDApp(App): @@ -4580,7 +4571,6 @@ def test_static_content_property(snap_compare): """ class StaticApp(App): - def compose(self) -> ComposeResult: yield Static("Hello, World") @@ -4588,3 +4578,35 @@ def on_mount(self) -> None: self.query_one(Static).content = "FOO BAR" assert snap_compare(StaticApp()) + + +def test_textarea_suggestion(snap_compare): + """Test Text Area displays suggestions. + + You should see "Hello ," followed by a dimmed "World!" + The cursor should be over the comma. + """ + + class TextApp(App): + def compose(self) -> ComposeResult: + yield TextArea() + + def on_mount(self) -> None: + self.query_one(TextArea).insert("Hello, ") + self.query_one(TextArea).suggestion = "World!" + + assert snap_compare(TextApp()) + + +def test_textarea_placeholder(snap_compare): + """Test text area placeholder + + You should see a TextArea with dimmed text "Your text here". + + """ + + class TextApp(App): + def compose(self) -> ComposeResult: + yield TextArea(placeholder="Your text here") + + assert snap_compare(TextApp())