mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:Textualize/textual into query-star-exclude-self
This commit is contained in:
20
.faq/FAQ.md
Normal file
20
.faq/FAQ.md
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
{%- for question in questions %}
|
||||
- [{{ question.title }}](#{{ question.slug }})
|
||||
{%- endfor %}
|
||||
|
||||
|
||||
{%- for question in questions %}
|
||||
|
||||
<a name="{{ question.slug }}"></a>
|
||||
## {{ question.title }}
|
||||
|
||||
{{ question.body }}
|
||||
|
||||
{%- endfor %}
|
||||
|
||||
<hr>
|
||||
|
||||
Generated by [FAQtory](https://github.com/willmcgugan/faqtory)
|
||||
20
.faq/suggest.md
Normal file
20
.faq/suggest.md
Normal file
@@ -0,0 +1,20 @@
|
||||
{%- if questions -%}
|
||||
{% if questions|length == 1 %}
|
||||
We found the following entry in the [FAQ]({{ faq_url }}) which you may find helpful:
|
||||
{%- else %}
|
||||
We found the following entries in the [FAQ]({{ faq_url }}) which you may find helpful:
|
||||
{%- endif %}
|
||||
|
||||
{% for question in questions %}
|
||||
- [{{ question.title }}]({{ faq_url }}#{{ question.slug }})
|
||||
{%- endfor %}
|
||||
|
||||
Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.
|
||||
|
||||
{%- else -%}
|
||||
Thank you for your issue. Give us a little time to review it.
|
||||
|
||||
PS. You might want to check the [FAQ]({{ faq_url }}) if you haven't done so already.
|
||||
{%- endif %}
|
||||
|
||||
This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
|
||||
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -7,8 +7,12 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Please give a brief but clear explanation of what the issue is. Let us know what the behaviour you expect is, and what is actually happening. Let us know what operating system you are running on, and what terminal you are using.
|
||||
Have you checked closed issues? https://github.com/Textualize/textual/issues?q=is%3Aissue+is%3Aclosed
|
||||
|
||||
Please give a brief but clear explanation of the issue.
|
||||
|
||||
What Operating System are you running on?
|
||||
|
||||
Feel free to add screenshots and/or videos. These can be very helpful!
|
||||
|
||||
If you can, include a complete working example that demonstrates the bug. Please check it can run without modifications.
|
||||
If you can, include a complete working example that demonstrates the bug. Check it can run without modifications.
|
||||
|
||||
27
.github/workflows/new_issue.yml
vendored
Normal file
27
.github/workflows/new_issue.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: issues
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main
|
||||
- name: Install FAQtory
|
||||
run: pip install FAQtory
|
||||
- name: Run Suggest
|
||||
run: faqtory suggest "${{ github.event.issue.title }}" > suggest.md
|
||||
- name: Read suggest.md
|
||||
id: suggest
|
||||
uses: juliangruber/read-file-action@v1
|
||||
with:
|
||||
path: ./suggest.md
|
||||
- name: Suggest FAQ
|
||||
uses: peter-evans/create-or-update-comment@a35cf36e5301d70b76f316e867e7788a55a31dae
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: ${{ steps.suggest.outputs.content }}
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.8.0] - Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issues with nested auto dimensions https://github.com/Textualize/textual/issues/1402
|
||||
- Fixed watch method incorrectly running on first set when value hasn't changed and init=False https://github.com/Textualize/textual/pull/1367
|
||||
|
||||
### Added
|
||||
|
||||
- Added `textual.actions.SkipAction` exception which can be raised from an action to allow parents to process bindings.
|
||||
- Added `textual keys` preview.
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved Ctrl+C, tab, and shift+tab to App BINDINGS
|
||||
|
||||
## [0.7.0] - 2022-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added `PRIORITY_BINDINGS` class variable, which can be used to control if a widget's bindings have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the `Binding` argument `universal` to `priority`. https://github.com/Textualize/textual/issues/1343
|
||||
- When looking for bindings that have priority, they are now looked from `App` downwards. https://github.com/Textualize/textual/issues/1343
|
||||
- `BINDINGS` on an `App`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
- `BINDINGS` on a `Screen`-derived class have priority by default. https://github.com/Textualize/textual/issues/1343
|
||||
- Added a message parameter to Widget.exit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed validator not running on first reactive set https://github.com/Textualize/textual/pull/1359
|
||||
- Ensure only printable characters are used as key_display https://github.com/Textualize/textual/pull/1361
|
||||
|
||||
|
||||
## [0.6.0] - 2022-12-11
|
||||
|
||||
@@ -248,7 +283,8 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
||||
- New handler system for messages that doesn't require inheritance
|
||||
- Improved traceback handling
|
||||
|
||||
[0.6.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.6.0
|
||||
[0.7.0]: https://github.com/Textualize/textual/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://github.com/Textualize/textual/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://github.com/Textualize/textual/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://github.com/Textualize/textual/compare/v0.2.1...v0.3.0
|
||||
|
||||
14
FAQ.md
Normal file
14
FAQ.md
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
# Frequently Asked Questions
|
||||
- [Does Textual support images?](#does-textual-support-images)
|
||||
|
||||
<a name="does-textual-support-images"></a>
|
||||
## Does Textual support images?
|
||||
|
||||
Textual doesn't have built in support for images yet, but it is on the [Roadmap](https://textual.textualize.io/roadmap/).
|
||||
|
||||
See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual.
|
||||
|
||||
<hr>
|
||||
|
||||
Generated by [FAQtory](https://github.com/willmcgugan/faqtory)
|
||||
@@ -12,6 +12,7 @@ class FooterApp(App):
|
||||
description="Show help screen",
|
||||
key_display="?",
|
||||
),
|
||||
Binding(key="delete", action="delete", description="Delete the thing"),
|
||||
Binding(key="j", action="down", description="Scroll down", show=False),
|
||||
]
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ When you click any of the links, Textual runs the `"set_background"` action to c
|
||||
|
||||
## Bindings
|
||||
|
||||
Textual will also run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.
|
||||
Textual will run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action.
|
||||
|
||||
=== "actions04.py"
|
||||
|
||||
@@ -92,7 +92,7 @@ If you run this example, you can change the background by pressing keys in addit
|
||||
|
||||
## Namespaces
|
||||
|
||||
Textual will look for action methods on the widget or app where they are used. If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
|
||||
Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions.
|
||||
|
||||
The following example defines a custom widget with its own `set_background` action.
|
||||
|
||||
|
||||
@@ -127,6 +127,15 @@ Note how the footer displays bindings and makes them clickable.
|
||||
Multiple keys can be bound to a single action by comma-separating them.
|
||||
For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`.
|
||||
|
||||
|
||||
!!! note
|
||||
|
||||
Ordinarily a binding on a focused widget has precedence over the same key binding at a higher level. However, bindings at the `App` or `Screen` level always have priority.
|
||||
|
||||
The priority of a single binding can be controlled with the `priority` parameter of a `Binding` instance. Set it to `True` to give it priority, or `False` to not.
|
||||
|
||||
The default priority of all bindings on a class can be controlled with the `PRIORITY_BINDINGS` class variable. Set it to `True` or `False` to set the default priroty for all `BINDINGS`.
|
||||
|
||||
### Binding class
|
||||
|
||||
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
`ListItem` is the type of the elements in a `ListView`.
|
||||
|
||||
- [] Focusable
|
||||
- [] Container
|
||||
- [ ] Focusable
|
||||
- [ ] Container
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -41,4 +41,4 @@ This widget sends no messages.
|
||||
|
||||
## See Also
|
||||
|
||||
* [TextLog](../api/textlog.md) code reference
|
||||
* [TextLog](../api/text_log.md) code reference
|
||||
|
||||
7
faq.yml
Normal file
7
faq.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
# FAQtory settings
|
||||
|
||||
faq_url: "https://github.com/textualize/textual/blob/main/FAQ.md" # Replace this with the URL to your FAQ.md!
|
||||
|
||||
questions_path: "./questions" # Where questions should be stored
|
||||
output_path: "./FAQ.md" # Where FAQ.md should be generated
|
||||
templates_path: ".faq" # Path to templates
|
||||
@@ -112,7 +112,6 @@ nav:
|
||||
- "api/color.md"
|
||||
- "api/containers.md"
|
||||
- "api/data_table.md"
|
||||
- "api/text_log.md"
|
||||
- "api/directory_tree.md"
|
||||
- "api/dom_node.md"
|
||||
- "api/events.md"
|
||||
@@ -130,6 +129,7 @@ nav:
|
||||
- "api/reactive.md"
|
||||
- "api/screen.md"
|
||||
- "api/static.md"
|
||||
- "api/text_log.md"
|
||||
- "api/timer.md"
|
||||
- "api/walk.md"
|
||||
- "api/widget.md"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
|
||||
18
questions/README.md
Normal file
18
questions/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
# Questions
|
||||
|
||||
Your questions should go in this directory.
|
||||
|
||||
Question files should be named with the extension ".question.md".
|
||||
|
||||
To build the faq, install [faqtory](https://github.com/willmcgugan/faqtory) if you haven't already:
|
||||
|
||||
```
|
||||
pip install faqtory
|
||||
```
|
||||
|
||||
The run the following from the top of the repository:
|
||||
|
||||
```
|
||||
faqtory build
|
||||
```
|
||||
34
questions/align-center-middle.question.md
Normal file
34
questions/align-center-middle.question.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "How do I center a widget in a screen?"
|
||||
alt_titles:
|
||||
- "centre a widget"
|
||||
- "center a control"
|
||||
- "centre a control"
|
||||
---
|
||||
|
||||
To center a widget within a container use
|
||||
[`align`](https://textual.textualize.io/styles/align/). But remember that
|
||||
`align` works on the *children* of a container, it isn't something you use
|
||||
on the child you want centered.
|
||||
|
||||
For example, here's an app that shows a `Button` in the middle of a
|
||||
`Screen`:
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
|
||||
class ButtonApp(App):
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Button("PUSH ME!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
ButtonApp().run()
|
||||
```
|
||||
11
questions/images.question.md
Normal file
11
questions/images.question.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Does Textual support images?"
|
||||
alt_titles:
|
||||
- "Can Textual display PNG / SVG files?"
|
||||
- "Render images"
|
||||
|
||||
---
|
||||
|
||||
Textual doesn't have built in support for images yet, but it is on the [Roadmap](https://textual.textualize.io/roadmap/).
|
||||
|
||||
See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual.
|
||||
38
questions/pass-args-to-app.question.md
Normal file
38
questions/pass-args-to-app.question.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "How do I pass arguments to an app?"
|
||||
alt_titles:
|
||||
- "pass arguments to an application"
|
||||
- "pass parameters to an app"
|
||||
- "pass parameters to an application"
|
||||
---
|
||||
|
||||
When creating your `App` class, override `__init__` as you would when
|
||||
inheriting normally. For example:
|
||||
|
||||
```python
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
class Greetings(App[None]):
|
||||
|
||||
def __init__(self, greeting: str="Hello", to_greet: str="World") -> None:
|
||||
self.greeting = greeting
|
||||
self.to_greet = to_greet
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f"{self.greeting}, {self.to_greet}")
|
||||
```
|
||||
|
||||
Then the app can be run, passing in various arguments; for example:
|
||||
|
||||
```python
|
||||
# Running with default arguments.
|
||||
Greetings().run()
|
||||
|
||||
# Running with a keyword arguyment.
|
||||
Greetings(to_greet="davep").run()
|
||||
|
||||
# Running with both positional arguments.
|
||||
Greetings("Well hello", "there").run()
|
||||
```
|
||||
@@ -9,3 +9,5 @@ if sys.version_info >= (3, 8):
|
||||
from typing import Final, Literal, Protocol, TypedDict
|
||||
else:
|
||||
from typing_extensions import Final, Literal, Protocol, TypedDict
|
||||
|
||||
__all__ = ["TypeAlias", "Final", "Literal", "Protocol", "TypedDict"]
|
||||
|
||||
@@ -4,6 +4,10 @@ import ast
|
||||
import re
|
||||
|
||||
|
||||
class SkipAction(Exception):
|
||||
"""Raise in an action to skip the action (and allow any parent bindings to run)."""
|
||||
|
||||
|
||||
class ActionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ from rich.protocol import is_renderable
|
||||
from rich.segment import Segment, Segments
|
||||
from rich.traceback import Traceback
|
||||
|
||||
from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
|
||||
from . import actions, Logger, LogGroup, LogVerbosity, events, log, messages
|
||||
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
|
||||
from ._ansi_sequences import SYNC_END, SYNC_START
|
||||
from ._callback import invoke
|
||||
@@ -47,6 +47,7 @@ from ._event_broker import NoHandler, extract_handler_actions
|
||||
from ._filter import LineFilter, Monochrome
|
||||
from ._path import _make_path_object_relative
|
||||
from ._typing import Final, TypeAlias
|
||||
from .actions import SkipAction
|
||||
from .await_remove import AwaitRemove
|
||||
from .binding import Binding, Bindings
|
||||
from .css.query import NoMatches
|
||||
@@ -231,12 +232,22 @@ class App(Generic[ReturnType], DOMNode):
|
||||
}
|
||||
"""
|
||||
|
||||
PRIORITY_BINDINGS = True
|
||||
|
||||
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
|
||||
_BASE_PATH: str | None = None
|
||||
CSS_PATH: CSSPathType = None
|
||||
TITLE: str | None = None
|
||||
SUB_TITLE: str | None = None
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
||||
Binding("tab", "focus_next", "Focus Next", show=False, priority=False),
|
||||
Binding(
|
||||
"shift+tab", "focus_previous", "Focus Previous", show=False, priority=False
|
||||
),
|
||||
]
|
||||
|
||||
title: Reactive[str] = Reactive("")
|
||||
sub_title: Reactive[str] = Reactive("")
|
||||
dark: Reactive[bool] = Reactive(True)
|
||||
@@ -298,7 +309,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
self._logger = Logger(self._log)
|
||||
|
||||
self._bindings.bind("ctrl+c", "quit", show=False, universal=True)
|
||||
self._refresh_required = False
|
||||
|
||||
self.design = DEFAULT_COLORS
|
||||
@@ -348,6 +358,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.devtools = DevtoolsClient()
|
||||
|
||||
self._return_value: ReturnType | None = None
|
||||
self._exit = False
|
||||
|
||||
self.css_monitor = (
|
||||
FileMonitor(self.css_path, self._on_css_change)
|
||||
@@ -357,6 +368,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._screenshot: str | None = None
|
||||
self._dom_lock = asyncio.Lock()
|
||||
self._dom_ready = False
|
||||
self.set_class(self.dark, "-dark-mode")
|
||||
|
||||
@property
|
||||
def return_value(self) -> ReturnType | None:
|
||||
@@ -414,14 +426,20 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""list[Screen]: A *copy* of the screen stack."""
|
||||
return self._screen_stack.copy()
|
||||
|
||||
def exit(self, result: ReturnType | None = None) -> None:
|
||||
def exit(
|
||||
self, result: ReturnType | None = None, message: RenderableType | None = None
|
||||
) -> None:
|
||||
"""Exit the app, and return the supplied result.
|
||||
|
||||
Args:
|
||||
result (ReturnType | None, optional): Return value. Defaults to None.
|
||||
message (RenderableType | None): Optional message to display on exit.
|
||||
"""
|
||||
self._exit = True
|
||||
self._return_value = result
|
||||
self.post_message_no_wait(messages.ExitApp(sender=self))
|
||||
if message:
|
||||
self._exit_renderables.append(message)
|
||||
|
||||
@property
|
||||
def focused(self) -> Widget | None:
|
||||
@@ -1080,9 +1098,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
_screen = self.get_screen(screen)
|
||||
if not _screen.is_running:
|
||||
widgets = self._register(self, _screen)
|
||||
return (_screen, AwaitMount(widgets))
|
||||
return (_screen, AwaitMount(_screen, widgets))
|
||||
else:
|
||||
return (_screen, AwaitMount([]))
|
||||
return (_screen, AwaitMount(_screen, []))
|
||||
|
||||
def _replace_screen(self, screen: Screen) -> Screen:
|
||||
"""Handle the replaced screen.
|
||||
@@ -1128,7 +1146,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.screen.post_message_no_wait(events.ScreenResume(self))
|
||||
self.log.system(f"{self.screen} is current (SWITCHED)")
|
||||
return await_mount
|
||||
return AwaitMount([])
|
||||
return AwaitMount(self.screen, [])
|
||||
|
||||
def install_screen(self, screen: Screen, name: str | None = None) -> AwaitMount:
|
||||
"""Install a screen.
|
||||
@@ -1377,11 +1395,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
raise
|
||||
|
||||
finally:
|
||||
self._running = True
|
||||
await self._ready()
|
||||
await invoke_ready_callback()
|
||||
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
await self._process_messages_loop()
|
||||
except asyncio.CancelledError:
|
||||
@@ -1406,28 +1423,29 @@ class App(Generic[ReturnType], DOMNode):
|
||||
)
|
||||
driver = self._driver = driver_class(self.console, self, size=terminal_size)
|
||||
|
||||
driver.start_application_mode()
|
||||
try:
|
||||
if headless:
|
||||
await run_process_messages()
|
||||
else:
|
||||
if self.devtools is not None:
|
||||
devtools = self.devtools
|
||||
assert devtools is not None
|
||||
from .devtools.redirect_output import StdoutRedirector
|
||||
|
||||
redirector = StdoutRedirector(devtools)
|
||||
with redirect_stderr(redirector):
|
||||
with redirect_stdout(redirector): # type: ignore
|
||||
await run_process_messages()
|
||||
if not self._exit:
|
||||
driver.start_application_mode()
|
||||
try:
|
||||
if headless:
|
||||
await run_process_messages()
|
||||
else:
|
||||
null_file = _NullFile()
|
||||
with redirect_stderr(null_file):
|
||||
with redirect_stdout(null_file):
|
||||
await run_process_messages()
|
||||
if self.devtools is not None:
|
||||
devtools = self.devtools
|
||||
assert devtools is not None
|
||||
from .devtools.redirect_output import StdoutRedirector
|
||||
|
||||
finally:
|
||||
driver.stop_application_mode()
|
||||
redirector = StdoutRedirector(devtools)
|
||||
with redirect_stderr(redirector):
|
||||
with redirect_stdout(redirector): # type: ignore
|
||||
await run_process_messages()
|
||||
else:
|
||||
null_file = _NullFile()
|
||||
with redirect_stderr(null_file):
|
||||
with redirect_stdout(null_file):
|
||||
await run_process_messages()
|
||||
|
||||
finally:
|
||||
driver.stop_application_mode()
|
||||
except Exception as error:
|
||||
self._handle_exception(error)
|
||||
|
||||
@@ -1563,7 +1581,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
if widget.children:
|
||||
self._register(widget, *widget.children)
|
||||
apply_stylesheet(widget)
|
||||
|
||||
return list(widgets)
|
||||
|
||||
def _unregister(self, widget: Widget) -> None:
|
||||
@@ -1729,22 +1746,23 @@ class App(Generic[ReturnType], DOMNode):
|
||||
]
|
||||
return namespace_bindings
|
||||
|
||||
async def check_bindings(self, key: str, universal: bool = False) -> bool:
|
||||
async def check_bindings(self, key: str, priority: bool = False) -> bool:
|
||||
"""Handle a key press.
|
||||
|
||||
Args:
|
||||
key (str): A key
|
||||
universal (bool): Check universal keys if True, otherwise non-universal keys.
|
||||
key (str): A key.
|
||||
priority (bool): If `True` check from `App` down, otherwise from focused up.
|
||||
|
||||
Returns:
|
||||
bool: True if the key was handled by a binding, otherwise False
|
||||
"""
|
||||
|
||||
for namespace, bindings in self._binding_chain:
|
||||
for namespace, bindings in (
|
||||
reversed(self._binding_chain) if priority else self._binding_chain
|
||||
):
|
||||
binding = bindings.keys.get(key)
|
||||
if binding is not None and binding.universal == universal:
|
||||
await self.action(binding.action, default_namespace=namespace)
|
||||
return True
|
||||
if binding is not None and binding.priority == priority:
|
||||
if await self.action(binding.action, namespace):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def on_event(self, event: events.Event) -> None:
|
||||
@@ -1762,7 +1780,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.mouse_position = Offset(event.x, event.y)
|
||||
await self.screen._forward_event(event)
|
||||
elif isinstance(event, events.Key):
|
||||
if not await self.check_bindings(event.key, universal=True):
|
||||
if not await self.check_bindings(event.key, priority=True):
|
||||
forward_target = self.focused or self.screen
|
||||
await forward_target._forward_event(event)
|
||||
else:
|
||||
@@ -1813,32 +1831,41 @@ class App(Generic[ReturnType], DOMNode):
|
||||
async def _dispatch_action(
|
||||
self, namespace: object, action_name: str, params: Any
|
||||
) -> bool:
|
||||
"""Dispatch an action to an action method.
|
||||
|
||||
Args:
|
||||
namespace (object): Namespace (object) of action.
|
||||
action_name (str): Name of the action.
|
||||
params (Any): Action parameters.
|
||||
|
||||
Returns:
|
||||
bool: True if handled, otherwise False.
|
||||
"""
|
||||
_rich_traceback_guard = True
|
||||
|
||||
log(
|
||||
"<action>",
|
||||
namespace=namespace,
|
||||
action_name=action_name,
|
||||
params=params,
|
||||
)
|
||||
_rich_traceback_guard = True
|
||||
|
||||
public_method_name = f"action_{action_name}"
|
||||
private_method_name = f"_{public_method_name}"
|
||||
|
||||
private_method = getattr(namespace, private_method_name, None)
|
||||
public_method = getattr(namespace, public_method_name, None)
|
||||
|
||||
if private_method is None and public_method is None:
|
||||
try:
|
||||
private_method = getattr(namespace, f"_action_{action_name}", None)
|
||||
if callable(private_method):
|
||||
await invoke(private_method, *params)
|
||||
return True
|
||||
public_method = getattr(namespace, f"action_{action_name}", None)
|
||||
if callable(public_method):
|
||||
await invoke(public_method, *params)
|
||||
return True
|
||||
log(
|
||||
f"<action> {action_name!r} has no target. Couldn't find methods {public_method_name!r} or {private_method_name!r}"
|
||||
f"<action> {action_name!r} has no target."
|
||||
f" Could not find methods '_action_{action_name}' or 'action_{action_name}'"
|
||||
)
|
||||
|
||||
if callable(private_method):
|
||||
await invoke(private_method, *params)
|
||||
return True
|
||||
elif callable(public_method):
|
||||
await invoke(public_method, *params)
|
||||
return True
|
||||
|
||||
except SkipAction:
|
||||
# The action method raised this to explicitly not handle the action
|
||||
log("<action> {action_name!r} skipped.")
|
||||
return False
|
||||
|
||||
async def _broker_event(
|
||||
@@ -1849,7 +1876,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Args:
|
||||
event_name (str): _description_
|
||||
event (events.Event): An event object.
|
||||
default_namespace (object | None): TODO: _description_
|
||||
default_namespace (object | None): The default namespace, where one isn't supplied.
|
||||
|
||||
Returns:
|
||||
bool: True if an action was processed.
|
||||
@@ -1879,13 +1906,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
message.stop()
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None:
|
||||
if event.key == "tab":
|
||||
self.screen.focus_next()
|
||||
elif event.key == "shift+tab":
|
||||
self.screen.focus_previous()
|
||||
else:
|
||||
if not (await self.check_bindings(event.key)):
|
||||
await self.dispatch_key(event)
|
||||
if not (await self.check_bindings(event.key)):
|
||||
await self.dispatch_key(event)
|
||||
|
||||
async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
||||
log("shutdown request")
|
||||
@@ -2042,7 +2064,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._unregister(root)
|
||||
|
||||
async def action_check_bindings(self, key: str) -> None:
|
||||
await self.check_bindings(key)
|
||||
if not await self.check_bindings(key, priority=True):
|
||||
await self.check_bindings(key, priority=False)
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the app as soon as possible."""
|
||||
@@ -2104,6 +2127,14 @@ class App(Generic[ReturnType], DOMNode):
|
||||
async def action_toggle_class(self, selector: str, class_name: str) -> None:
|
||||
self.screen.query(selector).toggle_class(class_name)
|
||||
|
||||
def action_focus_next(self) -> None:
|
||||
"""Focus the next widget."""
|
||||
self.screen.focus_next()
|
||||
|
||||
def action_focus_previous(self) -> None:
|
||||
"""Focus the previous widget."""
|
||||
self.screen.focus_previous()
|
||||
|
||||
def _on_terminal_supports_synchronized_output(
|
||||
self, message: messages.TerminalSupportsSynchronizedOutput
|
||||
) -> None:
|
||||
|
||||
@@ -20,6 +20,8 @@ class NoBinding(Exception):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
"""The configuration of a key binding."""
|
||||
|
||||
key: str
|
||||
"""str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
|
||||
action: str
|
||||
@@ -30,15 +32,31 @@ class Binding:
|
||||
"""bool: Show the action in Footer, or False to hide."""
|
||||
key_display: str | None = None
|
||||
"""str | None: How the key should be shown in footer."""
|
||||
universal: bool = False
|
||||
"""bool: Allow forwarding from app to focused widget."""
|
||||
priority: bool | None = None
|
||||
"""bool | None: Is this a priority binding, checked form app down to focused widget?"""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Bindings:
|
||||
"""Manage a set of bindings."""
|
||||
|
||||
def __init__(self, bindings: Iterable[BindingType] | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
bindings: Iterable[BindingType] | None = None,
|
||||
default_priority: bool | None = None,
|
||||
) -> None:
|
||||
"""Initialise a collection of bindings.
|
||||
|
||||
Args:
|
||||
bindings (Iterable[BindingType] | None, optional): An optional set of initial bindings.
|
||||
default_priority (bool | None, optional): The default priority of the bindings.
|
||||
|
||||
Note:
|
||||
The iterable of bindings can contain either a `Binding`
|
||||
instance, or a tuple of 3 values mapping to the first three
|
||||
properties of a `Binding`.
|
||||
"""
|
||||
|
||||
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
|
||||
for binding in bindings:
|
||||
# If it's a tuple of length 3, convert into a Binding first
|
||||
@@ -49,20 +67,22 @@ class Bindings:
|
||||
)
|
||||
binding = Binding(*binding)
|
||||
|
||||
binding_keys = binding.key.split(",")
|
||||
if len(binding_keys) > 1:
|
||||
for key in binding_keys:
|
||||
new_binding = Binding(
|
||||
key=key,
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=binding.show,
|
||||
key_display=binding.key_display,
|
||||
universal=binding.universal,
|
||||
)
|
||||
yield new_binding
|
||||
else:
|
||||
yield binding
|
||||
# At this point we have a Binding instance, but the key may
|
||||
# be a list of keys, so now we unroll that single Binding
|
||||
# into a (potential) collection of Binding instances.
|
||||
for key in binding.key.split(","):
|
||||
yield Binding(
|
||||
key=key.strip(),
|
||||
action=binding.action,
|
||||
description=binding.description,
|
||||
show=binding.show,
|
||||
key_display=binding.key_display,
|
||||
priority=(
|
||||
default_priority
|
||||
if binding.priority is None
|
||||
else binding.priority
|
||||
),
|
||||
)
|
||||
|
||||
self.keys: MutableMapping[str, Binding] = (
|
||||
{binding.key: binding for binding in make_bindings(bindings)}
|
||||
@@ -105,7 +125,7 @@ class Bindings:
|
||||
description: str = "",
|
||||
show: bool = True,
|
||||
key_display: str | None = None,
|
||||
universal: bool = False,
|
||||
priority: bool = False,
|
||||
) -> None:
|
||||
"""Bind keys to an action.
|
||||
|
||||
@@ -115,7 +135,7 @@ class Bindings:
|
||||
description (str, optional): An optional description for the binding.
|
||||
show (bool, optional): A flag to say if the binding should appear in the footer.
|
||||
key_display (str | None, optional): Optional string to display in the footer for the key.
|
||||
universal (bool, optional): Allow forwarding from the app to the focused widget.
|
||||
priority (bool, optional): Is this a priority binding, checked form app down to focused widget?
|
||||
"""
|
||||
all_keys = [key.strip() for key in keys.split(",")]
|
||||
for key in all_keys:
|
||||
@@ -125,7 +145,7 @@ class Bindings:
|
||||
description,
|
||||
show=show,
|
||||
key_display=key_display,
|
||||
universal=universal,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def get_key(self, key: str) -> Binding:
|
||||
|
||||
@@ -123,3 +123,11 @@ def colors():
|
||||
from textual.cli.previews import colors
|
||||
|
||||
colors.app.run()
|
||||
|
||||
|
||||
@run.command("keys")
|
||||
def keys():
|
||||
"""Show key events"""
|
||||
from textual.cli.previews import keys
|
||||
|
||||
keys.app.run()
|
||||
|
||||
60
src/textual/cli/previews/keys.py
Normal file
60
src/textual/cli/previews/keys.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from rich.panel import Panel
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual import events
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Button, Header, TextLog
|
||||
|
||||
|
||||
INSTRUCTIONS = """\
|
||||
Press some keys!
|
||||
|
||||
Because we want to display all the keys, Ctrl+C won't work for this example. Use the button below to quit.\
|
||||
"""
|
||||
|
||||
|
||||
class KeyLog(TextLog, inherit_bindings=False):
|
||||
"""We don't want to handle scroll keys."""
|
||||
|
||||
|
||||
class KeysApp(App, inherit_bindings=False):
|
||||
"""Show key events in a text log."""
|
||||
|
||||
TITLE = "Textual Keys"
|
||||
BINDINGS = [("c", "clear", "Clear")]
|
||||
CSS = """
|
||||
#buttons {
|
||||
dock: bottom;
|
||||
height: 3;
|
||||
}
|
||||
Button {
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Button("Clear", id="clear", variant="warning"),
|
||||
Button("Quit", id="quit", variant="error"),
|
||||
id="buttons",
|
||||
)
|
||||
yield KeyLog()
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.query_one(KeyLog).write(Panel(INSTRUCTIONS), expand=True)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
self.query_one(KeyLog).write(event)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "quit":
|
||||
self.exit()
|
||||
elif event.button.id == "clear":
|
||||
self.query_one(KeyLog).clear()
|
||||
|
||||
|
||||
app = KeysApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -794,9 +794,8 @@ class NameListProperty:
|
||||
class ColorProperty:
|
||||
"""Descriptor for getting and setting color properties."""
|
||||
|
||||
def __init__(self, default_color: Color | str, background: bool = False) -> None:
|
||||
def __init__(self, default_color: Color | str) -> None:
|
||||
self._default_color = Color.parse(default_color)
|
||||
self._is_background = background
|
||||
|
||||
def __set_name__(self, owner: StylesBase, name: str) -> None:
|
||||
self.name = name
|
||||
@@ -830,11 +829,10 @@ class ColorProperty:
|
||||
_rich_traceback_omit = True
|
||||
if color is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(children=self._is_background)
|
||||
obj.refresh(children=True)
|
||||
elif isinstance(color, Color):
|
||||
if obj.set_rule(self.name, color):
|
||||
obj.refresh(children=self._is_background)
|
||||
|
||||
obj.refresh(children=True)
|
||||
elif isinstance(color, str):
|
||||
alpha = 1.0
|
||||
parsed_color = Color(255, 255, 255)
|
||||
@@ -855,8 +853,9 @@ class ColorProperty:
|
||||
),
|
||||
)
|
||||
parsed_color = parsed_color.with_alpha(alpha)
|
||||
|
||||
if obj.set_rule(self.name, parsed_color):
|
||||
obj.refresh(children=self._is_background)
|
||||
obj.refresh(children=True)
|
||||
else:
|
||||
raise StyleValueError(f"Invalid color value {color}")
|
||||
|
||||
|
||||
@@ -36,20 +36,21 @@ VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
|
||||
VALID_TEXT_ALIGN: Final = {"start", "end", "left", "right", "center", "justify"}
|
||||
VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"}
|
||||
VALID_STYLE_FLAGS: Final = {
|
||||
"b",
|
||||
"blink",
|
||||
"bold",
|
||||
"dim",
|
||||
"i",
|
||||
"italic",
|
||||
"none",
|
||||
"not",
|
||||
"bold",
|
||||
"blink",
|
||||
"italic",
|
||||
"underline",
|
||||
"overline",
|
||||
"strike",
|
||||
"b",
|
||||
"i",
|
||||
"u",
|
||||
"uu",
|
||||
"o",
|
||||
"overline",
|
||||
"reverse",
|
||||
"strike",
|
||||
"u",
|
||||
"underline",
|
||||
"uu",
|
||||
}
|
||||
|
||||
NULL_SPACING: Final = Spacing.all(0)
|
||||
|
||||
@@ -214,7 +214,7 @@ class StylesBase(ABC):
|
||||
|
||||
auto_color = BooleanProperty(default=False)
|
||||
color = ColorProperty(Color(255, 255, 255))
|
||||
background = ColorProperty(Color(0, 0, 0, 0), background=True)
|
||||
background = ColorProperty(Color(0, 0, 0, 0))
|
||||
text_style = StyleFlagsProperty()
|
||||
|
||||
opacity = FractionalProperty()
|
||||
@@ -421,7 +421,7 @@ class StylesBase(ABC):
|
||||
|
||||
Args:
|
||||
layout (bool, optional): Also require a layout. Defaults to False.
|
||||
children (bool, opional): Also refresh children. Defaults to False.
|
||||
children (bool, optional): Also refresh children. Defaults to False.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -29,7 +29,7 @@ Sidebar {
|
||||
}
|
||||
|
||||
Sidebar:focus-within {
|
||||
offset: 0 0 !important;
|
||||
offset: 0 0 !important;
|
||||
}
|
||||
|
||||
Sidebar.-hidden {
|
||||
|
||||
@@ -22,7 +22,7 @@ from rich.tree import Tree
|
||||
|
||||
from ._context import NoActiveAppError
|
||||
from ._node_list import NodeList
|
||||
from .binding import Binding, Bindings, BindingType
|
||||
from .binding import Bindings, BindingType
|
||||
from .color import BLACK, WHITE, Color
|
||||
from .css._error_tools import friendly_list
|
||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||
@@ -92,6 +92,9 @@ class DOMNode(MessagePump):
|
||||
# Virtual DOM nodes
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = set()
|
||||
|
||||
# Should the content of BINDINGS be treated as priority bindings?
|
||||
PRIORITY_BINDINGS: ClassVar[bool] = False
|
||||
|
||||
# Mapping of key bindings
|
||||
BINDINGS: ClassVar[list[BindingType]] = []
|
||||
|
||||
@@ -225,11 +228,18 @@ class DOMNode(MessagePump):
|
||||
"""
|
||||
bindings: list[Bindings] = []
|
||||
|
||||
# To start with, assume that bindings won't be priority bindings.
|
||||
priority = False
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
if issubclass(base, DOMNode):
|
||||
# See if the current class wants to set the bindings as
|
||||
# priority bindings. If it doesn't have that property on the
|
||||
# class, go with what we saw last.
|
||||
priority = base.__dict__.get("PRIORITY_BINDINGS", priority)
|
||||
if not base._inherit_bindings:
|
||||
bindings.clear()
|
||||
bindings.append(Bindings(base.__dict__.get("BINDINGS", [])))
|
||||
bindings.append(Bindings(base.__dict__.get("BINDINGS", []), priority))
|
||||
keys = {}
|
||||
for bindings_ in bindings:
|
||||
keys.update(bindings_.keys)
|
||||
@@ -507,8 +517,8 @@ class DOMNode(MessagePump):
|
||||
@property
|
||||
def rich_style(self) -> Style:
|
||||
"""Get a Rich Style object for this DOMNode."""
|
||||
background = WHITE
|
||||
color = BLACK
|
||||
background = Color(0, 0, 0, 0)
|
||||
color = Color(255, 255, 255, 0)
|
||||
style = Style()
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
@@ -520,7 +530,8 @@ class DOMNode(MessagePump):
|
||||
if styles.has_rule("auto_color") and styles.auto_color:
|
||||
color = background.get_contrast_text(color.a)
|
||||
style += Style.from_color(
|
||||
(background + color).rich_color, background.rich_color
|
||||
(background + color).rich_color if (background.a or color.a) else None,
|
||||
background.rich_color if background.a else None,
|
||||
)
|
||||
return style
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ class Key(InputEvent):
|
||||
key_aliases (list[str]): The aliases for the key, including the key itself
|
||||
"""
|
||||
|
||||
__slots__ = ["key", "char"]
|
||||
__slots__ = ["key", "char", "key_aliases"]
|
||||
|
||||
def __init__(self, sender: MessageTarget, key: str, char: str | None) -> None:
|
||||
super().__init__(sender)
|
||||
@@ -209,7 +209,9 @@ class Key(InputEvent):
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "key", self.key
|
||||
yield "char", self.char, None
|
||||
yield "char", self.char
|
||||
yield "is_printable", self.is_printable
|
||||
yield "key_aliases", self.key_aliases, [self.key_name]
|
||||
|
||||
@property
|
||||
def key_name(self) -> str | None:
|
||||
|
||||
@@ -320,7 +320,7 @@ class Region(NamedTuple):
|
||||
Offset: An offset required to add to region to move it inside window_region.
|
||||
"""
|
||||
|
||||
if region in window_region:
|
||||
if region in window_region and not top:
|
||||
# Region is already inside the window, so no need to move it.
|
||||
return NULL_OFFSET
|
||||
|
||||
@@ -341,19 +341,19 @@ class Region(NamedTuple):
|
||||
key=abs,
|
||||
)
|
||||
|
||||
if not (
|
||||
if top:
|
||||
delta_y = top_ - window_top
|
||||
|
||||
elif not (
|
||||
(window_bottom > top_ >= window_top)
|
||||
and (window_bottom > bottom >= window_top)
|
||||
):
|
||||
# The window needs to scroll on the Y axis to bring region in to view
|
||||
if top:
|
||||
delta_y = top_ - window_top
|
||||
else:
|
||||
delta_y = min(
|
||||
top_ - window_top,
|
||||
top_ - (window_bottom - region.height),
|
||||
key=abs,
|
||||
)
|
||||
delta_y = min(
|
||||
top_ - window_top,
|
||||
top_ - (window_bottom - region.height),
|
||||
key=abs,
|
||||
)
|
||||
return Offset(delta_x, delta_y)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
|
||||
@@ -245,9 +245,14 @@ def _get_key_display(key: str) -> str:
|
||||
return display_alias
|
||||
|
||||
original_key = REPLACED_KEYS.get(key, key)
|
||||
upper_original = original_key.upper().replace("_", " ")
|
||||
try:
|
||||
unicode_character = unicodedata.lookup(original_key.upper().replace("_", " "))
|
||||
unicode_character = unicodedata.lookup(upper_original)
|
||||
except KeyError:
|
||||
return original_key.upper()
|
||||
return upper_original
|
||||
|
||||
return unicode_character
|
||||
# Check if printable. `delete` for example maps to a control sequence
|
||||
# which we don't want to write to the terminal.
|
||||
if unicode_character.isprintable():
|
||||
return unicode_character
|
||||
return upper_original
|
||||
|
||||
@@ -175,15 +175,11 @@ class Reactive(Generic[ReactiveType]):
|
||||
current_value = getattr(obj, name)
|
||||
# Check for validate function
|
||||
validate_function = getattr(obj, f"validate_{name}", None)
|
||||
# Check if this is the first time setting the value
|
||||
first_set = getattr(obj, f"__first_set_{self.internal_name}", True)
|
||||
# Call validate, but not on first set.
|
||||
if callable(validate_function) and not first_set:
|
||||
# Call validate
|
||||
if callable(validate_function):
|
||||
value = validate_function(value)
|
||||
# If the value has changed, or this is the first time setting the value
|
||||
if current_value != value or first_set or self._always_update:
|
||||
# Set the first set flag to False
|
||||
setattr(obj, f"__first_set_{self.internal_name}", False)
|
||||
if current_value != value or self._always_update:
|
||||
# Store the internal value
|
||||
setattr(obj, self.internal_name, value)
|
||||
# Check all watchers
|
||||
@@ -200,7 +196,6 @@ class Reactive(Generic[ReactiveType]):
|
||||
obj (Reactable): The reactable object.
|
||||
name (str): Attribute name.
|
||||
old_value (Any): The old (previous) value of the attribute.
|
||||
first_set (bool, optional): True if this is the first time setting the value. Defaults to False.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
# Get the current value.
|
||||
|
||||
@@ -26,6 +26,11 @@ UPDATE_PERIOD: Final[float] = 1 / 120
|
||||
class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
# The screen is a special case and unless a class that inherits from us
|
||||
# says otherwise, all screen-level bindings should be treated as having
|
||||
# priority.
|
||||
PRIORITY_BINDINGS = True
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
@@ -290,21 +295,20 @@ class Screen(Widget):
|
||||
# No focus, so blur currently focused widget if it exists
|
||||
if self.focused is not None:
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
self.focused.emit_no_wait(events.DescendantBlur(self))
|
||||
self.focused = None
|
||||
self.log.debug("focus was removed")
|
||||
elif widget.can_focus:
|
||||
if self.focused != widget:
|
||||
if self.focused is not None:
|
||||
# Blur currently focused widget
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
self.focused.emit_no_wait(events.DescendantBlur(self))
|
||||
# Change focus
|
||||
self.focused = widget
|
||||
# Send focus event
|
||||
if scroll_visible:
|
||||
self.screen.scroll_to_widget(widget)
|
||||
widget.post_message_no_wait(events.Focus(self))
|
||||
widget.emit_no_wait(events.DescendantFocus(self))
|
||||
self.log.debug(widget, "was focused")
|
||||
|
||||
async def _on_idle(self, event: events.Idle) -> None:
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
|
||||
@@ -44,6 +44,7 @@ from ._layout import Layout
|
||||
from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .actions import SkipAction
|
||||
from .await_remove import AwaitRemove
|
||||
from .binding import Binding
|
||||
from .box_model import BoxModel, get_box_model
|
||||
@@ -85,7 +86,8 @@ class AwaitMount:
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: Sequence[Widget]) -> None:
|
||||
def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:
|
||||
self._parent = parent
|
||||
self._widgets = widgets
|
||||
|
||||
def __await__(self) -> Generator[None, None, None]:
|
||||
@@ -97,6 +99,7 @@ class AwaitMount:
|
||||
]
|
||||
if aws:
|
||||
await wait(aws)
|
||||
self._parent.refresh(layout=True)
|
||||
|
||||
return await_mount().__await__()
|
||||
|
||||
@@ -595,12 +598,12 @@ class Widget(DOMNode):
|
||||
else:
|
||||
parent = self
|
||||
|
||||
return AwaitMount(
|
||||
self.app._register(
|
||||
parent, *widgets, before=insert_before, after=insert_after
|
||||
)
|
||||
mounted = self.app._register(
|
||||
parent, *widgets, before=insert_before, after=insert_after
|
||||
)
|
||||
|
||||
return AwaitMount(self, mounted)
|
||||
|
||||
def move_child(
|
||||
self,
|
||||
child: int | Widget,
|
||||
@@ -1805,7 +1808,7 @@ class Widget(DOMNode):
|
||||
if spacing is not None:
|
||||
window = window.shrink(spacing)
|
||||
|
||||
if window in region:
|
||||
if window in region and not top:
|
||||
return Offset()
|
||||
|
||||
delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top)
|
||||
@@ -2173,8 +2176,10 @@ class Widget(DOMNode):
|
||||
|
||||
if layout:
|
||||
self._layout_required = True
|
||||
if isinstance(self._parent, Widget):
|
||||
self._parent._clear_arrangement_cache()
|
||||
for ancestor in self.ancestors:
|
||||
if not isinstance(ancestor, Widget):
|
||||
break
|
||||
ancestor._clear_arrangement_cache()
|
||||
|
||||
if repaint:
|
||||
self._set_dirty(*regions)
|
||||
@@ -2342,17 +2347,22 @@ class Widget(DOMNode):
|
||||
self.mouse_over = True
|
||||
|
||||
def _on_focus(self, event: events.Focus) -> None:
|
||||
for node in self.ancestors_with_self:
|
||||
if node._has_focus_within:
|
||||
self.app.update_styles(node)
|
||||
self.has_focus = True
|
||||
self.refresh()
|
||||
self.emit_no_wait(events.DescendantFocus(self))
|
||||
|
||||
def _on_blur(self, event: events.Blur) -> None:
|
||||
if any(node._has_focus_within for node in self.ancestors_with_self):
|
||||
self.app.update_styles(self)
|
||||
self.has_focus = False
|
||||
self.refresh()
|
||||
self.emit_no_wait(events.DescendantBlur(self))
|
||||
|
||||
def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
||||
if self._has_focus_within:
|
||||
self.app.update_styles(self)
|
||||
|
||||
def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
|
||||
if self._has_focus_within:
|
||||
self.app.update_styles(self)
|
||||
|
||||
def _on_mouse_scroll_down(self, event) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
@@ -2397,33 +2407,41 @@ class Widget(DOMNode):
|
||||
self.scroll_to_region(message.region, animate=True)
|
||||
|
||||
def action_scroll_home(self) -> None:
|
||||
if self._allow_scroll:
|
||||
self.scroll_home()
|
||||
if not self._allow_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_home()
|
||||
|
||||
def action_scroll_end(self) -> None:
|
||||
if self._allow_scroll:
|
||||
self.scroll_end()
|
||||
if not self._allow_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_end()
|
||||
|
||||
def action_scroll_left(self) -> None:
|
||||
if self.allow_horizontal_scroll:
|
||||
self.scroll_left()
|
||||
if not self.allow_horizontal_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_left()
|
||||
|
||||
def action_scroll_right(self) -> None:
|
||||
if self.allow_horizontal_scroll:
|
||||
self.scroll_right()
|
||||
if not self.allow_horizontal_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_right()
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_up()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_up()
|
||||
|
||||
def action_scroll_down(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_down()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_down()
|
||||
|
||||
def action_page_down(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_page_down()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_page_down()
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
if self.allow_vertical_scroll:
|
||||
self.scroll_page_up()
|
||||
if not self.allow_vertical_scroll:
|
||||
raise SkipAction()
|
||||
self.scroll_page_up()
|
||||
|
||||
@@ -91,13 +91,13 @@ class Header(Widget):
|
||||
Header {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
background: $secondary-background;
|
||||
background: $foreground 5%;
|
||||
color: $text;
|
||||
height: 1;
|
||||
}
|
||||
Header.-tall {
|
||||
height: 3;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
tall = Reactive(False)
|
||||
|
||||
@@ -24,7 +24,7 @@ def _check_renderable(renderable: object):
|
||||
)
|
||||
|
||||
|
||||
class Static(Widget):
|
||||
class Static(Widget, inherit_bindings=False):
|
||||
"""A widget to display simple static content, or use as a base class for more complex widgets.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -61,11 +61,18 @@ class TextLog(ScrollView, can_focus=True):
|
||||
def _on_styles_updated(self) -> None:
|
||||
self._line_cache.clear()
|
||||
|
||||
def write(self, content: RenderableType | object) -> None:
|
||||
def write(
|
||||
self,
|
||||
content: RenderableType | object,
|
||||
width: int | None = None,
|
||||
expand: bool = False,
|
||||
) -> None:
|
||||
"""Write text or a rich renderable.
|
||||
|
||||
Args:
|
||||
content (RenderableType): Rich renderable (or text).
|
||||
width (int): Width to render or None to use optimal width. Defaults to None.
|
||||
expand (bool): Enable expand to widget width, or False to use `width`.
|
||||
"""
|
||||
|
||||
renderable: RenderableType
|
||||
@@ -88,13 +95,17 @@ class TextLog(ScrollView, can_focus=True):
|
||||
if isinstance(renderable, Text) and not self.wrap:
|
||||
render_options = render_options.update(overflow="ignore", no_wrap=True)
|
||||
|
||||
width = max(
|
||||
self.min_width,
|
||||
measure_renderables(console, render_options, [renderable]).maximum,
|
||||
)
|
||||
if expand:
|
||||
render_width = self.scrollable_content_region.width
|
||||
else:
|
||||
render_width = (
|
||||
measure_renderables(console, render_options, [renderable]).maximum
|
||||
if width is None
|
||||
else width
|
||||
)
|
||||
|
||||
segments = self.app.console.render(
|
||||
renderable, render_options.update_width(width)
|
||||
renderable, render_options.update_width(render_width)
|
||||
)
|
||||
lines = list(Segment.split_lines(segments))
|
||||
if not lines:
|
||||
|
||||
@@ -451,6 +451,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all nodes under root."""
|
||||
self._line_cache.clear()
|
||||
self._tree_lines_cached = None
|
||||
self._current_id = 0
|
||||
root_label = self.root._label
|
||||
@@ -614,6 +615,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
assert self._tree_lines_cached is not None
|
||||
return self._tree_lines_cached
|
||||
|
||||
def _on_idle(self) -> None:
|
||||
"""Check tree needs a rebuild on idle."""
|
||||
# Property calls build if required
|
||||
self._tree_lines
|
||||
|
||||
def _build(self) -> None:
|
||||
"""Builds the tree by traversing nodes, and creating tree lines."""
|
||||
|
||||
@@ -805,7 +811,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
cursor_line = meta["line"]
|
||||
if meta.get("toggle", False):
|
||||
node = self.get_node_at_line(cursor_line)
|
||||
if node is not None and self.auto_expand:
|
||||
if node is not None:
|
||||
self._toggle_node(node)
|
||||
|
||||
else:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -188,7 +188,7 @@ def pytest_terminal_summary(
|
||||
Displays the link to the snapshot report that was generated in a prior hook.
|
||||
"""
|
||||
diffs = getattr(config, "_textual_snapshots", None)
|
||||
console = Console()
|
||||
console = Console(legacy_windows=False, force_terminal=True)
|
||||
if diffs:
|
||||
snapshot_report_location = config._textual_snapshot_html_report
|
||||
console.rule("[b red]Textual Snapshot Report", style="red")
|
||||
|
||||
64
tests/snapshot_tests/snapshot_apps/nested_auto_heights.py
Normal file
64
tests/snapshot_tests/snapshot_apps/nested_auto_heights.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class NestedAutoApp(App[None]):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: red;
|
||||
}
|
||||
|
||||
#my-static-container {
|
||||
border: heavy lightgreen;
|
||||
background: green;
|
||||
height: auto;
|
||||
max-height: 10;
|
||||
}
|
||||
|
||||
#my-static-wrapper {
|
||||
border: heavy lightblue;
|
||||
background: blue;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#my-static {
|
||||
border: heavy gray;
|
||||
background: black;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
BINDINGS = [
|
||||
("1", "1", "1"),
|
||||
("2", "2", "2"),
|
||||
("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._static = Static("", id="my-static")
|
||||
yield Vertical(
|
||||
Vertical(
|
||||
self._static,
|
||||
id="my-static-wrapper",
|
||||
),
|
||||
id="my-static-container",
|
||||
)
|
||||
|
||||
def action_1(self) -> None:
|
||||
self._static.update(
|
||||
"\n".join(f"Lorem {i} Ipsum {i} Sit {i}" for i in range(1, 21))
|
||||
)
|
||||
|
||||
def action_2(self) -> None:
|
||||
self._static.update("JUST ONE LINE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = NestedAutoApp()
|
||||
app.run()
|
||||
@@ -101,7 +101,9 @@ def test_header_render(snap_compare):
|
||||
|
||||
|
||||
def test_list_view(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"])
|
||||
assert snap_compare(
|
||||
WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"]
|
||||
)
|
||||
|
||||
|
||||
def test_textlog_max_lines(snap_compare):
|
||||
@@ -160,6 +162,11 @@ def test_offsets(snap_compare):
|
||||
assert snap_compare("snapshot_apps/offsets.py")
|
||||
|
||||
|
||||
def test_nested_auto_heights(snap_compare):
|
||||
"""Test refreshing widget within a auto sized container"""
|
||||
assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"])
|
||||
|
||||
|
||||
# --- Other ---
|
||||
|
||||
|
||||
@@ -169,4 +176,8 @@ def test_key_display(snap_compare):
|
||||
|
||||
def test_demo(snap_compare):
|
||||
"""Test the demo app (python -m textual)"""
|
||||
assert snap_compare(Path("../../src/textual/demo.py"))
|
||||
assert snap_compare(
|
||||
Path("../../src/textual/demo.py"),
|
||||
press=["down", "down", "down", "_"],
|
||||
terminal_size=(100, 30),
|
||||
)
|
||||
|
||||
@@ -6,12 +6,16 @@ from textual.binding import Bindings, Binding, BindingError, NoBinding
|
||||
|
||||
BINDING1 = Binding("a,b", action="action1", description="description1")
|
||||
BINDING2 = Binding("c", action="action2", description="description2")
|
||||
BINDING3 = Binding(" d , e ", action="action3", description="description3")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bindings():
|
||||
yield Bindings([BINDING1, BINDING2])
|
||||
|
||||
@pytest.fixture
|
||||
def more_bindings():
|
||||
yield Bindings([BINDING1, BINDING2, BINDING3])
|
||||
|
||||
def test_bindings_get_key(bindings):
|
||||
assert bindings.get_key("b") == Binding("b", action="action1", description="description1")
|
||||
@@ -19,6 +23,9 @@ def test_bindings_get_key(bindings):
|
||||
with pytest.raises(NoBinding):
|
||||
bindings.get_key("control+meta+alt+shift+super+hyper+t")
|
||||
|
||||
def test_bindings_get_key_spaced_list(more_bindings):
|
||||
assert more_bindings.get_key("d").action == more_bindings.get_key("e").action
|
||||
|
||||
def test_bindings_merge_simple(bindings):
|
||||
left = Bindings([BINDING1])
|
||||
right = Bindings([BINDING2])
|
||||
|
||||
653
tests/test_binding_inheritance.py
Normal file
653
tests/test_binding_inheritance.py
Normal file
@@ -0,0 +1,653 @@
|
||||
"""Tests relating to key binding inheritance.
|
||||
|
||||
In here you'll find some tests for general key binding inheritance, but
|
||||
there is an emphasis on the inheriting of movement key bindings as they (as
|
||||
of the time of writing) hold a special place in the Widget hierarchy of
|
||||
Textual.
|
||||
|
||||
<URL:https://github.com/Textualize/textual/issues/1343> holds much of the
|
||||
background relating to this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.actions import SkipAction
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container
|
||||
from textual.screen import Screen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
##############################################################################
|
||||
# These are the movement keys within Textual; they kind of have a special
|
||||
# status in that they will get bound to movement-related methods.
|
||||
MOVEMENT_KEYS = ["up", "down", "left", "right", "home", "end", "pageup", "pagedown"]
|
||||
|
||||
##############################################################################
|
||||
# An application with no bindings anywhere.
|
||||
#
|
||||
# The idea of this first little test is that an application that has no
|
||||
# bindings set anywhere, and uses a default screen, should only have the one
|
||||
# binding in place: ctrl+c; it's hard-coded in the app class for now.
|
||||
|
||||
|
||||
class NoBindings(App[None]):
|
||||
"""An app with zero bindings."""
|
||||
|
||||
|
||||
async def test_just_app_no_bindings() -> None:
|
||||
"""An app with no bindings should have no bindings, other than ctrl+c."""
|
||||
async with NoBindings().run_test() as pilot:
|
||||
assert list(pilot.app._bindings.keys.keys()) == ["ctrl+c", "tab", "shift+tab"]
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("tab").priority is False
|
||||
assert pilot.app._bindings.get_key("shift+tab").priority is False
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An application with a single alpha binding.
|
||||
#
|
||||
# Sticking with just an app and the default screen: this configuration has a
|
||||
# BINDINGS on the app itself, and simply binds the letter a -- in other
|
||||
# words avoiding anything to do with movement keys. The result should be
|
||||
# that we see the letter a, ctrl+c, and nothing else.
|
||||
|
||||
|
||||
class AlphaBinding(App[None]):
|
||||
"""An app with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
async def test_just_app_alpha_binding() -> None:
|
||||
"""An app with a single binding should have just the one binding."""
|
||||
async with AlphaBinding().run_test() as pilot:
|
||||
assert sorted(pilot.app._bindings.keys.keys()) == sorted(
|
||||
["ctrl+c", "tab", "shift+tab", "a"]
|
||||
)
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("a").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An application with a single low-priority alpha binding.
|
||||
#
|
||||
# The same as the above, but in this case we're going to, on purpose, lower
|
||||
# the priority of our own bindings, while any define by App itself should
|
||||
# remain the same.
|
||||
|
||||
|
||||
class LowAlphaBinding(App[None]):
|
||||
"""An app with a simple low-priority alpha key binding."""
|
||||
|
||||
PRIORITY_BINDINGS = False
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
async def test_just_app_low_priority_alpha_binding() -> None:
|
||||
"""An app with a single low-priority binding should have just the one binding."""
|
||||
async with LowAlphaBinding().run_test() as pilot:
|
||||
assert sorted(pilot.app._bindings.keys.keys()) == sorted(
|
||||
["ctrl+c", "tab", "shift+tab", "a"]
|
||||
)
|
||||
assert pilot.app._bindings.get_key("ctrl+c").priority is True
|
||||
assert pilot.app._bindings.get_key("a").priority is False
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A non-default screen with a single alpha key binding.
|
||||
#
|
||||
# There's little point in testing a screen with no bindings added as that's
|
||||
# pretty much the same as an app with a default screen (for the purposes of
|
||||
# these tests). So, let's test a screen with a single alpha-key binding.
|
||||
|
||||
|
||||
class ScreenWithBindings(Screen):
|
||||
"""A screen with a simple alpha key binding."""
|
||||
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
class AppWithScreenThatHasABinding(App[None]):
|
||||
"""An app with no extra bindings but with a custom screen with a binding."""
|
||||
|
||||
SCREENS = {"main": ScreenWithBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_app_screen_with_bindings() -> None:
|
||||
"""Test a screen with a single key binding defined."""
|
||||
async with AppWithScreenThatHasABinding().run_test() as pilot:
|
||||
# The screen will contain all of the movement keys, because it
|
||||
# inherits from Widget. That's fine. Let's check they're there, but
|
||||
# also let's check that they all have a non-priority binding.
|
||||
assert all(
|
||||
pilot.app.screen._bindings.get_key(key).priority is False
|
||||
for key in MOVEMENT_KEYS
|
||||
)
|
||||
# Let's also check that the 'a' key is there, and it *is* a priority
|
||||
# binding.
|
||||
assert pilot.app.screen._bindings.get_key("a").priority is True
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A non-default screen with a single low-priority alpha key binding.
|
||||
#
|
||||
# As above, but because Screen sets akk keys as high priority by default, we
|
||||
# want to be sure that if we set our keys in our subclass as low priority as
|
||||
# default, they come through as such.
|
||||
|
||||
|
||||
class ScreenWithLowBindings(Screen):
|
||||
"""A screen with a simple low-priority alpha key binding."""
|
||||
|
||||
PRIORITY_BINDINGS = False
|
||||
BINDINGS = [Binding("a", "a", "a")]
|
||||
|
||||
|
||||
class AppWithScreenThatHasALowBinding(App[None]):
|
||||
"""An app with no extra bindings but with a custom screen with a low-priority binding."""
|
||||
|
||||
SCREENS = {"main": ScreenWithLowBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_app_screen_with_low_bindings() -> None:
|
||||
"""Test a screen with a single low-priority key binding defined."""
|
||||
async with AppWithScreenThatHasALowBinding().run_test() as pilot:
|
||||
# Screens inherit from Widget which means they get movement keys
|
||||
# too, so let's ensure they're all in there, along with our own key,
|
||||
# and that everyone is low-priority.
|
||||
assert all(
|
||||
pilot.app.screen._bindings.get_key(key).priority is False
|
||||
for key in ["a", *MOVEMENT_KEYS]
|
||||
)
|
||||
|
||||
|
||||
##############################################################################
|
||||
# From here on in we're going to start simulating keystrokes to ensure that
|
||||
# any bindings that are in place actually fire the correct actions. To help
|
||||
# with this let's build a simple key/binding/action recorder base app.
|
||||
|
||||
|
||||
class AppKeyRecorder(App[None]):
|
||||
"""Base application class that can be used to record keystrokes."""
|
||||
|
||||
ALPHAS = "abcxyz"
|
||||
"""str: The alpha keys to test against."""
|
||||
|
||||
ALL_KEYS = [*ALPHAS, *MOVEMENT_KEYS]
|
||||
"""list[str]: All the test keys."""
|
||||
|
||||
@staticmethod
|
||||
def make_bindings(action_prefix: str = "") -> list[Binding]:
|
||||
"""Make the binding list for testing an app.
|
||||
|
||||
Args:
|
||||
action_prefix (str, optional): An optional prefix for the action name.
|
||||
|
||||
Returns:
|
||||
list[Binding]: The resulting list of bindings.
|
||||
"""
|
||||
return [
|
||||
Binding(key, f"{action_prefix}record('{key}')", key)
|
||||
for key in [*AppKeyRecorder.ALPHAS, *MOVEMENT_KEYS]
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the recording app."""
|
||||
super().__init__()
|
||||
self.pressed_keys: list[str] = []
|
||||
|
||||
async def action_record(self, key: str) -> None:
|
||||
"""Record a key, as used from a binding.
|
||||
|
||||
Args:
|
||||
key (str): The name of the key to record.
|
||||
"""
|
||||
self.pressed_keys.append(key)
|
||||
|
||||
def all_recorded(self, marker_prefix: str = "") -> None:
|
||||
"""Were all the bindings recorded from the presses?
|
||||
|
||||
Args:
|
||||
marker_prefix (str, optional): An optional prefix for the result markers.
|
||||
"""
|
||||
assert self.pressed_keys == [f"{marker_prefix}{key}" for key in self.ALL_KEYS]
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An app with bindings for movement keys.
|
||||
#
|
||||
# Having gone through various permutations of testing for what bindings are
|
||||
# seen to be in place, we now move on to adding bindings, invoking them and
|
||||
# seeing what happens. First off let's start with an application that has
|
||||
# bindings, both for an alpha key, and also for all of the movement keys.
|
||||
|
||||
|
||||
class AppWithMovementKeysBound(AppKeyRecorder):
|
||||
"""An application with bindings."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings()
|
||||
|
||||
|
||||
async def test_pressing_alpha_on_app() -> None:
|
||||
"""Test that pressing the alpha key, when it's bound on the app, results in an action fire."""
|
||||
async with AppWithMovementKeysBound().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALPHAS)
|
||||
await pilot.pause(2 / 100)
|
||||
assert pilot.app.pressed_keys == [*AppKeyRecorder.ALPHAS]
|
||||
|
||||
|
||||
async def test_pressing_movement_keys_app() -> None:
|
||||
"""Test that pressing the movement keys, when they're bound on the app, results in an action fire."""
|
||||
async with AppWithMovementKeysBound().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded()
|
||||
|
||||
|
||||
##############################################################################
|
||||
# An app with a focused child widget with bindings.
|
||||
#
|
||||
# Now let's spin up an application, using the default screen, where the app
|
||||
# itself is composing in a widget that can have, and has, focus. The widget
|
||||
# also has bindings for all of the test keys. That child widget should be
|
||||
# able to handle all of the test keys on its own and nothing else should
|
||||
# grab them.
|
||||
|
||||
|
||||
class FocusableWidgetWithBindings(Static, can_focus=True):
|
||||
"""A widget that has its own bindings for the movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("local_")
|
||||
|
||||
async def action_local_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"locally_{key}")
|
||||
|
||||
|
||||
class AppWithWidgetWithBindings(AppKeyRecorder):
|
||||
"""A test app that composes with a widget that has movement bindings."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithBindings()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithBindings).focus()
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings() -> None:
|
||||
"""A focused child widget with movement bindings should handle its own actions."""
|
||||
async with AppWithWidgetWithBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("locally_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget within a screen that handles bindings.
|
||||
#
|
||||
# Similar to the previous test, here we're wrapping an app around a
|
||||
# non-default screen, which in turn wraps a widget that can has, and will
|
||||
# have, focus. The difference here however is that the screen has the
|
||||
# bindings. What we should expect to see is that the bindings don't fire on
|
||||
# the widget (it has none) and instead get caught by the screen.
|
||||
|
||||
|
||||
class FocusableWidgetWithNoBindings(Static, can_focus=True):
|
||||
"""A widget that can receive focus but has no bindings."""
|
||||
|
||||
|
||||
class ScreenWithMovementBindings(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithNoBindings()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindings).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetNoBindings(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetNoBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget within a container within a screen that handles bindings.
|
||||
#
|
||||
# Similar again to the previous test, here we're wrapping an app around a
|
||||
# non-default screen, which in turn wraps a container which wraps a widget
|
||||
# that can have, and will have, focus. The issue here is that if the
|
||||
# container isn't scrolling, especially if it's set up to just wrap a widget
|
||||
# and do nothing else, it should not rob the screen of the binding hits.
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsAndContainerAroundWidget(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(FocusableWidgetWithNoBindings())
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindings).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWrappedWidgetNoBindings(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindings}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_contained_focused_child_widget_with_movement_bindings_on_screen() -> None:
|
||||
"""A contained focused child widget, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with bindings but no inheriting of bindings, on app.
|
||||
#
|
||||
# Now we move on to testing inherit_bindings. To start with we go back to an
|
||||
# app with a default screen, with the app itself composing in a widget that
|
||||
# can and will have focus, which has bindings for all the test keys, and
|
||||
# crucially has inherit_bindings set to False.
|
||||
#
|
||||
# We should expect to see all of the test keys recorded post-press.
|
||||
|
||||
|
||||
class WidgetWithBindingsNoInherit(Static, can_focus=True, inherit_bindings=False):
|
||||
"""A widget that has its own bindings for the movement keys, no binding inheritance."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("local_")
|
||||
|
||||
async def action_local_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"locally_{key}")
|
||||
|
||||
|
||||
class AppWithWidgetWithBindingsNoInherit(AppKeyRecorder):
|
||||
"""A test app that composes with a widget that has movement bindings without binding inheritance."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield WidgetWithBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(WidgetWithBindingsNoInherit).focus()
|
||||
|
||||
|
||||
async def test_focused_child_widget_with_movement_bindings_no_inherit() -> None:
|
||||
"""A focused child widget with movement bindings and inherit_bindings=False should handle its own actions."""
|
||||
async with AppWithWidgetWithBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("locally_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with no bindings and no inheriting of bindings, on screen.
|
||||
#
|
||||
# Now let's test with a widget that can and will have focus, which has no
|
||||
# bindings, and which won't inherit bindings either. The bindings we're
|
||||
# going to test are moved up to the screen. We should expect to see all of
|
||||
# the test keys not be consumed by the focused widget, but instead they
|
||||
# should make it up to the screen.
|
||||
#
|
||||
# NOTE: no bindings are declared for the widget, which is different from
|
||||
# zero bindings declared.
|
||||
|
||||
|
||||
class FocusableWidgetWithNoBindingsNoInherit(
|
||||
Static, can_focus=True, inherit_bindings=False
|
||||
):
|
||||
"""A widget that can receive focus but has no bindings and doesn't inherit bindings."""
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsNoInheritChild(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithNoBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithNoBindingsNoInherit).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetNoBindingsNoInherit(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings, child no-inherit."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindingsNoInheritChild}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# A focused widget with zero bindings declared, but no inheriting of
|
||||
# bindings, on screen.
|
||||
#
|
||||
# Now let's test with a widget that can and will have focus, which has zero
|
||||
# (an empty collection of) bindings, and which won't inherit bindings
|
||||
# either. The bindings we're going to test are moved up to the screen. We
|
||||
# should expect to see all of the test keys not be consumed by the focused
|
||||
# widget, but instead they should make it up to the screen.
|
||||
#
|
||||
# NOTE: zero bindings are declared for the widget, which is different from
|
||||
# no bindings declared.
|
||||
|
||||
|
||||
class FocusableWidgetWithEmptyBindingsNoInherit(
|
||||
Static, can_focus=True, inherit_bindings=False
|
||||
):
|
||||
"""A widget that can receive focus but has empty bindings and doesn't inherit bindings."""
|
||||
|
||||
BINDINGS = []
|
||||
|
||||
|
||||
class ScreenWithMovementBindingsNoInheritEmptyChild(Screen):
|
||||
"""A screen that binds keys, including movement keys."""
|
||||
|
||||
BINDINGS = AppKeyRecorder.make_bindings("screen_")
|
||||
|
||||
async def action_screen_record(self, key: str) -> None:
|
||||
# Sneaky forward reference. Just for the purposes of testing.
|
||||
await self.app.action_record(f"screenly_{key}")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FocusableWidgetWithEmptyBindingsNoInherit()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(FocusableWidgetWithEmptyBindingsNoInherit).focus()
|
||||
|
||||
|
||||
class AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit(AppKeyRecorder):
|
||||
"""An app with a non-default screen that handles movement key bindings, child no-inherit."""
|
||||
|
||||
SCREENS = {"main": ScreenWithMovementBindingsNoInheritEmptyChild}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bindings_on_screen() -> None:
|
||||
"""A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions."""
|
||||
async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot:
|
||||
await pilot.press(*AppKeyRecorder.ALL_KEYS)
|
||||
await pilot.pause(2 / 100)
|
||||
pilot.app.all_recorded("screenly_")
|
||||
|
||||
|
||||
##############################################################################
|
||||
# Testing priority of overlapping bindings.
|
||||
#
|
||||
# Here we we'll have an app, screen, and a focused widget, along with a
|
||||
# combination of overlapping bindings, each with different forms of
|
||||
# priority, so we can check who wins where.
|
||||
#
|
||||
# Here are the permutations tested, with the expected winner:
|
||||
#
|
||||
# |-----|----------|----------|----------|--------|
|
||||
# | Key | App | Screen | Widget | Winner |
|
||||
# |-----|----------|----------|----------|--------|
|
||||
# | 0 | | | | Widget |
|
||||
# | A | Priority | | | App |
|
||||
# | B | | Priority | | Screen |
|
||||
# | C | | | Priority | Widget |
|
||||
# | D | Priority | Priority | | App |
|
||||
# | E | Priority | | Priority | App |
|
||||
# | F | | Priority | Priority | Screen |
|
||||
|
||||
|
||||
class PriorityOverlapWidget(Static, can_focus=True):
|
||||
"""A focusable widget with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "app.record('widget_0')", "0", priority=False),
|
||||
Binding("a", "app.record('widget_a')", "a", priority=False),
|
||||
Binding("b", "app.record('widget_b')", "b", priority=False),
|
||||
Binding("c", "app.record('widget_c')", "c", priority=True),
|
||||
Binding("d", "app.record('widget_d')", "d", priority=False),
|
||||
Binding("e", "app.record('widget_e')", "e", priority=True),
|
||||
Binding("f", "app.record('widget_f')", "f", priority=True),
|
||||
]
|
||||
|
||||
|
||||
class PriorityOverlapScreen(Screen):
|
||||
"""A screen with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "app.record('screen_0')", "0", priority=False),
|
||||
Binding("a", "app.record('screen_a')", "a", priority=False),
|
||||
Binding("b", "app.record('screen_b')", "b", priority=True),
|
||||
Binding("c", "app.record('screen_c')", "c", priority=False),
|
||||
Binding("d", "app.record('screen_d')", "c", priority=True),
|
||||
Binding("e", "app.record('screen_e')", "e", priority=False),
|
||||
Binding("f", "app.record('screen_f')", "f", priority=True),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield PriorityOverlapWidget()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(PriorityOverlapWidget).focus()
|
||||
|
||||
|
||||
class PriorityOverlapApp(AppKeyRecorder):
|
||||
"""An application with a priority binding."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("0", "record('app_0')", "0", priority=False),
|
||||
Binding("a", "record('app_a')", "a", priority=True),
|
||||
Binding("b", "record('app_b')", "b", priority=False),
|
||||
Binding("c", "record('app_c')", "c", priority=False),
|
||||
Binding("d", "record('app_d')", "c", priority=True),
|
||||
Binding("e", "record('app_e')", "e", priority=True),
|
||||
Binding("f", "record('app_f')", "f", priority=False),
|
||||
]
|
||||
|
||||
SCREENS = {"main": PriorityOverlapScreen}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen("main")
|
||||
|
||||
|
||||
async def test_overlapping_priority_bindings() -> None:
|
||||
"""Test an app stack with overlapping bindings."""
|
||||
async with PriorityOverlapApp().run_test() as pilot:
|
||||
await pilot.press(*"0abcdef")
|
||||
await pilot.pause(2 / 100)
|
||||
assert pilot.app.pressed_keys == [
|
||||
"widget_0",
|
||||
"app_a",
|
||||
"screen_b",
|
||||
"widget_c",
|
||||
"app_d",
|
||||
"app_e",
|
||||
"screen_f",
|
||||
]
|
||||
|
||||
|
||||
async def test_skip_action() -> None:
|
||||
"""Test that a binding may be skipped by an action raising SkipAction"""
|
||||
|
||||
class Handle(Widget, can_focus=True):
|
||||
BINDINGS = [("t", "test('foo')", "Test")]
|
||||
|
||||
def action_test(self, text: str) -> None:
|
||||
self.app.exit(text)
|
||||
|
||||
no_handle_invoked = False
|
||||
|
||||
class NoHandle(Widget, can_focus=True):
|
||||
BINDINGS = [("t", "test('bar')", "Test")]
|
||||
|
||||
def action_test(self, text: str) -> bool:
|
||||
nonlocal no_handle_invoked
|
||||
no_handle_invoked = True
|
||||
raise SkipAction()
|
||||
|
||||
class SkipApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Handle(NoHandle())
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(NoHandle).focus()
|
||||
|
||||
async with SkipApp().run_test() as pilot:
|
||||
# Check the NoHandle widget has focus
|
||||
assert pilot.app.query_one(NoHandle).has_focus
|
||||
# Press the "t" key
|
||||
await pilot.press("t")
|
||||
# Check the action on the no handle widget was called
|
||||
assert no_handle_invoked
|
||||
# Check the return value, confirming that the action on Handle was called
|
||||
assert pilot.app.return_value == "foo"
|
||||
@@ -2,9 +2,8 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.app import App
|
||||
from textual.reactive import reactive, var
|
||||
from textual.widget import Widget
|
||||
|
||||
OLD_VALUE = 5_000
|
||||
NEW_VALUE = 1_000_000
|
||||
@@ -81,14 +80,14 @@ async def test_watch_async_init_true():
|
||||
try:
|
||||
await asyncio.wait_for(app.watcher_called_event.wait(), timeout=0.05)
|
||||
except TimeoutError:
|
||||
pytest.fail("Async watcher wasn't called within timeout when reactive init = True")
|
||||
pytest.fail(
|
||||
"Async watcher wasn't called within timeout when reactive init = True")
|
||||
|
||||
assert app.count == OLD_VALUE
|
||||
assert app.watcher_old_value == OLD_VALUE
|
||||
assert app.watcher_new_value == OLD_VALUE # The value wasn't changed
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Reactive watcher is incorrectly always called the first time it is set, even if value is same [issue#1230]")
|
||||
async def test_watch_init_false_always_update_false():
|
||||
class WatcherInitFalse(App):
|
||||
count = reactive(0, init=False)
|
||||
@@ -101,6 +100,10 @@ async def test_watch_init_false_always_update_false():
|
||||
async with app.run_test():
|
||||
app.count = 0 # Value hasn't changed, and always_update=False, so watch_count shouldn't run
|
||||
assert app.watcher_call_count == 0
|
||||
app.count = 0
|
||||
assert app.watcher_call_count == 0
|
||||
app.count = 1
|
||||
assert app.watcher_call_count == 1
|
||||
|
||||
|
||||
async def test_watch_init_true():
|
||||
@@ -173,20 +176,45 @@ async def test_reactive_with_callable_default():
|
||||
assert app.watcher_called_with == OLD_VALUE
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Validator methods not running when init=True [issue#1220]")
|
||||
async def test_validate_init_true():
|
||||
"""When init is True for a reactive attribute, Textual should call the validator
|
||||
AND the watch method when the app starts."""
|
||||
validator_call_count = 0
|
||||
|
||||
class ValidatorInitTrue(App):
|
||||
count = var(5, init=True)
|
||||
|
||||
def validate_count(self, value: int) -> int:
|
||||
nonlocal validator_call_count
|
||||
validator_call_count += 1
|
||||
return value + 1
|
||||
|
||||
app = ValidatorInitTrue()
|
||||
async with app.run_test():
|
||||
app.count = 5
|
||||
assert app.count == 6 # Validator should run, so value should be 5+1=6
|
||||
assert validator_call_count == 1
|
||||
|
||||
|
||||
async def test_validate_init_true_set_before_dom_ready():
|
||||
"""When init is True for a reactive attribute, Textual should call the validator
|
||||
AND the watch method when the app starts."""
|
||||
validator_call_count = 0
|
||||
|
||||
class ValidatorInitTrue(App):
|
||||
count = var(5, init=True)
|
||||
|
||||
def validate_count(self, value: int) -> int:
|
||||
nonlocal validator_call_count
|
||||
validator_call_count += 1
|
||||
return value + 1
|
||||
|
||||
app = ValidatorInitTrue()
|
||||
app.count = 5
|
||||
async with app.run_test():
|
||||
assert app.count == 6 # Validator should run, so value should be 5+1=6
|
||||
assert validator_call_count == 1
|
||||
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Compute methods not called when init=True [issue#1227]")
|
||||
|
||||
@@ -12,7 +12,9 @@ async def test_run_test() -> None:
|
||||
|
||||
app = TestApp()
|
||||
async with app.run_test() as pilot:
|
||||
assert str(pilot) == "<Pilot app=TestApp(title='TestApp')>"
|
||||
assert (
|
||||
str(pilot) == "<Pilot app=TestApp(title='TestApp', classes={'-dark-mode'})>"
|
||||
)
|
||||
await pilot.press("tab", *"foo")
|
||||
await pilot.pause(1 / 100)
|
||||
await pilot.exit("bar")
|
||||
|
||||
Reference in New Issue
Block a user