1pub mod widgets;
7pub mod panels;
8pub mod layout;
9pub mod framework;
10
11pub 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
20pub use widgets::{
22 UiLabel, UiProgressBar, UiButton, UiPanel, UiPulseRing,
23};
24
25pub 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#[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#[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#[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#[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#[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#[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#[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
279pub 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#[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#[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#[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#[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
557pub 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#[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}