369 lines
12 KiB
Rust
369 lines
12 KiB
Rust
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},
|
|
handwriting::{self, Handwriting, HandwritingStyle},
|
|
preferences::Preferences,
|
|
text_editor::MdTextEdit,
|
|
};
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize)]
|
|
pub struct FileEditor {
|
|
title: String,
|
|
pub path: Option<PathBuf>,
|
|
pub buffer: Vec<BufferItem>,
|
|
|
|
/// Whether the file has been edited since it was laste saved to disk.
|
|
pub is_dirty: bool,
|
|
}
|
|
|
|
#[derive(serde::Deserialize, serde::Serialize)]
|
|
pub enum BufferItem {
|
|
Text(MdTextEdit),
|
|
Handwriting(Handwriting),
|
|
}
|
|
|
|
impl FileEditor {
|
|
pub fn new(title: impl Into<String>) -> Self {
|
|
let buffer = vec![BufferItem::Text(MdTextEdit::new())];
|
|
Self {
|
|
title: title.into(),
|
|
path: None,
|
|
buffer,
|
|
is_dirty: false,
|
|
}
|
|
}
|
|
|
|
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.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()));
|
|
}
|
|
});
|
|
|
|
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::<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;
|
|
|
|
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, 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) => {
|
|
if text_edit.ui(ui).changed {
|
|
self.is_dirty = true;
|
|
}
|
|
}
|
|
BufferItem::Handwriting(handwriting) => {
|
|
let style = HandwritingStyle {
|
|
animate: preferences.animations,
|
|
..HandwritingStyle::from_theme(ui.ctx().theme())
|
|
};
|
|
if handwriting.ui(&style, ui).changed {
|
|
self.is_dirty = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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::<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);
|
|
}
|
|
}
|
|
|
|
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(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(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!("<handwriting>");
|
|
}
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!();
|
|
|
|
let serialized = file_editor.to_string();
|
|
assert_eq!(
|
|
markdown, serialized,
|
|
"FileEditor should preserve formatting"
|
|
);
|
|
}
|
|
}
|