compositor refactor and transparent screens (#2139)

* compositor refactor and trasparent screens

* multuple layers

* catch screen stack error

* refinement

* error messages

* capture screen stack

* new border type

* Background screen

* borders and bindings

* snapshot

* screen docs

* fix for missing screens

* screens docs

* fix for non updating transparent screens

* fix background resize

* changelog

* copy

* superfluous function

* update diagram

* inline code

* Update CHANGELOG.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/guide/screens.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* superfluous file

* Explicit None

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* docstring

* update docstring

* docstring make property private

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* docstring

* update docstring

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* docstrings

* remove comment, add docstring

* Apply suggestions from code review

Co-authored-by: Dave Pearson <davep@davep.org>

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
Co-authored-by: Dave Pearson <davep@davep.org>
This commit is contained in:
Will McGugan
2023-03-27 16:44:58 +01:00
committed by GitHub
parent d0035b4d4b
commit 0940546aab
24 changed files with 1787 additions and 1361 deletions

View File

@@ -27,12 +27,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `TextLog`: `write`, `clear` - `TextLog`: `write`, `clear`
- `TreeNode`: `expand`, `expand_all`, `collapse`, `collapse_all`, `toggle`, `toggle_all` - `TreeNode`: `expand`, `expand_all`, `collapse`, `collapse_all`, `toggle`, `toggle_all`
- `Tree`: `clear`, `reset` - `Tree`: `clear`, `reset`
- Replaced some private attributes with public ones in the json tree example. https://github.com/Textualize/textual/pull/2138 - Screens with alpha in their background color will now blend with the background. https://github.com/Textualize/textual/pull/2139
- Added "thick" border style. https://github.com/Textualize/textual/pull/2139
### Added ### Added
- Added auto_scroll attribute to TextLog https://github.com/Textualize/textual/pull/2127 - Added auto_scroll attribute to TextLog https://github.com/Textualize/textual/pull/2127
- Added scroll_end switch to TextLog.write https://github.com/Textualize/textual/pull/2127 - Added scroll_end switch to TextLog.write https://github.com/Textualize/textual/pull/2127
- Added Screen.ModalScreen which prevents App from handling bindings. https://github.com/Textualize/textual/pull/2139
## [0.16.0] - 2023-03-22 ## [0.16.0] - 2023-03-22

View File

@@ -1,14 +1,23 @@
QuitScreen {
align: center middle;
}
#dialog { #dialog {
grid-size: 2; grid-size: 2;
grid-gutter: 1 2; grid-gutter: 1 2;
padding: 1 2; grid-rows: 1fr 3;
padding: 0 1;
width: 60;
height: 11;
border: thick $background 80%;
background: $surface;
} }
#question { #question {
column-span: 2; column-span: 2;
content-align: center bottom; height: 1fr;
width: 100%; width: 1fr;
height: 100%; content-align: center middle;
} }
Button { Button {

View File

@@ -1,13 +1,23 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Grid from textual.containers import Grid
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Static, Header, Footer, Button from textual.widgets import Button, Footer, Header, Label
TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear.
I will permit it to pass over me and through me.
And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain."""
class QuitScreen(Screen): class QuitScreen(Screen):
"""Screen with a dialog to quit."""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
Static("Are you sure you want to quit?", id="question"), Label("Are you sure you want to quit?", id="question"),
Button("Quit", variant="error", id="quit"), Button("Quit", variant="error", id="quit"),
Button("Cancel", variant="primary", id="cancel"), Button("Cancel", variant="primary", id="cancel"),
id="dialog", id="dialog",
@@ -21,11 +31,14 @@ class QuitScreen(Screen):
class ModalApp(App): class ModalApp(App):
"""A app with a modal dialog."""
CSS_PATH = "modal01.css" CSS_PATH = "modal01.css"
BINDINGS = [("q", "request_quit", "Quit")] BINDINGS = [("q", "request_quit", "Quit")]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield Label(TEXT * 8)
yield Footer() yield Footer()
def action_request_quit(self) -> None: def action_request_quit(self) -> None:

View File

@@ -0,0 +1,50 @@
from textual.app import App, ComposeResult
from textual.containers import Grid
from textual.screen import ModalScreen
from textual.widgets import Button, Footer, Header, Label
TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear.
I will permit it to pass over me and through me.
And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain."""
class QuitScreen(ModalScreen):
"""Screen with a dialog to quit."""
def compose(self) -> ComposeResult:
yield Grid(
Label("Are you sure you want to quit?", id="question"),
Button("Quit", variant="error", id="quit"),
Button("Cancel", variant="primary", id="cancel"),
id="dialog",
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "quit":
self.app.exit()
else:
self.app.pop_screen()
class ModalApp(App):
"""A app with a modal dialog."""
CSS_PATH = "modal01.css"
BINDINGS = [("q", "request_quit", "Quit")]
def compose(self) -> ComposeResult:
yield Header()
yield Label(TEXT * 8)
yield Footer()
def action_request_quit(self) -> None:
self.push_screen(QuitScreen())
if __name__ == "__main__":
app = ModalApp()
app.run()

View File

@@ -4,14 +4,10 @@ This chapter covers Textual's screen API. We will discuss how to create screens
## What is a screen? ## What is a screen?
Screens are containers for widgets that occupy the dimensions of your terminal. There can be many screens in a given app, but only one screen is visible at a time. Screens are containers for widgets that occupy the dimensions of your terminal. There can be many screens in a given app, but only one screen is active at a time.
Textual requires that there be at least one screen object and will create one implicitly in the App class. If you don't change the screen, any widgets you [mount][textual.widget.Widget.mount] or [compose][textual.widget.Widget.compose] will be added to this default screen. Textual requires that there be at least one screen object and will create one implicitly in the App class. If you don't change the screen, any widgets you [mount][textual.widget.Widget.mount] or [compose][textual.widget.Widget.compose] will be added to this default screen.
!!! tip
Try printing `widget.parent` to see what object your widget is connected to.
<div class="excalidraw"> <div class="excalidraw">
--8<-- "docs/images/dom1.excalidraw.svg" --8<-- "docs/images/dom1.excalidraw.svg"
</div> </div>
@@ -78,7 +74,11 @@ If you have installed a screen, but you later want it to be removed and cleaned
## Screen stack ## Screen stack
Textual keeps track of a _stack_ of screens. You can think of the screen stack as a stack of paper, where only the very top sheet is visible. If you remove the top sheet the paper underneath becomes visible. Screens work in a similar way. Textual apps keep a _stack_ of screens. You can think of this screen stack as a stack of paper, where only the very top sheet is visible. If you remove the top sheet, the paper underneath becomes visible. Screens work in a similar way.
!!! note
You can also make parts of the top screen translucent, so that deeper screens show through. See [Screen opacity](#screen-opacity).
The active screen (top of the stack) will render the screen and receive input events. The following API methods on the App class can manipulate this stack, and let you decide which screen the user can interact with. The active screen (top of the stack) will render the screen and receive input events. The following API methods on the App class can manipulate this stack, and let you decide which screen the user can interact with.
@@ -127,13 +127,45 @@ Like [pop_screen](#pop-screen), if the screen being replaced is not installed it
You can also switch screens with the `"app.switch_screen"` action which accepts the name of the screen to switch to. You can also switch screens with the `"app.switch_screen"` action which accepts the name of the screen to switch to.
## Screen opacity
If a screen has a background color with an *alpha* component, then the background color will be blended with the screen beneath it.
For example, if the top-most screen has a background set to `rgba(0,0,255,0.5)` then anywhere in the screen not occupied with a widget will display the *second* screen from the top, tinted with 50% blue.
<div class="excalidraw">
--8<-- "docs/images/screens/screen_alpha.excalidraw.svg"
</div>
!!! note
Although parts of other screens may be made visible with background alpha, only the top-most is *active* (can respond to mouse and keyboard).
One use of background alpha is to style *modal dialogs* (see below).
## Modal screens ## Modal screens
Screens can be used to implement modal dialogs. The following example pushes a screen when you hit the ++q++ key to ask you if you really want to quit. Screens may be used to create modal dialogs, where the main interface is temporarily disabled (but still visible) while the user is entering information.
The following example pushes a screen when you hit the ++q++ key to ask you if you really want to quit.
From the quit screen you can click either Quit to exit the app immediately, or Cancel to dismiss the screen and return to the main screen.
=== "Output"
```{.textual path="docs/examples/guide/screens/modal01.py"}
```
=== "Output (after pressing ++q++)"
```{.textual path="docs/examples/guide/screens/modal01.py" press="q"}
```
=== "modal01.py" === "modal01.py"
```python title="modal01.py" hl_lines="18 20 32" ```python title="modal01.py"
--8<-- "docs/examples/guide/screens/modal01.py" --8<-- "docs/examples/guide/screens/modal01.py"
``` ```
@@ -143,9 +175,47 @@ Screens can be used to implement modal dialogs. The following example pushes a s
--8<-- "docs/examples/guide/screens/modal01.css" --8<-- "docs/examples/guide/screens/modal01.css"
``` ```
Note the `request_quit` action in the app which pushes a new instance of `QuitScreen`.
This makes the quit screen active. If you click Cancel, the quit screen calls [pop_screen][textual.app.App.pop_screen] to return the default screen. This also removes and deletes the `QuitScreen` object.
There are two flaws with this modal screen, which we can fix in the same way.
The first flaw is that the app adds a new quit screen every time you press ++q++, even when the quit screen is still visible.
Consequently if you press ++q++ three times, you will have to click Cancel three times to get back to the main screen.
This is because bindings defined on App are always checked, and we call `push_screen` for every press of ++q++.
The second flaw is that the modal dialog doesn't *look* modal.
There is no indication that the main interface is still there, waiting to become active again.
We can solve both those issues by replacing our use of [Screen][textual.screen.Screen] with [ModalScreen][textual.screen.ModalScreen].
This screen sub-class will prevent key bindings on the app from being processed.
It also sets a background with a little alpha to allow the previous screen to show through.
Let's see what happens when we use `ModalScreen`.
=== "Output" === "Output"
```{.textual path="docs/examples/guide/screens/modal01.py" press="q"} ```{.textual path="docs/examples/guide/screens/modal02.py"}
``` ```
Note the `request_quit` action in the app which pushes a new instance of `QuitScreen`. This makes the quit screen active. if you click cancel, the quit screen calls `pop_screen` to return the default screen. This also removes and deletes the `QuitScreen` object. === "Output (after pressing ++q++)"
```{.textual path="docs/examples/guide/screens/modal02.py" press="q"}
```
=== "modal02.py"
```python title="modal02.py" hl_lines="3 15"
--8<-- "docs/examples/guide/screens/modal02.py"
```
=== "modal01.css"
```sass title="modal01.css"
--8<-- "docs/examples/guide/screens/modal01.css"
```
Now when we press ++q++, the dialog is displayed over the main screen.
The main screen is darkened to indicate to the user that it is not active, and only the dialog will respond to input.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

2382
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ textual = "textual.cli.cli:run"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.7"
rich = ">12.6.0" rich = ">=13.3.3"
markdown-it-py = {extras = ["plugins", "linkify"], version = "^2.1.0"} markdown-it-py = {extras = ["plugins", "linkify"], version = "^2.1.0"}
#rich = {path="../rich", develop=true} #rich = {path="../rich", develop=true}
importlib-metadata = ">=4.11.3" importlib-metadata = ">=4.11.3"
@@ -48,7 +48,6 @@ typing-extensions = "^4.4.0"
aiohttp = { version = ">=3.8.1", optional = true } aiohttp = { version = ">=3.8.1", optional = true }
click = {version = ">=8.1.2", optional = true} click = {version = ">=8.1.2", optional = true}
msgpack = { version = ">=1.0.3", optional = true } msgpack = { version = ">=1.0.3", optional = true }
mkdocs-exclude = { version = "^1.0.2", optional = true }
[tool.poetry.extras] [tool.poetry.extras]
dev = ["aiohttp", "click", "msgpack"] dev = ["aiohttp", "click", "msgpack"]
@@ -61,6 +60,7 @@ pytest-cov = "^2.12.1"
mkdocs = "^1.3.0" mkdocs = "^1.3.0"
mkdocstrings = {extras = ["python"], version = "^0.20.0"} mkdocstrings = {extras = ["python"], version = "^0.20.0"}
mkdocs-material = "^9.0.11" mkdocs-material = "^9.0.11"
mkdocs-exclude = "^1.0.2"
pre-commit = "^2.13.0" pre-commit = "^2.13.0"
pytest-aiohttp = "^1.0.4" pytest-aiohttp = "^1.0.4"
time-machine = "^2.6.0" time-machine = "^2.6.0"

View File

@@ -17,8 +17,6 @@ if TYPE_CHECKING:
INNER = 1 INNER = 1
OUTER = 2 OUTER = 2
_EMPTY_SEGMENT = Segment("", Style())
BORDER_CHARS: dict[ BORDER_CHARS: dict[
EdgeType, tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]] EdgeType, tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]]
] = { ] = {
@@ -84,6 +82,11 @@ BORDER_CHARS: dict[
("", " ", ""), ("", " ", ""),
("", "", ""), ("", "", ""),
), ),
"thick": (
("", "", ""),
("", " ", ""),
("", "", ""),
),
"hkey": ( "hkey": (
("", "", ""), ("", "", ""),
(" ", " ", " "), (" ", " ", " "),
@@ -172,6 +175,11 @@ BORDER_LOCATIONS: dict[
(0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0),
), ),
"thick": (
(0, 0, 0),
(0, 0, 0),
(0, 0, 0),
),
"hkey": ( "hkey": (
(0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0),

View File

@@ -24,13 +24,15 @@ from rich.style import Style
from . import errors from . import errors
from ._cells import cell_len from ._cells import cell_len
from ._context import visible_screen_stack
from ._loop import loop_last from ._loop import loop_last
from .geometry import NULL_OFFSET, Offset, Region, Size from .geometry import NULL_OFFSET, Offset, Region, Size
from .strip import Strip from .strip import Strip, StripRenderable
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import TypeAlias from typing_extensions import TypeAlias
from .screen import Screen
from .widget import Widget from .widget import Widget
@@ -370,7 +372,7 @@ class Compositor:
return {} return {}
if self._full_map_invalidated: if self._full_map_invalidated:
self._full_map_invalidated = False self._full_map_invalidated = False
map, widgets = self._arrange_root(self.root, self.size, visible_only=False) map, _widgets = self._arrange_root(self.root, self.size, visible_only=False)
self._full_map = map self._full_map = map
self._visible_widgets = None self._visible_widgets = None
self._visible_map = None self._visible_map = None
@@ -446,7 +448,7 @@ class Compositor:
layer_order: The order of the widget in its layer. layer_order: The order of the widget in its layer.
clip: The clipping region (i.e. the viewport which contains it). clip: The clipping region (i.e. the viewport which contains it).
visible: Whether the widget should be visible by default. visible: Whether the widget should be visible by default.
This may be overriden by the CSS rule `visibility`. This may be overridden by the CSS rule `visibility`.
""" """
visibility = widget.styles.get_rule("visibility") visibility = widget.styles.get_rule("visibility")
if visibility is not None: if visibility is not None:
@@ -543,7 +545,7 @@ class Compositor:
container_region container_region
): ):
map[chrome_widget] = _MapGeometry( map[chrome_widget] = _MapGeometry(
chrome_region + layout_offset, chrome_region,
order, order,
clip, clip,
container_size, container_size,
@@ -681,6 +683,7 @@ class Compositor:
x -= region.x x -= region.x
y -= region.y y -= region.y
visible_screen_stack.set(widget.app._background_screens)
lines = widget.render_lines(Region(0, y, region.width, 1)) lines = widget.render_lines(Region(0, y, region.width, 1))
if not lines: if not lines:
@@ -756,6 +759,9 @@ class Compositor:
) -> Iterable[tuple[Region, Region, list[Strip]]]: ) -> Iterable[tuple[Region, Region, list[Strip]]]:
"""Get rendered widgets (lists of segments) in the composition. """Get rendered widgets (lists of segments) in the composition.
Args:
crop: Region to crop to, or `None` for entire screen.
Returns: Returns:
An iterable of <region>, <clip region>, and <strips> An iterable of <region>, <clip region>, and <strips>
""" """
@@ -800,41 +806,83 @@ class Compositor:
_Region(delta_x, delta_y, new_width, new_height) _Region(delta_x, delta_y, new_width, new_height)
) )
def render(self, full: bool = False) -> RenderableType | None: def render_update(
"""Render a layout. self, full: bool = False, screen_stack: list[Screen] | None = None
) -> RenderableType | None:
"""Render an update renderable.
Args:
full: Enable full update, or `False` for a partial update.
Returns: Returns:
A renderable A renderable for the update, or `None` if no update was required.
""" """
width, height = self.size visible_screen_stack.set([] if screen_stack is None else screen_stack)
screen_region = Region(0, 0, width, height) screen_region = self.size.region
if full or screen_region in self._dirty_regions:
if full: return self.render_full_update()
update_regions: set[Region] = set()
else: else:
update_regions = self._dirty_regions.copy() return self.render_partial_update()
if screen_region in update_regions:
# If one of the updates is the entire screen, then we only need one update
full = True
self._dirty_regions.clear()
if full: def render_full_update(self) -> LayoutUpdate:
crop = screen_region """Render a full update.
spans = []
is_rendered_line = lambda y: True Returns:
elif update_regions: A LayoutUpdate renderable.
# Create a crop regions that surrounds all updates """
screen_region = self.size.region
self._dirty_regions.clear()
crop = screen_region
chops = self._render_chops(crop, lambda y: True)
render_strips = [Strip.join(chop.values()) for chop in chops]
return LayoutUpdate(render_strips, screen_region)
def render_partial_update(self) -> ChopsUpdate | None:
"""Render a partial update.
Returns:
A ChopsUpdate if there is anything to update, otherwise `None`.
"""
screen_region = self.size.region
update_regions = self._dirty_regions.copy()
if update_regions:
# Create a crop region that surrounds all updates.
crop = Region.from_union(update_regions).intersection(screen_region) crop = Region.from_union(update_regions).intersection(screen_region)
spans = list(self._regions_to_spans(update_regions)) spans = list(self._regions_to_spans(update_regions))
is_rendered_line = {y for y, _, _ in spans}.__contains__ is_rendered_line = {y for y, _, _ in spans}.__contains__
else: else:
return None return None
chops = self._render_chops(crop, is_rendered_line)
chop_ends = [cut_set[1:] for cut_set in self.cuts]
return ChopsUpdate(chops, spans, chop_ends)
# Maps each cut on to a list of segments def render_strips(self) -> list[Strip]:
"""Render to a list of strips.
Returns:
A list of strips with the screen content.
"""
chops = self._render_chops(self.size.region, lambda y: True)
render_strips = [Strip.join(chop.values()) for chop in chops]
return render_strips
def _render_chops(
self,
crop: Region,
is_rendered_line: Callable[[int], bool],
) -> list[dict[int, Strip | None]]:
"""Render update 'chops'.
Args:
crop: Region to crop to.
is_rendered_line: Callable to check if line should be rendered.
Returns:
Chops structure.
"""
cuts = self.cuts cuts = self.cuts
# dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints onto a Segment or None.
fromkeys = cast("Callable[[list[int]], dict[int, Strip | None]]", dict.fromkeys) fromkeys = cast("Callable[[list[int]], dict[int, Strip | None]]", dict.fromkeys)
chops: list[dict[int, Strip | None]] chops: list[dict[int, Strip | None]]
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts] chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
@@ -872,18 +920,10 @@ class Compositor:
if chops_line[cut] is None: if chops_line[cut] is None:
chops_line[cut] = strip chops_line[cut] = strip
if full: return chops
render_strips = [Strip.join(chop.values()) for chop in chops]
return LayoutUpdate(render_strips, screen_region)
else:
chop_ends = [cut_set[1:] for cut_set in cuts]
return ChopsUpdate(chops, spans, chop_ends)
def __rich_console__( def __rich__(self) -> StripRenderable:
self, console: Console, options: ConsoleOptions return StripRenderable(self.render_strips())
) -> RenderResult:
if self._dirty_regions:
yield self.render() or ""
def update_widgets(self, widgets: set[Widget]) -> None: def update_widgets(self, widgets: set[Widget]) -> None:
"""Update a given widget in the composition. """Update a given widget in the composition.

View File

@@ -7,6 +7,7 @@ if TYPE_CHECKING:
from .app import App from .app import App
from .message import Message from .message import Message
from .message_pump import MessagePump from .message_pump import MessagePump
from .screen import Screen
class NoActiveAppError(RuntimeError): class NoActiveAppError(RuntimeError):
@@ -18,3 +19,5 @@ active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump
prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar( prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar(
"prevent_message_types_stack" "prevent_message_types_stack"
) )
visible_screen_stack: ContextVar[list[Screen]] = ContextVar("visible_screen_stack")
"""A stack of visible screens (with background alpha < 1), used in the screen render process."""

View File

@@ -343,9 +343,13 @@ class StylesCache:
pad_bottom and y >= height - gutter.bottom pad_bottom and y >= height - gutter.bottom
): ):
background_style = from_color(bgcolor=background.rich_color) background_style = from_color(bgcolor=background.rich_color)
left_style = from_color(color=(background + border_left_color).rich_color) left_style = from_color(
color=(base_background + border_left_color).rich_color
)
left = get_box(border_left, inner, outer, left_style)[1][0] left = get_box(border_left, inner, outer, left_style)[1][0]
right_style = from_color(color=(background + border_right_color).rich_color) right_style = from_color(
color=(base_background + border_right_color).rich_color
)
right = get_box(border_right, inner, outer, right_style)[1][2] right = get_box(border_right, inner, outer, right_style)[1][2]
if border_left and border_right: if border_left and border_right:
line = [left, make_blank(width - 2, background_style), right] line = [left, make_blank(width - 2, background_style), right]

