//! 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, } /// 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>> { static CACHE: OnceLock>>> = 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 { 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 { 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); } }