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
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):
{
"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
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:
registry.load()at startup — loads all entries into the cache.- Widgets
get/setentries as they mount and change. registry.flush()writes the cache back to the backend. ReturnsOk(false)if nothing was dirty (no disk write happens).registry.clear()wipes everything.
PersistenceConfig
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:
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:
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
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)
| Failure | Cause | Behaviour |
|---|---|---|
StorageError::Io | File I/O failure. | Returned, cache unaffected. |
StorageError::Serialization | JSON encode/decode. | Entry skipped, logged. |
StorageError::Corruption | Invalid file format. | load_all returns partial data. |
| Missing entry | First 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.