Merge branch 'main' into toggle-boxen

This commit is contained in:
Dave Pearson
2023-02-27 08:53:30 +00:00
34 changed files with 762 additions and 119 deletions

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 72 KiB

View 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.

View 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()

View File

@@ -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__":

View 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()

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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