🔒 Repository is read-only – file editing is disabled.

PaganDE/pagan-compositor/src/icons.rs main

213 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
//! Wektorowe ikony paska tytułu (przyciski okna), rasteryzowane Cairo.
//!
//! PO CO wektor, a nie aproksymacja prostokątami (jak wcześniej pixel shaderem):
//! ikony z prostokątów miały tylko zaokrąglone krawędzie, więc np. „X" i kwadrat
//! wyglądały kanciasto i wyblakło. Cairo rysuje prawdziwe ścieżki z zaokrąglonymi
//! końcami (`LineCap::Round`) i połączeniami — jak w nowoczesnych pulpitach.
//!
//! PO CO Cairo, a nie pliki SVG: renderowanie SVG wymaga `librsvg`, które ciągnie
//! za sobą stos GTK/GDK — wbrew założeniu „bez gotowych frameworków UI”. Ikony
//! rysujemy więc KODEM (te same kształty, które miałyby w SVG), bez nowych
//! zależności. Jeśli w przyszłości zapragniemy edytowalnych plików `.svg`,
//! wystarczy podmienić tę funkcję na wczytanie pliku przez librsvg.
//!
//! Cache: rasteryzacja jest kosztowna, a ikony zmieniają się tylko przy zmianie
//! motywu — kluczujemy więc rozmiar i kolor.

use std::{
    collections::HashMap,
    sync::{Mutex, OnceLock},
};

use cairo::{Context, Format, ImageSurface, LineCap, LineJoin};
use smithay::{
    backend::renderer::element::memory::MemoryRenderBuffer,
    utils::{Logical, Size},
};

/// Ikona przycisku paska tytułu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Icon {
    /// Menu aplikacji — trzy poziome kreski („burger”).
    Menu,
    /// Minimalizuj — pozioma kreska.
    Minimize,
    /// Maksymalizuj — kwadratowy obrys.
    Maximize,
    /// Zamknij — ukośny krzyżyk.
    Close,
}

/// Gotowa tekstura ikony wraz z rozmiarem w pikselach logicznych.
#[derive(Clone)]
pub struct IconTexture {
    /// Bufor pamięciowy gotowy do importu przez renderer.
    pub buffer: MemoryRenderBuffer,
    /// Rozmiar tekstury (kwadrat, bok = rozmiar obszaru ikony).
    pub size: Size<i32, Logical>,
}

/// Klucz cache — to, co wpływa na wygląd tekstury.
#[derive(PartialEq, Eq, Hash, Clone)]
struct Key {
    icon: Icon,
    size_px: i32,
    color: [u8; 3],
}

fn cache() -> &'static Mutex<HashMap<Key, Option<IconTexture>>> {
    static CACHE: OnceLock<Mutex<HashMap<Key, Option<IconTexture>>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Zwraca (z cache) teksturę ikony. `None`, gdy rasteryzacja się nie powiodła.
pub fn texture(icon: Icon, size_px: i32, color: (f64, f64, f64)) -> Option<IconTexture> {
    let size_px = size_px.clamp(8, 96);
    let key = Key {
        icon,
        size_px,
        color: [
            (color.0.clamp(0.0, 1.0) * 255.0).round() as u8,
            (color.1.clamp(0.0, 1.0) * 255.0).round() as u8,
            (color.2.clamp(0.0, 1.0) * 255.0).round() as u8,
        ],
    };

    if let Some(cached) = cache()
        .lock()
        .expect("cache ikon zatruty")
        .get(&key)
        .cloned()
    {
        return cached;
    }

    let raster = rasterize(icon, size_px, color);
    if raster.is_none() {
        tracing::warn!(?icon, size_px, "nie udało się zrasteryzować ikony");
    }
    cache()
        .lock()
        .expect("cache ikon zatruty")
        .insert(key, raster.clone());
    raster
}

