Merge pull request #685 from Textualize/screens

Screens API
This commit is contained in:
Will McGugan
2022-08-17 12:04:44 +01:00
committed by GitHub
26 changed files with 662 additions and 105 deletions

View File

@@ -34,7 +34,7 @@ def launch_sandbox_script(python_file_name: str) -> None:
thread = threading.Thread(
target=launch_sandbox_script, args=(target_script_name,), daemon=True
target=launch_sandbox_script, args=(target_script_name,), daemon=False
)
thread.start()

View File

@@ -177,7 +177,7 @@ class BasicApp(App, css_path="basic.css"):
app = BasicApp()
if __name__ == "__main__":
app.run()
app.run(quit_after=2)
# from textual.geometry import Region
# from textual.color import Color

11
poetry.lock generated
View File

@@ -444,6 +444,14 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "nanoid"
version = "2.0.0"
description = "A tiny, secure, URL-friendly, unique string ID generator for Python"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "nodeenv"
version = "1.7.0"
@@ -780,7 +788,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "2d0f99d7fb563eb0b34cda9542ecf87c35cf5944a67510625969ec7b046b6d03"
content-hash = "61db56567f708cd9ca1c27f0e4a4b4aa3dd808fc8411f80967a90995d7fdd8c8"
[metadata.files]
aiohttp = [
@@ -1275,6 +1283,7 @@ mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
nanoid = []
nodeenv = [
{file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"},
{file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"},

View File

@@ -31,6 +31,7 @@ typing-extensions = { version = "^4.0.0", python = "<3.8" }
aiohttp = { version = "^3.8.1", optional = true }
click = {version = "8.1.2", optional = true}
msgpack = { version = "^1.0.3", optional = true }
nanoid = "^2.0.0"
[tool.poetry.extras]
dev = ["aiohttp", "click", "msgpack"]

View File

@@ -43,7 +43,7 @@ class BasicApp(App):
self.panic(self.app.tree)
def action_dump(self):
self.panic(str(self.app.registry))
self.panic(str(self.app._registry))
def action_log_tree(self):
self.log(self.screen.tree)

View File

@@ -98,7 +98,7 @@ Tweet {
height:12;
width: 100%;
margin:0 2;
background: $panel;
color: $text-panel;
layout: vertical;

View File

@@ -5,12 +5,12 @@ from textual.widgets import Static
class CenterApp(App):
CSS = """
Screen {
CenterApp Screen {
layout: center;
overflow: auto auto;
}
Static {
CenterApp Static {
border: wide $primary;
background: $panel;
width: 50;

66
sandbox/will/screens.py Normal file
View File

@@ -0,0 +1,66 @@
from textual.app import App, Screen, ComposeResult
from textual.widgets import Static, Footer, Pretty
class ModalScreen(Screen):
def compose(self) -> ComposeResult:
yield Pretty(self.app.screen_stack)
yield Footer()
def on_screen_resume(self):
self.query_one(Pretty).update(self.app.screen_stack)
class NewScreen(Screen):
def compose(self):
yield Pretty(self.app.screen_stack)
yield Footer()
def on_screen_resume(self):
self.query_one(Pretty).update(self.app.screen_stack)
class ScreenApp(App):
CSS = """
ScreenApp Screen {
background: #111144;
color: white;
}
ScreenApp ModalScreen {
background: #114411;
color: white;
}
ScreenApp Pretty {
height: auto;
content-align: center middle;
background: white 20%;
}
"""
def compose(self) -> ComposeResult:
yield Static("On Screen 1")
yield Footer()
def on_mount(self) -> None:
self.install_screen(NewScreen("Screen1"), name="1")
self.install_screen(NewScreen("Screen2"), name="2")
self.install_screen(NewScreen("Screen3"), name="3")
self.bind("1", "switch_screen('1')", description="Screen 1")
self.bind("2", "switch_screen('2')", description="Screen 2")
self.bind("3", "switch_screen('3')", description="Screen 3")
self.bind("s", "modal_screen", description="add screen")
self.bind("escape", "back", description="Go back")
def action_modal_screen(self) -> None:
self.push_screen(ModalScreen())
app = ScreenApp()
if __name__ == "__main__":
app.run()

View File

@@ -264,7 +264,6 @@ class Border:
render_options = options.update_width(width)
lines = console.render_lines(self.renderable, render_options)
if self.outline:
self._crop_renderable(lines, options.max_width)

View File

@@ -10,6 +10,9 @@ from rich.segment import Segment
from rich.style import Style
from ._cells import cell_len
from ._types import Lines
from .css.types import AlignHorizontal, AlignVertical
from .geometry import Size
def line_crop(
@@ -124,3 +127,67 @@ def line_pad(
Segment(" " * pad_right, style),
]
return list(segments)
def align_lines(
lines: Lines,
style: Style,
size: Size,
horizontal: AlignHorizontal,
vertical: AlignVertical,
) -> Iterable[list[Segment]]:
"""Align lines.
Args:
lines (Lines): A list of lines.
style (Style): Background style.
size (Size): Size of container.
horizontal (AlignHorizontal): Horizontal alignment.
vertical (AlignVertical): Vertical alignment
Returns:
Iterable[list[Segment]]: Aligned lines.
"""
width, height = size
shape_width, shape_height = Segment.get_shape(lines)
def blank_lines(count: int) -> Lines:
return [[Segment(" " * width, style)]] * count
top_blank_lines = bottom_blank_lines = 0
vertical_excess_space = max(0, height - shape_height)
if vertical == "top":
bottom_blank_lines = vertical_excess_space
elif vertical == "middle":
top_blank_lines = vertical_excess_space // 2
bottom_blank_lines = height - top_blank_lines
elif vertical == "bottom":
top_blank_lines = vertical_excess_space
yield from blank_lines(top_blank_lines)
horizontal_excess_space = max(0, width - shape_width)
adjust_line_length = Segment.adjust_line_length
if horizontal == "left":
for line in lines:
yield adjust_line_length(line, width, style, pad=True)
elif horizontal == "center":
left_space = horizontal_excess_space // 2
for line in lines:
yield [
Segment(" " * left_space, style),
*adjust_line_length(line, width - left_space, style, pad=True),
]
elif horizontal == "right":
get_line_length = Segment.get_line_length
for line in lines:
left_space = width - get_line_length(line)
yield [*line, Segment(" " * left_space, style)]
yield from blank_lines(bottom_blank_lines)

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
from datetime import datetime
import inspect
import io
import os
@@ -9,9 +8,11 @@ import platform
import sys
import warnings
from contextlib import redirect_stdout
from datetime import datetime
from pathlib import PurePath
from time import perf_counter
from typing import (
TYPE_CHECKING,
Any,
Generic,
Iterable,
@@ -19,16 +20,17 @@ from typing import (
TextIO,
Type,
TypeVar,
TYPE_CHECKING,
)
from weakref import WeakSet, WeakValueDictionary
from ._ansi_sequences import SYNC_START, SYNC_END
from ._ansi_sequences import SYNC_END, SYNC_START
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal # pragma: no cover
import nanoid
import rich
import rich.repr
from rich.console import Console, RenderableType
@@ -37,30 +39,24 @@ from rich.protocol import is_renderable
from rich.segment import Segments
from rich.traceback import Traceback
from . import actions
from . import events
from . import log
from . import messages
from . import actions, events, log, messages
from ._animator import Animator
from ._callback import invoke
from ._context import active_app
from ._event_broker import extract_handler_actions, NoHandler
from ._event_broker import NoHandler, extract_handler_actions
from .binding import Bindings, NoBinding
from .css.stylesheet import Stylesheet
from .css.query import NoMatchingNodesError
from .css.stylesheet import Stylesheet
from .design import ColorSystem
from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog
from .devtools.redirect_output import StdoutRedirector
from .dom import DOMNode
from .driver import Driver
from .features import parse_features, FeatureFlag
from .features import FeatureFlag, parse_features
from .file_monitor import FileMonitor
from .geometry import Offset, Region, Size
from .message_pump import MessagePump
from .reactive import Reactive
from .renderables.blank import Blank
from ._profile import timer
from .screen import Screen
from .widget import Widget
@@ -112,6 +108,14 @@ class ActionError(Exception):
pass
class ScreenError(Exception):
pass
class ScreenStackError(ScreenError):
pass
ReturnType = TypeVar("ReturnType")
@@ -126,6 +130,8 @@ class App(Generic[ReturnType], DOMNode):
}
"""
SCREENS: dict[str, Screen] = {}
CSS_PATH: str | None = None
def __init__(
@@ -176,7 +182,6 @@ class App(Generic[ReturnType], DOMNode):
self._driver: Driver | None = None
self._exit_renderables: list[RenderableType] = []
self._docks: list[Dock] = []
self._action_targets = {"app", "screen"}
self._animator = Animator(self)
self.animate = self._animator.bind(self)
@@ -208,7 +213,12 @@ class App(Generic[ReturnType], DOMNode):
self._require_stylesheet_update = False
self.css_path = css_path or self.CSS_PATH
self.registry: set[MessagePump] = set()
self._registry: WeakSet[DOMNode] = WeakSet()
self._installed_screens: WeakValueDictionary[
str, Screen
] = WeakValueDictionary()
self.devtools = DevtoolsClient()
self._return_value: ReturnType | None = None
@@ -243,6 +253,11 @@ class App(Generic[ReturnType], DOMNode):
"""Check if the app is running in 'headless' mode."""
return "headless" in self.features
@property
def screen_stack(self) -> list[Screen]:
"""Get a *copy* of the screen stack."""
return self._screen_stack.copy()
def exit(self, result: ReturnType | None = None) -> None:
"""Exit the app, and return the supplied result.
@@ -403,7 +418,10 @@ class App(Generic[ReturnType], DOMNode):
@property
def screen(self) -> Screen:
return self._screen_stack[-1]
try:
return self._screen_stack[-1]
except IndexError:
raise ScreenStackError("No screens on stack") from None
@property
def size(self) -> Size:
@@ -503,6 +521,7 @@ class App(Generic[ReturnType], DOMNode):
self,
keys: str,
action: str,
*,
description: str = "",
show: bool = True,
key_display: str | None = None,
@@ -520,10 +539,20 @@ class App(Generic[ReturnType], DOMNode):
keys, action, description, show=show, key_display=key_display
)
def run(self) -> ReturnType | None:
"""The entry point to run a Textual app."""
def run(self, quit_after: float | None = None) -> ReturnType | None:
"""The main entry point for apps.
Args:
quit_after (float | None, optional): Quit after a given number of seconds, or None
to run forever. Defaults to None.
Returns:
ReturnType | None: _description_
"""
async def run_app() -> None:
if quit_after is not None:
self.set_timer(quit_after, self.shutdown)
await self.process_messages()
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
@@ -601,19 +630,160 @@ class App(Generic[ReturnType], DOMNode):
for widget in widgets:
self._register(self.screen, widget)
def push_screen(self, screen: Screen | None = None) -> Screen:
def is_screen_installed(self, screen: Screen | str) -> bool:
"""Check if a given screen has been installed.
Args:
screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed).
Returns:
bool: True if the screen is currently installed,
"""
if isinstance(screen, str):
return screen in self._installed_screens
else:
return screen in self._installed_screens.values()
def get_screen(self, screen: Screen | str) -> Screen:
"""Get an installed screen.
If the screen isn't running, it will be registered before it is run.
Args:
screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed).
Raises:
KeyError: If the named screen doesn't exist.
Returns:
Screen: A screen instance.
"""
if isinstance(screen, str):
try:
next_screen = self._installed_screens[screen]
except KeyError:
raise KeyError("No screen called {screen!r} installed") from None
else:
next_screen = screen
if not next_screen.is_running:
self._register(self, next_screen)
return next_screen
def _replace_screen(self, screen: Screen) -> Screen:
"""Handle the replaced screen.
Args:
screen (Screen): A screen object.
Returns:
Screen: The screen that was replaced.
"""
screen.post_message_no_wait(events.ScreenSuspend(self))
self.log(f"{screen} SUSPENDED")
if not self.is_screen_installed(screen) and screen not in self._screen_stack:
screen.remove()
self.log(f"{screen} REMOVED")
return screen
def push_screen(self, screen: Screen | str) -> None:
"""Push a new screen on the screen stack.
Args:
screen (Screen | None, optional): A new Screen instance or None to create
one internally. Defaults to None.
screen (Screen | str): A Screen instance or an id.
"""
next_screen = self.get_screen(screen)
self._screen_stack.append(next_screen)
self.screen.post_message_no_wait(events.ScreenResume(self))
self.log(f"{self.screen} is current (PUSHED)")
def switch_screen(self, screen: Screen | str) -> None:
"""Switch to a another screen by replacing the top of the screen stack with a new screen.
Args:
screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed).
"""
if self.screen is not screen:
self._replace_screen(self._screen_stack.pop())
next_screen = self.get_screen(screen)
self._screen_stack.append(next_screen)
self.screen.post_message_no_wait(events.ScreenResume(self))
self.log(f"{self.screen} is current (SWITCHED)")
def install_screen(self, screen: Screen, name: str | None = None) -> str:
"""Install a screen.
Args:
screen (Screen): Screen to install.
name (str | None, optional): Unique name of screen or None to auto-generate.
Defaults to None.
Raises:
ScreenError: If the screen can't be installed.
Returns:
Screen: Newly active screen.
str: The name of the screen
"""
new_screen = Screen() if screen is None else screen
self._screen_stack.append(new_screen)
return new_screen
if name is None:
name = nanoid.generate()
if name in self._installed_screens:
raise ScreenError(f"Can't install screen; {name!r} is already installed")
if screen in self._installed_screens.values():
raise ScreenError(
"Can't install screen; {screen!r} has already been installed"
)
self._installed_screens[name] = screen
self.get_screen(name) # Ensures screen is running
self.log(f"{screen} INSTALLED name={name!r}")
return name
def uninstall_screen(self, screen: Screen | str) -> str | None:
"""Uninstall a screen. If the screen was not previously installed then this
method is a null-op.
Args:
screen (Screen | str): The screen to uninstall or the name of a installed screen.
Returns:
str | None: The name of the screen that was uninstalled, or None if no screen was uninstalled.
"""
if isinstance(screen, str):
if screen not in self._installed_screens:
return None
uninstall_screen = self._installed_screens[screen]
if uninstall_screen in self._screen_stack:
raise ScreenStackError("Can't uninstall screen in screen stack")
del self._installed_screens[screen]
self.log(f"{uninstall_screen} UNINSTALLED name={screen!r}")
return screen
else:
if screen in self._screen_stack:
raise ScreenStackError("Can't uninstall screen in screen stack")
for name, installed_screen in self._installed_screens.items():
if installed_screen is screen:
self._installed_screens.pop(name)
self.log(f"{screen} UNINSTALLED name={name!r}")
return name
return None
def pop_screen(self) -> Screen:
"""Pop the current screen from the stack, and switch to the previous screen.
Returns:
Screen: The screen that was replaced.
"""
screen_stack = self._screen_stack
if len(screen_stack) <= 1:
raise ScreenStackError(
"Can't pop screen; there must be at least one screen on the stack"
)
previous_screen = self._replace_screen(screen_stack.pop())
self.screen._screen_resized(self.size)
self.screen.post_message_no_wait(events.ScreenResume(self))
self.log(f"{self.screen} is active")
return previous_screen
def set_focus(self, widget: Widget | None) -> None:
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
@@ -621,7 +791,6 @@ class App(Generic[ReturnType], DOMNode):
Args:
widget (Widget): [description]
"""
self.log("set_focus", widget=widget)
if widget == self.focused:
# Widget is already focused
return
@@ -835,11 +1004,11 @@ class App(Generic[ReturnType], DOMNode):
self._require_stylesheet_update = False
self.stylesheet.update(self, animate=True)
def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
if child not in self.registry:
def _register_child(self, parent: DOMNode, child: Widget) -> bool:
if child not in self._registry:
parent.children._append(child)
self.registry.add(child)
child.set_parent(parent)
self._registry.add(child)
child._attach(parent)
child.on_register(self)
child.start_messages()
return True
@@ -862,7 +1031,7 @@ class App(Generic[ReturnType], DOMNode):
apply_stylesheet = self.stylesheet.apply
for widget_id, widget in name_widgets:
if widget not in self.registry:
if widget not in self._registry:
if widget_id is not None:
widget.id = widget_id
self._register_child(parent, widget)
@@ -877,11 +1046,12 @@ class App(Generic[ReturnType], DOMNode):
"""Unregister a widget.
Args:
widget (Widget): _description_
widget (Widget): A Widget to unregister
"""
if isinstance(widget._parent, Widget):
widget._parent.children._remove(widget)
self.registry.discard(widget)
widget._attach(None)
self._registry.discard(widget)
async def _disconnect_devtools(self):
await self.devtools.disconnect()
@@ -893,33 +1063,29 @@ class App(Generic[ReturnType], DOMNode):
parent (Widget): The parent of the Widget.
widget (Widget): The Widget to start.
"""
widget.set_parent(parent)
widget._attach(parent)
widget.start_messages()
widget.post_message_no_wait(events.Mount(sender=parent))
def is_mounted(self, widget: Widget) -> bool:
return widget in self.registry
return widget in self._registry
async def close_all(self) -> None:
while self.registry:
child = self.registry.pop()
while self._registry:
child = self._registry.pop()
await child.close_messages()
async def shutdown(self):
await self._disconnect_devtools()
driver = self._driver
assert driver is not None
driver.disable_input()
if driver is not None:
driver.disable_input()
await self.close_messages()
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
self.screen.refresh(repaint=repaint, layout=layout)
self.check_idle()
def _paint(self):
"""Perform a "paint" (draw the screen)."""
self._display(self.screen._compositor.render())
def refresh_css(self, animate: bool = True) -> None:
"""Refresh CSS.
@@ -932,13 +1098,14 @@ class App(Generic[ReturnType], DOMNode):
stylesheet.update(self.app, animate=animate)
self.screen._refresh_layout(self.size, full=True)
def _display(self, renderable: RenderableType | None) -> None:
def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
"""Display a renderable within a sync.
Args:
screen (Screen): Screen instance
renderable (RenderableType): A Rich renderable.
"""
if renderable is None:
if screen is not self.screen or renderable is None:
return
if self._running and not self._closed and not self.is_headless:
console = self.console
@@ -1004,9 +1171,9 @@ class App(Generic[ReturnType], 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):
screen = Screen()
screen = Screen(id="_default")
self._register(self, screen)
self.push_screen(screen)
self._screen_stack.append(screen)
await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
@@ -1104,11 +1271,9 @@ class App(Generic[ReturnType], DOMNode):
async def on_update(self, message: messages.Update) -> None:
message.stop()
self._paint()
async def on_layout(self, message: messages.Layout) -> None:
message.stop()
self._paint()
async def on_key(self, event: events.Key) -> None:
if event.key == "tab":
@@ -1125,7 +1290,6 @@ class App(Generic[ReturnType], DOMNode):
async def on_resize(self, event: events.Resize) -> None:
event.stop()
self.screen._screen_resized(event.size)
await self.screen.post_message(event)
async def action_press(self, key: str) -> None:
@@ -1149,6 +1313,21 @@ class App(Generic[ReturnType], DOMNode):
if isinstance(node, Widget):
self.set_focus(node)
async def action_switch_screen(self, screen: str) -> None:
self.switch_screen(screen)
async def action_push_screen(self, screen: str) -> None:
self.push_screen(screen)
async def action_pop_screen(self) -> None:
self.pop_screen()
async def action_back(self) -> None:
try:
self.pop_screen()
except ScreenStackError:
pass
async def action_add_class_(self, selector: str, class_name: str) -> None:
self.screen.query(selector).add_class(class_name)

View File

@@ -355,7 +355,7 @@ class Stylesheet:
node._component_styles.clear()
for component in node.COMPONENT_CLASSES:
virtual_node = DOMNode(classes=component)
virtual_node.set_parent(node)
virtual_node._attach(node)
self.apply(virtual_node, animate=False)
node._component_styles[component] = virtual_node.styles

View File

@@ -43,6 +43,9 @@ class DOMNode(MessagePump):
# Custom CSS
CSS: ClassVar[str] = ""
# Default classes argument if not supplied
DEFAULT_CLASSES: str = ""
# Virtual DOM nodes
COMPONENT_CLASSES: ClassVar[set[str]] = set()
@@ -467,7 +470,7 @@ class DOMNode(MessagePump):
node (DOMNode): A DOM node.
"""
self.children._append(node)
node.set_parent(self)
node._attach(self)
def add_children(self, *nodes: Widget, **named_nodes: Widget) -> None:
"""Add multiple children to this node.
@@ -478,10 +481,10 @@ class DOMNode(MessagePump):
"""
_append = self.children._append
for node in nodes:
node.set_parent(self)
node._attach(self)
_append(node)
for node_id, node in named_nodes.items():
node.set_parent(self)
node._attach(self)
_append(node)
node.id = node_id

View File

@@ -436,3 +436,11 @@ class Paste(Event, bubble=False):
def __rich_repr__(self) -> rich.repr.Result:
yield "text", self.text
class ScreenResume(Event, bubble=False):
pass
class ScreenSuspend(Event, bubble=False):
pass

View File

@@ -122,7 +122,12 @@ class MessagePump(metaclass=MessagePumpMeta):
def log(self, *args, **kwargs) -> None:
return self.app.log(*args, **kwargs, _textual_calling_frame=inspect.stack()[1])
def set_parent(self, parent: MessagePump) -> None:
def _attach(self, parent: MessagePump) -> None:
"""Set the parent, and therefore attach this node to the tree.
Args:
parent (MessagePump): Parent node.
"""
self._parent = parent
def check_message_enabled(self, message: Message) -> bool:

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.measure import Measurement
from rich.segment import Segment
from rich.style import Style
from .._segment_tools import align_lines
from ..css.types import AlignHorizontal, AlignVertical
from ..geometry import Size
class Align:
def __init__(
self,
renderable: RenderableType,
size: Size,
style: Style,
horizontal: AlignHorizontal,
vertical: AlignVertical,
) -> None:
"""Align a child renderable
Args:
renderable (RenderableType): Renderable to align.
size (Size): Size of container.
style (Style): Style of any padding.
horizontal (AlignHorizontal): Horizontal alignment.
vertical (AlignVertical): Vertical alignment.
"""
self.renderable = renderable
self.size = size
self.style = style
self.horizontal = horizontal
self.vertical = vertical
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
lines = console.render_lines(self.renderable, options, pad=False)
new_line = Segment.line()
for line in align_lines(
lines,
self.style,
self.size,
self.horizontal,
self.vertical,
):
yield from line
yield new_line
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
width, _ = self.size
return Measurement(width, width)

View File

@@ -33,7 +33,6 @@ class Screen(Widget):
CSS = """
Screen {
layout: vertical;
overflow-y: auto;
}
@@ -52,6 +51,11 @@ class Screen(Widget):
def is_transparent(self) -> bool:
return False
@property
def is_current(self) -> bool:
"""Check if this screen is current (i.e. visible to user)."""
return self.app.screen is self
@property
def update_timer(self) -> Timer:
"""Timer used to perform updates."""
@@ -113,20 +117,22 @@ class Screen(Widget):
"""
return self._compositor.find_widget(widget)
async def on_idle(self, event: events.Idle) -> None:
async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
if self._layout_required:
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False
if self._dirty_widgets:
self.update_timer.resume()
if self.is_current:
if self._layout_required:
self._refresh_layout()
self._layout_required = False
self._dirty_widgets.clear()
if self._repaint_required:
self._dirty_widgets.clear()
self._dirty_widgets.add(self)
self._repaint_required = False
if self._dirty_widgets:
self.update_timer.resume()
# The Screen is idle - a good opportunity to invoke the scheduled callbacks
await self._invoke_and_clear_callbacks()
@@ -136,14 +142,14 @@ class Screen(Widget):
# Render widgets together
if self._dirty_widgets:
self._compositor.update_widgets(self._dirty_widgets)
self.app._display(self._compositor.render())
self.app._display(self, self._compositor.render())
self._dirty_widgets.clear()
self.update_timer.pause()
if self._callbacks:
self.post_message_no_wait(events.InvokeCallbacks(self))
async def on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None:
async def _on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None:
"""Handle PostScreenUpdate events, which are sent after the screen is updated"""
await self._invoke_and_clear_callbacks()
@@ -205,9 +211,9 @@ class Screen(Widget):
return
display_update = self._compositor.render(full=full)
if display_update is not None:
self.app._display(display_update)
self.app._display(self, display_update)
async def on_update(self, message: messages.Update) -> None:
async def _on_update(self, message: messages.Update) -> None:
message.stop()
message.prevent_default()
widget = message.widget
@@ -215,7 +221,7 @@ class Screen(Widget):
self._dirty_widgets.add(widget)
self.check_idle()
async def on_layout(self, message: messages.Layout) -> None:
async def _on_layout(self, message: messages.Layout) -> None:
message.stop()
message.prevent_default()
self._layout_required = True
@@ -225,7 +231,13 @@ class Screen(Widget):
"""Called by App when the screen is resized."""
self._refresh_layout(size, full=True)
async def on_resize(self, event: events.Resize) -> None:
def _on_screen_resume(self) -> None:
"""Called by the App"""
size = self.app.size
self._refresh_layout(size, full=True)
async def _on_resize(self, event: events.Resize) -> None:
event.stop()
async def _handle_mouse_move(self, event: events.MouseMove) -> None:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from itertools import islice
from fractions import Fraction
from operator import attrgetter
from typing import (
@@ -14,7 +15,7 @@ from typing import (
)
import rich.repr
from rich.align import Align
from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.segment import Segment
@@ -27,6 +28,7 @@ from ._animator import BoundAnimator
from ._arrange import arrange, DockArrangeResult
from ._context import active_app
from ._layout import Layout
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from ._types import Lines
from .box_model import BoxModel, get_box_model
@@ -36,6 +38,7 @@ from .layouts.vertical import VerticalLayout
from .message import Message
from .reactive import Reactive, watch
if TYPE_CHECKING:
from .app import App, ComposeResult
from .scrollbar import (
@@ -78,7 +81,6 @@ class Widget(DOMNode):
scrollbar-size-horizontal: 1;
}
"""
COMPONENT_CLASSES: ClassVar[set[str]] = set()
can_focus: bool = False
@@ -121,7 +123,11 @@ class Widget(DOMNode):
self._styles_cache = StylesCache()
super().__init__(name=name, id=id, classes=classes)
super().__init__(
name=name,
id=id,
classes=self.DEFAULT_CLASSES if classes is None else classes,
)
self.add_children(*children)
virtual_size = Reactive(Size(0, 0), layout=True)
@@ -287,6 +293,7 @@ class Widget(DOMNode):
Returns:
int: The height of the content.
"""
if self.is_container:
assert self.layout is not None
height = (
@@ -982,15 +989,6 @@ class Widget(DOMNode):
else:
renderable = Styled(renderable, rich_style)
styles = self.styles
content_align = (
styles.content_align_horizontal,
styles.content_align_vertical,
)
if content_align != ("left", "top"):
horizontal, vertical = content_align
renderable = Align(renderable, horizontal, vertical=vertical)
return renderable
def watch_mouse_over(self, value: bool) -> None:
@@ -1033,7 +1031,30 @@ class Widget(DOMNode):
options = self.console.options.update_dimensions(width, height).update(
highlight=False
)
lines = self.console.render_lines(renderable, options)
segments = self.console.render(renderable, options)
lines = list(
islice(
Segment.split_and_crop_lines(
segments, width, include_new_lines=False, pad=False
),
None,
height,
)
)
styles = self.styles
align_horizontal, align_vertical = styles.content_align
lines = list(
align_lines(
lines,
Style(),
self.size,
align_horizontal,
align_vertical,
)
)
self._render_cache = RenderCache(self.size, lines)
self._dirty_regions.clear()
@@ -1104,6 +1125,8 @@ class Widget(DOMNode):
def remove(self) -> None:
"""Remove the Widget from the DOM (effectively deleting it)"""
for child in self.children:
child.remove()
self.post_message_no_wait(events.Remove(self))
def render(self) -> RenderableType:
@@ -1115,7 +1138,8 @@ class Widget(DOMNode):
Returns:
RenderableType: Any renderable
"""
return "" if self.is_container else self.css_identifier_styled
render = "" if self.is_container else self.css_identifier_styled
return render
async def action(self, action: str, *params) -> None:
await self.app.action(action, self)
@@ -1127,7 +1151,7 @@ class Widget(DOMNode):
self.log(self, f"IS NOT RUNNING, {message!r} not sent")
return await super().post_message(message)
def on_idle(self, event: events.Idle) -> None:
async def _on_idle(self, event: events.Idle) -> None:
"""Called when there are no more events on the queue.
Args:
@@ -1178,9 +1202,9 @@ class Widget(DOMNode):
async def on_remove(self, event: events.Remove) -> None:
await self.close_messages()
self.app._unregister(self)
assert self.parent
self.parent.refresh(layout=True)
self.app._unregister(self)
def _on_mount(self, event: events.Mount) -> None:
widgets = list(self.compose())

View File

@@ -17,6 +17,7 @@ __all__ = [
"Footer",
"Header",
"Placeholder",
"Pretty",
"Static",
"TreeControl",
]

View File

@@ -5,5 +5,6 @@ from ._directory_tree import DirectoryTree as DirectoryTree
from ._footer import Footer as Footer
from ._header import Header as Header
from ._placeholder import Placeholder as Placeholder
from ._pretty import Pretty as Pretty
from ._static import Static as Static
from ._tree_control import TreeControl as TreeControl

View File

@@ -83,6 +83,8 @@ class Header(Widget):
}
"""
DEFAULT_CLASSES = "tall"
async def on_click(self, event):
self.toggle_class("tall")
@@ -95,7 +97,6 @@ class Header(Widget):
watch(self.app, "title", set_title)
watch(self.app, "sub_title", set_sub_title)
self.add_class("tall")
def compose(self):
yield HeaderIcon()

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Any
from rich.pretty import Pretty as PrettyRenderable
from ..widget import Widget
class Pretty(Widget):
CSS = """
Static {
height: auto;
}
"""
def __init__(
self,
object: Any,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(
name=name,
id=id,
classes=classes,
)
self._renderable = PrettyRenderable(object)
def render(self) -> PrettyRenderable:
return self._renderable
def update(self, object: Any) -> None:
self._renderable = PrettyRenderable(object)
self.refresh(layout=True)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from rich.console import RenderableType
from rich.style import Style
from ..widget import Widget

View File

@@ -14,6 +14,7 @@ class NonFocusable(Widget, can_focus=False, can_focus_children=False):
async def test_focus_chain():
app = App()
app._set_active()
app.push_screen(Screen())
# Check empty focus chain
@@ -34,6 +35,7 @@ async def test_focus_chain():
async def test_focus_next_and_previous():
app = App()
app._set_active()
app.push_screen(Screen())
app.screen.add_children(
Focusable(id="foo"),

93
tests/test_screens.py Normal file
View File

@@ -0,0 +1,93 @@
import sys
import pytest
from textual.app import App, ScreenStackError
from textual.screen import Screen
skip_py310 = pytest.mark.skipif(
sys.version_info.minor == 10 and sys.version_info.major == 3,
reason="segfault on py3.10",
)
@skip_py310
@pytest.mark.asyncio
async def test_screens():
app = App()
app._set_active()
with pytest.raises(ScreenStackError):
app.screen
assert not app._installed_screens
screen1 = Screen(name="screen1")
screen2 = Screen(name="screen2")
screen3 = Screen(name="screen3")
# installs screens
app.install_screen(screen1, "screen1")
app.install_screen(screen2, "screen2")
# Check they are installed
assert app.is_screen_installed("screen1")
assert app.is_screen_installed("screen2")
assert app.get_screen("screen1") is screen1
with pytest.raises(KeyError):
app.get_screen("foo")
# Check screen3 is not installed
assert not app.is_screen_installed("screen3")
# Installs screen3
app.install_screen(screen3, "screen3")
# Confirm installed
assert app.is_screen_installed("screen3")
# Check screen stack is empty
assert app.screen_stack == []
# Push a screen
app.push_screen("screen1")
# Check it is on the stack
assert app.screen_stack == [screen1]
# Check it is current
assert app.screen is screen1
# Switch to another screen
app.switch_screen("screen2")
# Check it has changed the stack and that it is current
assert app.screen_stack == [screen2]
assert app.screen is screen2
# Push another screen
app.push_screen("screen3")
assert app.screen_stack == [screen2, screen3]
assert app.screen is screen3
# Pop a screen
assert app.pop_screen() is screen3
assert app.screen is screen2
assert app.screen_stack == [screen2]
# Uninstall screens
app.uninstall_screen(screen1)
assert not app.is_screen_installed(screen1)
app.uninstall_screen("screen3")
assert not app.is_screen_installed(screen1)
# Check we can't uninstall a screen on the stack
with pytest.raises(ScreenStackError):
app.uninstall_screen(screen2)
# Check we can't pop last screen
with pytest.raises(ScreenStackError):
app.pop_screen()
screen1.remove()
screen2.remove()
screen3.remove()
await app.shutdown()

View File

@@ -1,6 +0,0 @@
import pytest
from textual.layouts.grid import GridLayout
from textual.layouts.horizontal import HorizontalLayout
from textual.layouts.vertical import VerticalLayout
from textual.screen import Screen