Nib uses a simple reactivity model: mutate a view property, and the UI updates automatically. There is no virtual DOM, no signals, no subscription system. You change a value; Nib detects it and sends the new view tree to Swift.
How It Works¶
Every view holds a reference to the parent App. When you set a property on a view (e.g., text.content = "new value"), the View.__setattr__ override detects the change and calls app._trigger_rerender(). This sets a threading Event that wakes the render loop, which re-serializes the entire view tree and sends it to Swift.
import nib
def main(app: nib.App):
app.title = "Counter"
app.icon = nib.SFSymbol("number")
app.width = 300
app.height = 200
counter = nib.Text("0", font=nib.Font.TITLE)
def increment():
counter.content = str(int(counter.content) + 1) # Triggers re-render
app.build(
nib.VStack(
controls=[
counter,
nib.Button("Add", action=increment),
],
spacing=8,
padding=16,
)
)
nib.run(main)
When the button is tapped, increment() runs, setting counter.content. This triggers a full re-render: the view tree is re-serialized and sent to Swift, which updates the native UI.
The Render Loop¶
The render loop runs on a dedicated background thread. It is event-based and coalesced:
- A property change calls
app._trigger_rerender(), which sets athreading.Event. - The render thread is waiting on this event. When set, it wakes up.
- The event is cleared, and a render happens.
- The thread sleeps for 2ms (minimum interval), then waits again.
This design means rapid consecutive changes are coalesced into a single render:
# These three changes result in ONE render, not three
label1.content = "Updated"
label2.content = "Also updated"
label3.opacity = 0.5
The maximum render rate is approximately 500 fps. In practice, renders happen much less frequently since they are driven by user events.
Manual Updates¶
Sometimes you modify data that the reactivity system cannot detect -- for example, mutating a list in place or changing a nested object. In those cases, call app.update() to force a re-render:
items = ["Apple", "Banana"]
item_list = nib.VStack(
controls=[nib.Text(item) for item in items],
spacing=4,
)
def add_item():
items.append("Cherry")
# Rebuild the controls list
item_list._children = [nib.Text(item) for item in items]
for child in item_list._children:
child._set_app(app)
app.update() # Force re-render
When to use app.update()
You rarely need app.update(). Direct property mutations on views (like text.content = "new" or view.opacity = 0.5) automatically trigger re-renders. Use app.update() only when you are modifying data structures that views reference indirectly.
Function-Based Reactivity¶
The recommended approach. You create view instances, keep references to them, and mutate their properties directly:
import nib
def main(app: nib.App):
status = nib.Text("Idle", foreground_color=nib.Color.SECONDARY_LABEL)
progress = nib.ProgressView(value=0.0)
start_button = nib.Button("Start", action=None)
def start_task():
status.content = "Running..."
status.foreground_color = nib.Color.BLUE
start_button.visible = False
# Simulate progress updates
for i in range(10):
import time
time.sleep(0.1)
progress.value = (i + 1) / 10
status.content = "Done"
status.foreground_color = nib.Color.GREEN
start_button.visible = True
start_button._action = start_task
app.build(
nib.VStack(
controls=[status, progress, start_button],
spacing=12,
padding=16,
)
)
nib.run(main)
Every property assignment (status.content = ..., progress.value = ..., start_button.visible = ...) triggers a re-render automatically.
Class-Based Reactivity¶
For class-based apps, Nib provides the State[T] descriptor. When you assign to a State attribute, it calls _trigger_rerender() on the app instance:
import nib
class CounterApp(nib.App):
count = nib.State(0)
def body(self) -> nib.View:
return nib.VStack(
controls=[
nib.Text(f"Count: {self.count}", font=nib.Font.TITLE),
nib.Button("Increment", action=self.increment),
],
spacing=8,
padding=16,
)
def increment(self):
self.count += 1 # State descriptor triggers re-render
CounterApp(icon="number").run()
Class-based rebuilds the tree
In the class-based approach, body() is called on every render, creating new view instances each time. In the function-based approach, you mutate existing instances. Both work; the function-based approach is recommended because it avoids object allocation overhead.
Reactive Modifiers¶
View modifiers (styling properties) are also reactive. Changing a modifier triggers a re-render just like changing content:
box = nib.VStack(
controls=[nib.Text("Hello")],
background="#333333",
opacity=1.0,
padding=16,
)
def fade_out():
box.opacity = 0.3 # Triggers re-render
box.background = "#ff0000" # Triggers re-render
The following modifier properties can be mutated reactively on any view:
width,height,min_width,min_height,max_width,max_heightpadding,marginbackground,foreground_color,fill,stroke,stroke_widthopacity,corner_radiusfont,font_weightshadow_color,shadow_radius,shadow_x,shadow_yborder_color,border_widthclip_shape,blend_mode,scale,offsetanimation,content_transition,transitionvisible
No Virtual DOM¶
Nib does not use a virtual DOM or a reconciliation algorithm on the Python side. The diff engine (diff.py) exists but is currently unused. Instead, on every render, the full view tree is serialized to a flat node list and sent to Swift. Swift's SwiftUI framework handles the actual view diffing and only repaints what changed on screen.
This approach is simple and fast enough because:
- Menu bar apps typically have small view trees (tens to low hundreds of nodes).
- MessagePack serialization is fast.
- SwiftUI is efficient at diffing and partial re-renders.
- The render loop coalesces rapid changes.
Batching Changes¶
If you are making several changes and want a single render, you can rely on the natural coalescing behavior -- consecutive property mutations in the same function call will be coalesced by the render loop:
def update_everything():
title.content = "New Title"
subtitle.content = "New Subtitle"
icon.foreground_color = nib.Color.RED
container.opacity = 0.8
# All four changes coalesce into one render
If you need explicit control, you can batch changes and call app.update() once at the end:
def update_everything():
title.content = "New Title"
subtitle.content = "New Subtitle"
icon.foreground_color = nib.Color.RED
container.opacity = 0.8
app.update() # Explicit single render