mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
refactor of compositor
This commit is contained in:
@@ -17,7 +17,7 @@ class Clock(Widget):
|
||||
|
||||
class ClockApp(App):
|
||||
async def on_mount(self):
|
||||
await self.view.dock(Clock())
|
||||
await self.screen.dock(Clock())
|
||||
|
||||
|
||||
ClockApp.run()
|
||||
|
||||
@@ -26,7 +26,7 @@ class HoverApp(App):
|
||||
"""Build layout here."""
|
||||
|
||||
hovers = (Hover() for _ in range(10))
|
||||
await self.view.dock(*hovers, edge="top")
|
||||
await self.screen.dock(*hovers, edge="top")
|
||||
|
||||
|
||||
HoverApp.run(log="textual.log")
|
||||
|
||||
@@ -8,8 +8,8 @@ class SimpleApp(App):
|
||||
async def on_mount(self) -> None:
|
||||
"""Build layout here."""
|
||||
|
||||
await self.view.dock(Placeholder(), edge="left", size=40)
|
||||
await self.view.dock(Placeholder(), Placeholder(), edge="top")
|
||||
await self.screen.dock(Placeholder(), edge="left", size=40)
|
||||
await self.screen.dock(Placeholder(), Placeholder(), edge="top")
|
||||
|
||||
|
||||
SimpleApp.run(log="textual.log")
|
||||
|
||||
@@ -26,9 +26,9 @@ class SmoothApp(App):
|
||||
footer = Footer()
|
||||
self.bar = Placeholder(name="left")
|
||||
|
||||
await self.view.dock(footer, edge="bottom")
|
||||
await self.view.dock(Placeholder(), Placeholder(), edge="top")
|
||||
await self.view.dock(self.bar, edge="left", size=40, z=1)
|
||||
await self.screen.dock(footer, edge="bottom")
|
||||
await self.screen.dock(Placeholder(), Placeholder(), edge="top")
|
||||
await self.screen.dock(self.bar, edge="left", size=40, z=1)
|
||||
|
||||
self.bar.layout_offset_x = -40
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
$primary: #20639b;
|
||||
|
||||
App > View {
|
||||
App > Screen {
|
||||
layout: dock;
|
||||
docks: side=left/1;
|
||||
text: on $primary;
|
||||
@@ -13,6 +13,7 @@ App > View {
|
||||
dock: side;
|
||||
width: 30;
|
||||
offset-x: -100%;
|
||||
|
||||
transition: offset 500ms in_out_cubic;
|
||||
border-right: outer #09312e;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class MyApp(App):
|
||||
|
||||
self.body = body = ScrollView(auto_width=True)
|
||||
|
||||
await self.view.dock(body)
|
||||
await self.screen.dock(body)
|
||||
|
||||
async def add_content():
|
||||
table = Table(title="Demo")
|
||||
|
||||
@@ -209,7 +209,7 @@ class CalculatorApp(App):
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Mount the calculator widget."""
|
||||
await self.view.dock(Calculator())
|
||||
await self.screen.dock(Calculator())
|
||||
|
||||
|
||||
CalculatorApp.run(title="Calculator Test", log="textual.log")
|
||||
|
||||
@@ -36,14 +36,14 @@ class MyApp(App):
|
||||
self.directory = DirectoryTree(self.path, "Code")
|
||||
|
||||
# Dock our widgets
|
||||
await self.view.dock(Header(), edge="top")
|
||||
await self.view.dock(Footer(), edge="bottom")
|
||||
await self.screen.dock(Header(), edge="top")
|
||||
await self.screen.dock(Footer(), edge="bottom")
|
||||
|
||||
# Note the directory is also in a scroll view
|
||||
await self.view.dock(
|
||||
await self.screen.dock(
|
||||
ScrollView(self.directory), edge="left", size=48, name="sidebar"
|
||||
)
|
||||
await self.view.dock(self.body, edge="top")
|
||||
await self.screen.dock(self.body, edge="top")
|
||||
|
||||
async def handle_file_click(self, message: FileClick) -> None:
|
||||
"""A message sent by the directory tree when a file is clicked."""
|
||||
|
||||
@@ -32,8 +32,8 @@ class EasingApp(App):
|
||||
await tree.add(tree.root.id, easing_key, {"easing": easing_key})
|
||||
await tree.root.expand()
|
||||
|
||||
await self.view.dock(ScrollView(tree), edge="left", size=32)
|
||||
await self.view.dock(self.easing_view)
|
||||
await self.screen.dock(ScrollView(tree), edge="left", size=32)
|
||||
await self.screen.dock(self.easing_view)
|
||||
await self.easing_view.dock(self.placeholder, edge="left", size=32)
|
||||
|
||||
async def handle_tree_click(self, message: TreeClick[dict]) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ class GridTest(App):
|
||||
async def on_mount(self) -> None:
|
||||
"""Make a simple grid arrangement."""
|
||||
|
||||
grid = await self.view.dock_grid(edge="left", name="left")
|
||||
grid = await self.screen.dock_grid(edge="left", name="left")
|
||||
|
||||
grid.add_column(fraction=1, name="left", min_size=20)
|
||||
grid.add_column(size=30, name="center")
|
||||
|
||||
@@ -7,7 +7,7 @@ class GridTest(App):
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
"""Create a grid with auto-arranging cells."""
|
||||
|
||||
grid = await self.view.dock_grid()
|
||||
grid = await self.screen.dock_grid()
|
||||
|
||||
grid.add_column("col", fraction=1, max_size=20)
|
||||
grid.add_row("row", fraction=1, max_size=10)
|
||||
|
||||
@@ -33,7 +33,7 @@ class MyApp(App):
|
||||
"""Create and dock the widgets."""
|
||||
|
||||
body = ScrollView()
|
||||
await self.view.mount(
|
||||
await self.screen.mount(
|
||||
Header(),
|
||||
Footer(),
|
||||
body=body,
|
||||
|
||||
@@ -19,6 +19,7 @@ class BasicApp(App):
|
||||
self.bind("a", "toggle_class('#header', '-visible')")
|
||||
self.bind("c", "toggle_class('#content', '-content-visible')")
|
||||
self.bind("d", "toggle_class('#footer', 'dim')")
|
||||
self.bind("x", "dump")
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
@@ -29,5 +30,8 @@ class BasicApp(App):
|
||||
sidebar=Widget(),
|
||||
)
|
||||
|
||||
def action_dump(self):
|
||||
self.panic(self.tree)
|
||||
|
||||
|
||||
BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
$text: #f0f0f0;
|
||||
$primary: #021720;
|
||||
$secondary:#95d52a;
|
||||
$secondary: #95d52a;
|
||||
$background: #262626;
|
||||
|
||||
$primary-style: $text on $background;
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
#uber {
|
||||
border: heavy green;
|
||||
margin: 5;
|
||||
/* border: heavy green; */
|
||||
margin: 2;
|
||||
layout: dock;
|
||||
docks: panels=top;
|
||||
}
|
||||
|
||||
#child1 {
|
||||
dock: panels;
|
||||
}
|
||||
|
||||
#child2 {
|
||||
dock: panels;
|
||||
}
|
||||
|
||||
#child3 {
|
||||
dock: panels;
|
||||
}
|
||||
|
||||
#uber2 {
|
||||
margin: 3;
|
||||
layout: dock;
|
||||
docks: _default=left;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tkinter import Place
|
||||
from textual.app import App
|
||||
from textual import events
|
||||
from textual.widgets import Placeholder
|
||||
@@ -9,7 +10,22 @@ class BasicApp(App):
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(uber=Placeholder())
|
||||
|
||||
uber2 = Widget()
|
||||
uber2.add_children(
|
||||
Placeholder(id="uber2-child1"),
|
||||
Placeholder(id="uber2-child2"),
|
||||
)
|
||||
|
||||
self.mount(
|
||||
uber=Widget(
|
||||
Placeholder(id="child1"),
|
||||
Placeholder(id="child2"),
|
||||
Placeholder(id="child3"),
|
||||
),
|
||||
uber2=uber2,
|
||||
)
|
||||
# self.panic(self.tree)
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
@@ -110,6 +110,8 @@ class BoundAnimator:
|
||||
|
||||
|
||||
class Animator:
|
||||
"""An object to manage updates to a given attributed over a period of time."""
|
||||
|
||||
def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
|
||||
self._animations: dict[tuple[object, str], Animation] = {}
|
||||
self.target = target
|
||||
@@ -225,4 +227,6 @@ class Animator:
|
||||
|
||||
def on_animation_frame(self) -> None:
|
||||
# TODO: We should be able to do animation without refreshing everything
|
||||
self.target.view.refresh(True, True)
|
||||
|
||||
self.target.screen.refresh(layout=True)
|
||||
# self.target.screen.app.refresh()
|
||||
|
||||
@@ -11,10 +11,9 @@ from rich.control import Control
|
||||
from rich.segment import Segment, SegmentLines
|
||||
from rich.style import Style
|
||||
|
||||
from . import log
|
||||
from . import errors, log
|
||||
from .geometry import Region, Offset, Size
|
||||
|
||||
from .layout import WidgetPlacement
|
||||
from ._loop import loop_last
|
||||
from ._types import Lines
|
||||
from .widget import Widget
|
||||
@@ -26,13 +25,10 @@ else: # pragma: no cover
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .screen import Screen
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class NoWidget(Exception):
|
||||
"""Raised when there is no widget at the requested coordinate."""
|
||||
|
||||
|
||||
class ReflowResult(NamedTuple):
|
||||
"""The result of a reflow operation. Describes the chances to widgets."""
|
||||
|
||||
@@ -137,8 +133,8 @@ class Compositor:
|
||||
self.width = size.width
|
||||
self.height = size.height
|
||||
|
||||
map, virtual_size = self._arrange_root(parent)
|
||||
log(map)
|
||||
map, virtual_size, widgets = self._arrange_root(parent)
|
||||
|
||||
self._require_update = False
|
||||
|
||||
old_widgets = set(self.map.keys())
|
||||
@@ -165,12 +161,13 @@ class Compositor:
|
||||
}
|
||||
|
||||
parent.virtual_size = virtual_size
|
||||
|
||||
self.widgets.clear()
|
||||
self.widgets.update(widgets)
|
||||
return ReflowResult(
|
||||
hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets
|
||||
)
|
||||
|
||||
def _arrange_root(self, root: Widget) -> tuple[RenderRegionMap, Size]:
|
||||
def _arrange_root(self, root: Widget) -> tuple[RenderRegionMap, Size, set[Widget]]:
|
||||
"""Arrange a widgets children based on its layout attribute.
|
||||
|
||||
Args:
|
||||
@@ -192,7 +189,7 @@ class Compositor:
|
||||
order: tuple[int, ...],
|
||||
clip: Region,
|
||||
):
|
||||
widgets: set[Widget] = set()
|
||||
widgets.add(widget)
|
||||
styles_offset = widget.styles.offset
|
||||
total_region = region
|
||||
layout_offset = (
|
||||
@@ -200,7 +197,6 @@ class Compositor:
|
||||
if styles_offset
|
||||
else ORIGIN
|
||||
)
|
||||
|
||||
map[widget] = RenderRegion(region + layout_offset, order, clip)
|
||||
|
||||
if widget.layout is not None:
|
||||
@@ -227,12 +223,10 @@ class Compositor:
|
||||
return total_region.size
|
||||
|
||||
virtual_size = add_widget(root, size.region, (), size.region)
|
||||
self.widgets.clear()
|
||||
self.widgets.update(widgets)
|
||||
return map, virtual_size
|
||||
return map, virtual_size, widgets
|
||||
|
||||
async def mount_all(self, view: "View") -> None:
|
||||
view.mount(*self.widgets)
|
||||
async def mount_all(self, screen: Screen) -> None:
|
||||
screen.app.mount(*self.widgets)
|
||||
|
||||
def __iter__(self) -> Iterator[tuple[Widget, Region, Region]]:
|
||||
layers = sorted(self.map.items(), key=lambda item: item[1].order, reverse=True)
|
||||
@@ -244,14 +238,14 @@ class Compositor:
|
||||
try:
|
||||
return self.map[widget].region.origin
|
||||
except KeyError:
|
||||
raise NoWidget("Widget is not in layout")
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
|
||||
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
|
||||
"""Get the widget under the given point or None."""
|
||||
for widget, cropped_region, region in self:
|
||||
if widget.is_visual and cropped_region.contains(x, y):
|
||||
if cropped_region.contains(x, y):
|
||||
return widget, region
|
||||
raise NoWidget(f"No widget under screen coordinate ({x}, {y})")
|
||||
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")
|
||||
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
"""Get the Style at the given cell or Style.null()
|
||||
@@ -265,13 +259,15 @@ class Compositor:
|
||||
"""
|
||||
try:
|
||||
widget, region = self.get_widget_at(x, y)
|
||||
except NoWidget:
|
||||
except errors.NoWidget:
|
||||
return Style.null()
|
||||
if widget not in self.regions:
|
||||
return Style.null()
|
||||
lines = widget._get_lines()
|
||||
x -= region.x
|
||||
y -= region.y
|
||||
if y > len(lines):
|
||||
return Style.null()
|
||||
line = lines[y]
|
||||
end = 0
|
||||
for segment in line:
|
||||
@@ -296,7 +292,7 @@ class Compositor:
|
||||
try:
|
||||
region, *_ = self.map[widget]
|
||||
except KeyError:
|
||||
raise NoWidget("Widget is not in layout")
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
else:
|
||||
return region
|
||||
|
||||
@@ -344,7 +340,7 @@ class Compositor:
|
||||
|
||||
for widget, region, _order, clip in widget_regions:
|
||||
|
||||
if not (widget.is_visual and widget.visible):
|
||||
if not (widget.visible and widget.is_visual):
|
||||
continue
|
||||
|
||||
lines = widget._get_lines()
|
||||
@@ -465,5 +461,4 @@ class Compositor:
|
||||
update_region = region.intersection(clip)
|
||||
update_lines = self.render(console, crop=update_region).lines
|
||||
update = LayoutUpdate(update_lines, update_region)
|
||||
log(update)
|
||||
return update
|
||||
|
||||
@@ -22,6 +22,13 @@ class NodeList:
|
||||
self._node_refs: list[ref[DOMNode]] = []
|
||||
self.__nodes: list[DOMNode] | None = []
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
self._prune()
|
||||
return bool(self._node_refs)
|
||||
|
||||
def __length_hint__(self) -> int:
|
||||
return len(self._node_refs)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self._widgets
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class Timer:
|
||||
*,
|
||||
name: str | None = None,
|
||||
callback: TimerCallback | None = None,
|
||||
repeat: int = None,
|
||||
repeat: int | None = None,
|
||||
skip: bool = False,
|
||||
pause: bool = False,
|
||||
) -> None:
|
||||
|
||||
@@ -9,11 +9,9 @@ else:
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .events import Event
|
||||
from .message import Message
|
||||
|
||||
Callback = Callable[[], None]
|
||||
# IntervalID = int
|
||||
|
||||
|
||||
class MessageTarget(Protocol):
|
||||
|
||||
@@ -12,7 +12,7 @@ import rich.repr
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.control import Control
|
||||
from rich.measure import Measurement
|
||||
from rich.screen import Screen
|
||||
from rich.screen import Screen as ScreenRenderable
|
||||
from rich.traceback import Traceback
|
||||
|
||||
from . import actions
|
||||
@@ -34,10 +34,9 @@ from .layouts.dock import Dock
|
||||
from .message_pump import MessagePump
|
||||
from .reactive import Reactive
|
||||
from .renderables.gradient import VerticalGradient
|
||||
from .view import View
|
||||
from .screen import Screen
|
||||
from .widget import Widget
|
||||
|
||||
from .css.query import NoMatchingNodesError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .css.query import DOMQuery
|
||||
@@ -50,8 +49,6 @@ warnings.simplefilter("always", ResourceWarning)
|
||||
|
||||
LayoutDefinition = "dict[str, Any]"
|
||||
|
||||
ViewType = TypeVar("ViewType", bound=View)
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
pass
|
||||
@@ -86,12 +83,12 @@ class App(DOMNode):
|
||||
driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None.
|
||||
title (str, optional): Title of the application. Defaults to "Textual Application".
|
||||
"""
|
||||
self.console = Console()
|
||||
self.error_console = Console(stderr=True)
|
||||
self.console = Console(markup=False)
|
||||
self.error_console = Console(markup=False, stderr=True)
|
||||
self._screen = screen
|
||||
self.driver_class = driver_class or self.get_driver_class()
|
||||
self._title = title
|
||||
self._view_stack: list[View] = []
|
||||
self._screen_stack: list[Screen] = []
|
||||
|
||||
self.focused: Widget | None = None
|
||||
self.mouse_over: Widget | None = None
|
||||
@@ -100,7 +97,7 @@ class App(DOMNode):
|
||||
self._exit_renderables: list[RenderableType] = []
|
||||
|
||||
self._docks: list[Dock] = []
|
||||
self._action_targets = {"app", "view"}
|
||||
self._action_targets = {"app", "screen"}
|
||||
self._animator = Animator(self)
|
||||
self.animate = self._animator.bind(self)
|
||||
self.mouse_position = Offset(0, 0)
|
||||
@@ -158,8 +155,8 @@ class App(DOMNode):
|
||||
return self._animator
|
||||
|
||||
@property
|
||||
def view(self) -> View:
|
||||
return self._view_stack[-1]
|
||||
def screen(self) -> Screen:
|
||||
return self._screen_stack[-1]
|
||||
|
||||
@property
|
||||
def css_type(self) -> str:
|
||||
@@ -262,10 +259,10 @@ class App(DOMNode):
|
||||
self.reset_styles()
|
||||
self.stylesheet = stylesheet
|
||||
self.stylesheet.update(self)
|
||||
self.view.refresh(layout=True)
|
||||
self.screen.refresh(layout=True)
|
||||
|
||||
def query(self, selector: str | None = None) -> DOMQuery:
|
||||
"""Get a DOM query in the current view.
|
||||
"""Get a DOM query in the current screen.
|
||||
|
||||
Args:
|
||||
selector (str, optional): A CSS selector or `None` for all nodes. Defaults to None.
|
||||
@@ -275,10 +272,10 @@ class App(DOMNode):
|
||||
"""
|
||||
from .css.query import DOMQuery
|
||||
|
||||
return DOMQuery(self.view, selector)
|
||||
return DOMQuery(self.screen, selector)
|
||||
|
||||
def get_child(self, id: str) -> DOMNode:
|
||||
"""Shorthand for self.view.get_child(id: str)
|
||||
"""Shorthand for self.screen.get_child(id: str)
|
||||
Returns the first child (immediate descendent) of this DOMNode
|
||||
with the given ID.
|
||||
|
||||
@@ -288,7 +285,7 @@ class App(DOMNode):
|
||||
Returns:
|
||||
DOMNode: The first child of this node with the specified ID.
|
||||
"""
|
||||
return self.view.get_child(id)
|
||||
return self.screen.get_child(id)
|
||||
|
||||
def render_background(self) -> RenderableType:
|
||||
gradient = VerticalGradient("red", "blue")
|
||||
@@ -303,12 +300,12 @@ class App(DOMNode):
|
||||
self.post_message_no_wait(messages.StylesUpdated(self))
|
||||
|
||||
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
||||
self.register(self.view, *anon_widgets, **widgets)
|
||||
self.view.refresh()
|
||||
self.register(self.screen, *anon_widgets, **widgets)
|
||||
self.screen.refresh()
|
||||
|
||||
async def push_view(self, view: ViewType) -> ViewType:
|
||||
self._view_stack.append(view)
|
||||
return view
|
||||
async def push_screen(self, screen: Screen) -> Screen:
|
||||
self._screen_stack.append(screen)
|
||||
return screen
|
||||
|
||||
async def set_focus(self, widget: Widget | None) -> None:
|
||||
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
|
||||
@@ -450,7 +447,7 @@ class App(DOMNode):
|
||||
|
||||
def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
|
||||
if child not in self.registry:
|
||||
parent.children._append(child)
|
||||
parent.node_list._append(child)
|
||||
self.registry.add(child)
|
||||
child.set_parent(parent)
|
||||
child.start_messages()
|
||||
@@ -473,6 +470,11 @@ class App(DOMNode):
|
||||
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
|
||||
apply_stylesheet = self.stylesheet.apply
|
||||
|
||||
# Register children
|
||||
for _widget_id, widget in name_widgets:
|
||||
if widget.node_list:
|
||||
self.register(widget, *widget.children)
|
||||
|
||||
for widget_id, widget in name_widgets:
|
||||
if widget not in self.registry:
|
||||
if widget_id is not None:
|
||||
@@ -500,7 +502,9 @@ class App(DOMNode):
|
||||
driver.disable_input()
|
||||
await self.close_messages()
|
||||
|
||||
def refresh(self) -> None:
|
||||
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
sync_available = (
|
||||
os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" and not WINDOWS
|
||||
)
|
||||
@@ -509,9 +513,7 @@ class App(DOMNode):
|
||||
try:
|
||||
if sync_available:
|
||||
console.file.write("\x1bP=1s\x1b\\")
|
||||
console.print(
|
||||
Screen(Control.home(), self.view.render_styled(), Control.home())
|
||||
)
|
||||
console.print(ScreenRenderable(Control.home(), self.screen.render()))
|
||||
if sync_available:
|
||||
console.file.write("\x1bP=2s\x1b\\")
|
||||
console.file.flush()
|
||||
@@ -519,6 +521,8 @@ class App(DOMNode):
|
||||
self.panic()
|
||||
|
||||
def display(self, renderable: RenderableType) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
if not self._closed:
|
||||
console = self.console
|
||||
try:
|
||||
@@ -551,7 +555,7 @@ class App(DOMNode):
|
||||
Returns:
|
||||
tuple[Widget, Region]: The widget and the widget's screen region.
|
||||
"""
|
||||
return self.view.get_widget_at(x, y)
|
||||
return self.screen.get_widget_at(x, y)
|
||||
|
||||
def bell(self) -> None:
|
||||
"""Play the console 'bell'."""
|
||||
@@ -578,9 +582,9 @@ class App(DOMNode):
|
||||
# Handle input events that haven't been forwarded
|
||||
# If the event has been forwarded it may have bubbled up back to the App
|
||||
if isinstance(event, events.Mount):
|
||||
view = View()
|
||||
self.register(self, view)
|
||||
await self.push_view(view)
|
||||
screen = Screen()
|
||||
self.register(self, screen)
|
||||
await self.push_screen(screen)
|
||||
await super().on_event(event)
|
||||
|
||||
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
|
||||
@@ -596,7 +600,7 @@ class App(DOMNode):
|
||||
await super().on_event(event)
|
||||
else:
|
||||
# Forward the event to the view
|
||||
await self.view.forward_event(event)
|
||||
await self.screen.forward_event(event)
|
||||
else:
|
||||
await super().on_event(event)
|
||||
|
||||
@@ -636,6 +640,16 @@ class App(DOMNode):
|
||||
async def broker_event(
|
||||
self, event_name: str, event: events.Event, default_namespace: object | None
|
||||
) -> bool:
|
||||
"""Allow the app an opportunity to dispatch events to action system.
|
||||
|
||||
Args:
|
||||
event_name (str): _description_
|
||||
event (events.Event): An event object.
|
||||
default_namespace (object | None): _description_
|
||||
|
||||
Returns:
|
||||
bool: _description_
|
||||
"""
|
||||
event.stop()
|
||||
try:
|
||||
style = getattr(event, "style")
|
||||
@@ -661,7 +675,7 @@ class App(DOMNode):
|
||||
|
||||
async def handle_layout(self, message: messages.Layout) -> None:
|
||||
message.stop()
|
||||
# await self.view.refresh_layout()
|
||||
# await self.screen.refresh_layout()
|
||||
self.app.refresh()
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
@@ -672,7 +686,7 @@ class App(DOMNode):
|
||||
await self.close_messages()
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
await self.view.post_message(event)
|
||||
await self.screen.post_message(event)
|
||||
|
||||
async def action_press(self, key: str) -> None:
|
||||
await self.press(key)
|
||||
@@ -687,13 +701,13 @@ class App(DOMNode):
|
||||
self.bell()
|
||||
|
||||
async def action_add_class_(self, selector: str, class_name: str) -> None:
|
||||
self.view.query(selector).add_class(class_name)
|
||||
self.screen.query(selector).add_class(class_name)
|
||||
|
||||
async def action_remove_class_(self, selector: str, class_name: str) -> None:
|
||||
self.view.query(selector).remove_class(class_name)
|
||||
self.screen.query(selector).remove_class(class_name)
|
||||
|
||||
async def action_toggle_class(self, selector: str, class_name: str) -> None:
|
||||
self.view.query(selector).toggle_class(class_name)
|
||||
self.screen.query(selector).toggle_class(class_name)
|
||||
|
||||
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
|
||||
self.stylesheet.update(self)
|
||||
|
||||
@@ -15,6 +15,7 @@ import rich.repr
|
||||
from rich.color import Color
|
||||
from rich.style import Style
|
||||
|
||||
from .. import log
|
||||
from ._error_tools import friendly_list
|
||||
from .constants import NULL_SPACING
|
||||
from .errors import StyleTypeError, StyleValueError
|
||||
@@ -537,7 +538,6 @@ class OffsetProperty:
|
||||
ScalarParseError: If any of the string values supplied in the 2-tuple cannot
|
||||
be parsed into a Scalar. For example, if you specify an non-existent unit.
|
||||
"""
|
||||
|
||||
if offset is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(layout=True)
|
||||
@@ -557,6 +557,7 @@ class OffsetProperty:
|
||||
else Scalar(float(y), Unit.CELLS, Unit.HEIGHT)
|
||||
)
|
||||
_offset = ScalarOffset(scalar_x, scalar_y)
|
||||
|
||||
if obj.set_rule(self.name, _offset):
|
||||
obj.refresh(layout=True)
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class DOMQuery:
|
||||
node.set_styles(css, **styles)
|
||||
return self
|
||||
|
||||
def refresh(self, repaint: bool = True, layout: bool = False) -> DOMQuery:
|
||||
def refresh(self, *, repaint: bool = True, layout: bool = False) -> DOMQuery:
|
||||
"""Refresh matched nodes.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .. import events
|
||||
from .. import events, log
|
||||
from ..geometry import Offset
|
||||
from .._animator import Animation
|
||||
from .scalar import ScalarOffset
|
||||
|
||||
@@ -11,6 +11,7 @@ import rich.repr
|
||||
from rich.color import Color
|
||||
from rich.style import Style
|
||||
|
||||
from .. import log
|
||||
from .._animator import Animation, EasingFunction
|
||||
from ..geometry import Spacing
|
||||
from ._style_properties import (
|
||||
@@ -361,6 +362,7 @@ class Styles(StylesBase):
|
||||
return self._rules.get(rule, default)
|
||||
|
||||
def refresh(self, *, layout: bool = False) -> None:
|
||||
return
|
||||
self._repaint_required = True
|
||||
self._layout_required = self._layout_required or layout
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import rich.repr
|
||||
from rich.highlighter import ReprHighlighter
|
||||
from rich.pretty import Pretty
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from ._node_list import NodeList
|
||||
@@ -19,7 +20,7 @@ from .message_pump import MessagePump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .css.query import DOMQuery
|
||||
from .view import View
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
class NoParent(Exception):
|
||||
@@ -37,11 +38,16 @@ class DOMNode(MessagePump):
|
||||
DEFAULT_STYLES = ""
|
||||
INLINE_STYLES = ""
|
||||
|
||||
def __init__(self, name: str | None = None, id: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self._id = id
|
||||
self._classes: set[str] = set()
|
||||
self.children = NodeList()
|
||||
self._classes: set[str] = set(classes) if classes else set()
|
||||
self.node_list = NodeList()
|
||||
self._css_styles: Styles = Styles(self)
|
||||
self._inline_styles: Styles = Styles.parse(
|
||||
self.INLINE_STYLES, repr(self), node=self
|
||||
@@ -73,18 +79,23 @@ class DOMNode(MessagePump):
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def view(self) -> "View":
|
||||
"""Get the current view."""
|
||||
def screen(self) -> "Screen":
|
||||
"""Get the current screen."""
|
||||
# Get the node by looking up a chain of parents
|
||||
# Note that self.view may not be the same as self.app.view
|
||||
from .view import View
|
||||
# Note that self.screen may not be the same as self.app.screen
|
||||
from .screen import Screen
|
||||
|
||||
node = self
|
||||
while node and not isinstance(node, View):
|
||||
while node and not isinstance(node, Screen):
|
||||
node = node._parent
|
||||
assert isinstance(node, View)
|
||||
assert isinstance(node, Screen)
|
||||
return node
|
||||
|
||||
@property
|
||||
def is_visual(self) -> bool:
|
||||
"""Check if the widget is visual (i.e. draws something on Screen)."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def id(self) -> str | None:
|
||||
"""The ID of this node, or None if the node has no ID.
|
||||
@@ -116,6 +127,22 @@ class DOMNode(MessagePump):
|
||||
def name(self) -> str | None:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def css_identifier(self) -> str:
|
||||
tokens = [self.__class__.__name__]
|
||||
if self.id is not None:
|
||||
tokens.append(f"#{self.id}")
|
||||
return "".join(tokens)
|
||||
|
||||
@property
|
||||
def css_identifier_styled(self) -> Text:
|
||||
tokens = Text(self.__class__.__name__)
|
||||
if self.id is not None:
|
||||
tokens.append(f"#{self.id}", style="bold")
|
||||
if self.name:
|
||||
tokens.append(f"[name={self.name}]", style="underline")
|
||||
return tokens
|
||||
|
||||
@property
|
||||
def classes(self) -> frozenset[str]:
|
||||
return frozenset(self._classes)
|
||||
@@ -237,12 +264,25 @@ class DOMNode(MessagePump):
|
||||
Returns:
|
||||
Tree: A Rich object which may be printed.
|
||||
"""
|
||||
from rich.columns import Columns
|
||||
from rich.panel import Panel
|
||||
|
||||
highlighter = ReprHighlighter()
|
||||
tree = Tree(highlighter(repr(self)))
|
||||
|
||||
def add_children(tree, node):
|
||||
for child in node.children:
|
||||
branch = tree.add(Pretty(child))
|
||||
for child in node.node_list:
|
||||
branch = tree.add(
|
||||
Columns(
|
||||
[
|
||||
Pretty(child),
|
||||
Text(
|
||||
f"{child.size.width} X {child.size.height}", style="dim"
|
||||
),
|
||||
Panel(Text(child.styles.css), border_style="dim"),
|
||||
]
|
||||
)
|
||||
)
|
||||
if tree.children:
|
||||
add_children(branch, child)
|
||||
|
||||
@@ -276,12 +316,20 @@ class DOMNode(MessagePump):
|
||||
Args:
|
||||
node (DOMNode): A DOM node.
|
||||
"""
|
||||
self.children._append(node)
|
||||
self.node_list._append(node)
|
||||
node.set_parent(self)
|
||||
|
||||
def add_children(self, *nodes: DOMNode, **named_nodes: DOMNode) -> None:
|
||||
_append = self.node_list._append
|
||||
for node in nodes:
|
||||
_append(node)
|
||||
for node_id, node in named_nodes.items():
|
||||
_append(node)
|
||||
node.id = node_id
|
||||
|
||||
def walk_children(self, with_self: bool = True) -> Iterable[DOMNode]:
|
||||
|
||||
stack: list[Iterator[DOMNode]] = [iter(self.children)]
|
||||
stack: list[Iterator[DOMNode]] = [iter(self.node_list)]
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
|
||||
@@ -294,8 +342,8 @@ class DOMNode(MessagePump):
|
||||
pop()
|
||||
else:
|
||||
yield node
|
||||
if node.children:
|
||||
push(iter(node.children))
|
||||
if node.node_list:
|
||||
push(iter(node.node_list))
|
||||
|
||||
def get_child(self, id: str) -> DOMNode:
|
||||
"""Return the first child (immediate descendent) of this node with the given ID.
|
||||
@@ -306,7 +354,7 @@ class DOMNode(MessagePump):
|
||||
Returns:
|
||||
DOMNode: The first child of this node with the ID.
|
||||
"""
|
||||
for child in self.children:
|
||||
for child in self.node_list:
|
||||
if child.id == id:
|
||||
return child
|
||||
raise NoMatchingNodesError(f"No child found with id={id!r}")
|
||||
@@ -379,5 +427,5 @@ class DOMNode(MessagePump):
|
||||
has_pseudo_classes = self.pseudo_classes.issuperset(class_names)
|
||||
return has_pseudo_classes
|
||||
|
||||
def refresh(self, repaint: bool = True, layout: bool = False) -> None:
|
||||
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
class MissingWidget(Exception):
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TextualError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoWidget(TextualError):
|
||||
pass
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar
|
||||
import rich.repr
|
||||
from rich.style import Style
|
||||
|
||||
from . import log
|
||||
from .geometry import Offset, Size
|
||||
from .message import Message
|
||||
from ._types import MessageTarget
|
||||
@@ -389,8 +390,3 @@ class Focus(Event, bubble=False):
|
||||
|
||||
class Blur(Event, bubble=False):
|
||||
pass
|
||||
|
||||
|
||||
# class Update(Event, bubble=False):
|
||||
# def can_replace(self, event: Message) -> bool:
|
||||
# return isinstance(event, Update) and event.sender == self.sender
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import ClassVar, Generator, Iterable, NamedTuple, Sequence, TYPE_CHECKING
|
||||
from typing import ClassVar, Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
|
||||
from .geometry import Region, Offset, Size
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .dom import DOMNode
|
||||
from .widget import Widget
|
||||
from .view import View
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
class WidgetPlacement(NamedTuple):
|
||||
@@ -47,8 +48,8 @@ class Layout(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def arrange(
|
||||
self, parent: View, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
self, parent: Screen, size: Size, scroll: Offset
|
||||
) -> tuple[Iterable[WidgetPlacement], set[Widget]]:
|
||||
"""Generate a layout map that defines where on the screen the widgets will be drawn.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
|
||||
Planned for deprecation
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from typing import ItemsView, KeysView, ValuesView, NamedTuple
|
||||
|
||||
from . import log
|
||||
from .geometry import Offset, Region, Size
|
||||
from operator import attrgetter
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class RenderRegion(NamedTuple):
|
||||
"""Defines the absolute location of a Widget."""
|
||||
|
||||
region: Region
|
||||
order: tuple[int, ...]
|
||||
clip: Region
|
||||
|
||||
|
||||
class LayoutMap:
|
||||
"""A container that maps widgets on to their absolute location."""
|
||||
|
||||
def __init__(self, size: Size) -> None:
|
||||
self.size = size
|
||||
self.widgets: dict[Widget, RenderRegion] = {}
|
||||
|
||||
def __getitem__(self, widget: Widget) -> RenderRegion:
|
||||
return self.widgets[widget]
|
||||
|
||||
def items(self) -> ItemsView[Widget, RenderRegion]:
|
||||
return self.widgets.items()
|
||||
|
||||
def keys(self) -> KeysView[Widget]:
|
||||
return self.widgets.keys()
|
||||
|
||||
def values(self) -> ValuesView[RenderRegion]:
|
||||
return self.widgets.values()
|
||||
|
||||
def clear(self) -> None:
|
||||
self.widgets.clear()
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget: Widget,
|
||||
region: Region,
|
||||
order: tuple[int, ...],
|
||||
clip: Region,
|
||||
) -> None:
|
||||
from .view import View
|
||||
|
||||
if widget in self.widgets:
|
||||
return
|
||||
|
||||
layout_offset = Offset(0, 0)
|
||||
if any(widget.styles.offset):
|
||||
layout_offset = widget.styles.offset.resolve(region.size, clip.size)
|
||||
|
||||
self.widgets[widget] = RenderRegion(region + layout_offset, order, clip)
|
||||
|
||||
# TODO: replace with widget.layout
|
||||
|
||||
if isinstance(widget, View):
|
||||
view: View = widget
|
||||
scroll = view.scroll
|
||||
total_region = region.size.region
|
||||
sub_clip = clip.intersection(region)
|
||||
|
||||
arrangement = sorted(
|
||||
view.get_arrangement(region.size, scroll), key=attrgetter("order")
|
||||
)
|
||||
for sub_region, sub_widget, z in arrangement:
|
||||
total_region = total_region.union(sub_region)
|
||||
if sub_widget is not None:
|
||||
self.add_widget(
|
||||
sub_widget,
|
||||
sub_region + region.origin - scroll,
|
||||
sub_widget.z + (z,),
|
||||
sub_clip,
|
||||
)
|
||||
view.virtual_size = total_region.size
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, NamedTuple, Sequence
|
||||
from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
|
||||
|
||||
from .._layout_resolve import layout_resolve
|
||||
from ..css.types import Edge
|
||||
@@ -17,7 +17,7 @@ else:
|
||||
from typing_extensions import Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
|
||||
DockEdge = Literal["top", "right", "bottom", "left"]
|
||||
|
||||
@@ -61,7 +61,7 @@ class DockLayout(Layout):
|
||||
|
||||
def arrange(
|
||||
self, parent: Widget, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
) -> tuple[Iterable[WidgetPlacement], set[Widget]]:
|
||||
|
||||
width, height = size
|
||||
layout_region = Region(0, 0, width, height)
|
||||
@@ -69,7 +69,7 @@ class DockLayout(Layout):
|
||||
|
||||
docks = self.get_docks(parent)
|
||||
|
||||
def make_dock_options(widget, edge: Edge) -> DockOptions:
|
||||
def make_dock_options(widget: Widget, edge: Edge) -> DockOptions:
|
||||
styles = widget.styles
|
||||
has_rule = styles.has_rule
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..layout import Layout, WidgetPlacement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
@@ -266,7 +266,7 @@ class GridLayout(Layout):
|
||||
return self.widgets.keys()
|
||||
|
||||
def arrange(
|
||||
self, view: View, size: Size, scroll: Offset
|
||||
self, view: Screen, size: Size, scroll: Offset
|
||||
) -> Iterable[WidgetPlacement]:
|
||||
"""Generate a map that associates widgets with their location on screen.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from textual._loop import loop_last
|
||||
from textual.css.styles import Styles
|
||||
from textual.geometry import Size, Offset, Region
|
||||
from textual.layout import Layout, WidgetPlacement
|
||||
from textual.view import View
|
||||
from textual.screen import Screen
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
@@ -15,26 +15,29 @@ class HorizontalLayout(Layout):
|
||||
fill the space of their parent container, all widgets used in a horizontal layout should have a specified.
|
||||
"""
|
||||
|
||||
def get_widgets(self, view: View) -> Iterable[Widget]:
|
||||
return view.children
|
||||
|
||||
def arrange(
|
||||
self, view: View, size: Size, scroll: Offset
|
||||
) -> Iterable[WidgetPlacement]:
|
||||
self, parent: Widget, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
|
||||
parent_width, parent_height = size
|
||||
x, y = 0, 0
|
||||
for last, widget in loop_last(view.children):
|
||||
styles: Styles = widget.styles
|
||||
x = y = 0
|
||||
app = parent.app
|
||||
for widget in parent.children:
|
||||
styles = widget.styles
|
||||
|
||||
if styles.height:
|
||||
render_height = int(
|
||||
styles.height.resolve_dimension(size, view.app.size)
|
||||
)
|
||||
render_height = int(styles.height.resolve_dimension(size, app.size))
|
||||
else:
|
||||
render_height = parent_height
|
||||
if styles.width:
|
||||
render_width = int(styles.width.resolve_dimension(size, view.app.size))
|
||||
render_width = int(styles.width.resolve_dimension(size, app.size))
|
||||
else:
|
||||
render_width = parent_width
|
||||
region = Region(x, y, render_width, render_height)
|
||||
yield WidgetPlacement(region, widget, order=0)
|
||||
add_placement(WidgetPlacement(region, widget, order=0))
|
||||
x += render_width
|
||||
|
||||
return placements, set(parent.children)
|
||||
|
||||
@@ -8,34 +8,35 @@ from ..layout import Layout, WidgetPlacement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
|
||||
|
||||
class VerticalLayout(Layout):
|
||||
def get_widgets(self, view: View) -> Iterable[Widget]:
|
||||
return view.children
|
||||
|
||||
def arrange(
|
||||
self, view: View, size: Size, scroll: Offset
|
||||
) -> Iterable[WidgetPlacement]:
|
||||
parent_width, parent_height = size
|
||||
x, y = 0, 0
|
||||
self, parent: Widget, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
|
||||
for widget in view.children:
|
||||
styles: Styles = widget.styles
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
|
||||
parent_width, parent_height = size
|
||||
x = y = 0
|
||||
app = parent.app
|
||||
for widget in parent.children:
|
||||
styles = widget.styles
|
||||
|
||||
if styles.height:
|
||||
render_height = int(
|
||||
styles.height.resolve_dimension(size, view.app.size)
|
||||
)
|
||||
render_height = int(styles.height.resolve_dimension(size, app.size))
|
||||
else:
|
||||
render_height = size.height
|
||||
|
||||
if styles.width:
|
||||
render_width = int(styles.width.resolve_dimension(size, view.app.size))
|
||||
render_width = int(styles.width.resolve_dimension(size, app.size))
|
||||
else:
|
||||
render_width = parent_width
|
||||
|
||||
region = Region(x, y, render_width, render_height)
|
||||
yield WidgetPlacement(region, widget, 0)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
y += render_height
|
||||
|
||||
return placements, set(parent.children)
|
||||
|
||||
@@ -24,8 +24,11 @@ class Message:
|
||||
]
|
||||
|
||||
sender: MessageTarget
|
||||
bubble: ClassVar[bool] = True
|
||||
verbosity: ClassVar[int] = 1
|
||||
bubble: ClassVar[bool] = True # Message will bubble to parent
|
||||
verbosity: ClassVar[int] = 1 # Verbosity (higher the more verbose)
|
||||
system: ClassVar[
|
||||
bool
|
||||
] = False # Message is system related and may not be handled by client code
|
||||
|
||||
def __init__(self, sender: MessageTarget) -> None:
|
||||
"""
|
||||
@@ -45,10 +48,13 @@ class Message:
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.sender
|
||||
|
||||
def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None:
|
||||
def __init_subclass__(
|
||||
cls, bubble: bool = True, verbosity: int = 1, system: bool = False
|
||||
) -> None:
|
||||
super().__init_subclass__()
|
||||
cls.bubble = bubble
|
||||
cls.verbosity = verbosity
|
||||
cls.system = system
|
||||
|
||||
@property
|
||||
def is_forwarded(self) -> bool:
|
||||
|
||||
@@ -13,10 +13,11 @@ from ._timer import Timer, TimerCallback
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from .message import Message
|
||||
from . import messages
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .view import View
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
class NoParent(Exception):
|
||||
@@ -215,7 +216,7 @@ class MessagePump:
|
||||
self.app.panic()
|
||||
break
|
||||
finally:
|
||||
if isinstance(message, events.Event) and self._message_queue.empty():
|
||||
if self._message_queue.empty():
|
||||
if not self._closed:
|
||||
event = events.Idle(self)
|
||||
for method in self._get_dispatch_methods("on_idle", event):
|
||||
@@ -225,6 +226,8 @@ class MessagePump:
|
||||
|
||||
async def dispatch_message(self, message: Message) -> bool | None:
|
||||
_rich_traceback_guard = True
|
||||
if message.system:
|
||||
return False
|
||||
if isinstance(message, events.Event):
|
||||
if not isinstance(message, events.Null):
|
||||
await self.on_event(message)
|
||||
@@ -271,13 +274,10 @@ class MessagePump:
|
||||
if not self._parent._closed and not self._parent._closing:
|
||||
await self._parent.post_message(message)
|
||||
|
||||
def post_message_no_wait(self, message: Message) -> bool:
|
||||
if self._closing or self._closed:
|
||||
return False
|
||||
if not self.check_message_enabled(message):
|
||||
return True
|
||||
self._message_queue.put_nowait(message)
|
||||
return True
|
||||
def check_idle(self):
|
||||
"""Prompt the message pump to call idle if the queue is empty."""
|
||||
if self._message_queue.empty():
|
||||
self.post_message_no_wait(messages.Prompt(sender=self))
|
||||
|
||||
async def post_message(self, message: Message) -> bool:
|
||||
if self._closing or self._closed:
|
||||
@@ -287,16 +287,24 @@ class MessagePump:
|
||||
await self._message_queue.put(message)
|
||||
return True
|
||||
|
||||
def post_message_from_child_no_wait(self, message: Message) -> bool:
|
||||
def post_message_no_wait(self, message: Message) -> bool:
|
||||
if self._closing or self._closed:
|
||||
return False
|
||||
return self.post_message_no_wait(message)
|
||||
if not self.check_message_enabled(message):
|
||||
return True
|
||||
self._message_queue.put_nowait(message)
|
||||
return True
|
||||
|
||||
async def post_message_from_child(self, message: Message) -> bool:
|
||||
if self._closing or self._closed:
|
||||
return False
|
||||
return await self.post_message(message)
|
||||
|
||||
def post_message_from_child_no_wait(self, message: Message) -> bool:
|
||||
if self._closing or self._closed:
|
||||
return False
|
||||
return self.post_message_no_wait(message)
|
||||
|
||||
async def on_callback(self, event: events.Callback) -> None:
|
||||
await event.callback()
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Refresh(Message):
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, Refresh)
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Update(Message, verbosity=3):
|
||||
def __init__(self, sender: MessagePump, widget: Widget):
|
||||
@@ -50,3 +56,10 @@ class StylesUpdated(Message):
|
||||
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, StylesUpdated)
|
||||
|
||||
|
||||
class Prompt(Message, system=True):
|
||||
"""Used to 'wake up' an event loop."""
|
||||
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, StylesUpdated)
|
||||
|
||||
@@ -5,7 +5,7 @@ import rich.repr
|
||||
from rich.style import Style
|
||||
|
||||
|
||||
from . import events, messages
|
||||
from . import events, messages, errors
|
||||
|
||||
from .geometry import Offset, Region
|
||||
from ._compositor import Compositor
|
||||
@@ -14,7 +14,7 @@ from .renderables.gradient import VerticalGradient
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class View(Widget):
|
||||
class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
DEFAULT_STYLES = """
|
||||
@@ -33,7 +33,7 @@ class View(Widget):
|
||||
return False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return VerticalGradient("#11998e", "#38ef7d")
|
||||
return self._compositor
|
||||
|
||||
def get_offset(self, widget: Widget) -> Offset:
|
||||
"""Get the absolute offset of a given Widget.
|
||||
@@ -58,7 +58,7 @@ class View(Widget):
|
||||
"""
|
||||
return self._compositor.get_widget_at(x, y)
|
||||
|
||||
def get_style_add(self, x: int, y: int) -> Style:
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
"""Get the style under a given coordinate.
|
||||
|
||||
Args:
|
||||
@@ -96,8 +96,7 @@ class View(Widget):
|
||||
for widget in shown:
|
||||
widget.post_message_no_wait(events.Show(self))
|
||||
|
||||
send_resize = shown
|
||||
send_resize.update(resized)
|
||||
send_resize = shown | resized
|
||||
|
||||
for widget, region, unclipped_region in self._compositor:
|
||||
widget._update_size(unclipped_region.size)
|
||||
@@ -123,12 +122,11 @@ class View(Widget):
|
||||
async def handle_layout(self, message: messages.Layout) -> None:
|
||||
message.stop()
|
||||
await self.refresh_layout()
|
||||
self.app.refresh()
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
event.stop()
|
||||
self._update_size(event.size)
|
||||
await self.refresh_layout()
|
||||
event.stop()
|
||||
|
||||
async def on_idle(self, event: events.Idle) -> None:
|
||||
if self._compositor.check_update():
|
||||
@@ -143,23 +141,61 @@ class View(Widget):
|
||||
region = self.get_widget_region(widget)
|
||||
else:
|
||||
widget, region = self.get_widget_at(event.x, event.y)
|
||||
except NoWidget:
|
||||
except errors.NoWidget:
|
||||
await self.app.set_mouse_over(None)
|
||||
else:
|
||||
await self.app.set_mouse_over(widget)
|
||||
await widget.forward_event(
|
||||
events.MouseMove(
|
||||
self,
|
||||
event.x - region.x,
|
||||
event.y - region.y,
|
||||
event.delta_x,
|
||||
event.delta_y,
|
||||
event.button,
|
||||
event.shift,
|
||||
event.meta,
|
||||
event.ctrl,
|
||||
screen_x=event.screen_x,
|
||||
screen_y=event.screen_y,
|
||||
style=event.style,
|
||||
)
|
||||
mouse_event = events.MouseMove(
|
||||
self,
|
||||
event.x - region.x,
|
||||
event.y - region.y,
|
||||
event.delta_x,
|
||||
event.delta_y,
|
||||
event.button,
|
||||
event.shift,
|
||||
event.meta,
|
||||
event.ctrl,
|
||||
screen_x=event.screen_x,
|
||||
screen_y=event.screen_y,
|
||||
style=event.style,
|
||||
)
|
||||
mouse_event.set_forwarded()
|
||||
await widget.forward_event(mouse_event)
|
||||
|
||||
async def forward_event(self, event: events.Event) -> None:
|
||||
if event.is_forwarded:
|
||||
return
|
||||
event.set_forwarded()
|
||||
if isinstance(event, (events.Enter, events.Leave)):
|
||||
await self.post_message(event)
|
||||
|
||||
elif isinstance(event, events.MouseMove):
|
||||
event.style = self.get_style_at(event.screen_x, event.screen_y)
|
||||
await self._on_mouse_move(event)
|
||||
|
||||
elif isinstance(event, events.MouseEvent):
|
||||
try:
|
||||
if self.app.mouse_captured:
|
||||
widget = self.app.mouse_captured
|
||||
region = self.get_widget_region(widget)
|
||||
else:
|
||||
widget, region = self.get_widget_at(event.x, event.y)
|
||||
except errors.NoWidget:
|
||||
await self.app.set_focus(None)
|
||||
else:
|
||||
if isinstance(event, events.MouseDown) and widget.can_focus:
|
||||
await self.app.set_focus(widget)
|
||||
event.style = self.get_style_at(event.screen_x, event.screen_y)
|
||||
await widget.forward_event(event.offset(-region.x, -region.y))
|
||||
|
||||
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
|
||||
try:
|
||||
widget, _region = self.get_widget_at(event.x, event.y)
|
||||
except errors.NoWidget:
|
||||
return
|
||||
scroll_widget = widget
|
||||
if scroll_widget is not None:
|
||||
await scroll_widget.forward_event(event)
|
||||
else:
|
||||
self.log("view.forwarded", event)
|
||||
await self.post_message(event)
|
||||
@@ -127,7 +127,7 @@ class View(Widget):
|
||||
try:
|
||||
await self.layout.mount_all(self)
|
||||
if not self.is_root_view:
|
||||
await self.app.view.refresh_layout()
|
||||
await self.app.screen.refresh_layout()
|
||||
return
|
||||
|
||||
if not self.size:
|
||||
@@ -247,7 +247,7 @@ class View(Widget):
|
||||
self.log("view.forwarded", event)
|
||||
await self.post_message(event)
|
||||
|
||||
async def action_toggle(self, name: str) -> None:
|
||||
widget = self[name]
|
||||
widget.visible = not widget.display
|
||||
await self.post_message(messages.Layout(self))
|
||||
# async def action_toggle(self, name: str) -> None:
|
||||
# widget = self[name]
|
||||
# widget.visible = not widget.display
|
||||
# await self.post_message(messages.Layout(self))
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import cast, Optional
|
||||
|
||||
from ..layouts.dock import DockLayout, Dock, DockEdge
|
||||
from ..layouts.grid import GridLayout, GridAlign
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class DoNotSet:
|
||||
do_not_set = DoNotSet()
|
||||
|
||||
|
||||
class DockView(View):
|
||||
class DockView(Screen):
|
||||
def __init__(self, name: str | None = None) -> None:
|
||||
super().__init__(layout=DockLayout(), name=name)
|
||||
|
||||
@@ -58,7 +58,7 @@ class DockView(View):
|
||||
) -> GridLayout:
|
||||
|
||||
grid = GridLayout(gap=gap, gutter=gutter, align=align)
|
||||
view = View(layout=grid, id=id, name=name)
|
||||
view = Screen(layout=grid, id=id, name=name)
|
||||
dock = Dock(edge, (view,), z)
|
||||
assert isinstance(self.layout, DockLayout)
|
||||
self.layout.docks.append(dock)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
from ..layouts.grid import GridLayout
|
||||
|
||||
|
||||
class GridView(View, layout=GridLayout):
|
||||
class GridView(Screen, layout=GridLayout):
|
||||
@property
|
||||
def grid(self) -> GridLayout:
|
||||
assert isinstance(self.layout, GridLayout)
|
||||
|
||||
@@ -5,7 +5,7 @@ from rich.console import RenderableType
|
||||
from .. import events
|
||||
from ..geometry import Size, SpacingDimensions
|
||||
from ..layouts.vertical import VerticalLayout
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
from ..message import Message
|
||||
from .. import messages
|
||||
from ..widget import Widget
|
||||
@@ -17,7 +17,7 @@ class WindowChange(Message):
|
||||
return isinstance(message, WindowChange)
|
||||
|
||||
|
||||
class WindowView(View, layout=VerticalLayout):
|
||||
class WindowView(Screen, layout=VerticalLayout):
|
||||
def __init__(
|
||||
self,
|
||||
widget: RenderableType | Widget,
|
||||
|
||||
@@ -37,7 +37,7 @@ from .reactive import Reactive, watch
|
||||
from .renderables.opacity import Opacity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .view import View
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
class RenderCache(NamedTuple):
|
||||
@@ -55,20 +55,20 @@ class RenderCache(NamedTuple):
|
||||
|
||||
@rich.repr.auto
|
||||
class Widget(DOMNode):
|
||||
_counts: ClassVar[dict[str, int]] = {}
|
||||
|
||||
can_focus: bool = False
|
||||
|
||||
DEFAULT_STYLES = """
|
||||
dock: _default
|
||||
"""
|
||||
|
||||
def __init__(self, name: str | None = None, id: str | None = None) -> None:
|
||||
if name is None:
|
||||
class_name = self.__class__.__name__
|
||||
Widget._counts.setdefault(class_name, 0)
|
||||
Widget._counts[class_name] += 1
|
||||
_count = self._counts[class_name]
|
||||
name = f"{class_name}{_count}"
|
||||
def __init__(
|
||||
self,
|
||||
*children: Widget,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
|
||||
self._size = Size(0, 0)
|
||||
self._repaint_required = False
|
||||
@@ -79,8 +79,11 @@ class Widget(DOMNode):
|
||||
self.render_cache: RenderCache | None = None
|
||||
self.highlight_style: Style | None = None
|
||||
|
||||
super().__init__(name=name, id=id)
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.add_children(*children)
|
||||
|
||||
has_focus = Reactive(False)
|
||||
mouse_over = Reactive(False)
|
||||
scroll_x = Reactive(0)
|
||||
scroll_y = Reactive(0)
|
||||
virtual_size = Reactive(Size(0, 0))
|
||||
@@ -103,6 +106,8 @@ class Widget(DOMNode):
|
||||
"""Pseudo classes for a widget"""
|
||||
if self._mouse_over:
|
||||
yield "hover"
|
||||
if self.has_focus:
|
||||
yield "focus"
|
||||
# TODO: focus
|
||||
|
||||
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
|
||||
@@ -148,6 +153,10 @@ class Widget(DOMNode):
|
||||
|
||||
return renderable
|
||||
|
||||
@property
|
||||
def children(self) -> list[Widget]:
|
||||
return list(self.node_list)
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
return self._size
|
||||
@@ -165,11 +174,6 @@ class Widget(DOMNode):
|
||||
"""Get the current console."""
|
||||
return active_app.get().console
|
||||
|
||||
@property
|
||||
def root_view(self) -> "View":
|
||||
"""Return the top-most view."""
|
||||
return active_app.get().view
|
||||
|
||||
@property
|
||||
def animate(self) -> BoundAnimator:
|
||||
if self._animate is None:
|
||||
@@ -181,6 +185,14 @@ class Widget(DOMNode):
|
||||
def layout(self) -> Layout | None:
|
||||
return self.styles.layout
|
||||
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
self.app.update_styles()
|
||||
|
||||
def watch_has_focus(self, value: bool) -> None:
|
||||
"""Update from CSS if has focus state changes."""
|
||||
self.app.update_styles()
|
||||
|
||||
def on_style_change(self) -> None:
|
||||
self.clear_render_cache()
|
||||
|
||||
@@ -218,8 +230,8 @@ class Widget(DOMNode):
|
||||
self._layout_required = False
|
||||
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
offset_x, offset_y = self.root_view.get_offset(self)
|
||||
return self.root_view.get_style_at(x + offset_x, y + offset_y)
|
||||
offset_x, offset_y = self.screen.get_offset(self)
|
||||
return self.screen.get_style_at(x + offset_x, y + offset_y)
|
||||
|
||||
async def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
||||
await self.app.call_later(callback, *args, **kwargs)
|
||||
@@ -228,7 +240,7 @@ class Widget(DOMNode):
|
||||
event.set_forwarded()
|
||||
await self.post_message(event)
|
||||
|
||||
def refresh(self, repaint: bool = True, layout: bool = False) -> None:
|
||||
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
|
||||
"""Initiate a refresh of the widget.
|
||||
|
||||
This method sets an internal flag to perform a refresh, which will be done on the
|
||||
@@ -244,7 +256,7 @@ class Widget(DOMNode):
|
||||
elif repaint:
|
||||
self.clear_render_cache()
|
||||
self._repaint_required = True
|
||||
self.post_message_no_wait(events.Null(self))
|
||||
self.check_idle()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Get renderable for widget.
|
||||
@@ -254,9 +266,8 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
# Default displays a pretty repr in the center of the screen
|
||||
return Align.center(
|
||||
Pretty(self, no_wrap=True, overflow="ellipsis"), vertical="middle"
|
||||
)
|
||||
|
||||
return Align.center(self.css_identifier_styled, vertical="middle")
|
||||
|
||||
async def action(self, action: str, *params) -> None:
|
||||
await self.app.action(action, self)
|
||||
@@ -269,6 +280,7 @@ class Widget(DOMNode):
|
||||
return await super().post_message(message)
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
self._update_size(event.size)
|
||||
self.refresh()
|
||||
|
||||
async def on_idle(self, event: events.Idle) -> None:
|
||||
@@ -277,13 +289,14 @@ class Widget(DOMNode):
|
||||
# self.render_cache = None
|
||||
self.reset_check_repaint()
|
||||
self.reset_check_layout()
|
||||
await self.emit(messages.Layout(self))
|
||||
await self.screen.post_message(messages.Layout(self))
|
||||
elif repaint or self.check_repaint():
|
||||
# self.render_cache = None
|
||||
self.reset_check_repaint()
|
||||
await self.emit(messages.Update(self, self))
|
||||
|
||||
async def focus(self) -> None:
|
||||
"""Give input focus to this widget."""
|
||||
await self.app.set_focus(self)
|
||||
|
||||
async def capture_mouse(self, capture: bool = True) -> None:
|
||||
@@ -315,14 +328,6 @@ class Widget(DOMNode):
|
||||
async def on_click(self, event: events.Click) -> None:
|
||||
await self.broker_event("click", event)
|
||||
|
||||
async def on_enter(self, event: events.Enter) -> None:
|
||||
self._mouse_over = True
|
||||
self.app.update_styles()
|
||||
|
||||
async def on_leave(self, event: events.Leave) -> None:
|
||||
self._mouse_over = False
|
||||
self.app.update_styles()
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
if await self.dispatch_key(event):
|
||||
event.prevent_default()
|
||||
|
||||
@@ -130,6 +130,6 @@ if __name__ == "__main__":
|
||||
|
||||
class TreeApp(App):
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
await self.view.dock(DirectoryTree("/Users/willmcgugan/projects"))
|
||||
await self.screen.dock(DirectoryTree("/Users/willmcgugan/projects"))
|
||||
|
||||
TreeApp.run(log="textual.log")
|
||||
|
||||
@@ -24,9 +24,9 @@ class Placeholder(Widget, can_focus=True):
|
||||
style: Reactive[str] = Reactive("")
|
||||
height: Reactive[int | None] = Reactive(None)
|
||||
|
||||
def __init__(self, *, name: str | None = None, height: int | None = None) -> None:
|
||||
super().__init__(name=name)
|
||||
self.height = height
|
||||
# def __init__(self, *, name: str | None = None, height: int | None = None) -> None:
|
||||
# super().__init__(name=name)
|
||||
# self.height = height
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield from super().__rich_repr__()
|
||||
|
||||
@@ -11,14 +11,14 @@ from ..message import Message
|
||||
from ..messages import CursorMove
|
||||
from ..scrollbar import ScrollTo, ScrollBar
|
||||
from ..geometry import clamp
|
||||
from ..view import View
|
||||
from ..screen import Screen
|
||||
|
||||
from ..widget import Widget
|
||||
|
||||
from ..reactive import Reactive
|
||||
|
||||
|
||||
class ScrollView(View):
|
||||
class ScrollView(Screen):
|
||||
def __init__(
|
||||
self,
|
||||
contents: RenderableType | Widget | None = None,
|
||||
|
||||
@@ -317,7 +317,7 @@ if __name__ == "__main__":
|
||||
|
||||
class TreeApp(App):
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
await self.view.dock(TreeControl("Tree Root", data="foo"))
|
||||
await self.screen.dock(TreeControl("Tree Root", data="foo"))
|
||||
|
||||
async def handle_tree_click(self, message: TreeClick) -> None:
|
||||
if message.node.empty:
|
||||
|
||||
@@ -4,17 +4,4 @@ from textual.layouts.dock import DockLayout
|
||||
from textual.layouts.grid import GridLayout
|
||||
from textual.layouts.horizontal import HorizontalLayout
|
||||
from textual.layouts.vertical import VerticalLayout
|
||||
from textual.view import View
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_name, layout_type", [
|
||||
["dock", DockLayout],
|
||||
["grid", GridLayout],
|
||||
["vertical", VerticalLayout],
|
||||
["horizontal", HorizontalLayout],
|
||||
])
|
||||
def test_view_layout_get_and_set(layout_name, layout_type):
|
||||
view = View()
|
||||
view.layout = layout_name
|
||||
assert type(view.layout) is layout_type
|
||||
assert view.styles.layout is view.layout
|
||||
from textual.screen import Screen
|
||||
|
||||
Reference in New Issue
Block a user