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, PRIMARY, PRIMARY_DARKER, build_job, glyph_factor, shake_offset, shake_ready,
start_byte_of_second_to_last_word, word_count,
};
const SYNC_INTERVAL: Duration = Duration::from_secs(30);
const FONT_SIZE: f32 = 28.0;
type SessionFile = (File, usize, u64);
pub struct WriteApp {
document: String, visible: VecDeque<Glyph>, writer: BufWriter<File>, visibility_head: f64, shake_start: f64, enter_run: u8, boot_size: u64, delete_floor: u64, menu_open: bool,
fullscreen: bool, darker: bool, entered_fullscreen: bool, seed_words: usize, last_sync: Instant,
}
pub fn open_session_file() -> Result<Option<SessionFile>, Box<dyn std::error::Error + Send + Sync>>
{
let path = today_file_path()?;
let file = OpenOptions::new().create(true).append(true).open(&path)?;
match file.try_lock() {
Ok(()) => {
let existing = fs::read_to_string(&path).unwrap_or_default();
let seed = word_count(&existing);
let prefix = if existing.is_empty() || existing.ends_with("\n\n") {
""
} else if existing.ends_with('\n') {
"\n"
} else {
"\n\n"
};
let header = format!(
"{prefix}## {}\n\n",
Local::now().format("%Y-%m-%d %-I:%M:%S%P %Z")
);
let mut file = file;
file.write_all(header.as_bytes())?;
let boot_size = file.metadata()?.len();
Ok(Some((file, seed, boot_size)))
}
Err(TryLockError::WouldBlock) => Ok(None),
Err(TryLockError::Error(e)) => Err(Box::new(e)),
}
}
pub fn today_file_path() -> Result<PathBuf, 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")));
Ok(dir)
}
pub fn run_edit() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let path = today_file_path()?;
drop(OpenOptions::new().create(true).append(true).open(&path)?);
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let mut parts = editor.split_whitespace();
let program = parts.next().ok_or("empty editor command")?;
let status = std::process::Command::new(program)
.args(parts)
.arg(&path)
.status()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
pub fn run_show() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let path = today_file_path()?;
match fs::read(&path) {
Ok(bytes) => {
use std::io::Write as _;
std::io::stdout().write_all(&bytes)?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Box::new(e)),
}
}
impl WriteApp {
pub fn new(
_cc: &eframe::CreationContext<'_>,
file: File,
seed_words: usize,
boot_size: u64,
) -> Self {
Self {
document: String::new(),
visible: VecDeque::new(),
writer: BufWriter::new(file),
visibility_head: 0.0,
shake_start: f64::NEG_INFINITY,
enter_run: 0,
boot_size,
delete_floor: boot_size,
menu_open: false,
fullscreen: false,
darker: false,
entered_fullscreen: false,
seed_words,
last_sync: Instant::now(),
}
}
fn append(&mut self, s: &str, now: f64) {
let mut end = self.document.len();
for ch in s.chars() {
end += ch.len_utf8();
self.visible.push_back(Glyph {
ch,
birth: now,
end_offset: end,
});
}
self.document.push_str(s);
if let Err(e) = self.writer.write_all(s.as_bytes()) {
eprintln!("write: failed to append to file: {e}");
}
let candidate = self.boot_size + start_byte_of_second_to_last_word(&self.document) as u64;
self.delete_floor = self.delete_floor.max(candidate); }
fn start_shake(&mut self, now: f64) {
if shake_ready(now, self.shake_start) {
self.shake_start = now;
}
}
fn backspace(&mut self, now: f64) {
let current_end = self.boot_size + self.document.len() as u64;
if self.document.is_empty() || current_end <= self.delete_floor {
self.start_shake(now); return;
}
let ch = self.document.chars().next_back().unwrap();
if current_end - (ch.len_utf8() as u64) < self.delete_floor {
self.start_shake(now); return;
}
self.document.pop(); if let Err(e) = self.writer.flush() {
eprintln!("write: flush before truncate failed: {e}");
return; }
let new_end = self.boot_size + self.document.len() as u64;
if let Err(e) = self.writer.get_ref().set_len(new_end) {
eprintln!("write: truncate failed: {e}");
}
let doc_len = self.document.len();
while let Some(g) = self.visible.back() {
if g.end_offset > doc_len {
self.visible.pop_back();
} else {
break;
}
}
self.enter_run = 0;
}
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;
self.fullscreen = true;
} else {
ctx.request_repaint();
}
}
let events = ctx.input(|i| i.events.clone());
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; }
egui::Key::D => {
self.darker = !self.darker;
close_menu = true; }
egui::Key::Backslash => {
self.append("\\", now);
self.enter_run = 0; close_menu = true; }
egui::Key::Escape => close_menu = true, _ => {}
}
}
continue; }
match event {
egui::Event::Text(t) => {
self.append(&t, now); self.enter_run = 0;
}
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.delete_floor = self.boot_size + self.document.len() as u64;
self.enter_run = 2;
}
_ => {} }
}
egui::Key::Backspace => {
self.backspace(now);
}
egui::Key::Escape | egui::Key::Backslash => {
self.menu_open = true;
self.fsync(); }
_ => {} }
}
_ => {} }
}
if close_menu {
self.menu_open = false;
}
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 primary = if self.darker { PRIMARY_DARKER } else { PRIMARY };
let floor_byte = (self.delete_floor - self.boot_size) as usize;
let job = build_job(
self.visible.make_contiguous(),
now,
self.visibility_head,
egui::FontId::proportional(FONT_SIZE),
primary,
col_width,
floor_byte,
);
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; let center_y = rect.center().y;
let y = center_y + x_height - galley.size().y;
let cursor_rect = (!self.visible.is_empty()).then(|| {
let tail = galley.pos_from_cursor(galley.end()); let top_left = egui::pos2(x, y) + tail.min.to_vec2();
let h = tail.height().max(FONT_SIZE); egui::Rect::from_min_size(top_left, egui::vec2(0.5 * FONT_SIZE, h))
});
painter.galley(egui::pos2(x, y), galley, primary);
if let Some(cursor_rect) = cursor_rect
&& let Some(g) = self.visible.back()
{
let factor = glyph_factor(g.birth, now, self.visibility_head);
let cursor_color = primary.gamma_multiply(factor);
painter.rect_filled(cursor_rect, 0.0, cursor_color);
}
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), egui::Color32::from_gray(110), );
}
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)
.color(PRIMARY),
);
ui.add_space(8.0);
let win_cmd = if self.fullscreen {
"windowed"
} else {
"fullscreen"
};
let dark_cmd = if self.darker { "normal" } else { "darker" };
let rows = [
("quit", "Q"),
("resume", "Esc"),
(win_cmd, "W"),
(dark_cmd, "D"),
("backslash", "\\"),
];
egui::Grid::new("menu_commands")
.num_columns(2)
.spacing([24.0, 6.0])
.show(ui, |ui| {
for (cmd, key) in rows {
ui.label(egui::RichText::new(cmd).size(17.5).color(PRIMARY));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
egui::RichText::new(key).size(17.5).color(PRIMARY),
);
},
);
ui.end_row();
}
});
});
});
}
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(); }
}