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

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

208 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
//! Rasteryzacja tekstu do tekstury GL (Cairo → `MemoryRenderBuffer`).
//!
//! PO CO: kompozytor rysuje pasek tytułu dla WSZYSTKICH okien (wymuszony SSD).
//! Żeby dekoracja była kompletna (tytuł okna oraz etykiety menu aplikacji:
//! Plik/Edycja/Widok/Pomoc), kompozytor musi umieć narysować TEKST — inaczej
//! pasek jest „ślepy”.
//!
//! DLACZEGO Cairo: to ta sama biblioteka, której używa nasz toolkit, i jest już
//! spakowana w PaganOS. Nie dokładamy więc nowej zależności systemowej.
//!
//! DLACZEGO cache: rasteryzacja napisu jest kosztowna, a teksty (tytuły, etykiety
//! menu) zmieniają się rzadko. Trzymamy gotowe tekstury kluczowane treścią,
//! rozmiarem czcionki i kolorem — klatka tylko je składa.
//!
//! FORMAT: Cairo `ARgb32` jest premultiplied, a renderer Smithaya miesza kolory
//! funkcją `ONE, ONE_MINUS_SRC_ALPHA` (czyli też premultiplied) — dlatego bajty
//! Cairo można wysłać na GPU bez żadnej konwersji, jako `Fourcc::Argb8888`.

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

use cairo::{Context, FontSlant, FontWeight, Format, ImageSurface};
use smithay::{
    backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer},
    utils::{Logical, Size, Transform},
};

/// Gotowa tekstura napisu wraz z jej rozmiarem w pikselach logicznych.
#[derive(Clone)]
pub struct TextTexture {
    /// Bufor pamięciowy gotowy do importu przez renderer.
    pub buffer: MemoryRenderBuffer,
    /// Rozmiar tekstury (szerokość × wysokość).
    pub size: Size<i32, Logical>,
}

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

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

/// Cache samych POMIARÓW (szerokość napisu) — używany przy układaniu menu,
/// gdzie potrzebujemy rozmiaru, a nie tekstury.
fn measures() -> &'static Mutex<HashMap<(String, i32), Option<Size<i32, Logical>>>> {
    static MEASURES: OnceLock<Mutex<HashMap<(String, i32), Option<Size<i32, Logical>>>>> =
        OnceLock::new();
    MEASURES.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Zwraca (z cache) rozmiar napisu w pikselach, bez tworzenia tekstury.
///
/// PO CO osobno: układ menu (pozycje etykiet) liczymy także przy obsłudze myszy,
/// gdzie renderera nie ma pod ręką — wystarczy nam wtedy sam pomiar.
pub fn measure(text: &str, font_px: f64) -> Option<Size<i32, Logical>> {
    if text.trim().is_empty() {
        return None;
    }
    let key = (text.to_string(), font_px.round().max(1.0) as i32);
    if let Some(cached) = measures()
        .lock()
        .expect("cache pomiarów zatruty")
        .get(&key)
        .cloned()
    {
        return cached;
    }

    let result = measure_uncached(text, font_px);
    measures()
        .lock()
        .expect("cache pomiarów zatruty")
        .insert(key, result);
    result
}

fn measure_uncached(text: &str, font_px: f64) -> Option<Size<i32, Logical>> {
    let dummy = ImageSurface::create(Format::ARgb32, 1, 1).ok()?;
    let ctx = Context::new(&dummy).ok()?;
    ctx.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
    ctx.set_font_size(font_px);
    let ink = ctx.text_extents(text).ok()?;
    let font = ctx.font_extents().ok()?;
    Some(Size::from((
        ink.width().ceil() as i32,
        (font.ascent() + font.descent()).ceil() as i32,
    )))
}

/// Zwraca (z cache) teksturę napisu. `None`, gdy rasteryzacja się nie powiodła —
/// wtedy wołający po prostu nie rysuje tekstu (dekoracja działa dalej).
pub fn texture(text: &str, font_px: f64, color: (f64, f64, f64)) -> Option<TextTexture> {
    if text.trim().is_empty() {
        return None;
    }

    let key = Key {
        text: text.to_string(),
        font_px: font_px.round().max(1.0) as i32,
        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 tekstu zatruty")
        .get(&key)
        .cloned()
    {
        return cached;
    }

    let raster = rasterize(text, key.font_px as f64, color);
    if raster.is_none() {
        tracing::warn!(text, "nie udało się zrasteryzować tekstu");
    }
    cache()
        .lock()
        .expect("cache tekstu zatruty")
        .insert(key, raster.clone());
    raster
}

/// Właściwa rasteryzacja: mierzy napis, tworzy powierzchnię i rysuje tekst.
fn rasterize(text: &str, font_px: f64, color: (f64, f64, f64)) -> Option<TextTexture> {
    /// Zapas wokół napisu, żeby antyaliasing nie został obcięty na krawędzi.
    const PADDING: i32 = 2;

    // 1) Pomiar na powierzchni pomocniczej (Cairo nie mierzy tekstu bez kontekstu).
    let dummy = ImageSurface::create(Format::ARgb32, 1, 1).ok()?;
    let measure = Context::new(&dummy).ok()?;
    measure.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
    measure.set_font_size(font_px);
    let ink = measure.text_extents(text).ok()?;
    let font = measure.font_extents().ok()?;

    let width = ink.width().ceil() as i32 + PADDING * 2 + 1;
    let height = (font.ascent() + font.descent()).ceil() as i32 + PADDING * 2;
    if width <= 0 || height <= 0 {
        return None;
    }

    // 2) Właściwa powierzchnia + rysunek.
    let surface = ImageSurface::create(Format::ARgb32, width, height).ok()?;
    {
        let ctx = Context::new(&surface).ok()?;
        ctx.set_source_rgb(color.0, color.1, color.2);
        ctx.select_font_face("sans-serif", FontSlant::Normal, FontWeight::Normal);
        ctx.set_font_size(font_px);
        ctx.move_to(PADDING as f64, PADDING as f64 + font.ascent());
        ctx.show_text(text).ok()?;
    }
    surface.flush();

    // 3) Kopiujemy bajty, zwijając wiersze do `width * 4`, gdyby Cairo dodał
    //    wyrównanie (dla ARGB32 zwykle go nie ma, ale nie zakładamy tego).
    let stride = surface.stride();
    let row_bytes = width * 4;
    let mut data = Vec::with_capacity((row_bytes * height) 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(height as usize) {
                    data.extend_from_slice(&row[..row_bytes as usize]);
                }
            }
        })
        .ok()?;
    let (buffer, size) = upload(&data, width, height)?;
    Some(TextTexture { buffer, size })
}

/// Wysyła bajty powierzchni Cairo (ARGB32, premultiplied) jako teksturę GL.
///
/// PO CO wspólny helper: korzystają z niego i tekst ([`texture`]), i ikony
/// (`crate::icons`) — konwersja formatu i wyrównanie wierszy muszą być identyczne.
pub(crate) fn upload(
    data: &[u8],
    width: i32,
    height: i32,
) -> Option<(MemoryRenderBuffer, Size<i32, Logical>)> {
    if data.len() < (width * height * 4) as usize {
        return None;
    }
    let buffer = MemoryRenderBuffer::from_slice(
        data,
        Fourcc::Argb8888,
        (width, height),
        1,
        Transform::Normal,
        None,
    );
    Some((buffer, Size::from((width, height))))
}