use std::{ cmp::Ordering, fmt::{self, Display}, ops::{Div as _, Sub as _}, path::{Path, PathBuf}, str::FromStr, }; use egui::{ Align, Button, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, Widget as _, vec2, }; use crate::{ custom_code_block::{MdItem, iter_lines_and_code_blocks}, painting::{self, Handwriting, HandwritingStyle}, preferences::Preferences, text_editor::MdTextEdit, }; #[derive(serde::Deserialize, serde::Serialize)] pub struct FileEditor { title: String, pub path: Option, pub buffer: Vec, } #[derive(serde::Deserialize, serde::Serialize)] pub enum BufferItem { Text(MdTextEdit), Handwriting(Handwriting), } impl FileEditor { pub fn new(title: impl Into) -> Self { let buffer = vec![BufferItem::Text(MdTextEdit::new())]; Self { title: title.into(), path: None, buffer, } } pub fn from_file(file_path: PathBuf, contents: &str) -> 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), ..FileEditor::from(contents) } } pub fn title(&self) -> &str { &self.title } pub fn path(&self) -> Option<&Path> { self.path.as_deref() } pub fn show(&mut self, ui: &mut Ui, preferences: &Preferences) { ui.vertical_centered_justified(|ui| { ui.heading(&self.title); const MAX_NOTE_WIDTH: f32 = 600.0; ui.horizontal(|ui| { ui.label("new"); if ui.button("text").clicked() { self.buffer.push(BufferItem::Text(Default::default())); } if ui.button("writing").clicked() { self.buffer .push(BufferItem::Handwriting(Default::default())); } }); ScrollArea::vertical().show(ui, |ui| { 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); }); }); }); } 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; 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, i)); } } else { // the dnd_drop_zone adds 3pts work of extra space ui.add_space(drag_zone_height + 3.0); } ui.horizontal(|ui| { // 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 dragger // later. let (dragger_id, mut dragger_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) => { text_edit.ui(ui); } BufferItem::Handwriting(painting) => { let style = HandwritingStyle { animate: preferences.animations, ..HandwritingStyle::from_theme(ui.ctx().theme()) }; painting.ui(&style, ui); } }); // Delete-button if ui.button("x").clicked() { retain = false; ui.ctx().request_repaint(); } // Draw the dragger using the height from the buffer item dragger_rect.set_height(item_response.response.rect.height()); // Controls for moving the buffer item ui.allocate_new_ui( UiBuilder::new() .max_rect(dragger_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)); } ui.dnd_drag_source(dragger_id, DraggingItem { index: i }, |ui| { Button::new("≡") .min_size( // Use all available height, save for the height taken up by // the up/down buttons + padding. Assume down-button is the // equally tall as the up-button. dragger_rect.size() - Vec2::Y * (up_button_response.rect.height() * 2.0 + 4.0), ) .ui(ui); }); 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); } Ordering::Less => { let item = self.buffer.remove(from); self.buffer.insert(to - 1, item); } 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); } } 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(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 { painting::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(handwriting)) } Err(e) => { log::error!("Failed to decode handwriting {content:?}: {e}"); push_text(buffer, span); } }, _ => push_text(buffer, span), }, } } editor } } #[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" ); } }