mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
header fixes, and lazy queries
This commit is contained in:
@@ -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;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
78
src/textual/widgets/_headerx.py
Normal file
78
src/textual/widgets/_headerx.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user