rust_widgets 2.9.0

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Container utilities: SoftwareSurface lifecycle management, configuration,
//! frame operations, text shaping, gradient fill, and clip stack.
//!
//! Rendering primitives (rect, circle, line, text, etc.) are in the
//! `primitives` sub-module.

use crate::compat::atomic::{AtomicU64, Ordering};
use crate::compat::{HashMap, Rc, RefCell, Vec, VecDeque};
use crate::core::{Color, Font, Rect, Size};
use crate::render::default_software_render_config;
use crate::render::pipeline::pixel_ops::{fill_pixels, pixel_visible, write_pixel_with_mode};
use crate::render::{
    BackBuffer, BlendMode, ShapedText, SoftwareRenderConfig, SoftwareSurface, TextMetrics,
};
use crate::style::Gradient;
use crate::style::GradientType;

/// Changes whenever the text engine's answer for a `(text, font)` pair can change.
///
/// The measurement cache below is keyed by that pair, and the pair alone is not quite the whole
/// input: a host can register a runtime face (`runtime-fonts`), which replaces the bytes a family
/// resolves to without changing any `Font` field. Bumping this epoch on such a change makes the
/// cached widths unreachable instead of silently wrong. Initialised to one so the zero epoch is
/// never a valid key.
static TEXT_METRICS_EPOCH: AtomicU64 = AtomicU64::new(1);

/// Invalidates every cached text measurement.
///
/// Called when the set of faces a family resolves to changes (runtime font registration/clearing).
/// A text cache keyed only by `(text, font)` cannot see that change on its own.
///
/// Only a build with runtime fonts has such a change: the compiled faces are fixed for the
/// process, so every other profile can keep its measurements forever.
#[cfg(feature = "runtime-fonts")]
pub(crate) fn invalidate_text_measurements() {
    TEXT_METRICS_EPOCH.fetch_add(1, Ordering::Relaxed);
}

/// The largest number of measurements the per-thread cache keeps.
const TEXT_METRICS_CACHE_LIMIT: usize = 2048;

/// The largest number of shaped runs the per-thread cache keeps.
///
/// Shaped runs are larger than measurements (clusters own their text), so this bound is tighter.
/// A repaint asks for the same captions frame after frame, which is the set this keeps.
const TEXT_SHAPES_CACHE_LIMIT: usize = 512;

/// A cached measurement: the two fingerprints of `(text, font, scale)` and the answer.
type TextMetricsCache = HashMap<(u64, u64), TextMetrics>;

/// Shaped runs for one text epoch, keyed by the same measurement fingerprint as the metrics.
///
/// The values are `Rc<ShapedText>`: a hit is a refcount bump. `ShapedText` owns a
/// `Vec<TextCluster>` whose every entry owns a `String`, so an owned cache value made each hit
/// clone the whole run to hand back a value nothing mutates (REND-03).
type ShapeCache = RefCell<(u64, HashMap<(u64, u64), Rc<ShapedText>>)>;

thread_local! {
    // Two independently seeded hashes rather than an owning key: the pair is computed from the
    // borrowed inputs, so a lookup allocates nothing, and two 64-bit values make an accidental
    // match between different inputs effectively impossible. A cache miss is the only way an
    // input can reach the shaping path, so a collision would show as a wrong width, not as
    // corruption.
    static TEXT_METRICS: RefCell<(u64, TextMetricsCache)> =
        RefCell::new((0, HashMap::new()));
    // Insertion order, for FIFO eviction. The caches used to `clear()` when full, so one entry
    // over the bound discarded the whole working set and the next frame re-shaped everything.
    static TEXT_METRICS_ORDER: RefCell<VecDeque<(u64, u64)>> =
        const { RefCell::new(VecDeque::new()) };
    static TEXT_SHAPES: ShapeCache =
        RefCell::new((0, HashMap::new()));
    static TEXT_SHAPES_ORDER: RefCell<VecDeque<(u64, u64)>> =
        const { RefCell::new(VecDeque::new()) };
}

