1use std::collections::HashMap;
14
15#[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#[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#[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#[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 pub fn clear(&mut self) {
282 self.commands.clear();
283 self.layers.clear();
284 }
285}
286
287#[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#[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#[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#[derive(Debug, Clone, Default)]
336pub struct WidgetState {
337 pub hovered: bool,
338 pub pressed: bool,
339 pub focused: bool,
340 pub hover_anim: f32, pub press_anim: f32, pub alpha: f32, pub translate_y: f32, }
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#[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 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 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, 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
453pub 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#[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#[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#[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 dl.push(DrawCommand::Rect { rect: track, color: theme.surface, radius: track_h * 0.5 });
645
646 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 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 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#[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 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#[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 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 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#[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 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 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#[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 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#[derive(Debug)]
1073pub struct ProgressBar {
1074 pub id: WidgetId,
1075 pub rect: Rect,
1076 pub value: f32, 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#[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 dl.push(DrawCommand::Shadow { rect: self.rect.expand(4.0), color: theme.shadow_color, blur: 12.0, offset: (0.0, 4.0) });
1180 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#[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#[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#[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
1357pub 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 pub fn frame(&mut self, input: &UiInput, dt: f32) {
1397 self.elapsed += dt;
1398 self.events.clear();
1399 self.draw.clear();
1400
1401 for w in &mut self.widgets {
1403 w.update(dt);
1404 }
1405
1406 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 let theme = &self.theme;
1415 let dl = &mut self.draw;
1416 for w in &self.widgets {
1417 w.draw(dl, theme);
1418 }
1419
1420 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#[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 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); }
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); 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}