use std::cell::RefCell;
use std::rc::Rc;
use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Wrap};
use tiny_skia::{
FillRule, FilterQuality, Paint, Path, PathBuilder, Pixmap, PixmapPaint, PixmapRef, Rect,
Stroke, Transform,
};
use crate::icon::BuiltinIcon;
pub struct FontResources {
font_system: FontSystem,
swash_cache: SwashCache,
}
impl FontResources {
pub fn new() -> Self {
Self {
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
}
}
}
impl Default for FontResources {
fn default() -> Self {
Self::new()
}
}
pub type SharedFonts = Rc<RefCell<FontResources>>;
pub fn shared_fonts() -> SharedFonts {
Rc::new(RefCell::new(FontResources::new()))
}
pub const BG: (u8, u8, u8, u8) = (0x18, 0x18, 0x18, 0xFF);
pub const FG: (u8, u8, u8, u8) = (0xEA, 0xEA, 0xEA, 0xFF);
const FONT_SIZE: f32 = 16.0;
#[derive(Debug, Clone, PartialEq)]
pub struct RenderSettings {
pub background: (u8, u8, u8, u8),
pub foreground: (u8, u8, u8, u8),
pub accent: (u8, u8, u8, u8),
pub font_size: f32,
pub font_family: Option<String>,
pub scale: u32,
}
impl Default for RenderSettings {
fn default() -> Self {
Self {
background: BG,
foreground: FG,
accent: FG,
font_size: FONT_SIZE,
font_family: None,
scale: 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bounds {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl Bounds {
pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
}
}
pub fn contains(&self, px: u32, py: u32) -> bool {
px >= self.x && px < self.x + self.width && py >= self.y && py < self.y + self.height
}
}
fn rounded_rect_path(bounds: Bounds, radius: f32, inset: f32) -> Option<Path> {
let x = bounds.x as f32 + inset;
let y = bounds.y as f32 + inset;
let w = bounds.width as f32 - 2.0 * inset;
let h = bounds.height as f32 - 2.0 * inset;
if w <= 0.0 || h <= 0.0 {
return None;
}
let radius = radius.clamp(0.0, (w / 2.0).min(h / 2.0));
if radius <= 0.0 {
return Some(PathBuilder::from_rect(Rect::from_xywh(x, y, w, h)?));
}
let mut pb = PathBuilder::new();
pb.move_to(x + radius, y);
pb.line_to(x + w - radius, y);
pb.quad_to(x + w, y, x + w, y + radius);
pb.line_to(x + w, y + h - radius);
pb.quad_to(x + w, y + h, x + w - radius, y + h);
pb.line_to(x + radius, y + h);
pb.quad_to(x, y + h, x, y + h - radius);
pb.line_to(x, y + radius);
pb.quad_to(x, y, x + radius, y);
pb.close();
pb.finish()
}
pub struct RenderContext {
fonts: SharedFonts,
pixmap: Pixmap,
settings: RenderSettings,
}
impl RenderContext {
pub fn new(width: u32, height: u32) -> Self {
Self::with_settings(width, height, RenderSettings::default())
}
pub fn with_settings(width: u32, height: u32, settings: RenderSettings) -> Self {
Self::with_fonts(width, height, settings, shared_fonts())
}
pub fn with_fonts(
width: u32,
height: u32,
settings: RenderSettings,
fonts: SharedFonts,
) -> Self {
let pixmap = Pixmap::new(width.max(1), height.max(1)).expect("non-zero pixmap dimensions");
Self {
fonts,
pixmap,
settings,
}
}
pub fn foreground(&self) -> (u8, u8, u8, u8) {
self.settings.foreground
}
pub fn accent(&self) -> (u8, u8, u8, u8) {
self.settings.accent
}
pub fn settings(&self) -> &RenderSettings {
&self.settings
}
pub fn set_settings(&mut self, settings: RenderSettings) {
self.settings = settings;
}
pub fn fonts(&self) -> &SharedFonts {
&self.fonts
}
pub fn width(&self) -> u32 {
self.pixmap.width()
}
pub fn height(&self) -> u32 {
self.pixmap.height()
}
pub fn scale_factor(&self) -> u32 {
self.settings.scale.max(1)
}
pub fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 {
return;
}
if self.pixmap.width() != width || self.pixmap.height() != height {
self.pixmap = Pixmap::new(width, height).expect("non-zero pixmap dimensions");
}
}
pub fn fill_background(&mut self) {
let (r, g, b, a) = self.settings.background;
self.pixmap.fill(tiny_skia::Color::from_rgba8(r, g, b, a));
}
pub fn fill_rounded_rect(&mut self, bounds: Bounds, color: (u8, u8, u8, u8), radius: f32) {
let (r, g, b, a) = color;
if bounds.width == 0 || bounds.height == 0 || a == 0 {
return;
}
let path =
rounded_rect_path(bounds, radius, 0.0).expect("validated non-zero rounded rectangle");
let mut paint = Paint::default();
paint.set_color_rgba8(r, g, b, a);
self.pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
pub fn stroke_rounded_rect(
&mut self,
bounds: Bounds,
color: (u8, u8, u8, u8),
radius: f32,
width: f32,
) {
let (r, g, b, a) = color;
if bounds.width == 0 || bounds.height == 0 || a == 0 || width <= 0.0 {
return;
}
let max_width = bounds.width.min(bounds.height) as f32;
let width = width.min(max_width);
let inset = width / 2.0;
let Some(path) = rounded_rect_path(bounds, (radius - inset).max(0.0), inset) else {
return;
};
let mut paint = Paint::default();
paint.set_color_rgba8(r, g, b, a);
let stroke = Stroke {
width,
..Stroke::default()
};
self.pixmap
.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
pub fn draw_text(&mut self, text: &str, bounds: Bounds, color: (u8, u8, u8, u8)) {
if bounds.width == 0 || bounds.height == 0 {
return;
}
let mut fonts = self.fonts.borrow_mut();
let FontResources {
font_system,
swash_cache,
} = &mut *fonts;
let settings = &self.settings;
let metrics = Metrics::new(settings.font_size, bounds.height as f32);
let mut buffer = Buffer::new(font_system, metrics);
buffer.set_size(Some(bounds.width as f32), Some(bounds.height as f32));
buffer.set_wrap(Wrap::None);
let mut attrs = Attrs::new();
if let Some(family) = &settings.font_family {
attrs = attrs.family(Family::Name(family));
}
buffer.set_text(text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(font_system, false);
let text_color = Color::rgba(color.0, color.1, color.2, color.3);
let (ox, oy) = (bounds.x as f32, bounds.y as f32);
let mut dst = self.pixmap.as_mut();
buffer.draw(font_system, swash_cache, text_color, |x, y, w, h, color| {
let Some(rect) = Rect::from_xywh(ox + x as f32, oy + y as f32, w as f32, h as f32)
else {
return;
};
let mut paint = Paint::default();
let rgba = color.as_rgba();
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
dst.fill_rect(rect, &paint, Transform::default(), None);
});
}
pub fn draw_text_centered(&mut self, text: &str, bounds: Bounds, color: (u8, u8, u8, u8)) {
if text.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let mut fonts = self.fonts.borrow_mut();
let FontResources {
font_system,
swash_cache,
} = &mut *fonts;
let settings = &self.settings;
let metrics = Metrics::new(settings.font_size, bounds.height as f32);
let mut buffer = Buffer::new(font_system, metrics);
buffer.set_size(Some(bounds.width as f32), Some(bounds.height as f32));
buffer.set_wrap(Wrap::None);
let mut attrs = Attrs::new();
if let Some(family) = &settings.font_family {
attrs = attrs.family(Family::Name(family));
}
buffer.set_text(text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(font_system, false);
let text_color = Color::rgba(color.0, color.1, color.2, color.3);
let mut ink_bounds: Option<(i32, i32, i32, i32)> = None;
buffer.draw(font_system, swash_cache, text_color, |x, y, w, h, _| {
let right = x.saturating_add(i32::try_from(w).unwrap_or(i32::MAX));
let bottom = y.saturating_add(i32::try_from(h).unwrap_or(i32::MAX));
ink_bounds = Some(match ink_bounds {
Some((left, top, old_right, old_bottom)) => (
left.min(x),
top.min(y),
old_right.max(right),
old_bottom.max(bottom),
),
None => (x, y, right, bottom),
});
});
let Some((left, top, right, bottom)) = ink_bounds else {
return;
};
let ink_width = i64::from(right - left);
let ink_height = i64::from(bottom - top);
let offset_x =
i64::from(bounds.x) + (i64::from(bounds.width) - ink_width) / 2 - i64::from(left);
let offset_y =
i64::from(bounds.y) + (i64::from(bounds.height) - ink_height) / 2 - i64::from(top);
let offset_x = offset_x.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
let offset_y = offset_y.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
let mut dst = self.pixmap.as_mut();
buffer.draw(font_system, swash_cache, text_color, |x, y, w, h, color| {
let Some(rect) = Rect::from_xywh(
(offset_x + x) as f32,
(offset_y + y) as f32,
w as f32,
h as f32,
) else {
return;
};
let mut paint = Paint::default();
let rgba = color.as_rgba();
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
dst.fill_rect(rect, &paint, Transform::default(), None);
});
}
pub fn measure_text(&mut self, text: &str) -> u32 {
if text.is_empty() {
return 0;
}
let mut fonts = self.fonts.borrow_mut();
let font_system = &mut fonts.font_system;
let settings = &self.settings;
let metrics = Metrics::new(settings.font_size, settings.font_size);
let mut buffer = Buffer::new(font_system, metrics);
buffer.set_size(None, None);
buffer.set_wrap(Wrap::None);
let mut attrs = Attrs::new();
if let Some(family) = &settings.font_family {
attrs = attrs.family(Family::Name(family));
}
buffer.set_text(text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(font_system, false);
buffer
.layout_runs()
.map(|run| run.line_w)
.fold(0.0_f32, f32::max)
.ceil() as u32
}
pub fn draw_icon(&mut self, rgba: &[u8], img_w: u32, img_h: u32, bounds: Bounds) {
if bounds.width == 0 || bounds.height == 0 || img_w == 0 || img_h == 0 {
return;
}
let Some(src) = PixmapRef::from_bytes(rgba, img_w, img_h) else {
return;
};
let scale = (bounds.width as f32 / img_w as f32).min(bounds.height as f32 / img_h as f32);
let draw_w = img_w as f32 * scale;
let draw_h = img_h as f32 * scale;
let tx = bounds.x as f32 + (bounds.width as f32 - draw_w) / 2.0;
let ty = bounds.y as f32 + (bounds.height as f32 - draw_h) / 2.0;
let transform = Transform::from_row(scale, 0.0, 0.0, scale, tx, ty);
let paint = PixmapPaint {
quality: FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap
.as_mut()
.draw_pixmap(0, 0, src, &paint, transform, None);
}
pub fn icon_edge(&self) -> u32 {
self.settings.font_size.round().max(1.0) as u32
}
pub fn draw_builtin_icon(
&mut self,
icon: BuiltinIcon,
bounds: Bounds,
color: (u8, u8, u8, u8),
) {
if bounds.width == 0 || bounds.height == 0 || color.3 == 0 {
return;
}
crate::icon::draw_into(&mut self.pixmap, icon, bounds, color);
}
pub fn pixels(&self) -> &[u8] {
self.pixmap.data()
}
}
pub fn render_text(text: &str, width: u32, height: u32) -> Vec<u8> {
let mut ctx = RenderContext::new(width, height);
ctx.fill_background();
ctx.draw_text(text, Bounds::new(0, 0, width, height), FG);
ctx.pixels().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_text_has_rgba_length() {
let px = render_text("12:00:00", 320, 32);
assert_eq!(px.len(), 320 * 32 * 4);
}
#[test]
fn render_text_fills_dark_opaque_background() {
let px = render_text("12:00:00", 320, 32);
let last = &px[px.len() - 4..];
assert!(
last[0] < 0x30 && last[1] < 0x30 && last[2] < 0x30,
"corner not dark: {last:?}"
);
assert_eq!(last[3], 0xFF, "corner not opaque");
}
#[test]
fn fill_background_alpha_is_transparent() {
let settings = RenderSettings {
background: (0x80, 0x40, 0x20, 0x00),
..RenderSettings::default()
};
let mut ctx = RenderContext::with_settings(8, 4, settings);
ctx.fill_background();
assert_eq!(
&ctx.pixels()[0..4],
&[0, 0, 0, 0],
"background not transparent"
);
}
#[test]
fn fill_rounded_rect_paints_interior_and_skips_transparent() {
let mut ctx = RenderContext::new(40, 40);
ctx.fill_background();
ctx.fill_rounded_rect(Bounds::new(4, 4, 32, 32), (0x00, 0xC0, 0x00, 0xFF), 8.0);
let center = (20 * 40 + 20) * 4;
{
let px = ctx.pixels();
assert!(
px[center] < 0x30 && px[center + 1] > 0x80 && px[center + 2] < 0x30,
"pill center not green: {:?}",
&px[center..center + 4]
);
}
let before = ctx.pixels()[0..4].to_vec();
ctx.fill_rounded_rect(Bounds::new(0, 0, 40, 40), (0xFF, 0x00, 0x00, 0x00), 8.0);
assert_eq!(
&ctx.pixels()[0..4],
&before[..],
"transparent fill changed pixels"
);
}
#[test]
fn stroke_rounded_rect_paints_an_inset_border_and_scales_width() {
let mut ctx = RenderContext::new(24, 24);
ctx.fill_background();
ctx.fill_rounded_rect(Bounds::new(2, 2, 20, 20), (0x20, 0x40, 0x20, 0xFF), 2.0);
ctx.stroke_rounded_rect(
Bounds::new(2, 2, 20, 20),
(0xE0, 0xA0, 0x20, 0xFF),
2.0,
2.0,
);
let px = ctx.pixels();
let border = (12 * 24 + 2) * 4;
let center = (12 * 24 + 12) * 4;
assert!(
px[border] > 0xB0 && px[border + 1] > 0x70 && px[border + 2] < 0x50,
"border pixel not amber: {:?}",
&px[border..border + 4]
);
assert!(
px[center] < 0x40 && px[center + 1] > 0x30 && px[center + 2] < 0x40,
"center fill was overwritten: {:?}",
&px[center..center + 4]
);
}
#[test]
fn measure_text_grows_with_content_and_is_zero_for_empty() {
let mut ctx = RenderContext::new(200, 32);
assert_eq!(ctx.measure_text(""), 0, "empty text has zero width");
let one = ctx.measure_text("8");
let many = ctx.measure_text("88:88:88");
assert!(one > 0, "a single glyph has nonzero width");
assert!(many > one, "more text is wider: {many} !> {one}");
}
#[test]
fn scale_factor_defaults_to_one_and_follows_settings() {
let ctx = RenderContext::new(10, 10);
assert_eq!(ctx.scale_factor(), 1);
let scaled = RenderContext::with_settings(
10,
10,
RenderSettings {
scale: 2,
..RenderSettings::default()
},
);
assert_eq!(scaled.scale_factor(), 2);
}
#[test]
fn render_text_draws_some_foreground() {
let px = render_text("12:00:00", 320, 32);
let has_light = px.as_chunks::<4>().0.iter().any(|p| p[0] > 0x60);
assert!(has_light, "no foreground pixels found");
}
#[test]
fn centered_text_centers_visible_ink_on_both_axes() {
let settings = RenderSettings {
background: (0, 0, 0, 0),
..RenderSettings::default()
};
let mut ctx = RenderContext::with_settings(60, 40, settings);
let bounds = Bounds::new(7, 3, 40, 30);
ctx.draw_text_centered("j", bounds, FG);
let mut min_x = u32::MAX;
let mut min_y = u32::MAX;
let mut max_x = 0;
let mut max_y = 0;
for (index, pixel) in ctx.pixels().as_chunks::<4>().0.iter().enumerate() {
if pixel[3] == 0 {
continue;
}
let x = index as u32 % ctx.width();
let y = index as u32 / ctx.width();
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
assert_ne!(min_x, u32::MAX, "centered glyph painted no pixels");
let ink_center_x_twice = min_x + max_x + 1;
let ink_center_y_twice = min_y + max_y + 1;
let bounds_center_x_twice = 2 * bounds.x + bounds.width;
let bounds_center_y_twice = 2 * bounds.y + bounds.height;
assert!(ink_center_x_twice.abs_diff(bounds_center_x_twice) <= 1);
assert!(ink_center_y_twice.abs_diff(bounds_center_y_twice) <= 1);
}
#[test]
fn bounds_contains_is_half_open() {
let b = Bounds::new(10, 5, 100, 20);
assert!(b.contains(10, 5), "top-left corner is inside");
assert!(b.contains(109, 24), "last interior pixel is inside");
assert!(!b.contains(110, 24), "right edge is exclusive");
assert!(!b.contains(109, 25), "bottom edge is exclusive");
assert!(!b.contains(9, 5), "left of origin is outside");
}
#[test]
fn render_context_reuses_pixmap_on_same_size_resize() {
let mut ctx = RenderContext::new(64, 16);
ctx.fill_background();
ctx.resize(64, 16); assert_eq!((ctx.width(), ctx.height()), (64, 16));
ctx.resize(128, 16); assert_eq!((ctx.width(), ctx.height()), (128, 16));
ctx.resize(0, 16);
assert_eq!((ctx.width(), ctx.height()), (128, 16));
assert_eq!(ctx.pixels().len(), 128 * 16 * 4);
}
#[test]
fn draw_icon_blits_a_centered_image_into_its_slot() {
let red = [255u8, 0, 0, 255];
let icon: Vec<u8> = red.iter().cycle().take(2 * 2 * 4).copied().collect();
let mut ctx = RenderContext::new(64, 32);
ctx.fill_background();
ctx.draw_icon(&icon, 2, 2, Bounds::new(6, 6, 20, 20));
let px = ctx.pixels();
let center = (16 * 64 + 16) * 4;
assert!(
px[center] > 0xC0 && px[center + 1] < 0x30 && px[center + 2] < 0x30,
"icon center not red: {:?}",
&px[center..center + 4]
);
let corner = (16 * 64 + 2) * 4;
assert!(
px[corner] < 0x30 && px[corner + 1] < 0x30 && px[corner + 2] < 0x30,
"outside-icon area not background"
);
}
#[test]
fn draw_icon_ignores_malformed_byte_lengths() {
let mut ctx = RenderContext::new(32, 32);
ctx.fill_background();
ctx.draw_icon(&[0xFF, 0x00, 0x00], 4, 4, Bounds::new(0, 0, 32, 32));
let px = ctx.pixels();
assert!(px[0] < 0x30 && px[1] < 0x30 && px[2] < 0x30);
}
#[test]
fn draw_text_offsets_glyphs_by_bounds_origin() {
let mut ctx = RenderContext::new(200, 40);
ctx.fill_background();
ctx.draw_text("8", Bounds::new(150, 0, 40, 40), FG);
let px = ctx.pixels();
assert!(
px[0] < 0x30 && px[1] < 0x30 && px[2] < 0x30,
"top-left not bg"
);
assert!(
px.as_chunks::<4>().0.iter().any(|p| p[0] > 0x60),
"no glyph drawn"
);
}
}