Skip to main content

proof_engine/ui/
panels.rs

1//! UI panel and container implementations.
2//!
3//! Panels are larger-scale UI constructs that contain other widgets:
4//! windows, split panes, tab bars, toolbars, context menus, dialogs, etc.
5
6use crate::ui::{Rect, Color, DrawCmd, UiContext, UiId, UiStyle};
7
8// ── Window ────────────────────────────────────────────────────────────────────
9
10/// Floating window with title bar, resize handles (8 directions), minimize/maximize/close,
11/// z-order focus stack, and snap-to-edge docking.
12#[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    /// Update window interaction state.
116    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        // Button hover
125        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        // Button clicks
130        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        // Title bar drag to move
148        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            // Snap to edges if dockable
164            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        // Resize handles
174        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;  // delta from when drag started (simplified)
182            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    /// Draw the window frame into the context.  Returns the content rect.
230    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        // Title bar
246        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        // Control buttons
258        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
276// ── DockableWindow ────────────────────────────────────────────────────────────
277
278/// Window with snap-to-edge behaviour (uses `Window` with `dockable = true`).
279pub type DockableWindow = Window;
280
281// ── SplitPane ─────────────────────────────────────────────────────────────────
282
283/// A container split into two panes by a draggable handle.
284#[derive(Debug)]
285pub struct SplitPane {
286    pub id:         UiId,
287    pub horizontal: bool,    // true = left|right, false = top|bottom
288    pub ratio:      f32,     // 0–1 split point
289    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    /// Returns (first_rect, second_rect) for the two panes.
305    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        // Grip dots
355        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    /// Serialize layout as a single f32 ratio (simple persistence).
371    pub fn serialize(&self) -> f32 { self.ratio }
372
373    /// Deserialize from a stored ratio.
374    pub fn deserialize(&mut self, ratio: f32) {
375        self.ratio = ratio.clamp(self.min_ratio, self.max_ratio);
376    }
377}
378
379// ── TabBar / TabPanel ──────────────────────────────────────────────────────────
380
381/// A single tab descriptor.
382#[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/// A horizontal tab bar with drag-reorder, close buttons, overflow scrolling.
398#[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        // Scroll overflow with keyboard
449        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                    // Start drag for reorder
465                    if !tab.pinned {
466                        self.drag_tab     = Some(i);
467                        self.drag_start_x = ctx.mouse_x;
468                    }
469                }
470
471                // Close button
472                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        // Drag reorder
482        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        // Bottom line
506        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            // Pin icon
535            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            // Close button
543            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
556/// Convenience type combining a TabBar with content areas.
557pub type TabPanel = TabBar;
558
559// ── Helper: UiStyle surface color ─────────────────────────────────────────────
560
561trait 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// ── Toolbar ───────────────────────────────────────────────────────────────────
574
575/// A toolbar item: button, separator, or toggle group member.
576#[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/// A horizontal toolbar with icon buttons, separators, and overflow menu.
585#[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// ── StatusBar ─────────────────────────────────────────────────────────────────
699
700/// A status bar with left/center/right sections and an optional progress slot.
701#[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// ── ContextMenu ───────────────────────────────────────────────────────────────
748
749/// A context menu item.
750#[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/// A context menu with submenus (up to 3 levels), keyboard navigation.
779#[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        // Close on Escape
834        if ctx.key_pressed(crate::ui::KeyCode::Escape) { self.close(); return; }
835
836        // Keyboard navigation
837        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        // Mouse interaction
857        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        // Close if clicked outside
884        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        // Shadow
898        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// ── Notification / Toast ──────────────────────────────────────────────────────
947
948/// Severity level for notifications.
949#[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/// A single toast notification.
969#[derive(Debug)]
970pub struct Toast {
971    pub id:       UiId,
972    pub message:  String,
973    pub severity: NotificationSeverity,
974    ttl:          f32,       // time to live
975    pub max_ttl:  f32,
976    slide_in:     f32,       // 0 = off-screen, 1 = on-screen
977    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
1000/// Manages a queue of `Toast` notifications.
1001pub 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            // Dismiss button
1050            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// ── Modal / Dialog ────────────────────────────────────────────────────────────
1060
1061/// A modal dialog with overlay, focus trap, title, content, and button row.
1062#[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    /// Build a simple confirmation dialog.
1092    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    /// Build an input dialog (caller reads `content` field for entered text).
1102    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        // Tab cycles focus inside modal (focus trap)
1120        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        // Trap focus: clicking outside does nothing (overlay blocks)
1138        if ctx.mouse_just_pressed && !rect.contains(ctx.mouse_x, ctx.mouse_y) {
1139            // Block, but don't close — user must press a button
1140        }
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        // Overlay
1148        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        // Title
1156        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        // Content
1165        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        // Buttons
1171        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// ── DragDropContext ────────────────────────────────────────────────────────────
1200
1201/// Payload carried by a drag operation.
1202#[derive(Debug, Clone)]
1203pub struct DragPayload {
1204    pub source_id: UiId,
1205    pub data:      String,
1206}
1207
1208/// Manages drag-and-drop interactions.
1209pub 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    /// Begin a drag from `source_id` with `data`.
1228    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    /// Check if this drop target accepts the current drag; returns true if dropped.
1235    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                // Drop!
1242                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                // Drop without target — cancel
1257                self.dragging = false;
1258                self.payload  = None;
1259            }
1260        }
1261    }
1262
1263    /// Draw the drag ghost near the cursor.
1264    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// ── Tests ─────────────────────────────────────────────────────────────────────
1278
1279#[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); // gap takes some space
1317    }
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        // Tick past the duration
1376        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        // Simulate holding mouse (no just_pressed, no just_released)
1391        ctx.begin_frame();
1392        ddc.begin_drag(UiId::new("src"), "payload", "Item");
1393        assert!(ddc.dragging);
1394        // Update without holding mouse
1395        ddc.update(&ctx);
1396        // mouse_down is false by default so drag is cancelled
1397        assert!(!ddc.dragging);
1398    }
1399}