Skip to main content

sim_lib_view/
surface.rs

1//! Surface capability metadata -- the library-level "surface" output position.
2//!
3//! VIEW_4 frames a view as a codec at output position `surface`. The kernel
4//! keeps its closed [`sim_kernel::EncodePosition`] (`Eval`/`Quote`/`Data`/
5//! `Pattern`); the surface position lives here as OPEN metadata so a view/edit
6//! lens projects toward a device described purely by capability data, never a
7//! closed device enum. A surface advertises what it can show and accept; the
8//! projection ranker (see [`crate::dispatch`]) reads those capabilities.
9//!
10//! [`SurfaceCaps`] round-trips through a `surface/caps` tagged [`Expr`] map, the
11//! same shape SIM uses for [`Scene`](sim_lib_scene) and Intent values, so a
12//! surface descriptor is itself an ordinary SIM value that can cross a session.
13//!
14//! # Example
15//!
16//! ```
17//! use sim_lib_view::surface;
18//!
19//! let cli = surface::preset("cli").expect("cli is a known preset");
20//! // Capabilities round-trip losslessly through their `surface/caps` Expr form.
21//! let back = surface::SurfaceCaps::from_expr(&cli.to_expr()).unwrap();
22//! assert_eq!(cli, back);
23//! assert!(cli.input_flag("keyboard"));
24//! ```
25
26use sim_kernel::{Error, Expr, Symbol};
27use sim_value::{access, build};
28
29/// The metadata namespace for surface descriptors (`surface/...`).
30pub const SURFACE_NAMESPACE: &str = "surface";
31
32/// The `kind` tag of a serialized [`SurfaceCaps`] map.
33pub const CAPS_KIND: &str = "caps";
34
35/// The catalog of well-known surface presets, by unqualified name.
36///
37/// These are named capability bundles, NOT a runtime enum: a device that is not
38/// in this list still works by advertising its own [`SurfaceCaps`]. The presets
39/// exist so common surfaces have a one-line starting point.
40pub const SURFACE_PRESETS: &[&str] = &[
41    "cli", "tui", "webui", "watch", "glasses", "phone", "desktop",
42];
43
44/// A surface's advertised capabilities, as open metadata over [`Expr`].
45///
46/// The four capability maps (`display`, `input`, `transport`, `privacy`) are
47/// open: a surface may carry fields beyond the well-known ones, and the ranker
48/// reads only the fields it understands. `codecs` lists the surface codecs the
49/// client can decode (lisp/json/bin/...).
50#[derive(Clone, Debug, PartialEq)]
51pub struct SurfaceCaps {
52    /// A stable client identifier, e.g. `"tty.local.1"`.
53    pub client_id: String,
54    /// The preset name this surface is based on (`surface/<preset>`).
55    pub preset: Symbol,
56    /// Display capabilities: cells/pixels, color, density, motion, budget.
57    pub display: Expr,
58    /// Input capabilities: keyboard/pointer/touch/voice/camera/tap/...
59    pub input: Expr,
60    /// Transport capabilities: kind, round-trip, offline queue, ordering.
61    pub transport: Expr,
62    /// Privacy policy: redaction class, retention, private fields.
63    pub privacy: Expr,
64    /// Surface codecs the client can decode, in preference order.
65    pub codecs: Vec<Symbol>,
66}
67
68/// A reason a [`SurfaceCaps`] value could not be parsed from an [`Expr`].
69///
70/// Parsing fails closed: a malformed descriptor never yields partial caps.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum SurfaceError {
73    /// The value was not a `surface/caps`-tagged map.
74    NotCaps,
75    /// A required field was missing.
76    MissingField(&'static str),
77    /// A field carried the wrong value shape.
78    BadField(&'static str),
79    /// A shared `sim_value::access` slice reader rejected a field (e.g. a
80    /// required field was missing). Carries the reader's rendered message so the
81    /// surface decoder can lean on the shared readers without growing a variant
82    /// per field. The reader's error is the kernel `Error` type that
83    /// `sim_value::access` returns.
84    Field(String),
85}
86
87impl core::fmt::Display for SurfaceError {
88    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
89        match self {
90            SurfaceError::NotCaps => write!(f, "value is not a surface/caps map"),
91            SurfaceError::MissingField(name) => write!(f, "surface caps missing field: {name}"),
92            SurfaceError::BadField(name) => write!(f, "surface caps field has wrong shape: {name}"),
93            SurfaceError::Field(message) => write!(f, "surface caps field: {message}"),
94        }
95    }
96}
97
98impl std::error::Error for SurfaceError {}
99
100impl From<Error> for SurfaceError {
101    /// Adopts a shared `sim_value::access` reader error (the kernel `Error` those
102    /// readers return) as a surface parse failure, so [`map_field`] can defer
103    /// required-field lookup to the substrate while the surface decoder keeps
104    /// failing closed.
105    fn from(err: Error) -> Self {
106        SurfaceError::Field(err.to_string())
107    }
108}
109
110impl SurfaceCaps {
111    /// Builds caps from a preset name plus a concrete `client_id`.
112    ///
113    /// Returns `None` when `preset_name` is not in [`SURFACE_PRESETS`].
114    pub fn from_preset(preset_name: &str, client_id: impl Into<String>) -> Option<Self> {
115        let mut caps = preset(preset_name)?;
116        caps.client_id = client_id.into();
117        Some(caps)
118    }
119
120    /// Encodes these caps as a `surface/caps` tagged [`Expr`] map.
121    pub fn to_expr(&self) -> Expr {
122        build::map(vec![
123            (
124                "kind",
125                Expr::Symbol(Symbol::qualified(SURFACE_NAMESPACE, CAPS_KIND)),
126            ),
127            ("client-id", build::text(self.client_id.clone())),
128            ("preset", Expr::Symbol(self.preset.clone())),
129            ("display", self.display.clone()),
130            ("input", self.input.clone()),
131            ("transport", self.transport.clone()),
132            ("privacy", self.privacy.clone()),
133            (
134                "codecs",
135                build::list(self.codecs.iter().cloned().map(Expr::Symbol).collect()),
136            ),
137        ])
138    }
139
140    /// Parses caps from a `surface/caps` tagged [`Expr`] map, failing closed.
141    pub fn from_expr(expr: &Expr) -> Result<Self, SurfaceError> {
142        let Expr::Map(entries) = expr else {
143            return Err(SurfaceError::NotCaps);
144        };
145        match field(entries, "kind") {
146            Some(Expr::Symbol(kind))
147                if kind.namespace.as_deref() == Some(SURFACE_NAMESPACE)
148                    && &*kind.name == CAPS_KIND => {}
149            _ => return Err(SurfaceError::NotCaps),
150        }
151        let client_id = match field(entries, "client-id") {
152            Some(Expr::String(text)) => text.clone(),
153            Some(_) => return Err(SurfaceError::BadField("client-id")),
154            None => return Err(SurfaceError::MissingField("client-id")),
155        };
156        let preset = match field(entries, "preset") {
157            Some(Expr::Symbol(symbol)) => symbol.clone(),
158            Some(_) => return Err(SurfaceError::BadField("preset")),
159            None => return Err(SurfaceError::MissingField("preset")),
160        };
161        let display = map_field(entries, "display")?;
162        let input = map_field(entries, "input")?;
163        let transport = map_field(entries, "transport")?;
164        let privacy = map_field(entries, "privacy")?;
165        let codecs = match field(entries, "codecs") {
166            Some(Expr::List(items)) => {
167                let mut out = Vec::with_capacity(items.len());
168                for item in items {
169                    let Expr::Symbol(symbol) = item else {
170                        return Err(SurfaceError::BadField("codecs"));
171                    };
172                    out.push(symbol.clone());
173                }
174                out
175            }
176            Some(_) => return Err(SurfaceError::BadField("codecs")),
177            None => return Err(SurfaceError::MissingField("codecs")),
178        };
179        Ok(SurfaceCaps {
180            client_id,
181            preset,
182            display,
183            input,
184            transport,
185            privacy,
186            codecs,
187        })
188    }
189
190    /// Returns the unqualified preset name (`cli`, `watch`, ...).
191    pub fn preset_name(&self) -> &str {
192        &self.preset.name
193    }
194
195    /// Reads a boolean `input` capability flag, defaulting to `false`.
196    pub fn input_flag(&self, name: &str) -> bool {
197        matches!(map_get(&self.input, name), Some(Expr::Bool(true)))
198    }
199
200    /// Reads the `display` density symbol (`glance`/`compact`/`regular`/`dense`).
201    pub fn display_density(&self) -> Option<Symbol> {
202        match map_get(&self.display, "density") {
203            Some(Expr::Symbol(symbol)) => Some(symbol.clone()),
204            _ => None,
205        }
206    }
207
208    /// Whether this surface can decode the named surface codec.
209    pub fn accepts_codec(&self, codec: &str) -> bool {
210        self.codecs.iter().any(|symbol| &*symbol.name == codec)
211    }
212}
213
214/// Returns the baseline [`SurfaceCaps`] for a well-known preset name.
215///
216/// The `client_id` is set to the preset name and should be overridden with a
217/// real id via [`SurfaceCaps::from_preset`]. Returns `None` for unknown presets.
218pub fn preset(name: &str) -> Option<SurfaceCaps> {
219    let (display, input, transport, privacy) = match name {
220        "cli" => (
221            display_map(&[("density", sym("dense")), ("color", sym("ansi"))]),
222            input_map(&["keyboard"]),
223            transport_map("tty", 1, false),
224            privacy_map("local", 60_000),
225        ),
226        "tui" => (
227            display_map(&[("density", sym("dense")), ("color", sym("ansi256"))]),
228            input_map(&["keyboard", "pointer"]),
229            transport_map("tty", 1, false),
230            privacy_map("local", 60_000),
231        ),
232        "webui" => (
233            display_map(&[("density", sym("regular")), ("color", sym("truecolor"))]),
234            input_map(&["keyboard", "pointer", "touch", "wheel", "file-drop"]),
235            transport_map("websocket", 40, false),
236            privacy_map("session", 600_000),
237        ),
238        "watch" => (
239            display_map(&[("density", sym("glance")), ("shape", sym("round"))]),
240            input_map(&["touch", "tap", "crown", "haptic-ack"]),
241            transport_map("relay", 250, true),
242            privacy_map("local", 60_000),
243        ),
244        "glasses" => (
245            display_map(&[("density", sym("glance")), ("lines", build::uint(2))]),
246            input_map(&["voice", "tap"]),
247            transport_map("relay", 250, true),
248            privacy_map("local", 60_000),
249        ),
250        "phone" => (
251            display_map(&[("density", sym("compact")), ("color", sym("truecolor"))]),
252            input_map(&["touch", "voice", "camera"]),
253            transport_map("relay", 120, true),
254            privacy_map("session", 300_000),
255        ),
256        "desktop" => (
257            display_map(&[("density", sym("dense")), ("color", sym("truecolor"))]),
258            input_map(&["keyboard", "pointer", "wheel", "file-drop"]),
259            transport_map("local", 1, false),
260            privacy_map("session", 600_000),
261        ),
262        _ => return None,
263    };
264    Some(SurfaceCaps {
265        client_id: name.to_owned(),
266        preset: Symbol::qualified(SURFACE_NAMESPACE, name),
267        display,
268        input,
269        transport,
270        privacy,
271        codecs: vec![
272            Symbol::qualified(SURFACE_NAMESPACE, "lisp"),
273            Symbol::qualified(SURFACE_NAMESPACE, "json"),
274        ],
275    })
276}
277
278use sim_value::build::sym;
279
280fn display_map(extra: &[(&str, Expr)]) -> Expr {
281    let mut entries: Vec<(&str, Expr)> = vec![("media", build::list(Vec::new()))];
282    entries.extend(extra.iter().map(|(k, v)| (*k, v.clone())));
283    build::map(entries)
284}
285
286fn input_map(flags: &[&str]) -> Expr {
287    build::map(flags.iter().map(|flag| (*flag, Expr::Bool(true))).collect())
288}
289
290fn transport_map(kind: &str, round_trip_ms: u64, offline_queue: bool) -> Expr {
291    build::map(vec![
292        ("kind", build::sym(kind)),
293        ("round-trip-ms", build::uint(round_trip_ms)),
294        ("offline-queue", Expr::Bool(offline_queue)),
295        ("ordered", Expr::Bool(true)),
296    ])
297}
298
299fn privacy_map(class: &str, retain_ms: u64) -> Expr {
300    build::map(vec![
301        ("class", build::sym(class)),
302        ("retain-ms", build::uint(retain_ms)),
303        ("private-fields", build::list(Vec::new())),
304    ])
305}
306
307fn field<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Option<&'a Expr> {
308    entries.iter().find_map(|(key, value)| {
309        matches!(key, Expr::Symbol(symbol) if &*symbol.name == name && symbol.namespace.is_none())
310            .then_some(value)
311    })
312}
313
314fn map_field(entries: &[(Expr, Expr)], name: &'static str) -> Result<Expr, SurfaceError> {
315    // Defer the required-field lookup to the shared `sim_value::access` reader
316    // (mapping its error via `SurfaceError::from`); keep the map-shape check
317    // local, since the surface fields are whole `Expr::Map` values with no typed
318    // slice reader of their own.
319    match access::entry_required(entries, name, "surface caps").map_err(SurfaceError::from)? {
320        value @ Expr::Map(_) => Ok(value.clone()),
321        _ => Err(SurfaceError::BadField(name)),
322    }
323}
324
325fn map_get<'a>(map: &'a Expr, name: &str) -> Option<&'a Expr> {
326    match map {
327        Expr::Map(entries) => field(entries, name),
328        _ => None,
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn every_preset_round_trips() {
338        for name in SURFACE_PRESETS {
339            let caps = preset(name).expect("preset exists");
340            assert_eq!(caps.preset_name(), *name);
341            let back = SurfaceCaps::from_expr(&caps.to_expr()).expect("round-trips");
342            assert_eq!(caps, back, "{name} caps must round-trip losslessly");
343        }
344    }
345
346    #[test]
347    fn unknown_preset_is_none() {
348        assert!(preset("hologram").is_none());
349    }
350
351    #[test]
352    fn from_preset_overrides_client_id() {
353        let caps = SurfaceCaps::from_preset("cli", "tty.local.7").unwrap();
354        assert_eq!(caps.client_id, "tty.local.7");
355        assert_eq!(caps.preset_name(), "cli");
356    }
357
358    #[test]
359    fn capability_accessors_read_fields() {
360        let cli = preset("cli").unwrap();
361        assert!(cli.input_flag("keyboard"));
362        assert!(!cli.input_flag("touch"));
363        assert_eq!(cli.display_density().unwrap().name.as_ref(), "dense");
364        assert!(cli.accepts_codec("lisp"));
365        assert!(!cli.accepts_codec("algol"));
366
367        let watch = preset("watch").unwrap();
368        assert!(watch.input_flag("haptic-ack"));
369        assert_eq!(watch.display_density().unwrap().name.as_ref(), "glance");
370    }
371
372    #[test]
373    fn surface_map_field_wrong_shape_fails_closed() {
374        // A caps map whose `display` field is not a map must fail closed with a
375        // located `BadField`, never partial caps.
376        let mut entries = match preset("cli").unwrap().to_expr() {
377            Expr::Map(entries) => entries,
378            _ => unreachable!(),
379        };
380        for (key, value) in entries.iter_mut() {
381            if matches!(key, Expr::Symbol(symbol) if &*symbol.name == "display") {
382                *value = Expr::Bool(true);
383            }
384        }
385        assert_eq!(
386            SurfaceCaps::from_expr(&Expr::Map(entries)),
387            Err(SurfaceError::BadField("display"))
388        );
389    }
390
391    #[test]
392    fn surface_map_field_missing_flows_through_sim_value_reader() {
393        // A missing map field is reported by the shared `sim_value::access`
394        // reader, adopted as `SurfaceError::Field` via `From<sim_value::Error>`.
395        let mut entries = match preset("cli").unwrap().to_expr() {
396            Expr::Map(entries) => entries,
397            _ => unreachable!(),
398        };
399        entries.retain(|(key, _)| !matches!(key, Expr::Symbol(s) if &*s.name == "transport"));
400        match SurfaceCaps::from_expr(&Expr::Map(entries)) {
401            Err(SurfaceError::Field(message)) => assert!(message.contains("transport")),
402            other => panic!("expected a located field error, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn parse_fails_closed() {
408        assert_eq!(
409            SurfaceCaps::from_expr(&Expr::Nil),
410            Err(SurfaceError::NotCaps)
411        );
412        // A caps map missing `codecs` must not yield partial caps.
413        let mut entries = match preset("cli").unwrap().to_expr() {
414            Expr::Map(entries) => entries,
415            _ => unreachable!(),
416        };
417        entries.retain(|(key, _)| !matches!(key, Expr::Symbol(s) if &*s.name == "codecs"));
418        assert_eq!(
419            SurfaceCaps::from_expr(&Expr::Map(entries)),
420            Err(SurfaceError::MissingField("codecs"))
421        );
422    }
423}