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)]
281#[path = "tests/nine_patch_tests.rs"]
282mod tests;