docs/development/property-binding-deep-dive.md
Note for AI coding assistants (agents): When to load this document: Working on
internal/core/properties.rs, debugging binding issues, implementing new property types, or understanding how Slint's reactive system works under the hood. For general build commands and project structure, see/AGENTS.md.
Slint's property system is the reactive foundation of the entire framework. Every UI element's state (position, color, text, visibility) is stored in properties. When properties change, dependent bindings automatically re-evaluate, keeping the UI in sync.
Key characteristics:
| File | Purpose |
|---|---|
internal/core/properties.rs | Core Property<T>, bindings, dependency tracking |
internal/core/properties/change_tracker.rs | ChangeTracker for property change callbacks |
internal/core/properties/properties_animations.rs | Animated property values |
internal/core/properties/ffi.rs | FFI bindings for C++ interop |
The main property type that holds a value and optional binding:
#[repr(C)]
pub struct Property<T> {
handle: PropertyHandle, // Binding state + dependency list
value: UnsafeCell<T>, // The actual value (interior mutability)
pinned: PhantomPinned, // Must be pinned for dependency tracking
}
Important: Properties must be Pinned because dependency nodes store raw pointers back to them. Moving a property would invalidate these pointers.
The handle manages binding state using bit flags in a single usize:
struct PropertyHandle {
handle: Cell<usize>,
}
// Bit flags:
const BINDING_BORROWED: usize = 0b01; // Lock flag (prevents recursion)
const BINDING_POINTER_TO_BINDING: usize = 0b10; // Has binding vs dependency list
The handle serves dual purpose:
BindingHolder (bit 1 set)Wraps a binding callable with metadata:
#[repr(C)]
struct BindingHolder<B = ()> {
dependencies: Cell<usize>, // Head of dependents list (who depends on us)
dep_nodes: Cell<...>, // Nodes in other properties' dependency lists
vtable: &'static BindingVTable,
dirty: Cell<bool>, // Needs re-evaluation?
is_two_way_binding: bool,
binding: B, // The actual binding callable
}
// Head of a doubly-linked list of dependents
pub struct DependencyListHead<T>(Cell<*const DependencyNode<T>>);
// Node in the dependency list
pub struct DependencyNode<T> {
next: Cell<*const DependencyNode<T>>,
prev: Cell<*const Cell<*const DependencyNode<T>>>, // Points to prev.next
binding: T, // Pointer to the BindingHolder that depends on us
}
When a binding evaluates and reads a property:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Property A │ │ Binding B │ │ Property C │
│ (being read) │ │ (evaluating) │ │ (depends on A) │
└────────┬────────┘ └────────┬────────┘ └─────────────────┘
│ │
│ 1. B calls A.get() │
│<──────────────────────│
│ │
│ 2. A checks CURRENT_BINDING thread-local
│ (finds B is currently evaluating)
│ │
│ 3. A adds B to its dependency list
│ (B now listed as dependent on A)
│ │
│ 4. B stores a DependencyNode pointing to A
│ (so B can unregister when re-evaluated)
│ │
Code path:
Property::get() calls handle.update() then register_as_dependency_to_current_binding()CURRENT_BINDING thread-local contains the currently evaluating bindingDependencyNode is added to the property's DependencyListHeadWhen a property value changes:
┌─────────────────┐ ┌─────────────────┐
│ Property A │──────────>│ Binding B │
│ value changed │ mark │ dirty=true │
└────────┬────────┘ dirty └────────┬────────┘
│ │
│ │ (B has dependents too)
│ ▼
│ ┌─────────────────┐
│ │ Binding C │
│ │ dirty=true │
│ └─────────────────┘
Code path:
Property::set() calls handle.mark_dirty()mark_dependencies_dirty() iterates the dependency listdirty flag is set to truemark_dirty callback is invoked (for animations, etc.)Bindings don't evaluate immediately when marked dirty. Instead:
// In Property::get()
unsafe { self.handle.update(self.value.get()) }; // Only evaluates if dirty
// In PropertyHandle::update()
if binding.dirty.get() {
// Clear old dependencies
binding.dep_nodes.set(Default::default());
// Evaluate with CURRENT_BINDING set to this binding
CURRENT_BINDING.set(Some(binding), || {
(binding.vtable.evaluate)(...)
});
binding.dirty.set(false);
}
Two-way bindings link properties so changes to either propagate to both:
struct TwoWayBinding<T> {
common_property: Pin<Rc<Property<T>>>, // Shared backing property
}
How it works:
TwoWayBinding that points to a shared "common property"intercept_set callback redirects writes to the common property┌──────────┐ ┌─────────────────┐ ┌──────────┐
│ Property │────>│ Common Property │<────│ Property │
│ A │ │ (shared) │ │ B │
└──────────┘ └─────────────────┘ └──────────┘
│ │ │
└───────────────────┴────────────────────┘
All reads/writes go here
For tracking dependencies outside of property bindings:
pub struct PropertyTracker<DirtyHandler = ()> {
holder: BindingHolder<DirtyHandler>,
}
Usage:
let tracker = Box::pin(PropertyTracker::default());
// Evaluate and track dependencies
let value = tracker.as_ref().evaluate(|| {
prop_a.as_ref().get() + prop_b.as_ref().get()
});
// Check if any dependency changed
if tracker.is_dirty() {
// Re-evaluate...
}
With dirty handler:
let tracker = PropertyTracker::new_with_dirty_handler(|| {
// Called immediately when any dependency changes
schedule_repaint();
});
For running callbacks when property values actually change:
let change = ChangeTracker::default();
change.init(
data, // User data passed to callbacks
|data| property.get(), // Eval function (reads property)
|data, new_value| { ... }, // Notify function (called on change)
);
// Later, process all pending changes:
ChangeTracker::run_change_handlers();
Key difference from PropertyTracker:
PropertyTracker: Notified when dependencies become dirtyChangeTracker: Notified when the evaluated value actually changesAnimated properties use special bindings:
pub struct AnimatedBindingCallable<T, A> {
original_binding: PropertyHandle, // The underlying binding
state: Cell<AnimatedBindingState>, // Animating/NotAnimating/ShouldStart
animation_data: RefCell<PropertyValueAnimationData<T>>,
compute_animation_details: A, // Returns animation parameters
}
Animation flow:
mark_dirty sets state to ShouldStartevaluate, animation begins from current value to new binding valueupdate_animations() to advance timeNotAnimatingProperties can be marked constant to optimize dependency tracking:
static CONSTANT_PROPERTY_SENTINEL: u32 = 0;
// A property is constant if its dependency list head points to the sentinel
pub fn set_constant(&self) {
// ... sets dependency head to point to CONSTANT_PROPERTY_SENTINEL
}
When reading a constant property, no dependency is registered (optimization).
Properties must be pinned because:
DependencyNode stores raw pointers to DependencyListHeadDependencyListHead stores raw pointers to DependencyNodeBINDING_BORROWED flag must be set before accessing value and cleared afterprev and next pointers must remain valid while nodes existBindingHolder<B> must only be cast via its own vtable// Safe way to access binding - handles lock flag
fn access<R>(&self, f: impl FnOnce(Option<Pin<&mut BindingHolder>>) -> R) -> R {
assert!(!self.lock_flag(), "Recursion detected");
self.set_lock_flag(true);
scopeguard::defer! { self.set_lock_flag(false); }
// ... access binding ...
}
#[derive(Default)]
struct MyComponent {
input: Property<i32>,
output: Property<i32>, // Will be bound to input * 2
}
let comp = Rc::pin(MyComponent::default());
let weak = Rc::downgrade(&comp);
comp.output.set_binding(move || {
let comp = weak.upgrade().unwrap();
Pin::new(&comp.input).get() * 2
});
// Using PropertyTracker
let tracker = Box::pin(PropertyTracker::new_with_dirty_handler(|| {
println!("Something changed!");
}));
tracker.as_ref().evaluate(|| {
a.get() + b.get()
});
// Using ChangeTracker
let change = ChangeTracker::default();
change.init((), |_| property.get(), |_, val| println!("New value: {}", val));
let prop1 = Rc::pin(Property::new(42));
let prop2 = Rc::pin(Property::new(0));
Property::link_two_way(prop1.as_ref(), prop2.as_ref());
// Now prop1 and prop2 are synchronized
Compile with RUSTFLAGS='--cfg slint_debug_property' to enable property debug names:
#[cfg(slint_debug_property)]
pub debug_name: RefCell<String>,
This helps identify which property is involved in recursion errors.
| Issue | Cause | Solution |
|---|---|---|
| "Recursion detected" panic | Binding reads its own property | Break the cycle, use get_untracked() |
| Binding not updating | Dependency not registered | Ensure property read happens during binding evaluation |
| Memory leak | Circular Rc references | Use weak references in bindings |
| Stale value | Missing mark_dirty call | Ensure all value changes go through set() |
// Check if property has binding
prop.handle.access(|b| b.is_some())
// Check if property is dirty
prop.is_dirty()
// Check if property is constant
prop.is_constant()
# Run property system tests
cargo test -p i-slint-core properties
# Run with debug names enabled
RUSTFLAGS='--cfg slint_debug_property' cargo test -p i-slint-core properties
# Run animation tests
cargo test -p i-slint-core animation_tests
BindingHolder on the heapmark_dirty traverses all dependents recursivelyFor hot paths, consider:
get_untracked() when dependency tracking isn't needed