Skip to main content

sim_lib_view_device/
glance.rs

1//! Glance-card reduction for one-card device tiers.
2
3use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_scene::{GLANCE_KIND, GlanceAction, GlanceCard, GlanceMetric};
5use sim_value::{access, build};
6
7use crate::{DeviceProfile, DeviceTier};
8
9/// Local acknowledgement channel used by a glance adapter.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum AckChannel {
12    /// A haptic pulse.
13    Haptic,
14    /// A brief glyph flash.
15    GlyphFlash,
16    /// A short tone.
17    Tone,
18}
19
20impl AckChannel {
21    /// Stable token for serialized ack metadata.
22    pub fn token(self) -> &'static str {
23        match self {
24            AckChannel::Haptic => "haptic",
25            AckChannel::GlyphFlash => "glyph-flash",
26            AckChannel::Tone => "tone",
27        }
28    }
29
30    /// Encodes the channel as an unqualified symbol.
31    pub fn to_symbol(self) -> Symbol {
32        Symbol::new(self.token())
33    }
34}
35
36/// Abstract budget for fitting one glance card.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct GlanceBudget {
39    /// Abstract cells available for the card.
40    pub cells: u8,
41    /// Maximum rendered glyphs for compact text fields.
42    pub glyphs: u16,
43    /// Ack channel used for immediate local feedback.
44    pub ack: AckChannel,
45}
46
47impl GlanceBudget {
48    /// Tiny monochrome HUD budget.
49    pub fn mono_hud() -> Self {
50        Self {
51            cells: 2,
52            glyphs: 12,
53            ack: AckChannel::GlyphFlash,
54        }
55    }
56
57    /// Round watch-face budget.
58    pub fn round_watch() -> Self {
59        Self {
60            cells: 4,
61            glyphs: 24,
62            ack: AckChannel::Haptic,
63        }
64    }
65}
66
67/// Device-local input that may need immediate local acknowledgement.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum GlanceInput {
70    /// A tap/press acknowledgement.
71    Tap,
72}
73
74impl GlanceInput {
75    /// Stable token for serialized input metadata.
76    pub fn token(self) -> &'static str {
77        match self {
78            GlanceInput::Tap => "tap",
79        }
80    }
81}
82
83/// State consumed by [`crate::GlanceAdapter`].
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct GlanceState {
86    /// Pending input to acknowledge locally.
87    pub pending_input: Option<GlanceInput>,
88    /// Modeled device tick.
89    pub tick: u64,
90}
91
92impl GlanceState {
93    /// Builds a state with no pending input.
94    pub fn idle(tick: u64) -> Self {
95        Self {
96            pending_input: None,
97            tick,
98        }
99    }
100
101    /// Builds a state carrying one pending input.
102    pub fn with_input(input: GlanceInput, tick: u64) -> Self {
103        Self {
104            pending_input: Some(input),
105            tick,
106        }
107    }
108}
109
110/// Reduces general Scenes to one-card `scene/glance` nodes for small tiers.
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112pub struct GlanceReducer;
113
114impl GlanceReducer {
115    /// Reduces `scene` when `profile` is a one-card tier.
116    pub fn reduce(&self, scene: &Expr, profile: &DeviceProfile) -> Result<Expr> {
117        reduce_scene_to_glance(scene, profile)
118    }
119}
120
121/// Reduces a Scene to one `scene/glance` for non-rich device tiers.
122pub fn reduce_scene_to_glance(scene: &Expr, profile: &DeviceProfile) -> Result<Expr> {
123    sim_lib_scene::validate_scene(scene)
124        .map_err(|err| Error::HostError(format!("invalid source scene: {err}")))?;
125    if profile.tier == DeviceTier::Rich {
126        return Ok(scene.clone());
127    }
128    Ok(extract_card(scene).to_scene())
129}
130
131/// Fits a glance card to a local budget without changing its semantic fields.
132pub fn fit_to_budget(glance: &Expr, budget: &GlanceBudget) -> Result<Expr> {
133    let mut card = GlanceCard::from_scene(glance)?;
134    if !card.bypass_budget {
135        let cap = usize::from(budget.glyphs);
136        card.title = trim_glyphs(&card.title, cap);
137        if let Some(metric) = &mut card.metric {
138            metric.label = trim_glyphs(&metric.label, cap / 2);
139            metric.value = trim_glyphs(&metric.value, cap);
140        }
141        if let Some(action) = &mut card.action {
142            action.label = trim_glyphs(&action.label, cap / 2);
143        }
144    }
145    card.cells = u16::from(budget.cells);
146    Ok(card.to_scene())
147}
148
149fn extract_card(scene: &Expr) -> GlanceCard {
150    let title = first_text_like(scene).unwrap_or_else(|| {
151        kind_name(scene)
152            .map(|kind| kind.replace('-', " "))
153            .unwrap_or_else(|| "Scene".to_owned())
154    });
155    let urgency = urgency(scene);
156    let bypass = bypass_budget(scene);
157    GlanceCard::new(title, first_metric(scene), first_action(scene), urgency, 1)
158        .with_budget_bypass(bypass)
159}
160
161fn kind_name(expr: &Expr) -> Option<String> {
162    let kind = sim_lib_scene::node_kind(expr)?;
163    (kind.namespace.as_deref() == Some(sim_lib_scene::kinds::SCENE_NAMESPACE))
164        .then(|| kind.name.to_string())
165}
166
167fn first_text_like(expr: &Expr) -> Option<String> {
168    for field in ["title", "text", "label", "id"] {
169        if let Some(text) = access::field_str(expr, field) {
170            return Some(text.to_owned());
171        }
172        if let Some(symbol) = access::field_sym(expr, field) {
173            return Some(symbol.name.to_string());
174        }
175    }
176    children(expr).find_map(first_text_like)
177}
178
179fn first_metric(expr: &Expr) -> Option<GlanceMetric> {
180    if matches!(kind_name(expr).as_deref(), Some("meter" | "badge" | "plot"))
181        || access::field(expr, "value").is_some()
182    {
183        let label = access::field_str(expr, "label")
184            .map(str::to_owned)
185            .or_else(|| access::field_sym(expr, "status").map(|symbol| symbol.name.to_string()))
186            .or_else(|| kind_name(expr))
187            .unwrap_or_else(|| "value".to_owned());
188        let value = access::field_str(expr, "value")
189            .map(str::to_owned)
190            .or_else(|| field_number_text(expr, "value"))
191            .or_else(|| access::field_str(expr, "text").map(str::to_owned))
192            .or_else(|| access::field_str(expr, "label").map(str::to_owned))
193            .unwrap_or_else(|| label.clone());
194        return Some(GlanceMetric::new(label, value));
195    }
196    children(expr).find_map(first_metric)
197}
198
199fn first_action(expr: &Expr) -> Option<GlanceAction> {
200    if kind_name(expr).as_deref() == Some("button") {
201        let label = access::field_str(expr, "label")
202            .map(str::to_owned)
203            .or_else(|| access::field_str(expr, "text").map(str::to_owned))
204            .unwrap_or_else(|| "Open".to_owned());
205        let target = access::field(expr, "target")
206            .cloned()
207            .or_else(|| access::field(expr, "control").cloned())
208            .unwrap_or_else(|| build::sym("tap"));
209        return Some(GlanceAction::new(label, target));
210    }
211    children(expr).find_map(first_action)
212}
213
214fn field_number_text(expr: &Expr, field: &str) -> Option<String> {
215    let Expr::Number(number) = access::field(expr, field)? else {
216        return None;
217    };
218    Some(number.canonical.clone())
219}
220
221fn urgency(expr: &Expr) -> String {
222    if let Some(status) = access::field_sym(expr, "status") {
223        let token = status.name.as_ref();
224        if matches!(token, "error" | "warn" | "critical" | "ok" | "info") {
225            return token.to_owned();
226        }
227    }
228    children(expr)
229        .find_map(|child| {
230            let value = urgency(child);
231            (value != "info").then_some(value)
232        })
233        .unwrap_or_else(|| "info".to_owned())
234}
235
236fn bypass_budget(expr: &Expr) -> bool {
237    matches!(kind_name(expr).as_deref(), Some("warrant"))
238        || access::field_sym(expr, "status")
239            .is_some_and(|status| matches!(status.name.as_ref(), "error" | "critical" | "warrant"))
240        || children(expr).any(bypass_budget)
241}
242
243fn children(expr: &Expr) -> impl Iterator<Item = &Expr> {
244    ["children", "nodes", "rows", "items"]
245        .into_iter()
246        .filter_map(|field| access::field(expr, field))
247        .flat_map(|value| match value {
248            Expr::List(items) | Expr::Vector(items) => items.as_slice(),
249            _ => &[][..],
250        })
251}
252
253fn trim_glyphs(text: &str, cap: usize) -> String {
254    if cap == 0 {
255        return String::new();
256    }
257    let mut out = String::new();
258    for (index, ch) in text.chars().enumerate() {
259        if index >= cap {
260            break;
261        }
262        out.push(ch);
263    }
264    out
265}
266
267pub(crate) fn is_glance(expr: &Expr) -> bool {
268    kind_name(expr).as_deref() == Some(GLANCE_KIND)
269}