Skip to Content
ftui-runtimeState persistence

State persistence

FrankenTUI can persist widget state across sessions. The infrastructure is a thin registry over a pluggable storage backend, with atomic writes, graceful degradation, and feature-gated file support so applications that never touch disk pay nothing for the capability.

Files:

  • Registry and backends: crates/ftui-runtime/src/state_persistence.rs
  • PersistenceConfig: crates/ftui-runtime/src/program.rs:1884
  • Commands: Cmd::SaveState, Cmd::RestoreState — see commands.

Architecture

Widgets don’t talk to the backend directly; they talk to the registry, which owns the in-memory cache and schedules backend writes.

StorageBackend

crates/ftui-runtime/src/state_persistence.rs
pub trait StorageBackend: Send + Sync { fn name(&self) -> &str; fn load_all(&self) -> StorageResult<HashMap<String, StoredEntry>>; fn save_all(&self, entries: &HashMap<String, StoredEntry>) -> StorageResult<()>; fn clear(&self) -> StorageResult<()>; fn is_available(&self) -> bool { true } }

Implementations must be Send + Sync. Two are provided:

MemoryStorage

Always available; state is lost when the process exits. Useful for tests, transient apps, and unit-testing widget state logic.

use ftui_runtime::state_persistence::{MemoryStorage, StateRegistry}; let registry = StateRegistry::new(Box::new(MemoryStorage::new()));

FileStorage (feature state-persistence)

JSON file with a write-to-temp, fsync, rename atomic pattern. Base64-encodes entry bytes for binary safety. Compatible across versions via a top-level format_version field.

#[cfg(feature = "state-persistence")] use ftui_runtime::state_persistence::{FileStorage, StateRegistry}; let registry = StateRegistry::with_file("state.json"); // Or platform-appropriate path: let fs = FileStorage::default_for_app("my-app"); // Linux: $XDG_STATE_HOME/ftui/my-app/state.json // (falls back to $HOME/.local/state/ftui/my-app/state.json, // then to ./ftui/my-app/state.json)

The file schema (see file_storage.rs inside state_persistence.rs):

state.json
{ "format_version": 1, "entries": { "ScrollView::main": { "version": 1, "data_base64": "eyJzY3JvbGxfb2Zmc2V0IjogNDJ9" } } }

If the format_version does not match the one the crate understands, the load is skipped (empty map returned) with a tracing::warn!. No panic, no corruption of an otherwise-valid file.

StateRegistry

crates/ftui-runtime/src/state_persistence.rs
pub struct StateRegistry { /* backend + RwLock<HashMap> */ } impl StateRegistry { pub fn new(backend: Box<dyn StorageBackend>) -> Self; pub fn in_memory() -> Self; #[cfg(feature = "state-persistence")] pub fn with_file(path: impl AsRef<Path>) -> Self; pub fn load(&self) -> StorageResult<usize>; pub fn flush(&self) -> StorageResult<bool>; // false if clean pub fn get(&self, key: &str) -> Option<StoredEntry>; pub fn set(&self, key: impl Into<String>, version: u32, data: Vec<u8>); pub fn remove(&self, key: &str) -> Option<StoredEntry>; pub fn clear(&self) -> StorageResult<()>; pub fn keys(&self) -> Vec<String>; pub fn is_dirty(&self) -> bool; pub fn len(&self) -> usize; pub fn backend_name(&self) -> &str; pub fn is_available(&self) -> bool; pub fn shared(self) -> Arc<Self>; }

Typical flow:

  1. registry.load() at startup — loads all entries into the cache.
  2. Widgets get / set entries as they mount and change.
  3. registry.flush() writes the cache back to the backend. Returns Ok(false) if nothing was dirty (no disk write happens).
  4. registry.clear() wipes everything.

PersistenceConfig

crates/ftui-runtime/src/program.rs:1888
pub struct PersistenceConfig { pub registry: Option<Arc<StateRegistry>>, pub checkpoint_interval: Option<Duration>, pub auto_load: bool, pub auto_save: bool, }

Defaults: no registry, no periodic checkpoint, auto_load = true, auto_save = true (so that when a registry is supplied, the runtime loads on startup and flushes on shutdown).

Builder:

PersistenceConfig::disabled() // default PersistenceConfig::with_registry(arc) // install a registry .checkpoint_every(Duration::from_secs(30)) // periodic flush .auto_load(true) .auto_save(true);

Hand it to ProgramConfig:

main.rs
use ftui::prelude::*; use ftui_runtime::PersistenceConfig; use ftui_runtime::state_persistence::StateRegistry; use std::sync::Arc; use std::time::Duration; let registry = Arc::new(StateRegistry::with_file("state.json")); let config = ProgramConfig::fullscreen() .with_persistence( PersistenceConfig::with_registry(registry) .checkpoint_every(Duration::from_secs(30)), ); Program::with_config(model, config)?.run()

The Stateful widget contract

Widgets participate in persistence by implementing Stateful from ftui-widgets:

crates/ftui-widgets/src/stateful.rs
pub trait Stateful { type State: serde::Serialize + serde::de::DeserializeOwned; fn key(&self) -> &str; fn state(&self) -> Self::State; fn restore(&mut self, state: Self::State); fn state_version(&self) -> u32 { 1 } }

The registry stores (key, version, bytes) triples. When state_version changes, the registry gracefully ignores the old entry rather than attempting a lossy migration.

Worked example

examples/persist_counter.rs
use ftui::prelude::*; use ftui_core::geometry::Rect; use ftui_runtime::PersistenceConfig; use ftui_runtime::state_persistence::StateRegistry; use ftui_widgets::Widget; use ftui_widgets::paragraph::Paragraph; use std::sync::Arc; #[derive(serde::Serialize, serde::Deserialize, Clone, Default)] struct CounterState { count: i32 } struct Counter { state: CounterState, registry: Arc<StateRegistry> } impl Model for Counter { type Message = Msg; fn init(&mut self) -> Cmd<Msg> { if let Some(entry) = self.registry.get("Counter::main") { if let Ok(s) = serde_json::from_slice::<CounterState>(&entry.data) { self.state = s; } } Cmd::none() } fn update(&mut self, msg: Msg) -> Cmd<Msg> { match msg { Msg::Inc => { self.state.count += 1; let bytes = serde_json::to_vec(&self.state).unwrap_or_default(); self.registry.set("Counter::main", 1, bytes); Cmd::save_state() } Msg::Quit => Cmd::quit(), _ => Cmd::none(), } } fn view(&self, frame: &mut Frame) { let text = format!("count: {}", self.state.count); Paragraph::new(text.as_ref()) .render(Rect::new(0, 0, frame.width(), 1), frame); } }

Failure modes (designed in)

FailureCauseBehaviour
StorageError::IoFile I/O failure.Returned, cache unaffected.
StorageError::SerializationJSON encode/decode.Entry skipped, logged.
StorageError::CorruptionInvalid file format.load_all returns partial data.
Missing entryFirst run or renamed widget.registry.get returns None; widget uses Default.

None of these panic. The registry is Send + Sync and uses RwLock; a poisoned lock is surfaced as StorageError::Corruption rather than unwinding.

Pitfalls

Don’t Cmd::save_state() on every keystroke. Each call flushes the registry to the backend. Prefer batching — checkpoint on a timer (PersistenceConfig::checkpoint_every) or on explicit user action (save, commit).

Bump state_version when your serialized layout changes. If you leave the version at 1 and change the struct, older entries will deserialize into garbage. Incrementing the version makes the registry treat old entries as absent and fall back to Default.

Cross-references