Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
1 change: 1 addition & 0 deletions src/textual/highlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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%",
Expand Down
18 changes: 11 additions & 7 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -1346,23 +1346,27 @@ 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
)
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:
Expand Down
24 changes: 23 additions & 1 deletion src/textual/widgets/_option_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 10 additions & 10 deletions src/textual/widgets/_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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)
60 changes: 60 additions & 0 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand All @@ -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 = [
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -607,6 +631,7 @@ def code_editor(
tooltip=tooltip,
compact=compact,
highlight_cursor_line=highlight_cursor_line,
placeholder=placeholder,
)

@staticmethod
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/option_list/test_option_list_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading