diff --git a/CHANGELOG.md b/CHANGELOG.md index 963e715032..f4fc3d2bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048 - Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048 - Added `Widget.get_line_filters` and `App.get_line_filters` https://github.com/Textualize/textual/pull/6057 +- Added `Binding.Group` https://github.com/Textualize/textual/pull/6070 +- Added `DOMNode.displayed_children` https://github.com/Textualize/textual/pull/6070 +- Added `TextArea.UserInsert` message https://github.com/Textualize/textual/pull/6070 +- Added `TextArea.hide_suggestion_on_blur` boolean https://github.com/Textualize/textual/pull/6070 ### Changed diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 24d2ae7b19..b8e1b044ec 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1132,6 +1132,8 @@ def render_full_update(self, simplify: bool = False) -> LayoutUpdate: crop = screen_region chops = self._render_chops(crop, lambda y: True) if simplify: + # Simplify is done when exporting to SVG + # It doesn't make things faster render_strips = [ Strip.join(chop.values()).simplify().discard_meta() for chop in chops ] diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 54c3dae026..42b287cb26 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -3,7 +3,7 @@ import sys import weakref from operator import attrgetter -from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Sequence, overload +from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, overload import rich.repr @@ -14,6 +14,10 @@ from textual.widget import Widget +_display_getter = attrgetter("display") +_visible_getter = attrgetter("visible") + + class DuplicateIds(Exception): """Raised when attempting to add a widget with an id that already exists.""" @@ -41,6 +45,8 @@ def __init__(self, parent: DOMNode | None = None) -> None: # The nodes in the list self._nodes: list[Widget] = [] self._nodes_set: set[Widget] = set() + self._displayed_nodes: tuple[int, list[Widget]] = (-1, []) + self._displayed_visible_nodes: tuple[int, list[Widget]] = (-1, []) # We cache widgets by their IDs too for a quick lookup # Note that only widgets with IDs are cached like this, so @@ -69,8 +75,6 @@ def updated(self) -> None: """Mark the nodes as having been updated.""" self._updates += 1 node = None if self._parent is None else self._parent() - if node is None: - return while node is not None and (node := node._parent) is not None: node._nodes._updates += 1 @@ -187,18 +191,29 @@ def __reversed__(self) -> Iterator[Widget]: return reversed(self._nodes) @property - def displayed(self) -> Iterable[Widget]: + def displayed(self) -> Sequence[Widget]: """Just the nodes where `display==True`.""" - for node in self._nodes: - if node.display: - yield node + if self._displayed_nodes[0] != self._updates: + self._displayed_nodes = ( + self._updates, + list(filter(_display_getter, self._nodes)), + ) + return self._displayed_nodes[1] @property - def displayed_reverse(self) -> Iterable[Widget]: + def displayed_and_visible(self) -> Sequence[Widget]: + """Nodes with both `display==True` and `visible==True`.""" + if self._displayed_visible_nodes[0] != self._updates: + self._displayed_nodes = ( + self._updates, + list(filter(_visible_getter, self.displayed)), + ) + return self._displayed_nodes[1] + + @property + def displayed_reverse(self) -> Iterator[Widget]: """Just the nodes where `display==True`, in reverse order.""" - for node in reversed(self._nodes): - if node.display: - yield node + return filter(_display_getter, reversed(self._nodes)) if TYPE_CHECKING: @@ -211,9 +226,11 @@ def __getitem__(self, index: slice) -> list[Widget]: ... def __getitem__(self, index: int | slice) -> Widget | list[Widget]: return self._nodes[index] - def __getattr__(self, key: str) -> object: - if key in {"clear", "append", "pop", "insert", "remove", "extend"}: - raise ReadOnlyError( - "Widget.children is read-only: use Widget.mount(...) or Widget.remove(...) to add or remove widgets" - ) - raise AttributeError(key) + if not TYPE_CHECKING: + # This confused the type checker for some reason + def __getattr__(self, key: str) -> object: + if key in {"clear", "append", "pop", "insert", "remove", "extend"}: + raise ReadOnlyError( + "Widget.children is read-only: use Widget.mount(...) or Widget.remove(...) to add or remove widgets" + ) + raise AttributeError(key) diff --git a/src/textual/app.py b/src/textual/app.py index c3b6b5e9a4..5a61eba9fa 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -813,6 +813,8 @@ def __init__( self._resize_event: events.Resize | None = None """A pending resize event, sent on idle.""" + self._size: Size | None = None + self._css_update_count: int = 0 """Incremented when CSS is invalidated.""" @@ -1532,6 +1534,8 @@ def size(self) -> Size: Returns: Size of the terminal. """ + if self._size is not None: + return self._size if self._driver is not None and self._driver._size is not None: width, height = self._driver._size else: @@ -4114,6 +4118,7 @@ async def _on_key(self, event: events.Key) -> None: async def _on_resize(self, event: events.Resize) -> None: event.stop() + self._size = event.size self._resize_event = event async def _on_app_focus(self, event: events.AppFocus) -> None: diff --git a/src/textual/binding.py b/src/textual/binding.py index 40afa6cf42..345cc5f820 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -66,7 +66,7 @@ class Binding: key_display: str | None = None """How the key should be shown in footer. - If None, the display of the key will use the result of `App.get_key_display`. + If `None`, the display of the key will use the result of `App.get_key_display`. If overridden in a keymap then this value is ignored. """ @@ -84,6 +84,16 @@ class Binding: system: bool = False """Make this binding a system binding, which removes it from the key panel.""" + @dataclass(frozen=True) + class Group: + """A binding group causes the keys to be grouped under a single description.""" + + description: str = "" + """Description of the group.""" + + group: Group | None = None + """Optional binding group (used to group related bindings in the footer).""" + def parse_key(self) -> tuple[list[str], str]: """Parse a key into a list of modifiers, and the actual key. @@ -151,6 +161,7 @@ def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]: tooltip=binding.tooltip, id=binding.id, system=binding.system, + group=binding.group, ) diff --git a/src/textual/content.py b/src/textual/content.py index 6c4d24ca15..70c60813ae 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -405,13 +405,12 @@ def simplify(self) -> Content: Returns: Self. """ - spans = self.spans - if not spans: + if not (spans := self._spans): return self - last_span = Span(0, 0, Style()) + last_span = Span(-1, -1, "") new_spans: list[Span] = [] changed: bool = False - for span in self._spans: + for span in spans: if span.start == last_span.end and span.style == last_span.style: last_span = new_spans[-1] = Span(last_span.start, span.end, span.style) changed = True @@ -422,6 +421,19 @@ def simplify(self) -> Content: self._spans[:] = new_spans return self + def add_spans(self, spans: Sequence[Span]) -> Content: + """Adds spans to this Content instance. + + Args: + spans: A sequence of spans. + + Returns: + A Content instance. + """ + if spans: + return Content(self.plain, [*self._spans, *spans], self._cell_length) + return self + def __eq__(self, other: object) -> bool: """Compares text only, so that markup doesn't effect sorting.""" if isinstance(other, str): @@ -693,7 +705,9 @@ def plain(self) -> str: @property def without_spans(self) -> Content: """The content with no spans""" - return Content(self.plain, [], self._cell_length) + if self._spans: + return Content(self.plain, [], self._cell_length) + return self @property def first_line(self) -> Content: @@ -741,11 +755,7 @@ def __add__(self, other: Content | str) -> Content: for start, end, style in other._spans ], ], - ( - self.cell_length + other._cell_length - if other._cell_length is not None - else None - ), + (self.cell_length + other.cell_length), ) return content return NotImplemented @@ -1470,7 +1480,7 @@ def highlight_regex( self, highlight_regex: re.Pattern[str] | str, *, - style: Style, + style: Style | str, maximum_highlights: int | None = None, ) -> Content: """Apply a style to text that matches a regular expression. diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index b613e21c8f..0c2bed837f 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1463,7 +1463,6 @@ def has_any_rules(self, *rule_names: str) -> bool: return any(inline_has_rule(name) or base_has_rule(name) for name in rule_names) def set_rule(self, rule_name: str, value: object | None) -> bool: - self._updates += 1 return self._inline_styles.set_rule(rule_name, value) def get_rule(self, rule_name: str, default: object = None) -> object: @@ -1473,7 +1472,6 @@ def get_rule(self, rule_name: str, default: object = None) -> object: def clear_rule(self, rule_name: str) -> bool: """Clear a rule (from inline).""" - self._updates += 1 return self._inline_styles.clear_rule(rule_name) def get_rules(self) -> RulesMap: diff --git a/src/textual/dom.py b/src/textual/dom.py index 11d649ff03..265de2f7b7 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -10,7 +10,6 @@ import threading from functools import lru_cache, partial from inspect import getfile -from operator import attrgetter from typing import ( TYPE_CHECKING, Any, @@ -408,6 +407,15 @@ def children(self) -> Sequence["Widget"]: """ return self._nodes + @property + def displayed_children(self) -> Sequence[Widget]: + """The displayed children (where node.display==True). + + Returns: + A sequence of widgets. + """ + return self._nodes.displayed + @property def is_empty(self) -> bool: """Are there no displayed children?""" @@ -1215,15 +1223,6 @@ def ancestors(self) -> list[DOMNode]: add_node(node) return cast("list[DOMNode]", nodes) - @property - def displayed_children(self) -> list[Widget]: - """The child nodes which will be displayed. - - Returns: - A list of nodes. - """ - return list(filter(attrgetter("display"), self._nodes)) - def watch( self, obj: DOMNode, diff --git a/src/textual/fuzzy.py b/src/textual/fuzzy.py index efdb4ed3b7..4d8c16a6c5 100644 --- a/src/textual/fuzzy.py +++ b/src/textual/fuzzy.py @@ -7,9 +7,10 @@ from __future__ import annotations +from functools import lru_cache from operator import itemgetter -from re import IGNORECASE, escape, finditer, search -from typing import Iterable, NamedTuple +from re import finditer +from typing import Iterable, Sequence import rich.repr @@ -18,60 +19,28 @@ from textual.visual import Style -class _Search(NamedTuple): - """Internal structure to keep track of a recursive search.""" - - candidate_offset: int = 0 - query_offset: int = 0 - offsets: tuple[int, ...] = () - - def branch(self, offset: int) -> tuple[_Search, _Search]: - """Branch this search when an offset is found. - - Args: - offset: Offset of a matching letter in the query. - - Returns: - A pair of search objects. - """ - _, query_offset, offsets = self - return ( - _Search(offset + 1, query_offset + 1, offsets + (offset,)), - _Search(offset + 1, query_offset, offsets), - ) - - @property - def groups(self) -> int: - """Number of groups in offsets.""" - groups = 1 - last_offset, *offsets = self.offsets - for offset in offsets: - if offset != last_offset + 1: - groups += 1 - last_offset = offset - return groups - - class FuzzySearch: """Performs a fuzzy search. Unlike a regex solution, this will finds all possible matches. """ - cache: LRUCache[tuple[str, str, bool], tuple[float, tuple[int, ...]]] = LRUCache( - 1024 * 4 - ) - - def __init__(self, case_sensitive: bool = False) -> None: + def __init__( + self, case_sensitive: bool = False, *, cache_size: int = 1024 * 4 + ) -> None: """Initialize fuzzy search. Args: case_sensitive: Is the match case sensitive? + cache_size: Number of queries to cache. """ self.case_sensitive = case_sensitive + self.cache: LRUCache[tuple[str, str], tuple[float, Sequence[int]]] = LRUCache( + cache_size + ) - def match(self, query: str, candidate: str) -> tuple[float, tuple[int, ...]]: + def match(self, query: str, candidate: str) -> tuple[float, Sequence[int]]: """Match against a query. Args: @@ -81,86 +50,105 @@ def match(self, query: str, candidate: str) -> tuple[float, tuple[int, ...]]: Returns: A pair of (score, tuple of offsets). `(0, ())` for no result. """ - query_regex = ".*?".join(f"({escape(character)})" for character in query) - if not search( - query_regex, candidate, flags=0 if self.case_sensitive else IGNORECASE - ): - # Bail out early if there is no possibility of a match - return (0.0, ()) - - cache_key = (query, candidate, self.case_sensitive) + + cache_key = (query, candidate) if cache_key in self.cache: return self.cache[cache_key] - result = max( - self._match(query, candidate), key=itemgetter(0), default=(0.0, ()) - ) + default: tuple[float, Sequence[int]] = (0.0, []) + result = max(self._match(query, candidate), key=itemgetter(0), default=default) self.cache[cache_key] = result return result - def _match( - self, query: str, candidate: str - ) -> Iterable[tuple[float, tuple[int, ...]]]: - """Generator to do the matching. + @classmethod + @lru_cache(maxsize=1024) + def get_first_letters(cls, candidate: str) -> frozenset[int]: + return frozenset({match.start() for match in finditer(r"\w+", candidate)}) + + def score(self, candidate: str, positions: Sequence[int]) -> float: + """Score a search. Args: - query: Query to match. - candidate: Candidate to check against. + search: Search object. - Yields: - Pairs of score and tuple of offsets. + Returns: + Score. """ - if not self.case_sensitive: - query = query.lower() - candidate = candidate.lower() + first_letters = self.get_first_letters(candidate) + # This is a heuristic, and can be tweaked for better results + # Boost first letter matches + offset_count = len(positions) + score: float = offset_count + len(first_letters.intersection(positions)) - # We need this to give a bonus to first letters. - first_letters = {match.start() for match in finditer(r"\w+", candidate)} + groups = 1 + last_offset, *offsets = positions + for offset in offsets: + if offset != last_offset + 1: + groups += 1 + last_offset = offset - def score(search: _Search) -> float: - """Sore a search. + # Boost to favor less groups + normalized_groups = (offset_count - (groups - 1)) / offset_count + score *= 1 + (normalized_groups * normalized_groups) + return score - Args: - search: Search object. + def _match( + self, query: str, candidate: str + ) -> Iterable[tuple[float, Sequence[int]]]: + letter_positions: list[list[int]] = [] + position = 0 + + if not self.case_sensitive: + candidate = candidate.lower() + query = query.lower() + score = self.score + if query in candidate: + # Quick exit when the query exists as a substring + query_location = candidate.rfind(query) + offsets = list(range(query_location, query_location + len(query))) + yield ( + score(candidate, offsets) * (2.0 if candidate == query else 1.5), + offsets, + ) + return + + for offset, letter in enumerate(query): + last_index = len(candidate) - offset + positions: list[int] = [] + letter_positions.append(positions) + index = position + while (location := candidate.find(letter, index)) != -1: + positions.append(location) + index = location + 1 + if index >= last_index: + break + if not positions: + yield (0.0, ()) + return + position = positions[0] + 1 + + possible_offsets: list[list[int]] = [] + query_length = len(query) + + def get_offsets(offsets: list[int], positions_index: int) -> None: + """Recursively match offsets. - Returns: - Score. + Args: + offsets: A list of offsets. + positions_index: Index of query letter. """ - # This is a heuristic, and can be tweaked for better results - # Boost first letter matches - offset_count = len(search.offsets) - score: float = offset_count + len( - first_letters.intersection(search.offsets) - ) - # Boost to favor less groups - normalized_groups = (offset_count - (search.groups - 1)) / offset_count - score *= 1 + (normalized_groups * normalized_groups) - return score - - stack: list[_Search] = [_Search()] - push = stack.append - pop = stack.pop - query_size = len(query) - find = candidate.find - # Limit the number of loops out of an abundance of caution. - # This should be hard to reach without contrived data. - remaining_loops = 10_000 - while stack and (remaining_loops := remaining_loops - 1): - search = pop() - offset = find(query[search.query_offset], search.candidate_offset) - if offset != -1: - if not set(candidate[search.candidate_offset :]).issuperset( - query[search.query_offset :] - ): - # Early out if there is not change of a match - continue - advance_branch, branch = search.branch(offset) - if advance_branch.query_offset == query_size: - yield score(advance_branch), advance_branch.offsets - push(branch) - else: - push(branch) - push(advance_branch) + for offset in letter_positions[positions_index]: + if not offsets or offset > offsets[-1]: + new_offsets = [*offsets, offset] + if len(new_offsets) == query_length: + possible_offsets.append(new_offsets) + else: + get_offsets(new_offsets, positions_index + 1) + + get_offsets([], 0) + + for offsets in possible_offsets: + yield score(candidate, offsets), offsets @rich.repr.auto @@ -229,3 +217,8 @@ def highlight(self, candidate: str) -> Content: if not candidate[offset].isspace(): content = content.stylize(self._match_style, offset, offset + 1) return content + + +if __name__ == "__main__": + fuzzy_search = FuzzySearch() + fuzzy_search.match("foo.bar", "foo/egg.bar") diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 46d3303360..57c0bf3ea1 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -222,6 +222,8 @@ def _initialize_reactive(self, obj: Reactable, name: str) -> None: else default_or_callable ) setattr(obj, internal_name, default) + if (toggle_class := self._toggle_class) is not None: + obj.set_class(bool(default), *toggle_class.split()) if self._init: self._check_watchers(obj, name, default) diff --git a/src/textual/strip.py b/src/textual/strip.py index 8197a14a69..b608a92f78 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -7,7 +7,6 @@ from __future__ import annotations -from itertools import chain from typing import Any, Iterable, Iterator, Sequence import rich.repr @@ -20,7 +19,6 @@ from textual._segment_tools import index_to_cell_position, line_pad from textual.cache import FIFOCache from textual.color import Color -from textual.constants import DEBUG from textual.css.types import AlignHorizontal, AlignVertical from textual.filter import LineFilter @@ -86,6 +84,7 @@ class Strip: "_crop_extend_cache", "_offsets_cache", "_link_ids", + "_cell_count", ] def __init__( @@ -108,10 +107,7 @@ def __init__( self._offsets_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4) self._render_cache: str | None = None self._link_ids: set[str] | None = None - - if DEBUG and cell_length is not None: - # If `cell_length` is incorrect, render will be fubar - assert get_line_length(self._segments) == cell_length + self._cell_count: int | None = None def __rich_repr__(self) -> rich.repr.Result: try: @@ -290,16 +286,17 @@ def join(cls, strips: Iterable[Strip | None]) -> Strip: Returns: A new combined strip. """ + join_strips = [strip for strip in strips if strip is not None] + segments = [segment for strip in join_strips for segment in strip._segments] + cell_length: int | None = None + if any([strip._cell_length is None for strip in join_strips]): + cell_length = None + else: + cell_length = sum([strip._cell_length or 0 for strip in join_strips]) + return cls(segments, cell_length) - segments: list[list[Segment]] = [] - add_segments = segments.append - total_cell_length = 0 - for strip in strips: - if strip is not None: - total_cell_length += strip.cell_length - add_segments(strip._segments) - strip = cls(chain.from_iterable(segments), total_cell_length) - return strip + def __add__(self, other: Strip) -> Strip: + return Strip.join([self, other]) def __bool__(self) -> bool: return not not self._segments # faster than bool(...) @@ -314,10 +311,22 @@ def __len__(self) -> int: return len(self._segments) def __eq__(self, strip: object) -> bool: - return isinstance(strip, Strip) and ( - self._segments == strip._segments and self.cell_length == strip.cell_length + return isinstance(strip, Strip) and (self._segments == strip._segments) + + def __getitem__(self, index: int | slice) -> Strip: + if isinstance(index, int): + index = slice(index, index + 1) + return self.crop( + index.start, self.cell_count if index.stop is None else index.stop ) + @property + def cell_count(self) -> int: + """Number of cells in the strip""" + if self._cell_count is None: + self._cell_count = sum(len(segment.text) for segment in self._segments) + return self._cell_count + def extend_cell_length(self, cell_length: int, style: Style | None = None) -> Strip: """Extend the cell length if it is less than the given value. @@ -391,7 +400,7 @@ def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> St return strip def simplify(self) -> Strip: - """Simplify the segments (join segments with same style) + """Simplify the segments (join segments with same style). Returns: New strip. @@ -567,6 +576,7 @@ def divide(self, cuts: Iterable[int]) -> Sequence[Strip]: cuts = [cut for cut in cuts if cut <= cell_length] cache_key = tuple(cuts) cached = self._divide_cache.get(cache_key) + if cached is not None: return cached diff --git a/src/textual/widget.py b/src/textual/widget.py index 51dbac0c8d..6d77efcd60 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -929,7 +929,7 @@ def is_odd(self) -> bool: if parent._nodes._updates == self._odd[0]: return self._odd[1] try: - is_odd = parent._nodes.index(self) % 2 == 0 + is_odd = parent._nodes.displayed_and_visible.index(self) % 2 == 0 self._odd = (parent._nodes._updates, is_odd) return is_odd except ValueError: @@ -1411,7 +1411,7 @@ def update_styles(children: list[DOMNode]) -> None: if child._has_order_style: child._update_styles() - self.call_later(update_styles, list(self.children)) + self.call_later(update_styles, self.displayed_children) await_mount = AwaitMount(self, mounted) self.call_next(await_mount) @@ -4391,7 +4391,8 @@ def _check_refresh(self) -> None: screen.post_message(messages.UpdateScroll()) if self._repaint_required: self._repaint_required = False - screen.post_message(messages.Update(self)) + if self.display: + screen.post_message(messages.Update(self)) if self._layout_required: self._layout_required = False screen.post_message(messages.Layout()) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index efc56d8613..0a0b98a1f8 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -66,7 +66,7 @@ class DirectoryTree(Tree[DirEntry]): } & > .directory-tree--hidden { - color: $text 50%; + text-style: dim; } &:ansi { @@ -431,7 +431,7 @@ def render_label( if node_label.plain.startswith("."): node_label.stylize_before( - self.get_component_rich_style("directory-tree--hidden") + self.get_component_rich_style("directory-tree--hidden", partial=True) ) text = Text.assemble(prefix, node_label) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index b643ea8fda..41a24ea514 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import defaultdict +from itertools import groupby from typing import TYPE_CHECKING import rich.repr @@ -12,6 +13,7 @@ from textual.containers import ScrollableContainer from textual.reactive import reactive from textual.widget import Widget +from textual.widgets import Label if TYPE_CHECKING: from textual.screen import Screen @@ -64,6 +66,7 @@ class FooterKey(Widget): """ compact = reactive(True) + """Display compact style.""" def __init__( self, @@ -95,18 +98,22 @@ def render(self) -> Text: "footer-key--description" ).padding description = self.description - label_text = Text.assemble( - ( - " " * key_padding.left + key_display + " " * key_padding.right, - key_style, - ), - ( - " " * description_padding.left - + description - + " " * description_padding.right, - description_style, - ), - ) + if description: + label_text = Text.assemble( + ( + " " * key_padding.left + key_display + " " * key_padding.right, + key_style, + ), + ( + " " * description_padding.left + + description + + " " * description_padding.right, + description_style, + ), + ) + else: + label_text = Text.assemble((key_display, key_style)) + label_text.stylize_before(self.rich_style) return label_text @@ -120,13 +127,16 @@ def _watch_compact(self, compact: bool) -> None: self.set_class(compact, "-compact") +class FooterLabel(Label): + """Text displayed in the footer (used by binding groups).""" + + @rich.repr.auto class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): ALLOW_SELECT = False DEFAULT_CSS = """ Footer { - layout: grid; - grid-columns: auto; + layout: horizontal; color: $footer-foreground; background: $footer-background; dock: bottom; @@ -140,6 +150,11 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): padding-right: 1; border-left: vkey $foreground 20%; } + HorizontalGroup.binding-group { + width: auto; + height: 1; + layout: horizontal; + } &:ansi { background: ansi_default; @@ -164,6 +179,15 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): border-left: vkey ansi_black; } } + FooterKey.-grouped { + margin: 0 1; + } + FooterLabel { + margin: 0 1; + background: red; + color: $footer-description-foreground; + background: $footer-description-background; + } } """ @@ -173,6 +197,8 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): """True if the bindings are ready to be displayed.""" show_command_palette = reactive(True) """Show the key to invoke the command palette.""" + combine_groups = reactive(True) + """Combine bindings in the same group?""" def __init__( self, @@ -217,16 +243,33 @@ def compose(self) -> ComposeResult: action_to_bindings[binding.action].append((binding, enabled, tooltip)) self.styles.grid_size_columns = len(action_to_bindings) - for multi_bindings in action_to_bindings.values(): - binding, enabled, tooltip = multi_bindings[0] - yield FooterKey( - binding.key, - self.app.get_key_display(binding), - binding.description, - binding.action, - disabled=not enabled, - tooltip=tooltip, - ).data_bind(Footer.compact) + + for group, multi_bindings_iterable in groupby( + action_to_bindings.values(), + lambda multi_bindings: multi_bindings[0][0].group, + ): + if group is not None: + for multi_bindings in multi_bindings_iterable: + binding, enabled, tooltip = multi_bindings[0] + yield FooterKey( + binding.key, + self.app.get_key_display(binding), + "", + binding.action, + classes="-grouped", + ).data_bind(Footer.compact) + yield FooterLabel(group.description) + else: + for multi_bindings in multi_bindings_iterable: + binding, enabled, tooltip = multi_bindings[0] + yield FooterKey( + binding.key, + self.app.get_key_display(binding), + binding.description, + binding.action, + disabled=not enabled, + tooltip=tooltip, + ).data_bind(Footer.compact) if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE: try: _node, binding, enabled, tooltip = active_bindings[ diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 50b1462f4b..de072da280 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -409,6 +409,9 @@ class TextArea(ScrollView): suggestion: Reactive[str] = reactive("") """A suggestion for auto-complete (pressing right will insert it).""" + hide_suggestion_on_blur: Reactive[bool] = reactive(True) + """Hide suggestion when the TextArea does not have focus.""" + placeholder: Reactive[str | Content] = reactive("") """Text to show when the text area has no content.""" @@ -443,6 +446,23 @@ class SelectionChanged(Message): def control(self) -> TextArea: return self.text_area + @dataclass + class UserInsert(Message): + """Posted when the user has entered text via the keyboard.""" + + location: Location + """The location where the text was added.""" + + text: str + """The text that was entered.""" + + text_area: TextArea + """The `text_area` that sent this message.""" + + @property + def control(self) -> TextArea: + return self.text_area + def __init__( self, text: str = "", @@ -1383,7 +1403,7 @@ def _render_line(self, y: int) -> Strip: cursor_column + 1, ) - if self.suggestion and self.has_focus: + if self.suggestion and (self.has_focus or not self.hide_suggestion_on_blur): suggestion_style = self.get_component_rich_style( "text-area--suggestion" ) @@ -2423,6 +2443,7 @@ def _replace_via_keyboard( """ if self.read_only: return None + self.post_message(self.UserInsert(start, insert, self)) return self.replace(insert, start, end, maintain_selection_offset=False) def action_delete_left(self) -> None: diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 54ff172d67..f64d98f05f 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -2571,9 +2571,9 @@ def compose(self) -> ComposeResult: for item_number in range(5): yield Label(f"Item {item_number + 1}") - def on_mount(self) -> None: + async def on_mount(self) -> None: # Mounting a new widget should updated previous widgets, as the last of type has changed - self.mount(Label("HELLO")) + await self.mount(Label("HELLO")) assert snap_compare(PSApp()) diff --git a/tests/test_content.py b/tests/test_content.py index 1ca13423bd..ec28058386 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -338,3 +338,14 @@ def test_expand_tabs(input: Content, tab_width: int, expected: Content): print(repr(output)) assert output.plain == expected.plain assert output._spans == expected._spans + + +def test_add_spans() -> None: + content = Content.from_markup("[red]Hello[/red], World!") + content = content.add_spans([Span(0, 5, "green"), Span(7, 9, "blue")]) + expected = [ + Span(0, 5, style="red"), + Span(0, 5, style="green"), + Span(7, 9, style="blue"), + ] + assert content.spans == expected