From 85e3409309e452d45f75c9557f3ad2bf865034ce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 11:19:32 +0100 Subject: [PATCH 01/10] center layout --- sandbox/will/center.py | 28 ++++++++++++++++++++++++ src/textual/css/constants.py | 2 +- src/textual/layouts/center.py | 40 ++++++++++++++++++++++++++++++++++ src/textual/layouts/factory.py | 10 ++++++--- src/textual/layouts/grid.py | 2 ++ 5 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 sandbox/will/center.py create mode 100644 src/textual/layouts/center.py diff --git a/sandbox/will/center.py b/sandbox/will/center.py new file mode 100644 index 000000000..e2941f970 --- /dev/null +++ b/sandbox/will/center.py @@ -0,0 +1,28 @@ +from textual.app import App +from textual.widgets import Static + + +class CenterApp(App): + CSS = """ + + Screen { + layout: center; + overflow: auto auto; + } + + Static { + border: wide $primary; + background: $panel; + width: 50; + height: 20; + margin: 1 2; + content-align: center middle; + } + + """ + + def compose(self): + yield Static("Hello World!") + + +app = CenterApp() diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 57cfc3201..cf096d98a 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -32,7 +32,7 @@ VALID_BORDER: Final[set[EdgeType]] = { "wide", } VALID_EDGE: Final = {"top", "right", "bottom", "left"} -VALID_LAYOUT: Final = {"vertical", "horizontal"} +VALID_LAYOUT: Final = {"vertical", "horizontal", "center"} VALID_BOX_SIZING: Final = {"border-box", "content-box"} VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} diff --git a/src/textual/layouts/center.py b/src/textual/layouts/center.py new file mode 100644 index 000000000..813029475 --- /dev/null +++ b/src/textual/layouts/center.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from fractions import Fraction +from typing import cast + +from textual.geometry import Size, Region +from textual._layout import ArrangeResult, Layout, WidgetPlacement +from textual.widget import Widget + + +class CenterLayout(Layout): + """Positions widgets in the center of the screen.""" + + name = "center" + + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: + + placements: list[WidgetPlacement] = [] + total_regions: list[Region] = [] + + parent_size = parent.outer_size + container_width, container_height = size + fraction_unit = Fraction(size.width) + + for widget in children: + width, height, margin = widget.get_box_model( + size, parent_size, fraction_unit + ) + margin_width = width + margin.width + margin_height = height + margin.height + x = margin.left + max(0, (container_width - margin_width) // 2) + y = margin.top + max(0, (container_height - margin_height) // 2) + region = Region(x, y, int(width), int(height)) + total_regions.append(region.grow(margin)) + placements.append(WidgetPlacement(region, widget, 0)) + + placements.append(WidgetPlacement(Region.from_union(total_regions), None, 0)) + return placements, set(children) diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index cc0de2e00..5dd4eae9d 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,11 +1,15 @@ -from .horizontal import HorizontalLayout +from __future__ import annotations + from .._layout import Layout -from ..layouts.vertical import VerticalLayout +from .horizontal import HorizontalLayout +from .vertical import VerticalLayout +from .center import CenterLayout -LAYOUT_MAP = { +LAYOUT_MAP: dict[str, type[Layout]] = { "vertical": VerticalLayout, "horizontal": HorizontalLayout, + "center": CenterLayout, } diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 40526f29a..e2124f804 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -1,3 +1,5 @@ +# TODO: This is deprecated (and probably doesn't work any more) + from __future__ import annotations import sys From 5d7a821e1f98f3922576628ca0b9126bd591d85a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 11:27:33 +0100 Subject: [PATCH 02/10] test for center layout --- src/textual/layouts/center.py | 7 +++---- tests/test_layouts_center.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 tests/test_layouts_center.py diff --git a/src/textual/layouts/center.py b/src/textual/layouts/center.py index 813029475..164b136ca 100644 --- a/src/textual/layouts/center.py +++ b/src/textual/layouts/center.py @@ -1,11 +1,10 @@ from __future__ import annotations from fractions import Fraction -from typing import cast -from textual.geometry import Size, Region -from textual._layout import ArrangeResult, Layout, WidgetPlacement -from textual.widget import Widget +from ..geometry import Size, Region +from .._layout import ArrangeResult, Layout, WidgetPlacement +from ..widget import Widget class CenterLayout(Layout): diff --git a/tests/test_layouts_center.py b/tests/test_layouts_center.py new file mode 100644 index 000000000..227f8400e --- /dev/null +++ b/tests/test_layouts_center.py @@ -0,0 +1,33 @@ +from textual.geometry import Region, Size +from textual.widget import Widget +from textual.layouts.center import CenterLayout +from textual._layout import WidgetPlacement + + +def test_center_layout(): + + widget = Widget() + widget._size = Size(80, 24) + child = Widget() + child.styles.width = 10 + child.styles.height = 5 + layout = CenterLayout() + + placements, widgets = layout.arrange(widget, [child], Size(60, 20)) + assert widgets == {child} + + expected = [ + WidgetPlacement( + region=Region(x=25, y=7, width=10, height=5), + widget=child, + order=0, + fixed=False, + ), + WidgetPlacement( + region=Region(x=25, y=7, width=10, height=5), + widget=None, + order=0, + fixed=False, + ), + ] + assert placements == expected From c4f9659c2931df850fc79f680a18048975785cd5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 14:14:01 +0100 Subject: [PATCH 03/10] imports --- src/textual/layouts/center.py | 2 +- tests/test_layouts_center.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/layouts/center.py b/src/textual/layouts/center.py index 164b136ca..e1650f19f 100644 --- a/src/textual/layouts/center.py +++ b/src/textual/layouts/center.py @@ -2,8 +2,8 @@ from __future__ import annotations from fractions import Fraction -from ..geometry import Size, Region from .._layout import ArrangeResult, Layout, WidgetPlacement +from ..geometry import Region, Size from ..widget import Widget diff --git a/tests/test_layouts_center.py b/tests/test_layouts_center.py index 227f8400e..52cbd0cc6 100644 --- a/tests/test_layouts_center.py +++ b/tests/test_layouts_center.py @@ -1,7 +1,7 @@ -from textual.geometry import Region, Size -from textual.widget import Widget -from textual.layouts.center import CenterLayout from textual._layout import WidgetPlacement +from textual.geometry import Region, Size +from textual.layouts.center import CenterLayout +from textual.widget import Widget def test_center_layout(): From 76cdea92c6f56e352ab1acc3b45feee169c35a7f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 14:19:57 +0100 Subject: [PATCH 04/10] capture mouse issue --- src/textual/app.py | 6 +++--- src/textual/scrollbar.py | 20 ++++++++++++-------- src/textual/widget.py | 8 ++++---- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 18f229e9d..d04573d31 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -680,7 +680,7 @@ class App(Generic[ReturnType], DOMNode): finally: self.mouse_over = widget - async def capture_mouse(self, widget: Widget | None) -> None: + def capture_mouse(self, widget: Widget | None) -> None: """Send all mouse events to the given widget, disable mouse capture. Args: @@ -689,12 +689,12 @@ class App(Generic[ReturnType], DOMNode): if widget == self.mouse_captured: return if self.mouse_captured is not None: - await self.mouse_captured.post_message( + self.mouse_captured.post_message_no_wait( events.MouseRelease(self, self.mouse_position) ) self.mouse_captured = widget if widget is not None: - await widget.post_message(events.MouseCapture(self, self.mouse_position)) + widget.post_message_no_wait(events.MouseCapture(self, self.mouse_position)) def panic(self, *renderables: RenderableType) -> None: """Exits the app then displays a message. diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index d70faca32..bd9545982 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -231,10 +231,14 @@ class ScrollBar(Widget): style=scrollbar_style, ) - async def on_enter(self, event: events.Enter) -> None: + def on_hide(self, event: events.Hide) -> None: + if self.grabbed: + self.release_mouse() + + def on_enter(self, event: events.Enter) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self, event: events.Leave) -> None: self.mouse_over = False async def action_scroll_down(self) -> None: @@ -243,15 +247,15 @@ class ScrollBar(Widget): async def action_scroll_up(self) -> None: await self.emit(ScrollUp(self) if self.vertical else ScrollLeft(self)) - async def action_grab(self) -> None: - await self.capture_mouse() + def action_grab(self) -> None: + self.capture_mouse() - async def action_released(self) -> None: - await self.capture_mouse(False) + def action_released(self) -> None: + self.capture_mouse(False) - async def on_mouse_up(self, event: events.MouseUp) -> None: + def on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: - await self.release_mouse() + self.release_mouse() async def on_mouse_capture(self, event: events.MouseCapture) -> None: self.grabbed = event.mouse_position diff --git a/src/textual/widget.py b/src/textual/widget.py index d1f218bc2..1f7eca0e3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1090,7 +1090,7 @@ class Widget(DOMNode): """Give input focus to this widget.""" self.app.set_focus(self) - async def capture_mouse(self, capture: bool = True) -> None: + def capture_mouse(self, capture: bool = True) -> None: """Capture (or release) the mouse. When captured, all mouse coordinates will go to this widget even when the pointer is not directly over the widget. @@ -1098,14 +1098,14 @@ class Widget(DOMNode): Args: capture (bool, optional): True to capture or False to release. Defaults to True. """ - await self.app.capture_mouse(self if capture else None) + self.app.capture_mouse(self if capture else None) - async def release_mouse(self) -> None: + def release_mouse(self) -> None: """Release the mouse. Mouse events will only be sent when the mouse is over the widget. """ - await self.app.capture_mouse(None) + self.app.capture_mouse(None) async def broker_event(self, event_name: str, event: events.Event) -> bool: return await self.app.broker_event(event_name, event, default_namespace=self) From b8bc41fafebdbceb24e2a2f43e787b24b7e526e6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 14:23:31 +0100 Subject: [PATCH 05/10] async tweak --- src/textual/scrollbar.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index bd9545982..d13c502f8 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -235,10 +235,10 @@ class ScrollBar(Widget): if self.grabbed: self.release_mouse() - def on_enter(self, event: events.Enter) -> None: + async def on_enter(self, event: events.Enter) -> None: self.mouse_over = True - def on_leave(self, event: events.Leave) -> None: + async def on_leave(self, event: events.Leave) -> None: self.mouse_over = False async def action_scroll_down(self) -> None: @@ -253,15 +253,15 @@ class ScrollBar(Widget): def action_released(self) -> None: self.capture_mouse(False) - def on_mouse_up(self, event: events.MouseUp) -> None: + async def on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: self.release_mouse() - async def on_mouse_capture(self, event: events.MouseCapture) -> None: + def on_mouse_capture(self, event: events.MouseCapture) -> None: self.grabbed = event.mouse_position self.grabbed_position = self.position - async def on_mouse_release(self, event: events.MouseRelease) -> None: + def on_mouse_release(self, event: events.MouseRelease) -> None: self.grabbed = None async def on_mouse_move(self, event: events.MouseMove) -> None: From e7cfe88b02f67305b24822a74dcf62d53559ac32 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 14:26:05 +0100 Subject: [PATCH 06/10] events signature --- src/textual/scrollbar.py | 4 ++-- src/textual/widget.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index d13c502f8..ea5d510dd 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -235,10 +235,10 @@ class ScrollBar(Widget): if self.grabbed: self.release_mouse() - async def on_enter(self, event: events.Enter) -> None: + def on_enter(self, event: events.Enter) -> None: self.mouse_over = True - async def on_leave(self, event: events.Leave) -> None: + def on_leave(self, event: events.Leave) -> None: self.mouse_over = False async def action_scroll_down(self) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 1f7eca0e3..69886f6ab 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1128,10 +1128,10 @@ class Widget(DOMNode): self.mount(*widgets) self.screen.refresh(repaint=False, layout=True) - def on_leave(self) -> None: + def on_leave(self, event: events.Leave) -> None: self.mouse_over = False - def on_enter(self) -> None: + def on_enter(self, event: events.Enter) -> None: self.mouse_over = True def on_focus(self, event: events.Focus) -> None: From 2a8504973280946de32eaa769c764358467bc845 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 15:00:21 +0100 Subject: [PATCH 07/10] added center layout --- sandbox/will/center2.py | 48 +++++++++++++++++++++++++++++++++++++++++ src/textual/layout.py | 12 +++++++++++ 2 files changed, 60 insertions(+) create mode 100644 sandbox/will/center2.py diff --git a/sandbox/will/center2.py b/sandbox/will/center2.py new file mode 100644 index 000000000..e4d78ac25 --- /dev/null +++ b/sandbox/will/center2.py @@ -0,0 +1,48 @@ +from textual.app import App +from textual.layout import Vertical, Center +from textual.widgets import Static + + +class CenterApp(App): + CSS = """ + + #sidebar { + dock: left; + width: 32; + height: 100%; + border-right: vkey $primary; + } + + #bottombar { + dock: bottom; + height: 12; + width: 100%; + border-top: hkey $primary; + } + + #hello { + border: wide $primary; + width: 40; + height: 16; + margin: 2 4; + } + + Static { + background: $panel; + color: $text-panel; + content-align: center middle; + } + + """ + + def compose(self): + yield Static("Sidebar", id="sidebar") + yield Vertical( + Static("Bottom bar", id="bottombar"), + Center( + Static("Hello World!", id="hello"), + ), + ) + + +app = CenterApp() diff --git a/src/textual/layout.py b/src/textual/layout.py index 82db0e7da..597dd5326 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -21,3 +21,15 @@ class Horizontal(Widget): overflow: auto; } """ + + +class Center(Widget): + """A container widget to align children in the center.""" + + CSS = """ + Center { + layout: center; + overflow: auto; + } + + """ From c4ac2cce95de4c11ec93936265b8b7abbe054f24 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 21:03:16 +0100 Subject: [PATCH 08/10] layout hierarchy --- src/textual/layout.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/textual/layout.py b/src/textual/layout.py index 597dd5326..c4b64bcbd 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -1,35 +1,37 @@ from .widget import Widget -class Vertical(Widget): - """A container widget to align children vertically.""" +class Container(Widget): + """Simple container widget, with vertical layout.""" CSS = """ - Vertical { + Container { layout: vertical; overflow: auto; } """ -class Horizontal(Widget): +class Vertical(Container): + """A container widget to align children vertically.""" + + +class Horizontal(Container): """A container widget to align children horizontally.""" CSS = """ Horizontal { - layout: horizontal; - overflow: auto; + layout: horizontal; } """ -class Center(Widget): +class Center(Container): """A container widget to align children in the center.""" CSS = """ Center { - layout: center; - overflow: auto; + layout: center; } """ From f9828fba715311cc3ec1ac591833e8392aa014d2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Aug 2022 21:16:59 +0100 Subject: [PATCH 09/10] fix bug with layout updates --- sandbox/will/center2.py | 9 ++++++++- src/textual/_compositor.py | 1 + src/textual/box_model.py | 2 +- src/textual/screen.py | 1 - src/textual/widget.py | 4 +++- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/sandbox/will/center2.py b/sandbox/will/center2.py index e4d78ac25..1711fd09a 100644 --- a/sandbox/will/center2.py +++ b/sandbox/will/center2.py @@ -27,6 +27,10 @@ class CenterApp(App): margin: 2 4; } + #sidebar.hidden { + width: 0; + } + Static { background: $panel; color: $text-panel; @@ -35,6 +39,9 @@ class CenterApp(App): """ + def on_mount(self) -> None: + self.bind("t", "toggle_class('#sidebar', 'hidden')") + def compose(self): yield Static("Sidebar", id="sidebar") yield Vertical( @@ -45,4 +52,4 @@ class CenterApp(App): ) -app = CenterApp() +app = CenterApp(log_verbosity=3) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 72b309d89..1de9e67f3 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -632,6 +632,7 @@ class Compositor: update_regions: set[Region] = set() else: update_regions = self._dirty_regions.copy() + if screen_region in update_regions: # If one of the updates is the entire screen, then we only need one update full = True diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 3d7a9a6cd..e05ace9e3 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -81,7 +81,7 @@ def get_box_model( ) content_width = min(content_width, max_width) - content_width = max(Fraction(1), content_width) + content_width = max(Fraction(0), content_width) if styles.height is None: # No height specified, fill the available space diff --git a/src/textual/screen.py b/src/textual/screen.py index 2af611482..c4773b76e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -170,7 +170,6 @@ class Screen(Widget): self.update_timer.pause() try: hidden, shown, resized = self._compositor.reflow(self, size) - Hide = events.Hide Show = events.Show for widget in hidden: diff --git a/src/textual/widget.py b/src/textual/widget.py index 69886f6ab..558733c8d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1040,7 +1040,9 @@ class Widget(DOMNode): if layout: self._layout_required = True - self._clear_arrangement_cache() + if isinstance(self._parent, Widget): + self._parent._clear_arrangement_cache() + if repaint: self._set_dirty(*regions) self._content_width_cache = (None, 0) From a4e0a43273b443cc1f359031b4c7910d227a0105 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Aug 2022 17:44:36 +0100 Subject: [PATCH 10/10] superfluous space --- src/textual/_compositor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1de9e67f3..72b309d89 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -632,7 +632,6 @@ class Compositor: update_regions: set[Region] = set() else: update_regions = self._dirty_regions.copy() - if screen_region in update_regions: # If one of the updates is the entire screen, then we only need one update full = True