mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into toggle-boxen
This commit is contained in:
28
CHANGELOG.md
28
CHANGELOG.md
@@ -5,17 +5,33 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
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/).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
## [0.12.0] - Unreleased
|
### Added
|
||||||
|
|
||||||
|
- Added `Checkbox` https://github.com/Textualize/textual/pull/1872
|
||||||
|
- Added `RadioButton` https://github.com/Textualize/textual/pull/1872
|
||||||
|
- Added `RadioSet` https://github.com/Textualize/textual/pull/1872
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix exceptions in watch methods being hidden on startup https://github.com/Textualize/textual/issues/1886
|
||||||
|
|
||||||
|
## [0.12.1] - 2023-02-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix for batch update glitch https://github.com/Textualize/textual/pull/1880
|
||||||
|
|
||||||
|
## [0.12.0] - 2023-02-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added `App.batch_update` https://github.com/Textualize/textual/pull/1832
|
- Added `App.batch_update` https://github.com/Textualize/textual/pull/1832
|
||||||
- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
|
- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
|
||||||
- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785
|
- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785
|
||||||
- Added `Checkbox` https://github.com/Textualize/textual/pull/1872
|
- Added `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message https://github.com/Textualize/textual/pull/1861
|
||||||
- Added `RadioButton` https://github.com/Textualize/textual/pull/1872
|
- Added `MessagePump.prevent` context manager to temporarily suppress a given message type https://github.com/Textualize/textual/pull/1866
|
||||||
- Added `RadioSet` https://github.com/Textualize/textual/pull/1872
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -26,11 +42,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Removed `screen.visible_widgets` and `screen.widgets`
|
- Removed `screen.visible_widgets` and `screen.widgets`
|
||||||
|
- Removed `StylesUpdate` message. https://github.com/Textualize/textual/pull/1861
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836
|
- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836
|
||||||
- Fixed superfluous scrolling when focusing a docked widget https://github.com/Textualize/textual/issues/1816
|
- Fixed superfluous scrolling when focusing a docked widget https://github.com/Textualize/textual/issues/1816
|
||||||
|
- Fixes walk_children which was returning more than one screen https://github.com/Textualize/textual/issues/1846
|
||||||
|
- Fixed issue with watchers fired for detached nodes https://github.com/Textualize/textual/issues/1846
|
||||||
|
|
||||||
## [0.11.1] - 2023-02-17
|
## [0.11.1] - 2023-02-17
|
||||||
|
|
||||||
@@ -498,6 +517,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.12.0]: https://github.com/Textualize/textual/compare/v0.11.1...v0.12.0
|
||||||
[0.11.1]: https://github.com/Textualize/textual/compare/v0.11.0...v0.11.1
|
[0.11.1]: https://github.com/Textualize/textual/compare/v0.11.0...v0.11.1
|
||||||
[0.11.0]: https://github.com/Textualize/textual/compare/v0.10.1...v0.11.0
|
[0.11.0]: https://github.com/Textualize/textual/compare/v0.10.1...v0.11.0
|
||||||
[0.10.1]: https://github.com/Textualize/textual/compare/v0.10.0...v0.10.1
|
[0.10.1]: https://github.com/Textualize/textual/compare/v0.10.0...v0.10.1
|
||||||
|
|||||||
235
docs/blog/images/colors.svg
Normal file
235
docs/blog/images/colors.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 72 KiB |
107
docs/blog/posts/release0-12-0.md
Normal file
107
docs/blog/posts/release0-12-0.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
draft: false
|
||||||
|
date: 2023-02-24
|
||||||
|
categories:
|
||||||
|
- Release
|
||||||
|
title: "Textual 0.12.0 adds syntactical sugar and batch updates"
|
||||||
|
authors:
|
||||||
|
- willmcgugan
|
||||||
|
---
|
||||||
|
|
||||||
|
# Textual 0.12.0 adds syntactical sugar and batch updates
|
||||||
|
|
||||||
|
It's been just 9 days since the previous release, but we have a few interesting enhancements to the Textual API to talk about.
|
||||||
|
|
||||||
|
<!-- more -->
|
||||||
|
|
||||||
|
## Better compose
|
||||||
|
|
||||||
|
We've added a little *syntactical sugar* to Textual's `compose` methods, which aids both
|
||||||
|
readability and *editability* (that might not be a word).
|
||||||
|
|
||||||
|
First, let's look at the old way of building compose methods. This snippet is taken from the `textual colors` command.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
for color_name in ColorSystem.COLOR_NAMES:
|
||||||
|
|
||||||
|
items: list[Widget] = [ColorLabel(f'"{color_name}"')]
|
||||||
|
for level in LEVELS:
|
||||||
|
color = f"{color_name}-{level}" if level else color_name
|
||||||
|
item = ColorItem(
|
||||||
|
ColorBar(f"${color}", classes="text label"),
|
||||||
|
ColorBar("$text-muted", classes="muted"),
|
||||||
|
ColorBar("$text-disabled", classes="disabled"),
|
||||||
|
classes=color,
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
yield ColorGroup(*items, id=f"group-{color_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
This code *composes* the following color swatches:
|
||||||
|
|
||||||
|
<div>
|
||||||
|
--8<-- "docs/blog/images/colors.svg"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
You can see this by running `textual colors` from the command line.
|
||||||
|
|
||||||
|
|
||||||
|
The old way was not all that bad, but it did make it hard to see the structure of your app at-a-glance, and editing compose methods always felt a little laborious.
|
||||||
|
|
||||||
|
Here's the new syntax, which uses context managers to add children to containers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for color_name in ColorSystem.COLOR_NAMES:
|
||||||
|
with ColorGroup(id=f"group-{color_name}"):
|
||||||
|
yield Label(f'"{color_name}"')
|
||||||
|
for level in LEVELS:
|
||||||
|
color = f"{color_name}-{level}" if level else color_name
|
||||||
|
with ColorItem(classes=color):
|
||||||
|
yield ColorBar(f"${color}", classes="text label")
|
||||||
|
yield ColorBar("$text-muted", classes="muted")
|
||||||
|
yield ColorBar("$text-disabled", classes="disabled")
|
||||||
|
```
|
||||||
|
|
||||||
|
The context manager approach generally results in fewer lines of code, and presents attributes on the same line as containers themselves. Additionally, adding widgets to a container can be as simple is indenting them.
|
||||||
|
|
||||||
|
You can still construct widgets and containers with positional arguments, but this new syntax is preferred. It's not documented yet, but you can start using it now. We will be updating our examples in the next few weeks.
|
||||||
|
|
||||||
|
## Batch updates
|
||||||
|
|
||||||
|
Textual is smart about performing updates to the screen. When you make a change that might *repaint* the screen, those changes don't happen immediately. Textual makes a note of them, and repaints the screen a short time later (around a 1/60th of a second). Multiple updates are combined so that Textual does less work overall, and there is none of the flicker you might get with multiple repaints.
|
||||||
|
|
||||||
|
Although this works very well, it is possible to introduce a little flicker if you make changes across multiple widgets. And especially if you add or remove many widgets at once. To combat this we have added a [batch_update][textual.app.App.batch_update] context manager which tells Textual to disable screen updates until the end of the with block.
|
||||||
|
|
||||||
|
The new [Markdown](./release0-11-0.md) widget uses this context manager when it updates its content. Here's the code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with self.app.batch_update():
|
||||||
|
await self.query("MarkdownBlock").remove()
|
||||||
|
await self.mount_all(output)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the batch update there are a few frames where the old markdown blocks are removed and the new blocks are added (which would be perceived as a brief flicker). With the update, the update appears instant.
|
||||||
|
|
||||||
|
## Disabled widgets
|
||||||
|
|
||||||
|
A few widgets (such as [Button](./../../widgets/button.md)) had a `disabled` attribute which would fade the widget a little and make it unselectable. We've extended this to all widgets. Although it is particularly applicable to input controls, anything may be disabled. Disabling a container makes its children disabled, so you could use this for disabling a form, for example.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
Disabled widgets may be styled with the `:disabled` CSS pseudo-selector.
|
||||||
|
|
||||||
|
## Preventing messages
|
||||||
|
|
||||||
|
Also in this release is another context manager, which will disable specified Message types. This doesn't come up as a requirement very often, but it can be very useful when it does. This one is documented, see [Preventing events](./../../guide/events.md#preventing-messages) for details.
|
||||||
|
|
||||||
|
## Full changelog
|
||||||
|
|
||||||
|
As always see the [release page](https://github.com/Textualize/textual/releases/tag/v0.12.0) for additional changes and bug fixes.
|
||||||
|
|
||||||
|
## Join us!
|
||||||
|
|
||||||
|
We're having fun on our [Discord server](https://discord.gg/Enf6Z3qhVr). Join us there to talk to Textualize developers and share ideas.
|
||||||
26
docs/examples/events/prevent.py
Normal file
26
docs/examples/events/prevent.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from textual.widgets import Button, Input
|
||||||
|
|
||||||
|
|
||||||
|
class PreventApp(App):
|
||||||
|
"""Demonstrates `prevent` context manager."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Input()
|
||||||
|
yield Button("Clear", id="clear")
|
||||||
|
|
||||||
|
def on_button_pressed(self) -> None:
|
||||||
|
"""Clear the text input."""
|
||||||
|
input = self.query_one(Input)
|
||||||
|
with input.prevent(Input.Changed): # (1)!
|
||||||
|
input.value = ""
|
||||||
|
|
||||||
|
def on_input_changed(self) -> None:
|
||||||
|
"""Called as the user types."""
|
||||||
|
self.bell() # (2)!
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = PreventApp()
|
||||||
|
app.run()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.app import ComposeResult, App
|
from textual.widgets import Header, Static
|
||||||
from textual.widgets import Static, Header
|
|
||||||
|
|
||||||
|
|
||||||
class CombiningLayoutsExample(App):
|
class CombiningLayoutsExample(App):
|
||||||
@@ -8,28 +8,21 @@ class CombiningLayoutsExample(App):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
yield Container(
|
with Container(id="app-grid"):
|
||||||
Vertical(
|
with Vertical(id="left-pane"):
|
||||||
*[Static(f"Vertical layout, child {number}") for number in range(15)],
|
for number in range(15):
|
||||||
id="left-pane",
|
yield Static(f"Vertical layout, child {number}")
|
||||||
),
|
with Horizontal(id="top-right"):
|
||||||
Horizontal(
|
yield Static("Horizontally")
|
||||||
Static("Horizontally"),
|
yield Static("Positioned")
|
||||||
Static("Positioned"),
|
yield Static("Children")
|
||||||
Static("Children"),
|
yield Static("Here")
|
||||||
Static("Here"),
|
with Container(id="bottom-right"):
|
||||||
id="top-right",
|
yield Static("This")
|
||||||
),
|
yield Static("panel")
|
||||||
Container(
|
yield Static("is")
|
||||||
Static("This"),
|
yield Static("using")
|
||||||
Static("panel"),
|
yield Static("grid layout!", id="bottom-right-final")
|
||||||
Static("is"),
|
|
||||||
Static("using"),
|
|
||||||
Static("grid layout!", id="bottom-right-final"),
|
|
||||||
id="bottom-right",
|
|
||||||
),
|
|
||||||
id="app-grid",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
21
docs/examples/guide/layout/utility_containers_using_with.py
Normal file
21
docs/examples/guide/layout/utility_containers_using_with.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityContainersExample(App):
|
||||||
|
CSS_PATH = "utility_containers.css"
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Horizontal():
|
||||||
|
with Vertical(classes="column"):
|
||||||
|
yield Static("One")
|
||||||
|
yield Static("Two")
|
||||||
|
with Vertical(classes="column"):
|
||||||
|
yield Static("Three")
|
||||||
|
yield Static("Four")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = UtilityContainersExample()
|
||||||
|
app.run()
|
||||||
@@ -108,7 +108,7 @@ The message class is defined within the widget class itself. This is not strictl
|
|||||||
- It creates a namespace for the handler. So rather than `on_selected`, the handler name becomes `on_color_button_selected`. This makes it less likely that your chosen name will clash with another message.
|
- It creates a namespace for the handler. So rather than `on_selected`, the handler name becomes `on_color_button_selected`. This makes it less likely that your chosen name will clash with another message.
|
||||||
|
|
||||||
|
|
||||||
## Sending events
|
## Sending messages
|
||||||
|
|
||||||
In the previous example we used [post_message()][textual.message_pump.MessagePump.post_message] to send an event to its parent. We could also have used [post_message_no_wait()][textual.message_pump.MessagePump.post_message_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used.
|
In the previous example we used [post_message()][textual.message_pump.MessagePump.post_message] to send an event to its parent. We could also have used [post_message_no_wait()][textual.message_pump.MessagePump.post_message_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used.
|
||||||
|
|
||||||
@@ -118,6 +118,32 @@ There are other ways of sending (posting) messages, which you may need to use le
|
|||||||
- [post_message_no_wait][textual.message_pump.MessagePump.post_message_no_wait] The non-async version of `post_message`.
|
- [post_message_no_wait][textual.message_pump.MessagePump.post_message_no_wait] The non-async version of `post_message`.
|
||||||
|
|
||||||
|
|
||||||
|
## Preventing messages
|
||||||
|
|
||||||
|
You can *temporarily* disable posting of messages of a particular type by calling [prevent][textual.message_pump.MessagePump.prevent], which returns a context manager (used with Python's `with` keyword). This is typically used when updating data in a child widget and you don't want to receive notifications that something has changed.
|
||||||
|
|
||||||
|
The following example will play the terminal bell as you type. It does this by handling [Input.Changed][textual.widgets.Input.Changed] and calling [bell()][textual.app.App.bell]. There is a Clear button which sets the input's value to an empty string. This would normally also result in a `Input.Changed` event being sent (and the bell playing). Since we don't want the button to make a sound, the assignment to `value` is wrapped within a [prevent][textual.message_pump.MessagePump.prevent] context manager.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
In reality, playing the terminal bell as you type would be very irritating -- we don't recommend it!
|
||||||
|
|
||||||
|
=== "prevent.py"
|
||||||
|
|
||||||
|
```python title="prevent.py"
|
||||||
|
--8<-- "docs/examples/events/prevent.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Clear the input without sending an Input.Changed event.
|
||||||
|
2. Plays the terminal sound when typing.
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/events/prevent.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Message handlers
|
## Message handlers
|
||||||
|
|
||||||
Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail.
|
Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail.
|
||||||
|
|||||||
@@ -159,7 +159,50 @@ In other words, we have a single row containing two columns.
|
|||||||
```
|
```
|
||||||
|
|
||||||
You may be tempted to use many levels of nested utility containers in order to build advanced, grid-like layouts.
|
You may be tempted to use many levels of nested utility containers in order to build advanced, grid-like layouts.
|
||||||
However, Textual comes with a more powerful mechanism for achieving this known as _grid layout_, which we'll discuss next.
|
However, Textual comes with a more powerful mechanism for achieving this known as _grid layout_, which we'll discuss below.
|
||||||
|
|
||||||
|
## Composing with context managers
|
||||||
|
|
||||||
|
In the previous section we've show how you add children to a container (such as `Horizontal` and `Vertical`) using positional arguments.
|
||||||
|
It's fine to do it this way, but Textual offers a simplified syntax using [context managers](https://docs.python.org/3/reference/datamodel.html#context-managers) which is generally easier to write and edit.
|
||||||
|
|
||||||
|
When composing a widget, you can introduce a container using Python's `with` statement.
|
||||||
|
Any widgets yielded within that block are added as a child of the container.
|
||||||
|
|
||||||
|
Let's update the [utility containers](#utility-containers) example to use the context manager approach.
|
||||||
|
|
||||||
|
=== "utility_containers_using_with.py"
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
This code uses context managers to compose widgets.
|
||||||
|
|
||||||
|
```python hl_lines="10-16"
|
||||||
|
--8<-- "docs/examples/guide/layout/utility_containers_using_with.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "utility_containers.py"
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
This is the original code using positional arguments.
|
||||||
|
|
||||||
|
```python hl_lines="10-21"
|
||||||
|
--8<-- "docs/examples/guide/layout/utility_containers.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "utility_containers.css"
|
||||||
|
|
||||||
|
```sass
|
||||||
|
--8<-- "docs/examples/guide/layout/utility_containers.css"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/guide/layout/utility_containers_using_with.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note how the end result is the same, but the code with context managers is a little easer to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like!
|
||||||
|
|
||||||
## Grid
|
## Grid
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ The example below shows switches in various states.
|
|||||||
|
|
||||||
## Reactive Attributes
|
## Reactive Attributes
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
|---------|--------|---------|----------------------------------|
|
|---------|--------|---------|--------------------------|
|
||||||
| `value` | `bool` | `False` | The default value of the switch. |
|
| `value` | `bool` | `False` | The value of the switch. |
|
||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Call [TextLog.write][textual.widgets.TextLog.write] with a string or [Rich Rende
|
|||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
The example below shows each placeholder variant.
|
The example below shows an application showing a `TextLog` with different kinds of data logged.
|
||||||
|
|
||||||
=== "Output"
|
=== "Output"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual import events
|
from textual import events
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Container
|
from textual.containers import Container
|
||||||
from textual.css.query import NoMatches
|
from textual.css.query import NoMatches
|
||||||
from textual.reactive import var
|
from textual.reactive import var
|
||||||
@@ -48,30 +48,28 @@ class CalculatorApp(App):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Add our buttons."""
|
"""Add our buttons."""
|
||||||
yield Container(
|
with Container(id="calculator"):
|
||||||
Static(id="numbers"),
|
yield Static(id="numbers")
|
||||||
Button("AC", id="ac", variant="primary"),
|
yield Button("AC", id="ac", variant="primary")
|
||||||
Button("C", id="c", variant="primary"),
|
yield Button("C", id="c", variant="primary")
|
||||||
Button("+/-", id="plus-minus", variant="primary"),
|
yield Button("+/-", id="plus-minus", variant="primary")
|
||||||
Button("%", id="percent", variant="primary"),
|
yield Button("%", id="percent", variant="primary")
|
||||||
Button("÷", id="divide", variant="warning"),
|
yield Button("÷", id="divide", variant="warning")
|
||||||
Button("7", id="number-7"),
|
yield Button("7", id="number-7")
|
||||||
Button("8", id="number-8"),
|
yield Button("8", id="number-8")
|
||||||
Button("9", id="number-9"),
|
yield Button("9", id="number-9")
|
||||||
Button("×", id="multiply", variant="warning"),
|
yield Button("×", id="multiply", variant="warning")
|
||||||
Button("4", id="number-4"),
|
yield Button("4", id="number-4")
|
||||||
Button("5", id="number-5"),
|
yield Button("5", id="number-5")
|
||||||
Button("6", id="number-6"),
|
yield Button("6", id="number-6")
|
||||||
Button("-", id="minus", variant="warning"),
|
yield Button("-", id="minus", variant="warning")
|
||||||
Button("1", id="number-1"),
|
yield Button("1", id="number-1")
|
||||||
Button("2", id="number-2"),
|
yield Button("2", id="number-2")
|
||||||
Button("3", id="number-3"),
|
yield Button("3", id="number-3")
|
||||||
Button("+", id="plus", variant="warning"),
|
yield Button("+", id="plus", variant="warning")
|
||||||
Button("0", id="number-0"),
|
yield Button("0", id="number-0")
|
||||||
Button(".", id="point"),
|
yield Button(".", id="point")
|
||||||
Button("=", id="equals", variant="warning"),
|
yield Button("=", id="equals", variant="warning")
|
||||||
id="calculator",
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_key(self, event: events.Key) -> None:
|
def on_key(self, event: events.Key) -> None:
|
||||||
"""Called when the user presses a key."""
|
"""Called when the user presses a key."""
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ Two tildes indicates strikethrough, e.g. `~~cross out~~` render ~~cross out~~.
|
|||||||
|
|
||||||
Inline code is indicated by backticks. e.g. `import this`.
|
Inline code is indicated by backticks. e.g. `import this`.
|
||||||
|
|
||||||
|
## Horizontal rule
|
||||||
|
|
||||||
|
Draw a horizontal rule with three dashes (`---`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Good for natural breaks in the content, that don't require another header.
|
||||||
|
|
||||||
## Lists
|
## Lists
|
||||||
|
|
||||||
1. Lists can be ordered
|
1. Lists can be ordered
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "0.11.1"
|
version = "0.12.1"
|
||||||
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>"]
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .app import App
|
from .app import App
|
||||||
|
from .message import Message
|
||||||
from .message_pump import MessagePump
|
from .message_pump import MessagePump
|
||||||
|
|
||||||
|
|
||||||
@@ -12,3 +15,6 @@ class NoActiveAppError(RuntimeError):
|
|||||||
|
|
||||||
active_app: ContextVar["App"] = ContextVar("active_app")
|
active_app: ContextVar["App"] = ContextVar("active_app")
|
||||||
active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")
|
active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")
|
||||||
|
prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar(
|
||||||
|
"prevent_message_types_stack"
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from rich.segment import Segment
|
|||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
|
||||||
from ._border import get_box, render_row
|
from ._border import get_box, render_row
|
||||||
from .filter import LineFilter
|
|
||||||
from ._opacity import _apply_opacity
|
from ._opacity import _apply_opacity
|
||||||
from ._segment_tools import line_pad, line_trim
|
from ._segment_tools import line_pad, line_trim
|
||||||
from .color import Color
|
from .color import Color
|
||||||
|
from .filter import LineFilter
|
||||||
from .geometry import Region, Size, Spacing
|
from .geometry import Region, Size, Spacing
|
||||||
from .renderables.text_opacity import TextOpacity
|
from .renderables.text_opacity import TextOpacity
|
||||||
from .renderables.tint import Tint
|
from .renderables.tint import Tint
|
||||||
@@ -120,13 +120,12 @@ class StylesCache:
|
|||||||
)
|
)
|
||||||
if widget.auto_links:
|
if widget.auto_links:
|
||||||
hover_style = widget.hover_style
|
hover_style = widget.hover_style
|
||||||
link_hover_style = widget.link_hover_style
|
|
||||||
if (
|
if (
|
||||||
link_hover_style
|
hover_style._link_id
|
||||||
and hover_style._link_id
|
|
||||||
and hover_style._meta
|
and hover_style._meta
|
||||||
and "@click" in hover_style.meta
|
and "@click" in hover_style.meta
|
||||||
):
|
):
|
||||||
|
link_hover_style = widget.link_hover_style
|
||||||
if link_hover_style:
|
if link_hover_style:
|
||||||
strips = [
|
strips = [
|
||||||
strip.style_links(hover_style.link_id, link_hover_style)
|
strip.style_links(hover_style.link_id, link_hover_style)
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def batch_update(self) -> Generator[None, None, None]:
|
def batch_update(self) -> Generator[None, None, None]:
|
||||||
"""Suspend all repaints until the end of the batch."""
|
"""A context manager to suspend all repaints until the end of the batch."""
|
||||||
self._begin_batch()
|
self._begin_batch()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -461,10 +461,6 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._batch_count -= 1
|
self._batch_count -= 1
|
||||||
assert self._batch_count >= 0, "This won't happen if you use `batch_update`"
|
assert self._batch_count >= 0, "This won't happen if you use `batch_update`"
|
||||||
if not self._batch_count:
|
if not self._batch_count:
|
||||||
try:
|
|
||||||
self.screen.check_idle()
|
|
||||||
except ScreenStackError:
|
|
||||||
pass
|
|
||||||
self.check_idle()
|
self.check_idle()
|
||||||
|
|
||||||
def animate(
|
def animate(
|
||||||
@@ -2154,6 +2150,9 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
if widget.parent is not None:
|
if widget.parent is not None:
|
||||||
widget.parent._nodes._remove(widget)
|
widget.parent._nodes._remove(widget)
|
||||||
|
|
||||||
|
for node in pruned_remove:
|
||||||
|
node._detach()
|
||||||
|
|
||||||
# Return the list of widgets that should end up being sent off in a
|
# Return the list of widgets that should end up being sent off in a
|
||||||
# prune event.
|
# prune event.
|
||||||
return pruned_remove
|
return pruned_remove
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ class Stylesheet:
|
|||||||
for key in modified_rule_keys:
|
for key in modified_rule_keys:
|
||||||
setattr(base_styles, key, get_rule(key))
|
setattr(base_styles, key, get_rule(key))
|
||||||
|
|
||||||
node.post_message_no_wait(messages.StylesUpdated(sender=node))
|
node.notify_style_update()
|
||||||
|
|
||||||
def update(self, root: DOMNode, animate: bool = False) -> None:
|
def update(self, root: DOMNode, animate: bool = False) -> None:
|
||||||
"""Update styles on node and its children.
|
"""Update styles on node and its children.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
from inspect import getfile
|
from inspect import getfile
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
Iterable,
|
Iterable,
|
||||||
Iterator,
|
|
||||||
Sequence,
|
Sequence,
|
||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
@@ -221,16 +221,21 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
def _post_mount(self):
|
def _post_mount(self):
|
||||||
"""Called after the object has been mounted."""
|
"""Called after the object has been mounted."""
|
||||||
|
_rich_traceback_omit = True
|
||||||
Reactive._initialize_object(self)
|
Reactive._initialize_object(self)
|
||||||
|
|
||||||
|
def notify_style_update(self) -> None:
|
||||||
|
"""Called after styles are updated."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _node_bases(self) -> Iterator[Type[DOMNode]]:
|
def _node_bases(self) -> Sequence[Type[DOMNode]]:
|
||||||
"""The DOMNode bases classes (including self.__class__)"""
|
"""The DOMNode bases classes (including self.__class__)"""
|
||||||
# Node bases are in reversed order so that the base class is lower priority
|
# Node bases are in reversed order so that the base class is lower priority
|
||||||
return self._css_bases(self.__class__)
|
return self._css_bases(self.__class__)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]:
|
@lru_cache(maxsize=None)
|
||||||
|
def _css_bases(cls, base: Type[DOMNode]) -> Sequence[Type[DOMNode]]:
|
||||||
"""Get the DOMNode base classes, which inherit CSS.
|
"""Get the DOMNode base classes, which inherit CSS.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -239,9 +244,10 @@ class DOMNode(MessagePump):
|
|||||||
Returns:
|
Returns:
|
||||||
An iterable of DOMNode classes.
|
An iterable of DOMNode classes.
|
||||||
"""
|
"""
|
||||||
|
classes: list[type[DOMNode]] = []
|
||||||
_class = base
|
_class = base
|
||||||
while True:
|
while True:
|
||||||
yield _class
|
classes.append(_class)
|
||||||
if not _class._inherit_css:
|
if not _class._inherit_css:
|
||||||
break
|
break
|
||||||
for _base in _class.__bases__:
|
for _base in _class.__bases__:
|
||||||
@@ -250,6 +256,7 @@ class DOMNode(MessagePump):
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
return classes
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _merge_bindings(cls) -> Bindings:
|
def _merge_bindings(cls) -> Bindings:
|
||||||
@@ -314,7 +321,9 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
return css_stack
|
return css_stack
|
||||||
|
|
||||||
def _get_component_classes(self) -> set[str]:
|
@classmethod
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _get_component_classes(cls) -> frozenset[str]:
|
||||||
"""Gets the component classes for this class and inherited from bases.
|
"""Gets the component classes for this class and inherited from bases.
|
||||||
|
|
||||||
Component classes are inherited from base classes, unless
|
Component classes are inherited from base classes, unless
|
||||||
@@ -325,12 +334,12 @@ class DOMNode(MessagePump):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
component_classes: set[str] = set()
|
component_classes: set[str] = set()
|
||||||
for base in self._node_bases:
|
for base in cls._css_bases(cls):
|
||||||
component_classes.update(base.__dict__.get("COMPONENT_CLASSES", set()))
|
component_classes.update(base.__dict__.get("COMPONENT_CLASSES", set()))
|
||||||
if not base.__dict__.get("_inherit_component_classes", True):
|
if not base.__dict__.get("_inherit_component_classes", True):
|
||||||
break
|
break
|
||||||
|
|
||||||
return component_classes
|
return frozenset(component_classes)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parent(self) -> DOMNode | None:
|
def parent(self) -> DOMNode | None:
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class Event(Message):
|
|||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Callback(Event, bubble=False, verbose=True):
|
class Callback(Event, bubble=False, verbose=True):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]]
|
self,
|
||||||
|
sender: MessageTarget,
|
||||||
|
callback: Callable[[], Awaitable[None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
super().__init__(sender)
|
super().__init__(sender)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Message:
|
|||||||
"_no_default_action",
|
"_no_default_action",
|
||||||
"_stop_propagation",
|
"_stop_propagation",
|
||||||
"_handler_name",
|
"_handler_name",
|
||||||
|
"_prevent",
|
||||||
]
|
]
|
||||||
|
|
||||||
sender: MessageTarget
|
sender: MessageTarget
|
||||||
@@ -50,6 +51,7 @@ class Message:
|
|||||||
self._handler_name = (
|
self._handler_name = (
|
||||||
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
|
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
|
||||||
)
|
)
|
||||||
|
self._prevent: set[type[Message]] = set()
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
from asyncio import CancelledError, Queue, QueueEmpty, Task
|
from asyncio import CancelledError, Queue, QueueEmpty, Task
|
||||||
|
from contextlib import contextmanager
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Iterable
|
||||||
from weakref import WeakSet
|
from weakref import WeakSet
|
||||||
|
|
||||||
from . import Logger, events, log, messages
|
from . import Logger, events, log, messages
|
||||||
from ._asyncio import create_task
|
from ._asyncio import create_task
|
||||||
from ._callback import invoke
|
from ._callback import invoke
|
||||||
from ._context import NoActiveAppError, active_app, active_message_pump
|
from ._context import (
|
||||||
|
NoActiveAppError,
|
||||||
|
active_app,
|
||||||
|
active_message_pump,
|
||||||
|
prevent_message_types_stack,
|
||||||
|
)
|
||||||
from ._time import time
|
from ._time import time
|
||||||
|
from ._types import CallbackType
|
||||||
from .case import camel_to_snake
|
from .case import camel_to_snake
|
||||||
from .errors import DuplicateKeyHandlers
|
from .errors import DuplicateKeyHandlers
|
||||||
from .events import Event
|
from .events import Event
|
||||||
@@ -78,6 +85,54 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
self._mounted_event = asyncio.Event()
|
self._mounted_event = asyncio.Event()
|
||||||
self._next_callbacks: list[CallbackType] = []
|
self._next_callbacks: list[CallbackType] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _prevent_message_types_stack(self) -> list[set[type[Message]]]:
|
||||||
|
"""The stack that manages prevented messages."""
|
||||||
|
try:
|
||||||
|
stack = prevent_message_types_stack.get()
|
||||||
|
except LookupError:
|
||||||
|
stack = [set()]
|
||||||
|
prevent_message_types_stack.set(stack)
|
||||||
|
return stack
|
||||||
|
|
||||||
|
def _get_prevented_messages(self) -> set[type[Message]]:
|
||||||
|
"""A set of all the prevented message types."""
|
||||||
|
return self._prevent_message_types_stack[-1]
|
||||||
|
|
||||||
|
def _is_prevented(self, message_type: type[Message]) -> bool:
|
||||||
|
"""Check if a message type has been prevented via the
|
||||||
|
[prevent][textual.message_pump.MessagePump.prevent] context manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_type: A message type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`True` if the message has been prevented from sending, or `False` if it will be sent as normal.
|
||||||
|
"""
|
||||||
|
return message_type in self._prevent_message_types_stack[-1]
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def prevent(self, *message_types: type[Message]) -> Generator[None, None, None]:
|
||||||
|
"""A context manager to *temporarily* prevent the given message types from being posted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
input = self.query_one(Input)
|
||||||
|
with self.prevent(Input.Changed):
|
||||||
|
input.value = "foo"
|
||||||
|
```
|
||||||
|
|
||||||
|
"""
|
||||||
|
if message_types:
|
||||||
|
prevent_stack = self._prevent_message_types_stack
|
||||||
|
prevent_stack.append(prevent_stack[-1].union(message_types))
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
prevent_stack.pop()
|
||||||
|
else:
|
||||||
|
yield
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def task(self) -> Task:
|
def task(self) -> Task:
|
||||||
assert self._task is not None
|
assert self._task is not None
|
||||||
@@ -149,6 +204,14 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
self._parent = None
|
self._parent = None
|
||||||
|
|
||||||
def check_message_enabled(self, message: Message) -> bool:
|
def check_message_enabled(self, message: Message) -> bool:
|
||||||
|
"""Check if a given message is enabled (allowed to be sent).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: A message object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`True` if the message will be sent, or `False` if it is disabled.
|
||||||
|
"""
|
||||||
return type(message) not in self._disabled_messages
|
return type(message) not in self._disabled_messages
|
||||||
|
|
||||||
def disable_messages(self, *messages: type[Message]) -> None:
|
def disable_messages(self, *messages: type[Message]) -> None:
|
||||||
@@ -366,12 +429,12 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
try:
|
try:
|
||||||
await self._dispatch_message(events.Compose(sender=self))
|
await self._dispatch_message(events.Compose(sender=self))
|
||||||
await self._dispatch_message(events.Mount(sender=self))
|
await self._dispatch_message(events.Mount(sender=self))
|
||||||
|
self._post_mount()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.app._handle_exception(error)
|
self.app._handle_exception(error)
|
||||||
finally:
|
finally:
|
||||||
# This is critical, mount may be waiting
|
# This is critical, mount may be waiting
|
||||||
self._mounted_event.set()
|
self._mounted_event.set()
|
||||||
self._post_mount()
|
|
||||||
|
|
||||||
def _post_mount(self):
|
def _post_mount(self):
|
||||||
"""Called after the object has been mounted."""
|
"""Called after the object has been mounted."""
|
||||||
@@ -458,12 +521,13 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
if message.no_dispatch:
|
if message.no_dispatch:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Allow apps to treat events and messages separately
|
with self.prevent(*message._prevent):
|
||||||
if isinstance(message, Event):
|
# Allow apps to treat events and messages separately
|
||||||
await self.on_event(message)
|
if isinstance(message, Event):
|
||||||
else:
|
await self.on_event(message)
|
||||||
await self._on_message(message)
|
else:
|
||||||
await self._flush_next_callbacks()
|
await self._on_message(message)
|
||||||
|
await self._flush_next_callbacks()
|
||||||
|
|
||||||
def _get_dispatch_methods(
|
def _get_dispatch_methods(
|
||||||
self, method_name: str, message: Message
|
self, method_name: str, message: Message
|
||||||
@@ -542,6 +606,9 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
return False
|
return False
|
||||||
if not self.check_message_enabled(message):
|
if not self.check_message_enabled(message):
|
||||||
return True
|
return True
|
||||||
|
# Add a copy of the prevented message types to the message
|
||||||
|
# This is so that prevented messages are honoured by the event's handler
|
||||||
|
message._prevent.update(self._get_prevented_messages())
|
||||||
await self._message_queue.put(message)
|
await self._message_queue.put(message)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -580,6 +647,9 @@ class MessagePump(metaclass=MessagePumpMeta):
|
|||||||
return False
|
return False
|
||||||
if not self.check_message_enabled(message):
|
if not self.check_message_enabled(message):
|
||||||
return False
|
return False
|
||||||
|
# Add a copy of the prevented message types to the message
|
||||||
|
# This is so that prevented messages are honoured by the event's handler
|
||||||
|
message._prevent.update(self._get_prevented_messages())
|
||||||
self._message_queue.put_nowait(message)
|
self._message_queue.put_nowait(message)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -80,15 +80,6 @@ class ScrollToRegion(Message, bubble=False):
|
|||||||
super().__init__(sender)
|
super().__init__(sender)
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
|
||||||
class StylesUpdated(Message, verbose=True):
|
|
||||||
def __init__(self, sender: MessagePump) -> None:
|
|
||||||
super().__init__(sender)
|
|
||||||
|
|
||||||
def can_replace(self, message: Message) -> bool:
|
|
||||||
return isinstance(message, StylesUpdated)
|
|
||||||
|
|
||||||
|
|
||||||
class Prompt(Message, no_dispatch=True):
|
class Prompt(Message, no_dispatch=True):
|
||||||
"""Used to 'wake up' an event loop."""
|
"""Used to 'wake up' an event loop."""
|
||||||
|
|
||||||
|
|||||||
@@ -89,10 +89,12 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
obj: An object with reactive attributes.
|
obj: An object with reactive attributes.
|
||||||
name: Name of attribute.
|
name: Name of attribute.
|
||||||
"""
|
"""
|
||||||
|
_rich_traceback_omit = True
|
||||||
internal_name = f"_reactive_{name}"
|
internal_name = f"_reactive_{name}"
|
||||||
if hasattr(obj, internal_name):
|
if hasattr(obj, internal_name):
|
||||||
# Attribute already has a value
|
# Attribute already has a value
|
||||||
return
|
return
|
||||||
|
|
||||||
compute_method = getattr(obj, f"compute_{name}", None)
|
compute_method = getattr(obj, f"compute_{name}", None)
|
||||||
if compute_method is not None and self._init:
|
if compute_method is not None and self._init:
|
||||||
default = getattr(obj, f"compute_{name}")()
|
default = getattr(obj, f"compute_{name}")()
|
||||||
@@ -114,7 +116,7 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
Args:
|
Args:
|
||||||
obj: An object with Reactive descriptors
|
obj: An object with Reactive descriptors
|
||||||
"""
|
"""
|
||||||
|
_rich_traceback_omit = True
|
||||||
for name, reactive in obj._reactives.items():
|
for name, reactive in obj._reactives.items():
|
||||||
reactive._initialize_reactive(obj, name)
|
reactive._initialize_reactive(obj, name)
|
||||||
|
|
||||||
@@ -253,8 +255,9 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
for reactable, callback in watchers
|
for reactable, callback in watchers
|
||||||
if reactable.is_attached and not reactable._closing
|
if reactable.is_attached and not reactable._closing
|
||||||
]
|
]
|
||||||
for _, callback in watchers:
|
for reactable, callback in watchers:
|
||||||
invoke_watcher(callback, old_value, value)
|
with reactable.prevent(*obj._prevent_message_types_stack[-1]):
|
||||||
|
invoke_watcher(callback, old_value, value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _compute(cls, obj: Reactable) -> None:
|
def _compute(cls, obj: Reactable) -> None:
|
||||||
@@ -342,7 +345,6 @@ def _watch(
|
|||||||
callback: A callable to call when the attribute changes.
|
callback: A callable to call when the attribute changes.
|
||||||
init: True to call watcher initialization. Defaults to True.
|
init: True to call watcher initialization. Defaults to True.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not hasattr(obj, "__watchers"):
|
if not hasattr(obj, "__watchers"):
|
||||||
setattr(obj, "__watchers", {})
|
setattr(obj, "__watchers", {})
|
||||||
watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers")
|
watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers")
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from rich.segment import Segment
|
|||||||
from rich.style import Style, StyleType
|
from rich.style import Style, StyleType
|
||||||
|
|
||||||
from ._cache import FIFOCache
|
from ._cache import FIFOCache
|
||||||
from .filter import LineFilter
|
|
||||||
from ._segment_tools import index_to_cell_position
|
from ._segment_tools import index_to_cell_position
|
||||||
|
from .filter import LineFilter
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
@@ -29,6 +29,7 @@ class Strip:
|
|||||||
"_cell_length",
|
"_cell_length",
|
||||||
"_divide_cache",
|
"_divide_cache",
|
||||||
"_crop_cache",
|
"_crop_cache",
|
||||||
|
"_link_ids",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -38,6 +39,7 @@ class Strip:
|
|||||||
self._cell_length = cell_length
|
self._cell_length = cell_length
|
||||||
self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4)
|
self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4)
|
||||||
self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4)
|
self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4)
|
||||||
|
self._link_ids: set[str] | None = None
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield self._segments
|
yield self._segments
|
||||||
@@ -48,6 +50,15 @@ class Strip:
|
|||||||
"""Segment text."""
|
"""Segment text."""
|
||||||
return "".join(segment.text for segment in self._segments)
|
return "".join(segment.text for segment in self._segments)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link_ids(self) -> set[str]:
|
||||||
|
"""A set of the link ids in this Strip."""
|
||||||
|
if self._link_ids is None:
|
||||||
|
self._link_ids = {
|
||||||
|
style._link_id for _, style, _ in self._segments if style is not None
|
||||||
|
}
|
||||||
|
return self._link_ids
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def blank(cls, cell_length: int, style: StyleType | None = None) -> Strip:
|
def blank(cls, cell_length: int, style: StyleType | None = None) -> Strip:
|
||||||
"""Create a blank strip.
|
"""Create a blank strip.
|
||||||
@@ -230,19 +241,18 @@ class Strip:
|
|||||||
Returns:
|
Returns:
|
||||||
New strip (or same Strip if no changes).
|
New strip (or same Strip if no changes).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_Segment = Segment
|
_Segment = Segment
|
||||||
if not any(
|
if link_id not in self.link_ids:
|
||||||
segment.style._link_id == link_id
|
|
||||||
for segment in self._segments
|
|
||||||
if segment.style
|
|
||||||
):
|
|
||||||
return self
|
return self
|
||||||
segments = [
|
segments = [
|
||||||
_Segment(
|
_Segment(
|
||||||
text,
|
text,
|
||||||
(style + link_style if style is not None else None)
|
(
|
||||||
if (style and not style._null and style._link_id == link_id)
|
(style + link_style if style is not None else None)
|
||||||
else style,
|
if (style and not style._null and style._link_id == link_id)
|
||||||
|
else style
|
||||||
|
),
|
||||||
control,
|
control,
|
||||||
)
|
)
|
||||||
for text, style, control in self._segments
|
for text, style, control in self._segments
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class Timer:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
event_target: The object which will receive the timer events.
|
event_target: The object which will receive the timer events.
|
||||||
interval: The time between timer events.
|
interval: The time between timer events, in seconds.
|
||||||
sender: The sender of the event.
|
sender: The sender of the event.
|
||||||
name: A name to assign the event (for debugging). Defaults to None.
|
name: A name to assign the event (for debugging). Defaults to None.
|
||||||
callback: A optional callback to invoke when the event is handled. Defaults to None.
|
callback: A optional callback to invoke when the event is handled. Defaults to None.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def walk_depth_first(
|
|||||||
"""
|
"""
|
||||||
from textual.dom import DOMNode
|
from textual.dom import DOMNode
|
||||||
|
|
||||||
stack: list[Iterator[DOMNode]] = [iter(root._nodes)]
|
stack: list[Iterator[DOMNode]] = [iter(root.children)]
|
||||||
pop = stack.pop
|
pop = stack.pop
|
||||||
push = stack.append
|
push = stack.append
|
||||||
check_type = filter_type or DOMNode
|
check_type = filter_type or DOMNode
|
||||||
|
|||||||
@@ -2489,9 +2489,20 @@ class Widget(DOMNode):
|
|||||||
self.app.capture_mouse(None)
|
self.app.capture_mouse(None)
|
||||||
|
|
||||||
def check_message_enabled(self, message: Message) -> bool:
|
def check_message_enabled(self, message: Message) -> bool:
|
||||||
|
"""Check if a given message is enabled (allowed to be sent).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: A message object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`True` if the message will be sent, or `False` if it is disabled.
|
||||||
|
"""
|
||||||
# Do the normal checking and get out if that fails.
|
# Do the normal checking and get out if that fails.
|
||||||
if not super().check_message_enabled(message):
|
if not super().check_message_enabled(message):
|
||||||
return False
|
return False
|
||||||
|
message_type = type(message)
|
||||||
|
if self._is_prevented(message_type):
|
||||||
|
return False
|
||||||
# Otherwise, if this is a mouse event, the widget receiving the
|
# Otherwise, if this is a mouse event, the widget receiving the
|
||||||
# event must not be disabled at this moment.
|
# event must not be disabled at this moment.
|
||||||
return (
|
return (
|
||||||
@@ -2503,7 +2514,7 @@ class Widget(DOMNode):
|
|||||||
async def broker_event(self, event_name: str, event: events.Event) -> bool:
|
async def broker_event(self, event_name: str, event: events.Event) -> bool:
|
||||||
return await self.app._broker_event(event_name, event, default_namespace=self)
|
return await self.app._broker_event(event_name, event, default_namespace=self)
|
||||||
|
|
||||||
def _on_styles_updated(self) -> None:
|
def notify_style_update(self) -> None:
|
||||||
self._rich_style_cache.clear()
|
self._rich_style_cache.clear()
|
||||||
|
|
||||||
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
||||||
|
|||||||
@@ -130,9 +130,8 @@ class Footer(Widget):
|
|||||||
text.append_text(key_text)
|
text.append_text(key_text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _on_styles_updated(self) -> None:
|
def notify_style_update(self) -> None:
|
||||||
self._key_text = None
|
self._key_text = None
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def post_render(self, renderable):
|
def post_render(self, renderable):
|
||||||
return renderable
|
return renderable
|
||||||
|
|||||||
@@ -707,8 +707,7 @@ class Markdown(Widget):
|
|||||||
)
|
)
|
||||||
with self.app.batch_update():
|
with self.app.batch_update():
|
||||||
await self.query("MarkdownBlock").remove()
|
await self.query("MarkdownBlock").remove()
|
||||||
await self.mount(*output)
|
await self.mount_all(output)
|
||||||
self.refresh(layout=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownTableOfContents(Widget, can_focus_children=True):
|
class MarkdownTableOfContents(Widget, can_focus_children=True):
|
||||||
|
|||||||
@@ -45,20 +45,38 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
classes: str | None = None,
|
classes: str | None = None,
|
||||||
disabled: bool = False,
|
disabled: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Create a TextLog widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_lines: Maximum number of lines in the log or `None` for no maximum.
|
||||||
|
min_width: Minimum width of renderables.
|
||||||
|
wrap: Enable word wrapping (default is off).
|
||||||
|
highlight: Automatically highlight content.
|
||||||
|
markup: Apply Rich console markup.
|
||||||
|
name: The name of the button.
|
||||||
|
id: The ID of the button in the DOM.
|
||||||
|
classes: The CSS classes of the button.
|
||||||
|
disabled: Whether the button is disabled or not.
|
||||||
|
"""
|
||||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||||
self.max_lines = max_lines
|
self.max_lines = max_lines
|
||||||
|
"""Maximum number of lines in the log or `None` for no maximum."""
|
||||||
self._start_line: int = 0
|
self._start_line: int = 0
|
||||||
self.lines: list[Strip] = []
|
self.lines: list[Strip] = []
|
||||||
self._line_cache: LRUCache[tuple[int, int, int, int], Strip]
|
self._line_cache: LRUCache[tuple[int, int, int, int], Strip]
|
||||||
self._line_cache = LRUCache(1024)
|
self._line_cache = LRUCache(1024)
|
||||||
self.max_width: int = 0
|
self.max_width: int = 0
|
||||||
self.min_width = min_width
|
self.min_width = min_width
|
||||||
|
"""Minimum width of renderables."""
|
||||||
self.wrap = wrap
|
self.wrap = wrap
|
||||||
|
"""Enable word wrapping."""
|
||||||
self.highlight = highlight
|
self.highlight = highlight
|
||||||
|
"""Automatically highlight content."""
|
||||||
self.markup = markup
|
self.markup = markup
|
||||||
|
"""Apply Rich console markup."""
|
||||||
self.highlighter = ReprHighlighter()
|
self.highlighter = ReprHighlighter()
|
||||||
|
|
||||||
def _on_styles_updated(self) -> None:
|
def notify_style_update(self) -> None:
|
||||||
self._line_cache.clear()
|
self._line_cache.clear()
|
||||||
|
|
||||||
def write(
|
def write(
|
||||||
|
|||||||
@@ -993,7 +993,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
|||||||
self.cursor_line = cursor_line
|
self.cursor_line = cursor_line
|
||||||
await self.action("select_cursor")
|
await self.action("select_cursor")
|
||||||
|
|
||||||
def _on_styles_updated(self) -> None:
|
def notify_style_update(self) -> None:
|
||||||
self._invalidate()
|
self._invalidate()
|
||||||
|
|
||||||
def action_cursor_up(self) -> None:
|
def action_cursor_up(self) -> None:
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ def test_component_classes_inheritance():
|
|||||||
f = F()
|
f = F()
|
||||||
f_cc = f._get_component_classes()
|
f_cc = f._get_component_classes()
|
||||||
|
|
||||||
assert node_cc == set()
|
assert node_cc == frozenset()
|
||||||
assert a_cc == {"a-1", "a-2"}
|
assert a_cc == {"a-1", "a-2"}
|
||||||
assert b_cc == {"b-1"}
|
assert b_cc == {"b-1"}
|
||||||
assert c_cc == {"b-1", "c-1", "c-2"}
|
assert c_cc == {"b-1", "c-1", "c-2"}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
from textual.errors import DuplicateKeyHandlers
|
from textual.errors import DuplicateKeyHandlers
|
||||||
from textual.events import Key
|
from textual.events import Key
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
from textual.widgets import Input
|
||||||
|
|
||||||
|
|
||||||
class ValidWidget(Widget):
|
class ValidWidget(Widget):
|
||||||
@@ -54,3 +56,34 @@ async def test_dispatch_key_raises_when_conflicting_handler_aliases():
|
|||||||
with pytest.raises(DuplicateKeyHandlers):
|
with pytest.raises(DuplicateKeyHandlers):
|
||||||
await widget.dispatch_key(Key(widget, key="tab", character="\t"))
|
await widget.dispatch_key(Key(widget, key="tab", character="\t"))
|
||||||
assert widget.called_by == widget.key_tab
|
assert widget.called_by == widget.key_tab
|
||||||
|
|
||||||
|
|
||||||
|
class PreventTestApp(App):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.input_changed_events = []
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Input()
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
self.input_changed_events.append(event)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_prevent() -> None:
|
||||||
|
app = PreventTestApp()
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
assert not app.input_changed_events
|
||||||
|
input = app.query_one(Input)
|
||||||
|
input.value = "foo"
|
||||||
|
await pilot.pause()
|
||||||
|
assert len(app.input_changed_events) == 1
|
||||||
|
assert app.input_changed_events[0].value == "foo"
|
||||||
|
|
||||||
|
with input.prevent(Input.Changed):
|
||||||
|
input.value = "bar"
|
||||||
|
|
||||||
|
await pilot.pause()
|
||||||
|
assert len(app.input_changed_events) == 1
|
||||||
|
assert app.input_changed_events[0].value == "foo"
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ skip_py310 = pytest.mark.skipif(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_screen_walk_children():
|
||||||
|
"""Test query only reports active screen."""
|
||||||
|
|
||||||
|
class ScreensApp(App):
|
||||||
|
pass
|
||||||
|
|
||||||
|
app = ScreensApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
screen1 = Screen()
|
||||||
|
screen2 = Screen()
|
||||||
|
pilot.app.push_screen(screen1)
|
||||||
|
assert list(pilot.app.query("*")) == [screen1]
|
||||||
|
pilot.app.push_screen(screen2)
|
||||||
|
assert list(pilot.app.query("*")) == [screen2]
|
||||||
|
|
||||||
|
|
||||||
async def test_installed_screens():
|
async def test_installed_screens():
|
||||||
class ScreensApp(App):
|
class ScreensApp(App):
|
||||||
SCREENS = {
|
SCREENS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user