1use serde_json::Value;
24
25const DEFAULT_POSITION: &str = "top_right";
27
28const VALID_POSITIONS: &[&str] = &[
30 "top_left",
31 "top_center",
32 "top_right",
33 "middle_left",
34 "center",
35 "middle_right",
36 "bottom_left",
37 "bottom_center",
38 "bottom_right",
39];
40
41#[derive(Debug, Clone, PartialEq)]
44pub struct StyledSpan {
45 pub text: String,
46 pub fg: Option<String>,
47 pub bg: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum WidgetEvent {
53 Upsert {
56 id: String,
57 lines: Vec<String>,
58 styled_lines: Option<Vec<Vec<StyledSpan>>>,
62 position: String,
65 title: Option<String>,
66 ttl_secs: Option<u64>,
68 },
69 Dismiss { id: String },
71}
72
73impl WidgetEvent {
74 pub fn id(&self) -> &str {
75 match self {
76 WidgetEvent::Upsert { id, .. } | WidgetEvent::Dismiss { id } => id,
77 }
78 }
79}
80
81pub fn is_widget_method(method: &str) -> bool {
83 matches!(method, "widget.upsert" | "widget.dismiss")
84}
85
86pub fn parse_widget_event(method: &str, params: &Value) -> Result<WidgetEvent, String> {
88 let obj = params
89 .as_object()
90 .ok_or_else(|| format!("{method} params must be a JSON object"))?;
91
92 let id = obj
93 .get("id")
94 .and_then(Value::as_str)
95 .ok_or_else(|| format!("{method} missing 'id'"))?
96 .to_string();
97 if id.is_empty() {
98 return Err(format!("{method} 'id' must be non-empty"));
99 }
100
101 match method {
102 "widget.upsert" => {
103 let lines_raw = obj
105 .get("lines")
106 .ok_or_else(|| "widget.upsert missing 'lines'".to_string())?;
107 let lines_arr = lines_raw
108 .as_array()
109 .ok_or_else(|| "widget.upsert 'lines' must be an array".to_string())?;
110 let mut lines = Vec::with_capacity(lines_arr.len());
111 for (i, v) in lines_arr.iter().enumerate() {
112 let s = v.as_str().ok_or_else(|| {
113 format!("widget.upsert 'lines[{i}]' must be a string")
114 })?;
115 lines.push(s.to_string());
116 }
117
118 let position = match obj.get("position") {
120 None => DEFAULT_POSITION.to_string(),
121 Some(Value::Null) => DEFAULT_POSITION.to_string(),
122 Some(v) => {
123 let s = v
124 .as_str()
125 .ok_or_else(|| "widget.upsert 'position' must be a string".to_string())?;
126 if !VALID_POSITIONS.contains(&s) {
127 return Err(format!("widget.upsert unknown position '{s}'"));
128 }
129 s.to_string()
130 }
131 };
132
133 let title = match obj.get("title") {
135 None | Some(Value::Null) => None,
136 Some(v) => {
137 let s = v
138 .as_str()
139 .ok_or_else(|| "widget.upsert 'title' must be a string".to_string())?;
140 if s.is_empty() { None } else { Some(s.to_string()) }
141 }
142 };
143
144 let ttl_secs = match obj.get("ttl_secs") {
146 None | Some(Value::Null) => None,
147 Some(v) => {
148 let n = v.as_u64().ok_or_else(|| {
149 format!(
150 "widget.upsert 'ttl_secs' must be a non-negative integer or null, got {v}"
151 )
152 })?;
153 Some(n)
154 }
155 };
156
157 let styled_lines = match obj.get("styled_lines") {
159 None | Some(Value::Null) => None,
160 Some(v) => {
161 let outer = v.as_array().ok_or_else(|| {
162 "widget.upsert 'styled_lines' must be an array of arrays".to_string()
163 })?;
164 let mut result = Vec::with_capacity(outer.len());
165 for (i, row) in outer.iter().enumerate() {
166 let spans_arr = row.as_array().ok_or_else(|| {
167 format!("widget.upsert 'styled_lines[{i}]' must be an array of span objects")
168 })?;
169 let mut spans = Vec::with_capacity(spans_arr.len());
170 for (j, span_val) in spans_arr.iter().enumerate() {
171 let span_obj = span_val.as_object().ok_or_else(|| {
172 format!("widget.upsert 'styled_lines[{i}][{j}]' must be an object")
173 })?;
174 let text = span_obj.get("text")
175 .and_then(Value::as_str)
176 .ok_or_else(|| {
177 format!("widget.upsert 'styled_lines[{i}][{j}].text' must be a string")
178 })?
179 .to_string();
180 let fg = span_obj.get("fg")
181 .and_then(Value::as_str)
182 .map(str::to_string);
183 let bg = span_obj.get("bg")
184 .and_then(Value::as_str)
185 .map(str::to_string);
186 spans.push(StyledSpan { text, fg, bg });
187 }
188 result.push(spans);
189 }
190 Some(result)
191 }
192 };
193
194 Ok(WidgetEvent::Upsert { id, lines, styled_lines, position, title, ttl_secs })
195 }
196
197 "widget.dismiss" => Ok(WidgetEvent::Dismiss { id }),
198
199 other => Err(format!("not a widget method: {other}")),
200 }
201}
202
203#[derive(Debug, Clone)]
206pub struct ExtensionWidgetEvent {
207 pub extension_id: String,
208 pub event: WidgetEvent,
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use serde_json::json;
215
216 #[test]
219 fn parses_upsert_minimal() {
220 let ev = parse_widget_event(
221 "widget.upsert",
222 &json!({"id": "status", "lines": ["hello"]}),
223 )
224 .unwrap();
225 assert_eq!(
226 ev,
227 WidgetEvent::Upsert {
228 id: "status".into(),
229 lines: vec!["hello".into()],
230 position: "top_right".into(),
231 title: None,
232 ttl_secs: None,
233 styled_lines: None,
234 }
235 );
236 }
237
238 #[test]
239 fn parses_upsert_full() {
240 let ev = parse_widget_event(
241 "widget.upsert",
242 &json!({
243 "id": "hud",
244 "lines": ["line one", "line two"],
245 "position": "bottom_left",
246 "title": "My Widget",
247 "ttl_secs": 30
248 }),
249 )
250 .unwrap();
251 assert_eq!(
252 ev,
253 WidgetEvent::Upsert {
254 id: "hud".into(),
255 lines: vec!["line one".into(), "line two".into()],
256 position: "bottom_left".into(),
257 title: Some("My Widget".into()),
258 ttl_secs: Some(30),
259 styled_lines: None,
260 }
261 );
262 }
263
264 #[test]
265 fn upsert_null_position_defaults_to_top_right() {
266 let ev = parse_widget_event(
267 "widget.upsert",
268 &json!({"id": "w", "lines": [], "position": null}),
269 )
270 .unwrap();
271 assert!(matches!(
272 ev,
273 WidgetEvent::Upsert { position, .. } if position == "top_right"
274 ));
275 }
276
277 #[test]
278 fn upsert_null_ttl_means_persistent() {
279 let ev = parse_widget_event(
280 "widget.upsert",
281 &json!({"id": "w", "lines": [], "ttl_secs": null}),
282 )
283 .unwrap();
284 assert!(matches!(ev, WidgetEvent::Upsert { ttl_secs: None, .. }));
285 }
286
287 #[test]
288 fn upsert_empty_lines_is_valid() {
289 let ev = parse_widget_event(
290 "widget.upsert",
291 &json!({"id": "w", "lines": []}),
292 )
293 .unwrap();
294 assert!(matches!(ev, WidgetEvent::Upsert { lines, .. } if lines.is_empty()));
295 }
296
297 #[test]
298 fn upsert_empty_title_coerces_to_none() {
299 let ev = parse_widget_event(
300 "widget.upsert",
301 &json!({"id": "w", "lines": [], "title": ""}),
302 )
303 .unwrap();
304 assert!(matches!(ev, WidgetEvent::Upsert { title: None, .. }));
305 }
306
307 #[test]
308 fn upsert_all_positions_accepted() {
309 for pos in VALID_POSITIONS {
310 let ev = parse_widget_event(
311 "widget.upsert",
312 &json!({"id": "w", "lines": [], "position": pos}),
313 )
314 .unwrap();
315 assert!(
316 matches!(&ev, WidgetEvent::Upsert { position, .. } if position == pos),
317 "position '{pos}' was rejected"
318 );
319 }
320 }
321
322 #[test]
325 fn parses_dismiss() {
326 let ev =
327 parse_widget_event("widget.dismiss", &json!({"id": "hud"})).unwrap();
328 assert_eq!(ev, WidgetEvent::Dismiss { id: "hud".into() });
329 }
330
331 #[test]
334 fn rejects_missing_id() {
335 assert!(parse_widget_event("widget.upsert", &json!({"lines": []})).is_err());
336 assert!(parse_widget_event("widget.dismiss", &json!({})).is_err());
337 }
338
339 #[test]
340 fn rejects_empty_id() {
341 assert!(
342 parse_widget_event("widget.upsert", &json!({"id": "", "lines": []})).is_err()
343 );
344 }
345
346 #[test]
347 fn rejects_missing_lines() {
348 assert!(parse_widget_event("widget.upsert", &json!({"id": "w"})).is_err());
349 }
350
351 #[test]
352 fn rejects_non_string_line_element() {
353 let err = parse_widget_event(
354 "widget.upsert",
355 &json!({"id": "w", "lines": ["ok", 42]}),
356 )
357 .unwrap_err();
358 assert!(err.contains("lines[1]"));
359 }
360
361 #[test]
362 fn rejects_unknown_position() {
363 let err = parse_widget_event(
364 "widget.upsert",
365 &json!({"id": "w", "lines": [], "position": "floating"}),
366 )
367 .unwrap_err();
368 assert!(err.contains("unknown position"));
369 }
370
371 #[test]
372 fn rejects_non_object_params() {
373 assert!(parse_widget_event("widget.upsert", &json!("bad")).is_err());
374 assert!(parse_widget_event("widget.dismiss", &json!(null)).is_err());
375 }
376
377 #[test]
378 fn rejects_unknown_method() {
379 let err =
380 parse_widget_event("widget.flash", &json!({"id": "w"})).unwrap_err();
381 assert!(err.contains("not a widget method"));
382 }
383
384 #[test]
387 fn is_widget_method_works() {
388 assert!(is_widget_method("widget.upsert"));
389 assert!(is_widget_method("widget.dismiss"));
390 assert!(!is_widget_method("widget.flash"));
391 assert!(!is_widget_method("task.start"));
392 assert!(!is_widget_method(""));
393 }
394
395 #[test]
396 fn event_id_helper() {
397 let upsert = parse_widget_event(
398 "widget.upsert",
399 &json!({"id": "my-widget", "lines": []}),
400 )
401 .unwrap();
402 assert_eq!(upsert.id(), "my-widget");
403
404 let dismiss =
405 parse_widget_event("widget.dismiss", &json!({"id": "my-widget"})).unwrap();
406 assert_eq!(dismiss.id(), "my-widget");
407 }
408}