mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
renderer refactor
This commit is contained in:
5
examples/README.md
Normal file
5
examples/README.md
Normal 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
20
examples/test_layout.py
Normal 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
19
examples/vertical.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user