1use serde_json::{json, Map, Value};
10
11use super::{bool_prop, js, str_prop, Builder, LNode, LayerPlacement, NodeKind};
12use crate::overlay::{OverlayAlign, OverlaySide};
13use crate::theme::RecipeQuery;
14use crate::types::UiNode;
15
16pub mod dates {
18 pub fn is_leap(y: i32) -> bool {
19 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
20 }
21
22 pub fn days_in_month(y: i32, m: u32) -> u32 {
23 match m {
24 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
25 4 | 6 | 9 | 11 => 30,
26 _ => {
27 if is_leap(y) {
28 29
29 } else {
30 28
31 }
32 }
33 }
34 }
35
36 pub fn weekday(y: i32, m: u32, d: u32) -> u32 {
38 const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
39 let y = if m < 3 { y - 1 } else { y };
40 ((y + y / 4 - y / 100 + y / 400 + T[(m - 1) as usize] + d as i32).rem_euclid(7)) as u32
41 }
42
43 pub fn parse(s: &str) -> Option<(i32, u32, u32)> {
45 let b = s.get(0..10)?;
46 let mut it = b.split('-');
47 let y = it.next()?.parse().ok()?;
48 let m: u32 = it.next()?.parse().ok()?;
49 let d: u32 = it.next()?.parse().ok()?;
50 ((1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m)).then_some((y, m, d))
51 }
52
53 pub fn format(y: i32, m: u32, d: u32) -> String {
54 format!("{y:04}-{m:02}-{d:02}")
55 }
56
57 pub fn shift_month(y: i32, m: u32, delta: i32) -> (i32, u32) {
59 let total = y * 12 + (m as i32 - 1) + delta;
60 (total.div_euclid(12), (total.rem_euclid(12) + 1) as u32)
61 }
62
63 pub fn add_days(y: i32, m: u32, d: u32, n: i32) -> (i32, u32, u32) {
65 let (mut y, mut m, mut d) = (y, m, d as i32 + n);
66 while d < 1 {
67 let (py, pm) = shift_month(y, m, -1);
68 y = py;
69 m = pm;
70 d += days_in_month(y, m) as i32;
71 }
72 while d > days_in_month(y, m) as i32 {
73 d -= days_in_month(y, m) as i32;
74 let (ny, nm) = shift_month(y, m, 1);
75 y = ny;
76 m = nm;
77 }
78 (y, m, d as u32)
79 }
80
81 pub const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
82 pub const WEEKDAYS_SHORT: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
83
84 pub fn label(s: &str) -> String {
87 match parse(s) {
88 Some((y, m, d)) => format!("{} {d}, {y}", &MONTHS[(m - 1) as usize][..3]),
89 None => s.to_string(),
90 }
91 }
92}
93
94pub fn parse_time(s: &str) -> Option<u32> {
96 let (h, m) = s.split_once(':')?;
97 let h: u32 = h.parse().ok()?;
98 let m: u32 = m.get(0..2)?.parse().ok()?;
99 (h < 24 && m < 60).then_some(h * 60 + m)
100}
101
102pub fn format_time(minutes: u32) -> String {
103 format!("{:02}:{:02}", minutes / 60, minutes % 60)
104}
105
106fn round_decimal(s: &str, p: usize) -> String {
109 let (int, frac) = s.split_once('.').unwrap_or((s, ""));
110 let mut digits: Vec<u8> = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(p)).collect();
111 if frac.as_bytes().get(p).copied().unwrap_or(b'0') >= b'5' {
112 let mut k = digits.len();
113 loop {
114 if k == 0 {
115 digits.insert(0, b'1');
116 break;
117 }
118 k -= 1;
119 if digits[k] == b'9' {
120 digits[k] = b'0';
121 } else {
122 digits[k] += 1;
123 break;
124 }
125 }
126 }
127 let split = digits.len() - p;
128 let mut out = String::from_utf8_lossy(&digits[..split]).into_owned();
129 if out.is_empty() {
130 out.push('0');
131 }
132 if p > 0 {
133 out.push('.');
134 out.push_str(&String::from_utf8_lossy(&digits[split..]));
135 }
136 out
137}
138
139pub fn js_number_string(n: f64) -> String {
141 crate::json::number_to_string(n)
142}
143
144pub fn decimals_of(n: f64) -> u32 {
147 let s = js_number_string(n);
148 s.find('.').map(|i| (s.len() - i - 1) as u32).unwrap_or(0)
149}
150
151pub fn to_fixed(n: f64, p: u32) -> String {
154 if !n.is_finite() || n.abs() >= 1e21 {
155 return js_number_string(n);
156 }
157 let p = p.min(100) as usize;
158 let exact = format!("{:.1100}", n.abs());
160 let body = round_decimal(&exact, p);
161 if n < 0.0 {
163 format!("-{body}")
164 } else {
165 body
166 }
167}
168
169pub fn format_number_value(n: f64, precision: u32) -> String {
173 if !n.is_finite() {
174 return js_number_string(n);
175 }
176 let shortest = format!("{}", n.abs());
177 let rounded = round_decimal(&shortest, precision.min(100) as usize);
178 let (int, frac) = rounded.split_once('.').unwrap_or((&rounded, ""));
179 let mut out = String::new();
180 if n.is_sign_negative() {
181 out.push('-');
182 }
183 let len = int.len();
184 for (i, c) in int.chars().enumerate() {
185 if i > 0 && (len - i) % 3 == 0 {
186 out.push(',');
187 }
188 out.push(c);
189 }
190 if !frac.is_empty() {
191 out.push('.');
192 out.push_str(frac);
193 }
194 out
195}
196
197pub fn number_step_precision(props: &crate::types::Props) -> (f64, u32) {
200 let step = props.get("step").map(crate::json::to_number).filter(|s| *s != 0.0 && s.is_finite()).unwrap_or(1.0);
201 let precision = match props.get("precision") {
202 Some(p) => crate::json::to_number(p).max(0.0).floor().min(100.0) as u32,
203 None => decimals_of(step),
204 };
205 (step, precision)
206}
207
208pub fn number_prop(props: &crate::types::Props, key: &str) -> Option<f64> {
211 match props.get(key)? {
212 Value::Number(n) => n.as_f64(),
213 Value::String(s) if !s.trim().is_empty() => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).or(Some(0.0)),
214 Value::Null => None,
215 Value::String(_) => None,
216 _ => Some(0.0),
217 }
218}
219
220pub fn clamp_number_value(v: f64, min: Option<f64>, max: Option<f64>, precision: u32) -> f64 {
222 let mut v = v;
223 if let Some(m) = min {
224 v = v.max(m);
225 }
226 if let Some(m) = max {
227 v = v.min(m);
228 }
229 to_fixed(v, precision).parse().unwrap_or(v)
230}
231
232pub fn format_bytes_with(formatter: &dyn crate::format::Formatter, n: f64) -> String {
238 use crate::format::ByteUnit;
239 const UNITS: [ByteUnit; 4] = [ByteUnit::Byte, ByteUnit::Kilobyte, ByteUnit::Megabyte, ByteUnit::Gigabyte];
240 let mut v = n.max(0.0);
241 let mut i = 0;
242 while v >= 1024.0 && i < UNITS.len() - 1 {
243 v /= 1024.0;
244 i += 1;
245 }
246 formatter.bytes((v * 10.0).round() / 10.0, UNITS[i])
247}
248
249pub fn format_bytes(n: f64) -> String {
251 format_bytes_with(&crate::format::EnglishFormatter, n)
252}
253
254impl Builder<'_, '_> {
255 fn field_open(&mut self, index: u32) -> LNode {
257 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("gap", json!("$spacing.xs"))]);
258 self.nodes[index as usize].kind = NodeKind::Container;
259 let owner = self.nodes[index as usize].clone();
260 if let Some(label) = str_prop(&owner.props, "label") {
261 self.text_part(index, &owner, "label", label, "label", "");
262 }
263 owner
264 }
265
266 fn field_errors(&self, owner: &LNode) -> Vec<String> {
269 let mut out: Vec<String> = self.ctx.local.errors.get(&owner.id).map(|e| e.messages.clone()).unwrap_or_default();
270 if let Some(e) = str_prop(&owner.props, "error") {
271 out.push(e.to_string());
272 }
273 out
274 }
275
276 fn field_close(&mut self, owner: &LNode, control: Option<u32>) {
279 if let Some(d) = str_prop(&owner.props, "description") {
280 self.text_part(owner.index, owner, "description", d, "muted", "");
281 }
282 let errors = self.field_errors(owner);
283 if !errors.is_empty() {
284 let e = self.text_part(owner.index, owner, "error", &errors.join("\n"), "caption", "");
285 self.add_state(e, "invalid");
286 if let Some(c) = control {
287 self.add_state(c, "invalid");
288 }
289 }
290 }
291
292 fn control_props(owner: &LNode) -> Value {
294 let mut props = owner.props.clone();
295 for k in ["label", "description", "style", "checks", "error"] {
296 props.shift_remove(k);
297 }
298 Value::Object(props)
299 }
300
301 pub(crate) fn field(&mut self, index: u32) {
303 let owner = self.field_open(index);
304 let f = self.part(&owner, "field", &owner.component, NodeKind::Leaf, json!({"alignSelf": "stretch"}), Self::control_props(&owner));
305 self.nodes[f as usize].pressable = !bool_prop(&owner.props, "disabled");
306 self.field_close(&owner, Some(f));
307 }
308
309 fn trigger(&mut self, owner: &LNode, text: &str, placeholder: bool, glyph: &str) -> u32 {
311 let mut props = Self::control_props(owner);
312 props["text"] = json!(text);
313 props["placeholder"] = json!(placeholder);
314 props["icon"] = json!(super::builtin_icon(glyph).unwrap_or("ui-selector"));
315 let t = self.part(owner, "trigger", &owner.component, NodeKind::Leaf, json!({"alignSelf": "stretch"}), props);
316 let disabled = bool_prop(&owner.props, "disabled");
317 self.nodes[t as usize].pressable = !disabled;
318 self.nodes[t as usize].trigger_for = Some(owner.id.clone());
319 if self.is_open(&owner.id, &Map::new(), false) {
320 self.add_state(t, "open");
321 }
322 t
323 }
324
325 fn popup(&mut self, owner: &LNode, part: &str, anchor: u32, style: Value) -> (u32, u32) {
328 let (layer, root) = self.layer_root(owner, part, style);
329 let _ = anchor;
330 (layer, root)
331 }
332
333 pub(crate) fn select(&mut self, index: u32) {
336 let owner = self.field_open(index);
337 let options: Vec<Value> = owner.props.get("options").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
339 let multiple = bool_prop(&owner.props, "multiple");
340 let value = owner.props.get("value").map(js).unwrap_or_default();
341 let label_of = |v: &str| options.iter().find(|o| o.get("value").map(js).as_deref() == Some(v)).and_then(|o| o.get("label")).map(js);
342 let chosen: Vec<String> = if multiple { value.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect() } else { vec![value.clone()].into_iter().filter(|v| !v.is_empty() || label_of(v).is_some()).collect() };
345 let shown: Vec<String> = chosen.iter().map(|v| label_of(v).unwrap_or_else(|| v.clone())).collect();
346 let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| self.string("choose"));
347 let (text, is_placeholder) = if shown.is_empty() { (placeholder, true) } else { (shown.join(", "), false) };
348 let trigger = self.trigger(&owner, &text, is_placeholder, "Select.trigger");
349 self.field_close(&owner, Some(trigger));
350 if !self.is_open(&owner.id, &Map::new(), false) {
351 return;
352 }
353 let (layer, root) = self.popup(&owner, "content", trigger, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs", "padding": "$spacing.xs", "borderRadius": "$radius.md", "maxHeight": 320}));
354 let query = self.ctx.local.query.get(&owner.id).cloned().unwrap_or_default();
355 if bool_prop(&owner.props, "searchable") {
356 let search = self.part_in(root, &owner, "search", "Select", NodeKind::Leaf, json!({"alignSelf": "stretch", "flexShrink": 0}), json!({"placeholder": self.string("search"), "value": query, "icon": super::builtin_icon("Select.search")}), "");
357 self.nodes[search as usize].pressable = true;
358 }
359 let list = self.part_in(root, &owner, "list", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "flexShrink": 1, "minHeight": 0, "overflowY": "scroll"}), json!({}), "");
360 self.nodes[list as usize].part_query = None;
361 let needle = query.to_lowercase();
362 let mut shown_any = false;
363 for (i, option) in options.iter().enumerate() {
364 let label = option.get("label").map(js).unwrap_or_default();
365 if !needle.is_empty() && !label.to_lowercase().contains(&needle) {
366 continue;
367 }
368 shown_any = true;
369 let v = option.get("value").map(js).unwrap_or_default();
370 let selected = chosen.contains(&v);
371 let disabled = option.get("disabled").and_then(Value::as_bool).unwrap_or(false);
372 let mut props = json!({"text": label, "value": v, "lines": 1, "selected": selected, "disabled": disabled});
373 if let Some(icon) = option.get("icon") {
374 props["icon"] = icon.clone();
375 }
376 if selected {
377 props["check"] = json!(super::builtin_icon("Select.check"));
378 }
379 let item = self.part_in(list, &owner, "item", "Text", NodeKind::Leaf, json!({"display": "flex", "flexDirection": "row", "alignItems": "center"}), props, &format!(".{i}"));
380 self.nodes[item as usize].pressable = !disabled;
381 if selected {
382 self.add_state(item, "selected");
383 }
384 if disabled {
385 self.add_state(item, "disabled");
386 }
387 }
388 if !shown_any {
389 let empty = owner.props.get("emptyText").map(js).unwrap_or_else(|| self.string("noResults"));
390 self.text_part(list, &owner, "empty", &empty, "muted", "");
391 }
392 self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: trigger, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, true, Some(list));
393 }
394
395 pub(crate) fn date_label(&self, iso: &str) -> String {
398 match crate::format::format_date_value(self.ctx.formatter, &json!(iso), &crate::format::DateOptions::default()) {
399 s if s.is_empty() => iso.to_string(),
400 s => s,
401 }
402 }
403
404 pub(crate) fn date_pattern(&self, iso: &str, pattern: &str) -> String {
406 crate::format::format_date_value(self.ctx.formatter, &json!(iso), &crate::format::DateOptions { format: Some(pattern.to_string()), ..Default::default() })
407 }
408
409 pub(crate) fn date_picker(&mut self, index: u32) {
414 let owner = self.field_open(index);
415 let range = owner.component == "DateRangePicker";
416 let (start, end) = if range {
417 (owner.props.get("start").and_then(Value::as_str).unwrap_or("").to_string(), owner.props.get("end").and_then(Value::as_str).unwrap_or("").to_string())
418 } else {
419 (owner.props.get("value").and_then(Value::as_str).unwrap_or("").to_string(), String::new())
420 };
421 let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| self.string("choose"));
422 let text = match (range, start.is_empty()) {
423 (_, true) => placeholder.clone(),
424 (false, false) => self.date_label(&start),
425 (true, false) => format!("{} – {}", self.date_label(&start), if end.is_empty() { "…".to_string() } else { self.date_label(&end) }),
426 };
427 let glyph = format!("{}.trigger", owner.component);
428 let trigger = self.trigger(&owner, &text, start.is_empty(), &glyph);
429 self.field_close(&owner, Some(trigger));
430 if !self.is_open(&owner.id, &Map::new(), false) {
431 return;
432 }
433 let today = self.today();
434 let (y, m) = self.ctx.local.month.get(&owner.id).copied().or_else(|| dates::parse(&start).map(|(y, m, _)| (y, m))).or_else(|| today.as_deref().and_then(dates::parse).map(|(y, m, _)| (y, m))).or_else(|| owner.props.get("min").and_then(Value::as_str).and_then(dates::parse).map(|(y, m, _)| (y, m))).unwrap_or((2026, 1));
435 let first_dow = owner.props.get("firstDayOfWeek").and_then(Value::as_u64).map(|d| (d % 7) as u32).unwrap_or_else(|| crate::locale::week_start(self.ctx.locale) as u32);
436 let (layer, root) = self.popup(&owner, "calendar", trigger, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xs", "padding": "$spacing.sm", "borderRadius": "$radius.md"}));
437 let header = self.part_in(root, &owner, "header", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "justifyContent": "space-between", "gap": "$spacing.xs"}), json!({}), "");
438 self.nodes[header as usize].part_query = None;
439 let button = |b: &mut Self, parent: u32, part: &str, glyph: &str, label_id: &str| {
440 let label = b.string(label_id);
441 let n = b.part_in(parent, &owner, part, "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"icon": super::builtin_icon(glyph), "label": label, "variant": "ghost", "size": "icon"}), "");
442 b.nodes[n as usize].pressable = true;
443 b.nodes[n as usize].own_query = Some(RecipeQuery::new("Button", "root", b.ctx.recipes.native_recipe_props("Button", &b.nodes[n as usize].props.clone()), Vec::new()));
444 n
445 };
446 button(self, header, "previous", &format!("{}.previousMonth", "DatePicker"), "previousMonth");
447 let title = self.date_pattern(&dates::format(y, m, 1), "MMMM yyyy");
449 let t = self.part_in(header, &owner, "title", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "textAlign": "center"}), json!({"text": title, "variant": "label", "month": m, "year": y}), "");
450 self.nodes[t as usize].live = Some("polite".into());
451 button(self, header, "next", "DatePicker.nextMonth", "nextMonth");
452 let week_style = json!({"display": "flex", "flexDirection": "row", "gap": "$spacing.xxs"});
453 let cell_style = json!({"flexGrow": 1, "flexBasis": 0, "minWidth": 32, "height": 32, "textAlign": "center"});
454 let weekdays = self.part_in(root, &owner, "weekdays", "Box", NodeKind::Container, week_style.clone(), json!({}), "");
455 self.nodes[weekdays as usize].part_query = None;
456 for c in 0..7u32 {
457 let dow = (first_dow + c) % 7;
458 let name = self.date_pattern(&dates::format(2026, 1, 4 + dow), "EEE");
460 let w = self.part_in(weekdays, &owner, "weekday", "Text", NodeKind::Leaf, cell_style.clone(), json!({"text": name, "variant": "caption", "weekday": dow}), &format!(".{c}"));
461 self.nodes[w as usize].part_query = None;
462 }
463 let lead = (dates::weekday(y, m, 1) + 7 - first_dow) % 7;
464 let (sy, sm, sd) = dates::add_days(y, m, 1, -(lead as i32));
465 let min = owner.props.get("min").and_then(Value::as_str).unwrap_or("").to_string();
466 let max = owner.props.get("max").and_then(Value::as_str).unwrap_or("").to_string();
467 let anchor = self.ctx.local.range_anchor.get(&owner.id).cloned();
468 let (lo, hi) = match (&anchor, range) {
469 (Some(a), true) => (a.clone(), String::new()),
470 _ => (start.clone(), end.clone()),
471 };
472 for r in 0..6 {
473 let week = self.part_in(root, &owner, "week", "Box", NodeKind::Container, week_style.clone(), json!({}), &format!(".{r}"));
474 self.nodes[week as usize].part_query = None;
475 for c in 0..7 {
476 let (dy, dm, dd) = dates::add_days(sy, sm, sd, r * 7 + c);
477 let date = dates::format(dy, dm, dd);
478 let outside = dm != m;
479 let disabled = (!min.is_empty() && date < min) || (!max.is_empty() && date > max);
480 let is_start = !lo.is_empty() && date == lo;
481 let is_end = range && !hi.is_empty() && date == hi;
482 let in_range = range && !lo.is_empty() && !hi.is_empty() && date > lo && date < hi;
483 let selected = is_start || is_end;
484 let props = json!({"text": dd.to_string(), "date": date, "outside": outside, "disabled": disabled, "selected": selected, "inRange": in_range, "rangeStart": range && is_start, "rangeEnd": is_end, "today": today.as_deref() == Some(date.as_str())});
485 let day = self.part_in(week, &owner, "day", "Text", NodeKind::Leaf, cell_style.clone(), props, &format!(".{r}.{c}"));
486 let label = crate::format::format_date_value(self.ctx.formatter, &json!(date), &crate::format::DateOptions { style: Some(crate::format::DateStyle::Full), ..Default::default() });
489 self.default_a11y(day, "label", json!(label));
490 self.nodes[day as usize].pressable = !disabled;
491 if selected {
492 self.add_state(day, "selected");
493 }
494 if disabled {
495 self.add_state(day, "disabled");
496 }
497 if in_range {
498 self.query_prop(day, "inRange", Value::Bool(true));
499 }
500 }
501 }
502 self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: trigger, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, false, None);
503 }
504
505 fn today(&self) -> Option<String> {
507 self.ctx.today.clone()
508 }
509
510 pub(crate) fn time_picker(&mut self, index: u32) {
513 let owner = self.field_open(index);
514 let value = owner.props.get("value").and_then(Value::as_str).unwrap_or("").to_string();
515 let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| "--:--".to_string());
516 let trigger = self.trigger(&owner, if value.is_empty() { &placeholder } else { &value }, value.is_empty(), "TimePicker.trigger");
517 self.field_close(&owner, Some(trigger));
518 if !self.is_open(&owner.id, &Map::new(), false) {
519 return;
520 }
521 let step = owner.props.get("step").and_then(Value::as_f64).unwrap_or(15.0).max(1.0) as u32;
522 let min = owner.props.get("min").and_then(Value::as_str).and_then(parse_time).unwrap_or(0);
523 let max = owner.props.get("max").and_then(Value::as_str).and_then(parse_time).unwrap_or(23 * 60 + 59);
524 let (layer, root) = self.popup(&owner, "list", trigger, json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "maxHeight": 240, "overflowY": "scroll"}));
525 let mut t = min;
526 let mut i = 0;
527 while t <= max && i < 24 * 60 {
528 let label = format_time(t);
529 let selected = label == value;
530 let item = self.part_in(root, &owner, "item", "Text", NodeKind::Leaf, json!({}), json!({"text": label, "value": label, "lines": 1, "selected": selected}), &format!(".{i}"));
531 self.nodes[item as usize].pressable = true;
532 if selected {
533 self.add_state(item, "selected");
534 }
535 t += step;
536 i += 1;
537 }
538 self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: trigger, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, true, Some(root));
539 }
540
541 pub(crate) fn number_field(&mut self, index: u32) {
544 let owner = self.field_open(index);
545 let value = number_prop(&owner.props, "value");
546 let min = number_prop(&owner.props, "min");
547 let max = number_prop(&owner.props, "max");
548 let (_, precision) = number_step_precision(&owner.props);
549 let disabled = bool_prop(&owner.props, "disabled");
550 let field = self.part(&owner, "field", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "alignSelf": "stretch", "gap": "$spacing.xxs"}), json!({}));
551 let stepper = |b: &mut Self, part: &str, at_limit: bool| {
552 let label = b.string(part);
553 let glyph = format!("NumberField.{part}");
554 let n = b.part_in(field, &owner, part, "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"icon": super::builtin_icon(&glyph), "label": label, "variant": "ghost", "size": "icon", "disabled": disabled || at_limit}), "");
555 b.nodes[n as usize].pressable = !(disabled || at_limit);
556 if disabled || at_limit {
557 b.add_state(n, "disabled");
558 }
559 n
560 };
561 stepper(self, "decrement", value.zip(min).is_some_and(|(v, m)| v <= m));
562 let text = value.map(|v| self.ctx.formatter.number(v, crate::format::NumberOptions { decimals: Some(precision), grouping: true })).unwrap_or_default();
563 let mut props = Self::control_props(&owner);
564 props["text"] = json!(text);
565 let input = self.part_in(field, &owner, "input", "NumberField", NodeKind::Leaf, json!({"flexGrow": 1, "flexBasis": 0, "minWidth": 40}), props, "");
566 self.nodes[input as usize].pressable = !disabled;
567 if let Some(unit) = str_prop(&owner.props, "unit") {
568 self.text_part(field, &owner, "unit", unit, "muted", "");
569 }
570 stepper(self, "increment", value.zip(max).is_some_and(|(v, m)| v >= m));
571 self.field_close(&owner, Some(field));
572 }
573
574 pub(crate) fn chip_input(&mut self, index: u32) {
577 let owner = self.field_open(index);
578 let values: Vec<String> = owner.props.get("values").and_then(Value::as_array).map(|a| a.iter().map(js).collect()).unwrap_or_default();
579 let disabled = bool_prop(&owner.props, "disabled");
580 let field = self.part(&owner, "field", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "flexWrap": "wrap", "alignItems": "center", "alignSelf": "stretch", "gap": "$spacing.xs"}), json!({}));
581 for (i, v) in values.iter().enumerate() {
582 let suffix = format!(".{i}");
583 let chip = self.part_in(field, &owner, "chip", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.xxs", "flexShrink": 0}), json!({"value": v}), &suffix);
584 let _ = chip;
585 self.part_in(chip, &owner, "chipLabel", "Text", NodeKind::Leaf, json!({}), json!({"text": v, "lines": 1}), &suffix);
586 if !disabled {
587 let label = self.string_with("removeItem", &[("name", v)]);
588 let r = self.part_in(chip, &owner, "remove", "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": super::builtin_icon("ChipInput.remove"), "size": "sm", "label": label, "value": v}), &suffix);
589 self.nodes[r as usize].pressable = true;
590 }
591 }
592 let query = self.ctx.local.query.get(&owner.id).cloned().unwrap_or_default();
593 let at_max = owner.props.get("max").and_then(Value::as_f64).is_some_and(|m| values.len() as f64 >= m);
594 let placeholder = if values.is_empty() { str_prop(&owner.props, "placeholder").unwrap_or("").to_string() } else { String::new() };
595 let input = self.part_in(field, &owner, "input", "ChipInput", NodeKind::Leaf, json!({"flexGrow": 1, "flexBasis": 80, "minWidth": 80}), json!({"text": query, "placeholder": placeholder, "disabled": disabled || at_max}), "");
596 self.nodes[input as usize].pressable = !disabled;
597 self.field_close(&owner, Some(field));
598 let needle = query.trim().to_lowercase();
599 if needle.is_empty() || disabled || at_max {
600 return;
601 }
602 let suggestions: Vec<Value> = owner
603 .props
604 .get("suggestions")
605 .and_then(Value::as_array)
606 .map(|a| a.iter().filter(|o| o.get("label").map(js).is_some_and(|l| l.to_lowercase().contains(&needle)) && !values.contains(&o.get("value").map(js).unwrap_or_default())).cloned().collect())
607 .unwrap_or_default();
608 if suggestions.is_empty() {
609 return;
610 }
611 let (layer, root) = self.popup(&owner, "suggestions", field, json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "maxHeight": 240, "overflowY": "scroll"}));
612 for (i, o) in suggestions.iter().enumerate() {
613 let item = self.part_in(root, &owner, "suggestion", "Text", NodeKind::Leaf, json!({}), json!({"text": o.get("label").map(js).unwrap_or_default(), "value": o.get("value").map(js).unwrap_or_default(), "lines": 1}), &format!(".{i}"));
614 self.nodes[item as usize].pressable = true;
615 }
616 self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: field, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, true, Some(root));
617 }
618
619 pub(crate) fn file_upload(&mut self, index: u32) {
622 let owner = self.field_open(index);
623 let disabled = bool_prop(&owner.props, "disabled");
624 let zone = self.part(&owner, "dropzone", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "alignItems": "center", "gap": "$spacing.xs", "padding": "$spacing.md", "alignSelf": "stretch"}), json!({"accept": owner.props.get("accept").cloned().unwrap_or(Value::Null), "multiple": bool_prop(&owner.props, "multiple")}));
625 self.nodes[zone as usize].pressable = !disabled;
626 self.icon_part(zone, &owner, "icon", "FileUpload.icon", "");
627 let title = self.string("dropFiles");
628 let t = self.text_part(zone, &owner, "title", &title, "label", "");
629 self.style_default(t, &[("fontWeight", json!("$type.weight.medium"))]);
632 if let Some(hint) = str_prop(&owner.props, "hint") {
633 self.text_part(zone, &owner, "hint", hint, "muted", "");
634 }
635 let browse_label = self.string("browse");
636 let browse = self.part_in(zone, &owner, "browse", "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"label": browse_label, "variant": "outline", "size": "sm", "disabled": disabled}), "");
637 self.nodes[browse as usize].pressable = !disabled;
638 self.nodes[browse as usize].own_query = Some(RecipeQuery::new("Button", "root", self.ctx.recipes.native_recipe_props("Button", &self.nodes[browse as usize].props.clone()), Vec::new()));
639 let files = owner.props.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
640 for (i, f) in files.iter().enumerate() {
641 let suffix = format!(".{i}");
642 let name = f.get("name").map(js).unwrap_or_default();
643 let row = self.part_in(owner.index, &owner, "file", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm"}), json!({"name": name}), &suffix);
644 self.icon_part(row, &owner, "fileIcon", "FileUpload.file", &suffix);
645 self.part_in(row, &owner, "fileName", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0}), json!({"text": name, "lines": 1}), &suffix);
646 if let Some(size) = f.get("size").and_then(Value::as_f64) {
647 let meta = format_bytes_with(self.ctx.formatter, size);
648 self.text_part(row, &owner, "fileMeta", &meta, "muted", &suffix);
649 }
650 if !disabled {
651 let label = self.string_with("removeItem", &[("name", &name)]);
652 let r = self.part_in(row, &owner, "remove", "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": super::builtin_icon("FileUpload.remove"), "size": "sm", "label": label, "file": name}), &suffix);
653 self.nodes[r as usize].pressable = true;
654 }
655 }
656 self.field_close(&owner, Some(zone));
657 }
658
659 pub(crate) fn check(&mut self, index: u32) {
661 let owner = self.nodes[index as usize].clone();
662 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("center")), ("gap", json!("$spacing.sm"))]);
663 self.nodes[index as usize].kind = NodeKind::Container;
664 self.nodes[index as usize].pressable = !bool_prop(&owner.props, "disabled");
665 let control_part = if owner.component == "Switch" { "track" } else { "box" };
666 let checked = bool_prop(&owner.props, "checked");
667 let invalid = !self.field_errors(&owner).is_empty();
668 let control = |b: &mut Self, parent: u32| {
669 let c = b.part_in(parent, &owner, control_part, &owner.component, NodeKind::Leaf, json!({"flexShrink": 0}), json!({"checked": checked, "disabled": bool_prop(&owner.props, "disabled")}), "");
670 if checked {
671 b.add_state(c, "checked");
672 }
673 if invalid {
674 b.add_state(c, "invalid");
675 }
676 c
677 };
678 let text = |b: &mut Self, parent: u32| {
679 let has_desc = str_prop(&owner.props, "description").is_some();
680 if has_desc {
681 let col = b.part_in(parent, &owner, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs", "flexGrow": 1, "minWidth": 0}), json!({}), "");
682 if let Some(label) = str_prop(&owner.props, "label") {
683 b.text_part(col, &owner, "label", label, "body", "");
684 }
685 if let Some(d) = str_prop(&owner.props, "description") {
686 b.text_part(col, &owner, "description", d, "muted", "");
687 }
688 } else if let Some(label) = str_prop(&owner.props, "label") {
689 let l = b.text_part(parent, &owner, "label", label, "body", "");
690 b.nodes[l as usize].base_style.insert("flexGrow".into(), json!(1));
691 b.nodes[l as usize].base_style.insert("minWidth".into(), json!(0));
692 }
693 };
694 if owner.component == "Switch" {
695 text(self, index);
696 control(self, index);
697 } else {
698 control(self, index);
699 text(self, index);
700 }
701 let errors = self.field_errors(&owner);
702 if !errors.is_empty() {
703 let wrap_note = errors.join("\n");
704 let e = self.text_part(index, &owner, "error", &wrap_note, "caption", "");
705 self.nodes[e as usize].base_style.insert("flexBasis".into(), json!("100%"));
706 self.add_state(e, "invalid");
707 self.style_default(index, &[("flexWrap", json!("wrap"))]);
708 }
709 }
710
711 pub(crate) fn radio(&mut self, index: u32) {
712 let owner = self.nodes[index as usize].clone();
713 let horizontal = str_prop(&owner.props, "orientation") == Some("horizontal");
714 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column"))]);
716 self.nodes[index as usize].kind = NodeKind::Container;
717 if let Some(label) = str_prop(&owner.props, "label") {
718 self.text_part(index, &owner, "label", label, "label", "");
719 }
720 let items = self.part(&owner, "items", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": if horizontal { "row" } else { "column" }, "gap": "$spacing.sm", "flexWrap": "wrap"}), json!({}));
721 let value = owner.props.get("value").map(js);
722 let options: Vec<Value> = owner.props.get("options").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
724 for (i, option) in options.iter().enumerate() {
725 let v = option.get("value").map(js).unwrap_or_default();
726 let checked = value.as_deref() == Some(v.as_str());
727 let row = self.part_in(items, &owner, "item", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm"}), json!({"value": v, "checked": checked}), &format!(".{i}"));
731 let disabled = bool_prop(&owner.props, "disabled") || option.get("disabled").and_then(Value::as_bool).unwrap_or(false);
732 self.nodes[row as usize].pressable = !disabled;
733 self.nodes[row as usize].part_query = None;
734 let dot = self.part_in(row, &owner, "dot", "Radio", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"checked": checked}), &format!(".{i}"));
735 self.nodes[dot as usize].pressable = !disabled;
736 let mut circle = RecipeQuery::new("Radio", "item", self.ctx.recipes.native_recipe_props("Radio", &owner.props), Vec::new());
737 if checked {
738 circle.states.push("checked".into());
739 }
740 self.nodes[dot as usize].part_query = Some(circle);
741 let label = option.get("label").map(js).unwrap_or_default();
742 self.text_part(row, &owner, "label", &label, "body", &format!(".{i}"));
743 }
744 let errors = self.field_errors(&owner);
745 if !errors.is_empty() {
746 let e = self.text_part(index, &owner, "error", &errors.join("\n"), "caption", "");
747 self.add_state(e, "invalid");
748 }
749 }
750
751 pub(crate) fn choice_tiles(&mut self, index: u32) {
758 let owner = self.nodes[index as usize].clone();
759 let columns = owner.props.get("columns").and_then(Value::as_f64).filter(|c| c.is_finite()).unwrap_or(2.0).round().clamp(1.0, 4.0) as u32;
760 self.style_default(index, &[("display", json!("grid")), ("gridTemplateColumns", json!(format!("repeat({columns}, minmax(0, 1fr))"))), ("alignItems", json!("stretch"))]);
762 self.nodes[index as usize].kind = NodeKind::Container;
763 let picked = choice_values(&owner.props);
764 let options: Vec<Value> = owner.props.get("options").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
765 let pictures = options.iter().any(|o| o.get("image").and_then(Value::as_str).is_some_and(|s| !s.is_empty()));
766 let all_disabled = bool_prop(&owner.props, "disabled");
767 let invalid = !self.field_errors(&owner).is_empty();
768 for (i, option) in options.iter().enumerate() {
769 let suffix = format!(".{i}");
770 let v = option.get("value").map(js).unwrap_or_default();
771 let selected = picked.contains(&v);
772 let disabled = all_disabled || option.get("disabled").and_then(Value::as_bool).unwrap_or(false);
773 let label = option.get("label").map(js).unwrap_or_default();
774 let tile = self.part_in(index, &owner, "tile", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "minWidth": 0}), json!({"value": v, "label": label, "selected": selected, "disabled": disabled}), &suffix);
775 self.nodes[tile as usize].pressable = !disabled;
776 for (on, state) in [(selected, "selected"), (disabled, "disabled"), (invalid, "invalid")] {
777 if on {
778 self.add_state(tile, state);
779 }
780 }
781 if pictures {
782 let src = option.get("image").and_then(Value::as_str).filter(|s| !s.is_empty());
783 let mut props = json!({"alt": "", "fit": "cover", "aspectRatio": CHOICE_PICTURE_ASPECT});
784 if let Some(src) = src {
785 props["src"] = json!(src);
786 }
787 let pic = self.part_in(tile, &owner, "picture", "Image", NodeKind::Leaf, json!({"alignSelf": "stretch", "aspectRatio": CHOICE_PICTURE_ASPECT, "flexShrink": 0, "minWidth": 0}), props, &suffix);
788 let _ = pic;
789 }
790 self.text_part(tile, &owner, "label", &label, "body", &suffix);
791 if let Some(d) = option.get("description").map(js).filter(|d| !d.is_empty()) {
792 self.text_part(tile, &owner, "description", &d, "muted", &suffix);
793 }
794 }
795 let errors = self.field_errors(&owner);
796 if !errors.is_empty() {
797 let e = self.text_part(index, &owner, "error", &errors.join("\n"), "caption", "");
798 self.nodes[e as usize].base_style.insert("gridColumn".into(), json!("1 / -1"));
800 self.add_state(e, "invalid");
801 }
802 }
803
804 pub(crate) fn slider(&mut self, index: u32) {
805 let owner = self.nodes[index as usize].clone();
806 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("gap", json!("$spacing.xs"))]);
807 self.nodes[index as usize].kind = NodeKind::Container;
808 if owner.props.get("label").is_some() {
809 let head = self.part(&owner, "header", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "justifyContent": "space-between"}), json!({}));
810 if let Some(label) = str_prop(&owner.props, "label") {
811 self.text_part(head, &owner, "label", label, "label", "");
812 }
813 if let Some(v) = owner.props.get("value") {
814 let text = js(v);
815 self.text_part(head, &owner, "value", &text, "muted", "");
816 }
817 }
818 let mut props = owner.props.clone();
819 props.shift_remove("label");
820 let track = self.part_in(index, &owner, "track", "Slider", NodeKind::Leaf, json!({"alignSelf": "stretch"}), Value::Object(props), "");
821 self.nodes[track as usize].pressable = !bool_prop(&owner.props, "disabled");
822 }
823
824 pub(crate) fn form(&mut self, index: u32, node: &UiNode, scope: &str) {
827 self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("gap", json!("$spacing.md"))]);
828 let owner = self.nodes[index as usize].clone();
829 self.forms.insert(owner.id.clone(), (bool_prop(&owner.props, "disabled"), bool_prop(&owner.props, "busy")));
830 if bool_prop(&owner.props, "summary") {
831 let mut messages: Vec<String> = Vec::new();
832 for field in self.form_fields_of(node) {
833 if let Some(e) = self.ctx.local.errors.get(&field) {
834 messages.extend(e.messages.iter().cloned());
835 }
836 }
837 if !messages.is_empty() {
838 let s = self.text_part(index, &owner, "summary", &messages.join("\n"), "body", "");
839 self.nodes[s as usize].live = Some("assertive".into());
840 self.add_state(s, "invalid");
841 }
842 }
843 self.children_of(index, node, scope, false);
844 }
845
846 pub(crate) fn form_fields_of(&self, node: &UiNode) -> Vec<String> {
848 fn walk(n: &UiNode, out: &mut Vec<String>, top: bool) {
849 if !top && n.component == "Form" {
850 return;
851 }
852 if super::FORM_FIELDS.contains(&n.component.as_str()) && n.props.contains_key("name") {
853 out.push(n.id.clone());
854 }
855 for s in n.slots.iter().flat_map(|s| s.values()) {
856 walk(s, out, false);
857 }
858 for c in &n.children {
859 walk(c, out, false);
860 }
861 }
862 let mut out = Vec::new();
863 walk(node, &mut out, true);
864 out
865 }
866}
867
868pub const CHOICE_PICTURE_ASPECT: f64 = 1.6;
870
871pub fn choice_values(props: &serde_json::Map<String, Value>) -> Vec<String> {
874 let raw = props.get("value").filter(|v| !v.is_null()).map(js).unwrap_or_default();
875 if props.get("type").and_then(Value::as_str) == Some("multiple") {
876 raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()
877 } else if raw.is_empty() {
878 Vec::new()
879 } else {
880 vec![raw]
881 }
882}
883
884pub fn choice_toggle(props: &serde_json::Map<String, Value>, value: &str) -> String {
887 if props.get("type").and_then(Value::as_str) != Some("multiple") {
888 return value.to_string();
889 }
890 let mut picked = choice_values(props);
891 if let Some(i) = picked.iter().position(|v| v == value) {
892 picked.remove(i);
893 } else {
894 picked.push(value.to_string());
895 }
896 let order: Vec<String> = props.get("options").and_then(Value::as_array).map(|a| a.iter().filter_map(|o| o.get("value")).map(js).collect()).unwrap_or_default();
897 picked.sort_by_key(|v| order.iter().position(|o| o == v).unwrap_or(usize::MAX));
898 picked.join(",")
899}
900
901#[cfg(test)]
902mod tests {
903 use super::dates::*;
904 use super::*;
905
906 #[test]
907 fn numbers_round_like_to_fixed_and_intl() {
908 assert_eq!(to_fixed(2.5, 0), "3");
910 assert_eq!(to_fixed(-2.5, 0), "-3");
911 assert_eq!(to_fixed(0.125, 2), "0.13");
912 assert_eq!(to_fixed(1.005, 2), "1.00", "1.005 is 1.00499999…");
913 assert_eq!(to_fixed(0.1 + 0.2, 1), "0.3");
914 assert_eq!(to_fixed(9.95, 1), "9.9", "9.95 is 9.9499999…");
915 assert_eq!(to_fixed(99.5, 0), "100");
916 assert_eq!(to_fixed(-0.04, 1), "-0.0");
917 assert_eq!(to_fixed(1234.5678, 3), "1234.568");
918 assert_eq!(format_number_value(1.005, 2), "1.01");
920 assert_eq!(format_number_value(2.5, 0), "3");
921 assert_eq!(format_number_value(1234.55, 1), "1,234.6");
922 assert_eq!(format_number_value(0.30000000000000004, 1), "0.3");
923 assert_eq!(format_number_value(999_999.5, 0), "1,000,000");
924 assert_eq!(format_number_value(-1234.5, 0), "-1,235");
925 assert_eq!(decimals_of(0.1), 1);
927 assert_eq!(decimals_of(0.25), 2);
928 assert_eq!(decimals_of(1.0), 0);
929 assert_eq!(decimals_of(1e-7), 0, "String(1e-7) = \"1e-7\"");
930 assert_eq!(decimals_of(1.5e-7), 4, "String(1.5e-7) = \"1.5e-7\"");
931 assert_eq!(clamp_number_value(0.30000000000000004, None, None, 1), 0.3);
932 assert_eq!(clamp_number_value(2.5, Some(0.0), Some(10.0), 0), 3.0);
933 assert_eq!(clamp_number_value(42.0, Some(0.0), Some(10.0), 0), 10.0);
934 let props = |v: serde_json::Value| v.as_object().cloned().unwrap();
935 assert_eq!(number_step_precision(&props(json!({"step": 0.1}))), (0.1, 1));
936 assert_eq!(number_step_precision(&props(json!({"step": 0}))), (1.0, 0), "step || 1");
937 assert_eq!(number_step_precision(&props(json!({"step": 0.5, "precision": 3}))), (0.5, 3));
938 }
939
940 #[test]
941 fn calendar_arithmetic() {
942 assert_eq!(weekday(2026, 10, 7), 3, "a Wednesday");
943 assert_eq!(weekday(2000, 1, 1), 6);
944 assert_eq!(days_in_month(2024, 2), 29);
945 assert_eq!(days_in_month(2100, 2), 28);
946 assert_eq!(add_days(2026, 1, 1, -1), (2025, 12, 31));
947 assert_eq!(add_days(2026, 2, 27, 3), (2026, 3, 2));
948 assert_eq!(shift_month(2026, 12, 1), (2027, 1));
949 assert_eq!(parse("2026-02-30"), None);
950 assert_eq!(label("2026-10-07"), "Oct 7, 2026");
951 assert_eq!(parse_time("09:30"), Some(570));
952 assert_eq!(format_time(570), "09:30");
953 assert_eq!(format_number_value(2.5, 2), "2.50");
954 assert_eq!(format_bytes(48213.0), "47.1 kB");
955 assert_eq!(format_bytes(2048.0), "2 kB");
956 assert_eq!(format_bytes(500.0), "500 byte");
957 }
958}