refactor of compositor

This commit is contained in:
Will McGugan
2022-03-02 15:39:26 +00:00
parent 7c64c7813c
commit 237c556673
50 changed files with 429 additions and 342 deletions

View File

@@ -17,7 +17,7 @@ class Clock(Widget):
class ClockApp(App): class ClockApp(App):
async def on_mount(self): async def on_mount(self):
await self.view.dock(Clock()) await self.screen.dock(Clock())
ClockApp.run() ClockApp.run()

View File

@@ -26,7 +26,7 @@ class HoverApp(App):
"""Build layout here.""" """Build layout here."""
hovers = (Hover() for _ in range(10)) 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") HoverApp.run(log="textual.log")

View File

@@ -8,8 +8,8 @@ class SimpleApp(App):
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Build layout here.""" """Build layout here."""
await self.view.dock(Placeholder(), edge="left", size=40) await self.screen.dock(Placeholder(), edge="left", size=40)
await self.view.dock(Placeholder(), Placeholder(), edge="top") await self.screen.dock(Placeholder(), Placeholder(), edge="top")
SimpleApp.run(log="textual.log") SimpleApp.run(log="textual.log")

View File

@@ -26,9 +26,9 @@ class SmoothApp(App):
footer = Footer() footer = Footer()
self.bar = Placeholder(name="left") self.bar = Placeholder(name="left")
await self.view.dock(footer, edge="bottom") await self.screen.dock(footer, edge="bottom")
await self.view.dock(Placeholder(), Placeholder(), edge="top") await self.screen.dock(Placeholder(), Placeholder(), edge="top")
await self.view.dock(self.bar, edge="left", size=40, z=1) await self.screen.dock(self.bar, edge="left", size=40, z=1)
self.bar.layout_offset_x = -40 self.bar.layout_offset_x = -40

View File

@@ -2,7 +2,7 @@
$primary: #20639b; $primary: #20639b;
App > View { App > Screen {
layout: dock; layout: dock;
docks: side=left/1; docks: side=left/1;
text: on $primary; text: on $primary;
@@ -13,6 +13,7 @@ App > View {
dock: side; dock: side;
width: 30; width: 30;
offset-x: -100%; offset-x: -100%;
transition: offset 500ms in_out_cubic; transition: offset 500ms in_out_cubic;
border-right: outer #09312e; border-right: outer #09312e;
} }

View File

@@ -15,7 +15,7 @@ class MyApp(App):
self.body = body = ScrollView(auto_width=True) self.body = body = ScrollView(auto_width=True)
await self.view.dock(body) await self.screen.dock(body)
async def add_content(): async def add_content():
table = Table(title="Demo") table = Table(title="Demo")

View File

@@ -209,7 +209,7 @@ class CalculatorApp(App):
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Mount the calculator widget.""" """Mount the calculator widget."""
await self.view.dock(Calculator()) await self.screen.dock(Calculator())
CalculatorApp.run(title="Calculator Test", log="textual.log") CalculatorApp.run(title="Calculator Test", log="textual.log")

View File

@@ -36,14 +36,14 @@ class MyApp(App):
self.directory = DirectoryTree(self.path, "Code") self.directory = DirectoryTree(self.path, "Code")
# Dock our widgets # Dock our widgets
await self.view.dock(Header(), edge="top") await self.screen.dock(Header(), edge="top")
await self.view.dock(Footer(), edge="bottom") await self.screen.dock(Footer(), edge="bottom")
# Note the directory is also in a scroll view # 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" 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: async def handle_file_click(self, message: FileClick) -> None:
"""A message sent by the directory tree when a file is clicked.""" """A message sent by the directory tree when a file is clicked."""

View File

@@ -32,8 +32,8 @@ class EasingApp(App):
await tree.add(tree.root.id, easing_key, {"easing": easing_key}) await tree.add(tree.root.id, easing_key, {"easing": easing_key})
await tree.root.expand() await tree.root.expand()
await self.view.dock(ScrollView(tree), edge="left", size=32) await self.screen.dock(ScrollView(tree), edge="left", size=32)
await self.view.dock(self.easing_view) await self.screen.dock(self.easing_view)
await self.easing_view.dock(self.placeholder, edge="left", size=32) await self.easing_view.dock(self.placeholder, edge="left", size=32)
async def handle_tree_click(self, message: TreeClick[dict]) -> None: async def handle_tree_click(self, message: TreeClick[dict]) -> None:

View File

@@ -6,7 +6,7 @@ class GridTest(App):
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Make a simple grid arrangement.""" """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(fraction=1, name="left", min_size=20)
grid.add_column(size=30, name="center") grid.add_column(size=30, name="center")

View File

@@ -7,7 +7,7 @@ class GridTest(App):
async def on_mount(self, event: events.Mount) -> None: async def on_mount(self, event: events.Mount) -> None:
"""Create a grid with auto-arranging cells.""" """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_column("col", fraction=1, max_size=20)
grid.add_row("row", fraction=1, max_size=10) grid.add_row("row", fraction=1, max_size=10)

View File

@@ -33,7 +33,7 @@ class MyApp(App):
"""Create and dock the widgets.""" """Create and dock the widgets."""
body = ScrollView() body = ScrollView()
await self.view.mount( await self.screen.mount(
Header(), Header(),
Footer(), Footer(),
body=body, body=body,

View File

@@ -19,6 +19,7 @@ class BasicApp(App):
self.bind("a", "toggle_class('#header', '-visible')") self.bind("a", "toggle_class('#header', '-visible')")
self.bind("c", "toggle_class('#content', '-content-visible')") self.bind("c", "toggle_class('#content', '-content-visible')")
self.bind("d", "toggle_class('#footer', 'dim')") self.bind("d", "toggle_class('#footer', 'dim')")
self.bind("x", "dump")
def on_mount(self): def on_mount(self):
"""Build layout here.""" """Build layout here."""
@@ -29,5 +30,8 @@ class BasicApp(App):
sidebar=Widget(), sidebar=Widget(),
) )
def action_dump(self):
self.panic(self.tree)
BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log") BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log")

View File

@@ -2,7 +2,7 @@
$text: #f0f0f0; $text: #f0f0f0;
$primary: #021720; $primary: #021720;
$secondary:#95d52a; $secondary: #95d52a;
$background: #262626; $background: #262626;
$primary-style: $text on $background; $primary-style: $text on $background;

View File

@@ -1,4 +1,24 @@
#uber { #uber {
border: heavy green; /* border: heavy green; */
margin: 5; margin: 2;
layout: dock;
docks: panels=top;
}
#child1 {
dock: panels;
}
#child2 {
dock: panels;
}
#child3 {
dock: panels;
}
#uber2 {
margin: 3;
layout: dock;
docks: _default=left;
} }

View File

@@ -1,3 +1,4 @@
from tkinter import Place
from textual.app import App from textual.app import App
from textual import events from textual import events
from textual.widgets import Placeholder from textual.widgets import Placeholder
@@ -9,7 +10,22 @@ class BasicApp(App):
def on_mount(self): def on_mount(self):
"""Build layout here.""" """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: async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event) await self.dispatch_key(event)

