Skip to main content

astrelis_text/
pipeline.rs

1//! Text shaping pipeline for deferred/async text processing.
2//!
3//! This module implements a two-tier text pipeline.
4//! It provides a worker-ready abstraction for text shaping that can be executed
5//! synchronously now and moved to worker threads later without API changes.
6
7use crate::ShapedTextResult as BaseShapedTextResult;
8use crate::cache::ShapeKey;
9use astrelis_core::alloc::HashMap;
10
11use std::sync::Arc;
12
13/// Unique identifier for a text shaping request.
14pub type RequestId = u64;
15
16/// Request for text shaping with all necessary parameters.
17///
18/// Uses owned data (String) instead of references to enable Send+Sync
19/// for future worker thread compatibility.
20#[derive(Debug, Clone)]
21pub struct TextShapeRequest {
22    /// Unique ID for this request
23    pub id: RequestId,
24    /// Text content to shape (owned for Send)
25    pub text: String,
26    /// Font identifier from font system
27    pub font_id: u32,
28    /// Font size in pixels
29    pub font_size: f32,
30    /// Optional wrap width for text layout
31    pub wrap_width: Option<f32>,
32}
33
34impl TextShapeRequest {
35    /// Create a new text shape request.
36    pub fn new(
37        id: RequestId,
38        text: String,
39        font_id: u32,
40        font_size: f32,
41        wrap_width: Option<f32>,
42    ) -> Self {
43        Self {
44            id,
45            text,
46            font_id,
47            font_size,
48            wrap_width,
49        }
50    }
51
52    /// Create a shape key for caching.
53    pub fn shape_key(&self) -> ShapeKey {
54        ShapeKey::new(
55            self.font_id,
56            self.font_size,
57            self.text.as_str(),
58            self.wrap_width,
59        )
60    }
61}
62
63/// Result of text shaping with metadata for pipeline management.
64///
65/// Wraps astrelis_text::ShapedTextResult with request tracking and cache stats.
66#[derive(Debug, Clone)]
67pub struct ShapedTextResult {
68    /// Original request ID
69    pub request_id: RequestId,
70    /// Inner shaped text data from astrelis-text
71    pub inner: BaseShapedTextResult,
72    /// Text version this was shaped for
73    /// Number of times this shaped result has been rendered
74    pub render_count: u64,
75}
76
77impl ShapedTextResult {
78    /// Create a new shaped text result.
79    pub fn new(request_id: RequestId, inner: BaseShapedTextResult) -> Self {
80        Self {
81            request_id,
82            inner,
83            render_count: 0,
84        }
85    }
86
87    /// Get the bounds of the shaped text.
88    pub fn bounds(&self) -> (f32, f32) {
89        self.inner.bounds
90    }
91
92    /// Increment render count for cache statistics.
93    pub fn increment_render_count(&mut self) {
94        self.render_count = self.render_count.saturating_add(1);
95    }
96}
97
98/// Trait for text shaping implementations.
99///
100/// This abstraction allows swapping between sync and async implementations
101/// without changing the API. Currently synchronous, but designed for future
102/// worker thread execution.
103pub trait TextShaper: Send + Sync {
104    /// Shape text according to the request parameters.
105    fn shape(&mut self, request: TextShapeRequest) -> ShapedTextResult;
106}
107
108/// Synchronous text shaper using a callback for measurement.
109///
110/// This is the initial implementation that performs shaping on the calling thread.
111/// Since FontRenderer isn't Send+Sync, we don't implement TextShaper trait here.
112pub struct SyncTextShaper;
113
114impl Default for SyncTextShaper {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl SyncTextShaper {
121    /// Create a new synchronous text shaper.
122    pub fn new() -> Self {
123        Self {}
124    }
125
126    /// Shape text using the provided shaping function.
127    ///
128    /// The shaping function should call astrelis_text::shape_text and return
129    /// the BaseShapedTextResult with actual glyph data.
130    pub fn shape_with_measurer<F>(request: &TextShapeRequest, shape_fn: F) -> ShapedTextResult
131    where
132        F: FnOnce(&str, f32, Option<f32>) -> BaseShapedTextResult,
133    {
134        let inner = shape_fn(&request.text, request.font_size, request.wrap_width);
135
136        ShapedTextResult::new(request.id, inner)
137    }
138}
139
140/// Text shaping pipeline managing requests and results.
141///
142/// Coordinates text shaping operations with caching and request management.
143/// Currently processes synchronously but designed for async execution.
144pub struct TextPipeline {
145    /// Pending requests waiting to be processed
146    pending: HashMap<RequestId, TextShapeRequest>,
147    /// Completed results ready for pickup
148    completed: HashMap<RequestId, Arc<ShapedTextResult>>,
149    /// Next request ID to allocate
150    next_request_id: RequestId,
151    /// Cache of shaped results by shape key
152    cache: HashMap<ShapeKey, Arc<ShapedTextResult>>,
153    /// Statistics
154    pub cache_hits: u64,
155    pub cache_misses: u64,
156    pub total_requests: u64,
157}
158
159impl TextPipeline {
160    /// Create a new text pipeline.
161    pub fn new() -> Self {
162        Self {
163            pending: HashMap::with_capacity(64),
164            completed: HashMap::with_capacity(64),
165            next_request_id: 1,
166            cache: HashMap::with_capacity(256),
167            cache_hits: 0,
168            cache_misses: 0,
169            total_requests: 0,
170        }
171    }
172
173    /// Request text shaping, returns request ID.
174    ///
175    /// If the text is already cached with matching parameters, the cached result
176    /// is immediately available. Otherwise, it's queued for processing.
177    pub fn request_shape(
178        &mut self,
179        text: String,
180        font_id: u32,
181        font_size: f32,
182        wrap_width: Option<f32>,
183    ) -> RequestId {
184        self.total_requests += 1;
185        let request_id = self.next_request_id;
186        self.next_request_id += 1;
187
188        let request = TextShapeRequest::new(request_id, text, font_id, font_size, wrap_width);
189
190        let shape_key = request.shape_key();
191
192        // Check cache first
193        if let Some(cached) = self.cache.get(&shape_key).cloned() {
194            self.cache_hits += 1;
195            self.completed.insert(request_id, cached);
196        } else {
197            self.cache_misses += 1;
198            self.pending.insert(request_id, request);
199        }
200
201        request_id
202    }
203
204    /// Process all pending shape requests using the provided shaping function.
205    ///
206    /// The shaping function should perform actual text shaping via astrelis_text::shape_text.
207    /// Currently synchronous, but the API allows future async implementations
208    /// where this would dispatch to workers and poll for completion.
209    pub fn process_pending<F>(&mut self, shape_fn: F)
210    where
211        F: Fn(&str, f32, Option<f32>) -> BaseShapedTextResult,
212    {
213        if self.pending.is_empty() {
214            return;
215        }
216
217        let mut completed_requests = Vec::new();
218
219        for (_request_id, request) in self.pending.drain() {
220            let result = SyncTextShaper::shape_with_measurer(&request, &shape_fn);
221            let result_arc = Arc::new(result);
222
223            // Cache by shape key
224            let shape_key = request.shape_key();
225            self.cache.insert(shape_key, result_arc.clone());
226
227            completed_requests.push((request.id, result_arc));
228        }
229
230        for (request_id, result) in completed_requests {
231            self.completed.insert(request_id, result);
232        }
233    }
234
235    /// Take a completed result by request ID.
236    ///
237    /// Returns None if the request hasn't completed yet or doesn't exist.
238    pub fn take_completed(&mut self, request_id: RequestId) -> Option<Arc<ShapedTextResult>> {
239        self.completed.remove(&request_id)
240    }
241
242    /// Get a completed result by request ID without removing it.
243    pub fn get_completed(&self, request_id: RequestId) -> Option<Arc<ShapedTextResult>> {
244        self.completed.get(&request_id).cloned()
245    }
246
247    /// Check if a request is still pending.
248    pub fn is_pending(&self, request_id: RequestId) -> bool {
249        self.pending.contains_key(&request_id)
250    }
251
252    /// Get cache statistics.
253    pub fn cache_stats(&self) -> (u64, u64, usize) {
254        (self.cache_hits, self.cache_misses, self.cache.len())
255    }
256
257    /// Get cache hit rate as a percentage.
258    pub fn cache_hit_rate(&self) -> f32 {
259        if self.total_requests == 0 {
260            return 0.0;
261        }
262        (self.cache_hits as f32 / self.total_requests as f32) * 100.0
263    }
264
265    /// Clear the cache.
266    pub fn clear_cache(&mut self) {
267        self.cache.clear();
268        self.cache_hits = 0;
269        self.cache_misses = 0;
270    }
271
272    /// Prune cache entries that haven't been used recently.
273    ///
274    /// Removes entries with low render counts to keep memory usage bounded.
275    pub fn prune_cache(&mut self, min_render_count: u64) {
276        self.cache.retain(|_, result| {
277            // Keep if render count is high enough or if there are multiple references
278            Arc::strong_count(result) > 1 || result.render_count >= min_render_count
279        });
280    }
281}
282
283impl Default for TextPipeline {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    // Mock shaping function for testing
294    fn mock_shape(_text: &str, _font_size: f32, _wrap_width: Option<f32>) -> BaseShapedTextResult {
295        BaseShapedTextResult::new((100.0, 20.0), Vec::new())
296    }
297
298    #[test]
299    fn test_request_and_process() {
300        let mut pipeline = TextPipeline::new();
301
302        let req_id = pipeline.request_shape("Hello".to_string(), 0, 16.0, None);
303
304        assert!(pipeline.is_pending(req_id));
305
306        pipeline.process_pending(mock_shape);
307
308        assert!(!pipeline.is_pending(req_id));
309        let result = pipeline.take_completed(req_id);
310        assert!(result.is_some());
311        assert_eq!(result.unwrap().bounds(), (100.0, 20.0));
312    }
313
314    #[test]
315    fn test_cache_hit() {
316        let mut pipeline = TextPipeline::new();
317
318        // First request - cache miss
319        let req_id1 = pipeline.request_shape("Hello".to_string(), 0, 16.0, None);
320        pipeline.process_pending(mock_shape);
321        let _ = pipeline.take_completed(req_id1);
322
323        assert_eq!(pipeline.cache_hits, 0);
324        assert_eq!(pipeline.cache_misses, 1);
325
326        // Second request with same parameters - cache hit
327        let req_id2 = pipeline.request_shape("Hello".to_string(), 0, 16.0, None);
328
329        assert_eq!(pipeline.cache_hits, 1);
330        assert_eq!(pipeline.cache_misses, 1);
331        assert!(!pipeline.is_pending(req_id2));
332
333        let result = pipeline.take_completed(req_id2);
334        assert!(result.is_some());
335    }
336
337    #[test]
338    fn test_content_invalidation() {
339        let mut pipeline = TextPipeline::new();
340
341        // Shape "Hello"
342        let req_id1 = pipeline.request_shape("Hello".to_string(), 0, 16.0, None);
343        pipeline.process_pending(mock_shape);
344        let _ = pipeline.take_completed(req_id1);
345
346        assert_eq!(pipeline.cache_misses, 1);
347
348        // Shape "Hello World" - should be cache miss (different content)
349        let req_id2 = pipeline.request_shape("Hello World".to_string(), 0, 16.0, None);
350
351        assert_eq!(pipeline.cache_misses, 2);
352        assert!(pipeline.is_pending(req_id2));
353    }
354
355    #[test]
356    fn test_width_bucketing() {
357        let mut pipeline = TextPipeline::new();
358
359        // Shape at width 402
360        let req_id1 = pipeline.request_shape("Hello".to_string(), 0, 16.0, Some(402.0));
361        pipeline.process_pending(mock_shape);
362        let _ = pipeline.take_completed(req_id1);
363
364        // Shape at width 404 - should hit cache due to bucketing
365        let _req_id2 = pipeline.request_shape("Hello".to_string(), 0, 16.0, Some(404.0));
366
367        assert_eq!(
368            pipeline.cache_hits, 1,
369            "Width bucketing should allow cache hit"
370        );
371    }
372
373    #[test]
374    fn test_cache_prune() {
375        let mut pipeline = TextPipeline::new();
376
377        // Add multiple entries
378        for i in 0..5 {
379            let req_id = pipeline.request_shape(format!("Text {}", i), 0, 16.0, None);
380            pipeline.process_pending(mock_shape);
381            let _ = pipeline.take_completed(req_id);
382        }
383
384        assert_eq!(pipeline.cache.len(), 5);
385
386        // Prune entries with low render count
387        pipeline.prune_cache(10);
388
389        assert_eq!(pipeline.cache.len(), 0, "All entries should be pruned");
390    }
391
392    #[test]
393    fn test_hit_rate_calculation() {
394        let mut pipeline = TextPipeline::new();
395
396        let req_id = pipeline.request_shape("A".to_string(), 0, 16.0, None);
397        pipeline.process_pending(mock_shape);
398        let _ = pipeline.take_completed(req_id);
399
400        // One miss
401        assert_eq!(pipeline.cache_hit_rate(), 0.0);
402
403        // One hit
404        let req_id2 = pipeline.request_shape("A".to_string(), 0, 16.0, None);
405        let _ = pipeline.take_completed(req_id2);
406
407        assert_eq!(pipeline.cache_hit_rate(), 50.0);
408    }
409}