Skip to main content

proof_engine/ui/
widgets.rs

1//! UI widget implementations: labels, progress bars, buttons, panels, rings.
2
3use crate::{MathFunction, Glyph, RenderLayer};
4use crate::glyph::GlyphId;
5use crate::ProofEngine;
6use glam::{Vec2, Vec3, Vec4};
7
8// ── UiLabel ───────────────────────────────────────────────────────────────────
9
10/// A static or dynamically-updated text label.
11pub struct UiLabel {
12    pub text:       String,
13    pub position:   Vec3,
14    pub color:      Vec4,
15    pub char_scale: f32,
16    pub char_spacing: f32,
17    pub emission:   f32,
18    pub glow_color: Vec3,
19    pub glow_radius: f32,
20    /// Optional animation: applies to emission over time.
21    pub emission_fn: Option<MathFunction>,
22    /// Optional color pulse animation.
23    pub color_fn:   Option<MathFunction>,
24    pub visible:    bool,
25}
26
27impl UiLabel {
28    pub fn new(text: impl Into<String>, position: Vec3, color: Vec4) -> Self {
29        Self {
30            text:        text.into(),
31            position,
32            color,
33            char_scale:  0.6,
34            char_spacing: 0.45,
35            emission:    0.3,
36            glow_color:  Vec3::new(color.x, color.y, color.z),
37            glow_radius: 0.0,
38            emission_fn: None,
39            color_fn:    None,
40            visible:     true,
41        }
42    }
43
44    pub fn with_glow(mut self, radius: f32) -> Self {
45        self.glow_radius = radius;
46        self.emission    = 0.8;
47        self
48    }
49
50    pub fn with_pulse(mut self, rate: f32) -> Self {
51        self.emission_fn = Some(MathFunction::Sine {
52            amplitude: 0.4, frequency: rate, phase: 0.0,
53        }.offset(0.6));
54        self
55    }
56
57    pub fn with_color_cycle(mut self, speed: f32) -> Self {
58        self.color_fn = Some(MathFunction::Sine {
59            amplitude: 1.0, frequency: speed, phase: 0.0,
60        });
61        self
62    }
63
64    /// Render the label and return spawned glyph IDs.
65    pub fn render(&self, engine: &mut ProofEngine, time: f32) -> Vec<GlyphId> {
66        if !self.visible { return Vec::new(); }
67        let mut ids = Vec::new();
68
69        let emission = if let Some(ref f) = self.emission_fn {
70            f.evaluate(time, 0.0).clamp(0.0, 2.0)
71        } else {
72            self.emission
73        };
74
75        let color = if let Some(ref f) = self.color_fn {
76            let v = f.evaluate(time, 0.0);
77            Vec4::new(
78                (self.color.x + v * 0.2).clamp(0.0, 1.0),
79                (self.color.y + v * 0.1).clamp(0.0, 1.0),
80                (self.color.z - v * 0.1).clamp(0.0, 1.0),
81                self.color.w,
82            )
83        } else {
84            self.color
85        };
86
87        for (i, ch) in self.text.chars().enumerate() {
88            let x = self.position.x + i as f32 * self.char_spacing;
89            let id = engine.scene.spawn_glyph(Glyph {
90                character:   ch,
91                position:    Vec3::new(x, self.position.y, self.position.z),
92                color,
93                scale:       Vec2::splat(self.char_scale),
94                emission,
95                glow_color:  self.glow_color,
96                glow_radius: self.glow_radius,
97                layer:       RenderLayer::UI,
98                ..Default::default()
99            });
100            ids.push(id);
101        }
102        ids
103    }
104}
105
106// ── UiProgressBar ─────────────────────────────────────────────────────────────
107
108/// A horizontal bar that fills from left to right based on a [0, 1] value.
109///
110/// Uses block characters (▏▎▍▌▋▊▉█) for sub-character precision rendering.
111pub struct UiProgressBar {
112    pub position:     Vec3,
113    pub width_chars:  usize,
114    pub value:        f32,        // current value [0, 1]
115    pub target_value: f32,        // smooth-follow target
116    pub smoothing:    f32,        // lerp speed
117    pub full_color:   Vec4,
118    pub empty_color:  Vec4,
119    pub border_color: Vec4,
120    pub label:        Option<String>,
121    pub label_color:  Vec4,
122    pub show_value:   bool,
123    pub flash_on_low: bool,       // flashes when value < 0.2
124    flash_timer:      f32,
125}
126
127const FILL_CHARS: &[char] = &[' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
128
129impl UiProgressBar {
130    pub fn new(position: Vec3, width_chars: usize, full_color: Vec4) -> Self {
131        Self {
132            position,
133            width_chars: width_chars.max(4),
134            value:         1.0,
135            target_value:  1.0,
136            smoothing:     8.0,
137            full_color,
138            empty_color:   Vec4::new(0.15, 0.15, 0.15, 0.8),
139            border_color:  Vec4::new(0.4, 0.4, 0.4, 0.7),
140            label:         None,
141            label_color:   Vec4::new(0.9, 0.9, 0.9, 1.0),
142            show_value:    false,
143            flash_on_low:  false,
144            flash_timer:   0.0,
145        }
146    }
147
148    pub fn with_label(mut self, label: impl Into<String>) -> Self {
149        self.label = Some(label.into());
150        self
151    }
152
153    pub fn with_value_display(mut self) -> Self {
154        self.show_value = true;
155        self
156    }
157
158    pub fn with_flash_on_low(mut self) -> Self {
159        self.flash_on_low = true;
160        self
161    }
162
163    /// Set the target value [0, 1]. Bar will smooth-lerp toward it.
164    pub fn set_value(&mut self, v: f32) {
165        self.target_value = v.clamp(0.0, 1.0);
166    }
167
168    /// Instantly jump to a value.
169    pub fn snap_value(&mut self, v: f32) {
170        self.value        = v.clamp(0.0, 1.0);
171        self.target_value = self.value;
172    }
173
174    /// Tick the bar (advance smoothing and flash timer).
175    pub fn tick(&mut self, dt: f32) {
176        let diff = self.target_value - self.value;
177        self.value += diff * (self.smoothing * dt).min(1.0);
178        if self.flash_on_low && self.value < 0.2 {
179            self.flash_timer += dt * 4.0;
180        } else {
181            self.flash_timer = 0.0;
182        }
183    }
184
185    pub fn render(&self, engine: &mut ProofEngine, time: f32) -> Vec<GlyphId> {
186        let mut ids = Vec::new();
187        let cols    = self.width_chars;
188
189        // Flash effect when low
190        let flash_alpha = if self.flash_on_low && self.value < 0.2 {
191            0.5 + 0.5 * self.flash_timer.sin()
192        } else {
193            1.0
194        };
195
196        // Draw border: [  bar  ]
197        let border_left  = '[';
198        let border_right = ']';
199
200        let id = engine.scene.spawn_glyph(Glyph {
201            character: border_left,
202            position:  self.position,
203            color:     self.border_color * Vec4::new(1.0, 1.0, 1.0, flash_alpha),
204            scale:     Vec2::splat(0.55),
205            layer:     RenderLayer::UI,
206            ..Default::default()
207        });
208        ids.push(id);
209
210        // Draw fill cells
211        let fill_amount  = self.value * cols as f32;
212        for col in 0..cols {
213            let pos = self.position + Vec3::new((col + 1) as f32 * 0.48, 0.0, 0.0);
214            let cell_fill = (fill_amount - col as f32).clamp(0.0, 1.0);
215            let ch_idx    = (cell_fill * (FILL_CHARS.len() - 1) as f32).round() as usize;
216            let ch        = FILL_CHARS[ch_idx];
217
218            let blend  = col as f32 / cols.max(1) as f32;
219            let color  = if cell_fill > 0.0 {
220                Vec4::new(
221                    self.full_color.x * (1.0 - blend * 0.3),
222                    self.full_color.y,
223                    self.full_color.z * (1.0 - blend * 0.2),
224                    self.full_color.w * flash_alpha,
225                )
226            } else {
227                Vec4::new(
228                    self.empty_color.x,
229                    self.empty_color.y,
230                    self.empty_color.z,
231                    self.empty_color.w * flash_alpha,
232                )
233            };
234
235            let emission = if cell_fill > 0.8 { 0.5 } else { 0.1 };
236
237            let id = engine.scene.spawn_glyph(Glyph {
238                character: ch,
239                position:  pos,
240                color,
241                scale:     Vec2::splat(0.50),
242                emission,
243                glow_color: Vec3::new(self.full_color.x, self.full_color.y, self.full_color.z),
244                glow_radius: if cell_fill > 0.9 { 0.4 } else { 0.0 },
245                layer:     RenderLayer::UI,
246                ..Default::default()
247            });
248            ids.push(id);
249        }
250
251        // Right border
252        let rpos = self.position + Vec3::new((cols + 1) as f32 * 0.48, 0.0, 0.0);
253        let id = engine.scene.spawn_glyph(Glyph {
254            character: border_right,
255            position:  rpos,
256            color:     self.border_color * Vec4::new(1.0, 1.0, 1.0, flash_alpha),
257            scale:     Vec2::splat(0.55),
258            layer:     RenderLayer::UI,
259            ..Default::default()
260        });
261        ids.push(id);
262
263        // Label
264        if let Some(ref label) = self.label {
265            let label_x = self.position.x - label.len() as f32 * 0.35 - 0.3;
266            for (i, ch) in label.chars().enumerate() {
267                let pos = Vec3::new(label_x + i as f32 * 0.35, self.position.y, self.position.z);
268                let id = engine.scene.spawn_glyph(Glyph {
269                    character: ch,
270                    position:  pos,
271                    color:     self.label_color,
272                    scale:     Vec2::splat(0.45),
273                    layer:     RenderLayer::UI,
274                    ..Default::default()
275                });
276                ids.push(id);
277            }
278        }
279
280        // Value percentage
281        if self.show_value {
282            let pct_str = format!("{:>3.0}%", self.value * 100.0);
283            let value_x = rpos.x + 0.5;
284            for (i, ch) in pct_str.chars().enumerate() {
285                let pos = Vec3::new(value_x + i as f32 * 0.35, self.position.y, self.position.z);
286                let id = engine.scene.spawn_glyph(Glyph {
287                    character: ch,
288                    position:  pos,
289                    color:     Vec4::new(0.8, 0.8, 0.8, 0.9),
290                    scale:     Vec2::splat(0.42),
291                    layer:     RenderLayer::UI,
292                    ..Default::default()
293                });
294                ids.push(id);
295            }
296        }
297
298        let _ = time; // may be used by animations later
299        ids
300    }
301}
302
303// ── UiButton ──────────────────────────────────────────────────────────────────
304
305/// A clickable text button.
306///
307/// Tracks hover (via NDC mouse position) and click state.
308/// Triggers `on_click` callback when the left mouse button is released over it.
309pub struct UiButton {
310    pub label:        String,
311    pub position:     Vec3,
312    pub normal_color: Vec4,
313    pub hover_color:  Vec4,
314    pub press_color:  Vec4,
315    pub border_color: Vec4,
316    pub char_scale:   f32,
317    pub padding:      f32,
318    /// Width in character units (auto-computed from label if 0).
319    pub width:        f32,
320    hovered:          bool,
321    pressed:          bool,
322    hover_anim:       f32,
323    pub clicked:      bool,
324    /// Callback identifier (checked by game code via `button.clicked`).
325    pub id:           u32,
326}
327
328impl UiButton {
329    pub fn new(label: impl Into<String>, position: Vec3, id: u32) -> Self {
330        let label: String = label.into();
331        let width = label.len() as f32 * 0.5 + 0.8;
332        Self {
333            label,
334            position,
335            normal_color: Vec4::new(0.3, 0.3, 0.35, 0.9),
336            hover_color:  Vec4::new(0.5, 0.5, 0.6, 1.0),
337            press_color:  Vec4::new(0.7, 0.7, 0.8, 1.0),
338            border_color: Vec4::new(0.6, 0.6, 0.7, 0.8),
339            char_scale:   0.55,
340            padding:      0.3,
341            width,
342            hovered:      false,
343            pressed:      false,
344            hover_anim:   0.0,
345            clicked:      false,
346            id,
347        }
348    }
349
350    pub fn tick(&mut self, mouse_ndc: glam::Vec2, mouse_clicked: bool, dt: f32) {
351        self.clicked = false;
352        // TODO: proper NDC → world hit testing requires camera matrices.
353        // For now this is a placeholder that always returns not-hovered.
354        self.hovered = false;
355        self.pressed = self.hovered && mouse_clicked;
356        let hover_anim_target = if self.hovered { 1.0 } else { 0.0 };
357        self.hover_anim += (hover_anim_target - self.hover_anim) * (10.0 * dt).min(1.0);
358        let _ = mouse_ndc;
359    }
360
361    pub fn render(&self, engine: &mut ProofEngine, time: f32) -> Vec<GlyphId> {
362        let mut ids = Vec::new();
363
364        let color = if self.pressed { self.press_color }
365                    else if self.hovered { self.hover_color }
366                    else { self.normal_color };
367
368        let emit = if self.hovered { 0.6 } else { 0.2 };
369        let pulse = if self.hovered {
370            0.0 + 0.1 * (time * 3.0).sin()
371        } else {
372            0.0
373        };
374
375        // Render button text
376        for (i, ch) in self.label.chars().enumerate() {
377            let x = self.position.x + i as f32 * 0.48 - self.label.len() as f32 * 0.24;
378            let id = engine.scene.spawn_glyph(Glyph {
379                character: ch,
380                position:  Vec3::new(x, self.position.y + pulse, self.position.z),
381                color,
382                scale:     Vec2::splat(self.char_scale),
383                emission:  emit,
384                glow_color: Vec3::new(color.x, color.y, color.z),
385                glow_radius: if self.hovered { 0.6 } else { 0.2 },
386                layer:     RenderLayer::UI,
387                ..Default::default()
388            });
389            ids.push(id);
390        }
391
392        ids
393    }
394}
395
396// ── UiPanel ───────────────────────────────────────────────────────────────────
397
398/// A bordered panel container (background + optional title).
399pub struct UiPanel {
400    pub position:     Vec3,
401    pub width:        usize,
402    pub height:       usize,
403    pub border_color: Vec4,
404    pub fill_color:   Vec4,
405    pub title:        Option<String>,
406    pub title_color:  Vec4,
407    pub char_scale:   f32,
408}
409
410impl UiPanel {
411    pub fn new(position: Vec3, width: usize, height: usize) -> Self {
412        Self {
413            position,
414            width:        width.max(3),
415            height:       height.max(3),
416            border_color: Vec4::new(0.4, 0.5, 0.6, 0.8),
417            fill_color:   Vec4::new(0.05, 0.05, 0.1, 0.5),
418            title:        None,
419            title_color:  Vec4::new(0.8, 0.9, 1.0, 1.0),
420            char_scale:   0.5,
421        }
422    }
423
424    pub fn with_title(mut self, title: impl Into<String>) -> Self {
425        self.title = Some(title.into());
426        self
427    }
428
429    pub fn render(&self, engine: &mut ProofEngine, _time: f32) -> Vec<GlyphId> {
430        let mut ids = Vec::new();
431        let cw      = 0.5_f32;
432        let ch      = 0.65_f32;
433
434        // Top border:  ┌──────┐
435        // Fill rows:   │      │
436        // Bottom:      └──────┘
437
438        let top_chars = std::iter::once('┌')
439            .chain(std::iter::repeat('─').take(self.width))
440            .chain(std::iter::once('┐'));
441        let bot_chars = std::iter::once('└')
442            .chain(std::iter::repeat('─').take(self.width))
443            .chain(std::iter::once('┘'));
444
445        for (col, ch_) in top_chars.enumerate() {
446            let pos = self.position + Vec3::new(col as f32 * cw, 0.0, 0.0);
447            let id = engine.scene.spawn_glyph(Glyph {
448                character: ch_, position: pos, color: self.border_color,
449                scale: Vec2::splat(self.char_scale), layer: RenderLayer::UI,
450                ..Default::default()
451            });
452            ids.push(id);
453        }
454
455        for row in 1..=self.height {
456            let y = -(row as f32 * ch);
457            let left_pos  = self.position + Vec3::new(0.0, y, 0.0);
458            let right_pos = self.position + Vec3::new((self.width + 1) as f32 * cw, y, 0.0);
459
460            let id = engine.scene.spawn_glyph(Glyph {
461                character: '│', position: left_pos, color: self.border_color,
462                scale: Vec2::splat(self.char_scale), layer: RenderLayer::UI,
463                ..Default::default()
464            });
465            ids.push(id);
466
467            // Fill row background
468            for col in 1..=self.width {
469                let fill_pos = self.position + Vec3::new(col as f32 * cw, y, -0.1);
470                let id = engine.scene.spawn_glyph(Glyph {
471                    character: ' ', position: fill_pos, color: self.fill_color,
472                    scale: Vec2::splat(self.char_scale), layer: RenderLayer::UI,
473                    ..Default::default()
474                });
475                ids.push(id);
476            }
477
478            let id = engine.scene.spawn_glyph(Glyph {
479                character: '│', position: right_pos, color: self.border_color,
480                scale: Vec2::splat(self.char_scale), layer: RenderLayer::UI,
481                ..Default::default()
482            });
483            ids.push(id);
484        }
485
486        // Bottom border
487        let bot_y = -((self.height as f32 + 1.0) * ch);
488        for (col, ch_) in bot_chars.enumerate() {
489            let pos = self.position + Vec3::new(col as f32 * cw, bot_y, 0.0);
490            let id = engine.scene.spawn_glyph(Glyph {
491                character: ch_, position: pos, color: self.border_color,
492                scale: Vec2::splat(self.char_scale), layer: RenderLayer::UI,
493                ..Default::default()
494            });
495            ids.push(id);
496        }
497
498        // Title text inside the top border
499        if let Some(ref title) = self.title {
500            for (i, ch_) in title.chars().enumerate().take(self.width) {
501                let pos = self.position + Vec3::new((i + 1) as f32 * cw, 0.0, 0.1);
502                let id = engine.scene.spawn_glyph(Glyph {
503                    character: ch_, position: pos, color: self.title_color,
504                    scale: Vec2::splat(self.char_scale * 1.0),
505                    emission: 0.4, glow_color: Vec3::new(0.8, 0.9, 1.0), glow_radius: 0.3,
506                    layer: RenderLayer::UI,
507                    ..Default::default()
508                });
509                ids.push(id);
510            }
511        }
512
513        ids
514    }
515}
516
517// ── UiPulseRing ───────────────────────────────────────────────────────────────
518
519/// A pulsing ring of glyphs around a center point — used for HUD status indicators.
520pub struct UiPulseRing {
521    pub center:   Vec3,
522    pub radius:   f32,
523    pub count:    usize,
524    pub glyph:    char,
525    pub color:    Vec4,
526    pub speed:    f32,
527    pub emission: f32,
528    phase:        f32,
529}
530
531impl UiPulseRing {
532    pub fn new(center: Vec3, radius: f32, count: usize, color: Vec4) -> Self {
533        Self {
534            center, radius, count: count.max(3),
535            glyph: '◆', color, speed: 1.0, emission: 0.8, phase: 0.0,
536        }
537    }
538
539    pub fn with_glyph(mut self, ch: char) -> Self { self.glyph = ch; self }
540    pub fn with_speed(mut self, speed: f32) -> Self { self.speed = speed; self }
541
542    pub fn tick(&mut self, dt: f32) {
543        self.phase += dt * self.speed;
544    }
545
546    pub fn render(&self, engine: &mut ProofEngine, time: f32) -> Vec<GlyphId> {
547        let mut ids = Vec::new();
548        let n = self.count;
549
550        for i in 0..n {
551            let base_angle  = (i as f32 / n as f32) * std::f32::consts::TAU;
552            let wobble      = (time * self.speed * 2.0 + base_angle).sin() * 0.1;
553            let angle       = base_angle + self.phase;
554            let r           = self.radius + wobble;
555            let pos         = self.center + Vec3::new(angle.cos() * r, angle.sin() * r, 0.0);
556
557            // Scale pulses with a sine wave, staggered per glyph
558            let pulse       = 0.8 + 0.2 * ((time * self.speed * 3.0 + base_angle).sin());
559            let emit_pulse  = self.emission * (0.5 + 0.5 * ((time * 2.0 + base_angle).sin()));
560
561            let id = engine.scene.spawn_glyph(Glyph {
562                character:   self.glyph,
563                position:    pos,
564                color:       self.color,
565                scale:       Vec2::splat(0.4 * pulse),
566                emission:    emit_pulse,
567                glow_color:  Vec3::new(self.color.x, self.color.y, self.color.z),
568                glow_radius: 0.6,
569                rotation:    angle + self.phase,
570                layer:       RenderLayer::UI,
571                ..Default::default()
572            });
573            ids.push(id);
574        }
575
576        ids
577    }
578}