View File

@@ -110,6 +110,8 @@ class BoundAnimator:
class Animator: 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: def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
self._animations: dict[tuple[object, str], Animation] = {} self._animations: dict[tuple[object, str], Animation] = {}
self.target = target self.target = target
@@ -225,4 +227,6 @@ class Animator:
def on_animation_frame(self) -> None: def on_animation_frame(self) -> None:
# TODO: We should be able to do animation without refreshing everything # 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()

View File

@@ -11,10 +11,9 @@ from rich.control import Control
from rich.segment import Segment, SegmentLines from rich.segment import Segment, SegmentLines
from rich.style import Style from rich.style import Style
from . import log from . import errors, log
from .geometry import Region, Offset, Size from .geometry import Region, Offset, Size
from .layout import WidgetPlacement
from ._loop import loop_last from ._loop import loop_last
from ._types import Lines from ._types import Lines
from .widget import Widget from .widget import Widget
@@ -26,13 +25,10 @@ else: # pragma: no cover
if TYPE_CHECKING: if TYPE_CHECKING:
from .screen import Screen
from .widget import Widget from .widget import Widget
class NoWidget(Exception):
"""Raised when there is no widget at the requested coordinate."""
class ReflowResult(NamedTuple): class ReflowResult(NamedTuple):
"""The result of a reflow operation. Describes the chances to widgets.""" """The result of a reflow operation. Describes the chances to widgets."""
@@ -137,8 +133,8 @@ class Compositor:
self.width = size.width self.width = size.width
self.height = size.height self.height = size.height
map, virtual_size = self._arrange_root(parent) map, virtual_size, widgets = self._arrange_root(parent)
log(map)
self._require_update = False self._require_update = False
old_widgets = set(self.map.keys()) old_widgets = set(self.map.keys())
@@ -165,12 +161,13 @@ class Compositor:
} }
parent.virtual_size = virtual_size parent.virtual_size = virtual_size
self.widgets.clear()
self.widgets.update(widgets)
return ReflowResult( return ReflowResult(
hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets 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. """Arrange a widgets children based on its layout attribute.
Args: Args:
@@ -192,7 +189,7 @@ class Compositor:
order: tuple[int, ...], order: tuple[int, ...],
clip: Region, clip: Region,
): ):
widgets: set[Widget] = set() widgets.add(widget)
styles_offset = widget.styles.offset styles_offset = widget.styles.offset
total_region = region total_region = region
layout_offset = ( layout_offset = (
@@ -200,7 +197,6 @@ class Compositor:
if styles_offset if styles_offset
else ORIGIN else ORIGIN
) )
map[widget] = RenderRegion(region + layout_offset, order, clip) map[widget] = RenderRegion(region + layout_offset, order, clip)
if widget.layout is not None: if widget.layout is not None:
@@ -227,12 +223,10 @@ class Compositor:
return total_region.size return total_region.size
virtual_size = add_widget(root, size.region, (), size.region) virtual_size = add_widget(root, size.region, (), size.region)
self.widgets.clear() return map, virtual_size, widgets
self.widgets.update(widgets)
return map, virtual_size
async def mount_all(self, view: "View") -> None: async def mount_all(self, screen: Screen) -> None:
view.mount(*self.widgets) screen.app.mount(*self.widgets)
def __iter__(self) -> Iterator[tuple[Widget, Region, Region]]: def __iter__(self) -> Iterator[tuple[Widget, Region, Region]]:
layers = sorted(self.map.items(), key=lambda item: item[1].order, reverse=True) layers = sorted(self.map.items(), key=lambda item: item[1].order, reverse=True)
@@ -244,14 +238,14 @@ class Compositor:
try: try:
return self.map[widget].region.origin return self.map[widget].region.origin
except KeyError: 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]: def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under the given point or None.""" """Get the widget under the given point or None."""
for widget, cropped_region, region in self: 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 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: def get_style_at(self, x: int, y: int) -> Style:
"""Get the Style at the given cell or Style.null() """Get the Style at the given cell or Style.null()
@@ -265,13 +259,15 @@ class Compositor:
""" """
try: try:
widget, region = self.get_widget_at(x, y) widget, region = self.get_widget_at(x, y)
except NoWidget: except errors.NoWidget:
return Style.null() return Style.null()
if widget not in self.regions: if widget not in self.regions:
return Style.null() return Style.null()
lines = widget._get_lines() lines = widget._get_lines()
x -= region.x x -= region.x
y -= region.y y -= region.y
if y > len(lines):
return Style.null()
line = lines[y] line = lines[y]
end = 0 end = 0
for segment in line: for segment in line:
@@ -296,7 +292,7 @@ class Compositor:
try: try:
region, *_ = self.map[widget] region, *_ = self.map[widget]
except KeyError: except KeyError:
raise NoWidget("Widget is not in layout") raise errors.NoWidget("Widget is not in layout")
else: else:
return region return region
@@ -344,7 +340,7 @@ class Compositor:
for widget, region, _order, clip in widget_regions: 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 continue
lines = widget._get_lines() lines = widget._get_lines()
@@ -465,5 +461,4 @@ class Compositor:
update_region = region.intersection(clip) update_region = region.intersection(clip)
update_lines = self.render(console, crop=update_region).lines update_lines = self.render(console, crop=update_region).lines
update = LayoutUpdate(update_lines, update_region) update = LayoutUpdate(update_lines, update_region)
log(update)
return update return update

View File

@@ -22,6 +22,13 @@ class NodeList:
self._node_refs: list[ref[DOMNode]] = [] self._node_refs: list[ref[DOMNode]] = []
self.__nodes: list[DOMNode] | None = [] 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: def __rich_repr__(self) -> rich.repr.Result:
yield self._widgets yield self._widgets

View File

@@ -35,7 +35,7 @@ class Timer:
*, *,
name: str | None = None, name: str | None = None,
callback: TimerCallback | None = None, callback: TimerCallback | None = None,
repeat: int = None, repeat: int | None = None,
skip: bool = False, skip: bool = False,
pause: bool = False, pause: bool = False,
) -> None: ) -> None:

