Skip to main content

sim_lib_view/
codec.rs

1//! The unified, reversible surface codec.
2//!
3//! VIEW_4 collapses the separate [`View`] (encode: `Value -> Scene`) and
4//! [`Editor`] (decode: `(Value, Intent) -> Draft`, then `Draft -> Operation`)
5//! halves into ONE contract, [`SurfaceCodec`], so a lens renders and parses from
6//! one object and the two directions cannot drift. Rendering runs the codec
7//! forward (capability-aware projection); editing runs it backward.
8//!
9//! The headline invariant is a CHECKABLE roundtrip property: decoding a REAL
10//! `edit-field` that sets the value to a distinct sentinel yields a committable
11//! [`Draft`] that actually proposes that sentinel, and the draft commits. A
12//! lossy editor -- one that drops the edit and re-proposes the base -- fails the
13//! property. [`roundtrip_holds`] verifies it for any codec + value;
14//! [`noop_roundtrip_holds`] keeps the weaker no-op check (a cancel proposes no
15//! change).
16//!
17//! Projection ([`SurfaceCodec::encode`]) is deterministic for a given
18//! `(value, caps)`: [`reduce_for_caps`] fits the rendered Scene to the surface's
19//! display density (glance/compact/regular/dense) by a fixed strategy, so the
20//! same inputs always yield the same Scene -- the basis for replay and tests.
21//!
22//! # Example
23//!
24//! ```
25//! use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Expr};
26//! use sim_lib_view::{codec::{PairCodec, SurfaceCodec, roundtrip_holds}, surface, UniversalView, UniversalEditor};
27//! use std::sync::Arc;
28//!
29//! let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
30//! let codec = PairCodec::new(Arc::new(UniversalView), Arc::new(UniversalEditor::writable()));
31//! let value = Expr::String("hello".to_owned());
32//! // A real edit applied through the codec is faithfully reproduced -- the
33//! // reversibility property (a lossy editor would make this false).
34//! assert!(roundtrip_holds(&mut cx, &codec, &value).unwrap());
35//! // Projection to a glance surface still yields a valid Scene.
36//! let watch = surface::preset("watch").unwrap();
37//! let scene = codec.encode(&mut cx, &value, &watch).unwrap();
38//! assert!(sim_lib_scene::validate_scene(&scene).is_ok());
39//! ```
40
41use std::sync::Arc;
42
43use sim_kernel::{Cx, Error, Expr, Result, Symbol};
44use sim_lib_intent::Origin;
45
46use crate::contract::{Draft, Editor, Operation, View};
47use crate::surface::SurfaceCaps;
48
49/// One reversible surface codec: encode a value to a projected Scene, decode an
50/// Intent to a Draft, and commit a Draft to a checked Operation.
51///
52/// Implementors back both directions with one definition. [`PairCodec`] adapts a
53/// legacy ([`View`], [`Editor`]) pair to this contract.
54pub trait SurfaceCodec: Send + Sync {
55    /// Renders `value` to a Scene projected for `caps` (deterministic).
56    fn encode(&self, cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr>;
57    /// Decodes `intent` against `value` into a previewable [`Draft`].
58    fn decode(&self, cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft>;
59    /// Commits a committable [`Draft`] into a checked [`Operation`].
60    fn commit(&self, cx: &mut Cx, draft: &Draft) -> Result<Operation>;
61}
62
63/// Adapts a ([`View`], [`Editor`]) pair to the unified [`SurfaceCodec`].
64///
65/// `encode` runs the view forward then projects to the surface via
66/// [`reduce_for_caps`]; `decode`/`commit` delegate to the editor.
67pub struct PairCodec {
68    view: Arc<dyn View>,
69    editor: Arc<dyn Editor>,
70}
71
72impl PairCodec {
73    /// Pairs a view and an editor into one reversible codec.
74    pub fn new(view: Arc<dyn View>, editor: Arc<dyn Editor>) -> Self {
75        Self { view, editor }
76    }
77}
78
79impl SurfaceCodec for PairCodec {
80    fn encode(&self, cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr> {
81        let scene = self.view.encode(cx, value)?;
82        sim_lib_scene::validate_scene(&scene)
83            .map_err(|error| Error::HostError(format!("invalid scene: {error}")))?;
84        let projected = reduce_for_caps(&scene, caps);
85        sim_lib_scene::validate_scene(&projected).map_err(|error| {
86            Error::HostError(format!("projection produced an invalid scene: {error}"))
87        })?;
88        Ok(projected)
89    }
90
91    fn decode(&self, cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft> {
92        sim_lib_intent::validate_intent(intent)
93            .map_err(|error| Error::HostError(format!("invalid intent: {error}")))?;
94        self.editor.decode(cx, value, intent)
95    }
96
97    fn commit(&self, cx: &mut Cx, draft: &Draft) -> Result<Operation> {
98        self.editor.commit(cx, draft)
99    }
100}
101
102/// Builds the canonical no-op Intent (`intent/cancel`): it proposes no change.
103///
104/// `intent/cancel` carries the pane it cancels; a synthetic `"roundtrip"` pane
105/// is used for the conformance check.
106pub fn noop_intent() -> Expr {
107    sim_lib_intent::intent(
108        "cancel",
109        Origin::human(0),
110        vec![("pane", Expr::String("roundtrip".to_owned()))],
111    )
112}
113
114/// A sentinel value guaranteed to differ from `value`: it embeds `value`, so it
115/// can never be structurally equal to it.
116fn roundtrip_sentinel(value: &Expr) -> Expr {
117    Expr::List(vec![
118        Expr::Symbol(Symbol::new("roundtrip-edit")),
119        value.clone(),
120    ])
121}
122
123/// A real `edit-field` Intent that sets the whole value (root path) to `target`.
124fn roundtrip_edit(value: &Expr, target: Expr) -> Expr {
125    sim_lib_intent::intent(
126        "edit-field",
127        Origin::human(0),
128        vec![
129            ("target", value.clone()),
130            ("path", Expr::List(Vec::new())),
131            ("value", target),
132        ],
133    )
134}
135
136/// Verifies the reversibility property for `codec` and `value` with a REAL edit.
137///
138/// Decodes an `edit-field` that sets the value to a distinct sentinel; the
139/// property holds when the resulting [`Draft`] is committable, actually proposes
140/// that sentinel (so an editor that drops the edit fails), and commits to an
141/// [`Operation`]. This is not a tautology: a lossy editor that re-proposes the
142/// base returns `false`. [`noop_roundtrip_holds`] keeps the weaker no-op check.
143pub fn roundtrip_holds(cx: &mut Cx, codec: &dyn SurfaceCodec, value: &Expr) -> Result<bool> {
144    let target = roundtrip_sentinel(value);
145    let intent = roundtrip_edit(value, target.clone());
146    let draft = codec.decode(cx, value, &intent)?;
147    if !draft.committable || draft.proposed != target {
148        return Ok(false);
149    }
150    // The committed operation must be producible and carry the edited value
151    // (the universal operation sets the resource to `draft.proposed == target`).
152    codec.commit(cx, &draft)?;
153    Ok(true)
154}
155
156/// The weaker no-op reversibility check: decoding a [`noop_intent`] proposes no
157/// change. True for any editor that honors cancel, so it cannot stand in for
158/// [`roundtrip_holds`] -- keep both.
159pub fn noop_roundtrip_holds(cx: &mut Cx, codec: &dyn SurfaceCodec, value: &Expr) -> Result<bool> {
160    let draft = codec.decode(cx, value, &noop_intent())?;
161    Ok(draft.committable && &draft.proposed == value)
162}
163
164/// Deterministically fits a Scene to a surface's display density.
165///
166/// Reads `caps.display_density()` and reduces child lists / table rows:
167/// `glance` keeps the first item, `compact` keeps the first three, and
168/// `regular`/`dense`/absent keep everything. The reduction is shallow and total
169/// -- the same `(scene, caps)` always yields the same Scene.
170pub fn reduce_for_caps(scene: &Expr, caps: &SurfaceCaps) -> Expr {
171    let limit = match caps.display_density().as_ref().map(|d| &*d.name) {
172        Some("glance") => Some(1),
173        Some("compact") => Some(3),
174        _ => None,
175    };
176    match limit {
177        Some(n) => truncate_collections(scene, n),
178        None => scene.clone(),
179    }
180}
181
182/// Truncates a Scene node's `children` (stacks/boxes) and `rows` (tables) to at
183/// most `n`, recursing into kept children. Non-Scene shapes pass through.
184fn truncate_collections(scene: &Expr, n: usize) -> Expr {
185    let Expr::Map(entries) = scene else {
186        return scene.clone();
187    };
188    let reduced = entries
189        .iter()
190        .map(|(key, value)| {
191            let collection = match key {
192                Expr::Symbol(symbol) if symbol.namespace.is_none() => {
193                    matches!(&*symbol.name, "children" | "rows")
194                }
195                _ => false,
196            };
197            match value {
198                Expr::List(items) if collection => {
199                    let kept: Vec<Expr> = items
200                        .iter()
201                        .take(n)
202                        .map(|item| truncate_collections(item, n))
203                        .collect();
204                    (key.clone(), Expr::List(kept))
205                }
206                _ => (key.clone(), value.clone()),
207            }
208        })
209        .collect();
210    Expr::Map(reduced)
211}
212
213/// The id under which the universal default surface codec is registered.
214pub const UNIVERSAL_SURFACE_CODEC_ID: &str = "surface:default";
215
216/// Symbol form of [`UNIVERSAL_SURFACE_CODEC_ID`].
217pub fn universal_surface_codec_symbol() -> Symbol {
218    Symbol::new(UNIVERSAL_SURFACE_CODEC_ID)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::surface;
225    use crate::{UniversalEditor, UniversalView};
226
227    use sim_kernel::testing::eager_cx as cx;
228
229    fn codec() -> PairCodec {
230        PairCodec::new(
231            Arc::new(UniversalView),
232            Arc::new(UniversalEditor::writable()),
233        )
234    }
235
236    #[test]
237    fn roundtrip_holds_for_values() {
238        let mut cx = cx();
239        let codec = codec();
240        for value in [
241            Expr::Nil,
242            Expr::String("text".to_owned()),
243            Expr::List(vec![Expr::Nil, Expr::Bool(true)]),
244        ] {
245            assert!(
246                roundtrip_holds(&mut cx, &codec, &value).unwrap(),
247                "no-op edit must preserve {value:?}"
248            );
249        }
250    }
251
252    /// A deliberately lossy editor: it ignores every edit and re-proposes the
253    /// base. The real-edit roundtrip must reject it; the no-op check still
254    /// passes, which is exactly why the no-op check alone is not enough.
255    struct LossyEditor;
256
257    impl Editor for LossyEditor {
258        fn decode(&self, _cx: &mut Cx, value: &Expr, _intent: &Expr) -> Result<Draft> {
259            Ok(Draft::clean(value.clone(), value.clone()))
260        }
261        fn commit(&self, _cx: &mut Cx, draft: &Draft) -> Result<Operation> {
262            Ok(Operation {
263                form: draft.proposed.clone(),
264            })
265        }
266    }
267
268    #[test]
269    fn a_lossy_editor_fails_the_reversibility_property() {
270        let mut cx = cx();
271        let codec = PairCodec::new(Arc::new(UniversalView), Arc::new(LossyEditor));
272        let value = Expr::String("hello".to_owned());
273        assert!(
274            !roundtrip_holds(&mut cx, &codec, &value).unwrap(),
275            "an editor that drops edits must fail the reversibility property"
276        );
277        // The weaker no-op check still passes for the SAME lossy editor, proving
278        // it cannot substitute for the real-edit roundtrip.
279        assert!(noop_roundtrip_holds(&mut cx, &codec, &value).unwrap());
280    }
281
282    #[test]
283    fn projection_is_deterministic_per_caps() {
284        let mut cx = cx();
285        let codec = codec();
286        let value = Expr::List(vec![Expr::String("a".into()), Expr::String("b".into())]);
287        for name in surface::SURFACE_PRESETS {
288            let caps = surface::preset(name).unwrap();
289            let first = codec.encode(&mut cx, &value, &caps).unwrap();
290            let second = codec.encode(&mut cx, &value, &caps).unwrap();
291            assert_eq!(first, second, "{name} projection must be deterministic");
292            assert!(sim_lib_scene::validate_scene(&first).is_ok());
293        }
294    }
295
296    #[test]
297    fn glance_reduces_more_than_dense() {
298        let glance = surface::preset("watch").unwrap(); // glance density
299        let dense = surface::preset("desktop").unwrap(); // dense density
300        let scene = sim_lib_scene::build::stack(
301            "column",
302            vec![
303                sim_lib_scene::build::text_node("one"),
304                sim_lib_scene::build::text_node("two"),
305                sim_lib_scene::build::text_node("three"),
306                sim_lib_scene::build::text_node("four"),
307            ],
308        );
309        let reduced = reduce_for_caps(&scene, &glance);
310        let kept = reduce_for_caps(&scene, &dense);
311        assert!(sim_lib_scene::validate_scene(&reduced).is_ok());
312        assert_eq!(child_count(&kept), 4, "dense keeps all children");
313        assert_eq!(child_count(&reduced), 1, "glance keeps one child");
314    }
315
316    fn child_count(scene: &Expr) -> usize {
317        let Expr::Map(entries) = scene else {
318            return 0;
319        };
320        for (key, value) in entries {
321            match (key, value) {
322                (Expr::Symbol(symbol), Expr::List(items)) if &*symbol.name == "children" => {
323                    return items.len();
324                }
325                _ => {}
326            }
327        }
328        0
329    }
330}