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::{Expr, Symbol};
27use sim_value::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}
80
81impl core::fmt::Display for SurfaceError {
82    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83        match self {
84            SurfaceError::NotCaps => write!(f, "value is not a surface/caps map"),
85            SurfaceError::MissingField(name) => write!(f, "surface caps missing field: {name}"),
86            SurfaceError::BadField(name) => write!(f, "surface caps field has wrong shape: {name}"),
87        }
88    }
89}
90
91impl std::error::Error for SurfaceError {}
92
93impl SurfaceCaps {
94    /// Builds caps from a preset name plus a concrete `client_id`.
95    ///
96    /// Returns `None` when `preset_name` is not in [`SURFACE_PRESETS`].
97    pub fn from_preset(preset_name: &str, client_id: impl Into<String>) -> Option<Self> {
98        let mut caps = preset(preset_name)?;
99        caps.client_id = client_id.into();
100        Some(caps)
101    }
102
103    /// Encodes these caps as a `surface/caps` tagged [`Expr`] map.
104    pub fn to_expr(&self) -> Expr {
105        build::map(vec![
106            (
107                "kind",
108                Expr::Symbol(Symbol::qualified(SURFACE_NAMESPACE, CAPS_KIND)),
109            ),
110            ("client-id", build::text(self.client_id.clone())),
111            ("preset", Expr::Symbol(self.preset.clone())),
112            ("display", self.display.clone()),
113            ("input", self.input.clone()),
114            ("transport", self.transport.clone()),
115            ("privacy", self.privacy.clone()),
116            (
117                "codecs",
118                build::list(self.codecs.iter().cloned().map(Expr::Symbol).collect()),
119            ),
120        ])
121    }
122
123    /// Parses caps from a `surface/caps` tagged [`Expr`] map, failing closed.
124    pub fn from_expr(expr: &Expr) -> Result<Self, SurfaceError> {
125        let Expr::Map(entries) = expr else {
126            return Err(SurfaceError::NotCaps);
127        };
128        match field(entries, "kind") {
129            Some(Expr::Symbol(kind))
130                if kind.namespace.as_deref() == Some(SURFACE_NAMESPACE)
131                    && &*kind.name == CAPS_KIND => {}
132            _ => return Err(SurfaceError::NotCaps),
133        }
134        let client_id = match field(entries, "client-id") {
135            Some(Expr::String(text)) => text.clone(),
136            Some(_) => return Err(SurfaceError::BadField("client-id")),
137            None => return Err(SurfaceError::MissingField("client-id")),
138        };
139        let preset = match field(entries, "preset") {
140            Some(Expr::Symbol(symbol)) => symbol.clone(),
141            Some(_) => return Err(SurfaceError::BadField("preset")),
142            None => return Err(SurfaceError::MissingField("preset")),
143        };
144        let display = map_field(entries, "display")?;
145        let input = map_field(entries, "input")?;
146        let transport = map_field(entries, "transport")?;
147        let privacy = map_field(entries, "privacy")?;
148        let codecs = match field(entries, "codecs") {
149            Some(Expr::List(items)) => {
150                let mut out = Vec::with_capacity(items.len());
151                for item in items {
152                    let Expr::Symbol(symbol) = item else {
153                        return Err(SurfaceError::BadField("codecs"));
154                    };
155                    out.push(symbol.clone());
156                }
157                out
158            }
159            Some(_) => return Err(SurfaceError::BadField("codecs")),
160            None => return Err(SurfaceError::MissingField("codecs")),
161        };
162        Ok(SurfaceCaps {
163            client_id,
164            preset,
165            display,
166            input,
167            transport,
168            privacy,
169            codecs,
170        })
171    }
172
173    /// Returns the unqualified preset name (`cli`, `watch`, ...).
174    pub fn preset_name(&self) -> &str {
175        &self.preset.name
176    }
177
178    /// Reads a boolean `input` capability flag, defaulting to `false`.
179    pub fn input_flag(&self, name: &str) -> bool {
180        matches!(map_get(&self.input, name), Some(Expr::Bool(true)))
181    }
182
183    /// Reads the `display` density symbol (`glance`/`compact`/`regular`/`dense`).
184    pub fn display_density(&self) -> Option<Symbol> {
185        match map_get(&self.display, "density") {
186            Some(Expr::Symbol(symbol)) => Some(symbol.clone()),
187            _ => None,
188        }
189    }
190
191    /// Whether this surface can decode the named surface codec.
192    pub fn accepts_codec(&self, codec: &str) -> bool {
193        self.codecs.iter().any(|symbol| &*symbol.name == codec)
194    }
195}
196
197/// Returns the baseline [`SurfaceCaps`] for a well-known preset name.
198///
199/// The `client_id` is set to the preset name and should be overridden with a
200/// real id via [`SurfaceCaps::from_preset`]. Returns `None` for unknown presets.
201pub fn preset(name: &str) -> Option<SurfaceCaps> {
202    let (display, input, transport, privacy) = match name {
203        "cli" => (
204            display_map(&[("density", sym("dense")), ("color", sym("ansi"))]),
205            input_map(&["keyboard"]),
206            transport_map("tty", 1, false),
207            privacy_map("local", 60_000),
208        ),
209        "tui" => (
210            display_map(&[("density", sym("dense")), ("color", sym("ansi256"))]),
211            input_map(&["keyboard", "pointer"]),
212            transport_map("tty", 1, false),
213            privacy_map("local", 60_000),
214        ),
215        "webui" => (
216            display_map(&[("density", sym("regular")), ("color", sym("truecolor"))]),
217            input_map(&["keyboard", "pointer", "touch", "wheel", "file-drop"]),
218            transport_map("websocket", 40, false),
219            privacy_map("session", 600_000),
220        ),
221        "watch" => (
222            display_map(&[("density", sym("glance")), ("shape", sym("round"))]),
223            input_map(&["touch", "tap", "crown", "haptic-ack"]),
224            transport_map("relay", 250, true),
225            privacy_map("local", 60_000),
226        ),
227        "glasses" => (
228            display_map(&[("density", sym("glance")), ("lines", build::uint(2))]),
229            input_map(&["voice", "tap"]),
230            transport_map("relay", 250, true),
231            privacy_map("local", 60_000),
232        ),
233        "phone" => (
234            display_map(&[("density", sym("compact")), ("color", sym("truecolor"))]),
235            input_map(&["touch", "voice", "camera"]),
236            transport_map("relay", 120, true),
237            privacy_map("session", 300_000),
238        ),
239        "desktop" => (
240            display_map(&[("density", sym("dense")), ("color", sym("truecolor"))]),
241            input_map(&["keyboard", "pointer", "wheel", "file-drop"]),
242            transport_map("local", 1, false),
243            privacy_map("session", 600_000),
244        ),
245        _ => return None,
246    };
247    Some(SurfaceCaps {
248        client_id: name.to_owned(),
249        preset: Symbol::qualified(SURFACE_NAMESPACE, name),
250        display,
251        input,
252        transport,
253        privacy,
254        codecs: vec![
255            Symbol::qualified(SURFACE_NAMESPACE, "lisp"),
256            Symbol::qualified(SURFACE_NAMESPACE, "json"),
257        ],
258    })
259}
260
261use sim_value::build::sym;
262
263fn display_map(extra: &[(&str, Expr)]) -> Expr {
264    let mut entries: Vec<(&str, Expr)> = vec![("media", build::list(Vec::new()))];
265    entries.extend(extra.iter().map(|(k, v)| (*k, v.clone())));
266    build::map(entries)
267}
268
269fn input_map(flags: &[&str]) -> Expr {
270    build::map(flags.iter().map(|flag| (*flag, Expr::Bool(true))).collect())
271}
272
273fn transport_map(kind: &str, round_trip_ms: u64, offline_queue: bool) -> Expr {
274    build::map(vec![
275        ("kind", build::sym(kind)),
276        ("round-trip-ms", build::uint(round_trip_ms)),
277        ("offline-queue", Expr::Bool(offline_queue)),
278        ("ordered", Expr::Bool(true)),
279    ])
280}
281
282fn privacy_map(class: &str, retain_ms: u64) -> Expr {
283    build::map(vec![
284        ("class", build::sym(class)),
285        ("retain-ms", build::uint(retain_ms)),
286        ("private-fields", build::list(Vec::new())),
287    ])
288}
289
290fn field<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Option<&'a Expr> {
291    entries.iter().find_map(|(key, value)| {
292        matches!(key, Expr::Symbol(symbol) if &*symbol.name == name && symbol.namespace.is_none())
293            .then_some(value)
294    })
295}
296
297fn map_field(entries: &[(Expr, Expr)], name: &'static str) -> Result<Expr, SurfaceError> {
298    match field(entries, name) {
299        Some(value @ Expr::Map(_)) => Ok(value.clone()),
300        Some(_) => Err(SurfaceError::BadField(name)),
301        None => Err(SurfaceError::MissingField(name)),
302    }
303}
304
305fn map_get<'a>(map: &'a Expr, name: &str) -> Option<&'a Expr> {
306    match map {
307        Expr::Map(entries) => field(entries, name),
308        _ => None,
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn every_preset_round_trips() {
318        for name in SURFACE_PRESETS {
319            let caps = preset(name).expect("preset exists");
320            assert_eq!(caps.preset_name(), *name);
321            let back = SurfaceCaps::from_expr(&caps.to_expr()).expect("round-trips");
322            assert_eq!(caps, back, "{name} caps must round-trip losslessly");
323        }
324    }
325
326    #[test]
327    fn unknown_preset_is_none() {
328        assert!(preset("hologram").is_none());
329    }
330
331    #[test]
332    fn from_preset_overrides_client_id() {
333        let caps = SurfaceCaps::from_preset("cli", "tty.local.7").unwrap();
334        assert_eq!(caps.client_id, "tty.local.7");
335        assert_eq!(caps.preset_name(), "cli");
336    }
337
338    #[test]
339    fn capability_accessors_read_fields() {
340        let cli = preset("cli").unwrap();
341        assert!(cli.input_flag("keyboard"));
342        assert!(!cli.input_flag("touch"));
343        assert_eq!(cli.display_density().unwrap().name.as_ref(), "dense");
344        assert!(cli.accepts_codec("lisp"));
345        assert!(!cli.accepts_codec("algol"));
346
347        let watch = preset("watch").unwrap();
348        assert!(watch.input_flag("haptic-ack"));
349        assert_eq!(watch.display_density().unwrap().name.as_ref(), "glance");
350    }
351
352    #[test]
353    fn parse_fails_closed() {
354        assert_eq!(
355            SurfaceCaps::from_expr(&Expr::Nil),
356            Err(SurfaceError::NotCaps)
357        );
358        // A caps map missing `codecs` must not yield partial caps.
359        let mut entries = match preset("cli").unwrap().to_expr() {
360            Expr::Map(entries) => entries,
361            _ => unreachable!(),
362        };
363        entries.retain(|(key, _)| !matches!(key, Expr::Symbol(s) if &*s.name == "codecs"));
364        assert_eq!(
365            SurfaceCaps::from_expr(&Expr::Map(entries)),
366            Err(SurfaceError::MissingField("codecs"))
367        );
368    }
369}