Skip to content

Commit e501252

Browse files
Support text elements for PySide widgets that display text (#191)
Implement create_text_element() and set_element_text() in the PySideRenderer with a TextElementProxy object that holds the text content. Widgets that support text elements as children (QLabel and QAbstractButton subclasses) register insert/remove handlers that keep an ordered list of proxies on the parent widget and display the joined content through setText(). Fixes #189 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e83a4c5 commit e501252

5 files changed

Lines changed: 250 additions & 9 deletions

File tree

collagraph/renderers/pyside/objects/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
standarditem,
1414
statusbar,
1515
tab,
16+
textelement,
1617
toolbar,
1718
treewidget,
1819
treewidgetitem,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Support for text elements as children of widgets that display text,
3+
such as QLabel and QPushButton.
4+
5+
Text element proxies are kept in an ordered list on the parent widget
6+
and the joined content of all of them is displayed by the parent
7+
through its `setText` method.
8+
Register insert and remove functions for more widget types (that have
9+
a `setText` method) to support text elements as children.
10+
"""
11+
12+
from PySide6.QtWidgets import QAbstractButton, QLabel
13+
14+
from ...pyside_renderer import PySideRenderer, TextElementProxy
15+
from .widget import insert as widget_insert
16+
from .widget import remove as widget_remove
17+
18+
19+
@PySideRenderer.register_insert(QLabel, QAbstractButton)
20+
def insert(self, el, anchor=None):
21+
if not isinstance(el, TextElementProxy):
22+
widget_insert(self, el, anchor=anchor)
23+
return
24+
25+
if not hasattr(self, "_cg_text_proxies"):
26+
self._cg_text_proxies = []
27+
proxies = self._cg_text_proxies
28+
if anchor is not None and anchor in proxies:
29+
proxies.insert(proxies.index(anchor), el)
30+
else:
31+
proxies.append(el)
32+
el.set_parent(self)
33+
el.sync()
34+
35+
36+
@PySideRenderer.register_remove(QLabel, QAbstractButton)
37+
def remove(self, el):
38+
if not isinstance(el, TextElementProxy):
39+
widget_remove(self, el)
40+
return
41+
42+
self._cg_text_proxies.remove(el)
43+
el.sync()
44+
el.set_parent(None)

collagraph/renderers/pyside_renderer.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from functools import lru_cache, partial
44
from typing import Any, Callable
55
from warnings import warn
6+
from weakref import ref
67

78
from PySide6 import QtCore, QtGui, QtWidgets
89

@@ -96,6 +97,36 @@ def eventFilter(self, obj, event): # noqa: N802
9697
return super().eventFilter(obj, event)
9798

9899

100+
class TextElementProxy:
101+
"""
102+
Proxy object for text elements.
103+
104+
Widgets that support text elements as children keep an ordered
105+
list of these proxies and display the joined content of all of
106+
them through their `setText` method.
107+
See pyside/objects/textelement.py for which widget types support
108+
text elements and how insert/remove is implemented for them.
109+
"""
110+
111+
def __init__(self):
112+
self.content = ""
113+
self._parent_ref = None
114+
115+
def set_parent(self, parent):
116+
self._parent_ref = ref(parent) if parent is not None else None
117+
118+
def sync(self):
119+
"""Set the joined content of all the parent's text element
120+
proxies as the text of the parent widget."""
121+
parent = self._parent_ref() if self._parent_ref is not None else None
122+
if parent is None:
123+
return
124+
parent.setText("".join(proxy.content for proxy in parent._cg_text_proxies))
125+
126+
def __repr__(self):
127+
return f"<TextElementProxy({self.content!r})>"
128+
129+
99130
class PySideRenderer(Renderer):
100131
"""PySide6 renderer."""
101132

@@ -302,7 +333,7 @@ def create_object(cls, type_name: str) -> Any:
302333
return create_instance(WRAPPED_TYPES[type_name])
303334

304335
def create_text_element(self):
305-
raise NotImplementedError
336+
return TextElementProxy()
306337

307338
def insert(self, el: Any, parent: Any, anchor: Any = None):
308339
"""
@@ -352,7 +383,9 @@ def remove(self, el: Any, parent: Any):
352383
logger.exception(f"Error removing {el} from {parent}")
353384

354385
def set_element_text(self, el: Any, value: str):
355-
raise NotImplementedError
386+
"""Set the text of a text element (proxy)."""
387+
el.content = value
388+
el.sync()
356389

357390
def set_attribute(self, el: Any, attr: str, value: Any):
358391
"""Set the attribute `attr` of the element `el` to the value `value`."""

examples/pyside/counter.cgx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,8 @@
33
uv run collagraph examples/pyside/counter.cgx
44
-->
55
<widget>
6-
<label
7-
:text="f'Count: {count}'"
8-
/>
9-
<button
10-
text="bump"
11-
@clicked="bump"
12-
/>
6+
<label>Count: {{ count }}</label>
7+
<button @clicked="bump">bump</button>
138
</widget>
149

1510
<script>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import pytest
2+
from observ import reactive
3+
4+
pytest.importorskip("PySide6")
5+
6+
from PySide6 import QtCore, QtWidgets
7+
8+
import collagraph as cg
9+
from collagraph.renderers.pyside_renderer import TextElementProxy
10+
11+
12+
def test_create_text_element(qapp):
13+
renderer = cg.PySideRenderer(autoshow=False)
14+
15+
proxy = renderer.create_text_element()
16+
17+
assert isinstance(proxy, TextElementProxy)
18+
assert proxy.content == ""
19+
20+
21+
def test_label_text_element(qapp):
22+
renderer = cg.PySideRenderer(autoshow=False)
23+
label = renderer.create_element("label")
24+
proxy = renderer.create_text_element()
25+
26+
# Setting text before insert only updates the proxy
27+
renderer.set_element_text(proxy, "Hello")
28+
assert label.text() == ""
29+
30+
renderer.insert(proxy, label)
31+
assert label.text() == "Hello"
32+
33+
renderer.set_element_text(proxy, "Hello world")
34+
assert label.text() == "Hello world"
35+
36+
renderer.remove(proxy, label)
37+
assert label.text() == ""
38+
39+
40+
def test_button_text_element(qapp):
41+
renderer = cg.PySideRenderer(autoshow=False)
42+
button = renderer.create_element("button")
43+
proxy = renderer.create_text_element()
44+
45+
renderer.set_element_text(proxy, "Click me")
46+
renderer.insert(proxy, button)
47+
48+
assert button.text() == "Click me"
49+
50+
51+
def test_text_element_order_with_anchor(qapp):
52+
renderer = cg.PySideRenderer(autoshow=False)
53+
label = renderer.create_element("label")
54+
55+
first = renderer.create_text_element()
56+
second = renderer.create_text_element()
57+
third = renderer.create_text_element()
58+
renderer.set_element_text(first, "a")
59+
renderer.set_element_text(second, "b")
60+
renderer.set_element_text(third, "c")
61+
62+
renderer.insert(first, label)
63+
renderer.insert(third, label)
64+
# Insert with anchor should insert before the anchor
65+
renderer.insert(second, label, anchor=third)
66+
67+
assert label.text() == "abc"
68+
69+
renderer.remove(second, label)
70+
assert label.text() == "ac"
71+
72+
73+
def test_text_element_in_plain_widget_fails(qapp, caplog):
74+
renderer = cg.PySideRenderer(autoshow=False)
75+
widget = renderer.create_element("widget")
76+
proxy = renderer.create_text_element()
77+
78+
# Widget does not support text elements: the error is logged
79+
renderer.insert(proxy, widget)
80+
81+
assert "Error inserting" in caplog.text
82+
83+
84+
def test_label_child_text(qtbot, parse_source):
85+
"""Test that child text of labels and buttons updates with state."""
86+
Counter, _ = parse_source(
87+
"""
88+
<widget>
89+
<label>Count: {{ count }}</label>
90+
<button @clicked="bump">bump</button>
91+
</widget>
92+
93+
<script>
94+
import collagraph as cg
95+
96+
class Counter(cg.Component):
97+
def init(self):
98+
self.state["count"] = 0
99+
100+
def bump(self):
101+
self.state["count"] += 1
102+
</script>
103+
"""
104+
)
105+
106+
renderer = cg.PySideRenderer(autoshow=False)
107+
gui = cg.Collagraph(renderer=renderer)
108+
container = renderer.create_element("widget")
109+
gui.render(Counter, container)
110+
111+
label = None
112+
button = None
113+
114+
def widgets_are_found():
115+
nonlocal label
116+
nonlocal button
117+
label = container.findChild(QtWidgets.QLabel)
118+
button = container.findChild(QtWidgets.QPushButton)
119+
assert label and button
120+
121+
qtbot.waitUntil(widgets_are_found, timeout=500)
122+
123+
assert label.text() == "Count: 0"
124+
assert button.text() == "bump"
125+
126+
qtbot.mouseClick(button, QtCore.Qt.LeftButton)
127+
qtbot.waitUntil(lambda: label.text() == "Count: 1", timeout=500)
128+
129+
130+
def test_conditional_child_text(qtbot, parse_source):
131+
"""Test that text elements are properly removed on unmount."""
132+
Example, _ = parse_source(
133+
"""
134+
<label object_name="label">
135+
<template v-if="show">{{ message }}</template>
136+
</label>
137+
138+
<script>
139+
import collagraph as cg
140+
141+
class Example(cg.Component):
142+
pass
143+
</script>
144+
"""
145+
)
146+
147+
renderer = cg.PySideRenderer(autoshow=False)
148+
gui = cg.Collagraph(renderer=renderer)
149+
container = renderer.create_element("widget")
150+
state = reactive({"show": True, "message": "Hello"})
151+
gui.render(Example, container, state=state)
152+
153+
label = None
154+
155+
def label_is_found():
156+
nonlocal label
157+
label = container.findChild(QtWidgets.QLabel, name="label")
158+
assert label
159+
160+
qtbot.waitUntil(label_is_found, timeout=500)
161+
162+
qtbot.waitUntil(lambda: label.text() == "Hello", timeout=500)
163+
164+
state["message"] = "Hi"
165+
qtbot.waitUntil(lambda: label.text() == "Hi", timeout=500)
166+
167+
state["show"] = False
168+
qtbot.waitUntil(lambda: label.text() == "", timeout=500)

0 commit comments

Comments
 (0)