renderer refactor

This commit is contained in:
Will McGugan
2021-07-29 16:53:54 +01:00
parent ecc5d24b54
commit 6cb25add4a
13 changed files with 187 additions and 66 deletions

5
examples/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Examples
Run any of these examples to demonstrate a features.
These examples may not be feature complete, but they should be somewhat useful and a good starting point for your own code.

20
examples/test_layout.py Normal file
View File

@@ -0,0 +1,20 @@
from rich import print
from rich.console import Console
from textual.geometry import Offset, Region
from textual.widgets import Placeholder
from textual.views import WindowView
p = Placeholder(height=10)
view = WindowView(p)
console = Console()
view.layout.reflow(console, 30, 25, Offset(0, 3))
print(view.layout._layout_map.widgets)
console.print(view.layout.render(console))
# console.print(view.layout.render(console, Region(100, 2, 10, 10)))

19
examples/vertical.py Normal file
View File

@@ -0,0 +1,19 @@
from textual import events
from textual.app import App
from textual.views import WindowView
from textual.widgets import Placeholder
class MyApp(App):
async def on_mount(self, event: events.Mount) -> None:
window1 = WindowView(Placeholder(height=20))
# window2 = WindowView(Placeholder(height=20))
# window1.scroll_x = -10
# window1.scroll_y = 5
await self.view.dock(window1, edge="left")
MyApp.run(log="textual.log")

View File

@@ -1,8 +1,17 @@
from typing import Any
__all__ = ["log", "panic"]
def log(*args: Any) -> None:
from ._context import active_app
app = active_app.get()
app.log(*args)
def panic(*args: Any) -> None:
from ._context import active_app
app = active_app.get()
app.panic(*args)

View File

@@ -97,7 +97,7 @@ class App(MessagePump):
self.mouse_over: Widget | None = None
self.mouse_captured: Widget | None = None
self._driver: Driver | None = None
self._tracebacks: list[Traceback] = []
self._exit_renderables: list[RenderableType] = []
self._docks: list[Dock] = []
self._action_targets = {"app", "view"}
@@ -231,16 +231,19 @@ class App(MessagePump):
if widget is not None:
await widget.post_message(events.MouseCapture(self, self.mouse_position))
def panic(self, traceback: Traceback | None = None) -> None:
def panic(self, *renderables: RenderableType) -> None:
"""Exits the app with a traceback.
Args:
traceback (Traceback, optional): Rich Traceback object or None to generate one
for the most recent exception. Defaults to None.
"""
if traceback is None:
traceback = Traceback(show_locals=True)
self._tracebacks.append(traceback)
if not renderables:
renderables = (
Traceback(show_locals=True, width=None, locals_max_length=5),
)
self._exit_renderables.extend(renderables)
self.close_messages_no_wait()
async def process_messages(self) -> None:
@@ -283,8 +286,8 @@ class App(MessagePump):
self.panic()
finally:
driver.stop_application_mode()
if self._tracebacks:
for traceback in self._tracebacks:
if self._exit_renderables:
for traceback in self._exit_renderables:
self.error_console.print(traceback)
if self.log_file is not None:
self.log_file.close()
@@ -339,7 +342,7 @@ class App(MessagePump):
console.file.write("\x1bP=2s\x1b\\")
console.file.flush()
except Exception:
self.panic(Traceback(show_locals=True))
self.panic()
def display(self, renderable: RenderableType) -> None:
if not self._closed:

View File

@@ -167,6 +167,22 @@ class Region(NamedTuple):
def __bool__(self) -> bool:
return bool(self.width and self.height)
@property
def x_extents(self) -> tuple[int, int]:
return (self.x, self.x + self.width)
@property
def y_extents(self) -> tuple[int, int]:
return (self.y, self.y + self.height)
@property
def x_end(self) -> int:
return self.x + self.width
@property
def y_end(self) -> int:
return self.y + self.height
@property
def area(self) -> int:
"""Get the area within the region."""
@@ -192,6 +208,14 @@ class Region(NamedTuple):
x, y, width, height = self
return x, y, x + width, y + height
@property
def x_range(self) -> range:
return range(self.x, self.x_end)
@property
def y_range(self) -> range:
return range(self.y, self.y_end)
def __add__(self, other: Any) -> Region:
if isinstance(other, tuple):
ox, oy = other

