Skip to main content

proof_engine/ui/
framework.rs

1//! Standalone UI framework — widget tree, event system, theming, and layout.
2//!
3//! Designed to be graphics-backend-agnostic: outputs a `DrawList` of
4//! `DrawCommand` primitives that the renderer can consume.
5//!
6//! # Architecture
7//! ```text
8//! UiContext → UiTree → UiNode hierarchy → layout → DrawList
9//!                          ↑
10//!             Widget impls (Button, Label, Slider, etc.)
11//! ```
12
13use std::collections::HashMap;
14
15// ── Geometry types ─────────────────────────────────────────────────────────
16
17#[derive(Debug, Clone, Copy, PartialEq, Default)]
18pub struct Rect {
19    pub x: f32,
20    pub y: f32,
21    pub w: f32,
22    pub h: f32,
23}
24
25impl Rect {
26    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self { Self { x, y, w, h } }
27    pub fn zero() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0 } }
28    pub fn contains(&self, px: f32, py: f32) -> bool {
29        px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
30    }
31    pub fn min_x(&self) -> f32 { self.x }
32    pub fn min_y(&self) -> f32 { self.y }
33    pub fn max_x(&self) -> f32 { self.x + self.w }
34    pub fn max_y(&self) -> f32 { self.y + self.h }
35    pub fn center(&self) -> (f32, f32) { (self.x + self.w * 0.5, self.y + self.h * 0.5) }
36    pub fn center_x(&self) -> f32 { self.x + self.w * 0.5 }
37    pub fn center_y(&self) -> f32 { self.y + self.h * 0.5 }
38    pub fn shrink(&self, margin: f32) -> Self {
39        Self { x: self.x + margin, y: self.y + margin, w: (self.w - margin * 2.0).max(0.0), h: (self.h - margin * 2.0).max(0.0) }
40    }
41    pub fn expand(&self, margin: f32) -> Self {
42        Self { x: self.x - margin, y: self.y - margin, w: self.w + margin * 2.0, h: self.h + margin * 2.0 }
43    }
44    pub fn translate(&self, dx: f32, dy: f32) -> Self {
45        Self { x: self.x + dx, y: self.y + dy, w: self.w, h: self.h }
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Default)]
50pub struct Color {
51    pub r: f32,
52    pub g: f32,
53    pub b: f32,
54    pub a: f32,
55}
56
57impl Color {
58    pub const WHITE:   Self = Self { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
59    pub const BLACK:   Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
60    pub const RED:     Self = Self { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
61    pub const GREEN:   Self = Self { r: 0.0, g: 1.0, b: 0.0, a: 1.0 };
62    pub const BLUE:    Self = Self { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
63    pub const YELLOW:  Self = Self { r: 1.0, g: 1.0, b: 0.0, a: 1.0 };
64    pub const CYAN:    Self = Self { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
65    pub const MAGENTA: Self = Self { r: 1.0, g: 0.0, b: 1.0, a: 1.0 };
66    pub const CLEAR:   Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
67
68    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r, g, b, a } }
69    pub fn with_alpha(mut self, a: f32) -> Self { self.a = a; self }
70
71    pub fn lerp(self, other: Self, t: f32) -> Self {
72        Self {
73            r: self.r + (other.r - self.r) * t,
74            g: self.g + (other.g - self.g) * t,
75            b: self.b + (other.b - self.b) * t,
76            a: self.a + (other.a - self.a) * t,
77        }
78    }
79
80    pub fn to_rgba_u8(self) -> [u8; 4] {
81        [
82            (self.r.clamp(0.0, 1.0) * 255.0) as u8,
83            (self.g.clamp(0.0, 1.0) * 255.0) as u8,
84            (self.b.clamp(0.0, 1.0) * 255.0) as u8,
85            (self.a.clamp(0.0, 1.0) * 255.0) as u8,
86        ]
87    }
88}
89
90// ── Theme ──────────────────────────────────────────────────────────────────
91
92#[derive(Debug, Clone)]
93pub struct Theme {
94    pub background:         Color,
95    pub surface:            Color,
96    pub surface_hover:      Color,
97    pub surface_pressed:    Color,
98    pub surface_disabled:   Color,
99    pub border:             Color,
100    pub border_focused:     Color,
101    pub text:               Color,
102    pub text_disabled:      Color,
103    pub text_hint:          Color,
104    pub accent:             Color,
105    pub accent_hover:       Color,
106    pub accent_pressed:     Color,
107    pub danger:             Color,
108    pub warning:            Color,
109    pub success:            Color,
110    pub shadow_color:       Color,
111    pub border_radius:      f32,
112    pub border_width:       f32,
113    pub font_size:          f32,
114    pub spacing:            f32,
115    pub padding:            f32,
116    pub animation_speed:    f32,
117}
118
119impl Theme {
120    pub fn dark() -> Self {
121        Self {
122            background:      Color::new(0.08, 0.08, 0.1,  1.0),
123            surface:         Color::new(0.14, 0.14, 0.18, 1.0),
124            surface_hover:   Color::new(0.2,  0.2,  0.26, 1.0),
125            surface_pressed: Color::new(0.1,  0.1,  0.14, 1.0),
126            surface_disabled:Color::new(0.1,  0.1,  0.12, 0.7),
127            border:          Color::new(0.3,  0.3,  0.4,  1.0),
128            border_focused:  Color::new(0.4,  0.6,  1.0,  1.0),
129            text:            Color::new(0.9,  0.9,  0.95, 1.0),
130            text_disabled:   Color::new(0.4,  0.4,  0.45, 1.0),
131            text_hint:       Color::new(0.5,  0.5,  0.55, 0.8),
132            accent:          Color::new(0.3,  0.5,  1.0,  1.0),
133            accent_hover:    Color::new(0.4,  0.6,  1.0,  1.0),
134            accent_pressed:  Color::new(0.2,  0.4,  0.9,  1.0),
135            danger:          Color::new(0.9,  0.2,  0.2,  1.0),
136            warning:         Color::new(1.0,  0.65, 0.0,  1.0),
137            success:         Color::new(0.2,  0.8,  0.3,  1.0),
138            shadow_color:    Color::new(0.0,  0.0,  0.0,  0.5),
139            border_radius:   4.0,
140            border_width:    1.0,
141            font_size:       14.0,
142            spacing:         8.0,
143            padding:         10.0,
144            animation_speed: 8.0,
145        }
146    }
147
148    pub fn light() -> Self {
149        Self {
150            background:      Color::new(0.95, 0.95, 0.97, 1.0),
151            surface:         Color::new(1.0,  1.0,  1.0,  1.0),
152            surface_hover:   Color::new(0.93, 0.93, 0.96, 1.0),
153            surface_pressed: Color::new(0.88, 0.88, 0.92, 1.0),
154            surface_disabled:Color::new(0.85, 0.85, 0.88, 0.7),
155            border:          Color::new(0.7,  0.7,  0.75, 1.0),
156            border_focused:  Color::new(0.2,  0.4,  0.9,  1.0),
157            text:            Color::new(0.1,  0.1,  0.12, 1.0),
158            text_disabled:   Color::new(0.5,  0.5,  0.55, 1.0),
159            text_hint:       Color::new(0.5,  0.5,  0.55, 0.8),
160            accent:          Color::new(0.2,  0.4,  0.9,  1.0),
161            accent_hover:    Color::new(0.25, 0.5,  1.0,  1.0),
162            accent_pressed:  Color::new(0.15, 0.35, 0.85, 1.0),
163            danger:          Color::new(0.85, 0.15, 0.15, 1.0),
164            warning:         Color::new(0.9,  0.55, 0.0,  1.0),
165            success:         Color::new(0.1,  0.7,  0.2,  1.0),
166            shadow_color:    Color::new(0.0,  0.0,  0.0,  0.15),
167            border_radius:   4.0,
168            border_width:    1.0,
169            font_size:       14.0,
170            spacing:         8.0,
171            padding:         10.0,
172            animation_speed: 10.0,
173        }
174    }
175
176    pub fn neon() -> Self {
177        let mut t = Self::dark();
178        t.accent       = Color::new(0.0,  1.0,  0.8,  1.0);
179        t.accent_hover = Color::new(0.2,  1.0,  0.9,  1.0);
180        t.border       = Color::new(0.0,  0.8,  0.6,  0.8);
181        t.border_focused = Color::new(0.0, 1.0,  0.8,  1.0);
182        t.background   = Color::new(0.03, 0.03, 0.05, 1.0);
183        t.surface      = Color::new(0.06, 0.08, 0.1,  1.0);
184        t
185    }
186}
187
188impl Default for Theme {
189    fn default() -> Self { Self::dark() }
190}
191
192// ── Draw Commands ──────────────────────────────────────────────────────────
193
194/// A single draw command for the renderer.
195#[derive(Debug, Clone)]
196pub enum DrawCommand {
197    Rect {
198        rect:    Rect,
199        color:   Color,
200        radius:  f32,
201    },
202    RectOutline {
203        rect:    Rect,
204        color:   Color,
205        width:   f32,
206        radius:  f32,
207    },
208    Text {
209        text:    String,
210        pos:     (f32, f32),
211        size:    f32,
212        color:   Color,
213        align:   TextAlign,
214    },
215    Image {
216        rect:    Rect,
217        image_id: u32,
218        tint:    Color,
219        uv_min:  (f32, f32),
220        uv_max:  (f32, f32),
221    },
222    Line {
223        from:    (f32, f32),
224        to:      (f32, f32),
225        color:   Color,
226        width:   f32,
227    },
228    Circle {
229        center:  (f32, f32),
230        radius:  f32,
231        color:   Color,
232    },
233    Clip {
234        rect:    Rect,
235    },
236    ClipEnd,
237    Shadow {
238        rect:    Rect,
239        color:   Color,
240        blur:    f32,
241        offset:  (f32, f32),
242    },
243}
244
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub enum TextAlign { Left, Center, Right }
247
248/// Output draw list for one frame of UI.
249#[derive(Debug, Clone, Default)]
250pub struct DrawList {
251    pub commands: Vec<DrawCommand>,
252    pub layers:   Vec<Vec<DrawCommand>>,
253}
254
255impl DrawList {
256    pub fn new() -> Self { Self::default() }
257
258    pub fn push(&mut self, cmd: DrawCommand) { self.commands.push(cmd); }
259
260    pub fn begin_layer(&mut self) { self.layers.push(Vec::new()); }
261
262    pub fn push_to_layer(&mut self, cmd: DrawCommand) {
263        if let Some(layer) = self.layers.last_mut() {
264            layer.push(cmd);
265        } else {
266            self.commands.push(cmd);
267        }
268    }
269
270    pub fn end_layer(&mut self) {
271        if let Some(layer) = self.layers.pop() {
272            self.commands.extend(layer);
273        }
274    }
275
276    pub fn total_commands(&self) -> usize {
277        self.commands.len() + self.layers.iter().map(|l| l.len()).sum::<usize>()
278    }
279
280    /// Clear for next frame.
281    pub fn clear(&mut self) {
282        self.commands.clear();
283        self.layers.clear();
284    }
285}
286
287// ── Widget ID ──────────────────────────────────────────────────────────────
288
289/// Stable identifier for a UI widget.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
291pub struct WidgetId(pub u64);
292
293impl WidgetId {
294    pub fn new(id: u64) -> Self { Self(id) }
295}
296
297// ── Events ─────────────────────────────────────────────────────────────────
298
299#[derive(Debug, Clone)]
300pub enum UiEvent {
301    Clicked     { id: WidgetId },
302    DoubleClick { id: WidgetId },
303    Hovered     { id: WidgetId },
304    HoverEnd    { id: WidgetId },
305    Focused     { id: WidgetId },
306    Blurred     { id: WidgetId },
307    ValueChanged { id: WidgetId, value: f32 },
308    TextChanged  { id: WidgetId, text: String },
309    SelectionChanged { id: WidgetId, index: usize },
310    DragStart   { id: WidgetId, pos: (f32, f32) },
311    DragEnd     { id: WidgetId, pos: (f32, f32) },
312    DragMove    { id: WidgetId, delta: (f32, f32) },
313    Scrolled    { id: WidgetId, delta: (f32, f32) },
314    KeyPressed  { id: WidgetId, key: u32 },
315}
316
317// ── Input state ────────────────────────────────────────────────────────────
318
319/// Input snapshot for one UI tick.
320#[derive(Debug, Clone, Default)]
321pub struct UiInput {
322    pub mouse_pos:    (f32, f32),
323    pub mouse_delta:  (f32, f32),
324    pub left_down:    bool,
325    pub left_just_pressed: bool,
326    pub left_just_released: bool,
327    pub right_just_pressed: bool,
328    pub scroll_delta: (f32, f32),
329    pub keys_pressed: Vec<u32>,
330    pub text_input:   String,
331}
332
333// ── Widget state ───────────────────────────────────────────────────────────
334
335#[derive(Debug, Clone, Default)]
336pub struct WidgetState {
337    pub hovered:      bool,
338    pub pressed:      bool,
339    pub focused:      bool,
340    pub hover_anim:   f32,  // [0, 1] animated hover
341    pub press_anim:   f32,  // [0, 1] animated press
342    pub alpha:        f32,  // fade animation
343    pub translate_y:  f32,  // slide animation
344}
345
346impl WidgetState {
347    pub fn new() -> Self { Self { alpha: 1.0, ..Default::default() } }
348
349    pub fn update(&mut self, hovered: bool, pressed: bool, dt: f32, speed: f32) {
350        self.hovered = hovered;
351        self.pressed = pressed;
352        let target_hover = if hovered { 1.0 } else { 0.0 };
353        let target_press = if pressed { 1.0 } else { 0.0 };
354        self.hover_anim += (target_hover - self.hover_anim) * (speed * dt).min(1.0);
355        self.press_anim += (target_press - self.press_anim) * (speed * 2.0 * dt).min(1.0);
356    }
357}
358
359// ── Constraint / Layout ────────────────────────────────────────────────────
360
361#[derive(Debug, Clone, Copy, PartialEq)]
362pub enum SizeConstraint {
363    Fixed(f32),
364    Fill,
365    Hug,
366    MinMax { min: f32, max: f32 },
367}
368
369#[derive(Debug, Clone, Copy, PartialEq)]
370pub enum FlexDirection { Row, Column }
371
372#[derive(Debug, Clone, Copy, PartialEq)]
373pub enum JustifyContent { Start, End, Center, SpaceBetween, SpaceAround }
374
375#[derive(Debug, Clone, Copy, PartialEq)]
376pub enum AlignItems { Start, End, Center, Stretch }
377
378#[derive(Debug, Clone)]
379pub struct FlexLayout {
380    pub direction:     FlexDirection,
381    pub justify:       JustifyContent,
382    pub align:         AlignItems,
383    pub gap:           f32,
384    pub padding:       f32,
385    pub wrap:          bool,
386}
387
388impl FlexLayout {
389    pub fn column() -> Self {
390        Self { direction: FlexDirection::Column, justify: JustifyContent::Start,
391               align: AlignItems::Stretch, gap: 4.0, padding: 8.0, wrap: false }
392    }
393
394    pub fn row() -> Self {
395        Self { direction: FlexDirection::Row, justify: JustifyContent::Start,
396               align: AlignItems::Center, gap: 8.0, padding: 4.0, wrap: false }
397    }
398
399    /// Compute child rects within a parent rect.
400    pub fn compute(&self, parent: Rect, children: &[(SizeConstraint, SizeConstraint)]) -> Vec<Rect> {
401        let inner = parent.shrink(self.padding);
402        let n = children.len();
403        if n == 0 { return Vec::new(); }
404
405        let is_row = self.direction == FlexDirection::Row;
406        let main_size = if is_row { inner.w } else { inner.h };
407        let cross_size = if is_row { inner.h } else { inner.w };
408        let total_gap = if n > 1 { self.gap * (n - 1) as f32 } else { 0.0 };
409
410        // Count fill items
411        let fixed_total: f32 = children.iter().map(|(w, h)| {
412            let c = if is_row { w } else { h };
413            match c { SizeConstraint::Fixed(v) => *v, _ => 0.0 }
414        }).sum();
415        let fill_count = children.iter().filter(|(w, h)| {
416            matches!(if is_row { w } else { h }, SizeConstraint::Fill)
417        }).count();
418        let fill_size = if fill_count > 0 {
419            ((main_size - fixed_total - total_gap) / fill_count as f32).max(0.0)
420        } else { 0.0 };
421
422        let mut out = Vec::with_capacity(n);
423        let mut cursor = if is_row { inner.x } else { inner.y };
424
425        for (i, (w_c, h_c)) in children.iter().enumerate() {
426            let (main_c, cross_c) = if is_row { (w_c, h_c) } else { (h_c, w_c) };
427            let size = match main_c {
428                SizeConstraint::Fixed(v) => *v,
429                SizeConstraint::Fill     => fill_size,
430                SizeConstraint::Hug      => 20.0, // default hug
431                SizeConstraint::MinMax { min, max } => fill_size.clamp(*min, *max),
432            };
433            let cross = match cross_c {
434                SizeConstraint::Fixed(v) => *v,
435                SizeConstraint::Fill => cross_size,
436                SizeConstraint::Hug => 20.0,
437                SizeConstraint::MinMax { min, max } => cross_size.clamp(*min, *max),
438            };
439            let (x, y, w, h) = if is_row {
440                (cursor, inner.y, size, cross)
441            } else {
442                (inner.x, cursor, cross, size)
443            };
444            out.push(Rect::new(x, y, w, h));
445            cursor += size + if i + 1 < n { self.gap } else { 0.0 };
446            let _ = i;
447        }
448        out
449    }
450}
451
452
453// ── Widget trait ───────────────────────────────────────────────────────────
454
455/// A UI widget that can draw itself and handle input.
456pub trait Widget: std::fmt::Debug + Send + Sync {
457    fn id(&self) -> WidgetId;
458    fn rect(&self) -> Rect;
459    fn set_rect(&mut self, rect: Rect);
460    fn draw(&self, dl: &mut DrawList, theme: &Theme);
461    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32);
462    fn is_visible(&self) -> bool { true }
463    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
464        (SizeConstraint::Hug, SizeConstraint::Hug)
465    }
466    fn update(&mut self, _dt: f32) {}
467}
468
469// ── Label widget ───────────────────────────────────────────────────────────
470
471#[derive(Debug)]
472pub struct Label {
473    pub id:     WidgetId,
474    pub rect:   Rect,
475    pub text:   String,
476    pub size:   f32,
477    pub color:  Option<Color>,
478    pub align:  TextAlign,
479    pub visible: bool,
480}
481
482impl Label {
483    pub fn new(id: WidgetId, text: &str) -> Self {
484        Self { id, rect: Rect::zero(), text: text.to_string(), size: 14.0,
485               color: None, align: TextAlign::Left, visible: true }
486    }
487
488    pub fn with_size(mut self, s: f32) -> Self { self.size = s; self }
489    pub fn with_align(mut self, a: TextAlign) -> Self { self.align = a; self }
490    pub fn with_color(mut self, c: Color) -> Self { self.color = Some(c); self }
491}
492
493impl Widget for Label {
494    fn id(&self) -> WidgetId { self.id }
495    fn rect(&self) -> Rect { self.rect }
496    fn set_rect(&mut self, r: Rect) { self.rect = r; }
497    fn is_visible(&self) -> bool { self.visible }
498    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
499        (SizeConstraint::Hug, SizeConstraint::Fixed(self.size + 4.0))
500    }
501
502    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
503        if !self.visible { return; }
504        let (cx, cy) = self.rect.center();
505        let x = match self.align {
506            TextAlign::Left   => self.rect.x,
507            TextAlign::Center => cx,
508            TextAlign::Right  => self.rect.max_x(),
509        };
510        dl.push(DrawCommand::Text {
511            text:  self.text.clone(),
512            pos:   (x, cy - self.size * 0.5),
513            size:  self.size,
514            color: self.color.unwrap_or(theme.text),
515            align: self.align,
516        });
517    }
518
519    fn handle_input(&mut self, _input: &UiInput, _events: &mut Vec<UiEvent>, _theme: &Theme, _dt: f32) {}
520}
521
522// ── Button widget ──────────────────────────────────────────────────────────
523
524#[derive(Debug)]
525pub struct Button {
526    pub id:      WidgetId,
527    pub rect:    Rect,
528    pub label:   String,
529    pub enabled: bool,
530    pub visible: bool,
531    state:       WidgetState,
532}
533
534impl Button {
535    pub fn new(id: WidgetId, label: &str) -> Self {
536        Self { id, rect: Rect::zero(), label: label.to_string(), enabled: true,
537               visible: true, state: WidgetState::new() }
538    }
539
540    pub fn is_hovered(&self) -> bool { self.state.hovered }
541}
542
543impl Widget for Button {
544    fn id(&self) -> WidgetId { self.id }
545    fn rect(&self) -> Rect { self.rect }
546    fn set_rect(&mut self, r: Rect) { self.rect = r; }
547    fn is_visible(&self) -> bool { self.visible }
548    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
549        (SizeConstraint::Hug, SizeConstraint::Fixed(32.0))
550    }
551
552    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
553        if !self.visible { return; }
554        let bg = if !self.enabled {
555            theme.surface_disabled
556        } else if self.state.press_anim > 0.1 {
557            theme.accent_pressed.lerp(theme.accent, self.state.press_anim)
558        } else {
559            theme.accent.lerp(theme.accent_hover, self.state.hover_anim)
560        };
561        let border = if self.state.focused { theme.border_focused } else { theme.border };
562
563        dl.push(DrawCommand::Shadow {
564            rect: self.rect.expand(2.0),
565            color: theme.shadow_color.with_alpha(0.3 * self.state.hover_anim),
566            blur: 6.0,
567            offset: (0.0, 2.0),
568        });
569        dl.push(DrawCommand::Rect { rect: self.rect, color: bg, radius: theme.border_radius });
570        dl.push(DrawCommand::RectOutline { rect: self.rect, color: border, width: theme.border_width, radius: theme.border_radius });
571        let txt_color = if self.enabled { Color::WHITE } else { theme.text_disabled };
572        let (cx, cy) = self.rect.center();
573        dl.push(DrawCommand::Text {
574            text: self.label.clone(), pos: (cx, cy - theme.font_size * 0.5),
575            size: theme.font_size, color: txt_color, align: TextAlign::Center,
576        });
577    }
578
579    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32) {
580        if !self.visible || !self.enabled { return; }
581        let hovered = self.rect.contains(input.mouse_pos.0, input.mouse_pos.1);
582        let pressed = hovered && input.left_down;
583        self.state.update(hovered, pressed, dt, theme.animation_speed);
584
585        if hovered && input.left_just_pressed {
586            events.push(UiEvent::Clicked { id: self.id });
587        }
588        if hovered && !self.state.hovered {
589            events.push(UiEvent::Hovered { id: self.id });
590        }
591    }
592}
593
594// ── Slider widget ──────────────────────────────────────────────────────────
595
596#[derive(Debug)]
597pub struct Slider {
598    pub id:       WidgetId,
599    pub rect:     Rect,
600    pub value:    f32,
601    pub min:      f32,
602    pub max:      f32,
603    pub step:     Option<f32>,
604    pub label:    Option<String>,
605    pub enabled:  bool,
606    pub visible:  bool,
607    state:        WidgetState,
608    dragging:     bool,
609}
610
611impl Slider {
612    pub fn new(id: WidgetId, min: f32, max: f32) -> Self {
613        Self { id, rect: Rect::zero(), value: min, min, max, step: None,
614               label: None, enabled: true, visible: true,
615               state: WidgetState::new(), dragging: false }
616    }
617
618    pub fn with_value(mut self, v: f32) -> Self { self.value = v; self }
619    pub fn with_step(mut self, s: f32) -> Self { self.step = Some(s); self }
620    pub fn with_label(mut self, l: &str) -> Self { self.label = Some(l.to_string()); self }
621
622    fn normalized(&self) -> f32 {
623        if (self.max - self.min).abs() < 1e-6 { 0.0 }
624        else { (self.value - self.min) / (self.max - self.min) }
625    }
626}
627
628impl Widget for Slider {
629    fn id(&self) -> WidgetId { self.id }
630    fn rect(&self) -> Rect { self.rect }
631    fn set_rect(&mut self, r: Rect) { self.rect = r; }
632    fn is_visible(&self) -> bool { self.visible }
633    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
634        (SizeConstraint::Fill, SizeConstraint::Fixed(28.0))
635    }
636
637    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
638        if !self.visible { return; }
639        let track_h = 4.0;
640        let track_y = self.rect.y + self.rect.h * 0.5 - track_h * 0.5;
641        let track = Rect::new(self.rect.x, track_y, self.rect.w, track_h);
642
643        // Track background
644        dl.push(DrawCommand::Rect { rect: track, color: theme.surface, radius: track_h * 0.5 });
645
646        // Filled portion
647        let fill_w = track.w * self.normalized();
648        if fill_w > 0.0 {
649            let fill = Rect::new(track.x, track.y, fill_w, track.h);
650            let color = if self.enabled { theme.accent.lerp(theme.accent_hover, self.state.hover_anim) } else { theme.text_disabled };
651            dl.push(DrawCommand::Rect { rect: fill, color, radius: track_h * 0.5 });
652        }
653
654        // Thumb
655        let thumb_r = 8.0;
656        let thumb_x = self.rect.x + self.rect.w * self.normalized();
657        let thumb_cy = self.rect.y + self.rect.h * 0.5;
658        let thumb_color = if self.enabled { Color::WHITE } else { theme.text_disabled };
659        dl.push(DrawCommand::Circle { center: (thumb_x, thumb_cy), radius: thumb_r + self.state.hover_anim * 2.0, color: thumb_color });
660        dl.push(DrawCommand::Circle { center: (thumb_x, thumb_cy), radius: thumb_r * 0.5, color: theme.accent });
661
662        // Label + value
663        if let Some(ref lbl) = self.label {
664            dl.push(DrawCommand::Text {
665                text: format!("{}: {:.2}", lbl, self.value),
666                pos: (self.rect.x, self.rect.y - theme.font_size),
667                size: theme.font_size * 0.85, color: theme.text_hint, align: TextAlign::Left,
668            });
669        }
670    }
671
672    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32) {
673        if !self.visible || !self.enabled { return; }
674        let hovered = self.rect.contains(input.mouse_pos.0, input.mouse_pos.1);
675
676        if hovered && input.left_just_pressed { self.dragging = true; }
677        if input.left_just_released { self.dragging = false; }
678
679        if self.dragging {
680            let t = ((input.mouse_pos.0 - self.rect.x) / self.rect.w.max(1e-6)).clamp(0.0, 1.0);
681            let mut new_val = self.min + t * (self.max - self.min);
682            if let Some(step) = self.step {
683                new_val = (new_val / step).round() * step;
684            }
685            if (new_val - self.value).abs() > 1e-5 {
686                self.value = new_val;
687                events.push(UiEvent::ValueChanged { id: self.id, value: self.value });
688            }
689        }
690
691        self.state.update(hovered || self.dragging, self.dragging, dt, theme.animation_speed);
692    }
693}
694
695// ── Checkbox widget ────────────────────────────────────────────────────────
696
697#[derive(Debug)]
698pub struct Checkbox {
699    pub id:      WidgetId,
700    pub rect:    Rect,
701    pub checked: bool,
702    pub label:   String,
703    pub enabled: bool,
704    pub visible: bool,
705    state:       WidgetState,
706    check_anim:  f32,
707}
708
709impl Checkbox {
710    pub fn new(id: WidgetId, label: &str) -> Self {
711        Self { id, rect: Rect::zero(), checked: false, label: label.to_string(),
712               enabled: true, visible: true, state: WidgetState::new(), check_anim: 0.0 }
713    }
714}
715
716impl Widget for Checkbox {
717    fn id(&self) -> WidgetId { self.id }
718    fn rect(&self) -> Rect { self.rect }
719    fn set_rect(&mut self, r: Rect) { self.rect = r; }
720    fn is_visible(&self) -> bool { self.visible }
721    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
722        (SizeConstraint::Hug, SizeConstraint::Fixed(24.0))
723    }
724
725    fn update(&mut self, dt: f32) {
726        let target = if self.checked { 1.0 } else { 0.0 };
727        self.check_anim += (target - self.check_anim) * (12.0 * dt).min(1.0);
728    }
729
730    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
731        if !self.visible { return; }
732        let box_size = 18.0;
733        let box_rect = Rect::new(self.rect.x, self.rect.y + (self.rect.h - box_size) * 0.5, box_size, box_size);
734
735        let bg = if self.check_anim > 0.1 {
736            theme.accent.lerp(theme.accent_hover, self.state.hover_anim)
737        } else {
738            theme.surface.lerp(theme.surface_hover, self.state.hover_anim)
739        };
740        dl.push(DrawCommand::Rect { rect: box_rect, color: bg, radius: 3.0 });
741        dl.push(DrawCommand::RectOutline { rect: box_rect, color: theme.border, width: 1.5, radius: 3.0 });
742
743        if self.check_anim > 0.05 {
744            // Checkmark as lines
745            let (cx, cy) = box_rect.center();
746            let a = self.check_anim;
747            dl.push(DrawCommand::Line {
748                from: (cx - 4.0 * a, cy),
749                to: (cx - 1.0 * a, cy + 3.0 * a),
750                color: Color::WHITE.with_alpha(a),
751                width: 2.0,
752            });
753            dl.push(DrawCommand::Line {
754                from: (cx - 1.0 * a, cy + 3.0 * a),
755                to: (cx + 5.0 * a, cy - 4.0 * a),
756                color: Color::WHITE.with_alpha(a),
757                width: 2.0,
758            });
759        }
760
761        let text_color = if self.enabled { theme.text } else { theme.text_disabled };
762        dl.push(DrawCommand::Text {
763            text: self.label.clone(),
764            pos: (box_rect.max_x() + 8.0, box_rect.y),
765            size: theme.font_size, color: text_color, align: TextAlign::Left,
766        });
767    }
768
769    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32) {
770        if !self.visible || !self.enabled { return; }
771        let hovered = self.rect.contains(input.mouse_pos.0, input.mouse_pos.1);
772        self.state.update(hovered, hovered && input.left_down, dt, theme.animation_speed);
773        if hovered && input.left_just_pressed {
774            self.checked = !self.checked;
775            events.push(UiEvent::ValueChanged { id: self.id, value: if self.checked { 1.0 } else { 0.0 } });
776        }
777    }
778}
779
780// ── TextInput widget ───────────────────────────────────────────────────────
781
782#[derive(Debug)]
783pub struct TextInput {
784    pub id:          WidgetId,
785    pub rect:        Rect,
786    pub text:        String,
787    pub placeholder: String,
788    pub max_len:     Option<usize>,
789    pub password:    bool,
790    pub enabled:     bool,
791    pub visible:     bool,
792    state:           WidgetState,
793    cursor_pos:      usize,
794    cursor_blink:    f32,
795}
796
797impl TextInput {
798    pub fn new(id: WidgetId) -> Self {
799        Self { id, rect: Rect::zero(), text: String::new(),
800               placeholder: String::new(), max_len: None, password: false,
801               enabled: true, visible: true, state: WidgetState::new(),
802               cursor_pos: 0, cursor_blink: 0.0 }
803    }
804
805    pub fn with_placeholder(mut self, p: &str) -> Self { self.placeholder = p.to_string(); self }
806    pub fn with_max_len(mut self, n: usize) -> Self { self.max_len = Some(n); self }
807    pub fn as_password(mut self) -> Self { self.password = true; self }
808}
809
810impl Widget for TextInput {
811    fn id(&self) -> WidgetId { self.id }
812    fn rect(&self) -> Rect { self.rect }
813    fn set_rect(&mut self, r: Rect) { self.rect = r; }
814    fn is_visible(&self) -> bool { self.visible }
815    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
816        (SizeConstraint::Fill, SizeConstraint::Fixed(36.0))
817    }
818
819    fn update(&mut self, dt: f32) {
820        if self.state.focused {
821            self.cursor_blink = (self.cursor_blink + dt * 2.0) % 2.0;
822        }
823    }
824
825    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
826        if !self.visible { return; }
827        let border = if self.state.focused { theme.border_focused } else { theme.border };
828        let bg = if self.state.focused { theme.surface_hover } else { theme.surface };
829        dl.push(DrawCommand::Rect { rect: self.rect, color: bg, radius: theme.border_radius });
830        dl.push(DrawCommand::RectOutline { rect: self.rect, color: border, width: theme.border_width, radius: theme.border_radius });
831
832        let text_rect = self.rect.shrink(theme.padding * 0.5);
833        let display = if self.text.is_empty() {
834            (true, self.placeholder.clone())
835        } else if self.password {
836            (false, "•".repeat(self.text.len()))
837        } else {
838            (false, self.text.clone())
839        };
840
841        let text_color = if display.0 { theme.text_hint } else { theme.text };
842        dl.push(DrawCommand::Text {
843            text: display.1, pos: (text_rect.x, text_rect.y),
844            size: theme.font_size, color: text_color, align: TextAlign::Left,
845        });
846
847        // Cursor
848        if self.state.focused && self.cursor_blink < 1.0 {
849            let cursor_x = text_rect.x + self.cursor_pos as f32 * theme.font_size * 0.6;
850            dl.push(DrawCommand::Line {
851                from: (cursor_x, text_rect.y),
852                to:   (cursor_x, text_rect.y + theme.font_size + 2.0),
853                color: theme.accent,
854                width: 1.5,
855            });
856        }
857    }
858
859    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32) {
860        if !self.visible || !self.enabled { return; }
861        let hovered = self.rect.contains(input.mouse_pos.0, input.mouse_pos.1);
862        let was_focused = self.state.focused;
863
864        if input.left_just_pressed {
865            let now_focused = hovered;
866            if now_focused && !was_focused {
867                self.state.focused = true;
868                events.push(UiEvent::Focused { id: self.id });
869            } else if !now_focused && was_focused {
870                self.state.focused = false;
871                events.push(UiEvent::Blurred { id: self.id });
872            }
873        }
874
875        if self.state.focused && !input.text_input.is_empty() {
876            for ch in input.text_input.chars() {
877                if ch == '\x08' {
878                    // Backspace
879                    if !self.text.is_empty() {
880                        self.text.pop();
881                        self.cursor_pos = self.cursor_pos.saturating_sub(1);
882                    }
883                } else if !ch.is_control() {
884                    if self.max_len.map(|m| self.text.len() < m).unwrap_or(true) {
885                        self.text.push(ch);
886                        self.cursor_pos += 1;
887                    }
888                }
889            }
890            events.push(UiEvent::TextChanged { id: self.id, text: self.text.clone() });
891        }
892
893        self.state.update(hovered, false, dt, theme.animation_speed);
894        let _ = theme;
895    }
896}
897
898// ── Dropdown widget ────────────────────────────────────────────────────────
899
900#[derive(Debug)]
901pub struct Dropdown {
902    pub id:           WidgetId,
903    pub rect:         Rect,
904    pub options:      Vec<String>,
905    pub selected:     usize,
906    pub open:         bool,
907    pub enabled:      bool,
908    pub visible:      bool,
909    state:            WidgetState,
910    open_anim:        f32,
911}
912
913impl Dropdown {
914    pub fn new(id: WidgetId, options: Vec<String>) -> Self {
915        Self { id, rect: Rect::zero(), options, selected: 0, open: false,
916               enabled: true, visible: true, state: WidgetState::new(), open_anim: 0.0 }
917    }
918
919    pub fn selected_text(&self) -> &str {
920        self.options.get(self.selected).map(|s| s.as_str()).unwrap_or("")
921    }
922}
923
924impl Widget for Dropdown {
925    fn id(&self) -> WidgetId { self.id }
926    fn rect(&self) -> Rect { self.rect }
927    fn set_rect(&mut self, r: Rect) { self.rect = r; }
928    fn is_visible(&self) -> bool { self.visible }
929    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
930        (SizeConstraint::Fill, SizeConstraint::Fixed(32.0))
931    }
932
933    fn update(&mut self, dt: f32) {
934        let target = if self.open { 1.0 } else { 0.0 };
935        self.open_anim += (target - self.open_anim) * (12.0 * dt).min(1.0);
936    }
937
938    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
939        if !self.visible { return; }
940        let bg = theme.surface.lerp(theme.surface_hover, self.state.hover_anim);
941        dl.push(DrawCommand::Rect { rect: self.rect, color: bg, radius: theme.border_radius });
942        dl.push(DrawCommand::RectOutline { rect: self.rect, color: theme.border, width: theme.border_width, radius: theme.border_radius });
943        dl.push(DrawCommand::Text {
944            text: self.selected_text().to_string(),
945            pos: (self.rect.x + theme.padding, self.rect.y + (self.rect.h - theme.font_size) * 0.5),
946            size: theme.font_size, color: theme.text, align: TextAlign::Left,
947        });
948        // Arrow
949        let ax = self.rect.max_x() - 20.0;
950        let ay = self.rect.y + self.rect.h * 0.5;
951        dl.push(DrawCommand::Line { from: (ax - 4.0, ay - 2.0), to: (ax, ay + 3.0), color: theme.text_hint, width: 1.5 });
952        dl.push(DrawCommand::Line { from: (ax, ay + 3.0), to: (ax + 4.0, ay - 2.0), color: theme.text_hint, width: 1.5 });
953
954        // Dropdown panel
955        if self.open_anim > 0.01 {
956            let item_h = 28.0;
957            let n = self.options.len();
958            let panel_h = item_h * n as f32 * self.open_anim;
959            let panel = Rect::new(self.rect.x, self.rect.max_y() + 2.0, self.rect.w, panel_h);
960
961            dl.push(DrawCommand::Shadow { rect: panel.expand(2.0), color: theme.shadow_color, blur: 8.0, offset: (0.0, 4.0) });
962            dl.push(DrawCommand::Rect { rect: panel, color: theme.surface, radius: theme.border_radius });
963            dl.push(DrawCommand::Clip { rect: panel });
964
965            for (i, opt) in self.options.iter().enumerate() {
966                let item_rect = Rect::new(panel.x, panel.y + i as f32 * item_h, panel.w, item_h);
967                if i == self.selected {
968                    dl.push(DrawCommand::Rect { rect: item_rect, color: theme.accent.with_alpha(0.2), radius: 0.0 });
969                }
970                dl.push(DrawCommand::Text {
971                    text: opt.clone(),
972                    pos: (item_rect.x + theme.padding, item_rect.y + (item_h - theme.font_size) * 0.5),
973                    size: theme.font_size, color: theme.text, align: TextAlign::Left,
974                });
975            }
976            dl.push(DrawCommand::ClipEnd);
977        }
978    }
979
980    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32) {
981        if !self.visible || !self.enabled { return; }
982        let hovered = self.rect.contains(input.mouse_pos.0, input.mouse_pos.1);
983        self.state.update(hovered, hovered && input.left_down, dt, theme.animation_speed);
984
985        if hovered && input.left_just_pressed {
986            self.open = !self.open;
987        }
988
989        if self.open {
990            let item_h = 28.0;
991            let panel_y = self.rect.max_y() + 2.0;
992            for (i, _) in self.options.iter().enumerate() {
993                let item_rect = Rect::new(self.rect.x, panel_y + i as f32 * item_h, self.rect.w, item_h);
994                if item_rect.contains(input.mouse_pos.0, input.mouse_pos.1) && input.left_just_pressed {
995                    self.selected = i;
996                    self.open = false;
997                    events.push(UiEvent::SelectionChanged { id: self.id, index: i });
998                }
999            }
1000        }
1001    }
1002}
1003
1004// ── ScrollView widget ──────────────────────────────────────────────────────
1005
1006#[derive(Debug)]
1007pub struct ScrollView {
1008    pub id:           WidgetId,
1009    pub rect:         Rect,
1010    pub content_height: f32,
1011    pub scroll_y:     f32,
1012    pub visible:      bool,
1013    state:            WidgetState,
1014    scrollbar_drag:   bool,
1015}
1016
1017impl ScrollView {
1018    pub fn new(id: WidgetId) -> Self {
1019        Self { id, rect: Rect::zero(), content_height: 0.0, scroll_y: 0.0,
1020               visible: true, state: WidgetState::new(), scrollbar_drag: false }
1021    }
1022
1023    pub fn scroll_max(&self) -> f32 { (self.content_height - self.rect.h).max(0.0) }
1024
1025    pub fn draw_content<F: Fn(&mut DrawList, Rect, f32)>(&self, dl: &mut DrawList, theme: &Theme, draw_fn: F) {
1026        let view_rect = Rect::new(self.rect.x, self.rect.y, self.rect.w - 12.0, self.rect.h);
1027        dl.push(DrawCommand::Clip { rect: view_rect });
1028        draw_fn(dl, view_rect, self.scroll_y);
1029        dl.push(DrawCommand::ClipEnd);
1030
1031        // Scrollbar
1032        let max = self.scroll_max();
1033        if max > 0.0 {
1034            let bar_x = self.rect.max_x() - 10.0;
1035            let bar_h = (self.rect.h / self.content_height * self.rect.h).max(20.0);
1036            let bar_y = self.rect.y + (self.scroll_y / max) * (self.rect.h - bar_h);
1037            let track = Rect::new(bar_x, self.rect.y, 8.0, self.rect.h);
1038            let bar   = Rect::new(bar_x, bar_y, 8.0, bar_h);
1039            dl.push(DrawCommand::Rect { rect: track, color: theme.surface, radius: 4.0 });
1040            dl.push(DrawCommand::Rect { rect: bar,   color: theme.border.lerp(theme.accent, self.state.hover_anim), radius: 4.0 });
1041        }
1042    }
1043}
1044
1045impl Widget for ScrollView {
1046    fn id(&self) -> WidgetId { self.id }
1047    fn rect(&self) -> Rect { self.rect }
1048    fn set_rect(&mut self, r: Rect) { self.rect = r; }
1049    fn is_visible(&self) -> bool { self.visible }
1050
1051    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
1052        if !self.visible { return; }
1053        dl.push(DrawCommand::Rect { rect: self.rect, color: theme.surface, radius: theme.border_radius });
1054    }
1055
1056    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, theme: &Theme, dt: f32) {
1057        if !self.visible { return; }
1058        let hovered = self.rect.contains(input.mouse_pos.0, input.mouse_pos.1);
1059        self.state.update(hovered, false, dt, theme.animation_speed);
1060
1061        if hovered {
1062            self.scroll_y = (self.scroll_y - input.scroll_delta.1 * 20.0).clamp(0.0, self.scroll_max());
1063            if input.scroll_delta.1.abs() > 1e-3 {
1064                events.push(UiEvent::Scrolled { id: self.id, delta: input.scroll_delta });
1065            }
1066        }
1067    }
1068}
1069
1070// ── ProgressBar widget ─────────────────────────────────────────────────────
1071
1072#[derive(Debug)]
1073pub struct ProgressBar {
1074    pub id:        WidgetId,
1075    pub rect:      Rect,
1076    pub value:     f32,   // [0, 1]
1077    pub animated:  bool,
1078    pub label:     Option<String>,
1079    pub color:     Option<Color>,
1080    pub visible:   bool,
1081    anim_value:    f32,
1082}
1083
1084impl ProgressBar {
1085    pub fn new(id: WidgetId) -> Self {
1086        Self { id, rect: Rect::zero(), value: 0.0, animated: true,
1087               label: None, color: None, visible: true, anim_value: 0.0 }
1088    }
1089
1090    pub fn with_color(mut self, c: Color) -> Self { self.color = Some(c); self }
1091    pub fn with_label(mut self, l: &str) -> Self { self.label = Some(l.to_string()); self }
1092}
1093
1094impl Widget for ProgressBar {
1095    fn id(&self) -> WidgetId { self.id }
1096    fn rect(&self) -> Rect { self.rect }
1097    fn set_rect(&mut self, r: Rect) { self.rect = r; }
1098    fn is_visible(&self) -> bool { self.visible }
1099    fn preferred_size(&self) -> (SizeConstraint, SizeConstraint) {
1100        (SizeConstraint::Fill, SizeConstraint::Fixed(20.0))
1101    }
1102
1103    fn update(&mut self, dt: f32) {
1104        if self.animated {
1105            self.anim_value += (self.value - self.anim_value) * (6.0 * dt).min(1.0);
1106        } else {
1107            self.anim_value = self.value;
1108        }
1109    }
1110
1111    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
1112        if !self.visible { return; }
1113        dl.push(DrawCommand::Rect { rect: self.rect, color: theme.surface, radius: self.rect.h * 0.5 });
1114        if self.anim_value > 0.001 {
1115            let fill = Rect::new(self.rect.x, self.rect.y, self.rect.w * self.anim_value, self.rect.h);
1116            let color = self.color.unwrap_or(theme.accent);
1117            dl.push(DrawCommand::Rect { rect: fill, color, radius: self.rect.h * 0.5 });
1118        }
1119        if let Some(ref lbl) = self.label {
1120            let (cx, cy) = self.rect.center();
1121            dl.push(DrawCommand::Text {
1122                text: format!("{}: {:.0}%", lbl, self.value * 100.0),
1123                pos: (cx, cy - theme.font_size * 0.5),
1124                size: theme.font_size * 0.8, color: theme.text, align: TextAlign::Center,
1125            });
1126        }
1127    }
1128
1129    fn handle_input(&mut self, _input: &UiInput, _events: &mut Vec<UiEvent>, _theme: &Theme, _dt: f32) {}
1130}
1131
1132// ── Panel / Container ──────────────────────────────────────────────────────
1133
1134#[derive(Debug)]
1135pub struct Panel {
1136    pub id:          WidgetId,
1137    pub rect:        Rect,
1138    pub title:       Option<String>,
1139    pub collapsible: bool,
1140    pub collapsed:   bool,
1141    pub visible:     bool,
1142    pub draggable:   bool,
1143    drag_offset:     (f32, f32),
1144    dragging:        bool,
1145    collapse_anim:   f32,
1146}
1147
1148impl Panel {
1149    pub fn new(id: WidgetId) -> Self {
1150        Self { id, rect: Rect::zero(), title: None, collapsible: false, collapsed: false,
1151               visible: true, draggable: false, drag_offset: (0.0, 0.0),
1152               dragging: false, collapse_anim: 1.0 }
1153    }
1154
1155    pub fn with_title(mut self, t: &str) -> Self { self.title = Some(t.to_string()); self }
1156    pub fn collapsible(mut self) -> Self { self.collapsible = true; self }
1157    pub fn draggable(mut self) -> Self { self.draggable = true; self }
1158
1159    pub fn content_rect(&self) -> Rect {
1160        let title_h = if self.title.is_some() { 28.0 } else { 0.0 };
1161        Rect::new(self.rect.x, self.rect.y + title_h, self.rect.w, self.rect.h - title_h)
1162    }
1163}
1164
1165impl Widget for Panel {
1166    fn id(&self) -> WidgetId { self.id }
1167    fn rect(&self) -> Rect { self.rect }
1168    fn set_rect(&mut self, r: Rect) { self.rect = r; }
1169    fn is_visible(&self) -> bool { self.visible }
1170
1171    fn update(&mut self, dt: f32) {
1172        let target = if self.collapsed { 0.0 } else { 1.0 };
1173        self.collapse_anim += (target - self.collapse_anim) * (10.0 * dt).min(1.0);
1174    }
1175
1176    fn draw(&self, dl: &mut DrawList, theme: &Theme) {
1177        if !self.visible { return; }
1178        // Shadow
1179        dl.push(DrawCommand::Shadow { rect: self.rect.expand(4.0), color: theme.shadow_color, blur: 12.0, offset: (0.0, 4.0) });
1180        // Background
1181        dl.push(DrawCommand::Rect { rect: self.rect, color: theme.surface, radius: theme.border_radius });
1182        dl.push(DrawCommand::RectOutline { rect: self.rect, color: theme.border, width: theme.border_width, radius: theme.border_radius });
1183
1184        if let Some(ref title) = self.title {
1185            let title_rect = Rect::new(self.rect.x, self.rect.y, self.rect.w, 28.0);
1186            let title_bg = Color::new(0.0, 0.0, 0.0, 0.1);
1187            dl.push(DrawCommand::Rect { rect: title_rect, color: title_bg, radius: theme.border_radius });
1188            dl.push(DrawCommand::Text {
1189                text: title.clone(),
1190                pos: (self.rect.x + theme.padding, self.rect.y + (28.0 - theme.font_size) * 0.5),
1191                size: theme.font_size, color: theme.text, align: TextAlign::Left,
1192            });
1193            if self.collapsible {
1194                let arrow_x = self.rect.max_x() - 20.0;
1195                let arrow_y = self.rect.y + 14.0;
1196                let a = if self.collapsed { 0.0 } else { 1.0 };
1197                dl.push(DrawCommand::Line {
1198                    from: (arrow_x - 4.0, arrow_y - 2.0 * a + 2.0 * (1.0 - a)),
1199                    to:   (arrow_x,       arrow_y + 3.0 * a - 3.0 * (1.0 - a)),
1200                    color: theme.text_hint, width: 1.5,
1201                });
1202            }
1203        }
1204    }
1205
1206    fn handle_input(&mut self, input: &UiInput, events: &mut Vec<UiEvent>, _theme: &Theme, _dt: f32) {
1207        if !self.visible { return; }
1208
1209        if self.draggable {
1210            let title_rect = Rect::new(self.rect.x, self.rect.y, self.rect.w, 28.0);
1211            if title_rect.contains(input.mouse_pos.0, input.mouse_pos.1) && input.left_just_pressed {
1212                self.dragging = true;
1213                self.drag_offset = (input.mouse_pos.0 - self.rect.x, input.mouse_pos.1 - self.rect.y);
1214                events.push(UiEvent::DragStart { id: self.id, pos: input.mouse_pos });
1215            }
1216        }
1217        if input.left_just_released && self.dragging {
1218            self.dragging = false;
1219            events.push(UiEvent::DragEnd { id: self.id, pos: input.mouse_pos });
1220        }
1221        if self.dragging {
1222            let new_x = input.mouse_pos.0 - self.drag_offset.0;
1223            let new_y = input.mouse_pos.1 - self.drag_offset.1;
1224            self.rect.x = new_x;
1225            self.rect.y = new_y;
1226            events.push(UiEvent::DragMove { id: self.id, delta: input.mouse_delta });
1227        }
1228
1229        if self.collapsible {
1230            let title_rect = Rect::new(self.rect.x, self.rect.y, self.rect.w, 28.0);
1231            if title_rect.contains(input.mouse_pos.0, input.mouse_pos.1) && input.left_just_pressed && !self.dragging {
1232                self.collapsed = !self.collapsed;
1233            }
1234        }
1235    }
1236}
1237
1238// ── Toast / Notification ───────────────────────────────────────────────────
1239
1240#[derive(Debug, Clone)]
1241pub enum ToastKind { Info, Success, Warning, Error }
1242
1243#[derive(Debug)]
1244pub struct Toast {
1245    pub message:   String,
1246    pub kind:      ToastKind,
1247    pub lifetime:  f32,
1248    pub max_life:  f32,
1249}
1250
1251impl Toast {
1252    pub fn new(message: &str, kind: ToastKind, lifetime: f32) -> Self {
1253        Self { message: message.to_string(), kind, lifetime, max_life: lifetime }
1254    }
1255    pub fn alpha(&self) -> f32 {
1256        let progress = 1.0 - self.lifetime / self.max_life;
1257        if progress < 0.1 { progress / 0.1 }
1258        else if progress > 0.85 { 1.0 - (progress - 0.85) / 0.15 }
1259        else { 1.0 }
1260    }
1261    pub fn is_alive(&self) -> bool { self.lifetime > 0.0 }
1262}
1263
1264/// Manages a toast notification stack.
1265#[derive(Debug, Default)]
1266pub struct ToastManager {
1267    toasts:      Vec<Toast>,
1268    pub position: (f32, f32),
1269    pub width:    f32,
1270}
1271
1272impl ToastManager {
1273    pub fn new(x: f32, y: f32, width: f32) -> Self {
1274        Self { toasts: Vec::new(), position: (x, y), width }
1275    }
1276
1277    pub fn push(&mut self, message: &str, kind: ToastKind, lifetime: f32) {
1278        self.toasts.push(Toast::new(message, kind, lifetime));
1279    }
1280
1281    pub fn info(&mut self, msg: &str)    { self.push(msg, ToastKind::Info,    3.0); }
1282    pub fn success(&mut self, msg: &str) { self.push(msg, ToastKind::Success, 3.0); }
1283    pub fn warning(&mut self, msg: &str) { self.push(msg, ToastKind::Warning, 4.0); }
1284    pub fn error(&mut self, msg: &str)   { self.push(msg, ToastKind::Error,   5.0); }
1285
1286    pub fn update(&mut self, dt: f32) {
1287        for t in &mut self.toasts { t.lifetime = (t.lifetime - dt).max(0.0); }
1288        self.toasts.retain(|t| t.is_alive());
1289    }
1290
1291    pub fn draw(&self, dl: &mut DrawList, theme: &Theme) {
1292        let mut y = self.position.1;
1293        for toast in &self.toasts {
1294            let a = toast.alpha();
1295            let color = match toast.kind {
1296                ToastKind::Info    => theme.accent.with_alpha(0.9 * a),
1297                ToastKind::Success => theme.success.with_alpha(0.9 * a),
1298                ToastKind::Warning => theme.warning.with_alpha(0.9 * a),
1299                ToastKind::Error   => theme.danger.with_alpha(0.9 * a),
1300            };
1301            let h = 40.0;
1302            let rect = Rect::new(self.position.0, y, self.width, h);
1303            dl.push(DrawCommand::Shadow { rect: rect.expand(2.0), color: theme.shadow_color.with_alpha(0.5 * a), blur: 8.0, offset: (0.0, 2.0) });
1304            dl.push(DrawCommand::Rect { rect, color, radius: theme.border_radius });
1305            dl.push(DrawCommand::Text {
1306                text: toast.message.clone(),
1307                pos: (rect.x + theme.padding, rect.y + (h - theme.font_size) * 0.5),
1308                size: theme.font_size, color: Color::WHITE.with_alpha(a), align: TextAlign::Left,
1309            });
1310            y += h + 4.0;
1311        }
1312    }
1313}
1314
1315// ── Tooltip ────────────────────────────────────────────────────────────────
1316
1317#[derive(Debug, Default)]
1318pub struct TooltipManager {
1319    pub text:     String,
1320    pub visible:  bool,
1321    pub pos:      (f32, f32),
1322    show_delay:   f32,
1323    timer:        f32,
1324}
1325
1326impl TooltipManager {
1327    pub fn show(&mut self, text: &str, pos: (f32, f32), delay: f32) {
1328        self.text = text.to_string();
1329        self.pos  = pos;
1330        self.show_delay = delay;
1331    }
1332
1333    pub fn hide(&mut self) { self.visible = false; self.timer = 0.0; }
1334
1335    pub fn update(&mut self, dt: f32) {
1336        if !self.text.is_empty() {
1337            self.timer += dt;
1338            if self.timer >= self.show_delay { self.visible = true; }
1339        }
1340    }
1341
1342    pub fn draw(&self, dl: &mut DrawList, theme: &Theme) {
1343        if !self.visible || self.text.is_empty() { return; }
1344        let w = (self.text.len() as f32 * theme.font_size * 0.55).min(300.0) + theme.padding * 2.0;
1345        let h = theme.font_size + theme.padding * 1.5;
1346        let rect = Rect::new(self.pos.0 + 12.0, self.pos.1 - h - 4.0, w, h);
1347        dl.push(DrawCommand::Rect { rect, color: theme.surface_hover, radius: theme.border_radius });
1348        dl.push(DrawCommand::RectOutline { rect, color: theme.border, width: theme.border_width, radius: theme.border_radius });
1349        dl.push(DrawCommand::Text {
1350            text: self.text.clone(),
1351            pos: (rect.x + theme.padding, rect.y + (h - theme.font_size) * 0.5),
1352            size: theme.font_size * 0.85, color: theme.text, align: TextAlign::Left,
1353        });
1354    }
1355}
1356
1357// ── UiContext ──────────────────────────────────────────────────────────────
1358
1359/// The top-level UI context: manages widgets, events, and layout.
1360pub struct UiContext {
1361    widgets:     Vec<Box<dyn Widget>>,
1362    pub events:  Vec<UiEvent>,
1363    pub theme:   Theme,
1364    pub draw:    DrawList,
1365    pub toast:   ToastManager,
1366    pub tooltip: TooltipManager,
1367    next_id:     u64,
1368    elapsed:     f32,
1369}
1370
1371impl UiContext {
1372    pub fn new(theme: Theme) -> Self {
1373        Self {
1374            widgets:  Vec::new(),
1375            events:   Vec::new(),
1376            theme,
1377            draw:     DrawList::new(),
1378            toast:    ToastManager::new(20.0, 20.0, 280.0),
1379            tooltip:  TooltipManager::default(),
1380            next_id:  1,
1381            elapsed:  0.0,
1382        }
1383    }
1384
1385    pub fn allocate_id(&mut self) -> WidgetId {
1386        let id = self.next_id;
1387        self.next_id += 1;
1388        WidgetId(id)
1389    }
1390
1391    pub fn add_widget(&mut self, widget: Box<dyn Widget>) {
1392        self.widgets.push(widget);
1393    }
1394
1395    /// Process one frame: update, handle input, draw.
1396    pub fn frame(&mut self, input: &UiInput, dt: f32) {
1397        self.elapsed += dt;
1398        self.events.clear();
1399        self.draw.clear();
1400
1401        // Update all widgets
1402        for w in &mut self.widgets {
1403            w.update(dt);
1404        }
1405
1406        // Handle input for all widgets (reverse order for z-order)
1407        let events = &mut self.events;
1408        let theme = &self.theme;
1409        for w in self.widgets.iter_mut().rev() {
1410            w.handle_input(input, events, theme, dt);
1411        }
1412
1413        // Draw all widgets
1414        let theme = &self.theme;
1415        let dl = &mut self.draw;
1416        for w in &self.widgets {
1417            w.draw(dl, theme);
1418        }
1419
1420        // Draw toasts and tooltips on top
1421        self.toast.update(dt);
1422        self.toast.draw(&mut self.draw, &self.theme);
1423        self.tooltip.update(dt);
1424        self.tooltip.draw(&mut self.draw, &self.theme);
1425    }
1426
1427    pub fn drain_events(&mut self) -> Vec<UiEvent> {
1428        std::mem::take(&mut self.events)
1429    }
1430}
1431
1432// ── Tests ──────────────────────────────────────────────────────────────────
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437
1438    fn input() -> UiInput { UiInput::default() }
1439    fn ctx() -> UiContext { UiContext::new(Theme::dark()) }
1440
1441    #[test]
1442    fn test_rect_contains() {
1443        let r = Rect::new(10.0, 10.0, 100.0, 50.0);
1444        assert!(r.contains(50.0, 30.0));
1445        assert!(!r.contains(5.0, 5.0));
1446        assert!(!r.contains(120.0, 30.0));
1447    }
1448
1449    #[test]
1450    fn test_color_lerp() {
1451        let a = Color::BLACK;
1452        let b = Color::WHITE;
1453        let mid = a.lerp(b, 0.5);
1454        assert!((mid.r - 0.5).abs() < 1e-5);
1455    }
1456
1457    #[test]
1458    fn test_flex_layout_row() {
1459        let layout = FlexLayout::row();
1460        let parent = Rect::new(0.0, 0.0, 200.0, 40.0);
1461        let children = vec![
1462            (SizeConstraint::Fixed(60.0), SizeConstraint::Fill),
1463            (SizeConstraint::Fill,        SizeConstraint::Fill),
1464        ];
1465        let rects = layout.compute(parent, &children);
1466        assert_eq!(rects.len(), 2);
1467        assert!((rects[0].w - 60.0).abs() < 1.0);
1468    }
1469
1470    #[test]
1471    fn test_button_click_event() {
1472        let mut btn = Button::new(WidgetId(1), "Click Me");
1473        btn.rect = Rect::new(0.0, 0.0, 100.0, 40.0);
1474        let mut events = Vec::new();
1475        let theme = Theme::dark();
1476        let input = UiInput {
1477            mouse_pos: (50.0, 20.0),
1478            left_just_pressed: true,
1479            left_down: true,
1480            ..Default::default()
1481        };
1482        btn.handle_input(&input, &mut events, &theme, 0.016);
1483        assert!(events.iter().any(|e| matches!(e, UiEvent::Clicked { id } if id.0 == 1)));
1484    }
1485
1486    #[test]
1487    fn test_slider_value_change() {
1488        let mut slider = Slider::new(WidgetId(2), 0.0, 100.0);
1489        slider.rect = Rect::new(0.0, 0.0, 200.0, 28.0);
1490        let mut events = Vec::new();
1491        let theme = Theme::dark();
1492        // Press at middle = value ~50
1493        let input = UiInput {
1494            mouse_pos: (100.0, 14.0),
1495            left_just_pressed: true,
1496            left_down: true,
1497            ..Default::default()
1498        };
1499        slider.handle_input(&input, &mut events, &theme, 0.016);
1500        let val_changed = events.iter().any(|e| matches!(e, UiEvent::ValueChanged { .. }));
1501        assert!(val_changed || slider.value == 0.0); // either started drag or no change
1502    }
1503
1504    #[test]
1505    fn test_checkbox_toggle() {
1506        let mut cb = Checkbox::new(WidgetId(3), "test");
1507        cb.rect = Rect::new(0.0, 0.0, 100.0, 24.0);
1508        let mut events = Vec::new();
1509        let theme = Theme::dark();
1510        let input = UiInput {
1511            mouse_pos: (50.0, 12.0),
1512            left_just_pressed: true,
1513            left_down: true,
1514            ..Default::default()
1515        };
1516        cb.handle_input(&input, &mut events, &theme, 0.016);
1517        assert!(cb.checked);
1518        assert!(events.iter().any(|e| matches!(e, UiEvent::ValueChanged { value, .. } if *value > 0.5)));
1519    }
1520
1521    #[test]
1522    fn test_toast_lifecycle() {
1523        let mut tm = ToastManager::new(0.0, 0.0, 200.0);
1524        tm.info("Test message");
1525        assert_eq!(tm.toasts.len(), 1);
1526        tm.update(10.0); // Exhaust lifetime
1527        assert_eq!(tm.toasts.len(), 0);
1528    }
1529
1530    #[test]
1531    fn test_draw_list_commands() {
1532        let mut dl = DrawList::new();
1533        let theme = Theme::dark();
1534        let btn = Button::new(WidgetId(1), "Draw Test");
1535        let mut btn = btn;
1536        btn.rect = Rect::new(10.0, 10.0, 100.0, 40.0);
1537        btn.draw(&mut dl, &theme);
1538        assert!(!dl.commands.is_empty());
1539    }
1540
1541    #[test]
1542    fn test_progress_bar_animated() {
1543        let mut pb = ProgressBar::new(WidgetId(4));
1544        pb.rect = Rect::new(0.0, 0.0, 200.0, 20.0);
1545        pb.value = 0.7;
1546        pb.update(0.5);
1547        assert!(pb.anim_value > 0.0 && pb.anim_value <= 0.7 + 0.01);
1548    }
1549
1550    #[test]
1551    fn test_ui_context_frame() {
1552        let mut ctx = ctx();
1553        let input = input();
1554        ctx.frame(&input, 0.016);
1555        assert_eq!(ctx.events.len(), 0);
1556    }
1557
1558    #[test]
1559    fn test_theme_colors() {
1560        let dark = Theme::dark();
1561        assert!(dark.text.r > 0.5, "dark theme text should be light");
1562        let light = Theme::light();
1563        assert!(light.text.r < 0.5, "light theme text should be dark");
1564    }
1565}