Skip to main content

sim_lib_view/
surface.rs

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