layout mechanism

This commit is contained in:
Will McGugan
2021-08-02 09:43:20 +01:00
parent 2967fc0d8e
commit e04aaf4ed2
13 changed files with 185 additions and 106 deletions

View File

@@ -11,8 +11,8 @@ class GridTest(App):
grid = await self.view.dock_grid(edge="left", size=70, name="left")
self.view["left"].scroll_y = 5
self.view["left"].scroll_x = 5
# self.view["left"].scroll_y = 5
# self.view["left"].scroll_x = 5
grid.add_column(fraction=1, name="left", min_size=20)
grid.add_column(size=30, name="center")
@@ -35,6 +35,7 @@ class GridTest(App):
area3=Placeholder(name="area3"),
area4=Placeholder(name="area4"),
)
await self.view.update_layout()
GridTest.run(title="Grid Test", log="textual.log")

View File

@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from . import events
from .driver import Driver
from .geometry import Size
from ._types import MessageTarget
from ._xterm_parser import XTermParser
@@ -72,7 +73,7 @@ class LinuxDriver(Driver):
def on_terminal_resize(signum, stack) -> None:
terminal_size = self._get_terminal_size()
width, height = terminal_size
event = events.Resize(self._target, width, height)
event = events.Resize(self._target, Size(width, height))
self.console.size = terminal_size
asyncio.run_coroutine_threadsafe(
self._target.post_message(event),
@@ -115,7 +116,7 @@ class LinuxDriver(Driver):
)
width, height = self.console.size = self._get_terminal_size()
asyncio.run_coroutine_threadsafe(
self._target.post_message(events.Resize(self._target, width, height)),
self._target.post_message(events.Resize(self._target, Size(width, height))),
loop=loop,
)
self._key_thread.start()

View File

@@ -267,7 +267,6 @@ class App(MessagePump):
self.title = self._title
self.require_layout()
await self.animator.start()
await super().process_messages()
log("PROCESS END")
await self.animator.stop()

View File

