write 0.3.0

A fullscreen, distraction-free, write-only Markdown editor that fades text away to silence the writer's inner editor.
use std::collections::VecDeque;
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};

use chrono::Local;
use eframe::egui;

mod render;

use render::{Glyph, build_job, glyph_factor, shake_offset, word_count};

const SYNC_INTERVAL: Duration = Duration::from_secs(30);
const FONT_SIZE: f32 = 28.0;

pub struct WriteApp {
    document: String,         // file mirror; retained for 1c word count
    visible: VecDeque<Glyph>, // render buffer; pruned each frame as glyphs fully fade
    writer: BufWriter<File>,  // append-only handle to the session .md
    visibility_head: f64,     // glyphs born before this fade out at once (visual clear)
    shake_start: f64,         // time of the last backspace-clear; drives the wiggle feedback
    enter_run: u8,            // 0 = none, 1 = one Enter written, 2+ = cleared / no-op
    last_was_backspace: bool,
    menu_open: bool,
    fullscreen: bool,         // live window state; toggled from the Esc menu (W)
    darker: bool,             // session-only render state; toggled from the Esc menu (D)
    entered_fullscreen: bool, // one-shot: launch-time deferred fullscreen, applied once focused
    seed_words: usize,        // day-total words already on disk at open time
    last_sync: Instant,
}

/// Open today's append-only session file (`~/Documents/write/YYYY-MM-DD.md`),
/// creating it if needed, and take an exclusive advisory lock. Invocations on the
/// same day share one file; the lock ensures only one `write` process appends to
/// it at a time. Returns `Ok(None)` when another process already holds today's
/// lock — the caller should exit quietly.
pub fn open_session_file() -> Result<Option<(File, usize)>, Box<dyn std::error::Error + Send + Sync>>
{
    let mut dir: PathBuf = dirs::document_dir()
        .or_else(dirs::home_dir)
        .ok_or("no Documents or home directory")?;
    dir.push("write");
    fs::create_dir_all(&dir)?;
    dir.push(format!("{}.md", Local::now().format("%Y-%m-%d")));
    let file = OpenOptions::new().create(true).append(true).open(&dir)?;
    // Advisory flock tied to this fd; released automatically when the process exits.
    match file.try_lock() {
        Ok(()) => {
            let seed = word_count(&fs::read_to_string(&dir).unwrap_or_default());
            Ok(Some((file, seed)))
        }
        Err(TryLockError::WouldBlock) => Ok(None),
        Err(TryLockError::Error(e)) => Err(Box::new(e)),
    }
}

impl WriteApp {
    pub fn new(_cc: &eframe::CreationContext<'_>, file: File, seed_words: usize) -> Self {
        Self {
            document: String::new(),
            visible: VecDeque::new(),
            writer: BufWriter::new(file),
            visibility_head: 0.0,
            shake_start: f64::NEG_INFINITY,
            enter_run: 0,
            last_was_backspace: false,
            menu_open: false,
            fullscreen: false,
            darker: false,
            entered_fullscreen: false,
            seed_words,
            last_sync: Instant::now(),
        }
    }

    // Append raw text to the file mirror, the file, and the visible render buffer.
    fn append(&mut self, s: &str, now: f64) {
        self.document.push_str(s);
        if let Err(e) = self.writer.write_all(s.as_bytes()) {
            eprintln!("write: failed to append to file: {e}");
        }
        self.visible
            .extend(s.chars().map(|ch| Glyph { ch, birth: now }));
    }

    fn fsync(&mut self) {
        if let Err(e) = self.writer.flush() {
            eprintln!("write: flush failed: {e}");
        }
        if let Err(e) = self.writer.get_ref().sync_data() {
            eprintln!("write: sync_data failed: {e}");
        }
    }
}

