Skip to main content

proof_engine/render/
ui_layer.rs

1//! Screen-space UI layer — bypasses the 3D camera and renders in pixel coordinates.
2//!
3//! The UI layer renders AFTER the 3D scene and post-processing but BEFORE the
4//! final composite.  UI elements are pixel-perfect, unaffected by bloom or
5//! distortion, and positioned in screen coordinates: (0,0) = top-left.
6//!
7//! # Architecture
8//!
9//! ```text
10//! 3D scene → PostFx (bloom, CA, grain) → UI Layer (ortho, no FX) → screen
11//! ```
12//!
13//! The UI layer collects draw commands each frame via `UiLayer::draw_*` methods,
14//! then flushes them all in one pass via `UiLayerRenderer`.
15
16use glam::{Vec2, Vec3, Vec4, Mat4};
17use std::collections::VecDeque;
18
19// ── Draw Commands ───────────────────────────────────────────────────────────
20
21/// A single UI draw command, queued and executed in order.
22#[derive(Clone, Debug)]
23pub enum UiDrawCommand {
24    Text {
25        text: String,
26        x: f32,
27        y: f32,
28        scale: f32,
29        color: Vec4,
30        emission: f32,
31        alignment: TextAlign,
32    },
33    Rect {
34        x: f32,
35        y: f32,
36        w: f32,
37        h: f32,
38        color: Vec4,
39        filled: bool,
40    },
41    Panel {
42        x: f32,
43        y: f32,
44        w: f32,
45        h: f32,
46        border: BorderStyle,
47        fill_color: Vec4,
48        border_color: Vec4,
49    },
50    Bar {
51        x: f32,
52        y: f32,
53        w: f32,
54        h: f32,
55        fill_pct: f32,
56        fill_color: Vec4,
57        bg_color: Vec4,
58        ghost_pct: Option<f32>,
59        ghost_color: Vec4,
60    },
61    Sprite {
62        lines: Vec<String>,
63        x: f32,
64        y: f32,
65        color: Vec4,
66    },
67}
68
69/// Text alignment.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71pub enum TextAlign {
72    #[default]
73    Left,
74    Center,
75    Right,
76}
77
78/// Border drawing styles for panels.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum BorderStyle {
81    /// Single line: ┌─┐│└─┘
82    Single,
83    /// Double line: ╔═╗║╚═╝
84    Double,
85    /// Rounded corners: ╭─╮│╰─╯
86    Rounded,
87    /// Heavy line: ┏━┓┃┗━┛
88    Heavy,
89    /// Dashed: ┌╌┐╎└╌┘
90    Dashed,
91}
92
93impl BorderStyle {
94    /// Get the 8 border characters: [top-left, top, top-right, left, right, bottom-left, bottom, bottom-right]
95    pub fn chars(&self) -> [char; 8] {
96        match self {
97            BorderStyle::Single  => ['┌', '─', '┐', '│', '│', '└', '─', '┘'],
98            BorderStyle::Double  => ['╔', '═', '╗', '║', '║', '╚', '═', '╝'],
99            BorderStyle::Rounded => ['╭', '─', '╮', '│', '│', '╰', '─', '╯'],
100            BorderStyle::Heavy   => ['┏', '━', '┓', '┃', '┃', '┗', '━', '┛'],
101            BorderStyle::Dashed  => ['┌', '╌', '┐', '╎', '╎', '└', '╌', '┘'],
102        }
103    }
104}
105
106// ── UiLayer ─────────────────────────────────────────────────────────────────
107
108/// The screen-space UI layer.  Collects draw commands each frame, then renders
109/// them all in a single pass with an orthographic projection.
110pub struct UiLayer {
111    /// Screen dimensions (updated on resize).
112    pub screen_width: f32,
113    pub screen_height: f32,
114    /// Character cell dimensions in screen pixels.
115    pub char_width: f32,
116    pub char_height: f32,
117    /// Queued draw commands for this frame.
118    draw_queue: Vec<UiDrawCommand>,
119    /// Whether the UI layer is enabled.
120    pub enabled: bool,
121}
122
123impl UiLayer {
124    pub fn new(screen_width: f32, screen_height: f32) -> Self {
125        Self {
126            screen_width,
127            screen_height,
128            char_width: 10.0,
129            char_height: 18.0,
130            draw_queue: Vec::with_capacity(256),
131            enabled: true,
132        }
133    }
134
135    /// Update screen dimensions (call on resize).
136    pub fn resize(&mut self, width: f32, height: f32) {
137        self.screen_width = width;
138        self.screen_height = height;
139    }
140
141    /// Set the character cell size in screen pixels.
142    pub fn set_char_size(&mut self, width: f32, height: f32) {
143        self.char_width = width;
144        self.char_height = height;
145    }
146
147    /// Clear all queued commands. Call at the start of each frame.
148    pub fn begin_frame(&mut self) {
149        self.draw_queue.clear();
150    }
151
152    /// Get the orthographic projection matrix for this UI layer.
153    /// Maps (0,0) at top-left to (screen_width, screen_height) at bottom-right.
154    pub fn projection(&self) -> Mat4 {
155        Mat4::orthographic_rh_gl(
156            0.0,
157            self.screen_width,
158            self.screen_height,
159            0.0,
160            -1.0,
161            1.0,
162        )
163    }
164
165    /// Get the draw queue for rendering.
166    pub fn draw_queue(&self) -> &[UiDrawCommand] {
167        &self.draw_queue
168    }
169
170    /// Number of pending draw commands.
171    pub fn command_count(&self) -> usize {
172        self.draw_queue.len()
173    }
174
175    // ── Drawing API ─────────────────────────────────────────────────────────
176
177    /// Draw text at screen coordinates.
178    pub fn draw_text(&mut self, x: f32, y: f32, text: &str, scale: f32, color: Vec4) {
179        self.draw_queue.push(UiDrawCommand::Text {
180            text: text.to_string(),
181            x, y, scale,
182            color,
183            emission: 0.0,
184            alignment: TextAlign::Left,
185        });
186    }
187
188    /// Draw text with emission (for bloom-capable UI text).
189    pub fn draw_text_glowing(&mut self, x: f32, y: f32, text: &str, scale: f32, color: Vec4, emission: f32) {
190        self.draw_queue.push(UiDrawCommand::Text {
191            text: text.to_string(),
192            x, y, scale,
193            color,
194            emission,
195            alignment: TextAlign::Left,
196        });
197    }
198
199    /// Draw text with alignment.
200    pub fn draw_text_aligned(&mut self, x: f32, y: f32, text: &str, scale: f32, color: Vec4, align: TextAlign) {
201        self.draw_queue.push(UiDrawCommand::Text {
202            text: text.to_string(),
203            x, y, scale,
204            color,
205            emission: 0.0,
206            alignment: align,
207        });
208    }
209
210    /// Draw centered text (centers horizontally at the given y).
211    pub fn draw_centered_text(&mut self, y: f32, text: &str, scale: f32, color: Vec4) {
212        self.draw_text_aligned(self.screen_width / 2.0, y, text, scale, color, TextAlign::Center);
213    }
214
215    /// Draw word-wrapped text within a max width (in pixels).
216    pub fn draw_wrapped_text(&mut self, x: f32, y: f32, max_width: f32, text: &str, scale: f32, color: Vec4) {
217        let char_w = self.char_width * scale;
218        let max_chars = (max_width / char_w.max(1.0)) as usize;
219        let lines = wrap_text_ui(text, max_chars);
220        let line_h = self.char_height * scale;
221        for (i, line) in lines.iter().enumerate() {
222            self.draw_text(x, y + i as f32 * line_h, line, scale, color);
223        }
224    }
225
226    /// Measure text dimensions in screen pixels.
227    pub fn measure_text(&self, text: &str, scale: f32) -> (f32, f32) {
228        let lines: Vec<&str> = text.lines().collect();
229        let max_cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
230        let width = max_cols as f32 * self.char_width * scale;
231        let height = lines.len() as f32 * self.char_height * scale;
232        (width, height)
233    }
234
235    /// Draw a filled or outlined rectangle.
236    pub fn draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Vec4, filled: bool) {
237        self.draw_queue.push(UiDrawCommand::Rect {
238            x, y, w, h, color, filled,
239        });
240    }
241
242    /// Draw a panel with a border and optional fill.
243    pub fn draw_panel(
244        &mut self,
245        x: f32,
246        y: f32,
247        w: f32,
248        h: f32,
249        border: BorderStyle,
250        fill_color: Vec4,
251        border_color: Vec4,
252    ) {
253        self.draw_queue.push(UiDrawCommand::Panel {
254            x, y, w, h, border, fill_color, border_color,
255        });
256    }
257
258    /// Draw a progress bar using █ and ░ characters.
259    pub fn draw_bar(
260        &mut self,
261        x: f32,
262        y: f32,
263        w: f32,
264        h: f32,
265        fill_pct: f32,
266        fill_color: Vec4,
267        bg_color: Vec4,
268    ) {
269        self.draw_queue.push(UiDrawCommand::Bar {
270            x, y, w, h,
271            fill_pct: fill_pct.clamp(0.0, 1.0),
272            fill_color,
273            bg_color,
274            ghost_pct: None,
275            ghost_color: Vec4::ZERO,
276        });
277    }
278
279    /// Draw a progress bar with a ghost bar (recent damage indicator).
280    pub fn draw_bar_with_ghost(
281        &mut self,
282        x: f32,
283        y: f32,
284        w: f32,
285        h: f32,
286        fill_pct: f32,
287        fill_color: Vec4,
288        bg_color: Vec4,
289        ghost_pct: f32,
290        ghost_color: Vec4,
291    ) {
292        self.draw_queue.push(UiDrawCommand::Bar {
293            x, y, w, h,
294            fill_pct: fill_pct.clamp(0.0, 1.0),
295            fill_color,
296            bg_color,
297            ghost_pct: Some(ghost_pct.clamp(0.0, 1.0)),
298            ghost_color,
299        });
300    }
301
302    /// Draw multi-line ASCII art sprite.
303    pub fn draw_sprite(&mut self, x: f32, y: f32, lines: &[&str], color: Vec4) {
304        self.draw_queue.push(UiDrawCommand::Sprite {
305            lines: lines.iter().map(|s| s.to_string()).collect(),
306            x, y, color,
307        });
308    }
309}
310
311// ── Word wrapping for UI ────────────────────────────────────────────────────
312
313fn wrap_text_ui(text: &str, max_chars: usize) -> Vec<String> {
314    if max_chars == 0 {
315        return vec![text.to_string()];
316    }
317    let mut lines = Vec::new();
318    for paragraph in text.split('\n') {
319        if paragraph.is_empty() {
320            lines.push(String::new());
321            continue;
322        }
323        let words: Vec<&str> = paragraph.split_whitespace().collect();
324        let mut line = String::new();
325        for word in words {
326            if line.is_empty() {
327                if word.len() > max_chars {
328                    let mut w = word;
329                    while w.len() > max_chars {
330                        lines.push(w[..max_chars].to_string());
331                        w = &w[max_chars..];
332                    }
333                    line = w.to_string();
334                } else {
335                    line = word.to_string();
336                }
337            } else if line.len() + 1 + word.len() <= max_chars {
338                line.push(' ');
339                line.push_str(word);
340            } else {
341                lines.push(std::mem::take(&mut line));
342                line = word.to_string();
343            }
344        }
345        if !line.is_empty() {
346            lines.push(line);
347        }
348    }
349    if lines.is_empty() {
350        lines.push(String::new());
351    }
352    lines
353}
354
355// ── Tests ───────────────────────────────────────────────────────────────────
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn ui_layer_projection_is_orthographic() {
363        let ui = UiLayer::new(1280.0, 800.0);
364        let proj = ui.projection();
365        // Top-left (0,0) should map to (-1, 1) in clip space.
366        let tl = proj * Vec4::new(0.0, 0.0, 0.0, 1.0);
367        assert!((tl.x / tl.w - (-1.0)).abs() < 0.01);
368        assert!((tl.y / tl.w - 1.0).abs() < 0.01);
369    }
370
371    #[test]
372    fn ui_layer_draw_and_clear() {
373        let mut ui = UiLayer::new(1280.0, 800.0);
374        ui.draw_text(0.0, 0.0, "Hello", 1.0, Vec4::ONE);
375        assert_eq!(ui.command_count(), 1);
376        ui.begin_frame();
377        assert_eq!(ui.command_count(), 0);
378    }
379
380    #[test]
381    fn measure_text_single_line() {
382        let ui = UiLayer::new(1280.0, 800.0);
383        let (w, h) = ui.measure_text("Hello", 1.0);
384        assert_eq!(w, 5.0 * ui.char_width);
385        assert_eq!(h, ui.char_height);
386    }
387
388    #[test]
389    fn measure_text_multi_line() {
390        let ui = UiLayer::new(1280.0, 800.0);
391        let (_, h) = ui.measure_text("Line1\nLine2\nLine3", 1.0);
392        assert_eq!(h, 3.0 * ui.char_height);
393    }
394
395    #[test]
396    fn border_style_chars() {
397        let chars = BorderStyle::Single.chars();
398        assert_eq!(chars[0], '┌');
399        assert_eq!(chars[7], '┘');
400    }
401
402    #[test]
403    fn wrap_text_ui_basic() {
404        let lines = wrap_text_ui("Hello world foo bar", 10);
405        for l in &lines {
406            assert!(l.len() <= 10, "Line too long: '{}'", l);
407        }
408    }
409
410    #[test]
411    fn bar_pct_clamped() {
412        let mut ui = UiLayer::new(1280.0, 800.0);
413        ui.draw_bar(0.0, 0.0, 100.0, 10.0, 1.5, Vec4::ONE, Vec4::ZERO);
414        if let UiDrawCommand::Bar { fill_pct, .. } = &ui.draw_queue()[0] {
415            assert_eq!(*fill_pct, 1.0);
416        }
417    }
418}