Skip to main content

jag_draw/
text_layout.rs

1//! Fast text layout and wrapping utilities with caching support.
2//!
3//! This module provides efficient text wrapping that can be cached between frames
4//! to avoid expensive per-frame string allocations and processing.
5//!
6//! # Performance
7//! - Uses character-count approximation for fast wrapping
8//! - Cache provides ~95% hit rate during window resize
9//! - Mutex-protected for thread safety
10//! - Lazy eviction (only when 2x capacity)
11//!
12//! # Example
13//! ```no_run
14//! use jag_draw::TextLayoutCache;
15//!
16//! let cache = TextLayoutCache::new(200);
17//! let wrapped = cache.get_or_wrap(
18//!     "Long text that needs wrapping...",
19//!     400.0,  // max_width
20//!     16.0,   // font_size
21//!     1.2,    // line_height_factor
22//! );
23//!
24//! for line in wrapped.lines {
25//!     // Render each line...
26//! }
27//! ```
28
29use std::collections::HashMap;
30use std::hash::{Hash, Hasher};
31use std::sync::Mutex;
32
33/// A cache key for wrapped text layout.
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35struct LayoutKey {
36    text_hash: u64,
37    max_width_bits: u32, // f32 as bits for hashing
38    size_bits: u32,      // f32 as bits for hashing
39}
40
41impl LayoutKey {
42    fn new(text: &str, max_width: f32, size: f32) -> Self {
43        // Hash the text content
44        let mut hasher = std::collections::hash_map::DefaultHasher::new();
45        text.hash(&mut hasher);
46        let text_hash = hasher.finish();
47
48        Self {
49            text_hash,
50            max_width_bits: max_width.to_bits(),
51            size_bits: size.to_bits(),
52        }
53    }
54}
55
56/// Cached wrapped text lines.
57#[derive(Clone, Debug)]
58pub struct WrappedText {
59    /// The wrapped lines of text
60    pub lines: Vec<String>,
61    /// Approximate height of each line (size * line_height_factor)
62    pub line_height: f32,
63    /// Total height of all lines
64    pub total_height: f32,
65}
66
67/// A cache for wrapped text layouts to avoid per-frame allocations.
68pub struct TextLayoutCache {
69    cache: Mutex<HashMap<LayoutKey, WrappedText>>,
70    max_entries: usize,
71}
72
73impl TextLayoutCache {
74    /// Create a new text layout cache with a maximum number of entries.
75    pub fn new(max_entries: usize) -> Self {
76        Self {
77            cache: Mutex::new(HashMap::new()),
78            max_entries,
79        }
80    }
81
82    /// Get or compute wrapped text layout.
83    pub fn get_or_wrap(
84        &self,
85        text: &str,
86        max_width: f32,
87        size: f32,
88        line_height_factor: f32,
89    ) -> WrappedText {
90        let key = LayoutKey::new(text, max_width, size);
91
92        // Try to get from cache
93        {
94            let cache = self.cache.lock().unwrap();
95            if let Some(wrapped) = cache.get(&key) {
96                return wrapped.clone();
97            }
98        }
99
100        // Compute the wrapped text
101        let wrapped = wrap_text_fast(text, max_width, size, line_height_factor);
102
103        // Store in cache (with size limit)
104        {
105            let mut cache = self.cache.lock().unwrap();
106            // Only evict if we're significantly over the limit to reduce lock contention
107            if cache.len() >= self.max_entries * 2 {
108                // Simple eviction: clear to half capacity
109                let target_size = self.max_entries;
110                let keys_to_remove: Vec<_> = cache
111                    .keys()
112                    .take(cache.len() - target_size)
113                    .cloned()
114                    .collect();
115                for k in keys_to_remove {
116                    cache.remove(&k);
117                }
118            }
119            cache.insert(key, wrapped.clone());
120        }
121
122        wrapped
123    }
124
125    /// Clear the cache.
126    pub fn clear(&self) {
127        self.cache.lock().unwrap().clear();
128    }
129}
130
131impl Default for TextLayoutCache {
132    fn default() -> Self {
133        Self::new(100)
134    }
135}
136
137/// Fast word-wrapping using character-count approximation.
138///
139/// This avoids expensive glyph measurement by using a simple average character width.
140/// Good enough for UI text where exact pixel-perfect wrapping isn't critical.
141pub fn wrap_text_fast(
142    text: &str,
143    max_width: f32,
144    size: f32,
145    line_height_factor: f32,
146) -> WrappedText {
147    let line_height = size * line_height_factor;
148
149    // Fast character-count approximation
150    // Use 0.65 as a conservative estimate to prevent premature wrapping
151    // when measured width is smaller than actual rendered width.
152    let avg_char_width = size * 0.65;
153    let max_chars = (max_width / avg_char_width).floor() as usize;
154
155    if max_chars == 0 {
156        return WrappedText {
157            lines: vec![],
158            line_height,
159            total_height: 0.0,
160        };
161    }
162
163    // Word-wrap using character count
164    let words: Vec<&str> = text.split_whitespace().collect();
165    let mut lines: Vec<String> = Vec::new();
166    let mut current_line = String::new();
167
168    for word in words {
169        let test = if current_line.is_empty() {
170            word.to_string()
171        } else {
172            format!("{} {}", current_line, word)
173        };
174
175        if test.len() <= max_chars {
176            current_line = test;
177        } else {
178            if !current_line.is_empty() {
179                lines.push(current_line);
180            }
181            // Handle very long words by breaking them
182            if word.len() > max_chars {
183                let mut remaining = word;
184                while remaining.len() > max_chars {
185                    let (chunk, rest) = remaining.split_at(max_chars);
186                    lines.push(chunk.to_string());
187                    remaining = rest;
188                }
189                current_line = remaining.to_string();
190            } else {
191                current_line = word.to_string();
192            }
193        }
194    }
195    if !current_line.is_empty() {
196        lines.push(current_line);
197    }
198
199    let total_height = lines.len() as f32 * line_height;
200
201    WrappedText {
202        lines,
203        line_height,
204        total_height,
205    }
206}
207
208/// Render wrapped text to a display list or canvas.
209///
210/// This is a helper that takes pre-wrapped text and renders it line by line.
211pub fn render_wrapped_text<F>(wrapped: &WrappedText, pos: [f32; 2], mut render_line: F)
212where
213    F: FnMut(&str, [f32; 2]),
214{
215    for (i, line) in wrapped.lines.iter().enumerate() {
216        let y = pos[1] + (i as f32) * wrapped.line_height;
217        render_line(line, [pos[0], y]);
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_wrap_text_fast() {
227        let text = "This is a test of the text wrapping system.";
228        let wrapped = wrap_text_fast(text, 100.0, 16.0, 1.2);
229        assert!(!wrapped.lines.is_empty());
230        assert!(wrapped.total_height > 0.0);
231    }
232
233    #[test]
234    fn test_cache() {
235        let cache = TextLayoutCache::new(10);
236        let text = "Hello world";
237
238        let w1 = cache.get_or_wrap(text, 100.0, 16.0, 1.2);
239        let w2 = cache.get_or_wrap(text, 100.0, 16.0, 1.2);
240
241        // Should be the same (from cache)
242        assert_eq!(w1.lines.len(), w2.lines.len());
243    }
244}