use std::borrow::Cow;
use std::sync::atomic::{AtomicU64, Ordering};
use teksilo_canvas::{Canvas, RasterIcon, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::environment::LayoutDirection;
use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
use teksilo_tokens::Alignment;
use super::image_mask::{ImageMaskShape, apply_alpha_mask, center_crop_square};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImageFit {
#[default]
Contain,
Cover,
Fill,
ScaleDown,
None,
}
pub struct ImageWidget {
name: String,
width: u32,
height: u32,
upload_pixels: Vec<u8>,
fit: ImageFit,
alignment: Alignment,
display_width: Option<f32>,
display_height: Option<f32>,
resizable: bool,
alt: Option<String>,
a11y_hidden: bool,
}
impl ImageWidget {
pub fn new(icon: &RasterIcon) -> Self {
let name = format!("_img_{:p}", icon as *const RasterIcon);
Self {
name,
width: icon.width(),
height: icon.height(),
upload_pixels: icon.pixels().to_vec(),
fit: ImageFit::Contain,
alignment: Alignment::CENTER,
display_width: None,
display_height: None,
resizable: true,
alt: None,
a11y_hidden: false,
}
}
pub fn from_raw(pixels: Vec<u8>, width: u32, height: u32) -> Self {
static NEXT_RAW_ID: AtomicU64 = AtomicU64::new(0);
let id = NEXT_RAW_ID.fetch_add(1, Ordering::Relaxed);
Self {
name: format!("_img_raw_{id}_{width}x{height}"),
width,
height,
upload_pixels: pixels,
fit: ImageFit::Contain,
alignment: Alignment::CENTER,
display_width: None,
display_height: None,
resizable: true,
alt: None,
a11y_hidden: false,
}
}
pub fn mask(mut self, shape: ImageMaskShape) -> Self {
if matches!(shape, ImageMaskShape::None) {
return self;
}
let (mut cropped, side) = center_crop_square(&self.upload_pixels, self.width, self.height);
apply_alpha_mask(&mut cropped, side, side, shape);
self.upload_pixels = cropped;
self.width = side;
self.height = side;
static NEXT_MASK_ID: AtomicU64 = AtomicU64::new(0);
let id = NEXT_MASK_ID.fetch_add(1, Ordering::Relaxed);
self.name = format!("{}_masked_{id}", self.name);
self
}
pub fn fit(mut self, fit: ImageFit) -> Self {
self.fit = fit;
self
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn width(mut self, w: f32) -> Self {
self.display_width = Some(w);
self
}
pub fn height(mut self, h: f32) -> Self {
self.display_height = Some(h);
self
}
pub fn size(mut self, w: f32, h: f32) -> Self {
self.display_width = Some(w);
self.display_height = Some(h);
self
}
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
pub fn alt(mut self, text: impl Into<String>) -> Self {
self.alt = Some(text.into());
self
}
pub fn a11y_hidden(mut self) -> Self {
self.a11y_hidden = true;
self
}
fn aspect_ratio(&self) -> f32 {
if self.height == 0 {
1.0
} else {
self.width as f32 / self.height as f32
}
}
fn fitted_rect(&self, bounds: Rect, rtl: bool) -> Rect {
let img_w = self.width as f32;
let img_h = self.height as f32;
if img_w <= 0.0 || img_h <= 0.0 {
return bounds;
}
let (content_w, content_h) = match self.fit {
ImageFit::Fill => (bounds.width, bounds.height),
ImageFit::Contain => {
let scale = (bounds.width / img_w).min(bounds.height / img_h);
(img_w * scale, img_h * scale)
}
ImageFit::Cover => {
let scale = (bounds.width / img_w).max(bounds.height / img_h);
(img_w * scale, img_h * scale)
}
ImageFit::ScaleDown => {
let scale = (bounds.width / img_w).min(bounds.height / img_h).min(1.0);
(img_w * scale, img_h * scale)
}
ImageFit::None => (img_w, img_h),
};
let x = bounds.x
+ self
.alignment
.horizontal
.resolve(content_w, bounds.width, rtl);
let y = bounds.y + self.alignment.vertical.resolve(content_h, bounds.height);
Rect::new(x, y, content_w, content_h)
}
}
impl std::fmt::Debug for ImageWidget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ImageWidget")
.field("width", &self.width)
.field("height", &self.height)
.field("fit", &self.fit)
.finish()
}
}
impl Widget for ImageWidget {
fn layout_response(
&self,
proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let ar = self.aspect_ratio();
let size = match (self.display_width, self.display_height) {
(Some(w), Some(h)) => Size::new(w, h),
(Some(w), None) => Size::new(w, w / ar),
(None, Some(h)) => Size::new(h * ar, h),
(None, None) => {
let natural_w = self.width as f32;
let natural_h = self.height as f32;
if !self.resizable {
Size::new(natural_w, natural_h)
} else {
match (proposal.width, proposal.height) {
(Some(pw), Some(ph)) => {
let scale = (pw / natural_w).min(ph / natural_h);
Size::new(natural_w * scale, natural_h * scale)
}
(Some(pw), None) => Size::new(pw, pw / ar),
(None, Some(ph)) => Size::new(ph * ar, ph),
(None, None) => Size::new(natural_w, natural_h),
}
}
}
};
size.into()
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
if !canvas.has_pending_image(&self.name) {
canvas.ensure_image_registered(
&self.name,
self.width,
self.height,
Cow::Owned(self.upload_pixels.clone()),
);
}
let rtl = matches!(ctx.layout_direction, LayoutDirection::RightToLeft);
let rect = self.fitted_rect(bounds, rtl);
const EPS: f32 = 0.01;
let overflows = rect.x < bounds.x - EPS
|| rect.y < bounds.y - EPS
|| rect.x + rect.width > bounds.x + bounds.width + EPS
|| rect.y + rect.height > bounds.y + bounds.height + EPS;
if overflows {
canvas.set_clip(bounds);
canvas.draw_image(rect, &self.name);
canvas.clear_clip();
} else {
canvas.draw_image(rect, &self.name);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
if self.a11y_hidden {
builder.set_hidden();
return;
}
debug_assert!(
self.alt.is_some(),
"ImageWidget has no alt text — call .alt(\"…\") for meaningful images or .a11y_hidden() for decorative ones"
);
builder.set_role(teksilo_core::accesskit::Role::Image);
if let Some(ref alt) = self.alt {
builder.set_name(alt);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::widget_tree::WidgetTree;
#[test]
fn natural_size() {
let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
let mut tree = WidgetTree::new();
let img = tree.add(ImageWidget::new(&icon));
tree.layout(SizeProposal::unspecified());
let b = tree.bounds(img);
assert!((b.width - 10.0).abs() < 0.01);
assert!((b.height - 10.0).abs() < 0.01);
}
#[test]
fn explicit_display_size() {
let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
let mut tree = WidgetTree::new();
let img = tree.add(ImageWidget::new(&icon).size(200.0, 100.0));
tree.layout(SizeProposal::unspecified());
let b = tree.bounds(img);
assert!((b.width - 200.0).abs() < 0.01);
assert!((b.height - 100.0).abs() < 0.01);
}
#[test]
fn size_that_fits_preserves_aspect_ratio() {
let icon = RasterIcon::from_raw(vec![255; 800], 20, 10); let widget = ImageWidget::new(&icon);
let theme = teksilo_core::presets::intui::light();
let ctx = LayoutContext::for_testing(&theme);
let size = widget
.layout_response(
SizeProposal {
width: Some(100.0),
height: None,
},
&ctx,
)
.size;
assert!((size.width - 100.0).abs() < 0.5, "width: {}", size.width);
assert!((size.height - 50.0).abs() < 0.5, "height: {}", size.height);
}
#[test]
fn paints_image_quad() {
let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
let mut tree = WidgetTree::new();
tree.add(ImageWidget::new(&icon));
tree.layout(SizeProposal::exact(10.0, 10.0));
let frame = tree.render();
assert!(!frame.images.is_empty(), "should render an image");
assert!(frame.images[0].tint.is_none(), "should be full-color");
}
#[test]
fn pending_image_registered() {
let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
let mut tree = WidgetTree::new();
tree.add(ImageWidget::new(&icon));
tree.layout(SizeProposal::exact(10.0, 10.0));
let frame = tree.render();
assert!(
!frame.pending_images.is_empty(),
"should register pending image"
);
}
#[test]
fn from_raw_unique_name_per_call() {
let a = ImageWidget::from_raw(vec![255; 16], 2, 2);
let b = ImageWidget::from_raw(vec![0; 16], 2, 2);
assert_ne!(a.name, b.name);
}
#[test]
fn fixed_size_is_rigid_against_constraining_proposal() {
let icon = RasterIcon::from_raw(vec![255; 512 * 512 * 4], 512, 512);
let widget = ImageWidget::new(&icon).size(32.0, 32.0);
let theme = teksilo_core::presets::intui::light();
let ctx = LayoutContext::for_testing(&theme);
let size = widget
.layout_response(
SizeProposal {
width: Some(600.0),
height: None,
},
&ctx,
)
.size;
assert!((size.width - 32.0).abs() < 0.01, "width: {}", size.width);
assert!((size.height - 32.0).abs() < 0.01, "height: {}", size.height);
}
#[test]
fn single_axis_pin_derives_other_from_aspect_ratio() {
let icon = RasterIcon::from_raw(vec![255; 40 * 10 * 4], 40, 10); let theme = teksilo_core::presets::intui::light();
let ctx = LayoutContext::for_testing(&theme);
let w_pinned = ImageWidget::new(&icon)
.width(200.0)
.layout_response(SizeProposal::exact(999.0, 999.0), &ctx)
.size;
assert!((w_pinned.width - 200.0).abs() < 0.01);
assert!(
(w_pinned.height - 50.0).abs() < 0.01,
"h: {}",
w_pinned.height
);
let h_pinned = ImageWidget::new(&icon)
.height(20.0)
.layout_response(SizeProposal::exact(999.0, 999.0), &ctx)
.size;
assert!(
(h_pinned.width - 80.0).abs() < 0.01,
"w: {}",
h_pinned.width
);
assert!((h_pinned.height - 20.0).abs() < 0.01);
}
#[test]
fn resizable_false_locks_natural_pixel_size() {
let icon = RasterIcon::from_raw(vec![255; 64 * 64 * 4], 64, 64);
let theme = teksilo_core::presets::intui::light();
let ctx = LayoutContext::for_testing(&theme);
let scaled = ImageWidget::new(&icon)
.layout_response(SizeProposal::exact(16.0, 16.0), &ctx)
.size;
assert!((scaled.width - 16.0).abs() < 0.01);
let locked = ImageWidget::new(&icon)
.resizable(false)
.layout_response(SizeProposal::exact(16.0, 16.0), &ctx)
.size;
assert!((locked.width - 64.0).abs() < 0.01, "w: {}", locked.width);
assert!((locked.height - 64.0).abs() < 0.01);
}
#[test]
fn contain_centers_inside_a_wider_box() {
let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
let widget = ImageWidget::new(&icon).fit(ImageFit::Contain);
let r = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), false);
assert!((r.width - 100.0).abs() < 0.01);
assert!((r.height - 100.0).abs() < 0.01);
assert!((r.x - 50.0).abs() < 0.01, "x: {}", r.x); assert!((r.y - 0.0).abs() < 0.01);
}
#[test]
fn alignment_positions_content_within_box() {
use teksilo_tokens::{HAlignment, VAlignment};
let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
let widget = ImageWidget::new(&icon)
.fit(ImageFit::Contain)
.alignment(Alignment {
horizontal: HAlignment::Trailing,
vertical: VAlignment::Bottom,
});
let r = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), false);
assert!((r.x - 100.0).abs() < 0.01, "x: {}", r.x);
assert!((r.y - 0.0).abs() < 0.01, "y: {}", r.y);
let r_rtl = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), true);
assert!((r_rtl.x - 0.0).abs() < 0.01, "rtl x: {}", r_rtl.x);
}
#[test]
fn fit_none_draws_at_natural_pixel_size() {
let icon = RasterIcon::from_raw(vec![255; 20 * 20 * 4], 20, 20);
let widget = ImageWidget::new(&icon).fit(ImageFit::None);
let r = widget.fitted_rect(Rect::new(0.0, 0.0, 8.0, 8.0), false);
assert!((r.width - 20.0).abs() < 0.01);
assert!((r.height - 20.0).abs() < 0.01);
assert!((r.x - (-6.0)).abs() < 0.01, "x: {}", r.x); }
#[test]
fn cover_overflow_is_clipped_to_bounds() {
let icon = RasterIcon::from_raw(vec![255; 20 * 10 * 4], 20, 10);
let mut tree = WidgetTree::new();
tree.add(
ImageWidget::new(&icon)
.size(50.0, 50.0)
.fit(ImageFit::Cover),
);
tree.layout(SizeProposal::exact(50.0, 50.0));
let frame = tree.render();
let has_set_clip = frame
.draw_order
.iter()
.any(|c| matches!(c, teksilo_canvas::DrawCommand::SetClip(_)));
let has_clear_clip = frame
.draw_order
.iter()
.any(|c| matches!(c, teksilo_canvas::DrawCommand::ClearClip));
assert!(has_set_clip, "Cover overflow should emit SetClip");
assert!(has_clear_clip, "Cover overflow should emit ClearClip");
}
#[test]
fn contain_within_box_emits_no_clip() {
let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
let mut tree = WidgetTree::new();
tree.add(
ImageWidget::new(&icon)
.size(50.0, 50.0)
.fit(ImageFit::Contain),
);
tree.layout(SizeProposal::exact(50.0, 50.0));
let frame = tree.render();
let has_set_clip = frame
.draw_order
.iter()
.any(|c| matches!(c, teksilo_canvas::DrawCommand::SetClip(_)));
assert!(!has_set_clip, "Contain should not clip");
}
#[test]
fn mask_circle_alpha_zero_at_corners() {
let icon = RasterIcon::from_raw(vec![255; 32 * 32 * 4], 32, 32);
let widget = ImageWidget::from_raw(icon.pixels().to_vec(), icon.width(), icon.height())
.mask(ImageMaskShape::Circle);
let stride = (widget.width * 4) as usize;
let top_left_alpha = widget.upload_pixels[3];
let top_right_alpha = widget.upload_pixels[stride - 1];
assert_eq!(top_left_alpha, 0);
assert_eq!(top_right_alpha, 0);
let center_idx = (((widget.height / 2) * widget.width + widget.width / 2) * 4 + 3) as usize;
assert_eq!(widget.upload_pixels[center_idx], 255);
}
#[test]
fn mask_none_is_passthrough() {
let original = vec![123, 45, 67, 200, 8, 9, 10, 200];
let widget = ImageWidget::from_raw(original.clone(), 2, 1).mask(ImageMaskShape::None);
assert_eq!(widget.upload_pixels, original);
}
#[test]
fn mask_crops_non_square_to_square() {
let pixels = vec![255; 8 * 4 * 4];
let widget = ImageWidget::from_raw(pixels, 8, 4).mask(ImageMaskShape::Circle);
assert_eq!(widget.width, 4);
assert_eq!(widget.height, 4);
}
#[test]
fn mask_bumps_name_to_avoid_atlas_collision() {
let icon = RasterIcon::from_raw(vec![255; 16 * 16 * 4], 16, 16);
let unmasked = ImageWidget::new(&icon);
let masked = ImageWidget::new(&icon).mask(ImageMaskShape::Circle);
assert_ne!(unmasked.name, masked.name);
}
}