Skip to main content

sim_lib_scene/
glance.rs

1//! One-card Scene helpers for tiny glance surfaces.
2//!
3//! A `scene/glance` is the reduced, portable card shape shared by small device
4//! surfaces. It is still an ordinary Scene node: a tagged map with open fields,
5//! not a renderer-specific widget.
6
7use sim_kernel::{Error, Expr, Result};
8use sim_value::{access, build};
9
10use crate::model::{node, validate_scene};
11
12/// The local scene kind name for one-card glance nodes.
13pub const GLANCE_KIND: &str = "glance";
14
15/// One metric row on a glance card.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct GlanceMetric {
18    /// Metric label.
19    pub label: String,
20    /// Metric value rendered as compact text.
21    pub value: String,
22}
23
24impl GlanceMetric {
25    /// Builds a metric row.
26    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
27        Self {
28            label: label.into(),
29            value: value.into(),
30        }
31    }
32
33    fn to_expr(&self) -> Expr {
34        build::map(vec![
35            ("label", Expr::String(self.label.clone())),
36            ("value", Expr::String(self.value.clone())),
37        ])
38    }
39
40    fn from_expr(expr: &Expr) -> Result<Self> {
41        Ok(Self {
42            label: access::required_str(expr, "label", "scene/glance metric")?.to_owned(),
43            value: access::required_str(expr, "value", "scene/glance metric")?.to_owned(),
44        })
45    }
46}
47
48/// The single primary action on a glance card.
49#[derive(Clone, Debug, PartialEq)]
50pub struct GlanceAction {
51    /// Action label shown on the card.
52    pub label: String,
53    /// Stable action target/control token as Scene data.
54    pub target: Expr,
55}
56
57impl GlanceAction {
58    /// Builds an action row.
59    pub fn new(label: impl Into<String>, target: Expr) -> Self {
60        Self {
61            label: label.into(),
62            target,
63        }
64    }
65
66    fn to_expr(&self) -> Expr {
67        build::map(vec![
68            ("label", Expr::String(self.label.clone())),
69            ("target", self.target.clone()),
70        ])
71    }
72
73    fn from_expr(expr: &Expr) -> Result<Self> {
74        Ok(Self {
75            label: access::required_str(expr, "label", "scene/glance action")?.to_owned(),
76            target: access::required(expr, "target", "scene/glance action")?.clone(),
77        })
78    }
79}
80
81/// A single portable card for HUD, watch, and other tiny display surfaces.
82#[derive(Clone, Debug, PartialEq)]
83pub struct GlanceCard {
84    /// The card title.
85    pub title: String,
86    /// Optional single metric row.
87    pub metric: Option<GlanceMetric>,
88    /// Optional single action row.
89    pub action: Option<GlanceAction>,
90    /// Urgency token such as `info`, `warn`, or `error`.
91    pub urgency: String,
92    /// Abstract cell budget, not a device unit.
93    pub cells: u16,
94    /// Whether safety-critical content bypasses local trimming.
95    pub bypass_budget: bool,
96}
97
98impl GlanceCard {
99    /// Builds a card with optional metric/action rows.
100    pub fn new(
101        title: impl Into<String>,
102        metric: Option<GlanceMetric>,
103        action: Option<GlanceAction>,
104        urgency: impl Into<String>,
105        cells: u16,
106    ) -> Self {
107        Self {
108            title: title.into(),
109            metric,
110            action,
111            urgency: urgency.into(),
112            cells,
113            bypass_budget: false,
114        }
115    }
116
117    /// Marks this card as exempt from local glyph trimming.
118    pub fn with_budget_bypass(mut self, bypass: bool) -> Self {
119        self.bypass_budget = bypass;
120        self
121    }
122
123    /// Encodes the card as a `scene/glance` node.
124    pub fn to_scene(&self) -> Expr {
125        let mut entries = vec![
126            ("title", Expr::String(self.title.clone())),
127            ("urgency", build::sym(&self.urgency)),
128            ("cells", build::uint(u64::from(self.cells))),
129            ("bypass-budget", Expr::Bool(self.bypass_budget)),
130        ];
131        if let Some(metric) = &self.metric {
132            entries.push(("metric", metric.to_expr()));
133        }
134        if let Some(action) = &self.action {
135            entries.push(("action", action.to_expr()));
136        }
137        node(GLANCE_KIND, entries)
138    }
139
140    /// Reads a `scene/glance` node back into a typed card.
141    pub fn from_scene(expr: &Expr) -> Result<Self> {
142        validate_scene(expr)
143            .map_err(|err| Error::HostError(format!("invalid scene/glance: {err}")))?;
144        match crate::model::node_kind(expr) {
145            Some(kind)
146                if kind.namespace.as_deref() == Some(crate::kinds::SCENE_NAMESPACE)
147                    && kind.name.as_ref() == GLANCE_KIND => {}
148            _ => {
149                return Err(Error::HostError("expected a scene/glance card".to_owned()));
150            }
151        }
152        let cells = field_u16(expr, "cells").unwrap_or(1);
153        Ok(Self {
154            title: access::required_str(expr, "title", "scene/glance")?.to_owned(),
155            metric: access::field(expr, "metric")
156                .map(GlanceMetric::from_expr)
157                .transpose()?,
158            action: access::field(expr, "action")
159                .map(GlanceAction::from_expr)
160                .transpose()?,
161            urgency: access::field_sym(expr, "urgency")
162                .map(|symbol| symbol.name.to_string())
163                .unwrap_or_else(|| "info".to_owned()),
164            cells,
165            bypass_budget: access::field_bool(expr, "bypass-budget").unwrap_or(false),
166        })
167    }
168}
169
170fn field_u16(expr: &Expr, name: &str) -> Option<u16> {
171    let Expr::Number(number) = access::field(expr, name)? else {
172        return None;
173    };
174    number.canonical.parse().ok()
175}
176
177/// Builds a `scene/glance` node.
178pub fn glance_card(
179    title: impl Into<String>,
180    metric: Option<GlanceMetric>,
181    action: Option<GlanceAction>,
182    urgency: impl Into<String>,
183    cells: u16,
184) -> Expr {
185    GlanceCard::new(title, metric, action, urgency, cells).to_scene()
186}