argui-platform 0.2.1

Native platform integrations for Argui applications
Documentation
use std::sync::Arc;

use image::{ImageEncoder, codecs::png::PngEncoder};

#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ApplicationId(String);

impl ApplicationId {
    pub fn new(value: impl Into<String>) -> Result<Self, ApplicationIdError> {
        let value = value.into();
        if valid_application_id(&value) {
            Ok(Self(value))
        } else {
            Err(ApplicationIdError(value))
        }
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ApplicationIdError(String);

impl std::fmt::Display for ApplicationIdError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "invalid reverse-DNS application id: {}", self.0)
    }
}

impl std::error::Error for ApplicationIdError {}

fn valid_application_id(value: &str) -> bool {
    let mut segments = value.split('.');
    let Some(first) = segments.next() else {
        return false;
    };
    let Some(second) = segments.next() else {
        return false;
    };
    valid_segment(first) && valid_segment(second) && segments.all(valid_segment)
}

fn valid_segment(segment: &str) -> bool {
    !segment.is_empty()
        && segment
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
        && segment.as_bytes()[0].is_ascii_lowercase()
}

#[derive(Clone, Debug, PartialEq)]
pub struct ApplicationIdentity {
    pub id: ApplicationId,
    pub display_name: String,
    pub icons: IconSet,
    linux_application_id: Option<String>,
}

impl ApplicationIdentity {
    #[must_use]
    pub fn new(id: ApplicationId, display_name: impl Into<String>, icons: IconSet) -> Self {
        Self {
            id,
            display_name: display_name.into(),
            icons,
            linux_application_id: None,
        }
    }

    /// Overrides the desktop-file id exposed to Wayland and X11.
    ///
    /// This should match the installed `<id>.desktop` file name without its
    /// `.desktop` suffix. It is useful when a packager derives that name from
    /// an executable instead of the reverse-DNS bundle identifier.
    #[must_use]
    pub fn with_linux_application_id(mut self, id: impl Into<String>) -> Self {
        self.linux_application_id = Some(id.into());
        self
    }

    #[must_use]
    pub fn linux_application_id(&self) -> &str {
        self.linux_application_id
            .as_deref()
            .unwrap_or_else(|| self.id.as_str())
    }

    #[must_use]
    pub fn development(display_name: impl Into<String>) -> Self {
        Self {
            id: ApplicationId("dev.argui.application".into()),
            display_name: display_name.into(),
            icons: IconSet::new(),
            linux_application_id: None,
        }
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct IconSet {
    icons: Vec<AppIcon>,
}

impl IconSet {
    #[must_use]
    pub const fn new() -> Self {
        Self { icons: Vec::new() }
    }

    #[must_use]
    pub fn single(icon: AppIcon) -> Self {
        Self { icons: vec![icon] }
    }

    #[must_use]
    pub fn with(mut self, icon: AppIcon) -> Self {
        self.icons
            .retain(|candidate| candidate.width != icon.width || candidate.height != icon.height);
        self.icons.push(icon);
        self.icons.sort_by_key(AppIcon::pixel_count);
        self
    }

    #[must_use]
    pub fn icons(&self) -> &[AppIcon] {
        &self.icons
    }

    #[must_use]
    pub fn best_square(&self, target: u32) -> Option<&AppIcon> {
        self.icons.iter().min_by_key(|icon| {
            let size = icon.width.max(icon.height);
            size.abs_diff(target)
        })
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.icons.is_empty()
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct AppIcon {
    pub width: u32,
    pub height: u32,
    pub rgba8: Arc<[u8]>,
    pub png: Arc<[u8]>,
}

impl AppIcon {
    pub fn from_png(encoded: impl AsRef<[u8]>) -> Result<Self, AppIconError> {
        let encoded = encoded.as_ref();
        let decoded =
            image::load_from_memory_with_format(encoded, image::ImageFormat::Png)?.into_rgba8();
        Self::from_parts(
            decoded.width(),
            decoded.height(),
            decoded.into_raw(),
            encoded.to_vec(),
        )
    }

    pub fn from_rgba8(
        width: u32,
        height: u32,
        rgba8: impl Into<Vec<u8>>,
    ) -> Result<Self, AppIconError> {
        let rgba8 = rgba8.into();
        validate_rgba(width, height, rgba8.len())?;
        let mut png = Vec::new();
        PngEncoder::new(&mut png).write_image(
            &rgba8,
            width,
            height,
            image::ExtendedColorType::Rgba8,
        )?;
        Self::from_parts(width, height, rgba8, png)
    }

    fn from_parts(
        width: u32,
        height: u32,
        rgba8: Vec<u8>,
        png: Vec<u8>,
    ) -> Result<Self, AppIconError> {
        validate_rgba(width, height, rgba8.len())?;
        Ok(Self {
            width,
            height,
            rgba8: rgba8.into(),
            png: png.into(),
        })
    }

    fn pixel_count(&self) -> u64 {
        u64::from(self.width) * u64::from(self.height)
    }
}

fn validate_rgba(width: u32, height: u32, actual: usize) -> Result<(), AppIconError> {
    let expected = usize::try_from(width)
        .ok()
        .and_then(|width| usize::try_from(height).ok().map(|height| width * height))
        .and_then(|pixels| pixels.checked_mul(4))
        .filter(|_| width != 0 && height != 0)
        .ok_or(AppIconError::InvalidDimensions)?;
    if expected == actual {
        Ok(())
    } else {
        Err(AppIconError::InvalidByteLength { expected, actual })
    }
}

#[derive(Debug)]
pub enum AppIconError {
    Decode(image::ImageError),
    InvalidDimensions,
    InvalidByteLength { expected: usize, actual: usize },
}

impl std::fmt::Display for AppIconError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Decode(error) => write!(formatter, "icon decoding failed: {error}"),
            Self::InvalidDimensions => formatter.write_str("invalid icon dimensions"),
            Self::InvalidByteLength { expected, actual } => {
                write!(
                    formatter,
                    "invalid icon byte length: expected {expected}, got {actual}"
                )
            }
        }
    }
}

impl std::error::Error for AppIconError {}

impl From<image::ImageError> for AppIconError {
    fn from(error: image::ImageError) -> Self {
        Self::Decode(error)
    }
}