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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
Expand Down
51 changes: 34 additions & 17 deletions src/textual/_node_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""

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

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

Expand All @@ -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)
5 changes: 5 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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.

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


Expand Down
32 changes: 21 additions & 11 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions src/textual/css/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
19 changes: 9 additions & 10 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?"""
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading