Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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;