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
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;
}viewreads the focused part (cloned if needed — lenses targetA: Clonefor composition).setwrites.overisread → transform → writein one step.thenchains lenses:outer.then(inner)focuses deeper.
Laws (important)
A lens is well-behaved when it satisfies:
| Law | Statement | Intuition |
|---|---|---|
| GetPut | lens.set(s, lens.view(s)) == s | Reading and writing back is a no-op. |
| PutGet | After lens.set(s, a), lens.view(s) == a | Writes are effective. |
| PutPut | Two 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:
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:
| Name | Focuses on | Notes |
|---|---|---|
Identity | The whole value S | Mostly useful for generic code. |
Fst / Snd | First / second of a (A, B) tuple | |
AtIndex (via at_index(i)) | Vec<T> element by index | Panics on OOB. |
SomePrism | Option<T> contents | Returns None when absent (prism, not lens). |
Composition
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
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:
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.