write 0.3.0

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
}

pub(crate) const SHAKE_SECS: f64 = 0.4; // duration of the wiggle
pub(crate) const SHAKE_AMPLITUDE: f32 = 12.0; // initial peak horizontal offset, px
pub(crate) const SHAKE_FREQ: f64 = 47.0; // ~3 full oscillations over SHAKE_SECS

/// Horizontal shake offset (px) for a backspace-clear, a sine oscillation under a
/// linear-decay envelope. `since` = seconds since the backspace event; outside
/// (0, SHAKE_SECS) it returns 0.0 (at rest).
pub(crate) fn shake_offset(since: f64) -> f32 {
    if since <= 0.0 || since >= SHAKE_SECS {
        0.0
    } else {
        let decay = 1.0 - since / SHAKE_SECS; // 1.0 -> 0.0
        (SHAKE_AMPLITUDE as f64 * decay * (since * SHAKE_FREQ).sin()) as f32
    }
}

/// 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
}

/// Whitespace-delimited word count. Shared by the HUD and the Esc menu so the
/// two always agree, and consistent with the on-disk seed computed at open time.
pub(crate) fn word_count(s: &str) -> usize {
    s.split_whitespace().count()
}

/// 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 word_count_counts_whitespace_tokens() {
        assert_eq!(word_count(""), 0);
        assert_eq!(word_count("   "), 0);
        assert_eq!(word_count("hello"), 1);
        assert_eq!(word_count("hello world"), 2);
        assert_eq!(word_count("  multiple   spaces\tand\nnewlines  "), 4);
    }

    #[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 shake_offset_at_rest_outside_window() {
        assert_eq!(shake_offset(-1.0), 0.0);
        assert_eq!(shake_offset(0.0), 0.0);
        assert_eq!(shake_offset(SHAKE_SECS), 0.0);
        assert_eq!(shake_offset(SHAKE_SECS + 1.0), 0.0);
    }

    #[test]
    fn shake_offset_bounded_and_decays() {
        // Stays within the decay envelope and is visibly nonzero somewhere mid-window.
        let mut nonzero = 0;
        let mut t = 0.005_f64;
        while t < SHAKE_SECS {
            let env = SHAKE_AMPLITUDE * (1.0 - (t / SHAKE_SECS) as f32);
            assert!(shake_offset(t).abs() <= env + 1e-3);
            if shake_offset(t).abs() > 0.1 {
                nonzero += 1;
            }
            t += 0.005;
        }
        assert!(nonzero > 0, "shake should be visibly nonzero mid-window");
    }

    #[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);
    }
}