1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum AckChannel {
12 Haptic,
14 GlyphFlash,
16 Tone,
18}
19
20impl AckChannel {
21 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 pub fn to_symbol(self) -> Symbol {
32 Symbol::new(self.token())
33 }
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct GlanceBudget {
39 pub cells: u8,
41 pub glyphs: u16,
43 pub ack: AckChannel,
45}
46
47impl GlanceBudget {
48 pub fn mono_hud() -> Self {
50 Self {
51 cells: 2,
52 glyphs: 12,
53 ack: AckChannel::GlyphFlash,
54 }
55 }
56
57 pub fn round_watch() -> Self {
59 Self {
60 cells: 4,
61 glyphs: 24,
62 ack: AckChannel::Haptic,
63 }
64 }
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum GlanceInput {
70 Tap,
72}
73
74impl GlanceInput {
75 pub fn token(self) -> &'static str {
77 match self {
78 GlanceInput::Tap => "tap",
79 }
80 }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct GlanceState {
86 pub pending_input: Option<GlanceInput>,
88 pub tick: u64,
90}
91
92impl GlanceState {
93 pub fn idle(tick: u64) -> Self {
95 Self {
96 pending_input: None,
97 tick,
98 }
99 }
100
101 pub fn with_input(input: GlanceInput, tick: u64) -> Self {
103 Self {
104 pending_input: Some(input),
105 tick,
106 }
107 }
108}
109
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112pub struct GlanceReducer;
113
114impl GlanceReducer {
115 pub fn reduce(&self, scene: &Expr, profile: &DeviceProfile) -> Result<Expr> {
117 reduce_scene_to_glance(scene, profile)
118 }
119}
120
121pub 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
131pub 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}