Files
inkr/src/file_editor.rs

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"
);
}
}