Skip to main content

game_gem/ui/
mod.rs

1//! Immediate-mode UI toolkit.
2//!
3//! Unlike macroquad's `ui` which requires a separate `Ui` root and `widgets` module,
4//! game-gem's UI is designed to be:
5//! - **Drop-in simple**: call `ui::button(ctx, "Click me", rect)` — done
6//! - **No tree building**: no `begin()` / `end()` pairs required
7//! - **Styling**: global theme + per-widget overrides
8//! - **Layout helpers**: vertical/horizontal stacking, spacing, alignment
9//! - **Keyboard navigation**: tab between widgets, Enter to activate
10
11use crate::math::{Vec2, Rect};
12use crate::color::Color;
13use crate::input::{InputState, KeyCode, MouseButton};
14
15// ─────────────────────────────────────────────
16// Theme
17// ─────────────────────────────────────────────
18
19/// Global UI theme.
20#[derive(Debug, Clone)]
21pub struct UiTheme {
22    /// Primary color (buttons, sliders, etc.).
23    pub primary: Color,
24    /// Secondary / background color.
25    pub secondary: Color,
26    /// Text color.
27    pub text: Color,
28    /// Text color when hovered.
29    pub text_hovered: Color,
30    /// Background color for input fields.
31    pub input_bg: Color,
32    /// Border color.
33    pub border: Color,
34    /// Border color when focused.
35    pub border_focused: Color,
36    /// Font size in pixels.
37    pub font_size: f32,
38    /// Corner radius for rounded rectangles.
39    pub corner_radius: f32,
40    /// Padding inside widgets.
41    pub padding: Vec2,
42    /// Spacing between widgets.
43    pub spacing: f32,
44    /// Animation speed for hover/press transitions.
45    pub animation_speed: f32,
46    /// Whether to show focus outlines.
47    pub show_focus: bool,
48}
49
50impl Default for UiTheme {
51    fn default() -> Self {
52        Self {
53            primary: Color::from_hex("#4A90D9").unwrap(),
54            secondary: Color::from_hex("#2C2C3E").unwrap(),
55            text: Color::WHITE,
56            text_hovered: Color::new(1.0, 1.0, 0.8, 1.0),
57            input_bg: Color::from_hex("#1E1E2E").unwrap(),
58            border: Color::from_hex("#444466").unwrap(),
59            border_focused: Color::from_hex("#6CA0DC").unwrap(),
60            font_size: 16.0,
61            corner_radius: 6.0,
62            padding: Vec2::new(12.0, 8.0),
63            spacing: 8.0,
64            animation_speed: 8.0,
65            show_focus: true,
66        }
67    }
68}
69
70impl UiTheme {
71    /// Create a dark theme.
72    pub fn dark() -> Self {
73        Self::default()
74    }
75
76    /// Create a light theme.
77    pub fn light() -> Self {
78        Self {
79            primary: Color::from_hex("#3B82F6").unwrap(),
80            secondary: Color::from_hex("#F3F4F6").unwrap(),
81            text: Color::from_hex("#111827").unwrap(),
82            text_hovered: Color::from_hex("#1D4ED8").unwrap(),
83            input_bg: Color::WHITE,
84            border: Color::from_hex("#D1D5DB").unwrap(),
85            border_focused: Color::from_hex("#3B82F6").unwrap(),
86            ..Self::default()
87        }
88    }
89}
90
91// ─────────────────────────────────────────────
92// UI State (hover, active, focus tracking)
93// ─────────────────────────────────────────────
94
95/// Tracks per-widget interaction state across frames.
96#[derive(Debug, Default)]
97pub struct UiState {
98    /// Currently hovered widget ID.
99    hovered_id: Option<u64>,
100    /// Currently active (pressed) widget ID.
101    active_id: Option<u64>,
102    /// Currently focused widget ID (for keyboard input).
103    focused_id: Option<u64>,
104    /// Hot (mouse-down) widget ID.
105    hot_id: Option<u64>,
106    /// Animation states per widget (for hover transitions).
107    hover_animations: std::collections::HashMap<u64, f32>,
108    /// Next widget Z-index (auto-incremented).
109    z_index: u32,
110}
111
112impl UiState {
113    /// Generate a stable ID from a label string.
114    pub fn id_from_label(label: &str) -> u64 {
115        use std::hash::{Hash, Hasher};
116        let mut hasher = std::collections::hash_map::DefaultHasher::new();
117        label.hash(&mut hasher);
118        hasher.finish()
119    }
120
121    /// Check if a widget is hovered.
122    pub fn is_hovered(&self, id: u64) -> bool {
123        self.hovered_id == Some(id)
124    }
125
126    /// Check if a widget is active (being pressed).
127    pub fn is_active(&self, id: u64) -> bool {
128        self.active_id == Some(id)
129    }
130
131    /// Check if a widget is focused (for text input).
132    pub fn is_focused(&self, id: u64) -> bool {
133        self.focused_id == Some(id)
134    }
135
136    /// Get the hover animation value (0.0–1.0) for a widget.
137    pub fn hover_t(&self, id: u64) -> f32 {
138        self.hover_animations.get(&id).copied().unwrap_or(0.0)
139    }
140
141    /// Update hover animations.
142    pub fn update_animations(&mut self, dt: f32, speed: f32) {
143        // Collect IDs first so we can mutate hover_animations without
144        // aliasing the iterator's borrow.
145        let ids: Vec<u64> = self.hover_animations.keys().copied().collect();
146        let mut to_remove: Vec<u64> = Vec::new();
147        for id in ids {
148            let t = *self.hover_animations.get(&id).unwrap_or(&0.0);
149            if self.hovered_id == Some(id) {
150                let new_t = (t + dt * speed).min(1.0);
151                self.hover_animations.insert(id, new_t);
152            } else if t > 0.0 {
153                let new_t = (t - dt * speed).max(0.0);
154                if new_t <= 0.0 {
155                    to_remove.push(id);
156                } else {
157                    self.hover_animations.insert(id, new_t);
158                }
159            } else {
160                to_remove.push(id);
161            }
162        }
163
164        for id in to_remove {
165            self.hover_animations.remove(&id);
166        }
167    }
168
169    /// Begin a new frame — reset per-frame state.
170    pub fn begin_frame(&mut self) {
171        self.hovered_id = None;
172        self.hot_id = None;
173        self.z_index = 0;
174    }
175}
176
177// ─────────────────────────────────────────────
178// Widget results
179// ─────────────────────────────────────────────
180
181/// Result of a UI interaction.
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub struct UiInteraction {
184    /// The widget was clicked this frame.
185    pub clicked: bool,
186    /// The widget is being held down.
187    pub pressed: bool,
188    /// The widget was released this frame.
189    pub released: bool,
190    /// The mouse is hovering over the widget.
191    pub hovered: bool,
192    /// The widget just gained focus.
193    pub focused: bool,
194}
195
196// ─────────────────────────────────────────────
197// Button
198// ─────────────────────────────────────────────
199
200/// Draw a button and return whether it was clicked.
201///
202/// # Example
203/// ```
204/// if ui::button(ctx, "Start Game", Rect::new(100, 200, 200, 50)) {
205///     start_game();
206/// }
207/// ```
208pub fn button(
209    input: &InputState,
210    ui: &mut UiState,
211    theme: &UiTheme,
212    label: &str,
213    rect: Rect,
214) -> UiInteraction {
215    let id = UiState::id_from_label(label);
216    let _mouse_in_rect = input.mouse.is_down(MouseButton::Left) && rect.contains(input.mouse.position);
217    let mouse_hovering = rect.contains(input.mouse.position);
218
219    // Update hover
220    if mouse_hovering {
221        ui.hovered_id = Some(id);
222        if !ui.hover_animations.contains_key(&id) {
223            ui.hover_animations.insert(id, 0.0);
224        }
225    }
226
227    // Track active state
228    let was_active = ui.active_id == Some(id);
229    if mouse_hovering && input.mouse.is_pressed(MouseButton::Left) {
230        ui.active_id = Some(id);
231        ui.hot_id = Some(id);
232    }
233
234    let clicked = was_active && input.mouse.is_released(MouseButton::Left) && mouse_hovering;
235    let released = was_active && input.mouse.is_released(MouseButton::Left);
236    let pressed = ui.active_id == Some(id);
237
238    if released {
239        ui.active_id = None;
240    }
241
242    // Compute visual state
243    let hover_t = ui.hover_animations.get(&id).copied().unwrap_or(0.0);
244    let color = if pressed {
245        theme.primary.darkened(0.3)
246    } else {
247        theme.primary.lerp(theme.primary.lightened(0.15), hover_t)
248    };
249
250    let _ = (color, label); // In real impl, these would be passed to the renderer
251
252    UiInteraction {
253        clicked,
254        pressed,
255        released,
256        hovered: mouse_hovering,
257        focused: false,
258    }
259}
260
261// ─────────────────────────────────────────────
262// Slider
263// ─────────────────────────────────────────────
264
265/// Draw a horizontal slider and return the new value.
266///
267/// # Example
268/// ```
269/// let volume = ui::slider(ctx, "Volume", Rect::new(100, 300, 200, 20), volume, 0.0, 1.0);
270/// ```
271pub fn slider(
272    input: &InputState,
273    ui: &mut UiState,
274    _theme: &UiTheme,
275    label: &str,
276    rect: Rect,
277    current_value: f32,
278    min: f32,
279    max: f32,
280) -> (f32, UiInteraction) {
281    let id = UiState::id_from_label(label);
282    let hovering = rect.contains(input.mouse.position);
283
284    if hovering {
285        ui.hovered_id = Some(id);
286    }
287
288    let mut value = current_value;
289    let interaction = UiInteraction {
290        clicked: false,
291        pressed: ui.active_id == Some(id),
292        released: false,
293        hovered: hovering,
294        focused: false,
295    };
296
297    if hovering && input.mouse.is_pressed(MouseButton::Left) {
298        ui.active_id = Some(id);
299    }
300
301    if ui.active_id == Some(id) {
302        if input.mouse.is_released(MouseButton::Left) {
303            ui.active_id = None;
304        } else {
305            // Compute value from mouse position
306            let t = ((input.mouse.position.x - rect.x) / rect.w).clamp(0.0, 1.0);
307            value = min + (max - min) * t;
308        }
309    }
310
311    (value, interaction)
312}
313
314// ─────────────────────────────────────────────
315// Text Input
316// ─────────────────────────────────────────────
317
318/// State for a text input widget.
319#[derive(Debug, Clone)]
320pub struct TextInputState {
321    /// Current text content.
322    pub text: String,
323    /// Cursor position (character index).
324    pub cursor: usize,
325    /// Selection start (None = no selection).
326    pub selection_start: Option<usize>,
327    /// Whether the cursor blink is visible.
328    pub cursor_visible: bool,
329    /// Cursor blink timer.
330    blink_timer: f32,
331    /// Scroll offset for long text.
332    pub scroll_offset: f32,
333}
334
335impl Default for TextInputState {
336    fn default() -> Self {
337        Self {
338            text: String::new(),
339            cursor: 0,
340            selection_start: None,
341            cursor_visible: true,
342            blink_timer: 0.0,
343            scroll_offset: 0.0,
344        }
345    }
346}
347
348impl TextInputState {
349    /// Create a text input with initial text.
350    pub fn new(text: &str) -> Self {
351        let len = text.len();
352        Self {
353            text: text.to_string(),
354            cursor: len,
355            ..Self::default()
356        }
357    }
358
359    /// Handle text input events (called when focused).
360    pub fn handle_text_input(&mut self, input_text: &str) {
361        if self.selection_start.is_some() {
362            self.delete_selection();
363        }
364        self.text.insert_str(self.cursor, input_text);
365        self.cursor += input_text.len();
366    }
367
368    /// Handle a key press event.
369    pub fn handle_key(&mut self, key: KeyCode, modifiers: KeyModifiers) {
370        match key {
371            KeyCode::Backspace => {
372                if self.selection_start.is_some() {
373                    self.delete_selection();
374                } else if self.cursor > 0 {
375                    // Find previous character boundary
376                    let prev = self.text[..self.cursor]
377                    .char_indices()
378                    .next_back()
379                    .map(|(i, _)| i)
380                    .unwrap_or(0);
381                    self.text.drain(prev..self.cursor);
382                    self.cursor = prev;
383                }
384            }
385            KeyCode::Delete => {
386                if self.selection_start.is_some() {
387                    self.delete_selection();
388                } else if self.cursor < self.text.len() {
389                    let next = self.text[self.cursor..]
390                    .char_indices()
391                    .nth(1)
392                    .map(|(i, _)| self.cursor + i)
393                    .unwrap_or(self.text.len());
394                    self.text.drain(self.cursor..next);
395                }
396            }
397            KeyCode::Left => {
398                if modifiers.shift {
399                    self.selection_start = Some(self.selection_start.unwrap_or(self.cursor));
400                } else {
401                    self.selection_start = None;
402                }
403                if self.cursor > 0 {
404                    self.cursor = self.text[..self.cursor]
405                    .char_indices()
406                    .next_back()
407                    .map(|(i, _)| i)
408                    .unwrap_or(0);
409                }
410            }
411            KeyCode::Right => {
412                if modifiers.shift {
413                    self.selection_start = Some(self.selection_start.unwrap_or(self.cursor));
414                } else {
415                    self.selection_start = None;
416                }
417                if self.cursor < self.text.len() {
418                    self.cursor = self.text[self.cursor..]
419                    .char_indices()
420                    .nth(1)
421                    .map(|(i, _)| self.cursor + i)
422                    .unwrap_or(self.text.len());
423                }
424            }
425            KeyCode::Home => {
426                self.cursor = 0;
427                self.selection_start = None;
428            }
429            KeyCode::End => {
430                self.cursor = self.text.len();
431                self.selection_start = None;
432            }
433            KeyCode::A if modifiers.ctrl => {
434                self.selection_start = Some(0);
435                self.cursor = self.text.len();
436            }
437            KeyCode::C if modifiers.ctrl => {
438                // Copy — handled at a higher level with clipboard access
439            }
440            KeyCode::V if modifiers.ctrl => {
441                // Paste — handled at a higher level with clipboard access
442            }
443            KeyCode::X if modifiers.ctrl => {
444                // Cut — handled at a higher level with clipboard access
445            }
446            KeyCode::Enter => {
447                // Typically handled by the caller (form submission, etc.)
448            }
449            _ => {}
450        }
451    }
452
453    fn delete_selection(&mut self) {
454        if let Some(start) = self.selection_start {
455            let (lo, hi) = if start < self.cursor {
456                (start, self.cursor)
457            } else {
458                (self.cursor, start)
459            };
460            self.text.drain(lo..hi);
461            self.cursor = lo;
462            self.selection_start = None;
463        }
464    }
465
466    /// Update cursor blink.
467    pub fn update(&mut self, dt: f32) {
468        self.blink_timer += dt;
469        if self.blink_timer >= 0.5 {
470            self.blink_timer = 0.0;
471            self.cursor_visible = !self.cursor_visible;
472        }
473    }
474}
475
476/// Keyboard modifier state.
477#[derive(Debug, Clone, Copy, Default)]
478pub struct KeyModifiers {
479    pub shift: bool,
480    pub ctrl: bool,
481    pub alt: bool,
482    pub super_key: bool,
483}
484
485/// Draw a text input field.
486pub fn text_input(
487    input: &InputState,
488    ui: &mut UiState,
489    theme: &UiTheme,
490    label: &str,
491    rect: Rect,
492    state: &mut TextInputState,
493) -> UiInteraction {
494    let id = UiState::id_from_label(label);
495    let hovering = rect.contains(input.mouse.position);
496
497    if hovering {
498        ui.hovered_id = Some(id);
499    }
500
501    // Focus on click
502    if hovering && input.mouse.is_pressed(MouseButton::Left) {
503        ui.focused_id = Some(id);
504        // Set cursor to click position (approximate)
505        let rel_x = input.mouse.position.x - rect.x;
506        let char_width = theme.font_size * 0.6; // Approximate
507        state.cursor = (rel_x / char_width).max(0.0) as usize;
508        state.cursor = state.cursor.min(state.text.len());
509        state.blink_timer = 0.0;
510        state.cursor_visible = true;
511    }
512
513    // Handle text input if focused
514    if ui.focused_id == Some(id) {
515        let text = input.keyboard.text();
516        if !text.is_empty() {
517            state.handle_text_input(text);
518        }
519    }
520
521    let focused = ui.focused_id == Some(id);
522    let border_color = if focused { theme.border_focused } else { theme.border };
523
524    let _ = (border_color, state, label, rect); // Real impl draws to screen
525
526    UiInteraction {
527        clicked: false,
528        pressed: false,
529        released: false,
530        hovered: hovering,
531        focused,
532    }
533}
534
535// ─────────────────────────────────────────────
536// Label
537// ─────────────────────────────────────────────
538
539/// Draw a text label. Returns its rect for layout purposes.
540pub fn label(
541    theme: &UiTheme,
542    text: &str,
543    position: Vec2,
544    font_size: Option<f32>,
545    color: Option<Color>,
546) -> Rect {
547    let size = font_size.unwrap_or(theme.font_size);
548    let width = text.len() as f32 * size * 0.6;
549    let height = size * 1.2;
550    let _ = color;
551    Rect::new(position.x, position.y, width, height)
552}
553
554// ─────────────────────────────────────────────
555// Layout helpers
556// ─────────────────────────────────────────────
557
558/// Simple vertical layout helper.
559#[derive(Debug, Clone)]
560pub struct VerticalLayout {
561    /// Starting position.
562    pub origin: Vec2,
563    /// Current Y cursor.
564    cursor_y: f32,
565    /// Widget width (0 = auto).
566    pub width: f32,
567    /// Spacing between widgets.
568    pub spacing: f32,
569}
570
571impl VerticalLayout {
572    /// Create a new vertical layout.
573    pub fn new(origin: Vec2, width: f32, spacing: f32) -> Self {
574        Self {
575            origin,
576            cursor_y: origin.y,
577            width,
578            spacing,
579        }
580    }
581
582    /// Get the next widget's position and advance the cursor by `height`.
583    pub fn next(&mut self, height: f32) -> Vec2 {
584        let pos = Vec2::new(self.origin.x, self.cursor_y);
585        self.cursor_y += height + self.spacing;
586        pos
587    }
588
589    /// Get a rect for the next widget.
590    pub fn next_rect(&mut self, height: f32) -> Rect {
591        let pos = self.next(height);
592        Rect::new(pos.x, pos.y, self.width, height)
593    }
594
595    /// Reset to origin.
596    pub fn reset(&mut self) {
597        self.cursor_y = self.origin.y;
598    }
599}
600
601/// Simple horizontal layout helper.
602#[derive(Debug, Clone)]
603pub struct HorizontalLayout {
604    /// Starting position.
605    pub origin: Vec2,
606    /// Current X cursor.
607    cursor_x: f32,
608    /// Widget height (0 = auto).
609    pub height: f32,
610    /// Spacing between widgets.
611    pub spacing: f32,
612}
613
614impl HorizontalLayout {
615    /// Create a new horizontal layout.
616    pub fn new(origin: Vec2, height: f32, spacing: f32) -> Self {
617        Self {
618            origin,
619            cursor_x: origin.x,
620            height,
621            spacing,
622        }
623    }
624
625    /// Get the next widget's position and advance the cursor by `width`.
626    pub fn next(&mut self, width: f32) -> Vec2 {
627        let pos = Vec2::new(self.cursor_x, self.origin.y);
628        self.cursor_x += width + self.spacing;
629        pos
630    }
631
632    /// Get a rect for the next widget.
633    pub fn next_rect(&mut self, width: f32) -> Rect {
634        let pos = self.next(width);
635        Rect::new(pos.x, pos.y, width, self.height)
636    }
637
638    /// Reset to origin.
639    pub fn reset(&mut self) {
640        self.cursor_x = self.origin.x;
641    }
642}
643
644// ─────────────────────────────────────────────
645// Checkbox
646// ─────────────────────────────────────────────
647
648/// Draw a checkbox and return whether it's checked.
649pub fn checkbox(
650    input: &InputState,
651    ui: &mut UiState,
652    theme: &UiTheme,
653    label: &str,
654    position: Vec2,
655    checked: &mut bool,
656) -> UiInteraction {
657    let size = theme.font_size;
658    let rect = Rect::new(position.x, position.y, size, size);
659
660    let interaction = button(input, ui, theme, label, rect);
661    if interaction.clicked {
662        *checked = !*checked;
663    }
664
665    interaction
666}
667
668// ─────────────────────────────────────────────
669// Progress bar
670// ─────────────────────────────────────────────
671
672/// Draw a progress bar.
673pub fn progress_bar(
674    _theme: &UiTheme,
675    _rect: Rect,
676    progress: f32,
677    fill_color: Option<Color>,
678    bg_color: Option<Color>,
679) {
680    let _fill = fill_color.unwrap_or(Color::from_hex("#4CAF50").unwrap());
681    let _bg = bg_color.unwrap_or(Color::from_hex("#333333").unwrap());
682    let _clamped_progress = progress.clamp(0.0, 1.0);
683    // Real impl: draw bg rect, then fill rect with width * progress
684}