facett-form/src/lib.rs
//! **facett-form** — administrative / CRUD forms as a [`Facet`]. A consumer
//! implements [`FormModel`] for its record struct `T`; [`FormView<T>`] drives it:
//! labelled fields (text / multiline / number / select-enum / masked date /
//! checkbox), **per-field validation + red-outline error styling** (delegated to
//! [`egui_form`] backed by [`garde`]), sections, submit / reset, and
//! **dirty-tracking** against a baseline snapshot.
//!
//! The hard part — turning a validation report into per-field error outlines and
//! messages — is *not* re-solved here: it's `egui_form`'s, wrapped behind a thin
//! [`FormUi`] field-builder so consumers never touch `egui_form` directly and the
//! backend stays swappable (see `.nornir/design/facett-form.md`).
//!
//! Clipboard: a field's own Ctrl-C/V inside a `TextEdit` is egui-native; the
//! **facet-level** [`Facet::copy`]/[`Facet::paste`] round-trips the *whole model*
//! as JSON via [`FormModel::to_clip`]/[`FormModel::from_clip`].
mod pdf;
use egui::Ui;
use egui_form::garde::GardeReport;
use egui_form::{Form, FormField};
use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::{Facet, FacetCaps, theme};
/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps [`nornir_testmatrix::functional_status`] behind the
/// `testmatrix` feature; a compiled-out `#[inline]` no-op otherwise (and no
/// nornir dep in the default build). `component` is the reporting surface
/// (`"facett-form"`), `check` what it verified, `ok` the verdict, `detail` a
/// short human note. The form's submit/reset/paste surfaces call this so
/// `nornir test` SEES the form's health.
#[inline]
pub(crate) fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
{
let _ = (component, check, ok, detail);
}
}
/// The validation report handed to [`FormView`]: a thin wrapper over
/// `egui_form`'s [`GardeReport`], so the backend stays behind facett's seam.
/// Build one with [`ValidationReport::ok`] (no errors) or
/// [`ValidationReport::from_garde`] (from a `garde::Validate::validate` result).
#[derive(Default)]
pub struct ValidationReport {
/// `field path -> message`, extracted once so we can both seed `egui_form`'s
/// per-field outlines and surface a `state_json` errors map for headless tests.
errors: Vec<(String, String)>,
}
impl ValidationReport {
/// An empty (valid) report.
pub fn ok() -> Self {
Self { errors: Vec::new() }
}
/// Build from a `garde::Validate::validate()` result. `Ok(())` → no errors;
/// `Err(report)` → one entry per offending field path.
pub fn from_garde(result: Result<(), garde::Report>) -> Self {
let mut errors = Vec::new();
if let Err(report) = &result {
for (path, err) in report.iter() {
errors.push((path.to_string(), err.to_string()));
}
}
Self { errors }
}
/// True when there are no validation errors.
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
/// Number of field errors.
pub fn len(&self) -> usize {
self.errors.len()
}
/// The message for a specific field id, if any.
pub fn field_error(&self, id: &str) -> Option<&str> {
self.errors.iter().find(|(p, _)| p == id).map(|(_, m)| m.as_str())
}
/// `{ field_id: message, … }` — folded into the facet's `state_json` so
/// headless tests can assert exactly which fields are invalid.
pub fn to_json(&self) -> serde_json::Value {
let mut map = serde_json::Map::new();
for (path, msg) in &self.errors {
map.insert(path.clone(), serde_json::Value::String(msg.clone()));
}
serde_json::Value::Object(map)
}
}
/// Implemented by the consumer's record model: describe its fields + (optionally)
/// validate and serialize. `Clone + PartialEq` powers dirty-tracking and reset.
pub trait FormModel: Clone + PartialEq {
/// Lay the fields out into `form`. Called each frame (and once headlessly to
/// build the validation report). Read/write `&mut self`'s fields here.
fn fields(&mut self, form: &mut FormUi<'_>);
/// Validate; return a report keyed by field id. Default: always ok. Typically
/// `ValidationReport::from_garde(self.validate())` with `#[derive(garde::Validate)]`.
fn validate(&self) -> ValidationReport {
ValidationReport::ok()
}
/// Serialize to text for the facet-level clipboard copy (default: empty).
/// Typically `serde_json::to_string_pretty(self)`.
fn to_clip(&self) -> String {
String::new()
}
/// Hydrate from pasted text (default: no-op). Return true if applied.
/// Typically `serde_json::from_str(text).map(|m| *self = m).is_ok()`.
// Name + `&mut self` are intentional (the inverse of `to_clip`; mutates in
// place from a clipboard payload) — not a constructor, so the from_* lint
// doesn't apply here.
#[allow(clippy::wrong_self_convention)]
fn from_clip(&mut self, _text: &str) -> bool {
false
}
}
/// One captured field of a [`FormSnapshot`]: the field id, its label, its current
/// value rendered to text, and the section it sits under. Produced by running
/// [`FormModel::fields`] in the non-visual **capture** mode (see
/// [`FormView::snapshot`]) — the text export / PDF path reads these, so the *same*
/// `fields` layout drives both the on-screen form and the printed document.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CapturedField {
/// The field's stable id (matches the on-screen widget + validation key).
pub id: String,
/// The human label drawn next to the widget.
pub label: String,
/// The current value, rendered to text (numbers stringified, bool → yes/no).
pub value: String,
/// The enclosing [`FormUi::section`] title, if the field sits inside one.
pub section: Option<String>,
}
/// A non-visual, section-tagged snapshot of a form's current values — the input to
/// [`FormView::to_pdf`] and any other text export. Serializable so a host can also
/// ship it as JSON.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FormSnapshot {
/// The form title.
pub title: String,
/// Whether the model validated clean at snapshot time.
pub valid: bool,
/// Every field, in layout order, each tagged with its section.
pub fields: Vec<CapturedField>,
}
/// The per-frame field builder handed to [`FormModel::fields`]. Its **live** mode
/// wraps the `egui::Ui` and the `egui_form` [`Form`] (carrying the validation
/// report) so every widget renders with the right label + per-field error outline;
/// its **capture** mode records each field as text (no egui) so the *same*
/// [`FormModel::fields`] layout also drives the [PDF export](FormView::to_pdf).
pub struct FormUi<'a> {
backend: Backend<'a>,
}
/// The two things a [`FormUi`] can be doing with the fields the model declares:
/// paint them (live) or collect them as text (capture).
enum Backend<'a> {
/// Paint into egui, seeded with the validation report.
Live { ui: &'a mut Ui, form: Form<GardeReport> },
/// Collect fields as text (the current section + the accumulated fields).
Capture { section: Option<String>, fields: Vec<CapturedField> },
}
impl<'a> FormUi<'a> {
fn new(ui: &'a mut Ui, report: GardeReport) -> Self {
Self { backend: Backend::Live { ui, form: Form::new().add_report(report) } }
}
/// A capturing builder: [`FormModel::fields`] laid out into text, not egui.
fn capturing() -> Self {
Self { backend: Backend::Capture { section: None, fields: Vec::new() } }
}
/// Consume a capturing builder, yielding the collected fields (empty if live).
fn into_captured(self) -> Vec<CapturedField> {
match self.backend {
Backend::Capture { fields, .. } => fields,
Backend::Live { .. } => Vec::new(),
}
}
/// In capture mode, set the current section, returning the previous one (no-op
/// and `None` when live).
fn swap_section(&mut self, next: Option<String>) -> Option<String> {
if let Backend::Capture { section, .. } = &mut self.backend {
std::mem::replace(section, next)
} else {
None
}
}
/// In capture mode, record one field (no-op when live).
fn record(&mut self, id: &str, label: &str, value: String) {
if let Backend::Capture { section, fields } = &mut self.backend {
fields.push(CapturedField {
id: id.to_string(),
label: label.to_string(),
value,
section: section.clone(),
});
}
}
/// A titled group of fields under a heading + separator. Field ids inside the
/// section share the form's single validation report (live) and are tagged with
/// the section title (capture).
pub fn section(&mut self, title: &str, body: impl FnOnce(&mut FormUi<'_>)) {
if let Backend::Live { ui, form } = &mut self.backend {
let th = theme(ui);
ui.add_space(4.0);
ui.colored_label(th.accent, egui::RichText::new(title).strong());
ui.separator();
// Re-borrow the Ui into a fresh FormUi *sharing the same Form* (move it
// out, run the body, move it back) so all field ids land in one report.
let taken = std::mem::replace(form, Form::new());
let mut inner = FormUi { backend: Backend::Live { ui, form: taken } };
body(&mut inner);
if let Backend::Live { form: inner_form, .. } = inner.backend {
*form = inner_form;
}
} else {
let prev = self.swap_section(Some(title.to_string()));
body(self);
self.swap_section(prev);
}
}
/// Single-line text field.
pub fn text(&mut self, id: &str, label: &str, value: &mut String) {
if let Backend::Live { ui, form } = &mut self.backend {
FormField::new(form, id).label(label).ui(ui, egui::TextEdit::singleline(value));
} else {
let v = value.clone();
self.record(id, label, v);
}
}
/// Multi-line text field.
pub fn multiline(&mut self, id: &str, label: &str, value: &mut String) {
if let Backend::Live { ui, form } = &mut self.backend {
FormField::new(form, id).label(label).ui(ui, egui::TextEdit::multiline(value));
} else {
let v = value.clone();
self.record(id, label, v);
}
}
/// Integer field (a `DragValue`, optionally range-clamped).
pub fn number_i64(&mut self, id: &str, label: &str, value: &mut i64, range: Option<std::ops::RangeInclusive<i64>>) {
if let Backend::Live { ui, form } = &mut self.backend {
let mut dv = egui::DragValue::new(value);
if let Some(r) = range {
dv = dv.range(r);
}
FormField::new(form, id).label(label).ui(ui, dv);
} else {
let v = value.to_string();
self.record(id, label, v);
}
}
/// Float field (a `DragValue`, optionally range-clamped).
pub fn number_f64(&mut self, id: &str, label: &str, value: &mut f64, range: Option<std::ops::RangeInclusive<f64>>) {
if let Backend::Live { ui, form } = &mut self.backend {
let mut dv = egui::DragValue::new(value);
if let Some(r) = range {
dv = dv.range(r);
}
FormField::new(form, id).label(label).ui(ui, dv);
} else {
let v = value.to_string();
self.record(id, label, v);
}
}
/// Enum-style select via a `ComboBox` over `options`. The bound `value` is the
/// chosen string; an out-of-set value still renders (the host may validate it).
pub fn select(&mut self, id: &str, label: &str, value: &mut String, options: &[&str]) {
if let Backend::Live { ui, .. } = &mut self.backend {
// ComboBox isn't a `Widget`, so paint label + combo directly (the field
// outline matters less for a closed set, but we still keep the layout).
let th = theme(ui);
ui.add_space(2.0);
ui.label(egui::RichText::new(label).color(th.text).size(13.0));
egui::ComboBox::from_id_salt(format!("facett_form_select_{id}"))
.selected_text(value.clone())
.show_ui(ui, |ui| {
for opt in options {
ui.selectable_value(value, (*opt).to_string(), *opt);
}
});
} else {
let v = value.clone();
self.record(id, label, v);
}
}
/// A `YYYY-MM-DD` masked text date field (zero extra deps). Pair with a
/// `#[garde(custom(...))]` or `#[garde(pattern(...))]` rule to validate format.
pub fn date(&mut self, id: &str, label: &str, value: &mut String) {
if let Backend::Live { ui, form } = &mut self.backend {
FormField::new(form, id)
.label(label)
.ui(ui, egui::TextEdit::singleline(value).hint_text("YYYY-MM-DD"));
} else {
let v = value.clone();
self.record(id, label, v);
}
}
/// Boolean checkbox.
pub fn checkbox(&mut self, id: &str, label: &str, value: &mut bool) {
if let Backend::Live { ui, form } = &mut self.backend {
FormField::new(form, id).label("").ui(ui, egui::Checkbox::new(value, label));
} else {
let v = if *value { "yes" } else { "no" }.to_string();
self.record(id, label, v);
}
}
}
/// Validate `YYYY-MM-DD` cheaply (no date crate): four-digit year, 01–12 month,
/// 01–31 day (day-in-month not range-checked beyond 31). Handy as a garde
/// `#[garde(custom(facett_form::valid_ymd))]` rule for [`FormUi::date`] fields.
pub fn valid_ymd<C>(value: &str, _ctx: &C) -> garde::Result {
let bytes = value.as_bytes();
let shaped = bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-';
let digits_ok = |range: std::ops::Range<usize>| value[range].bytes().all(|b| b.is_ascii_digit());
if shaped && digits_ok(0..4) && digits_ok(5..7) && digits_ok(8..10) {
let m: u32 = value[5..7].parse().unwrap_or(0);
let d: u32 = value[8..10].parse().unwrap_or(0);
if (1..=12).contains(&m) && (1..=31).contains(&d) {
return Ok(());
}
}
Err(garde::Error::new("expected a date as YYYY-MM-DD"))
}
/// The [`Facet`] driver: owns a model `T`, a baseline snapshot (for dirty-tracking
/// + reset), the last validation report, and the last valid submitted model.
pub struct FormView<T: FormModel> {
/// Tab / heading label.
pub title: String,
model: T,
baseline: T,
last_report: ValidationReport,
submitted: Option<T>,
}
impl<T: FormModel> FormView<T> {
/// New form over `model`; the baseline (clean state) is `model.clone()`.
pub fn new(title: impl Into<String>, model: T) -> Self {
let baseline = model.clone();
let last_report = model.validate();
Self { title: title.into(), model, baseline, last_report, submitted: None }
}
/// True when the model differs from the baseline (drives the unsaved marker +
/// Save/Reset enablement).
pub fn is_dirty(&self) -> bool {
self.model != self.baseline
}
/// Whether the model currently validates clean.
pub fn is_valid(&self) -> bool {
self.last_report.is_empty()
}
/// Take the last valid submitted model, if any (the host pulls this to persist).
pub fn take_submitted(&mut self) -> Option<T> {
self.submitted.take()
}
/// Discard edits: `model = baseline`.
pub fn reset(&mut self) {
self.model = self.baseline.clone();
self.last_report = self.model.validate();
}
/// Borrow the current model.
pub fn model(&self) -> &T {
&self.model
}
/// Borrow the current model mutably (host-side programmatic edits; re-validates).
pub fn model_mut(&mut self) -> &mut T {
&mut self.model
}
/// Re-validate after a programmatic edit via [`model_mut`](Self::model_mut).
pub fn revalidate(&mut self) {
self.last_report = self.model.validate();
}
/// Mark the current model as the clean baseline (e.g. after the host persists).
pub fn mark_clean(&mut self) {
self.baseline = self.model.clone();
}
/// Capture the form's current field values as a flat, section-tagged
/// [`FormSnapshot`] — the input to [`to_pdf`](Self::to_pdf) and any other text
/// export. Runs the model's [`fields`](FormModel::fields) layout in the
/// non-visual **capture** mode over a *clone* of the model, so it never mutates
/// the live model and needs no egui context (works headless + on wasm).
#[must_use]
pub fn snapshot(&self) -> FormSnapshot {
let mut model = self.model.clone();
let mut fu = FormUi::capturing();
model.fields(&mut fu);
FormSnapshot { title: self.title.clone(), valid: self.is_valid(), fields: fu.into_captured() }
}
/// Render the form's current state to a **PDF** document (the raw bytes). Pure
/// Rust via [`pdf_writer`] — no shell-out, wasm-clean. The page carries the form
/// title, a validity line, and every field grouped under its section heading.
/// Feed the bytes to a host file-save (native) or a browser download (wasm).
#[must_use]
pub fn to_pdf(&self) -> Vec<u8> {
pdf::render(&self.snapshot())
}
}
// ── typed copy/paste (§16) — the whole model as its clipboard Text (JSON) ──────
impl<T: FormModel> CopySource for FormView<T> {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
let s = self.model.to_clip();
(!s.is_empty()).then_some(ClipPayload::Text(s))
}
}
impl<T: FormModel> PasteTarget for FormView<T> {
fn accepts(&self, k: ClipKind) -> bool {
matches!(k, ClipKind::Text)
}
fn paste_payload(&mut self, p: &ClipPayload) {
if self.model.from_clip(&p.as_text()) {
self.last_report = self.model.validate();
}
}
}
impl<T: FormModel> Facet for FormView<T> {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut Ui) {
// Validate the live model, then build the field UI seeded with the report
// so egui_form paints per-field red outlines + messages on the offenders.
// We reconstruct a garde::Report from our ValidationReport so the outlined
// field ids line up exactly with `state_json`'s `errors` map — and the
// FormModel stays garde-agnostic (it owns where errors come from).
self.last_report = self.model.validate();
let report = GardeReport::new(garde_result_from(&self.last_report));
{
let mut fu = FormUi::new(ui, report);
self.model.fields(&mut fu);
}
ui.separator();
ui.horizontal(|ui| {
let can_save = self.is_valid() && self.is_dirty();
if ui.add_enabled(can_save, egui::Button::new("Save")).on_hover_text("Submit the valid form").clicked() {
self.submitted = Some(self.model.clone());
self.baseline = self.model.clone();
functional_status("facett-form", "submit", true, &self.title);
}
if ui.add_enabled(self.is_dirty(), egui::Button::new("Reset")).clicked() {
self.reset();
functional_status("facett-form", "reset", true, &self.title);
}
if self.is_dirty() {
let th = theme(ui);
ui.colored_label(th.accent, "● unsaved");
}
if !self.is_valid() {
let th = theme(ui);
ui.colored_label(th.text_dim, format!("{} field error(s)", self.last_report.len()));
}
});
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"dirty": self.is_dirty(),
"valid": self.is_valid(),
"errors": self.last_report.to_json(),
"submitted": self.submitted.is_some(),
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.copyable().pasteable().cuttable().selectable().searchable().themeable()
}
fn selection_json(&self) -> serde_json::Value {
// The whole model is the "selection" for clipboard purposes.
serde_json::json!({ "dirty": self.is_dirty(), "valid": self.is_valid() })
}
/// Facet-level copy: the whole model as the consumer's clipboard text (JSON).
fn copy(&mut self) -> Option<String> {
let s = self.model.to_clip();
if s.is_empty() { None } else { Some(s) }
}
/// Cut on a form is a copy (a form isn't a deletable selection); never clears.
fn cut(&mut self) -> Option<String> {
self.copy()
}
/// Facet-level paste: fill the model from pasted text; re-validate on success.
fn paste(&mut self, text: &str) -> bool {
if self.model.from_clip(text) {
self.last_report = self.model.validate();
functional_status("facett-form", "paste", self.is_valid(), &self.title);
true
} else {
functional_status("facett-form", "paste", false, "unparseable clipboard");
false
}
}
}
/// Reconstruct the `Result<(), garde::Report>` that `egui_form`'s [`GardeReport`]
/// consumes, from our backend-neutral [`ValidationReport`]. This lets `egui_form`
/// outline the exact field ids our `state_json` reports, while keeping
/// [`FormModel`] garde-agnostic (it owns where its errors come from).
fn garde_result_from(report: &ValidationReport) -> Result<(), garde::Report> {
if report.is_empty() {
return Ok(());
}
let mut gr = garde::Report::new();
for (path, msg) in report.errors.iter() {
gr.append(garde::Path::new(path.clone()), garde::Error::new(msg.clone()));
}
Err(gr)
}
#[cfg(test)]
mod tests;