Files
ltk/src/widget/image.rs

218 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
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<u8>` clone.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ img_widget, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
/// 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<Vec<u8>>,
/// 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<Vec<u8>>, 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<Self, Box<dyn std::error::Error>>
{
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<Vec<u8>>
{
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 );
}
}