tui-lipan 0.2.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use std::io::Cursor;
use std::sync::Arc;

use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use tui_lipan::prelude::*;

struct ImageDemo;

struct State {
    sample_png: Arc<[u8]>,
    gandalf_path: Arc<str>,
    paused: bool,
    looped: bool,
    speed_percent: u16,
}

fn make_sample_png(width: u32, height: u32) -> Arc<[u8]> {
    let mut rgba = RgbaImage::new(width, height);

    for y in 0..height {
        for x in 0..width {
            let fx = x as f32 / width.max(1) as f32;
            let fy = y as f32 / height.max(1) as f32;
            let checker = ((x / 8 + y / 8) % 2) as u8;

            let r = (40.0 + 180.0 * fx) as u8;
            let g = (30.0 + 190.0 * fy) as u8;
            let b = if checker == 0 { 70 } else { 150 };

            rgba.put_pixel(x, y, Rgba([r, g, b, 255]));
        }
    }

    let image = DynamicImage::ImageRgba8(rgba);
    let mut out = Cursor::new(Vec::new());
    image
        .write_to(&mut out, ImageFormat::Png)
        .expect("sample image PNG encoding should succeed");
    out.into_inner().into()
}

impl Component for ImageDemo {
    type Message = ();
    type Properties = ();
    type State = State;

    fn create_state(&self, _props: &Self::Properties) -> Self::State {
        let gandalf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("examples/assets/gandalf.gif")
            .to_string_lossy()
            .into_owned()
            .into();

        State {
            sample_png: make_sample_png(96, 56),
            gandalf_path,
            paused: false,
            looped: true,
            speed_percent: 100,
        }
    }

    fn on_key(&mut self, key: KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
        if key.mods.ctrl && matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) {
            ctx.quit();
            return KeyUpdate::handled(Update::none());
        }

        match key.code {
            KeyCode::Char(' ') => {
                ctx.state.paused = !ctx.state.paused;
                return KeyUpdate::handled(Update::full());
            }
            KeyCode::Char('l') | KeyCode::Char('L') => {
                ctx.state.looped = !ctx.state.looped;
                return KeyUpdate::handled(Update::full());
            }
            KeyCode::Char('+') | KeyCode::Char('=') => {
                ctx.state.speed_percent = ctx.state.speed_percent.saturating_add(25).min(400);
                return KeyUpdate::handled(Update::full());
            }
            KeyCode::Char('-') | KeyCode::Char('_') => {
                ctx.state.speed_percent = ctx.state.speed_percent.saturating_sub(25).max(25);
                return KeyUpdate::handled(Update::full());
            }
            _ => {}
        }

        KeyUpdate::unhandled(Update::none())
    }

    fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
        Update::none()
    }

    fn view(&self, ctx: &Context<Self>) -> Element {
        let sample = Arc::clone(&ctx.state.sample_png);
        let playback = if ctx.state.paused {
            ImagePlayback::Paused
        } else {
            ImagePlayback::Playing
        };
        let repeat = if ctx.state.looped {
            ImageRepeat::Loop
        } else {
            ImageRepeat::Once
        };

        Frame::new()
            .header_left("Image Widget")
            .footer_left("Ctrl+Q quit | Space play/pause | L loop | +/- speed")
            .padding(1)
            .child(
                VStack::new()
                    .gap(1)
                    .child(Text::new(format!(
                        "GIF controls: playback={:?} repeat={:?} speed={}%",
                        playback, repeat, ctx.state.speed_percent
                    )))
                    .child(
                        HStack::new()
                            .gap(1)
                            .height(Length::Flex(1))
                            .child(
                                Frame::new()
                                    .header_left("Static PNG")
                                    .width(Length::Flex(1))
                                    .padding(1)
                                    .child(
                                        Image::from_bytes(sample)
                                            .fit(ImageFit::Contain)
                                            .protocol(ImageProtocol::Auto)
                                            .width(Length::Flex(1))
                                            .height(Length::Flex(1)),
                                    ),
                            )
                            .child(
                                Frame::new()
                                    .header_left("Gandalf GIF")
                                    .width(Length::Flex(1))
                                    .padding(1)
                                    .child(
                                        Image::new(Arc::clone(&ctx.state.gandalf_path))
                                            .fit(ImageFit::Scale)
                                            .protocol(ImageProtocol::Auto)
                                            .playback(playback)
                                            .repeat(repeat)
                                            .speed_percent(ctx.state.speed_percent)
                                            .width(Length::Flex(1))
                                            .height(Length::Flex(1)),
                                    ),
                            )
                            .child(
                                Frame::new()
                                    .header_left("Decode Fallback")
                                    .width(Length::Flex(1))
                                    .padding(1)
                                    .child(
                                        Image::from_bytes(vec![1, 2, 3, 4])
                                            .alt("Could not decode image bytes")
                                            .style(Style::new().fg(Color::LightRed))
                                            .width(Length::Flex(1))
                                            .height(Length::Flex(1)),
                                    ),
                            ),
                    ),
            )
            .into()
    }
}

fn main() -> Result<()> {
    App::new().mount(ImageDemo).run()
}