Merge branch 'main' into screen-modes

This commit is contained in:
Rodrigo Girão Serrão
2023-05-18 15:11:21 +01:00
57 changed files with 5333 additions and 3443 deletions

22
.github/workflows/black_format.yml vendored Normal file
View 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

View File

@@ -1,7 +1,8 @@
name: issues name: Closed issue comment
on: on:
issues: issues:
types: [closed] types: [closed]
jobs: jobs:
add-comment: add-comment:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1,7 +1,8 @@
name: issues name: FAQ issue comment
on: on:
issues: issues:
types: [opened] types: [opened]
jobs: jobs:
add-comment: add-comment:
if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }} if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }}

View File

@@ -3,6 +3,7 @@ name: Test Textual module
on: on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/pythonpackage.yml'
- '**.py' - '**.py'
- '**.pyi' - '**.pyi'
- '**.css' - '**.css'
@@ -21,27 +22,28 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3.5.2
- name: Set up Python ${{ matrix.python-version }} - name: Install and configure Poetry # This could be cached, too...
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Install and configure Poetry
uses: snok/install-poetry@v1.3.3 uses: snok/install-poetry@v1.3.3
with: with:
version: 1.4.2 version: 1.4.2
virtualenvs-in-project: true 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 - name: Install dependencies
run: poetry install --extras "dev" run: poetry install --extras "dev"
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Format check with black
run: |
source $VENV
make format-check
# - name: Typecheck with mypy # - name: Typecheck with mypy
# run: | # run: |
# source $VENV
# make typecheck # make typecheck
- name: Test with pytest - name: Test with pytest
run: | run: |

View File

@@ -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/). and this project adheres to [Semantic Versioning](http://semver.org/).
## Unrealeased ## [0.25.0] - 2023-05-17
### Changed ### Changed
- App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521 - 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 ### 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.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.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 - 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 ## [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 - New handler system for messages that doesn't require inheritance
- Improved traceback handling - 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.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.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 [0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0

View File

@@ -17,4 +17,17 @@
<meta property="og:description" content="Textual is a TUI framework for Python, inspired by modern web development."> <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"> <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 %} {% endblock %}

View File

@@ -42,6 +42,7 @@ class ModalApp(App):
yield Footer() yield Footer()
def action_request_quit(self) -> None: def action_request_quit(self) -> None:
"""Action to display the quit dialog."""
self.push_screen(QuitScreen()) self.push_screen(QuitScreen())

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View File

@@ -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. 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. 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.

View 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
View 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!

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -157,6 +157,7 @@ nav:
- "widgets/text_log.md" - "widgets/text_log.md"
- "widgets/tree.md" - "widgets/tree.md"
- API: - API:
- "api/index.md"
- "api/app.md" - "api/app.md"
- "api/await_remove.md" - "api/await_remove.md"
- "api/binding.md" - "api/binding.md"
@@ -189,6 +190,9 @@ nav:
- "api/work.md" - "api/work.md"
- "api/worker.md" - "api/worker.md"
- "api/worker_manager.md" - "api/worker_manager.md"
- "How To":
- "how-to/index.md"
- "how-to/design-a-layout.md"
- "roadmap.md" - "roadmap.md"
- "Blog": - "Blog":
- blog/index.md - blog/index.md

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "textual" name = "textual"
version = "0.24.1" version = "0.25.0"
homepage = "https://github.com/Textualize/textual" homepage = "https://github.com/Textualize/textual"
description = "Modern Text User Interface framework" description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"] authors = ["Will McGugan <will@textualize.io>"]
@@ -83,5 +83,5 @@ markers = [
] ]
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.2.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

BIN
reference/spacing.monopic Normal file

Binary file not shown.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from fractions import Fraction from fractions import Fraction
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING, Sequence from typing import TYPE_CHECKING, Iterable, Mapping, Sequence
from ._layout import DockArrangeResult, WidgetPlacement from ._layout import DockArrangeResult, WidgetPlacement
from ._partition import partition from ._partition import partition
@@ -16,6 +16,21 @@ if TYPE_CHECKING:
TOP_Z = 2**31 - 1 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( def arrange(
widget: Widget, children: Sequence[Widget], size: Size, viewport: Size widget: Widget, children: Sequence[Widget], size: Size, viewport: Size
) -> DockArrangeResult: ) -> DockArrangeResult:
@@ -30,34 +45,86 @@ def arrange(
Widget arrangement information. 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] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append
_WidgetPlacement = WidgetPlacement
top_z = TOP_Z
scroll_spacing = Spacing() scroll_spacing = Spacing()
null_spacing = Spacing()
get_dock = attrgetter("styles.dock") get_dock = attrgetter("styles.dock")
styles = widget.styles 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 layer_region = size.region
for widgets in dock_layers.values(): for widgets in dock_layers.values():
region = layer_region 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) 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 top = right = bottom = left = 0
placements: list[WidgetPlacement] = []
append_placement = placements.append
for dock_widget in dock_widgets: for dock_widget in dock_widgets:
edge = dock_widget.styles.dock edge = dock_widget.styles.dock
@@ -70,9 +137,7 @@ def arrange(
widget_height = int(widget_height_fraction) + margin.height widget_height = int(widget_height_fraction) + margin.height
if edge == "bottom": if edge == "bottom":
dock_region = Region( dock_region = Region(0, height - widget_height, widget_width, widget_height)
0, height - widget_height, widget_width, widget_height
)
bottom = max(bottom, widget_height) bottom = max(bottom, widget_height)
elif edge == "top": elif edge == "top":
dock_region = Region(0, 0, widget_width, widget_height) dock_region = Region(0, 0, widget_width, widget_height)
@@ -81,9 +146,7 @@ def arrange(
dock_region = Region(0, 0, widget_width, widget_height) dock_region = Region(0, 0, widget_width, widget_height)
left = max(left, widget_width) left = max(left, widget_width)
elif edge == "right": elif edge == "right":
dock_region = Region( dock_region = Region(width - widget_width, 0, widget_width, widget_height)
width - widget_width, 0, widget_width, widget_height
)
right = max(right, widget_width) right = max(right, widget_width)
else: else:
# Should not occur, mainly to keep Mypy happy # Should not occur, mainly to keep Mypy happy
@@ -93,44 +156,9 @@ def arrange(
(widget_width, widget_height), size (widget_width, widget_height), size
) )
dock_region = dock_region.shrink(margin).translate(align_offset) dock_region = dock_region.shrink(margin).translate(align_offset)
add_placement( append_placement(
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
) )
dock_spacing = Spacing(top, right, bottom, left) 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 return (placements, dock_spacing)
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)