/// Returns two fingerprints over everything a text measurement depends on.
///
/// # Why the second pass hashes a summary instead of the whole string
///
/// The pair only has to be a *function of the same inputs* — a cache collision needs both halves to
/// match, and the first half is SipHash over every byte of the text, so the second half contributes
/// independence rather than coverage. Hashing the entire text a second time therefore doubled the
/// cost of every lookup for no extra safety (REND-14); it now hashes the length plus the first and
/// last eight bytes, which still distinguishes the strings a label cache actually sees (a status
/// line that ends differently, a number that grew) without reading the middle again.
fn text_measurement_fingerprint(text: &str, font: &Font, scale: f32) -> (u64, u64) {
    use core::hash::{Hash, Hasher};
    let size = font.size().to_bits();
    let letter = font.letter_spacing().to_bits();
    let word = font.word_spacing().to_bits();
    let line = font.line_height().to_bits();
    let scale = scale.to_bits();

    let mut first = std::collections::hash_map::DefaultHasher::new();
    text.hash(&mut first);
    font.family().hash(&mut first);
    size.hash(&mut first);
    font.weight().hash(&mut first);
    font.is_bold().hash(&mut first);
    font.is_italic().hash(&mut first);
    letter.hash(&mut first);
    word.hash(&mut first);
    line.hash(&mut first);
    scale.hash(&mut first);

    let bytes = text.as_bytes();
    let head = &bytes[..bytes.len().min(8)];
    let tail = &bytes[bytes.len().saturating_sub(8)..];

    let mut second = std::collections::hash_map::DefaultHasher::new();
    0xA5u8.hash(&mut second);
    bytes.len().hash(&mut second);
    head.hash(&mut second);
    tail.hash(&mut second);
    scale.hash(&mut second);
    line.hash(&mut second);
    word.hash(&mut second);
    letter.hash(&mut second);
    font.is_italic().hash(&mut second);
    font.is_bold().hash(&mut second);
    font.weight().hash(&mut second);
    size.hash(&mut second);
    font.family().hash(&mut second);

    (first.finish(), second.finish())
}

impl SoftwareSurface {
    /// Creates a software surface with size and DPI scale.
    pub fn new(size: Size, dpi_scale: f32) -> Self {
        let config = default_software_render_config();
        Self {
            buffer: BackBuffer::new(size, dpi_scale),
            aa_samples_per_axis: config.aa_samples_per_axis,
            clip_stack: Vec::new(),
            nonzero_intersections: Vec::new(),
            text_coverage_scratch: Vec::new(),
            blend_mode: BlendMode::Normal,
        }
    }

    /// Sets the blend mode every subsequent pixel write composites through.
    ///
    /// Driven by [`crate::render::RenderCommand::SetBlendMode`]. The mode is frame state, like the
    /// clip stack, because it applies to everything drawn until it changes rather than to one
    /// primitive. `Normal` restores plain source-over compositing.
    pub fn set_blend_mode(&mut self, mode: BlendMode) {
        self.blend_mode = mode;
    }

    /// The mode the next pixel write will composite through.
    pub fn blend_mode(&self) -> BlendMode {
        self.blend_mode
    }
    /// Get current software render configuration.
    pub fn render_config(&self) -> SoftwareRenderConfig {
        SoftwareRenderConfig { aa_samples_per_axis: self.aa_samples_per_axis }
    }
    /// Apply software render configuration.
    pub fn apply_render_config(&mut self, config: SoftwareRenderConfig) {
        let normalized = config.normalized();
        self.aa_samples_per_axis = normalized.aa_samples_per_axis;
    }
    /// Set anti-aliasing sample grid size per axis for high-sample raster paths.
    pub fn set_aa_samples_per_axis(&mut self, samples: u8) {
        self.apply_render_config(SoftwareRenderConfig { aa_samples_per_axis: samples });
    }
    /// Get anti-aliasing sample grid size per axis.
    pub fn aa_samples_per_axis(&self) -> u8 {
        self.aa_samples_per_axis
    }
    /// Clears the current back buffer with a solid color.
    pub fn begin_frame(&mut self, clear: Color) {
        fill_pixels(self.buffer.back_mut(), clear);
    }
    /// Presents the back buffer as the current frame.
    pub fn end_frame(&mut self) {
        self.buffer.present();
    }
    /// Returns logical surface size.
    pub fn size(&self) -> Size {
        self.buffer.size()
    }
    /// Resizes the surface buffers.
    pub fn resize(&mut self, size: Size) {
        self.buffer.resize(size);
    }
    /// Sets logical DPI scale for text and geometry.
    pub fn set_dpi_scale(&mut self, dpi_scale: f32) {
        self.buffer.set_dpi_scale(dpi_scale);
    }
    /// Returns logical DPI scale.
    pub fn dpi_scale(&self) -> f32 {
        self.buffer.dpi_scale()
    }
    /// Returns RGBA bytes of the presented frame.
    pub fn frame_rgba(&self) -> &[u8] {
        self.buffer.front()
    }

