mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -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()
|
||||
|
||||
|
||||
@@ -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
11
poetry.lock
generated
@@ -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"},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -98,7 +98,7 @@ Tweet {
|
||||
height:12;
|
||||
width: 100%;
|
||||
|
||||
|
||||
margin:0 2;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
layout: vertical;
|
||||
|
||||
@@ -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
66
sandbox/will/screens.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
56
src/textual/renderables/align.py
Normal file
56
src/textual/renderables/align.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -17,6 +17,7 @@ __all__ = [
|
||||
"Footer",
|
||||
"Header",
|
||||
"Placeholder",
|
||||
"Pretty",
|
||||
"Static",
|
||||
"TreeControl",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
36
src/textual/widgets/_pretty.py
Normal file
36
src/textual/widgets/_pretty.py
Normal 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)
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.style import Style
|
||||
|
||||
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
@@ -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
93
tests/test_screens.py
Normal 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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user