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    let source_columns = [
156        (source.x, insets.left),
157        (
158            source.x + insets.left,
159            source.width - insets.left - insets.right,
160        ),
161        (source.x + source.width - insets.right, insets.right),
162    ];
163    let source_rows = [
164        (source.y, insets.top),
165        (
166            source.y + insets.top,
167            source.height - insets.top - insets.bottom,
168        ),
169        (source.y + source.height - insets.bottom, insets.bottom),
170    ];
171    let destination_columns = [
172        (destination.x, insets.left),
173        (
174            destination.x + insets.left,
175            destination.width - insets.left - insets.right,
176        ),
177        (
178            destination.x + destination.width - insets.right,
179            insets.right,
180        ),
181    ];
182    let destination_rows = [
183        (destination.y, insets.top),
184        (
185            destination.y + insets.top,
186            destination.height - insets.top - insets.bottom,
187        ),
188        (
189            destination.y + destination.height - insets.bottom,
190            insets.bottom,
191        ),
192    ];
193
194    let mut quads = Vec::new();
195    for row in 0..3 {
196        for column in 0..3 {
197            let (source_x, source_width) = source_columns[column];
198            let (source_y, source_height) = source_rows[row];
199            let (destination_x, destination_width) = destination_columns[column];
200            let (destination_y, destination_height) = destination_rows[row];
201            if source_width <= 0.0
202                || source_height <= 0.0
203                || destination_width <= 0.0
204                || destination_height <= 0.0
205            {
206                continue;
207            }
208            let patch_source = Rect {
209                x: source_x,
210                y: source_y,
211                width: source_width,
212                height: source_height,
213            };
214            let patch_destination = Rect {
215                x: destination_x,
216                y: destination_y,
217                width: destination_width,
218                height: destination_height,
219            };
220            let stretched = column == 1 || row == 1;
221            let fill = match (column, row) {
222                (1, 1) => center,
223                _ if stretched => edges,
224                _ => PatchFill::Stretch,
225            };
226            if fill.is_tiled() {
227                push_tiles(&mut quads, patch_source, patch_destination);
228            } else {
229                quads.push(PatchQuad {
230                    source: patch_source,
231                    destination: patch_destination,
232                });
233            }
234        }
235    }
236    quads
237}
238
239fn usable(rect: Rect) -> bool {
240    rect.x.is_finite()
241        && rect.y.is_finite()
242        && rect.width.is_finite()
243        && rect.height.is_finite()
244        && rect.width > 0.0
245        && rect.height > 0.0
246}
247
248fn push_tiles(quads: &mut Vec<PatchQuad>, source: Rect, destination: Rect) {
249    if !usable(source) || !usable(destination) {
250        return;
251    }
252    let mut y = destination.y;
253    let bottom = destination.y + destination.height;
254    while y < bottom {
255        let height = source.height.min(bottom - y);
256        let mut x = destination.x;
257        let right = destination.x + destination.width;
258        while x < right {
259            let width = source.width.min(right - x);
260            quads.push(PatchQuad {
261                source: Rect {
262                    x: source.x,
263                    y: source.y,
264                    width,
265                    height,
266                },
267                destination: Rect {
268                    x,
269                    y,
270                    width,
271                    height,
272                },
273            });
274            x += source.width;
275        }
276        y += source.height;
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn only_a_tiled_fill_repeats() {
286        assert!(PatchFill::Tile.is_tiled());
287        assert!(!PatchFill::Stretch.is_tiled());
288    }
289
290    fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
291        Rect {
292            x,
293            y,
294            width,
295            height,
296        }
297    }
298
299    #[test]
300    fn insets_cannot_be_negative_or_unmeasurable() {
301        let insets = NinePatchInsets::new(-4.0, f32::NAN, 6.0, f32::INFINITY);
302        assert_eq!(insets.left, 0.0);
303        assert_eq!(insets.top, 0.0);
304        assert_eq!(insets.right, 6.0);
305        assert_eq!(insets.bottom, 0.0);
306        assert_eq!(NinePatchInsets::uniform(3.0).left, 3.0);
307    }
308
309    #[test]
310    fn insets_scale_with_the_source_they_were_measured_on() {
311        let insets = NinePatchInsets::uniform(4.0).scaled(2.0);
312        assert_eq!(insets, NinePatchInsets::uniform(8.0));
313        assert_eq!(
314            NinePatchInsets::uniform(4.0).scaled(0.0),
315            NinePatchInsets::uniform(4.0)
316        );
317    }
318
319    #[test]
320    fn insets_that_leave_no_middle_do_not_fit() {
321        let source = Size::new(20.0, 20.0);
322        assert!(NinePatchInsets::uniform(4.0).fit(source));
323        assert!(!NinePatchInsets::uniform(10.0).fit(source));
324        assert!(!NinePatchInsets::uniform(12.0).fit(source));
325    }
326
327    #[test]
328    fn a_whole_number_of_tiles_covers_the_destination_exactly() {
329        let quads = tile_quads(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 20.0, 20.0));
330        assert_eq!(quads.len(), 4);
331        assert_eq!(
332            tile_count(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 20.0, 20.0)),
333            4
334        );
335        assert_eq!(quads[0].destination, rect(0.0, 0.0, 10.0, 10.0));
336        assert_eq!(quads[3].destination, rect(10.0, 10.0, 10.0, 10.0));
337        assert!(quads.iter().all(|quad| quad.source.width == 10.0));
338    }
339
340    #[test]
341    fn a_partial_tile_is_clipped_rather_than_squeezed() {
342        let quads = tile_quads(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 25.0, 10.0));
343        assert_eq!(quads.len(), 3);
344        let last = quads[2];
345        assert_eq!(last.destination, rect(20.0, 0.0, 5.0, 10.0));
346        assert_eq!(
347            last.source,
348            rect(0.0, 0.0, 5.0, 10.0),
349            "the clipped tile shows the leading part of the source at 1:1"
350        );
351    }
352
353    #[test]
354    fn tiling_reads_from_the_region_it_was_given_not_the_whole_atlas() {
355        let quads = tile_quads(rect(64.0, 32.0, 8.0, 8.0), rect(0.0, 0.0, 16.0, 8.0));
356        assert_eq!(quads.len(), 2);
357        assert!(
358            quads
359                .iter()
360                .all(|quad| quad.source.x == 64.0 && quad.source.y == 32.0)
361        );
362    }
363
364    #[test]
365    fn nothing_is_drawn_for_a_source_or_destination_with_no_area() {
366        assert!(tile_quads(rect(0.0, 0.0, 0.0, 10.0), rect(0.0, 0.0, 20.0, 20.0)).is_empty());
367        assert!(tile_quads(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 20.0, 0.0)).is_empty());
368        assert_eq!(
369            tile_count(rect(0.0, 0.0, 0.0, 0.0), rect(0.0, 0.0, 8.0, 8.0)),
370            0
371        );
372        assert!(
373            nine_patch_quads(
374                rect(0.0, 0.0, 0.0, 0.0),
375                rect(0.0, 0.0, 20.0, 20.0),
376                NinePatchInsets::uniform(4.0),
377                PatchFill::Stretch,
378                PatchFill::Stretch,
379            )
380            .is_empty()
381        );
382    }
383
384    #[test]
385    fn a_stretched_nine_patch_keeps_its_corners_and_grows_the_rest() {
386        let quads = nine_patch_quads(
387            rect(0.0, 0.0, 30.0, 30.0),
388            rect(0.0, 0.0, 100.0, 60.0),
389            NinePatchInsets::uniform(10.0),
390            PatchFill::Stretch,
391            PatchFill::Stretch,
392        );
393        assert_eq!(quads.len(), 9);
394
395        let top_left = quads[0];
396        assert_eq!(top_left.source, rect(0.0, 0.0, 10.0, 10.0));
397        assert_eq!(
398            top_left.destination,
399            rect(0.0, 0.0, 10.0, 10.0),
400            "a corner is drawn at its own size"
401        );
402
403        let bottom_right = quads[8];
404        assert_eq!(bottom_right.source, rect(20.0, 20.0, 10.0, 10.0));
405        assert_eq!(bottom_right.destination, rect(90.0, 50.0, 10.0, 10.0));
406
407        let middle = quads[4];
408        assert_eq!(middle.source, rect(10.0, 10.0, 10.0, 10.0));
409        assert_eq!(middle.destination, rect(10.0, 10.0, 80.0, 40.0));
410    }
411
412    #[test]
413    fn the_patches_cover_the_destination_without_gaps_or_overlap() {
414        let destination = rect(5.0, 7.0, 100.0, 60.0);
415        let quads = nine_patch_quads(
416            rect(0.0, 0.0, 30.0, 30.0),
417            destination,
418            NinePatchInsets::new(10.0, 8.0, 6.0, 4.0),
419            PatchFill::Stretch,
420            PatchFill::Stretch,
421        );
422        let area: f32 = quads
423            .iter()
424            .map(|quad| quad.destination.width * quad.destination.height)
425            .sum();
426        assert!(
427            (area - destination.width * destination.height).abs() < 0.001,
428            "nine patches must tile the destination exactly, covered {area}"
429        );
430    }
431
432    #[test]
433    fn a_tiled_nine_patch_repeats_its_edges_and_middle() {
434        let quads = nine_patch_quads(
435            rect(0.0, 0.0, 30.0, 30.0),
436            rect(0.0, 0.0, 50.0, 30.0),
437            NinePatchInsets::uniform(10.0),
438            PatchFill::Tile,
439            PatchFill::Tile,
440        );
441        let corners = quads
442            .iter()
443            .filter(|quad| quad.destination.width == 10.0 && quad.destination.height == 10.0)
444            .count();
445        assert!(corners >= 4);
446        assert!(
447            quads
448                .iter()
449                .all(|quad| quad.source.width <= 10.0 && quad.source.height <= 10.0),
450            "a tiled patch never reads more than one source tile at a time"
451        );
452        assert!(
453            quads.len() > 9,
454            "tiling produces more draws than the nine stretched patches"
455        );
456    }
457
458    #[test]
459    fn a_destination_too_small_for_the_corners_falls_back_to_a_plain_scale() {
460        let quads = nine_patch_quads(
461            rect(0.0, 0.0, 30.0, 30.0),
462            rect(0.0, 0.0, 12.0, 12.0),
463            NinePatchInsets::uniform(10.0),
464            PatchFill::Stretch,
465            PatchFill::Stretch,
466        );
467        assert_eq!(quads.len(), 1);
468        assert_eq!(quads[0].destination, rect(0.0, 0.0, 12.0, 12.0));
469        assert_eq!(quads[0].source, rect(0.0, 0.0, 30.0, 30.0));
470    }
471
472    #[test]
473    fn insets_with_no_middle_left_fall_back_to_a_plain_scale() {
474        let quads = nine_patch_quads(
475            rect(0.0, 0.0, 20.0, 20.0),
476            rect(0.0, 0.0, 100.0, 100.0),
477            NinePatchInsets::uniform(10.0),
478            PatchFill::Stretch,
479            PatchFill::Stretch,
480        );
481        assert_eq!(quads.len(), 1);
482    }
483}