tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# Components

## Component Trait

Every component implements the `Component` trait with three associated types:

```rust
impl Component for MyApp {
    type Message = Msg;       // Events this component handles
    type Properties = Props;  // Input from parent (often `()`)
    type State = State;       // Local mutable state
}
```

## Lifecycle Methods

| Method | Required | Signature | Purpose |
|--------|----------|-----------|---------|
| `create_state` | Yes | `(&self, &Props) -> State` | Initialize state from properties |
| `memo_key` | No | `(&self, &Props, &Context<Self>) -> Option<u64>` | Opt into retained subtree reuse |
| `view` | Yes | `(&self, &Context<Self>) -> Element` | Return UI tree |
| `update` | Yes | `(&mut self, Msg, &mut Context<Self>) -> Update` | Handle messages |
| `init` | No | `(&mut self, &mut Context<Self>) -> Option<Command>` | One-time setup on mount |
| `on_key` | No | `(&mut self, KeyEvent, &mut Context<Self>) -> KeyUpdate` | Handle unhandled key events |
| `on_props_changed` | No | `(&mut self, &Props, &mut Context<Self>) -> Update` | React to property changes |
| `unmount` | No | `(&mut self, &mut Context<Self>)` | Teardown before removal |

## State Flow

```
User Action → Event → Message → update() → State Change → Re-render
                  ↑___________________________|
```

1. User interacts (click, keypress)
2. Callback fires (`ctx.link().callback(...)`)
3. Message queued
4. `update()` called - mutate state
5. Return `(needs_redraw: bool, command: Option<Command>)`
6. `view()` re-executed if dirty or memoization cannot retain the subtree
7. Tree reconciled and rendered

## The `Update` Return Type

`Update` is a named struct with a dirty flag, a refresh level, and an optional
`Command`. Pick the smallest refresh that matches the state change:

| Return | Use when |
|--------|----------|
| `Update::none()` | State changed only to mirror widget-owned runtime state, or nothing visual changed |
| `Update::paint()` | Existing tree can be repainted without layout changes |
| `Update::layout()` | Existing component subtree needs reconcile/layout, but `view()` does not need to rebuild expensive app data |
| `Update::full()` | Your component's `view()` output changed and must be rebuilt |
| `Update::with_command(cmd)` | State changed and background work should start |

High-frequency widget callbacks such as `ScrollView::on_viewport_change`,
`on_scroll`, drag updates, and cursor/selection sync should usually return
`Update::none()` when they only store the reported offset or selection in parent
state. Returning `Update::full()` from those paths can rebuild large trees on
every wheel tick or drag frame.

```rust
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::Increment => {
            ctx.state.count += 1;
            Update::full()   // redraw, no background work
        }
        Msg::LoadData => {
            let id = ctx.props.user_id;
            Update::with_command(ctx.link().command(move |link| {
                // Runs on background thread
                let data = fetch_data(id);
                link.send(Msg::DataLoaded(data));
            }))
        }
        Msg::DataLoaded(data) => {
            ctx.state.data = data;
            Update::full()
        }
        Msg::NoOp => Update::none(),  // no redraw
    }
}
```

## Context Methods

