Skip to main content

proof_engine/ui/
mod.rs

1//! UI Widget System for Proof Engine.
2//!
3//! Provides retained-state widgets, panel containers, layout engine,
4//! theming, animation, and tooltip systems.
5
6pub mod widgets;
7pub mod panels;
8pub mod layout;
9pub mod framework;
10
11// Layout types
12pub use layout::{
13    UiLayout, Anchor, UiRect, AutoLayout,
14    Constraint, FlexLayout, GridLayout, AbsoluteLayout,
15    StackLayout, FlowLayout, LayoutNode, ResponsiveBreakpoints,
16    SafeAreaInsets, Breakpoint, Axis, JustifyContent,
17    CrossAlign, FlexWrap,
18};
19
20// Widget types (legacy + new)
21pub use widgets::{
22    UiLabel, UiProgressBar, UiButton, UiPanel, UiPulseRing,
23};
24
25// Panel types
26pub use panels::{
27    Window, SplitPane, TabBar, TabPanel, Toolbar, StatusBar,
28    ContextMenu, Notification, Modal, DragDropContext,
29    NotificationSeverity, Toast, ToolbarItem,
30};
31
32use std::collections::HashMap;
33
34// ── UiId ─────────────────────────────────────────────────────────────────────
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct UiId(pub u64);
38
39impl UiId {
40    pub fn new(label: &str) -> Self {
41        let mut hash: u64 = 14695981039346656037;
42        for byte in label.bytes() {
43            hash ^= byte as u64;
44            hash = hash.wrapping_mul(1099511628211);
45        }
46        Self(hash)
47    }
48    pub fn with_index(self, idx: usize) -> Self {
49        let mut h = self.0 ^ idx as u64;
50        h = h.wrapping_mul(1099511628211);
51        Self(h)
52    }
53    pub fn child(self, child: UiId) -> Self {
54        let mut h = self.0 ^ child.0;
55        h = h.wrapping_mul(1099511628211);
56        Self(h)
57    }
58}
59
60// ── Rect ─────────────────────────────────────────────────────────────────────
61
62#[derive(Debug, Clone, Copy, PartialEq, Default)]
63pub struct Rect {
64    pub x: f32, pub y: f32, pub w: f32, pub h: f32,
65}
66
67impl Rect {
68    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self { Self { x, y, w, h } }
69    pub fn zero() -> Self { Default::default() }
70    pub fn from_min_max(x0: f32, y0: f32, x1: f32, y1: f32) -> Self {
71        Self { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }
72    }
73    pub fn min_x(&self) -> f32 { self.x }
74    pub fn min_y(&self) -> f32 { self.y }
75    pub fn max_x(&self) -> f32 { self.x + self.w }
76    pub fn max_y(&self) -> f32 { self.y + self.h }
77    pub fn center_x(&self) -> f32 { self.x + self.w * 0.5 }
78    pub fn center_y(&self) -> f32 { self.y + self.h * 0.5 }
79    pub fn center(&self) -> (f32, f32) { (self.center_x(), self.center_y()) }
80    pub fn contains(&self, px: f32, py: f32) -> bool {
81        px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
82    }
83    pub fn intersect(&self, other: &Rect) -> Option<Rect> {
84        let x = self.x.max(other.x);
85        let y = self.y.max(other.y);
86        let x2 = (self.x + self.w).min(other.x + other.w);
87        let y2 = (self.y + self.h).min(other.y + other.h);
88        if x2 > x && y2 > y { Some(Self::new(x, y, x2-x, y2-y)) } else { None }
89    }
90    pub fn expand(&self, m: f32) -> Self {
91        Self { x: self.x-m, y: self.y-m, w: (self.w+m*2.0).max(0.0), h: (self.h+m*2.0).max(0.0) }
92    }
93    pub fn shrink(&self, p: f32) -> Self {
94        Self { x: self.x+p, y: self.y+p, w: (self.w-p*2.0).max(0.0), h: (self.h-p*2.0).max(0.0) }
95    }
96    pub fn split_left(&self, a: f32) -> (Rect, Rect) {
97        let a = a.min(self.w);
98        (Self::new(self.x, self.y, a, self.h), Self::new(self.x+a, self.y, self.w-a, self.h))
99    }
100    pub fn split_right(&self, a: f32) -> (Rect, Rect) {
101        let a = a.min(self.w);
102        (Self::new(self.x, self.y, self.w-a, self.h), Self::new(self.x+self.w-a, self.y, a, self.h))
103    }
104    pub fn split_top(&self, a: f32) -> (Rect, Rect) {
105        let a = a.min(self.h);
106        (Self::new(self.x, self.y, self.w, a), Self::new(self.x, self.y+a, self.w, self.h-a))
107    }
108    pub fn split_bottom(&self, a: f32) -> (Rect, Rect) {
109        let a = a.min(self.h);
110        (Self::new(self.x, self.y+self.h-a, self.w, a), Self::new(self.x, self.y, self.w, self.h-a))
111    }
112    pub fn center_rect(&self, w: f32, h: f32) -> Rect {
113        Self::new(self.x+(self.w-w)*0.5, self.y+(self.h-h)*0.5, w, h)
114    }
115    pub fn translate(&self, dx: f32, dy: f32) -> Self {
116        Self { x: self.x+dx, y: self.y+dy, w: self.w, h: self.h }
117    }
118}
119
120// ── Color ─────────────────────────────────────────────────────────────────────
121
122#[derive(Debug, Clone, Copy, PartialEq, Default)]
123pub struct Color { pub r: f32, pub g: f32, pub b: f32, pub a: f32 }
124
125impl Color {
126    pub const WHITE:       Self = Self { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
127    pub const BLACK:       Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
128    pub const TRANSPARENT: Self = Self { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
129    pub const RED:         Self = Self { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
130    pub const GREEN:       Self = Self { r: 0.0, g: 1.0, b: 0.0, a: 1.0 };
131    pub const BLUE:        Self = Self { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
132    pub const YELLOW:      Self = Self { r: 1.0, g: 1.0, b: 0.0, a: 1.0 };
133    pub const CYAN:        Self = Self { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
134    pub const MAGENTA:     Self = Self { r: 1.0, g: 0.0, b: 1.0, a: 1.0 };
135    pub const GRAY:        Self = Self { r: 0.5, g: 0.5, b: 0.5, a: 1.0 };
136
137    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r, g, b, a } }
138    pub fn rgb(r: f32, g: f32, b: f32) -> Self { Self { r, g, b, a: 1.0 } }
139    pub fn from_u8(r: u8, g: u8, b: u8, a: u8) -> Self {
140        Self { r: r as f32/255.0, g: g as f32/255.0, b: b as f32/255.0, a: a as f32/255.0 }
141    }
142    pub fn from_hex(s: &str) -> Option<Self> {
143        let s = s.trim_start_matches('#');
144        match s.len() {
145            6 => {
146                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
147                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
148                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
149                Some(Self::from_u8(r, g, b, 255))
150            }
151            8 => {
152                let r = u8::from_str_radix(&s[0..2], 16).ok()?;
153                let g = u8::from_str_radix(&s[2..4], 16).ok()?;
154                let b = u8::from_str_radix(&s[4..6], 16).ok()?;
155                let a = u8::from_str_radix(&s[6..8], 16).ok()?;
156                Some(Self::from_u8(r, g, b, a))
157            }
158            _ => None,
159        }
160    }
161    pub fn lerp(&self, other: Color, t: f32) -> Self {
162        Self { r: self.r+(other.r-self.r)*t, g: self.g+(other.g-self.g)*t,
163               b: self.b+(other.b-self.b)*t, a: self.a+(other.a-self.a)*t }
164    }
165    pub fn with_alpha(&self, a: f32) -> Self { Self { r: self.r, g: self.g, b: self.b, a } }
166    pub fn to_hex(&self) -> String {
167        format!("#{:02X}{:02X}{:02X}{:02X}",
168            (self.r*255.0) as u8, (self.g*255.0) as u8,
169            (self.b*255.0) as u8, (self.a*255.0) as u8)
170    }
171    pub fn to_hsv(&self) -> (f32, f32, f32) {
172        let max = self.r.max(self.g).max(self.b);
173        let min = self.r.min(self.g).min(self.b);
174        let d = max - min;
175        let v = max;
176        let s = if max > 0.0 { d / max } else { 0.0 };
177        let h = if d < 1e-6 { 0.0 }
178                else if max == self.r { 60.0 * (((self.g - self.b) / d) % 6.0) }
179                else if max == self.g { 60.0 * ((self.b - self.r) / d + 2.0) }
180                else                  { 60.0 * ((self.r - self.g) / d + 4.0) };
181        let h = if h < 0.0 { h + 360.0 } else { h };
182        (h, s, v)
183    }
184    pub fn from_hsv(h: f32, s: f32, v: f32) -> Self {
185        let h = h % 360.0;
186        let c = v * s;
187        let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
188        let m = v - c;
189        let (r1,g1,b1) = match (h / 60.0) as u32 {
190            0 => (c,x,0.0), 1 => (x,c,0.0), 2 => (0.0,c,x),
191            3 => (0.0,x,c), 4 => (x,0.0,c), _ => (c,0.0,x),
192        };
193        Self::rgb(r1+m, g1+m, b1+m)
194    }
195}
196
197// ── UiStyle ───────────────────────────────────────────────────────────────────
198
199#[derive(Debug, Clone)]
200pub struct UiStyle {
201    pub font_size: f32, pub fg: Color, pub bg: Color,
202    pub border: Color, pub hover: Color, pub active: Color,
203    pub disabled: Color, pub padding: f32, pub margin: f32,
204    pub border_width: f32, pub border_radius: f32,
205    pub opacity: f32, pub z_index: i32,
206}
207
208impl Default for UiStyle {
209    fn default() -> Self {
210        Self {
211            font_size: 14.0,
212            fg:       Color::new(0.9, 0.9, 0.9, 1.0),
213            bg:       Color::new(0.15, 0.15, 0.18, 1.0),
214            border:   Color::new(0.35, 0.35, 0.4, 1.0),
215            hover:    Color::new(0.25, 0.25, 0.3, 1.0),
216            active:   Color::new(0.35, 0.35, 0.5, 1.0),
217            disabled: Color::new(0.4, 0.4, 0.4, 0.5),
218            padding: 6.0, margin: 4.0, border_width: 1.0,
219            border_radius: 4.0, opacity: 1.0, z_index: 0,
220        }
221    }
222}
223
224impl UiStyle {
225    pub fn fg_with_opacity(&self) -> Color { self.fg.with_alpha(self.fg.a * self.opacity) }
226    pub fn bg_with_opacity(&self) -> Color { self.bg.with_alpha(self.bg.a * self.opacity) }
227    pub fn warning(&self) -> Color { Color::new(0.9, 0.6, 0.1, 1.0) }
228    pub fn disabled_color(&self) -> Color { self.fg.with_alpha(0.4) }
229}
230
231// ── DrawCmd ───────────────────────────────────────────────────────────────────
232
233#[derive(Debug, Clone)]
234pub enum DrawCmd {
235    FillRect          { rect: Rect, color: Color },
236    StrokeRect        { rect: Rect, color: Color, width: f32 },
237    RoundedRect       { rect: Rect, radius: f32, color: Color },
238    RoundedRectStroke { rect: Rect, radius: f32, color: Color, width: f32 },
239    Text              { text: String, x: f32, y: f32, font_size: f32, color: Color, clip: Option<Rect> },
240    Line              { x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 },
241    Circle            { cx: f32, cy: f32, radius: f32, color: Color },
242    CircleStroke      { cx: f32, cy: f32, radius: f32, color: Color, width: f32 },
243    Scissor(Rect),
244    PopScissor,
245    Image             { id: u64, rect: Rect, tint: Color },
246}
247
248// ── WidgetStateRetained ───────────────────────────────────────────────────────
249
250#[derive(Debug, Clone, Default)]
251pub struct WidgetStateRetained {
252    pub hovered: bool, pub focused: bool, pub active: bool,
253    pub last_rect: Rect, pub payload: Vec<f32>,
254}
255
256// ── InputEvent / KeyCode ──────────────────────────────────────────────────────
257
258#[derive(Debug, Clone)]
259pub enum InputEvent {
260    MouseMove  { x: f32, y: f32 },
261    MouseDown  { x: f32, y: f32, button: u8 },
262    MouseUp    { x: f32, y: f32, button: u8 },
263    MouseWheel { delta_x: f32, delta_y: f32 },
264    KeyDown    { key: KeyCode },
265    KeyUp      { key: KeyCode },
266    Char       { ch: char },
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
270pub enum KeyCode {
271    Tab, Enter, Escape, Backspace, Delete,
272    Left, Right, Up, Down, Home, End, PageUp, PageDown,
273    Shift, Ctrl, Alt,
274    A, C, V, X, Z, Y,
275    F1, F2, F3, F4,
276    Other(u32),
277}
278
279// ── UiContext ─────────────────────────────────────────────────────────────────
280
281pub struct UiContext {
282    pub states:      HashMap<UiId, WidgetStateRetained>,
283    pub focused_id:  Option<UiId>,
284    pub hovered_id:  Option<UiId>,
285    pub active_id:   Option<UiId>,
286    pub events:      Vec<InputEvent>,
287    layout_stack:    Vec<Rect>,
288    pub draw_cmds:   Vec<DrawCmd>,
289    pub mouse_x:     f32,
290    pub mouse_y:     f32,
291    pub mouse_down:  bool,
292    pub mouse_just_pressed:  bool,
293    pub mouse_just_released: bool,
294    held_keys:       std::collections::HashSet<KeyCode>,
295    just_pressed:    Vec<KeyCode>,
296    pub typed_chars: Vec<char>,
297    pub viewport_w:  f32,
298    pub viewport_h:  f32,
299    pub animators:   HashMap<UiId, Animator>,
300}
301
302impl UiContext {
303    pub fn new(vw: f32, vh: f32) -> Self {
304        Self {
305            states: HashMap::new(), focused_id: None, hovered_id: None, active_id: None,
306            events: Vec::new(), layout_stack: Vec::new(), draw_cmds: Vec::new(),
307            mouse_x: 0.0, mouse_y: 0.0, mouse_down: false,
308            mouse_just_pressed: false, mouse_just_released: false,
309            held_keys: std::collections::HashSet::new(), just_pressed: Vec::new(),
310            typed_chars: Vec::new(), viewport_w: vw, viewport_h: vh,
311            animators: HashMap::new(),
312        }
313    }
314    pub fn push_event(&mut self, event: InputEvent) { self.events.push(event); }
315    pub fn begin_frame(&mut self) {
316        self.mouse_just_pressed = false;
317        self.mouse_just_released = false;
318        self.just_pressed.clear();
319        self.typed_chars.clear();
320        self.draw_cmds.clear();
321        let events = std::mem::take(&mut self.events);
322        for ev in events {
323            match ev {
324                InputEvent::MouseMove { x, y }         => { self.mouse_x = x; self.mouse_y = y; }
325                InputEvent::MouseDown { button: 0, .. } => { self.mouse_down = true;  self.mouse_just_pressed  = true; }
326                InputEvent::MouseUp   { button: 0, .. } => { self.mouse_down = false; self.mouse_just_released = true; self.active_id = None; }
327                InputEvent::KeyDown   { key }           => { self.held_keys.insert(key); self.just_pressed.push(key); }
328                InputEvent::KeyUp     { key }           => { self.held_keys.remove(&key); }
329                InputEvent::Char      { ch }            => { self.typed_chars.push(ch); }
330                _ => {}
331            }
332        }
333    }
334    pub fn end_frame(&mut self) -> Vec<DrawCmd> { std::mem::take(&mut self.draw_cmds) }
335    pub fn key_pressed(&self, key: KeyCode) -> bool { self.just_pressed.contains(&key) }
336    pub fn key_held(&self, key: KeyCode) -> bool { self.held_keys.contains(&key) }
337    pub fn shift(&self) -> bool { self.key_held(KeyCode::Shift) }
338    pub fn ctrl(&self)  -> bool { self.key_held(KeyCode::Ctrl) }
339    pub fn alt(&self)   -> bool { self.key_held(KeyCode::Alt) }
340    pub fn push_layout(&mut self, rect: Rect) { self.layout_stack.push(rect); }
341    pub fn pop_layout(&mut self) -> Option<Rect> { self.layout_stack.pop() }
342    pub fn current_layout(&self) -> Rect {
343        self.layout_stack.last().copied().unwrap_or(Rect::new(0.0, 0.0, self.viewport_w, self.viewport_h))
344    }
345    pub fn get_state(&mut self, id: UiId) -> &mut WidgetStateRetained { self.states.entry(id).or_default() }
346    pub fn is_hovered(&self, rect: &Rect) -> bool { rect.contains(self.mouse_x, self.mouse_y) }
347    pub fn is_focused(&self, id: UiId) -> bool { self.focused_id == Some(id) }
348    pub fn set_focus(&mut self, id: UiId) { self.focused_id = Some(id); }
349    pub fn clear_focus(&mut self) { self.focused_id = None; }
350    pub fn emit(&mut self, cmd: DrawCmd) { self.draw_cmds.push(cmd); }
351    pub fn push_scissor(&mut self, rect: Rect) { self.emit(DrawCmd::Scissor(rect)); }
352    pub fn pop_scissor(&mut self) { self.emit(DrawCmd::PopScissor); }
353    pub fn fill_rect(&mut self, rect: Rect, color: Color) { self.emit(DrawCmd::FillRect { rect, color }); }
354    pub fn rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) { self.emit(DrawCmd::RoundedRect { rect, radius, color }); }
355    pub fn text(&mut self, s: &str, x: f32, y: f32, font_size: f32, color: Color) {
356        self.emit(DrawCmd::Text { text: s.to_string(), x, y, font_size, color, clip: None });
357    }
358    pub fn line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32) {
359        self.emit(DrawCmd::Line { x0, y0, x1, y1, color, width });
360    }
361    pub fn animator(&mut self, id: UiId) -> &mut Animator { self.animators.entry(id).or_insert_with(Animator::new) }
362    pub fn tick_animators(&mut self, dt: f32) { for a in self.animators.values_mut() { a.tick(dt); } }
363}
364
365// ── LayoutEngine ──────────────────────────────────────────────────────────────
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum Direction { Row, Column }
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum Align { Start, Center, End, Stretch }
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum Justify { Start, End, Center, SpaceBetween, SpaceAround, SpaceEvenly }
375
376#[derive(Debug, Clone)]
377pub struct LayoutItem {
378    pub id: UiId, pub min_size: f32, pub max_size: f32,
379    pub flex_grow: f32, pub cross_size: f32,
380}
381
382impl LayoutItem {
383    pub fn new(id: UiId, min_size: f32) -> Self {
384        Self { id, min_size, max_size: f32::MAX, flex_grow: 0.0, cross_size: 0.0 }
385    }
386    pub fn with_flex(mut self, g: f32) -> Self { self.flex_grow = g; self }
387    pub fn with_max(mut self, m: f32)  -> Self { self.max_size  = m; self }
388}
389
390#[derive(Debug, Clone, Copy)]
391pub struct LayoutResult { pub id: UiId, pub rect: Rect }
392
393pub struct LayoutEngine {
394    pub direction: Direction, pub align: Align, pub justify: Justify,
395    pub wrap: bool, pub gap: f32,
396}
397
398impl LayoutEngine {
399    pub fn row()    -> Self { Self { direction: Direction::Row,    align: Align::Start, justify: Justify::Start, wrap: false, gap: 0.0 } }
400    pub fn column() -> Self { Self { direction: Direction::Column, align: Align::Start, justify: Justify::Start, wrap: false, gap: 0.0 } }
401    pub fn with_align(mut self, a: Align) -> Self   { self.align   = a; self }
402    pub fn with_justify(mut self, j: Justify) -> Self { self.justify = j; self }
403    pub fn with_gap(mut self, g: f32) -> Self   { self.gap   = g; self }
404    pub fn with_wrap(mut self) -> Self          { self.wrap  = true; self }
405
406    pub fn arrange(&self, container: Rect, items: &[LayoutItem]) -> Vec<LayoutResult> {
407        if items.is_empty() { return Vec::new(); }
408        let is_row = self.direction == Direction::Row;
409        let main   = if is_row { container.w } else { container.h };
410        let cross  = if is_row { container.h } else { container.w };
411        let n      = items.len();
412        let gaps   = self.gap * n.saturating_sub(1) as f32;
413        let mut sizes: Vec<f32> = items.iter().map(|i| i.min_size).collect();
414        let total_min: f32 = sizes.iter().sum::<f32>() + gaps;
415        let leftover = (main - total_min).max(0.0);
416        let total_flex: f32 = items.iter().map(|i| i.flex_grow).sum();
417        if total_flex > 0.0 && leftover > 0.0 {
418            for (i, item) in items.iter().enumerate() {
419                if item.flex_grow > 0.0 {
420                    sizes[i] = (sizes[i] + leftover * item.flex_grow / total_flex).min(item.max_size);
421                }
422            }
423        }
424        let total_used: f32 = sizes.iter().sum::<f32>() + gaps;
425        let mut cursor = match self.justify {
426            Justify::Start        => 0.0,
427            Justify::End          => main - total_used,
428            Justify::Center       => (main - total_used) * 0.5,
429            Justify::SpaceBetween => 0.0,
430            Justify::SpaceAround  => if n > 0 { (main-total_used)/(n as f32*2.0) } else { 0.0 },
431            Justify::SpaceEvenly  => if n > 0 { (main-total_used)/(n as f32+1.0) } else { 0.0 },
432        };
433        let gap_between = match self.justify {
434            Justify::SpaceBetween => if n > 1 { (main-total_used)/(n-1) as f32 } else { 0.0 },
435            Justify::SpaceAround  => (main-total_used)/n as f32,
436            Justify::SpaceEvenly  => (main-total_used)/(n as f32+1.0),
437            _                     => self.gap,
438        };
439        let mut results = Vec::with_capacity(n);
440        for (i, item) in items.iter().enumerate() {
441            let im = sizes[i];
442            let ic = if item.cross_size > 0.0 { item.cross_size } else { cross };
443            let co = match self.align {
444                Align::Start | Align::Stretch => 0.0,
445                Align::End    => cross - ic,
446                Align::Center => (cross - ic) * 0.5,
447            };
448            let (x, y, w, h) = if is_row { (container.x+cursor, container.y+co, im, ic) }
449                               else       { (container.x+co, container.y+cursor, ic, im) };
450            results.push(LayoutResult { id: item.id, rect: Rect::new(x, y, w, h) });
451            cursor += im;
452            if i + 1 < n { cursor += gap_between; }
453        }
454        results
455    }
456}
457
458// ── UiTheme ───────────────────────────────────────────────────────────────────
459
460#[derive(Debug, Clone)]
461pub struct UiTheme {
462    pub background: Color, pub surface: Color, pub surface_variant: Color,
463    pub border: Color, pub text_primary: Color, pub text_secondary: Color,
464    pub text_disabled: Color, pub accent: Color, pub accent_hover: Color,
465    pub accent_active: Color, pub error: Color, pub warning: Color,
466    pub success: Color, pub info: Color, pub hover_overlay: Color,
467    pub focus_outline: Color, pub shadow: Color,
468}
469
470impl UiTheme {
471    pub fn apply_to_style(&self, style: &mut UiStyle) {
472        style.fg = self.text_primary; style.bg = self.surface;
473        style.border = self.border; style.hover = self.hover_overlay;
474        style.active = self.accent_active;
475    }
476    pub fn dark_theme() -> Self {
477        Self {
478            background: Color::from_u8(18,18,21,255), surface: Color::from_u8(30,30,35,255),
479            surface_variant: Color::from_u8(40,40,48,255), border: Color::from_u8(60,60,72,255),
480            text_primary: Color::from_u8(220,220,225,255), text_secondary: Color::from_u8(150,150,160,255),
481            text_disabled: Color::from_u8(90,90,100,140), accent: Color::from_u8(80,120,240,255),
482            accent_hover: Color::from_u8(100,140,255,255), accent_active: Color::from_u8(60,100,220,255),
483            error: Color::from_u8(220,60,60,255), warning: Color::from_u8(230,160,40,255),
484            success: Color::from_u8(60,200,100,255), info: Color::from_u8(80,160,230,255),
485            hover_overlay: Color::from_u8(255,255,255,20), focus_outline: Color::from_u8(80,120,240,200),
486            shadow: Color::from_u8(0,0,0,80),
487        }
488    }
489    pub fn light_theme() -> Self {
490        Self {
491            background: Color::from_u8(245,245,248,255), surface: Color::from_u8(255,255,255,255),
492            surface_variant: Color::from_u8(235,235,240,255), border: Color::from_u8(200,200,210,255),
493            text_primary: Color::from_u8(20,20,25,255), text_secondary: Color::from_u8(90,90,100,255),
494            text_disabled: Color::from_u8(160,160,170,200), accent: Color::from_u8(50,100,220,255),
495            accent_hover: Color::from_u8(30,80,200,255), accent_active: Color::from_u8(20,60,180,255),
496            error: Color::from_u8(200,40,40,255), warning: Color::from_u8(200,130,20,255),
497            success: Color::from_u8(30,160,70,255), info: Color::from_u8(40,130,210,255),
498            hover_overlay: Color::from_u8(0,0,0,15), focus_outline: Color::from_u8(50,100,220,200),
499            shadow: Color::from_u8(0,0,0,30),
500        }
501    }
502}
503
504// ── Easing ────────────────────────────────────────────────────────────────────
505
506#[derive(Debug, Clone, Copy, PartialEq)]
507pub enum Easing {
508    Linear, EaseIn, EaseOut, EaseInOut,
509    Spring { stiffness: f32, damping: f32 },
510}
511
512impl Easing {
513    pub fn apply(&self, t: f32) -> f32 {
514        let t = t.clamp(0.0, 1.0);
515        match self {
516            Easing::Linear    => t,
517            Easing::EaseIn    => t * t,
518            Easing::EaseOut   => 1.0 - (1.0-t)*(1.0-t),
519            Easing::EaseInOut => if t < 0.5 { 2.0*t*t } else { 1.0 - (-2.0*t+2.0).powi(2)*0.5 },
520            Easing::Spring { .. } => { let c = 1.70158; let t2 = t-1.0; t2*t2*((c+1.0)*t2+c)+1.0 }
521        }
522    }
523}
524
525// ── Animator ─────────────────────────────────────────────────────────────────
526
527#[derive(Debug, Clone)]
528pub struct Animator {
529    pub value: f32, pub target: f32, pub duration: f32,
530    elapsed: f32, start: f32, pub easing: Easing,
531}
532
533impl Animator {
534    pub fn new() -> Self {
535        Self { value: 0.0, target: 0.0, duration: 0.15, elapsed: 0.0, start: 0.0, easing: Easing::EaseOut }
536    }
537    pub fn with_duration(mut self, s: f32) -> Self { self.duration = s; self }
538    pub fn with_easing(mut self, e: Easing) -> Self { self.easing = e; self }
539    pub fn set_target(&mut self, t: f32) {
540        if (self.target - t).abs() > 1e-5 {
541            self.start = self.value; self.target = t; self.elapsed = 0.0;
542        }
543    }
544    pub fn tick(&mut self, dt: f32) {
545        if (self.value - self.target).abs() < 1e-5 { self.value = self.target; return; }
546        self.elapsed += dt;
547        let t = (self.elapsed / self.duration.max(1e-6)).min(1.0);
548        self.value = self.start + (self.target - self.start) * self.easing.apply(t);
549    }
550    pub fn get(&self) -> f32 { self.value }
551    pub fn is_done(&self) -> bool { (self.value - self.target).abs() < 1e-4 }
552    pub fn snap(&mut self, v: f32) { self.value = v; self.target = v; self.elapsed = self.duration; }
553}
554
555impl Default for Animator { fn default() -> Self { Self::new() } }
556
557// ── TooltipSystem ─────────────────────────────────────────────────────────────
558
559pub struct TooltipSystem {
560    hover_id: Option<UiId>, hover_time: f32,
561    pub delay: f32, pub max_width: f32, pub font_size: f32,
562    pub bg: Color, pub fg: Color, pub border: Color, pub padding: f32,
563}
564
565impl TooltipSystem {
566    pub fn new() -> Self {
567        Self {
568            hover_id: None, hover_time: 0.0, delay: 0.5, max_width: 200.0, font_size: 12.0,
569            bg: Color::from_u8(40,40,48,240), fg: Color::WHITE,
570            border: Color::from_u8(80,80,100,255), padding: 6.0,
571        }
572    }
573    pub fn update(&mut self, id: Option<UiId>, dt: f32) {
574        if self.hover_id == id { self.hover_time += dt; } else { self.hover_id = id; self.hover_time = 0.0; }
575    }
576    pub fn should_show(&self, id: UiId) -> bool {
577        self.hover_id == Some(id) && self.hover_time >= self.delay
578    }
579    pub fn compute_rect(&self, mx: f32, my: f32, text: &str, vw: f32, vh: f32) -> Rect {
580        let cw = self.font_size * 0.6;
581        let tw = (text.len() as f32 * cw).min(self.max_width);
582        let w  = tw + self.padding * 2.0;
583        let h  = self.font_size + 4.0 + self.padding * 2.0;
584        let mut x = mx + 8.0; let mut y = my + 20.0;
585        if x + w > vw { x = vw - w - 4.0; }
586        if x < 0.0    { x = 4.0; }
587        if y + h > vh { y = my - h - 4.0; }
588        if y < 0.0    { y = my + 20.0; }
589        Rect::new(x, y, w, h)
590    }
591    pub fn render(&self, ctx: &mut UiContext, id: UiId, text: &str) {
592        if !self.should_show(id) { return; }
593        let rect = self.compute_rect(ctx.mouse_x, ctx.mouse_y, text, ctx.viewport_w, ctx.viewport_h);
594        ctx.emit(DrawCmd::RoundedRect { rect: rect.expand(1.0), radius: 4.0, color: self.border });
595        ctx.emit(DrawCmd::RoundedRect { rect, radius: 4.0, color: self.bg });
596        ctx.emit(DrawCmd::Text {
597            text: text.to_string(), x: rect.x + self.padding, y: rect.y + self.padding,
598            font_size: self.font_size, color: self.fg, clip: Some(rect),
599        });
600    }
601}
602
603impl Default for TooltipSystem { fn default() -> Self { Self::new() } }
604
605// ── Tests ─────────────────────────────────────────────────────────────────────
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn uid_hashing_is_stable() { assert_eq!(UiId::new("button_ok"), UiId::new("button_ok")); }
613
614    #[test]
615    fn uid_different_labels() { assert_ne!(UiId::new("foo"), UiId::new("bar")); }
616
617    #[test]
618    fn rect_contains_basic() {
619        let r = Rect::new(10.0, 10.0, 100.0, 50.0);
620        assert!(r.contains(50.0, 30.0));
621        assert!(!r.contains(5.0, 30.0));
622    }
623
624    #[test]
625    fn rect_split_left() {
626        let (left, right) = Rect::new(0.0, 0.0, 200.0, 100.0).split_left(60.0);
627        assert!((left.w  - 60.0).abs() < 1e-4);
628        assert!((right.w - 140.0).abs() < 1e-4);
629    }
630
631    #[test]
632    fn rect_split_top() {
633        let (top, bot) = Rect::new(0.0, 0.0, 200.0, 100.0).split_top(40.0);
634        assert!((top.h - 40.0).abs() < 1e-4);
635        assert!((bot.h - 60.0).abs() < 1e-4);
636    }
637
638    #[test]
639    fn rect_intersect_overlap() {
640        let a = Rect::new(0.0, 0.0, 100.0, 100.0);
641        let b = Rect::new(50.0, 50.0, 100.0, 100.0);
642        assert!(a.intersect(&b).is_some());
643    }
644
645    #[test]
646    fn rect_intersect_no_overlap() {
647        let a = Rect::new(0.0, 0.0, 10.0, 10.0);
648        let b = Rect::new(20.0, 20.0, 10.0, 10.0);
649        assert!(a.intersect(&b).is_none());
650    }
651
652    #[test]
653    fn color_lerp() { assert!((Color::BLACK.lerp(Color::WHITE, 0.5).r - 0.5).abs() < 1e-5); }
654
655    #[test]
656    fn color_hsv_roundtrip() {
657        let c = Color::rgb(0.8, 0.3, 0.1);
658        let (h, s, v) = c.to_hsv();
659        let c2 = Color::from_hsv(h, s, v);
660        assert!((c.r - c2.r).abs() < 0.01);
661    }
662
663    #[test]
664    fn animator_reaches_target() {
665        let mut a = Animator::new();
666        a.set_target(1.0);
667        for _ in 0..100 { a.tick(0.01); }
668        assert!((a.get() - 1.0).abs() < 0.01);
669    }
670
671    #[test]
672    fn easing_linear() { assert!((Easing::Linear.apply(0.5) - 0.5).abs() < 1e-5); }
673
674    #[test]
675    fn easing_ease_in_out_midpoint() { assert!((Easing::EaseInOut.apply(0.5) - 0.5).abs() < 0.01); }
676
677    #[test]
678    fn layout_engine_row_fills() {
679        let engine    = LayoutEngine::row().with_gap(4.0);
680        let container = Rect::new(0.0, 0.0, 200.0, 50.0);
681        let items = vec![
682            LayoutItem::new(UiId::new("a"), 40.0).with_flex(1.0),
683            LayoutItem::new(UiId::new("b"), 40.0).with_flex(1.0),
684        ];
685        let results = engine.arrange(container, &items);
686        assert_eq!(results.len(), 2);
687        assert!((results[0].rect.w + results[1].rect.w - 196.0).abs() < 1.0);
688    }
689
690    #[test]
691    fn theme_dark_distinct() {
692        let t = UiTheme::dark_theme();
693        assert!(t.background.r + t.background.g + t.background.b
694             <= t.surface.r + t.surface.g + t.surface.b + 0.01);
695    }
696
697    #[test]
698    fn tooltip_avoids_right_edge() {
699        let tt   = TooltipSystem::new();
700        let rect = tt.compute_rect(1900.0, 100.0, "Some tooltip text here", 1920.0, 1080.0);
701        assert!(rect.x + rect.w <= 1920.0);
702    }
703
704    #[test]
705    fn ui_context_mouse_move() {
706        let mut ctx = UiContext::new(800.0, 600.0);
707        ctx.push_event(InputEvent::MouseMove { x: 100.0, y: 200.0 });
708        ctx.begin_frame();
709        assert!((ctx.mouse_x - 100.0).abs() < 1e-4);
710    }
711}