Skip to main content

minifb_ui/ui/
textinput.rs

1use crate::Key;
2use fontdue::layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle};
3
4pub struct TextInput {
5    pub text: String,
6    pub font: Option<crate::ttf::Font>,
7    pub font_size: f32,
8
9    pub pos_x: usize,
10    pub pos_y: usize,
11    pub width: usize,
12    pub height: usize,
13
14    pub bg_col_idle: crate::color::Color,
15    pub border_col_idle: crate::color::Color,
16    pub text_col_idle: crate::color::Color,
17    pub border_size_idle: usize,
18
19    pub bg_col_editing: crate::color::Color,
20    pub border_col_editing: crate::color::Color,
21    pub text_col_editing: crate::color::Color,
22    pub border_size_editing: usize,
23
24    pub cursor_col: crate::color::Color,
25    pub cursor_width: usize,
26
27    /// Corner radius for rounded edges
28    pub radius: usize,
29
30    pub state: TextInputState,
31    pub cursor_pos: usize,
32    pub scroll_offset: f32,
33    lmb_was_down: bool,
34}
35
36#[derive(Default, PartialEq)]
37pub enum TextInputState {
38    #[default]
39    Idle,
40    Editing,
41}
42
43impl Default for TextInput {
44    fn default() -> Self {
45        Self {
46            text: String::new(),
47            font: None,
48            font_size: 16.0,
49            pos_x: 0,
50            pos_y: 0,
51            width: 200,
52            height: 24,
53            bg_col_idle: crate::color::Color::new(30, 30, 30),
54            border_col_idle: crate::color::Color::new(100, 100, 100),
55            text_col_idle: crate::color::Color::new(200, 200, 200),
56            border_size_idle: 1,
57            bg_col_editing: crate::color::Color::new(40, 40, 40),
58            border_col_editing: crate::color::Color::new(100, 160, 255),
59            text_col_editing: crate::color::Color::new(255, 255, 255),
60            border_size_editing: 2,
61            cursor_col: crate::color::Color::new(255, 255, 255),
62            cursor_width: 2,
63            radius: 0,
64            state: TextInputState::Idle,
65            cursor_pos: 0,
66            scroll_offset: 0.0,
67            lmb_was_down: false,
68        }
69    }
70}
71
72impl TextInput {
73    pub fn font(mut self, font: crate::ttf::Font, size: f32) -> Self {
74        self.font = Some(font);
75        self.font_size = size;
76        self
77    }
78
79    pub fn position(mut self, x: usize, y: usize) -> Self {
80        self.pos_x = x;
81        self.pos_y = y;
82        self
83    }
84
85    pub fn size(mut self, width: usize, height: usize) -> Self {
86        self.width = width;
87        self.height = height;
88        self
89    }
90
91    pub fn placeholder(mut self, text: &str) -> Self {
92        self.text = text.to_string();
93        self.cursor_pos = text.len();
94        self
95    }
96
97    pub fn background(mut self, color: crate::color::Color) -> Self {
98        self.bg_col_editing = color.clone();
99        self.bg_col_idle = color;
100        self
101    }
102
103    pub fn idle_bg(mut self, color: crate::color::Color) -> Self {
104        self.bg_col_idle = color;
105        self
106    }
107
108    pub fn editing_bg(mut self, color: crate::color::Color) -> Self {
109        self.bg_col_editing = color;
110        self
111    }
112
113    pub fn border_color(mut self, color: crate::color::Color) -> Self {
114        self.border_col_editing = color.clone();
115        self.border_col_idle = color;
116        self
117    }
118
119    pub fn idle_border_col(mut self, color: crate::color::Color) -> Self {
120        self.border_col_idle = color;
121        self
122    }
123
124    pub fn editing_border_col(mut self, color: crate::color::Color) -> Self {
125        self.border_col_editing = color;
126        self
127    }
128
129    pub fn border(mut self, size: usize) -> Self {
130        self.border_size_idle = size;
131        self.border_size_editing = size;
132        self
133    }
134
135    pub fn text_color(mut self, color: crate::color::Color) -> Self {
136        self.text_col_editing = color.clone();
137        self.text_col_idle = color;
138        self
139    }
140
141    pub fn idle_text_col(mut self, color: crate::color::Color) -> Self {
142        self.text_col_idle = color;
143        self
144    }
145
146    pub fn editing_text_col(mut self, color: crate::color::Color) -> Self {
147        self.text_col_editing = color;
148        self
149    }
150
151    pub fn cursor_color(mut self, color: crate::color::Color) -> Self {
152        self.cursor_col = color;
153        self
154    }
155
156    /// Sets the corner radius for rounded edges
157    pub fn radius(mut self, radius: usize) -> Self {
158        self.radius = radius;
159        self
160    }
161
162    /// Returns the current text content
163    pub fn value(&self) -> &str {
164        &self.text
165    }
166
167    /// Returns whether the input is currently being edited
168    pub fn is_editing(&self) -> bool {
169        self.state == TextInputState::Editing
170    }
171
172    /// Uses fontdue Layout to compute the x offset (in pixels) for each glyph,
173    /// returning the x position where character at `index` starts.
174    /// If index == text.len(), returns the position after the last glyph.
175    fn cursor_x_offset(&self, font: &crate::ttf::Font, index: usize) -> f32 {
176        if self.text.is_empty() || index == 0 {
177            return 0.0;
178        }
179
180        let fonts = font.as_slice();
181        let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
182        layout.reset(&LayoutSettings {
183            x: 0.0,
184            y: 0.0,
185            ..Default::default()
186        });
187        layout.append(&fonts, &TextStyle::new(&self.text, self.font_size, 0));
188
189        let glyphs = layout.glyphs();
190
191        // Map cursor_pos (byte index) to glyph index
192        let mut byte_offset = 0;
193        let mut glyph_index = 0;
194        for (i, c) in self.text.chars().enumerate() {
195            if byte_offset >= index {
196                glyph_index = i;
197                break;
198            }
199            byte_offset += c.len_utf8();
200            glyph_index = i + 1;
201        }
202
203        if glyph_index >= glyphs.len() {
204            // Cursor is at the end: use last glyph's x + its advance width
205            if let Some(last) = glyphs.last() {
206                let metrics = font.font.metrics(
207                    self.text.chars().last().unwrap(),
208                    self.font_size,
209                );
210                return last.x + metrics.advance_width;
211            }
212            return 0.0;
213        }
214
215        glyphs[glyph_index].x
216    }
217
218    fn update(&mut self, window: &mut crate::window::Window) {
219        let mouse = window.get_mouse_state();
220        let lmb_down = mouse.lmb_clicked;
221        let click_edge = lmb_down && !self.lmb_was_down;
222
223        let mx = mouse.pos_x as usize;
224        let my = mouse.pos_y as usize;
225        let in_bounds = mx >= self.pos_x
226            && mx < self.pos_x + self.width
227            && my >= self.pos_y
228            && my < self.pos_y + self.height;
229
230        if click_edge {
231            match self.state {
232                TextInputState::Idle => {
233                    if in_bounds {
234                        self.state = TextInputState::Editing;
235                        self.cursor_pos = self.text.len();
236                    }
237                }
238                TextInputState::Editing => {
239                    self.state = TextInputState::Idle;
240                }
241            }
242        }
243
244        if self.state == TextInputState::Editing {
245            // Use OS-level InputCallback for character input.
246            // This correctly handles Shift, Caps Lock, dead keys, compose, etc.
247            let typed = window.get_typed_chars();
248            for c in typed {
249                if c >= ' ' && c != '\x7f' {
250                    self.text.insert(self.cursor_pos, c);
251                    self.cursor_pos += 1;
252                }
253            }
254
255            // Use get_keys_pressed only for control/navigation keys
256            let keys = window.window.get_keys_pressed(crate::KeyRepeat::Yes);
257            for key in keys {
258                match key {
259                    Key::Enter => {
260                        self.state = TextInputState::Idle;
261                        break;
262                    }
263                    Key::Escape => {
264                        self.state = TextInputState::Idle;
265                        break;
266                    }
267                    Key::Backspace => {
268                        if self.cursor_pos > 0 {
269                            self.text.remove(self.cursor_pos - 1);
270                            self.cursor_pos -= 1;
271                        }
272                    }
273                    Key::Delete => {
274                        if self.cursor_pos < self.text.len() {
275                            self.text.remove(self.cursor_pos);
276                        }
277                    }
278                    Key::Left => {
279                        if self.cursor_pos > 0 {
280                            self.cursor_pos -= 1;
281                        }
282                    }
283                    Key::Right => {
284                        if self.cursor_pos < self.text.len() {
285                            self.cursor_pos += 1;
286                        }
287                    }
288                    Key::Home => {
289                        self.cursor_pos = 0;
290                    }
291                    Key::End => {
292                        self.cursor_pos = self.text.len();
293                    }
294                    _ => {}
295                }
296            }
297        }
298
299        self.lmb_was_down = lmb_down;
300    }
301
302    /// Draws and updates the text input
303    pub fn draw(&mut self, window: &mut crate::window::Window) {
304        self.update(window);
305
306        let (bg_col, border_col, text_col, border_size) = match self.state {
307            TextInputState::Idle => (
308                &self.bg_col_idle,
309                &self.border_col_idle,
310                &self.text_col_idle,
311                self.border_size_idle,
312            ),
313            TextInputState::Editing => (
314                &self.bg_col_editing,
315                &self.border_col_editing,
316                &self.text_col_editing,
317                self.border_size_editing,
318            ),
319        };
320
321        // Background
322        window.draw_rect_f(self.pos_x, self.pos_y, self.width, self.height, self.radius, bg_col, 0);
323
324        // Border
325        for i in 0..border_size {
326            window.draw_rect(
327                self.pos_x + i,
328                self.pos_y + i,
329                self.width - i * 2,
330                self.height - i * 2,
331                self.radius.saturating_sub(i),
332                border_col,
333            );
334        }
335
336        // Text and cursor
337        if let Some(font) = &self.font {
338            let padding = 4;
339            let text_area_x = self.pos_x + border_size + padding;
340            let text_area_w = self.width.saturating_sub((border_size + padding) * 2);
341
342            // Compute cursor x offset using layout engine
343            let cursor_offset = self.cursor_x_offset(font, self.cursor_pos);
344
345            // Adjust scroll_offset so cursor is always visible
346            let cursor_in_view = cursor_offset - self.scroll_offset;
347            if cursor_in_view < 0.0 {
348                self.scroll_offset = cursor_offset;
349            } else if cursor_in_view > text_area_w as f32 {
350                self.scroll_offset = cursor_offset - text_area_w as f32;
351            }
352
353            // Vertical centering
354            let lm = font.font.horizontal_line_metrics(self.font_size).unwrap();
355            let text_y = (self.pos_y as f32 + (self.height as f32 / 2.0) - (lm.ascent / 2.0)
356                + (lm.descent / 3.0))
357                .max(0.0) as usize;
358
359            // Render text with scroll offset using clipped drawing
360            let text_render_x = text_area_x as f32 - self.scroll_offset;
361            self.draw_text_clipped(
362                window,
363                text_render_x,
364                text_y,
365                font,
366                text_col,
367                text_area_x,
368                text_area_x + text_area_w,
369            );
370
371            // Cursor
372            if self.state == TextInputState::Editing {
373                let cursor_screen_x = text_area_x as f32 + cursor_offset - self.scroll_offset;
374                let cx = cursor_screen_x as usize;
375                // Only draw cursor if it's within the visible area
376                if cx >= text_area_x && cx < text_area_x + text_area_w {
377                    let cursor_y = self.pos_y + border_size + 2;
378                    let cursor_h = self.height.saturating_sub(border_size * 2 + 4);
379                    window.draw_rect_f(cx, cursor_y, self.cursor_width, cursor_h, 0, &self.cursor_col, 0);
380                }
381            }
382        }
383    }
384
385    /// Renders text clipped to [clip_left, clip_right) horizontally
386    fn draw_text_clipped(
387        &self,
388        window: &mut crate::window::Window,
389        x: f32,
390        y: usize,
391        font: &crate::ttf::Font,
392        color: &crate::color::Color,
393        clip_left: usize,
394        clip_right: usize,
395    ) {
396        let fonts = font.as_slice();
397        let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
398        layout.reset(&LayoutSettings {
399            x,
400            y: y as f32,
401            ..Default::default()
402        });
403        layout.append(&fonts, &TextStyle::new(&self.text, self.font_size, 0));
404
405        let fg_r = color.r as u32;
406        let fg_g = color.g as u32;
407        let fg_b = color.b as u32;
408
409        for glyph in layout.glyphs() {
410            let (metrics, bitmap) = font.font.rasterize_config(glyph.key);
411
412            let glyph_x = glyph.x as i32;
413            let glyph_y = glyph.y as i32;
414
415            for row in 0..metrics.height {
416                for col in 0..metrics.width {
417                    let px = glyph_x + col as i32;
418                    let py = glyph_y + row as i32;
419
420                    if px < clip_left as i32 || px >= clip_right as i32 {
421                        continue;
422                    }
423                    if py < 0 || py >= window.height as i32 {
424                        continue;
425                    }
426
427                    let (px, py) = (px as usize, py as usize);
428
429                    let alpha = bitmap[row * metrics.width + col] as u32;
430                    if alpha == 0 {
431                        continue;
432                    }
433
434                    let idx = py * window.width + px;
435                    let bg = window.framebuffer_raw[idx];
436                    let bg_r = (bg >> 16) & 0xFF;
437                    let bg_g = (bg >> 8) & 0xFF;
438                    let bg_b = bg & 0xFF;
439
440                    let r = (fg_r * alpha + bg_r * (255 - alpha)) / 255;
441                    let g = (fg_g * alpha + bg_g * (255 - alpha)) / 255;
442                    let b = (fg_b * alpha + bg_b * (255 - alpha)) / 255;
443
444                    window.framebuffer_raw[idx] = (r << 16) | (g << 8) | b;
445                }
446            }
447        }
448    }
449}
450