Skip to content

Commit e4c85ff

Browse files
authored
1 parent 225fa24 commit e4c85ff

14 files changed

+274
-15
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1818

1919
- Added `DOMNode.has_pseudo_classes` https://github.com/Textualize/textual/pull/3970
2020
- Added `Widget.allow_focus` and `Widget.allow_focus_children` https://github.com/Textualize/textual/pull/3989
21+
- Added `Query.blur` and `Query.focus` https://github.com/Textualize/textual/pull/4012
22+
- Added `MessagePump.message_queue_size` https://github.com/Textualize/textual/pull/4012
23+
- Added `TabbedContent.active_pane` https://github.com/Textualize/textual/pull/4012
2124

2225
### Fixed
2326

@@ -27,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2730
- `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903
2831
- Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998
2932
- Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011
33+
- Fixed declaration after nested rule set causing a parse error https://github.com/Textualize/textual/pull/4012
3034
- ID and class validation was too lenient https://github.com/Textualize/textual/issues/3954
3135
- Fixed a crash if the `TextArea` language was set but tree-sitter lanuage binaries were not installed https://github.com/Textualize/textual/issues/4045
3236

src/textual/css/query.py

+25
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,28 @@ def refresh(
430430
for node in self:
431431
node.refresh(repaint=repaint, layout=layout)
432432
return self
433+
434+
def focus(self) -> DOMQuery[QueryType]:
435+
"""Focus the first matching node that permits focus.
436+
437+
Returns:
438+
Query for chaining.
439+
"""
440+
for node in self:
441+
if node.allow_focus():
442+
node.focus()
443+
break
444+
return self
445+
446+
def blur(self) -> DOMQuery[QueryType]:
447+
"""Blur the first matching node that is focused.
448+
449+
Returns:
450+
Query for chaining.
451+
"""
452+
focused = self._node.screen.focused
453+
if focused is not None:
454+
nodes: list[Widget] = list(self)
455+
if focused in nodes:
456+
self._node.screen._reset_focus(focused, avoiding=nodes)
457+
return self

src/textual/css/tokenize.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]:
210210
nest_level += 1
211211
elif name == "declaration_set_end":
212212
nest_level -= 1
213-
expect = expect_root_nested if nest_level else expect_root_scope
213+
expect = expect_declaration if nest_level else expect_root_scope
214214
yield token
215215
continue
216216
expect = get_state(name, expect)

src/textual/css/tokenizer.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,14 @@ def get_token(self, expect: Expect) -> Token:
248248
line = self.lines[line_no]
249249
match = expect.match(line, col_no)
250250
if match is None:
251+
error_line = line[col_no:].rstrip()
252+
error_message = (
253+
f"{expect.description} (found {error_line.split(';')[0]!r})."
254+
)
255+
if not error_line.endswith(";"):
256+
error_message += "; Did you forget a semicolon at the end of a line?"
251257
raise TokenError(
252-
self.read_from,
253-
self.code,
254-
(line_no + 1, col_no + 1),
255-
f"{expect.description} (found {line[col_no:].rstrip()!r}).; Did you forget a semicolon at the end of a line?",
258+
self.read_from, self.code, (line_no + 1, col_no + 1), error_message
256259
)
257260
iter_groups = iter(match.groups())
258261

src/textual/message.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from .case import camel_to_snake
1515

1616
if TYPE_CHECKING:
17+
from .dom import DOMNode
1718
from .message_pump import MessagePump
18-
from .widget import Widget
1919

2020

2121
@rich.repr.auto
@@ -77,7 +77,7 @@ def __init_subclass__(
7777
cls.handler_name = f"on_{namespace}_{name}" if namespace else f"on_{name}"
7878

7979
@property
80-
def control(self) -> Widget | None:
80+
def control(self) -> DOMNode | None:
8181
"""The widget associated with this message, or None by default."""
8282
return None
8383

src/textual/message_pump.py

+5
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ def has_parent(self) -> bool:
188188
"""Does this object have a parent?"""
189189
return self._parent is not None
190190

191+
@property
192+
def message_queue_size(self) -> int:
193+
"""The current size of the message queue."""
194+
return self._message_queue.qsize()
195+
191196
@property
192197
def app(self) -> "App[object]":
193198
"""

src/textual/signal.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Signals are a simple pub-sub mechanism.
3+
4+
DOMNodes can subscribe to a signal, which will invoke a callback when the signal is published.
5+
6+
This is experimental for now, for internal use. It may be part of the public API in a future release.
7+
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import TYPE_CHECKING
13+
from weakref import WeakKeyDictionary
14+
15+
import rich.repr
16+
17+
from textual import log
18+
19+
if TYPE_CHECKING:
20+
from ._types import IgnoreReturnCallbackType
21+
from .dom import DOMNode
22+
23+
24+
class SignalError(Exception):
25+
"""Base class for a signal."""
26+
27+
28+
@rich.repr.auto(angular=True)
29+
class Signal:
30+
"""A signal that a widget may subscribe to, in order to invoke callbacks when an associated event occurs."""
31+
32+
def __init__(self, owner: DOMNode, name: str) -> None:
33+
"""Initialize a signal.
34+
35+
Args:
36+
owner: The owner of this signal.
37+
name: An identifier for debugging purposes.
38+
"""
39+
self._owner = owner
40+
self._name = name
41+
self._subscriptions: WeakKeyDictionary[
42+
DOMNode, list[IgnoreReturnCallbackType]
43+
] = WeakKeyDictionary()
44+
45+
def __rich_repr__(self) -> rich.repr.Result:
46+
yield "owner", self._owner
47+
yield "name", self._name
48+
yield "subscriptions", list(self._subscriptions.keys())
49+
50+
def subscribe(self, node: DOMNode, callback: IgnoreReturnCallbackType) -> None:
51+
"""Subscribe a node to this signal.
52+
53+
When the signal is published, the callback will be invoked.
54+
55+
Args:
56+
node: Node to subscribe.
57+
callback: A callback function which takes no arguments, and returns anything (return type ignored).
58+
"""
59+
if not node.is_running:
60+
raise SignalError(
61+
f"Node must be running to subscribe to a signal (has {node} been mounted)?"
62+
)
63+
callbacks = self._subscriptions.setdefault(node, [])
64+
if callback not in callbacks:
65+
callbacks.append(callback)
66+
67+
def unsubscribe(self, node: DOMNode) -> None:
68+
"""Unsubscribe a node from this signal.
69+
70+
Args:
71+
node: Node to unsubscribe,
72+
"""
73+
self._subscriptions.pop(node, None)
74+
75+
def publish(self) -> None:
76+
"""Publish the signal (invoke subscribed callbacks)."""
77+
78+
for node, callbacks in list(self._subscriptions.items()):
79+
if not node.is_running:
80+
# Removed nodes that are no longer running
81+
self._subscriptions.pop(node)
82+
else:
83+
# Call callbacks
84+
for callback in callbacks:
85+
try:
86+
callback()
87+
except Exception as error:
88+
log.error(f"error publishing signal to {node} ignored; {error}")

src/textual/widget.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -2806,10 +2806,10 @@ def _get_scrollable_region(self, region: Region) -> Region:
28062806
scrollbar_size_horizontal = styles.scrollbar_size_horizontal
28072807
scrollbar_size_vertical = styles.scrollbar_size_vertical
28082808

2809-
show_vertical_scrollbar: bool = (
2809+
show_vertical_scrollbar: bool = bool(
28102810
show_vertical_scrollbar and scrollbar_size_vertical
28112811
)
2812-
show_horizontal_scrollbar: bool = (
2812+
show_horizontal_scrollbar: bool = bool(
28132813
show_horizontal_scrollbar and scrollbar_size_horizontal
28142814
)
28152815

@@ -2843,10 +2843,10 @@ def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]]
28432843
scrollbar_size_horizontal = self.scrollbar_size_horizontal
28442844
scrollbar_size_vertical = self.scrollbar_size_vertical
28452845

2846-
show_vertical_scrollbar: bool = (
2846+
show_vertical_scrollbar: bool = bool(
28472847
show_vertical_scrollbar and scrollbar_size_vertical
28482848
)
2849-
show_horizontal_scrollbar: bool = (
2849+
show_horizontal_scrollbar: bool = bool(
28502850
show_horizontal_scrollbar and scrollbar_size_horizontal
28512851
)
28522852

src/textual/widgets/_tabbed_content.py

+5
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,11 @@ def __init__(
321321
self._initial = initial
322322
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
323323

324+
@property
325+
def active_pane(self) -> TabPane | None:
326+
"""The currently active pane, or `None` if no pane is active."""
327+
return self.get_pane(self.active)
328+
324329
def validate_active(self, active: str) -> str:
325330
"""It doesn't make sense for `active` to be an empty string.
326331

tests/snapshot_tests/snapshot_apps/nested_specificity.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ class NestedCSS(BaseTester):
3131
DEFAULT_CSS = """
3232
NestedCSS {
3333
width: 1fr;
34-
height: 1fr;
35-
background: green 10%;
36-
border: blank;
34+
height: 1fr;
3735
3836
&:focus {
3937
background: green 20%;
4038
border: round green;
4139
}
40+
41+
background: green 10%;
42+
border: blank;
4243
}
4344
"""
4445

tests/test_message_pump.py

+20
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from textual.app import App, ComposeResult
44
from textual.errors import DuplicateKeyHandlers
55
from textual.events import Key
6+
from textual.message import Message
67
from textual.widget import Widget
78
from textual.widgets import Input
89

@@ -70,6 +71,25 @@ def on_input_changed(self, event: Input.Changed) -> None:
7071
self.input_changed_events.append(event)
7172

7273

74+
async def test_message_queue_size():
75+
"""Test message queue size property."""
76+
app = App()
77+
assert app.message_queue_size == 0
78+
79+
class TestMessage(Message):
80+
pass
81+
82+
async with app.run_test() as pilot:
83+
assert app.message_queue_size == 0
84+
app.post_message(TestMessage())
85+
assert app.message_queue_size == 1
86+
app.post_message(TestMessage())
87+
assert app.message_queue_size == 2
88+
# A pause will process all the messages
89+
await pilot.pause()
90+
assert app.message_queue_size == 0
91+
92+
7393
async def test_prevent() -> None:
7494
app = PreventTestApp()
7595

tests/test_query.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
WrongType,
1212
)
1313
from textual.widget import Widget
14-
from textual.widgets import Label
14+
from textual.widgets import Input, Label
1515

1616

1717
def test_query():
@@ -313,3 +313,33 @@ def compose(self):
313313
async with app.run_test() as pilot:
314314
app.query(MyWidget).refresh(repaint=args[0], layout=args[1])
315315
assert refreshes[-1] == args
316+
317+
318+
async def test_query_focus_blur():
319+
class FocusApp(App):
320+
AUTO_FOCUS = None
321+
322+
def compose(self) -> ComposeResult:
323+
yield Input(id="foo")
324+
yield Input(id="bar")
325+
yield Input(id="baz")
326+
327+
app = FocusApp()
328+
async with app.run_test() as pilot:
329+
# Nothing focused
330+
assert app.focused is None
331+
# Focus first input
332+
app.query(Input).focus()
333+
await pilot.pause()
334+
assert app.focused.id == "foo"
335+
# Blur inputs
336+
app.query(Input).blur()
337+
await pilot.pause()
338+
assert app.focused is None
339+
# Focus another
340+
app.query("#bar").focus()
341+
await pilot.pause()
342+
assert app.focused.id == "bar"
343+
# Focus non existing
344+
app.query("#egg").focus()
345+
assert app.focused.id == "bar"

0 commit comments

Comments
 (0)