mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
50
docs/examples/guide/screens/modal02.py
Normal file
50
docs/examples/guide/screens/modal02.py
Normal 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()
|
||||||
@@ -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.
|
||||||
|
|||||||
16
docs/images/screens/screen_alpha.excalidraw.svg
Normal file
16
docs/images/screens/screen_alpha.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.8 KiB |
2382
poetry.lock
generated
2382
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ EdgeType = Literal[
|
|||||||
"blank",
|
"blank",
|
||||||
"round",
|
"round",
|
||||||
"solid",
|
"solid",
|
||||||
|
"thick",
|
||||||
"double",
|
"double",
|
||||||
"dashed",
|
"dashed",
|
||||||
"heavy",
|
"heavy",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
83
src/textual/renderables/background_screen.py
Normal file
83
src/textual/renderables/background_screen.py
Normal 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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user