mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into screen-modes
This commit is contained in:
22
.github/workflows/black_format.yml
vendored
Normal file
22
.github/workflows/black_format.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
name: Black format check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/black_format.yml'
|
||||
- '**.py'
|
||||
|
||||
jobs:
|
||||
black-format-check:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install black
|
||||
run: python -m pip install black
|
||||
- name: Run black
|
||||
run: black --check src
|
||||
3
.github/workflows/comment.yml
vendored
3
.github/workflows/comment.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: issues
|
||||
name: Closed issue comment
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
3
.github/workflows/new_issue.yml
vendored
3
.github/workflows/new_issue.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: issues
|
||||
name: FAQ issue comment
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }}
|
||||
|
||||
26
.github/workflows/pythonpackage.yml
vendored
26
.github/workflows/pythonpackage.yml
vendored
@@ -3,6 +3,7 @@ name: Test Textual module
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/pythonpackage.yml'
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- '**.css'
|
||||
@@ -21,27 +22,28 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install and configure Poetry
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- name: Install and configure Poetry # This could be cached, too...
|
||||
uses: snok/install-poetry@v1.3.3
|
||||
with:
|
||||
version: 1.4.2
|
||||
virtualenvs-in-project: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
- name: Install dependencies
|
||||
run: poetry install --extras "dev"
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Format check with black
|
||||
run: |
|
||||
source $VENV
|
||||
make format-check
|
||||
# - name: Typecheck with mypy
|
||||
# run: |
|
||||
# source $VENV
|
||||
# make typecheck
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -6,11 +6,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
|
||||
## Unrealeased
|
||||
## [0.25.0] - 2023-05-17
|
||||
|
||||
### Changed
|
||||
|
||||
- App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521
|
||||
- `DirectoryTree` now loads directory contents in a worker https://github.com/Textualize/textual/issues/2456
|
||||
- Only a single error will be written by default, unless in dev mode ("debug" in App.features) https://github.com/Textualize/textual/issues/2480
|
||||
- Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743
|
||||
- Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575
|
||||
- `MessagePump.call_after_refresh` and `MessagePump.call_later` will now return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -18,6 +23,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Fixed `TreeNode.expand` and `TreeNode.expand_all` not posting a `Tree.NodeExpanded` message https://github.com/Textualize/textual/issues/2535
|
||||
- Fixed `TreeNode.collapse` and `TreeNode.collapse_all` not posting a `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
|
||||
- Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
|
||||
- `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544
|
||||
- Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563
|
||||
- `Screen.AUTO_FOCUS` now focuses the first _focusable_ widget that matches the selector https://github.com/Textualize/textual/issues/2578
|
||||
- `Screen.AUTO_FOCUS` now works on the default screen on startup https://github.com/Textualize/textual/pull/2581
|
||||
- Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583
|
||||
- Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525
|
||||
- Fix not being able to use CSS classes with `Tab` https://github.com/Textualize/textual/pull/2589
|
||||
|
||||
### Added
|
||||
|
||||
- Class variable `AUTO_FOCUS` to screens https://github.com/Textualize/textual/issues/2457
|
||||
- Added `NULL_SPACING` and `NULL_REGION` to geometry.py
|
||||
|
||||
## [0.24.1] - 2023-05-08
|
||||
|
||||
@@ -948,6 +965,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
||||
- New handler system for messages that doesn't require inheritance
|
||||
- Improved traceback handling
|
||||
|
||||
[0.25.0]: https://github.com/Textualize/textual/compare/v0.24.1...v0.25.0
|
||||
[0.24.1]: https://github.com/Textualize/textual/compare/v0.24.0...v0.24.1
|
||||
[0.24.0]: https://github.com/Textualize/textual/compare/v0.23.0...v0.24.0
|
||||
[0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0
|
||||
|
||||
@@ -17,4 +17,17 @@
|
||||
<meta property="og:description" content="Textual is a TUI framework for Python, inspired by modern web development.">
|
||||
<meta property="og:image" content="https://raw.githubusercontent.com/Textualize/textual/main/imgs/textual.png">
|
||||
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("https://unpkg.com/@excalidraw/excalidraw@0.12.0/dist/excalidraw-assets/Virgil.woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cascadia";
|
||||
src: url("https://unpkg.com/@excalidraw/excalidraw@0.12.0/dist/excalidraw-assets/Cascadia.woff2");
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -42,6 +42,7 @@ class ModalApp(App):
|
||||
yield Footer()
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
"""Action to display the quit dialog."""
|
||||
self.push_screen(QuitScreen())
|
||||
|
||||
|
||||
|
||||
57
docs/examples/guide/screens/modal03.py
Normal file
57
docs/examples/guide/screens/modal03.py
Normal file
@@ -0,0 +1,57 @@
|
||||
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[bool]): # (1)!
|
||||
"""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.dismiss(True)
|
||||
else:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class ModalApp(App):
|
||||
"""An 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:
|
||||
"""Action to display the quit dialog."""
|
||||
|
||||
def check_quit(quit: bool) -> None:
|
||||
"""Called when QuitScreen is dismissed."""
|
||||
if quit:
|
||||
self.exit()
|
||||
|
||||
self.push_screen(QuitScreen(), check_quit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ModalApp()
|
||||
app.run()
|
||||
69
docs/examples/how-to/layout.py
Normal file
69
docs/examples/how-to/layout.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Tweet(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Tweet {
|
||||
height: 5;
|
||||
width: 1fr;
|
||||
border: tall $background;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Column(VerticalScroll):
|
||||
DEFAULT_CSS = """
|
||||
Column {
|
||||
height: 1fr;
|
||||
width: 32;
|
||||
margin: 0 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for tweet_no in range(1, 20):
|
||||
yield Tweet(id=f"Tweet{tweet_no}")
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
with HorizontalScroll():
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
CSS_PATH = "layout.css"
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
27
docs/examples/how-to/layout01.py
Normal file
27
docs/examples/how-to/layout01.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder): # (1)!
|
||||
pass
|
||||
|
||||
|
||||
class Footer(Placeholder): # (2)!
|
||||
pass
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header") # (3)!
|
||||
yield Footer(id="Footer") # (4)!
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
37
docs/examples/how-to/layout02.py
Normal file
37
docs/examples/how-to/layout02.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
48
docs/examples/how-to/layout03.py
Normal file
48
docs/examples/how-to/layout03.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ColumnsContainer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
ColumnsContainer {
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
border: solid white;
|
||||
}
|
||||
""" # (1)!
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
yield ColumnsContainer(id="Columns")
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
39
docs/examples/how-to/layout04.py
Normal file
39
docs/examples/how-to/layout04.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
yield HorizontalScroll() # (1)!
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
53
docs/examples/how-to/layout05.py
Normal file
53
docs/examples/how-to/layout05.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Tweet(Placeholder):
|
||||
pass
|
||||
|
||||
|
||||
class Column(VerticalScroll):
|
||||
def compose(self) -> ComposeResult:
|
||||
for tweet_no in range(1, 20):
|
||||
yield Tweet(id=f"Tweet{tweet_no}")
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
with HorizontalScroll():
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
67
docs/examples/how-to/layout06.py
Normal file
67
docs/examples/how-to/layout06.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Tweet(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Tweet {
|
||||
height: 5;
|
||||
width: 1fr;
|
||||
border: tall $background;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Column(VerticalScroll):
|
||||
DEFAULT_CSS = """
|
||||
Column {
|
||||
height: 1fr;
|
||||
width: 32;
|
||||
margin: 0 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for tweet_no in range(1, 20):
|
||||
yield Tweet(id=f"Tweet{tweet_no}")
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
with HorizontalScroll():
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
@@ -219,3 +219,40 @@ Let's see what happens when we use `ModalScreen`.
|
||||
|
||||
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.
|
||||
|
||||
## Returning data from screens
|
||||
|
||||
It is a common requirement for screens to be able to return data.
|
||||
For instance, you may want a screen to show a dialog and have the result of that dialog processed *after* the screen has been popped.
|
||||
|
||||
To return data from a screen, call [`dismiss()`][textual.screen.dismiss] on the screen with the data you wish to return.
|
||||
This will pop the screen and invoke a callback set when the screen was pushed (with [`push_screen`][textual.app.push_screen]).
|
||||
|
||||
Let's modify the previous example to use `dismiss` rather than an explicit `pop_screen`.
|
||||
|
||||
=== "modal03.py"
|
||||
|
||||
```python title="modal03.py" hl_lines="15 27-30 47-50 52"
|
||||
--8<-- "docs/examples/guide/screens/modal03.py"
|
||||
```
|
||||
|
||||
1. See below for an explanation of the `[bool]`
|
||||
|
||||
=== "modal01.css"
|
||||
|
||||
```sass title="modal01.css"
|
||||
--8<-- "docs/examples/guide/screens/modal01.css"
|
||||
```
|
||||
|
||||
In the `on_button_pressed` message handler we call `dismiss` with a boolean that indicates if the user has chosen to quit the app.
|
||||
This boolean is passed to the `check_quit` function we provided when `QuitScreen` was pushed.
|
||||
|
||||
Although this example behaves the same as the previous code, it is more flexible because it has removed responsibility for exiting from the modal screen to the caller.
|
||||
This makes it easier for the app to perform any cleanup actions prior to exiting, for example.
|
||||
|
||||
Returning data in this way can help keep your code manageable by making it easy to re-use your `Screen` classes in other contexts.
|
||||
|
||||
### Typing screen results
|
||||
|
||||
You may have noticed in the previous example that we changed the base class to `ModalScreen[bool]`.
|
||||
The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
|
||||
|
||||
200
docs/how-to/design-a-layout.md
Normal file
200
docs/how-to/design-a-layout.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Design a Layout
|
||||
|
||||
This article discusses an approach you can take when designing the layout for your applications.
|
||||
|
||||
Textual's layout system is flexible enough to accommodate just about any application design you could conceive of, but it may be hard to know where to start. We will go through a few tips which will help you get over the initial hurdle of designing an application layout.
|
||||
|
||||
|
||||
## Tip 1. Make a sketch
|
||||
|
||||
The initial design of your application is best done with a sketch.
|
||||
You could use a drawing package such as [Excalidraw](https://excalidraw.com/) for your sketch, but pen and paper is equally as good.
|
||||
|
||||
Start by drawing a rectangle to represent a blank terminal, then draw a rectangle for each element in your application. Annotate each of the rectangles with the content they will contain, and note wether they will scroll (and in what direction).
|
||||
|
||||
For the purposes of this article we are going to design a layout for a Twitter or Mastodon client, which will have a header / footer and a number of columns.
|
||||
|
||||
!!! note
|
||||
|
||||
The approach we are discussing here is applicable even if the app you want to build looks nothing like our sketch!
|
||||
|
||||
Here's our sketch:
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/how-to/layout.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
It's rough, but it's all we need.
|
||||
|
||||
|
||||
## Tip 2. Work outside in
|
||||
|
||||
Like a sculpture with a block of marble, it is best to work from the outside towards the center.
|
||||
If your design has fixed elements (like a header, footer, or sidebar), start with those first.
|
||||
|
||||
In our sketch we have a header and footer.
|
||||
Since these are the outermost widgets, we will begin by adding them.
|
||||
|
||||
!!! tip
|
||||
|
||||
Textual has builtin [Header](../widgets/header.md) and [Footer](../widgets/footer.md) widgets which you could use in a real application.
|
||||
|
||||
The following example defines an [app](../guide/app.md), a [screen](../guide/screens.md), and our header and footer widgets.
|
||||
Since we're starting from scratch and don't have any functionality for our widgets, we are going to use the [Placeholder][textual.widgets.Placeholder] widget to help us visualize our design.
|
||||
|
||||
In a real app, we would replace these placeholders with more useful content.
|
||||
|
||||
=== "layout01.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/how-to/layout01.py"
|
||||
```
|
||||
|
||||
1. The Header widget extends Placeholder.
|
||||
2. The footer widget extends Placeholder.
|
||||
3. Creates the header widget (the id will be displayed within the placeholder widget).
|
||||
4. Creates the footer widget.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/how-to/layout01.py"}
|
||||
```
|
||||
|
||||
## Tip 3. Apply docks
|
||||
|
||||
This app works, but the header and footer don't behave as expected.
|
||||
We want both of these widgets to be fixed to an edge of the screen and limited in height.
|
||||
In Textual this is known as *docking* which you can apply with the [dock](../styles/dock.md) rule.
|
||||
|
||||
We will dock the header and footer to the top and bottom edges of the screen respectively, by adding a little [CSS](../guide/CSS.md) to the widget classes:
|
||||
|
||||
=== "layout02.py"
|
||||
|
||||
```python hl_lines="7-12 16-21"
|
||||
--8<-- "docs/examples/how-to/layout02.py"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/how-to/layout02.py"}
|
||||
```
|
||||
|
||||
The `DEFAULT_CSS` class variable is used to set CSS directly in Python code.
|
||||
We could define these in an external CSS file, but writing the CSS inline like this can be convenient if it isn't too complex.
|
||||
|
||||
When you dock a widget, it reduces the available area for other widgets.
|
||||
This means that Textual will automatically compensate for the 6 additional lines reserved for the header and footer.
|
||||
|
||||
## Tip 4. Use FR Units for flexible things
|
||||
|
||||
After we've added the header and footer, we want the remaining space to be used for the main interface, which will contain the columns in the sketch.
|
||||
This area is flexible (will change according to the size of the terminal), so how do we ensure that it takes up precisely the space needed?
|
||||
|
||||
The simplest way is to use [fr](../css_types/scalar.md#fraction) units.
|
||||
By setting both the width and height to `1fr`, we are telling Textual to divide the space equally amongst the remaining widgets.
|
||||
There is only a single widget, so that widget will fill all of the remaining space.
|
||||
|
||||
Let's make that change.
|
||||
|
||||
=== "layout03.py"
|
||||
|
||||
```python hl_lines="24-31 38"
|
||||
--8<-- "docs/examples/how-to/layout03.py"
|
||||
```
|
||||
|
||||
1. Here's where we set the width and height to `1fr`. We also add a border just to illustrate the dimensions better.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/how-to/layout03.py"}
|
||||
```
|
||||
|
||||
As you can see, the central Columns area will resize with the terminal window.
|
||||
|
||||
## Tip 5. Use containers
|
||||
|
||||
Before we add content to the Columns area, we have an opportunity to simplify.
|
||||
Rather than extend `Placeholder` for our `ColumnsContainer` widget, we can use one of the builtin *containers*.
|
||||
A container is simply a widget designed to *contain* other widgets.
|
||||
Containers are styled with `fr` units to fill the remaining space so we won't need to add any more CSS.
|
||||
|
||||
Let's replace the `ColumnsContainer` class in the previous example with a `HorizontalScroll` container, which also adds an automatic horizontal scrollbar.
|
||||
|
||||
=== "layout04.py"
|
||||
|
||||
```python hl_lines="2 29"
|
||||
--8<-- "docs/examples/how-to/layout04.py"
|
||||
```
|
||||
|
||||
1. The builtin container widget.
|
||||
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/how-to/layout04.py"}
|
||||
```
|
||||
|
||||
The container will appear as blank space until we add some widgets to it.
|
||||
|
||||
Let's add the columns to the `HorizontalScroll`.
|
||||
A column is itself a container which will have a vertical scrollbar, so we will define our `Column` by subclassing `VerticalScroll`.
|
||||
In a real app, these columns will likely be added dynamically from some kind of configuration, but let's add 4 to visualize the layout.
|
||||
|
||||
We will also define a `Tweet` placeholder and add a few to each column.
|
||||
|
||||
=== "layout05.py"
|
||||
|
||||
```python hl_lines="2 25-26 29-32 39-43"
|
||||
--8<-- "docs/examples/how-to/layout05.py"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/how-to/layout05.py"}
|
||||
```
|
||||
|
||||
Note from the output that each `Column` takes a quarter of the screen width.
|
||||
This happens because `Column` extends a container which has a width of `1fr`.
|
||||
|
||||
It makes more sense for a column in a Twitter / Mastodon client to use a fixed width.
|
||||
Let's set the width of the columns to 32.
|
||||
|
||||
We also want to reduce the height of each "tweet".
|
||||
In the real app, you might set the height to "auto" so it fits the content, but lets set it to 5 lines for now.
|
||||
|
||||
Here's the final example and a reminder of the sketch.
|
||||
|
||||
=== "layout06.py"
|
||||
|
||||
```python hl_lines="25-32 36-46"
|
||||
--8<-- "docs/examples/how-to/layout06.py"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/how-to/layout06.py" columns="100" lines="32"}
|
||||
```
|
||||
|
||||
=== "Sketch"
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/how-to/layout.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
|
||||
A layout like this is a great starting point.
|
||||
In a real app, you would start replacing each of the placeholders with [builtin](../widget_gallery.md) or [custom](../guide/widgets.md) widgets.
|
||||
|
||||
|
||||
## Summary
|
||||
|
||||
Layout is the first thing you will tackle when building a Textual app.
|
||||
The following tips will help you get started.
|
||||
|
||||
1. Make a sketch (pen and paper is fine).
|
||||
2. Work outside in. Start with the entire space of the terminal, add the outermost content first.
|
||||
3. Dock fixed widgets. If the content doesn't move or scroll, you probably want to *dock* it.
|
||||
4. Make use of `fr` for flexible space within layouts.
|
||||
5. Use containers to contain other widgets, particularly if they scroll!
|
||||
|
||||
If you need further help, we are here to [help](/help/).
|
||||
7
docs/how-to/index.md
Normal file
7
docs/how-to/index.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# How To
|
||||
|
||||
Welcome to the How To section.
|
||||
|
||||
Here you will find How To articles which cover various topics at a higher level than the Guide or Reference.
|
||||
We will be adding more articles in the future.
|
||||
If there is anything you would like to see covered, [open an issue](https://github.com/Textualize/textual/issues) in the Textual repository!
|
||||
16
docs/images/how-to/layout.excalidraw.svg
Normal file
16
docs/images/how-to/layout.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 44 KiB |
@@ -157,6 +157,7 @@ nav:
|
||||
- "widgets/text_log.md"
|
||||
- "widgets/tree.md"
|
||||
- API:
|
||||
- "api/index.md"
|
||||
- "api/app.md"
|
||||
- "api/await_remove.md"
|
||||
- "api/binding.md"
|
||||
@@ -189,6 +190,9 @@ nav:
|
||||
- "api/work.md"
|
||||
- "api/worker.md"
|
||||
- "api/worker_manager.md"
|
||||
- "How To":
|
||||
- "how-to/index.md"
|
||||
- "how-to/design-a-layout.md"
|
||||
- "roadmap.md"
|
||||
- "Blog":
|
||||
- blog/index.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.24.1"
|
||||
version = "0.25.0"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
@@ -83,5 +83,5 @@ markers = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
requires = ["poetry-core>=1.2.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
BIN
reference/spacing.monopic
Normal file
BIN
reference/spacing.monopic
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from fractions import Fraction
|
||||
from operator import attrgetter
|
||||
from typing import TYPE_CHECKING, Sequence
|
||||
from typing import TYPE_CHECKING, Iterable, Mapping, Sequence
|
||||
|
||||
from ._layout import DockArrangeResult, WidgetPlacement
|
||||
from ._partition import partition
|
||||
@@ -16,6 +16,21 @@ if TYPE_CHECKING:
|
||||
TOP_Z = 2**31 - 1
|
||||
|
||||
|
||||
def _build_dock_layers(widgets: Iterable[Widget]) -> Mapping[str, Sequence[Widget]]:
|
||||
"""Organize widgets into layers.
|
||||
|
||||
Args:
|
||||
widgets: The widgets.
|
||||
|
||||
Returns:
|
||||
A mapping of layer name onto the widgets within the layer.
|
||||
"""
|
||||
layers: defaultdict[str, list[Widget]] = defaultdict(list)
|
||||
for widget in widgets:
|
||||
layers[widget.layer].append(widget)
|
||||
return layers
|
||||
|
||||
|
||||
def arrange(
|
||||
widget: Widget, children: Sequence[Widget], size: Size, viewport: Size
|
||||
) -> DockArrangeResult:
|
||||
@@ -30,34 +45,86 @@ def arrange(
|
||||
Widget arrangement information.
|
||||
"""
|
||||
|
||||
arrange_widgets: set[Widget] = set()
|
||||
|
||||
dock_layers: defaultdict[str, list[Widget]] = defaultdict(list)
|
||||
for child in children:
|
||||
if child.display:
|
||||
dock_layers[child.layer].append(child)
|
||||
|
||||
width, height = size
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
|
||||
_WidgetPlacement = WidgetPlacement
|
||||
top_z = TOP_Z
|
||||
scroll_spacing = Spacing()
|
||||
null_spacing = Spacing()
|
||||
get_dock = attrgetter("styles.dock")
|
||||
styles = widget.styles
|
||||
|
||||
# Widgets which will be displayed
|
||||
display_widgets = [child for child in children if child.styles.display != "none"]
|
||||
|
||||
# Widgets organized into layers
|
||||
dock_layers = _build_dock_layers(display_widgets)
|
||||
|
||||
layer_region = size.region
|
||||
for widgets in dock_layers.values():
|
||||
region = layer_region
|
||||
|
||||
# Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the
|
||||
# document), and "dock" widgets which are positioned relative to an edge
|
||||
layout_widgets, dock_widgets = partition(get_dock, widgets)
|
||||
|
||||
arrange_widgets.update(dock_widgets)
|
||||
# Arrange docked widgets
|
||||
_dock_placements, dock_spacing = _arrange_dock_widgets(
|
||||
dock_widgets, size, viewport
|
||||
)
|
||||
placements.extend(_dock_placements)
|
||||
|
||||
# Reduce the region to compensate for docked widgets
|
||||
region = region.shrink(dock_spacing)
|
||||
|
||||
if layout_widgets:
|
||||
# Arrange layout widgets (i.e. not docked)
|
||||
layout_placements = widget._layout.arrange(
|
||||
widget,
|
||||
layout_widgets,
|
||||
region.size,
|
||||
)
|
||||
|
||||
scroll_spacing = scroll_spacing.grow_maximum(dock_spacing)
|
||||
|
||||
placement_offset = region.offset
|
||||
# Perform any alignment of the widgets.
|
||||
if styles.align_horizontal != "left" or styles.align_vertical != "top":
|
||||
bounding_region = WidgetPlacement.get_bounds(layout_placements)
|
||||
placement_offset += styles._align_size(
|
||||
bounding_region.size, region.size
|
||||
).clamped
|
||||
|
||||
if placement_offset:
|
||||
# Translate placements if required.
|
||||
layout_placements = WidgetPlacement.translate(
|
||||
layout_placements, placement_offset
|
||||
)
|
||||
|
||||
placements.extend(layout_placements)
|
||||
|
||||
return DockArrangeResult(placements, set(display_widgets), scroll_spacing)
|
||||
|
||||
|
||||
def _arrange_dock_widgets(
|
||||
dock_widgets: Sequence[Widget], size: Size, viewport: Size
|
||||
) -> tuple[list[WidgetPlacement], Spacing]:
|
||||
"""Arrange widgets which are *docked*.
|
||||
|
||||
Args:
|
||||
dock_widgets: Widgets with a non-empty dock.
|
||||
size: Size of the container.
|
||||
viewport: Size of the viewport.
|
||||
|
||||
Returns:
|
||||
A tuple of widget placements, and additional spacing around them
|
||||
"""
|
||||
_WidgetPlacement = WidgetPlacement
|
||||
top_z = TOP_Z
|
||||
width, height = size
|
||||
null_spacing = Spacing()
|
||||
|
||||
top = right = bottom = left = 0
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
append_placement = placements.append
|
||||
|
||||
for dock_widget in dock_widgets:
|
||||
edge = dock_widget.styles.dock
|
||||
|
||||
@@ -70,9 +137,7 @@ def arrange(
|
||||
widget_height = int(widget_height_fraction) + margin.height
|
||||
|
||||
if edge == "bottom":
|
||||
dock_region = Region(
|
||||
0, height - widget_height, widget_width, widget_height
|
||||
)
|
||||
dock_region = Region(0, height - widget_height, widget_width, widget_height)
|
||||
bottom = max(bottom, widget_height)
|
||||
elif edge == "top":
|
||||
dock_region = Region(0, 0, widget_width, widget_height)
|
||||
@@ -81,9 +146,7 @@ def arrange(
|
||||
dock_region = Region(0, 0, widget_width, widget_height)
|
||||
left = max(left, widget_width)
|
||||
elif edge == "right":
|
||||
dock_region = Region(
|
||||
width - widget_width, 0, widget_width, widget_height
|
||||
)
|
||||
dock_region = Region(width - widget_width, 0, widget_width, widget_height)
|
||||
right = max(right, widget_width)
|
||||
else:
|
||||
# Should not occur, mainly to keep Mypy happy
|
||||
@@ -93,44 +156,9 @@ def arrange(
|
||||
(widget_width, widget_height), size
|
||||
)
|
||||
dock_region = dock_region.shrink(margin).translate(align_offset)
|
||||
add_placement(
|
||||
append_placement(
|
||||
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
|
||||
)
|
||||
|
||||
dock_spacing = Spacing(top, right, bottom, left)
|
||||
region = region.shrink(dock_spacing)
|
||||
layout_placements, arranged_layout_widgets = widget._layout.arrange(
|
||||
widget, layout_widgets, region.size
|
||||
)
|
||||
if arranged_layout_widgets:
|
||||
scroll_spacing = scroll_spacing.grow_maximum(dock_spacing)
|
||||
arrange_widgets.update(arranged_layout_widgets)
|
||||
|
||||
placement_offset = region.offset
|
||||
if styles.align_horizontal != "left" or styles.align_vertical != "top":
|
||||
placement_size = Region.from_union(
|
||||
[
|
||||
placement.region.grow(placement.margin)
|
||||
for placement in layout_placements
|
||||
]
|
||||
).size
|
||||
placement_offset += styles._align_size(
|
||||
placement_size, region.size
|
||||
).clamped
|
||||
|
||||
if placement_offset:
|
||||
layout_placements = [
|
||||
_WidgetPlacement(
|
||||
_region + placement_offset,
|
||||
margin,
|
||||
layout_widget,
|
||||
order,
|
||||
fixed,
|
||||
overlay,
|
||||
)
|
||||
for _region, margin, layout_widget, order, fixed, overlay in layout_placements
|
||||
]
|
||||
|
||||
placements.extend(layout_placements)
|
||||
|
||||
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
|
||||
return (placements, dock_spacing)
|
||||
|
||||
@@ -33,7 +33,7 @@ from . import errors
|
||||
from ._cells import cell_len
|
||||
from ._context import visible_screen_stack
|
||||
from ._loop import loop_last
|
||||
from .geometry import NULL_OFFSET, Offset, Region, Size
|
||||
from .geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing
|
||||
from .strip import Strip, StripRenderable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -71,6 +71,8 @@ class MapGeometry(NamedTuple):
|
||||
"""The container [size][textual.geometry.Size] (area not occupied by scrollbars)."""
|
||||
virtual_region: Region
|
||||
"""The [region][textual.geometry.Region] relative to the container (but not necessarily visible)."""
|
||||
dock_gutter: Spacing
|
||||
"""Space from the container reserved by docked widgets."""
|
||||
|
||||
@property
|
||||
def visible_region(self) -> Region:
|
||||
@@ -484,7 +486,7 @@ class Compositor:
|
||||
# Widgets and regions in render order
|
||||
visible_widgets = [
|
||||
(order, widget, region, clip)
|
||||
for widget, (region, order, clip, _, _, _) in map.items()
|
||||
for widget, (region, order, clip, _, _, _, _) in map.items()
|
||||
if in_screen(region) and overlaps(clip, region)
|
||||
]
|
||||
visible_widgets.sort(key=itemgetter(0), reverse=True)
|
||||
@@ -522,6 +524,7 @@ class Compositor:
|
||||
layer_order: int,
|
||||
clip: Region,
|
||||
visible: bool,
|
||||
dock_gutter: Spacing,
|
||||
_MapGeometry: type[MapGeometry] = MapGeometry,
|
||||
) -> None:
|
||||
"""Called recursively to place a widget and its children in the map.
|
||||
@@ -591,10 +594,8 @@ class Compositor:
|
||||
|
||||
get_layer_index = layers_to_index.get
|
||||
|
||||
scroll_spacing = arrange_result.scroll_spacing
|
||||
|
||||
# Add all the widgets
|
||||
for sub_region, margin, sub_widget, z, fixed, overlay in reversed(
|
||||
for sub_region, _, sub_widget, z, fixed, overlay in reversed(
|
||||
placements
|
||||
):
|
||||
layer_index = get_layer_index(sub_widget.layer, 0)
|
||||
@@ -602,11 +603,6 @@ class Compositor:
|
||||
if fixed:
|
||||
widget_region = sub_region + placement_offset
|
||||
else:
|
||||
total_region = total_region.union(
|
||||
sub_region.grow(
|
||||
margin if layer_index else margin + scroll_spacing
|
||||
)
|
||||
)
|
||||
widget_region = sub_region + placement_scroll_offset
|
||||
|
||||
widget_order = order + ((layer_index, z, layer_order),)
|
||||
@@ -629,6 +625,7 @@ class Compositor:
|
||||
layer_order,
|
||||
no_clip if overlay else sub_clip,
|
||||
visible,
|
||||
arrange_result.scroll_spacing,
|
||||
)
|
||||
|
||||
layer_order -= 1
|
||||
@@ -646,6 +643,7 @@ class Compositor:
|
||||
container_size,
|
||||
container_size,
|
||||
chrome_region,
|
||||
dock_gutter,
|
||||
)
|
||||
|
||||
map[widget] = _MapGeometry(
|
||||
@@ -655,6 +653,7 @@ class Compositor:
|
||||
total_region.size,
|
||||
container_size,
|
||||
virtual_region,
|
||||
dock_gutter,
|
||||
)
|
||||
|
||||
elif visible:
|
||||
@@ -666,6 +665,7 @@ class Compositor:
|
||||
region.size,
|
||||
container_size,
|
||||
virtual_region,
|
||||
dock_gutter,
|
||||
)
|
||||
|
||||
# Add top level (root) widget
|
||||
@@ -677,6 +677,7 @@ class Compositor:
|
||||
layer_order,
|
||||
size.region,
|
||||
True,
|
||||
NULL_SPACING,
|
||||
)
|
||||
return map, widgets
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, ClassVar, NamedTuple
|
||||
from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple
|
||||
|
||||
from ._spatial_map import SpatialMap
|
||||
from .geometry import Region, Size, Spacing
|
||||
from .geometry import Offset, Region, Size, Spacing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from .widget import Widget
|
||||
|
||||
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
|
||||
ArrangeResult: TypeAlias = "list[WidgetPlacement]"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,7 +51,8 @@ class DockArrangeResult:
|
||||
Returns:
|
||||
A Region.
|
||||
"""
|
||||
return self.spatial_map.total_region
|
||||
_top, right, bottom, _left = self.scroll_spacing
|
||||
return self.spatial_map.total_region.grow((0, right, bottom, 0))
|
||||
|
||||
def get_visible_placements(self, region: Region) -> list[WidgetPlacement]:
|
||||
"""Get the placements visible within the given region.
|
||||
@@ -76,6 +77,41 @@ class WidgetPlacement(NamedTuple):
|
||||
fixed: bool = False
|
||||
overlay: bool = False
|
||||
|
||||
@classmethod
|
||||
def translate(
|
||||
cls, placements: list[WidgetPlacement], offset: Offset
|
||||
) -> list[WidgetPlacement]:
|
||||
"""Move all placements by a given offset.
|
||||
|
||||
Args:
|
||||
placements: List of placements.
|
||||
offset: Offset to add to placements.
|
||||
|
||||
Returns:
|
||||
Placements with adjusted region, or same instance if offset is null.
|
||||
"""
|
||||
if offset:
|
||||
return [
|
||||
cls(region + offset, margin, layout_widget, order, fixed, overlay)
|
||||
for region, margin, layout_widget, order, fixed, overlay in placements
|
||||
]
|
||||
return placements
|
||||
|
||||
@classmethod
|
||||
def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region:
|
||||
"""Get a bounding region around all placements.
|
||||
|
||||
Args:
|
||||
placements: A number of placements.
|
||||
|
||||
Returns:
|
||||
An optimal binding box around all placements.
|
||||
"""
|
||||
bounding_region = Region.from_union(
|
||||
[placement.region.grow(placement.margin) for placement in placements]
|
||||
)
|
||||
return bounding_region
|
||||
|
||||
|
||||
class Layout(ABC):
|
||||
"""Responsible for arranging Widgets in a view and rendering them."""
|
||||
|
||||
@@ -72,11 +72,11 @@ class SpatialMap(Generic[ValueType]):
|
||||
_region_to_grid = self._region_to_grid_coordinates
|
||||
total_region = self.total_region
|
||||
for region, fixed, overlay, value in regions_and_values:
|
||||
if not overlay:
|
||||
total_region = total_region.union(region)
|
||||
if fixed:
|
||||
append_fixed(value)
|
||||
else:
|
||||
if not overlay:
|
||||
total_region = total_region.union(region)
|
||||
for grid in _region_to_grid(region):
|
||||
get_grid_list(grid).append(value)
|
||||
self.total_region = total_region
|
||||
|
||||
@@ -87,14 +87,15 @@ def work(
|
||||
self = args[0]
|
||||
assert isinstance(self, DOMNode)
|
||||
|
||||
try:
|
||||
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
|
||||
keyword_arguments = ", ".join(
|
||||
f"{name}={value!r}" for name, value in kwargs.items()
|
||||
)
|
||||
tokens = [positional_arguments, keyword_arguments]
|
||||
worker_description = (
|
||||
f"{method.__name__}({', '.join(token for token in tokens if token)})"
|
||||
)
|
||||
worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
|
||||
except Exception:
|
||||
worker_description = "<worker>"
|
||||
worker = cast(
|
||||
"Worker[ReturnType]",
|
||||
self.run_worker(
|
||||
|
||||
@@ -156,7 +156,23 @@ class ScreenError(Exception):
|
||||
|
||||
|
||||
class ScreenStackError(ScreenError):
|
||||
"""Raised when attempting to pop the last screen from the stack."""
|
||||
"""Raised when trying to manipulate the screen stack incorrectly."""
|
||||
|
||||
|
||||
class ModeError(Exception):
|
||||
"""Base class for exceptions related to modes."""
|
||||
|
||||
|
||||
class InvalidModeError(ModeError):
|
||||
"""Raised if there is an issue with a mode name."""
|
||||
|
||||
|
||||
class UnknownModeError(ModeError):
|
||||
"""Raised when attempting to use a mode that is not known."""
|
||||
|
||||
|
||||
class ActiveModeError(ModeError):
|
||||
"""Raised when attempting to remove the currently active mode."""
|
||||
|
||||
|
||||
class ModeError(Exception):
|
||||
@@ -652,14 +668,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""
|
||||
self.set_class(dark, "-dark-mode")
|
||||
self.set_class(not dark, "-light-mode")
|
||||
try:
|
||||
self.refresh_css()
|
||||
except ScreenStackError:
|
||||
# It's possible that `dark` can be set before we have a default
|
||||
# screen, in an app's `on_load`, for example. So let's eat the
|
||||
# ScreenStackError -- the above styles will be handled once the
|
||||
# screen is spun up anyway.
|
||||
pass
|
||||
self.call_later(self.refresh_css)
|
||||
|
||||
def get_driver_class(self) -> Type[Driver]:
|
||||
"""Get a driver class for this platform.
|
||||
@@ -1550,7 +1559,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
Args:
|
||||
screen: A Screen instance or the name of an installed screen.
|
||||
callback: An optional callback function that is called if the screen is dismissed with a result.
|
||||
callback: An optional callback function that will be called if the screen is [dismissed][textual.screen.Screen.dismiss] with a result.
|
||||
|
||||
Returns:
|
||||
An optional awaitable that awaits the mounting of the screen and its children.
|
||||
@@ -1766,8 +1775,22 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
def _print_error_renderables(self) -> None:
|
||||
"""Print and clear exit renderables."""
|
||||
error_count = len(self._exit_renderables)
|
||||
if "debug" in self.features:
|
||||
for renderable in self._exit_renderables:
|
||||
self.error_console.print(renderable)
|
||||
if error_count > 1:
|
||||
self.error_console.print(
|
||||
f"\n[b]NOTE:[/b] {error_count} errors show above.", markup=True
|
||||
)
|
||||
elif self._exit_renderables:
|
||||
self.error_console.print(self._exit_renderables[0])
|
||||
if error_count > 1:
|
||||
self.error_console.print(
|
||||
f"\n[b]NOTE:[/b] 1 of {error_count} errors show. Run with [b]--dev[/] to see all errors.",
|
||||
markup=True,
|
||||
)
|
||||
|
||||
self._exit_renderables.clear()
|
||||
|
||||
async def _process_messages(
|
||||
@@ -2269,6 +2292,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
screen = Screen(id=f"_default")
|
||||
self._register(self, screen)
|
||||
self._screen_stacks[self._current_mode].append(screen)
|
||||
screen.post_message(events.ScreenResume())
|
||||
await super().on_event(event)
|
||||
|
||||
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
|
||||
|
||||
@@ -887,7 +887,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
Example:
|
||||
|
||||
Here's how you could detect when the app changes from dark to light mode (and visa versa).
|
||||
Here's how you could detect when the app changes from dark to light mode (and vice versa).
|
||||
|
||||
```python
|
||||
def on_dark_change(old_value:bool, new_value:bool):
|
||||
|
||||
@@ -839,7 +839,7 @@ class Region(NamedTuple):
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def split_horizontal(self, cut: int) -> tuple[Region, Region]:
|
||||
"""Split a region in to two, from a given x offset.
|
||||
"""Split a region in to two, from a given y offset.
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
@@ -852,8 +852,8 @@ class Region(NamedTuple):
|
||||
```
|
||||
|
||||
Args:
|
||||
cut: An offset from self.x where the cut should be made. May be negative,
|
||||
for the offset to start from the right edge.
|
||||
cut: An offset from self.y where the cut should be made. May be negative,
|
||||
for the offset to start from the lower edge.
|
||||
|
||||
Returns:
|
||||
Two regions, which add up to the original (self).
|
||||
@@ -907,9 +907,21 @@ class Region(NamedTuple):
|
||||
|
||||
|
||||
class Spacing(NamedTuple):
|
||||
"""The spacing around a renderable, such as padding and border
|
||||
"""The spacing around a renderable, such as padding and border.
|
||||
|
||||
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region,
|
||||
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
|
||||
|
||||
```
|
||||
┌ ─ ─ ─ ─ ─ ─ ─▲─ ─ ─ ─ ─ ─ ─ ─ ┐
|
||||
│ top
|
||||
│ ┏━━━━━▼━━━━━━┓ │
|
||||
◀──────▶┃ ┃◀───────▶
|
||||
│ left ┃ ┃ right │
|
||||
┃ ┃
|
||||
│ ┗━━━━━▲━━━━━━┛ │
|
||||
│ bottom
|
||||
└ ─ ─ ─ ─ ─ ─ ─▼─ ─ ─ ─ ─ ─ ─ ─ ┘
|
||||
```
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -928,7 +940,7 @@ class Spacing(NamedTuple):
|
||||
top: int = 0
|
||||
"""Space from the top of a region."""
|
||||
right: int = 0
|
||||
"""Space from the left of a region."""
|
||||
"""Space from the right of a region."""
|
||||
bottom: int = 0
|
||||
"""Space from the bottom of a region."""
|
||||
left: int = 0
|
||||
@@ -1083,3 +1095,9 @@ class Spacing(NamedTuple):
|
||||
|
||||
NULL_OFFSET: Final = Offset(0, 0)
|
||||
"""An [offset][textual.geometry.Offset] constant for (0, 0)."""
|
||||
|
||||
NULL_REGION: Final = Region(0, 0, 0, 0)
|
||||
"""A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero)."""
|
||||
|
||||
NULL_SPACING: Final = Spacing(0, 0, 0, 0)
|
||||
"""A [Spacing][textual.geometry.Spacing] constant for no space."""
|
||||
|
||||
@@ -156,4 +156,4 @@ class GridLayout(Layout):
|
||||
add_placement(WidgetPlacement(region, margin, widget))
|
||||
add_widget(widget)
|
||||
|
||||
return (placements, set(widgets))
|
||||
return placements
|
||||
|
||||
@@ -65,8 +65,6 @@ class HorizontalLayout(Layout):
|
||||
|
||||
x = Fraction(box_models[0].margin.left if box_models else 0)
|
||||
|
||||
displayed_children = [child for child in children if child.display]
|
||||
|
||||
_Region = Region
|
||||
_WidgetPlacement = WidgetPlacement
|
||||
for widget, box_model, margin in zip(children, box_models, margins):
|
||||
@@ -86,4 +84,4 @@ class HorizontalLayout(Layout):
|
||||
if not overlay:
|
||||
x = next_x + margin
|
||||
|
||||
return placements, set(displayed_children)
|
||||
return placements
|
||||
|
||||
@@ -86,4 +86,4 @@ class VerticalLayout(Layout):
|
||||
if not overlay:
|
||||
y = next_y + margin
|
||||
|
||||
return placements, set(children)
|
||||
return placements
|
||||
|
||||
@@ -349,20 +349,25 @@ class MessagePump(metaclass=_MessagePumpMeta):
|
||||
self._timers.add(timer)
|
||||
return timer
|
||||
|
||||
def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
|
||||
def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> bool:
|
||||
"""Schedule a callback to run after all messages are processed and the screen
|
||||
has been refreshed. Positional and keyword arguments are passed to the callable.
|
||||
|
||||
Args:
|
||||
callback: A callable.
|
||||
|
||||
Returns:
|
||||
`True` if the callback was scheduled, or `False` if the callback could not be
|
||||
scheduled (may occur if the message pump was closed or closing).
|
||||
|
||||
"""
|
||||
# We send the InvokeLater message to ourselves first, to ensure we've cleared
|
||||
# out anything already pending in our own queue.
|
||||
|
||||
message = messages.InvokeLater(partial(callback, *args, **kwargs))
|
||||
self.post_message(message)
|
||||
return self.post_message(message)
|
||||
|
||||
def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
|
||||
def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> bool:
|
||||
"""Schedule a callback to run after all messages are processed in this object.
|
||||
Positional and keywords arguments are passed to the callable.
|
||||
|
||||
@@ -370,9 +375,14 @@ class MessagePump(metaclass=_MessagePumpMeta):
|
||||
callback: Callable to call next.
|
||||
*args: Positional arguments to pass to the callable.
|
||||
**kwargs: Keyword arguments to pass to the callable.
|
||||
|
||||
Returns:
|
||||
`True` if the callback was scheduled, or `False` if the callback could not be
|
||||
scheduled (may occur if the message pump was closed or closing).
|
||||
|
||||
"""
|
||||
message = events.Callback(callback=partial(callback, *args, **kwargs))
|
||||
self.post_message(message)
|
||||
return self.post_message(message)
|
||||
|
||||
def call_next(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
|
||||
"""Schedule a callback to run immediately after processing the current message.
|
||||
|
||||
@@ -65,6 +65,7 @@ class Pilot(Generic[ReturnType]):
|
||||
"""
|
||||
if keys:
|
||||
await self._app._press_keys(keys)
|
||||
await self._wait_for_screen()
|
||||
|
||||
async def click(
|
||||
self,
|
||||
@@ -132,13 +133,49 @@ class Pilot(Generic[ReturnType]):
|
||||
app.post_message(MouseMove(**message_arguments))
|
||||
await self.pause()
|
||||
|
||||
async def _wait_for_screen(self, timeout: float = 30.0) -> bool:
|
||||
"""Wait for the current screen to have processed all pending events.
|
||||
|
||||
Args:
|
||||
timeout: A timeout in seconds to wait.
|
||||
|
||||
Returns:
|
||||
`True` if all events were processed, or `False` if the wait timed out.
|
||||
"""
|
||||
children = [self.app, *self.app.screen.walk_children(with_self=True)]
|
||||
count = 0
|
||||
count_zero_event = asyncio.Event()
|
||||
|
||||
def decrement_counter() -> None:
|
||||
"""Decrement internal counter, and set an event if it reaches zero."""
|
||||
nonlocal count
|
||||
count -= 1
|
||||
if count == 0:
|
||||
# When count is zero, all messages queued at the start of the method have been processed
|
||||
count_zero_event.set()
|
||||
|
||||
# Increase the count for every successful call_later
|
||||
for child in children:
|
||||
if child.call_later(decrement_counter):
|
||||
count += 1
|
||||
|
||||
if count:
|
||||
# Wait for the count to return to zero, or a timeout
|
||||
try:
|
||||
await asyncio.wait_for(count_zero_event.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def pause(self, delay: float | None = None) -> None:
|
||||
"""Insert a pause.
|
||||
|
||||
Args:
|
||||
delay: Seconds to pause, or None to wait for cpu idle.
|
||||
"""
|
||||
# These sleep zeros, are to force asyncio to give up a time-slice,
|
||||
# These sleep zeros, are to force asyncio to give up a time-slice.
|
||||
await self._wait_for_screen()
|
||||
if delay is None:
|
||||
await wait_for_idle(0)
|
||||
else:
|
||||
@@ -152,7 +189,9 @@ class Pilot(Generic[ReturnType]):
|
||||
|
||||
async def wait_for_scheduled_animations(self) -> None:
|
||||
"""Wait for any current and scheduled animations to complete."""
|
||||
await self._wait_for_screen()
|
||||
await self._app.animator.wait_until_complete()
|
||||
await self._wait_for_screen()
|
||||
await wait_for_idle()
|
||||
self.app.screen._on_timer_update()
|
||||
|
||||
@@ -162,5 +201,6 @@ class Pilot(Generic[ReturnType]):
|
||||
Args:
|
||||
result: The app result returned by `run` or `run_async`.
|
||||
"""
|
||||
await self._wait_for_screen()
|
||||
await wait_for_idle()
|
||||
self.app.exit(result)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Awaitable,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Generic,
|
||||
Iterable,
|
||||
Iterator,
|
||||
@@ -30,7 +31,7 @@ from ._types import CallbackType
|
||||
from .binding import Binding
|
||||
from .css.match import match
|
||||
from .css.parse import parse_selectors
|
||||
from .css.query import QueryType
|
||||
from .css.query import NoMatches, QueryType
|
||||
from .dom import DOMNode
|
||||
from .geometry import Offset, Region, Size
|
||||
from .reactive import Reactive
|
||||
@@ -93,6 +94,13 @@ class ResultCallback(Generic[ScreenResultType]):
|
||||
class Screen(Generic[ScreenResultType], Widget):
|
||||
"""The base class for screens."""
|
||||
|
||||
AUTO_FOCUS: ClassVar[str | None] = "*"
|
||||
"""A selector to determine what to focus automatically when the screen is activated.
|
||||
|
||||
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
|
||||
Set to `None` to disable auto focus.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
@@ -100,7 +108,6 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
focused: Reactive[Widget | None] = Reactive(None)
|
||||
"""The focused [widget][textual.widget.Widget] or `None` for no focus."""
|
||||
stack_updates: Reactive[int] = Reactive(0, repaint=False)
|
||||
@@ -577,6 +584,7 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
virtual_size,
|
||||
container_size,
|
||||
_,
|
||||
_,
|
||||
) in layers:
|
||||
if widget in exposed_widgets:
|
||||
if widget._size_updated(
|
||||
@@ -607,6 +615,7 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
virtual_size,
|
||||
container_size,
|
||||
_,
|
||||
_,
|
||||
) in layers:
|
||||
widget._size_updated(region.size, virtual_size, container_size)
|
||||
if widget in send_resize:
|
||||
@@ -661,6 +670,11 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
size = self.app.size
|
||||
self._refresh_layout(size, full=True)
|
||||
self.refresh()
|
||||
if self.AUTO_FOCUS is not None and self.focused is None:
|
||||
for widget in self.query(self.AUTO_FOCUS):
|
||||
if widget.focusable:
|
||||
self.set_focus(widget)
|
||||
break
|
||||
|
||||
def _on_screen_suspend(self) -> None:
|
||||
"""Screen has suspended."""
|
||||
@@ -754,16 +768,23 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None:
|
||||
"""Dismiss the screen, optionally with a result.
|
||||
|
||||
If `result` is provided and a callback was set when the screen was [pushed][textual.app.push_screen], then
|
||||
the callback will be invoked with `result`.
|
||||
|
||||
Args:
|
||||
result: The optional result to be passed to the result callback.
|
||||
|
||||
Note:
|
||||
If the screen was pushed with a callback, the callback will be
|
||||
called with the given result and then a call to
|
||||
[`App.pop_screen`][textual.app.App.pop_screen] is performed. If
|
||||
no callback was provided calling this method is the same as
|
||||
simply calling [`App.pop_screen`][textual.app.App.pop_screen].
|
||||
Raises:
|
||||
ScreenStackError: If trying to dismiss a screen that is not at the top of
|
||||
the stack.
|
||||
|
||||
"""
|
||||
if self is not self.app.screen:
|
||||
from .app import ScreenStackError
|
||||
|
||||
raise ScreenStackError(
|
||||
f"Can't dismiss screen {self} that's not at the top of the stack."
|
||||
)
|
||||
if result is not self._NoResult and self._result_callbacks:
|
||||
self._result_callbacks[-1](cast(ScreenResultType, result))
|
||||
self.app.pop_screen()
|
||||
|
||||
@@ -379,12 +379,18 @@ class Strip:
|
||||
"""
|
||||
|
||||
pos = 0
|
||||
cell_length = self.cell_length
|
||||
cuts = [cut for cut in cuts if cut <= cell_length]
|
||||
cache_key = tuple(cuts)
|
||||
cached = self._divide_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
strips: list[Strip] = []
|
||||
strips: list[Strip]
|
||||
if cuts == [cell_length]:
|
||||
strips = [self]
|
||||
else:
|
||||
strips = []
|
||||
add_strip = strips.append
|
||||
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
||||
add_strip(Strip(segments, cut - pos))
|
||||
|
||||
@@ -57,7 +57,7 @@ from .box_model import BoxModel
|
||||
from .css.query import NoMatches, WrongType
|
||||
from .css.scalar import ScalarOffset
|
||||
from .dom import DOMNode, NoScreen
|
||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||
from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp
|
||||
from .layouts.vertical import VerticalLayout
|
||||
from .message import Message
|
||||
from .messages import CallbackType
|
||||
@@ -795,19 +795,15 @@ class Widget(DOMNode):
|
||||
|
||||
Args:
|
||||
child: The child widget to move.
|
||||
before: Optional location to move before. An `int` is the index
|
||||
of the child to move before, a `str` is a `query_one` query to
|
||||
find the widget to move before.
|
||||
after: Optional location to move after. An `int` is the index
|
||||
of the child to move after, a `str` is a `query_one` query to
|
||||
find the widget to move after.
|
||||
before: Child widget or location index to move before.
|
||||
after: Child widget or location index to move after.
|
||||
|
||||
Raises:
|
||||
WidgetError: If there is a problem with the child or target.
|
||||
|
||||
Note:
|
||||
Only one of ``before`` or ``after`` can be provided. If neither
|
||||
or both are provided a ``WidgetError`` will be raised.
|
||||
Only one of `before` or `after` can be provided. If neither
|
||||
or both are provided a `WidgetError` will be raised.
|
||||
"""
|
||||
|
||||
# One or the other of before or after are required. Can't do
|
||||
@@ -817,6 +813,10 @@ class Widget(DOMNode):
|
||||
elif before is not None and after is not None:
|
||||
raise WidgetError("Only one of `before` or `after` can be handled.")
|
||||
|
||||
# We short-circuit the no-op, otherwise it will error later down the road.
|
||||
if child is before or child is after:
|
||||
return
|
||||
|
||||
def _to_widget(child: int | Widget, called: str) -> Widget:
|
||||
"""Ensure a given child reference is a Widget."""
|
||||
if isinstance(child, int):
|
||||
@@ -1375,10 +1375,20 @@ class Widget(DOMNode):
|
||||
"""
|
||||
try:
|
||||
return self.screen.find_widget(self).region
|
||||
except NoScreen:
|
||||
return Region()
|
||||
except errors.NoWidget:
|
||||
return Region()
|
||||
except (NoScreen, errors.NoWidget):
|
||||
return NULL_REGION
|
||||
|
||||
@property
|
||||
def dock_gutter(self) -> Spacing:
|
||||
"""Space allocated to docks in the parent.
|
||||
|
||||
Returns:
|
||||
Space to be subtracted from scrollable area.
|
||||
"""
|
||||
try:
|
||||
return self.screen.find_widget(self).dock_gutter
|
||||
except (NoScreen, errors.NoWidget):
|
||||
return NULL_SPACING
|
||||
|
||||
@property
|
||||
def container_viewport(self) -> Region:
|
||||
@@ -2263,7 +2273,7 @@ class Widget(DOMNode):
|
||||
else:
|
||||
scroll_offset = container.scroll_to_region(
|
||||
region,
|
||||
spacing=widget.parent.gutter,
|
||||
spacing=widget.parent.gutter + widget.dock_gutter,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Queue
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Iterable
|
||||
from typing import ClassVar, Iterable, Iterator
|
||||
|
||||
from rich.style import Style
|
||||
from rich.text import Text, TextType
|
||||
|
||||
from ..events import Mount
|
||||
from .. import work
|
||||
from ..message import Message
|
||||
from ..reactive import var
|
||||
from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker
|
||||
from ._tree import TOGGLE_STYLE, Tree, TreeNode
|
||||
|
||||
|
||||
@@ -90,7 +92,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
"""
|
||||
return self.tree
|
||||
|
||||
path: var[str | Path] = var["str | Path"](Path("."), init=False)
|
||||
path: var[str | Path] = var["str | Path"](Path("."), init=False, always_update=True)
|
||||
"""The path that is the root of the directory tree.
|
||||
|
||||
Note:
|
||||
@@ -116,6 +118,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
classes: A space-separated list of classes, or None for no classes.
|
||||
disabled: Whether the directory tree is disabled or not.
|
||||
"""
|
||||
self._load_queue: Queue[TreeNode[DirEntry]] = Queue()
|
||||
super().__init__(
|
||||
str(path),
|
||||
data=DirEntry(Path(path)),
|
||||
@@ -126,10 +129,26 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
)
|
||||
self.path = path
|
||||
|
||||
def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> None:
|
||||
"""Add the given node to the load queue.
|
||||
|
||||
Args:
|
||||
node: The node to add to the load queue.
|
||||
"""
|
||||
assert node.data is not None
|
||||
node.data.loaded = True
|
||||
self._load_queue.put_nowait(node)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload the `DirectoryTree` contents."""
|
||||
self.reset(str(self.path), DirEntry(Path(self.path)))
|
||||
self._load_directory(self.root)
|
||||
# Orphan the old queue...
|
||||
self._load_queue = Queue()
|
||||
# ...and replace the old load with a new one.
|
||||
self._loader()
|
||||
# We have a fresh queue, we have a fresh loader, get the fresh root
|
||||
# loading up.
|
||||
self._add_to_load_queue(self.root)
|
||||
|
||||
def validate_path(self, path: str | Path) -> Path:
|
||||
"""Ensure that the path is of the `Path` type.
|
||||
@@ -229,37 +248,115 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
"""
|
||||
return paths
|
||||
|
||||
def _load_directory(self, node: TreeNode[DirEntry]) -> None:
|
||||
@staticmethod
|
||||
def _safe_is_dir(path: Path) -> bool:
|
||||
"""Safely check if a path is a directory.
|
||||
|
||||
Args:
|
||||
path: The path to check.
|
||||
|
||||
Returns:
|
||||
`True` if the path is for a directory, `False` if not.
|
||||
"""
|
||||
try:
|
||||
return path.is_dir()
|
||||
except PermissionError:
|
||||
# We may or may not have been looking at a directory, but we
|
||||
# don't have the rights or permissions to even know that. Best
|
||||
# we can do, short of letting the error blow up, is assume it's
|
||||
# not a directory. A possible improvement in here could be to
|
||||
# have a third state which is "unknown", and reflect that in the
|
||||
# tree.
|
||||
return False
|
||||
|
||||
def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None:
|
||||
"""Populate the given tree node with the given directory content.
|
||||
|
||||
Args:
|
||||
node: The Tree node to populate.
|
||||
content: The collection of `Path` objects to populate the node with.
|
||||
"""
|
||||
for path in content:
|
||||
node.add(
|
||||
path.name,
|
||||
data=DirEntry(path),
|
||||
allow_expand=self._safe_is_dir(path),
|
||||
)
|
||||
node.expand()
|
||||
|
||||
def _directory_content(self, location: Path, worker: Worker) -> Iterator[Path]:
|
||||
"""Load the content of a given directory.
|
||||
|
||||
Args:
|
||||
location: The location to load from.
|
||||
worker: The worker that the loading is taking place in.
|
||||
|
||||
Yields:
|
||||
Path: An entry within the location.
|
||||
"""
|
||||
try:
|
||||
for entry in location.iterdir():
|
||||
if worker.is_cancelled:
|
||||
break
|
||||
yield entry
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
@work
|
||||
def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]:
|
||||
"""Load the directory contents for a given node.
|
||||
|
||||
Args:
|
||||
node: The node to load the directory contents for.
|
||||
|
||||
Returns:
|
||||
The list of entries within the directory associated with the node.
|
||||
"""
|
||||
assert node.data is not None
|
||||
node.data.loaded = True
|
||||
directory = sorted(
|
||||
self.filter_paths(node.data.path.iterdir()),
|
||||
key=lambda path: (not path.is_dir(), path.name.lower()),
|
||||
return sorted(
|
||||
self.filter_paths(
|
||||
self._directory_content(node.data.path, get_current_worker())
|
||||
),
|
||||
key=lambda path: (not self._safe_is_dir(path), path.name.lower()),
|
||||
)
|
||||
for path in directory:
|
||||
node.add(
|
||||
path.name,
|
||||
data=DirEntry(path),
|
||||
allow_expand=path.is_dir(),
|
||||
)
|
||||
node.expand()
|
||||
|
||||
def _on_mount(self, _: Mount) -> None:
|
||||
self._load_directory(self.root)
|
||||
@work(exclusive=True)
|
||||
async def _loader(self) -> None:
|
||||
"""Background loading queue processor."""
|
||||
worker = get_current_worker()
|
||||
while not worker.is_cancelled:
|
||||
# Get the next node that needs loading off the queue. Note that
|
||||
# this blocks if the queue is empty.
|
||||
node = await self._load_queue.get()
|
||||
content: list[Path] = []
|
||||
try:
|
||||
# Spin up a short-lived thread that will load the content of
|
||||
# the directory associated with that node.
|
||||
content = await self._load_directory(node).wait()
|
||||
except WorkerCancelled:
|
||||
# The worker was cancelled, that would suggest we're all
|
||||
# done here and we should get out of the loader in general.
|
||||
break
|
||||
except WorkerFailed:
|
||||
# This particular worker failed to start. We don't know the
|
||||
# reason so let's no-op that (for now anyway).
|
||||
pass
|
||||
else:
|
||||
# We're still here and we have directory content, get it into
|
||||
# the tree.
|
||||
if content:
|
||||
self._populate_node(node, content)
|
||||
# Mark this iteration as done.
|
||||
self._load_queue.task_done()
|
||||
|
||||
def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
|
||||
event.stop()
|
||||
dir_entry = event.node.data
|
||||
if dir_entry is None:
|
||||
return
|
||||
if dir_entry.path.is_dir():
|
||||
if self._safe_is_dir(dir_entry.path):
|
||||
if not dir_entry.loaded:
|
||||
self._load_directory(event.node)
|
||||
self._add_to_load_queue(event.node)
|
||||
else:
|
||||
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
|
||||
|
||||
@@ -268,5 +365,5 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
dir_entry = event.node.data
|
||||
if dir_entry is None:
|
||||
return
|
||||
if not dir_entry.path.is_dir():
|
||||
if not self._safe_is_dir(dir_entry.path):
|
||||
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
|
||||
|
||||
@@ -98,6 +98,7 @@ class Footer(Widget):
|
||||
highlight_style = self.get_component_rich_style("footer--highlight")
|
||||
highlight_key_style = self.get_component_rich_style("footer--highlight-key")
|
||||
key_style = self.get_component_rich_style("footer--key")
|
||||
description_style = self.get_component_rich_style("footer--description")
|
||||
|
||||
bindings = [
|
||||
binding
|
||||
@@ -122,7 +123,7 @@ class Footer(Widget):
|
||||
(f" {key_display} ", highlight_key_style if hovered else key_style),
|
||||
(
|
||||
f" {binding.description} ",
|
||||
highlight_style if hovered else base_style,
|
||||
highlight_style if hovered else base_style + description_style,
|
||||
),
|
||||
meta={
|
||||
"@click": f"app.check_bindings('{binding.key}')",
|
||||
|
||||
@@ -332,6 +332,7 @@ class Input(Widget, can_focus=True):
|
||||
event.prevent_default()
|
||||
|
||||
def _on_paste(self, event: events.Paste) -> None:
|
||||
if event.text:
|
||||
line = event.text.splitlines()[0]
|
||||
self.insert_text_at_cursor(line)
|
||||
event.stop()
|
||||
|
||||
@@ -627,11 +627,17 @@ class OptionList(ScrollView, can_focus=True):
|
||||
"""
|
||||
self._contents.clear()
|
||||
self._options.clear()
|
||||
self._refresh_content_tracking(force=True)
|
||||
self.highlighted = None
|
||||
self._mouse_hovering_over = None
|
||||
self.virtual_size = Size(self.scrollable_content_region.width, 0)
|
||||
self.refresh()
|
||||
# TODO: See https://github.com/Textualize/textual/issues/2582 -- it
|
||||
# should not be necessary to do this like this here; ideally here in
|
||||
# clear_options it would be a forced refresh, and also in a
|
||||
# `on_show` it would be the same (which, I think, would actually
|
||||
# solve the problem we're seeing). But, until such a time as we get
|
||||
# to the bottom of 2582... this seems to delay the refresh enough
|
||||
# that things fall into place.
|
||||
self._request_content_tracking_refresh()
|
||||
return self
|
||||
|
||||
def _set_option_disabled(self, index: int, disabled: bool) -> Self:
|
||||
|
||||
@@ -185,10 +185,6 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
|
||||
border: tall $accent;
|
||||
}
|
||||
|
||||
Select {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Select > SelectOverlay {
|
||||
width: 1fr;
|
||||
display: none;
|
||||
|
||||
@@ -117,15 +117,17 @@ class Tab(Static):
|
||||
label: TextType,
|
||||
*,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
"""Initialise a Tab.
|
||||
|
||||
Args:
|
||||
label: The label to use in the tab.
|
||||
id: Optional ID for the widget.
|
||||
classes: Space separated list of class names.
|
||||
"""
|
||||
self.label = Text.from_markup(label) if isinstance(label, str) else label
|
||||
super().__init__(id=id)
|
||||
super().__init__(id=id, classes=classes)
|
||||
self.update(label)
|
||||
|
||||
@property
|
||||
|
||||
File diff suppressed because one or more lines are too long
33
tests/snapshot_tests/snapshot_apps/dock_scroll2.py
Normal file
33
tests/snapshot_tests/snapshot_apps/dock_scroll2.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from textual.app import App
|
||||
from textual.widgets import Header, Label, Footer
|
||||
|
||||
|
||||
# Same as dock_scroll.py but with 2 labels
|
||||
class TestApp(App):
|
||||
BINDINGS = [("ctrl+q", "app.quit", "Quit")]
|
||||
CSS = """
|
||||
|
||||
Label {
|
||||
border: solid red;
|
||||
}
|
||||
Footer {
|
||||
height: 4;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
text = (
|
||||
"this is a sample sentence and here are some words".replace(" ", "\n") * 2
|
||||
)
|
||||
yield Header()
|
||||
yield Label(text)
|
||||
yield Label(text)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.dark = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = TestApp()
|
||||
app.run()
|
||||
17
tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
Normal file
17
tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Checkbox, Footer
|
||||
|
||||
|
||||
class ScrollOffByOne(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
for number in range(1, 100):
|
||||
yield Checkbox(str(number))
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("Screen").scroll_end()
|
||||
|
||||
|
||||
app = ScrollOffByOne()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -19,6 +19,6 @@
|
||||
|
||||
#horizontal {
|
||||
width: auto;
|
||||
height: auto;
|
||||
height: 4;
|
||||
background: darkslateblue;
|
||||
}
|
||||
19
tests/snapshot_tests/snapshot_apps/scroll_to.py
Normal file
19
tests/snapshot_tests/snapshot_apps/scroll_to.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Checkbox, Footer
|
||||
|
||||
|
||||
class ScrollOffByOne(App):
|
||||
"""Scroll to item 50."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for number in range(1, 100):
|
||||
yield Checkbox(str(number), id=f"number-{number}")
|
||||
yield Footer()
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.query_one("#number-50").scroll_visible()
|
||||
|
||||
|
||||
app = ScrollOffByOne()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
21
tests/snapshot_tests/snapshot_apps/select_rebuild.py
Normal file
21
tests/snapshot_tests/snapshot_apps/select_rebuild.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Test https://github.com/Textualize/textual/issues/2557"""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Select, Button
|
||||
|
||||
|
||||
class SelectRebuildApp(App[None]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Select[int]((("1", 1), ("2", 2)))
|
||||
yield Button("Rebuild")
|
||||
|
||||
def on_button_pressed(self):
|
||||
self.query_one(Select).set_options((
|
||||
("This", 0), ("Should", 1), ("Be", 2),
|
||||
("What", 3), ("Goes", 4), ("Into",5),
|
||||
("The", 6), ("Snapshit", 7)
|
||||
))
|
||||
|
||||
if __name__ == "__main__":
|
||||
SelectRebuildApp().run()
|
||||
@@ -1,5 +1,4 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -78,8 +77,7 @@ def test_switches(snap_compare):
|
||||
|
||||
def test_input_and_focus(snap_compare):
|
||||
press = [
|
||||
"tab",
|
||||
*"Darren", # Focus first input, write "Darren"
|
||||
*"Darren", # Write "Darren"
|
||||
"tab",
|
||||
*"Burns", # Focus second input, write "Burns"
|
||||
]
|
||||
@@ -88,7 +86,7 @@ def test_input_and_focus(snap_compare):
|
||||
|
||||
def test_buttons_render(snap_compare):
|
||||
# Testing button rendering. We press tab to focus the first button too.
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab", "tab"])
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
||||
|
||||
|
||||
def test_placeholder_render(snap_compare):
|
||||
@@ -189,7 +187,7 @@ def test_content_switcher_example_initial(snap_compare):
|
||||
def test_content_switcher_example_switch(snap_compare):
|
||||
assert snap_compare(
|
||||
WIDGET_EXAMPLES_DIR / "content_switcher.py",
|
||||
press=["tab", "tab", "enter", "wait:500"],
|
||||
press=["tab", "enter", "wait:500"],
|
||||
terminal_size=(50, 50),
|
||||
)
|
||||
|
||||
@@ -203,9 +201,11 @@ def test_option_list(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py")
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py")
|
||||
|
||||
|
||||
def test_option_list_build(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py")
|
||||
|
||||
|
||||
def test_progress_bar_indeterminate(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"])
|
||||
|
||||
@@ -313,7 +313,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare):
|
||||
|
||||
|
||||
def test_borders_preview(snap_compare):
|
||||
assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "enter"])
|
||||
assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["enter"])
|
||||
|
||||
|
||||
def test_colors_preview(snap_compare):
|
||||
@@ -377,9 +377,7 @@ def test_disabled_widgets(snap_compare):
|
||||
|
||||
|
||||
def test_focus_component_class(snap_compare):
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab", "tab"]
|
||||
)
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab"])
|
||||
|
||||
|
||||
def test_line_api_scrollbars(snap_compare):
|
||||
@@ -440,7 +438,7 @@ def test_modal_dialog_bindings_input(snap_compare):
|
||||
# Check https://github.com/Textualize/textual/issues/2194
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "modal_screen_bindings.py",
|
||||
press=["enter", "tab", "h", "!", "left", "i", "tab"],
|
||||
press=["enter", "h", "!", "left", "i", "tab"],
|
||||
)
|
||||
|
||||
|
||||
@@ -457,6 +455,23 @@ def test_dock_scroll(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll.py", terminal_size=(80, 25))
|
||||
|
||||
|
||||
def test_dock_scroll2(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2525
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll2.py", terminal_size=(80, 25))
|
||||
|
||||
|
||||
def test_dock_scroll_off_by_one(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2525
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py", terminal_size=(80, 25)
|
||||
)
|
||||
|
||||
|
||||
def test_scroll_to(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2525
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25))
|
||||
|
||||
|
||||
def test_auto_fr(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2220
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "auto_fr.py", terminal_size=(80, 25))
|
||||
@@ -493,3 +508,11 @@ def test_quickly_change_tabs(snap_compare):
|
||||
def test_fr_unit_with_min(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2378
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "fr_with_min.py")
|
||||
|
||||
|
||||
def test_select_rebuild(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2557
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "select_rebuild.py",
|
||||
press=["space", "escape", "tab", "enter", "tab", "space"],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
from textual.widgets import Button, Input
|
||||
|
||||
|
||||
def test_batch_update():
|
||||
@@ -20,6 +20,7 @@ def test_batch_update():
|
||||
|
||||
class MyApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input()
|
||||
yield Button("Click me!")
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ async def test_on_button_pressed() -> None:
|
||||
|
||||
app = ButtonApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("tab", "enter", "tab", "enter", "tab", "enter")
|
||||
await pilot.press("enter", "tab", "enter", "tab", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert pressed == [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
async def test_paste_app():
|
||||
@@ -16,3 +17,29 @@ async def test_paste_app():
|
||||
|
||||
assert len(paste_events) == 1
|
||||
assert paste_events[0].text == "Hello"
|
||||
|
||||
|
||||
async def test_empty_paste():
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/2563."""
|
||||
|
||||
paste_events = []
|
||||
|
||||
class MyInput(Input):
|
||||
def on_paste(self, event):
|
||||
super()._on_paste(event)
|
||||
paste_events.append(event)
|
||||
|
||||
class PasteApp(App):
|
||||
def compose(self):
|
||||
yield MyInput()
|
||||
|
||||
def key_p(self):
|
||||
self.query_one(MyInput).post_message(events.Paste(""))
|
||||
|
||||
app = PasteApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.set_focus(None)
|
||||
await pilot.press("p")
|
||||
assert app.query_one(MyInput).value == ""
|
||||
assert len(paste_events) == 1
|
||||
assert paste_events[0].text == ""
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from textual.app import App, ScreenStackError
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Input, Label
|
||||
|
||||
skip_py310 = pytest.mark.skipif(
|
||||
sys.version_info.minor == 10 and sys.version_info.major == 3,
|
||||
@@ -150,3 +151,73 @@ async def test_screens():
|
||||
screen2.remove()
|
||||
screen3.remove()
|
||||
await app._shutdown()
|
||||
|
||||
|
||||
async def test_auto_focus():
|
||||
class MyScreen(Screen[None]):
|
||||
def compose(self):
|
||||
yield Button()
|
||||
yield Input(id="one")
|
||||
yield Input(id="two")
|
||||
|
||||
class MyApp(App[None]):
|
||||
pass
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Button)
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = None
|
||||
await app.push_screen(MyScreen())
|
||||
assert app.focused is None
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = "Input"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Input)
|
||||
assert app.focused.id == "one"
|
||||
app.pop_screen()
|
||||
|
||||
MyScreen.AUTO_FOCUS = "#two"
|
||||
await app.push_screen(MyScreen())
|
||||
assert isinstance(app.focused, Input)
|
||||
assert app.focused.id == "two"
|
||||
|
||||
# If we push and pop another screen, focus should be preserved for #two.
|
||||
MyScreen.AUTO_FOCUS = None
|
||||
await app.push_screen(MyScreen())
|
||||
assert app.focused is None
|
||||
app.pop_screen()
|
||||
assert app.focused.id == "two"
|
||||
|
||||
|
||||
async def test_auto_focus_skips_non_focusable_widgets():
|
||||
class MyScreen(Screen[None]):
|
||||
def compose(self):
|
||||
yield Label()
|
||||
yield Button()
|
||||
|
||||
class MyApp(App[None]):
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
assert app.focused is not None
|
||||
assert isinstance(app.focused, Button)
|
||||
|
||||
|
||||
async def test_dismiss_non_top_screen():
|
||||
class MyApp(App[None]):
|
||||
async def key_p(self) -> None:
|
||||
self.bottom, top = Screen(), Screen()
|
||||
await self.push_screen(self.bottom)
|
||||
await self.push_screen(top)
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("p")
|
||||
with pytest.raises(ScreenStackError):
|
||||
app.bottom.dismiss()
|
||||
|
||||
@@ -42,22 +42,18 @@ async def test_move_child_to_outside() -> None:
|
||||
pilot.app.screen.move_child(child, before=Widget())
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True, reason="https://github.com/Textualize/textual/issues/1743"
|
||||
)
|
||||
async def test_move_child_before_itself() -> None:
|
||||
"""Test moving a widget before itself."""
|
||||
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
pilot.app.screen.move_child(child, before=child)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True, reason="https://github.com/Textualize/textual/issues/1743"
|
||||
)
|
||||
async def test_move_child_after_itself() -> None:
|
||||
"""Test moving a widget after itself."""
|
||||
# Regression test for https://github.com/Textualize/textual/issues/1743
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
|
||||
@@ -39,6 +39,7 @@ async def test_radio_sets_initial_state():
|
||||
async def test_click_sets_focus():
|
||||
"""Clicking within a radio set should set focus."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
pilot.app.set_focus(None)
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.click("#clickme")
|
||||
assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons")
|
||||
@@ -72,8 +73,6 @@ async def test_radioset_same_button_mash():
|
||||
async def test_radioset_inner_navigation():
|
||||
"""Using the cursor keys should navigate between buttons in a set."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
for key, landing in (
|
||||
("down", 1),
|
||||
("up", 0),
|
||||
@@ -88,8 +87,6 @@ async def test_radioset_inner_navigation():
|
||||
== pilot.app.query_one("#from_buttons").children[landing]
|
||||
)
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons")
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings")
|
||||
@@ -101,8 +98,6 @@ async def test_radioset_inner_navigation():
|
||||
async def test_radioset_breakout_navigation():
|
||||
"""Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons")
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.query_one("#from_strings")
|
||||
|
||||
Reference in New Issue
Block a user