From 81e81f4f984bc552163b18cb99a2cdb82acb31ef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 15:39:00 +0000 Subject: [PATCH 01/59] Cache internals --- src/textual/dom.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index e68939b39..100aed965 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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, @@ -224,13 +224,14 @@ class DOMNode(MessagePump): Reactive._initialize_object(self) @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 +240,10 @@ class DOMNode(MessagePump): Returns: An iterable of DOMNode classes. """ + classes = [] _class = base while True: - yield _class + classes.append(_class) if not _class._inherit_css: break for _base in _class.__bases__: @@ -250,6 +252,7 @@ class DOMNode(MessagePump): break else: break + return classes @classmethod def _merge_bindings(cls) -> Bindings: @@ -314,7 +317,9 @@ class DOMNode(MessagePump): return css_stack - def _get_component_classes(self) -> set[str]: + @classmethod + @lru_cache(maxsize=None) + def _get_component_classes(cls) -> Sequence[str]: """Gets the component classes for this class and inherited from bases. Component classes are inherited from base classes, unless @@ -325,12 +330,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 sorted(component_classes) @property def parent(self) -> DOMNode | None: From 32eb6ee07532387e283c875c66c82519ec7bff17 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 15:47:28 +0000 Subject: [PATCH 02/59] test fix --- src/textual/dom.py | 4 ++-- tests/test_dom.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 100aed965..2adebdafe 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -319,7 +319,7 @@ class DOMNode(MessagePump): @classmethod @lru_cache(maxsize=None) - def _get_component_classes(cls) -> Sequence[str]: + 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 @@ -335,7 +335,7 @@ class DOMNode(MessagePump): if not base.__dict__.get("_inherit_component_classes", True): break - return sorted(component_classes) + return frozenset(component_classes) @property def parent(self) -> DOMNode | None: diff --git a/tests/test_dom.py b/tests/test_dom.py index f366d0706..3553a4443 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -157,13 +157,13 @@ def test_component_classes_inheritance(): f = F() f_cc = f._get_component_classes() - assert node_cc == set() - assert a_cc == {"a-1", "a-2"} - assert b_cc == {"b-1"} - assert c_cc == {"b-1", "c-1", "c-2"} + assert node_cc == [] + assert a_cc == ["a-1", "a-2"] + assert b_cc == ["b-1"] + assert c_cc == ["b-1", "c-1", "c-2"] assert d_cc == c_cc - assert e_cc == {"b-1", "c-1", "c-2", "e-1"} - assert f_cc == {"f-1"} + assert e_cc == ["b-1", "c-1", "c-2", "e-1"] + assert f_cc == ["f-1"] @pytest.fixture From 07d728382d9f0efab067920a461026aa56a1cc2e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 15:59:34 +0000 Subject: [PATCH 03/59] fix test --- tests/test_dom.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_dom.py b/tests/test_dom.py index 3553a4443..b78a958c9 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -157,13 +157,13 @@ def test_component_classes_inheritance(): f = F() f_cc = f._get_component_classes() - assert node_cc == [] - assert a_cc == ["a-1", "a-2"] - assert b_cc == ["b-1"] - assert c_cc == ["b-1", "c-1", "c-2"] + 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"} assert d_cc == c_cc - assert e_cc == ["b-1", "c-1", "c-2", "e-1"] - assert f_cc == ["f-1"] + assert e_cc == {"b-1", "c-1", "c-2", "e-1"} + assert f_cc == {"f-1"} @pytest.fixture From 497fff91356c8fd02d5e802aa90c602e6429bad1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 16:00:41 +0000 Subject: [PATCH 04/59] typing --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 2adebdafe..56b9e8770 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -240,7 +240,7 @@ class DOMNode(MessagePump): Returns: An iterable of DOMNode classes. """ - classes = [] + classes: list[type[DOMNode]] = [] _class = base while True: classes.append(_class) From 019a0dde5b862a9e25a11324f0d8afe8b1c20715 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 16:27:05 +0000 Subject: [PATCH 05/59] Fix text log refreshing --- CHANGELOG.md | 2 ++ src/textual/css/stylesheet.py | 2 +- src/textual/dom.py | 3 +++ src/textual/messages.py | 9 --------- src/textual/widget.py | 2 +- src/textual/widgets/_footer.py | 2 +- src/textual/widgets/_text_log.py | 3 ++- src/textual/widgets/_tree.py | 2 +- 8 files changed, 11 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3de1f8f..32c12ceca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message ### Changed @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - Removed `screen.visible_widgets` and `screen.widgets` +- Removed `StylesUpdate` message. ### Fixed diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index e92a87579..1ebc1121e 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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. diff --git a/src/textual/dom.py b/src/textual/dom.py index 56b9e8770..9980c9471 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -223,6 +223,9 @@ class DOMNode(MessagePump): """Called after the object has been mounted.""" Reactive._initialize_object(self) + def notify_style_update(self) -> None: + """Called after styles are updated.""" + @property def _node_bases(self) -> Sequence[Type[DOMNode]]: """The DOMNode bases classes (including self.__class__)""" diff --git a/src/textual/messages.py b/src/textual/messages.py index 882fe887a..778751d26 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -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.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 736223119..22faeff55 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2503,7 +2503,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: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 00ccf9e1f..286eeaf5f 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -130,7 +130,7 @@ 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() diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index c7efee07a..d429311c5 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -58,8 +58,9 @@ class TextLog(ScrollView, can_focus=True): self.markup = markup self.highlighter = ReprHighlighter() - def _on_styles_updated(self) -> None: + def notify_style_update(self) -> None: self._line_cache.clear() + self.refresh() def write( self, diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index bc7fc1833..c3dcb28a3 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -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: From a18685c9c819897b3a41299c188ecf03894d4580 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 22 Feb 2023 16:28:23 +0000 Subject: [PATCH 06/59] Fix a copy/paste-o in the `TextLog` reference entry --- docs/widgets/text_log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/text_log.md b/docs/widgets/text_log.md index ccc7ea78c..bb491a28a 100644 --- a/docs/widgets/text_log.md +++ b/docs/widgets/text_log.md @@ -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" From bf50e6a4240093c3ddd4d9d49c061070a7306663 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 16:29:15 +0000 Subject: [PATCH 07/59] changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c12ceca..042796b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message +- Added `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message https://github.com/Textualize/textual/pull/1861 ### Changed @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - Removed `screen.visible_widgets` and `screen.widgets` -- Removed `StylesUpdate` message. +- Removed `StylesUpdate` message. https://github.com/Textualize/textual/pull/1861 ### Fixed From 13b99edbb9c32ef9aaf0ccd25b524538717ea3ef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 16:31:31 +0000 Subject: [PATCH 08/59] Simplify --- src/textual/widgets/_text_log.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index d429311c5..0170eaba1 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -60,7 +60,6 @@ class TextLog(ScrollView, can_focus=True): def notify_style_update(self) -> None: self._line_cache.clear() - self.refresh() def write( self, From 7a0506a3a2aced2fb1ab9d1534498e6f8cfe52a4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 22 Feb 2023 16:32:45 +0000 Subject: [PATCH 09/59] Add an __init__ docstring to TextLog A contribution to #1811. --- src/textual/widgets/_text_log.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index c7efee07a..3f3ced940 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -45,6 +45,19 @@ 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 self._start_line: int = 0 From 40de64773e8eeb0c823c51a17d89d76dcc15cc4b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Feb 2023 16:33:48 +0000 Subject: [PATCH 10/59] superfluous refresh --- src/textual/widgets/_footer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 286eeaf5f..076b9445a 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -132,7 +132,6 @@ class Footer(Widget): def notify_style_update(self) -> None: self._key_text = None - self.refresh() def post_render(self, renderable): return renderable From d854cb81af66214cf2259fe83f9de1f37f93c036 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 22 Feb 2023 16:35:31 +0000 Subject: [PATCH 11/59] Add some reactive attribute docstrings to TextLog A contribution to #1811. --- src/textual/widgets/_text_log.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 3f3ced940..27e8783f0 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -60,15 +60,20 @@ class TextLog(ScrollView, can_focus=True): """ 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: From 7d99d168ff907955bda8c1da609ebc9093f1a188 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 13:49:07 +0000 Subject: [PATCH 12/59] prevent implementation --- CHANGELOG.md | 1 + src/textual/events.py | 6 +++++- src/textual/message_pump.py | 35 +++++++++++++++++++++++++++++++++-- src/textual/reactive.py | 4 +++- tests/test_message_pump.py | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 042796b7b..7e9eb0a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832 - Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 - 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 ### Changed diff --git a/src/textual/events.py b/src/textual/events.py index 9914c03d5..34f302fe2 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -29,9 +29,13 @@ 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]], + prevent: set[type[Message]] | None = None, ) -> None: self.callback = callback + self.prevent = frozenset(prevent) if prevent else None super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d17bd8249..4391103cc 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -10,8 +10,9 @@ 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 @@ -19,6 +20,7 @@ from ._asyncio import create_task from ._callback import invoke from ._context import NoActiveAppError, active_app, active_message_pump from ._time import time +from ._types import CallbackType from .case import camel_to_snake from .errors import DuplicateKeyHandlers from .events import Event @@ -77,6 +79,7 @@ class MessagePump(metaclass=MessagePumpMeta): self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] + self._prevent_events: list[set[type[Message]]] = [] @property def task(self) -> Task: @@ -149,6 +152,9 @@ class MessagePump(metaclass=MessagePumpMeta): self._parent = None def check_message_enabled(self, message: Message) -> bool: + message_type = type(message) + if self._prevent_events and message_type in self._prevent_events[-1]: + return False return type(message) not in self._disabled_messages def disable_messages(self, *messages: type[Message]) -> None: @@ -527,6 +533,27 @@ class MessagePump(metaclass=MessagePumpMeta): if self._message_queue.empty(): self.post_message_no_wait(messages.Prompt(sender=self)) + @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: + + input = self.query_one(Input) + with self.prevent(Input.Changed): + input.value = "foo" + + + """ + if self._prevent_events: + self._prevent_events.append(self._prevent_events[-1].union(message_types)) + else: + self._prevent_events.append(set(message_types)) + try: + yield + finally: + self._prevent_events.pop() + async def post_message(self, message: Message) -> bool: """Post a message or an event to this message pump. @@ -594,7 +621,11 @@ class MessagePump(metaclass=MessagePumpMeta): return self.post_message_no_wait(message) async def on_callback(self, event: events.Callback) -> None: - await invoke(event.callback) + if event.prevent: + with self.prevent(*event.prevent): + await invoke(event.callback) + else: + await invoke(event.callback) # TODO: Does dispatch_key belong on message pump? async def dispatch_key(self, event: events.Key) -> bool: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 0553d076c..140cb0ff1 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -235,7 +235,9 @@ class Reactive(Generic[ReactiveType]): # Result is awaitable, so we need to await it within an async context obj.post_message_no_wait( events.Callback( - sender=obj, callback=partial(await_watcher, watch_result) + sender=obj, + callback=partial(await_watcher, watch_result), + prevent=obj._prevent_events[0] if obj._prevent_events else None, ) ) diff --git a/tests/test_message_pump.py b/tests/test_message_pump.py index ce665f7c9..9667b4cbc 100644 --- a/tests/test_message_pump.py +++ b/tests/test_message_pump.py @@ -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" From f723f3771f5fe71735a91e002e555c5e2c1bb460 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 13:51:34 +0000 Subject: [PATCH 13/59] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9eb0a49..8242d5dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832 - Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 - 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 +- Added `MessagePump.prevent` context manager to temporarily suppress a given message type https://github.com/Textualize/textual/pull/1866 ### Changed From 4cf7492a280a59231f0d7995a22b318b3f521ff9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 13:53:36 +0000 Subject: [PATCH 14/59] docstrings --- src/textual/message_pump.py | 8 ++++++++ src/textual/widget.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4391103cc..061743722 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -152,6 +152,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. + """ message_type = type(message) if self._prevent_events and message_type in self._prevent_events[-1]: return False diff --git a/src/textual/widget.py b/src/textual/widget.py index 22faeff55..68a5c56f7 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2489,6 +2489,14 @@ 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 From 6738c5686c49d17a8e9823ea54c5db5b90a479fc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 23 Feb 2023 14:28:25 +0000 Subject: [PATCH 15/59] Correct the wording of the description of Switch.value It's not just the default value, it's the ongoing value too and can be used to change the switch. --- docs/widgets/switch.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/widgets/switch.md b/docs/widgets/switch.md index 1cb77be6e..0b497da50 100644 --- a/docs/widgets/switch.md +++ b/docs/widgets/switch.md @@ -28,9 +28,9 @@ The example below shows switches in various states. ## Reactive Attributes -| Name | Type | Default | Description | -|---------|--------|---------|----------------------------------| -| `value` | `bool` | `False` | The default value of the switch. | +| Name | Type | Default | Description | +|---------|--------|---------|--------------------------| +| `value` | `bool` | `False` | The value of the switch. | ## Bindings From 2ee61f95dbf57b4bbe7c7af29826ffd22be6632e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 14:37:47 +0000 Subject: [PATCH 16/59] Docs --- docs/guide/events.md | 28 +++++++++++++++++++++++++++- src/textual/message_pump.py | 4 ++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/guide/events.md b/docs/guide/events.md index 7c3df74c4..3a6da3dae 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -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* prevent a Widget or App from posting messages or events of a particular type by calling [prevent][textual.message_pump.MessagePump.prevent], which returns a context manager. 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, by handling the [Input.Changed][textual.widgets.Input.Changed] event. There's also a button to clear the input by setting `input.value` to empty string. We don't want to play the bell when the button is clicked so we wrap setting the attribute with a call to [prevent][textual.message_pump.MessagePump.prevent]. + +!!! 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. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 061743722..bea707a02 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -546,11 +546,11 @@ class MessagePump(metaclass=MessagePumpMeta): """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 self._prevent_events: From da9e28d4d67a76cc72b9574a651ff049e920213b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 14:41:44 +0000 Subject: [PATCH 17/59] Add example --- docs/examples/events/prevent.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/examples/events/prevent.py diff --git a/docs/examples/events/prevent.py b/docs/examples/events/prevent.py new file mode 100644 index 000000000..66ffb034c --- /dev/null +++ b/docs/examples/events/prevent.py @@ -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() From 0f1251d0bf4b8c33a43741681a9fc7875b4d0efe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 15:22:40 +0000 Subject: [PATCH 18/59] pass prevent to messages --- src/textual/message.py | 2 ++ src/textual/message_pump.py | 43 +++++++++++++++++++++++++++---------- src/textual/reactive.py | 4 +++- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/textual/message.py b/src/textual/message.py index 7ed72789b..8f6816619 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -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: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index bea707a02..b56f5bc03 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -79,7 +79,7 @@ class MessagePump(metaclass=MessagePumpMeta): self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] - self._prevent_events: list[set[type[Message]]] = [] + self._prevent_message_types_stack: list[set[type[Message]]] = [] @property def task(self) -> Task: @@ -161,7 +161,10 @@ class MessagePump(metaclass=MessagePumpMeta): `True` if the message will be sent, or `False` if it is disabled. """ message_type = type(message) - if self._prevent_events and message_type in self._prevent_events[-1]: + if ( + self._prevent_message_types_stack + and message_type in self._prevent_message_types_stack[-1] + ): return False return type(message) not in self._disabled_messages @@ -472,11 +475,12 @@ class MessagePump(metaclass=MessagePumpMeta): if message.no_dispatch: return - # Allow apps to treat events and messages separately - if isinstance(message, Event): - await self.on_event(message) - else: - await self._on_message(message) + with self.prevent(*message._prevent): + # Allow apps to treat events and messages separately + if isinstance(message, Event): + await self.on_event(message) + else: + await self._on_message(message) await self._flush_next_callbacks() def _get_dispatch_methods( @@ -553,14 +557,27 @@ class MessagePump(metaclass=MessagePumpMeta): ``` """ - if self._prevent_events: - self._prevent_events.append(self._prevent_events[-1].union(message_types)) + if self._prevent_message_types_stack: + self._prevent_message_types_stack.append( + self._prevent_message_types_stack[-1].union(message_types) + ) else: - self._prevent_events.append(set(message_types)) + self._prevent_message_types_stack.append(set(message_types)) try: yield finally: - self._prevent_events.pop() + self._prevent_message_types_stack.pop() + + def is_prevented(self, message_type: type[Message]) -> bool: + """Check if a message type is currently prevented from posting with [prevent][textual.message_pump.MessagePump.prevent]. + + Args: + message_type: A message type. + + Returns: + `True` if the message type is currently prevented, otherwise `False` + """ + return message_type in self._prevent_message_types_stack async def post_message(self, message: Message) -> bool: """Post a message or an event to this message pump. @@ -577,6 +594,8 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True + if self._prevent_message_types_stack: + message._prevent.update(self._prevent_message_types_stack[-1]) await self._message_queue.put(message) return True @@ -599,6 +618,8 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False + if self._prevent_message_types_stack: + message._prevent.update(self._prevent_message_types_stack[-1]) await self._message_queue.put(message) return True diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 140cb0ff1..d074b1d92 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -237,7 +237,9 @@ class Reactive(Generic[ReactiveType]): events.Callback( sender=obj, callback=partial(await_watcher, watch_result), - prevent=obj._prevent_events[0] if obj._prevent_events else None, + prevent=obj._prevent_message_types_stack[0] + if obj._prevent_message_types_stack + else None, ) ) From fd9ce05305e19045af1fe161201bf7f3d237fb01 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 15:26:41 +0000 Subject: [PATCH 19/59] prevent messages stack --- src/textual/message_pump.py | 41 +++++++++---------------------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index b56f5bc03..c2bcb701c 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -161,10 +161,7 @@ class MessagePump(metaclass=MessagePumpMeta): `True` if the message will be sent, or `False` if it is disabled. """ message_type = type(message) - if ( - self._prevent_message_types_stack - and message_type in self._prevent_message_types_stack[-1] - ): + if self._prevent_events and message_type in self._prevent_events[-1]: return False return type(message) not in self._disabled_messages @@ -475,12 +472,11 @@ 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) - else: - await self._on_message(message) + # Allow apps to treat events and messages separately + if isinstance(message, Event): + await self.on_event(message) + else: + await self._on_message(message) await self._flush_next_callbacks() def _get_dispatch_methods( @@ -557,27 +553,14 @@ class MessagePump(metaclass=MessagePumpMeta): ``` """ - if self._prevent_message_types_stack: - self._prevent_message_types_stack.append( - self._prevent_message_types_stack[-1].union(message_types) - ) + if self._prevent_events: + self._prevent_events.append(self._prevent_events[-1].union(message_types)) else: - self._prevent_message_types_stack.append(set(message_types)) + self._prevent_events.append(set(message_types)) try: yield finally: - self._prevent_message_types_stack.pop() - - def is_prevented(self, message_type: type[Message]) -> bool: - """Check if a message type is currently prevented from posting with [prevent][textual.message_pump.MessagePump.prevent]. - - Args: - message_type: A message type. - - Returns: - `True` if the message type is currently prevented, otherwise `False` - """ - return message_type in self._prevent_message_types_stack + self._prevent_events.pop() async def post_message(self, message: Message) -> bool: """Post a message or an event to this message pump. @@ -594,8 +577,6 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True - if self._prevent_message_types_stack: - message._prevent.update(self._prevent_message_types_stack[-1]) await self._message_queue.put(message) return True @@ -618,8 +599,6 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False - if self._prevent_message_types_stack: - message._prevent.update(self._prevent_message_types_stack[-1]) await self._message_queue.put(message) return True From c2ea074f4af0876c3452fd2370afc8ea9eb9822c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 15:28:11 +0000 Subject: [PATCH 20/59] fix name --- src/textual/message_pump.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c2bcb701c..4eafa15fb 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -161,7 +161,10 @@ class MessagePump(metaclass=MessagePumpMeta): `True` if the message will be sent, or `False` if it is disabled. """ message_type = type(message) - if self._prevent_events and message_type in self._prevent_events[-1]: + if ( + self._prevent_message_types_stack + and message_type in self._prevent_message_types_stack[-1] + ): return False return type(message) not in self._disabled_messages @@ -553,14 +556,16 @@ class MessagePump(metaclass=MessagePumpMeta): ``` """ - if self._prevent_events: - self._prevent_events.append(self._prevent_events[-1].union(message_types)) + if self._prevent_message_types_stack: + self._prevent_message_types_stack.append( + self._prevent_message_types_stack[-1].union(message_types) + ) else: - self._prevent_events.append(set(message_types)) + self._prevent_message_types_stack.append(set(message_types)) try: yield finally: - self._prevent_events.pop() + self._prevent_message_types_stack.pop() async def post_message(self, message: Message) -> bool: """Post a message or an event to this message pump. From ba30e0dd66a9df4e5ba3eb287da6b0b9c89e46b3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 15:34:02 +0000 Subject: [PATCH 21/59] set prevent --- src/textual/message_pump.py | 4 ++++ src/textual/reactive.py | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4eafa15fb..89183175a 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -582,6 +582,8 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True + if self._prevent_message_types_stack: + message._prevent.update(self._prevent_message_types_stack[-1]) await self._message_queue.put(message) return True @@ -620,6 +622,8 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False + if self._prevent_message_types_stack: + message._prevent.update(self._prevent_message_types_stack[-1]) self._message_queue.put_nowait(message) return True diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d074b1d92..a64bdc594 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -237,9 +237,11 @@ class Reactive(Generic[ReactiveType]): events.Callback( sender=obj, callback=partial(await_watcher, watch_result), - prevent=obj._prevent_message_types_stack[0] - if obj._prevent_message_types_stack - else None, + prevent=( + obj._prevent_message_types_stack[0] + if obj._prevent_message_types_stack + else None + ), ) ) From fd13b33cce47640397c9b9f4ba949858e52aac0e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 16:05:11 +0000 Subject: [PATCH 22/59] prevent message stack --- src/textual/message_pump.py | 16 +++++----------- src/textual/reactive.py | 11 ++++------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 89183175a..2d78cca64 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -79,7 +79,7 @@ class MessagePump(metaclass=MessagePumpMeta): self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] - self._prevent_message_types_stack: list[set[type[Message]]] = [] + self._prevent_message_types_stack: list[set[type[Message]]] = [set()] @property def task(self) -> Task: @@ -161,10 +161,7 @@ class MessagePump(metaclass=MessagePumpMeta): `True` if the message will be sent, or `False` if it is disabled. """ message_type = type(message) - if ( - self._prevent_message_types_stack - and message_type in self._prevent_message_types_stack[-1] - ): + if message_type in self._prevent_message_types_stack[-1]: return False return type(message) not in self._disabled_messages @@ -556,12 +553,9 @@ class MessagePump(metaclass=MessagePumpMeta): ``` """ - if self._prevent_message_types_stack: - self._prevent_message_types_stack.append( - self._prevent_message_types_stack[-1].union(message_types) - ) - else: - self._prevent_message_types_stack.append(set(message_types)) + self._prevent_message_types_stack.append( + self._prevent_message_types_stack[-1].union(message_types) + ) try: yield finally: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index a64bdc594..81085db77 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -237,11 +237,7 @@ class Reactive(Generic[ReactiveType]): events.Callback( sender=obj, callback=partial(await_watcher, watch_result), - prevent=( - obj._prevent_message_types_stack[0] - if obj._prevent_message_types_stack - else None - ), + prevent=obj._prevent_message_types_stack[-1], ) ) @@ -259,8 +255,9 @@ class Reactive(Generic[ReactiveType]): for reactable, callback in watchers if reactable.is_attached and not reactable._closing ] - for _, callback in watchers: - invoke_watcher(callback, old_value, value) + for reactable, callback in watchers: + with reactable.prevent(*obj._prevent_message_types_stack[-1]): + invoke_watcher(callback, old_value, value) @classmethod def _compute(cls, obj: Reactable) -> None: From 2f104a5db41c4f2d0e783bede7806abfe89d83eb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 17:41:13 +0000 Subject: [PATCH 23/59] pass prevented messages types in post --- src/textual/dom.py | 29 ++++++++++++++++- src/textual/message_pump.py | 62 +++++++++++++++++-------------------- src/textual/widget.py | 3 ++ 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 9980c9471..22eaa3d00 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,11 +1,13 @@ from __future__ import annotations import re -from functools import lru_cache +from contextlib import contextmanager +from functools import lru_cache, reduce from inspect import getfile from typing import ( TYPE_CHECKING, ClassVar, + Generator, Iterable, Sequence, Type, @@ -39,6 +41,7 @@ from .walk import walk_breadth_first, walk_depth_first if TYPE_CHECKING: from .app import App + from .messages import Message from .css.query import DOMQuery from .screen import Screen from .widget import Widget @@ -202,6 +205,30 @@ class DOMNode(MessagePump): cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) + 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 any( + message_type in node._prevent_message_types_stack[-1] + for node in self.ancestors_with_self + ) + + @property + def prevented_messages(self) -> set[type[Message]]: + """A set of all the prevented message types.""" + return set().union( + *[ + node._prevent_message_types_stack[-1] + for node in self.ancestors_with_self + ] + ) + def get_component_styles(self, name: str) -> RenderStyles: """Get a "component" styles object (must be defined in COMPONENT_CLASSES classvar). diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 2d78cca64..49d2336d9 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -81,6 +81,26 @@ class MessagePump(metaclass=MessagePumpMeta): self._next_callbacks: list[CallbackType] = [] self._prevent_message_types_stack: list[set[type[Message]]] = [set()] + @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" + ``` + + """ + self._prevent_message_types_stack.append( + self._prevent_message_types_stack[-1].union(message_types) + ) + try: + yield + finally: + self._prevent_message_types_stack.pop() + @property def task(self) -> Task: assert self._task is not None @@ -160,9 +180,6 @@ class MessagePump(metaclass=MessagePumpMeta): Returns: `True` if the message will be sent, or `False` if it is disabled. """ - message_type = type(message) - if message_type in self._prevent_message_types_stack[-1]: - return False return type(message) not in self._disabled_messages def disable_messages(self, *messages: type[Message]) -> None: @@ -472,12 +489,13 @@ class MessagePump(metaclass=MessagePumpMeta): if message.no_dispatch: return - # Allow apps to treat events and messages separately - if isinstance(message, Event): - await self.on_event(message) - else: - await self._on_message(message) - await self._flush_next_callbacks() + with self.prevent(*message._prevent): + # Allow apps to treat events and messages separately + if isinstance(message, Event): + await self.on_event(message) + else: + await self._on_message(message) + await self._flush_next_callbacks() def _get_dispatch_methods( self, method_name: str, message: Message @@ -541,26 +559,6 @@ class MessagePump(metaclass=MessagePumpMeta): if self._message_queue.empty(): self.post_message_no_wait(messages.Prompt(sender=self)) - @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" - ``` - - """ - self._prevent_message_types_stack.append( - self._prevent_message_types_stack[-1].union(message_types) - ) - try: - yield - finally: - self._prevent_message_types_stack.pop() - async def post_message(self, message: Message) -> bool: """Post a message or an event to this message pump. @@ -576,8 +574,7 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True - if self._prevent_message_types_stack: - message._prevent.update(self._prevent_message_types_stack[-1]) + message._prevent.update(self.prevented_messages) await self._message_queue.put(message) return True @@ -616,8 +613,7 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False - if self._prevent_message_types_stack: - message._prevent.update(self._prevent_message_types_stack[-1]) + message._prevent.update(self.prevented_messages) self._message_queue.put_nowait(message) return True diff --git a/src/textual/widget.py b/src/textual/widget.py index 68a5c56f7..ad9b87cc5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2500,6 +2500,9 @@ class Widget(DOMNode): # 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 ( From 9579b9fd24154198f4314a8d9a1ac9e1caea545e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 21:15:05 +0000 Subject: [PATCH 24/59] prevent API refinement --- src/textual/dom.py | 9 ++++----- src/textual/message_pump.py | 8 ++++++-- src/textual/widget.py | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 22eaa3d00..aa59f6f6b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -205,7 +205,7 @@ class DOMNode(MessagePump): cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) - def is_prevented(self, message_type: type[Message]) -> bool: + 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: @@ -219,14 +219,13 @@ class DOMNode(MessagePump): for node in self.ancestors_with_self ) - @property - def prevented_messages(self) -> set[type[Message]]: + def _get_prevented_messages(self) -> set[type[Message]]: """A set of all the prevented message types.""" return set().union( - *[ + *( node._prevent_message_types_stack[-1] for node in self.ancestors_with_self - ] + ) ) def get_component_styles(self, name: str) -> RenderStyles: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 49d2336d9..9a866d82d 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -81,6 +81,10 @@ class MessagePump(metaclass=MessagePumpMeta): self._next_callbacks: list[CallbackType] = [] self._prevent_message_types_stack: list[set[type[Message]]] = [set()] + def _get_prevented_messages(self) -> set[type[Message]]: + """A set of all the prevented message types.""" + return set() + @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. @@ -574,7 +578,7 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True - message._prevent.update(self.prevented_messages) + message._prevent.update(self._get_prevented_messages()) await self._message_queue.put(message) return True @@ -613,7 +617,7 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False - message._prevent.update(self.prevented_messages) + message._prevent.update(self._get_prevented_messages()) self._message_queue.put_nowait(message) return True diff --git a/src/textual/widget.py b/src/textual/widget.py index ad9b87cc5..47e1b431d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2501,7 +2501,7 @@ class Widget(DOMNode): if not super().check_message_enabled(message): return False message_type = type(message) - if self.is_prevented(message_type): + 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. From 0894881950571affe7d2fec0245a5b908dbc90f3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 21:18:30 +0000 Subject: [PATCH 25/59] simplify --- src/textual/message_pump.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 9a866d82d..0db654bc7 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -97,13 +97,12 @@ class MessagePump(metaclass=MessagePumpMeta): ``` """ - self._prevent_message_types_stack.append( - self._prevent_message_types_stack[-1].union(message_types) - ) + prevent_stack = self._prevent_message_types_stack + prevent_stack.append(prevent_stack[-1].union(message_types)) try: yield finally: - self._prevent_message_types_stack.pop() + prevent_stack.pop() @property def task(self) -> Task: From 3231b7a8b83eeb9e3aac6da6f9a8a62a93f35f4f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 21:22:24 +0000 Subject: [PATCH 26/59] remove prevent from callback --- src/textual/events.py | 2 -- src/textual/message_pump.py | 6 +----- src/textual/reactive.py | 4 +--- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index 34f302fe2..d4c1625b2 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -32,10 +32,8 @@ class Callback(Event, bubble=False, verbose=True): self, sender: MessageTarget, callback: Callable[[], Awaitable[None]], - prevent: set[type[Message]] | None = None, ) -> None: self.callback = callback - self.prevent = frozenset(prevent) if prevent else None super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 0db654bc7..31922a8d5 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -631,11 +631,7 @@ class MessagePump(metaclass=MessagePumpMeta): return self.post_message_no_wait(message) async def on_callback(self, event: events.Callback) -> None: - if event.prevent: - with self.prevent(*event.prevent): - await invoke(event.callback) - else: - await invoke(event.callback) + await invoke(event.callback) # TODO: Does dispatch_key belong on message pump? async def dispatch_key(self, event: events.Key) -> bool: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 81085db77..ae5cf9d6d 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -235,9 +235,7 @@ class Reactive(Generic[ReactiveType]): # Result is awaitable, so we need to await it within an async context obj.post_message_no_wait( events.Callback( - sender=obj, - callback=partial(await_watcher, watch_result), - prevent=obj._prevent_message_types_stack[-1], + sender=obj, callback=partial(await_watcher, watch_result) ) ) From b2ab5b68869b2acd2c14a85bcc8b18813bf520e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 21:36:33 +0000 Subject: [PATCH 27/59] docs --- docs/guide/events.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/events.md b/docs/guide/events.md index 3a6da3dae..299bbd9d9 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -120,9 +120,9 @@ There are other ways of sending (posting) messages, which you may need to use le ## Preventing messages -You can *temporarily* prevent a Widget or App from posting messages or events of a particular type by calling [prevent][textual.message_pump.MessagePump.prevent], which returns a context manager. This is typically used when updating data in a child widget and you don't want to receive notifications that something has changed. +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, by handling the [Input.Changed][textual.widgets.Input.Changed] event. There's also a button to clear the input by setting `input.value` to empty string. We don't want to play the bell when the button is clicked so we wrap setting the attribute with a call to [prevent][textual.message_pump.MessagePump.prevent]. +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 don't want the button to make a sound, the assignment to `value` is wrapped in with a [prevent][textual.message_pump.MessagePump.prevent] context manager. !!! tip From d1d6f03b2d4fee3a9dff3adbe43b99660a6a635f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Feb 2023 21:39:45 +0000 Subject: [PATCH 28/59] microoptmization --- src/textual/message_pump.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 31922a8d5..faf92ac38 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -97,12 +97,15 @@ class MessagePump(metaclass=MessagePumpMeta): ``` """ - prevent_stack = self._prevent_message_types_stack - prevent_stack.append(prevent_stack[-1].union(message_types)) - try: + 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 - finally: - prevent_stack.pop() @property def task(self) -> Task: From 7c470fc5dd11cfb423102aa11044747d6c847032 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 08:44:40 +0000 Subject: [PATCH 29/59] Move prevent stack to contextvar --- src/textual/_context.py | 6 ++++++ src/textual/dom.py | 23 ----------------------- src/textual/message_pump.py | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/textual/_context.py b/src/textual/_context.py index 6a5562476..9dc5d8ca3 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -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" +) diff --git a/src/textual/dom.py b/src/textual/dom.py index aa59f6f6b..62997576f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -205,29 +205,6 @@ class DOMNode(MessagePump): cls._merged_bindings = cls._merge_bindings() cls._css_type_names = frozenset(css_type_names) - 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 any( - message_type in node._prevent_message_types_stack[-1] - for node in self.ancestors_with_self - ) - - def _get_prevented_messages(self) -> set[type[Message]]: - """A set of all the prevented message types.""" - return set().union( - *( - node._prevent_message_types_stack[-1] - for node in self.ancestors_with_self - ) - ) - def get_component_styles(self, name: str) -> RenderStyles: """Get a "component" styles object (must be defined in COMPONENT_CLASSES classvar). diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index faf92ac38..013a7e865 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -18,7 +18,12 @@ 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 @@ -79,11 +84,36 @@ class MessagePump(metaclass=MessagePumpMeta): self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] - self._prevent_message_types_stack: list[set[type[Message]]] = [set()] + + @property + def _prevent_message_types_stack(self) -> list[set[type[Message]]]: + """A stack that manages prevented messages. + + Returns: + A list of sets of Message Types. + """ + 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 set() + 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]: From c5cb0100ff0960c6e8fa2b64cb6c5ef53adc4d82 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 10:02:52 +0000 Subject: [PATCH 30/59] fix walk children --- src/textual/app.py | 3 +++ src/textual/reactive.py | 8 ++++---- src/textual/walk.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 318dc3223..ed3adc280 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2153,6 +2153,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 diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 0553d076c..f03624a0c 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -239,9 +239,10 @@ class Reactive(Generic[ReactiveType]): ) ) - watch_function = getattr(obj, f"watch_{name}", None) - if callable(watch_function): - invoke_watcher(watch_function, old_value, value) + if obj.is_attached: + watch_function = getattr(obj, f"watch_{name}", None) + if callable(watch_function): + invoke_watcher(watch_function, old_value, value) # Process "global" watchers watchers: list[tuple[Reactable, Callable]] @@ -342,7 +343,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") diff --git a/src/textual/walk.py b/src/textual/walk.py index d43d40b06..0dc672471 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -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 From 5a77e3493dd723a1000c94d233813c40581064f9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 10:13:58 +0000 Subject: [PATCH 31/59] add test --- CHANGELOG.md | 2 ++ src/textual/reactive.py | 7 +++---- tests/test_screens.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 042796b7b..52b700ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 diff --git a/src/textual/reactive.py b/src/textual/reactive.py index f03624a0c..098ab031b 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -239,10 +239,9 @@ class Reactive(Generic[ReactiveType]): ) ) - if obj.is_attached: - watch_function = getattr(obj, f"watch_{name}", None) - if callable(watch_function): - invoke_watcher(watch_function, old_value, value) + watch_function = getattr(obj, f"watch_{name}", None) + if callable(watch_function): + invoke_watcher(watch_function, old_value, value) # Process "global" watchers watchers: list[tuple[Reactable, Callable]] diff --git a/tests/test_screens.py b/tests/test_screens.py index ca56df9c5..3edc5dce5 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -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 = { From 099911481141207c9e3ef4c0fefe31933ca5762e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:02:20 +0000 Subject: [PATCH 32/59] Update docs/guide/events.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/events.md b/docs/guide/events.md index 299bbd9d9..09f9a7043 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -122,7 +122,7 @@ There are other ways of sending (posting) messages, which you may need to use le 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 don't want the button to make a sound, the assignment to `value` is wrapped in with a [prevent][textual.message_pump.MessagePump.prevent] context manager. +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 From 217956bbdff01509322ab28a90b03135c520d206 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:02:46 +0000 Subject: [PATCH 33/59] Update docs/examples/events/prevent.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/examples/events/prevent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/events/prevent.py b/docs/examples/events/prevent.py index 66ffb034c..e8fd0de05 100644 --- a/docs/examples/events/prevent.py +++ b/docs/examples/events/prevent.py @@ -13,7 +13,7 @@ class PreventApp(App): def on_button_pressed(self) -> None: """Clear the text input.""" input = self.query_one(Input) - with input.prevent(Input.Changed): # (1) + with input.prevent(Input.Changed): # (1)! input.value = "" def on_input_changed(self) -> None: From 63438f170def9bbdadf42d3a58d8d543b9939cd6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:02:56 +0000 Subject: [PATCH 34/59] Update docs/examples/events/prevent.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/examples/events/prevent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/events/prevent.py b/docs/examples/events/prevent.py index e8fd0de05..39fe437c2 100644 --- a/docs/examples/events/prevent.py +++ b/docs/examples/events/prevent.py @@ -18,7 +18,7 @@ class PreventApp(App): def on_input_changed(self) -> None: """Called as the user types.""" - self.bell() # (2) + self.bell() # (2)! if __name__ == "__main__": From 884db9c39c8039aaef948295a11a64f80f2ab5ce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:03:05 +0000 Subject: [PATCH 35/59] Update src/textual/dom.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/dom.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 62997576f..51497d221 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,8 +1,7 @@ from __future__ import annotations import re -from contextlib import contextmanager -from functools import lru_cache, reduce +from functools import lru_cache from inspect import getfile from typing import ( TYPE_CHECKING, From 15cd3ef5ed9403e910397cb5aa97f0cc555aa64f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:03:16 +0000 Subject: [PATCH 36/59] Update src/textual/dom.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/dom.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 51497d221..55ff4ffd6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -6,7 +6,6 @@ from inspect import getfile from typing import ( TYPE_CHECKING, ClassVar, - Generator, Iterable, Sequence, Type, From a5f139b45e17e2918ad609bf8f619134e810e533 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:04:08 +0000 Subject: [PATCH 37/59] Update src/textual/message_pump.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/message_pump.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 013a7e865..d3704da22 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -87,11 +87,7 @@ class MessagePump(metaclass=MessagePumpMeta): @property def _prevent_message_types_stack(self) -> list[set[type[Message]]]: - """A stack that manages prevented messages. - - Returns: - A list of sets of Message Types. - """ + """The stack that manages prevented messages.""" try: stack = prevent_message_types_stack.get() except LookupError: From 03ffcdab0ff147e9b2270bd88cb8753e434afb7b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:05:24 +0000 Subject: [PATCH 38/59] Update src/textual/message_pump.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/message_pump.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d3704da22..9122175d6 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -207,7 +207,7 @@ class MessagePump(metaclass=MessagePumpMeta): """Check if a given message is enabled (allowed to be sent). Args: - message: A message object + message: A message object. Returns: `True` if the message will be sent, or `False` if it is disabled. From 825f21fe2865b5d7c1b0e38b433c3b19e71541ee Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:09:50 +0000 Subject: [PATCH 39/59] comments --- src/textual/message_pump.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 9122175d6..7a2f6d24e 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -606,6 +606,8 @@ 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 @@ -645,6 +647,8 @@ 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 From 0924bb2ba40973f4865943f5fc9ccd2b29626023 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:45:49 +0000 Subject: [PATCH 40/59] Version bump --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- src/textual/dom.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b07741544..4a927ebae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.12.0] - Unreleased +## [0.12.0] - 2023-02-24 ### Added diff --git a/pyproject.toml b/pyproject.toml index 17ac3c8b1..97694cc23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.11.1" +version = "0.12.0" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] diff --git a/src/textual/dom.py b/src/textual/dom.py index 55ff4ffd6..9980c9471 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -39,7 +39,6 @@ from .walk import walk_breadth_first, walk_depth_first if TYPE_CHECKING: from .app import App - from .messages import Message from .css.query import DOMQuery from .screen import Screen from .widget import Widget From 99a9b6aa914b85dde081f6c475690995ccfc7ac1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 12:46:21 +0000 Subject: [PATCH 41/59] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a927ebae..0c47a9c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -500,6 +500,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 From 04c75cd2d998cec8d481f9fa189d5f023a826238 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Feb 2023 08:52:05 -0500 Subject: [PATCH 42/59] add units to timer docs --- src/textual/timer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/timer.py b/src/textual/timer.py index 649c5cf80..e54c7cef1 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -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. From 661990f6e4460c5bd204796f1c5a6571538d1fd3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:01:02 +0000 Subject: [PATCH 43/59] new blog post --- docs/blog/images/colors.svg | 235 +++++++++++++++++++++++++++++++ docs/blog/posts/release0-12-0.md | 103 ++++++++++++++ src/textual/widgets/_markdown.py | 3 +- 3 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 docs/blog/images/colors.svg create mode 100644 docs/blog/posts/release0-12-0.md diff --git a/docs/blog/images/colors.svg b/docs/blog/images/colors.svg new file mode 100644 index 000000000..5a2534394 --- /dev/null +++ b/docs/blog/images/colors.svg @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ColorsApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +primary +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁ +secondary"primary" +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +background$primary-darken-3$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +primary-background$primary-darken-2$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +secondary-background$primary-darken-1$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +surface$primary$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +panel$primary-lighten-1$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +boost$primary-lighten-2$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +warning$primary-lighten-3$text-muted$text-disabled +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +error +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +success +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +accent"secondary" +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + D  Toggle dark mode  + + + diff --git a/docs/blog/posts/release0-12-0.md b/docs/blog/posts/release0-12-0.md new file mode 100644 index 000000000..c875b5981 --- /dev/null +++ b/docs/blog/posts/release0-12-0.md @@ -0,0 +1,103 @@ +--- +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. + + + +## 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, lets 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: + +
+--8<-- "docs/blog/images/colors.svg" +
+ +!!! 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) 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 the user will perceive as a brief flicker. + +## 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 it children disabled, so you could use this for disabling a form, for example. + +## 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. diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index e2178ca2d..d7aabe628 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -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): From 8a5e5f29e0482ecf106b176a0b9e4af4e4fce68e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:12:04 +0000 Subject: [PATCH 44/59] Update docs/blog/posts/release0-12-0.md Co-authored-by: Dave Pearson --- docs/blog/posts/release0-12-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/release0-12-0.md b/docs/blog/posts/release0-12-0.md index c875b5981..d25810397 100644 --- a/docs/blog/posts/release0-12-0.md +++ b/docs/blog/posts/release0-12-0.md @@ -88,7 +88,7 @@ Without the batch update there are a few frames where the old markdown blocks ar ## 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 it children disabled, so you could use this for disabling a form, for example. +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. ## Preventing messages From e3976155fddab4d29187e302e5ccb9770041dca6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:12:14 +0000 Subject: [PATCH 45/59] Update docs/blog/posts/release0-12-0.md Co-authored-by: Dave Pearson --- docs/blog/posts/release0-12-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/release0-12-0.md b/docs/blog/posts/release0-12-0.md index d25810397..d99ce3c0a 100644 --- a/docs/blog/posts/release0-12-0.md +++ b/docs/blog/posts/release0-12-0.md @@ -19,7 +19,7 @@ It's been just 9 days since the previous release, but we have a few interesting 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, lets look at the old way of building compose methods. This snippet is taken from the `textual colors` command. +First, let's look at the old way of building compose methods. This snippet is taken from the `textual colors` command. ```python From 01a4ed97e12785c7b2c76e1e7bceb089f84078af Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:07:51 +0000 Subject: [PATCH 46/59] update blog --- docs/blog/posts/release0-12-0.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/blog/posts/release0-12-0.md b/docs/blog/posts/release0-12-0.md index d99ce3c0a..cfd43c580 100644 --- a/docs/blog/posts/release0-12-0.md +++ b/docs/blog/posts/release0-12-0.md @@ -66,7 +66,7 @@ for color_name in ColorSystem.COLOR_NAMES: 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, +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. @@ -76,7 +76,7 @@ Textual is smart about performing updates to the screen. When you make a change 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) uses this context manager when it updates its content. Here's the code: +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(): @@ -84,12 +84,16 @@ with self.app.batch_update(): 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 the user will perceive as a brief flicker. +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. From 33bbdf14a3e0285c985e7352dae81849c7f8a8c1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:11:14 +0000 Subject: [PATCH 47/59] docstring --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index ed3adc280..44d20e76f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -444,7 +444,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 From 461abc7fbecffdc0607a872b0a62686810ae9d53 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:19:55 +0000 Subject: [PATCH 48/59] missing bracket --- docs/blog/posts/release0-12-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/release0-12-0.md b/docs/blog/posts/release0-12-0.md index cfd43c580..324914311 100644 --- a/docs/blog/posts/release0-12-0.md +++ b/docs/blog/posts/release0-12-0.md @@ -88,7 +88,7 @@ Without the batch update there are a few frames where the old markdown blocks ar ## 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. +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 From bd838c7f03a8e393e7829dc6ddf4cb63fc3afbc5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Feb 2023 17:33:20 +0000 Subject: [PATCH 49/59] added hr to markdown example --- examples/example.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/example.md b/examples/example.md index e79234025..fe7c10cae 100644 --- a/examples/example.md +++ b/examples/example.md @@ -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 From 05bb10aed01d0775e1f9043fee3cce2d6b617d86 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Feb 2023 08:15:00 +0000 Subject: [PATCH 50/59] batch update fix, and optimization --- CHANGELOG.md | 6 ++++++ src/textual/_styles_cache.py | 7 +++---- src/textual/app.py | 4 ---- src/textual/strip.py | 28 +++++++++++++++++++--------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c47a9c0c..8b05046a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.12.1] - 2023-02-25 + +### Fixed + +- Fix for batch updates + ## [0.12.0] - 2023-02-24 ### Added diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index c760fcfe3..1082f4c05 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -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) diff --git a/src/textual/app.py b/src/textual/app.py index 44d20e76f..e49812f01 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -460,10 +460,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( diff --git a/src/textual/strip.py b/src/textual/strip.py index 53d8a8e9f..9b82db01d 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -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, + ( + (style + link_style if style is not None else None) + if (style and not style._null and style._link_id == link_id) + else style + ), control, ) for text, style, control in self._segments From 8b17579ff4c627657b56ec294089cd5870c9f36d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Feb 2023 08:18:37 +0000 Subject: [PATCH 51/59] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b05046a2..bf2841630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fix for batch updates +- Fix for batch updates https://github.com/Textualize/textual/pull/1880 ## [0.12.0] - 2023-02-24 From 4e766887bc4a004b5730858262d148abe0a6cc42 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Feb 2023 08:19:08 +0000 Subject: [PATCH 52/59] version bump --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2841630..097ac54b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fix for batch updates https://github.com/Textualize/textual/pull/1880 +- Fix for batch update glitch https://github.com/Textualize/textual/pull/1880 ## [0.12.0] - 2023-02-24 diff --git a/pyproject.toml b/pyproject.toml index 97694cc23..75e03a155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.12.0" +version = "0.12.1" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] From 5c2cf2960e44ea171dee3743509ebc19ccf8e14c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Feb 2023 14:41:00 +0000 Subject: [PATCH 53/59] new syntax for calculator --- examples/calculator.py | 48 ++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/examples/calculator.py b/examples/calculator.py index e42273fe0..f494185ab 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -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.""" From 9c1f6f7fad7101aacf129bd0370efd44781bbc85 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 26 Feb 2023 10:34:30 +0000 Subject: [PATCH 54/59] Added docs for the new compose method --- .../guide/layout/combining_layouts.py | 41 ++++++++----------- .../layout/utility_containers_using_with.py | 21 ++++++++++ docs/guide/layout.md | 40 +++++++++++++++++- 3 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 docs/examples/guide/layout/utility_containers_using_with.py diff --git a/docs/examples/guide/layout/combining_layouts.py b/docs/examples/guide/layout/combining_layouts.py index d832bd628..c52ecce0d 100644 --- a/docs/examples/guide/layout/combining_layouts.py +++ b/docs/examples/guide/layout/combining_layouts.py @@ -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__": diff --git a/docs/examples/guide/layout/utility_containers_using_with.py b/docs/examples/guide/layout/utility_containers_using_with.py new file mode 100644 index 000000000..d09a3481e --- /dev/null +++ b/docs/examples/guide/layout/utility_containers_using_with.py @@ -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() diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 94405d837..99edf2e96 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -159,7 +159,45 @@ 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`) with 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. + +You can introduce a container using Python's `with` statement. Any widgets yielded within that block are added to the container's children. + +Let's take the [utility containers](#utility-containers) example and update it to use the context manager approach. + +=== "utility_containers_using_with.py" + + Composing with context managers. + + ```python + --8<-- "docs/examples/guide/layout/utility_containers_using_with.py" + ``` + +=== "utility_containers.py" + + This is the original code. + + ```python + --8<-- "docs/examples/guide/layout/utility_containers.py" + ``` + +=== "utility_containers.css" + + ```sass hl_lines="2" + --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 From b23fbd9be88d2658f08b17eb2b95e1101bf9c66c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 26 Feb 2023 10:36:17 +0000 Subject: [PATCH 55/59] words --- docs/guide/layout.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 99edf2e96..4f4b31f3a 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -163,10 +163,11 @@ However, Textual comes with a more powerful mechanism for achieving this known a ## Composing with context managers -In the previous section we've show how you add children to a container (such as `Horizontal` and `Vertical`) with positional arguments. +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. -You can introduce a container using Python's `with` statement. Any widgets yielded within that block are added to the container's children. +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 take the [utility containers](#utility-containers) example and update it to use the context manager approach. From da1a82056527468693fcbf3e863a4e1b1ce27197 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 26 Feb 2023 12:08:18 +0000 Subject: [PATCH 56/59] highlight lines --- docs/guide/layout.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 4f4b31f3a..5062c492c 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -175,7 +175,7 @@ Let's take the [utility containers](#utility-containers) example and update it t Composing with context managers. - ```python + ```python hl_lines="10-16" --8<-- "docs/examples/guide/layout/utility_containers_using_with.py" ``` @@ -183,7 +183,7 @@ Let's take the [utility containers](#utility-containers) example and update it t This is the original code. - ```python + ```python hl_lines="10-21" --8<-- "docs/examples/guide/layout/utility_containers.py" ``` From bc620fd46f0053ca5af79f3c0029c89b588191fd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 26 Feb 2023 12:24:39 +0000 Subject: [PATCH 57/59] remove highlighted line --- docs/guide/layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 5062c492c..dd06bb16d 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -189,7 +189,7 @@ Let's take the [utility containers](#utility-containers) example and update it t === "utility_containers.css" - ```sass hl_lines="2" + ```sass --8<-- "docs/examples/guide/layout/utility_containers.css" ``` From 5b1cba1d85c82ca0619e764d232c0db7936918e8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 26 Feb 2023 12:31:50 +0000 Subject: [PATCH 58/59] update to copy --- docs/guide/layout.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/guide/layout.md b/docs/guide/layout.md index dd06bb16d..3fe1184e0 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -169,11 +169,13 @@ It's fine to do it this way, but Textual offers a simplified syntax using [conte 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 take the [utility containers](#utility-containers) example and update it to use the context manager approach. +Let's update the [utility containers](#utility-containers) example to use the context manager approach. === "utility_containers_using_with.py" - Composing with context managers. + !!! note + + This code uses context managers to compose widgets. ```python hl_lines="10-16" --8<-- "docs/examples/guide/layout/utility_containers_using_with.py" @@ -181,7 +183,9 @@ Let's take the [utility containers](#utility-containers) example and update it t === "utility_containers.py" - This is the original code. + !!! note + + This is the original code using positional arguments. ```python hl_lines="10-21" --8<-- "docs/examples/guide/layout/utility_containers.py" From 8ba789367be7658b0513888c18f0fb69649b4333 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 26 Feb 2023 16:54:29 +0000 Subject: [PATCH 59/59] catch exceptions from post mount --- CHANGELOG.md | 5 +++++ src/textual/dom.py | 1 + src/textual/message_pump.py | 2 +- src/textual/reactive.py | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 097ac54b6..706651f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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 + +### Fixed + +- Fix exceptions in watch methods being hidden on startup https://github.com/Textualize/textual/issues/1886 ## [0.12.1] - 2023-02-25 diff --git a/src/textual/dom.py b/src/textual/dom.py index 9980c9471..829b36796 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -221,6 +221,7 @@ 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: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7a2f6d24e..448df5323 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -429,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.""" diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 927b6a845..5942809f1 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -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)