    /// Consumes the surface and returns the presented frame without copying it.
    ///
    /// See [`BackBuffer::take_front`] for why this exists: the render entry points own
    /// the backend, so moving the buffer is always safe there and saves one full-frame
    /// copy per painted frame.
    pub fn into_frame_rgba(mut self) -> Vec<u8> {
        self.buffer.take_front()
    }
    /// Measures text bounds and baseline metrics.
    ///
    /// The line box is the font's **effective** line height, so an explicit `line_height` — the
    /// field a text scale needs — actually sets how tall a line of this font is. The
    /// height/ascent/descent triple comes from `TextMetrics::for_font` rather than being derived
    /// here, because the SVG backend derives the same triple and the two had drifted.
    ///
    /// # Why the result is cached
    ///
    /// `measure_text` shapes the line (`shape_text`), which is the expensive half of laying out a
    /// label: it segments clusters, resolves a face per cluster and collects advances. A single
    /// `draw_text_fitted` asks for the same string **twice** (once to decide whether it fits and
    /// once to place the fitted result), and layout asks again for the same caption on every
    /// frame. Measured on this crate's own benchmark, shaping was ~1.7µs a call, so an unscaled
    /// window paid milliseconds a frame for answers it had already computed.
    ///
    /// The answer depends only on the text, the font and the device scale; the epoch guards the
    /// one input a `Font` does not describe (which face a family resolves to).
    pub fn measure_text(&self, text: &str, font: &Font) -> TextMetrics {
        let scale = self.buffer.dpi_scale();
        let epoch = TEXT_METRICS_EPOCH.load(Ordering::Relaxed);
        let key = text_measurement_fingerprint(text, font, scale);
        if let Some(hit) = TEXT_METRICS.with(|cache| {
            let cache = cache.borrow();
            (cache.0 == epoch).then(|| cache.1.get(&key).cloned()).flatten()
        }) {
            return hit;
        }

        let shaped = self.shape_text(text, font);
        let width = shaped.advance().round() as u32;
        let metrics = TextMetrics { width, ..TextMetrics::for_font(font, scale) };
        note_measurement_built();
        TEXT_METRICS.with(|cache| {
            let mut cache = cache.borrow_mut();
            if cache.0 != epoch {
                cache.0 = epoch;
                cache.1.clear();
                let _ = TEXT_METRICS_ORDER.try_with(|order| order.borrow_mut().clear());
            }
            TEXT_METRICS_ORDER.with(|order| {
                let mut order = order.borrow_mut();
                if order.len() >= TEXT_METRICS_CACHE_LIMIT {
                    if let Some(oldest) = order.pop_front() {
                        cache.1.remove(&oldest);
                    }
                }
                order.push_back(key);
            });
            cache.1.insert(key, metrics);
        });
        metrics
    }
    /// Shape text into unicode-aware clusters, in **visual** order for painting.
    ///
    /// The derivation itself lives in [`crate::render::text`] (`shape_line`), so this backend and
    /// the SVG backend cannot drift: a change to clustering, advances or bidirectional reordering
    /// reaches both at once. All this method supplies is the device scale it already knows.
    ///
    /// # Why the result is cached
    ///
    /// Shaping is the expensive half of text: it segments the string, resolves a face for every
    /// cluster and collects advances (~1.7µs for a short caption, measured). `draw_text` asked for
    /// the shaped run and then `draw_text_fitted` asked again for the same string and font on the
    /// next frame; the answer is immutable for a given `(text, font, scale)`.
    pub fn shape_text(&self, text: &str, font: &Font) -> Rc<ShapedText> {
        let scale = self.buffer.dpi_scale();
        let epoch = TEXT_METRICS_EPOCH.load(Ordering::Relaxed);
        let key = text_measurement_fingerprint(text, font, scale);
        // The cache holds `Rc<ShapedText>` and the function hands that handle out, so a hit is a
        // refcount bump. Returning an owned `ShapedText` cloned the whole run on every hit —
        // one `Vec<TextCluster>` plus one `String` per cluster, per label, per frame — to hand
        // back a value nobody mutates.
        if let Some(hit) = TEXT_SHAPES.with(|cache| {
            let cache = cache.borrow();
            (cache.0 == epoch).then(|| cache.1.get(&key).cloned()).flatten()
        }) {
            return hit;
        }
        let shaped = Rc::new(crate::render::text::shape_line(text, font, scale));
        note_shape_built();
        TEXT_SHAPES.with(|cache| {
            let mut cache = cache.borrow_mut();
            if cache.0 != epoch {
                cache.0 = epoch;
                cache.1.clear();
                let _ = TEXT_SHAPES_ORDER.try_with(|order| order.borrow_mut().clear());
            }
            TEXT_SHAPES_ORDER.with(|order| {
                let mut order = order.borrow_mut();
                if order.len() >= TEXT_SHAPES_CACHE_LIMIT {
                    if let Some(oldest) = order.pop_front() {
                        cache.1.remove(&oldest);
                    }
                }
                order.push_back(key);
            });
            cache.1.insert(key, Rc::clone(&shaped));
        });
        shaped
    }
    /// Fills a rectangle with a gradient.
    pub fn fill_rect_gradient(&mut self, rect: Rect, gradient: &Gradient) {
        let size = self.buffer.size();
        let clip = self.current_clip();
        let x0 = rect.x.max(0) as u32;
        let y0 = rect.y.max(0) as u32;
        let x1 = rect.x.saturating_add(rect.width as i32).max(0) as u32;
        let y1 = rect.y.saturating_add(rect.height as i32).max(0) as u32;
        let x1 = x1.min(size.width);
        let y1 = y1.min(size.height);
        let frame = self.buffer.back_mut();

        // Resolve the ramp once into a 256-entry table. Every pixel previously ran
        // `Gradient::interpolate`, which searches the stops and lerps each channel; for a
        // full-window gradient that was width×height stop searches per frame. Sampling a
        // fixed table is both cheaper and consistent across the three gradient types.
        const LUT_SIZE: usize = 256;
        let mut lut = [Color::BLACK; LUT_SIZE];
        for (index, slot) in lut.iter_mut().enumerate() {
            *slot = gradient.interpolate(index as f32 / (LUT_SIZE - 1) as f32);
        }
        let lut_index = |pos: f32| -> usize {
            ((pos.clamp(0.0, 1.0) * (LUT_SIZE - 1) as f32) as usize).min(LUT_SIZE - 1)
        };

        // Linear geometry is constant across the whole rectangle: hoist it and walk the
        // projection with an increment per pixel instead of recomputing it from x/y.
        let linear_dx = gradient.end_point.x as f32 - gradient.start_point.x as f32;
        let linear_dy = gradient.end_point.y as f32 - gradient.start_point.y as f32;
        let linear_dot = linear_dx * linear_dx + linear_dy * linear_dy;
        let linear_step = if linear_dot == 0.0 { 0.0 } else { linear_dx / linear_dot };

        for y in y0..y1 {
            let row_start = if linear_dot == 0.0 {
                0.0
            } else {
                ((x0 as f32 - gradient.start_point.x as f32) * linear_dx
                    + (y as f32 - gradient.start_point.y as f32) * linear_dy)
                    / linear_dot
            };
            for x in x0..x1 {
                let pos = match gradient.gradient_type {
                    GradientType::Linear => {
                        if linear_dot == 0.0 {
                            0.0
                        } else {
                            (row_start + (x - x0) as f32 * linear_step).clamp(0.0, 1.0)
                        }
                    }
                    GradientType::Radial => {
                        let dx = x as f32 - gradient.center.x as f32;
                        let dy = y as f32 - gradient.center.y as f32;
                        let dist = (dx * dx + dy * dy).sqrt();
                        if gradient.radius <= 0.0 {
                            0.0
                        } else {
                            (dist / gradient.radius).clamp(0.0, 1.0)
                        }
                    }
                    GradientType::Conic => {
                        let dx = x as f32 - gradient.center.x as f32;
                        let dy = y as f32 - gradient.center.y as f32;
                        // The per-pixel angle is in **radians** (that is what `atan2` returns),
                        // while `Gradient::angle` is documented in **degrees** — so it is converted
                        // before being added. Adding it raw rotated the ramp by `angle` *radians*
                        // (~117° for the common `conic(center, 90.0)`) and made a caller's degree
                        // value meaningless. The same conversion is applied by the SVG backend's
                        // `conic_sample`, so the two agree.
                        let angle = dy.atan2(dx) + core::f32::consts::PI;
                        let start = gradient.angle.to_radians();
                        let angle = (angle + start) % core::f32::consts::TAU;
                        let angle =
                            if angle < 0.0 { angle + core::f32::consts::TAU } else { angle };
                        angle / core::f32::consts::TAU
                    }
                };
                let color = lut[lut_index(pos)];
                if pixel_visible(clip, x as i32, y as i32) {
                    // Through the blend-aware write, so a gradient drawn under a `SetBlendMode`
                    // composites the same way an opaque fill does.
                    write_pixel_with_mode(self.blend_mode, frame, size.width, x, y, color, 1.0);
                }
            }
        }
    }
    /// Pushes a clip rectangle onto the clip stack.
    /// The clip rectangle is intersected with any existing clip region.
    pub fn push_clip(&mut self, x: i32, y: i32, width: u32, height: u32) {
        if let Some(&(cx, cy, cw, ch)) = self.clip_stack.last() {
            let nx = x.max(cx);
            let ny = y.max(cy);
            let nx2 = (x.saturating_add(width as i32)).min(cx.saturating_add(cw as i32));
            let ny2 = (y.saturating_add(height as i32)).min(cy.saturating_add(ch as i32));
            let nw = (nx2 - nx).max(0) as u32;
            let nh = (ny2 - ny).max(0) as u32;
            self.clip_stack.push((nx, ny, nw, nh));
        } else {
            self.clip_stack.push((x, y, width, height));
        }
    }
    /// Pops the top clip rectangle from the clip stack.
    pub fn pop_clip(&mut self) {
        self.clip_stack.pop();
    }
    /// Returns the current effective clip rect, or `None` if the clip stack is empty.
    pub fn current_clip(&self) -> Option<(i32, i32, u32, u32)> {
        self.clip_stack.last().copied()
    }
}

