write 0.4.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
    pub end_offset: usize, // byte end-of-char within `document` at append time
}

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

/// The writer's primary text color: a light green. Fresh (still-backspaceable) text
/// renders at this directly; older/locked text is darkened from it.
pub(crate) const PRIMARY: egui::Color32 = egui::Color32::from_rgb(154, 220, 154);

/// Darker-mode primary: the same green hue scaled to the old gray-30 brightness
/// (ratio ≈ 30/220), so darker mode reads as a dim green rather than gray.
pub(crate) const PRIMARY_DARKER: egui::Color32 = egui::Color32::from_rgb(21, 30, 21);

/// Uniform attenuation applied to locked text so it reads as a darker shade of the
/// primary green while preserving its hue.
pub(crate) const OLDER_SCALE: f32 = 0.78;

/// Darken a primary color uniformly (all three channels) to derive the locked-text
/// color. Applied to the (opaque) color *before* gamma_multiply, so the age-fade still
/// drains to black on the premultiplied path.
pub(crate) fn darken(c: egui::Color32) -> egui::Color32 {
    egui::Color32::from_rgb(
        (c.r() as f32 * OLDER_SCALE) as u8,
        (c.g() as f32 * OLDER_SCALE) as u8,
        (c.b() as f32 * OLDER_SCALE) 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 and freshness into runs.
/// A glyph is "fresh" (still backspaceable) when its start byte within `document`
/// is at or past `floor_byte`, the monotonic delete floor in document coordinates.
pub(crate) fn runs(
    glyphs: &[Glyph],
    now: f64,
    visibility_head: f64,
    floor_byte: usize,
) -> Vec<(String, u8, bool)> {
    let mut out: Vec<(String, u8, bool)> = Vec::new();
    for g in glyphs {
        let alpha = quantize_alpha(glyph_factor(g.birth, now, visibility_head));
        // Glyph start byte within `document`; saturating to avoid usize underflow.
        let fresh = g.end_offset.saturating_sub(g.ch.len_utf8()) >= floor_byte;
        match out.last_mut() {
            Some((s, a, f)) if *a == alpha && *f == fresh => s.push(g.ch),
            _ => out.push((g.ch.to_string(), alpha, fresh)),
        }
    }
    out
}

/// Whitespace-delimited word count, **excluding** ATX level-2 header lines
/// (`## …`, written as session timestamp headers by `open_session_file`). Shared by
/// the HUD and the Esc menu so the two always agree, and consistent with the on-disk
/// seed computed at open time. A line is a header when its first non-space content is
/// `## `, so session headers contribute zero words on every session (not just the
/// current one).
pub(crate) fn word_count(s: &str) -> usize {
    s.lines()
        .filter(|line| !line.trim_start().starts_with("## "))
        .flat_map(str::split_whitespace)
        .count()
}

/// Byte offset (within `s`) of the start of `words[len-2]` for whitespace-delimited
/// words, or `0` when `s` has fewer than 2 words. Single pass over `char_indices`,
/// tracking word-starts and keeping the last two start offsets. Uses
/// `char::is_whitespace`, matching `split_whitespace` semantics so the HUD word
/// count stays consistent. Drives the `delete_floor` ratchet ("two words ago").
pub(crate) fn start_byte_of_second_to_last_word(s: &str) -> usize {
    let (mut second_last, mut last, mut count, mut prev_ws) = (0usize, 0usize, 0u32, true);
    for (i, ch) in s.char_indices() {
        let ws = ch.is_whitespace();
        if !ws && prev_ws {
            second_last = last;
            last = i;
            count += 1;
        }
        prev_ws = ws;
    }
    if count >= 2 { second_last } else { 0 }
}

/// Build a word-wrapped LayoutJob from the visible glyphs.
pub(crate) fn build_job(
    glyphs: &[Glyph],
    now: f64,
    visibility_head: f64,
    font: egui::FontId,
    primary: egui::Color32,
    max_width: f32,
    floor_byte: usize,
) -> LayoutJob {
    let mut job = LayoutJob::default();
    job.wrap.max_width = max_width;
    for (text, alpha, fresh) in runs(glyphs, now, visibility_head, floor_byte) {
        let base = if fresh { primary } else { darken(primary) };
        // 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 word_count_skips_atx_headers() {
        // A bare header line contributes nothing.
        assert_eq!(word_count("## 2026-06-26 1:23:45pm PDT"), 0);
        // Headers interleaved with prose: only the prose counts.
        let doc = "\
## 2026-06-26 1:23:45pm PDT

hello world

## 2026-06-26 2:00:00pm PDT

three more words";
        assert_eq!(word_count(doc), 5); // "hello world" + "three more words"
        // Leading whitespace before the hashes still counts as a header.
        assert_eq!(word_count("   ## indented header"), 0);
        // A level-3 header is NOT skipped (first non-space content is "###", not "## ").
        assert_eq!(word_count("### kept words here"), 4);
    }

    #[test]
    fn start_byte_of_second_to_last_word_cases() {
        // Fewer than 2 words → 0.
        assert_eq!(start_byte_of_second_to_last_word(""), 0);
        assert_eq!(start_byte_of_second_to_last_word("   "), 0);
        assert_eq!(start_byte_of_second_to_last_word("hello"), 0);
        // Exactly 2 words → start of words[0] → 0.
        assert_eq!(start_byte_of_second_to_last_word("hello world"), 0);
        // 3 words → start of the middle word.
        assert_eq!(start_byte_of_second_to_last_word("foo bar baz"), 4);
        // Leading whitespace shifts the offsets.
        assert_eq!(start_byte_of_second_to_last_word("  foo bar baz"), 6);
        // Trailing whitespace doesn't add a phantom word.
        assert_eq!(start_byte_of_second_to_last_word("foo bar baz   "), 4);
        // Paragraph break counts as whitespace.
        assert_eq!(start_byte_of_second_to_last_word("foo\n\nbar baz"), 5);
        // Multi-byte: "café déjà vu" → start of "déjà" is byte 6 (café = 5 bytes + space).
        assert_eq!(start_byte_of_second_to_last_word("café déjà vu"), 6);
    }

    #[test]
    fn delete_floor_ratchet_is_monotonic() {
        // Fold the ratchet over a growing document; it must never decrease, and a
        // momentarily-shorter doc must not lower it.
        let boot_size = 100u64;
        let snapshots = [
            "",
            "a",
            "a b",
            "a b c",
            "a b c d",
            "a b", // shorter doc must not lower the floor
            "a b c d e f",
        ];
        let mut floor = boot_size;
        let mut prev = floor;
        for doc in snapshots {
            let candidate = boot_size + start_byte_of_second_to_last_word(doc) as u64;
            floor = floor.max(candidate);
            assert!(floor >= prev, "floor decreased: {prev} -> {floor}");
            assert!(floor >= boot_size);
            prev = floor;
        }
    }

    #[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,
                end_offset: 0,
            },
            Glyph {
                ch: 'b',
                birth: 0.0,
                end_offset: 0,
            },
            Glyph {
                ch: 'c',
                birth: 0.0,
                end_offset: 0,
            },
        ];
        assert_eq!(
            runs(&glyphs, 0.0, 0.0, usize::MAX),
            vec![("abc".to_string(), 255, false)]
        );
    }

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

    #[test]
    fn runs_split_on_fresh_boundary() {
        // Four same-age glyphs with end_offsets 1,2,3,4 → start bytes 0,1,2,3.
        // floor_byte = 2 ⇒ a,b locked (starts 0,1 < 2); c,d fresh (starts 2,3 >= 2).
        let glyphs = [
            Glyph {
                ch: 'a',
                birth: 0.0,
                end_offset: 1,
            },
            Glyph {
                ch: 'b',
                birth: 0.0,
                end_offset: 2,
            },
            Glyph {
                ch: 'c',
                birth: 0.0,
                end_offset: 3,
            },
            Glyph {
                ch: 'd',
                birth: 0.0,
                end_offset: 4,
            },
        ];
        assert_eq!(
            runs(&glyphs, 0.0, 0.0, 2),
            vec![
                ("ab".to_string(), 255, false),
                ("cd".to_string(), 255, true)
            ]
        );
    }

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