// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use std::sync::Arc; use crate::types::Rect; use crate::render::Canvas; /// A static image widget that renders RGBA pixel data. /// /// Images are scaled to fill their allocated rect. Alpha blending against the /// background is handled automatically (straight → premultiplied conversion). /// /// The pixel buffer is shared via `Arc` so reusing the same image across /// frames (e.g. a background decoded once at startup) is a cheap pointer /// copy instead of a full `Vec` clone. /// /// ```rust,no_run /// # use std::sync::Arc; /// # use ltk::{ img_widget, Element }; /// # #[ derive( Clone ) ] enum Msg {} /// # fn _ex( rgba_bytes: Arc>, width: u32, height: u32 ) -> Element { /// img_widget( rgba_bytes, width, height ) /// .opacity( 0.8 ) /// .into() /// # } /// ``` pub struct Image { /// Raw RGBA pixel data (4 bytes per pixel, straight alpha). pub rgba: Arc>, /// Pixel width of the source image. pub width: u32, /// Pixel height of the source image. pub height: u32, /// When `true` the image scales to fill the available width (cover mode). pub cover: bool, /// Optional explicit display size in logical pixels. pub display_size: Option<( f32, f32 )>, /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. pub opacity: f32, } impl Image { /// Create an image from a shared RGBA buffer. /// /// `width` and `height` must match the dimensions of `rgba`. pub fn new( rgba: Arc>, width: u32, height: u32 ) -> Self { Self { rgba, width, height, cover: false, display_size: None, opacity: 1.0 } } /// Load an image from a file path. Supports PNG, JPEG, and other formats /// supported by the [`image`](https://crates.io/crates/image) crate. pub fn from_path( path: &str ) -> Result> { let img = image::open( path )?.into_rgba8(); let ( width, height ) = img.dimensions(); Ok( Self { rgba: Arc::new( img.into_raw() ), width, height, cover: false, display_size: None, opacity: 1.0 } ) } /// Scale the image to fill the available width, preserving aspect ratio (cover mode). pub fn cover( mut self ) -> Self { self.cover = true; self } /// Set an explicit display size in logical pixels. pub fn size( mut self, width: f32, height: f32 ) -> Self { self.display_size = Some( ( width.max( 0.0 ), height.max( 0.0 ) ) ); self } /// Set the opacity multiplier. Clamped to `[0.0, 1.0]`. pub fn opacity( mut self, o: f32 ) -> Self { self.opacity = o.clamp( 0.0, 1.0 ); self } /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) { if let Some( size ) = self.display_size { return size; } if self.cover { ( max_width, max_width * self.height as f32 / self.width as f32 ) } else { let scale = max_width / self.width as f32; ( max_width, self.height as f32 * scale ) } } /// Draw the image into `canvas` at `rect`. pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) { canvas.draw_image_data( &self.rgba[..], self.width, self.height, rect, self.opacity ); } } #[ cfg( test ) ] mod tests { use super::*; fn solid_rgba( w: u32, h: u32 ) -> Arc> { Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] ) } // ── builders / defaults ─────────────────────────────────────────────────── #[ test ] fn new_uses_documented_defaults() { let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ); assert_eq!( img.width, 4 ); assert_eq!( img.height, 4 ); assert!( !img.cover ); assert!( img.display_size.is_none() ); assert_eq!( img.opacity, 1.0 ); } #[ test ] fn cover_builder_sets_cover_flag() { let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).cover(); assert!( img.cover ); } #[ test ] fn size_builder_sets_explicit_display_size() { let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( 120.0, 80.0 ); assert_eq!( img.display_size, Some( ( 120.0, 80.0 ) ) ); } #[ test ] fn size_builder_clamps_negative_dimensions_to_zero() { let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( -10.0, -20.0 ); assert_eq!( img.display_size, Some( ( 0.0, 0.0 ) ) ); } #[ test ] fn opacity_clamps_to_unit_interval() { assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( -0.5 ).opacity, 0.0 ); assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 1.5 ).opacity, 1.0 ); assert_eq!( Image::new( solid_rgba( 1, 1 ), 1, 1 ).opacity( 0.5 ).opacity, 0.5 ); } // ── preferred_size ──────────────────────────────────────────────────────── #[ test ] fn preferred_size_default_scales_height_to_match_width_aspect() { // 200×100 source image at max_width = 400 → scale = 2 → height 200. let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ); let ( w, h ) = img.preferred_size( 400.0 ); assert_eq!( w, 400.0 ); assert_eq!( h, 200.0 ); } #[ test ] fn preferred_size_with_explicit_size_wins_over_aspect_logic() { let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 ); assert_eq!( img.preferred_size( 1000.0 ), ( 50.0, 25.0 ) ); } #[ test ] fn preferred_size_cover_mode_keeps_aspect_ratio() { // 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150. let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); let ( w, h ) = img.preferred_size( 300.0 ); assert_eq!( w, 300.0 ); assert_eq!( h, 150.0 ); } #[ test ] fn preferred_size_cover_and_default_match_for_uniform_scaling() { // When the source aspect matches the requested width, cover and the // default fit-width path produce identical sizes — they only diverge // when the source is taller than wide. let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 ); let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); assert_eq!( normal.preferred_size( 200.0 ), covered.preferred_size( 200.0 ) ); } // ── Arc sharing ─────────────────────────────────────────────────────────── #[ test ] fn rgba_buffer_is_shared_via_arc_not_cloned_per_image() { let bytes = solid_rgba( 8, 8 ); let strong_before = Arc::strong_count( &bytes ); let img = Image::new( bytes.clone(), 8, 8 ); assert_eq!( Arc::strong_count( &bytes ), strong_before + 1 ); // Same buffer goes into a second image — strong count rises again. let img2 = Image::new( bytes.clone(), 8, 8 ); assert_eq!( Arc::strong_count( &bytes ), strong_before + 2 ); drop( img ); drop( img2 ); assert_eq!( Arc::strong_count( &bytes ), strong_before ); } }