@@ -10,7 +10,6 @@ from .message import Message
from ._types import MessageTarget
from .keys import Keys
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")
if TYPE_CHECKING:
@@ -53,6 +52,11 @@ class Shutdown(Event):
pass
class Repaint(Event, bubble=False):
def can_replace(self, message: "Message") -> bool:
return isinstance(message, Repaint)
class Load(Event):
"""
Sent when the App is running but *before* the terminal is in application mode.
@@ -87,27 +91,29 @@ class Action(Event, bubble=True):
class Resize(Event):
"""Sent when the app or widget has been resized."""
__slots__ = ["width", "height"]
width: int
height: int
__slots__ = ["size"]
size: Size
def __init__(self, sender: MessageTarget, width: int, height: int) -> None:
def __init__(self, sender: MessageTarget, size: Size) -> None:
"""
Args:
sender (MessageTarget): Event sender.
width (int): New width in terminal cells.
height (int): New height in terminal cells.
"""
self.width = width
self.height = height
self.size = size
super().__init__(sender)
def can_replace(self, message: "Message") -> bool:
return isinstance(message, Resize)
@property
def size(self) -> Size:
return Size(self.width, self.height)
def width(self) -> int:
return self.size.width
@property
def height(self) -> int:
return self.size.height
def __rich_repr__(self) -> rich.repr.RichReprResult:
yield self.width

View File

@@ -76,7 +76,7 @@ class Layout(ABC):
self._layout_map: LayoutMap | None = None
self.width = 0
self.height = 0
self.renders: dict[Widget, tuple[Region, Region, Lines]] = {}
self.regions: dict[Widget, tuple[Region, Region]] = {}
self._cuts: list[list[int]] | None = None
self._require_update: bool = True
self.background = ""
@@ -92,9 +92,9 @@ class Layout(ABC):
def reset(self) -> None:
self._cuts = None
if self._require_update:
self.renders.clear()
self._layout_map = None
# if self._require_update:
# self.regions.clear()
# self._layout_map = None
def reflow(
self, console: Console, width: int, height: int, scroll: Offset
@@ -137,15 +137,9 @@ class Layout(ABC):
# Copy renders if the size hasn't changed
new_renders = {
widget: (region, clip, self.renders[widget][2])
for widget, (region, _order, clip) in map.items()
if (
widget in self.renders
and self.renders[widget][0].size == region.size
and not widget.check_repaint()
)
widget: (region, clip) for widget, (region, _order, clip) in map.items()
}
self.renders = new_renders
self.regions = new_renders
# Widgets with changed size
resized_widgets = {
@@ -216,9 +210,9 @@ class Layout(ABC):
widget, region = self.get_widget_at(x, y)
except NoWidget:
return Style.null()
if widget not in self.renders:
if widget not in self.regions:
return Style.null()
_region, clip, lines = self.renders[widget]
lines = widget._get_lines()
x -= region.x
y -= region.y
line = lines[y]
@@ -281,39 +275,56 @@ class Layout(ABC):
else:
widget_regions = []
def render(widget: Widget, width: int, height: int) -> Lines:
lines = console.render_lines(
widget, console.options.update_dimensions(width, height)
)
return lines
# def render(widget: Widget, width: int, height: int) -> Lines:
# lines = console.render_lines(
# widget, console.options.update_dimensions(width, height)
# )
# return lines
for widget, region, _order, clip in widget_regions:
if not widget.is_visual:
continue
region_lines = self.renders.get(widget)
if region_lines is not None:
region, clip, lines = region_lines
else:
lines = render(widget, region.width, region.height)
log("RENDERING", widget)
lines = widget._get_lines()
width, height = region.size
lines = Segment.set_shape(lines, width, height)
# assert Segment.get_shape(lines) == region.size
if region in clip:
self.renders[widget] = (region, clip, lines)
yield region, clip, lines
elif clip.overlaps(region):
new_region = region.intersection(clip)
delta_x = new_region.x - region.x
delta_y = new_region.y - region.y
self.renders[widget] = (region, clip, lines)
splits = [delta_x, delta_x + new_region.width]
lines = lines[delta_y : delta_y + new_region.height]
divide = Segment.divide
lines = [list(divide(line, splits))[1] for line in lines]
yield region, clip, lines
# region_lines = self.regions.get(widget)
# if region_lines is not None:
# region, clip, lines = region_lines
# else:
# lines = render(widget, region.width, region.height)
# log("RENDERING", widget)
# if region in clip:
# self.regions[widget] = (region, clip, lines)
# yield region, clip, lines
# elif clip.overlaps(region):
# new_region = region.intersection(clip)
# delta_x = new_region.x - region.x
# delta_y = new_region.y - region.y
# self.regions[widget] = (region, clip, lines)
# splits = [delta_x, delta_x + new_region.width]
# lines = lines[delta_y : delta_y + new_region.height]
# divide = Segment.divide
# lines = [list(divide(line, splits))[1] for line in lines]
# yield region, clip, lines
@classmethod
def _assemble_chops(
cls, chops: list[dict[int, list[Segment] | None]]
@@ -347,8 +358,6 @@ class Layout(ABC):
crop_region = crop or Region(0, 0, self.width, self.height)
# clip_x, clip_y, clip_x2, clip_y2 = clip.corners
divide = Segment.divide
# Maps each cut on to a list of segments
@@ -411,15 +420,27 @@ class Layout(ABC):
yield self.render(console)
def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None:
if widget not in self.renders:
if widget not in self.regions:
return None
region, clip, lines = self.renders[widget]
new_lines = console.render_lines(
widget, console.options.update_dimensions(region.width, region.height)
)
region, clip = self.regions[widget]
self.renders[widget] = (region, clip, new_lines)
if not region.size:
return None
widget._clear_render_cache()
# if not region or not clip:
# return
# widget._clear_render_cache()
# widget.render_lines()
# new_lines = console.render_lines(
# widget, console.options.update_dimensions(region.width, region.height)
# )
# self.regions[widget] = (region, clip, new_lines)
update_region = region.intersection(clip)
update_lines = self.render(console, update_region).lines

View File

@@ -28,13 +28,13 @@ class LayoutMap:
def __getitem__(self, widget: Widget) -> RenderRegion:
return self.widgets[widget]
def items(self) -> ItemsView:
def items(self) -> ItemsView[Widget, RenderRegion]:
return self.widgets.items()
def keys(self) -> KeysView:
def keys(self) -> KeysView[Widget]:
return self.widgets.keys()
def values(self) -> ValuesView:
def values(self) -> ValuesView[RenderRegion]:
return self.widgets.values()
def clear(self) -> None:

View File

@@ -4,7 +4,7 @@ from typing import Iterable
from rich.console import Console
from .. import log
from ..geometry import Offset, Region, Size
from ..layout import Layout
from ..layout_map import LayoutMap
@@ -42,22 +42,25 @@ class VerticalLayout(Layout):
map.add_widget(console, widget, region, (self.z, index), clip)
for widget in self._widgets:
try:
region, clip, lines = self.renders[widget]
except KeyError:
renderable = widget.render()
lines = console.render_lines(
renderable, console.options.update_width(render_width)
)
region = Region(x, y, render_width, len(lines))
self.renders[widget] = (region - scroll, viewport, lines)
add_widget(widget, region - scroll, viewport)
else:
add_widget(
widget,
Region(x, y, region.width, region.height) - scroll,
clip,
)
y += region.height + gutter_height
# if widget._render_cache is not None:
# lines = widget._render_cache.lines
# else:
# lines = widget.render_lines(render_width).lines
region = Region(x, y, render_width, 100)
add_widget(widget, region - scroll, viewport)
# try:
# region, clip = self.regions[widget]
# except KeyError:
# lines = widget.render_lines(render_width)
# log("***VERTICAL", len(lines))
# region = Region(x, y, render_width, len(lines))
# add_widget(widget, region - scroll, viewport)
# else:
# add_widget(
# widget, Region(x, y, region.width, region.height) - scroll, clip
# )
# y += region.height + gutter_height
return map

View File

@@ -17,28 +17,19 @@ class UpdateMessage(Message):
self,
sender: MessagePump,
widget: Widget,
offset_x: int = 0,
offset_y: int = 0,
reflow: bool = False,
):
super().__init__(sender)
self.widget = widget
self.offset_x = offset_x
self.offset_y = offset_y
self.reflow = reflow
def __rich_repr__(self) -> rich.repr.RichReprResult:
yield self.sender
yield "widget"
yield "offset_x", self.offset_x, 0
yield "offset_y", self.offset_y, 0
yield "reflow", self.reflow, False
def can_replace(self, message: Message) -> bool:
return isinstance(message, UpdateMessage) and message.sender == self.sender
return isinstance(message, UpdateMessage) and self.widget is message.widget
@rich.repr.auto
class LayoutMessage(Message):
def can_replace(self, message: Message) -> bool:
return isinstance(message, LayoutMessage) and message.sender == self.sender
return isinstance(message, LayoutMessage)

View File

@@ -30,7 +30,6 @@ class View(Widget):
self.layout: Layout = layout or self.layout_factory()
self.mouse_over: Widget | None = None
self.focused: Widget | None = None
self.size = Size(0, 0)
self.widgets: set[Widget] = set()
self.named_widgets: dict[str, Widget] = {}
self._mouse_style: Style = Style()
@@ -127,6 +126,7 @@ class View(Widget):
async def message_update(self, message: UpdateMessage) -> None:
widget = message.widget
assert isinstance(widget, Widget)
display_update = self.root_view.layout.update_widget(self.console, widget)
if display_update is not None:
self.app.display(display_update)
@@ -159,8 +159,12 @@ class View(Widget):
hidden, shown, resized = self.layout.reflow(
self.console, width, height, self.scroll
)
assert self.layout.map is not None
self.virtual_size = self.layout.map.virtual_size
# self.app.refresh()
# for widget, region in self.layout:
# widget._update_size(region.size)
self.app.refresh()
for widget in hidden:
widget.post_message_no_wait(events.Hide(self))
@@ -172,13 +176,13 @@ class View(Widget):
for widget, region in self.layout:
if widget in send_resize:
widget.post_message_no_wait(
events.Resize(self, region.width, region.height)
)
widget._update_size(region.size)
widget.post_message_no_wait(events.Resize(self, region.size))
async def on_resize(self, event: events.Resize) -> None:
self.size = Size(event.width, event.height)
await self.refresh_layout()
self._update_size(event.size)
if self.is_root_view:
await self.refresh_layout()
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
return self.layout.get_widget_at(x, y)

View File

@@ -41,6 +41,6 @@ class WindowView(View, layout=VerticalLayout):
async def watch_virtual_size(self, size: Size) -> None:
await self.emit(VirtualSizeChange(self))
# async def on_resize(self, event: events.Resize) -> None:
# self.layout.renders.pop(self.widget)
# self.require_repaint()
async def on_resize(self, event: events.Resize) -> None:
# self.layout.renders.pop(self.widget)
self.require_repaint()

View File

@@ -7,6 +7,7 @@ from typing import (
TYPE_CHECKING,
Callable,
ClassVar,
NamedTuple,
NewType,
cast,
)
@@ -15,6 +16,7 @@ from rich.align import Align
from rich.console import Console, RenderableType
from rich.panel import Panel
from rich.pretty import Pretty
from rich.segment import Segment
from rich.style import Style
from . import events
@@ -25,6 +27,7 @@ from .message import Message
from .message_pump import MessagePump
from .messages import LayoutMessage, UpdateMessage
from .reactive import Reactive, watch
from ._types import Lines
if TYPE_CHECKING:
from .app import App
@@ -33,6 +36,11 @@ if TYPE_CHECKING:
log = getLogger("rich")
class RenderCache(NamedTuple):
size: Size
lines: Lines
@rich.repr.auto
class Widget(MessagePump):
_id: ClassVar[int] = 0
@@ -47,12 +55,12 @@ class Widget(MessagePump):
self.name = name or f"{class_name}#{_count}"
self.size = Size(0, 0)
self.size_changed = False
self._size = Size(0, 0)
self._repaint_required = False
self._layout_required = False
self._animate: BoundAnimator | None = None
self._reactive_watches: dict[str, Callable] = {}
self._render_cache: RenderCache | None = None
self.highlight_style: Style | None = None
super().__init__()
@@ -83,6 +91,10 @@ class Widget(MessagePump):
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
watch(self, attribute_name, callback)
@property
def size(self) -> Size:
return self._size
@property
def is_visual(self) -> bool:
return True
@@ -109,11 +121,46 @@ class Widget(MessagePump):
"""Get the layout offset as a tuple."""
return (round(self.layout_offset_x), round(self.layout_offset_y))
def _update_size(self, size: Size) -> None:
self._size = size
# if self._render_cache and self._render_cache.size != size:
# self.render_lines()
# self.require_repaint()
# self.size = size
def render_lines(self) -> RenderCache:
width, height = self.size
renderable = self.render()
options = self.console.options.update_dimensions(width, height)
lines = self.console.render_lines(renderable, options)
self._render_cache = RenderCache(self.size, lines)
return self._render_cache
def _get_lines(self) -> Lines:
"""Get render lines for given dimensions.
Args:
width (int): [description]
height (int): [description]
Returns:
Lines: [description]
"""
if self._render_cache is None:
self._render_cache = self.render_lines()
lines = self._render_cache.lines
return lines
def _clear_render_cache(self) -> None:
self._render_cache = None
def require_repaint(self) -> None:
"""Mark widget as requiring a repaint.
Actual repaint is done by parent on idle.
"""
self._render_cache = None
self._repaint_required = True
self.post_message_no_wait(events.Null(self))
@@ -173,13 +220,15 @@ class Widget(MessagePump):
return True
return await super().post_message(message)
async def on_event(self, event: events.Event) -> None:
if isinstance(event, events.Resize):
new_size = Size(event.width, event.height)
if self.size != new_size:
self.size = new_size
self.require_repaint()
await super().on_event(event)
# async def on_event(self, event: events.Event) -> None:
# if isinstance(event, events.Resize):
# if self.size != event.size:
# # self.size = event.size
# self.require_repaint()
# await super().on_event(event)
async def on_resize(self, event: events.Resize) -> None:
self.render_lines()
async def on_idle(self, event: events.Idle) -> None:
if self.check_layout():
@@ -215,6 +264,10 @@ class Widget(MessagePump):
if key_method is not None:
await key_method()
# async def on_repaint(self) -> None:
# if self._render_cache is None or self._render_cache.size != self.size:
# self._render_cache = self.render_lines()
async def on_mouse_down(self, event: events.MouseUp) -> None:
await self.broker_event("mouse.down", event)

View File

@@ -158,9 +158,8 @@ class ScrollView(View):
self.animate("y", self.target_y, duration=1, easing="out_cubic")
async def on_resize(self, event: events.Resize) -> None:
return
if self.fluid:
self.window.update()
self.window.require_repaint()
async def message_scroll_up(self, message: Message) -> None:
self.page_up()
@@ -185,7 +184,7 @@ class ScrollView(View):
async def message_virtual_size_change(self, message: Message) -> None:
virtual_size = self.window.virtual_size
# self.log("VIRTUAL_SIZE", self.size, virtual_size)
self.log("VIRTUAL_SIZE", self.size, virtual_size)
self.x = self.validate_x(self.x)
self.y = self.validate_y(self.y)
self.log(self.y)

View File

@@ -21,6 +21,7 @@ class Static(Widget):
self.padding = padding
def render(self) -> RenderableType:
self.log("RENDERING", self.renderable)
renderable = self.renderable
if self.padding:
renderable = Padding(renderable, self.padding)