use std::sync::Arc;
use crate::base::{Point, Rect, Rgba};
use crate::gfx::decode_image;
use crate::gfx::mosaic::{MosaicMode, MosaicRenderer};
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 decode_image(&bytes) {
Ok(bmp) => Ok(Arc::new(bmp)),
Err(e) => Err(format!("undecodable: {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()
}
fn natural_cells(source: &Result<Arc<Bitmap>, String>, mode: MosaicMode) -> crate::base::Size {
match source {
Ok(bitmap) => {
let (subw, subh) = mode.cell_pixels();
crate::base::Size::new(
(bitmap.width().div_ceil(subw) as i32).max(1),
(bitmap.height().div_ceil(subh) as i32).max(1),
)
}
Err(_) => crate::base::Size::new(7, 2), }
}
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 natural = Self::natural_cells(&self.source, mode);
let source = self.source;
let mut renderer = MosaicRenderer::new();
Element::new()
.style(self.layout)
.measure(move |_avail| natural)
.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)>) {
if rect.w <= 0 || rect.h <= 0 || iw == 0 || ih == 0 {
return (Rect::new(rect.x, rect.y, 0, 0), None);
}
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 from_path_decodes_a_real_file_through_the_unified_decoder() {
let bmp = Bitmap::from_fn(
5,
3,
|x, _| if x % 2 == 0 { Rgba::WHITE } else { Rgba::BLACK },
);
let path = std::env::temp_dir().join(format!(
"abstracttui_image_probe_{}.png",
std::process::id()
));
std::fs::write(&path, crate::gfx::png_encode::encode(&bmp)).unwrap();
let img = Image::from_path(&path);
assert!(img.error().is_none(), "{:?}", img.error());
let _ = std::fs::remove_file(&path);
}
#[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);
}
}
#[test]
fn fit_never_zero_for_visible_rects_at_hostile_aspects() {
let rects = [
Rect::new(0, 0, 3, 50), Rect::new(0, 0, 200, 1), Rect::new(0, 0, 1, 40), Rect::new(0, 0, 40, 1), Rect::new(0, 0, 1, 1), Rect::new(0, 0, 15, 51), ];
let sources = [(160, 96), (8, 8), (1000, 2), (2, 1000), (1, 1)];
let modes = [
MosaicMode::HalfBlock,
MosaicMode::Quadrant,
MosaicMode::Sextant,
MosaicMode::Braille,
];
let fits = [
ImageFit::Contain,
ImageFit::Cover,
ImageFit::Fill,
ImageFit::None,
];
for rect in rects {
for (iw, ih) in sources {
for mode in modes {
for fit in fits {
let (target, crop) = resolve_fit(
rect,
iw,
ih,
fit,
mode,
ImageAlign::Center,
ImageAlign::Center,
);
assert!(
target.w >= 1 && target.h >= 1,
"zero target {target:?} for rect {rect:?} img {iw}x{ih} {mode:?} {fit:?}"
);
assert!(
target.w <= rect.w && target.h <= rect.h,
"target {target:?} escapes rect {rect:?} ({mode:?} {fit:?})"
);
if let Some((_, _, cw, ch)) = crop {
assert!(
cw >= 1 && ch >= 1,
"zero crop for rect {rect:?} img {iw}x{ih} {mode:?} {fit:?}"
);
}
}
}
}
}
}
#[test]
fn fit_is_total_on_degenerate_inputs() {
for rect in [
Rect::new(2, 3, 0, 10),
Rect::new(2, 3, 10, 0),
Rect::new(2, 3, 0, 0),
] {
for fit in [
ImageFit::Contain,
ImageFit::Cover,
ImageFit::Fill,
ImageFit::None,
] {
let (target, crop) = resolve_fit(
rect,
160,
96,
fit,
MosaicMode::HalfBlock,
ImageAlign::Start,
ImageAlign::Start,
);
assert!(target.is_empty(), "{fit:?}: {target:?}");
assert!(crop.is_none());
}
}
let (target, _) = resolve_fit(
Rect::new(0, 0, 10, 5),
0,
0,
ImageFit::Contain,
MosaicMode::HalfBlock,
ImageAlign::Start,
ImageAlign::Start,
);
assert!(target.is_empty());
}
#[test]
fn natural_cells_answers_mode_density() {
let src: Result<Arc<Bitmap>, String> = Ok(Arc::new(Bitmap::new(160, 96, Rgba::WHITE)));
assert_eq!(
Image::natural_cells(&src, MosaicMode::HalfBlock),
Size::new(160, 48)
);
assert_eq!(
Image::natural_cells(&src, MosaicMode::Quadrant),
Size::new(80, 48)
);
assert_eq!(
Image::natural_cells(&src, MosaicMode::Sextant),
Size::new(80, 32)
);
assert_eq!(
Image::natural_cells(&src, MosaicMode::Braille),
Size::new(80, 24)
);
let odd: Result<Arc<Bitmap>, String> = Ok(Arc::new(Bitmap::new(3, 3, Rgba::WHITE)));
assert_eq!(
Image::natural_cells(&odd, MosaicMode::Quadrant),
Size::new(2, 2)
);
let broken: Result<Arc<Bitmap>, String> = Err("unreadable".into());
assert_eq!(
Image::natural_cells(&broken, MosaicMode::HalfBlock),
Size::new(7, 2)
);
}
}