Skip to main content

cranpose_ui/
nine_patch.rs

1//! Filling a box with an image that is bigger, or smaller, than the box.
2//!
3//! Two questions this answers that a plain scale cannot. A tiled fill repeats
4//! its source instead of stretching it, which is how a texture, a hatch or a
5//! skin's background covers an area of any size without going soft. And a
6//! nine-patch fill keeps the corners of a source at their own size while the
7//! edges and the middle grow, which is how a button, a panel or a scrollbar
8//! trough drawn once at one size is drawn correctly at every other.
9//!
10//! Both are expressed the same way: a list of source-rectangle to
11//! destination-rectangle pairs, each of which is one ordinary image draw. That
12//! keeps the answer identical on every backend and, unlike a repeating sampler,
13//! cannot bleed a neighbouring sprite in from a shared atlas — the sprite an
14//! application tiles is usually a region of an atlas, and a wrapped sampler
15//! reads whatever is next to it.
16
17use cranpose_ui_graphics::{Rect, Size};
18
19/// How a region larger than its source is filled.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub enum PatchFill {
22    /// Scale the source to fill the region.
23    #[default]
24    Stretch,
25    /// Repeat the source at its own size, clipping the last row and column.
26    Tile,
27}
28
29impl PatchFill {
30    /// Whether this fill repeats rather than scales.
31    pub fn is_tiled(self) -> bool {
32        matches!(self, PatchFill::Tile)
33    }
34}
35
36/// How far in from each edge of a source image the stretchable middle starts.
37///
38/// The four corners outside these insets are drawn at their own size at every
39/// destination size; everything inside grows.
40#[derive(Clone, Copy, Debug, Default, PartialEq)]
41pub struct NinePatchInsets {
42    pub left: f32,
43    pub top: f32,
44    pub right: f32,
45    pub bottom: f32,
46}
47
48impl NinePatchInsets {
49    /// Insets clamped to non-negative values.
50    pub fn new(left: f32, top: f32, right: f32, bottom: f32) -> Self {
51        Self {
52            left: sanitize(left),
53            top: sanitize(top),
54            right: sanitize(right),
55            bottom: sanitize(bottom),
56        }
57    }
58
59    /// The same inset on all four edges.
60    pub fn uniform(inset: f32) -> Self {
61        Self::new(inset, inset, inset, inset)
62    }
63
64    /// Insets scaled by `factor`, for a source measured in pixels drawn into a
65    /// destination measured in points.
66    pub fn scaled(self, factor: f32) -> Self {
67        if !factor.is_finite() || factor <= 0.0 {
68            return self;
69        }
70        Self::new(
71            self.left * factor,
72            self.top * factor,
73            self.right * factor,
74            self.bottom * factor,
75        )
76    }
77
78    /// Whether a source of this size has room for the corners plus a middle.
79    ///
80    /// Insets that meet or cross leave no stretchable middle, which is not a
81    /// nine-patch at all — the caller wanted a plain scale.
82    pub fn fit(self, source: Size) -> bool {
83        source.width > self.left + self.right && source.height > self.top + self.bottom
84    }
85}
86
87fn sanitize(value: f32) -> f32 {
88    if value.is_finite() && value > 0.0 {
89        value
90    } else {
91        0.0
92    }
93}
94
95/// One image draw: the part of the source to read and the part of the
96/// destination to fill.
97#[derive(Clone, Copy, Debug, PartialEq)]
98pub struct PatchQuad {
99    pub source: Rect,
100    pub destination: Rect,
101}
102
103/// The draws that tile `source` across `destination` at the source's own size.
104///
105/// The last row and column are clipped, so a destination that is not a whole
106/// number of tiles ends mid-tile rather than being scaled to fit.
107pub fn tile_quads(source: Rect, destination: Rect) -> Vec<PatchQuad> {
108    let mut quads = Vec::new();
109    push_tiles(&mut quads, source, destination);
110    quads
111}
112
113/// How many tiles [`tile_quads`] would produce, without building them.
114///
115/// A caller filling a large area with a small source can check the cost before
116/// paying it: each tile is one draw.
117pub fn tile_count(source: Rect, destination: Rect) -> usize {
118    if !usable(source) || !usable(destination) {
119        return 0;
120    }
121    let columns = (destination.width / source.width).ceil().max(0.0) as usize;
122    let rows = (destination.height / source.height).ceil().max(0.0) as usize;
123    columns.saturating_mul(rows)
124}
125
126/// The draws that fill `destination` with `source` as a nine-patch.
127///
128/// The four corners keep their source size. The four edges grow along one axis
129/// and keep their size across it. The middle grows along both. `center` and
130/// `edges` decide whether the growing parts are stretched or tiled.
131///
132/// Returns a single stretched quad when the insets leave no middle, or when the
133/// destination is too small to hold the corners — the picture stays sensible
134/// instead of drawing corners on top of each other.
135pub fn nine_patch_quads(
136    source: Rect,
137    destination: Rect,
138    insets: NinePatchInsets,
139    center: PatchFill,
140    edges: PatchFill,
141) -> Vec<PatchQuad> {
142    if !usable(source) || !usable(destination) {
143        return Vec::new();
144    }
145    let source_size = Size::new(source.width, source.height);
146    let corners_fit = destination.width > insets.left + insets.right
147        && destination.height > insets.top + insets.bottom;
148    if !insets.fit(source_size) || !corners_fit {
149        return vec![PatchQuad {
150            source,
151            destination,
152        }];
153    }
154
155    // Source columns and rows: leading corner, stretchable middle, trailing
156    // corner. The destination has the same three bands, with the middle taking
157    // whatever is left over.
158    let source_columns = [
159        (source.x, insets.left),
160        (
161            source.x + insets.left,
162            source.width - insets.left - insets.right,
163        ),
164        (source.x + source.width - insets.right, insets.right),
165    ];
166    let source_rows = [
167        (source.y, insets.top),
168        (
169            source.y + insets.top,
170            source.height - insets.top - insets.bottom,
171        ),
172        (source.y + source.height - insets.bottom, insets.bottom),
173    ];
174    let destination_columns = [
175        (destination.x, insets.left),
176        (
177            destination.x + insets.left,
178            destination.width - insets.left - insets.right,
179        ),
180        (
181            destination.x + destination.width - insets.right,
182            insets.right,
183        ),
184    ];
185    let destination_rows = [
186        (destination.y, insets.top),
187        (
188            destination.y + insets.top,
189            destination.height - insets.top - insets.bottom,
190        ),
191        (
192            destination.y + destination.height - insets.bottom,
193            insets.bottom,
194        ),
195    ];
196
197    let mut quads = Vec::new();
198    for row in 0..3 {
199        for column in 0..3 {
200            let (source_x, source_width) = source_columns[column];
201            let (source_y, source_height) = source_rows[row];
202            let (destination_x, destination_width) = destination_columns[column];
203            let (destination_y, destination_height) = destination_rows[row];
204            if source_width <= 0.0
205                || source_height <= 0.0
206                || destination_width <= 0.0
207                || destination_height <= 0.0
208            {
209                continue;
210            }
211            let patch_source = Rect {
212                x: source_x,
213                y: source_y,
214                width: source_width,
215                height: source_height,
216            };
217            let patch_destination = Rect {
218                x: destination_x,
219                y: destination_y,
220                width: destination_width,
221                height: destination_height,
222            };
223            let stretched = column == 1 || row == 1;
224            let fill = match (column, row) {
225                (1, 1) => center,
226                _ if stretched => edges,
227                // A corner is never scaled, whatever the fills say.
228                _ => PatchFill::Stretch,
229            };
230            if fill.is_tiled() {
231                push_tiles(&mut quads, patch_source, patch_destination);
232            } else {
233                quads.push(PatchQuad {
234                    source: patch_source,
235                    destination: patch_destination,
236                });
237            }
238        }
239    }
240    quads
241}
242
243fn usable(rect: Rect) -> bool {
244    rect.x.is_finite()
245        && rect.y.is_finite()
246        && rect.width.is_finite()
247        && rect.height.is_finite()
248        && rect.width > 0.0
249        && rect.height > 0.0
250}
251
252/// Repeats `source` across `destination`, clipping the final row and column so
253/// a partial tile shows the leading part of the source rather than a squeezed
254/// whole one.
255fn push_tiles(quads: &mut Vec<PatchQuad>, source: Rect, destination: Rect) {
256    if !usable(source) || !usable(destination) {
257        return;
258    }
259    let mut y = destination.y;
260    let bottom = destination.y + destination.height;
261    while y < bottom {
262        let height = source.height.min(bottom - y);
263        let mut x = destination.x;
264        let right = destination.x + destination.width;
265        while x < right {
266            let width = source.width.min(right - x);
267            quads.push(PatchQuad {
268                source: Rect {
269                    x: source.x,
270                    y: source.y,
271                    width,
272                    height,
273                },
274                destination: Rect {
275                    x,
276                    y,
277                    width,
278                    height,
279                },
280            });
281            x += source.width;
282        }
283        y += source.height;
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn only_a_tiled_fill_repeats() {
293        assert!(PatchFill::Tile.is_tiled());
294        assert!(!PatchFill::Stretch.is_tiled());
295    }
296
297    fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
298        Rect {
299            x,
300            y,
301            width,
302            height,
303        }
304    }
305
306    #[test]
307    fn insets_cannot_be_negative_or_unmeasurable() {
308        let insets = NinePatchInsets::new(-4.0, f32::NAN, 6.0, f32::INFINITY);
309        assert_eq!(insets.left, 0.0);
310        assert_eq!(insets.top, 0.0);
311        assert_eq!(insets.right, 6.0);
312        assert_eq!(insets.bottom, 0.0);
313        assert_eq!(NinePatchInsets::uniform(3.0).left, 3.0);
314    }
315
316    #[test]
317    fn insets_scale_with_the_source_they_were_measured_on() {
318        let insets = NinePatchInsets::uniform(4.0).scaled(2.0);
319        assert_eq!(insets, NinePatchInsets::uniform(8.0));
320        // A factor that means nothing leaves the insets alone rather than
321        // collapsing them.
322        assert_eq!(
323            NinePatchInsets::uniform(4.0).scaled(0.0),
324            NinePatchInsets::uniform(4.0)
325        );
326    }
327
328    #[test]
329    fn insets_that_leave_no_middle_do_not_fit() {
330        let source = Size::new(20.0, 20.0);
331        assert!(NinePatchInsets::uniform(4.0).fit(source));
332        assert!(!NinePatchInsets::uniform(10.0).fit(source));
333        assert!(!NinePatchInsets::uniform(12.0).fit(source));
334    }
335
336    #[test]
337    fn a_whole_number_of_tiles_covers_the_destination_exactly() {
338        let quads = tile_quads(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 20.0, 20.0));
339        assert_eq!(quads.len(), 4);
340        assert_eq!(
341            tile_count(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 20.0, 20.0)),
342            4
343        );
344        assert_eq!(quads[0].destination, rect(0.0, 0.0, 10.0, 10.0));
345        assert_eq!(quads[3].destination, rect(10.0, 10.0, 10.0, 10.0));
346        assert!(quads.iter().all(|quad| quad.source.width == 10.0));
347    }
348
349    #[test]
350    fn a_partial_tile_is_clipped_rather_than_squeezed() {
351        let quads = tile_quads(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 25.0, 10.0));
352        assert_eq!(quads.len(), 3);
353        let last = quads[2];
354        assert_eq!(last.destination, rect(20.0, 0.0, 5.0, 10.0));
355        assert_eq!(
356            last.source,
357            rect(0.0, 0.0, 5.0, 10.0),
358            "the clipped tile shows the leading part of the source at 1:1"
359        );
360    }
361
362    #[test]
363    fn tiling_reads_from_the_region_it_was_given_not_the_whole_atlas() {
364        let quads = tile_quads(rect(64.0, 32.0, 8.0, 8.0), rect(0.0, 0.0, 16.0, 8.0));
365        assert_eq!(quads.len(), 2);
366        assert!(
367            quads
368                .iter()
369                .all(|quad| quad.source.x == 64.0 && quad.source.y == 32.0)
370        );
371    }
372
373    #[test]
374    fn nothing_is_drawn_for_a_source_or_destination_with_no_area() {
375        assert!(tile_quads(rect(0.0, 0.0, 0.0, 10.0), rect(0.0, 0.0, 20.0, 20.0)).is_empty());
376        assert!(tile_quads(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 20.0, 0.0)).is_empty());
377        assert_eq!(
378            tile_count(rect(0.0, 0.0, 0.0, 0.0), rect(0.0, 0.0, 8.0, 8.0)),
379            0
380        );
381        assert!(
382            nine_patch_quads(
383                rect(0.0, 0.0, 0.0, 0.0),
384                rect(0.0, 0.0, 20.0, 20.0),
385                NinePatchInsets::uniform(4.0),
386                PatchFill::Stretch,
387                PatchFill::Stretch,
388            )
389            .is_empty()
390        );
391    }
392
393    #[test]
394    fn a_stretched_nine_patch_keeps_its_corners_and_grows_the_rest() {
395        let quads = nine_patch_quads(
396            rect(0.0, 0.0, 30.0, 30.0),
397            rect(0.0, 0.0, 100.0, 60.0),
398            NinePatchInsets::uniform(10.0),
399            PatchFill::Stretch,
400            PatchFill::Stretch,
401        );
402        assert_eq!(quads.len(), 9);
403
404        let top_left = quads[0];
405        assert_eq!(top_left.source, rect(0.0, 0.0, 10.0, 10.0));
406        assert_eq!(
407            top_left.destination,
408            rect(0.0, 0.0, 10.0, 10.0),
409            "a corner is drawn at its own size"
410        );
411
412        let bottom_right = quads[8];
413        assert_eq!(bottom_right.source, rect(20.0, 20.0, 10.0, 10.0));
414        assert_eq!(bottom_right.destination, rect(90.0, 50.0, 10.0, 10.0));
415
416        let middle = quads[4];
417        assert_eq!(middle.source, rect(10.0, 10.0, 10.0, 10.0));
418        assert_eq!(middle.destination, rect(10.0, 10.0, 80.0, 40.0));
419    }
420
421    #[test]
422    fn the_patches_cover_the_destination_without_gaps_or_overlap() {
423        let destination = rect(5.0, 7.0, 100.0, 60.0);
424        let quads = nine_patch_quads(
425            rect(0.0, 0.0, 30.0, 30.0),
426            destination,
427            NinePatchInsets::new(10.0, 8.0, 6.0, 4.0),
428            PatchFill::Stretch,
429            PatchFill::Stretch,
430        );
431        let area: f32 = quads
432            .iter()
433            .map(|quad| quad.destination.width * quad.destination.height)
434            .sum();
435        assert!(
436            (area - destination.width * destination.height).abs() < 0.001,
437            "nine patches must tile the destination exactly, covered {area}"
438        );
439    }
440
441    #[test]
442    fn a_tiled_nine_patch_repeats_its_edges_and_middle() {
443        let quads = nine_patch_quads(
444            rect(0.0, 0.0, 30.0, 30.0),
445            rect(0.0, 0.0, 50.0, 30.0),
446            NinePatchInsets::uniform(10.0),
447            PatchFill::Tile,
448            PatchFill::Tile,
449        );
450        // Corners are still four single quads; the middle band is 30 wide and
451        // 10 tall over a 10x10 source, so three tiles per middle row.
452        let corners = quads
453            .iter()
454            .filter(|quad| quad.destination.width == 10.0 && quad.destination.height == 10.0)
455            .count();
456        assert!(corners >= 4);
457        assert!(
458            quads
459                .iter()
460                .all(|quad| quad.source.width <= 10.0 && quad.source.height <= 10.0),
461            "a tiled patch never reads more than one source tile at a time"
462        );
463        assert!(
464            quads.len() > 9,
465            "tiling produces more draws than the nine stretched patches"
466        );
467    }
468
469    #[test]
470    fn a_destination_too_small_for_the_corners_falls_back_to_a_plain_scale() {
471        let quads = nine_patch_quads(
472            rect(0.0, 0.0, 30.0, 30.0),
473            rect(0.0, 0.0, 12.0, 12.0),
474            NinePatchInsets::uniform(10.0),
475            PatchFill::Stretch,
476            PatchFill::Stretch,
477        );
478        assert_eq!(quads.len(), 1);
479        assert_eq!(quads[0].destination, rect(0.0, 0.0, 12.0, 12.0));
480        assert_eq!(quads[0].source, rect(0.0, 0.0, 30.0, 30.0));
481    }
482
483    #[test]
484    fn insets_with_no_middle_left_fall_back_to_a_plain_scale() {
485        let quads = nine_patch_quads(
486            rect(0.0, 0.0, 20.0, 20.0),
487            rect(0.0, 0.0, 100.0, 100.0),
488            NinePatchInsets::uniform(10.0),
489            PatchFill::Stretch,
490            PatchFill::Stretch,
491        );
492        assert_eq!(quads.len(), 1);
493    }
494}