write 0.1.1

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

const SYNC_INTERVAL: Duration = Duration::from_secs(30);

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)
    enter_run: u8,            // 0 = none, 1 = one Enter written, 2+ = cleared / no-op
    last_was_backspace: bool,
    menu_open: bool,
    entered_fullscreen: bool, // deferred fullscreen, applied once the window has focus
    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>, 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(()) => Ok(Some(file)),
        Err(TryLockError::WouldBlock) => Ok(None),
        Err(TryLockError::Error(e)) => Err(Box::new(e)),
    }
}

impl WriteApp {
    pub fn new(_cc: &eframe::CreationContext<'_>, file: File) -> Self {
        Self {
            document: String::new(),
            visible: VecDeque::new(),
            writer: BufWriter::new(file),
            visibility_head: 0.0,
            enter_run: 0,
            last_was_backspace: false,
            menu_open: false,
            entered_fullscreen: false,
            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;
            } else {
                ctx.request_repaint();
            }
        }

        // Copy events out, keep the input closure short.
        let events = ctx.input(|i| i.events.clone());
        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::Escape => self.menu_open = false, // 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.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
            }
        }

        // 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 bottom-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 job = build_job(
            self.visible.make_contiguous(),
            now,
            self.visibility_head,
            egui::FontId::proportional(28.0),
            egui::Color32::from_gray(220),
            col_width,
        );
        let painter = ui.painter();
        painter.rect_filled(rect, 0.0, egui::Color32::BLACK);
        let galley = painter.layout_job(job);
        let x = rect.left() + (rect.width() - col_width) * 0.5;
        let y = rect.bottom() - margin - galley.size().y; // bottom-anchored; old text scrolls off top
        painter.galley(egui::pos2(x, y), galley, egui::Color32::from_gray(220));

        if self.menu_open {
            let words = self.document.split_whitespace().count();
            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);
                        ui.label("Q — quit     Esc — resume");
                    });
                });
        }

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