Skip to main content

sim_lib_view/
universal_editor.rs

1//! The universal default editor: edit any value over one draft.
2//!
3//! The editor renders a draft in two real projections -- a readable `text`
4//! rendering and the codec-portable `raw` form (see [`EDIT_MODES`]). It
5//! validates before commit, anchors errors to the edited field, allows
6//! cancel/revert, preserves unknown fields when editing open maps (set
7//! semantics keep sibling keys), and refuses to commit a readonly value.
8//!
9//! Note: earlier scaffolding advertised four "synchronized" modes
10//! (form/tree/text/raw), but form, tree, and text all rendered identically;
11//! only the two distinct projections below are advertised, so the mode list
12//! matches what is actually implemented.
13
14use sim_kernel::{Cx, Diagnostic, Error, Expr, Result, Symbol};
15use sim_lib_intent::{field, intent_kind_of};
16use sim_value::path::{Path, PathError, set_at};
17
18use crate::contract::{Draft, Editor, Operation};
19
20/// The real edit-mode projections over one draft: a readable `text` rendering
21/// and the codec-portable `raw` form. These are the only two distinct
22/// projections [`render_draft`] produces, so only they are advertised.
23pub const EDIT_MODES: &[&str] = &["text", "raw"];
24
25/// The universal default editor.
26pub struct UniversalEditor {
27    readonly: bool,
28}
29
30impl UniversalEditor {
31    /// A writable universal editor.
32    pub fn writable() -> Self {
33        Self { readonly: false }
34    }
35
36    /// A read-only universal editor: it renders but never commits.
37    pub fn readonly() -> Self {
38        Self { readonly: true }
39    }
40}
41
42impl Editor for UniversalEditor {
43    fn decode(&self, _cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft> {
44        let Some(kind) = intent_kind_of(intent) else {
45            return Err(Error::HostError("editor input is not an Intent".to_owned()));
46        };
47        match &*kind.name {
48            "edit-field" => self.edit_field(value, intent),
49            "commit" => Ok(Draft::clean(value.clone(), value.clone())),
50            "cancel" => {
51                // Revert: discard any pending edit, proposing the base unchanged.
52                Ok(Draft::clean(value.clone(), value.clone()))
53            }
54            other => Ok(Draft::rejected(
55                value.clone(),
56                Diagnostic::error(format!("universal editor does not handle intent '{other}'")),
57            )),
58        }
59    }
60
61    fn commit(&self, _cx: &mut Cx, draft: &Draft) -> Result<Operation> {
62        if !draft.committable {
63            return Err(Error::HostError(
64                "draft is not committable; resolve diagnostics first".to_owned(),
65            ));
66        }
67        // The operation realizes by setting the resource to the proposed value.
68        Ok(Operation {
69            form: Expr::Map(vec![
70                (
71                    Expr::Symbol(Symbol::new("op")),
72                    Expr::Symbol(Symbol::new("set-value")),
73                ),
74                (Expr::Symbol(Symbol::new("value")), draft.proposed.clone()),
75            ]),
76        })
77    }
78}
79
80impl UniversalEditor {
81    fn edit_field(&self, value: &Expr, intent: &Expr) -> Result<Draft> {
82        if self.readonly {
83            return Ok(Draft::rejected(
84                value.clone(),
85                Diagnostic::error("value is read-only and cannot be edited"),
86            ));
87        }
88        let Some(path_expr @ Expr::List(_)) = field(intent, "path") else {
89            return Ok(Draft::rejected(
90                value.clone(),
91                Diagnostic::error("edit-field is missing a list 'path'"),
92            ));
93        };
94        let Some(new_value) = field(intent, "value") else {
95            return Ok(Draft::rejected(
96                value.clone(),
97                Diagnostic::error("edit-field is missing a 'value'"),
98            ));
99        };
100        let path = match Path::from_expr(path_expr) {
101            Ok(path) => path,
102            Err(error) => {
103                return Ok(Draft::rejected(
104                    value.clone(),
105                    path_error_diagnostic(path_expr, error),
106                ));
107            }
108        };
109        match set_at(value, &path, new_value.clone()) {
110            Ok(proposed) => Ok(Draft::clean(value.clone(), proposed)),
111            Err(error) => Ok(Draft::rejected(
112                value.clone(),
113                path_error_diagnostic(path_expr, error),
114            )),
115        }
116    }
117}
118
119/// Render a draft in one of the real edit modes (`text` readable, `raw`
120/// codec-portable; see [`EDIT_MODES`]). Both are views over the same
121/// `draft.proposed`, so switching mode never changes the draft. Any unknown
122/// mode renders the readable form.
123pub fn render_draft(draft: &Draft, mode: &str) -> Result<Expr> {
124    let proposed = &draft.proposed;
125    // `raw` shows the codec-portable encoding; every other mode renders the
126    // readable form.
127    let body = match mode {
128        "raw" => sim_codec::encode_portable(sim_kernel::CodecId(0), proposed)
129            .unwrap_or_else(|_| crate::universal_view::render_value(proposed)),
130        _ => crate::universal_view::render_value(proposed),
131    };
132    Ok(sim_lib_scene::node(
133        "box",
134        vec![
135            ("role", Expr::Symbol(Symbol::new("edit"))),
136            ("mode", Expr::Symbol(Symbol::new(mode))),
137            (
138                "children",
139                Expr::List(vec![
140                    sim_lib_scene::node(
141                        "field",
142                        vec![
143                            ("kind", Expr::Symbol(Symbol::new("text"))),
144                            ("value", Expr::String(body)),
145                            ("readonly", Expr::Bool(false)),
146                        ],
147                    ),
148                    committable_badge(draft),
149                ]),
150            ),
151        ],
152    ))
153}
154
155fn committable_badge(draft: &Draft) -> Expr {
156    if draft.committable {
157        sim_lib_scene::node(
158            "badge",
159            vec![
160                ("status", Expr::Symbol(Symbol::new("ok"))),
161                ("label", Expr::String("ready to commit".to_owned())),
162            ],
163        )
164    } else {
165        let message = draft
166            .diagnostics
167            .first()
168            .map(|diagnostic| diagnostic.message.clone())
169            .unwrap_or_else(|| "not committable".to_owned());
170        sim_lib_scene::node(
171            "badge",
172            vec![
173                ("status", Expr::Symbol(Symbol::new("error"))),
174                ("label", Expr::String(message)),
175            ],
176        )
177    }
178}
179
180/// Turn a `sim_value::path` failure into a field-anchored diagnostic naming the
181/// edit path. The set itself is the shared `sim_value::path::set_at` primitive.
182fn path_error_diagnostic(path: &Expr, error: PathError) -> Diagnostic {
183    Diagnostic::error(format!(
184        "edit rejected at path {}: {error:?}",
185        crate::universal_view::render_value(path)
186    ))
187}