1use 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
49pub trait SurfaceCodec: Send + Sync {
55 fn encode(&self, cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr>;
57 fn decode(&self, cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft>;
59 fn commit(&self, cx: &mut Cx, draft: &Draft) -> Result<Operation>;
61}
62
63pub struct PairCodec {
68 view: Arc<dyn View>,
69 editor: Arc<dyn Editor>,
70}
71
72impl PairCodec {
73 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
102pub 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
114fn roundtrip_sentinel(value: &Expr) -> Expr {
117 Expr::List(vec![
118 Expr::Symbol(Symbol::new("roundtrip-edit")),
119 value.clone(),
120 ])
121}
122
123fn 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
136pub 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 codec.commit(cx, &draft)?;
153 Ok(true)
154}
155
156pub 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
164pub 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
182fn 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
213pub const UNIVERSAL_SURFACE_CODEC_ID: &str = "surface:default";
215
216pub 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 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 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(); let dense = surface::preset("desktop").unwrap(); 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}