use std::{ cmp::Ordering, fmt::{self, Display}, fs::{self, File}, io::Write, ops::{Div as _, Sub as _}, path::{Path, PathBuf}, str::FromStr, sync::mpsc, }; use chrono::{DateTime, Local}; use egui::{ Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, vec2, }; use eyre::eyre; use notify::{EventKind, Watcher}; use crate::{ app::Jobs, custom_code_block::{MdItem, iter_lines_and_code_blocks}, handwriting::{self, Handwriting, HandwritingStyle}, preferences::Preferences, text_editor::MdTextEdit, util::{file_mtime, log_error}, }; #[derive(serde::Deserialize, serde::Serialize)] pub struct FileEditor { title: String, path: Option, pub buffer: Vec, pub file_mtime: Option>, pub buffer_mtime: DateTime, // TODO: instantiate these on load #[serde(skip)] inner: Option, /// The distance to scroll when paging up or down. /// /// This is calculated when the view is rendered. #[serde(skip)] scroll_delta: f32, /// Whether the file has been edited since it was last saved to disk. is_dirty: bool, } struct Inner { file_events: mpsc::Receiver, file_events_tx: mpsc::Sender, _file_watcher: notify::RecommendedWatcher, } #[derive(Debug)] pub enum SaveStatus { /// The contents of the buffer is the same as on disk. Synced, /// The contents exits only in memory and has never been saved to disk. NoFile, /// The buffer has been edited but not saved to disk. FileOutdated, /// The contents on disk has changes that are newer than the buffer. BufferOutdated, /// The contents on disk and in the buffer has diverged. Desynced, } #[derive(Debug)] enum FileEvent { NewFileMTime(DateTime), NewBufferMTime(DateTime), } #[derive(serde::Deserialize, serde::Serialize)] pub enum BufferItem { Text(Box), Handwriting(Box), } impl FileEditor { pub fn new(title: impl Into) -> Self { let buffer = vec![BufferItem::Text(Box::new(MdTextEdit::new()))]; Self { title: title.into(), path: None, buffer, file_mtime: None, buffer_mtime: Local::now(), scroll_delta: 0.0, is_dirty: false, inner: None, } } pub fn from_file(file_path: PathBuf, contents: &str, mtime: DateTime) -> Self { let file_title = file_path .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| String::from("untitled.md")); Self { title: file_title, path: Some(file_path), file_mtime: Some(mtime), buffer_mtime: mtime, ..FileEditor::from(contents) } } pub fn title(&self) -> &str { &self.title } pub fn path(&self) -> Option<&Path> { self.path.as_deref() } pub fn save_status(&self) -> SaveStatus { let Some(file_mtime) = self.file_mtime else { return SaveStatus::NoFile; }; let buffer_is_newer = self.buffer_mtime > file_mtime; let file_is_newer = self.buffer_mtime < file_mtime; if buffer_is_newer || (file_is_newer && self.is_dirty) { SaveStatus::Desynced } else if file_is_newer { SaveStatus::BufferOutdated } else if self.is_dirty { SaveStatus::FileOutdated } else { SaveStatus::Synced } } pub fn show(&mut self, ui: &mut Ui, preferences: &Preferences) { if let Some(path) = &self.path && self.inner.is_none() { self.inner = Some(spawn_file_watcher(path)); } if let Some(inner) = &mut self.inner { while let Ok(event) = inner.file_events.try_recv() { match event { FileEvent::NewFileMTime(mtime) => { self.file_mtime = Some(mtime); } FileEvent::NewBufferMTime(mtime) => { self.buffer_mtime = mtime; self.file_mtime = Some(mtime); } } } } ui.vertical_centered_justified(|ui| { ui.heading(&self.title); const MAX_NOTE_WIDTH: f32 = 600.0; // distance to scroll when paging up or down. let mut scroll_delta = 0.0; ui.input_mut(|input| { if input.consume_key(egui::Modifiers::NONE, egui::Key::PageUp) { scroll_delta += self.scroll_delta; } if input.consume_key(egui::Modifiers::NONE, egui::Key::PageDown) { scroll_delta -= self.scroll_delta; } }); ui.horizontal(|ui| { ui.label("new"); if ui.button(" text ").clicked() { self.is_dirty = true; self.buffer.push(BufferItem::Text(Default::default())); } if ui.button("writing").clicked() { self.is_dirty = true; self.buffer .push(BufferItem::Handwriting(Default::default())); } ui.add_space(16.0); ui.label("scroll"); if ui.button(" up ").clicked() { scroll_delta += self.scroll_delta; } if ui.button("down").clicked() { scroll_delta -= self.scroll_delta; } }); let scroll_area = ScrollArea::vertical().show(ui, |ui| { if scroll_delta != 0.0 { ui.scroll_with_delta(Vec2::new(0.0, scroll_delta)); } ui.horizontal(|ui| { let side_padding = ui.available_width().sub(MAX_NOTE_WIDTH).max(0.0).div(2.0); ui.add_space(side_padding); ui.vertical(|ui| { ui.set_max_width(MAX_NOTE_WIDTH); self.show_contents(ui, preferences); }); ui.add_space(side_padding); }); }); self.scroll_delta = scroll_area.inner_rect.height() * 0.5; }); } fn show_contents(&mut self, ui: &mut Ui, preferences: &Preferences) { if self.buffer.is_empty() { self.buffer.push(BufferItem::Text(Default::default())); } struct DraggingItem { index: usize, } let mut drop_from_to: Option<(usize, usize)> = None; let is_dragging = DragAndDrop::has_payload_of_type::(ui.ctx()); let drag_zone_height = 10.0; // Iterate over buffer items using `retain` so that we can handle deletions let mut i = 0usize..; let len = self.buffer.len(); self.buffer.retain_mut(|item| { let i = i.next().unwrap(); let is_first = i == 0; let is_last = i == len - 1; let mut retain = true; // Createa horizontal area to draw the buffer item. The three things drawn here are: // - The controls that exist at the left-size of the buffer item, i.e. "up"/"down". // - The buffer item. // - The controls that exist at the right-size of the buffer item, i.e. "delete". ui.horizontal(|ui| { // At this point, we don't know how tall the buffer item will be, so we'll reserve // some horizontal space here and come back to drawing the controls later. let (_id, mut left_controls_rect) = ui.allocate_space(Vec2::new(20.0, 1.0)); // Leave some space at the end for the delete button. let w = ui.available_width(); let item_size = Vec2::new(w - 20.0, 0.0); let item_response = ui.allocate_ui(item_size, |ui| match item { BufferItem::Text(text_edit) => { if text_edit.ui(ui).changed { self.is_dirty = true; } } BufferItem::Handwriting(handwriting) => { let style = HandwritingStyle { animate: preferences.animations, hide_cursor: preferences.hide_handwriting_cursor, ..HandwritingStyle::from_theme(ui.ctx().theme()) }; if handwriting.ui(&style, ui).changed { self.is_dirty = true; } } }); // Delete-button if ui.button("⌫").clicked() { retain = false; ui.ctx().request_repaint(); } left_controls_rect.set_height(item_response.response.rect.height()); // Controls for moving the buffer item ui.scope_builder( UiBuilder::new() .max_rect(left_controls_rect) .layout(Layout::top_down(Align::Center)), |ui| { let up_button_response = ui.add_enabled(!is_first, Button::new("⇡")); if up_button_response.clicked() { drop_from_to = Some((i, i - 1)); } // Add some space so that the next button is drawn // at the bottom of the buffer item. ui.add_space( left_controls_rect.height() - (up_button_response.rect.height() * 2.0 + 4.0), ); if ui.add_enabled(!is_last, Button::new("⇣")).clicked() { drop_from_to = Some((i, i + 2)); } }, ); }); retain }); if is_dragging { let (_, drop) = ui.dnd_drop_zone::(Frame::NONE, |ui| { ui.set_min_size(vec2(ui.available_width(), drag_zone_height)); }); if let Some(drop) = drop { drop_from_to = Some((drop.index, self.buffer.len())); } } else { // the dnd_drop_zone adds 3.0pts work of extra space ui.add_space(drag_zone_height + 3.0); } // Handle drag-and-dropping buffer items // TODO: make sure nothing was removed from self.buffer this frame if let Some((from, to)) = drop_from_to { if from < self.buffer.len() { match from.cmp(&to) { Ordering::Greater => { let item = self.buffer.remove(from); self.buffer.insert(to, item); self.is_dirty = true; } Ordering::Less => { let item = self.buffer.remove(from); self.buffer.insert(to - 1, item); self.is_dirty = true; } Ordering::Equal => {} } } } } pub fn set_path(&mut self, new_path: PathBuf) { let Some(title) = new_path.file_name() else { log::error!("No filename in path {new_path:?}"); return; }; self.title = title.to_string_lossy().to_string(); self.path = Some(new_path); } pub fn set_dirty(&mut self, value: bool) { self.is_dirty = value; } pub fn save(&mut self, ctx: &Context, jobs: &mut Jobs) { let Some(file_path) = self.path.clone() else { log::info!("Can't save {}, no path set.", self.title); return; }; self.is_dirty = false; let text = self.to_string(); let inner = self .inner .get_or_insert_with(|| spawn_file_watcher(&file_path)); let file_event_tx = inner.file_events_tx.clone(); jobs.start(ctx, move || { log_error(eyre!("Failed to save file {file_path:?}"), || { let mut file = fs::File::create(file_path)?; file.write_all(text.as_bytes())?; let mtime = file_mtime(&file)?; let _ = file_event_tx.send(FileEvent::NewBufferMTime(mtime)); Ok(()) }); None }); } } impl Display for BufferItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { BufferItem::Text(md_text_edit) => Display::fmt(md_text_edit, f), BufferItem::Handwriting(handwriting) => Display::fmt(handwriting, f), } } } impl Display for FileEditor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut first = true; for item in &self.buffer { if !first { writeln!(f)?; } first = false; write!(f, "{item}")?; } Ok(()) } } impl From<&str> for FileEditor { fn from(s: &str) -> Self { let mut editor = FileEditor::new("note.md"); let buffer = &mut editor.buffer; let push_text = |buffer: &mut Vec, text| match buffer.last_mut() { Some(BufferItem::Text(text_edit)) => text_edit.text.push_str(text), _ => { let mut text_edit = MdTextEdit::new(); text_edit.text.push_str(text); buffer.push(BufferItem::Text(Box::new(text_edit))); } }; for item in iter_lines_and_code_blocks(s) { match item { MdItem::Line(line) => push_text(buffer, line), MdItem::CodeBlock { key, content, span } => match key { handwriting::CODE_BLOCK_KEY => match Handwriting::from_str(span) { Ok(handwriting) => { if let Some(BufferItem::Text(text_edit)) = buffer.last_mut() { if text_edit.text.ends_with('\n') { text_edit.text.pop(); if text_edit.text.is_empty() { buffer.pop(); } } }; buffer.push(BufferItem::Handwriting(Box::new(handwriting))) } Err(e) => { log::error!("Failed to decode handwriting {content:?}: {e}"); push_text(buffer, span); } }, _ => push_text(buffer, span), }, } } editor } } fn spawn_file_watcher(p: &Path) -> Inner { let (tx, rx) = mpsc::channel(); let path = p.to_owned(); let events_tx = tx.clone(); let mut watcher = notify::recommended_watcher(move |event: notify::Result| { log_error(eyre!("watch {path:?} error"), || { match event?.kind { EventKind::Create(..) | EventKind::Modify(..) | EventKind::Remove(..) => {} EventKind::Access(..) | EventKind::Any | EventKind::Other => return Ok(()), } let file = File::open(&path)?; let mtime = file_mtime(&file)?; let _ = events_tx.send(FileEvent::NewFileMTime(mtime)); Ok(()) }); }) .unwrap(); if let Err(e) = watcher.watch(p, notify::RecursiveMode::NonRecursive) { log::error!("Failed to watch {p:?}: {e}"); }; Inner { file_events: rx, file_events_tx: tx, _file_watcher: watcher, } } #[cfg(test)] mod test { use crate::file_editor::BufferItem; use super::FileEditor; #[test] fn from_str_and_back_1() { let markdown = r#" # Hello world! This is some text. Here's some handwriting: ```handwriting DgB0UUlNeFFJTX9RUE2pUYZNDlIATotSjk4AUwxPaFODT89T608UVBtQL1QqUDtULlBDVDFQSVQuUA== ``` And here's some more text :D ``` with a regular code-block! ```"#; println!("{markdown}"); println!(); println!(); println!("{markdown:?}"); println!(); println!(); let file_editor = FileEditor::from(markdown); for item in &file_editor.buffer { match item { BufferItem::Text(md_text_edit) => { println!("{:?}", md_text_edit.text); } BufferItem::Handwriting(_) => { println!(""); } } } println!(); println!(); let serialized = file_editor.to_string(); assert_eq!( markdown, serialized, "FileEditor should preserve formatting" ); } }