Layout & Diff System
Blinc separates layout computation from visual rendering, enabling incremental updates without full tree rebuilds.
Layout System
Taffy Integration
Blinc uses Taffy for flexbox layout computation. Taffy is a high-performance layout engine that implements the CSS Flexbox specification.
#![allow(unused)]
fn main() {
// LayoutTree wraps Taffy
struct LayoutTree {
taffy: TaffyTree,
node_map: HashMap<LayoutNodeId, TaffyNodeId>,
reverse_map: HashMap<TaffyNodeId, LayoutNodeId>,
}
}
Layout Properties vs Visual Properties
Layout and visual properties are handled separately:
| Layout Properties | Visual Properties |
|---|---|
width, height | background |
padding, margin | border_color |
flex_direction | shadow |
justify_content | opacity |
align_items | transform |
gap | rounded |
This separation allows visual-only updates without layout recomputation.
RenderTree Structure
#![allow(unused)]
fn main() {
struct RenderTree {
layout_tree: LayoutTree, // Taffy wrapper
render_props: HashMap<NodeId, RenderProps>, // Visual properties
dirty_nodes: HashSet<NodeId>, // Nodes needing update
scroll_state: HashMap<NodeId, ScrollState>,
motion_bindings: HashMap<NodeId, MotionBinding>,
}
}
Layout Computation
Layout is computed on-demand when the tree structure changes:
#![allow(unused)]
fn main() {
// Compute layout for the tree
render_tree.compute_layout(root_id, AvailableSpace {
width: window_width,
height: window_height,
});
// Get computed bounds for a node
let layout = render_tree.get_layout(node_id);
// Returns: Layout { x, y, width, height }
}
Diff System
The diff system determines the minimum changes needed when UI structure updates.
DivHash - Content-Based Identity
Every element has a hash computed from its properties:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct DivHash(u64);
impl Div {
// Hash of this element only (excludes children)
fn compute_hash(&self) -> DivHash {
let mut hasher = DefaultHasher::new();
self.width.hash(&mut hasher);
self.height.hash(&mut hasher);
self.background.hash(&mut hasher);
// ... all properties
DivHash(hasher.finish())
}
// Hash including entire subtree
fn compute_tree_hash(&self) -> DivHash {
let mut hasher = DefaultHasher::new();
self.compute_hash().hash(&mut hasher);
for child in &self.children {
child.compute_tree_hash().hash(&mut hasher);
}
DivHash(hasher.finish())
}
}
}
Change Categories
Changes are classified to determine the update strategy:
#![allow(unused)]
fn main() {
struct ChangeCategory {
layout: bool, // Requires layout recomputation
visual: bool, // Only visual properties changed
children: bool, // Children added/removed/reordered
handlers: bool, // Event handlers changed
}
}
| Category | Example Changes | Action |
|---|---|---|
| Visual only | bg, opacity, shadow | Update RenderProps |
| Layout | width, padding, gap | Recompute layout |
| Children | Add/remove child | Rebuild subtree |
| Handlers | Event callback changed | Update handlers |
Child Diffing Algorithm
When children change, the diff algorithm matches old and new children:
#![allow(unused)]
fn main() {
enum ChildDiff {
Unchanged { index: usize }, // Same content, same position
Moved { old_idx: usize, new_idx: usize }, // Same content, moved
Modified { old_idx: usize, new_idx: usize, diff: Box<DiffResult> },
Added { index: usize }, // New child
Removed { index: usize }, // Old child gone
}
}
Matching Strategy
- Compute hashes for all old and new children
- Build hash map of old children by hash
- Match new children to old by hash lookup
- Detect moves when hash matches at different position
- Classify remaining as added or removed
- Merge same-position changes as modifications
#![allow(unused)]
fn main() {
fn diff_children(old: &[Div], new: &[Div]) -> Vec<ChildDiff> {
let old_hashes: Vec<_> = old.iter().map(|c| c.compute_tree_hash()).collect();
let new_hashes: Vec<_> = new.iter().map(|c| c.compute_tree_hash()).collect();
let mut old_by_hash: HashMap<DivHash, usize> = old_hashes
.iter()
.enumerate()
.map(|(i, h)| (*h, i))
.collect();
let mut diffs = Vec::new();
for (new_idx, new_hash) in new_hashes.iter().enumerate() {
if let Some(old_idx) = old_by_hash.remove(new_hash) {
if old_idx == new_idx {
diffs.push(ChildDiff::Unchanged { index: new_idx });
} else {
diffs.push(ChildDiff::Moved { old_idx, new_idx });
}
} else {
diffs.push(ChildDiff::Added { index: new_idx });
}
}
// Remaining old children were removed
for (old_idx, _) in old_by_hash {
diffs.push(ChildDiff::Removed { index: old_idx });
}
diffs
}
}
Incremental Updates
Update Result
The incremental update process returns what changed:
#![allow(unused)]
fn main() {
enum UpdateResult {
NoChanges, // Nothing to do
VisualOnly(Vec<PropUpdate>), // Apply prop updates only
LayoutChanged, // Props + recompute layout
ChildrenChanged, // Rebuild subtrees + layout
}
}
Reconciliation
The reconcile function determines actions from diffs:
#![allow(unused)]
fn main() {
struct ReconcileActions {
prop_updates: Vec<(NodeId, RenderProps)>, // Visual updates
subtree_rebuild_ids: Vec<NodeId>, // Nodes to rebuild
needs_layout: bool, // Layout recomputation needed
}
fn reconcile(old: &Div, new: &Div) -> ReconcileActions {
let changes = categorize_changes(old, new);
let mut actions = ReconcileActions::default();
if changes.visual && !changes.layout && !changes.children {
// Visual-only: just update props
actions.prop_updates.push((node_id, new.to_render_props()));
}
if changes.layout {
actions.needs_layout = true;
}
if changes.children {
actions.subtree_rebuild_ids.push(node_id);
actions.needs_layout = true;
}
actions
}
}
Update Flow
incremental_update(root, new_element)
│
├── Compare hashes: old_tree_hash vs new_tree_hash
│ └── Same? Return NoChanges
│
├── Compare element hashes: old_hash vs new_hash
│ └── Same? Children might have changed
│
├── Categorize changes
│ ├── Visual only? Queue prop updates
│ ├── Layout changed? Mark layout dirty
│ └── Children changed? Diff children recursively
│
└── Return UpdateResult
Pending Update Queues
The system uses global queues for deferred updates:
#![allow(unused)]
fn main() {
// Global pending updates (thread-local)
static PENDING_PROP_UPDATES: RefCell<Vec<(NodeId, RenderProps)>>;
static PENDING_SUBTREE_REBUILDS: RefCell<Vec<NodeId>>;
static NEEDS_REDRAW: AtomicBool;
}
Queue Processing
The windowed app processes queues each frame:
#![allow(unused)]
fn main() {
fn process_pending_updates(&mut self) {
// Apply prop updates (no layout needed)
for (node_id, props) in drain_prop_updates() {
self.render_tree.update_props(node_id, props);
}
// Rebuild dirty subtrees
for node_id in drain_subtree_rebuilds() {
self.rebuild_subtree(node_id);
}
// Recompute layout if needed
if self.layout_dirty {
self.render_tree.compute_layout(self.root_id, self.available_space);
self.layout_dirty = false;
}
// Trigger redraw if visual changes occurred
if NEEDS_REDRAW.swap(false, Ordering::SeqCst) {
self.request_redraw();
}
}
}
Performance Benefits
Hash-Based Comparison
- O(1) per element to compute hash
- O(1) equality check via hash comparison
- No deep property-by-property comparison needed
Child Matching
- O(n) to build hash map
- O(n) to match children
- Detects moves without position-based assumptions
Minimal Recomputation
| Scenario | What Runs |
|---|---|
| Hover color change | Update 1 RenderProps |
| Text content change | Rebuild 1 text node |
| Add item to list | Insert node, layout affected subtree |
| Reorder list | Move nodes, minimal layout |
Layout Caching
- Layout only recomputes when structure/dimensions change
- Visual-only changes skip layout entirely
- Taffy caches intermediate results