View File

@@ -9,11 +9,9 @@ else:
if TYPE_CHECKING: if TYPE_CHECKING:
from .events import Event
from .message import Message from .message import Message
Callback = Callable[[], None] Callback = Callable[[], None]
# IntervalID = int
class MessageTarget(Protocol): class MessageTarget(Protocol):

View File

@@ -12,7 +12,7 @@ import rich.repr
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.control import Control from rich.control import Control
from rich.measure import Measurement from rich.measure import Measurement
from rich.screen import Screen from rich.screen import Screen as ScreenRenderable
from rich.traceback import Traceback from rich.traceback import Traceback
from . import actions from . import actions
@@ -34,10 +34,9 @@ from .layouts.dock import Dock
from .message_pump import MessagePump from .message_pump import MessagePump
from .reactive import Reactive from .reactive import Reactive
from .renderables.gradient import VerticalGradient from .renderables.gradient import VerticalGradient
from .view import View from .screen import Screen
from .widget import Widget from .widget import Widget
from .css.query import NoMatchingNodesError
if TYPE_CHECKING: if TYPE_CHECKING:
from .css.query import DOMQuery from .css.query import DOMQuery
@@ -50,8 +49,6 @@ warnings.simplefilter("always", ResourceWarning)
LayoutDefinition = "dict[str, Any]" LayoutDefinition = "dict[str, Any]"
ViewType = TypeVar("ViewType", bound=View)
class AppError(Exception): class AppError(Exception):
pass pass
@@ -86,12 +83,12 @@ class App(DOMNode):
driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. 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". title (str, optional): Title of the application. Defaults to "Textual Application".
""" """
self.console = Console() self.console = Console(markup=False)
self.error_console = Console(stderr=True) self.error_console = Console(markup=False, stderr=True)
self._screen = screen self._screen = screen
self.driver_class = driver_class or self.get_driver_class() self.driver_class = driver_class or self.get_driver_class()
self._title = title self._title = title
self._view_stack: list[View] = [] self._screen_stack: list[Screen] = []
self.focused: Widget | None = None self.focused: Widget | None = None
self.mouse_over: Widget | None = None self.mouse_over: Widget | None = None
@@ -100,7 +97,7 @@ class App(DOMNode):
self._exit_renderables: list[RenderableType] = [] self._exit_renderables: list[RenderableType] = []
self._docks: list[Dock] = [] self._docks: list[Dock] = []
self._action_targets = {"app", "view"} self._action_targets = {"app", "screen"}
self._animator = Animator(self) self._animator = Animator(self)
self.animate = self._animator.bind(self) self.animate = self._animator.bind(self)
self.mouse_position = Offset(0, 0) self.mouse_position = Offset(0, 0)
@@ -158,8 +155,8 @@ class App(DOMNode):
return self._animator return self._animator
@property @property
def view(self) -> View: def screen(self) -> Screen:
return self._view_stack[-1] return self._screen_stack[-1]
@property @property
def css_type(self) -> str: def css_type(self) -> str:
@@ -262,10 +259,10 @@ class App(DOMNode):
self.reset_styles() self.reset_styles()
self.stylesheet = stylesheet self.stylesheet = stylesheet
self.stylesheet.update(self) self.stylesheet.update(self)
self.view.refresh(layout=True) self.screen.refresh(layout=True)
def query(self, selector: str | None = None) -> DOMQuery: 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: Args:
selector (str, optional): A CSS selector or `None` for all nodes. Defaults to None. 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 from .css.query import DOMQuery
return DOMQuery(self.view, selector) return DOMQuery(self.screen, selector)
def get_child(self, id: str) -> DOMNode: 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 Returns the first child (immediate descendent) of this DOMNode
with the given ID. with the given ID.
@@ -288,7 +285,7 @@ class App(DOMNode):
Returns: Returns:
DOMNode: The first child of this node with the specified ID. 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: def render_background(self) -> RenderableType:
gradient = VerticalGradient("red", "blue") gradient = VerticalGradient("red", "blue")
@@ -303,12 +300,12 @@ class App(DOMNode):
self.post_message_no_wait(messages.StylesUpdated(self)) self.post_message_no_wait(messages.StylesUpdated(self))
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.register(self.view, *anon_widgets, **widgets) self.register(self.screen, *anon_widgets, **widgets)
self.view.refresh() self.screen.refresh()
async def push_view(self, view: ViewType) -> ViewType: async def push_screen(self, screen: Screen) -> Screen:
self._view_stack.append(view) self._screen_stack.append(screen)
return view return screen
async def set_focus(self, widget: Widget | None) -> None: async def set_focus(self, widget: Widget | None) -> None:
"""Focus (or unfocus) a widget. A focused widget will receive key events first. """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: def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
if child not in self.registry: if child not in self.registry:
parent.children._append(child) parent.node_list._append(child)
self.registry.add(child) self.registry.add(child)
child.set_parent(parent) child.set_parent(parent)
child.start_messages() child.start_messages()
@@ -473,6 +470,11 @@ class App(DOMNode):
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()] name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
apply_stylesheet = self.stylesheet.apply 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: for widget_id, widget in name_widgets:
if widget not in self.registry: if widget not in self.registry:
if widget_id is not None: if widget_id is not None:
@@ -500,7 +502,9 @@ class App(DOMNode):
driver.disable_input() driver.disable_input()
await self.close_messages() 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 = ( sync_available = (
os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" and not WINDOWS os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" and not WINDOWS
) )
@@ -509,9 +513,7 @@ class App(DOMNode):
try: try:
if sync_available: if sync_available:
console.file.write("\x1bP=1s\x1b\\") console.file.write("\x1bP=1s\x1b\\")
console.print( console.print(ScreenRenderable(Control.home(), self.screen.render()))
Screen(Control.home(), self.view.render_styled(), Control.home())
)
if sync_available: if sync_available:
console.file.write("\x1bP=2s\x1b\\") console.file.write("\x1bP=2s\x1b\\")
console.file.flush() console.file.flush()
@@ -519,6 +521,8 @@ class App(DOMNode):
self.panic() self.panic()
def display(self, renderable: RenderableType) -> None: def display(self, renderable: RenderableType) -> None:
if not self._running:
return
if not self._closed: if not self._closed:
console = self.console console = self.console
try: try:
@@ -551,7 +555,7 @@ class App(DOMNode):
Returns: Returns:
tuple[Widget, Region]: The widget and the widget's screen region. 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: def bell(self) -> None:
"""Play the console 'bell'.""" """Play the console 'bell'."""
@@ -578,9 +582,9 @@ class App(DOMNode):
# Handle input events that haven't been forwarded # Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App # If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Mount): if isinstance(event, events.Mount):
view = View() screen = Screen()
self.register(self, view) self.register(self, screen)
await self.push_view(view) await self.push_screen(screen)
await super().on_event(event) await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded: elif isinstance(event, events.InputEvent) and not event.is_forwarded:
@@ -596,7 +600,7 @@ class App(DOMNode):
await super().on_event(event) await super().on_event(event)
else: else:
# Forward the event to the view # Forward the event to the view
await self.view.forward_event(event) await self.screen.forward_event(event)
else: else:
await super().on_event(event) await super().on_event(event)
@@ -636,6 +640,16 @@ class App(DOMNode):
async def broker_event( async def broker_event(
self, event_name: str, event: events.Event, default_namespace: object | None self, event_name: str, event: events.Event, default_namespace: object | None
) -> bool: ) -> 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() event.stop()
try: try:
style = getattr(event, "style") style = getattr(event, "style")
@@ -661,7 +675,7 @@ class App(DOMNode):
async def handle_layout(self, message: messages.Layout) -> None: async def handle_layout(self, message: messages.Layout) -> None:
message.stop() message.stop()
# await self.view.refresh_layout() # await self.screen.refresh_layout()
self.app.refresh() self.app.refresh()
async def on_key(self, event: events.Key) -> None: async def on_key(self, event: events.Key) -> None:
@@ -672,7 +686,7 @@ class App(DOMNode):
await self.close_messages() await self.close_messages()
async def on_resize(self, event: events.Resize) -> None: 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: async def action_press(self, key: str) -> None:
await self.press(key) await self.press(key)
@@ -687,13 +701,13 @@ class App(DOMNode):
self.bell() self.bell()
async def action_add_class_(self, selector: str, class_name: str) -> None: 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: 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: 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: async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.stylesheet.update(self) self.stylesheet.update(self)