impl eframe::App for WriteApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        let ctx = ui.ctx().clone(); // cheap Arc clone; avoids borrow conflict with ui.painter()

        // Suppress Tab focus navigation.
        ctx.input_mut(|i| {
            let _ = i.consume_key(egui::Modifiers::NONE, egui::Key::Tab);
        });

        let now = ctx.input(|i| i.time);

        // Enter fullscreen only once the window has focus, to dodge the launch-time
        // activation race that otherwise bounces the Space back to the desktop. Keep
        // repainting until then so we actually observe the focus transition.
        if !self.entered_fullscreen {
            if ctx.input(|i| i.focused) {
                ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
                self.entered_fullscreen = true;
                self.fullscreen = true;
            } else {
                ctx.request_repaint();
            }
        }

        // Copy events out, keep the input closure short.
        let events = ctx.input(|i| i.events.clone());
        // Closing the menu is deferred to after this batch so the trailing Text
        // event a key emits (e.g. W's "w") is still swallowed by the menu gate
        // and doesn't leak into the document.
        let mut close_menu = false;
        for event in events {
            if self.menu_open {
                if let egui::Event::Key {
                    key, pressed: true, ..
                } = event
                {
                    match key {
                        egui::Key::Q => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
                        egui::Key::W => {
                            self.fullscreen = !self.fullscreen;
                            ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(
                                self.fullscreen,
                            ));
                            close_menu = true; // resume writing in the new window state
                        }
                        egui::Key::D => {
                            self.darker = !self.darker;
                            close_menu = true; // resume writing in the new brightness
                        }
                        egui::Key::Escape => close_menu = true, // resume writing
                        _ => {}
                    }
                }
                continue; // ignore Text and everything else while the menu is open
            }
            match event {
                egui::Event::Text(t) => {
                    self.append(&t, now); // Text already handles shift/dead/accents
                    self.enter_run = 0;
                    self.last_was_backspace = false;
                }
                egui::Event::Key {
                    key,
                    modifiers,
                    pressed: true,
                    ..
                } => {
                    if modifiers.command {
                        // ignore Cmd/Ctrl combos (paste, undo, ...)
                        continue;
                    }
                    match key {
                        egui::Key::Enter => {
                            match self.enter_run {
                                0 => {
                                    self.append("\n\n", now);
                                    self.enter_run = 1;
                                }
                                1 => {
                                    // Second consecutive Enter: clear the screen (no newlines).
                                    self.visibility_head = now;
                                    self.enter_run = 2;
                                }
                                _ => {} // already cleared → no-op
                            }
                            self.last_was_backspace = false;
                        }
                        egui::Key::Backspace => {
                            if !self.last_was_backspace {
                                self.append(" ", now);
                                self.visibility_head = now; // clear the screen
                                self.shake_start = now; // kick off the "no" wiggle
                            }
                            self.last_was_backspace = true;
                            self.enter_run = 0;
                        }
                        egui::Key::Escape => {
                            self.menu_open = true;
                            self.fsync(); // durable flush on menu open
                        }
                        _ => {} // arrows, Delete, Tab, Home, End, PageUp/Down → no-op
                    }
                }
                _ => {} // Event::Paste, Event::Ime, pointer, etc. → ignored
            }
        }
        if close_menu {
            self.menu_open = false;
        }

        // Periodic durability: flush + sync_data ~every 30s.
        if self.last_sync.elapsed() >= SYNC_INTERVAL {
            self.fsync();
            self.last_sync = Instant::now();
        }

        // Prune fully-faded glyphs from the front (oldest first; glyphs are in birth order).
        // Uses the combined factor so clear-faded glyphs drain too.
        while let Some(front) = self.visible.front() {
            if glyph_factor(front.birth, now, self.visibility_head) <= 0.0 {
                self.visible.pop_front();
            } else {
                break;
            }
        }

        // Render: full-rect black background + a center-anchored, centered fading galley.
        let rect = ctx.content_rect();
        let margin = 40.0_f32;
        let max_col = 760.0_f32;
        let col_width = (rect.width() - 2.0 * margin).clamp(0.0, max_col);
        let body = if self.darker {
            egui::Color32::from_gray(30)
        } else {
            egui::Color32::from_gray(220)
        };
        let job = build_job(
            self.visible.make_contiguous(),
            now,
            self.visibility_head,
            egui::FontId::proportional(FONT_SIZE),
            body,
            col_width,
        );
        let painter = ui.painter();
        painter.rect_filled(rect, 0.0, egui::Color32::BLACK);
        let galley = painter.layout_job(job);
        let dx = shake_offset(now - self.shake_start);
        let x = rect.left() + (rect.width() - col_width) * 0.5 + dx;
        let x_height = 0.5 * FONT_SIZE; // egui 0.34 has no x-height metric; 0.5em is close enough
        let center_y = rect.center().y;
        let y = center_y + x_height - galley.size().y; // newest line one x-height below center; older lines scroll up
        painter.galley(egui::pos2(x, y), galley, body);

        let words = self.seed_words + word_count(&self.document);
        if words >= 2 {
            let pos = egui::pos2(rect.right() - margin, rect.top() + margin);
            painter.text(
                pos,
                egui::Align2::RIGHT_TOP,
                format!("{words} words"),
                egui::FontId::proportional(0.5 * FONT_SIZE), // smaller than body
                egui::Color32::from_gray(110),               // dim gray so the HUD recedes
            );
        }

        if self.menu_open {
            let words = self.seed_words + word_count(&self.document);
            egui::Window::new("menu")
                .title_bar(false)
                .resizable(false)
                .collapsible(false)
                .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
                .show(&ctx, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.label(egui::RichText::new(format!("{words} words")).size(24.0));
                        ui.add_space(8.0);
                        let win_hint = if self.fullscreen {
                            "W — windowed"
                        } else {
                            "W — fullscreen"
                        };
                        let dark_hint = if self.darker {
                            "D — normal"
                        } else {
                            "D — darker"
                        };
                        ui.label(format!(
                            "Q — quit     Esc — resume     {win_hint}     {dark_hint}"
                        ));
                    });
                });
        }

        if self.visible.is_empty() {
            // Idle: nothing to animate. Wake only to run the periodic fsync.
            ctx.request_repaint_after(SYNC_INTERVAL.saturating_sub(self.last_sync.elapsed()));
        } else {
            // Text still visible/fading: animate.
            ctx.request_repaint();
        }
    }

    fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
        [0.0, 0.0, 0.0, 1.0]
    }

    fn on_exit(&mut self) {
        self.fsync(); // final flush + sync on clean exit
    }
}