Skip to Content

Lenses

A lens focuses on a part A of a whole S and gives you both read (view) and write (set) access. Lenses let widgets bind bidirectionally to nested model fields without each widget knowing the shape of your model.

File: crates/ftui-runtime/src/lens.rs.

Why lenses?

Form-like widgets (Input, Slider, Checkbox, Select) naturally want two things: read the current value to render, and propose a new value when the user interacts. Passing &mut AppState down the tree is awkward and couples every widget to the full state shape. Passing getter/setter pairs is verbose. A lens packages both into one value you can compose.

The trait

crates/ftui-runtime/src/lens.rs
pub trait Lens<S, A> { fn view(&self, whole: &S) -> A; fn set(&self, whole: &mut S, part: A); fn over(&self, whole: &mut S, f: impl FnOnce(A) -> A) where A: Clone { let current = self.view(whole); self.set(whole, f(current)); } fn then<B, L2: Lens<A, B>>(self, inner: L2) -> Composed<Self, L2, A> where Self: Sized, A: Clone; }
  • view reads the focused part (cloned if needed — lenses target A: Clone for composition).
  • set writes.
  • over is read → transform → write in one step.
  • then chains lenses: outer.then(inner) focuses deeper.

Laws (important)

A lens is well-behaved when it satisfies:

LawStatementIntuition
GetPutlens.set(s, lens.view(s)) == sReading and writing back is a no-op.
PutGetAfter lens.set(s, a), lens.view(s) == aWrites are effective.
PutPutTwo writes collapse to the last one.Updates are idempotent in the final value.

FrankenTUI’s field_lens and compose preserve these laws provided your getter/setter closures are consistent (get field / set field). Tests at the bottom of lens.rs (law_get_put, law_put_get, law_put_put, compose_laws_hold) are executable proofs.

If a widget depends on GetPut for correctness (e.g. it reads the current value into internal state and writes it back on blur), a broken lens will silently clear the field or leak stale data. Keep your getters and setters inverse.

Building a lens

The primary constructor is field_lens:

src/lens.rs
pub fn field_lens<S, A>( getter: impl Fn(&S) -> A + 'static, setter: impl Fn(&mut S, A) + 'static, ) -> FieldLens<impl Fn(&S) -> A, impl Fn(&mut S, A)>;

Alternative constructors exist for common patterns:

NameFocuses onNotes
IdentityThe whole value SMostly useful for generic code.
Fst / SndFirst / second of a (A, B) tuple
AtIndex (via at_index(i))Vec<T> element by indexPanics on OOB.
SomePrismOption<T> contentsReturns None when absent (prism, not lens).

Composition

src/lens.rs
pub fn compose<S, B, A, L1, L2>(outer: L1, inner: L2) -> Composed<L1, L2, B> where L1: Lens<S, B>, L2: Lens<B, A>, B: Clone;

Equivalently, method syntax:

let app_volume = settings_lens.then(volume_lens);

Composition preserves the laws when both components are law-abiding.

Worked example: binding a Slider to a nested field

examples/volume_slider.rs
use ftui::prelude::*; use ftui_runtime::lens::{Lens, compose, field_lens}; #[derive(Clone, Default)] struct Audio { volume: u8, muted: bool } #[derive(Clone, Default)] struct Settings { audio: Audio } #[derive(Default)] struct App { settings: Settings } // Build lenses once (usually in a constructor or factory). fn settings_lens() -> impl Lens<App, Settings> { field_lens( |a: &App| a.settings.clone(), |a: &mut App, s| a.settings = s, ) } fn audio_lens() -> impl Lens<Settings, Audio> { field_lens( |s: &Settings| s.audio.clone(), |s: &mut Settings, a| s.audio = a, ) } fn volume_lens() -> impl Lens<Audio, u8> { field_lens( |a: &Audio| a.volume, |a: &mut Audio, v| a.volume = v, ) } fn app_volume() -> impl Lens<App, u8> { compose(compose(settings_lens(), audio_lens()), volume_lens()) } // Elsewhere: a slider widget takes a Lens<App, u8>. // On drag: slider.set(&mut model, new_value) // On render: slider.draw(frame, slider.view(&model))

The widget never imports App, Settings, or Audio. It accepts any Lens<_, u8> and that’s it.

Mutating with over

app_volume().over(&mut app, |v| v.saturating_add(5));

Use over when the next value depends on the current one (toggles, increments). It is a single logical operation so intermediate values don’t escape.

Prisms, briefly

A Prism<S, A> is a lens whose preview can fail:

src/lens.rs
pub trait Prism<S, A> { fn preview(&self, whole: &S) -> Option<A>; fn set_if(&self, whole: &mut S, part: A) -> bool; } pub struct SomePrism; impl<T: Clone> Prism<Option<T>, T> for SomePrism { /* ... */ }

Use a prism when the target may not exist (sum types, optional fields, enum variants). set_if returns whether the set actually happened, so you can detect “wrote into a Some” vs “skipped a None”.

Pitfalls

Cloning in the getter is usually unavoidable. Lenses work with owned values so composition stays uniform; avoid this only by specialising with a zero-copy variant (not provided by default). If your field is large, store an Arc<T> or a small id into a side table instead of the whole payload.

Don’t fabricate a lens that violates GetPut. A getter that normalises (e.g. returns .trim().to_string()) combined with a setter that stores raw input makes set(s, view(s)) lossy. Normalise in both halves or in neither.

Cross-references