write 0.5.0

A fullscreen, distraction-free, write-only Markdown editor that fades text away to silence the writer's inner editor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
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
    }
}

/// Whether a fresh wiggle may begin: only once the previous one has fully
/// completed. With `shake_start = f64::NEG_INFINITY` (no wiggle yet) the
/// difference is +inf, so the first wiggle always fires.
pub(crate) fn shake_ready(now: f64, shake_start: f64) -> bool {
    now - shake_start >= SHAKE_SECS
}

/// 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 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_ready_debounces_until_completion() {
        // First wiggle always fires from the initial sentinel.
        assert!(shake_ready(0.0, f64::NEG_INFINITY));
        // Mid-wiggle re-triggers are suppressed.
        let start = 10.0;
        assert!(!shake_ready(start, start));
        assert!(!shake_ready(start + SHAKE_SECS / 2.0, start));
        assert!(!shake_ready(start + SHAKE_SECS - 0.001, start));
        // Once the full duration elapses, a fresh wiggle may fire again.
        assert!(shake_ready(start + SHAKE_SECS, start));
        assert!(shake_ready(start + SHAKE_SECS + 1.0, start));
    }

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