View File

@@ -650,6 +650,17 @@ class App(Generic[ReturnType], DOMNode):
except IndexError: except IndexError:
raise ScreenStackError("No screens on stack") from None raise ScreenStackError("No screens on stack") from None
@property
def _background_screens(self) -> list[Screen]:
"""A list of screens that may be visible due to background opacity (top-most first, not including current screen)."""
screens: list[Screen] = []
for screen in reversed(self._screen_stack[:-1]):
screens.append(screen)
if screen.styles.background.a == 1:
break
background_screens = screens[::-1]
return background_screens
@property @property
def size(self) -> Size: def size(self) -> Size:
"""Size: The size of the terminal.""" """Size: The size of the terminal."""
@@ -796,7 +807,9 @@ class App(Generic[ReturnType], DOMNode):
record=True, record=True,
legacy_windows=False, legacy_windows=False,
) )
screen_render = self.screen._compositor.render(full=True) screen_render = self.screen._compositor.render_update(
full=True, screen_stack=self.app._background_screens
)
console.print(screen_render) console.print(screen_render)
return console.export_svg(title=title or self.title) return console.export_svg(title=title or self.title)
@@ -1307,6 +1320,8 @@ class App(Generic[ReturnType], DOMNode):
The screen that was replaced. The screen that was replaced.
""" """
if self._screen_stack:
self.screen.refresh()
screen.post_message(events.ScreenSuspend()) screen.post_message(events.ScreenSuspend())
self.log.system(f"{screen} SUSPENDED") self.log.system(f"{screen} SUSPENDED")
if not self.is_screen_installed(screen) and screen not in self._screen_stack: if not self.is_screen_installed(screen) and screen not in self._screen_stack:
@@ -1321,9 +1336,17 @@ class App(Generic[ReturnType], DOMNode):
screen: A Screen instance or the name of an installed screen. screen: A Screen instance or the name of an installed screen.
""" """
if not isinstance(screen, (Screen, str)):
raise TypeError(
f"push_screen requires a Screen instance or str; not {screen!r}"
)
if self._screen_stack:
self.screen.post_message(events.ScreenSuspend())
self.screen.refresh()
next_screen, await_mount = self._get_screen(screen) next_screen, await_mount = self._get_screen(screen)
self._screen_stack.append(next_screen) self._screen_stack.append(next_screen)
self.screen.post_message(events.ScreenResume()) next_screen.post_message(events.ScreenResume())
self.log.system(f"{self.screen} is current (PUSHED)") self.log.system(f"{self.screen} is current (PUSHED)")
return await_mount return await_mount
@@ -1334,6 +1357,10 @@ class App(Generic[ReturnType], DOMNode):
screen: Either a Screen object or screen name (the `name` argument when installed). screen: Either a Screen object or screen name (the `name` argument when installed).
""" """
if not isinstance(screen, (Screen, str)):
raise TypeError(
f"switch_screen requires a Screen instance or str; not {screen!r}"
)
if self.screen is not screen: if self.screen is not screen:
self._replace_screen(self._screen_stack.pop()) self._replace_screen(self._screen_stack.pop())
next_screen, await_mount = self._get_screen(screen) next_screen, await_mount = self._get_screen(screen)
@@ -1446,9 +1473,9 @@ class App(Generic[ReturnType], DOMNode):
if self.mouse_over is not widget: if self.mouse_over is not widget:
try: try:
if self.mouse_over is not None: if self.mouse_over is not None:
self.mouse_over._forward_event(events.Leave()) self.mouse_over.post_message(events.Leave())
if widget is not None: if widget is not None:
widget._forward_event(events.Enter()) widget.post_message(events.Enter())
finally: finally:
self.mouse_over = widget self.mouse_over = widget
@@ -1680,7 +1707,7 @@ class App(Generic[ReturnType], DOMNode):
widgets = compose(self) widgets = compose(self)
except TypeError as error: except TypeError as error:
raise TypeError( raise TypeError(
f"{self!r} compose() returned an invalid response; {error}" f"{self!r} compose() method returned an invalid result; {error}"
) from error ) from error
await self.mount_all(widgets) await self.mount_all(widgets)
@@ -1938,22 +1965,37 @@ class App(Generic[ReturnType], DOMNode):
@property @property
def _binding_chain(self) -> list[tuple[DOMNode, Bindings]]: def _binding_chain(self) -> list[tuple[DOMNode, Bindings]]:
"""Get a chain of nodes and bindings to consider. If no widget is focused, returns the bindings from both the screen and the app level bindings. Otherwise, combines all the bindings from the currently focused node up the DOM to the root App. """Get a chain of nodes and bindings to consider.
If no widget is focused, returns the bindings from both the screen and the app level bindings.
Otherwise, combines all the bindings from the currently focused node up the DOM to the root App.
Returns: Returns:
List of DOM nodes and their bindings. List of DOM nodes and their bindings.
""" """
focused = self.focused focused = self.focused
namespace_bindings: list[tuple[DOMNode, Bindings]] namespace_bindings: list[tuple[DOMNode, Bindings]]
screen = self.screen
if focused is None: if focused is None:
namespace_bindings = [ if screen.is_modal:
(self.screen, self.screen._bindings), namespace_bindings = [
(self, self._bindings), (self.screen, self.screen._bindings),
] ]
else:
namespace_bindings = [
(self.screen, self.screen._bindings),
(self, self._bindings),
]
else: else:
namespace_bindings = [ if screen.is_modal:
(node, node._bindings) for node in focused.ancestors_with_self namespace_bindings = [
] (node, node._bindings) for node in focused.ancestors
]
else:
namespace_bindings = [
(node, node._bindings) for node in focused.ancestors_with_self
]
return namespace_bindings return namespace_bindings
async def check_bindings(self, key: str, priority: bool = False) -> bool: async def check_bindings(self, key: str, priority: bool = False) -> bool:
@@ -2127,6 +2169,8 @@ class App(Generic[ReturnType], DOMNode):
async def _on_resize(self, event: events.Resize) -> None: async def _on_resize(self, event: events.Resize) -> None:
event.stop() event.stop()
self.screen.post_message(event) self.screen.post_message(event)
for screen in self._background_screens:
screen.post_message(event)
def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]:
"""Detach a list of widgets from the DOM. """Detach a list of widgets from the DOM.

