Skip to content

Commit acc66e1

Browse files
committed
Adds object binding with change watching support
1 parent d2d1a8e commit acc66e1

File tree

3 files changed

+167
-5
lines changed

3 files changed

+167
-5
lines changed

src/compas_viewer/components/boundcomponent.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class BoundComponent(Component):
2121
The name of the attribute/key to be bound.
2222
action : Callable[[Component, float], None]
2323
A function to call when the value changes. Receives the component and new value.
24+
watch_interval : int, optional
25+
Interval in milliseconds to check for changes in the bound object.
26+
If None, watching is disabled. Default is 100.
2427
2528
Attributes
2629
----------
@@ -30,6 +33,12 @@ class BoundComponent(Component):
3033
The name of the attribute/key being bound.
3134
action : Callable[[Component, float], None]
3235
The action function to call when the value changes.
36+
watch_interval : int or None
37+
The watching interval in milliseconds, or None if watching is disabled. Default is 100.
38+
_watch_timer : Timer or None
39+
The timer used for watching changes in the bound object.
40+
_last_watched_value : Any
41+
The last known value of the bound attribute from watching.
3342
3443
Example
3544
-------
@@ -39,17 +48,28 @@ class BoundComponent(Component):
3948
>>> def my_action(component, value):
4049
... print(f"Value changed to: {value}")
4150
>>> obj = MyObject()
51+
>>> # Component with default watcher (100ms)
4252
>>> component = BoundComponent(obj, "value", my_action)
43-
>>> component.set_attr(20.0)
44-
>>> print(component.get_attr()) # prints 20.0
53+
>>> # Component without watcher
54+
>>> component = BoundComponent(obj, "value", my_action, watch_interval=None)
55+
>>> # Component with custom watcher interval
56+
>>> component = BoundComponent(obj, "value", my_action, watch_interval=200)
4557
"""
4658

47-
def __init__(self, obj: Union[object, dict], attr: str, action: Callable[[Component, float], None]):
59+
def __init__(self, obj: Union[object, dict], attr: str, action: Callable[[Component, float], None], watch_interval: int = 100):
4860
super().__init__()
4961

5062
self.obj = obj
5163
self.attr = attr
5264
self.action = action
65+
self.watch_interval = watch_interval
66+
self._watch_timer = None
67+
self._last_watched_value = None
68+
self._updating_from_watch = False
69+
70+
# Start watching if interval is provided
71+
if self.watch_interval is not None:
72+
self.start_watching()
5373

5474
def get_attr(self):
5575
"""
@@ -98,3 +118,86 @@ def on_value_changed(self, value: Any):
98118
self.set_attr(value)
99119
if self.action is not None:
100120
self.action(self, value)
121+
122+
def start_watching(self):
123+
"""
124+
Start watching the bound object for changes.
125+
126+
This method starts a timer that periodically checks if the bound attribute
127+
has changed and updates the component accordingly.
128+
"""
129+
if self.watch_interval is None:
130+
return
131+
132+
if self._watch_timer is not None:
133+
self.stop_watching()
134+
135+
from compas_viewer.timer import Timer
136+
137+
self._last_watched_value = self.get_attr()
138+
self._watch_timer = Timer(self.watch_interval, self._check_for_changes)
139+
140+
def stop_watching(self):
141+
"""
142+
Stop watching the bound object for changes.
143+
"""
144+
if self._watch_timer is not None:
145+
self._watch_timer.stop()
146+
self._watch_timer = None
147+
148+
def _check_for_changes(self):
149+
"""
150+
Check if the bound attribute has changed and update the component if needed.
151+
152+
This method is called periodically by the watch timer.
153+
"""
154+
if self._updating_from_watch:
155+
return
156+
157+
# Skip checking if widget is not visible to save resources
158+
if not self.widget.isVisible():
159+
return
160+
161+
current_value = self.get_attr()
162+
if current_value != self._last_watched_value:
163+
self._last_watched_value = current_value
164+
self._updating_from_watch = True
165+
try:
166+
print("sync from bound object", self.obj, self.attr)
167+
self.sync_from_bound_object(current_value)
168+
finally:
169+
self._updating_from_watch = False
170+
171+
def sync_from_bound_object(self, value: Any):
172+
"""
173+
Sync the component's display with the bound object's value.
174+
175+
This method should be overridden by subclasses to update their
176+
specific UI elements when the bound object changes.
177+
178+
Parameters
179+
----------
180+
value : Any
181+
The new value from the bound object.
182+
"""
183+
# Base implementation does nothing - subclasses should override
184+
pass
185+
186+
def set_watch_interval(self, interval: int):
187+
"""
188+
Set or change the watch interval.
189+
190+
Parameters
191+
----------
192+
interval : int or None
193+
The new interval in milliseconds, or None to disable watching.
194+
"""
195+
was_watching = self._watch_timer is not None
196+
197+
if was_watching:
198+
self.stop_watching()
199+
200+
self.watch_interval = interval
201+
202+
if interval is not None:
203+
self.start_watching()

