experimental app getter

This commit is contained in:
Will McGugan
2025-08-31 11:38:12 +01:00
parent dcc495ab42
commit 995c2b7e6d
4 changed files with 97 additions and 23 deletions

View File

@@ -5,12 +5,53 @@ Descriptors to define properties on your widget, screen, or App.
from __future__ import annotations 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.css.query import NoMatches, QueryType, WrongType
from textual.dom import DOMNode
from textual.widget import Widget 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]): class query_one(Generic[QueryType]):
"""Create a query one property. """Create a query one property.
@@ -45,7 +86,7 @@ class query_one(Generic[QueryType]):
""" """
selector: str selector: str
expect_type: type[Widget] expect_type: type["Widget"]
@overload @overload
def __init__(self, selector: str) -> None: def __init__(self, selector: str) -> None:
@@ -72,6 +113,8 @@ class query_one(Generic[QueryType]):
expect_type: type[QueryType] | None = None, expect_type: type[QueryType] | None = None,
) -> None: ) -> None:
if expect_type is None: if expect_type is None:
from textual.widget import Widget
self.expect_type = Widget self.expect_type = Widget
else: else:
self.expect_type = expect_type self.expect_type = expect_type

View File

@@ -227,29 +227,35 @@ class MessagePump(metaclass=_MessagePumpMeta):
"""Is this a root node (i.e. the App)?""" """Is this a root node (i.e. the App)?"""
return False return False
@property if TYPE_CHECKING:
def app(self) -> "App[object]": from textual import getters
"""
Get the current app.
Returns: app = getters.app(App)
The current app. else:
Raises: @property
NoActiveAppError: if no active app could be found for the current asyncio context def app(self) -> "App[object]":
""" """
try: Get the current app.
return active_app.get()
except LookupError:
from textual.app import App
node: MessagePump | None = self Returns:
while not isinstance(node, App): The current app.
if node is None:
raise NoActiveAppError()
node = node._parent
return node 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
return node
@property @property
def is_attached(self) -> bool: def is_attached(self) -> bool:

View File

@@ -662,6 +662,11 @@ TextArea {
self._line_cache.clear() self._line_cache.clear()
super().notify_style_update() 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: def check_consume_key(self, key: str, character: str | None = None) -> bool:
"""Check if the widget may consume the given key. """Check if the widget may consume the given key.
@@ -1534,7 +1539,10 @@ TextArea {
Data relating to the edit that may be useful. The data returned Data relating to the edit that may be useful. The data returned
may be different depending on the edit performed. 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 old_gutter_width = self.gutter_width
result = edit.do(self) result = edit.do(self)
self.history.record(edit) self.history.record(edit)
@@ -1553,6 +1561,7 @@ TextArea {
edit.after(self) edit.after(self)
self._build_highlight_map() self._build_highlight_map()
self.post_message(self.Changed(self)) self.post_message(self.Changed(self))
self.update_suggestion()
return result return result
def undo(self) -> None: def undo(self) -> None:

View File

@@ -43,3 +43,19 @@ async def test_getters() -> None:
with pytest.raises(NoMatches): with pytest.raises(NoMatches):
app.label2_missing 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)