Skip to content

Commit 995c2b7

Browse files
committed
experimental app getter
1 parent dcc495a commit 995c2b7

File tree

4 files changed

+97
-23
lines changed

4 files changed

+97
-23
lines changed

src/textual/getters.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,53 @@
55

66
from __future__ import annotations
77

8-
from typing import Generic, overload
8+
from typing import TYPE_CHECKING, Generic, TypeVar, overload
99

10+
from textual._context import NoActiveAppError, active_app
1011
from textual.css.query import NoMatches, QueryType, WrongType
11-
from textual.dom import DOMNode
1212
from textual.widget import Widget
1313

14+
if TYPE_CHECKING:
15+
from textual.app import App
16+
from textual.dom import DOMNode
17+
from textual.message_pump import MessagePump
18+
19+
20+
AppType = TypeVar("AppType", bound="App")
21+
22+
23+
class app(Generic[AppType]):
24+
"""Create a property to return the active app.
25+
26+
Example:
27+
```python
28+
class MyWidget(Widget):
29+
app = getters.app(MyApp)
30+
```
31+
32+
Args:
33+
app_type: The app class.
34+
"""
35+
36+
def __init__(self, app_type: type[AppType]) -> None:
37+
self._app_type = app_type
38+
39+
def __get__(self, obj: MessagePump, obj_type: type[MessagePump]) -> AppType:
40+
try:
41+
app = active_app.get()
42+
except LookupError:
43+
from textual.app import App
44+
45+
node: MessagePump | None = obj
46+
while not isinstance(node, App):
47+
if node is None:
48+
raise NoActiveAppError()
49+
node = node._parent
50+
app = node
51+
52+
assert isinstance(app, self._app_type)
53+
return app
54+
1455

1556
class query_one(Generic[QueryType]):
1657
"""Create a query one property.
@@ -45,7 +86,7 @@ def on_mount(self) -> None:
4586
"""
4687

4788
selector: str
48-
expect_type: type[Widget]
89+
expect_type: type["Widget"]
4990

5091
@overload
5192
def __init__(self, selector: str) -> None:
@@ -72,6 +113,8 @@ def __init__(
72113
expect_type: type[QueryType] | None = None,
73114
) -> None:
74115
if expect_type is None:
116+
from textual.widget import Widget
117+
75118
self.expect_type = Widget
76119
else:
77120
self.expect_type = expect_type

src/textual/message_pump.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -227,29 +227,35 @@ def is_dom_root(self):
227227
"""Is this a root node (i.e. the App)?"""
228228
return False
229229

230-
@property
231-
def app(self) -> "App[object]":
232-
"""
233-
Get the current app.
230+
if TYPE_CHECKING:
231+
from textual import getters
234232

235-
Returns:
236-
The current app.
233+
app = getters.app(App)
234+
else:
237235

238-
Raises:
239-
NoActiveAppError: if no active app could be found for the current asyncio context
240-
"""
241-
try:
242-
return active_app.get()
243-
except LookupError:
244-
from textual.app import App
236+
@property
237+
def app(self) -> "App[object]":
238+
"""
239+
Get the current app.
240+
241+
Returns:
242+
The current app.
243+
244+
Raises:
245+
NoActiveAppError: if no active app could be found for the current asyncio context
246+
"""
247+
try:
248+
return active_app.get()
249+
except LookupError:
250+
from textual.app import App
245251

246-
node: MessagePump | None = self
247-
while not isinstance(node, App):
248-
if node is None:
249-
raise NoActiveAppError()
250-
node = node._parent
252+
node: MessagePump | None = self
253+
while not isinstance(node, App):
254+
if node is None:
255+
raise NoActiveAppError()
256+
node = node._parent
251257

252-
return node
258+
return node
253259

254260
@property
255261
def is_attached(self) -> bool:

src/textual/widgets/_text_area.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,11 @@ def notify_style_update(self) -> None:
662662
self._line_cache.clear()
663663
super().notify_style_update()
664664

665+
def update_suggestion(self) -> None:
666+
"""A hook called after edits, to allow subclasses to update the
667+
[`suggestion`][textual.widgets.TextArea.suggestion] attribute.
668+
"""
669+
665670
def check_consume_key(self, key: str, character: str | None = None) -> bool:
666671
"""Check if the widget may consume the given key.
667672
@@ -1534,7 +1539,10 @@ def edit(self, edit: Edit) -> EditResult:
15341539
Data relating to the edit that may be useful. The data returned
15351540
may be different depending on the edit performed.
15361541
"""
1537-
self.suggestion = ""
1542+
if self.suggestion.startswith(edit.text):
1543+
self.suggestion = self.suggestion[len(edit.text) :]
1544+
else:
1545+
self.suggestion = ""
15381546
old_gutter_width = self.gutter_width
15391547
result = edit.do(self)
15401548
self.history.record(edit)
@@ -1553,6 +1561,7 @@ def edit(self, edit: Edit) -> EditResult:
15531561
edit.after(self)
15541562
self._build_highlight_map()
15551563
self.post_message(self.Changed(self))
1564+
self.update_suggestion()
15561565
return result
15571566

15581567
def undo(self) -> None:

tests/test_getters.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,19 @@ def compose(self) -> ComposeResult:
4343

4444
with pytest.raises(NoMatches):
4545
app.label2_missing
46+
47+
48+
async def test_app_getter() -> None:
49+
50+
class MyApp(App):
51+
def compose(self) -> ComposeResult:
52+
my_widget = MyWidget()
53+
my_widget.app
54+
yield my_widget
55+
56+
class MyWidget(Widget):
57+
app = getters.app(MyApp)
58+
59+
app = MyApp()
60+
async with app.run_test():
61+
assert isinstance(app.query_one(MyWidget).app, MyApp)

0 commit comments

Comments
 (0)