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 @@
+
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 @@
+
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())