Merge branch 'main' into multiselect

This commit is contained in:
Dave Pearson
2023-05-18 08:56:11 +01:00
24 changed files with 1642 additions and 952 deletions

View File

@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### 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
@@ -24,6 +25,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- 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

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: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 %}

View File

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,55 @@
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):
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,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,194 @@
# 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.
=== "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"}
```
You should see from the output that we have fixed width columns that will scroll horizontally.
You can also scroll the "tweets" in each column vertically.
This last example is a relatively complete design.
There are plenty of things you might want to tweak, but this contains all the elements you might need.
## 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/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

View File

@@ -2140,6 +2140,7 @@ class App(Generic[ReturnType], DOMNode):
screen = Screen(id="_default")
self._register(self, screen)
self._screen_stack.append(screen)
screen.post_message(events.ScreenResume())
await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded:

View File

@@ -668,15 +668,13 @@ class Screen(Generic[ScreenResultType], Widget):
"""Screen has resumed."""
self.stack_updates += 1
size = self.app.size
if self.AUTO_FOCUS is not None and self.focused is None:
try:
to_focus = self.query(self.AUTO_FOCUS).first()
except NoMatches:
pass
else:
self.set_focus(to_focus)
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."""

View File

@@ -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: A 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))

File diff suppressed because one or more lines are too long

View File

@@ -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),
)
@@ -315,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):
@@ -379,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):
@@ -442,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"],
)
@@ -518,6 +514,5 @@ def test_select_rebuild(snap_compare):
# https://github.com/Textualize/textual/issues/2557
assert snap_compare(
SNAPSHOT_APPS_DIR / "select_rebuild.py",
press=["tab", "space", "escape", "tab", "enter", "tab", "space"]
press=["space", "escape", "tab", "enter", "tab", "space"],
)

View File

@@ -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!")

View File

@@ -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 == [

View File

@@ -38,6 +38,7 @@ async def test_empty_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

View File

@@ -6,7 +6,7 @@ import pytest
from textual.app import App, ScreenStackError
from textual.screen import Screen
from textual.widgets import Button, Input
from textual.widgets import Button, Input, Label
skip_py310 = pytest.mark.skipif(
sys.version_info.minor == 10 and sys.version_info.major == 3,
@@ -155,8 +155,7 @@ async def test_screens():
async def test_auto_focus():
class MyScreen(Screen[None]):
def compose(self) -> None:
print("composing")
def compose(self):
yield Button()
yield Input(id="one")
yield Input(id="two")
@@ -194,6 +193,22 @@ async def test_auto_focus():
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:

View File

@@ -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")