Skip to content

Commit df37a9b

Browse files
authored
Add get_child_by_id and get_widget_by_id (#1146)
* Add get_child_by_id and get_widget_by_id * Remove redundant code * Add unit tests for app-level get_child_by_id and get_widget_by_id * Remove redundant test fixture injection * Update CHANGELOG * Enforce uniqueness of ID amongst widget children * Enforce unique widget IDs amongst widgets mounted together * Update CHANGELOG.md * Ensuring unique IDs in a more logical place * Add docstring to NodeList._get_by_id * Dont use duplicate IDs in tests, dont mount 2000 widgets * Mounting less widgets in a unit test * Reword error message * Use lower-level depth first search in get_widget_by_id to break out early
1 parent a465f5c commit df37a9b

File tree

10 files changed

+219
-64
lines changed

10 files changed

+219
-64
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## [0.5.0] - Unreleased
99

10-
1110
### Added
1211

12+
- Add get_child_by_id and get_widget_by_id, remove get_child https://github.com/Textualize/textual/pull/1146
1313
- Add easing parameter to Widget.scroll_* methods https://github.com/Textualize/textual/pull/1144
1414
- Added Widget.call_later which invokes a callback on idle.
1515
- `DOMNode.ancestors` no longer includes `self`.

src/textual/_node_list.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
from .widget import Widget
99

1010

11+
class DuplicateIds(Exception):
12+
pass
13+
14+
1115
@rich.repr.auto(angular=True)
1216
class NodeList(Sequence):
1317
"""
@@ -21,6 +25,12 @@ def __init__(self) -> None:
2125
# The nodes in the list
2226
self._nodes: list[Widget] = []
2327
self._nodes_set: set[Widget] = set()
28+
29+
# We cache widgets by their IDs too for a quick lookup
30+
# Note that only widgets with IDs are cached like this, so
31+
# this cache will likely hold fewer values than self._nodes.
32+
self._nodes_by_id: dict[str, Widget] = {}
33+
2434
# Increments when list is updated (used for caching)
2535
self._updates = 0
2636

@@ -53,6 +63,10 @@ def index(self, widget: Widget) -> int:
5363
"""
5464
return self._nodes.index(widget)
5565

66+
def _get_by_id(self, widget_id: str) -> Widget | None:
67+
"""Get the widget for the given widget_id, or None if there's no matches in this list"""
68+
return self._nodes_by_id.get(widget_id)
69+
5670
def _append(self, widget: Widget) -> None:
5771
"""Append a Widget.
5872
@@ -62,6 +76,10 @@ def _append(self, widget: Widget) -> None:
6276
if widget not in self._nodes_set:
6377
self._nodes.append(widget)
6478
self._nodes_set.add(widget)
79+
widget_id = widget.id
80+
if widget_id is not None:
81+
self._ensure_unique_id(widget_id)
82+
self._nodes_by_id[widget_id] = widget
6583
self._updates += 1
6684

6785
def _insert(self, index: int, widget: Widget) -> None:
@@ -73,8 +91,20 @@ def _insert(self, index: int, widget: Widget) -> None:
7391
if widget not in self._nodes_set:
7492
self._nodes.insert(index, widget)
7593
self._nodes_set.add(widget)
94+
widget_id = widget.id
95+
if widget_id is not None:
96+
self._ensure_unique_id(widget_id)
97+
self._nodes_by_id[widget_id] = widget
7698
self._updates += 1
7799

100+
def _ensure_unique_id(self, widget_id: str) -> None:
101+
if widget_id in self._nodes_by_id:
102+
raise DuplicateIds(
103+
f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} "
104+
f"already exists with that ID in this list of children. "
105+
f"The children of a widget must have unique IDs."
106+
)
107+
78108
def _remove(self, widget: Widget) -> None:
79109
"""Remove a widget from the list.
80110
@@ -86,13 +116,17 @@ def _remove(self, widget: Widget) -> None:
86116
if widget in self._nodes_set:
87117
del self._nodes[self._nodes.index(widget)]
88118
self._nodes_set.remove(widget)
119+
widget_id = widget.id
120+
if widget_id in self._nodes_by_id:
121+
del self._nodes_by_id[widget_id]
89122
self._updates += 1
90123

91124
def _clear(self) -> None:
92125
"""Clear the node list."""
93126
if self._nodes:
94127
self._nodes.clear()
95128
self._nodes_set.clear()
129+
self._nodes_by_id.clear()
96130
self._updates += 1
97131

98132
def __iter__(self) -> Iterator[Widget]:

src/textual/app.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
from .reactive import Reactive
6161
from .renderables.blank import Blank
6262
from .screen import Screen
63-
from .widget import AwaitMount, Widget
63+
from .widget import AwaitMount, Widget, MountError
6464

6565
if TYPE_CHECKING:
6666
from .devtools.client import DevtoolsClient
@@ -873,7 +873,7 @@ async def _on_css_change(self) -> None:
873873
def render(self) -> RenderableType:
874874
return Blank(self.styles.background)
875875

876-
def get_child(self, id: str) -> DOMNode:
876+
def get_child_by_id(self, id: str) -> Widget:
877877
"""Shorthand for self.screen.get_child(id: str)
878878
Returns the first child (immediate descendent) of this DOMNode
879879
with the given ID.
@@ -887,7 +887,26 @@ def get_child(self, id: str) -> DOMNode:
887887
Raises:
888888
NoMatches: if no children could be found for this ID
889889
"""
890-
return self.screen.get_child(id)
890+
return self.screen.get_child_by_id(id)
891+
892+
def get_widget_by_id(self, id: str) -> Widget:
893+
"""Shorthand for self.screen.get_widget_by_id(id)
894+
Return the first descendant widget with the given ID.
895+
896+
Performs a breadth-first search rooted at the current screen.
897+
It will not return the Screen if that matches the ID.
898+
To get the screen, use `self.screen`.
899+
900+
Args:
901+
id (str): The ID to search for in the subtree
902+
903+
Returns:
904+
DOMNode: The first descendant encountered with this ID.
905+
906+
Raises:
907+
NoMatches: if no children could be found for this ID
908+
"""
909+
return self.screen.get_widget_by_id(id)
891910

892911
def update_styles(self, node: DOMNode | None = None) -> None:
893912
"""Request update of styles.

src/textual/dom.py

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
2929
from .css.errors import DeclarationError, StyleValueError
3030
from .css.parse import parse_declarations
31-
from .css.query import NoMatches
3231
from .css.styles import RenderStyles, Styles
3332
from .css.tokenize import IDENTIFIER
3433
from .message_pump import MessagePump
@@ -645,7 +644,6 @@ def walk_children(
645644
list[DOMNode] | list[WalkType]: A list of nodes.
646645
647646
"""
648-
649647
check_type = filter_type or DOMNode
650648

651649
node_generator = (
@@ -661,23 +659,6 @@ def walk_children(
661659
nodes.reverse()
662660
return cast("list[DOMNode]", nodes)
663661

664-
def get_child(self, id: str) -> DOMNode:
665-
"""Return the first child (immediate descendent) of this node with the given ID.
666-
667-
Args:
668-
id (str): The ID of the child.
669-
670-
Returns:
671-
DOMNode: The first child of this node with the ID.
672-
673-
Raises:
674-
NoMatches: if no children could be found for this ID
675-
"""
676-
for child in self.children:
677-
if child.id == id:
678-
return child
679-
raise NoMatches(f"No child found with id={id!r}")
680-
681662
ExpectType = TypeVar("ExpectType", bound="Widget")
682663

683664
@overload

src/textual/walk.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Iterable, Iterator, TypeVar, overload, TYPE_CHECKING
55

66
if TYPE_CHECKING:
7-
from .dom import DOMNode
7+
from textual.dom import DOMNode
88

99
WalkType = TypeVar("WalkType", bound=DOMNode)
1010

@@ -51,6 +51,8 @@ def walk_depth_first(
5151
Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``.
5252
5353
"""
54+
from textual.dom import DOMNode
55+
5456
stack: list[Iterator[DOMNode]] = [iter(root.children)]
5557
pop = stack.pop
5658
push = stack.append
@@ -111,6 +113,8 @@ def walk_breadth_first(
111113
Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``.
112114
113115
"""
116+
from textual.dom import DOMNode
117+
114118
queue: deque[DOMNode] = deque()
115119
popleft = queue.popleft
116120
extend = queue.extend

src/textual/widget.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections import Counter
34
from asyncio import Lock, wait, create_task, Event as AsyncEvent
45
from fractions import Fraction
56
from itertools import islice
@@ -41,6 +42,7 @@
4142
from ._types import Lines
4243
from .binding import NoBinding
4344
from .box_model import BoxModel, get_box_model
45+
from .css.query import NoMatches
4446
from .css.scalar import ScalarOffset
4547
from .dom import DOMNode, NoScreen
4648
from .geometry import Offset, Region, Size, Spacing, clamp
@@ -50,6 +52,7 @@
5052
from .reactive import Reactive
5153
from .render import measure
5254
from .await_remove import AwaitRemove
55+
from .walk import walk_depth_first
5356

5457
if TYPE_CHECKING:
5558
from .app import App, ComposeResult
@@ -334,6 +337,43 @@ def offset(self) -> Offset:
334337
def offset(self, offset: Offset) -> None:
335338
self.styles.offset = ScalarOffset.from_offset(offset)
336339

340+
def get_child_by_id(self, id: str) -> Widget:
341+
"""Return the first child (immediate descendent) of this node with the given ID.
342+
343+
Args:
344+
id (str): The ID of the child.
345+
346+
Returns:
347+
DOMNode: The first child of this node with the ID.
348+
349+
Raises:
350+
NoMatches: if no children could be found for this ID
351+
"""
352+
child = self.children._get_by_id(id)
353+
if child is not None:
354+
return child
355+
raise NoMatches(f"No child found with id={id!r}")
356+
357+
def get_widget_by_id(self, id: str) -> Widget:
358+
"""Return the first descendant widget with the given ID.
359+
Performs a depth-first search rooted at this widget.
360+
361+
Args:
362+
id (str): The ID to search for in the subtree
363+
364+
Returns:
365+
DOMNode: The first descendant encountered with this ID.
366+
367+
Raises:
368+
NoMatches: if no children could be found for this ID
369+
"""
370+
for child in walk_depth_first(self):
371+
try:
372+
return child.get_child_by_id(id)
373+
except NoMatches:
374+
pass
375+
raise NoMatches(f"No descendant found with id={id!r}")
376+
337377
def get_component_rich_style(self, name: str) -> Style:
338378
"""Get a *Rich* style for a component.
339379
@@ -461,6 +501,20 @@ def mount(
461501
provided a ``MountError`` will be raised.
462502
"""
463503

504+
# Check for duplicate IDs in the incoming widgets
505+
ids_to_mount = [widget.id for widget in widgets if widget.id is not None]
506+
unique_ids = set(ids_to_mount)
507+
num_unique_ids = len(unique_ids)
508+
num_widgets_with_ids = len(ids_to_mount)
509+
if num_unique_ids != num_widgets_with_ids:
510+
counter = Counter(widget.id for widget in widgets)
511+
for widget_id, count in counter.items():
512+
if count > 1:
513+
raise MountError(
514+
f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. "
515+
f"Widget IDs must be unique."
516+
)
517+
464518
# Saying you want to mount before *and* after something is an error.
465519
if before is not None and after is not None:
466520
raise MountError(

tests/test_dom.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import pytest
22

33
from textual.css.errors import StyleValueError
4-
from textual.css.query import NoMatches
54
from textual.dom import DOMNode, BadIdentifier
65

76

@@ -26,37 +25,6 @@ def test_display_set_invalid_value():
2625
node.display = "blah"
2726

2827

29-
@pytest.fixture
30-
def parent():
31-
parent = DOMNode(id="parent")
32-
child1 = DOMNode(id="child1")
33-
child2 = DOMNode(id="child2")
34-
grandchild1 = DOMNode(id="grandchild1")
35-
child1._add_child(grandchild1)
36-
37-
parent._add_child(child1)
38-
parent._add_child(child2)
39-
40-
yield parent
41-
42-
43-
def test_get_child_gets_first_child(parent):
44-
child = parent.get_child(id="child1")
45-
assert child.id == "child1"
46-
assert child.get_child(id="grandchild1").id == "grandchild1"
47-
assert parent.get_child(id="child2").id == "child2"
48-
49-
50-
def test_get_child_no_matching_child(parent):
51-
with pytest.raises(NoMatches):
52-
parent.get_child(id="doesnt-exist")
53-
54-
55-
def test_get_child_only_immediate_descendents(parent):
56-
with pytest.raises(NoMatches):
57-
parent.get_child(id="grandchild1")
58-
59-
6028
def test_validate():
6129
with pytest.raises(BadIdentifier):
6230
DOMNode(id="23")

tests/test_unmount.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from textual.app import App, ComposeResult
24
from textual import events
35
from textual.containers import Container

0 commit comments

Comments
 (0)