1use crate::ShapedTextResult as BaseShapedTextResult;
8use crate::cache::ShapeKey;
9use astrelis_core::alloc::HashMap;
10
11use std::sync::Arc;
12
13pub type RequestId = u64;
15
16#[derive(Debug, Clone)]
21pub struct TextShapeRequest {
22 pub id: RequestId,
24 pub text: String,
26 pub font_id: u32,
28 pub font_size: f32,
30 pub wrap_width: Option<f32>,
32}
33
34impl TextShapeRequest {
35 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 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#[derive(Debug, Clone)]
67pub struct ShapedTextResult {
68 pub request_id: RequestId,
70 pub inner: BaseShapedTextResult,
72 pub render_count: u64,
75}
76
77impl ShapedTextResult {
78 pub fn new(request_id: RequestId, inner: BaseShapedTextResult) -> Self {
80 Self {
81 request_id,
82 inner,
83 render_count: 0,
84 }
85 }
86
87 pub fn bounds(&self) -> (f32, f32) {
89 self.inner.bounds
90 }
91
92 pub fn increment_render_count(&mut self) {
94 self.render_count = self.render_count.saturating_add(1);
95 }
96}
97
98pub trait TextShaper: Send + Sync {
104 fn shape(&mut self, request: TextShapeRequest) -> ShapedTextResult;
106}
107
108pub struct SyncTextShaper;
113
114impl Default for SyncTextShaper {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl SyncTextShaper {
121 pub fn new() -> Self {
123 Self {}
124 }
125
126 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
140pub struct TextPipeline {
145 pending: HashMap<RequestId, TextShapeRequest>,
147 completed: HashMap<RequestId, Arc<ShapedTextResult>>,
149 next_request_id: RequestId,
151 cache: HashMap<ShapeKey, Arc<ShapedTextResult>>,
153 pub cache_hits: u64,
155 pub cache_misses: u64,
156 pub total_requests: u64,
157}
158
159impl TextPipeline {
160 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 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 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 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 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 pub fn take_completed(&mut self, request_id: RequestId) -> Option<Arc<ShapedTextResult>> {
239 self.completed.remove(&request_id)
240 }
241
242 pub fn get_completed(&self, request_id: RequestId) -> Option<Arc<ShapedTextResult>> {
244 self.completed.get(&request_id).cloned()
245 }
246
247 pub fn is_pending(&self, request_id: RequestId) -> bool {
249 self.pending.contains_key(&request_id)
250 }
251
252 pub fn cache_stats(&self) -> (u64, u64, usize) {
254 (self.cache_hits, self.cache_misses, self.cache.len())
255 }
256
257 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 pub fn clear_cache(&mut self) {
267 self.cache.clear();
268 self.cache_hits = 0;
269 self.cache_misses = 0;
270 }
271
272 pub fn prune_cache(&mut self, min_render_count: u64) {
276 self.cache.retain(|_, result| {
277 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 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 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 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 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 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 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 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 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 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 assert_eq!(pipeline.cache_hit_rate(), 0.0);
402
403 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}