facett-form/examples/form_demo.rs
//! **form_demo** — a small admin record form, live. A surveillance "Case" with a
//! title, status enum, assignee, due date (YYYY-MM-DD masked), priority, an
//! "open" checkbox, and a notes block — with **per-field validation** (empty
//! title / out-of-range priority / malformed date go red), **dirty-tracking**
//! (the unsaved marker + Save/Reset enablement), a theme switcher, and the
//! facet-level **Ctrl-C / Ctrl-V** that copies / fills the whole model as JSON.
//!
//! Clipboard is routed exactly the way a [`facett_core::FacetDeck`] does it: drain
//! the frame's semantic clipboard events and dispatch to the facet, gated by its
//! `caps()`. Here we keep a concrete `FormView<Case>` handle so the host can also
//! pull `take_submitted()` after a Save.
//!
//! Run: `cargo run -p facett-form --example form_demo`
use eframe::egui;
use facett_core::{Facet, Theme, clipboard, set_theme};
use facett_form::{FormModel, FormUi, FormView, ValidationReport};
use serde::{Deserialize, Serialize};
/// The admin record. `garde` rules drive the per-field validation outlines.
#[derive(Clone, PartialEq, Serialize, Deserialize, garde::Validate)]
struct Case {
#[garde(length(min = 1))]
title: String,
#[garde(skip)]
status: String,
#[garde(length(min = 1))]
assignee: String,
#[garde(custom(facett_form::valid_ymd))]
due: String,
#[garde(range(min = 0, max = 5))]
priority: i64,
#[garde(skip)]
open: bool,
#[garde(skip)]
notes: String,
}
impl Default for Case {
fn default() -> Self {
Self {
title: "Investigate vessel Nordkapp".into(),
status: "open".into(),
assignee: "hugin".into(),
due: "2026-06-30".into(),
priority: 2,
open: true,
notes: "Cross-ref AIS gaps with port calls.".into(),
}
}
}
impl FormModel for Case {
fn fields(&mut self, f: &mut FormUi<'_>) {
f.section("Case", |f| {
f.text("title", "Title", &mut self.title);
f.select("status", "Status", &mut self.status, &["open", "closed", "escalated"]);
f.text("assignee", "Assignee", &mut self.assignee);
f.date("due", "Due (YYYY-MM-DD)", &mut self.due);
f.number_i64("priority", "Priority (0–5)", &mut self.priority, Some(0..=5));
f.checkbox("open", "Open", &mut self.open);
});
f.section("Notes", |f| {
f.multiline("notes", "Notes", &mut self.notes);
});
}
fn validate(&self) -> ValidationReport {
ValidationReport::from_garde(garde::Validate::validate(self))
}
fn to_clip(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
fn from_clip(&mut self, text: &str) -> bool {
serde_json::from_str::<Case>(text).map(|m| *self = m).is_ok()
}
}
struct Demo {
theme_idx: usize,
form: FormView<Case>,
/// The last record the form submitted (host-side "persisted" copy).
saved: Option<String>,
}
impl Default for Demo {
fn default() -> Self {
Self { theme_idx: 2, form: FormView::new("New Case", Case::default()), saved: None }
}
}
impl eframe::App for Demo {
fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = root.ctx().clone();
let theme = Theme::ALL[self.theme_idx]();
set_theme(&ctx, theme);
egui::Panel::top("controls").show(root, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("Theme:");
for (i, ctor) in Theme::ALL.iter().enumerate() {
ui.selectable_value(&mut self.theme_idx, i, ctor().name);
}
});
ui.label("Edit a field to go dirty · Ctrl-C copies the record as JSON · Ctrl-V fills it");
});
// Deck-style clipboard routing, gated by the facet's caps.
let caps = self.form.caps();
for action in clipboard::poll(&ctx) {
match action {
clipboard::ClipAction::Copy if caps.copyable => {
if let Some(t) = self.form.copy() {
clipboard::put(&ctx, t);
}
}
clipboard::ClipAction::Cut if caps.cuttable => {
if let Some(t) = self.form.cut() {
clipboard::put(&ctx, t);
}
}
clipboard::ClipAction::Paste(s) if caps.pasteable => {
self.form.paste(&s);
}
_ => {}
}
}
egui::CentralPanel::default().show(root, |ui| {
self.form.ui(ui);
if let Some(saved) = self.form.take_submitted() {
self.saved = Some(saved.to_clip());
}
if let Some(s) = &self.saved {
ui.separator();
ui.label("Last saved record:");
ui.code(s);
}
});
}
}
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size(egui::vec2(560.0, 640.0)),
..Default::default()
};
eframe::run_native("facett · form_demo", options, Box::new(|_cc| Ok(Box::<Demo>::default())))
}