write 0.1.1

A fullscreen, distraction-free, write-only Markdown editor that fades text away to silence the writer's inner editor.
use eframe::egui::{
    self,
    text::{LayoutJob, TextFormat},
};

pub(crate) const VISIBLE_SECS: f64 = 30.0; // fully opaque until this old
pub(crate) const FADE_SECS: f64 = 1.0; // linear fade window
pub(crate) const CUTOFF_SECS: f64 = VISIBLE_SECS + FADE_SECS; // fully faded; prunable

pub(crate) struct Glyph {
    pub ch: char,
    pub birth: f64, // ctx.input(|i| i.time) at the frame it was typed
}

/// Opacity factor in 0.0..=1.0 for a glyph of the given age (seconds).
pub(crate) fn fade_factor(age: f64) -> f32 {
    if age <= VISIBLE_SECS {
        1.0
    } else if age >= CUTOFF_SECS {
        0.0
    } else {
        (1.0 - (age - VISIBLE_SECS) / FADE_SECS) as f32
    }
}

pub(crate) fn quantize_alpha(factor: f32) -> u8 {
    (factor.clamp(0.0, 1.0) * 255.0).round() as u8
}

/// Immediate linear fade over FADE_SECS, no hold. Used for screen clears.
pub(crate) fn clear_factor(age_since_clear: f64) -> f32 {
    if age_since_clear <= 0.0 {
        1.0
    } else if age_since_clear >= FADE_SECS {
        0.0
    } else {
        (1.0 - age_since_clear / FADE_SECS) as f32
    }
}

/// Effective opacity for a glyph, combining its own age-fade with any clear.
/// Glyphs born at or after `visibility_head` are unaffected by the clear.
pub(crate) fn glyph_factor(birth: f64, now: f64, visibility_head: f64) -> f32 {
    let mut f = fade_factor(now - birth);
    if birth < visibility_head {
        f *= clear_factor(now - visibility_head);
    }
    f
}

/// Coalesce adjacent glyphs sharing the same quantized alpha into runs.
pub(crate) fn runs(glyphs: &[Glyph], now: f64, visibility_head: f64) -> Vec<(String, u8)> {
    let mut out: Vec<(String, u8)> = Vec::new();
    for g in glyphs {
        let alpha = quantize_alpha(glyph_factor(g.birth, now, visibility_head));
        match out.last_mut() {
            Some((s, a)) if *a == alpha => s.push(g.ch),
            _ => out.push((g.ch.to_string(), alpha)),
        }
    }
    out
}

/// Build a word-wrapped LayoutJob from the visible glyphs.
pub(crate) fn build_job(
    glyphs: &[Glyph],
    now: f64,
    visibility_head: f64,
    font: egui::FontId,
    base: egui::Color32,
    max_width: f32,
) -> LayoutJob {
    let mut job = LayoutJob::default();
    job.wrap.max_width = max_width;
    for (text, alpha) in runs(glyphs, now, visibility_head) {
        // Color32 is premultiplied; gamma_multiply fades opacity correctly (not to gray).
        let color = base.gamma_multiply(alpha as f32 / 255.0);
        job.append(
            &text,
            0.0,
            TextFormat {
                font_id: font.clone(),
                color,
                ..Default::default()
            },
        );
    }
    job
}

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

    #[test]
    fn fade_factor_thresholds() {
        assert_eq!(fade_factor(0.0), 1.0);
        assert_eq!(fade_factor(30.0), 1.0);
        assert!((fade_factor(30.5) - 0.5).abs() < 1e-6);
        assert_eq!(fade_factor(31.0), 0.0);
        assert_eq!(fade_factor(100.0), 0.0);
    }

    #[test]
    fn quantize_alpha_rounds() {
        assert_eq!(quantize_alpha(1.0), 255);
        assert_eq!(quantize_alpha(0.0), 0);
        assert_eq!(quantize_alpha(0.5), 128);
    }

    #[test]
    fn runs_coalesce_same_age() {
        let glyphs = [
            Glyph {
                ch: 'a',
                birth: 0.0,
            },
            Glyph {
                ch: 'b',
                birth: 0.0,
            },
            Glyph {
                ch: 'c',
                birth: 0.0,
            },
        ];
        assert_eq!(runs(&glyphs, 0.0, 0.0), vec![("abc".to_string(), 255)]);
    }

    #[test]
    fn runs_split_on_alpha_change() {
        let glyphs = [
            Glyph {
                ch: 'x',
                birth: 100.0,
            },
            Glyph {
                ch: 'y',
                birth: 0.0,
            },
        ];
        assert_eq!(
            runs(&glyphs, 100.0, 0.0),
            vec![("x".to_string(), 255), ("y".to_string(), 0)]
        );
    }

    #[test]
    fn clear_factor_immediate() {
        assert_eq!(clear_factor(0.0), 1.0);
        assert!((clear_factor(0.5) - 0.5).abs() < 1e-6);
        assert_eq!(clear_factor(1.0), 0.0);
        assert_eq!(clear_factor(5.0), 0.0);
    }

    #[test]
    fn glyph_factor_unaffected_after_head() {
        // A glyph born at the visibility_head (birth >= head) is full opacity now.
        let head = 5.0;
        let birth = 5.0;
        assert_eq!(glyph_factor(birth, birth, head), 1.0);
    }

    #[test]
    fn glyph_factor_clears_older_glyphs() {
        let birth = 0.0;
        let head = 10.0;
        assert_eq!(glyph_factor(birth, 10.0, head), 1.0);
        assert!((glyph_factor(birth, 10.5, head) - 0.5).abs() < 1e-6);
        assert_eq!(glyph_factor(birth, 11.0, head), 0.0);
    }
}