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",
42    "tui",
43    "webui",
44    "watch",
45    "watch-glance",
46    "watch-glance-large",
47    "watch-sport",
48    "watch-sleep",
49    "glasses",
50    "glasses-hud",
51    "glasses-hud-camera",
52    "glasses-3dof",
53    "glasses-stereo",
54    "glasses-luma-ultra",
55    "phone",
56    "desktop",
57];
58
59/// A surface's advertised capabilities, as open metadata over [`Expr`].
60///
61/// The capability maps (`display`, `input`, `output`, `transport`, `privacy`,
62/// `rate`, `streams`) are open: a surface may carry fields beyond the
63/// well-known ones, and the ranker reads only the fields it understands.
64/// `codecs` lists the surface codecs the client can decode (lisp/json/bin/...).
65#[derive(Clone, Debug, PartialEq)]
66pub struct SurfaceCaps {
67    /// A stable client identifier, e.g. `"tty.local.1"`.
68    pub client_id: String,
69    /// The preset name this surface is based on (`surface/<preset>`).
70    pub preset: Symbol,
71    /// Display capabilities: cells/pixels, color, density, motion, budget.
72    pub display: Expr,
73    /// Input capabilities: keyboard/pointer/touch/voice/camera/tap/...
74    pub input: Expr,
75    /// Output capabilities: screen/haptic/face/tone/speaker/mic/...
76    pub output: Expr,
77    /// Transport capabilities: kind, round-trip, offline queue, ordering.
78    pub transport: Expr,
79    /// Privacy policy: redaction class, retention, private fields.
80    pub privacy: Expr,
81    /// Timing envelope: content cadence, adapter cadence, and staleness budget.
82    pub rate: Expr,
83    /// Stream capabilities: heart-rate/motion/location/battery/...
84    pub streams: Expr,
85    /// Surface codecs the client can decode, in preference order.
86    pub codecs: Vec<Symbol>,
87}
88
89/// A reason a [`SurfaceCaps`] value could not be parsed from an [`Expr`].
90///
91/// Parsing fails closed: a malformed descriptor never yields partial caps.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub enum SurfaceError {
94    /// The value was not a `surface/caps`-tagged map.
95    NotCaps,
96    /// A required field was missing.
97    MissingField(&'static str),
98    /// A field carried the wrong value shape.
99    BadField(&'static str),
100    /// A shared `sim_value::access` slice reader rejected a field (e.g. a
101    /// required field was missing). Carries the reader's rendered message so the
102    /// surface decoder can lean on the shared readers without growing a variant
103    /// per field. The reader's error is the kernel `Error` type that
104    /// `sim_value::access` returns.
105    Field(String),
106}
107
108impl core::fmt::Display for SurfaceError {
109    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        match self {
111            SurfaceError::NotCaps => write!(f, "value is not a surface/caps map"),
112            SurfaceError::MissingField(name) => write!(f, "surface caps missing field: {name}"),
113            SurfaceError::BadField(name) => write!(f, "surface caps field has wrong shape: {name}"),
114            SurfaceError::Field(message) => write!(f, "surface caps field: {message}"),
115        }
116    }
117}
118
119impl std::error::Error for SurfaceError {}
120
121impl From<Error> for SurfaceError {
122    /// Adopts a shared `sim_value::access` reader error (the kernel `Error` those
123    /// readers return) as a surface parse failure, so `map_field` can defer
124    /// required-field lookup to the substrate while the surface decoder keeps
125    /// failing closed.
126    fn from(err: Error) -> Self {
127        SurfaceError::Field(err.to_string())
128    }
129}
130
131impl SurfaceCaps {
132    /// Builds caps from a preset name plus a concrete `client_id`.
133    ///
134    /// Returns `None` when `preset_name` is not in [`SURFACE_PRESETS`].
135    pub fn from_preset(preset_name: &str, client_id: impl Into<String>) -> Option<Self> {
136        let mut caps = preset(preset_name)?;
137        caps.client_id = client_id.into();
138        Some(caps)
139    }
140
141    /// Encodes these caps as a `surface/caps` tagged [`Expr`] map.
142    pub fn to_expr(&self) -> Expr {
143        build::map(vec![
144            (
145                "kind",
146                Expr::Symbol(Symbol::qualified(SURFACE_NAMESPACE, CAPS_KIND)),
147            ),
148            ("client-id", build::text(self.client_id.clone())),
149            ("preset", Expr::Symbol(self.preset.clone())),
150            ("display", self.display.clone()),
151            ("input", self.input.clone()),
152            ("output", self.output.clone()),
153            ("transport", self.transport.clone()),
154            ("privacy", self.privacy.clone()),
155            ("rate", self.rate.clone()),
156            ("streams", self.streams.clone()),
157            (
158                "codecs",
159                build::list(self.codecs.iter().cloned().map(Expr::Symbol).collect()),
160            ),
161        ])
162    }
163
164    /// Parses caps from a `surface/caps` tagged [`Expr`] map, failing closed.
165    pub fn from_expr(expr: &Expr) -> Result<Self, SurfaceError> {
166        let Expr::Map(entries) = expr else {
167            return Err(SurfaceError::NotCaps);
168        };
169        match access::entry_field(entries, "kind") {
170            Some(Expr::Symbol(kind))
171                if kind.namespace.as_deref() == Some(SURFACE_NAMESPACE)
172                    && &*kind.name == CAPS_KIND => {}
173            _ => return Err(SurfaceError::NotCaps),
174        }
175        let client_id = match access::entry_field(entries, "client-id") {
176            Some(Expr::String(text)) => text.clone(),
177            Some(_) => return Err(SurfaceError::BadField("client-id")),
178            None => return Err(SurfaceError::MissingField("client-id")),
179        };
180        let preset = match access::entry_field(entries, "preset") {
181            Some(Expr::Symbol(symbol)) => symbol.clone(),
182            Some(_) => return Err(SurfaceError::BadField("preset")),
183            None => return Err(SurfaceError::MissingField("preset")),
184        };
185        let display = map_field(entries, "display")?;
186        let input = map_field(entries, "input")?;
187        let output = optional_map_field(entries, "output")?;
188        let transport = map_field(entries, "transport")?;
189        let privacy = map_field(entries, "privacy")?;
190        let rate = match access::entry_field(entries, "rate") {
191            Some(value @ Expr::Map(_)) => value.clone(),
192            Some(_) => return Err(SurfaceError::BadField("rate")),
193            None => rate_map(1, 1, 1000),
194        };
195        let streams = optional_map_field(entries, "streams")?;
196        let codecs = match access::entry_field(entries, "codecs") {
197            Some(Expr::List(items)) => {
198                let mut out = Vec::with_capacity(items.len());
199                for item in items {
200                    let Expr::Symbol(symbol) = item else {
201                        return Err(SurfaceError::BadField("codecs"));
202                    };
203                    out.push(symbol.clone());
204                }
205                out
206            }
207            Some(_) => return Err(SurfaceError::BadField("codecs")),
208            None => return Err(SurfaceError::MissingField("codecs")),
209        };
210        Ok(SurfaceCaps {
211            client_id,
212            preset,
213            display,
214            input,
215            output,
216            transport,
217            privacy,
218            rate,
219            streams,
220            codecs,
221        })
222    }
223
224    /// Returns the unqualified preset name (`cli`, `watch`, ...).
225    pub fn preset_name(&self) -> &str {
226        &self.preset.name
227    }
228
229    /// Reads a boolean `input` capability flag, defaulting to `false`.
230    pub fn input_flag(&self, name: &str) -> bool {
231        matches!(access::field(&self.input, name), Some(Expr::Bool(true)))
232    }
233
234    /// Reads the `display` density symbol (`glance`/`compact`/`regular`/`dense`).
235    pub fn display_density(&self) -> Option<Symbol> {
236        match access::field(&self.display, "density") {
237            Some(Expr::Symbol(symbol)) => Some(symbol.clone()),
238            _ => None,
239        }
240    }
241
242    /// Whether this surface can decode the named surface codec.
243    pub fn accepts_codec(&self, codec: &str) -> bool {
244        self.codecs.iter().any(|symbol| &*symbol.name == codec)
245    }
246}
247
248/// Returns the baseline [`SurfaceCaps`] for a well-known preset name.
249///
250/// The `client_id` is set to the preset name and should be overridden with a
251/// real id via [`SurfaceCaps::from_preset`]. Returns `None` for unknown presets.
252pub fn preset(name: &str) -> Option<SurfaceCaps> {
253    let (display, input, output, transport, privacy, rate, streams) = match name {
254        "cli" => (
255            display_map(&[("density", sym("dense")), ("color", sym("ansi"))]),
256            input_map(&["keyboard"]),
257            output_map(&["screen"]),
258            transport_map("tty", 1, false),
259            privacy_map("local", 60_000),
260            rate_map(1, 1, 1000),
261            streams_map(&[]),
262        ),
263        "tui" => (
264            display_map(&[("density", sym("dense")), ("color", sym("ansi256"))]),
265            input_map(&["keyboard", "pointer"]),
266            output_map(&["screen"]),
267            transport_map("tty", 1, false),
268            privacy_map("local", 60_000),
269            rate_map(1, 1, 1000),
270            streams_map(&[]),
271        ),
272        "webui" => (
273            display_map(&[("density", sym("regular")), ("color", sym("truecolor"))]),
274            input_map(&["keyboard", "pointer", "touch", "wheel", "file-drop"]),
275            output_map(&["screen"]),
276            transport_map("websocket", 40, false),
277            privacy_map("session", 600_000),
278            rate_map(5, 30, 500),
279            streams_map(&[]),
280        ),
281        "watch" => (
282            watch_display_map("generic-round-watch", 480, 48),
283            input_map(&[
284                "button",
285                "touch",
286                "tap",
287                "raise",
288                "mic",
289                "crown",
290                "haptic-ack",
291            ]),
292            watch_output_map(),
293            transport_map_with_links("phone-relay", 250, true, &["phone-relay", "ble"]),
294            privacy_map("local", 60_000),
295            watch_rate_map(),
296            streams_map(&["heart-rate", "motion", "battery", "connection"]),
297        ),
298        "watch-glance" => (
299            watch_display_map("amazfit-t-rex-3-pro-44", 466, 44),
300            input_map(&["button", "touch", "tap", "raise", "mic", "haptic-ack"]),
301            watch_output_map(),
302            transport_map_with_links(
303                "phone-relay",
304                250,
305                true,
306                &["modeled", "phone-relay", "ble", "zepp-export"],
307            ),
308            privacy_map("local", 60_000),
309            watch_rate_map(),
310            streams_map(&[
311                "heart-rate",
312                "motion",
313                "location",
314                "environment",
315                "battery",
316                "connection",
317            ]),
318        ),
319        "watch-glance-large" => (
320            watch_display_map("amazfit-t-rex-3-pro-48", 480, 48),
321            input_map(&["button", "touch", "tap", "raise", "mic", "haptic-ack"]),
322            watch_output_map(),
323            transport_map_with_links(
324                "phone-relay",
325                250,
326                true,
327                &["modeled", "phone-relay", "ble", "zepp-export"],
328            ),
329            privacy_map("local", 60_000),
330            watch_rate_map(),
331            streams_map(&[
332                "heart-rate",
333                "motion",
334                "location",
335                "environment",
336                "battery",
337                "connection",
338            ]),
339        ),
340        "watch-sport" => (
341            watch_display_map("amazfit-t-rex-3-pro-48", 480, 48),
342            input_map(&["button", "touch", "tap", "raise", "mic", "haptic-ack"]),
343            watch_output_map(),
344            transport_map_with_links(
345                "phone-relay",
346                250,
347                true,
348                &["modeled", "phone-relay", "ble", "zepp-export"],
349            ),
350            privacy_map("local", 60_000),
351            watch_rate_map(),
352            streams_map(&[
353                "heart-rate",
354                "motion",
355                "location",
356                "environment",
357                "battery",
358                "connection",
359            ]),
360        ),
361        "watch-sleep" => (
362            watch_display_map("amazfit-t-rex-3-pro-44", 466, 44),
363            input_map(&["button", "tap", "raise", "haptic-ack"]),
364            output_map(&["screen", "haptic", "tone"]),
365            transport_map_with_links(
366                "phone-relay",
367                250,
368                true,
369                &["modeled", "phone-relay", "zepp-export"],
370            ),
371            privacy_map("local", 60_000),
372            watch_rate_map(),
373            streams_map(&["heart-rate", "motion", "battery"]),
374        ),
375        "glasses" => (
376            glasses_display_map(
377                "generic-display",
378                "mono",
379                (1280, 720),
380                40,
381                "display-only",
382                &[],
383                &["screen"],
384            ),
385            input_map(&["button"]),
386            output_map(&["screen"]),
387            transport_map_with_links("usb", 20, false, &["usb"]),
388            privacy_map("local", 60_000),
389            rate_map(1, 1, 1000),
390            streams_map(&[]),
391        ),
392        "glasses-hud" => (
393            halo_display_map(&[]),
394            halo_input_map(false),
395            halo_output_map(),
396            halo_transport_map(),
397            privacy_map("local", 60_000),
398            rate_map(5, 30, 200),
399            streams_map(&["motion", "mic", "battery", "connection"]),
400        ),
401        "glasses-hud-camera" => (
402            halo_display_map(&["camera"]),
403            halo_input_map(true),
404            halo_output_map(),
405            halo_transport_map(),
406            privacy_map("local", 60_000),
407            rate_map(5, 30, 200),
408            streams_map(&["motion", "camera", "mic", "battery", "connection"]),
409        ),
410        "glasses-3dof" => (
411            viture_display_map("3dof"),
412            input_map(&["head", "button"]),
413            output_map(&["screen"]),
414            transport_map_with_links("usb", 20, false, &["usb", "phone-relay"]),
415            privacy_map("local", 60_000),
416            rate_map(60, 120, 50),
417            streams_map(&["motion"]),
418        ),
419        "glasses-stereo" => (
420            viture_display_map("display-only"),
421            input_map(&["button"]),
422            output_map(&["screen"]),
423            transport_map_with_links("usb", 20, false, &["usb"]),
424            privacy_map("local", 60_000),
425            rate_map(1, 1, 1000),
426            streams_map(&[]),
427        ),
428        "glasses-luma-ultra" => (
429            viture_display_map("6dof"),
430            input_map(&["gaze", "head", "hand", "button"]),
431            output_map(&["screen", "hud"]),
432            transport_map_with_links("usb", 10, false, &["usb"]),
433            privacy_map("local", 60_000),
434            rate_map(60, 120, 25),
435            streams_map(&[
436                "pose",
437                "motion",
438                "camera",
439                "depth-camera",
440                "hand",
441                "vio-status",
442            ]),
443        ),
444        "phone" => (
445            display_map(&[("density", sym("compact")), ("color", sym("truecolor"))]),
446            input_map(&["touch", "voice", "camera"]),
447            output_map(&["screen", "speaker", "mic"]),
448            transport_map("relay", 120, true),
449            privacy_map("session", 300_000),
450            rate_map(5, 30, 500),
451            streams_map(&["motion"]),
452        ),
453        "desktop" => (
454            display_map(&[("density", sym("dense")), ("color", sym("truecolor"))]),
455            input_map(&["keyboard", "pointer", "wheel", "file-drop"]),
456            output_map(&["screen"]),
457            transport_map("local", 1, false),
458            privacy_map("session", 600_000),
459            rate_map(60, 120, 100),
460            streams_map(&[]),
461        ),
462        _ => return None,
463    };
464    Some(SurfaceCaps {
465        client_id: name.to_owned(),
466        preset: Symbol::qualified(SURFACE_NAMESPACE, name),
467        display,
468        input,
469        output,
470        transport,
471        privacy,
472        rate,
473        streams,
474        codecs: vec![
475            Symbol::qualified(SURFACE_NAMESPACE, "lisp"),
476            Symbol::qualified(SURFACE_NAMESPACE, "json"),
477        ],
478    })
479}
480
481use sim_value::build::sym;
482
483fn display_map(extra: &[(&str, Expr)]) -> Expr {
484    let mut entries: Vec<(&str, Expr)> = vec![("media", build::list(Vec::new()))];
485    entries.extend(extra.iter().map(|(k, v)| (*k, v.clone())));
486    build::map(entries)
487}
488
489fn input_map(flags: &[&str]) -> Expr {
490    build::map(flags.iter().map(|flag| (*flag, Expr::Bool(true))).collect())
491}
492
493fn output_map(flags: &[&str]) -> Expr {
494    input_map(flags)
495}
496
497fn streams_map(flags: &[&str]) -> Expr {
498    input_map(flags)
499}
500
501fn watch_display_map(model: &str, px: u64, size_mm: u64) -> Expr {
502    display_map(&[
503        ("class", sym("watch")),
504        ("shape", sym("round")),
505        ("model", sym(model)),
506        ("px", build::list(vec![build::uint(px), build::uint(px)])),
507        ("size-mm", build::uint(size_mm)),
508        ("color", sym("truecolor")),
509        ("max-hz", build::uint(1)),
510        ("density", sym("glance")),
511    ])
512}
513
514fn watch_output_map() -> Expr {
515    output_map(&["screen", "haptic", "face", "tone", "speaker", "mic"])
516}
517
518fn glasses_display_map(
519    model: &str,
520    display: &str,
521    px: (u64, u64),
522    fov_deg: u64,
523    tracking: &str,
524    cameras: &[&str],
525    anchor_spaces: &[&str],
526) -> Expr {
527    let mut entries = vec![
528        ("class", sym("glasses")),
529        ("model", sym(model)),
530        ("display", sym(display)),
531        ("fov-deg", build::uint(fov_deg)),
532        ("tracking-class", sym(tracking)),
533        ("cameras", sym_list(cameras)),
534        ("anchor-spaces", sym_list(anchor_spaces)),
535        ("density", sym("glance")),
536    ];
537    match display {
538        "mono" => {
539            entries.push(("mono", Expr::Bool(true)));
540            entries.push(("mono-px", px_pair(px.0, px.1)));
541        }
542        "stereo" => {
543            entries.push(("stereo", Expr::Bool(true)));
544            entries.push(("per-eye-px", px_pair(px.0, px.1)));
545        }
546        "none" => entries.push(("none", Expr::Bool(true))),
547        _ => {}
548    }
549    display_map(&entries)
550}
551
552fn halo_display_map(cameras: &[&str]) -> Expr {
553    let mut display = match glasses_display_map(
554        "brilliant-halo",
555        "mono",
556        (256, 256),
557        20,
558        "hud",
559        cameras,
560        &["screen"],
561    ) {
562        Expr::Map(entries) => entries,
563        _ => unreachable!(),
564    };
565    display.push((Expr::Symbol(build::keyword("lines")), build::uint(1)));
566    Expr::Map(display)
567}
568
569fn halo_input_map(camera: bool) -> Expr {
570    if camera {
571        input_map(&["voice", "tap", "button", "camera", "haptic-ack"])
572    } else {
573        input_map(&["voice", "tap", "button", "haptic-ack"])
574    }
575}
576
577fn halo_output_map() -> Expr {
578    output_map(&["screen", "hud", "audio", "speaker", "haptic"])
579}
580
581fn halo_transport_map() -> Expr {
582    transport_map_with_links(
583        "bluetooth",
584        80,
585        true,
586        &["bluetooth", "web-bluetooth", "phone-relay"],
587    )
588}
589
590fn viture_display_map(tracking: &str) -> Expr {
591    glasses_display_map(
592        "viture-luma-ultra",
593        "stereo",
594        (1920, 1200),
595        52,
596        tracking,
597        &["rgb-camera", "depth-camera"],
598        &["head", "world", "hand"],
599    )
600}
601
602fn px_pair(w: u64, h: u64) -> Expr {
603    build::list(vec![build::uint(w), build::uint(h)])
604}
605
606fn sym_list(names: &[&str]) -> Expr {
607    build::list(names.iter().map(|name| sym(name)).collect())
608}
609
610fn transport_map(kind: &str, round_trip_ms: u64, offline_queue: bool) -> Expr {
611    transport_map_with_links(kind, round_trip_ms, offline_queue, &[])
612}
613
614fn transport_map_with_links(
615    kind: &str,
616    round_trip_ms: u64,
617    offline_queue: bool,
618    links: &[&str],
619) -> Expr {
620    build::map(vec![
621        ("kind", build::sym(kind)),
622        ("round-trip-ms", build::uint(round_trip_ms)),
623        ("offline-queue", Expr::Bool(offline_queue)),
624        ("ordered", Expr::Bool(true)),
625        (
626            "links",
627            build::list(links.iter().map(|link| build::sym(link)).collect()),
628        ),
629    ])
630}
631
632fn privacy_map(class: &str, retain_ms: u64) -> Expr {
633    build::map(vec![
634        ("class", build::sym(class)),
635        ("retain-ms", build::uint(retain_ms)),
636        ("private-fields", build::list(Vec::new())),
637    ])
638}
639
640fn rate_map(content_hz: u64, adapt_hz: u64, max_stale_ms: u64) -> Expr {
641    build::map(vec![
642        ("content-hz", build::uint(content_hz)),
643        ("adapt-hz", build::uint(adapt_hz)),
644        ("max-stale-ms", build::uint(max_stale_ms)),
645    ])
646}
647
648fn watch_rate_map() -> Expr {
649    rate_map(1, 1, 4000)
650}
651
652fn map_field(entries: &[(Expr, Expr)], name: &'static str) -> Result<Expr, SurfaceError> {
653    // Defer the required-field lookup to the shared `sim_value::access` reader
654    // (mapping its error via `SurfaceError::from`); keep the map-shape check
655    // local, since the surface fields are whole `Expr::Map` values with no typed
656    // slice reader of their own.
657    match access::entry_required(entries, name, "surface caps").map_err(SurfaceError::from)? {
658        value @ Expr::Map(_) => Ok(value.clone()),
659        _ => Err(SurfaceError::BadField(name)),
660    }
661}
662
663fn optional_map_field(entries: &[(Expr, Expr)], name: &'static str) -> Result<Expr, SurfaceError> {
664    match access::entry_field(entries, name) {
665        Some(value @ Expr::Map(_)) => Ok(value.clone()),
666        Some(_) => Err(SurfaceError::BadField(name)),
667        None => Ok(build::map(Vec::new())),
668    }
669}
670
671#[cfg(test)]
672#[path = "surface_tests.rs"]
673mod tests;