/// Rysuje ikonę na kwadratowej powierzchni i wysyła ją jako teksturę.
fn rasterize(icon: Icon, size_px: i32, color: (f64, f64, f64)) -> Option<IconTexture> {
    let surface = ImageSurface::create(Format::ARgb32, size_px, size_px).ok()?;
    {
        let ctx = Context::new(&surface).ok()?;
        // Cairo włącza antyaliasing domyślnie — krawędzie będą gładkie.
        ctx.set_source_rgb(color.0, color.1, color.2);
        // Grubość kreski rośnie z rozmiarem ikony, ale nie schodzi poniżej 1.5 px.
        let thickness = (size_px as f64 / 8.5).max(1.5);
        ctx.set_line_width(thickness);
        ctx.set_line_cap(LineCap::Round);
        ctx.set_line_join(LineJoin::Round);

        let center = size_px as f64 / 2.0;
        // Pół-długość boku glifu: ~1/3 obszaru ikony, z zapasem na grubość kreski.
        let half = (size_px as f64 / 2.0) - thickness - 1.5;

        match icon {
            Icon::Menu => {
                // Trzy kreski; odstęp dobrany do grubości, żeby glif był zwarty.
                let gap = (half * 0.62).max(thickness);
                for line in -1..=1 {
                    let y = center + line as f64 * gap;
                    ctx.move_to(center - half, y);
                    ctx.line_to(center + half, y);
                }
                ctx.stroke().ok()?;
            }
            Icon::Minimize => {
                ctx.move_to(center - half, center);
                ctx.line_to(center + half, center);
                ctx.stroke().ok()?;
            }
            Icon::Maximize => {
                // Kwadrat o wyraźnie zaokrąglonych rogach (styl nowoczesny).
                let radius = (half * 0.35).max(1.0);
                rounded_square(&ctx, center - half, center - half, half * 2.0, radius);
                ctx.stroke().ok()?;
            }
            Icon::Close => {
                ctx.move_to(center - half, center - half);
                ctx.line_to(center + half, center + half);
                ctx.move_to(center - half, center + half);
                ctx.line_to(center + half, center - half);
                ctx.stroke().ok()?;
            }
        }
    }
    surface.flush();

    // Ponownie używamy wspólnej konwersji ARGB32 → tekstura (patrz `text.rs`).
    let stride = surface.stride();
    let row_bytes = size_px * 4;
    let mut data = Vec::with_capacity((row_bytes * size_px) as usize);
    surface
        .with_data(|raw| {
            if stride == row_bytes {
                data.extend_from_slice(raw);
            } else {
                for row in raw.chunks_exact(stride as usize).take(size_px as usize) {
                    data.extend_from_slice(&row[..row_bytes as usize]);
                }
            }
        })
        .ok()?;

    let (buffer, size) = crate::text::upload(&data, size_px, size_px)?;
    Some(IconTexture { buffer, size })
}

/// Dodaje do ścieżki kwadrat o zaokrąglonych rogach (bez wypełniania).
fn rounded_square(ctx: &Context, x: f64, y: f64, side: f64, radius: f64) {
    use std::f64::consts::FRAC_PI_2;
    let radius = radius.min(side / 2.0);
    let right = x + side;
    let bottom = y + side;
    ctx.new_sub_path();
    ctx.arc(right - radius, y + radius, radius, -FRAC_PI_2, 0.0);
    ctx.arc(right - radius, bottom - radius, radius, 0.0, FRAC_PI_2);
    ctx.arc(
        x + radius,
        bottom - radius,
        radius,
        FRAC_PI_2,
        std::f64::consts::PI,
    );
    ctx.arc(
        x + radius,
        y + radius,
        radius,
        std::f64::consts::PI,
        3.0 * FRAC_PI_2,
    );
    ctx.close_path();
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Każda ikona rasteryzuje się do niepustej tekstury o zadanym rozmiarze.
    #[test]
    fn ikony_sa_rasteryzowane() {
        for icon in [Icon::Menu, Icon::Minimize, Icon::Maximize, Icon::Close] {
            let texture =
                texture(icon, 18, (0.8, 0.8, 0.8)).expect("ikona powinna się wyrenderować");
            assert_eq!(texture.size.w, 18);
            assert_eq!(texture.size.h, 18);
        }
    }

    /// Rozmiar jest przycinany do bezpiecznego zakresu (ochrona przed absurdem z motywu).
    #[test]
    fn rozmiar_jest_przycinany() {
        let texture = texture(Icon::Close, 4, (1.0, 1.0, 1.0)).expect("ikona minimalna");
        assert_eq!(texture.size.w, 8);
    }
}