Skip to main content

forge_ui/
runtime.rs

1use crate::*;
2use serde_json::{json, Value};
3use std::collections::{HashMap, HashSet};
4use unicode_segmentation::UnicodeSegmentation;
5
6#[derive(Clone, Debug)]
7pub enum Input {
8    Move(f32, f32),
9    Down,
10    Up,
11    Wheel(f32),
12    Text(String),
13    Key {
14        key: String,
15        shift: bool,
16        command: bool,
17    },
18    Cancel,
19}
20#[derive(Clone)]
21pub struct LayoutItem {
22    pub node: Node,
23    pub rect: Rect,
24    pub clip: Rect,
25    pub parent: Option<usize>,
26    pub scroll_max: f32,
27}
28#[derive(Clone, Default)]
29struct Edit {
30    cursor: usize,
31    anchor: usize,
32}
33impl Edit {
34    fn range(&self) -> std::ops::Range<usize> {
35        self.cursor.min(self.anchor)..self.cursor.max(self.anchor)
36    }
37    fn clamp(&mut self, text: &str) {
38        let boundaries = boundaries(text);
39        self.cursor = *boundaries
40            .iter()
41            .rfind(|&&i| i <= self.cursor.min(text.len()))
42            .unwrap_or(&0);
43        self.anchor = *boundaries
44            .iter()
45            .rfind(|&&i| i <= self.anchor.min(text.len()))
46            .unwrap_or(&0);
47    }
48}
49fn boundaries(s: &str) -> Vec<usize> {
50    s.grapheme_indices(true)
51        .map(|(i, _)| i)
52        .chain(std::iter::once(s.len()))
53        .collect()
54}
55/// Headless-capable layout and interaction runtime. Native and agent input share it.
56pub struct Ui {
57    root: Node,
58    pub items: Vec<LayoutItem>,
59    pub theme: Theme,
60    pub focus: Option<String>,
61    pub hover: Option<String>,
62    pressed: Option<String>,
63    pointer: (f32, f32),
64    edits: HashMap<String, Edit>,
65    scroll: HashMap<String, f32>,
66    viewport: Rect,
67    pub painter: Painter,
68    pub debug_bounds: bool,
69}
70impl Ui {
71    pub fn new(root: Node) -> Result<Self, String> {
72        validate(&root)?;
73        Ok(Self {
74            root,
75            items: vec![],
76            theme: Theme::default(),
77            focus: None,
78            hover: None,
79            pressed: None,
80            pointer: (-1.0, -1.0),
81            edits: HashMap::new(),
82            scroll: HashMap::new(),
83            viewport: Rect::default(),
84            painter: Painter::new(),
85            debug_bounds: false,
86        })
87    }
88    pub fn set_root(&mut self, root: Node) -> Result<(), String> {
89        validate(&root)?;
90        self.root = root;
91        self.layout();
92        let ids: HashSet<_> = self.items.iter().map(|i| i.node.id.clone()).collect();
93        self.edits.retain(|id, _| ids.contains(id));
94        self.scroll.retain(|id, _| ids.contains(id));
95        if self.focus.as_ref().is_some_and(|id| {
96            !self
97                .items
98                .iter()
99                .any(|i| &i.node.id == id && i.node.focusable())
100        }) {
101            self.focus = None;
102        }
103        if self.pressed.as_ref().is_some_and(|id| {
104            !self
105                .items
106                .iter()
107                .any(|i| &i.node.id == id && i.node.focusable())
108        }) {
109            self.pressed = None;
110        }
111        for item in &self.items {
112            if let Kind::TextInput { value, .. } = &item.node.kind {
113                if let Some(e) = self.edits.get_mut(&item.node.id) {
114                    e.clamp(value);
115                }
116            }
117        }
118        Ok(())
119    }
120    pub fn resize(&mut self, width: u32, height: u32, scale: f32) {
121        self.painter.resize(width, height, scale);
122        self.viewport = Rect::new(
123            0.0,
124            0.0,
125            width as f32 / self.painter.scale,
126            height as f32 / self.painter.scale,
127        );
128        self.layout();
129    }
130    fn natural(&self, n: &Node) -> (f32, f32) {
131        let p = n.style.padding * 2.0;
132        let size = match &n.kind {
133            Kind::Container { axis, .. } => {
134                let sizes: Vec<_> = n.children.iter().map(|c| self.natural(c)).collect();
135                let gap = n.style.gap * sizes.len().saturating_sub(1) as f32;
136                if *axis == Axis::Vertical {
137                    (
138                        sizes.iter().map(|s| s.0).fold(0.0, f32::max) + p,
139                        sizes.iter().map(|s| s.1).sum::<f32>() + gap + p,
140                    )
141                } else {
142                    (
143                        sizes.iter().map(|s| s.0).sum::<f32>() + gap + p,
144                        sizes.iter().map(|s| s.1).fold(0.0, f32::max) + p,
145                    )
146                }
147            }
148            Kind::Label => (
149                self.painter
150                    .measure(&n.label, n.style.text_size, n.style.bold)
151                    + p,
152                n.style.text_size * 1.5 + p,
153            ),
154            _ => (
155                self.painter
156                    .measure(&n.label, n.style.text_size, n.style.bold)
157                    + 40.0,
158                42.0,
159            ),
160        };
161        (
162            if let Length::Px(w) = n.style.width {
163                w
164            } else {
165                size.0
166            },
167            if let Length::Px(h) = n.style.height {
168                h
169            } else {
170                size.1
171            },
172        )
173    }
174    pub fn layout(&mut self) {
175        self.items.clear();
176        self.place(self.root.clone(), self.viewport, self.viewport, None, true);
177    }
178    fn place(&mut self, mut n: Node, r: Rect, clip: Rect, parent: Option<usize>, enabled: bool) {
179        n.enabled &= enabled;
180        let idx = self.items.len();
181        let ownclip = r.intersect(clip);
182        let inner = r.inset(n.style.padding);
183        self.items.push(LayoutItem {
184            node: n.clone(),
185            rect: r,
186            clip: ownclip,
187            parent,
188            scroll_max: 0.0,
189        });
190        if let Kind::Container { axis, scroll } = &n.kind {
191            let vertical = *axis == Axis::Vertical;
192            let available = if vertical { inner.h } else { inner.w };
193            let natural: Vec<_> = n.children.iter().map(|c| self.natural(c)).collect();
194            let fills = n
195                .children
196                .iter()
197                .filter(|c| {
198                    matches!(
199                        if vertical {
200                            c.style.height
201                        } else {
202                            c.style.width
203                        },
204                        Length::Fill
205                    )
206                })
207                .count();
208            let fixed: f32 = n
209                .children
210                .iter()
211                .zip(&natural)
212                .filter(|(c, _)| {
213                    !matches!(
214                        if vertical {
215                            c.style.height
216                        } else {
217                            c.style.width
218                        },
219                        Length::Fill
220                    )
221                })
222                .map(|(_, s)| if vertical { s.1 } else { s.0 })
223                .sum();
224            let gaps = n.style.gap * n.children.len().saturating_sub(1) as f32;
225            let flex = (available - fixed - gaps).max(0.0) / fills.max(1) as f32;
226            let total = fixed + gaps + flex * fills as f32;
227            let maxscroll = if *scroll {
228                (total - available).max(0.0)
229            } else {
230                0.0
231            };
232            let offset = if *scroll {
233                let o = self.scroll.entry(n.id.clone()).or_default();
234                *o = o.clamp(0.0, maxscroll);
235                *o
236            } else {
237                0.0
238            };
239            self.items[idx].scroll_max = maxscroll;
240            let mut pos = if vertical { inner.y } else { inner.x } - offset;
241            for (c, s) in n.children.iter().zip(natural) {
242                let main = if matches!(
243                    if vertical {
244                        c.style.height
245                    } else {
246                        c.style.width
247                    },
248                    Length::Fill
249                ) {
250                    flex
251                } else if vertical {
252                    s.1
253                } else {
254                    s.0
255                };
256                let cross = if vertical {
257                    match c.style.width {
258                        Length::Fill => inner.w,
259                        Length::Px(v) => v,
260                        Length::Auto => s.0,
261                    }
262                } else {
263                    match c.style.height {
264                        Length::Fill => inner.h,
265                        Length::Px(v) => v,
266                        Length::Auto => s.1,
267                    }
268                };
269                let cr = if vertical {
270                    Rect::new(inner.x, pos, cross, main)
271                } else {
272                    Rect::new(pos, inner.y, main, cross)
273                };
274                self.place(
275                    c.clone(),
276                    cr,
277                    ownclip.intersect(inner),
278                    Some(idx),
279                    n.enabled,
280                );
281                pos += main + n.style.gap;
282            }
283        }
284    }
285    fn hit(&self) -> Option<usize> {
286        self.items
287            .iter()
288            .rposition(|i| i.node.focusable() && i.clip.contains(self.pointer.0, self.pointer.1))
289    }
290    pub fn item(&self, id: &str) -> Option<&LayoutItem> {
291        self.items.iter().find(|i| i.node.id == id)
292    }
293    fn activate(n: &Node) -> Vec<Action> {
294        let kind = match &n.kind {
295            Kind::Button
296            | Kind::Custom {
297                interactive: true, ..
298            } => ActionKind::Activate,
299            Kind::Toggle(v) => ActionKind::Toggle(!v),
300            _ => return vec![],
301        };
302        vec![Action {
303            id: n.id.clone(),
304            kind,
305        }]
306    }
307    fn slider(n: &Node, v: f32) -> Vec<Action> {
308        if let Kind::Slider { min, max, step, .. } = n.kind {
309            let value = (min + ((v - min) / step).round() * step).clamp(min, max);
310            vec![Action {
311                id: n.id.clone(),
312                kind: ActionKind::ChangeNumber(value),
313            }]
314        } else {
315            vec![]
316        }
317    }
318    fn drag(&self, i: &LayoutItem) -> Vec<Action> {
319        if let Kind::Slider { min, max, .. } = i.node.kind {
320            let t =
321                ((self.pointer.0 - i.rect.x - 10.0) / (i.rect.w - 20.0).max(1.0)).clamp(0.0, 1.0);
322            Self::slider(&i.node, min + t * (max - min))
323        } else {
324            vec![]
325        }
326    }
327    fn set_focus(&mut self, id: Option<String>) {
328        self.focus = id;
329        if let Some(id) = self.focus.clone() {
330            if let Some(item) = self.item(&id) {
331                if let Kind::TextInput { value, .. } = &item.node.kind {
332                    let len = value.len();
333                    self.edits.entry(id.clone()).or_insert(Edit {
334                        cursor: len,
335                        anchor: len,
336                    });
337                }
338            }
339            if let Some(index) = self.items.iter().position(|i| i.node.id == id) {
340                let r = self.items[index].rect;
341                let mut parent = self.items[index].parent;
342                while let Some(p) = parent {
343                    let i = &self.items[p];
344                    if matches!(i.node.kind, Kind::Container { scroll: true, .. }) {
345                        let inner = i.rect.inset(i.node.style.padding);
346                        let delta = if r.y < inner.y {
347                            r.y - inner.y
348                        } else if r.y + r.h > inner.y + inner.h {
349                            r.y + r.h - inner.y - inner.h
350                        } else {
351                            0.0
352                        };
353                        *self.scroll.entry(i.node.id.clone()).or_default() += delta;
354                    }
355                    parent = i.parent;
356                }
357                self.layout();
358            }
359        }
360    }
361    fn pointer_edit(&mut self, item: &LayoutItem, extend: bool) -> bool {
362        let Kind::TextInput { value, .. } = &item.node.kind else {
363            return false;
364        };
365        let current = self.edits.get(&item.node.id).cloned().unwrap_or(Edit {
366            cursor: value.len(),
367            anchor: value.len(),
368        });
369        let size = item.node.style.text_size;
370        let caret = self.painter.measure(&value[..current.cursor], size, false);
371        let offset = (caret - (item.rect.w - 20.0) + 2.0).max(0.0);
372        let target = self.pointer.0 - item.rect.x - 10.0 + offset;
373        let index = boundaries(value)
374            .into_iter()
375            .min_by(|a, b| {
376                let da = (self.painter.measure(&value[..*a], size, false) - target).abs();
377                let db = (self.painter.measure(&value[..*b], size, false) - target).abs();
378                da.total_cmp(&db)
379            })
380            .unwrap_or(0);
381        let edit = self.edits.entry(item.node.id.clone()).or_default();
382        edit.cursor = index;
383        if !extend {
384            edit.anchor = index;
385        }
386        true
387    }
388    pub fn event(&mut self, input: Input) -> Vec<Action> {
389        match input {
390            Input::Move(x, y) => {
391                self.pointer = (x, y);
392                self.hover = self.hit().map(|i| self.items[i].node.id.clone());
393                if let Some(i) = self.pressed.as_ref().and_then(|id| self.item(id)).cloned() {
394                    if !self.pointer_edit(&i, true) {
395                        return self.drag(&i);
396                    }
397                }
398            }
399            Input::Down => {
400                let hit = self.hit();
401                self.pressed = hit.map(|i| self.items[i].node.id.clone());
402                self.set_focus(self.pressed.clone());
403                if let Some(i) = self.pressed.as_ref().and_then(|id| self.item(id)).cloned() {
404                    if !self.pointer_edit(&i, false) {
405                        return self.drag(&i);
406                    }
407                }
408            }
409            Input::Up => {
410                let pressed = self.pressed.take();
411                if let Some(i) = self.hit() {
412                    if pressed.as_ref() == Some(&self.items[i].node.id) {
413                        return Self::activate(&self.items[i].node);
414                    }
415                }
416            }
417            Input::Cancel => {
418                self.pressed = None;
419                self.hover = None;
420            }
421            Input::Wheel(delta) => {
422                if !delta.is_finite() {
423                    return vec![];
424                }
425                if let Some(i) = self.items.iter().rposition(|i| {
426                    i.clip.contains(self.pointer.0, self.pointer.1)
427                        && matches!(i.node.kind, Kind::Container { scroll: true, .. })
428                }) {
429                    let item = &self.items[i];
430                    let o = self.scroll.entry(item.node.id.clone()).or_default();
431                    *o = (*o + delta).clamp(0.0, item.scroll_max);
432                    self.layout();
433                }
434            }
435            Input::Text(text) => return self.edit_text(Some(text), "", false, false),
436            Input::Key {
437                key,
438                shift,
439                command,
440            } => {
441                if key == "Tab" {
442                    let ids: Vec<_> = self
443                        .items
444                        .iter()
445                        .filter(|i| i.node.focusable())
446                        .map(|i| i.node.id.clone())
447                        .collect();
448                    if !ids.is_empty() {
449                        let current = self
450                            .focus
451                            .as_ref()
452                            .and_then(|id| ids.iter().position(|v| v == id));
453                        let next = match current {
454                            None => {
455                                if shift {
456                                    ids.len() - 1
457                                } else {
458                                    0
459                                }
460                            }
461                            Some(i) => {
462                                if shift {
463                                    (i + ids.len() - 1) % ids.len()
464                                } else {
465                                    (i + 1) % ids.len()
466                                }
467                            }
468                        };
469                        self.set_focus(Some(ids[next].clone()));
470                    }
471                } else if let Some(n) = self
472                    .focus
473                    .as_ref()
474                    .and_then(|id| self.item(id))
475                    .map(|i| i.node.clone())
476                {
477                    match &n.kind {
478                        Kind::TextInput { .. } => {
479                            return self.edit_text(None, &key, shift, command)
480                        }
481                        Kind::Slider {
482                            value,
483                            min,
484                            max,
485                            step,
486                        } => {
487                            let v = match key.as_str() {
488                                "ArrowLeft" | "ArrowDown" => value - step,
489                                "ArrowRight" | "ArrowUp" => value + step,
490                                "Home" => *min,
491                                "End" => *max,
492                                _ => return vec![],
493                            };
494                            return Self::slider(&n, v);
495                        }
496                        _ => {
497                            if key == "Enter" || key == "Space" {
498                                return Self::activate(&n);
499                            }
500                        }
501                    }
502                }
503            }
504        }
505        vec![]
506    }
507    fn edit_text(
508        &mut self,
509        text: Option<String>,
510        key: &str,
511        shift: bool,
512        command: bool,
513    ) -> Vec<Action> {
514        let Some(id) = self.focus.clone() else {
515            return vec![];
516        };
517        let Some(item) = self.item(&id) else {
518            return vec![];
519        };
520        let Kind::TextInput { value, .. } = &item.node.kind else {
521            return vec![];
522        };
523        let mut value = value.clone();
524        let e = self.edits.entry(id.clone()).or_insert(Edit {
525            cursor: value.len(),
526            anchor: value.len(),
527        });
528        e.clamp(&value);
529        let original = value.clone();
530        let points = boundaries(&value);
531        let prev = *points.iter().rfind(|&&p| p < e.cursor).unwrap_or(&0);
532        let next = *points
533            .iter()
534            .find(|&&p| p > e.cursor)
535            .unwrap_or(&value.len());
536        if let Some(text) = text {
537            let text: String = text.chars().filter(|c| !c.is_control()).collect();
538            let range = e.range();
539            let candidate_len = value.len() - range.len() + text.len();
540            if candidate_len <= 16384 {
541                let start = range.start;
542                value.replace_range(range, &text);
543                e.cursor = start + text.len();
544                e.anchor = e.cursor;
545            }
546        } else {
547            match key {
548                "a" | "A" if command => {
549                    e.anchor = 0;
550                    e.cursor = value.len();
551                }
552                "ArrowLeft" => {
553                    e.cursor = if !shift && e.cursor != e.anchor {
554                        e.range().start
555                    } else {
556                        prev
557                    };
558                    if !shift {
559                        e.anchor = e.cursor;
560                    }
561                }
562                "ArrowRight" => {
563                    e.cursor = if !shift && e.cursor != e.anchor {
564                        e.range().end
565                    } else {
566                        next
567                    };
568                    if !shift {
569                        e.anchor = e.cursor;
570                    }
571                }
572                "Home" => {
573                    e.cursor = 0;
574                    if !shift {
575                        e.anchor = 0;
576                    }
577                }
578                "End" => {
579                    e.cursor = value.len();
580                    if !shift {
581                        e.anchor = e.cursor;
582                    }
583                }
584                "Backspace" | "Delete" => {
585                    let range = if e.cursor != e.anchor {
586                        e.range()
587                    } else if key == "Backspace" {
588                        prev..e.cursor
589                    } else {
590                        e.cursor..next
591                    };
592                    e.cursor = range.start;
593                    e.anchor = e.cursor;
594                    value.replace_range(range, "");
595                }
596                "Enter" => {
597                    return vec![Action {
598                        id,
599                        kind: ActionKind::Submit(value),
600                    }]
601                }
602                _ => {}
603            }
604        }
605        if value != original {
606            vec![Action {
607                id,
608                kind: ActionKind::ChangeText(value),
609            }]
610        } else {
611            vec![]
612        }
613    }
614    /// Validated agent action. `inspect`, screenshots and quit are handled by the window adapter.
615    pub fn agent_action(
616        &mut self,
617        op: &str,
618        id: &str,
619        text: Option<&str>,
620        value: Option<f32>,
621    ) -> Result<Vec<Action>, String> {
622        let item = self
623            .item(id)
624            .ok_or_else(|| format!("unknown widget: {id}"))?
625            .clone();
626        if !item.node.focusable() {
627            return Err(format!("widget is disabled or not actionable: {id}"));
628        }
629        match op {
630            "click"
631                if matches!(
632                    item.node.kind,
633                    Kind::Button
634                        | Kind::Toggle(_)
635                        | Kind::Custom {
636                            interactive: true,
637                            ..
638                        }
639                ) =>
640            {
641                self.set_focus(Some(id.into()));
642                Ok(Self::activate(&item.node))
643            }
644            "set_text" if matches!(item.node.kind, Kind::TextInput { .. }) => {
645                let text = text.ok_or("text is required")?;
646                if text.len() > 16384 || text.chars().any(char::is_control) {
647                    return Err("text must be a single line, at most 16384 bytes".into());
648                }
649                self.set_focus(Some(id.into()));
650                self.edit_text(None, "a", false, true);
651                Ok(self.edit_text(Some(text.into()), "", false, false))
652            }
653            "set_value" => {
654                let v = value.ok_or("value is required")?;
655                if let Kind::Slider { min, max, .. } = item.node.kind {
656                    if !v.is_finite() || v < min || v > max {
657                        return Err("value outside slider range".into());
658                    }
659                    self.set_focus(Some(id.into()));
660                    Ok(Self::slider(&item.node, v))
661                } else {
662                    Err("set_value requires a slider".into())
663                }
664            }
665            _ => Err(format!("{op} is not supported by {}", item.node.role())),
666        }
667    }
668    pub fn inspect(&self) -> Value {
669        json!({"protocol":"forge-ui/1","viewport":self.viewport,"scale":self.painter.scale,"focus":self.focus,"nodes":self.items.iter().map(|i|{
670            let n=&i.node;let (value,range)=match &n.kind{Kind::Toggle(v)=>(json!(v),Value::Null),Kind::Slider{value,min,max,step}=>(json!(value),json!({"min":min,"max":max,"step":step})),Kind::TextInput{value,..}=>(json!(value),Value::Null),_=>(Value::Null,Value::Null)};
671            let actions=if !n.focusable(){vec![]}else{match n.kind{Kind::TextInput{..}=>vec!["set_text","key"],Kind::Slider{..}=>vec!["set_value","key"],_=>vec!["click","key"]}};
672            json!({"id":n.id,"parent":i.parent.map(|p|self.items[p].node.id.as_str()),"role":n.role(),"label":n.label,"value":value,"range":range,"bounds":i.rect,"visible_bounds":i.clip,"visible":i.clip.w>0.0&&i.clip.h>0.0,"enabled":n.enabled,"focused":self.focus.as_ref()==Some(&n.id),"actions":actions,"scroll_offset":self.scroll.get(&n.id).copied().unwrap_or(0.0),"scroll_max":i.scroll_max})
673        }).collect::<Vec<_>>()})
674    }
675    pub fn paint(&mut self) {
676        self.painter.clear(self.theme.background);
677        let t = self.theme;
678        for item in &self.items {
679            let n = &item.node;
680            let r = item.rect;
681            if item.clip.w <= 0.0 || item.clip.h <= 0.0 {
682                continue;
683            }
684            self.painter.set_clip(item.clip);
685            let p = &mut self.painter;
686            let hovered = self.hover.as_ref() == Some(&n.id);
687            let focused = self.focus.as_ref() == Some(&n.id);
688            let down = self.pressed.as_ref() == Some(&n.id);
689            let fg = if n.enabled {
690                n.style.foreground.unwrap_or(t.text)
691            } else {
692                t.muted.mix(t.panel, 0.45)
693            };
694            if let Some(bg) = n.style.background {
695                p.rect(r, bg, n.style.radius);
696            }
697            if n.style.border {
698                p.outline(r, t.border);
699            }
700            match &n.kind {
701                Kind::Container { scroll, .. } => {
702                    if *scroll && item.scroll_max > 0.0 {
703                        let inner = r.inset(n.style.padding);
704                        let h = (inner.h * inner.h / (inner.h + item.scroll_max)).max(18.0);
705                        let offset = self.scroll.get(&n.id).copied().unwrap_or(0.0);
706                        p.rect(
707                            Rect::new(
708                                r.x + r.w - 4.0,
709                                inner.y + offset / item.scroll_max * (inner.h - h),
710                                3.0,
711                                h,
712                            ),
713                            t.border,
714                            1.0,
715                        );
716                    }
717                }
718                Kind::Label => p.text(
719                    &n.label,
720                    r.x + n.style.padding,
721                    r.y + n.style.padding,
722                    n.style.text_size,
723                    fg,
724                    n.style.bold,
725                ),
726                Kind::Button => {
727                    let bg =
728                        n.style
729                            .background
730                            .unwrap_or(if n.enabled { t.elevated } else { t.panel });
731                    p.rect(
732                        r,
733                        if down {
734                            bg.mix(t.accent, 0.35)
735                        } else if hovered {
736                            bg.mix(t.text, 0.09)
737                        } else {
738                            bg
739                        },
740                        n.style.radius,
741                    );
742                    let w = p.measure(&n.label, n.style.text_size, n.style.bold);
743                    p.text(
744                        &n.label,
745                        r.x + (r.w - w) / 2.0,
746                        r.y + (r.h - n.style.text_size * 1.3) / 2.0,
747                        n.style.text_size,
748                        fg,
749                        n.style.bold,
750                    );
751                }
752                Kind::Toggle(v) => {
753                    p.text(&n.label, r.x, r.y + 9.0, n.style.text_size, fg, false);
754                    let x = r.x + r.w - 46.0;
755                    p.rect(
756                        Rect::new(x, r.y + 8.0, 44.0, 24.0),
757                        if *v { t.accent } else { t.border },
758                        12.0,
759                    );
760                    p.rect(
761                        Rect::new(x + if *v { 23.0 } else { 3.0 }, r.y + 11.0, 18.0, 18.0),
762                        if *v { t.on_accent } else { t.text },
763                        9.0,
764                    );
765                }
766                Kind::Slider {
767                    value, min, max, ..
768                } => {
769                    p.text(&n.label, r.x, r.y, n.style.text_size, fg, false);
770                    let s = format!("{value:.0}");
771                    let w = p.measure(&s, 13.0, true);
772                    p.text(&s, r.x + r.w - w, r.y, 13.0, t.accent, true);
773                    let x = r.x + 10.0;
774                    let width = (r.w - 20.0).max(0.0);
775                    let frac = (value - min) / (max - min);
776                    p.rect(Rect::new(x, r.y + 37.0, width, 3.0), t.border, 1.0);
777                    p.rect(Rect::new(x, r.y + 37.0, width * frac, 3.0), t.accent, 1.0);
778                    p.rect(
779                        Rect::new(x + width * frac - 6.0, r.y + 32.0, 12.0, 12.0),
780                        t.accent,
781                        3.0,
782                    );
783                }
784                Kind::TextInput { value, placeholder } => {
785                    p.rect(r, t.background, n.style.radius);
786                    p.outline(r, if focused { t.accent } else { t.border });
787                    let area = r.inset(10.0);
788                    p.set_clip(item.clip.intersect(area));
789                    let e = self.edits.get(&n.id).cloned().unwrap_or(Edit {
790                        cursor: value.len(),
791                        anchor: value.len(),
792                    });
793                    let caret = p.measure(
794                        &value[..e.cursor.min(value.len())],
795                        n.style.text_size,
796                        false,
797                    );
798                    let off = if focused {
799                        (caret - area.w + 2.0).max(0.0)
800                    } else {
801                        0.0
802                    };
803                    if focused && e.cursor != e.anchor {
804                        let sel = e.range();
805                        let a = p.measure(&value[..sel.start], n.style.text_size, false);
806                        let b = p.measure(&value[..sel.end], n.style.text_size, false);
807                        p.rect(
808                            Rect::new(area.x + a - off, r.y + 9.0, b - a, 24.0),
809                            t.accent.mix(t.background, 0.7),
810                            0.0,
811                        );
812                    }
813                    p.text(
814                        if value.is_empty() { placeholder } else { value },
815                        area.x - off,
816                        r.y + 10.0,
817                        n.style.text_size,
818                        if value.is_empty() { t.muted } else { fg },
819                        false,
820                    );
821                    if focused {
822                        p.rect(
823                            Rect::new(area.x + caret - off, r.y + 10.0, 1.0, 22.0),
824                            t.accent,
825                            0.0,
826                        );
827                    }
828                    p.set_clip(item.clip);
829                }
830                Kind::Custom { paint, .. } => paint(p, r, &t),
831            }
832            p.set_clip(item.clip);
833            if focused {
834                p.outline(r.inset(1.0), t.accent);
835            }
836            if self.debug_bounds {
837                p.outline(r, t.success);
838            }
839        }
840    }
841}
842fn validate(root: &Node) -> Result<(), String> {
843    fn visit(n: &Node, ids: &mut HashSet<String>) -> Result<(), String> {
844        if n.id.is_empty() || !ids.insert(n.id.clone()) {
845            return Err(format!("empty or duplicate widget ID: {}", n.id));
846        }
847        let s = &n.style;
848        let mut numbers = vec![s.padding, s.gap, s.radius, s.text_size];
849        if let Length::Px(v) = s.width {
850            numbers.push(v);
851        }
852        if let Length::Px(v) = s.height {
853            numbers.push(v);
854        }
855        if numbers.iter().any(|v| !v.is_finite() || *v < 0.0) || s.text_size > 256.0 {
856            return Err(format!("invalid style: {}", n.id));
857        }
858        if let Kind::Slider {
859            value,
860            min,
861            max,
862            step,
863        } = &n.kind
864        {
865            if [value, min, max, step].iter().any(|v| !v.is_finite())
866                || min >= max
867                || *step <= 0.0
868                || value < min
869                || value > max
870            {
871                return Err(format!("invalid slider: {}", n.id));
872            }
873        }
874        if let Kind::TextInput { value, .. } = &n.kind {
875            if value.len() > 16384 || value.chars().any(char::is_control) {
876                return Err(format!("invalid single-line text: {}", n.id));
877            }
878        }
879        for c in &n.children {
880            visit(c, ids)?;
881        }
882        Ok(())
883    }
884    visit(root, &mut HashSet::new())
885}