src/compas_viewer/components/numberedit.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ class NumberEdit(BoundComponent):
3434
The number of decimal places to display. Defaults to 1.
3535
action : Callable[[Component, float], None], optional
3636
A function to call when the value changes. Receives the component and new value.
37+
**kwargs
38+
Additional keyword arguments passed to BoundComponent, including:
39+
- watch_interval : int, optional
40+
Interval in milliseconds to check for changes in the bound object.
41+
If None, watching is disabled. Default is 100.
3742
3843
Attributes
3944
----------
@@ -58,7 +63,10 @@ class NumberEdit(BoundComponent):
5863
... def __init__(self):
5964
... self.x = 5.0
6065
>>> obj = MyObject()
66+
>>> # Component with default watcher (100ms)
6167
>>> component = NumberEdit(obj, "x", title="X Coordinate", min_val=0.0, max_val=10.0)
68+
>>> # Component without watcher
69+
>>> component = NumberEdit(obj, "x", title="X Coordinate", min_val=0.0, max_val=10.0, watch_interval=None)
6270
"""
6371

6472
def __init__(
@@ -71,8 +79,9 @@ def __init__(
7179
step: float = 0.1,
7280
decimals: int = 1,
7381
action: Callable[[Component, float], None] = None,
82+
**kwargs,
7483
):
75-
super().__init__(obj, attr, action=action)
84+
super().__init__(obj, attr, action=action, **kwargs)
7685

7786
self.widget = QWidget()
7887
self.layout = QHBoxLayout()
@@ -99,3 +108,22 @@ def __init__(
99108
self.layout.addWidget(self.spinbox)
100109
self.widget.setLayout(self.layout)
101110
self.spinbox.valueChanged.connect(self.on_value_changed)
111+
112+
def sync_from_bound_object(self, value: float):
113+
"""
114+
Sync the spinbox value with the bound object's value.
115+
116+
This method is called when the bound object's value changes externally.
117+
118+
Parameters
119+
----------
120+
value : float
121+
The new value from the bound object.
122+
"""
123+
# Temporarily disconnect the signal to prevent infinite loops
124+
self.spinbox.valueChanged.disconnect(self.on_value_changed)
125+
try:
126+
self.spinbox.setValue(value)
127+
finally:
128+
# Reconnect the signal
129+
self.spinbox.valueChanged.connect(self.on_value_changed)

src/compas_viewer/components/slider.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ class Slider(BoundComponent):
4242
Interval between tick marks. No ticks if None. Defaults to None.
4343
action : Callable[[Component, float], None], optional
4444
A function to call when the value changes. Receives the component and new value.
45+
**kwargs
46+
Additional keyword arguments passed to BoundComponent, including:
47+
- watch_interval : int, optional
48+
Interval in milliseconds to check for changes in the bound object.
49+
If None, watching is disabled. Default is 100.
4550
4651
Attributes
4752
----------
@@ -96,8 +101,9 @@ def __init__(
96101
step: float = 1,
97102
tick_interval: Optional[float] = None,
98103
action: Callable[[Component, float], None] = None,
104+
**kwargs,
99105
):
100-
super().__init__(obj, attr, action=action)
106+
super().__init__(obj, attr, action=action, **kwargs)
101107

102108
self.title = title if title is not None else (attr if attr is not None else "Value")
103109
self.min_val = min_val
@@ -196,3 +202,28 @@ def on_text_changed(self):
196202
except ValueError:
197203
pass # Handle cases where the text is not a valid number
198204
self._updating = False
205+
206+
def sync_from_bound_object(self, value: float):
207+
"""
208+
Sync the slider and text input with the bound object's value.
209+
210+
This method is called when the bound object's value changes externally.
211+
212+
Parameters
213+
----------
214+
value : float
215+
The new value from the bound object.
216+
"""
217+
if self._updating:
218+
return
219+
220+
self._updating = True
221+
try:
222+
# Clamp value to valid range
223+
clamped_value = max(self.min_val, min(self.max_val, value))
224+
225+
# Update both the slider and the text input
226+
self.slider.setValue(self._scale_value(clamped_value))
227+
self.line_edit.setText(str(clamped_value))
228+
finally:
229+
self._updating = False

0 commit comments

Comments
 (0)