use std::sync::Arc;
use sim_kernel::{Cx, Error, Expr, Result, Symbol};
use sim_lib_intent::Origin;
use crate::contract::{Draft, Editor, Operation, View};
use crate::surface::SurfaceCaps;
pub trait SurfaceCodec: Send + Sync {
fn encode(&self, cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr>;
fn decode(&self, cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft>;
fn commit(&self, cx: &mut Cx, draft: &Draft) -> Result<Operation>;
}
pub struct PairCodec {
view: Arc<dyn View>,
editor: Arc<dyn Editor>,
}
impl PairCodec {
pub fn new(view: Arc<dyn View>, editor: Arc<dyn Editor>) -> Self {
Self { view, editor }
}
}
impl SurfaceCodec for PairCodec {
fn encode(&self, cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr> {
let scene = self.view.encode(cx, value)?;
sim_lib_scene::validate_scene(&scene)
.map_err(|error| Error::HostError(format!("invalid scene: {error}")))?;
let projected = reduce_for_caps(&scene, caps);
sim_lib_scene::validate_scene(&projected).map_err(|error| {
Error::HostError(format!("projection produced an invalid scene: {error}"))
})?;
Ok(projected)
}
fn decode(&self, cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft> {
sim_lib_intent::validate_intent(intent)
.map_err(|error| Error::HostError(format!("invalid intent: {error}")))?;
self.editor.decode(cx, value, intent)
}
fn commit(&self, cx: &mut Cx, draft: &Draft) -> Result<Operation> {
self.editor.commit(cx, draft)
}
}
pub fn noop_intent() -> Expr {
sim_lib_intent::intent(
"cancel",
Origin::human(0),
vec![("pane", Expr::String("roundtrip".to_owned()))],
)
}
fn roundtrip_sentinel(value: &Expr) -> Expr {
Expr::List(vec![
Expr::Symbol(Symbol::new("roundtrip-edit")),
value.clone(),
])
}
fn roundtrip_edit(value: &Expr, target: Expr) -> Expr {
sim_lib_intent::intent(
"edit-field",
Origin::human(0),
vec![
("target", value.clone()),
("path", Expr::List(Vec::new())),
("value", target),
],
)
}
pub fn roundtrip_holds(cx: &mut Cx, codec: &dyn SurfaceCodec, value: &Expr) -> Result<bool> {
let target = roundtrip_sentinel(value);
let intent = roundtrip_edit(value, target.clone());
let draft = codec.decode(cx, value, &intent)?;
if !draft.committable || draft.proposed != target {
return Ok(false);
}
codec.commit(cx, &draft)?;
Ok(true)
}
pub fn noop_roundtrip_holds(cx: &mut Cx, codec: &dyn SurfaceCodec, value: &Expr) -> Result<bool> {
let draft = codec.decode(cx, value, &noop_intent())?;
Ok(draft.committable && &draft.proposed == value)
}
pub fn reduce_for_caps(scene: &Expr, caps: &SurfaceCaps) -> Expr {
let limit = match caps.display_density().as_ref().map(|d| &*d.name) {
Some("glance") => Some(1),
Some("compact") => Some(3),
_ => None,
};
match limit {
Some(n) => truncate_collections(scene, n),
None => scene.clone(),
}
}
fn truncate_collections(scene: &Expr, n: usize) -> Expr {
let Expr::Map(entries) = scene else {
return scene.clone();
};
let reduced = entries
.iter()
.map(|(key, value)| {
let collection = match key {
Expr::Symbol(symbol) if symbol.namespace.is_none() => {
matches!(&*symbol.name, "children" | "rows")
}
_ => false,
};
match value {
Expr::List(items) if collection => {
let kept: Vec<Expr> = items
.iter()
.take(n)
.map(|item| truncate_collections(item, n))
.collect();
(key.clone(), Expr::List(kept))
}
_ => (key.clone(), value.clone()),
}
})
.collect();
Expr::Map(reduced)
}
pub const UNIVERSAL_SURFACE_CODEC_ID: &str = "surface:default";
pub fn universal_surface_codec_symbol() -> Symbol {
Symbol::new(UNIVERSAL_SURFACE_CODEC_ID)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::surface;
use crate::{UniversalEditor, UniversalView};
use sim_kernel::testing::eager_cx as cx;
fn codec() -> PairCodec {
PairCodec::new(
Arc::new(UniversalView),
Arc::new(UniversalEditor::writable()),
)
}
#[test]
fn roundtrip_holds_for_values() {
let mut cx = cx();
let codec = codec();
for value in [
Expr::Nil,
Expr::String("text".to_owned()),
Expr::List(vec![Expr::Nil, Expr::Bool(true)]),
] {
assert!(
roundtrip_holds(&mut cx, &codec, &value).unwrap(),
"no-op edit must preserve {value:?}"
);
}
}
struct LossyEditor;
impl Editor for LossyEditor {
fn decode(&self, _cx: &mut Cx, value: &Expr, _intent: &Expr) -> Result<Draft> {
Ok(Draft::clean(value.clone(), value.clone()))
}
fn commit(&self, _cx: &mut Cx, draft: &Draft) -> Result<Operation> {
Ok(Operation {
form: draft.proposed.clone(),
})
}
}
#[test]
fn a_lossy_editor_fails_the_reversibility_property() {
let mut cx = cx();
let codec = PairCodec::new(Arc::new(UniversalView), Arc::new(LossyEditor));
let value = Expr::String("hello".to_owned());
assert!(
!roundtrip_holds(&mut cx, &codec, &value).unwrap(),
"an editor that drops edits must fail the reversibility property"
);
assert!(noop_roundtrip_holds(&mut cx, &codec, &value).unwrap());
}
#[test]
fn projection_is_deterministic_per_caps() {
let mut cx = cx();
let codec = codec();
let value = Expr::List(vec![Expr::String("a".into()), Expr::String("b".into())]);
for name in surface::SURFACE_PRESETS {
let caps = surface::preset(name).unwrap();
let first = codec.encode(&mut cx, &value, &caps).unwrap();
let second = codec.encode(&mut cx, &value, &caps).unwrap();
assert_eq!(first, second, "{name} projection must be deterministic");
assert!(sim_lib_scene::validate_scene(&first).is_ok());
}
}
#[test]
fn glance_reduces_more_than_dense() {
let glance = surface::preset("watch").unwrap(); let dense = surface::preset("desktop").unwrap(); let scene = sim_lib_scene::build::stack(
"column",
vec![
sim_lib_scene::build::text_node("one"),
sim_lib_scene::build::text_node("two"),
sim_lib_scene::build::text_node("three"),
sim_lib_scene::build::text_node("four"),
],
);
let reduced = reduce_for_caps(&scene, &glance);
let kept = reduce_for_caps(&scene, &dense);
assert!(sim_lib_scene::validate_scene(&reduced).is_ok());
assert_eq!(child_count(&kept), 4, "dense keeps all children");
assert_eq!(child_count(&reduced), 1, "glance keeps one child");
}
fn child_count(scene: &Expr) -> usize {
let Expr::Map(entries) = scene else {
return 0;
};
for (key, value) in entries {
match (key, value) {
(Expr::Symbol(symbol), Expr::List(items)) if &*symbol.name == "children" => {
return items.len();
}
_ => {}
}
}
0
}
}