diff --git a/examples/simple.py b/examples/simple.py index ac621befe..3e6ec4f55 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -27,6 +27,8 @@ class MyApp(App): # Dock the body in the remaining space await self.view.dock(body, edge="right") + # self.panic(self.tree) + async def get_markdown(filename: str) -> None: with open(filename, "rt") as fh: readme = Markdown(fh.read(), hyperlinks=True) diff --git a/src/textual/_widget_list.py b/src/textual/_widget_list.py index 07270d029..832b70725 100644 --- a/src/textual/_widget_list.py +++ b/src/textual/_widget_list.py @@ -49,7 +49,7 @@ class WidgetList: ], ) - def _append(self, widget: Widget): + def _append(self, widget: Widget) -> None: if widget not in self._widgets: self._widget_refs.append(ref(widget)) self.__widgets = None diff --git a/src/textual/app.py b/src/textual/app.py index 50890b099..71fc69a5f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -7,11 +7,14 @@ from typing import Any, Callable, ClassVar, Type, TypeVar import warnings from rich.control import Control +from rich.highlighter import ReprHighlighter import rich.repr from rich.screen import Screen from rich.console import Console, RenderableType from rich.measure import Measurement +from rich.pretty import Pretty from rich.traceback import Traceback +from rich.tree import Tree from . import events from . import actions @@ -130,6 +133,21 @@ class App(MessagePump): def view(self) -> DockView: return self._view_stack[-1] + @property + def tree(self) -> Tree: + highlighter = ReprHighlighter() + tree = Tree(highlighter(repr(self))) + + def add_children(tree, node): + for child in node.children: + branch = tree.add(Pretty(child)) + if tree.children: + add_children(branch, child) + + branch = tree.add(Pretty(self.view)) + add_children(branch, self.view) + return tree + def load_css(self, filename: str) -> None: pass diff --git a/src/textual/layout.py b/src/textual/layout.py index 0ce387548..ca8d0ecd1 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -6,8 +6,7 @@ from itertools import chain from operator import itemgetter import sys -from typing import Iterable, Iterator, NamedTuple, TYPE_CHECKING -from rich import segment +from typing import ClassVar, Iterable, Iterator, NamedTuple, TYPE_CHECKING import rich.repr from rich.control import Control @@ -87,6 +86,8 @@ class LayoutUpdate: class Layout(ABC): """Responsible for arranging Widgets in a view and rendering them.""" + name: ClassVar[str] = "" + def __init__(self) -> None: self._layout_map: LayoutMap | None = None self.width = 0 diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py new file mode 100644 index 000000000..a6718f427 --- /dev/null +++ b/src/textual/layouts/factory.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from ..layout import Layout +from .dock import DockLayout +from .grid import GridLayout +from .vertical import VerticalLayout + + +class MissingLayout(Exception): + pass + + +LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} + + +def get_layout(name: str) -> Layout: + """Get a named layout object. + + Args: + name (str): Name of the layout. + + Raises: + MissingLayout: If the named layout doesn't exist. + + Returns: + Layout: A layout object. + """ + layout_class = LAYOUT_MAP.get(name) + if layout_class is None: + raise MissingLayout("no layout called {name!r}") + return layout_class() diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 41e8ab89b..a35fbdd04 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -8,12 +8,9 @@ from itertools import cycle, product import sys from typing import Iterable, NamedTuple -from rich.console import Console - from .._layout_resolve import layout_resolve from ..geometry import Size, Offset, Region from ..layout import Layout, WidgetPlacement -from ..layout_map import LayoutMap from ..widget import Widget if sys.version_info >= (3, 8): diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 89c6ca8cc..3a5590b0f 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -187,6 +187,7 @@ class ScrollBar(Widget): grabbed: Reactive[Offset | None] = Reactive(None) def __rich_repr__(self) -> rich.repr.Result: + yield from super().__rich_repr__() yield "virtual_size", self.virtual_size yield "window_size", self.window_size yield "position", self.position diff --git a/src/textual/view.py b/src/textual/view.py index 164c9b38d..1732136d4 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -12,6 +12,7 @@ from . import errors from . import log from . import messages from .layout import Layout, NoWidget, WidgetPlacement +from .layouts.factory import get_layout from .geometry import Size, Offset, Region from .reactive import Reactive, watch @@ -22,13 +23,27 @@ if TYPE_CHECKING: from .app import App +class LayoutProperty: + def __get__(self, obj: View, objtype: type[View] | None = None) -> str: + return obj._layout.name + + def __set__(self, obj: View, layout: str | Layout) -> str: + if isinstance(layout, str): + new_layout = get_layout(layout) + else: + new_layout = layout + self._layout = new_layout + return self._layout.name + + @rich.repr.auto class View(Widget): layout_factory: ClassVar[Callable[[], Layout]] def __init__(self, layout: Layout = None, name: str | None = None) -> None: - self.layout: Layout = layout or self.layout_factory() + self._layout: Layout = layout or self.layout_factory() + self.mouse_over: Widget | None = None self.widgets: set[Widget] = set() self._mouse_style: Style = Style() @@ -49,13 +64,27 @@ class View(Widget): cls.layout_factory = layout super().__init_subclass__(**kwargs) + # @property + # def layout(self) -> str: + # return self._layout + + # @layout.setter + # def layout(self, layout: str | Layout) -> None: + # if isinstance(layout, str): + # new_layout = get_layout(layout) + # else: + # new_layout = layout + # self._layout = new_layout + + layout = LayoutProperty() + background: Reactive[str] = Reactive("") scroll_x: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0) virtual_size = Reactive(Size(0, 0)) async def watch_background(self, value: str) -> None: - self.layout.background = value + self._layout.background = value self.app.refresh() @property @@ -89,16 +118,16 @@ class View(Widget): return self.app.is_mounted(widget) def render(self) -> RenderableType: - return self.layout + return self._layout def get_offset(self, widget: Widget) -> Offset: - return self.layout.get_offset(widget) + return self._layout.get_offset(widget) def get_arrangement(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: cached_size, cached_scroll, arrangement = self._cached_arrangement if cached_size == size and cached_scroll == scroll: return arrangement - arrangement = list(self.layout.arrange(self, size, scroll)) + arrangement = list(self._layout.arrange(self, size, scroll)) self._cached_arrangement = (size, scroll, arrangement) return arrangement @@ -108,8 +137,7 @@ class View(Widget): widget = message.widget assert isinstance(widget, Widget) - display_update = self.layout.update_widget(self.console, widget) - # self.log("UPDATE", widget, display_update) + display_update = self._layout.update_widget(self.console, widget) if display_update is not None: self.app.display(display_update) @@ -135,7 +163,7 @@ class View(Widget): async def refresh_layout(self) -> None: self._cached_arrangement = (Size(), Offset(), []) try: - await self.layout.mount_all(self) + await self._layout.mount_all(self) if not self.is_root_view: await self.app.view.refresh_layout() return @@ -143,8 +171,8 @@ class View(Widget): if not self.size: return - hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size)) - assert self.layout.map is not None + hidden, shown, resized = self._layout.reflow(self, Size(*self.console.size)) + assert self._layout.map is not None for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -154,7 +182,7 @@ class View(Widget): send_resize = shown send_resize.update(resized) - for widget, region, unclipped_region in self.layout: + for widget, region, unclipped_region in self._layout: widget._update_size(unclipped_region.size) if widget in send_resize: widget.post_message_no_wait( @@ -171,13 +199,13 @@ class View(Widget): event.stop() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: - return self.layout.get_widget_at(x, y) + return self._layout.get_widget_at(x, y) def get_style_at(self, x: int, y: int) -> Style: - return self.layout.get_style_at(x, y) + return self._layout.get_style_at(x, y) def get_widget_region(self, widget: Widget) -> Region: - return self.layout.get_widget_region(widget) + return self._layout.get_widget_region(widget) async def on_mount(self, event: events.Mount) -> None: async def watch_background(value: str) -> None: @@ -186,8 +214,8 @@ class View(Widget): watch(self.app, "background", watch_background) async def on_idle(self, event: events.Idle) -> None: - if self.layout.check_update(): - self.layout.reset_update() + if self._layout.check_update(): + self._layout.reset_update() await self.refresh_layout() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index e468eab50..c4a4ccca7 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -28,8 +28,8 @@ class DockView(View): ) -> None: dock = Dock(edge, widgets, z) - assert isinstance(self.layout, DockLayout) - self.layout.docks.append(dock) + assert isinstance(self._layout, DockLayout) + self._layout.docks.append(dock) for widget in widgets: if size is not do_not_set: widget.layout_size = cast(Optional[int], size) @@ -54,8 +54,8 @@ class DockView(View): grid = GridLayout(gap=gap, gutter=gutter, align=align) view = View(layout=grid, name=name) dock = Dock(edge, (view,), z) - assert isinstance(self.layout, DockLayout) - self.layout.docks.append(dock) + assert isinstance(self._layout, DockLayout) + self._layout.docks.append(dock) if size is not do_not_set: view.layout_size = cast(Optional[int], size) if name is None: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 6e9a7b20e..92edce916 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -32,12 +32,12 @@ class WindowView(View, layout=VerticalLayout): super().__init__(name=name, layout=layout) async def update(self, widget: Widget | RenderableType) -> None: - layout = self.layout + layout = self._layout assert isinstance(layout, VerticalLayout) layout.clear() self.widget = widget if isinstance(widget, Widget) else Static(widget) layout.add(self.widget) - self.layout.require_update() + layout.require_update() self.refresh(layout=True) await self.emit(WindowChange(self)) @@ -46,8 +46,7 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def handle_layout(self, message: messages.Layout) -> None: - self.log("TRANSLATING layout") - self.layout.require_update() + self._layout.require_update() message.stop() self.refresh() @@ -55,11 +54,11 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def watch_scroll_x(self, value: int) -> None: - self.layout.require_update() + self._layout.require_update() self.refresh() async def watch_scroll_y(self, value: int) -> None: - self.layout.require_update() + self._layout.require_update() self.refresh() async def on_resize(self, event: events.Resize) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 8c81bd61b..5c12edfdf 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -67,7 +67,7 @@ class Widget(MessagePump): Widget._counts[class_name] += 1 _count = self._counts[class_name] - self.name = name or f"{class_name}#{_count}" + self.name = name or f"_{class_name}{_count}" self._id = id self._size = Size(0, 0) @@ -118,13 +118,14 @@ class Widget(MessagePump): cls.can_focus = can_focus def __rich_repr__(self) -> rich.repr.Result: + yield "id", self.id, None yield "name", self.name def __rich__(self) -> RenderableType: renderable = self.render_styled() return renderable - def add_child(self, widget: Widget) -> None: + def add_child(self, widget: Widget) -> Widget: """Add a child widget. Args: @@ -132,6 +133,7 @@ class Widget(MessagePump): """ self.app.register(widget, self) self.children._append(widget) + return widget def get_child(self, name: str | None = None) -> Widget: for widget in self.children: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 88eafac44..fba2efe1f 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -33,7 +33,7 @@ class Footer(Widget): self.highlight_key = None def __rich_repr__(self) -> rich.repr.Result: - yield "keys", self.keys + yield from super().__rich_repr__() def make_key_text(self) -> Text: """Create text containing all the keys.""" diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 15a027d34..a5f339de9 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -39,7 +39,8 @@ class Header(Widget): return f"{self.title} - {self.sub_title}" if self.sub_title else self.title def __rich_repr__(self) -> Result: - yield self.title + yield from super().__rich_repr__() + yield "title", self.title async def watch_tall(self, tall: bool) -> None: self.layout_size = 3 if tall else 1 diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 8cb0cc405..034861ea4 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -29,7 +29,7 @@ class Placeholder(Widget, can_focus=True): self.height = height def __rich_repr__(self) -> rich.repr.Result: - yield "name", self.name + yield from super().__rich_repr__() yield "has_focus", self.has_focus, False yield "mouse_over", self.mouse_over, False diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 2d8c2317a..562609a81 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -31,13 +31,20 @@ class ScrollView(View): ) -> None: from ..views import WindowView - self.fluid = fluid - self.vscroll = ScrollBar(vertical=True) - self.hscroll = ScrollBar(vertical=False) - self.window = WindowView( - "" if contents is None else contents, auto_width=auto_width, gutter=gutter - ) layout = GridLayout() + super().__init__(name=name, layout=layout) + + self.fluid = fluid + self.vscroll = self.add_child(ScrollBar(vertical=True)) + self.hscroll = self.add_child(ScrollBar(vertical=False)) + self.window = self.add_child( + WindowView( + "" if contents is None else contents, + auto_width=auto_width, + gutter=gutter, + ) + ) + layout.add_column("main") layout.add_column("vscroll", size=1) layout.add_row("main") @@ -47,7 +54,6 @@ class ScrollView(View): ) layout.show_row("hscroll", False) layout.show_column("vscroll", False) - super().__init__(name=name, layout=layout) x: Reactive[float] = Reactive(0, repaint=False) y: Reactive[float] = Reactive(0, repaint=False) @@ -89,13 +95,13 @@ class ScrollView(View): await self.window.update(renderable) async def on_mount(self, event: events.Mount) -> None: - assert isinstance(self.layout, GridLayout) - self.layout.place( + assert isinstance(self._layout, GridLayout) + self._layout.place( content=self.window, vscroll=self.vscroll, hscroll=self.hscroll, ) - await self.layout.mount_all(self) + await self._layout.mount_all(self) def home(self) -> None: self.x = self.y = 0 @@ -211,10 +217,10 @@ class ScrollView(View): self.vscroll.virtual_size = virtual_height self.vscroll.window_size = height - assert isinstance(self.layout, GridLayout) + assert isinstance(self._layout, GridLayout) - vscroll_change = self.layout.show_column("vscroll", virtual_height > height) - hscroll_change = self.layout.show_row("hscroll", virtual_width > width) + vscroll_change = self._layout.show_column("vscroll", virtual_height > height) + hscroll_change = self._layout.show_row("hscroll", virtual_width > width) if hscroll_change or vscroll_change: self.refresh(layout=True)