diff --git a/sandbox/will/add_remove.py b/sandbox/will/add_remove.py index 27de0c03f..5d95bd4da 100644 --- a/sandbox/will/add_remove.py +++ b/sandbox/will/add_remove.py @@ -14,9 +14,9 @@ class AddRemoveApp(App): CSS = """ #buttons { dock: top; - height: auto; + height: auto; } - Button { + #buttons Button { width: 1fr; } #items { @@ -26,8 +26,8 @@ class AddRemoveApp(App): Thing { height: 5; background: $panel; - border: wide $primary; - margin: 0 1; + border: tall $primary; + margin: 1 1; content-align: center middle; } """ diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index f315a990a..ade36299e 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -16,7 +16,7 @@ App > Screen { background: $surface; color: $text-surface; - layers: sidebar; + layers: base sidebar; color: $text-background; background: $background; @@ -89,14 +89,7 @@ DataTable { content-align: center middle; } -#header { - color: $text-secondary-background; - background: $secondary-background; - height: 1; - content-align: center middle; - - dock: top; -} + Tweet { @@ -121,7 +114,7 @@ Tweet { overflow-x: auto; overflow-y: scroll; margin: 1 2; - height: 20; + height: 24; align-horizontal: center; layout: vertical; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 990e4eea8..22756ac8c 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -6,7 +6,7 @@ from rich.text import Text from textual.app import App, ComposeResult from textual.reactive import Reactive from textual.widget import Widget -from textual.widgets import Static, DataTable, DirectoryTree, Footer +from textual.widgets import Static, DataTable, DirectoryTree, Header, Footer from textual.layout import Vertical CODE = ''' @@ -112,17 +112,14 @@ class BasicApp(App, css_path="basic.css"): self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar") self.bind("d", "toggle_dark", description="Dark mode") self.bind("q", "quit", description="Quit") + self.bind("f", "query_test", description="Query test") + + def compose(self): + yield Header() - def compose(self) -> ComposeResult: table = DataTable() self.scroll_to_target = Tweet(TweetBody()) - yield Static( - Text.from_markup( - "[b]This is a [u]Textual[/u] app, running in the terminal" - ), - id="header", - ) yield from ( Tweet(TweetBody()), Widget( @@ -166,12 +163,30 @@ class BasicApp(App, css_path="basic.css"): for n in range(100): table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) + def on_mount(self): + self.sub_title = "Widget demo" + async def on_key(self, event) -> None: await self.dispatch_key(event) def action_toggle_dark(self): self.dark = not self.dark + def action_query_test(self): + query = self.query("Tweet") + self.log(query) + self.log(query.nodes) + self.log(query) + self.log(query.nodes) + + query = query.exclude(".scroll-horizontal") + self.log(query) + self.log(query.nodes) + + query = query.filter(".rubbish") + self.log(query) + self.log(query.first()) + async def key_q(self): await self.shutdown() diff --git a/sandbox/will/footer.py b/sandbox/will/footer.py index b09db10ea..b2faa6a49 100644 --- a/sandbox/will/footer.py +++ b/sandbox/will/footer.py @@ -1,13 +1,17 @@ from textual.app import App -from textual.widgets import Footer +from textual.widgets import Header, Footer class FooterApp(App): def on_mount(self): - self.dark = True + self.sub_title = "Header and footer example" self.bind("b", "app.bell", description="Play the Bell") + self.bind("d", "dark", description="Toggle dark") self.bind("f1", "app.bell", description="Hello World") - self.bind("f2", "app.bell", description="Do something") + + def action_dark(self): + self.dark = not self.dark def compose(self): + yield Header() yield Footer() diff --git a/src/textual/app.py b/src/textual/app.py index 65178d418..8a843c3fc 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1082,7 +1082,6 @@ class App(Generic[ReturnType], DOMNode): Returns: bool: True if an action was processed. """ - event.stop() try: style = getattr(event, "style") except AttributeError: @@ -1091,6 +1090,8 @@ class App(Generic[ReturnType], DOMNode): modifiers, action = extract_handler_actions(event_name, style.meta) except NoHandler: return False + else: + event.stop() if isinstance(action, str): await self.action( action, default_namespace=default_namespace, modifiers=modifiers diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 64bf2bd9b..cc960eedb 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -133,6 +133,10 @@ class SelectorSet: for selector, next_selector in zip(self.selectors, self.selectors[1:]): selector.advance = int(next_selector.combinator != SAME) + @property + def css(self) -> str: + return RuleSet._selector_to_css(self.selectors) + def __rich_repr__(self) -> rich.repr.Result: selectors = RuleSet._selector_to_css(self.selectors) yield selectors diff --git a/src/textual/css/query.py b/src/textual/css/query.py index f71ed4a5e..951287658 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -6,7 +6,10 @@ actions to the nodes in the query. If this sounds like JQuery, a (once) popular JS library, it is no coincidence. -DOMQuery objects are typically created by Widget.filter method. +DOMQuery objects are typically created by Widget.query method. + +Queries are *lazy*. Results will be calculated at the point you iterate over the query, or call +a method which evaluates the query, such as first() and last(). """ @@ -16,53 +19,92 @@ from __future__ import annotations import rich.repr -from typing import Iterator, overload, TYPE_CHECKING - +from typing import Iterator, overload, TypeVar, TYPE_CHECKING from .match import match from .parse import parse_selectors +from .model import SelectorSet if TYPE_CHECKING: from ..dom import DOMNode from ..widget import Widget -class NoMatchingNodesError(Exception): +class QueryError(Exception): + pass + + +class NoMatchingNodesError(QueryError): + pass + + +class WrongType(QueryError): pass @rich.repr.auto(angular=True) class DOMQuery: + __slots__ = [ + "_node", + "_nodes", + "_filters", + "_excludes", + ] + def __init__( self, - node: DOMNode | None = None, - selector: str | None = None, - nodes: list[Widget] | None = None, + node: DOMNode, + *, + filter: str | None = None, + exclude: str | None = None, + parent: DOMQuery | None = None, ) -> None: + + self._node = node + self._nodes: list[Widget] | None = None + self._filters: list[tuple[SelectorSet, ...]] = ( + parent._filters.copy() if parent else [] + ) + self._excludes: list[tuple[SelectorSet, ...]] = ( + parent._excludes.copy() if parent else [] + ) + if filter is not None: + self._filters.append(parse_selectors(filter)) + if exclude is not None: + self._excludes.append(parse_selectors(exclude)) + + @property + def node(self) -> DOMNode: + return self._node + + @property + def nodes(self) -> list[Widget]: + """Lazily evaluate nodes.""" from ..widget import Widget - self._selector = selector - self._nodes: list[Widget] = [] - if nodes is not None: + if self._nodes is None: + nodes = [ + node + for node in self._node.walk_children(Widget) + if all(match(selector_set, node) for selector_set in self._filters) + ] + nodes = [ + node + for node in nodes + if not any(match(selector_set, node) for selector_set in self._excludes) + ] self._nodes = nodes - elif node is not None: - self._nodes = [node for node in node.walk_children()] - else: - self._nodes = [] - - if selector is not None: - selector_set = parse_selectors(selector) - self._nodes = [_node for _node in self._nodes if match(selector_set, _node)] + return self._nodes def __len__(self) -> int: - return len(self._nodes) + return len(self.nodes) def __bool__(self) -> bool: """True if non-empty, otherwise False.""" - return bool(self._nodes) + return bool(self.nodes) def __iter__(self) -> Iterator[Widget]: - return iter(self._nodes) + return iter(self.nodes) @overload def __getitem__(self, index: int) -> Widget: @@ -73,10 +115,20 @@ class DOMQuery: ... def __getitem__(self, index: int | slice) -> Widget | list[Widget]: - return self._nodes[index] + return self.nodes[index] def __rich_repr__(self) -> rich.repr.Result: - yield self._nodes + yield self.node + if self._filters: + yield "filter", " AND ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._filters + ) + if self._excludes: + yield "exclude", " OR ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._excludes + ) def filter(self, selector: str) -> DOMQuery: """Filter this set by the given CSS selector. @@ -88,11 +140,7 @@ class DOMQuery: DOMQuery: New DOM Query. """ - selector_set = parse_selectors(selector) - query = DOMQuery( - nodes=[_node for _node in self._nodes if match(selector_set, _node)] - ) - return query + return DOMQuery(self.node, filter=selector, parent=self) def exclude(self, selector: str) -> DOMQuery: """Exclude nodes that match a given selector. @@ -103,59 +151,81 @@ class DOMQuery: Returns: DOMQuery: New DOM query. """ - selector_set = parse_selectors(selector) - query = DOMQuery( - nodes=[_node for _node in self._nodes if not match(selector_set, _node)] - ) - return query + return DOMQuery(self.node, exclude=selector, parent=self) + ExpectType = TypeVar("ExpectType") + + @overload def first(self) -> Widget: + ... + + @overload + def first(self, expect_type: type[ExpectType]) -> ExpectType: + ... + + def first(self, expect_type: type[ExpectType] | None = None) -> Widget | ExpectType: """Get the first matched node. Returns: DOMNode: A DOM Node. """ - if self._nodes: - return self._nodes[0] + if self.nodes: + first = self.nodes[0] + if expect_type is not None: + if not isinstance(first, expect_type): + raise WrongType( + f"Query value is wrong type; expected {expect_type}, got {type(first)}" + ) + return first else: - raise NoMatchingNodesError( - f"No nodes match the selector {self._selector!r}" - ) + raise NoMatchingNodesError(f"No nodes match {self!r}") + @overload def last(self) -> Widget: + ... + + @overload + def last(self, expect_type: type[ExpectType]) -> ExpectType: + ... + + def last(self, expect_type: type[ExpectType] | None = None) -> Widget | ExpectType: """Get the last matched node. Returns: DOMNode: A DOM Node. """ - if self._nodes: - return self._nodes[-1] + if self.nodes: + last = self.nodes[-1] + if expect_type is not None: + if not isinstance(last, expect_type): + raise WrongType( + f"Query value is wrong type; expected {expect_type}, got {type(last)}" + ) + return last else: - raise NoMatchingNodesError( - f"No nodes match the selector {self._selector!r}" - ) + raise NoMatchingNodesError(f"No nodes match {self!r}") def add_class(self, *class_names: str) -> DOMQuery: """Add the given class name(s) to nodes.""" - for node in self._nodes: + for node in self.nodes: node.add_class(*class_names) return self def remove_class(self, *class_names: str) -> DOMQuery: """Remove the given class names from the nodes.""" - for node in self._nodes: + for node in self.nodes: node.remove_class(*class_names) return self def toggle_class(self, *class_names: str) -> DOMQuery: """Toggle the given class names from matched nodes.""" - for node in self._nodes: + for node in self.nodes: node.toggle_class(*class_names) return self def remove(self) -> DOMQuery: """Remove matched nodes from the DOM""" - for node in self._nodes: + for node in self.nodes: node.remove() return self @@ -165,7 +235,7 @@ class DOMQuery: Args: css (str, optional): CSS declarations to parser, or None. Defaults to None. """ - for node in self._nodes: + for node in self.nodes: node.set_styles(css, **styles) return self @@ -179,6 +249,6 @@ class DOMQuery: Returns: DOMQuery: Query for chaining. """ - for node in self._nodes: + for node in self.nodes: node.refresh(repaint=repaint, layout=layout) return self diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 06957892e..b0dbf23e3 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -261,6 +261,7 @@ class Stylesheet: content, is_defaults, source_tie_breaker = self.source[path] if source_tie_breaker > tie_breaker: self.source[path] = CssSource(content, is_defaults, tie_breaker) + self._require_parse = True return self.source[path] = CssSource(css, is_default_css, tie_breaker) self._require_parse = True diff --git a/src/textual/dom.py b/src/textual/dom.py index 1fbf3c03d..ad88fc530 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,8 +1,7 @@ from __future__ import annotations from inspect import getfile -from operator import attrgetter -from typing import ClassVar, Iterable, Iterator, Type, TYPE_CHECKING +from typing import ClassVar, Iterable, Iterator, Type, overload, TypeVar, TYPE_CHECKING import rich.repr from rich.highlighter import ReprHighlighter @@ -487,7 +486,27 @@ class DOMNode(MessagePump): _append(node) node.id = node_id - def walk_children(self, with_self: bool = True) -> Iterable[DOMNode]: + WalkType = TypeVar("WalkType") + + @overload + def walk_children( + self, + require_type: type[WalkType], + *, + with_self: bool = True, + ) -> Iterable[WalkType]: + ... + + @overload + def walk_children(self, *, with_self: bool = True) -> Iterable[DOMNode]: + ... + + def walk_children( + self, + require_type: type[WalkType] | None = None, + *, + with_self: bool = True, + ) -> Iterable[DOMNode | WalkType]: """Generate all descendants of this node. Args: @@ -502,12 +521,15 @@ class DOMNode(MessagePump): if with_self: yield self + check_type = require_type or DOMNode + while stack: node = next(stack[-1], None) if node is None: pop() else: - yield node + if isinstance(node, check_type): + yield node if node.children: push(iter(node.children)) @@ -539,10 +561,28 @@ class DOMNode(MessagePump): """ from .css.query import DOMQuery - return DOMQuery(self, selector) + return DOMQuery(self, filter=selector) + ExpectType = TypeVar("ExpectType") + + @overload def query_one(self, selector: str) -> Widget: - """Get the first Widget matching the given selector. + ... + + @overload + def query_one(self, selector: type[ExpectType]) -> ExpectType: + ... + + @overload + def query_one(self, selector: str, expect_type: type[ExpectType]) -> ExpectType: + ... + + def query_one( + self, + selector: str | type[ExpectType], + expect_type: type[ExpectType] | None = None, + ) -> ExpectType | Widget: + """Get the first Widget matching the given selector or selector type. Args: selector (str | None, optional): A selector. @@ -552,8 +592,16 @@ class DOMNode(MessagePump): """ from .css.query import DOMQuery - query = DOMQuery(self.screen, selector) - return query.first() + if isinstance(selector, str): + query_selector = selector + else: + query_selector = selector.__name__ + query = DOMQuery(self.screen, filter=query_selector) + + if expect_type is None: + return query.first() + else: + return query.first(expect_type) def set_styles(self, css: str | None = None, **styles) -> None: """Set custom styles on this object.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 80743fcfc..71c964de8 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -342,10 +342,13 @@ class MessagePump(metaclass=MessagePumpMeta): message (Message): Message object. """ + private_method = f"_{method_name}" for cls in self.__class__.__mro__: if message._no_default_action: break - method = cls.__dict__.get(method_name, None) + method = cls.__dict__.get(private_method, None) or cls.__dict__.get( + method_name, None + ) if method is not None: yield cls, method.__get__(self, cls) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 049ceef76..84d6ed990 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -137,7 +137,7 @@ class Reactive(Generic[ReactiveType]): def watch( - obj: Reactable, attribute_name: str, callback: Callable[[Any], Awaitable[None]] + obj: Reactable, attribute_name: str, callback: Callable[[Any], object] ) -> None: watcher_name = f"__{attribute_name}_watchers" current_value = getattr(obj, attribute_name, None) diff --git a/src/textual/screen.py b/src/textual/screen.py index 1ec1345fe..5e20862b2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -57,7 +57,7 @@ class Screen(Widget): """Timer used to perform updates.""" if self._update_timer is None: self._update_timer = self.set_interval( - UPDATE_PERIOD, self._on_update, name="screen_update", pause=True + UPDATE_PERIOD, self._on_timer_update, name="screen_update", pause=True ) return self._update_timer @@ -131,7 +131,7 @@ class Screen(Widget): # The Screen is idle - a good opportunity to invoke the scheduled callbacks await self._invoke_and_clear_callbacks() - def _on_update(self) -> None: + def _on_timer_update(self) -> None: """Called by the _update_timer.""" # Render widgets together if self._dirty_widgets: @@ -228,7 +228,7 @@ class Screen(Widget): async def on_resize(self, event: events.Resize) -> None: event.stop() - async def _on_mouse_move(self, event: events.MouseMove) -> None: + async def _handle_mouse_move(self, event: events.MouseMove) -> None: try: if self.app.mouse_captured: widget = self.app.mouse_captured @@ -265,7 +265,7 @@ class Screen(Widget): elif isinstance(event, events.MouseMove): event.style = self.get_style_at(event.screen_x, event.screen_y) - await self._on_mouse_move(event) + await self._handle_mouse_move(event) elif isinstance(event, events.MouseEvent): try: diff --git a/src/textual/widget.py b/src/textual/widget.py index a5159845f..494249912 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1154,7 +1154,7 @@ class Widget(DOMNode): assert self.parent self.parent.refresh(layout=True) - def on_mount(self, event: events.Mount) -> None: + def _on_mount(self, event: events.Mount) -> None: widgets = list(self.compose()) if widgets: self.mount(*widgets) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 9c596c034..1eb8c3f8a 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -106,7 +106,7 @@ class DirectoryTree(TreeControl[DirEntry]): return icon_label async def on_mount(self, event: events.Mount) -> None: - await self.load_directory(self.root) + self.call_later(self.load_directory, self.root) async def load_directory(self, node: TreeNode[DirEntry]): path = node.data.path diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index adf5e5e90..71f5477d4 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -1,78 +1,103 @@ from __future__ import annotations from datetime import datetime -from logging import getLogger -from rich.console import RenderableType -from rich.panel import Panel -from rich.repr import Result -from rich.style import StyleType, Style -from rich.table import Table +from rich.text import Text -from .. import events -from ..reactive import watch, Reactive from ..widget import Widget +from ..reactive import Reactive, watch -log = getLogger("rich") + +class HeaderIcon(Widget): + """Display an 'icon' on the left of the header.""" + + CSS = """ + HeaderIcon { + dock: left; + padding: 0 1; + width: 10; + content-align: left middle; + } + """ + icon = Reactive("⭘") + + def render(self): + return self.icon + + +class HeaderClock(Widget): + """Display a clock on the right of the header.""" + + CSS = """ + HeaderClock { + dock: right; + width: auto; + padding: 0 1; + background: $secondary-background-lighten-1; + color: $text-secondary-background; + opacity: 85%; + content-align: center middle; + } + """ + + def on_mount(self) -> None: + self.set_interval(1, callback=self.refresh) + + def render(self): + return Text(datetime.now().time().strftime("%X")) + + +class HeaderTitle(Widget): + """Display the title / subtitle in the header.""" + + CSS = """ + HeaderTitle { + content-align: center middle; + width: 100%; + } + """ + + text: Reactive[str] = Reactive("Hello World") + sub_text = Reactive("Test") + + def render(self) -> Text: + text = Text(self.text, no_wrap=True, overflow="ellipsis") + if self.sub_text: + text.append(f" - {self.sub_text}", "dim") + return text class Header(Widget): - def __init__( - self, - *, - tall: bool = True, - style: StyleType = "white on dark_green", - clock: bool = True, - ) -> None: - super().__init__() - self.tall = tall - self.style = style - self.clock = clock + """A header widget with icon and clock.""" - tall: Reactive[bool] = Reactive(True, layout=True) - style: Reactive[StyleType] = Reactive("white on blue") - clock: Reactive[bool] = Reactive(True) - title: Reactive[str] = Reactive("") - sub_title: Reactive[str] = Reactive("") + CSS = """ + Header { + dock: top; + width: 100%; + background: $secondary-background; + color: $text-secondary-background; + height: 1; + } + Header.tall { + height: 3; + } + """ - @property - def full_title(self) -> str: - return f"{self.title} - {self.sub_title}" if self.sub_title else self.title + async def on_click(self, event): + self.toggle_class("tall") - def __rich_repr__(self) -> Result: - yield from super().__rich_repr__() - yield "title", self.title + def on_mount(self) -> None: + def set_title(title: str) -> None: + self.query_one(HeaderTitle).text = title - async def watch_tall(self, tall: bool) -> None: - self.layout_size = 3 if tall else 1 - - def get_clock(self) -> str: - return datetime.now().time().strftime("%X") - - def render(self) -> RenderableType: - header_table = Table.grid(padding=(0, 1), expand=True) - header_table.style = self.style - header_table.add_column(justify="left", ratio=0, width=8) - header_table.add_column("title", justify="center", ratio=1) - header_table.add_column("clock", justify="right", width=8) - header_table.add_row( - "🐞", self.full_title, self.get_clock() if self.clock else "" - ) - header: RenderableType - header = Panel(header_table, style=self.style) if self.tall else header_table - return header - - async def on_mount(self, event: events.Mount) -> None: - self.set_interval(1.0, callback=self.refresh) - - async def set_title(title: str) -> None: - self.title = title - - async def set_sub_title(sub_title: str) -> None: - self.sub_title = sub_title + def set_sub_title(sub_title: str) -> None: + self.query_one(HeaderTitle).sub_text = sub_title watch(self.app, "title", set_title) watch(self.app, "sub_title", set_sub_title) + self.add_class("tall") - async def on_click(self, event: events.Click) -> None: - self.tall = not self.tall + def compose(self): + yield HeaderIcon() + yield HeaderTitle() + yield HeaderClock() diff --git a/src/textual/widgets/_headerx.py b/src/textual/widgets/_headerx.py new file mode 100644 index 000000000..adf5e5e90 --- /dev/null +++ b/src/textual/widgets/_headerx.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime +from logging import getLogger + +from rich.console import RenderableType +from rich.panel import Panel +from rich.repr import Result +from rich.style import StyleType, Style +from rich.table import Table + +from .. import events +from ..reactive import watch, Reactive +from ..widget import Widget + +log = getLogger("rich") + + +class Header(Widget): + def __init__( + self, + *, + tall: bool = True, + style: StyleType = "white on dark_green", + clock: bool = True, + ) -> None: + super().__init__() + self.tall = tall + self.style = style + self.clock = clock + + tall: Reactive[bool] = Reactive(True, layout=True) + style: Reactive[StyleType] = Reactive("white on blue") + clock: Reactive[bool] = Reactive(True) + title: Reactive[str] = Reactive("") + sub_title: Reactive[str] = Reactive("") + + @property + def full_title(self) -> str: + return f"{self.title} - {self.sub_title}" if self.sub_title else self.title + + def __rich_repr__(self) -> Result: + yield from super().__rich_repr__() + yield "title", self.title + + async def watch_tall(self, tall: bool) -> None: + self.layout_size = 3 if tall else 1 + + def get_clock(self) -> str: + return datetime.now().time().strftime("%X") + + def render(self) -> RenderableType: + header_table = Table.grid(padding=(0, 1), expand=True) + header_table.style = self.style + header_table.add_column(justify="left", ratio=0, width=8) + header_table.add_column("title", justify="center", ratio=1) + header_table.add_column("clock", justify="right", width=8) + header_table.add_row( + "🐞", self.full_title, self.get_clock() if self.clock else "" + ) + header: RenderableType + header = Panel(header_table, style=self.style) if self.tall else header_table + return header + + async def on_mount(self, event: events.Mount) -> None: + self.set_interval(1.0, callback=self.refresh) + + async def set_title(title: str) -> None: + self.title = title + + async def set_sub_title(sub_title: str) -> None: + self.sub_title = sub_title + + watch(self.app, "title", set_title) + watch(self.app, "sub_title", set_sub_title) + + async def on_click(self, event: events.Click) -> None: + self.tall = not self.tall diff --git a/tests/test_query.py b/tests/test_query.py index afbc99028..ac1b4cee8 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,14 +1,13 @@ from textual.dom import DOMNode +from textual.widget import Widget + def test_query(): - class Widget(DOMNode): + class View(Widget): pass - class View(DOMNode): - pass - - class App(DOMNode): + class App(Widget): pass app = App() diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index 8e422f2e7..23e21f00a 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -164,7 +164,7 @@ class AppTest(App): screen.refresh(repaint=repaint, layout=layout) # We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return: screen._dirty_widgets.add(screen) - screen._on_update() + screen._on_timer_update() await let_asyncio_process_some_events()