Merge pull request #656 from Textualize/center-layout

center layout
This commit is contained in:
Will McGugan
2022-08-09 11:58:39 +01:00
committed by GitHub
13 changed files with 211 additions and 31 deletions

28
sandbox/will/center.py Normal file
View File

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

55
sandbox/will/center2.py Normal file
View File

@@ -0,0 +1,55 @@
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;
}
#sidebar.hidden {
width: 0;
}
Static {
background: $panel;
color: $text-panel;
content-align: center middle;
}
"""
def on_mount(self) -> None:
self.bind("t", "toggle_class('#sidebar', 'hidden')")
def compose(self):
yield Static("Sidebar", id="sidebar")
yield Vertical(
Static("Bottom bar", id="bottombar"),
Center(
Static("Hello World!", id="hello"),
),
)
app = CenterApp(log_verbosity=3)

View File

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

View File

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

View File

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

View File

@@ -1,23 +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(Container):
"""A container widget to align children in the center."""
CSS = """
Center {
layout: center;
}
"""

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from fractions import Fraction
from .._layout import ArrangeResult, Layout, WidgetPlacement
from ..geometry import Region, Size
from ..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)

View File

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

View File

@@ -1,3 +1,5 @@
# TODO: This is deprecated (and probably doesn't work any more)
from __future__ import annotations
import sys

View File

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

View File

@@ -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,21 +247,21 @@ 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:
if self.grabbed:
await self.release_mouse()
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:

View File

@@ -1061,7 +1061,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)
@@ -1115,7 +1117,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.
@@ -1123,14 +1125,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)
@@ -1159,10 +1161,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:

View File

@@ -0,0 +1,33 @@
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():
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