Observable#
The ngapp.observable module provides reactive state management for
ngapp applications. An Observable holds a single
value and notifies registered listeners whenever the value changes.
Pass an Observable directly as a component prop (e.g.
ui_model_value=my_observable) for automatic two-way binding.
Why Observable?#
A common challenge in interactive applications is keeping multiple concerns synchronised: a checkbox in the UI, a keyboard shortcut, a persistence layer, and the underlying domain logic may all read or write the same piece of state. Without a shared mechanism, each pair requires manual glue code and guard flags to prevent feedback loops.
Observable solves this by providing a single source of truth that any
number of consumers can subscribe to. When the value changes — regardless
of which consumer changed it — every other subscriber is notified.
Quick start#
from ngapp.observable import Observable
from ngapp.components import QCheckbox
# 1. Declare an observable
visible = Observable(True, "visible")
# 2. React to changes
visible.on_change(lambda new, old: print(f"visible: {old} -> {new}"))
# 3. Pass it directly as a prop — two-way binding is automatic
checkbox = QCheckbox("Show", ui_model_value=visible)
# 4. Any mutation is propagated everywhere
visible.value = False # prints "visible: True -> False", unchecks the checkbox
visible.toggle() # prints "visible: False -> True", checks the checkbox
Creating an Observable#
from ngapp.observable import Observable
# Required: initial value. Optional: name for debugging / serialisation.
scale = Observable(1.0, "scale")
enabled = Observable(False, "enabled")
items = Observable([], "items")
The name parameter has no functional effect at runtime. It is used by
snapshot() and restore()
as the dictionary key, and appears in the repr() output.
Type coercion with converter#
Pass a converter function to automatically coerce incoming values.
If the converter raises ValueError or TypeError, the assignment
is silently ignored and the value stays unchanged:
scale = Observable(1.0, "scale", converter=float)
scale.value = "3.14" # converted to 3.14
scale.value = "abc" # silently ignored, value stays 3.14
scale.value = 5 # converted to 5.0
This is especially useful with NumberInput (or any QInput with
ui_type="number"), where the widget emits string values:
scale = Observable(1.0, "scale", converter=float)
inp = NumberInput(ui_model_value=scale, ui_label="Scale")
# User types "2.5" → observable receives 2.5 as float
Reading and writing#
Read and write through the value
property:
print(scale.value) # 1.0
scale.value = 2.5 # listeners are called
scale.value = 2.5 # no notification (value unchanged)
The setter compares the new value to the current one with ==. If they
are equal, no listeners are invoked.
Listening to changes#
Register a callback with on_change().
The callback receives (new_value, old_value):
def on_scale(new, old):
print(f"scale changed from {old} to {new}")
dispose = scale.on_change(on_scale)
scale.value = 3.0 # prints "scale changed from 2.5 to 3.0"
dispose() # removes the listener
scale.value = 4.0 # on_scale is NOT called
on_change returns a dispose function. Call it to unsubscribe.
Calling dispose more than once is safe.
Multiple listeners are supported; they fire in registration order.
Toggling booleans#
For boolean observables, toggle() is a
shorthand for inverting the current value:
enabled = Observable(False, "enabled")
enabled.toggle() # enabled.value is now True
enabled.toggle() # enabled.value is now False
Widget binding#
Direct prop binding#
Pass an Observable directly as any component prop. For
ui_model_value, binding is two-way: changes to the observable
update the widget, and user interaction updates the observable. For all
other props, binding is one-way (observable → widget).
from ngapp.observable import Observable
from ngapp.components import QSlider, QBtn
opacity = Observable(1.0, "opacity")
color = Observable("primary", "color")
# Two-way: dragging the slider updates opacity.value, and vice versa
slider = QSlider(ui_model_value=opacity, ui_min=0.0, ui_max=1.0, ui_step=0.01)
# One-way: changing color.value updates the button color
btn = QBtn(ui_label="Click", ui_color=color)
opacity.value = 0.5 # slider moves to 0.5
color.value = "red" # button turns red
Assigning an Observable to a prop after construction also works:
slider.ui_model_value = another_observable
The previous binding is automatically disposed and the new one takes effect.
Explicit binding with bind()#
For non-standard widget attributes or events, use
bind() to wire an Observable to any attribute
and event pair:
from ngapp.observable import bind
bind(my_obs, widget, widget_attr="ui_label", event="on_change")
bind returns a dispose function:
dispose = bind(opacity, slider)
# later…
dispose()
Batching changes#
When multiple observables must be updated together,
observable_batch() defers all listener calls until
the block exits. This avoids redundant intermediate work (for example,
multiple render calls):
from ngapp.observable import observable_batch
with observable_batch():
position.value = (1.0, 2.0, 3.0)
scale.value = 0.5
enabled.value = True
# All listeners are called here, once per changed observable.
Batches can be nested. Listeners fire only when the outermost batch completes.
Serialisation helpers#
Three functions make it easy to save and restore observable state without writing per-field boilerplate.
snapshot()#
Collect all Observable values on an object into a plain dictionary,
keyed by their name:
class MyComponent:
def __init__(self):
self.scale = Observable(1.0, "scale")
self.visible = Observable(True, "visible")
comp = MyComponent()
data = snapshot(comp)
# {"scale": 1.0, "visible": True}
restore()#
Write values from a dictionary back into the matching observables. Unknown keys are silently ignored:
restore(comp, {"scale": 2.5, "visible": False})
# comp.scale.value == 2.5
# comp.visible.value == False
Because restore sets each .value, all listeners fire as usual.
collect_observables()#
Return a {name: Observable} mapping for programmatic access:
obs = collect_observables(comp)
for name, o in obs.items():
print(f"{name} = {o.value}")
Typical usage patterns#
Reactive side-effects#
Wire domain logic to observables so that any change — from the UI, a keyboard shortcut, or code — triggers the correct response:
class Viewer:
def __init__(self):
self.wireframe = Observable(True, "wireframe")
self.wireframe.on_change(self._apply_wireframe)
def _apply_wireframe(self, val, _old):
self.renderer.show_wireframe = val
self.scene.render()
def toggle_wireframe(self):
self.wireframe.toggle() # one line — side-effects handled
Component with bound UI#
Declare observables in a component, bind them to widgets in a settings panel:
# Component
class SimView:
def __init__(self, saved_settings):
self.resolution = Observable(
saved_settings.get("resolution", 256), "resolution"
)
self.resolution.on_change(self._on_resolution)
def _on_resolution(self, val, _old):
self.solver.set_resolution(val)
self.solver.run()
# Settings panel
class SimSettings:
def __init__(self, comp):
slider = QSlider(
ui_model_value=comp.resolution,
ui_min=64, ui_max=1024, ui_step=64,
)
Save and restore#
Persist the full observable state at save time, restore it when loading:
from ngapp.observable import snapshot, restore
# Save
settings = snapshot(comp)
storage.save("settings", settings)
# Restore (on next launch)
saved = storage.load("settings")
restore(comp, saved)
API reference#
Reactive observable values with change notification and two-way widget binding.
This module provides Observable, a generic container that holds a
single typed value and notifies registered listeners whenever that value
changes. It serves as a single source of truth for application state that
may be read or written by multiple consumers (UI widgets, keyboard shortcuts,
programmatic logic, persistence layers, etc.).
The companion bind() function establishes a two-way connection between
an Observable and an ngapp Component
widget, keeping the two in sync with a built-in re-entrancy guard.
observable_batch() allows grouping multiple value changes so that
listeners are invoked only once per observable after all changes are applied.
Example#
from ngapp.observable import Observable, bind, observable_batch
visible = Observable(True, "visible")
visible.on_change(lambda new, old: scene.render())
checkbox = QCheckbox("Visible", ui_model_value=visible.value)
bind(visible, checkbox)
# Any source can change the value; all listeners and the widget
# are updated automatically.
visible.toggle()
- class ngapp.observable.Observable(
- default: T,
- name: str = '',
- converter: Callable | None = None,
A single observable value with change notification.
Observablewraps a value of type T and maintains a list of listener callbacks. Assigning tovaluecompares the new value against the current one (using==); if they differ, every registered listener is called with(new_value, old_value).Parameters#
- defaultT
The initial value.
- namestr, optional
A descriptive name used for debugging and serialisation keys.
- convertercallable, optional
A function applied to every incoming value before it is stored. If the converter raises
ValueErrororTypeError, the assignment is silently ignored (the value stays unchanged). Useful for type coercion, e.g.converter=float.
Attributes#
- valueT
The current value. Setting this property triggers change notification when the new value differs from the old one.
Example#
enabled = Observable(False, "enabled") dispose = enabled.on_change(lambda new, old: print(f"{old} -> {new}")) enabled.value = True # prints: False -> True enabled.value = True # no output (value unchanged) enabled.toggle() # prints: True -> False dispose() # removes the listener
- on_change(
- cb: Callable[[T, T], None],
Register a listener that is called on every value change.
Parameters#
- cbcallable(new_value, old_value)
The callback to invoke when
valuechanges.
Returns#
- callable
A dispose function. Calling it removes cb from the listener list. Calling it more than once is safe.
- Parameters:
cb (Callable[[T, T], None])
- Return type:
Callable[[], None]
- toggle() None#
Invert the current value. Intended for boolean observables.
- Return type:
None
- property value: T#
The current value.
- Parameters:
default (T)
name (str)
converter (Callable | None)
- ngapp.observable.bind(
- prop: Observable,
- widget,
- widget_attr: str = 'ui_model_value',
- event: str = 'on_update_model_value',
Establish a two-way binding between an
Observableand a widget.The binding synchronises the observable and the widget in both directions:
Observable -> Widget: When the observable value changes, the widget attribute widget_attr is updated.
Widget -> Observable: When the widget emits the event registered via event, the observable value is updated.
An internal re-entrancy guard ensures that a change originating on one side does not bounce back and cause an infinite loop.
Parameters#
- propObservable
The observable to bind.
- widgetComponent
An ngapp component instance (e.g.
QCheckbox,QSlider).- widget_attrstr, optional
The widget attribute to read/write. Defaults to
"ui_model_value".- eventstr, optional
The name of the widget method used to register an event handler. Defaults to
"on_update_model_value".
Returns#
- callable
A dispose function that removes the observable-to-widget listener.
- Parameters:
prop (Observable)
widget_attr (str)
event (str)
- Return type:
Callable[[], None]
- ngapp.observable.collect_observables(obj) dict[str, Observable]#
Return a
{name: observable}dict of allObservableattributes on obj.Inspects
obj.__dict__and returns every value that is anObservableinstance, keyed byObservable._name.Parameters#
- objobject
The object to inspect.
Returns#
- dict[str, Observable]
Mapping from observable name to instance.
- Return type:
dict[str, Observable]
- ngapp.observable.observable_batch()#
Defer
Observablelistener invocations until the block exits.Within the managed block, setting
Observable.valuerecords pending notifications instead of dispatching them immediately. When the outermost block exits, every recorded callback is invoked exactly once in the order it was queued. Batches may be nested; listeners fire only when the outermost batch completes.This is useful when multiple observables must be updated atomically to avoid redundant or intermediate side-effects.
Example#
with observable_batch(): position.value = (1.0, 2.0, 3.0) scale.value = 0.5 # All listeners are called here, once per changed observable.
- ngapp.observable.restore(obj, data: dict[str, Any]) None#
Restore
Observablevalues on obj from data.For every observable on obj whose
_nameappears in data, the value is set (triggering listeners as usual). Unknown keys in data are silently ignored.Parameters#
- objobject
The object whose observables should be restored.
- datadict[str, Any]
Mapping from observable name to value, as produced by
snapshot().
- Parameters:
data (dict[str, Any])
- Return type:
None
- ngapp.observable.snapshot(obj) dict[str, Any]#
Return a plain
{name: value}dict of allObservablevalues on obj.This is the serialisation counterpart of
restore().Parameters#
- objobject
The object whose observables should be snapshotted.
Returns#
- dict[str, Any]
Mapping from observable name to its current value.
- Return type:
dict[str, Any]