// Test-only counters of how many times the shaping and measurement work actually ran. They make
// "the cache is on the path" observable, because equal results alone cannot distinguish a hit
// from a re-computation.
#[cfg(test)]
thread_local! {
    #[allow(clippy::missing_const_for_thread_local)]
    static TEXT_SHAPES_BUILT: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
    #[allow(clippy::missing_const_for_thread_local)]
    static TEXT_METRICS_BUILT: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
}

#[cfg(test)]
fn note_shape_built() {
    TEXT_SHAPES_BUILT.with(|count| count.set(count.get() + 1));
}

#[cfg(not(test))]
#[inline(always)]
fn note_shape_built() {}

#[cfg(test)]
fn note_measurement_built() {
    TEXT_METRICS_BUILT.with(|count| count.set(count.get() + 1));
}

#[cfg(not(test))]
#[inline(always)]
fn note_measurement_built() {}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::{Font, Size};
    use crate::render::SoftwareSurface;

    fn take_builds() -> (usize, usize) {
        let shapes = TEXT_SHAPES_BUILT.with(|count| count.replace(0));
        let metrics = TEXT_METRICS_BUILT.with(|count| count.replace(0));
        (shapes, metrics)
    }

    /// A repeated measurement and shape answer from the cache instead of recomputing.
    #[test]
    fn repeated_text_measurements_are_cached() {
        // A host face registered by another test (in this process) invalidates the measurement
        // cache, which would turn the expected hit below into a miss. The registry lock is the
        // one serialisation point for that: hold it for the whole sequence.
        #[cfg(all(feature = "runtime-fonts", feature = "text-shaping"))]
        let _guard = crate::render::text::runtime_fonts::test_lock();
        let surface = SoftwareSurface::new(Size::new(20, 20), 1.0);
        let font = Font::default();

        let warm = surface.measure_text("hello", &font);
        let (shapes, metrics) = take_builds();
        assert_eq!((shapes, metrics), (1, 1), "the first measurement has to build its answer");

        assert_eq!(surface.measure_text("hello", &font), warm);
        assert_eq!(take_builds(), (0, 0), "a repeated measurement must be a cache hit");

        let _ = surface.shape_text("hello", &font);
        assert_eq!(take_builds(), (0, 0), "a repeated shape must be a cache hit too");

        let _ = surface.measure_text("world", &font);
        let (shapes, metrics) = take_builds();
        assert_eq!((shapes, metrics), (1, 1), "a different string is a different key");
    }

    /// A cache hit hands back the **same** shaped run, not a copy of it.
    ///
    /// `ShapedText` owns a `Vec<TextCluster>` and every cluster owns a `String`, so returning it by
    /// value cloned all of that on every hit: the cache avoided the shaping cost and paid for it in
    /// allocator traffic instead. The handle comparison is the evidence that nothing was cloned;
    /// the build counter is the evidence that the value came from the cache rather than a rebuild.
    #[test]
    fn a_shaped_run_hit_is_the_same_allocation_not_a_copy() {
        #[cfg(all(feature = "runtime-fonts", feature = "text-shaping"))]
        let _guard = crate::render::text::runtime_fonts::test_lock();
        let surface = SoftwareSurface::new(Size::new(20, 20), 1.0);
        let font = Font::default();

        let first = surface.shape_text("hello", &font);
        let _ = take_builds();
        let second = surface.shape_text("hello", &font);

        assert!(Rc::ptr_eq(&first, &second), "a cache hit must return the cached run itself");
        assert_eq!(take_builds(), (0, 0), "and it must not have shaped anything");
    }

    /// The epoch hook makes every cached measurement unreachable.
    ///
    /// Runtime font registration replaces the bytes a family resolves to, which no `Font` field
    /// describes. Without this the cache would keep serving advances from the old face.
    #[cfg(feature = "runtime-fonts")]
    #[test]
    fn invalidating_text_measurements_forces_a_rebuild() {
        // Same reason as `repeated_text_measurements_are_cached`: the build counters are process-
        // wide and a parallel registration also bumps the epoch this test is about.
        #[cfg(all(feature = "runtime-fonts", feature = "text-shaping"))]
        let _guard = crate::render::text::runtime_fonts::test_lock();
        let surface = SoftwareSurface::new(Size::new(20, 20), 1.0);
        let font = Font::default();
        let _ = surface.measure_text("hello", &font);
        let _ = take_builds();

        invalidate_text_measurements();
        let _ = surface.measure_text("hello", &font);
        assert_eq!(take_builds(), (1, 1), "a new epoch must miss the old entry");
    }

    /// The two fingerprints still separate inputs the summary half cannot see.
    ///
    /// # What this pins
    ///
    /// The second half of the key hashes the text's length plus its first and last eight bytes
    /// rather than every byte (REND-14). Two strings that agree on that summary — a long middle
    /// differing, which is exactly what an edited line or a changed number looks like — must still
    /// get different keys, or the cache would serve one label's width for another. The full-text
    /// first half is what makes that true, and this asserts it end to end through the pair.
    #[test]
    fn fingerprints_separate_texts_that_share_a_summary() {
        let font = Font::default();
        let head = "head-head-";
        let tail = "-tail-tail";
        let a = format!("{head}alpha{tail}");
        let b = format!("{head}bravo{tail}");
        assert_eq!(a.len(), b.len(), "the fixture must agree on the summary's length");
        assert_eq!(
            fingerprint_summary(&a),
            fingerprint_summary(&b),
            "and on the head/tail bytes, or this test proves nothing"
        );

        let key_a = text_measurement_fingerprint(&a, &font, 1.0);
        let key_b = text_measurement_fingerprint(&b, &font, 1.0);
        assert_ne!(key_a, key_b, "different middles must still be different keys");

        // The pair is a function, not a random value.
        assert_eq!(text_measurement_fingerprint(&a, &font, 1.0), key_a);
        // And a font difference is part of the key on both halves.
        let mut bigger = Font::default();
        bigger.set_size(28.0);
        assert_ne!(text_measurement_fingerprint(&a, &bigger, 1.0), key_a);
    }

    /// The summary the second fingerprint is built from, for the test above.
    fn fingerprint_summary(text: &str) -> (usize, &[u8], &[u8]) {
        let bytes = text.as_bytes();
        (bytes.len(), &bytes[..bytes.len().min(8)], &bytes[bytes.len().saturating_sub(8)..])
    }
}