faststep 0.1.0

UIKit-inspired embedded UI framework built on embedded-graphics
Documentation
use embedded_graphics::{
    Drawable,
    draw_target::DrawTarget,
    pixelcolor::Rgb565,
    prelude::{Primitive, RgbColor, Size},
    primitives::{PrimitiveStyle, Rectangle, RoundedRectangle},
};

use crate::FsTheme;

const SCROLLBAR_WIDTH: u32 = 6;
const SCROLLBAR_DIRTY_WIDTH: u32 = 14;
const SCROLLBAR_MARGIN_X: u32 = 4;
const SCROLLBAR_MARGIN_Y: u32 = 8;
const SCROLLBAR_MIN_HEIGHT: u32 = 28;

/// Geometry and opacity for a transient scrollbar thumb.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScrollBar {
    /// Thumb frame inside the viewport gutter.
    pub frame: Rectangle,
    /// Thumb opacity in the `0..=255` range.
    pub alpha: u8,
}

pub(super) fn scroll_bar_thumb(
    viewport: Rectangle,
    content_height: u32,
    content_offset: f32,
    alpha: u8,
) -> Option<ScrollBar> {
    let track = scrollbar_track(viewport);
    if track.size.width == 0 || track.size.height == 0 {
        return None;
    }

    let max_position = content_height.saturating_sub(viewport.size.height) as f32;
    let content_offset = content_offset.clamp(0.0, max_position);
    let track_height = track.size.height;
    let thumb_height = (((viewport.size.height as u64 * track_height as u64)
        / content_height.max(1) as u64) as u32)
        .max(SCROLLBAR_MIN_HEIGHT)
        .min(track_height);
    let travel = track_height.saturating_sub(thumb_height);
    let thumb_y = if max_position <= 0.0 || travel == 0 {
        track.top_left.y
    } else {
        track.top_left.y + (((content_offset / max_position) * travel as f32) + 0.5) as i32
    };
    Some(ScrollBar {
        frame: Rectangle::new(
            embedded_graphics::prelude::Point::new(track.top_left.x, thumb_y),
            Size::new(track.size.width, thumb_height),
        ),
        alpha,
    })
}

pub(super) fn scroll_bar_dirty_rect(viewport: Rectangle) -> Rectangle {
    let width = SCROLLBAR_DIRTY_WIDTH.min(viewport.size.width);
    let x = viewport.top_left.x + viewport.size.width as i32 - width as i32;
    Rectangle::new(
        embedded_graphics::prelude::Point::new(x, viewport.top_left.y),
        Size::new(width, viewport.size.height),
    )
}

pub(super) fn motion_content_rect(viewport: Rectangle) -> Rectangle {
    let reserved = scroll_bar_dirty_rect(viewport)
        .size
        .width
        .min(viewport.size.width);
    Rectangle::new(
        viewport.top_left,
        Size::new(
            viewport.size.width.saturating_sub(reserved),
            viewport.size.height,
        ),
    )
}

pub(super) fn draw_scrollbar<D>(display: &mut D, indicator: ScrollBar, theme: &FsTheme)
where
    D: DrawTarget<Color = Rgb565>,
{
    let color = blend(theme.surface_alt, theme.text_primary, indicator.alpha);
    RoundedRectangle::with_equal_corners(indicator.frame, Size::new(3, 3))
        .into_styled(PrimitiveStyle::with_fill(color))
        .draw(display)
        .ok();
}

fn scrollbar_track(viewport: Rectangle) -> Rectangle {
    let width = SCROLLBAR_WIDTH.min(viewport.size.width);
    let height = viewport
        .size
        .height
        .saturating_sub(SCROLLBAR_MARGIN_Y.saturating_mul(2));
    let x =
        viewport.top_left.x + viewport.size.width as i32 - SCROLLBAR_MARGIN_X as i32 - width as i32;
    let y = viewport.top_left.y + SCROLLBAR_MARGIN_Y as i32;
    Rectangle::new(
        embedded_graphics::prelude::Point::new(x, y),
        Size::new(width, height),
    )
}

fn blend(base: Rgb565, tint: Rgb565, alpha: u8) -> Rgb565 {
    Rgb565::new(
        mix_channel(base.r(), tint.r(), alpha),
        mix_channel(base.g(), tint.g(), alpha),
        mix_channel(base.b(), tint.b(), alpha),
    )
}

fn mix_channel(base: u8, tint: u8, alpha: u8) -> u8 {
    let alpha = u32::from(alpha);
    let base = u32::from(base);
    let tint = u32::from(tint);
    (((base * (255 - alpha)) + (tint * alpha) + 127) / 255) as u8
}