refactor of compositor

This commit is contained in:
Will McGugan
2022-03-02 15:39:26 +00:00
parent 7c64c7813c
commit 237c556673
50 changed files with 429 additions and 342 deletions

View File

@@ -17,7 +17,7 @@ class Clock(Widget):
class ClockApp(App):
async def on_mount(self):
await self.view.dock(Clock())
await self.screen.dock(Clock())
ClockApp.run()

View File

@@ -26,7 +26,7 @@ class HoverApp(App):
"""Build layout here."""
hovers = (Hover() for _ in range(10))
await self.view.dock(*hovers, edge="top")
await self.screen.dock(*hovers, edge="top")
HoverApp.run(log="textual.log")

View File

@@ -8,8 +8,8 @@ class SimpleApp(App):
async def on_mount(self) -> None:
"""Build layout here."""
await self.view.dock(Placeholder(), edge="left", size=40)
await self.view.dock(Placeholder(), Placeholder(), edge="top")
await self.screen.dock(Placeholder(), edge="left", size=40)
await self.screen.dock(Placeholder(), Placeholder(), edge="top")
SimpleApp.run(log="textual.log")

View File

@@ -26,9 +26,9 @@ class SmoothApp(App):
footer = Footer()
self.bar = Placeholder(name="left")
await self.view.dock(footer, edge="bottom")
await self.view.dock(Placeholder(), Placeholder(), edge="top")
await self.view.dock(self.bar, edge="left", size=40, z=1)
await self.screen.dock(footer, edge="bottom")
await self.screen.dock(Placeholder(), Placeholder(), edge="top")
await self.screen.dock(self.bar, edge="left", size=40, z=1)
self.bar.layout_offset_x = -40

View File

@@ -2,7 +2,7 @@
$primary: #20639b;
App > View {
App > Screen {
layout: dock;
docks: side=left/1;
text: on $primary;
@@ -13,6 +13,7 @@ App > View {
dock: side;
width: 30;
offset-x: -100%;
transition: offset 500ms in_out_cubic;
border-right: outer #09312e;
}

View File

@@ -15,7 +15,7 @@ class MyApp(App):
self.body = body = ScrollView(auto_width=True)
await self.view.dock(body)
await self.screen.dock(body)
async def add_content():
table = Table(title="Demo")

View File

@@ -209,7 +209,7 @@ class CalculatorApp(App):
async def on_mount(self) -> None:
"""Mount the calculator widget."""
await self.view.dock(Calculator())
await self.screen.dock(Calculator())
CalculatorApp.run(title="Calculator Test", log="textual.log")

View File

@@ -36,14 +36,14 @@ class MyApp(App):
self.directory = DirectoryTree(self.path, "Code")
# Dock our widgets
await self.view.dock(Header(), edge="top")
await self.view.dock(Footer(), edge="bottom")
await self.screen.dock(Header(), edge="top")
await self.screen.dock(Footer(), edge="bottom")
# Note the directory is also in a scroll view
await self.view.dock(
await self.screen.dock(
ScrollView(self.directory), edge="left", size=48, name="sidebar"
)
await self.view.dock(self.body, edge="top")
await self.screen.dock(self.body, edge="top")
async def handle_file_click(self, message: FileClick) -> None:
"""A message sent by the directory tree when a file is clicked."""

View File

@@ -32,8 +32,8 @@ class EasingApp(App):
await tree.add(tree.root.id, easing_key, {"easing": easing_key})
await tree.root.expand()
await self.view.dock(ScrollView(tree), edge="left", size=32)
await self.view.dock(self.easing_view)
await self.screen.dock(ScrollView(tree), edge="left", size=32)
await self.screen.dock(self.easing_view)
await self.easing_view.dock(self.placeholder, edge="left", size=32)
async def handle_tree_click(self, message: TreeClick[dict]) -> None:

View File

@@ -6,7 +6,7 @@ class GridTest(App):
async def on_mount(self) -> None:
"""Make a simple grid arrangement."""
grid = await self.view.dock_grid(edge="left", name="left")
grid = await self.screen.dock_grid(edge="left", name="left")
grid.add_column(fraction=1, name="left", min_size=20)
grid.add_column(size=30, name="center")

View File

@@ -7,7 +7,7 @@ class GridTest(App):
async def on_mount(self, event: events.Mount) -> None:
"""Create a grid with auto-arranging cells."""
grid = await self.view.dock_grid()
grid = await self.screen.dock_grid()
grid.add_column("col", fraction=1, max_size=20)
grid.add_row("row", fraction=1, max_size=10)

View File

@@ -33,7 +33,7 @@ class MyApp(App):
"""Create and dock the widgets."""
body = ScrollView()
await self.view.mount(
await self.screen.mount(
Header(),
Footer(),
body=body,

View File

@@ -19,6 +19,7 @@ class BasicApp(App):
self.bind("a", "toggle_class('#header', '-visible')")
self.bind("c", "toggle_class('#content', '-content-visible')")
self.bind("d", "toggle_class('#footer', 'dim')")
self.bind("x", "dump")
def on_mount(self):
"""Build layout here."""
@@ -29,5 +30,8 @@ class BasicApp(App):
sidebar=Widget(),
)
def action_dump(self):
self.panic(self.tree)
BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log")

View File

@@ -1,4 +1,24 @@
#uber {
border: heavy green;
margin: 5;
/* border: heavy green; */
margin: 2;
layout: dock;
docks: panels=top;
}
#child1 {
dock: panels;
}
#child2 {
dock: panels;
}
#child3 {
dock: panels;
}
#uber2 {
margin: 3;
layout: dock;
docks: _default=left;
}

View File

@@ -1,3 +1,4 @@
from tkinter import Place
from textual.app import App
from textual import events
from textual.widgets import Placeholder
@@ -9,7 +10,22 @@ class BasicApp(App):
def on_mount(self):
"""Build layout here."""
self.mount(uber=Placeholder())
uber2 = Widget()
uber2.add_children(
Placeholder(id="uber2-child1"),
Placeholder(id="uber2-child2"),
)
self.mount(
uber=Widget(
Placeholder(id="child1"),
Placeholder(id="child2"),
Placeholder(id="child3"),
),
uber2=uber2,
)
# self.panic(self.tree)
async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event)

