diff --git a/docs/examples/timers/clock.py b/docs/examples/timers/clock.py index 3d758cd78..b13287748 100644 --- a/docs/examples/timers/clock.py +++ b/docs/examples/timers/clock.py @@ -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() diff --git a/docs/examples/widgets/custom.py b/docs/examples/widgets/custom.py index 6856bcb20..f47d585cf 100644 --- a/docs/examples/widgets/custom.py +++ b/docs/examples/widgets/custom.py @@ -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") diff --git a/docs/examples/widgets/placeholders.py b/docs/examples/widgets/placeholders.py index 30e566b5b..5e6dea609 100644 --- a/docs/examples/widgets/placeholders.py +++ b/docs/examples/widgets/placeholders.py @@ -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") diff --git a/examples/animation.py b/examples/animation.py index bf5f9ae8e..7e9206b7d 100644 --- a/examples/animation.py +++ b/examples/animation.py @@ -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 diff --git a/examples/basic.css b/examples/basic.css index ca9150bf0..95b25e453 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -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; } diff --git a/examples/big_table.py b/examples/big_table.py index a11ce385b..23a66d47d 100644 --- a/examples/big_table.py +++ b/examples/big_table.py @@ -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") diff --git a/examples/calculator.py b/examples/calculator.py index 462a65d4a..0353c5b60 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -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") diff --git a/examples/code_viewer.py b/examples/code_viewer.py index 4aa10fc64..4b9def743 100644 --- a/examples/code_viewer.py +++ b/examples/code_viewer.py @@ -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.""" diff --git a/examples/easing.py b/examples/easing.py index 0360f42ae..339d414de 100644 --- a/examples/easing.py +++ b/examples/easing.py @@ -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: diff --git a/examples/grid.py b/examples/grid.py index 3fda31d10..7afe5690b 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -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") diff --git a/examples/grid_auto.py b/examples/grid_auto.py index 54dfb970d..dc811f599 100644 --- a/examples/grid_auto.py +++ b/examples/grid_auto.py @@ -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) diff --git a/examples/simple.py b/examples/simple.py index b2e8cbb4a..016328d2c 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -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, diff --git a/sandbox/dev_sandbox.py b/sandbox/dev_sandbox.py index 8a43a203e..ffbfcf8c2 100644 --- a/sandbox/dev_sandbox.py +++ b/sandbox/dev_sandbox.py @@ -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") diff --git a/sandbox/dev_sandbox.scss b/sandbox/dev_sandbox.scss index 987c0f09e..0b4fa6953 100644 --- a/sandbox/dev_sandbox.scss +++ b/sandbox/dev_sandbox.scss @@ -2,7 +2,7 @@ $text: #f0f0f0; $primary: #021720; -$secondary:#95d52a; +$secondary: #95d52a; $background: #262626; $primary-style: $text on $background; diff --git a/sandbox/uber.css b/sandbox/uber.css index 960f4b47b..01f7ad4ca 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -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; } diff --git a/sandbox/uber.py b/sandbox/uber.py index 1eb540d3e..5b9cf8f05 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -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) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 02365c4cd..64b96f20e 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -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() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3a3ca998e..05270d18b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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 diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index f5ae8ffeb..459928504 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -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 diff --git a/src/textual/_timer.py b/src/textual/_timer.py index cbeef27cd..44d3bb098 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -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: diff --git a/src/textual/_types.py b/src/textual/_types.py index ff5f75e18..71c228949 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -9,11 +9,9 @@ else: if TYPE_CHECKING: - from .events import Event from .message import Message Callback = Callable[[], None] -# IntervalID = int class MessageTarget(Protocol): diff --git a/src/textual/app.py b/src/textual/app.py index 7280e9b70..5cce47cba 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 4dc10ff37..f217f5b40 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -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) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 9532b34b8..43fdef576 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -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: diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 8d797eb6c..2c0de8a06 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -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 diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index c097eb7da..e7646373f 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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 diff --git a/src/textual/dom.py b/src/textual/dom.py index a35165333..4578debef 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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() diff --git a/src/textual/errors.py b/src/textual/errors.py index 2418a6885..338d5b341 100644 --- a/src/textual/errors.py +++ b/src/textual/errors.py @@ -1,2 +1,9 @@ -class MissingWidget(Exception): +from __future__ import annotations + + +class TextualError(Exception): + pass + + +class NoWidget(TextualError): pass diff --git a/src/textual/events.py b/src/textual/events.py index ec136a7f0..706faeb8a 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -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 diff --git a/src/textual/layout.py b/src/textual/layout.py index ea2cd7729..99f479cfc 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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: diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py deleted file mode 100644 index 4bf6464d6..000000000 --- a/src/textual/layout_map.py +++ /dev/null @@ -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 diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 97ef29e7f..864026837 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -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 diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 276cd417f..97b17c455 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -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. diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 6c2ad5538..c044ef366 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -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) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 07c202a95..e983da28a 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -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) diff --git a/src/textual/message.py b/src/textual/message.py index 1114c2b4f..66e72c275 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -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: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index f04fde76c..b7c98dea2 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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() diff --git a/src/textual/messages.py b/src/textual/messages.py index d43dc6a6c..d23475571 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -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) diff --git a/src/textual/view.py b/src/textual/screen.py similarity index 63% rename from src/textual/view.py rename to src/textual/screen.py index d7c69874e..163c8a62b 100644 --- a/src/textual/view.py +++ b/src/textual/screen.py @@ -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) diff --git a/src/textual/viewX.py b/src/textual/viewX.py index 8ef2e8df5..68e8cff5d 100644 --- a/src/textual/viewX.py +++ b/src/textual/viewX.py @@ -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)) diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index 22d55c93b..98367be6b 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -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) diff --git a/src/textual/views/_document_view.py b/src/textual/views/_document_view.py index 9fe5c6445..2e68e5829 100644 --- a/src/textual/views/_document_view.py +++ b/src/textual/views/_document_view.py @@ -1,3 +1,3 @@ from __future__ import annotations -from ..view import View +from ..screen import Screen diff --git a/src/textual/views/_grid_view.py b/src/textual/views/_grid_view.py index af04b6d3f..b08614b5e 100644 --- a/src/textual/views/_grid_view.py +++ b/src/textual/views/_grid_view.py @@ -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) diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 121c1aef0..3caf0b797 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -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, diff --git a/src/textual/widget.py b/src/textual/widget.py index 4fb081b95..1ee44e802 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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() diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 0808d0fe3..2754a5f7b 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -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") diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 9fa95bf0d..4da85930b 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -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__() diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 7e6f20249..0d5dcb1c2 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -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, diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index 0a0367684..276386546 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -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: diff --git a/tests/test_view.py b/tests/test_view.py index 1eed93c6f..fad4484bf 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -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