Skip to main content

proof_engine/render/
ui_primitives.rs

1//! High-level UI primitive drawing functions.
2//!
3//! These convenience functions wrap `UiLayer` to provide common UI patterns:
4//! stat displays, combat HUD, menus, status bars, labeled panels, etc.
5
6use glam::{Vec2, Vec4};
7
8use super::ui_layer::{UiLayer, TextAlign, BorderStyle};
9
10// ── Colors ──────────────────────────────────────────────────────────────────
11
12/// Common UI color palette.
13pub struct UiColors;
14
15impl UiColors {
16    pub const WHITE: Vec4 = Vec4::new(1.0, 1.0, 1.0, 1.0);
17    pub const GRAY: Vec4 = Vec4::new(0.6, 0.6, 0.6, 1.0);
18    pub const DARK_GRAY: Vec4 = Vec4::new(0.3, 0.3, 0.3, 1.0);
19    pub const RED: Vec4 = Vec4::new(1.0, 0.2, 0.2, 1.0);
20    pub const GREEN: Vec4 = Vec4::new(0.2, 1.0, 0.2, 1.0);
21    pub const BLUE: Vec4 = Vec4::new(0.3, 0.5, 1.0, 1.0);
22    pub const YELLOW: Vec4 = Vec4::new(1.0, 0.9, 0.2, 1.0);
23    pub const CYAN: Vec4 = Vec4::new(0.2, 0.9, 0.9, 1.0);
24    pub const MAGENTA: Vec4 = Vec4::new(0.9, 0.2, 0.9, 1.0);
25    pub const ORANGE: Vec4 = Vec4::new(1.0, 0.6, 0.1, 1.0);
26    pub const GOLD: Vec4 = Vec4::new(1.0, 0.84, 0.0, 1.0);
27    pub const PANEL_BG: Vec4 = Vec4::new(0.05, 0.05, 0.1, 0.85);
28    pub const PANEL_BORDER: Vec4 = Vec4::new(0.4, 0.4, 0.6, 1.0);
29    pub const HP_FILL: Vec4 = Vec4::new(0.8, 0.15, 0.15, 1.0);
30    pub const HP_BG: Vec4 = Vec4::new(0.3, 0.05, 0.05, 1.0);
31    pub const HP_GHOST: Vec4 = Vec4::new(1.0, 0.3, 0.3, 0.5);
32    pub const MP_FILL: Vec4 = Vec4::new(0.2, 0.3, 0.9, 1.0);
33    pub const MP_BG: Vec4 = Vec4::new(0.05, 0.05, 0.3, 1.0);
34    pub const XP_FILL: Vec4 = Vec4::new(0.9, 0.8, 0.1, 1.0);
35    pub const XP_BG: Vec4 = Vec4::new(0.3, 0.25, 0.05, 1.0);
36    pub const STAMINA_FILL: Vec4 = Vec4::new(0.1, 0.8, 0.3, 1.0);
37    pub const STAMINA_BG: Vec4 = Vec4::new(0.05, 0.25, 0.1, 1.0);
38}
39
40// ── Drawing helpers ─────────────────────────────────────────────────────────
41
42/// Draw a labeled panel with a title in the top border.
43pub fn draw_titled_panel(
44    ui: &mut UiLayer,
45    x: f32,
46    y: f32,
47    w: f32,
48    h: f32,
49    title: &str,
50    border: BorderStyle,
51    fill_color: Vec4,
52    border_color: Vec4,
53    title_color: Vec4,
54) {
55    ui.draw_panel(x, y, w, h, border, fill_color, border_color);
56    // Center the title on the top border.
57    let title_x = x + w * 0.5;
58    let title_y = y;
59    ui.draw_text_aligned(title_x, title_y, &format!(" {} ", title), 1.0, title_color, TextAlign::Center);
60}
61
62/// Draw an HP bar with label.
63pub fn draw_hp_bar(
64    ui: &mut UiLayer,
65    x: f32,
66    y: f32,
67    w: f32,
68    current: f32,
69    max: f32,
70    ghost_pct: Option<f32>,
71) {
72    let pct = if max > 0.0 { current / max } else { 0.0 };
73    let label = format!("HP {}/{}", current as i32, max as i32);
74    ui.draw_text(x, y, &label, 1.0, UiColors::WHITE);
75    let bar_y = y + ui.char_height;
76    if let Some(ghost) = ghost_pct {
77        ui.draw_bar_with_ghost(
78            x, bar_y, w, ui.char_height,
79            pct, UiColors::HP_FILL, UiColors::HP_BG,
80            ghost, UiColors::HP_GHOST,
81        );
82    } else {
83        ui.draw_bar(x, bar_y, w, ui.char_height, pct, UiColors::HP_FILL, UiColors::HP_BG);
84    }
85}
86
87/// Draw an MP bar with label.
88pub fn draw_mp_bar(
89    ui: &mut UiLayer,
90    x: f32,
91    y: f32,
92    w: f32,
93    current: f32,
94    max: f32,
95) {
96    let pct = if max > 0.0 { current / max } else { 0.0 };
97    let label = format!("MP {}/{}", current as i32, max as i32);
98    ui.draw_text(x, y, &label, 1.0, UiColors::BLUE);
99    let bar_y = y + ui.char_height;
100    ui.draw_bar(x, bar_y, w, ui.char_height, pct, UiColors::MP_FILL, UiColors::MP_BG);
101}
102
103/// Draw an XP bar with label.
104pub fn draw_xp_bar(
105    ui: &mut UiLayer,
106    x: f32,
107    y: f32,
108    w: f32,
109    current: f32,
110    max: f32,
111    level: u32,
112) {
113    let pct = if max > 0.0 { current / max } else { 0.0 };
114    let label = format!("Lv.{} XP {}/{}", level, current as i32, max as i32);
115    ui.draw_text(x, y, &label, 1.0, UiColors::YELLOW);
116    let bar_y = y + ui.char_height;
117    ui.draw_bar(x, bar_y, w, ui.char_height, pct, UiColors::XP_FILL, UiColors::XP_BG);
118}
119
120/// Draw a stamina bar with label.
121pub fn draw_stamina_bar(
122    ui: &mut UiLayer,
123    x: f32,
124    y: f32,
125    w: f32,
126    current: f32,
127    max: f32,
128) {
129    let pct = if max > 0.0 { current / max } else { 0.0 };
130    let label = format!("STA {}/{}", current as i32, max as i32);
131    ui.draw_text(x, y, &label, 1.0, UiColors::GREEN);
132    let bar_y = y + ui.char_height;
133    ui.draw_bar(x, bar_y, w, ui.char_height, pct, UiColors::STAMINA_FILL, UiColors::STAMINA_BG);
134}
135
136/// Draw a stat line: "Label: Value" with colored value.
137pub fn draw_stat_line(
138    ui: &mut UiLayer,
139    x: f32,
140    y: f32,
141    label: &str,
142    value: &str,
143    label_color: Vec4,
144    value_color: Vec4,
145) {
146    ui.draw_text(x, y, label, 1.0, label_color);
147    let value_x = x + label.len() as f32 * ui.char_width;
148    ui.draw_text(value_x, y, value, 1.0, value_color);
149}
150
151/// Draw a key-value pair right-aligned within a given width.
152pub fn draw_stat_line_justified(
153    ui: &mut UiLayer,
154    x: f32,
155    y: f32,
156    width: f32,
157    label: &str,
158    value: &str,
159    label_color: Vec4,
160    value_color: Vec4,
161) {
162    ui.draw_text(x, y, label, 1.0, label_color);
163    let value_w = value.len() as f32 * ui.char_width;
164    let value_x = x + width - value_w;
165    ui.draw_text(value_x, y, value, 1.0, value_color);
166}
167
168/// Draw a tooltip box near (x, y) with text content.
169pub fn draw_tooltip(
170    ui: &mut UiLayer,
171    x: f32,
172    y: f32,
173    text: &str,
174) {
175    let (tw, th) = ui.measure_text(text, 1.0);
176    let padding = ui.char_width;
177    let panel_w = tw + padding * 2.0;
178    let panel_h = th + padding * 2.0;
179
180    // Position tooltip below and to the right of the cursor.
181    let px = x + ui.char_width;
182    let py = y + ui.char_height;
183
184    // Clamp to screen bounds.
185    let px = px.min(ui.screen_width - panel_w);
186    let py = py.min(ui.screen_height - panel_h);
187
188    ui.draw_panel(
189        px, py, panel_w, panel_h,
190        BorderStyle::Rounded,
191        UiColors::PANEL_BG,
192        UiColors::PANEL_BORDER,
193    );
194    ui.draw_text(px + padding, py + padding, text, 1.0, UiColors::WHITE);
195}
196
197/// Draw a menu with a list of options, highlighting the selected one.
198pub fn draw_menu(
199    ui: &mut UiLayer,
200    x: f32,
201    y: f32,
202    options: &[&str],
203    selected: usize,
204    title: Option<&str>,
205) {
206    let max_len = options.iter().map(|o| o.len()).max().unwrap_or(10);
207    let title_len = title.map(|t| t.len()).unwrap_or(0);
208    let width = (max_len.max(title_len) + 6) as f32 * ui.char_width;
209    let height = (options.len() + 2 + if title.is_some() { 2 } else { 0 }) as f32 * ui.char_height;
210
211    if let Some(title) = title {
212        draw_titled_panel(
213            ui, x, y, width, height,
214            title,
215            BorderStyle::Double,
216            UiColors::PANEL_BG,
217            UiColors::PANEL_BORDER,
218            UiColors::GOLD,
219        );
220    } else {
221        ui.draw_panel(x, y, width, height, BorderStyle::Single, UiColors::PANEL_BG, UiColors::PANEL_BORDER);
222    }
223
224    let content_y = y + ui.char_height * (if title.is_some() { 2.0 } else { 1.0 });
225    let content_x = x + ui.char_width * 2.0;
226
227    for (i, option) in options.iter().enumerate() {
228        let oy = content_y + i as f32 * ui.char_height;
229        let (prefix, color) = if i == selected {
230            ("▶ ", UiColors::GOLD)
231        } else {
232            ("  ", UiColors::GRAY)
233        };
234        ui.draw_text(content_x, oy, &format!("{}{}", prefix, option), 1.0, color);
235    }
236}
237
238/// Draw a combat log panel with scrolling text lines.
239pub fn draw_combat_log(
240    ui: &mut UiLayer,
241    x: f32,
242    y: f32,
243    w: f32,
244    h: f32,
245    lines: &[(&str, Vec4)],
246) {
247    ui.draw_panel(x, y, w, h, BorderStyle::Single, UiColors::PANEL_BG, UiColors::PANEL_BORDER);
248
249    let content_x = x + ui.char_width;
250    let content_y = y + ui.char_height;
251    let max_visible = ((h - ui.char_height * 2.0) / ui.char_height) as usize;
252    let start = if lines.len() > max_visible { lines.len() - max_visible } else { 0 };
253
254    for (i, (text, color)) in lines[start..].iter().enumerate() {
255        let ly = content_y + i as f32 * ui.char_height;
256        ui.draw_text(content_x, ly, text, 1.0, *color);
257    }
258}
259
260/// Draw FPS and frame stats in the top-right corner.
261pub fn draw_fps_overlay(
262    ui: &mut UiLayer,
263    fps: f32,
264    glyph_count: usize,
265    particle_count: usize,
266    draw_calls: u32,
267) {
268    let x = ui.screen_width - ui.char_width * 25.0;
269    let y = ui.char_height * 0.5;
270    let color = if fps >= 55.0 {
271        UiColors::GREEN
272    } else if fps >= 30.0 {
273        UiColors::YELLOW
274    } else {
275        UiColors::RED
276    };
277
278    ui.draw_text(x, y, &format!("FPS: {:.0}", fps), 1.0, color);
279    ui.draw_text(x, y + ui.char_height, &format!("Glyphs: {}", glyph_count), 1.0, UiColors::GRAY);
280    ui.draw_text(x, y + ui.char_height * 2.0, &format!("Particles: {}", particle_count), 1.0, UiColors::GRAY);
281    ui.draw_text(x, y + ui.char_height * 3.0, &format!("Draws: {}", draw_calls), 1.0, UiColors::GRAY);
282}
283
284/// Draw a damage number floating up from a position.
285///
286/// `age` is 0.0 to 1.0 (lifetime progress). The number fades and rises.
287pub fn draw_floating_damage(
288    ui: &mut UiLayer,
289    x: f32,
290    y: f32,
291    damage: i32,
292    age: f32,
293    is_crit: bool,
294) {
295    let alpha = (1.0 - age).max(0.0);
296    let rise = age * ui.char_height * 3.0;
297
298    let (text, color) = if is_crit {
299        (format!("★{}★", damage), Vec4::new(1.0, 0.8, 0.0, alpha))
300    } else {
301        (format!("{}", damage), Vec4::new(1.0, 0.3, 0.3, alpha))
302    };
303
304    let scale = if is_crit { 1.5 } else { 1.0 };
305    ui.draw_text_aligned(x, y - rise, &text, scale, color, TextAlign::Center);
306}
307
308/// Draw a horizontal separator line.
309pub fn draw_separator(
310    ui: &mut UiLayer,
311    x: f32,
312    y: f32,
313    width: f32,
314    color: Vec4,
315) {
316    let chars = (width / ui.char_width) as usize;
317    let line: String = "─".repeat(chars);
318    ui.draw_text(x, y, &line, 1.0, color);
319}
320
321/// Draw a notification banner centered at the top of the screen.
322pub fn draw_notification(
323    ui: &mut UiLayer,
324    text: &str,
325    color: Vec4,
326    bg_alpha: f32,
327) {
328    let (tw, th) = ui.measure_text(text, 1.0);
329    let padding = ui.char_width * 2.0;
330    let banner_w = tw + padding * 2.0;
331    let banner_h = th + padding;
332    let bx = (ui.screen_width - banner_w) * 0.5;
333    let by = ui.char_height;
334
335    let bg = Vec4::new(0.0, 0.0, 0.0, bg_alpha);
336    ui.draw_rect(bx, by, banner_w, banner_h, bg, true);
337    ui.draw_centered_text(by + padding * 0.5, text, 1.0, color);
338}
339
340// ── Tests ───────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn draw_hp_bar_queues_commands() {
348        let mut ui = UiLayer::new(1280.0, 800.0);
349        draw_hp_bar(&mut ui, 10.0, 10.0, 200.0, 75.0, 100.0, None);
350        assert!(ui.command_count() >= 2); // label + bar
351    }
352
353    #[test]
354    fn draw_menu_queues_commands() {
355        let mut ui = UiLayer::new(1280.0, 800.0);
356        draw_menu(&mut ui, 100.0, 100.0, &["Option A", "Option B"], 0, Some("Menu"));
357        assert!(ui.command_count() > 0);
358    }
359
360    #[test]
361    fn draw_tooltip_clamps_to_screen() {
362        let mut ui = UiLayer::new(200.0, 200.0);
363        draw_tooltip(&mut ui, 190.0, 190.0, "Hello World");
364        // Should not panic — tooltip is clamped.
365        assert!(ui.command_count() > 0);
366    }
367
368    #[test]
369    fn colors_are_valid() {
370        assert_eq!(UiColors::WHITE.w, 1.0);
371        assert!(UiColors::PANEL_BG.w > 0.0 && UiColors::PANEL_BG.w < 1.0);
372    }
373}