use ezu_core::text::Aabb;
use ezu_graph::RasterBuf;
use tiny_skia::{FilterQuality, Pixmap, PixmapPaint, PixmapRef, Transform};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub(super) enum IconTextFit {
#[default]
None,
Width,
Height,
Both,
}
impl IconTextFit {
pub(super) fn parse(s: &str) -> Option<IconTextFit> {
Some(match s {
"none" => IconTextFit::None,
"width" => IconTextFit::Width,
"height" => IconTextFit::Height,
"both" => IconTextFit::Both,
_ => return None,
})
}
fn fits_width(self) -> bool {
matches!(self, IconTextFit::Width | IconTextFit::Both)
}
fn fits_height(self) -> bool {
matches!(self, IconTextFit::Height | IconTextFit::Both)
}
}
pub(super) fn fitted_content_box(
fit: IconTextFit,
text: Aabb,
display: (f32, f32),
padding: [f32; 4],
offset: (f32, f32),
) -> Aabb {
let (min_x, max_x) = if fit.fits_width() {
(
offset.0 + text.min_x - padding[3],
offset.0 + text.max_x + padding[1],
)
} else {
let left = offset.0 + 0.5 * (text.min_x + text.max_x - display.0);
(left, left + display.0)
};
let (min_y, max_y) = if fit.fits_height() {
(
offset.1 + text.min_y - padding[0],
offset.1 + text.max_y + padding[2],
)
} else {
let top = offset.1 + 0.5 * (text.min_y + text.max_y - display.1);
(top, top + display.1)
};
Aabb {
min_x,
min_y,
max_x,
max_y,
}
}
struct Axis {
cuts: Vec<f32>,
dst: Vec<f32>,
}
impl Axis {
fn new(len: u32, bands: &[[u32; 2]], content: (f32, f32), target: f32, scale: f32) -> Axis {
let len = len as f32;
let mut cuts = vec![0.0f32];
for b in bands {
let (a, z) = (b[0] as f32, b[1] as f32);
if z <= a || a >= len {
continue;
}
cuts.push(a.clamp(0.0, len));
cuts.push(z.clamp(0.0, len));
}
cuts.push(len);
cuts.sort_by(f32::total_cmp);
cuts.dedup();
let stretched = |u: f32| -> f32 {
bands
.iter()
.map(|b| {
let (a, z) = (b[0] as f32, b[1] as f32);
(u.min(z) - u.min(a)).max(0.0)
})
.sum()
};
let (c0, c1) = content;
let stretch_span = stretched(c1) - stretched(c0);
let fixed_span = (c1 - c0) - stretch_span;
let growth = if stretch_span > 0.0 {
((target - fixed_span * scale) / stretch_span).max(0.0)
} else {
(target / (c1 - c0).max(f32::EPSILON)).max(0.0)
};
let at = |u: f32| {
let (st, fx) = (stretched(u), u - stretched(u));
let (st0, fx0) = (stretched(c0), c0 - stretched(c0));
if stretch_span > 0.0 {
(st - st0) * growth + (fx - fx0) * scale
} else {
(u - c0) * growth
}
};
let origin = at(0.0);
let dst = cuts.iter().map(|&u| at(u) - origin).collect();
Axis { cuts, dst }
}
fn content_start(&self, content_from: f32) -> f32 {
let mut i = 0;
while i + 2 < self.cuts.len() && self.cuts[i + 1] <= content_from {
i += 1;
}
let (u0, u1) = (self.cuts[i], self.cuts[i + 1]);
let t = if u1 > u0 {
(content_from - u0) / (u1 - u0)
} else {
0.0
};
self.dst[i] + (self.dst[i + 1] - self.dst[i]) * t
}
fn extent(&self) -> f32 {
*self.dst.last().unwrap_or(&0.0)
}
}
pub(super) struct NineSlice<'a> {
pub stretch_x: &'a [[u32; 2]],
pub stretch_y: &'a [[u32; 2]],
pub content: Option<[u32; 4]>,
}
pub(super) fn stretch_image(
src: &RasterBuf,
slice: &NineSlice<'_>,
target: (f32, f32),
scale: f32,
) -> Option<(RasterBuf, (f32, f32))> {
let (sw, sh) = (src.width, src.height);
if sw == 0 || sh == 0 {
return None;
}
let content = slice.content.unwrap_or([0, 0, sw, sh]).map(|v| v as f32);
let ax = Axis::new(
sw,
slice.stretch_x,
(content[0], content[2]),
target.0.max(0.0),
scale,
);
let ay = Axis::new(
sh,
slice.stretch_y,
(content[1], content[3]),
target.1.max(0.0),
scale,
);
let (dw, dh) = (ax.extent(), ay.extent());
let mut out = Pixmap::new(dw.ceil().max(1.0) as u32, dh.ceil().max(1.0) as u32)?;
let paint = PixmapPaint {
quality: FilterQuality::Bilinear,
..PixmapPaint::default()
};
for xi in 0..ax.cuts.len() - 1 {
for yi in 0..ay.cuts.len() - 1 {
let (u0, u1) = (ax.cuts[xi], ax.cuts[xi + 1]);
let (v0, v1) = (ay.cuts[yi], ay.cuts[yi + 1]);
let (x0, x1) = (ax.dst[xi], ax.dst[xi + 1]);
let (y0, y1) = (ay.dst[yi], ay.dst[yi + 1]);
if u1 <= u0 || v1 <= v0 || x1 <= x0 || y1 <= y0 {
continue;
}
let tile = crop(
src,
u0 as u32,
v0 as u32,
(u1 - u0) as u32,
(v1 - v0) as u32,
)?;
let tile_ref = PixmapRef::from_bytes(&tile.pixels, tile.width, tile.height)?;
let t = Transform::from_translate(x0, y0).pre_scale(
(x1 - x0) / tile.width as f32,
(y1 - y0) / tile.height as f32,
);
out.draw_pixmap(0, 0, tile_ref, &paint, t, None);
}
}
let origin = (-ax.content_start(content[0]), -ay.content_start(content[1]));
Some((
RasterBuf {
width: out.width(),
height: out.height(),
pixels: out.take(),
},
origin,
))
}
fn crop(src: &RasterBuf, x: u32, y: u32, w: u32, h: u32) -> Option<RasterBuf> {
let (w, h) = (
w.min(src.width.saturating_sub(x)),
h.min(src.height.saturating_sub(y)),
);
if w == 0 || h == 0 {
return None;
}
let mut out = RasterBuf::new(w, h);
for row in 0..h {
let s = (((y + row) * src.width) + x) as usize * 4;
let d = (row * w) as usize * 4;
let n = w as usize * 4;
out.pixels[d..d + n].copy_from_slice(&src.pixels[s..s + n]);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
const TEXT: Aabb = Aabb {
min_x: -30.0,
min_y: -8.0,
max_x: 30.0,
max_y: 8.0,
};
#[test]
fn an_unfitted_axis_keeps_the_sprite_size_centred_on_the_text() {
let b = fitted_content_box(IconTextFit::None, TEXT, (20.0, 10.0), [0.0; 4], (0.0, 0.0));
assert_eq!((b.max_x - b.min_x, b.max_y - b.min_y), (20.0, 10.0));
assert_eq!((b.min_x, b.min_y), (-10.0, -5.0));
}
#[test]
fn a_fitted_axis_spans_the_text_plus_its_padding() {
let pad = [1.0, 2.0, 3.0, 4.0];
let b = fitted_content_box(IconTextFit::Both, TEXT, (20.0, 10.0), pad, (0.0, 0.0));
assert_eq!((b.min_x, b.max_x), (-34.0, 32.0));
assert_eq!((b.min_y, b.max_y), (-9.0, 11.0));
let w = fitted_content_box(IconTextFit::Width, TEXT, (20.0, 10.0), pad, (0.0, 0.0));
assert_eq!((w.min_x, w.max_x), (-34.0, 32.0));
assert_eq!(w.max_y - w.min_y, 10.0);
let o = fitted_content_box(IconTextFit::Both, TEXT, (20.0, 10.0), pad, (5.0, -5.0));
assert_eq!((o.min_x, o.min_y), (-29.0, -14.0));
}
fn banded() -> RasterBuf {
let mut buf = RasterBuf::new(10, 1);
for x in 0..10u32 {
buf.pixels[x as usize * 4 + 3] = (x as u8 + 1) * 20;
}
buf
}
#[test]
fn only_the_stretch_bands_absorb_the_growth() {
let slice = NineSlice {
stretch_x: &[[4, 6]],
stretch_y: &[],
content: None,
};
let (img, origin) = stretch_image(&banded(), &slice, (30.0, 1.0), 1.0).unwrap();
assert_eq!((img.width, img.height), (30, 1));
assert_eq!(origin, (0.0, 0.0));
assert_eq!(img.pixel(0, 0)[3], 20);
assert_eq!(img.pixel(3, 0)[3], 80);
assert_eq!(img.pixel(29, 0)[3], 200);
assert_eq!(img.pixel(26, 0)[3], 140);
}
#[test]
fn a_sprite_without_bands_scales_whole() {
let slice = NineSlice {
stretch_x: &[],
stretch_y: &[],
content: None,
};
let (img, _) = stretch_image(&banded(), &slice, (20.0, 2.0), 1.0).unwrap();
assert_eq!((img.width, img.height), (20, 2));
}
#[test]
fn the_content_box_lands_on_the_target() {
let slice = NineSlice {
stretch_x: &[[2, 8]],
stretch_y: &[],
content: Some([2, 0, 8, 1]),
};
let (img, origin) = stretch_image(&banded(), &slice, (26.0, 1.0), 1.0).unwrap();
assert_eq!(img.width, 30);
assert_eq!(origin.0, -2.0);
}
}