View File

@@ -33,7 +33,7 @@ from . import errors
from ._cells import cell_len from ._cells import cell_len
from ._context import visible_screen_stack 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, NULL_SPACING, Offset, Region, Size, Spacing
from .strip import Strip, StripRenderable from .strip import Strip, StripRenderable
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -71,6 +71,8 @@ class MapGeometry(NamedTuple):
"""The container [size][textual.geometry.Size] (area not occupied by scrollbars).""" """The container [size][textual.geometry.Size] (area not occupied by scrollbars)."""
virtual_region: Region virtual_region: Region
"""The [region][textual.geometry.Region] relative to the container (but not necessarily visible).""" """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 @property
def visible_region(self) -> Region: def visible_region(self) -> Region:
@@ -484,7 +486,7 @@ class Compositor:
# Widgets and regions in render order # Widgets and regions in render order
visible_widgets = [ visible_widgets = [
(order, widget, region, clip) (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) if in_screen(region) and overlaps(clip, region)
] ]
visible_widgets.sort(key=itemgetter(0), reverse=True) visible_widgets.sort(key=itemgetter(0), reverse=True)
@@ -522,6 +524,7 @@ class Compositor:
layer_order: int, layer_order: int,
clip: Region, clip: Region,
visible: bool, visible: bool,
dock_gutter: Spacing,
_MapGeometry: type[MapGeometry] = MapGeometry, _MapGeometry: type[MapGeometry] = MapGeometry,
) -> None: ) -> None:
"""Called recursively to place a widget and its children in the map. """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 get_layer_index = layers_to_index.get
scroll_spacing = arrange_result.scroll_spacing
# Add all the widgets # 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 placements
): ):
layer_index = get_layer_index(sub_widget.layer, 0) layer_index = get_layer_index(sub_widget.layer, 0)
@@ -602,11 +603,6 @@ class Compositor:
if fixed: if fixed:
widget_region = sub_region + placement_offset widget_region = sub_region + placement_offset
else: 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_region = sub_region + placement_scroll_offset
widget_order = order + ((layer_index, z, layer_order),) widget_order = order + ((layer_index, z, layer_order),)
@@ -629,6 +625,7 @@ class Compositor:
layer_order, layer_order,
no_clip if overlay else sub_clip, no_clip if overlay else sub_clip,
visible, visible,
arrange_result.scroll_spacing,
) )
layer_order -= 1 layer_order -= 1
@@ -646,6 +643,7 @@ class Compositor:
container_size, container_size,
container_size, container_size,
chrome_region, chrome_region,
dock_gutter,
) )
map[widget] = _MapGeometry( map[widget] = _MapGeometry(
@@ -655,6 +653,7 @@ class Compositor:
total_region.size, total_region.size,
container_size, container_size,
virtual_region, virtual_region,
dock_gutter,
) )
elif visible: elif visible:
@@ -666,6 +665,7 @@ class Compositor:
region.size, region.size,
container_size, container_size,
virtual_region, virtual_region,
dock_gutter,
) )
# Add top level (root) widget # Add top level (root) widget
@@ -677,6 +677,7 @@ class Compositor:
layer_order, layer_order,
size.region, size.region,
True, True,
NULL_SPACING,
) )
return map, widgets return map, widgets

View File

@@ -2,17 +2,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass 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 ._spatial_map import SpatialMap
from .geometry import Region, Size, Spacing from .geometry import Offset, Region, Size, Spacing
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import TypeAlias from typing_extensions import TypeAlias
from .widget import Widget from .widget import Widget
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" ArrangeResult: TypeAlias = "list[WidgetPlacement]"
@dataclass @dataclass
@@ -51,7 +51,8 @@ class DockArrangeResult:
Returns: Returns:
A Region. 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]: def get_visible_placements(self, region: Region) -> list[WidgetPlacement]:
"""Get the placements visible within the given region. """Get the placements visible within the given region.
@@ -76,6 +77,41 @@ class WidgetPlacement(NamedTuple):
fixed: bool = False fixed: bool = False
overlay: 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): class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them.""" """Responsible for arranging Widgets in a view and rendering them."""