View File

@@ -15,6 +15,7 @@ import rich.repr
from rich.color import Color from rich.color import Color
from rich.style import Style from rich.style import Style
from .. import log
from ._error_tools import friendly_list from ._error_tools import friendly_list
from .constants import NULL_SPACING from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError from .errors import StyleTypeError, StyleValueError
@@ -537,7 +538,6 @@ class OffsetProperty:
ScalarParseError: If any of the string values supplied in the 2-tuple cannot 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. be parsed into a Scalar. For example, if you specify an non-existent unit.
""" """
if offset is None: if offset is None:
if obj.clear_rule(self.name): if obj.clear_rule(self.name):
obj.refresh(layout=True) obj.refresh(layout=True)
@@ -557,6 +557,7 @@ class OffsetProperty:
else Scalar(float(y), Unit.CELLS, Unit.HEIGHT) else Scalar(float(y), Unit.CELLS, Unit.HEIGHT)
) )
_offset = ScalarOffset(scalar_x, scalar_y) _offset = ScalarOffset(scalar_x, scalar_y)
if obj.set_rule(self.name, _offset): if obj.set_rule(self.name, _offset):
obj.refresh(layout=True) obj.refresh(layout=True)

View File

@@ -135,7 +135,7 @@ class DOMQuery:
node.set_styles(css, **styles) node.set_styles(css, **styles)
return self 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. """Refresh matched nodes.
Args: Args:

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .. import events from .. import events, log
from ..geometry import Offset from ..geometry import Offset
from .._animator import Animation from .._animator import Animation
from .scalar import ScalarOffset from .scalar import ScalarOffset

View File

@@ -11,6 +11,7 @@ import rich.repr
from rich.color import Color from rich.color import Color
from rich.style import Style from rich.style import Style
from .. import log
from .._animator import Animation, EasingFunction from .._animator import Animation, EasingFunction
from ..geometry import Spacing from ..geometry import Spacing
from ._style_properties import ( from ._style_properties import (
@@ -361,6 +362,7 @@ class Styles(StylesBase):
return self._rules.get(rule, default) return self._rules.get(rule, default)
def refresh(self, *, layout: bool = False) -> None: def refresh(self, *, layout: bool = False) -> None:
return
self._repaint_required = True self._repaint_required = True
self._layout_required = self._layout_required or layout self._layout_required = self._layout_required or layout

View File

@@ -6,6 +6,7 @@ import rich.repr
from rich.highlighter import ReprHighlighter from rich.highlighter import ReprHighlighter
from rich.pretty import Pretty from rich.pretty import Pretty
from rich.style import Style from rich.style import Style
from rich.text import Text
from rich.tree import Tree from rich.tree import Tree
from ._node_list import NodeList from ._node_list import NodeList
@@ -19,7 +20,7 @@ from .message_pump import MessagePump
if TYPE_CHECKING: if TYPE_CHECKING:
from .css.query import DOMQuery from .css.query import DOMQuery
from .view import View from .screen import Screen
class NoParent(Exception): class NoParent(Exception):
@@ -37,11 +38,16 @@ class DOMNode(MessagePump):
DEFAULT_STYLES = "" DEFAULT_STYLES = ""
INLINE_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._name = name
self._id = id self._id = id
self._classes: set[str] = set() self._classes: set[str] = set(classes) if classes else set()
self.children = NodeList() self.node_list = NodeList()
self._css_styles: Styles = Styles(self) self._css_styles: Styles = Styles(self)
self._inline_styles: Styles = Styles.parse( self._inline_styles: Styles = Styles.parse(
self.INLINE_STYLES, repr(self), node=self self.INLINE_STYLES, repr(self), node=self
@@ -73,18 +79,23 @@ class DOMNode(MessagePump):
return self._parent return self._parent
@property @property
def view(self) -> "View": def screen(self) -> "Screen":
"""Get the current view.""" """Get the current screen."""
# Get the node by looking up a chain of parents # Get the node by looking up a chain of parents
# Note that self.view may not be the same as self.app.view # Note that self.screen may not be the same as self.app.screen
from .view import View from .screen import Screen
node = self node = self
while node and not isinstance(node, View): while node and not isinstance(node, Screen):
node = node._parent node = node._parent
assert isinstance(node, View) assert isinstance(node, Screen)
return node return node
@property
def is_visual(self) -> bool:
"""Check if the widget is visual (i.e. draws something on Screen)."""
return True
@property @property
def id(self) -> str | None: def id(self) -> str | None:
"""The ID of this node, or None if the node has no ID. """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: def name(self) -> str | None:
return self._name 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 @property
def classes(self) -> frozenset[str]: def classes(self) -> frozenset[str]:
return frozenset(self._classes) return frozenset(self._classes)
@@ -237,12 +264,25 @@ class DOMNode(MessagePump):
Returns: Returns:
Tree: A Rich object which may be printed. Tree: A Rich object which may be printed.
""" """
from rich.columns import Columns
from rich.panel import Panel
highlighter = ReprHighlighter() highlighter = ReprHighlighter()
tree = Tree(highlighter(repr(self))) tree = Tree(highlighter(repr(self)))
def add_children(tree, node): def add_children(tree, node):
for child in node.children: for child in node.node_list:
branch = tree.add(Pretty(child)) 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: if tree.children:
add_children(branch, child) add_children(branch, child)
@@ -276,12 +316,20 @@ class DOMNode(MessagePump):
Args: Args:
node (DOMNode): A DOM node. node (DOMNode): A DOM node.
""" """
self.children._append(node) self.node_list._append(node)
node.set_parent(self) 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]: 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 pop = stack.pop
push = stack.append push = stack.append
@@ -294,8 +342,8 @@ class DOMNode(MessagePump):
pop() pop()
else: else:
yield node yield node
if node.children: if node.node_list:
push(iter(node.children)) push(iter(node.node_list))
def get_child(self, id: str) -> DOMNode: def get_child(self, id: str) -> DOMNode:
"""Return the first child (immediate descendent) of this node with the given ID. """Return the first child (immediate descendent) of this node with the given ID.
@@ -306,7 +354,7 @@ class DOMNode(MessagePump):
Returns: Returns:
DOMNode: The first child of this node with the ID. 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: if child.id == id:
return child return child
raise NoMatchingNodesError(f"No child found with id={id!r}") 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) has_pseudo_classes = self.pseudo_classes.issuperset(class_names)
return has_pseudo_classes 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() raise NotImplementedError()

View File

@@ -1,2 +1,9 @@
class MissingWidget(Exception): from __future__ import annotations
class TextualError(Exception):
pass
class NoWidget(TextualError):
pass pass

View File

@@ -5,6 +5,7 @@ from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar
import rich.repr import rich.repr
from rich.style import Style from rich.style import Style
from . import log
from .geometry import Offset, Size from .geometry import Offset, Size
from .message import Message from .message import Message
from ._types import MessageTarget from ._types import MessageTarget
@@ -389,8 +390,3 @@ class Focus(Event, bubble=False):
class Blur(Event, bubble=False): class Blur(Event, bubble=False):
pass pass
# class Update(Event, bubble=False):
# def can_replace(self, event: Message) -> bool:
# return isinstance(event, Update) and event.sender == self.sender

View File

@@ -1,15 +1,16 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod 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 from .geometry import Region, Offset, Size
if TYPE_CHECKING: if TYPE_CHECKING:
from .dom import DOMNode
from .widget import Widget from .widget import Widget
from .view import View from .screen import Screen
class WidgetPlacement(NamedTuple): class WidgetPlacement(NamedTuple):
@@ -47,8 +48,8 @@ class Layout(ABC):
@abstractmethod @abstractmethod
def arrange( def arrange(
self, parent: View, size: Size, scroll: Offset self, parent: Screen, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]: ) -> tuple[Iterable[WidgetPlacement], set[Widget]]:
"""Generate a layout map that defines where on the screen the widgets will be drawn. """Generate a layout map that defines where on the screen the widgets will be drawn.
Args: Args:

View File

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

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys import sys
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass 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 .._layout_resolve import layout_resolve
from ..css.types import Edge from ..css.types import Edge
@@ -17,7 +17,7 @@ else:
from typing_extensions import Literal from typing_extensions import Literal
if TYPE_CHECKING: if TYPE_CHECKING:
from ..view import View from ..screen import Screen
DockEdge = Literal["top", "right", "bottom", "left"] DockEdge = Literal["top", "right", "bottom", "left"]
@@ -61,7 +61,7 @@ class DockLayout(Layout):
def arrange( def arrange(
self, parent: Widget, size: Size, scroll: Offset self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]: ) -> tuple[Iterable[WidgetPlacement], set[Widget]]:
width, height = size width, height = size
layout_region = Region(0, 0, width, height) layout_region = Region(0, 0, width, height)
@@ -69,7 +69,7 @@ class DockLayout(Layout):
docks = self.get_docks(parent) 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 styles = widget.styles
has_rule = styles.has_rule has_rule = styles.has_rule

View File

@@ -14,7 +14,7 @@ from ..layout import Layout, WidgetPlacement
if TYPE_CHECKING: if TYPE_CHECKING:
from ..widget import Widget from ..widget import Widget
from ..view import View from ..screen import Screen
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):
@@ -266,7 +266,7 @@ class GridLayout(Layout):
return self.widgets.keys() return self.widgets.keys()
def arrange( def arrange(
self, view: View, size: Size, scroll: Offset self, view: Screen, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]: ) -> Iterable[WidgetPlacement]:
"""Generate a map that associates widgets with their location on screen. """Generate a map that associates widgets with their location on screen.

View File

@@ -6,7 +6,7 @@ from textual._loop import loop_last
from textual.css.styles import Styles from textual.css.styles import Styles
from textual.geometry import Size, Offset, Region from textual.geometry import Size, Offset, Region
from textual.layout import Layout, WidgetPlacement from textual.layout import Layout, WidgetPlacement
from textual.view import View from textual.screen import Screen
from textual.widget import Widget 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. 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( def arrange(
self, view: View, size: Size, scroll: Offset self, parent: Widget, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]: ) -> tuple[list[WidgetPlacement], set[Widget]]:
placements: list[WidgetPlacement] = []
add_placement = placements.append
parent_width, parent_height = size parent_width, parent_height = size
x, y = 0, 0 x = y = 0
for last, widget in loop_last(view.children): app = parent.app
styles: Styles = widget.styles for widget in parent.children:
styles = widget.styles
if styles.height: if styles.height:
render_height = int( render_height = int(styles.height.resolve_dimension(size, app.size))
styles.height.resolve_dimension(size, view.app.size)
)
else: else:
render_height = parent_height render_height = parent_height
if styles.width: 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: else:
render_width = parent_width render_width = parent_width
region = Region(x, y, render_width, render_height) region = Region(x, y, render_width, render_height)
yield WidgetPlacement(region, widget, order=0) add_placement(WidgetPlacement(region, widget, order=0))
x += render_width x += render_width
return placements, set(parent.children)

View File

@@ -8,34 +8,35 @@ from ..layout import Layout, WidgetPlacement
if TYPE_CHECKING: if TYPE_CHECKING:
from ..widget import Widget from ..widget import Widget
from ..view import View from ..screen import Screen
class VerticalLayout(Layout): class VerticalLayout(Layout):
def get_widgets(self, view: View) -> Iterable[Widget]:
return view.children
def arrange( def arrange(
self, view: View, size: Size, scroll: Offset self, parent: Widget, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]: ) -> tuple[list[WidgetPlacement], set[Widget]]:
parent_width, parent_height = size
x, y = 0, 0
for widget in view.children: placements: list[WidgetPlacement] = []
styles: Styles = widget.styles 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: if styles.height:
render_height = int( render_height = int(styles.height.resolve_dimension(size, app.size))
styles.height.resolve_dimension(size, view.app.size)
)
else: else:
render_height = size.height render_height = size.height
if styles.width: 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: else:
render_width = parent_width render_width = parent_width
region = Region(x, y, render_width, render_height) region = Region(x, y, render_width, render_height)
yield WidgetPlacement(region, widget, 0) add_placement(WidgetPlacement(region, widget, 0))
y += render_height y += render_height
return placements, set(parent.children)

View File

@@ -24,8 +24,11 @@ class Message:
] ]
sender: MessageTarget sender: MessageTarget
bubble: ClassVar[bool] = True bubble: ClassVar[bool] = True # Message will bubble to parent
verbosity: ClassVar[int] = 1 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: def __init__(self, sender: MessageTarget) -> None:
""" """
@@ -45,10 +48,13 @@ class Message:
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield self.sender 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__() super().__init_subclass__()
cls.bubble = bubble cls.bubble = bubble
cls.verbosity = verbosity cls.verbosity = verbosity
cls.system = system
@property @property
def is_forwarded(self) -> bool: def is_forwarded(self) -> bool:

View File

@@ -13,10 +13,11 @@ from ._timer import Timer, TimerCallback
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app
from .message import Message from .message import Message
from . import messages
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .app import App
from .view import View from .screen import Screen
class NoParent(Exception): class NoParent(Exception):
@@ -215,7 +216,7 @@ class MessagePump:
self.app.panic() self.app.panic()
break break
finally: finally:
if isinstance(message, events.Event) and self._message_queue.empty(): if self._message_queue.empty():
if not self._closed: if not self._closed:
event = events.Idle(self) event = events.Idle(self)
for method in self._get_dispatch_methods("on_idle", event): 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: async def dispatch_message(self, message: Message) -> bool | None:
_rich_traceback_guard = True _rich_traceback_guard = True
if message.system:
return False
if isinstance(message, events.Event): if isinstance(message, events.Event):
if not isinstance(message, events.Null): if not isinstance(message, events.Null):
await self.on_event(message) await self.on_event(message)
@@ -271,13 +274,10 @@ class MessagePump:
if not self._parent._closed and not self._parent._closing: if not self._parent._closed and not self._parent._closing:
await self._parent.post_message(message) await self._parent.post_message(message)
def post_message_no_wait(self, message: Message) -> bool: def check_idle(self):
if self._closing or self._closed: """Prompt the message pump to call idle if the queue is empty."""
return False if self._message_queue.empty():
if not self.check_message_enabled(message): self.post_message_no_wait(messages.Prompt(sender=self))
return True
self._message_queue.put_nowait(message)
return True
async def post_message(self, message: Message) -> bool: async def post_message(self, message: Message) -> bool:
if self._closing or self._closed: if self._closing or self._closed:
@@ -287,16 +287,24 @@ class MessagePump:
await self._message_queue.put(message) await self._message_queue.put(message)
return True 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: if self._closing or self._closed:
return False 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: async def post_message_from_child(self, message: Message) -> bool:
if self._closing or self._closed: if self._closing or self._closed:
return False return False
return await self.post_message(message) 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: async def on_callback(self, event: events.Callback) -> None:
await event.callback() await event.callback()

View File

@@ -11,6 +11,12 @@ if TYPE_CHECKING:
from .widget import Widget from .widget import Widget
@rich.repr.auto
class Refresh(Message):
def can_replace(self, message: Message) -> bool:
return isinstance(message, Refresh)
@rich.repr.auto @rich.repr.auto
class Update(Message, verbosity=3): class Update(Message, verbosity=3):
def __init__(self, sender: MessagePump, widget: Widget): def __init__(self, sender: MessagePump, widget: Widget):
@@ -50,3 +56,10 @@ class StylesUpdated(Message):
def can_replace(self, message: Message) -> bool: def can_replace(self, message: Message) -> bool:
return isinstance(message, StylesUpdated) 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)

View File

@@ -5,7 +5,7 @@ import rich.repr
from rich.style import Style from rich.style import Style
from . import events, messages from . import events, messages, errors
from .geometry import Offset, Region from .geometry import Offset, Region
from ._compositor import Compositor from ._compositor import Compositor
@@ -14,7 +14,7 @@ from .renderables.gradient import VerticalGradient
@rich.repr.auto @rich.repr.auto
class View(Widget): class Screen(Widget):
"""A widget for the root of the app.""" """A widget for the root of the app."""
DEFAULT_STYLES = """ DEFAULT_STYLES = """
@@ -33,7 +33,7 @@ class View(Widget):
return False return False
def render(self) -> RenderableType: def render(self) -> RenderableType:
return VerticalGradient("#11998e", "#38ef7d") return self._compositor
def get_offset(self, widget: Widget) -> Offset: def get_offset(self, widget: Widget) -> Offset:
"""Get the absolute offset of a given Widget. """Get the absolute offset of a given Widget.
@@ -58,7 +58,7 @@ class View(Widget):
""" """
return self._compositor.get_widget_at(x, y) 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. """Get the style under a given coordinate.
Args: Args:
@@ -96,8 +96,7 @@ class View(Widget):
for widget in shown: for widget in shown:
widget.post_message_no_wait(events.Show(self)) widget.post_message_no_wait(events.Show(self))
send_resize = shown send_resize = shown | resized
send_resize.update(resized)
for widget, region, unclipped_region in self._compositor: for widget, region, unclipped_region in self._compositor:
widget._update_size(unclipped_region.size) widget._update_size(unclipped_region.size)
@@ -123,12 +122,11 @@ class View(Widget):
async def handle_layout(self, message: messages.Layout) -> None: async def handle_layout(self, message: messages.Layout) -> None:
message.stop() message.stop()
await self.refresh_layout() await self.refresh_layout()
self.app.refresh()
async def on_resize(self, event: events.Resize) -> None: async def on_resize(self, event: events.Resize) -> None:
event.stop()
self._update_size(event.size) self._update_size(event.size)
await self.refresh_layout() await self.refresh_layout()
event.stop()
async def on_idle(self, event: events.Idle) -> None: async def on_idle(self, event: events.Idle) -> None:
if self._compositor.check_update(): if self._compositor.check_update():
@@ -143,23 +141,61 @@ class View(Widget):
region = self.get_widget_region(widget) region = self.get_widget_region(widget)
else: else:
widget, region = self.get_widget_at(event.x, event.y) widget, region = self.get_widget_at(event.x, event.y)
except NoWidget: except errors.NoWidget:
await self.app.set_mouse_over(None) await self.app.set_mouse_over(None)
else: else:
await self.app.set_mouse_over(widget) await self.app.set_mouse_over(widget)
await widget.forward_event( mouse_event = events.MouseMove(
events.MouseMove( self,
self, event.x - region.x,
event.x - region.x, event.y - region.y,
event.y - region.y, event.delta_x,
event.delta_x, event.delta_y,
event.delta_y, event.button,
event.button, event.shift,
event.shift, event.meta,
event.meta, event.ctrl,
event.ctrl, screen_x=event.screen_x,
screen_x=event.screen_x, screen_y=event.screen_y,
screen_y=event.screen_y, style=event.style,
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)

View File

@@ -127,7 +127,7 @@ class View(Widget):
try: try:
await self.layout.mount_all(self) await self.layout.mount_all(self)
if not self.is_root_view: if not self.is_root_view:
await self.app.view.refresh_layout() await self.app.screen.refresh_layout()
return return
if not self.size: if not self.size:
@@ -247,7 +247,7 @@ class View(Widget):
self.log("view.forwarded", event) self.log("view.forwarded", event)
await self.post_message(event) await self.post_message(event)
async def action_toggle(self, name: str) -> None: # async def action_toggle(self, name: str) -> None:
widget = self[name] # widget = self[name]
widget.visible = not widget.display # widget.visible = not widget.display
await self.post_message(messages.Layout(self)) # await self.post_message(messages.Layout(self))

View File

@@ -3,7 +3,7 @@ from typing import cast, Optional
from ..layouts.dock import DockLayout, Dock, DockEdge from ..layouts.dock import DockLayout, Dock, DockEdge
from ..layouts.grid import GridLayout, GridAlign from ..layouts.grid import GridLayout, GridAlign
from ..view import View from ..screen import Screen
from ..widget import Widget from ..widget import Widget
@@ -14,7 +14,7 @@ class DoNotSet:
do_not_set = DoNotSet() do_not_set = DoNotSet()
class DockView(View): class DockView(Screen):
def __init__(self, name: str | None = None) -> None: def __init__(self, name: str | None = None) -> None:
super().__init__(layout=DockLayout(), name=name) super().__init__(layout=DockLayout(), name=name)
@@ -58,7 +58,7 @@ class DockView(View):
) -> GridLayout: ) -> GridLayout:
grid = GridLayout(gap=gap, gutter=gutter, align=align) 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) dock = Dock(edge, (view,), z)
assert isinstance(self.layout, DockLayout) assert isinstance(self.layout, DockLayout)
self.layout.docks.append(dock) self.layout.docks.append(dock)

