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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ class View(Widget):
@property @property
def virtual_size(self) -> Dimensions: 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_width: Reactive[int | None] = Reactive(None)
# virtual_height: Reactive[int | None] = Reactive(None) # virtual_height: Reactive[int | None] = Reactive(None)

View File

@@ -1,17 +1,26 @@
from __future__ import annotations from __future__ import annotations
from rich.console import RenderableType
from ..layouts.vertical import VerticalLayout from ..layouts.vertical import VerticalLayout
from ..view import View from ..view import View
from ..widget import Widget from ..widget import Widget
from ..widgets import Static
class WindowView(View, layout=VerticalLayout): class WindowView(View, layout=VerticalLayout):
def __init__( 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: ) -> None:
self.gutter = gutter 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 = 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 import box
from rich.align import Align from rich.align import Align
from rich.console import RenderableType from rich.console import RenderableType
@@ -19,6 +21,11 @@ class Placeholder(Widget, can_focus=True):
has_focus: Reactive[bool] = Reactive(False) has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False) mouse_over: Reactive[bool] = Reactive(False)
style: Reactive[str] = Reactive("") 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: def __rich_repr__(self) -> rich.repr.RichReprResult:
yield "name", self.name yield "name", self.name
@@ -34,6 +41,7 @@ class Placeholder(Widget, can_focus=True):
border_style="green" if self.mouse_over else "blue", border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.has_focus else box.ROUNDED, box=box.HEAVY if self.has_focus else box.ROUNDED,
style=self.style, style=self.style,
height=self.height,
) )
async def on_focus(self, event: events.Focus) -> None: 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 ..geometry import clamp
from ..page import Page from ..page import Page
from ..view import View from ..view import View
from ..reactive import Reactive from ..reactive import Reactive
@@ -23,10 +25,12 @@ class ScrollView(View):
style: StyleType = "", style: StyleType = "",
fluid: bool = True, fluid: bool = True,
) -> None: ) -> None:
from ..views import WindowView
self.fluid = fluid self.fluid = fluid
self.vscroll = ScrollBar(vertical=True) self.vscroll = ScrollBar(vertical=True)
self.hscroll = ScrollBar(vertical=False) self.hscroll = ScrollBar(vertical=False)
self.page = Page(renderable or "", style=style) self.window = WindowView(renderable or "")
layout = GridLayout() layout = GridLayout()
layout.add_column("main") layout.add_column("main")
layout.add_column("vscroll", size=1) layout.add_column("vscroll", size=1)
@@ -46,33 +50,33 @@ class ScrollView(View):
target_y: Reactive[float] = Reactive(0, repaint=False) target_y: Reactive[float] = Reactive(0, repaint=False)
def validate_x(self, value: float) -> float: 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: 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: 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: 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: 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) self.hscroll.position = round(new_value)
async def watch_y(self, new_value: float) -> None: 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) self.vscroll.position = round(new_value)
async def update(self, renderabe: RenderableType) -> None: async def update(self, renderabe: RenderableType) -> None:
self.page.update(renderabe) await self.window.update(renderabe)
self.require_repaint() self.require_repaint()
async def on_mount(self, event: events.Mount) -> None: async def on_mount(self, event: events.Mount) -> None:
assert isinstance(self.layout, GridLayout) assert isinstance(self.layout, GridLayout)
self.layout.place( self.layout.place(
content=self.page, content=self.window,
vscroll=self.vscroll, vscroll=self.vscroll,
hscroll=self.hscroll, hscroll=self.hscroll,
) )
@@ -132,7 +136,7 @@ class ScrollView(View):
async def key_end(self) -> None: async def key_end(self) -> None:
self.target_x = 0 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("x", self.target_x, duration=1, easing="out_cubic")
self.animate("y", self.target_y, 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") self.animate("y", self.target_y, duration=1, easing="out_cubic")
async def on_resize(self, event: events.Resize) -> None: async def on_resize(self, event: events.Resize) -> None:
return
if self.fluid: if self.fluid:
self.page.update() self.window.update()
async def message_scroll_up(self, message: Message) -> None: async def message_scroll_up(self, message: Message) -> None:
self.page_up() self.page_up()
@@ -169,16 +174,16 @@ class ScrollView(View):
async def message_page_update(self, message: Message) -> None: async def message_page_update(self, message: Message) -> None:
self.x = self.validate_x(self.x) self.x = self.validate_x(self.x)
self.y = self.validate_y(self.y) 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 self.vscroll.window_size = self.size.height
assert isinstance(self.layout, GridLayout) assert isinstance(self.layout, GridLayout)
if self.layout.show_column( 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() self.require_layout()
if self.layout.show_row( 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() self.require_layout()