Files
inkr/src/file_editor.rs
2025-06-12 20:23:52 +02:00

357 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},
painting::{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>,
}
#[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,
}
}
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::<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) => {
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::<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);
}
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<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 {
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!("<handwriting>");
}
}
}
println!();
println!();
let serialized = file_editor.to_string();
assert_eq!(
markdown, serialized,
"FileEditor should preserve formatting"
);
}
}