use std::sync::Arc;
use crate::base::{Point, Rect, Rgba};
use crate::gfx::mosaic::{MosaicMode, MosaicRenderer};
use crate::gfx::png;
pub use crate::gfx::Bitmap;
use crate::layout::Style as LayoutStyle;
use crate::theme::TokenSet;
use crate::ui::{Element, StyledCanvas};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ImageFit {
Contain,
Cover,
Fill,
None,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ImageAlign {
Start,
Center,
End,
}
impl ImageAlign {
fn offset(self, avail: i32, used: i32) -> i32 {
match self {
ImageAlign::Start => 0,
ImageAlign::Center => (avail - used).max(0) / 2,
ImageAlign::End => (avail - used).max(0),
}
}
}
pub struct Image {
source: Result<Arc<Bitmap>, String>,
fit: ImageFit,
mode: MosaicMode,
align_h: ImageAlign,
align_v: ImageAlign,
layout: LayoutStyle,
}
impl Image {
pub fn from_bitmap(bitmap: Arc<Bitmap>) -> Image {
Image::new(if bitmap.is_empty() {
Err("empty bitmap".to_string())
} else {
Ok(bitmap)
})
}
pub fn from_path(path: impl AsRef<std::path::Path>) -> Image {
let path = path.as_ref();
let source = match std::fs::read(path) {
Err(e) => Err(format!("unreadable: {e}")),
Ok(bytes) => match png::decode(&bytes) {
Ok(bmp) => Ok(Arc::new(bmp)),
Err(e) => Err(format!("undecodable (png only in v1): {e}")),
},
};
Image::new(source)
}
fn new(source: Result<Arc<Bitmap>, String>) -> Image {
Image {
source,
fit: ImageFit::Contain,
mode: MosaicMode::HalfBlock,
align_h: ImageAlign::Center,
align_v: ImageAlign::Center,
layout: LayoutStyle::default(),
}
}
pub fn fit(mut self, fit: ImageFit) -> Image {
self.fit = fit;
self
}
pub fn mode(mut self, mode: MosaicMode) -> Image {
self.mode = mode;
self
}
pub fn align(mut self, horizontal: ImageAlign, vertical: ImageAlign) -> Image {
self.align_h = horizontal;
self.align_v = vertical;
self
}
pub fn layout(mut self, style: LayoutStyle) -> Image {
self.layout = style;
self
}
pub fn error(&self) -> Option<&str> {
self.source.as_ref().err().map(String::as_str)
}
pub fn view(self, cx: crate::reactive::Scope) -> crate::ui::View {
let t = crate::widgets::theme_tokens(cx);
self.element(&t).build()
}
pub fn element(self, t: &TokenSet) -> Element {
let faint = t.text_faint;
let fit = self.fit;
let mode = self.mode;
let (ah, av) = (self.align_h, self.align_v);
let source = self.source;
let mut renderer = MosaicRenderer::new();
Element::new().style(self.layout).draw(move |canvas, rect| {
if rect.w <= 0 || rect.h <= 0 {
return;
}
let bitmap = match &source {
Ok(b) => b,
Err(label) => {
canvas.print(rect.origin(), "⌧ image", faint, Rgba::TRANSPARENT);
let msg: String = label.chars().take(rect.w.max(0) as usize).collect();
if rect.h > 1 {
canvas.print(
Point::new(rect.x, rect.y + 1),
&msg,
faint,
Rgba::TRANSPARENT,
);
}
return;
}
};
draw_fitted(canvas, rect, bitmap, fit, mode, ah, av, &mut renderer);
})
}
}
fn resolve_fit(
rect: Rect,
iw: u32,
ih: u32,
fit: ImageFit,
mode: MosaicMode,
ah: ImageAlign,
av: ImageAlign,
) -> (Rect, Option<(u32, u32, u32, u32)>) {
let img_aspect = iw as f32 / ih as f32; let (subw, subh) = mode.cell_pixels();
match fit {
ImageFit::Fill => (rect, None),
ImageFit::Contain => {
let box_w = rect.w as f32; let box_h = rect.h as f32 * 2.0;
let (used_w, used_h) = if box_w / box_h > img_aspect {
(box_h * img_aspect, box_h)
} else {
(box_w, box_w / img_aspect)
};
let cw = (used_w.round() as i32).clamp(1, rect.w);
let ch = ((used_h / 2.0).round() as i32).clamp(1, rect.h);
let target = Rect::new(
rect.x + ah.offset(rect.w, cw),
rect.y + av.offset(rect.h, ch),
cw,
ch,
);
(target, None)
}
ImageFit::Cover => {
let box_aspect = rect.w as f32 / (rect.h as f32 * 2.0);
let (cw, chh) = if img_aspect > box_aspect {
(((ih as f32) * box_aspect).round().max(1.0) as u32, ih)
} else {
(iw, ((iw as f32) / box_aspect).round().max(1.0) as u32)
};
let cx = ah.offset(iw as i32, cw as i32) as u32;
let cy = av.offset(ih as i32, chh as i32) as u32;
(rect, Some((cx, cy, cw, chh)))
}
ImageFit::None => {
let cw = (iw.div_ceil(subw) as i32).min(rect.w).max(1);
let ch = (ih.div_ceil(subh) as i32).min(rect.h).max(1);
let target = Rect::new(
rect.x + ah.offset(rect.w, cw),
rect.y + av.offset(rect.h, ch),
cw,
ch,
);
let crop = (0, 0, (cw as u32) * subw, (ch as u32) * subh);
(target, Some(crop))
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_fitted(
canvas: &mut dyn StyledCanvas,
rect: Rect,
bitmap: &Bitmap,
fit: ImageFit,
mode: MosaicMode,
ah: ImageAlign,
av: ImageAlign,
renderer: &mut MosaicRenderer,
) {
let (target, crop) = resolve_fit(rect, bitmap.width(), bitmap.height(), fit, mode, ah, av);
if target.is_empty() {
return;
}
let grid = match crop {
None => renderer.render(bitmap, target.w as u32, target.h as u32, mode),
Some((x, y, w, h)) => {
let sub = bitmap.crop(x, y, w, h);
renderer.render(&sub, target.w as u32, target.h as u32, mode)
}
};
for (pos, ch, fg, bg) in grid.cell_patches(target.origin()) {
canvas.put(pos, ch, fg, bg);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Size;
use crate::theme::default_theme;
use crate::widgets::test_util::draw_into;
fn stripes() -> Arc<Bitmap> {
Arc::new(Bitmap::from_fn(8, 8, |x, _| {
if x < 4 {
Rgba::WHITE
} else {
Rgba::BLACK
}
}))
}
#[test]
fn contain_letterboxes_square_image_in_wide_rect() {
let (target, crop) = resolve_fit(
Rect::new(0, 0, 20, 4),
8,
8,
ImageFit::Contain,
MosaicMode::HalfBlock,
ImageAlign::Center,
ImageAlign::Center,
);
assert_eq!(target, Rect::new(6, 0, 8, 4));
assert!(crop.is_none());
}
#[test]
fn cover_crops_the_long_axis() {
let (target, crop) = resolve_fit(
Rect::new(0, 0, 20, 4),
8,
8,
ImageFit::Cover,
MosaicMode::HalfBlock,
ImageAlign::Center,
ImageAlign::Center,
);
assert_eq!(target, Rect::new(0, 0, 20, 4));
let (_, cy, cw, ch) = crop.unwrap();
assert_eq!(cw, 8, "full width kept");
assert!(ch < 8, "height cropped: {ch}");
assert!(cy > 0, "center crop offset");
}
#[test]
fn none_is_native_size_clipped() {
let (target, crop) = resolve_fit(
Rect::new(2, 1, 3, 2),
8,
8,
ImageFit::None,
MosaicMode::HalfBlock, ImageAlign::Start,
ImageAlign::Start,
);
assert_eq!(target, Rect::new(2, 1, 3, 2), "clipped to rect");
assert_eq!(crop.unwrap(), (0, 0, 3, 4), "source crop matches clip");
}
#[test]
fn draws_mosaic_cells_into_canvas() {
let t = default_theme().tokens;
let el = Image::from_bitmap(stripes())
.fit(ImageFit::Fill)
.element(&t);
let c = draw_into(el, Size::new(8, 4));
let left = c.cell(Point::new(1, 1)).unwrap();
let right = c.cell(Point::new(6, 1)).unwrap();
assert!(left.2.r > 200, "{left:?}");
assert!(right.2.r < 60, "{right:?}");
}
#[test]
fn broken_source_shows_labeled_state() {
let t = default_theme().tokens;
let img = Image::from_path("/nonexistent/definitely/missing.png");
assert!(img.error().unwrap().contains("unreadable"));
let c = draw_into(img.element(&t), Size::new(20, 3));
assert!(c.row_text(0).contains("image"), "{:?}", c.row_text(0));
}
#[test]
fn non_png_is_labeled_undecodable() {
let img = Image::from_path(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
assert!(
img.error().unwrap().contains("undecodable"),
"{:?}",
img.error()
);
}
#[test]
fn degenerate_rects_never_panic() {
let t = default_theme().tokens;
for size in [Size::new(0, 0), Size::new(1, 1), Size::new(2, 1)] {
let el = Image::from_bitmap(stripes()).element(&t);
let _ = draw_into(el, size);
}
}
}