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/)
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 `App.batch_update` 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 `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
- Added `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message https://github.com/Textualize/textual/pull/1861
- Added `MessagePump.prevent` context manager to temporarily suppress a given message type https://github.com/Textualize/textual/pull/1866
### Changed
@@ -26,11 +42,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Removed
- Removed `screen.visible_widgets` and `screen.widgets`
- Removed `StylesUpdate` message. https://github.com/Textualize/textual/pull/1861
### Fixed
- 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
- 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
@@ -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
- 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.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

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.app import ComposeResult, App
from textual.widgets import Static, Header
from textual.widgets import Header, Static
class CombiningLayoutsExample(App):
@@ -8,28 +8,21 @@ class CombiningLayoutsExample(App):
def compose(self) -> ComposeResult:
yield Header()
yield Container(
Vertical(
*[Static(f"Vertical layout, child {number}") for number in range(15)],
id="left-pane",
),
Horizontal(
Static("Horizontally"),
Static("Positioned"),
Static("Children"),
Static("Here"),
id="top-right",
),
Container(
Static("This"),
Static("panel"),
Static("is"),
Static("using"),
Static("grid layout!", id="bottom-right-final"),
id="bottom-right",
),
id="app-grid",
)
with Container(id="app-grid"):
with Vertical(id="left-pane"):
for number in range(15):
yield Static(f"Vertical layout, child {number}")
with Horizontal(id="top-right"):
yield Static("Horizontally")
yield Static("Positioned")
yield Static("Children")
yield Static("Here")
with Container(id="bottom-right"):
yield Static("This")
yield Static("panel")
yield Static("is")
yield Static("using")
yield Static("grid layout!", id="bottom-right-final")
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.
## 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.
@@ -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`.
## 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
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.
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

View File

@@ -29,8 +29,8 @@ The example below shows switches in various states.
## Reactive Attributes
| Name | Type | Default | Description |
|---------|--------|---------|----------------------------------|
| `value` | `bool` | `False` | The default value of the switch. |
|---------|--------|---------|--------------------------|
| `value` | `bool` | `False` | The value of the switch. |
## Bindings

View File

@@ -9,7 +9,7 @@ Call [TextLog.write][textual.widgets.TextLog.write] with a string or [Rich Rende
## Example
The example below shows each placeholder variant.
The example below shows an application showing a `TextLog` with different kinds of data logged.
=== "Output"

View File

@@ -1,7 +1,7 @@
from decimal import Decimal
from textual.app import App, ComposeResult
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.css.query import NoMatches
from textual.reactive import var
@@ -48,30 +48,28 @@ class CalculatorApp(App):
def compose(self) -> ComposeResult:
"""Add our buttons."""
yield Container(
Static(id="numbers"),
Button("AC", id="ac", variant="primary"),
Button("C", id="c", variant="primary"),
Button("+/-", id="plus-minus", variant="primary"),
Button("%", id="percent", variant="primary"),
Button("÷", id="divide", variant="warning"),
Button("7", id="number-7"),
Button("8", id="number-8"),
Button("9", id="number-9"),
Button("×", id="multiply", variant="warning"),
Button("4", id="number-4"),
Button("5", id="number-5"),
Button("6", id="number-6"),
Button("-", id="minus", variant="warning"),
Button("1", id="number-1"),
Button("2", id="number-2"),
Button("3", id="number-3"),
Button("+", id="plus", variant="warning"),
Button("0", id="number-0"),
Button(".", id="point"),
Button("=", id="equals", variant="warning"),
id="calculator",
)
with Container(id="calculator"):
yield Static(id="numbers")
yield Button("AC", id="ac", variant="primary")
yield Button("C", id="c", variant="primary")
yield Button("+/-", id="plus-minus", variant="primary")
yield Button("%", id="percent", variant="primary")
yield Button("÷", id="divide", variant="warning")
yield Button("7", id="number-7")
yield Button("8", id="number-8")
yield Button("9", id="number-9")
yield Button("×", id="multiply", variant="warning")
yield Button("4", id="number-4")
yield Button("5", id="number-5")
yield Button("6", id="number-6")
yield Button("-", id="minus", variant="warning")
yield Button("1", id="number-1")
yield Button("2", id="number-2")
yield Button("3", id="number-3")
yield Button("+", id="plus", variant="warning")
yield Button("0", id="number-0")
yield Button(".", id="point")
yield Button("=", id="equals", variant="warning")
def on_key(self, event: events.Key) -> None:
"""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`.
## Horizontal rule
Draw a horizontal rule with three dashes (`---`).
---
Good for natural breaks in the content, that don't require another header.
## Lists
1. Lists can be ordered

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.11.1"
version = "0.12.1"
homepage = "https://github.com/Textualize/textual"
description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"]

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from contextvars import ContextVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .app import App
from .message import Message
from .message_pump import MessagePump
@@ -12,3 +15,6 @@ class NoActiveAppError(RuntimeError):
active_app: ContextVar["App"] = ContextVar("active_app")
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 ._border import get_box, render_row
from .filter import LineFilter
from ._opacity import _apply_opacity
from ._segment_tools import line_pad, line_trim
from .color import Color
from .filter import LineFilter
from .geometry import Region, Size, Spacing
from .renderables.text_opacity import TextOpacity
from .renderables.tint import Tint
@@ -120,13 +120,12 @@ class StylesCache:
)
if widget.auto_links:
hover_style = widget.hover_style
link_hover_style = widget.link_hover_style
if (
link_hover_style
and hover_style._link_id
hover_style._link_id
and hover_style._meta
and "@click" in hover_style.meta
):
link_hover_style = widget.link_hover_style
if link_hover_style:
strips = [
strip.style_links(hover_style.link_id, link_hover_style)

View File

@@ -445,7 +445,7 @@ class App(Generic[ReturnType], DOMNode):
@contextmanager
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()
try:
yield
@@ -461,10 +461,6 @@ class App(Generic[ReturnType], DOMNode):
self._batch_count -= 1
assert self._batch_count >= 0, "This won't happen if you use `batch_update`"
if not self._batch_count:
try:
self.screen.check_idle()
except ScreenStackError:
pass
self.check_idle()
def animate(
@@ -2154,6 +2150,9 @@ class App(Generic[ReturnType], DOMNode):
if widget.parent is not None:
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
# prune event.
return pruned_remove

View File

@@ -500,7 +500,7 @@ class Stylesheet:
for key in modified_rule_keys:
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:
"""Update styles on node and its children.

View File

@@ -1,12 +1,12 @@
from __future__ import annotations
import re
from functools import lru_cache
from inspect import getfile
from typing import (
TYPE_CHECKING,
ClassVar,
Iterable,
Iterator,
Sequence,
Type,
TypeVar,
@@ -221,16 +221,21 @@ class DOMNode(MessagePump):
def _post_mount(self):
"""Called after the object has been mounted."""
_rich_traceback_omit = True
Reactive._initialize_object(self)
def notify_style_update(self) -> None:
"""Called after styles are updated."""
@property
def _node_bases(self) -> Iterator[Type[DOMNode]]:
def _node_bases(self) -> Sequence[Type[DOMNode]]:
"""The DOMNode bases classes (including self.__class__)"""
# Node bases are in reversed order so that the base class is lower priority
return self._css_bases(self.__class__)
@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.
Args:
@@ -239,9 +244,10 @@ class DOMNode(MessagePump):
Returns:
An iterable of DOMNode classes.
"""
classes: list[type[DOMNode]] = []
_class = base
while True:
yield _class
classes.append(_class)
if not _class._inherit_css:
break
for _base in _class.__bases__:
@@ -250,6 +256,7 @@ class DOMNode(MessagePump):
break
else:
break
return classes
@classmethod
def _merge_bindings(cls) -> Bindings:
@@ -314,7 +321,9 @@ class DOMNode(MessagePump):
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.
Component classes are inherited from base classes, unless
@@ -325,12 +334,12 @@ class DOMNode(MessagePump):
"""
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()))
if not base.__dict__.get("_inherit_component_classes", True):
break
return component_classes
return frozenset(component_classes)
@property
def parent(self) -> DOMNode | None:

View File

@@ -29,7 +29,9 @@ class Event(Message):
@rich.repr.auto
class Callback(Event, bubble=False, verbose=True):
def __init__(
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]]
self,
sender: MessageTarget,
callback: Callable[[], Awaitable[None]],
) -> None:
self.callback = callback
super().__init__(sender)

View File

@@ -31,6 +31,7 @@ class Message:
"_no_default_action",
"_stop_propagation",
"_handler_name",
"_prevent",
]
sender: MessageTarget
@@ -50,6 +51,7 @@ class Message:
self._handler_name = (
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
)
self._prevent: set[type[Message]] = set()
super().__init__()
def __rich_repr__(self) -> rich.repr.Result:

View File

@@ -10,15 +10,22 @@ from __future__ import annotations
import asyncio
import inspect
from asyncio import CancelledError, Queue, QueueEmpty, Task
from contextlib import contextmanager
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 . import Logger, events, log, messages
from ._asyncio import create_task
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 ._types import CallbackType
from .case import camel_to_snake
from .errors import DuplicateKeyHandlers
from .events import Event
@@ -78,6 +85,54 @@ class MessagePump(metaclass=MessagePumpMeta):
self._mounted_event = asyncio.Event()
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
def task(self) -> Task:
assert self._task is not None
@@ -149,6 +204,14 @@ class MessagePump(metaclass=MessagePumpMeta):
self._parent = None
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
def disable_messages(self, *messages: type[Message]) -> None:
@@ -366,12 +429,12 @@ class MessagePump(metaclass=MessagePumpMeta):
try:
await self._dispatch_message(events.Compose(sender=self))
await self._dispatch_message(events.Mount(sender=self))
self._post_mount()
except Exception as error:
self.app._handle_exception(error)
finally:
# This is critical, mount may be waiting
self._mounted_event.set()
self._post_mount()
def _post_mount(self):
"""Called after the object has been mounted."""
@@ -458,6 +521,7 @@ class MessagePump(metaclass=MessagePumpMeta):
if message.no_dispatch:
return
with self.prevent(*message._prevent):
# Allow apps to treat events and messages separately
if isinstance(message, Event):
await self.on_event(message)
@@ -542,6 +606,9 @@ class MessagePump(metaclass=MessagePumpMeta):
return False
if not self.check_message_enabled(message):
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)
return True
@@ -580,6 +647,9 @@ class MessagePump(metaclass=MessagePumpMeta):
return False
if not self.check_message_enabled(message):
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)
return True

View File

@@ -80,15 +80,6 @@ class ScrollToRegion(Message, bubble=False):
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):
"""Used to 'wake up' an event loop."""

View File

@@ -89,10 +89,12 @@ class Reactive(Generic[ReactiveType]):
obj: An object with reactive attributes.
name: Name of attribute.
"""
_rich_traceback_omit = True
internal_name = f"_reactive_{name}"
if hasattr(obj, internal_name):
# Attribute already has a value
return
compute_method = getattr(obj, f"compute_{name}", None)
if compute_method is not None and self._init:
default = getattr(obj, f"compute_{name}")()
@@ -114,7 +116,7 @@ class Reactive(Generic[ReactiveType]):
Args:
obj: An object with Reactive descriptors
"""
_rich_traceback_omit = True
for name, reactive in obj._reactives.items():
reactive._initialize_reactive(obj, name)
@@ -253,7 +255,8 @@ class Reactive(Generic[ReactiveType]):
for reactable, callback in watchers
if reactable.is_attached and not reactable._closing
]
for _, callback in watchers:
for reactable, callback in watchers:
with reactable.prevent(*obj._prevent_message_types_stack[-1]):
invoke_watcher(callback, old_value, value)
@classmethod
@@ -342,7 +345,6 @@ def _watch(
callback: A callable to call when the attribute changes.
init: True to call watcher initialization. Defaults to True.
"""
if not hasattr(obj, "__watchers"):
setattr(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 ._cache import FIFOCache
from .filter import LineFilter
from ._segment_tools import index_to_cell_position
from .filter import LineFilter
@rich.repr.auto
@@ -29,6 +29,7 @@ class Strip:
"_cell_length",
"_divide_cache",
"_crop_cache",
"_link_ids",
]
def __init__(
@@ -38,6 +39,7 @@ class Strip:
self._cell_length = cell_length
self._divide_cache: FIFOCache[tuple[int, ...], list[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:
yield self._segments
@@ -48,6 +50,15 @@ class Strip:
"""Segment text."""
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
def blank(cls, cell_length: int, style: StyleType | None = None) -> Strip:
"""Create a blank strip.
@@ -230,19 +241,18 @@ class Strip:
Returns:
New strip (or same Strip if no changes).
"""
_Segment = Segment
if not any(
segment.style._link_id == link_id
for segment in self._segments
if segment.style
):
if link_id not in self.link_ids:
return self
segments = [
_Segment(
text,
(
(style + link_style if style is not None else None)
if (style and not style._null and style._link_id == link_id)
else style,
else style
),
control,
)
for text, style, control in self._segments

View File

@@ -33,7 +33,7 @@ class Timer:
Args:
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.
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.

View File

@@ -53,7 +53,7 @@ def walk_depth_first(
"""
from textual.dom import DOMNode
stack: list[Iterator[DOMNode]] = [iter(root._nodes)]
stack: list[Iterator[DOMNode]] = [iter(root.children)]
pop = stack.pop
push = stack.append
check_type = filter_type or DOMNode

View File

@@ -2489,9 +2489,20 @@ class Widget(DOMNode):
self.app.capture_mouse(None)
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.
if not super().check_message_enabled(message):
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
# event must not be disabled at this moment.
return (
@@ -2503,7 +2514,7 @@ class Widget(DOMNode):
async def broker_event(self, event_name: str, event: events.Event) -> bool:
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()
async def _on_mouse_down(self, event: events.MouseDown) -> None:

View File

@@ -130,9 +130,8 @@ class Footer(Widget):
text.append_text(key_text)
return text
def _on_styles_updated(self) -> None:
def notify_style_update(self) -> None:
self._key_text = None
self.refresh()
def post_render(self, renderable):
return renderable

View File

@@ -707,8 +707,7 @@ class Markdown(Widget):
)
with self.app.batch_update():
await self.query("MarkdownBlock").remove()
await self.mount(*output)
self.refresh(layout=True)
await self.mount_all(output)
class MarkdownTableOfContents(Widget, can_focus_children=True):

View File

@@ -45,20 +45,38 @@ class TextLog(ScrollView, can_focus=True):
classes: str | None = None,
disabled: bool = False,
) -> 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)
self.max_lines = max_lines
"""Maximum number of lines in the log or `None` for no maximum."""
self._start_line: int = 0
self.lines: list[Strip] = []
self._line_cache: LRUCache[tuple[int, int, int, int], Strip]
self._line_cache = LRUCache(1024)
self.max_width: int = 0
self.min_width = min_width
"""Minimum width of renderables."""
self.wrap = wrap
"""Enable word wrapping."""
self.highlight = highlight
"""Automatically highlight content."""
self.markup = markup
"""Apply Rich console markup."""
self.highlighter = ReprHighlighter()
def _on_styles_updated(self) -> None:
def notify_style_update(self) -> None:
self._line_cache.clear()
def write(

View File

@@ -993,7 +993,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.cursor_line = cursor_line
await self.action("select_cursor")
def _on_styles_updated(self) -> None:
def notify_style_update(self) -> None:
self._invalidate()
def action_cursor_up(self) -> None:

View File

@@ -157,7 +157,7 @@ def test_component_classes_inheritance():
f = F()
f_cc = f._get_component_classes()
assert node_cc == set()
assert node_cc == frozenset()
assert a_cc == {"a-1", "a-2"}
assert b_cc == {"b-1"}
assert c_cc == {"b-1", "c-1", "c-2"}

View File

@@ -1,8 +1,10 @@
import pytest
from textual.app import App, ComposeResult
from textual.errors import DuplicateKeyHandlers
from textual.events import Key
from textual.widget import Widget
from textual.widgets import Input
class ValidWidget(Widget):
@@ -54,3 +56,34 @@ async def test_dispatch_key_raises_when_conflicting_handler_aliases():
with pytest.raises(DuplicateKeyHandlers):
await widget.dispatch_key(Key(widget, key="tab", character="\t"))
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():
class ScreensApp(App):
SCREENS = {