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, visible: VecDeque<Glyph>, writer: BufWriter<File>, visibility_head: f64, enter_run: u8, last_was_backspace: bool,
menu_open: bool,
entered_fullscreen: bool, last_sync: Instant,
}
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)?;
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(),
}
}
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();
ctx.input_mut(|i| {
let _ = i.consume_key(egui::Modifiers::NONE, egui::Key::Tab);
});
let now = ctx.input(|i| i.time);
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();
}
}
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, _ => {}
}
}
continue; }
match event {
egui::Event::Text(t) => {
self.append(&t, now); self.enter_run = 0;
self.last_was_backspace = false;
}
egui::Event::Key {
key,
modifiers,
pressed: true,
..
} => {
if modifiers.command {
continue;
}
match key {
egui::Key::Enter => {
match self.enter_run {
0 => {
self.append("\n\n", now);
self.enter_run = 1;
}
1 => {
self.visibility_head = now;
self.enter_run = 2;
}
_ => {} }
self.last_was_backspace = false;
}
egui::Key::Backspace => {
if !self.last_was_backspace {
self.append(" ", now);
self.visibility_head = now; }
self.last_was_backspace = true;
self.enter_run = 0;
}
egui::Key::Escape => {
self.menu_open = true;
self.fsync(); }
_ => {} }
}
_ => {} }
}
if self.last_sync.elapsed() >= SYNC_INTERVAL {
self.fsync();
self.last_sync = Instant::now();
}
while let Some(front) = self.visible.front() {
if glyph_factor(front.birth, now, self.visibility_head) <= 0.0 {
self.visible.pop_front();
} else {
break;
}
}
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; 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() {
ctx.request_repaint_after(SYNC_INTERVAL.saturating_sub(self.last_sync.elapsed()));
} else {
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(); }
}