View File

@@ -72,11 +72,11 @@ class SpatialMap(Generic[ValueType]):
_region_to_grid = self._region_to_grid_coordinates _region_to_grid = self._region_to_grid_coordinates
total_region = self.total_region total_region = self.total_region
for region, fixed, overlay, value in regions_and_values: for region, fixed, overlay, value in regions_and_values:
if not overlay:
total_region = total_region.union(region)
if fixed: if fixed:
append_fixed(value) append_fixed(value)
else: else:
if not overlay:
total_region = total_region.union(region)
for grid in _region_to_grid(region): for grid in _region_to_grid(region):
get_grid_list(grid).append(value) get_grid_list(grid).append(value)
self.total_region = total_region self.total_region = total_region

View File

@@ -87,14 +87,15 @@ def work(
self = args[0] self = args[0]
assert isinstance(self, DOMNode) assert isinstance(self, DOMNode)
try:
positional_arguments = ", ".join(repr(arg) for arg in args[1:]) positional_arguments = ", ".join(repr(arg) for arg in args[1:])
keyword_arguments = ", ".join( keyword_arguments = ", ".join(
f"{name}={value!r}" for name, value in kwargs.items() f"{name}={value!r}" for name, value in kwargs.items()
) )
tokens = [positional_arguments, keyword_arguments] tokens = [positional_arguments, keyword_arguments]
worker_description = ( worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
f"{method.__name__}({', '.join(token for token in tokens if token)})" except Exception:
) worker_description = "<worker>"
worker = cast( worker = cast(
"Worker[ReturnType]", "Worker[ReturnType]",
self.run_worker( self.run_worker(

View File

@@ -156,7 +156,23 @@ class ScreenError(Exception):
class ScreenStackError(ScreenError): 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): class ModeError(Exception):
@@ -652,14 +668,7 @@ class App(Generic[ReturnType], DOMNode):
""" """
self.set_class(dark, "-dark-mode") self.set_class(dark, "-dark-mode")
self.set_class(not dark, "-light-mode") self.set_class(not dark, "-light-mode")
try: self.call_later(self.refresh_css)
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
def get_driver_class(self) -> Type[Driver]: def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform. """Get a driver class for this platform.
@@ -1550,7 +1559,7 @@ class App(Generic[ReturnType], DOMNode):
Args: Args:
screen: A Screen instance or the name of an installed screen. 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: Returns:
An optional awaitable that awaits the mounting of the screen and its children. 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: def _print_error_renderables(self) -> None:
"""Print and clear exit renderables.""" """Print and clear exit renderables."""
error_count = len(self._exit_renderables)
if "debug" in self.features:
for renderable in self._exit_renderables: for renderable in self._exit_renderables:
self.error_console.print(renderable) 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() self._exit_renderables.clear()
async def _process_messages( async def _process_messages(
@@ -2269,6 +2292,7 @@ class App(Generic[ReturnType], DOMNode):
screen = Screen(id=f"_default") screen = Screen(id=f"_default")
self._register(self, screen) self._register(self, screen)
self._screen_stacks[self._current_mode].append(screen) self._screen_stacks[self._current_mode].append(screen)
screen.post_message(events.ScreenResume())
await super().on_event(event) await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded: elif isinstance(event, events.InputEvent) and not event.is_forwarded:

View File

@@ -887,7 +887,7 @@ class DOMNode(MessagePump):
Example: 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 ```python
def on_dark_change(old_value:bool, new_value:bool): def on_dark_change(old_value:bool, new_value:bool):

View File

@@ -839,7 +839,7 @@ class Region(NamedTuple):
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)
def split_horizontal(self, cut: int) -> tuple[Region, Region]: 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: Args:
cut: An offset from self.x where the cut should be made. May be negative, cut: An offset from self.y where the cut should be made. May be negative,
for the offset to start from the right edge. for the offset to start from the lower edge.
Returns: Returns:
Two regions, which add up to the original (self). Two regions, which add up to the original (self).
@@ -907,9 +907,21 @@ class Region(NamedTuple):
class Spacing(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: Example:
```python ```python
@@ -928,7 +940,7 @@ class Spacing(NamedTuple):
top: int = 0 top: int = 0
"""Space from the top of a region.""" """Space from the top of a region."""
right: int = 0 right: int = 0
"""Space from the left of a region.""" """Space from the right of a region."""
bottom: int = 0 bottom: int = 0
"""Space from the bottom of a region.""" """Space from the bottom of a region."""
left: int = 0 left: int = 0
@@ -1083,3 +1095,9 @@ class Spacing(NamedTuple):
NULL_OFFSET: Final = Offset(0, 0) NULL_OFFSET: Final = Offset(0, 0)
"""An [offset][textual.geometry.Offset] constant for (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."""

View File

@@ -156,4 +156,4 @@ class GridLayout(Layout):
add_placement(WidgetPlacement(region, margin, widget)) add_placement(WidgetPlacement(region, margin, widget))
add_widget(widget) add_widget(widget)
return (placements, set(widgets)) return placements

View File

@@ -65,8 +65,6 @@ class HorizontalLayout(Layout):
x = Fraction(box_models[0].margin.left if box_models else 0) x = Fraction(box_models[0].margin.left if box_models else 0)
displayed_children = [child for child in children if child.display]
_Region = Region _Region = Region
_WidgetPlacement = WidgetPlacement _WidgetPlacement = WidgetPlacement
for widget, box_model, margin in zip(children, box_models, margins): for widget, box_model, margin in zip(children, box_models, margins):
@@ -86,4 +84,4 @@ class HorizontalLayout(Layout):
if not overlay: if not overlay:
x = next_x + margin x = next_x + margin
return placements, set(displayed_children) return placements

View File

@@ -86,4 +86,4 @@ class VerticalLayout(Layout):
if not overlay: if not overlay:
y = next_y + margin y = next_y + margin
return placements, set(children) return placements

View File

@@ -349,20 +349,25 @@ class MessagePump(metaclass=_MessagePumpMeta):
self._timers.add(timer) self._timers.add(timer)
return 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 """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. has been refreshed. Positional and keyword arguments are passed to the callable.
Args: Args:
callback: A callable. 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 # We send the InvokeLater message to ourselves first, to ensure we've cleared
# out anything already pending in our own queue. # out anything already pending in our own queue.
message = messages.InvokeLater(partial(callback, *args, **kwargs)) 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. """Schedule a callback to run after all messages are processed in this object.
Positional and keywords arguments are passed to the callable. Positional and keywords arguments are passed to the callable.
@@ -370,9 +375,14 @@ class MessagePump(metaclass=_MessagePumpMeta):
callback: Callable to call next. callback: Callable to call next.
*args: Positional arguments to pass to the callable. *args: Positional arguments to pass to the callable.
**kwargs: Keyword 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)) 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: def call_next(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
"""Schedule a callback to run immediately after processing the current message. """Schedule a callback to run immediately after processing the current message.

View File

@@ -65,6 +65,7 @@ class Pilot(Generic[ReturnType]):
""" """
if keys: if keys:
await self._app._press_keys(keys) await self._app._press_keys(keys)
await self._wait_for_screen()
async def click( async def click(
self, self,
@@ -132,13 +133,49 @@ class Pilot(Generic[ReturnType]):
app.post_message(MouseMove(**message_arguments)) app.post_message(MouseMove(**message_arguments))
await self.pause() 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: async def pause(self, delay: float | None = None) -> None:
"""Insert a pause. """Insert a pause.
Args: Args:
delay: Seconds to pause, or None to wait for cpu idle. 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: if delay is None:
await wait_for_idle(0) await wait_for_idle(0)
else: else:
@@ -152,7 +189,9 @@ class Pilot(Generic[ReturnType]):
async def wait_for_scheduled_animations(self) -> None: async def wait_for_scheduled_animations(self) -> None:
"""Wait for any current and scheduled animations to complete.""" """Wait for any current and scheduled animations to complete."""
await self._wait_for_screen()
await self._app.animator.wait_until_complete() await self._app.animator.wait_until_complete()
await self._wait_for_screen()
await wait_for_idle() await wait_for_idle()
self.app.screen._on_timer_update() self.app.screen._on_timer_update()
@@ -162,5 +201,6 @@ class Pilot(Generic[ReturnType]):
Args: Args:
result: The app result returned by `run` or `run_async`. result: The app result returned by `run` or `run_async`.
""" """
await self._wait_for_screen()
await wait_for_idle() await wait_for_idle()
self.app.exit(result) self.app.exit(result)

View File

@@ -9,6 +9,7 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Awaitable, Awaitable,
Callable, Callable,
ClassVar,
Generic, Generic,
Iterable, Iterable,
Iterator, Iterator,
@@ -30,7 +31,7 @@ from ._types import CallbackType
from .binding import Binding from .binding import Binding
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 NoMatches, QueryType
from .dom import DOMNode from .dom import DOMNode
from .geometry import Offset, Region, Size from .geometry import Offset, Region, Size
from .reactive import Reactive from .reactive import Reactive
@@ -93,6 +94,13 @@ class ResultCallback(Generic[ScreenResultType]):
class Screen(Generic[ScreenResultType], Widget): class Screen(Generic[ScreenResultType], Widget):
"""The base class for screens.""" """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 = """ DEFAULT_CSS = """
Screen { Screen {
layout: vertical; layout: vertical;
@@ -100,7 +108,6 @@ class Screen(Generic[ScreenResultType], Widget):
background: $surface; background: $surface;
} }
""" """
focused: Reactive[Widget | None] = Reactive(None) focused: Reactive[Widget | None] = Reactive(None)
"""The focused [widget][textual.widget.Widget] or `None` for no focus.""" """The focused [widget][textual.widget.Widget] or `None` for no focus."""
stack_updates: Reactive[int] = Reactive(0, repaint=False) stack_updates: Reactive[int] = Reactive(0, repaint=False)
@@ -577,6 +584,7 @@ class Screen(Generic[ScreenResultType], Widget):
virtual_size, virtual_size,
container_size, container_size,
_, _,
_,
) in layers: ) in layers:
if widget in exposed_widgets: if widget in exposed_widgets:
if widget._size_updated( if widget._size_updated(
@@ -607,6 +615,7 @@ class Screen(Generic[ScreenResultType], Widget):
virtual_size, virtual_size,
container_size, container_size,
_, _,
_,
) in layers: ) in layers:
widget._size_updated(region.size, virtual_size, container_size) widget._size_updated(region.size, virtual_size, container_size)
if widget in send_resize: if widget in send_resize:
@@ -661,6 +670,11 @@ class Screen(Generic[ScreenResultType], Widget):
size = self.app.size size = self.app.size
self._refresh_layout(size, full=True) self._refresh_layout(size, full=True)
self.refresh() 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: def _on_screen_suspend(self) -> None:
"""Screen has suspended.""" """Screen has suspended."""
@@ -754,16 +768,23 @@ class Screen(Generic[ScreenResultType], Widget):
def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None: def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None:
"""Dismiss the screen, optionally with a result. """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: Args:
result: The optional result to be passed to the result callback. result: The optional result to be passed to the result callback.
Note: Raises:
If the screen was pushed with a callback, the callback will be ScreenStackError: If trying to dismiss a screen that is not at the top of
called with the given result and then a call to the stack.
[`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].
""" """
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: if result is not self._NoResult and self._result_callbacks:
self._result_callbacks[-1](cast(ScreenResultType, result)) self._result_callbacks[-1](cast(ScreenResultType, result))
self.app.pop_screen() self.app.pop_screen()

View File

@@ -379,12 +379,18 @@ class Strip:
""" """
pos = 0 pos = 0
cell_length = self.cell_length
cuts = [cut for cut in cuts if cut <= cell_length]
cache_key = tuple(cuts) cache_key = tuple(cuts)
cached = self._divide_cache.get(cache_key) cached = self._divide_cache.get(cache_key)
if cached is not None: if cached is not None:
return cached return cached
strips: list[Strip] = [] strips: list[Strip]
if cuts == [cell_length]:
strips = [self]
else:
strips = []
add_strip = strips.append add_strip = strips.append
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts): for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
add_strip(Strip(segments, cut - pos)) add_strip(Strip(segments, cut - pos))

View File

@@ -57,7 +57,7 @@ from .box_model import BoxModel
from .css.query import NoMatches, WrongType from .css.query import NoMatches, WrongType
from .css.scalar import ScalarOffset from .css.scalar import ScalarOffset
from .dom import DOMNode, NoScreen 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 .layouts.vertical import VerticalLayout
from .message import Message from .message import Message
from .messages import CallbackType from .messages import CallbackType
@@ -795,19 +795,15 @@ class Widget(DOMNode):
Args: Args:
child: The child widget to move. child: The child widget to move.
before: Optional location to move before. An `int` is the index before: Child widget or location index to move before.
of the child to move before, a `str` is a `query_one` query to after: Child widget or location index to move after.
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.
Raises: Raises:
WidgetError: If there is a problem with the child or target. WidgetError: If there is a problem with the child or target.
Note: Note:
Only one of ``before`` or ``after`` can be provided. If neither Only one of `before` or `after` can be provided. If neither
or both are provided a ``WidgetError`` will be raised. or both are provided a `WidgetError` will be raised.
""" """
# One or the other of before or after are required. Can't do # 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: elif before is not None and after is not None:
raise WidgetError("Only one of `before` or `after` can be handled.") 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: def _to_widget(child: int | Widget, called: str) -> Widget:
"""Ensure a given child reference is a Widget.""" """Ensure a given child reference is a Widget."""
if isinstance(child, int): if isinstance(child, int):
@@ -1375,10 +1375,20 @@ class Widget(DOMNode):
""" """
try: try:
return self.screen.find_widget(self).region return self.screen.find_widget(self).region
except NoScreen: except (NoScreen, errors.NoWidget):
return Region() return NULL_REGION
except errors.NoWidget:
return 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 @property
def container_viewport(self) -> Region: def container_viewport(self) -> Region:
@@ -2263,7 +2273,7 @@ class Widget(DOMNode):
else: else:
scroll_offset = container.scroll_to_region( scroll_offset = container.scroll_to_region(
region, region,
spacing=widget.parent.gutter, spacing=widget.parent.gutter + widget.dock_gutter,
animate=animate, animate=animate,
speed=speed, speed=speed,
duration=duration, duration=duration,

View File

@@ -1,15 +1,17 @@
from __future__ import annotations from __future__ import annotations
from asyncio import Queue
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import ClassVar, Iterable from typing import ClassVar, Iterable, Iterator
from rich.style import Style from rich.style import Style
from rich.text import Text, TextType from rich.text import Text, TextType
from ..events import Mount from .. import work
from ..message import Message from ..message import Message
from ..reactive import var from ..reactive import var
from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker
from ._tree import TOGGLE_STYLE, Tree, TreeNode from ._tree import TOGGLE_STYLE, Tree, TreeNode
@@ -90,7 +92,7 @@ class DirectoryTree(Tree[DirEntry]):
""" """
return self.tree 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. """The path that is the root of the directory tree.
Note: Note:
@@ -116,6 +118,7 @@ class DirectoryTree(Tree[DirEntry]):
classes: A space-separated list of classes, or None for no classes. classes: A space-separated list of classes, or None for no classes.
disabled: Whether the directory tree is disabled or not. disabled: Whether the directory tree is disabled or not.
""" """
self._load_queue: Queue[TreeNode[DirEntry]] = Queue()
super().__init__( super().__init__(
str(path), str(path),
data=DirEntry(Path(path)), data=DirEntry(Path(path)),
@@ -126,10 +129,26 @@ class DirectoryTree(Tree[DirEntry]):
) )
self.path = path 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: def reload(self) -> None:
"""Reload the `DirectoryTree` contents.""" """Reload the `DirectoryTree` contents."""
self.reset(str(self.path), DirEntry(Path(self.path))) 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: def validate_path(self, path: str | Path) -> Path:
"""Ensure that the path is of the `Path` type. """Ensure that the path is of the `Path` type.
@@ -229,37 +248,115 @@ class DirectoryTree(Tree[DirEntry]):
""" """
return paths 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. """Load the directory contents for a given node.
Args: Args:
node: The node to load the directory contents for. 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 assert node.data is not None
node.data.loaded = True return sorted(
directory = sorted( self.filter_paths(
self.filter_paths(node.data.path.iterdir()), self._directory_content(node.data.path, get_current_worker())
key=lambda path: (not path.is_dir(), path.name.lower()), ),
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: @work(exclusive=True)
self._load_directory(self.root) 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: def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
event.stop() event.stop()
dir_entry = event.node.data dir_entry = event.node.data
if dir_entry is None: if dir_entry is None:
return return
if dir_entry.path.is_dir(): if self._safe_is_dir(dir_entry.path):
if not dir_entry.loaded: if not dir_entry.loaded:
self._load_directory(event.node) self._add_to_load_queue(event.node)
else: else:
self.post_message(self.FileSelected(self, event.node, dir_entry.path)) self.post_message(self.FileSelected(self, event.node, dir_entry.path))
@@ -268,5 +365,5 @@ class DirectoryTree(Tree[DirEntry]):
dir_entry = event.node.data dir_entry = event.node.data
if dir_entry is None: if dir_entry is None:
return 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)) self.post_message(self.FileSelected(self, event.node, dir_entry.path))

View File

@@ -98,6 +98,7 @@ class Footer(Widget):
highlight_style = self.get_component_rich_style("footer--highlight") highlight_style = self.get_component_rich_style("footer--highlight")
highlight_key_style = self.get_component_rich_style("footer--highlight-key") highlight_key_style = self.get_component_rich_style("footer--highlight-key")
key_style = self.get_component_rich_style("footer--key") key_style = self.get_component_rich_style("footer--key")
description_style = self.get_component_rich_style("footer--description")
bindings = [ bindings = [
binding binding
@@ -122,7 +123,7 @@ class Footer(Widget):
(f" {key_display} ", highlight_key_style if hovered else key_style), (f" {key_display} ", highlight_key_style if hovered else key_style),
( (
f" {binding.description} ", f" {binding.description} ",
highlight_style if hovered else base_style, highlight_style if hovered else base_style + description_style,
), ),
meta={ meta={
"@click": f"app.check_bindings('{binding.key}')", "@click": f"app.check_bindings('{binding.key}')",

View File

@@ -332,6 +332,7 @@ class Input(Widget, can_focus=True):
event.prevent_default() event.prevent_default()
def _on_paste(self, event: events.Paste) -> None: def _on_paste(self, event: events.Paste) -> None:
if event.text:
line = event.text.splitlines()[0] line = event.text.splitlines()[0]
self.insert_text_at_cursor(line) self.insert_text_at_cursor(line)
event.stop() event.stop()

View File

@@ -627,11 +627,17 @@ class OptionList(ScrollView, can_focus=True):
""" """
self._contents.clear() self._contents.clear()
self._options.clear() self._options.clear()
self._refresh_content_tracking(force=True)
self.highlighted = None self.highlighted = None
self._mouse_hovering_over = None self._mouse_hovering_over = None
self.virtual_size = Size(self.scrollable_content_region.width, 0) 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 return self
def _set_option_disabled(self, index: int, disabled: bool) -> Self: def _set_option_disabled(self, index: int, disabled: bool) -> Self:

View File

@@ -185,10 +185,6 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
border: tall $accent; border: tall $accent;
} }
Select {
height: auto;
}
Select > SelectOverlay { Select > SelectOverlay {
width: 1fr; width: 1fr;
display: none; display: none;

View File

@@ -117,15 +117,17 @@ class Tab(Static):
label: TextType, label: TextType,
*, *,
id: str | None = None, id: str | None = None,
classes: str | None = None,
) -> None: ) -> None:
"""Initialise a Tab. """Initialise a Tab.
Args: Args:
label: The label to use in the tab. label: The label to use in the tab.
id: Optional ID for the widget. 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 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) self.update(label)
@property @property

File diff suppressed because one or more lines are too long

View 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()

View 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()

View File

@@ -19,6 +19,6 @@
#horizontal { #horizontal {
width: auto; width: auto;
height: auto; height: 4;
background: darkslateblue; background: darkslateblue;
} }

View 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()

View 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()

View File

@@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
import sys
import pytest import pytest
@@ -78,8 +77,7 @@ def test_switches(snap_compare):
def test_input_and_focus(snap_compare): def test_input_and_focus(snap_compare):
press = [ press = [
"tab", *"Darren", # Write "Darren"
*"Darren", # Focus first input, write "Darren"
"tab", "tab",
*"Burns", # Focus second input, write "Burns" *"Burns", # Focus second input, write "Burns"
] ]
@@ -88,7 +86,7 @@ def test_input_and_focus(snap_compare):
def test_buttons_render(snap_compare): def test_buttons_render(snap_compare):
# Testing button rendering. We press tab to focus the first button too. # 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): 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): def test_content_switcher_example_switch(snap_compare):
assert snap_compare( assert snap_compare(
WIDGET_EXAMPLES_DIR / "content_switcher.py", WIDGET_EXAMPLES_DIR / "content_switcher.py",
press=["tab", "tab", "enter", "wait:500"], press=["tab", "enter", "wait:500"],
terminal_size=(50, 50), 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_options.py")
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py")
def test_option_list_build(snap_compare): def test_option_list_build(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py") assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py")
def test_progress_bar_indeterminate(snap_compare): def test_progress_bar_indeterminate(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"]) 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): 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): def test_colors_preview(snap_compare):
@@ -377,9 +377,7 @@ def test_disabled_widgets(snap_compare):
def test_focus_component_class(snap_compare): def test_focus_component_class(snap_compare):
assert snap_compare( assert snap_compare(SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab"])
SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab", "tab"]
)
def test_line_api_scrollbars(snap_compare): 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 # Check https://github.com/Textualize/textual/issues/2194
assert snap_compare( assert snap_compare(
SNAPSHOT_APPS_DIR / "modal_screen_bindings.py", 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)) 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): def test_auto_fr(snap_compare):
# https://github.com/Textualize/textual/issues/2220 # https://github.com/Textualize/textual/issues/2220
assert snap_compare(SNAPSHOT_APPS_DIR / "auto_fr.py", terminal_size=(80, 25)) 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): def test_fr_unit_with_min(snap_compare):
# https://github.com/Textualize/textual/issues/2378 # https://github.com/Textualize/textual/issues/2378
assert snap_compare(SNAPSHOT_APPS_DIR / "fr_with_min.py") 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"],
)

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Button from textual.widgets import Button, Input
def test_batch_update(): def test_batch_update():
@@ -20,6 +20,7 @@ def test_batch_update():
class MyApp(App): class MyApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Input()
yield Button("Click me!") yield Button("Click me!")

View File

@@ -36,7 +36,7 @@ async def test_on_button_pressed() -> None:
app = ButtonApp() app = ButtonApp()
async with app.run_test() as pilot: 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() await pilot.pause()
assert pressed == [ assert pressed == [

View File

@@ -1,5 +1,6 @@
from textual import events from textual import events
from textual.app import App from textual.app import App
from textual.widgets import Input
async def test_paste_app(): async def test_paste_app():
@@ -16,3 +17,29 @@ async def test_paste_app():
assert len(paste_events) == 1 assert len(paste_events) == 1
assert paste_events[0].text == "Hello" 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 == ""

View File

@@ -6,6 +6,7 @@ import pytest
from textual.app import App, ScreenStackError from textual.app import App, ScreenStackError
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Input, Label
skip_py310 = pytest.mark.skipif( skip_py310 = pytest.mark.skipif(
sys.version_info.minor == 10 and sys.version_info.major == 3, sys.version_info.minor == 10 and sys.version_info.major == 3,
@@ -150,3 +151,73 @@ async def test_screens():
screen2.remove() screen2.remove()
screen3.remove() screen3.remove()
await app._shutdown() 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()

View File

@@ -42,22 +42,18 @@ async def test_move_child_to_outside() -> None:
pilot.app.screen.move_child(child, before=Widget()) 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: async def test_move_child_before_itself() -> None:
"""Test moving a widget before itself.""" """Test moving a widget before itself."""
async with App().run_test() as pilot: async with App().run_test() as pilot:
child = Widget(Widget()) child = Widget(Widget())
await pilot.app.mount(child) await pilot.app.mount(child)
pilot.app.screen.move_child(child, before=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: async def test_move_child_after_itself() -> None:
"""Test moving a widget after itself.""" """Test moving a widget after itself."""
# Regression test for https://github.com/Textualize/textual/issues/1743
async with App().run_test() as pilot: async with App().run_test() as pilot:
child = Widget(Widget()) child = Widget(Widget())
await pilot.app.mount(child) await pilot.app.mount(child)

View File

@@ -39,6 +39,7 @@ async def test_radio_sets_initial_state():
async def test_click_sets_focus(): async def test_click_sets_focus():
"""Clicking within a radio set should set focus.""" """Clicking within a radio set should set focus."""
async with RadioSetApp().run_test() as pilot: async with RadioSetApp().run_test() as pilot:
pilot.app.set_focus(None)
assert pilot.app.screen.focused is None assert pilot.app.screen.focused is None
await pilot.click("#clickme") await pilot.click("#clickme")
assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons") 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(): async def test_radioset_inner_navigation():
"""Using the cursor keys should navigate between buttons in a set.""" """Using the cursor keys should navigate between buttons in a set."""
async with RadioSetApp().run_test() as pilot: async with RadioSetApp().run_test() as pilot:
assert pilot.app.screen.focused is None
await pilot.press("tab")
for key, landing in ( for key, landing in (
("down", 1), ("down", 1),
("up", 0), ("up", 0),
@@ -88,8 +87,6 @@ async def test_radioset_inner_navigation():
== pilot.app.query_one("#from_buttons").children[landing] == pilot.app.query_one("#from_buttons").children[landing]
) )
async with RadioSetApp().run_test() as pilot: 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") assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons")
await pilot.press("tab") await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings") 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(): async def test_radioset_breakout_navigation():
"""Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself.""" """Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself."""
async with RadioSetApp().run_test() as pilot: 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") assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons")
await pilot.press("tab") await pilot.press("tab")
assert pilot.app.screen.focused is pilot.app.query_one("#from_strings") assert pilot.app.screen.focused is pilot.app.query_one("#from_strings")