1use crate::ui::{Rect, Color, DrawCmd, UiContext, UiId, UiStyle};
7
8#[derive(Debug)]
13pub struct Window {
14 pub id: UiId,
15 pub title: String,
16 pub rect: Rect,
17 pub min_w: f32,
18 pub min_h: f32,
19 pub visible: bool,
20 pub minimized: bool,
21 pub maximized: bool,
22 pub dockable: bool,
23 pub z_order: i32,
24 drag_offset_x: f32,
25 drag_offset_y: f32,
26 dragging_title: bool,
27 resize_dir: Option<ResizeDir>,
28 resize_start: Rect,
29 hover_close: bool,
30 hover_min: bool,
31 hover_max: bool,
32 pub closed: bool,
33 pub focus_taken: bool,
34 pre_max_rect: Rect,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum ResizeDir { N, S, E, W, NE, NW, SE, SW }
39
40const TITLE_H: f32 = 28.0;
41const RESIZE_PAD: f32 = 6.0;
42const BTN_W: f32 = 18.0;
43
44impl Window {
45 pub fn new(id: UiId, title: impl Into<String>, rect: Rect) -> Self {
46 Self {
47 id,
48 title: title.into(),
49 rect,
50 min_w: 120.0,
51 min_h: 80.0,
52 visible: true,
53 minimized: false,
54 maximized: false,
55 dockable: false,
56 z_order: 0,
57 drag_offset_x: 0.0,
58 drag_offset_y: 0.0,
59 dragging_title: false,
60 resize_dir: None,
61 resize_start: Rect::zero(),
62 hover_close: false,
63 hover_min: false,
64 hover_max: false,
65 closed: false,
66 focus_taken: false,
67 pre_max_rect: Rect::zero(),
68 }
69 }
70
71 pub fn with_dockable(mut self) -> Self { self.dockable = true; self }
72 pub fn with_z(mut self, z: i32) -> Self { self.z_order = z; self }
73
74 fn title_bar_rect(&self) -> Rect {
75 Rect::new(self.rect.x, self.rect.y, self.rect.w, TITLE_H)
76 }
77
78 fn close_btn(&self) -> Rect {
79 Rect::new(self.rect.max_x() - BTN_W - 4.0, self.rect.y + 5.0, BTN_W, TITLE_H - 10.0)
80 }
81
82 fn max_btn(&self) -> Rect {
83 Rect::new(self.rect.max_x() - BTN_W * 2.0 - 8.0, self.rect.y + 5.0, BTN_W, TITLE_H - 10.0)
84 }
85
86 fn min_btn(&self) -> Rect {
87 Rect::new(self.rect.max_x() - BTN_W * 3.0 - 12.0, self.rect.y + 5.0, BTN_W, TITLE_H - 10.0)
88 }
89
90 fn content_rect(&self) -> Rect {
91 Rect::new(self.rect.x, self.rect.y + TITLE_H, self.rect.w, self.rect.h - TITLE_H)
92 }
93
94 fn resize_dir_for(&self, mx: f32, my: f32) -> Option<ResizeDir> {
95 if !self.rect.contains(mx, my) { return None; }
96 let p = RESIZE_PAD;
97 let near_l = mx < self.rect.x + p;
98 let near_r = mx > self.rect.max_x() - p;
99 let near_t = my < self.rect.y + p;
100 let near_b = my > self.rect.max_y() - p;
101
102 match (near_l, near_r, near_t, near_b) {
103 (true, false, true, false) => Some(ResizeDir::NW),
104 (false, true, true, false) => Some(ResizeDir::NE),
105 (true, false, false, true) => Some(ResizeDir::SW),
106 (false, true, false, true) => Some(ResizeDir::SE),
107 (true, false, false, false) => Some(ResizeDir::W),
108 (false, true, false, false) => Some(ResizeDir::E),
109 (false, false, true, false) => Some(ResizeDir::N),
110 (false, false, false, true) => Some(ResizeDir::S),
111 _ => None,
112 }
113 }
114
115 pub fn update(&mut self, ctx: &mut UiContext, viewport_w: f32, viewport_h: f32) {
117 if !self.visible { return; }
118 self.closed = false;
119 self.focus_taken = false;
120
121 let mx = ctx.mouse_x;
122 let my = ctx.mouse_y;
123
124 self.hover_close = self.close_btn().contains(mx, my);
126 self.hover_min = self.min_btn().contains(mx, my);
127 self.hover_max = self.max_btn().contains(mx, my);
128
129 if ctx.mouse_just_pressed {
131 if self.hover_close { self.closed = true; self.visible = false; return; }
132 if self.hover_min { self.minimized = !self.minimized; }
133 if self.hover_max {
134 if self.maximized {
135 self.rect = self.pre_max_rect;
136 self.maximized = false;
137 } else {
138 self.pre_max_rect = self.rect;
139 self.rect = Rect::new(0.0, 0.0, viewport_w, viewport_h);
140 self.maximized = true;
141 }
142 }
143 }
144
145 if self.minimized || self.maximized { return; }
146
147 let tb = self.title_bar_rect();
149 if tb.contains(mx, my) && ctx.mouse_just_pressed
150 && !self.hover_close && !self.hover_min && !self.hover_max
151 {
152 self.dragging_title = true;
153 self.drag_offset_x = mx - self.rect.x;
154 self.drag_offset_y = my - self.rect.y;
155 self.focus_taken = true;
156 }
157 if !ctx.mouse_down { self.dragging_title = false; }
158
159 if self.dragging_title {
160 self.rect.x = mx - self.drag_offset_x;
161 self.rect.y = my - self.drag_offset_y;
162
163 if self.dockable {
165 let snap = 16.0;
166 if self.rect.x < snap { self.rect.x = 0.0; }
167 if self.rect.y < snap { self.rect.y = 0.0; }
168 if self.rect.max_x() > viewport_w - snap { self.rect.x = viewport_w - self.rect.w; }
169 if self.rect.max_y() > viewport_h - snap { self.rect.y = viewport_h - self.rect.h; }
170 }
171 }
172
173 if ctx.mouse_just_pressed && self.resize_dir.is_none() {
175 self.resize_dir = self.resize_dir_for(mx, my);
176 self.resize_start = self.rect;
177 }
178 if !ctx.mouse_down { self.resize_dir = None; }
179
180 if let Some(dir) = self.resize_dir {
181 let dx = mx - ctx.mouse_x; let dy = my - ctx.mouse_y;
183 let _ = (dx, dy);
184 let new_x = mx;
185 let new_y = my;
186 let orig = self.resize_start;
187
188 match dir {
189 ResizeDir::E => { self.rect.w = (new_x - orig.x).max(self.min_w); }
190 ResizeDir::S => { self.rect.h = (new_y - orig.y).max(self.min_h); }
191 ResizeDir::W => {
192 let new_w = (orig.max_x() - new_x).max(self.min_w);
193 self.rect.x = orig.max_x() - new_w;
194 self.rect.w = new_w;
195 }
196 ResizeDir::N => {
197 let new_h = (orig.max_y() - new_y).max(self.min_h);
198 self.rect.y = orig.max_y() - new_h;
199 self.rect.h = new_h;
200 }
201 ResizeDir::SE => {
202 self.rect.w = (new_x - orig.x).max(self.min_w);
203 self.rect.h = (new_y - orig.y).max(self.min_h);
204 }
205 ResizeDir::SW => {
206 let new_w = (orig.max_x() - new_x).max(self.min_w);
207 self.rect.x = orig.max_x() - new_w;
208 self.rect.w = new_w;
209 self.rect.h = (new_y - orig.y).max(self.min_h);
210 }
211 ResizeDir::NE => {
212 self.rect.w = (new_x - orig.x).max(self.min_w);
213 let new_h = (orig.max_y() - new_y).max(self.min_h);
214 self.rect.y = orig.max_y() - new_h;
215 self.rect.h = new_h;
216 }
217 ResizeDir::NW => {
218 let new_w = (orig.max_x() - new_x).max(self.min_w);
219 self.rect.x = orig.max_x() - new_w;
220 self.rect.w = new_w;
221 let new_h = (orig.max_y() - new_y).max(self.min_h);
222 self.rect.y = orig.max_y() - new_h;
223 self.rect.h = new_h;
224 }
225 }
226 }
227 }
228
229 pub fn draw(&self, ctx: &mut UiContext, style: &UiStyle) -> Rect {
231 if !self.visible { return Rect::zero(); }
232
233 let shadow_r = self.rect.expand(4.0);
234 ctx.emit(DrawCmd::RoundedRect { rect: shadow_r, radius: style.border_radius + 2.0, color: Color::BLACK.with_alpha(0.3) });
235
236 let body_r = if self.minimized {
237 Rect::new(self.rect.x, self.rect.y, self.rect.w, TITLE_H)
238 } else {
239 self.rect
240 };
241
242 ctx.emit(DrawCmd::RoundedRect { rect: body_r, radius: style.border_radius, color: style.bg });
243 ctx.emit(DrawCmd::RoundedRectStroke { rect: body_r, radius: style.border_radius, color: style.border, width: style.border_width });
244
245 let tb_color = style.active.with_alpha(0.8);
247 ctx.emit(DrawCmd::RoundedRect { rect: Rect::new(self.rect.x, self.rect.y, self.rect.w, TITLE_H), radius: style.border_radius, color: tb_color });
248 ctx.emit(DrawCmd::Text {
249 text: self.title.clone(),
250 x: self.rect.x + style.padding,
251 y: self.rect.y + (TITLE_H - style.font_size) * 0.5,
252 font_size: style.font_size,
253 color: style.fg,
254 clip: Some(self.title_bar_rect()),
255 });
256
257 for (rect, label, hovered) in [
259 (self.close_btn(), "✕", self.hover_close),
260 (self.max_btn(), "□", self.hover_max),
261 (self.min_btn(), "─", self.hover_min),
262 ] {
263 let btn_color = if hovered { style.hover } else { style.bg };
264 ctx.emit(DrawCmd::RoundedRect { rect, radius: 3.0, color: btn_color });
265 ctx.emit(DrawCmd::Text {
266 text: label.to_string(), x: rect.center_x() - style.font_size * 0.3,
267 y: rect.center_y() - style.font_size * 0.5,
268 font_size: style.font_size * 0.8, color: style.fg, clip: Some(rect),
269 });
270 }
271
272 self.content_rect()
273 }
274}
275
276pub type DockableWindow = Window;
280
281#[derive(Debug)]
285pub struct SplitPane {
286 pub id: UiId,
287 pub horizontal: bool, pub ratio: f32, pub min_ratio: f32,
290 pub max_ratio: f32,
291 dragging: bool,
292 hover_anim: f32,
293}
294
295const SPLIT_HANDLE: f32 = 6.0;
296
297impl SplitPane {
298 pub fn new(id: UiId, horizontal: bool) -> Self {
299 Self { id, horizontal, ratio: 0.5, min_ratio: 0.1, max_ratio: 0.9, dragging: false, hover_anim: 0.0 }
300 }
301
302 pub fn with_ratio(mut self, r: f32) -> Self { self.ratio = r.clamp(0.01, 0.99); self }
303
304 pub fn pane_rects(&self, rect: Rect) -> (Rect, Rect) {
306 if self.horizontal {
307 let split_x = rect.x + rect.w * self.ratio;
308 (
309 Rect::new(rect.x, rect.y, split_x - rect.x - SPLIT_HANDLE * 0.5, rect.h),
310 Rect::new(split_x + SPLIT_HANDLE * 0.5, rect.y, rect.max_x() - split_x - SPLIT_HANDLE * 0.5, rect.h),
311 )
312 } else {
313 let split_y = rect.y + rect.h * self.ratio;
314 (
315 Rect::new(rect.x, rect.y, rect.w, split_y - rect.y - SPLIT_HANDLE * 0.5),
316 Rect::new(rect.x, split_y + SPLIT_HANDLE * 0.5, rect.w, rect.max_y() - split_y - SPLIT_HANDLE * 0.5),
317 )
318 }
319 }
320
321 fn handle_rect(&self, rect: Rect) -> Rect {
322 if self.horizontal {
323 let split_x = rect.x + rect.w * self.ratio - SPLIT_HANDLE * 0.5;
324 Rect::new(split_x, rect.y, SPLIT_HANDLE, rect.h)
325 } else {
326 let split_y = rect.y + rect.h * self.ratio - SPLIT_HANDLE * 0.5;
327 Rect::new(rect.x, split_y, rect.w, SPLIT_HANDLE)
328 }
329 }
330
331 pub fn update(&mut self, ctx: &mut UiContext, rect: Rect, dt: f32) {
332 let handle = self.handle_rect(rect);
333 let hovered = handle.contains(ctx.mouse_x, ctx.mouse_y);
334 let target = if hovered || self.dragging { 1.0_f32 } else { 0.0 };
335 self.hover_anim += (target - self.hover_anim) * (10.0 * dt).min(1.0);
336
337 if hovered && ctx.mouse_just_pressed { self.dragging = true; }
338 if !ctx.mouse_down { self.dragging = false; }
339
340 if self.dragging {
341 if self.horizontal {
342 self.ratio = ((ctx.mouse_x - rect.x) / rect.w.max(1.0)).clamp(self.min_ratio, self.max_ratio);
343 } else {
344 self.ratio = ((ctx.mouse_y - rect.y) / rect.h.max(1.0)).clamp(self.min_ratio, self.max_ratio);
345 }
346 }
347 }
348
349 pub fn draw(&self, ctx: &mut UiContext, rect: Rect, style: &UiStyle) {
350 let handle = self.handle_rect(rect);
351 let hv_color = style.border.lerp(style.fg, self.hover_anim * 0.4);
352 ctx.emit(DrawCmd::FillRect { rect: handle, color: hv_color });
353
354 if self.horizontal {
356 let cx = handle.center_x();
357 for i in -2_i32..=2 {
358 let cy = handle.center_y() + i as f32 * 5.0;
359 ctx.emit(DrawCmd::Circle { cx, cy, radius: 2.0, color: style.fg.with_alpha(0.4 + self.hover_anim * 0.4) });
360 }
361 } else {
362 let cy = handle.center_y();
363 for i in -2_i32..=2 {
364 let cx = handle.center_x() + i as f32 * 5.0;
365 ctx.emit(DrawCmd::Circle { cx, cy, radius: 2.0, color: style.fg.with_alpha(0.4 + self.hover_anim * 0.4) });
366 }
367 }
368 }
369
370 pub fn serialize(&self) -> f32 { self.ratio }
372
373 pub fn deserialize(&mut self, ratio: f32) {
375 self.ratio = ratio.clamp(self.min_ratio, self.max_ratio);
376 }
377}
378
379#[derive(Debug, Clone)]
383pub struct Tab {
384 pub id: UiId,
385 pub label: String,
386 pub closeable: bool,
387 pub pinned: bool,
388}
389
390impl Tab {
391 pub fn new(id: UiId, label: impl Into<String>) -> Self {
392 Self { id, label: label.into(), closeable: true, pinned: false }
393 }
394 pub fn pinned(mut self) -> Self { self.pinned = true; self.closeable = false; self }
395}
396
397#[derive(Debug)]
399pub struct TabBar {
400 pub id: UiId,
401 pub tabs: Vec<Tab>,
402 pub active: Option<UiId>,
403 scroll_offset: f32,
404 drag_tab: Option<usize>,
405 drag_start_x: f32,
406 hover_tab: Option<usize>,
407 pub closed: Option<UiId>,
408 pub changed: bool,
409 pub reordered: bool,
410}
411
412const TAB_H: f32 = 32.0;
413const TAB_MIN_W: f32 = 80.0;
414const TAB_MAX_W: f32 = 160.0;
415
416impl TabBar {
417 pub fn new(id: UiId) -> Self {
418 Self {
419 id, tabs: Vec::new(), active: None, scroll_offset: 0.0,
420 drag_tab: None, drag_start_x: 0.0, hover_tab: None,
421 closed: None, changed: false, reordered: false,
422 }
423 }
424
425 pub fn add_tab(&mut self, tab: Tab) {
426 if self.active.is_none() { self.active = Some(tab.id); }
427 self.tabs.push(tab);
428 }
429
430 pub fn remove_tab(&mut self, id: UiId) {
431 self.tabs.retain(|t| t.id != id);
432 if self.active == Some(id) {
433 self.active = self.tabs.first().map(|t| t.id);
434 }
435 }
436
437 pub fn tab_width(&self, available_w: f32) -> f32 {
438 let n = self.tabs.len().max(1) as f32;
439 ((available_w / n) - 4.0).clamp(TAB_MIN_W, TAB_MAX_W)
440 }
441
442 pub fn update(&mut self, ctx: &mut UiContext, rect: Rect, dt: f32) {
443 self.changed = false;
444 self.reordered = false;
445 self.closed = None;
446 let tab_w = self.tab_width(rect.w);
447
448 if ctx.key_pressed(crate::ui::KeyCode::Left) { self.scroll_offset = (self.scroll_offset - tab_w).max(0.0); }
450 if ctx.key_pressed(crate::ui::KeyCode::Right) {
451 let max_scroll = (self.tabs.len() as f32 * (tab_w + 4.0) - rect.w).max(0.0);
452 self.scroll_offset = (self.scroll_offset + tab_w).min(max_scroll);
453 }
454
455 let mut new_hover = None;
456 for (i, tab) in self.tabs.iter().enumerate() {
457 let tx = rect.x + i as f32 * (tab_w + 4.0) - self.scroll_offset;
458 let trect = Rect::new(tx, rect.y, tab_w, TAB_H);
459 if trect.contains(ctx.mouse_x, ctx.mouse_y) {
460 new_hover = Some(i);
461 if ctx.mouse_just_pressed {
462 self.active = Some(tab.id);
463 self.changed = true;
464 if !tab.pinned {
466 self.drag_tab = Some(i);
467 self.drag_start_x = ctx.mouse_x;
468 }
469 }
470
471 let close_r = Rect::new(trect.max_x() - 16.0, trect.y + 7.0, 14.0, 14.0);
473 if tab.closeable && close_r.contains(ctx.mouse_x, ctx.mouse_y) && ctx.mouse_just_pressed {
474 self.closed = Some(tab.id);
475 }
476 }
477 }
478
479 self.hover_tab = new_hover;
480
481 if !ctx.mouse_down { self.drag_tab = None; }
483 if let Some(src) = self.drag_tab {
484 let dx = ctx.mouse_x - self.drag_start_x;
485 let tab_step = tab_w + 4.0;
486 let shift = (dx / tab_step).round() as i32;
487 if shift != 0 {
488 let dst = (src as i32 + shift).clamp(0, self.tabs.len() as i32 - 1) as usize;
489 if dst != src {
490 self.tabs.swap(src, dst);
491 self.drag_tab = Some(dst);
492 self.drag_start_x = ctx.mouse_x;
493 self.reordered = true;
494 }
495 }
496 }
497
498 let _ = dt;
499 }
500
501 pub fn draw(&self, ctx: &mut UiContext, rect: Rect, style: &UiStyle) {
502 let tab_w = self.tab_width(rect.w);
503 ctx.push_scissor(rect);
504
505 ctx.emit(DrawCmd::Line {
507 x0: rect.x, y0: rect.max_y(), x1: rect.max_x(), y1: rect.max_y(),
508 color: style.border, width: style.border_width,
509 });
510
511 for (i, tab) in self.tabs.iter().enumerate() {
512 let tx = rect.x + i as f32 * (tab_w + 4.0) - self.scroll_offset;
513 let trect = Rect::new(tx, rect.y, tab_w, TAB_H);
514 let is_act = self.active == Some(tab.id);
515 let is_hov = self.hover_tab == Some(i);
516
517 let bg = if is_act { style.surface_color() } else if is_hov { style.hover } else { style.bg };
518 ctx.emit(DrawCmd::RoundedRect { rect: Rect::new(trect.x, trect.y, trect.w, trect.h + if is_act { 2.0 } else { 0.0 }), radius: 4.0, color: bg });
519
520 if !is_act {
521 ctx.emit(DrawCmd::RoundedRectStroke { rect: trect, radius: 4.0, color: style.border, width: style.border_width });
522 }
523
524 let label_x = trect.x + style.padding;
525 ctx.emit(DrawCmd::Text {
526 text: tab.label.clone(),
527 x: label_x,
528 y: trect.center_y() - style.font_size * 0.5,
529 font_size: style.font_size,
530 color: if is_act { style.fg } else { style.disabled },
531 clip: Some(trect),
532 });
533
534 if tab.pinned {
536 ctx.emit(DrawCmd::Text {
537 text: "📌".to_string(), x: trect.max_x() - 16.0,
538 y: trect.y + 4.0, font_size: 10.0, color: style.fg, clip: Some(trect),
539 });
540 }
541
542 if tab.closeable {
544 let close_r = Rect::new(trect.max_x() - 16.0, trect.y + 7.0, 14.0, 14.0);
545 ctx.emit(DrawCmd::Text {
546 text: "×".to_string(), x: close_r.center_x() - 4.0,
547 y: close_r.y, font_size: 12.0, color: style.border, clip: Some(trect),
548 });
549 }
550 }
551
552 ctx.pop_scissor();
553 }
554}
555
556pub type TabPanel = TabBar;
558
559trait StyleExt {
562 fn surface_color(&self) -> Color;
563 fn warning(&self) -> Color;
564 fn disabled(&self) -> Color;
565}
566
567impl StyleExt for UiStyle {
568 fn surface_color(&self) -> Color { self.bg.lerp(self.active, 0.15) }
569 fn warning(&self) -> Color { Color::new(0.9, 0.6, 0.1, 1.0) }
570 fn disabled(&self) -> Color { self.fg.with_alpha(0.4) }
571}
572
573#[derive(Debug, Clone)]
577pub enum ToolbarItem {
578 Button { id: UiId, label: String, icon: Option<String>, tooltip: String },
579 Toggle { id: UiId, label: String, icon: Option<String>, active: bool },
580 Separator,
581 Spacer,
582}
583
584#[derive(Debug)]
586pub struct Toolbar {
587 pub id: UiId,
588 pub items: Vec<ToolbarItem>,
589 pub height: f32,
590 pub clicked: Option<UiId>,
591 hover_anims: Vec<f32>,
592 overflow_open: bool,
593}
594
595const TB_BTN_W: f32 = 32.0;
596const TB_SEP_W: f32 = 10.0;
597
598impl Toolbar {
599 pub fn new(id: UiId) -> Self {
600 Self { id, items: Vec::new(), height: 36.0, clicked: None, hover_anims: Vec::new(), overflow_open: false }
601 }
602
603 pub fn add_button(&mut self, id: UiId, label: impl Into<String>, icon: Option<String>, tooltip: impl Into<String>) {
604 self.items.push(ToolbarItem::Button { id, label: label.into(), icon, tooltip: tooltip.into() });
605 self.hover_anims.push(0.0);
606 }
607
608 pub fn add_toggle(&mut self, id: UiId, label: impl Into<String>, icon: Option<String>) {
609 self.items.push(ToolbarItem::Toggle { id, label: label.into(), icon, active: false });
610 self.hover_anims.push(0.0);
611 }
612
613 pub fn add_separator(&mut self) {
614 self.items.push(ToolbarItem::Separator);
615 self.hover_anims.push(0.0);
616 }
617
618 pub fn set_toggle(&mut self, id: UiId, active: bool) {
619 for item in &mut self.items {
620 if let ToolbarItem::Toggle { id: tid, active: act, .. } = item {
621 if *tid == id { *act = active; }
622 }
623 }
624 }
625
626 pub fn update(&mut self, ctx: &mut UiContext, rect: Rect, dt: f32) {
627 self.clicked = None;
628 let mut x = rect.x;
629 let btn_h = rect.h;
630
631 for (i, item) in self.items.iter_mut().enumerate() {
632 match item {
633 ToolbarItem::Button { id, .. } | ToolbarItem::Toggle { id, .. } => {
634 let btn_r = Rect::new(x, rect.y, TB_BTN_W, btn_h);
635 let hov = btn_r.contains(ctx.mouse_x, ctx.mouse_y);
636 let target = if hov { 1.0_f32 } else { 0.0 };
637 if i < self.hover_anims.len() {
638 self.hover_anims[i] += (target - self.hover_anims[i]) * (10.0 * dt).min(1.0);
639 }
640 if hov && ctx.mouse_just_pressed {
641 let id_copy = *id;
642 self.clicked = Some(id_copy);
643 if let ToolbarItem::Toggle { active, .. } = item { *active = !*active; }
644 }
645 x += TB_BTN_W + 2.0;
646 }
647 ToolbarItem::Separator => { x += TB_SEP_W; }
648 ToolbarItem::Spacer => { x += TB_BTN_W; }
649 }
650 }
651 }
652
653 pub fn draw(&self, ctx: &mut UiContext, rect: Rect, style: &UiStyle) {
654 ctx.emit(DrawCmd::FillRect { rect, color: style.bg });
655 ctx.emit(DrawCmd::Line {
656 x0: rect.x, y0: rect.max_y(), x1: rect.max_x(), y1: rect.max_y(),
657 color: style.border, width: style.border_width,
658 });
659
660 let mut x = rect.x;
661 let btn_h = rect.h;
662
663 for (i, item) in self.items.iter().enumerate() {
664 match item {
665 ToolbarItem::Button { label, icon, .. } | ToolbarItem::Toggle { label, icon, active: _, .. } => {
666 let btn_r = Rect::new(x, rect.y, TB_BTN_W, btn_h);
667 let hov = self.hover_anims.get(i).copied().unwrap_or(0.0);
668 let is_act = matches!(item, ToolbarItem::Toggle { active: true, .. });
669 let bg = if is_act { style.active } else { style.bg.lerp(style.hover, hov) };
670
671 ctx.emit(DrawCmd::RoundedRect { rect: btn_r.shrink(2.0), radius: 3.0, color: bg });
672
673 let disp = icon.as_deref().unwrap_or(label.as_str());
674 ctx.emit(DrawCmd::Text {
675 text: disp.to_string(),
676 x: btn_r.center_x() - style.font_size * 0.4,
677 y: btn_r.center_y() - style.font_size * 0.5,
678 font_size: style.font_size,
679 color: style.fg,
680 clip: Some(btn_r),
681 });
682 x += TB_BTN_W + 2.0;
683 }
684 ToolbarItem::Separator => {
685 ctx.emit(DrawCmd::Line {
686 x0: x + TB_SEP_W * 0.5, y0: rect.y + 4.0,
687 x1: x + TB_SEP_W * 0.5, y1: rect.max_y() - 4.0,
688 color: style.border, width: 1.0,
689 });
690 x += TB_SEP_W;
691 }
692 ToolbarItem::Spacer => { x += TB_BTN_W; }
693 }
694 }
695 }
696}
697
698#[derive(Debug)]
702pub struct StatusBar {
703 pub id: UiId,
704 pub left: String,
705 pub center: String,
706 pub right: String,
707 pub progress: Option<f32>,
708 pub height: f32,
709}
710
711impl StatusBar {
712 pub fn new(id: UiId) -> Self {
713 Self { id, left: String::new(), center: String::new(), right: String::new(), progress: None, height: 22.0 }
714 }
715
716 pub fn set_left(&mut self, s: impl Into<String>) { self.left = s.into(); }
717 pub fn set_center(&mut self, s: impl Into<String>) { self.center = s.into(); }
718 pub fn set_right(&mut self, s: impl Into<String>) { self.right = s.into(); }
719 pub fn set_progress(&mut self, v: Option<f32>) { self.progress = v; }
720
721 pub fn draw(&self, ctx: &mut UiContext, rect: Rect, style: &UiStyle) {
722 ctx.emit(DrawCmd::FillRect { rect, color: style.bg });
723 ctx.emit(DrawCmd::Line {
724 x0: rect.x, y0: rect.y, x1: rect.max_x(), y1: rect.y,
725 color: style.border, width: style.border_width,
726 });
727
728 let y = rect.center_y() - style.font_size * 0.5;
729 let fs = style.font_size * 0.85;
730
731 ctx.emit(DrawCmd::Text { text: self.left.clone(), x: rect.x + 4.0, y, font_size: fs, color: style.fg, clip: Some(rect) });
732 ctx.emit(DrawCmd::Text { text: self.center.clone(), x: rect.center_x(), y, font_size: fs, color: style.fg, clip: Some(rect) });
733 ctx.emit(DrawCmd::Text { text: self.right.clone(), x: rect.max_x() - self.right.len() as f32 * fs * 0.6 - 4.0, y, font_size: fs, color: style.fg, clip: Some(rect) });
734
735 if let Some(prog) = self.progress {
736 let pw = 80.0;
737 let pr = Rect::new(rect.center_x() - pw * 0.5 - 50.0, rect.y + 4.0, pw, rect.h - 8.0);
738 ctx.emit(DrawCmd::RoundedRect { rect: pr, radius: pr.h * 0.5, color: style.border });
739 ctx.emit(DrawCmd::RoundedRect {
740 rect: Rect::new(pr.x, pr.y, pr.w * prog.clamp(0.0, 1.0), pr.h),
741 radius: pr.h * 0.5, color: style.active,
742 });
743 }
744 }
745}
746
747#[derive(Debug, Clone)]
751pub enum MenuItem {
752 Item { id: UiId, label: String, shortcut: Option<String>, icon: Option<String>, enabled: bool },
753 Separator,
754 Submenu { label: String, items: Vec<MenuItem> },
755}
756
757impl MenuItem {
758 pub fn item(id: UiId, label: impl Into<String>) -> Self {
759 MenuItem::Item { id, label: label.into(), shortcut: None, icon: None, enabled: true }
760 }
761 pub fn with_shortcut(self, s: impl Into<String>) -> Self {
762 if let MenuItem::Item { id, label, icon, enabled, .. } = self {
763 MenuItem::Item { id, label, shortcut: Some(s.into()), icon, enabled }
764 } else { self }
765 }
766 pub fn with_icon(self, icon: impl Into<String>) -> Self {
767 if let MenuItem::Item { id, label, shortcut, enabled, .. } = self {
768 MenuItem::Item { id, label, shortcut, icon: Some(icon.into()), enabled }
769 } else { self }
770 }
771 pub fn disabled(self) -> Self {
772 if let MenuItem::Item { id, label, shortcut, icon, .. } = self {
773 MenuItem::Item { id, label, shortcut, icon, enabled: false }
774 } else { self }
775 }
776}
777
778#[derive(Debug)]
780pub struct ContextMenu {
781 pub id: UiId,
782 pub items: Vec<MenuItem>,
783 pub is_open: bool,
784 pub x: f32,
785 pub y: f32,
786 highlight: Option<usize>,
787 sub_open: Option<usize>,
788 pub clicked: Option<UiId>,
789 level2_open: Option<usize>,
790 level3_open: Option<usize>,
791}
792
793const CM_ITEM_H: f32 = 26.0;
794const CM_WIDTH: f32 = 180.0;
795const CM_SEP_H: f32 = 8.0;
796
797impl ContextMenu {
798 pub fn new(id: UiId) -> Self {
799 Self {
800 id, items: Vec::new(), is_open: false, x: 0.0, y: 0.0,
801 highlight: None, sub_open: None, clicked: None,
802 level2_open: None, level3_open: None,
803 }
804 }
805
806 pub fn add_item(&mut self, item: MenuItem) { self.items.push(item); }
807
808 pub fn open_at(&mut self, x: f32, y: f32) {
809 self.is_open = true;
810 self.x = x;
811 self.y = y;
812 self.highlight = None;
813 self.clicked = None;
814 self.sub_open = None;
815 }
816
817 pub fn close(&mut self) {
818 self.is_open = false;
819 self.sub_open = None;
820 }
821
822 fn menu_height(items: &[MenuItem]) -> f32 {
823 items.iter().map(|item| match item {
824 MenuItem::Separator => CM_SEP_H,
825 _ => CM_ITEM_H,
826 }).sum()
827 }
828
829 pub fn update(&mut self, ctx: &mut UiContext, vw: f32, vh: f32) {
830 if !self.is_open { return; }
831 self.clicked = None;
832
833 if ctx.key_pressed(crate::ui::KeyCode::Escape) { self.close(); return; }
835
836 let item_count = self.items.iter().filter(|i| !matches!(i, MenuItem::Separator)).count();
838 if ctx.key_pressed(crate::ui::KeyCode::Down) {
839 self.highlight = Some((self.highlight.unwrap_or(0) + 1) % item_count.max(1));
840 }
841 if ctx.key_pressed(crate::ui::KeyCode::Up) {
842 self.highlight = Some(self.highlight.unwrap_or(0).saturating_sub(1));
843 }
844 if ctx.key_pressed(crate::ui::KeyCode::Enter) {
845 if let Some(h) = self.highlight {
846 let mut idx = 0;
847 for item in &self.items {
848 if let MenuItem::Item { id, enabled: true, .. } = item {
849 if idx == h { self.clicked = Some(*id); self.close(); return; }
850 idx += 1;
851 }
852 }
853 }
854 }
855
856 let mut y = self.y;
858 for (i, item) in self.items.iter().enumerate() {
859 match item {
860 MenuItem::Separator => { y += CM_SEP_H; }
861 MenuItem::Item { id, enabled, .. } => {
862 let item_r = Rect::new(self.x, y, CM_WIDTH, CM_ITEM_H);
863 if item_r.contains(ctx.mouse_x, ctx.mouse_y) {
864 self.highlight = Some(i);
865 if ctx.mouse_just_pressed && *enabled {
866 self.clicked = Some(*id);
867 self.close();
868 return;
869 }
870 }
871 y += CM_ITEM_H;
872 }
873 MenuItem::Submenu { .. } => {
874 let item_r = Rect::new(self.x, y, CM_WIDTH, CM_ITEM_H);
875 if item_r.contains(ctx.mouse_x, ctx.mouse_y) {
876 self.sub_open = Some(i);
877 }
878 y += CM_ITEM_H;
879 }
880 }
881 }
882
883 let total_h = Self::menu_height(&self.items);
885 let menu_r = Rect::new(self.x, self.y, CM_WIDTH, total_h);
886 if ctx.mouse_just_pressed && !menu_r.contains(ctx.mouse_x, ctx.mouse_y) {
887 self.close();
888 }
889 let _ = (vw, vh);
890 }
891
892 pub fn draw(&self, ctx: &mut UiContext, style: &UiStyle) {
893 if !self.is_open { return; }
894 let total_h = Self::menu_height(&self.items);
895 let menu_r = Rect::new(self.x, self.y, CM_WIDTH, total_h);
896
897 ctx.emit(DrawCmd::RoundedRect { rect: menu_r.expand(3.0), radius: 5.0, color: Color::BLACK.with_alpha(0.25) });
899 ctx.emit(DrawCmd::RoundedRect { rect: menu_r, radius: 4.0, color: style.bg });
900 ctx.emit(DrawCmd::RoundedRectStroke { rect: menu_r, radius: 4.0, color: style.border, width: style.border_width });
901
902 let mut y = self.y;
903 for (i, item) in self.items.iter().enumerate() {
904 match item {
905 MenuItem::Separator => {
906 let sy = y + CM_SEP_H * 0.5;
907 ctx.emit(DrawCmd::Line { x0: self.x + 4.0, y0: sy, x1: self.x + CM_WIDTH - 4.0, y1: sy, color: style.border, width: 1.0 });
908 y += CM_SEP_H;
909 }
910 MenuItem::Item { label, shortcut, icon, enabled, .. } => {
911 let item_r = Rect::new(self.x, y, CM_WIDTH, CM_ITEM_H);
912 let is_hl = self.highlight == Some(i);
913 if is_hl && *enabled {
914 ctx.emit(DrawCmd::RoundedRect { rect: item_r.shrink(1.0), radius: 3.0, color: style.active.with_alpha(0.5) });
915 }
916
917 let color = if *enabled { style.fg } else { style.disabled };
918 let lx = self.x + 4.0 + if icon.is_some() { 18.0 } else { 0.0 };
919
920 if let Some(ref ico) = icon {
921 ctx.emit(DrawCmd::Text { text: ico.clone(), x: self.x + 4.0, y: y + 5.0, font_size: style.font_size, color, clip: Some(item_r) });
922 }
923 ctx.emit(DrawCmd::Text { text: label.clone(), x: lx, y: y + (CM_ITEM_H - style.font_size) * 0.5, font_size: style.font_size, color, clip: Some(item_r) });
924
925 if let Some(ref sc) = shortcut {
926 let sc_x = self.x + CM_WIDTH - sc.len() as f32 * style.font_size * 0.55 - 4.0;
927 ctx.emit(DrawCmd::Text { text: sc.clone(), x: sc_x, y: y + (CM_ITEM_H - style.font_size * 0.8) * 0.5, font_size: style.font_size * 0.8, color: style.disabled, clip: Some(item_r) });
928 }
929 y += CM_ITEM_H;
930 }
931 MenuItem::Submenu { label, .. } => {
932 let item_r = Rect::new(self.x, y, CM_WIDTH, CM_ITEM_H);
933 let is_hl = self.sub_open == Some(i);
934 if is_hl {
935 ctx.emit(DrawCmd::RoundedRect { rect: item_r.shrink(1.0), radius: 3.0, color: style.active.with_alpha(0.5) });
936 }
937 ctx.emit(DrawCmd::Text { text: label.clone(), x: self.x + 4.0, y: y + (CM_ITEM_H - style.font_size) * 0.5, font_size: style.font_size, color: style.fg, clip: Some(item_r) });
938 ctx.emit(DrawCmd::Text { text: "▶".to_string(), x: self.x + CM_WIDTH - 16.0, y: y + (CM_ITEM_H - style.font_size) * 0.5, font_size: style.font_size, color: style.border, clip: Some(item_r) });
939 y += CM_ITEM_H;
940 }
941 }
942 }
943 }
944}
945
946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950pub enum NotificationSeverity {
951 Info,
952 Warning,
953 Error,
954 Success,
955}
956
957impl NotificationSeverity {
958 pub fn color(&self, style: &UiStyle) -> Color {
959 match self {
960 NotificationSeverity::Info => Color::new(0.3, 0.6, 1.0, 1.0),
961 NotificationSeverity::Warning => Color::new(0.9, 0.6, 0.1, 1.0),
962 NotificationSeverity::Error => Color::new(0.9, 0.2, 0.2, 1.0),
963 NotificationSeverity::Success => Color::new(0.2, 0.8, 0.3, 1.0),
964 }
965 }
966}
967
968#[derive(Debug)]
970pub struct Toast {
971 pub id: UiId,
972 pub message: String,
973 pub severity: NotificationSeverity,
974 ttl: f32, pub max_ttl: f32,
976 slide_in: f32, dismissed: bool,
978 hover: bool,
979}
980
981impl Toast {
982 pub fn new(id: UiId, message: impl Into<String>, severity: NotificationSeverity) -> Self {
983 Self { id, message: message.into(), severity, ttl: 4.0, max_ttl: 4.0, slide_in: 0.0, dismissed: false, hover: false }
984 }
985
986 pub fn with_duration(mut self, secs: f32) -> Self { self.ttl = secs; self.max_ttl = secs; self }
987
988 pub fn is_done(&self) -> bool { self.dismissed || self.ttl <= 0.0 }
989
990 pub fn tick(&mut self, dt: f32) {
991 if !self.dismissed {
992 self.slide_in = (self.slide_in + dt * 4.0).min(1.0);
993 self.ttl = (self.ttl - dt).max(0.0);
994 } else {
995 self.slide_in = (self.slide_in - dt * 6.0).max(0.0);
996 }
997 }
998}
999
1000pub struct Notification {
1002 pub id: UiId,
1003 pub queue: Vec<Toast>,
1004 pub max: usize,
1005}
1006
1007impl Notification {
1008 pub fn new(id: UiId) -> Self { Self { id, queue: Vec::new(), max: 5 } }
1009
1010 pub fn push(&mut self, msg: impl Into<String>, severity: NotificationSeverity) {
1011 if self.queue.len() >= self.max { self.queue.remove(0); }
1012 let id = UiId::new(&format!("toast_{}", self.queue.len()));
1013 self.queue.push(Toast::new(id, msg, severity));
1014 }
1015
1016 pub fn tick(&mut self, dt: f32) {
1017 for t in &mut self.queue { t.tick(dt); }
1018 self.queue.retain(|t| !t.is_done() || t.slide_in > 0.01);
1019 }
1020
1021 pub fn draw(&self, ctx: &mut UiContext, viewport_w: f32, viewport_h: f32, style: &UiStyle) {
1022 let toast_w = 280.0;
1023 let toast_h = 56.0;
1024 let margin = 12.0;
1025 let mut y = viewport_h - margin;
1026
1027 for toast in &self.queue {
1028 let alpha = (toast.ttl / toast.max_ttl.max(0.001)).min(1.0);
1029 let slide_x = viewport_w - (toast_w + margin) * toast.slide_in;
1030 y -= toast_h + 8.0;
1031
1032 let r = Rect::new(slide_x, y, toast_w, toast_h);
1033
1034 ctx.emit(DrawCmd::RoundedRect { rect: r.expand(2.0), radius: 6.0, color: Color::BLACK.with_alpha(0.2 * alpha) });
1035 ctx.emit(DrawCmd::RoundedRect { rect: r, radius: 5.0, color: style.bg.with_alpha(alpha) });
1036
1037 let accent = toast.severity.color(style);
1038 ctx.emit(DrawCmd::FillRect { rect: Rect::new(r.x, r.y, 4.0, r.h), color: accent });
1039
1040 ctx.emit(DrawCmd::Text {
1041 text: toast.message.clone(),
1042 x: r.x + 12.0,
1043 y: r.center_y() - style.font_size * 0.5,
1044 font_size: style.font_size,
1045 color: style.fg.with_alpha(alpha),
1046 clip: Some(r),
1047 });
1048
1049 let dr = Rect::new(r.max_x() - 20.0, r.y + 4.0, 16.0, 16.0);
1051 ctx.emit(DrawCmd::Text {
1052 text: "×".to_string(), x: dr.x, y: dr.y, font_size: 14.0,
1053 color: style.border.with_alpha(alpha), clip: Some(r),
1054 });
1055 }
1056 }
1057}
1058
1059#[derive(Debug)]
1063pub struct Modal {
1064 pub id: UiId,
1065 pub title: String,
1066 pub content: String,
1067 pub buttons: Vec<(UiId, String)>,
1068 pub is_open: bool,
1069 pub pressed: Option<UiId>,
1070 hover_btns: Vec<f32>,
1071}
1072
1073const MODAL_W: f32 = 360.0;
1074const MODAL_H: f32 = 200.0;
1075
1076impl Modal {
1077 pub fn new(id: UiId, title: impl Into<String>) -> Self {
1078 Self {
1079 id, title: title.into(), content: String::new(),
1080 buttons: Vec::new(), is_open: false, pressed: None, hover_btns: Vec::new(),
1081 }
1082 }
1083
1084 pub fn with_content(mut self, s: impl Into<String>) -> Self { self.content = s.into(); self }
1085
1086 pub fn add_button(&mut self, id: UiId, label: impl Into<String>) {
1087 self.buttons.push((id, label.into()));
1088 self.hover_btns.push(0.0);
1089 }
1090
1091 pub fn confirm(id: UiId, title: impl Into<String>, message: impl Into<String>) -> Self {
1093 let ok_id = UiId::new("modal_ok");
1094 let cancel_id = UiId::new("modal_cancel");
1095 let mut m = Self::new(id, title).with_content(message);
1096 m.add_button(ok_id, "OK");
1097 m.add_button(cancel_id, "Cancel");
1098 m
1099 }
1100
1101 pub fn input_dialog(id: UiId, title: impl Into<String>, prompt: impl Into<String>) -> Self {
1103 let ok_id = UiId::new("input_ok");
1104 let mut m = Self::new(id, title).with_content(prompt);
1105 m.add_button(ok_id, "OK");
1106 m
1107 }
1108
1109 pub fn open(&mut self) { self.is_open = true; self.pressed = None; }
1110 pub fn close(&mut self) { self.is_open = false; }
1111
1112 pub fn update(&mut self, ctx: &mut UiContext, vw: f32, vh: f32, dt: f32) {
1113 if !self.is_open { return; }
1114 let rect = Rect::new((vw - MODAL_W) * 0.5, (vh - MODAL_H) * 0.5, MODAL_W, MODAL_H);
1115 let btn_y = rect.max_y() - 44.0;
1116 let n = self.buttons.len().max(1);
1117 let btn_w = (MODAL_W - 32.0) / n as f32 - 8.0;
1118
1119 if ctx.key_pressed(crate::ui::KeyCode::Escape) { self.close(); return; }
1121
1122 let button_ids: Vec<UiId> = self.buttons.iter().map(|(bid, _)| *bid).collect();
1123 for (i, bid) in button_ids.iter().enumerate() {
1124 let bx = rect.x + 16.0 + i as f32 * (btn_w + 8.0);
1125 let brect = Rect::new(bx, btn_y, btn_w, 32.0);
1126 let hov = brect.contains(ctx.mouse_x, ctx.mouse_y);
1127 let target = if hov { 1.0_f32 } else { 0.0 };
1128 if i < self.hover_btns.len() {
1129 self.hover_btns[i] += (target - self.hover_btns[i]) * (10.0 * dt).min(1.0);
1130 }
1131 if hov && ctx.mouse_just_pressed {
1132 self.pressed = Some(*bid);
1133 self.close();
1134 }
1135 }
1136
1137 if ctx.mouse_just_pressed && !rect.contains(ctx.mouse_x, ctx.mouse_y) {
1139 }
1141 let _ = (vw, vh);
1142 }
1143
1144 pub fn draw(&self, ctx: &mut UiContext, vw: f32, vh: f32, style: &UiStyle) {
1145 if !self.is_open { return; }
1146
1147 ctx.emit(DrawCmd::FillRect { rect: Rect::new(0.0, 0.0, vw, vh), color: Color::BLACK.with_alpha(0.5) });
1149
1150 let rect = Rect::new((vw - MODAL_W) * 0.5, (vh - MODAL_H) * 0.5, MODAL_W, MODAL_H);
1151 ctx.emit(DrawCmd::RoundedRect { rect: rect.expand(4.0), radius: 8.0, color: Color::BLACK.with_alpha(0.3) });
1152 ctx.emit(DrawCmd::RoundedRect { rect, radius: 6.0, color: style.bg });
1153 ctx.emit(DrawCmd::RoundedRectStroke { rect, radius: 6.0, color: style.border, width: style.border_width });
1154
1155 let tb = Rect::new(rect.x, rect.y, rect.w, 40.0);
1157 ctx.emit(DrawCmd::RoundedRect { rect: tb, radius: 6.0, color: style.active.with_alpha(0.4) });
1158 ctx.emit(DrawCmd::Text {
1159 text: self.title.clone(), x: rect.x + 16.0,
1160 y: tb.center_y() - style.font_size * 0.5,
1161 font_size: style.font_size, color: style.fg, clip: Some(tb),
1162 });
1163
1164 ctx.emit(DrawCmd::Text {
1166 text: self.content.clone(), x: rect.x + 16.0, y: rect.y + 52.0,
1167 font_size: style.font_size, color: style.fg, clip: Some(rect),
1168 });
1169
1170 let btn_y = rect.max_y() - 44.0;
1172 let n = self.buttons.len().max(1);
1173 let btn_w = (MODAL_W - 32.0) / n as f32 - 8.0;
1174
1175 for (i, (_, label)) in self.buttons.iter().enumerate() {
1176 let bx = rect.x + 16.0 + i as f32 * (btn_w + 8.0);
1177 let brect = Rect::new(bx, btn_y, btn_w, 32.0);
1178 let hov = self.hover_btns.get(i).copied().unwrap_or(0.0);
1179 let bg = style.active.lerp(style.accent_color(), hov);
1180
1181 ctx.emit(DrawCmd::RoundedRect { rect: brect, radius: 4.0, color: bg });
1182 ctx.emit(DrawCmd::Text {
1183 text: label.clone(), x: brect.center_x() - label.len() as f32 * style.font_size * 0.3,
1184 y: brect.center_y() - style.font_size * 0.5,
1185 font_size: style.font_size, color: Color::WHITE, clip: Some(brect),
1186 });
1187 }
1188 }
1189}
1190
1191trait StyleExt2 {
1192 fn accent_color(&self) -> Color;
1193}
1194
1195impl StyleExt2 for UiStyle {
1196 fn accent_color(&self) -> Color { self.active.lerp(self.fg, 0.3) }
1197}
1198
1199#[derive(Debug, Clone)]
1203pub struct DragPayload {
1204 pub source_id: UiId,
1205 pub data: String,
1206}
1207
1208pub struct DragDropContext {
1210 pub id: UiId,
1211 pub dragging: bool,
1212 pub payload: Option<DragPayload>,
1213 ghost_label: String,
1214 ghost_x: f32,
1215 ghost_y: f32,
1216 pub dropped: Option<(DragPayload, UiId)>,
1217}
1218
1219impl DragDropContext {
1220 pub fn new(id: UiId) -> Self {
1221 Self {
1222 id, dragging: false, payload: None, ghost_label: String::new(),
1223 ghost_x: 0.0, ghost_y: 0.0, dropped: None,
1224 }
1225 }
1226
1227 pub fn begin_drag(&mut self, source_id: UiId, data: impl Into<String>, label: impl Into<String>) {
1229 self.dragging = true;
1230 self.payload = Some(DragPayload { source_id, data: data.into() });
1231 self.ghost_label = label.into();
1232 }
1233
1234 pub fn is_drop_target(&mut self, ctx: &UiContext, rect: Rect, accept: impl Fn(&DragPayload) -> bool) -> bool {
1236 if !self.dragging { return false; }
1237 if !rect.contains(ctx.mouse_x, ctx.mouse_y) { return false; }
1238 if let Some(ref payload) = self.payload {
1239 if !accept(payload) { return false; }
1240 if !ctx.mouse_down {
1241 self.dropped = Some((payload.clone(), self.id));
1243 self.dragging = false;
1244 self.payload = None;
1245 return true;
1246 }
1247 }
1248 false
1249 }
1250
1251 pub fn update(&mut self, ctx: &UiContext) {
1252 if self.dragging {
1253 self.ghost_x = ctx.mouse_x;
1254 self.ghost_y = ctx.mouse_y;
1255 if !ctx.mouse_down {
1256 self.dragging = false;
1258 self.payload = None;
1259 }
1260 }
1261 }
1262
1263 pub fn draw_ghost(&self, ctx: &mut UiContext, style: &UiStyle) {
1265 if !self.dragging { return; }
1266 let ghost_w = self.ghost_label.len() as f32 * style.font_size * 0.6 + 16.0;
1267 let ghost_h = style.font_size + 12.0;
1268 let r = Rect::new(self.ghost_x + 12.0, self.ghost_y - ghost_h * 0.5, ghost_w, ghost_h);
1269 ctx.emit(DrawCmd::RoundedRect { rect: r, radius: 4.0, color: style.active.with_alpha(0.85) });
1270 ctx.emit(DrawCmd::Text {
1271 text: self.ghost_label.clone(), x: r.x + 8.0, y: r.center_y() - style.font_size * 0.5,
1272 font_size: style.font_size, color: Color::WHITE, clip: None,
1273 });
1274 }
1275}
1276
1277#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use crate::ui::{UiContext, UiStyle, UiId, Rect};
1283
1284 fn make_ctx() -> UiContext { UiContext::new(1280.0, 720.0) }
1285 fn style() -> UiStyle { UiStyle::default() }
1286
1287 #[test]
1288 fn window_title_bar_rect() {
1289 let w = Window::new(UiId::new("win"), "Test", Rect::new(100.0, 100.0, 400.0, 300.0));
1290 assert!((w.title_bar_rect().h - TITLE_H).abs() < 1e-3);
1291 }
1292
1293 #[test]
1294 fn window_close_button_works() {
1295 let mut ctx = make_ctx();
1296 let mut win = Window::new(UiId::new("cw"), "Close Me", Rect::new(200.0, 200.0, 300.0, 200.0));
1297 let cb = win.close_btn();
1298 ctx.push_event(crate::ui::InputEvent::MouseMove { x: cb.center_x(), y: cb.center_y() });
1299 ctx.push_event(crate::ui::InputEvent::MouseDown { x: cb.center_x(), y: cb.center_y(), button: 0 });
1300 ctx.begin_frame();
1301 win.update(&mut ctx, 1280.0, 720.0);
1302 assert!(win.closed);
1303 }
1304
1305 #[test]
1306 fn split_pane_ratio_clamped() {
1307 let sp = SplitPane::new(UiId::new("sp"), true).with_ratio(0.7);
1308 assert!((sp.ratio - 0.7).abs() < 1e-5);
1309 }
1310
1311 #[test]
1312 fn split_pane_rects_sum() {
1313 let sp = SplitPane::new(UiId::new("sp2"), true);
1314 let rect = Rect::new(0.0, 0.0, 600.0, 400.0);
1315 let (a, b) = sp.pane_rects(rect);
1316 assert!(a.w + b.w < rect.w); }
1318
1319 #[test]
1320 fn tab_bar_add_and_activate() {
1321 let mut tb = TabBar::new(UiId::new("tb"));
1322 let t1 = Tab::new(UiId::new("t1"), "First");
1323 let t2 = Tab::new(UiId::new("t2"), "Second");
1324 tb.add_tab(t1);
1325 tb.add_tab(t2);
1326 assert_eq!(tb.tabs.len(), 2);
1327 assert!(tb.active.is_some());
1328 }
1329
1330 #[test]
1331 fn tab_bar_remove() {
1332 let mut tb = TabBar::new(UiId::new("tbr"));
1333 let id = UiId::new("removable");
1334 tb.add_tab(Tab::new(id, "Remove Me"));
1335 tb.remove_tab(id);
1336 assert!(tb.tabs.is_empty());
1337 }
1338
1339 #[test]
1340 fn toolbar_button_click() {
1341 let mut ctx = make_ctx();
1342 let mut bar = Toolbar::new(UiId::new("bar"));
1343 let bid = UiId::new("save");
1344 bar.add_button(bid, "S", None, "Save");
1345 let rect = Rect::new(0.0, 0.0, 200.0, 36.0);
1346 ctx.push_event(crate::ui::InputEvent::MouseMove { x: 16.0, y: 18.0 });
1347 ctx.push_event(crate::ui::InputEvent::MouseDown { x: 16.0, y: 18.0, button: 0 });
1348 ctx.begin_frame();
1349 bar.update(&mut ctx, rect, 0.016);
1350 assert_eq!(bar.clicked, Some(bid));
1351 }
1352
1353 #[test]
1354 fn context_menu_opens() {
1355 let mut cm = ContextMenu::new(UiId::new("cm"));
1356 cm.add_item(MenuItem::item(UiId::new("copy"), "Copy"));
1357 cm.open_at(100.0, 200.0);
1358 assert!(cm.is_open);
1359 }
1360
1361 #[test]
1362 fn context_menu_escape_closes() {
1363 let mut ctx = make_ctx();
1364 let mut cm = ContextMenu::new(UiId::new("cm2"));
1365 cm.open_at(100.0, 200.0);
1366 ctx.push_event(crate::ui::InputEvent::KeyDown { key: crate::ui::KeyCode::Escape });
1367 ctx.begin_frame();
1368 cm.update(&mut ctx, 1280.0, 720.0);
1369 assert!(!cm.is_open);
1370 }
1371
1372 #[test]
1373 fn toast_auto_dismiss() {
1374 let mut t = Toast::new(UiId::new("t"), "Hello", NotificationSeverity::Info).with_duration(0.1);
1375 for _ in 0..20 { t.tick(0.01); }
1377 assert!(t.is_done());
1378 }
1379
1380 #[test]
1381 fn modal_confirm_builder() {
1382 let m = Modal::confirm(UiId::new("conf"), "Confirm?", "Are you sure?");
1383 assert_eq!(m.buttons.len(), 2);
1384 }
1385
1386 #[test]
1387 fn drag_drop_begin_and_cancel() {
1388 let mut ctx = make_ctx();
1389 let mut ddc = DragDropContext::new(UiId::new("ddc"));
1390 ctx.begin_frame();
1392 ddc.begin_drag(UiId::new("src"), "payload", "Item");
1393 assert!(ddc.dragging);
1394 ddc.update(&ctx);
1396 assert!(!ddc.dragging);
1398 }
1399}