View File

@@ -1,3 +1,3 @@
from __future__ import annotations from __future__ import annotations
from ..view import View from ..screen import Screen

View File

@@ -1,8 +1,8 @@
from ..view import View from ..screen import Screen
from ..layouts.grid import GridLayout from ..layouts.grid import GridLayout
class GridView(View, layout=GridLayout): class GridView(Screen, layout=GridLayout):
@property @property
def grid(self) -> GridLayout: def grid(self) -> GridLayout:
assert isinstance(self.layout, GridLayout) assert isinstance(self.layout, GridLayout)

View File

@@ -5,7 +5,7 @@ from rich.console import RenderableType
from .. import events from .. import events
from ..geometry import Size, SpacingDimensions from ..geometry import Size, SpacingDimensions
from ..layouts.vertical import VerticalLayout from ..layouts.vertical import VerticalLayout
from ..view import View from ..screen import Screen
from ..message import Message from ..message import Message
from .. import messages from .. import messages
from ..widget import Widget from ..widget import Widget
@@ -17,7 +17,7 @@ class WindowChange(Message):
return isinstance(message, WindowChange) return isinstance(message, WindowChange)
class WindowView(View, layout=VerticalLayout): class WindowView(Screen, layout=VerticalLayout):
def __init__( def __init__(
self, self,
widget: RenderableType | Widget, widget: RenderableType | Widget,

View File

@@ -37,7 +37,7 @@ from .reactive import Reactive, watch
from .renderables.opacity import Opacity from .renderables.opacity import Opacity
if TYPE_CHECKING: if TYPE_CHECKING:
from .view import View from .screen import Screen
class RenderCache(NamedTuple): class RenderCache(NamedTuple):
@@ -55,20 +55,20 @@ class RenderCache(NamedTuple):
@rich.repr.auto @rich.repr.auto
class Widget(DOMNode): class Widget(DOMNode):
_counts: ClassVar[dict[str, int]] = {}
can_focus: bool = False can_focus: bool = False
DEFAULT_STYLES = """ DEFAULT_STYLES = """
dock: _default dock: _default
""" """
def __init__(self, name: str | None = None, id: str | None = None) -> None: def __init__(
if name is None: self,
class_name = self.__class__.__name__ *children: Widget,
Widget._counts.setdefault(class_name, 0) name: str | None = None,
Widget._counts[class_name] += 1 id: str | None = None,
_count = self._counts[class_name] classes: Iterable[str] | None = None,
name = f"{class_name}{_count}" ) -> None:
self._size = Size(0, 0) self._size = Size(0, 0)
self._repaint_required = False self._repaint_required = False
@@ -79,8 +79,11 @@ class Widget(DOMNode):
self.render_cache: RenderCache | None = None self.render_cache: RenderCache | None = None
self.highlight_style: Style | 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_x = Reactive(0)
scroll_y = Reactive(0) scroll_y = Reactive(0)
virtual_size = Reactive(Size(0, 0)) virtual_size = Reactive(Size(0, 0))
@@ -103,6 +106,8 @@ class Widget(DOMNode):
"""Pseudo classes for a widget""" """Pseudo classes for a widget"""
if self._mouse_over: if self._mouse_over:
yield "hover" yield "hover"
if self.has_focus:
yield "focus"
# TODO: focus # TODO: focus
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
@@ -148,6 +153,10 @@ class Widget(DOMNode):
return renderable return renderable
@property
def children(self) -> list[Widget]:
return list(self.node_list)
@property @property
def size(self) -> Size: def size(self) -> Size:
return self._size return self._size
@@ -165,11 +174,6 @@ class Widget(DOMNode):
"""Get the current console.""" """Get the current console."""
return active_app.get().console return active_app.get().console
@property
def root_view(self) -> "View":
"""Return the top-most view."""
return active_app.get().view
@property @property
def animate(self) -> BoundAnimator: def animate(self) -> BoundAnimator:
if self._animate is None: if self._animate is None:
@@ -181,6 +185,14 @@ class Widget(DOMNode):
def layout(self) -> Layout | None: def layout(self) -> Layout | None:
return self.styles.layout 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: def on_style_change(self) -> None:
self.clear_render_cache() self.clear_render_cache()
@@ -218,8 +230,8 @@ class Widget(DOMNode):
self._layout_required = False self._layout_required = False
def get_style_at(self, x: int, y: int) -> Style: def get_style_at(self, x: int, y: int) -> Style:
offset_x, offset_y = self.root_view.get_offset(self) offset_x, offset_y = self.screen.get_offset(self)
return self.root_view.get_style_at(x + offset_x, y + offset_y) return self.screen.get_style_at(x + offset_x, y + offset_y)
async def call_later(self, callback: Callable, *args, **kwargs) -> None: async def call_later(self, callback: Callable, *args, **kwargs) -> None:
await self.app.call_later(callback, *args, **kwargs) await self.app.call_later(callback, *args, **kwargs)
@@ -228,7 +240,7 @@ class Widget(DOMNode):
event.set_forwarded() event.set_forwarded()
await self.post_message(event) 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. """Initiate a refresh of the widget.
This method sets an internal flag to perform a refresh, which will be done on the 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: elif repaint:
self.clear_render_cache() self.clear_render_cache()
self._repaint_required = True self._repaint_required = True
self.post_message_no_wait(events.Null(self)) self.check_idle()
def render(self) -> RenderableType: def render(self) -> RenderableType:
"""Get renderable for widget. """Get renderable for widget.
@@ -254,9 +266,8 @@ class Widget(DOMNode):
""" """
# Default displays a pretty repr in the center of the screen # 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: async def action(self, action: str, *params) -> None:
await self.app.action(action, self) await self.app.action(action, self)
@@ -269,6 +280,7 @@ class Widget(DOMNode):
return await super().post_message(message) return await super().post_message(message)
async def on_resize(self, event: events.Resize) -> None: async def on_resize(self, event: events.Resize) -> None:
self._update_size(event.size)
self.refresh() self.refresh()
async def on_idle(self, event: events.Idle) -> None: async def on_idle(self, event: events.Idle) -> None:
@@ -277,13 +289,14 @@ class Widget(DOMNode):
# self.render_cache = None # self.render_cache = None
self.reset_check_repaint() self.reset_check_repaint()
self.reset_check_layout() self.reset_check_layout()
await self.emit(messages.Layout(self)) await self.screen.post_message(messages.Layout(self))
elif repaint or self.check_repaint(): elif repaint or self.check_repaint():
# self.render_cache = None # self.render_cache = None
self.reset_check_repaint() self.reset_check_repaint()
await self.emit(messages.Update(self, self)) await self.emit(messages.Update(self, self))
async def focus(self) -> None: async def focus(self) -> None:
"""Give input focus to this widget."""
await self.app.set_focus(self) await self.app.set_focus(self)
async def capture_mouse(self, capture: bool = True) -> None: 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: async def on_click(self, event: events.Click) -> None:
await self.broker_event("click", event) 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: async def on_key(self, event: events.Key) -> None:
if await self.dispatch_key(event): if await self.dispatch_key(event):
event.prevent_default() event.prevent_default()

View File

@@ -130,6 +130,6 @@ if __name__ == "__main__":
class TreeApp(App): class TreeApp(App):
async def on_mount(self, event: events.Mount) -> None: 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") TreeApp.run(log="textual.log")

View File

@@ -24,9 +24,9 @@ class Placeholder(Widget, can_focus=True):
style: Reactive[str] = Reactive("") style: Reactive[str] = Reactive("")
height: Reactive[int | None] = Reactive(None) height: Reactive[int | None] = Reactive(None)
def __init__(self, *, name: str | None = None, height: int | None = None) -> None: # def __init__(self, *, name: str | None = None, height: int | None = None) -> None:
super().__init__(name=name) # super().__init__(name=name)
self.height = height # self.height = height
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__() yield from super().__rich_repr__()

View File

@@ -11,14 +11,14 @@ from ..message import Message
from ..messages import CursorMove from ..messages import CursorMove
from ..scrollbar import ScrollTo, ScrollBar from ..scrollbar import ScrollTo, ScrollBar
from ..geometry import clamp from ..geometry import clamp
from ..view import View from ..screen import Screen
from ..widget import Widget from ..widget import Widget
from ..reactive import Reactive from ..reactive import Reactive
class ScrollView(View): class ScrollView(Screen):
def __init__( def __init__(
self, self,
contents: RenderableType | Widget | None = None, contents: RenderableType | Widget | None = None,

View File

@@ -317,7 +317,7 @@ if __name__ == "__main__":
class TreeApp(App): class TreeApp(App):
async def on_mount(self, event: events.Mount) -> None: 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: async def handle_tree_click(self, message: TreeClick) -> None:
if message.node.empty: if message.node.empty:

View File

@@ -4,17 +4,4 @@ from textual.layouts.dock import DockLayout
from textual.layouts.grid import GridLayout from textual.layouts.grid import GridLayout
from textual.layouts.horizontal import HorizontalLayout from textual.layouts.horizontal import HorizontalLayout
from textual.layouts.vertical import VerticalLayout from textual.layouts.vertical import VerticalLayout
from textual.view import View from textual.screen import Screen
@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