View File

@@ -63,7 +63,6 @@ class BorderApp(App):
event.button.id, event.button.id,
self.stylesheet._variables["secondary"], self.stylesheet._variables["secondary"],
) )
self.bell()
app = BorderApp() app = BorderApp()

View File

@@ -10,20 +10,21 @@ if typing.TYPE_CHECKING:
VALID_VISIBILITY: Final = {"visible", "hidden"} VALID_VISIBILITY: Final = {"visible", "hidden"}
VALID_DISPLAY: Final = {"block", "none"} VALID_DISPLAY: Final = {"block", "none"}
VALID_BORDER: Final = { VALID_BORDER: Final = {
"none",
"hidden",
"ascii", "ascii",
"round",
"blank", "blank",
"solid",
"double",
"dashed", "dashed",
"double",
"heavy", "heavy",
"inner", "hidden",
"outer",
"hkey", "hkey",
"vkey", "inner",
"none",
"outer",
"round",
"solid",
"tall", "tall",
"thick",
"vkey",
"wide", "wide",
} }
VALID_EDGE: Final = {"top", "right", "bottom", "left"} VALID_EDGE: Final = {"top", "right", "bottom", "left"}

View File

@@ -16,6 +16,7 @@ EdgeType = Literal[
"blank", "blank",
"round", "round",
"solid", "solid",
"thick",
"double", "double",
"dashed", "dashed",
"heavy", "heavy",

View File

@@ -130,7 +130,6 @@ class GridLayout(Layout):
placements: list[WidgetPlacement] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append add_placement = placements.append
fraction_unit = Fraction(1)
widgets: list[Widget] = [] widgets: list[Widget] = []
add_widget = widgets.append add_widget = widgets.append
max_column = len(columns) - 1 max_column = len(columns) - 1
@@ -145,7 +144,10 @@ class GridLayout(Layout):
y2, cell_height = rows[min(max_row, row + row_span)] y2, cell_height = rows[min(max_row, row + row_span)]
cell_size = Size(cell_width + x2 - x, cell_height + y2 - y) cell_size = Size(cell_width + x2 - x, cell_height + y2 - y)
width, height, margin = widget._get_box_model( width, height, margin = widget._get_box_model(
cell_size, viewport, fraction_unit, fraction_unit cell_size,
viewport,
Fraction(cell_size.width),
Fraction(cell_size.height),
) )
region = ( region = (
Region(x, y, int(width + margin.width), int(height + margin.height)) Region(x, y, int(width + margin.width), int(height + margin.height))

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.segment import Segment
from rich.style import Style
from ..color import Color
if TYPE_CHECKING:
from ..screen import Screen
class BackgroundScreen:
"""Tints a renderable and removes links / meta."""
def __init__(
self,
screen: Screen,
color: Color,
) -> None:
"""Initialize a BackgroundScreen instance.
Args:
screen: A Screen instance.
color: A color (presumably with alpha).
"""
self.screen = screen
"""Screen to process."""
self.color = color
"""Color to apply (should have alpha)."""
@classmethod
def process_segments(
cls, segments: Iterable[Segment], color: Color
) -> Iterable[Segment]:
"""Apply tint to segments and remove meta + styles
Args:
segments: Incoming segments.
color: Color of tint.
Returns:
Segments with applied tint.
"""
from_rich_color = Color.from_rich_color
style_from_color = Style.from_color
_Segment = Segment
NULL_STYLE = Style()
for segment in segments:
text, style, control = segment
if control:
yield segment
else:
style = NULL_STYLE if style is None else style.clear_meta_and_links()
yield _Segment(
text,
(
style
+ style_from_color(
(
(from_rich_color(style.color) + color).rich_color
if style.color is not None
else None
),
(
(from_rich_color(style.bgcolor) + color).rich_color
if style.bgcolor is not None
else None
),
)
),
control,
)
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
segments = console.render(self.screen._compositor, options)
color = self.color
return self.process_segments(segments, color)

View File

@@ -12,7 +12,11 @@ from ..color import Color
class Tint: class Tint:
"""Applies a color on top of an existing renderable.""" """Applies a color on top of an existing renderable."""
def __init__(self, renderable: RenderableType, color: Color) -> None: def __init__(
self,
renderable: RenderableType,
color: Color,
) -> None:
"""Wrap a renderable to apply a tint color. """Wrap a renderable to apply a tint color.
Args: Args:

View File

@@ -9,12 +9,14 @@ from rich.style import Style
from . import errors, events, messages from . import errors, events, messages
from ._callback import invoke from ._callback import invoke
from ._compositor import Compositor, MapGeometry from ._compositor import Compositor, MapGeometry
from ._context import visible_screen_stack
from ._types import CallbackType from ._types import CallbackType
from .css.match import match from .css.match import match
from .css.parse import parse_selectors from .css.parse import parse_selectors
from .css.query import QueryType from .css.query import QueryType
from .geometry import Offset, Region, Size from .geometry import Offset, Region, Size
from .reactive import Reactive from .reactive import Reactive
from .renderables.background_screen import BackgroundScreen
from .renderables.blank import Blank from .renderables.blank import Blank
from .timer import Timer from .timer import Timer
from .widget import Widget from .widget import Widget
@@ -30,10 +32,6 @@ UPDATE_PERIOD: Final[float] = 1 / 120
class Screen(Widget): class Screen(Widget):
"""A widget for the root of the app.""" """A widget for the root of the app."""
# The screen is a special case and unless a class that inherits from us
# says otherwise, all screen-level bindings should be treated as having
# priority.
DEFAULT_CSS = """ DEFAULT_CSS = """
Screen { Screen {
layout: vertical; layout: vertical;
@@ -43,6 +41,9 @@ class Screen(Widget):
""" """
focused: Reactive[Widget | None] = Reactive(None) focused: Reactive[Widget | None] = Reactive(None)
"""The focused widget or `None` for no focus."""
stack_updates: Reactive[int] = Reactive(0, repaint=False)
"""An integer that updates when the screen is resumed."""
def __init__( def __init__(
self, self,
@@ -50,12 +51,17 @@ class Screen(Widget):
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
) -> None: ) -> None:
self._modal = False
super().__init__(name=name, id=id, classes=classes) super().__init__(name=name, id=id, classes=classes)
self._compositor = Compositor() self._compositor = Compositor()
self._dirty_widgets: set[Widget] = set() self._dirty_widgets: set[Widget] = set()
self._update_timer: Timer | None = None self._update_timer: Timer | None = None
self._callbacks: list[CallbackType] = [] self._callbacks: list[CallbackType] = []
self._max_idle = UPDATE_PERIOD
@property
def is_modal(self) -> bool:
"""Is the screen modal?"""
return self._modal
@property @property
def is_transparent(self) -> bool: def is_transparent(self) -> bool:
@@ -82,6 +88,14 @@ class Screen(Widget):
def render(self) -> RenderableType: def render(self) -> RenderableType:
background = self.styles.background background = self.styles.background
try:
base_screen = visible_screen_stack.get().pop()
except IndexError:
base_screen = None
if base_screen is not None and 1 > background.a > 0:
return BackgroundScreen(base_screen, background)
if background.is_transparent: if background.is_transparent:
return self.app.render() return self.app.render()
return Blank(background) return Blank(background)
@@ -368,6 +382,7 @@ class Screen(Widget):
self._repaint_required = False self._repaint_required = False
if self._dirty_widgets: if self._dirty_widgets:
self._on_timer_update()
self.update_timer.resume() self.update_timer.resume()
# The Screen is idle - a good opportunity to invoke the scheduled callbacks # The Screen is idle - a good opportunity to invoke the scheduled callbacks
@@ -376,9 +391,12 @@ class Screen(Widget):
def _on_timer_update(self) -> None: def _on_timer_update(self) -> None:
"""Called by the _update_timer.""" """Called by the _update_timer."""
# Render widgets together # Render widgets together
if self._dirty_widgets: if self._dirty_widgets and self.is_current:
self._compositor.update_widgets(self._dirty_widgets) self._compositor.update_widgets(self._dirty_widgets)
self.app._display(self, self._compositor.render()) update = self._compositor.render_update(
screen_stack=self.app._background_screens
)
self.app._display(self, update)
self._dirty_widgets.clear() self._dirty_widgets.clear()
if self._callbacks: if self._callbacks:
self.post_message(events.InvokeCallbacks()) self.post_message(events.InvokeCallbacks())
@@ -393,7 +411,9 @@ class Screen(Widget):
"""If there are scheduled callbacks to run, call them and clear """If there are scheduled callbacks to run, call them and clear
the callback queue.""" the callback queue."""
if self._callbacks: if self._callbacks:
display_update = self._compositor.render() display_update = self._compositor.render_update(
screen_stack=self.app._background_screens
)
self.app._display(self, display_update) self.app._display(self, display_update)
callbacks = self._callbacks[:] callbacks = self._callbacks[:]
self._callbacks.clear() self._callbacks.clear()
@@ -417,7 +437,6 @@ class Screen(Widget):
size = self.outer_size if size is None else size size = self.outer_size if size is None else size
if not size: if not size:
return return
self._compositor.update_widgets(self._dirty_widgets) self._compositor.update_widgets(self._dirty_widgets)
self.update_timer.pause() self.update_timer.pause()
ResizeEvent = events.Resize ResizeEvent = events.Resize
@@ -477,8 +496,12 @@ class Screen(Widget):
except Exception as error: except Exception as error:
self.app._handle_exception(error) self.app._handle_exception(error)
return return
display_update = self._compositor.render(full=full) if self.is_current:
self.app._display(self, display_update) display_update = self._compositor.render_update(
full=full, screen_stack=self.app._background_screens
)
self.app._display(self, display_update)
if not self.app._dom_ready: if not self.app._dom_ready:
self.app.post_message(events.Ready()) self.app.post_message(events.Ready())
self.app._dom_ready = True self.app._dom_ready = True
@@ -506,15 +529,25 @@ class Screen(Widget):
def _screen_resized(self, size: Size): def _screen_resized(self, size: Size):
"""Called by App when the screen is resized.""" """Called by App when the screen is resized."""
self._refresh_layout(size, full=True) self._refresh_layout(size, full=True)
self.refresh()
def _on_screen_resume(self) -> None: def _on_screen_resume(self) -> None:
"""Called by the App""" """Screen has resumed."""
self.stack_updates += 1
size = self.app.size size = self.app.size
self._refresh_layout(size, full=True) self._refresh_layout(size, full=True)
self.refresh()
def _on_screen_suspend(self) -> None:
"""Screen has suspended."""
self.app._set_mouse_over(None)
self.stack_updates += 1
async def _on_resize(self, event: events.Resize) -> None: async def _on_resize(self, event: events.Resize) -> None:
event.stop() event.stop()
self._screen_resized(event.size) self._screen_resized(event.size)
for screen in self.app._background_screens:
screen._screen_resized(event.size)
def _handle_mouse_move(self, event: events.MouseMove) -> None: def _handle_mouse_move(self, event: events.MouseMove) -> None:
try: try:
@@ -590,3 +623,25 @@ class Screen(Widget):
scroll_widget._forward_event(event) scroll_widget._forward_event(event)
else: else:
self.post_message(event) self.post_message(event)
@rich.repr.auto
class ModalScreen(Screen):
"""A screen with bindings that take precedence over the App's key bindings."""
DEFAULT_CSS = """
ModalScreen {
layout: vertical;
overflow-y: auto;
background: $background 60%;
}
"""
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self._modal = True

View File

@@ -5,6 +5,7 @@ from typing import Iterable, Iterator
import rich.repr import rich.repr
from rich.cells import cell_len, set_cell_size from rich.cells import cell_len, set_cell_size
from rich.console import Console, ConsoleOptions, RenderResult
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style, StyleType from rich.style import Style, StyleType
@@ -27,6 +28,21 @@ def get_line_length(segments: Iterable[Segment]) -> int:
return sum(_cell_len(text) for text, _, control in segments if not control) return sum(_cell_len(text) for text, _, control in segments if not control)
class StripRenderable:
"""A renderable which renders a list of strips in to lines.."""
def __init__(self, strips: list[Strip]) -> None:
self._strips = strips
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
new_line = Segment.line()
for strip in self._strips:
yield from strip
yield new_line
@rich.repr.auto @rich.repr.auto
class Strip: class Strip:
"""Represents a 'strip' (horizontal line) of a Textual Widget. """Represents a 'strip' (horizontal line) of a Textual Widget.

View File

@@ -291,14 +291,20 @@ class Widget(DOMNode):
if self in children: if self in children:
raise WidgetError("A widget can't be its own parent") raise WidgetError("A widget can't be its own parent")
for child in children:
if not isinstance(child, Widget):
raise TypeError(
f"Widget positional arguments must be Widget subclasses; not {child!r}"
)
self._add_children(*children) self._add_children(*children)
self.disabled = disabled self.disabled = disabled
virtual_size = Reactive(Size(0, 0), layout=True) virtual_size = Reactive(Size(0, 0), layout=True)
auto_width = Reactive(True) auto_width = Reactive(True)
auto_height = Reactive(True) auto_height = Reactive(True)
has_focus = Reactive(False) has_focus = Reactive(False, repaint=False)
mouse_over = Reactive(False) mouse_over = Reactive(False, repaint=False)
scroll_x = Reactive(0.0, repaint=False, layout=False) scroll_x = Reactive(0.0, repaint=False, layout=False)
scroll_y = Reactive(0.0, repaint=False, layout=False) scroll_y = Reactive(0.0, repaint=False, layout=False)
scroll_target_x = Reactive(0.0, repaint=False) scroll_target_x = Reactive(0.0, repaint=False)
@@ -376,7 +382,7 @@ class Widget(DOMNode):
return self.styles.offset.resolve(self.size, self.app.size) return self.styles.offset.resolve(self.size, self.app.size)
@offset.setter @offset.setter
def offset(self, offset: Offset) -> None: def offset(self, offset: tuple[int, int]) -> None:
self.styles.offset = ScalarOffset.from_offset(offset) self.styles.offset = ScalarOffset.from_offset(offset)
def __enter__(self) -> Self: def __enter__(self) -> Self:
@@ -2272,7 +2278,6 @@ class Widget(DOMNode):
Tuples of scrollbar Widget and region. Tuples of scrollbar Widget and region.
""" """
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
scrollbar_size_horizontal = self.scrollbar_size_horizontal scrollbar_size_horizontal = self.scrollbar_size_horizontal
@@ -2779,7 +2784,7 @@ class Widget(DOMNode):
widgets = compose(self) widgets = compose(self)
except TypeError as error: except TypeError as error:
raise TypeError( raise TypeError(
f"{self!r} compose() returned an invalid response; {error}" f"{self!r} compose() method returned an invalid result; {error}"
) from error ) from error
except Exception: except Exception:
self.app.panic(Traceback()) self.app.panic(Traceback())

View File

@@ -66,9 +66,10 @@ class Footer(Widget):
self.refresh() self.refresh()
def on_mount(self) -> None: def on_mount(self) -> None:
self.watch(self.screen, "focused", self._focus_changed) self.watch(self.screen, "focused", self._bindings_changed)
self.watch(self.screen, "stack_updates", self._bindings_changed)
def _focus_changed(self, focused: Widget | None) -> None: def _bindings_changed(self, focused: Widget | None) -> None:
self._key_text = None self._key_text = None
self.refresh() self.refresh()
@@ -78,7 +79,8 @@ class Footer(Widget):
async def on_leave(self, event: events.Leave) -> None: async def on_leave(self, event: events.Leave) -> None:
"""Clear any highlight when the mouse leaves the widget""" """Clear any highlight when the mouse leaves the widget"""
self.highlight_key = None if self.screen.is_current:
self.highlight_key = None
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__() yield from super().__rich_repr__()

File diff suppressed because one or more lines are too long