| Method | Purpose |
|--------|---------|
| `ctx.state` | Mutable access to component state |
| `ctx.props` | Read-only access to current properties |
| `ctx.link()` | Build callbacks and commands |
| `ctx.request_focus(key)` | Move focus to a keyed widget |
| `ctx.show_devtools()` | Show the built-in DevTools panel on the next tick |
| `ctx.hide_devtools()` | Hide the built-in DevTools panel on the next tick |
| `ctx.toggle_devtools()` | Toggle the built-in DevTools panel on the next tick |
| `ctx.has_focus_within_key(key)` | Check if focus is within a subtree |
| `ctx.text_area_scrollbars(key)` | Read resolved vertical/horizontal scrollbar visibility for a keyed `TextArea` from the previous frame |
| `ctx.has_focus_within_scope(id)` | Check focus within a scope |
| `ctx.toast()` | Show toast notifications |
| `ctx.clipboard()` | Programmatic clipboard access (copy/read) |
| `ctx.quit()` | Exit the application |
| `ctx.is_inline()` | Whether running in inline mode |
| `ctx.effect_phase()` | Current renderer animation phase; capture it when starting one-shot phase-based effects |
| `ctx.mouse_capture_enabled()` | Current mouse capture state |
| `ctx.set_mouse_capture(bool)` | Change mouse capture at runtime |
| `ctx.toggle_mouse_capture()` | Toggle mouse capture, returns new state |
| `ctx.theme()` | Clone the active theme for this subtree |
| `ctx.theme_extension::<T>()` | Clone a typed app-specific theme extension |
| `ctx.host_terminal_colors()` | Read the runner-managed `HostTerminalColors` cache when live host colors are enabled |
| `ctx.host_terminal_color_generation()` | Read the cache generation; increments when refreshed colors differ |
| `ctx.request_host_terminal_color_refresh()` | Queue a safe runner-owned OSC 4/10/11 refresh on the UI thread |
| `ctx.use_context::<T>()` | Read nearest `ContextProvider<T>` value for this subtree |
| `ctx.append_transcript_lines(lines)` | Append styled lines to transcript history (inline only) |
| `ctx.append_transcript_element(el)` | Append a rendered element to transcript history (inline only) |
| `ctx.request_full_repaint()` | Next frame does a **full** reconcile + paint (use after the host terminal was used by another process; see [External programs](external-programs.md)) |
| `ctx.request_ui_snapshot_to(path)` | Queue a UI snapshot file write after the next paint (see [Agent snapshots](#agent--design-review-snapshots)) |
| `ctx.request_ui_snapshot_to_slot(slot)` | Queue in-memory UI snapshot delivery into `UiSnapshotSlot` after the next paint |

`ctx.effect_phase()` is a snapshot, not a render subscription. Use it to store a start tick in component state during `update()` / `init()`, then build phase-based effects like `VisualEffect::centered_burst_ripple(...)` from that stored value.

Live host terminal colors are opt-in. Use `App::system_theme()` for a framework-wide theme derived from the host palette, or `App::live_host_terminal_colors(true)` when app code needs extra host-derived tokens. The runner probes once at startup, refreshes on terminal focus gained, and services `ctx.request_host_terminal_color_refresh()` while coordinating with its input reader. It never polls continuously. If the palette changed, the runner updates `terminal_bg` and increments `ctx.host_terminal_color_generation()`. Use `ctx.host_terminal_colors()` for app-specific tokens beyond the framework theme; keep those tokens app-owned.

When the `devtools` feature is enabled, the built-in panel can be controlled from app code as well as the global keymap. This is useful for wiring DevTools to a button, command palette entry, startup action, or app-specific command:

```rust
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
    match msg {
        Msg::OpenDevtools => ctx.show_devtools(),
        Msg::CloseDevtools => ctx.hide_devtools(),
        Msg::ToggleDevtools => ctx.toggle_devtools(),
    }
    Update::none()
}
```

Built-in DevTools panel layout is fixed; `Context` methods control visibility only.

To opt out of individual subsystems (logs, metrics) at app start time, see [DevTools runtime configuration](quick-start.md#devtools-runtime-configuration) in the Quick Start.

## Component Mounting

```rust
fn main() -> tui_lipan::Result<()> {
    App::new()
        .mount(MyApp)        // Takes an instance, not a type
        .run()
}

// Dependency injection: pass data into the constructor
let app = MyApp::new(db_connection, config);
App::new().mount(app).run();
```

## Properties vs State

| | Properties | State |
|--|------------|-------|
| **Source** | Parent / mount | Local to component |
| **Mutability** | Immutable (read via `ctx.props`) | Mutable via `ctx.state` |
| **Lifetime** | Passed each render | Persisted across renders |
| **Common use** | Configuration, DI | User input, loaded data |

```rust
#[derive(Clone, PartialEq)]
struct Props { user_id: u64 }

#[derive(Default)]
struct State {
    user_name: String,
    is_loading: bool,
}
```

> **Note**: Properties must implement `Clone + PartialEq` for reconciliation.

## Commands (Async / Background Work)

Components are single-threaded. Use `Command` for background work:

```rust
// Generic command: any closure
let cmd = ctx.link().command(move |link| {
    let result = blocking_call();
    link.send(Msg::Done(result));
});

// Keyed command: prevent stale work from piling up
let cmd = ctx.link().command_keyed(
    "search",                  // key (any &'static str)
    TaskPolicy::LatestOnly,    // coalescing policy
    move |link| {
        if link.is_cancelled() {
            return;
        }
        let results = do_search(&query);
        let _sent = link.send_if_not_cancelled(Msg::SearchDone(results));
    },
);
```

### TaskPolicy Options

| Policy | Behavior |
|--------|----------|
| `QueueAll` | Enqueue every task. Native workers may run same-key tasks concurrently. |
| `DropIfRunning` | Ignore new task while one with the same key is running; the active task is not cancelled. |
| `LatestOnly` | Keep only the newest pending task, cancel the active token, and cancel replaced pending tokens. |

Cancellation is cooperative: a keyed `LatestOnly` task is not preempted. Poll
`link.is_cancelled()` or clone `link.cancellation_token()` for long loops, and
use `link.send_if_not_cancelled(msg)` to suppress stale results. `link.send(msg)`
remains unconditional for cleanup/error messages that should report even after
cancellation.

```rust
use tui_lipan::TaskPolicy;

// Example: filter-as-you-type pattern
match msg {
    Msg::QueryChanged(q) => {
        let cmd = ctx.link().command_keyed("filter", TaskPolicy::LatestOnly, move |link| {
            let results = filter_items(&q);
            let _ = link.send_if_not_cancelled(Msg::FilterDone(results));
        });
        Update { dirty: false, command: Some(cmd) }
    }
}
```

### Thread Safety

Commands use channels internally. The component itself never needs to be `Send` or `Sync`.

### External interactive subprocesses

Spawning an editor or pager that needs the real terminal must **not** use `Command::spawn` / `ctx.link().command(...)` alone: use `Command::new` on the UI thread together with [`terminal_handoff`](external-programs.md), then [`request_full_repaint()`](external-programs.md#force-a-full-redraw-after-handoff) if needed. See **[External programs](external-programs.md)**.

## Nested Components

Use `child()` to embed components within a view:

```rust
use tui_lipan::child;

fn view(&self, ctx: &Context<Self>) -> Element {
    child(
        || MyChild,             // factory closure
        MyChildProps { x: 1 }, // properties
    )
}
```

Or use the `rsx!` macro with a component type:

```rust
rsx! {
    // Widget types used directly as elements
    VStack {
        MyChildWidget { value: 42 }
    }
}
```

### Parent → Child Communication (Props)

Parents pass data and callbacks to children via Properties:

```rust
#[derive(Clone, PartialEq)]  // ← REQUIRED: Clone + PartialEq
struct SidebarProps {
    items: Vec<String>,
    selected: usize,
    on_select: Callback<usize>,   // Callback for child → parent
}
```

### Child → Parent Communication (Callback Props)

Children notify parents by emitting callback props. Messages are **scoped** - a child
cannot directly send messages to the parent's update loop:

```rust
struct Sidebar;

#[derive(Clone)]
enum SidebarMsg {
    Selected(usize),
}

impl Component for Sidebar {
    type Message = SidebarMsg;
    type Properties = SidebarProps;
    type State = ();

    fn create_state(&self, _: &SidebarProps) -> () { () }

    fn view(&self, ctx: &Context<Self>) -> Element {
        List::new()
            .items(ctx.props.items.iter().map(|s| ListItem::new(s.clone())))
            .selected(ctx.props.selected)
            .on_select(ctx.link().callback(|e: ListEvent| SidebarMsg::Selected(e.index)))
            .into()
    }

    fn update(&mut self, msg: SidebarMsg, ctx: &mut Context<Self>) -> Update {
        match msg {
            SidebarMsg::Selected(idx) => {
                // Notify parent via callback prop:
                ctx.props.on_select.emit(idx);
                Update::none()  // Parent will re-render with new props
            }
        }
    }
}

// In parent view():
fn view(&self, ctx: &Context<Self>) -> Element {
    HStack::new()
        .child(child(
            || Sidebar,
            SidebarProps {
                items: ctx.state.items.clone(),
                selected: ctx.state.selected,
                on_select: ctx.link().callback(Msg::ItemSelected),
            },
        ))
        .child(Text::new("Detail panel").into())
        .into()
}
```

### Key Rules for Nested Components

1. **Properties must implement `Clone + PartialEq`** - required for reconciliation.
2. **Messages are scoped** - each component has its own message queue.
3. **`child()` takes a factory closure** - not just a type: `child(|| MyComp, props)`.
4. **Communication is unidirectional**: parent → child via props, child → parent via callback props.
5. **State is isolated** - children don't access parent state.

## Retained Subtree Reuse

Components can opt into retained subtree reuse by returning a stable key from `memo_key()`:

```rust
impl Component for MessageRow {
    type Message = Msg;
    type Properties = RowProps;
    type State = RowState;

    fn create_state(&self, props: &Self::Properties) -> Self::State {
        RowState::from(props)
    }

    fn memo_key(&self, props: &Self::Properties, _ctx: &Context<Self>) -> Option<u64> {
        Some(props.revision)
    }

    fn view(&self, ctx: &Context<Self>) -> Element {
        render_row(ctx.props)
    }

    fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
        handle_row_msg(msg, ctx)
    }
}
```

When `memo_key()` returns the same value, the runtime may reuse the component's previously
expanded subtree and skip `view()`. Reuse is automatically invalidated when:

- local state or props mark the component dirty
- a nested child component under that subtree needs refresh
- a `Context` value read during `view()` changes (`theme()`, `theme_extension()`, focus/hover
  queries, `mouse_capture_enabled()`, `viewport()`, `breakpoint()`, `use_context::<T>()`)

Use `memo_key()` for expensive rows, panes, or tool outputs that are stable across unrelated
parent updates. Keep the key focused on semantic content identity (`revision`, `version`, hash of
derived props), not transient UI state that already lives in `State`.

## Component State Keys

`component_state_key` preserves a component's local state even when its ancestor container
structure changes (for example, wrapping a widget in an extra `VStack` or moving it between
branches). It is declared on the element that mounts the component:

```rust
fn view(&self, _ctx: &Context<Self>) -> Element {
    VStack::new()
        .child(
            child(|| Modal, modal_props)
                .component_state_key("modal")
        )
        .into()
}
```

### Scoping and duplicate-key policy

State keys are scoped per **parent component**. Two components with the same
`component_state_key` that are children of the same parent are considered duplicates.
In that case the runtime uses **last-writer-wins**: the second component reuses (and
overwrites props on) the same instance.

Debug builds log a warning when duplicate sibling keys are detected:

```
Duplicate component_state_key "modal" detected; last-writer-wins
```

Duplicates across **different parent scopes** (or unrelated branches) are fine. Because
the key is global within the registry, a component in one branch can reuse the state of
a previously-mounted component with the same key in another branch. This is useful for
preserving form state when switching between tabs or conditional views.

### Type mismatches

If a state key is reused but the component type does not match, the runtime falls back
to creating a fresh instance rather than coercing the wrong type.

## Snapshot / Visual Testing

`TestBackend` supports headless snapshot testing via `capture_frame()`. After a `render()` (or `dispatch()` / `send_key()` which implicitly re-render), call `capture_frame()` to get a `CapturedFrame` containing the full rendered buffer as crate-owned types - no ratatui types leak.

### Plain-text snapshot with `insta`

```rust
use tui_lipan::prelude::*;

struct MyWidget;

impl Component for MyWidget {
    type Message = ();
    type Properties = ();
    type State = ();

    fn create_state(&self, _: &()) -> () { () }
    fn update(&mut self, _: (), _: &mut Context<Self>) -> Update { Update::none() }

    fn view(&self, _ctx: &Context<Self>) -> Element {
        Frame::new()
            .title("Panel")
            .child(Text::new("hello"))
            .into()
    }
}

#[test]
fn snapshot_my_widget() {
    let mut backend = TestBackend::new(MyWidget);
    backend.set_viewport(Rect { x: 0, y: 0, w: 30, h: 5 });
    backend.render();

    let frame = backend.capture_frame();
    insta::assert_snapshot!(frame.plain_text());
}
```

`plain_text()` returns newline-joined rows with trailing spaces trimmed - the output is stable and deterministic across runs.

### Per-cell style assertions

```rust
let frame = backend.capture_frame();
let cell = frame.cell(0, 0);

assert_eq!(cell.symbol, "A");
assert_eq!(cell.fg, Color::Rgb(12, 34, 56));
assert_eq!(cell.bg, Color::Rgb(90, 80, 70));
assert!(cell.modifiers.bold);
```

### Styled runs

`styled_lines()` groups each row into `Vec<(String, Style)>` runs by identical style, useful for asserting that specific text is rendered with a certain color:

```rust
let runs = &frame.styled_lines()[0];
assert_eq!(runs[0].0, "error:");
assert_eq!(runs[0].1.fg, Some(Color::Red));
```

### Cursor capture

When a focused input widget requests cursor placement, `frame.cursor` is populated:

```rust
backend.focus_next();
backend.render();
let frame = backend.capture_frame();

let cursor = frame.cursor.expect("input should place cursor");
assert!(cursor.visible);
assert_eq!(cursor.y, 0);
```

### Viewport resize

```rust
backend.set_viewport(Rect { x: 0, y: 0, w: 40, h: 10 });
backend.render();
let frame = backend.capture_frame();
assert_eq!(frame.width, 40);
assert_eq!(frame.height, 10);
```

### `CapturedFrame` API summary

| Method | Returns | Description |
|--------|---------|-------------|
| `plain_text()` | `String` | Full frame as trimmed plain text, `\n`-separated |
| `to_lines()` | `Vec<String>` | Same as `plain_text()` but per-row |
| `row(y)` | `&[CapturedCell]` | All cells for row `y` |
| `cell(x, y)` | `&CapturedCell` | Single cell at `(x, y)` |
| `styled_lines()` | `Vec<Vec<(String, Style)>>` | Rows grouped into style runs |
| `to_fixed_grid()` | `String` | Full-width rows without trailing trim (layout-faithful) |
| `to_ansi()` | `String` | ANSI styled frame (full terminal repaint prelude) |
| `to_ansi_diff(prev)` | `String` | Incremental ANSI update from a previous frame |
| `to_png(&PngOptions)` | `Vec<u8>` | PNG bytes with font-backed or bitmap rendering (`ui-snapshot-png`) |
| `try_to_png(&PngOptions)` | `Result<Vec<u8>>` | PNG bytes with encoder errors surfaced (`ui-snapshot-png`) |

`CapturedCell` fields: `symbol`, `fg`, `bg`, `underline_color`, `modifiers` (`CellModifiers` with bool fields `bold`, `dim`, `italic`, `underline`, `reverse`, `strikethrough`).

### Agent / design-review snapshots

`TestBackend::capture_ui_snapshot()` returns a `UiSnapshot`: rendered
`CapturedFrame` plus semantic `UiWidgetDesc` entries (widget kind, keys, rects,
focus/hover, selection, values). Use `to_markdown()` for agent-readable reports.
Enable the `ui-snapshot-json` feature for `to_json()` / `to_json_pretty()`.
Enable `ui-snapshot-png` for `to_png()` / `to_png_default()` or `try_to_png()` /
`try_to_png_default()` when layout, color, focus chrome, and visual hierarchy
matter; PNG complements markdown/JSON rather than replacing them.

The PNG renderer uses antialiased real-font text by default when a system font is
available, with font8x8 bitmap rendering as the fallback. `PngOptions` is a
crate-root import (not prelude) and can select `PngTextRenderer::Auto`, `Font`,
or `Bitmap`; `font_family` / `font_path` let captures use system or Nerd Fonts.
Force `Bitmap` for deterministic coarse cell output and fallback-style reviews.

```rust
let mut backend = TestBackend::new(MyApp);
backend.set_viewport(Rect { x: 0, y: 0, w: 80, h: 24 });
backend.render();

let snapshot = backend.capture_ui_snapshot();
println!("{}", snapshot.to_markdown());

#[cfg(feature = "ui-snapshot-png")]
std::fs::write("/tmp/ui-snapshot.png", snapshot.to_png_default())?;
```

For design review captures, prefer fit-to-content margin helpers so flex space is visible without hand-tuning a viewport. The recommended default margin is `(20, 8)`:

```rust
let snapshot = backend.capture_ui_snapshot_with_margin(
    20,
    8,
    &UiSnapshotOptions::default(),
);
```

`capture_frame_with_margin(20, 8)` provides the same fit-to-content viewport behavior when you only need the rendered `CapturedFrame`.

**Live apps:** snapshot export is **queued until after the next paint** (not synchronous from `update()`). Each request replaces any earlier pending one. Requests schedule a repaint so idle apps still deliver. File routing follows the path extension: `.md` writes markdown, `.json` writes JSON with `ui-snapshot-json`, and `.png` writes the current viewport as PNG with `ui-snapshot-png`.

```rust
// Store the slot in component state:
struct State {
    slot: UiSnapshotSlot,
}

// In update():
ctx.request_ui_snapshot_to("ui-snapshot.md");
ctx.request_ui_snapshot_to_slot(&ctx.state.slot);

// Later (next tick / handler):
if let Some(snap) = ctx.state.slot.take() {
    // use snap
}
```

See `examples/ui_snapshot.rs`.

---

## Key Attribute (Reconciliation)

Assign stable keys to preserve state across re-renders and enable focus routing:

```rust
rsx! {
    List { key: "file-list", ... }
    Input { key: format!("input-{}", id), ... }
}
```

> Without a key, reconciliation uses position, which breaks when items are added/removed.