header fixes, and lazy queries

This commit is contained in:
Will McGugan
2022-08-13 09:12:59 +01:00
parent 71bb29d5d4
commit f00e2d22d4
18 changed files with 398 additions and 157 deletions

View File

@@ -16,7 +16,7 @@ class AddRemoveApp(App):
dock: top;
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;
}
"""

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

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

View File

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