View File

@@ -110,6 +110,8 @@ class BoundAnimator:
class Animator:
"""An object to manage updates to a given attributed over a period of time."""
def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
self._animations: dict[tuple[object, str], Animation] = {}
self.target = target
@@ -225,4 +227,6 @@ class Animator:
def on_animation_frame(self) -> None:
# TODO: We should be able to do animation without refreshing everything
self.target.view.refresh(True, True)
self.target.screen.refresh(layout=True)
# self.target.screen.app.refresh()

View File

@@ -11,10 +11,9 @@ from rich.control import Control
from rich.segment import Segment, SegmentLines
from rich.style import Style
from . import log
from . import errors, log
from .geometry import Region, Offset, Size
from .layout import WidgetPlacement
from ._loop import loop_last
from ._types import Lines
from .widget import Widget
@@ -26,13 +25,10 @@ else: # pragma: no cover
if TYPE_CHECKING:
from .screen import Screen
from .widget import Widget
class NoWidget(Exception):
"""Raised when there is no widget at the requested coordinate."""
class ReflowResult(NamedTuple):
"""The result of a reflow operation. Describes the chances to widgets."""
@@ -137,8 +133,8 @@ class Compositor:
self.width = size.width
self.height = size.height
map, virtual_size = self._arrange_root(parent)
log(map)
map, virtual_size, widgets = self._arrange_root(parent)
self._require_update = False
old_widgets = set(self.map.keys())
@@ -165,12 +161,13 @@ class Compositor:
}
parent.virtual_size = virtual_size
self.widgets.clear()
self.widgets.update(widgets)
return ReflowResult(
hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets
)
def _arrange_root(self, root: Widget) -> tuple[RenderRegionMap, Size]:
def _arrange_root(self, root: Widget) -> tuple[RenderRegionMap, Size, set[Widget]]:
"""Arrange a widgets children based on its layout attribute.
Args:
@@ -192,7 +189,7 @@ class Compositor:
order: tuple[int, ...],
clip: Region,
):
widgets: set[Widget] = set()
widgets.add(widget)
styles_offset = widget.styles.offset
total_region = region
layout_offset = (
@@ -200,7 +197,6 @@ class Compositor:
if styles_offset
else ORIGIN
)
map[widget] = RenderRegion(region + layout_offset, order, clip)
if widget.layout is not None:
@@ -227,12 +223,10 @@ class Compositor:
return total_region.size
virtual_size = add_widget(root, size.region, (), size.region)
self.widgets.clear()
self.widgets.update(widgets)
return map, virtual_size
return map, virtual_size, widgets
async def mount_all(self, view: "View") -> None:
view.mount(*self.widgets)
async def mount_all(self, screen: Screen) -> None:
screen.app.mount(*self.widgets)
def __iter__(self) -> Iterator[tuple[Widget, Region, Region]]:
layers = sorted(self.map.items(), key=lambda item: item[1].order, reverse=True)
@@ -244,14 +238,14 @@ class Compositor:
try:
return self.map[widget].region.origin
except KeyError:
raise NoWidget("Widget is not in layout")
raise errors.NoWidget("Widget is not in layout")
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under the given point or None."""
for widget, cropped_region, region in self:
if widget.is_visual and cropped_region.contains(x, y):
if cropped_region.contains(x, y):
return widget, region
raise NoWidget(f"No widget under screen coordinate ({x}, {y})")
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")
def get_style_at(self, x: int, y: int) -> Style:
"""Get the Style at the given cell or Style.null()
@@ -265,13 +259,15 @@ class Compositor:
"""
try:
widget, region = self.get_widget_at(x, y)
except NoWidget:
except errors.NoWidget:
return Style.null()
if widget not in self.regions:
return Style.null()
lines = widget._get_lines()
x -= region.x
y -= region.y
if y > len(lines):
return Style.null()
line = lines[y]
end = 0
for segment in line:
@@ -296,7 +292,7 @@ class Compositor:
try:
region, *_ = self.map[widget]
except KeyError:
raise NoWidget("Widget is not in layout")
raise errors.NoWidget("Widget is not in layout")
else:
return region
@@ -344,7 +340,7 @@ class Compositor:
for widget, region, _order, clip in widget_regions:
if not (widget.is_visual and widget.visible):
if not (widget.visible and widget.is_visual):
continue
lines = widget._get_lines()
@@ -465,5 +461,4 @@ class Compositor:
update_region = region.intersection(clip)
update_lines = self.render(console, crop=update_region).lines
update = LayoutUpdate(update_lines, update_region)
log(update)
return update

View File

@@ -22,6 +22,13 @@ class NodeList:
self._node_refs: list[ref[DOMNode]] = []
self.__nodes: list[DOMNode] | None = []
def __bool__(self) -> bool:
self._prune()
return bool(self._node_refs)
def __length_hint__(self) -> int:
return len(self._node_refs)
def __rich_repr__(self) -> rich.repr.Result:
yield self._widgets

View File

@@ -35,7 +35,7 @@ class Timer:
*,
name: str | None = None,
callback: TimerCallback | None = None,
repeat: int = None,
repeat: int | None = None,
skip: bool = False,
pause: bool = False,
) -> None:

View File

@@ -9,11 +9,9 @@ else:
if TYPE_CHECKING:
from .events import Event
from .message import Message
Callback = Callable[[], None]
# IntervalID = int
class MessageTarget(Protocol):

View File

