Skip to main content

cranpose_ui/
text.rs

1use std::cell::RefCell;
2
3use crate::text_layout_result::TextLayoutResult;
4
5#[derive(Clone, Copy, Debug, PartialEq)]
6pub struct TextMetrics {
7    pub width: f32,
8    pub height: f32,
9    /// Height of a single line of text
10    pub line_height: f32,
11    /// Number of lines in the text
12    pub line_count: usize,
13}
14
15pub trait TextMeasurer: 'static {
16    fn measure(&self, text: &str) -> TextMetrics;
17
18    /// Returns byte offset in text for given x position.
19    /// Used for cursor positioning on click.
20    ///
21    /// The y parameter is for future multiline support.
22    fn get_offset_for_position(&self, text: &str, x: f32, y: f32) -> usize;
23
24    /// Returns x position for given byte offset.
25    /// Used for cursor rendering and selection geometry.
26    fn get_cursor_x_for_offset(&self, text: &str, offset: usize) -> f32;
27
28    /// Computes full text layout with cached glyph positions.
29    /// Returns TextLayoutResult for O(1) position lookups.
30    fn layout(&self, text: &str) -> TextLayoutResult;
31}
32
33#[derive(Default)]
34struct MonospacedTextMeasurer;
35
36impl MonospacedTextMeasurer {
37    const CHAR_WIDTH: f32 = 8.0;
38    const LINE_HEIGHT: f32 = 20.0;
39}
40
41impl TextMeasurer for MonospacedTextMeasurer {
42    fn measure(&self, text: &str) -> TextMetrics {
43        // Split by newlines to handle multiline
44        let lines: Vec<&str> = text.split('\n').collect();
45        let line_count = lines.len().max(1);
46
47        // Width is the max width of any line
48        let width = lines
49            .iter()
50            .map(|line| line.chars().count() as f32 * Self::CHAR_WIDTH)
51            .fold(0.0_f32, f32::max);
52
53        TextMetrics {
54            width,
55            height: line_count as f32 * Self::LINE_HEIGHT,
56            line_height: Self::LINE_HEIGHT,
57            line_count,
58        }
59    }
60
61    fn get_offset_for_position(&self, text: &str, x: f32, y: f32) -> usize {
62        if text.is_empty() {
63            return 0;
64        }
65
66        // Find which line was clicked based on Y coordinate
67        let line_index = (y / Self::LINE_HEIGHT).floor().max(0.0) as usize;
68        let lines: Vec<&str> = text.split('\n').collect();
69        let target_line = line_index.min(lines.len().saturating_sub(1));
70
71        // Calculate byte offset to start of target line
72        let mut line_start_byte = 0;
73        for line in lines.iter().take(target_line) {
74            line_start_byte += line.len() + 1; // +1 for newline
75        }
76
77        // Find position within the line using X coordinate
78        let line_text = lines.get(target_line).unwrap_or(&"");
79        let char_index = (x / Self::CHAR_WIDTH).round() as usize;
80        let line_char_count = line_text.chars().count();
81        let clamped_index = char_index.min(line_char_count);
82
83        // Convert character index to byte offset within the line
84        let offset_in_line = line_text
85            .char_indices()
86            .nth(clamped_index)
87            .map(|(i, _)| i)
88            .unwrap_or(line_text.len());
89
90        line_start_byte + offset_in_line
91    }
92
93    fn get_cursor_x_for_offset(&self, text: &str, offset: usize) -> f32 {
94        // Count characters up to byte offset
95        let clamped_offset = offset.min(text.len());
96        let char_count = text[..clamped_offset].chars().count();
97        char_count as f32 * Self::CHAR_WIDTH
98    }
99
100    fn layout(&self, text: &str) -> TextLayoutResult {
101        TextLayoutResult::monospaced(text, Self::CHAR_WIDTH, Self::LINE_HEIGHT)
102    }
103}
104
105thread_local! {
106    static TEXT_MEASURER: RefCell<Box<dyn TextMeasurer>> = RefCell::new(Box::new(MonospacedTextMeasurer));
107}
108
109pub fn set_text_measurer<M: TextMeasurer>(measurer: M) {
110    TEXT_MEASURER.with(|m| {
111        *m.borrow_mut() = Box::new(measurer);
112    });
113}
114
115pub fn measure_text(text: &str) -> TextMetrics {
116    TEXT_MEASURER.with(|m| m.borrow().measure(text))
117}
118
119/// Returns byte offset in text for given x position.
120/// Used for cursor positioning on click.
121pub fn get_offset_for_position(text: &str, x: f32, y: f32) -> usize {
122    TEXT_MEASURER.with(|m| m.borrow().get_offset_for_position(text, x, y))
123}
124
125/// Returns x position for given byte offset.
126/// Used for cursor rendering and selection geometry.
127pub fn get_cursor_x_for_offset(text: &str, offset: usize) -> f32 {
128    TEXT_MEASURER.with(|m| m.borrow().get_cursor_x_for_offset(text, offset))
129}
130
131/// Computes full text layout with cached glyph positions.
132/// Returns TextLayoutResult for O(1) position lookups.
133pub fn layout_text(text: &str) -> TextLayoutResult {
134    TEXT_MEASURER.with(|m| m.borrow().layout(text))
135}