View File

@@ -15,7 +15,7 @@ from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.segment import Segment, SegmentLines
from rich.style import Style
from . import log
from . import log, panic
from ._loop import loop_last
from .layout_map import LayoutMap
from ._lines import crop_lines
@@ -101,6 +101,9 @@ class Layout(ABC):
) -> ReflowResult:
self.reset()
self.width = width
self.height = height
map = self.generate_map(
console,
Dimensions(width, height),
@@ -131,8 +134,6 @@ class Layout(ABC):
hidden_widgets = old_widgets - new_widgets
self._layout_map = map
self.width = width
self.height = height
# Copy renders if the size hasn't changed
new_renders = {
@@ -255,9 +256,10 @@ class Layout(ABC):
if self.map is not None:
for region, order, clip in self.map.values():
region = region.intersection(clip)
if region and (region in screen_region): # type: ignore
for y in range(region.y, region.y + region.height):
cuts_sets[y].update({region.x, region.x + region.width})
if region and (region in screen_region):
region_cuts = region.x_extents
for y in region.y_range:
cuts_sets[y].update(region_cuts)
# Sort the cuts for each line
self._cuts = [sorted(cut_set) for cut_set in cuts_sets]
@@ -265,9 +267,6 @@ class Layout(ABC):
def _get_renders(self, console: Console) -> Iterable[tuple[Region, Region, Lines]]:
_rich_traceback_guard = True
width = self.width
height = self.height
screen_region = Region(0, 0, width, height)
layout_map = self.map
if layout_map:
@@ -292,12 +291,12 @@ class Layout(ABC):
if not widget.is_visual:
continue
region_lines = self.renders.get(widget)
if region_lines is not None:
yield region_lines
continue
lines = render(widget, region.width, region.height)
region, clip, lines = region_lines
else:
lines = render(widget, region.width, region.height)
if region in clip:
self.renders[widget] = (region, clip, lines)
yield region, clip, lines
@@ -308,11 +307,10 @@ class Layout(ABC):
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[delta_y : delta_y + new_region.height]
]
lines = [list(divide(line, splits))[1] for line in lines]
yield region, clip, lines
@classmethod
@@ -331,7 +329,7 @@ class Layout(ABC):
def render(
self,
console: Console,
clip: Region = None,
crop: Region = None,
) -> SegmentLines:
"""Render a layout.
@@ -345,8 +343,10 @@ class Layout(ABC):
width = self.width
height = self.height
screen = Region(0, 0, width, height)
clip = clip or screen
clip_x, clip_y, clip_x2, clip_y2 = clip.corners
crop_region = crop or Region(0, 0, self.width, self.height)
# clip_x, clip_y, clip_x2, clip_y2 = clip.corners
divide = Segment.divide
@@ -363,27 +363,45 @@ class Layout(ABC):
]
# Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders(console)
clip_y, clip_y2 = crop_region.y_extents
for region, clip, lines in chain(
renders, [(screen, screen, background_render)]
):
for y, line in enumerate(lines, region.y):
# clip = clip.intersection(crop_region)
render_region = region.intersection(clip)
for y, line in enumerate(lines, render_region.y):
if clip_y > y > clip_y2:
continue
first_cut = clamp(region.x, clip_x, clip_x2)
last_cut = clamp(region.x + region.width, clip_x, clip_x2)
# first_cut = clamp(render_region.x, clip_x, clip_x2)
# last_cut = clamp(render_region.x + render_region.width, clip_x, clip_x2)
first_cut = render_region.x
last_cut = render_region.x_end
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
if len(final_cuts) > 1:
if final_cuts == [region.x, region.x + region.width]:
cut_segments = [line]
else:
relative_cuts = [cut - region.x for cut in final_cuts]
_, *cut_segments = divide(line, relative_cuts)
for cut, segments in zip(final_cuts, cut_segments):
if chops[y][cut] is None:
chops[y][cut] = segments
# final_cuts = cuts[y]
if final_cuts == render_region.y_extents:
cut_segments = [line]
else:
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts]
_, *cut_segments = divide(line, relative_cuts)
for cut, segments in zip(final_cuts, cut_segments):
if chops[y][cut] is None:
chops[y][cut] = segments
# Assemble the cut renders in to lists of segments
output_lines = list(self._assemble_chops(chops[clip_y:clip_y2]))
output_lines = list(self._assemble_chops(chops))
def width_view(line: list[Segment]) -> list[Segment]:
if line:
div_lines = list(Segment.divide(line, [crop_x, crop_x2]))
line = div_lines[1] if len(div_lines) > 1 else div_lines[0]
return line
if crop is not None:
crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners
output_lines = [width_view(line) for line in output_lines[crop_y:crop_y2]]
return SegmentLines(output_lines, new_lines=True)
def __rich_console__(
@@ -401,9 +419,9 @@ class Layout(ABC):
)
self.renders[widget] = (region, clip, new_lines)
update_lines = self.render(console, region.intersection(clip)).lines
clipped_region = region.intersection(clip)
update = LayoutUpdate(update_lines, clipped_region.x, clipped_region.y)
update_region = region.intersection(clip)
update_lines = self.render(console, update_region).lines
update = LayoutUpdate(update_lines, update_region.x, update_region.y)
return update

View File

@@ -14,7 +14,7 @@ from ..widget import Widget
class VerticalLayout(Layout):
def __init__(self, *, z: int = 0, gutter: tuple[int, int] | None = None):
self.z = z
self.gutter = gutter or (0, 1)
self.gutter = gutter or (1, 1)
self._widgets: list[Widget] = []
super().__init__()
@@ -49,8 +49,9 @@ class VerticalLayout(Layout):
lines = console.render_lines(
renderable, console.options.update_width(render_width)
)
region = Region(x, y, render_width, len(lines))
add_widget(widget, region, viewport)
add_widget(widget, region - scroll, viewport)
else:
add_widget(
widget,

View File

@@ -193,7 +193,7 @@ class MessagePump:
except CancelledError:
raise
except Exception as error:
self.app.panic(Traceback(show_locals=True))
self.app.panic()
break
finally:
if isinstance(message, events.Event) and self._message_queue.empty():

View File

@@ -58,7 +58,7 @@ class View(Widget):
@property
def virtual_size(self) -> Dimensions:
return self.layout.map.size
return self.layout.map.size if self.layout.map else Dimensions(0, 0)
# virtual_width: Reactive[int | None] = Reactive(None)
# virtual_height: Reactive[int | None] = Reactive(None)

View File

@@ -1,17 +1,26 @@
from __future__ import annotations
from rich.console import RenderableType
from ..layouts.vertical import VerticalLayout
from ..view import View
from ..widget import Widget
from ..widgets import Static
class WindowView(View, layout=VerticalLayout):
def __init__(
self, *, gutter: tuple[int, int] = (1, 1), name: str | None = None
self,
widget: RenderableType | Widget,
*,
gutter: tuple[int, int] = (1, 1),
name: str | None = None
) -> None:
self.gutter = gutter
super().__init__(name=name)
layout = VerticalLayout()
layout.add(widget if isinstance(widget, Widget) else Static(widget))
super().__init__(name=name, layout=layout)
async def update(self, widget: Widget) -> None:
async def update(self, widget: Widget | RenderableType) -> None:
self.layout = VerticalLayout(gutter=self.gutter)
self.layout.add(widget)
self.layout.add(widget if isinstance(widget, Widget) else Static(widget))

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from rich import box
from rich.align import Align
from rich.console import RenderableType
@@ -19,6 +21,11 @@ class Placeholder(Widget, can_focus=True):
has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False)
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 __rich_repr__(self) -> rich.repr.RichReprResult:
yield "name", self.name
@@ -34,6 +41,7 @@ class Placeholder(Widget, can_focus=True):
border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.has_focus else box.ROUNDED,
style=self.style,
height=self.height,
)
async def on_focus(self, event: events.Focus) -> None:

View File

@@ -11,6 +11,8 @@ from ..scrollbar import ScrollTo, ScrollBar
from ..geometry import clamp
from ..page import Page
from ..view import View
from ..reactive import Reactive
@@ -23,10 +25,12 @@ class ScrollView(View):
style: StyleType = "",
fluid: bool = True,
) -> None:
from ..views import WindowView
self.fluid = fluid
self.vscroll = ScrollBar(vertical=True)
self.hscroll = ScrollBar(vertical=False)
self.page = Page(renderable or "", style=style)
self.window = WindowView(renderable or "")
layout = GridLayout()
layout.add_column("main")
layout.add_column("vscroll", size=1)
@@ -46,33 +50,33 @@ class ScrollView(View):
target_y: Reactive[float] = Reactive(0, repaint=False)
def validate_x(self, value: float) -> float:
return clamp(value, 0, self.page.virtual_size.width - self.size.width)
return clamp(value, 0, self.window.virtual_size.width - self.size.width)
def validate_target_x(self, value: float) -> float:
return clamp(value, 0, self.page.virtual_size.width - self.size.width)
return clamp(value, 0, self.window.virtual_size.width - self.size.width)
def validate_y(self, value: float) -> float:
return clamp(value, 0, self.page.virtual_size.height - self.size.height)
return clamp(value, 0, self.window.virtual_size.height - self.size.height)
def validate_target_y(self, value: float) -> float:
return clamp(value, 0, self.page.virtual_size.height - self.size.height)
return clamp(value, 0, self.window.virtual_size.height - self.size.height)
async def watch_x(self, new_value: float) -> None:
self.page.scroll_x = round(new_value)
self.window.scroll_x = round(new_value)
self.hscroll.position = round(new_value)
async def watch_y(self, new_value: float) -> None:
self.page.scroll_y = round(new_value)
self.window.scroll_y = round(new_value)
self.vscroll.position = round(new_value)
async def update(self, renderabe: RenderableType) -> None:
self.page.update(renderabe)
await self.window.update(renderabe)
self.require_repaint()
async def on_mount(self, event: events.Mount) -> None:
assert isinstance(self.layout, GridLayout)
self.layout.place(
content=self.page,
content=self.window,
vscroll=self.vscroll,
hscroll=self.hscroll,
)
@@ -132,7 +136,7 @@ class ScrollView(View):
async def key_end(self) -> None:
self.target_x = 0
self.target_y = self.page.virtual_size.height - self.size.height
self.target_y = self.window.virtual_size.height - self.size.height
self.animate("x", self.target_x, duration=1, easing="out_cubic")
self.animate("y", self.target_y, duration=1, easing="out_cubic")
@@ -143,8 +147,9 @@ 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.page.update()
self.window.update()
async def message_scroll_up(self, message: Message) -> None:
self.page_up()
@@ -169,16 +174,16 @@ class ScrollView(View):
async def message_page_update(self, message: Message) -> None:
self.x = self.validate_x(self.x)
self.y = self.validate_y(self.y)
self.vscroll.virtual_size = self.page.virtual_size.height
self.vscroll.virtual_size = self.window.virtual_size.height
self.vscroll.window_size = self.size.height
assert isinstance(self.layout, GridLayout)
if self.layout.show_column(
"vscroll", self.page.virtual_size.height > self.size.height
"vscroll", self.window.virtual_size.height > self.size.height
):
self.require_layout()
if self.layout.show_row(
"hscroll", self.page.virtual_size.width > self.size.width
"hscroll", self.window.virtual_size.width > self.size.width
):
self.require_layout()