mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into multiselect
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
0
docs/examples/how-to/layout.css
Normal file
0
docs/examples/how-to/layout.css
Normal file
69
docs/examples/how-to/layout.py
Normal file
69
docs/examples/how-to/layout.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Tweet(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Tweet {
|
||||
height: 5;
|
||||
width: 1fr;
|
||||
border: tall $background;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Column(VerticalScroll):
|
||||
DEFAULT_CSS = """
|
||||
Column {
|
||||
height: 1fr;
|
||||
width: 32;
|
||||
margin: 0 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for tweet_no in range(1, 20):
|
||||
yield Tweet(id=f"Tweet{tweet_no}")
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
with HorizontalScroll():
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
CSS_PATH = "layout.css"
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
27
docs/examples/how-to/layout01.py
Normal file
27
docs/examples/how-to/layout01.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder): # (1)!
|
||||
pass
|
||||
|
||||
|
||||
class Footer(Placeholder): # (2)!
|
||||
pass
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header") # (3)!
|
||||
yield Footer(id="Footer") # (4)!
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
37
docs/examples/how-to/layout02.py
Normal file
37
docs/examples/how-to/layout02.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
48
docs/examples/how-to/layout03.py
Normal file
48
docs/examples/how-to/layout03.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ColumnsContainer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
ColumnsContainer {
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
border: solid white;
|
||||
}
|
||||
""" # (1)!
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
yield ColumnsContainer(id="Columns")
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
39
docs/examples/how-to/layout04.py
Normal file
39
docs/examples/how-to/layout04.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
yield HorizontalScroll() # (1)!
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
55
docs/examples/how-to/layout05.py
Normal file
55
docs/examples/how-to/layout05.py
Normal 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()
|
||||
69
docs/examples/how-to/layout06.py
Normal file
69
docs/examples/how-to/layout06.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class Header(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
height: 3;
|
||||
dock: top;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Footer(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Footer {
|
||||
height: 3;
|
||||
dock: bottom;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Tweet(Placeholder):
|
||||
DEFAULT_CSS = """
|
||||
Tweet {
|
||||
height: 5;
|
||||
width: 1fr;
|
||||
border: tall $background;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Column(VerticalScroll):
|
||||
DEFAULT_CSS = """
|
||||
Column {
|
||||
height: 1fr;
|
||||
width: 32;
|
||||
margin: 0 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for tweet_no in range(1, 20):
|
||||
yield Tweet(id=f"Tweet{tweet_no}")
|
||||
|
||||
|
||||
class TweetScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(id="Header")
|
||||
yield Footer(id="Footer")
|
||||
with HorizontalScroll():
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
yield Column()
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
CSS_PATH = "layout.css"
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LayoutApp()
|
||||
app.run()
|
||||
194
docs/how-to/design-a-layout.md
Normal file
194
docs/how-to/design-a-layout.md
Normal 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
7
docs/how-to/index.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# How To
|
||||
|
||||
Welcome to the How To section.
|
||||
|
||||
Here you will find How To articles which cover various topics at a higher level than the Guide or Reference.
|
||||
We will be adding more articles in the future.
|
||||
If there is anything you would like to see covered, [open an issue](https://github.com/Textualize/textual/issues) in the Textual repository!
|
||||
16
docs/images/how-to/layout.excalidraw.svg
Normal file
16
docs/images/how-to/layout.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 44 KiB |
@@ -157,6 +157,7 @@ nav:
|
||||
- "widgets/text_log.md"
|
||||
- "widgets/tree.md"
|
||||
- API:
|
||||
- "api/index.md"
|
||||
- "api/app.md"
|
||||
- "api/await_remove.md"
|
||||
- "api/binding.md"
|
||||
@@ -189,6 +190,9 @@ nav:
|
||||
- "api/work.md"
|
||||
- "api/worker.md"
|
||||
- "api/worker_manager.md"
|
||||
- "How To":
|
||||
- "how-to/index.md"
|
||||
- "how-to/design-a-layout.md"
|
||||
- "roadmap.md"
|
||||
- "Blog":
|
||||
- blog/index.md
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
from textual.widgets import Button, Input
|
||||
|
||||
|
||||
def test_batch_update():
|
||||
@@ -20,6 +20,7 @@ def test_batch_update():
|
||||
|
||||
class MyApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input()
|
||||
yield Button("Click me!")
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ async def test_on_button_pressed() -> None:
|
||||
|
||||
app = ButtonApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("tab", "enter", "tab", "enter", "tab", "enter")
|
||||
await pilot.press("enter", "tab", "enter", "tab", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert pressed == [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -39,6 +39,7 @@ async def test_radio_sets_initial_state():
|
||||
async def test_click_sets_focus():
|
||||
"""Clicking within a radio set should set focus."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
pilot.app.set_focus(None)
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.click("#clickme")
|
||||
assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons")
|
||||
@@ -72,8 +73,6 @@ async def test_radioset_same_button_mash():
|
||||
async def test_radioset_inner_navigation():
|
||||
"""Using the cursor keys should navigate between buttons in a set."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
for key, landing in (
|
||||
("down", 1),
|
||||
("up", 0),
|
||||
@@ -88,8 +87,6 @@ async def test_radioset_inner_navigation():
|
||||
== pilot.app.query_one("#from_buttons").children[landing]
|
||||
)
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons")
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings")
|
||||
@@ -101,8 +98,6 @@ async def test_radioset_inner_navigation():
|
||||
async def test_radioset_breakout_navigation():
|
||||
"""Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons")
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.query_one("#from_strings")
|
||||
|
||||
Reference in New Issue
Block a user