@@ -12,7 +12,7 @@ import rich.repr
from rich.console import Console, RenderableType
from rich.control import Control
from rich.measure import Measurement
from rich.screen import Screen
from rich.screen import Screen as ScreenRenderable
from rich.traceback import Traceback
from . import actions
@@ -34,10 +34,9 @@ from .layouts.dock import Dock
from .message_pump import MessagePump
from .reactive import Reactive
from .renderables.gradient import VerticalGradient
from .view import View
from .screen import Screen
from .widget import Widget
from .css.query import NoMatchingNodesError
if TYPE_CHECKING:
from .css.query import DOMQuery
@@ -50,8 +49,6 @@ warnings.simplefilter("always", ResourceWarning)
LayoutDefinition = "dict[str, Any]"
ViewType = TypeVar("ViewType", bound=View)
class AppError(Exception):
pass
@@ -86,12 +83,12 @@ class App(DOMNode):
driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None.
title (str, optional): Title of the application. Defaults to "Textual Application".
"""
self.console = Console()
self.error_console = Console(stderr=True)
self.console = Console(markup=False)
self.error_console = Console(markup=False, stderr=True)
self._screen = screen
self.driver_class = driver_class or self.get_driver_class()
self._title = title
self._view_stack: list[View] = []
self._screen_stack: list[Screen] = []
self.focused: Widget | None = None
self.mouse_over: Widget | None = None
@@ -100,7 +97,7 @@ class App(DOMNode):
self._exit_renderables: list[RenderableType] = []
self._docks: list[Dock] = []
self._action_targets = {"app", "view"}
self._action_targets = {"app", "screen"}
self._animator = Animator(self)
self.animate = self._animator.bind(self)
self.mouse_position = Offset(0, 0)
@@ -158,8 +155,8 @@ class App(DOMNode):
return self._animator
@property
def view(self) -> View:
return self._view_stack[-1]
def screen(self) -> Screen:
return self._screen_stack[-1]
@property
def css_type(self) -> str:
@@ -262,10 +259,10 @@ class App(DOMNode):
self.reset_styles()
self.stylesheet = stylesheet
self.stylesheet.update(self)
self.view.refresh(layout=True)
self.screen.refresh(layout=True)
def query(self, selector: str | None = None) -> DOMQuery:
"""Get a DOM query in the current view.
"""Get a DOM query in the current screen.
Args:
selector (str, optional): A CSS selector or `None` for all nodes. Defaults to None.
@@ -275,10 +272,10 @@ class App(DOMNode):
"""
from .css.query import DOMQuery
return DOMQuery(self.view, selector)
return DOMQuery(self.screen, selector)
def get_child(self, id: str) -> DOMNode:
"""Shorthand for self.view.get_child(id: str)
"""Shorthand for self.screen.get_child(id: str)
Returns the first child (immediate descendent) of this DOMNode
with the given ID.
@@ -288,7 +285,7 @@ class App(DOMNode):
Returns:
DOMNode: The first child of this node with the specified ID.
"""
return self.view.get_child(id)
return self.screen.get_child(id)
def render_background(self) -> RenderableType:
gradient = VerticalGradient("red", "blue")
@@ -303,12 +300,12 @@ class App(DOMNode):
self.post_message_no_wait(messages.StylesUpdated(self))
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.register(self.view, *anon_widgets, **widgets)
self.view.refresh()
self.register(self.screen, *anon_widgets, **widgets)
self.screen.refresh()
async def push_view(self, view: ViewType) -> ViewType:
self._view_stack.append(view)
return view
async def push_screen(self, screen: Screen) -> Screen:
self._screen_stack.append(screen)
return screen
async def set_focus(self, widget: Widget | None) -> None:
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
@@ -450,7 +447,7 @@ class App(DOMNode):
def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
if child not in self.registry:
parent.children._append(child)
parent.node_list._append(child)
self.registry.add(child)
child.set_parent(parent)
child.start_messages()
@@ -473,6 +470,11 @@ class App(DOMNode):
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
apply_stylesheet = self.stylesheet.apply
# Register children
for _widget_id, widget in name_widgets:
if widget.node_list:
self.register(widget, *widget.children)
for widget_id, widget in name_widgets:
if widget not in self.registry:
if widget_id is not None:
@@ -500,7 +502,9 @@ class App(DOMNode):
driver.disable_input()
await self.close_messages()
def refresh(self) -> None:
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
if not self._running:
return
sync_available = (
os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" and not WINDOWS
)
@@ -509,9 +513,7 @@ class App(DOMNode):
try:
if sync_available:
console.file.write("\x1bP=1s\x1b\\")
console.print(
Screen(Control.home(), self.view.render_styled(), Control.home())
)
console.print(ScreenRenderable(Control.home(), self.screen.render()))
if sync_available:
console.file.write("\x1bP=2s\x1b\\")
console.file.flush()
@@ -519,6 +521,8 @@ class App(DOMNode):
self.panic()
def display(self, renderable: RenderableType) -> None:
if not self._running:
return
if not self._closed:
console = self.console
try:
@@ -551,7 +555,7 @@ class App(DOMNode):
Returns:
tuple[Widget, Region]: The widget and the widget's screen region.
"""
return self.view.get_widget_at(x, y)
return self.screen.get_widget_at(x, y)
def bell(self) -> None:
"""Play the console 'bell'."""
@@ -578,9 +582,9 @@ class App(DOMNode):
# Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Mount):
view = View()
self.register(self, view)
await self.push_view(view)
screen = Screen()
self.register(self, screen)
await self.push_screen(screen)
await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
@@ -596,7 +600,7 @@ class App(DOMNode):
await super().on_event(event)
else:
# Forward the event to the view
await self.view.forward_event(event)
await self.screen.forward_event(event)
else:
await super().on_event(event)
@@ -636,6 +640,16 @@ class App(DOMNode):
async def broker_event(
self, event_name: str, event: events.Event, default_namespace: object | None
) -> bool:
"""Allow the app an opportunity to dispatch events to action system.
Args:
event_name (str): _description_
event (events.Event): An event object.
default_namespace (object | None): _description_
Returns:
bool: _description_
"""
event.stop()
try:
style = getattr(event, "style")
@@ -661,7 +675,7 @@ class App(DOMNode):
async def handle_layout(self, message: messages.Layout) -> None:
message.stop()
# await self.view.refresh_layout()
# await self.screen.refresh_layout()
self.app.refresh()
async def on_key(self, event: events.Key) -> None:
@@ -672,7 +686,7 @@ class App(DOMNode):
await self.close_messages()
async def on_resize(self, event: events.Resize) -> None:
await self.view.post_message(event)
await self.screen.post_message(event)
async def action_press(self, key: str) -> None:
await self.press(key)
@@ -687,13 +701,13 @@ class App(DOMNode):
self.bell()
async def action_add_class_(self, selector: str, class_name: str) -> None:
self.view.query(selector).add_class(class_name)
self.screen.query(selector).add_class(class_name)
async def action_remove_class_(self, selector: str, class_name: str) -> None:
self.view.query(selector).remove_class(class_name)
self.screen.query(selector).remove_class(class_name)
async def action_toggle_class(self, selector: str, class_name: str) -> None:
self.view.query(selector).toggle_class(class_name)
self.screen.query(selector).toggle_class(class_name)
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.stylesheet.update(self)

