547 lines
17 KiB
Rust
547 lines
17 KiB
Rust
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<PathBuf>,
|
|
pub buffer: Vec<BufferItem>,
|
|
|
|
pub file_mtime: Option<DateTime<Local>>,
|
|
pub buffer_mtime: DateTime<Local>,
|
|
|
|
// TODO: instantiate these on load
|
|
#[serde(skip)]
|
|
inner: Option<Inner>,
|
|
|
|
/// 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<FileEvent>,
|
|
file_events_tx: mpsc::Sender<FileEvent>,
|
|
_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<Local>),
|
|
NewBufferMTime(DateTime<Local>),
|
|
}
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize)]
|
|
pub enum BufferItem {
|
|
Text(Box<MdTextEdit>),
|
|
Handwriting(Box<Handwriting>),
|
|
}
|
|
|
|
impl FileEditor {
|
|
pub fn new(title: impl Into<String>) -> 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<Local>) -> 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::<DraggingItem>(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::<DraggingItem, _>(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<BufferItem>, 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<notify::Event>| {
|
|
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!("<handwriting>");
|
|
}
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!();
|
|
|
|
let serialized = file_editor.to_string();
|
|
assert_eq!(
|
|
markdown, serialized,
|
|
"FileEditor should preserve formatting"
|
|
);
|
|
}
|
|
}
|