Headerline
Authoritative design for Lattice's generic sticky headerline mechanism: the one surface for a buffer to emit a pinned status row above line 0, the trait-based extensibility model that lets modes and subsystems own their header rendering, and the cheap-clone handle pattern that avoids state duplication.
This document is a companion to design.md (§5.6 rendering, §5.15 virtual rows), to virtual-rows.md (the underlying sticky-row primitive), and to multibuffer-views.md (§6 first production consumer).
1. The design goal
A buffer's header is a read-only sticky row anchored above line 0, pinned to the viewport. Many subsystems produce content for this row:
- Tutor mode — lesson name, score, timer.
- Multibuffer search — scan progress ("searching 42 files..."), completion status.
- LSP session — "Initializing..." / "Ready" / "Error" status.
- VCS — current branch, dirty state.
- Diagnostics — summary count or worst severity.
Each producer is independent. Each owns its state and render logic. The substrate must provide one unified surface that lets modes register their header without baking producer-specific logic into the host.
Two saved invariants frame the design:
Don't add features beyond what the task requires. --
CLAUDE.md, design discipline rule
Modes own their full surface. -- saved feedback
feedback_mode_owns_its_surface.md
The first rules out a fixed set of ex-commands for each producer (:tutor-header, :lsp-header, :search-header). The second rules out a central Editor::update_headerline_for_<mode> dispatcher. Both push the same way: one generic mechanism, many producers, uniform render surface.
2. The shape
┌─────────────────────────────┐
│ Headerline (trait) │
│ │
│ version() -> u64 │
│ render() -> Option<Row> │
└────────┬────────────────────┘
│
┌────────────┴──────────────┐
│ │
▼ ▼
┌──────────────────────┐ ┌─────────────────────┐
│SimpleHeaderline<S> │ │Direct Headerline │
│ (owned state) │ │impl (existing state)│
└──────────────────────┘ └─────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌─────────────────────┐
│SimpleHeaderlineHandle│ │Arc<dyn Headerline> │
│ (cheap-clone) │ │(mode-provided) │
└──────────────────────┘ └─────────────────────┘
│ │
└────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ HeaderlineProvider │
│ (VirtualRowProvider impl) │
│ │
│ emits one VirtualRow │
│ at anchor_line=0, Above │
└─────────────────────────────┘
The trait is the load-bearing abstraction. Two paths feed it:
SimpleHeaderlineHandle<S>— for modes that own dedicated header state (tutor, multibuffer search). Zero boilerplate: the handle wraps the state, stores a renderer closure, and bumps version on update.Direct
Headerlineimpl — for modes whose header is a view into existing state (LSP session, VCS context) or for WASM plugin shims that wrap a WIT callback. Avoids state duplication.
HeaderlineProvider wraps any Headerline impl and registers as a VirtualRowProvider (the underlying sticky-row primitive from virtual-rows.md). It always emits one row anchored at line 0 above; returns empty vec when the impl returns None (hide the row).
3. The data model
3.1 HeaderlineRow
pub struct HeaderlineRow {
pub cells: Arc<[Cell]>,
pub bg: Option<u32>,
}
cells is the row content — the same Arc<[Cell]> shape that backs CellRow, so renderers paint headerlines through the existing fast path. bg: None means the renderer picks the theme-defined sticky-row background; bg: Some(0xRRGGBB) overrides it (tutor's retro palette does this).
3.2 Headerline trait
pub trait Headerline: Send + Sync + 'static {
fn version(&self) -> u64;
fn render(&self) -> Option<HeaderlineRow>;
}
version() is a monotonic counter. The cells worker calls it on every tick; when the version has advanced since the last call, it invokes render() to rebuild the row. render() returns None to hide the row entirely.
render() must not block — cache results; background tasks push updates via the owning handle. The cells worker publishes the result through the VirtualRowMatrix path, not a separate channel.
3.3 SimpleHeaderlineHandle<S> (convenience path)
pub struct SimpleHeaderlineHandle<S: Send + Sync + 'static>(
Arc<SimpleHeaderline<S>>
);
impl<S: Send + Sync + 'static> SimpleHeaderlineHandle<S> {
pub fn new(
initial: S,
renderer: impl Fn(&S) -> Option<HeaderlineRow> + Send + Sync + 'static,
) -> Self;
pub fn update(&self, f: impl FnOnce(&mut S));
pub fn version(&self) -> u64;
pub fn provider(&self, provider_id: ProviderId) -> HeaderlineProvider;
}
The handle is cheap-clone (manual Clone impl, only the Arc is cloned, so S does not need to be Clone). It owns the state in an Arc<RwLock<S>>, the renderer as an Arc<dyn Fn> closure, and the version as an AtomicU64.
update(f) acquires the write lock, calls the closure to mutate state, and bumps the version with Acquire/Release ordering. The next render() call sees the new state. Updates are immediate; no batching or debouncing at this layer.
provider(provider_id) returns a HeaderlineProvider that can be registered with register_virtual_row_provider. The provider holds an Arc<dyn Headerline> to the same underlying state, so updates via the handle are immediately visible to the provider's collect() call.
3.4 HeaderlineProvider (substrate adapter)
pub struct HeaderlineProvider {
provider_id: ProviderId,
inner: Arc<dyn Headerline>,
}
impl VirtualRowProvider for HeaderlineProvider {
fn id(&self) -> ProviderId;
fn version(&self) -> u64;
fn collect(&self) -> Vec<VirtualRow>;
}
Wraps any Headerline impl (either SimpleHeaderline<S> via the handle, or a direct impl). collect() calls inner.render(); when it returns Some(row), emits one VirtualRow at anchor_line=0, position=Above, kind=Sticky, height=1. When it returns None, returns an empty vec.
The provider can be constructed either from a SimpleHeaderlineHandle (via .provider(id)) or directly from an Arc<dyn Headerline> (via new(id, inner)).
4. Two paths: when to use which
Path 1: SimpleHeaderlineHandle<S> (owned state)
Use this when the mode has dedicated header state that doesn't live elsewhere. Examples:
- Tutor mode —
TutorViewStatewith lesson info, score, timer. - Multibuffer search —
HeaderlineStatuswith file count, match count, scan progress.
Pattern:
// In mode boot:
let handle = SimpleHeaderlineHandle::new(
TutorViewState::default(),
render_tutor_headerline, // closure in the mode
);
let provider = handle.provider(PROVIDER_ID);
register_virtual_row_provider(provider);
// Store the handle in buffer-local state so dispatch can update it:
buffer.set_headerline_handle(handle.clone());
// Later, on state change:
if let Some(handle) = buffer.get_headerline_handle::<TutorViewState>() {
handle.update(|s| {
s.score += 10;
s.timer = elapsed;
});
}
Benefit: no manual version management; the handle owns versioning.
Path 2: Direct Headerline impl (existing state)
Use this when the header is a view into existing state, or when the producer is a WASM plugin. Examples:
- LSP mode — the session struct carries ready/error/initializing flags. Implement
Headerlinedirectly on the session. - Plugin provider — the plugin's WIT result is wrapped in a thin
Headerlineshim that calls the plugin's render export.
Pattern:
// In LSP session struct:
impl Headerline for LspSession {
fn version(&self) -> u64 {
self.state_version // bump this when state changes
}
fn render(&self) -> Option<HeaderlineRow> {
match self.state {
LspState::Ready => Some(render_ready_status()),
LspState::Initializing => Some(render_initializing()),
LspState::Error(ref msg) => Some(render_error(msg)),
}
}
}
// At registration:
let provider = HeaderlineProvider::new(
PROVIDER_ID,
Arc::new(session) as Arc<dyn Headerline>,
);
register_virtual_row_provider(provider);
Benefit: no state duplication; the session is the source of truth.
5. Paramount-goal alignment
Goal #1 (performance):
update()is anArc<RwLock<S>>writeAtomicU64bump — trivially cheap O(1).collect()is one read-lock + oneVirtualRowbuild — O(1). The cells worker callsversion()every tick;render()is called only when version advances. Sub-frame cost across the hot path.
Goal #2 (extensibility): LSP, diagnostics, VCS, and WASM plugins all adopt the
Headerlinetrait without coupling to a specific state shape. New producers add aHeaderlineimpl in their own crate; zero host changes.Goal #3 (vim semantics): the render closure (or impl) is mode-owned. The substrate owns only the sticky-row mechanics. Modes control when and how the header updates.
Goal #4 (asynchronicity): header state lives in an
Arc<RwLock<S>>that can be mutated from any thread. The cells worker reads it on its own tick without blocking dispatch. Async producers cache results and push updates viahandle.update(f)from a background task.
6. H1: Tutor validation case
TutorHeaderlineProvider (a bespoke VirtualRowProvider with Arc<Mutex<TutorViewState>>) was replaced by:
render_tutor_headerline(s: &TutorViewState) -> Option<HeaderlineRow>— the mode's render function, lives inlattice-host/src/tutor/mode.rs. Pure function of the state.SimpleHeaderlineHandle<TutorViewState>stored asbuffer.set_local("headerline", handle). Boot code creates the handle withSimpleHeaderlineHandle::new(initial_state, render_tutor_headerline).Registration —
handle.provider(provider_id)registered as theVirtualRowProvider. The provider holds anArc<dyn Headerline>to the same underlying state.Update calls —
tutor_update_headerlinecallsstate_handle.update(|s| s.update_for_display(session, kind))instead of aMutex::lock. Atomic version bump happens automatically.Version management —
TutorViewStateloses its manualversion: u64field. Versioning is now owned bySimpleHeaderline::version: AtomicU64.
Result: tutor mode is now the canonical consumer of the generic Headerline surface. No substrate-level tutor-specific knowledge.
7. Future consumers
M.6.5 (multibuffer search status) —
MultibufferStatusProviderbecomesSimpleHeaderlineHandle<HeaderlineStatus>with fields likescanned_count,total_count,match_count. Render closure formats these into a status row.LSP session — direct
Headerlineimpl on the session struct, no handle needed. Session already owns initialization state + error messages; just expose them viarender().VCS branch / diagnostics summary —
SimpleHeaderlineHandle<T>or direct impl, depending on whether state is owned locally or shared with another consumer.
All producers use the same surface; the substrate never special-cases them.
8. Integration with virtual rows
HeaderlineProvider is a VirtualRowProvider (from virtual-rows.md §3.3). It plugs into the virtual-rows worker like any other provider:
- Boot registers
providerviaregister_virtual_row_provider(). - Cells worker ticks, calls
provider.version()to check for changes. - If version advanced, calls
provider.collect(). - Provider calls
inner.render(), wraps the result in aVirtualRowatanchor_line=0, position=Above, kind=Sticky. - Worker merges all registered providers' outputs into
VirtualRowMatrix, publishes viaArcSwap. - Renderer reads
VirtualRowMatrix, interleaves virtual rows into the display stream viadisplay_slice().
No special headerline plumbing. It's just another virtual-row producer.
9. Open questions
Per-buffer headerline masking. Should a multibuffer view allow toggling the headerline off for a specific pane? The current primitive is buffer-scoped (one headerline per buffer, all panes see it). Per-pane gating is a layered concern — the virtual-rows worker can support per-pane provider filtering via a pane-id arg on
collect(). Decide before M.6.5 lands the first multi-pane case that wants it.Headerline height > 1. Reserved for future multi-line headers (e.g. a two-line status row). Currently
height: 1always. Tests and first consumers stay onheight = 1. Multi-line consumers exercise it when they land.Dynamic provider registration. Today a mode registers its headerline at boot. If a future mode wants to dynamically show/hide the header based on user interaction (toggle the
*messages*headerline on:messagesopen/close), the provider registration itself needs a lifecycle hook. Deferred until a real consumer asks.
10. Slice plan
Sequencing lives inline in docs/dev/operations/slice-plans/multibuffer-views.md (M.6.5 section); authoritative status per slice lives in docs/dev/operations/implementation.md. This fragment owns what and why; the slice plan owns when and in what order.
H0 (trait + SimpleHeaderlineHandle) and H1 (tutor migration) shipped together as one coordinated slice. H0 validated the abstraction; H1 proved it on a real consumer.
11. Testing strategy
Unit tests (in
lattice-cells/src/headerline.rs):Headerlinetrait methods callable;SimpleHeaderlineHandle::newconstructs;updatebumps version;render()returns the renderer's result;provider()returns aVirtualRowProviderthat emits one row when render isSome, empty vec whenNone; version tracking across updates; manualHeaderlineimpl works;HeaderlineProvider::newwraps any impl.Tutor integration (H1 validation): mode boots with headerline; tutor mode transitions bump the version; renderer calls
collect(), gets a row with correct state;:quiton the tutor buffer cleans up the provider.Future (M.6.5 multibuffer): search scan pushes updates via
handle.update(); headerline reflects match count in real time; scan completion hides the header (returnsNone).
12. Risks
Version-check busy loop. The cells worker calls
version()on every tick (even when the buffer is idle). If a producer'sversion()call is expensive, it costs the frame. Mitigation:version()is a simple atomic read; the renderer's per-frame cost is below 1µs for a headerline check. Bench gate (recorded) catches outliers.State-lock contention. If a producer updates state frequently from a background task while the renderer is reading
statevia the read-lock, update calls block. Mitigation: updates areO(1)mutations + atomic bump; read-lock is held only duringrender(). Contention is rare in practice (update happens on state change, not per-frame).Provider registration leaks. A mode that registers a headerline but never deregisters leaves a stale provider. Mitigation: modes register headerlines at boot (once, per buffer opening). Explicit deregister is called when the buffer closes or the provider explicitly unsubscribes. The buffer-local storage pattern makes this explicit.
13. Cross-references
crates/lattice-cells/src/headerline.rs— implementation of trait,SimpleHeaderline,SimpleHeaderlineHandle,HeaderlineProvider.crates/lattice-cells/src/virtual_rows.rs—VirtualRowProvidertrait thatHeaderlineProviderimplements.crates/lattice-host/src/tutor/mode.rs—render_tutor_headerline,TutorHeaderlineState, how the mode creates and updates the handle.docs/dev/architecture/virtual-rows.md— the underlying sticky-row primitive and the virtual-rows worker that callscollect().docs/dev/operations/slice-plans/multibuffer-views.md§ M.6.5 — multibuffer search status as the first production post-tutor consumer; M.6.5 owns sequencing for the search-status transition.