View File

@@ -15,6 +15,7 @@ import rich.repr
from rich.color import Color
from rich.style import Style
from .. import log
from ._error_tools import friendly_list
from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError
@@ -537,7 +538,6 @@ class OffsetProperty:
ScalarParseError: If any of the string values supplied in the 2-tuple cannot
be parsed into a Scalar. For example, if you specify an non-existent unit.
"""
if offset is None:
if obj.clear_rule(self.name):
obj.refresh(layout=True)
@@ -557,6 +557,7 @@ class OffsetProperty:
else Scalar(float(y), Unit.CELLS, Unit.HEIGHT)
)
_offset = ScalarOffset(scalar_x, scalar_y)
if obj.set_rule(self.name, _offset):
obj.refresh(layout=True)

View File

@@ -135,7 +135,7 @@ class DOMQuery:
node.set_styles(css, **styles)
return self
def refresh(self, repaint: bool = True, layout: bool = False) -> DOMQuery:
def refresh(self, *, repaint: bool = True, layout: bool = False) -> DOMQuery:
"""Refresh matched nodes.
Args:

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from .. import events
from .. import events, log
from ..geometry import Offset
from .._animator import Animation
from .scalar import ScalarOffset

View File

@@ -11,6 +11,7 @@ import rich.repr
from rich.color import Color
from rich.style import Style
from .. import log
from .._animator import Animation, EasingFunction
from ..geometry import Spacing
from ._style_properties import (
@@ -361,6 +362,7 @@ class Styles(StylesBase):
return self._rules.get(rule, default)
def refresh(self, *, layout: bool = False) -> None:
return
self._repaint_required = True
self._layout_required = self._layout_required or layout

View File

@@ -6,6 +6,7 @@ import rich.repr
from rich.highlighter import ReprHighlighter
from rich.pretty import Pretty
from rich.style import Style
from rich.text import Text
from rich.tree import Tree
from ._node_list import NodeList
@@ -19,7 +20,7 @@ from .message_pump import MessagePump
if TYPE_CHECKING:
from .css.query import DOMQuery
from .view import View
from .screen import Screen
class NoParent(Exception):
@@ -37,11 +38,16 @@ class DOMNode(MessagePump):
DEFAULT_STYLES = ""
INLINE_STYLES = ""
def __init__(self, name: str | None = None, id: str | None = None) -> None:
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: Iterable[str] | None = None,
) -> None:
self._name = name
self._id = id
self._classes: set[str] = set()
self.children = NodeList()
self._classes: set[str] = set(classes) if classes else set()
self.node_list = NodeList()
self._css_styles: Styles = Styles(self)
self._inline_styles: Styles = Styles.parse(
self.INLINE_STYLES, repr(self), node=self
@@ -73,18 +79,23 @@ class DOMNode(MessagePump):
return self._parent
@property
def view(self) -> "View":
"""Get the current view."""
def screen(self) -> "Screen":
"""Get the current screen."""
# Get the node by looking up a chain of parents
# Note that self.view may not be the same as self.app.view
from .view import View
# Note that self.screen may not be the same as self.app.screen
from .screen import Screen
node = self
while node and not isinstance(node, View):
while node and not isinstance(node, Screen):
node = node._parent
assert isinstance(node, View)
assert isinstance(node, Screen)
return node
@property
def is_visual(self) -> bool:
"""Check if the widget is visual (i.e. draws something on Screen)."""
return True
@property
def id(self) -> str | None:
"""The ID of this node, or None if the node has no ID.
@@ -116,6 +127,22 @@ class DOMNode(MessagePump):
def name(self) -> str | None:
return self._name
@property
def css_identifier(self) -> str:
tokens = [self.__class__.__name__]
if self.id is not None:
tokens.append(f"#{self.id}")
return "".join(tokens)
@property
def css_identifier_styled(self) -> Text:
tokens = Text(self.__class__.__name__)
if self.id is not None:
tokens.append(f"#{self.id}", style="bold")
if self.name:
tokens.append(f"[name={self.name}]", style="underline")
return tokens
@property
def classes(self) -> frozenset[str]:
return frozenset(self._classes)
@@ -237,12 +264,25 @@ class DOMNode(MessagePump):
Returns:
Tree: A Rich object which may be printed.
"""
from rich.columns import Columns
from rich.panel import Panel
highlighter = ReprHighlighter()
tree = Tree(highlighter(repr(self)))
def add_children(tree, node):
for child in node.children:
branch = tree.add(Pretty(child))
for child in node.node_list:
branch = tree.add(
Columns(
[
Pretty(child),
Text(
f"{child.size.width} X {child.size.height}", style="dim"
),
Panel(Text(child.styles.css), border_style="dim"),
]
)
)
if tree.children:
add_children(branch, child)
@@ -276,12 +316,20 @@ class DOMNode(MessagePump):
Args:
node (DOMNode): A DOM node.
"""
self.children._append(node)
self.node_list._append(node)
node.set_parent(self)
def add_children(self, *nodes: DOMNode, **named_nodes: DOMNode) -> None:
_append = self.node_list._append
for node in nodes:
_append(node)
for node_id, node in named_nodes.items():
_append(node)
node.id = node_id
def walk_children(self, with_self: bool = True) -> Iterable[DOMNode]:
stack: list[Iterator[DOMNode]] = [iter(self.children)]
stack: list[Iterator[DOMNode]] = [iter(self.node_list)]
pop = stack.pop
push = stack.append
@@ -294,8 +342,8 @@ class DOMNode(MessagePump):
pop()
else:
yield node
if node.children:
push(iter(node.children))
if node.node_list:
push(iter(node.node_list))
def get_child(self, id: str) -> DOMNode:
"""Return the first child (immediate descendent) of this node with the given ID.
@@ -306,7 +354,7 @@ class DOMNode(MessagePump):
Returns:
DOMNode: The first child of this node with the ID.
"""
for child in self.children:
for child in self.node_list:
if child.id == id:
return child
raise NoMatchingNodesError(f"No child found with id={id!r}")
@@ -379,5 +427,5 @@ class DOMNode(MessagePump):
has_pseudo_classes = self.pseudo_classes.issuperset(class_names)
return has_pseudo_classes
def refresh(self, repaint: bool = True, layout: bool = False) -> None:
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
raise NotImplementedError()

View File

@@ -1,2 +1,9 @@
class MissingWidget(Exception):
from __future__ import annotations
class TextualError(Exception):
pass
class NoWidget(TextualError):
pass

View File

@@ -5,6 +5,7 @@ from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar
import rich.repr
from rich.style import Style
from . import log
from .geometry import Offset, Size
from .message import Message
from ._types import MessageTarget
@@ -389,8 +390,3 @@ class Focus(Event, bubble=False):
class Blur(Event, bubble=False):
pass
# class Update(Event, bubble=False):
# def can_replace(self, event: Message) -> bool:
# return isinstance(event, Update) and event.sender == self.sender

View File

@@ -1,15 +1,16 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import ClassVar, Generator, Iterable, NamedTuple, Sequence, TYPE_CHECKING
from typing import ClassVar, Iterable, NamedTuple, TYPE_CHECKING
from .geometry import Region, Offset, Size
if TYPE_CHECKING:
from .dom import DOMNode
from .widget import Widget
from .view import View
from .screen import Screen
class WidgetPlacement(NamedTuple):
@@ -47,8 +48,8 @@ class Layout(ABC):
@abstractmethod
def arrange(
self, parent: View, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
self, parent: Screen, size: Size, scroll: Offset
) -> tuple[Iterable[WidgetPlacement], set[Widget]]:
"""Generate a layout map that defines where on the screen the widgets will be drawn.
Args:

View File

@@ -1,86 +0,0 @@
"""
Planned for deprecation
"""
from __future__ import annotations
from typing import ItemsView, KeysView, ValuesView, NamedTuple
from . import log
from .geometry import Offset, Region, Size
from operator import attrgetter
from .widget import Widget
class RenderRegion(NamedTuple):
"""Defines the absolute location of a Widget."""
region: Region
order: tuple[int, ...]
clip: Region
class LayoutMap:
"""A container that maps widgets on to their absolute location."""
def __init__(self, size: Size) -> None:
self.size = size
self.widgets: dict[Widget, RenderRegion] = {}
def __getitem__(self, widget: Widget) -> RenderRegion:
return self.widgets[widget]
def items(self) -> ItemsView[Widget, RenderRegion]:
return self.widgets.items()
def keys(self) -> KeysView[Widget]:
return self.widgets.keys()
def values(self) -> ValuesView[RenderRegion]:
return self.widgets.values()
def clear(self) -> None:
self.widgets.clear()
def add_widget(
self,
widget: Widget,
region: Region,
order: tuple[int, ...],
clip: Region,
) -> None:
from .view import View
if widget in self.widgets:
return
layout_offset = Offset(0, 0)
if any(widget.styles.offset):
layout_offset = widget.styles.offset.resolve(region.size, clip.size)
self.widgets[widget] = RenderRegion(region + layout_offset, order, clip)
# TODO: replace with widget.layout
if isinstance(widget, View):
view: View = widget
scroll = view.scroll
total_region = region.size.region
sub_clip = clip.intersection(region)
arrangement = sorted(
view.get_arrangement(region.size, scroll), key=attrgetter("order")
)
for sub_region, sub_widget, z in arrangement:
total_region = total_region.union(sub_region)
if sub_widget is not None:
self.add_widget(
sub_widget,
sub_region + region.origin - scroll,
sub_widget.z + (z,),
sub_clip,
)
view.virtual_size = total_region.size

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, NamedTuple, Sequence
from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
from .._layout_resolve import layout_resolve
from ..css.types import Edge
@@ -17,7 +17,7 @@ else:
from typing_extensions import Literal
if TYPE_CHECKING:
from ..view import View
from ..screen import Screen
DockEdge = Literal["top", "right", "bottom", "left"]
@@ -61,7 +61,7 @@ class DockLayout(Layout):
def arrange(
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
) -> tuple[Iterable[WidgetPlacement], set[Widget]]:
width, height = size
layout_region = Region(0, 0, width, height)
@@ -69,7 +69,7 @@ class DockLayout(Layout):
docks = self.get_docks(parent)
def make_dock_options(widget, edge: Edge) -> DockOptions:
def make_dock_options(widget: Widget, edge: Edge) -> DockOptions:
styles = widget.styles
has_rule = styles.has_rule

View File

@@ -14,7 +14,7 @@ from ..layout import Layout, WidgetPlacement
if TYPE_CHECKING:
from ..widget import Widget
from ..view import View
from ..screen import Screen
if sys.version_info >= (3, 8):
@@ -266,7 +266,7 @@ class GridLayout(Layout):
return self.widgets.keys()
def arrange(
self, view: View, size: Size, scroll: Offset
self, view: Screen, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
"""Generate a map that associates widgets with their location on screen.

View File

@@ -6,7 +6,7 @@ from textual._loop import loop_last
from textual.css.styles import Styles
from textual.geometry import Size, Offset, Region
from textual.layout import Layout, WidgetPlacement
from textual.view import View
from textual.screen import Screen
from textual.widget import Widget
@@ -15,26 +15,29 @@ class HorizontalLayout(Layout):
fill the space of their parent container, all widgets used in a horizontal layout should have a specified.
"""
def get_widgets(self, view: View) -> Iterable[Widget]:
return view.children
def arrange(
self, view: View, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
placements: list[WidgetPlacement] = []
add_placement = placements.append
parent_width, parent_height = size
x, y = 0, 0
for last, widget in loop_last(view.children):
styles: Styles = widget.styles
x = y = 0
app = parent.app
for widget in parent.children:
styles = widget.styles
if styles.height:
render_height = int(
styles.height.resolve_dimension(size, view.app.size)
)
render_height = int(styles.height.resolve_dimension(size, app.size))
else:
render_height = parent_height
if styles.width:
render_width = int(styles.width.resolve_dimension(size, view.app.size))
render_width = int(styles.width.resolve_dimension(size, app.size))
else:
render_width = parent_width
region = Region(x, y, render_width, render_height)
yield WidgetPlacement(region, widget, order=0)
add_placement(WidgetPlacement(region, widget, order=0))
x += render_width
return placements, set(parent.children)

View File

@@ -8,34 +8,35 @@ from ..layout import Layout, WidgetPlacement
if TYPE_CHECKING:
from ..widget import Widget
from ..view import View
from ..screen import Screen
class VerticalLayout(Layout):
def get_widgets(self, view: View) -> Iterable[Widget]:
return view.children
def arrange(
self, view: View, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
parent_width, parent_height = size
x, y = 0, 0
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
for widget in view.children:
styles: Styles = widget.styles
placements: list[WidgetPlacement] = []
add_placement = placements.append
parent_width, parent_height = size
x = y = 0
app = parent.app
for widget in parent.children:
styles = widget.styles
if styles.height:
render_height = int(
styles.height.resolve_dimension(size, view.app.size)
)
render_height = int(styles.height.resolve_dimension(size, app.size))
else:
render_height = size.height
if styles.width:
render_width = int(styles.width.resolve_dimension(size, view.app.size))
render_width = int(styles.width.resolve_dimension(size, app.size))
else:
render_width = parent_width
region = Region(x, y, render_width, render_height)
yield WidgetPlacement(region, widget, 0)
add_placement(WidgetPlacement(region, widget, 0))
y += render_height
return placements, set(parent.children)

View File

@@ -24,8 +24,11 @@ class Message:
]
sender: MessageTarget
bubble: ClassVar[bool] = True
verbosity: ClassVar[int] = 1
bubble: ClassVar[bool] = True # Message will bubble to parent
verbosity: ClassVar[int] = 1 # Verbosity (higher the more verbose)
system: ClassVar[
bool
] = False # Message is system related and may not be handled by client code
def __init__(self, sender: MessageTarget) -> None:
"""
@@ -45,10 +48,13 @@ class Message:
def __rich_repr__(self) -> rich.repr.Result:
yield self.sender
def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None:
def __init_subclass__(
cls, bubble: bool = True, verbosity: int = 1, system: bool = False
) -> None:
super().__init_subclass__()
cls.bubble = bubble
cls.verbosity = verbosity
cls.system = system
@property
def is_forwarded(self) -> bool:

View File

@@ -13,10 +13,11 @@ from ._timer import Timer, TimerCallback
from ._callback import invoke
from ._context import active_app
from .message import Message
from . import messages
if TYPE_CHECKING:
from .app import App
from .view import View
from .screen import Screen
class NoParent(Exception):
@@ -215,7 +216,7 @@ class MessagePump:
self.app.panic()
break
finally:
if isinstance(message, events.Event) and self._message_queue.empty():
if self._message_queue.empty():
if not self._closed:
event = events.Idle(self)
for method in self._get_dispatch_methods("on_idle", event):
@@ -225,6 +226,8 @@ class MessagePump:
async def dispatch_message(self, message: Message) -> bool | None:
_rich_traceback_guard = True
if message.system:
return False
if isinstance(message, events.Event):
if not isinstance(message, events.Null):
await self.on_event(message)
@@ -271,13 +274,10 @@ class MessagePump:
if not self._parent._closed and not self._parent._closing:
await self._parent.post_message(message)
def post_message_no_wait(self, message: Message) -> bool:
if self._closing or self._closed:
return False
if not self.check_message_enabled(message):
return True
self._message_queue.put_nowait(message)
return True
def check_idle(self):
"""Prompt the message pump to call idle if the queue is empty."""
if self._message_queue.empty():
self.post_message_no_wait(messages.Prompt(sender=self))
async def post_message(self, message: Message) -> bool:
if self._closing or self._closed:
@@ -287,16 +287,24 @@ class MessagePump:
await self._message_queue.put(message)
return True
def post_message_from_child_no_wait(self, message: Message) -> bool:
def post_message_no_wait(self, message: Message) -> bool:
if self._closing or self._closed:
return False
return self.post_message_no_wait(message)
if not self.check_message_enabled(message):
return True
self._message_queue.put_nowait(message)
return True
async def post_message_from_child(self, message: Message) -> bool:
if self._closing or self._closed:
return False
return await self.post_message(message)
def post_message_from_child_no_wait(self, message: Message) -> bool:
if self._closing or self._closed:
return False
return self.post_message_no_wait(message)
async def on_callback(self, event: events.Callback) -> None:
await event.callback()

View File

@@ -11,6 +11,12 @@ if TYPE_CHECKING:
from .widget import Widget
@rich.repr.auto
class Refresh(Message):
def can_replace(self, message: Message) -> bool:
return isinstance(message, Refresh)
@rich.repr.auto
class Update(Message, verbosity=3):
def __init__(self, sender: MessagePump, widget: Widget):
@@ -50,3 +56,10 @@ class StylesUpdated(Message):
def can_replace(self, message: Message) -> bool:
return isinstance(message, StylesUpdated)
class Prompt(Message, system=True):
"""Used to 'wake up' an event loop."""
def can_replace(self, message: Message) -> bool:
return isinstance(message, StylesUpdated)

View File

@@ -5,7 +5,7 @@ import rich.repr
from rich.style import Style
from . import events, messages
from . import events, messages, errors
from .geometry import Offset, Region
from ._compositor import Compositor
@@ -14,7 +14,7 @@ from .renderables.gradient import VerticalGradient
@rich.repr.auto
class View(Widget):
class Screen(Widget):
"""A widget for the root of the app."""
DEFAULT_STYLES = """
@@ -33,7 +33,7 @@ class View(Widget):
return False
def render(self) -> RenderableType:
return VerticalGradient("#11998e", "#38ef7d")
return self._compositor
def get_offset(self, widget: Widget) -> Offset:
"""Get the absolute offset of a given Widget.
@@ -58,7 +58,7 @@ class View(Widget):
"""
return self._compositor.get_widget_at(x, y)
def get_style_add(self, x: int, y: int) -> Style:
def get_style_at(self, x: int, y: int) -> Style:
"""Get the style under a given coordinate.
Args:
@@ -96,8 +96,7 @@ class View(Widget):
for widget in shown:
widget.post_message_no_wait(events.Show(self))
send_resize = shown
send_resize.update(resized)
send_resize = shown | resized
for widget, region, unclipped_region in self._compositor:
widget._update_size(unclipped_region.size)
@@ -123,12 +122,11 @@ class View(Widget):
async def handle_layout(self, message: messages.Layout) -> None:
message.stop()
await self.refresh_layout()
self.app.refresh()
async def on_resize(self, event: events.Resize) -> None:
event.stop()
self._update_size(event.size)
await self.refresh_layout()
event.stop()
async def on_idle(self, event: events.Idle) -> None:
if self._compositor.check_update():
@@ -143,12 +141,11 @@ class View(Widget):
region = self.get_widget_region(widget)
else:
widget, region = self.get_widget_at(event.x, event.y)
except NoWidget:
except errors.NoWidget:
await self.app.set_mouse_over(None)
else:
await self.app.set_mouse_over(widget)
await widget.forward_event(
events.MouseMove(
mouse_event = events.MouseMove(
self,
event.x - region.x,
event.y - region.y,
@@ -162,4 +159,43 @@ class View(Widget):
screen_y=event.screen_y,
style=event.style,
)
)
mouse_event.set_forwarded()
await widget.forward_event(mouse_event)
async def forward_event(self, event: events.Event) -> None:
if event.is_forwarded:
return
event.set_forwarded()
if isinstance(event, (events.Enter, events.Leave)):
await self.post_message(event)
elif isinstance(event, events.MouseMove):
event.style = self.get_style_at(event.screen_x, event.screen_y)
await self._on_mouse_move(event)
elif isinstance(event, events.MouseEvent):
try:
if self.app.mouse_captured:
widget = self.app.mouse_captured
region = self.get_widget_region(widget)
else:
widget, region = self.get_widget_at(event.x, event.y)
except errors.NoWidget:
await self.app.set_focus(None)
else:
if isinstance(event, events.MouseDown) and widget.can_focus:
await self.app.set_focus(widget)
event.style = self.get_style_at(event.screen_x, event.screen_y)
await widget.forward_event(event.offset(-region.x, -region.y))
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
try:
widget, _region = self.get_widget_at(event.x, event.y)
except errors.NoWidget:
return
scroll_widget = widget
if scroll_widget is not None:
await scroll_widget.forward_event(event)
else:
self.log("view.forwarded", event)
await self.post_message(event)

View File

@@ -127,7 +127,7 @@ class View(Widget):
try:
await self.layout.mount_all(self)
if not self.is_root_view:
await self.app.view.refresh_layout()
await self.app.screen.refresh_layout()
return
if not self.size:
@@ -247,7 +247,7 @@ class View(Widget):
self.log("view.forwarded", event)
await self.post_message(event)
async def action_toggle(self, name: str) -> None:
widget = self[name]
widget.visible = not widget.display
await self.post_message(messages.Layout(self))
# async def action_toggle(self, name: str) -> None:
# widget = self[name]
# widget.visible = not widget.display
# await self.post_message(messages.Layout(self))

View File

@@ -3,7 +3,7 @@ from typing import cast, Optional
from ..layouts.dock import DockLayout, Dock, DockEdge
from ..layouts.grid import GridLayout, GridAlign
from ..view import View
from ..screen import Screen
from ..widget import Widget
@@ -14,7 +14,7 @@ class DoNotSet:
do_not_set = DoNotSet()
class DockView(View):
class DockView(Screen):
def __init__(self, name: str | None = None) -> None:
super().__init__(layout=DockLayout(), name=name)
@@ -58,7 +58,7 @@ class DockView(View):
) -> GridLayout:
grid = GridLayout(gap=gap, gutter=gutter, align=align)
view = View(layout=grid, id=id, name=name)
view = Screen(layout=grid, id=id, name=name)
dock = Dock(edge, (view,), z)
assert isinstance(self.layout, DockLayout)
self.layout.docks.append(dock)

View File

@@ -1,3 +1,3 @@
from __future__ import annotations
from ..view import View
from ..screen import Screen

View File

@@ -1,8 +1,8 @@
from ..view import View
from ..screen import Screen
from ..layouts.grid import GridLayout
class GridView(View, layout=GridLayout):
class GridView(Screen, layout=GridLayout):
@property
def grid(self) -> GridLayout:
assert isinstance(self.layout, GridLayout)

View File

@@ -5,7 +5,7 @@ from rich.console import RenderableType
from .. import events
from ..geometry import Size, SpacingDimensions
from ..layouts.vertical import VerticalLayout
from ..view import View
from ..screen import Screen
from ..message import Message
from .. import messages
from ..widget import Widget
@@ -17,7 +17,7 @@ class WindowChange(Message):
return isinstance(message, WindowChange)
class WindowView(View, layout=VerticalLayout):
class WindowView(Screen, layout=VerticalLayout):
def __init__(
self,
widget: RenderableType | Widget,

View File

@@ -37,7 +37,7 @@ from .reactive import Reactive, watch
from .renderables.opacity import Opacity
if TYPE_CHECKING:
from .view import View
from .screen import Screen
class RenderCache(NamedTuple):
@@ -55,20 +55,20 @@ class RenderCache(NamedTuple):
@rich.repr.auto
class Widget(DOMNode):
_counts: ClassVar[dict[str, int]] = {}
can_focus: bool = False
DEFAULT_STYLES = """
dock: _default
"""
def __init__(self, name: str | None = None, id: str | None = None) -> None:
if name is None:
class_name = self.__class__.__name__
Widget._counts.setdefault(class_name, 0)
Widget._counts[class_name] += 1
_count = self._counts[class_name]
name = f"{class_name}{_count}"
def __init__(
self,
*children: Widget,
name: str | None = None,
id: str | None = None,
classes: Iterable[str] | None = None,
) -> None:
self._size = Size(0, 0)
self._repaint_required = False
@@ -79,8 +79,11 @@ class Widget(DOMNode):
self.render_cache: RenderCache | None = None
self.highlight_style: Style | None = None
super().__init__(name=name, id=id)
super().__init__(name=name, id=id, classes=classes)
self.add_children(*children)
has_focus = Reactive(False)
mouse_over = Reactive(False)
scroll_x = Reactive(0)
scroll_y = Reactive(0)
virtual_size = Reactive(Size(0, 0))
@@ -103,6 +106,8 @@ class Widget(DOMNode):
"""Pseudo classes for a widget"""
if self._mouse_over:
yield "hover"
if self.has_focus:
yield "focus"
# TODO: focus
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
@@ -148,6 +153,10 @@ class Widget(DOMNode):
return renderable
@property
def children(self) -> list[Widget]:
return list(self.node_list)
@property
def size(self) -> Size:
return self._size
@@ -165,11 +174,6 @@ class Widget(DOMNode):
"""Get the current console."""
return active_app.get().console
@property
def root_view(self) -> "View":
"""Return the top-most view."""
return active_app.get().view
@property
def animate(self) -> BoundAnimator:
if self._animate is None:
@@ -181,6 +185,14 @@ class Widget(DOMNode):
def layout(self) -> Layout | None:
return self.styles.layout
def watch_mouse_over(self, value: bool) -> None:
"""Update from CSS if mouse over state changes."""
self.app.update_styles()
def watch_has_focus(self, value: bool) -> None:
"""Update from CSS if has focus state changes."""
self.app.update_styles()
def on_style_change(self) -> None:
self.clear_render_cache()
@@ -218,8 +230,8 @@ class Widget(DOMNode):
self._layout_required = False
def get_style_at(self, x: int, y: int) -> Style:
offset_x, offset_y = self.root_view.get_offset(self)
return self.root_view.get_style_at(x + offset_x, y + offset_y)
offset_x, offset_y = self.screen.get_offset(self)
return self.screen.get_style_at(x + offset_x, y + offset_y)
async def call_later(self, callback: Callable, *args, **kwargs) -> None:
await self.app.call_later(callback, *args, **kwargs)
@@ -228,7 +240,7 @@ class Widget(DOMNode):
event.set_forwarded()
await self.post_message(event)
def refresh(self, repaint: bool = True, layout: bool = False) -> None:
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
"""Initiate a refresh of the widget.
This method sets an internal flag to perform a refresh, which will be done on the
@@ -244,7 +256,7 @@ class Widget(DOMNode):
elif repaint:
self.clear_render_cache()
self._repaint_required = True
self.post_message_no_wait(events.Null(self))
self.check_idle()
def render(self) -> RenderableType:
"""Get renderable for widget.
@@ -254,9 +266,8 @@ class Widget(DOMNode):
"""
# Default displays a pretty repr in the center of the screen
return Align.center(
Pretty(self, no_wrap=True, overflow="ellipsis"), vertical="middle"
)
return Align.center(self.css_identifier_styled, vertical="middle")
async def action(self, action: str, *params) -> None:
await self.app.action(action, self)
@@ -269,6 +280,7 @@ class Widget(DOMNode):
return await super().post_message(message)
async def on_resize(self, event: events.Resize) -> None:
self._update_size(event.size)
self.refresh()
async def on_idle(self, event: events.Idle) -> None:
@@ -277,13 +289,14 @@ class Widget(DOMNode):
# self.render_cache = None
self.reset_check_repaint()
self.reset_check_layout()
await self.emit(messages.Layout(self))
await self.screen.post_message(messages.Layout(self))
elif repaint or self.check_repaint():
# self.render_cache = None
self.reset_check_repaint()
await self.emit(messages.Update(self, self))
async def focus(self) -> None:
"""Give input focus to this widget."""
await self.app.set_focus(self)
async def capture_mouse(self, capture: bool = True) -> None:
@@ -315,14 +328,6 @@ class Widget(DOMNode):
async def on_click(self, event: events.Click) -> None:
await self.broker_event("click", event)
async def on_enter(self, event: events.Enter) -> None:
self._mouse_over = True
self.app.update_styles()
async def on_leave(self, event: events.Leave) -> None:
self._mouse_over = False
self.app.update_styles()
async def on_key(self, event: events.Key) -> None:
if await self.dispatch_key(event):
event.prevent_default()

View File

@@ -130,6 +130,6 @@ if __name__ == "__main__":
class TreeApp(App):
async def on_mount(self, event: events.Mount) -> None:
await self.view.dock(DirectoryTree("/Users/willmcgugan/projects"))
await self.screen.dock(DirectoryTree("/Users/willmcgugan/projects"))
TreeApp.run(log="textual.log")

View File

@@ -24,9 +24,9 @@ class Placeholder(Widget, can_focus=True):
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 __init__(self, *, name: str | None = None, height: int | None = None) -> None:
# super().__init__(name=name)
# self.height = height
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()

View File

@@ -11,14 +11,14 @@ from ..message import Message
from ..messages import CursorMove
from ..scrollbar import ScrollTo, ScrollBar
from ..geometry import clamp
from ..view import View
from ..screen import Screen
from ..widget import Widget
from ..reactive import Reactive
class ScrollView(View):
class ScrollView(Screen):
def __init__(
self,
contents: RenderableType | Widget | None = None,

View File

@@ -317,7 +317,7 @@ if __name__ == "__main__":
class TreeApp(App):
async def on_mount(self, event: events.Mount) -> None:
await self.view.dock(TreeControl("Tree Root", data="foo"))
await self.screen.dock(TreeControl("Tree Root", data="foo"))
async def handle_tree_click(self, message: TreeClick) -> None:
if message.node.empty:

View File

@@ -4,17 +4,4 @@ from textual.layouts.dock import DockLayout
from textual.layouts.grid import GridLayout
from textual.layouts.horizontal import HorizontalLayout
from textual.layouts.vertical import VerticalLayout
from textual.view import View
@pytest.mark.parametrize("layout_name, layout_type", [
["dock", DockLayout],
["grid", GridLayout],
["vertical", VerticalLayout],
["horizontal", HorizontalLayout],
])
def test_view_layout_get_and_set(layout_name, layout_type):
view = View()
view.layout = layout_name
assert type(view.layout) is layout_type
assert view.styles.layout is view.layout
from textual.screen import Screen