mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into screen-modes
This commit is contained in:
22
.github/workflows/black_format.yml
vendored
Normal file
22
.github/workflows/black_format.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
name: Black format check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/black_format.yml'
|
||||||
|
- '**.py'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
black-format-check:
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3.5.2
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4.6.0
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Install black
|
||||||
|
run: python -m pip install black
|
||||||
|
- name: Run black
|
||||||
|
run: black --check src
|
||||||
5
.github/workflows/comment.yml
vendored
5
.github/workflows/comment.yml
vendored
@@ -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
|
||||||
@@ -14,5 +15,5 @@ jobs:
|
|||||||
issue-number: ${{ github.event.issue.number }}
|
issue-number: ${{ github.event.issue.number }}
|
||||||
body: |
|
body: |
|
||||||
Don't forget to [star](https://github.com/Textualize/textual) the repository!
|
Don't forget to [star](https://github.com/Textualize/textual) the repository!
|
||||||
|
|
||||||
Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.
|
Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.
|
||||||
|
|||||||
3
.github/workflows/new_issue.yml
vendored
3
.github/workflows/new_issue.yml
vendored
@@ -1,7 +1,8 @@
|
|||||||
name: issues
|
name: FAQ issue comment
|
||||||
on:
|
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 ) }}
|
||||||
|
|||||||
26
.github/workflows/pythonpackage.yml
vendored
26
.github/workflows/pythonpackage.yml
vendored
@@ -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: |
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -6,11 +6,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
docs/examples/guide/screens/modal03.py
Normal file
57
docs/examples/guide/screens/modal03.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Grid
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Button, Footer, Header, Label
|
||||||
|
|
||||||
|
TEXT = """I must not fear.
|
||||||
|
Fear is the mind-killer.
|
||||||
|
Fear is the little-death that brings total obliteration.
|
||||||
|
I will face my fear.
|
||||||
|
I will permit it to pass over me and through me.
|
||||||
|
And when it has gone past, I will turn the inner eye to see its path.
|
||||||
|
Where the fear has gone there will be nothing. Only I will remain."""
|
||||||
|
|
||||||
|
|
||||||
|
class QuitScreen(ModalScreen[bool]): # (1)!
|
||||||
|
"""Screen with a dialog to quit."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Grid(
|
||||||
|
Label("Are you sure you want to quit?", id="question"),
|
||||||
|
Button("Quit", variant="error", id="quit"),
|
||||||
|
Button("Cancel", variant="primary", id="cancel"),
|
||||||
|
id="dialog",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "quit":
|
||||||
|
self.dismiss(True)
|
||||||
|
else:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
|
||||||
|
class ModalApp(App):
|
||||||
|
"""An app with a modal dialog."""
|
||||||
|
|
||||||
|
CSS_PATH = "modal01.css"
|
||||||
|
BINDINGS = [("q", "request_quit", "Quit")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Label(TEXT * 8)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def action_request_quit(self) -> None:
|
||||||
|
"""Action to display the quit dialog."""
|
||||||
|
|
||||||
|
def check_quit(quit: bool) -> None:
|
||||||
|
"""Called when QuitScreen is dismissed."""
|
||||||
|
if quit:
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
self.push_screen(QuitScreen(), check_quit)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = ModalApp()
|
||||||
|
app.run()
|
||||||
69
docs/examples/how-to/layout.py
Normal file
69
docs/examples/how-to/layout.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import HorizontalScroll, VerticalScroll
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Footer {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Tweet(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Tweet {
|
||||||
|
height: 5;
|
||||||
|
width: 1fr;
|
||||||
|
border: tall $background;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Column(VerticalScroll):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Column {
|
||||||
|
height: 1fr;
|
||||||
|
width: 32;
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
for tweet_no in range(1, 20):
|
||||||
|
yield Tweet(id=f"Tweet{tweet_no}")
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header")
|
||||||
|
yield Footer(id="Footer")
|
||||||
|
with HorizontalScroll():
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
CSS_PATH = "layout.css"
|
||||||
|
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
27
docs/examples/how-to/layout01.py
Normal file
27
docs/examples/how-to/layout01.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder): # (1)!
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder): # (2)!
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header") # (3)!
|
||||||
|
yield Footer(id="Footer") # (4)!
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
37
docs/examples/how-to/layout02.py
Normal file
37
docs/examples/how-to/layout02.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Footer {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header")
|
||||||
|
yield Footer(id="Footer")
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
48
docs/examples/how-to/layout03.py
Normal file
48
docs/examples/how-to/layout03.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Footer {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ColumnsContainer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
ColumnsContainer {
|
||||||
|
width: 1fr;
|
||||||
|
height: 1fr;
|
||||||
|
border: solid white;
|
||||||
|
}
|
||||||
|
""" # (1)!
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header")
|
||||||
|
yield Footer(id="Footer")
|
||||||
|
yield ColumnsContainer(id="Columns")
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
39
docs/examples/how-to/layout04.py
Normal file
39
docs/examples/how-to/layout04.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import HorizontalScroll
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Footer {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header")
|
||||||
|
yield Footer(id="Footer")
|
||||||
|
yield HorizontalScroll() # (1)!
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
53
docs/examples/how-to/layout05.py
Normal file
53
docs/examples/how-to/layout05.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import HorizontalScroll, VerticalScroll
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Footer {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Tweet(Placeholder):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Column(VerticalScroll):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
for tweet_no in range(1, 20):
|
||||||
|
yield Tweet(id=f"Tweet{tweet_no}")
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header")
|
||||||
|
yield Footer(id="Footer")
|
||||||
|
with HorizontalScroll():
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
67
docs/examples/how-to/layout06.py
Normal file
67
docs/examples/how-to/layout06.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import HorizontalScroll, VerticalScroll
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Placeholder
|
||||||
|
|
||||||
|
|
||||||
|
class Header(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Footer(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Footer {
|
||||||
|
height: 3;
|
||||||
|
dock: bottom;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Tweet(Placeholder):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Tweet {
|
||||||
|
height: 5;
|
||||||
|
width: 1fr;
|
||||||
|
border: tall $background;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Column(VerticalScroll):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Column {
|
||||||
|
height: 1fr;
|
||||||
|
width: 32;
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
for tweet_no in range(1, 20):
|
||||||
|
yield Tweet(id=f"Tweet{tweet_no}")
|
||||||
|
|
||||||
|
|
||||||
|
class TweetScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header(id="Header")
|
||||||
|
yield Footer(id="Footer")
|
||||||
|
with HorizontalScroll():
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
yield Column()
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutApp(App):
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.push_screen(TweetScreen())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = LayoutApp()
|
||||||
|
app.run()
|
||||||
@@ -219,3 +219,40 @@ Let's see what happens when we use `ModalScreen`.
|
|||||||
|
|
||||||
Now when we press ++q++, the dialog is displayed over the main screen.
|
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.
|
||||||
|
|||||||
200
docs/how-to/design-a-layout.md
Normal file
200
docs/how-to/design-a-layout.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Design a Layout
|
||||||
|
|
||||||
|
This article discusses an approach you can take when designing the layout for your applications.
|
||||||
|
|
||||||
|
Textual's layout system is flexible enough to accommodate just about any application design you could conceive of, but it may be hard to know where to start. We will go through a few tips which will help you get over the initial hurdle of designing an application layout.
|
||||||
|
|
||||||
|
|
||||||
|
## Tip 1. Make a sketch
|
||||||
|
|
||||||
|
The initial design of your application is best done with a sketch.
|
||||||
|
You could use a drawing package such as [Excalidraw](https://excalidraw.com/) for your sketch, but pen and paper is equally as good.
|
||||||
|
|
||||||
|
Start by drawing a rectangle to represent a blank terminal, then draw a rectangle for each element in your application. Annotate each of the rectangles with the content they will contain, and note wether they will scroll (and in what direction).
|
||||||
|
|
||||||
|
For the purposes of this article we are going to design a layout for a Twitter or Mastodon client, which will have a header / footer and a number of columns.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
The approach we are discussing here is applicable even if the app you want to build looks nothing like our sketch!
|
||||||
|
|
||||||
|
Here's our sketch:
|
||||||
|
|
||||||
|
<div class="excalidraw">
|
||||||
|
--8<-- "docs/images/how-to/layout.excalidraw.svg"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
It's rough, but it's all we need.
|
||||||
|
|
||||||
|
|
||||||
|
## Tip 2. Work outside in
|
||||||
|
|
||||||
|
Like a sculpture with a block of marble, it is best to work from the outside towards the center.
|
||||||
|
If your design has fixed elements (like a header, footer, or sidebar), start with those first.
|
||||||
|
|
||||||
|
In our sketch we have a header and footer.
|
||||||
|
Since these are the outermost widgets, we will begin by adding them.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
Textual has builtin [Header](../widgets/header.md) and [Footer](../widgets/footer.md) widgets which you could use in a real application.
|
||||||
|
|
||||||
|
The following example defines an [app](../guide/app.md), a [screen](../guide/screens.md), and our header and footer widgets.
|
||||||
|
Since we're starting from scratch and don't have any functionality for our widgets, we are going to use the [Placeholder][textual.widgets.Placeholder] widget to help us visualize our design.
|
||||||
|
|
||||||
|
In a real app, we would replace these placeholders with more useful content.
|
||||||
|
|
||||||
|
=== "layout01.py"
|
||||||
|
|
||||||
|
```python
|
||||||
|
--8<-- "docs/examples/how-to/layout01.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The Header widget extends Placeholder.
|
||||||
|
2. The footer widget extends Placeholder.
|
||||||
|
3. Creates the header widget (the id will be displayed within the placeholder widget).
|
||||||
|
4. Creates the footer widget.
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/how-to/layout01.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tip 3. Apply docks
|
||||||
|
|
||||||
|
This app works, but the header and footer don't behave as expected.
|
||||||
|
We want both of these widgets to be fixed to an edge of the screen and limited in height.
|
||||||
|
In Textual this is known as *docking* which you can apply with the [dock](../styles/dock.md) rule.
|
||||||
|
|
||||||
|
We will dock the header and footer to the top and bottom edges of the screen respectively, by adding a little [CSS](../guide/CSS.md) to the widget classes:
|
||||||
|
|
||||||
|
=== "layout02.py"
|
||||||
|
|
||||||
|
```python hl_lines="7-12 16-21"
|
||||||
|
--8<-- "docs/examples/how-to/layout02.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/how-to/layout02.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `DEFAULT_CSS` class variable is used to set CSS directly in Python code.
|
||||||
|
We could define these in an external CSS file, but writing the CSS inline like this can be convenient if it isn't too complex.
|
||||||
|
|
||||||
|
When you dock a widget, it reduces the available area for other widgets.
|
||||||
|
This means that Textual will automatically compensate for the 6 additional lines reserved for the header and footer.
|
||||||
|
|
||||||
|
## Tip 4. Use FR Units for flexible things
|
||||||
|
|
||||||
|
After we've added the header and footer, we want the remaining space to be used for the main interface, which will contain the columns in the sketch.
|
||||||
|
This area is flexible (will change according to the size of the terminal), so how do we ensure that it takes up precisely the space needed?
|
||||||
|
|
||||||
|
The simplest way is to use [fr](../css_types/scalar.md#fraction) units.
|
||||||
|
By setting both the width and height to `1fr`, we are telling Textual to divide the space equally amongst the remaining widgets.
|
||||||
|
There is only a single widget, so that widget will fill all of the remaining space.
|
||||||
|
|
||||||
|
Let's make that change.
|
||||||
|
|
||||||
|
=== "layout03.py"
|
||||||
|
|
||||||
|
```python hl_lines="24-31 38"
|
||||||
|
--8<-- "docs/examples/how-to/layout03.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Here's where we set the width and height to `1fr`. We also add a border just to illustrate the dimensions better.
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/how-to/layout03.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
As you can see, the central Columns area will resize with the terminal window.
|
||||||
|
|
||||||
|
## Tip 5. Use containers
|
||||||
|
|
||||||
|
Before we add content to the Columns area, we have an opportunity to simplify.
|
||||||
|
Rather than extend `Placeholder` for our `ColumnsContainer` widget, we can use one of the builtin *containers*.
|
||||||
|
A container is simply a widget designed to *contain* other widgets.
|
||||||
|
Containers are styled with `fr` units to fill the remaining space so we won't need to add any more CSS.
|
||||||
|
|
||||||
|
Let's replace the `ColumnsContainer` class in the previous example with a `HorizontalScroll` container, which also adds an automatic horizontal scrollbar.
|
||||||
|
|
||||||
|
=== "layout04.py"
|
||||||
|
|
||||||
|
```python hl_lines="2 29"
|
||||||
|
--8<-- "docs/examples/how-to/layout04.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The builtin container widget.
|
||||||
|
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/how-to/layout04.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The container will appear as blank space until we add some widgets to it.
|
||||||
|
|
||||||
|
Let's add the columns to the `HorizontalScroll`.
|
||||||
|
A column is itself a container which will have a vertical scrollbar, so we will define our `Column` by subclassing `VerticalScroll`.
|
||||||
|
In a real app, these columns will likely be added dynamically from some kind of configuration, but let's add 4 to visualize the layout.
|
||||||
|
|
||||||
|
We will also define a `Tweet` placeholder and add a few to each column.
|
||||||
|
|
||||||
|
=== "layout05.py"
|
||||||
|
|
||||||
|
```python hl_lines="2 25-26 29-32 39-43"
|
||||||
|
--8<-- "docs/examples/how-to/layout05.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/how-to/layout05.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note from the output that each `Column` takes a quarter of the screen width.
|
||||||
|
This happens because `Column` extends a container which has a width of `1fr`.
|
||||||
|
|
||||||
|
It makes more sense for a column in a Twitter / Mastodon client to use a fixed width.
|
||||||
|
Let's set the width of the columns to 32.
|
||||||
|
|
||||||
|
We also want to reduce the height of each "tweet".
|
||||||
|
In the real app, you might set the height to "auto" so it fits the content, but lets set it to 5 lines for now.
|
||||||
|
|
||||||
|
Here's the final example and a reminder of the sketch.
|
||||||
|
|
||||||
|
=== "layout06.py"
|
||||||
|
|
||||||
|
```python hl_lines="25-32 36-46"
|
||||||
|
--8<-- "docs/examples/how-to/layout06.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/how-to/layout06.py" columns="100" lines="32"}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Sketch"
|
||||||
|
|
||||||
|
<div class="excalidraw">
|
||||||
|
--8<-- "docs/images/how-to/layout.excalidraw.svg"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
A layout like this is a great starting point.
|
||||||
|
In a real app, you would start replacing each of the placeholders with [builtin](../widget_gallery.md) or [custom](../guide/widgets.md) widgets.
|
||||||
|
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Layout is the first thing you will tackle when building a Textual app.
|
||||||
|
The following tips will help you get started.
|
||||||
|
|
||||||
|
1. Make a sketch (pen and paper is fine).
|
||||||
|
2. Work outside in. Start with the entire space of the terminal, add the outermost content first.
|
||||||
|
3. Dock fixed widgets. If the content doesn't move or scroll, you probably want to *dock* it.
|
||||||
|
4. Make use of `fr` for flexible space within layouts.
|
||||||
|
5. Use containers to contain other widgets, particularly if they scroll!
|
||||||
|
|
||||||
|
If you need further help, we are here to [help](/help/).
|
||||||
7
docs/how-to/index.md
Normal file
7
docs/how-to/index.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# How To
|
||||||
|
|
||||||
|
Welcome to the How To section.
|
||||||
|
|
||||||
|
Here you will find How To articles which cover various topics at a higher level than the Guide or Reference.
|
||||||
|
We will be adding more articles in the future.
|
||||||
|
If there is anything you would like to see covered, [open an issue](https://github.com/Textualize/textual/issues) in the Textual repository!
|
||||||
16
docs/images/how-to/layout.excalidraw.svg
Normal file
16
docs/images/how-to/layout.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 44 KiB |
@@ -157,6 +157,7 @@ nav:
|
|||||||
- "widgets/text_log.md"
|
- "widgets/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
|
||||||
|
|||||||
@@ -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
BIN
reference/spacing.monopic
Normal file
Binary file not shown.
@@ -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,107 +45,120 @@ 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
|
||||||
top = right = bottom = left = 0
|
_dock_placements, dock_spacing = _arrange_dock_widgets(
|
||||||
|
dock_widgets, size, viewport
|
||||||
for dock_widget in dock_widgets:
|
|
||||||
edge = dock_widget.styles.dock
|
|
||||||
|
|
||||||
box_model = dock_widget._get_box_model(
|
|
||||||
size, viewport, Fraction(size.width), Fraction(size.height)
|
|
||||||
)
|
|
||||||
widget_width_fraction, widget_height_fraction, margin = box_model
|
|
||||||
|
|
||||||
widget_width = int(widget_width_fraction) + margin.width
|
|
||||||
widget_height = int(widget_height_fraction) + margin.height
|
|
||||||
|
|
||||||
if edge == "bottom":
|
|
||||||
dock_region = Region(
|
|
||||||
0, height - widget_height, widget_width, widget_height
|
|
||||||
)
|
|
||||||
bottom = max(bottom, widget_height)
|
|
||||||
elif edge == "top":
|
|
||||||
dock_region = Region(0, 0, widget_width, widget_height)
|
|
||||||
top = max(top, widget_height)
|
|
||||||
elif edge == "left":
|
|
||||||
dock_region = Region(0, 0, widget_width, widget_height)
|
|
||||||
left = max(left, widget_width)
|
|
||||||
elif edge == "right":
|
|
||||||
dock_region = Region(
|
|
||||||
width - widget_width, 0, widget_width, widget_height
|
|
||||||
)
|
|
||||||
right = max(right, widget_width)
|
|
||||||
else:
|
|
||||||
# Should not occur, mainly to keep Mypy happy
|
|
||||||
raise AssertionError("invalid value for edge") # pragma: no-cover
|
|
||||||
|
|
||||||
align_offset = dock_widget.styles._align_size(
|
|
||||||
(widget_width, widget_height), size
|
|
||||||
)
|
|
||||||
dock_region = dock_region.shrink(margin).translate(align_offset)
|
|
||||||
add_placement(
|
|
||||||
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
|
|
||||||
)
|
|
||||||
|
|
||||||
dock_spacing = Spacing(top, right, bottom, left)
|
|
||||||
region = region.shrink(dock_spacing)
|
|
||||||
layout_placements, arranged_layout_widgets = widget._layout.arrange(
|
|
||||||
widget, layout_widgets, region.size
|
|
||||||
)
|
)
|
||||||
if arranged_layout_widgets:
|
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)
|
scroll_spacing = scroll_spacing.grow_maximum(dock_spacing)
|
||||||
arrange_widgets.update(arranged_layout_widgets)
|
|
||||||
|
|
||||||
placement_offset = region.offset
|
placement_offset = region.offset
|
||||||
|
# Perform any alignment of the widgets.
|
||||||
if styles.align_horizontal != "left" or styles.align_vertical != "top":
|
if styles.align_horizontal != "left" or styles.align_vertical != "top":
|
||||||
placement_size = Region.from_union(
|
bounding_region = WidgetPlacement.get_bounds(layout_placements)
|
||||||
[
|
|
||||||
placement.region.grow(placement.margin)
|
|
||||||
for placement in layout_placements
|
|
||||||
]
|
|
||||||
).size
|
|
||||||
placement_offset += styles._align_size(
|
placement_offset += styles._align_size(
|
||||||
placement_size, region.size
|
bounding_region.size, region.size
|
||||||
).clamped
|
).clamped
|
||||||
|
|
||||||
if placement_offset:
|
if placement_offset:
|
||||||
layout_placements = [
|
# Translate placements if required.
|
||||||
_WidgetPlacement(
|
layout_placements = WidgetPlacement.translate(
|
||||||
_region + placement_offset,
|
layout_placements, placement_offset
|
||||||
margin,
|
)
|
||||||
layout_widget,
|
|
||||||
order,
|
|
||||||
fixed,
|
|
||||||
overlay,
|
|
||||||
)
|
|
||||||
for _region, margin, layout_widget, order, fixed, overlay in layout_placements
|
|
||||||
]
|
|
||||||
|
|
||||||
placements.extend(layout_placements)
|
placements.extend(layout_placements)
|
||||||
|
|
||||||
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
|
return DockArrangeResult(placements, set(display_widgets), scroll_spacing)
|
||||||
|
|
||||||
|
|
||||||
|
def _arrange_dock_widgets(
|
||||||
|
dock_widgets: Sequence[Widget], size: Size, viewport: Size
|
||||||
|
) -> tuple[list[WidgetPlacement], Spacing]:
|
||||||
|
"""Arrange widgets which are *docked*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dock_widgets: Widgets with a non-empty dock.
|
||||||
|
size: Size of the container.
|
||||||
|
viewport: Size of the viewport.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of widget placements, and additional spacing around them
|
||||||
|
"""
|
||||||
|
_WidgetPlacement = WidgetPlacement
|
||||||
|
top_z = TOP_Z
|
||||||
|
width, height = size
|
||||||
|
null_spacing = Spacing()
|
||||||
|
|
||||||
|
top = right = bottom = left = 0
|
||||||
|
|
||||||
|
placements: list[WidgetPlacement] = []
|
||||||
|
append_placement = placements.append
|
||||||
|
|
||||||
|
for dock_widget in dock_widgets:
|
||||||
|
edge = dock_widget.styles.dock
|
||||||
|
|
||||||
|
box_model = dock_widget._get_box_model(
|
||||||
|
size, viewport, Fraction(size.width), Fraction(size.height)
|
||||||
|
)
|
||||||
|
widget_width_fraction, widget_height_fraction, margin = box_model
|
||||||
|
|
||||||
|
widget_width = int(widget_width_fraction) + margin.width
|
||||||
|
widget_height = int(widget_height_fraction) + margin.height
|
||||||
|
|
||||||
|
if edge == "bottom":
|
||||||
|
dock_region = Region(0, height - widget_height, widget_width, widget_height)
|
||||||
|
bottom = max(bottom, widget_height)
|
||||||
|
elif edge == "top":
|
||||||
|
dock_region = Region(0, 0, widget_width, widget_height)
|
||||||
|
top = max(top, widget_height)
|
||||||
|
elif edge == "left":
|
||||||
|
dock_region = Region(0, 0, widget_width, widget_height)
|
||||||
|
left = max(left, widget_width)
|
||||||
|
elif edge == "right":
|
||||||
|
dock_region = Region(width - widget_width, 0, widget_width, widget_height)
|
||||||
|
right = max(right, widget_width)
|
||||||
|
else:
|
||||||
|
# Should not occur, mainly to keep Mypy happy
|
||||||
|
raise AssertionError("invalid value for edge") # pragma: no-cover
|
||||||
|
|
||||||
|
align_offset = dock_widget.styles._align_size(
|
||||||
|
(widget_width, widget_height), size
|
||||||
|
)
|
||||||
|
dock_region = dock_region.shrink(margin).translate(align_offset)
|
||||||
|
append_placement(
|
||||||
|
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
|
||||||
|
)
|
||||||
|
dock_spacing = Spacing(top, right, bottom, left)
|
||||||
|
|
||||||
|
return (placements, dock_spacing)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -87,14 +87,15 @@ def work(
|
|||||||
self = args[0]
|
self = args[0]
|
||||||
assert isinstance(self, DOMNode)
|
assert isinstance(self, DOMNode)
|
||||||
|
|
||||||
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
|
try:
|
||||||
keyword_arguments = ", ".join(
|
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
|
||||||
f"{name}={value!r}" for name, value in kwargs.items()
|
keyword_arguments = ", ".join(
|
||||||
)
|
f"{name}={value!r}" for name, value in kwargs.items()
|
||||||
tokens = [positional_arguments, keyword_arguments]
|
)
|
||||||
worker_description = (
|
tokens = [positional_arguments, keyword_arguments]
|
||||||
f"{method.__name__}({', '.join(token for token in tokens if token)})"
|
worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
|
||||||
)
|
except Exception:
|
||||||
|
worker_description = "<worker>"
|
||||||
worker = cast(
|
worker = cast(
|
||||||
"Worker[ReturnType]",
|
"Worker[ReturnType]",
|
||||||
self.run_worker(
|
self.run_worker(
|
||||||
|
|||||||
@@ -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."""
|
||||||
for renderable in self._exit_renderables:
|
error_count = len(self._exit_renderables)
|
||||||
self.error_console.print(renderable)
|
if "debug" in self.features:
|
||||||
|
for renderable in self._exit_renderables:
|
||||||
|
self.error_console.print(renderable)
|
||||||
|
if error_count > 1:
|
||||||
|
self.error_console.print(
|
||||||
|
f"\n[b]NOTE:[/b] {error_count} errors show above.", markup=True
|
||||||
|
)
|
||||||
|
elif self._exit_renderables:
|
||||||
|
self.error_console.print(self._exit_renderables[0])
|
||||||
|
if error_count > 1:
|
||||||
|
self.error_console.print(
|
||||||
|
f"\n[b]NOTE:[/b] 1 of {error_count} errors show. Run with [b]--dev[/] to see all errors.",
|
||||||
|
markup=True,
|
||||||
|
)
|
||||||
|
|
||||||
self._exit_renderables.clear()
|
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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -379,16 +379,22 @@ 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]
|
||||||
add_strip = strips.append
|
if cuts == [cell_length]:
|
||||||
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
strips = [self]
|
||||||
add_strip(Strip(segments, cut - pos))
|
else:
|
||||||
pos = cut
|
strips = []
|
||||||
|
add_strip = strips.append
|
||||||
|
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
||||||
|
add_strip(Strip(segments, cut - pos))
|
||||||
|
pos = cut
|
||||||
|
|
||||||
self._divide_cache[cache_key] = strips
|
self._divide_cache[cache_key] = strips
|
||||||
return strips
|
return strips
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}')",
|
||||||
|
|||||||
@@ -332,8 +332,9 @@ 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:
|
||||||
line = event.text.splitlines()[0]
|
if event.text:
|
||||||
self.insert_text_at_cursor(line)
|
line = event.text.splitlines()[0]
|
||||||
|
self.insert_text_at_cursor(line)
|
||||||
event.stop()
|
event.stop()
|
||||||
|
|
||||||
async def _on_click(self, event: events.Click) -> None:
|
async def _on_click(self, event: events.Click) -> None:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
33
tests/snapshot_tests/snapshot_apps/dock_scroll2.py
Normal file
33
tests/snapshot_tests/snapshot_apps/dock_scroll2.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from textual.app import App
|
||||||
|
from textual.widgets import Header, Label, Footer
|
||||||
|
|
||||||
|
|
||||||
|
# Same as dock_scroll.py but with 2 labels
|
||||||
|
class TestApp(App):
|
||||||
|
BINDINGS = [("ctrl+q", "app.quit", "Quit")]
|
||||||
|
CSS = """
|
||||||
|
|
||||||
|
Label {
|
||||||
|
border: solid red;
|
||||||
|
}
|
||||||
|
Footer {
|
||||||
|
height: 4;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
text = (
|
||||||
|
"this is a sample sentence and here are some words".replace(" ", "\n") * 2
|
||||||
|
)
|
||||||
|
yield Header()
|
||||||
|
yield Label(text)
|
||||||
|
yield Label(text)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
self.dark = False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = TestApp()
|
||||||
|
app.run()
|
||||||
17
tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
Normal file
17
tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Checkbox, Footer
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollOffByOne(App):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
for number in range(1, 100):
|
||||||
|
yield Checkbox(str(number))
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("Screen").scroll_end()
|
||||||
|
|
||||||
|
|
||||||
|
app = ScrollOffByOne()
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
@@ -19,6 +19,6 @@
|
|||||||
|
|
||||||
#horizontal {
|
#horizontal {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: 4;
|
||||||
background: darkslateblue;
|
background: darkslateblue;
|
||||||
}
|
}
|
||||||
|
|||||||
19
tests/snapshot_tests/snapshot_apps/scroll_to.py
Normal file
19
tests/snapshot_tests/snapshot_apps/scroll_to.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Checkbox, Footer
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollOffByOne(App):
|
||||||
|
"""Scroll to item 50."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
for number in range(1, 100):
|
||||||
|
yield Checkbox(str(number), id=f"number-{number}")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
self.query_one("#number-50").scroll_visible()
|
||||||
|
|
||||||
|
|
||||||
|
app = ScrollOffByOne()
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
21
tests/snapshot_tests/snapshot_apps/select_rebuild.py
Normal file
21
tests/snapshot_tests/snapshot_apps/select_rebuild.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Test https://github.com/Textualize/textual/issues/2557"""
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Select, Button
|
||||||
|
|
||||||
|
|
||||||
|
class SelectRebuildApp(App[None]):
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Select[int]((("1", 1), ("2", 2)))
|
||||||
|
yield Button("Rebuild")
|
||||||
|
|
||||||
|
def on_button_pressed(self):
|
||||||
|
self.query_one(Select).set_options((
|
||||||
|
("This", 0), ("Should", 1), ("Be", 2),
|
||||||
|
("What", 3), ("Goes", 4), ("Into",5),
|
||||||
|
("The", 6), ("Snapshit", 7)
|
||||||
|
))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
SelectRebuildApp().run()
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from pathlib import Path
|
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"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -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!")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 == [
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user