From 995c2b7e6da08976952f9ebe8bc41c121f4516dc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 31 Aug 2025 11:38:12 +0100 Subject: [PATCH] experimental app getter --- src/textual/getters.py | 49 +++++++++++++++++++++++++++++-- src/textual/message_pump.py | 44 +++++++++++++++------------ src/textual/widgets/_text_area.py | 11 ++++++- tests/test_getters.py | 16 ++++++++++ 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/src/textual/getters.py b/src/textual/getters.py index c8bcfddfab..5b0840ce11 100644 --- a/src/textual/getters.py +++ b/src/textual/getters.py @@ -5,12 +5,53 @@ from __future__ import annotations -from typing import Generic, overload +from typing import TYPE_CHECKING, Generic, TypeVar, overload +from textual._context import NoActiveAppError, active_app from textual.css.query import NoMatches, QueryType, WrongType -from textual.dom import DOMNode from textual.widget import Widget +if TYPE_CHECKING: + from textual.app import App + from textual.dom import DOMNode + from textual.message_pump import MessagePump + + +AppType = TypeVar("AppType", bound="App") + + +class app(Generic[AppType]): + """Create a property to return the active app. + + Example: + ```python + class MyWidget(Widget): + app = getters.app(MyApp) + ``` + + Args: + app_type: The app class. + """ + + def __init__(self, app_type: type[AppType]) -> None: + self._app_type = app_type + + def __get__(self, obj: MessagePump, obj_type: type[MessagePump]) -> AppType: + try: + app = active_app.get() + except LookupError: + from textual.app import App + + node: MessagePump | None = obj + while not isinstance(node, App): + if node is None: + raise NoActiveAppError() + node = node._parent + app = node + + assert isinstance(app, self._app_type) + return app + class query_one(Generic[QueryType]): """Create a query one property. @@ -45,7 +86,7 @@ def on_mount(self) -> None: """ selector: str - expect_type: type[Widget] + expect_type: type["Widget"] @overload def __init__(self, selector: str) -> None: @@ -72,6 +113,8 @@ def __init__( expect_type: type[QueryType] | None = None, ) -> None: if expect_type is None: + from textual.widget import Widget + self.expect_type = Widget else: self.expect_type = expect_type diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4d2f4ccf27..fba821fafb 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -227,29 +227,35 @@ def is_dom_root(self): """Is this a root node (i.e. the App)?""" return False - @property - def app(self) -> "App[object]": - """ - Get the current app. + if TYPE_CHECKING: + from textual import getters - Returns: - The current app. + app = getters.app(App) + else: - Raises: - NoActiveAppError: if no active app could be found for the current asyncio context - """ - try: - return active_app.get() - except LookupError: - from textual.app import App + @property + def app(self) -> "App[object]": + """ + Get the current app. + + Returns: + The current app. + + Raises: + NoActiveAppError: if no active app could be found for the current asyncio context + """ + try: + return active_app.get() + except LookupError: + from textual.app import App - node: MessagePump | None = self - while not isinstance(node, App): - if node is None: - raise NoActiveAppError() - node = node._parent + node: MessagePump | None = self + while not isinstance(node, App): + if node is None: + raise NoActiveAppError() + node = node._parent - return node + return node @property def is_attached(self) -> bool: diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 3108b16c32..edf1d2002b 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -662,6 +662,11 @@ def notify_style_update(self) -> None: self._line_cache.clear() super().notify_style_update() + def update_suggestion(self) -> None: + """A hook called after edits, to allow subclasses to update the + [`suggestion`][textual.widgets.TextArea.suggestion] attribute. + """ + def check_consume_key(self, key: str, character: str | None = None) -> bool: """Check if the widget may consume the given key. @@ -1534,7 +1539,10 @@ def edit(self, edit: Edit) -> EditResult: Data relating to the edit that may be useful. The data returned may be different depending on the edit performed. """ - self.suggestion = "" + if self.suggestion.startswith(edit.text): + self.suggestion = self.suggestion[len(edit.text) :] + else: + self.suggestion = "" old_gutter_width = self.gutter_width result = edit.do(self) self.history.record(edit) @@ -1553,6 +1561,7 @@ def edit(self, edit: Edit) -> EditResult: edit.after(self) self._build_highlight_map() self.post_message(self.Changed(self)) + self.update_suggestion() return result def undo(self) -> None: diff --git a/tests/test_getters.py b/tests/test_getters.py index f92173e94c..2076f10dd6 100644 --- a/tests/test_getters.py +++ b/tests/test_getters.py @@ -43,3 +43,19 @@ def compose(self) -> ComposeResult: with pytest.raises(NoMatches): app.label2_missing + + +async def test_app_getter() -> None: + + class MyApp(App): + def compose(self) -> ComposeResult: + my_widget = MyWidget() + my_widget.app + yield my_widget + + class MyWidget(Widget): + app = getters.app(MyApp) + + app = MyApp() + async with app.run_test(): + assert isinstance(app.query_one(MyWidget).app, MyApp)