🔒 Repository is read-only – file editing is disabled.
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);
}
}