41 Commits

Author SHA1 Message Date
c63babb599 Make pencil the default tool 2025-11-07 16:43:08 +01:00
fac896161e Remove dead comments 2025-10-25 17:02:41 +02:00
945bb4d9fe Add basic eraser 2025-10-25 17:02:27 +02:00
47b7feeab8 Improve visuals slightly 2025-10-04 08:55:50 +02:00
b1eb5f91be handwriting: Add test for egui::Event handling 2025-09-28 10:06:10 +02:00
3669f54936 Cargo update 2025-09-27 10:31:16 +02:00
2fb9908329 Fix markdown monospace in headings 2025-08-03 10:08:03 +02:00
0acab0413c Remove drag-n-drop thingy 2025-07-24 01:02:17 +02:00
8f16741705 Lower paging distance 2025-07-22 14:16:28 +02:00
a663de3ca0 Respect animations preference when paging up/down 2025-07-22 14:10:25 +02:00
61575fbf65 Fix broken fonts in light theme 2025-07-22 14:05:35 +02:00
579aace306 Add buttons to scroll file editor 2025-07-22 12:58:53 +02:00
fe0b9d049e Fix clippy 2025-07-11 15:24:35 +02:00
c59febd924 Update egui to 0.32 2025-07-11 15:21:13 +02:00
276508713f Different header sizes 2025-07-10 19:50:38 +02:00
3a2f058456 Indicate when file has changed on disk 2025-07-09 13:29:22 +02:00
38d26f0028 Replace deprecated egui function 2025-07-07 13:12:30 +02:00
462c27e111 Split markdown parsing and highlighting 2025-07-07 13:10:45 +02:00
e0fd726f02 Merge branch 'markdown-parser' 2025-07-07 11:44:22 +02:00
7f93084e64 Restructure markdown highlighter 2025-07-07 11:40:15 +02:00
6e59cb86dc Tweak text colors 2025-07-06 19:57:14 +02:00
98a4f50031 Lower handwriting stroke width 2025-07-06 19:35:11 +02:00
0a19462b0f Add pref to hide cursor when handwriting 2025-06-29 18:55:20 +02:00
cfed4fd5ed Fix performance on large canvases 2025-06-23 22:18:57 +02:00
eaf0c3cb55 handwritin: Grow and shrink the canvas without refreshing 2025-06-23 21:50:37 +02:00
61669e15bd Split CanvasRasterizer image into many small tiles 2025-06-23 21:35:01 +02:00
f2556f7125 Split Image handling from handwriting/mod.rs 2025-06-23 20:34:02 +02:00
7494dc6b75 Add snapshots 2025-06-21 17:21:59 +02:00
43afb9dfd3 Re-use tessellator between frames 2025-06-21 17:20:00 +02:00
6b5bbfbc54 Make folder-list collapsible 2025-06-21 16:28:12 +02:00
b39419888b Add folder tree 2025-06-19 23:09:41 +02:00
4e9eacc7b0 Improve handwriting disk-format and decoding 2025-06-18 22:56:33 +02:00
8251937be9 Open tabs when opened 2025-06-18 22:55:09 +02:00
5ca9dfabb8 Rename painting module to handwriting 2025-06-15 12:53:23 +02:00
1df81509df Add some comments to the handwriting code 2025-06-15 12:52:13 +02:00
7d234641cb Add Ctrl+S and indicate when files are dirty 2025-06-15 12:39:52 +02:00
1ed278cc55 Add sketchy PKGBUILD 2025-06-14 23:09:03 +02:00
2a830f0539 Add janky spinner 2025-06-13 23:06:45 +02:00
83ad2068e0 Make it run in wasm 2025-06-12 20:38:51 +02:00
3908e6d913 Add fonts 2025-06-12 20:38:51 +02:00
27728fc431 Add App 2025-06-12 20:38:50 +02:00
58 changed files with 4830 additions and 2270 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

1974
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "inkr" name = "inkr"
version = "0.1.0" version = "1.0.0"
authors = [] authors = []
edition = "2024" edition = "2024"
@ -12,36 +12,44 @@ targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
pinenote = [] pinenote = []
[dependencies] [dependencies]
egui = "0.31" egui = "0.32"
eframe = { version = "0.31", default-features = false, features = [ egui_extras = { version = "0.32", features = ["svg"] }
egui_glow = "0.32"
eframe = { version = "0.32", default-features = false, features = [
"glow", # alt: "wgpu". "glow", # alt: "wgpu".
"persistence", "persistence",
"wayland", "wayland",
] } ] }
log = "0.4.27" log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
egui_glow = "0.31.1"
rfd = { version = "0.15.3", default-features = false, features = ["gtk3"] } rfd = { version = "0.15.3", default-features = false, features = ["gtk3"] }
rand = "0.9.1" rand = "0.9.1"
eyre = "0.6.12" eyre = "0.6.12"
half = "2.6.0" half = "2.6.0"
zerocopy = { version = "0.8.25", features = ["derive", "std"] } zerocopy = { version = "0.8.25", features = ["derive", "std"] }
base64 = "0.22.1" base64 = "0.22.1"
chrono = { version = "0.4.41", features = ["serde"] }
notify = "8.1.0"
arboard = "3.6.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.8" env_logger = "0.11.8"
rand = "0.9.1"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.3", features = ["wasm_js"] }
wasm-bindgen-futures = "0.4.50" wasm-bindgen-futures = "0.4.50"
web-sys = "0.3.77"
[patch.crates-io] [patch.crates-io]
egui = { git = "https://github.com/emilk/egui", rev = "f2ce6424f3a32f47308fb9871d540c01377b2cd9" } # egui = { path = "../egui/crates/egui" }
eframe = { git = "https://github.com/emilk/egui", rev = "f2ce6424f3a32f47308fb9871d540c01377b2cd9" } # eframe = { path = "../egui/crates/eframe" }
[dev-dependencies] [dev-dependencies]
insta = { version = "1.43.1", features = ["yaml"] } insta = { version = "1.43.1", features = ["yaml"] }
# egui = { path = "../egui/crates/egui" }
# eframe = { path = "../egui/crates/eframe" } [lints.clippy]
unnecessary_lazy_evaluations = "allow"
[profile.release] [profile.release]
opt-level = 2 # fast and small wasm opt-level = 2 # fast and small wasm

27
PKGBUILD Normal file
View File

@ -0,0 +1,27 @@
pkgname=inkr
pkgver=1.0.0
pkgrel=1
pkgdesc="A note-taking and handwriting tool"
arch=('x86_64' 'aarch64')
url="https://git.nubo.sh/hulthe/inkr"
#license=('GPL')
groups=('base-devel')
depends=('glibc')
makedepends=('cargo')
#optdepends=('ed: for "patch -e" functionality')
#source=(" ftp://ftp.gnu.org/gnu/$pkgname/$pkgname-$pkgver.tar.xz"{,.sig})
#sha256sums=('SKIP')
prepare() {
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
export RUSTUP_TOOLCHAIN=stable
cargo build --frozen --release
}
package() {
cd ..
install -Dm0755 -t "$pkgdir/usr/bin/" "${CARGO_TARGET_DIR:-target}/release/$pkgname"
install -Dm0755 -t "$pkgdir/usr/share/applications/" "assets/$pkgname.desktop"
install -Dm0755 "assets/icon.svg" "$pkgdir/usr/share/pixmaps/$pkgname.svg"
}

73
assets/collapse-icon.svg Normal file
View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256"
height="256"
viewBox="0 0 67.733332 67.733332"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="collapse-icon.svg"
inkscape:export-filename="collapse-icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="2.0010679"
inkscape:cx="97.198102"
inkscape:cy="140.42502"
inkscape:window-width="1472"
inkscape:window-height="815"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Lager 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;fill-opacity:0;stroke:#ffffff;stroke-width:4.92907;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="rect1"
width="62.804268"
height="52.220932"
x="2.4645352"
y="7.7562032"
ry="6.9627905" />
<path
style="fill:#000000;fill-opacity:0;stroke:#ffffff;stroke-width:4.92628;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 28.921473,6.35 V 61.383333"
id="path1" />
<path
style="fill:#000000;fill-opacity:0;stroke:#ffffff;stroke-width:3.12398;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 22.250504,15.411171 9.4994923,15.320325"
id="path1-7" />
<path
style="fill:#000000;fill-opacity:0;stroke:#ffffff;stroke-width:3.12398;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 22.250506,21.743139 9.4994938,21.652293"
id="path1-7-4" />
<path
style="fill:#000000;fill-opacity:0;stroke:#ffffff;stroke-width:3.12398;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 22.250506,28.075107 9.4994938,27.984261"
id="path1-7-5" />
<path
style="fill:#000000;fill-opacity:0;stroke:#ffffff;stroke-width:3.12398;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 22.250508,34.407075 9.4994958,34.316229"
id="path1-7-4-4" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

9
assets/inkr.desktop Executable file
View File

@ -0,0 +1,9 @@
[Desktop Entry]
Name=inkr
Exec=inkr
Terminal=false
Type=Application
Icon=inkr
StartupWMClass=inkr
MimeType=x-scheme-handler/inkr;
Categories=Office;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,14 +1,24 @@
use std::{ use std::{
fs, fs,
io::Read,
path::PathBuf, path::PathBuf,
sync::{Arc, mpsc}, sync::{Arc, mpsc},
thread, thread::JoinHandle,
time::{Duration, Instant},
}; };
use crate::{file_editor::FileEditor, preferences::Preferences, util::GuiSender}; use crate::{
use egui::{ file_editor::{FileEditor, SaveStatus},
Align, Button, Color32, FontData, FontDefinitions, PointerButton, RichText, ScrollArea, Stroke, folder::Folder,
preferences::Preferences,
text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO},
util::{GuiSender, file_mtime, log_error},
}; };
use egui::{
Align, Button, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Image, Key,
Modifiers, PointerButton, RichText, ScrollArea, Theme, Widget, include_image,
};
use eyre::eyre;
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] #[serde(default)]
@ -19,13 +29,46 @@ pub struct App {
actions_tx: mpsc::Sender<Action>, actions_tx: mpsc::Sender<Action>,
#[serde(skip)] #[serde(skip)]
actions_rx: mpsc::Receiver<Action>, actions_rx: mpsc::Receiver<Action>,
#[serde(skip)]
jobs: Jobs,
tabs: Vec<(TabId, Tab)>, tabs: Vec<(TabId, Tab)>,
show_folders: bool,
folders: Vec<Folder>,
open_tab_index: Option<usize>, open_tab_index: Option<usize>,
next_tab_id: TabId, next_tab_id: TabId,
} }
pub struct Jobs {
handles: Vec<JoinHandle<()>>,
actions_tx: mpsc::Sender<Action>,
}
impl Jobs {
pub fn start(&mut self, ctx: &Context, job: impl FnOnce() -> Option<Action> + Send + 'static) {
let ctx = ctx.clone();
let actions_tx = self.actions_tx.clone();
self.handles.push(std::thread::spawn(move || {
// start rendering the spinner thingy
ctx.request_repaint();
let start = Instant::now();
if let Some(action) = job() {
let _ = actions_tx.send(action);
ctx.request_repaint();
};
// Make sure that task takes at least 250ms to run, so that the spinner won't blink
let sleep_for = Duration::from_millis(250).saturating_sub(start.elapsed());
std::thread::sleep(sleep_for);
}));
}
}
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
enum Tab { enum Tab {
File(FileEditor), File(FileEditor),
@ -37,12 +80,31 @@ impl Tab {
Tab::File(file_editor) => file_editor.title(), Tab::File(file_editor) => file_editor.title(),
} }
} }
pub fn notice_symbol(&self) -> Option<&'static str> {
match self {
Tab::File(file_editor) => match file_editor.save_status() {
SaveStatus::Synced => None,
SaveStatus::NoFile => Some("?"),
SaveStatus::FileOutdated => Some("*"),
SaveStatus::BufferOutdated => Some("!"),
SaveStatus::Desynced => Some("!!"),
},
}
}
pub fn save(&mut self, ctx: &Context, jobs: &mut Jobs) {
match self {
Tab::File(file_editor) => file_editor.save(ctx, jobs),
}
}
} }
pub type TabId = usize; pub type TabId = usize;
pub enum Action { pub enum Action {
OpenFile(FileEditor), OpenFile(FileEditor),
OpenFolder(Folder),
MoveFile(TabId, PathBuf), MoveFile(TabId, PathBuf),
CloseTab(TabId), CloseTab(TabId),
// TODO // TODO
@ -56,11 +118,17 @@ impl Default for App {
let (actions_tx, actions_rx) = mpsc::channel(); let (actions_tx, actions_rx) = mpsc::channel();
Self { Self {
preferences: Preferences::default(), preferences: Preferences::default(),
actions_tx, actions_tx: actions_tx.clone(/* this is silly, i know */),
actions_rx, actions_rx,
jobs: Jobs {
handles: Default::default(),
actions_tx,
},
tabs: vec![(1, Tab::File(FileEditor::new("note.md")))], tabs: vec![(1, Tab::File(FileEditor::new("note.md")))],
open_tab_index: None, open_tab_index: None,
next_tab_id: 2, next_tab_id: 2,
show_folders: false,
folders: vec![],
} }
} }
} }
@ -123,22 +191,41 @@ impl App {
.map(|(name, data)| (name.to_string(), Arc::new(FontData::from_static(data)))) .map(|(name, data)| (name.to_string(), Arc::new(FontData::from_static(data))))
.collect(); .collect();
fonts.families.insert( fonts
egui::FontFamily::Proportional, .families
vec!["IosevkaAile-Regular".into()], .insert(FontFamily::Proportional, vec!["IosevkaAile-Regular".into()]);
);
fonts fonts
.families .families
.insert(egui::FontFamily::Monospace, vec!["Iosevka-Thin".into()]); .insert(FontFamily::Monospace, vec!["Iosevka-Thin".into()]);
cc.egui_ctx.set_fonts(fonts); cc.egui_ctx.set_fonts(fonts);
cc.egui_ctx.style_mut(|style| { // markdown font styles
// TODO: change color of text in TextEdit for theme in [Theme::Dark, Theme::Light] {
style.visuals.widgets.noninteractive.fg_stroke = cc.egui_ctx.style_mut_of(theme, |style| {
Stroke::new(1.0, Color32::from_rgb(200, 200, 200)); for (name, size, family) in [
}); (H1, 28.0, FontFamily::Proportional),
(H2, 26.0, FontFamily::Proportional),
(H3, 24.0, FontFamily::Proportional),
(H4, 22.0, FontFamily::Proportional),
(H5, 20.0, FontFamily::Proportional),
(H6, 18.0, FontFamily::Proportional),
(H1_MONO, 28.0, FontFamily::Monospace),
(H2_MONO, 26.0, FontFamily::Monospace),
(H3_MONO, 24.0, FontFamily::Monospace),
(H4_MONO, 22.0, FontFamily::Monospace),
(H5_MONO, 20.0, FontFamily::Monospace),
(H6_MONO, 18.0, FontFamily::Monospace),
] {
let name = egui::TextStyle::Name(name.into());
style.text_styles.insert(name, FontId { size, family });
}
});
}
// enable features on egui_extras to add more image types
egui_extras::install_image_loaders(&cc.egui_ctx);
if let Some(storage) = cc.storage { if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
@ -147,12 +234,24 @@ impl App {
Default::default() Default::default()
} }
fn actions_tx(&self, ctx: &egui::Context) -> GuiSender<Action> { fn actions_tx(&self, ctx: &Context) -> GuiSender<Action> {
GuiSender::new(self.actions_tx.clone(), ctx) GuiSender::new(self.actions_tx.clone(), ctx)
} }
fn handle_action(&mut self, action: Action) { fn handle_action(&mut self, action: Action) {
match action { match action {
Action::OpenFolder(new_folder) => {
if let Some(folder) = self
.folders
.iter_mut()
.find(|folder| folder.path() == new_folder.path())
{
*folder = new_folder;
} else {
self.folders.push(new_folder);
self.folders.sort_by(|a, b| a.name().cmp(b.name()));
}
}
Action::OpenFile(file_editor) => { Action::OpenFile(file_editor) => {
self.open_tab(Tab::File(file_editor)); self.open_tab(Tab::File(file_editor));
} }
@ -176,9 +275,11 @@ impl eframe::App for App {
eframe::set_value(storage, eframe::APP_KEY, self); eframe::set_value(storage, eframe::APP_KEY, self);
} }
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
self.preferences.apply(ctx); self.preferences.apply(ctx);
self.jobs.handles.retain(|job| !job.is_finished());
while let Ok(action) = self.actions_rx.try_recv() { while let Ok(action) = self.actions_rx.try_recv() {
self.handle_action(action); self.handle_action(action);
} }
@ -187,14 +288,14 @@ impl eframe::App for App {
self.open_tab_index = Some(self.tabs.len().saturating_sub(1)); self.open_tab_index = Some(self.tabs.len().saturating_sub(1));
} }
//ctx.input_mut(|input| { ctx.input_mut(|input| {
// if input.consume_key(Modifiers::CTRL, Key::H) { if input.consume_key(Modifiers::CTRL, Key::S) {
// self.buffer.push(BufferItem::Painting(Default::default())); self.save_active_tab(ctx);
// } }
//}); });
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
egui::containers::menu::Bar::new().ui(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| {
// NOTE: no File->Quit on web pages! // NOTE: no File->Quit on web pages!
ui.menu_button("Menu ⚙", |ui| { ui.menu_button("Menu ⚙", |ui| {
ui.label(RichText::new("Action").weak()); ui.label(RichText::new("Action").weak());
@ -204,28 +305,34 @@ impl eframe::App for App {
self.open_tab(Tab::File(file)); self.open_tab(Tab::File(file));
} }
#[cfg(not(target_arch = "wasm32"))]
if ui.button("Open File").clicked() { if ui.button("Open File").clicked() {
let actions_tx = self.actions_tx(ui.ctx()); self.jobs.start(ui.ctx(), move || {
thread::spawn(move || { let file_path = rfd::FileDialog::new().pick_file()?;
let file = rfd::FileDialog::new().pick_file();
let Some(file_path) = file else { return }; let mut file = fs::File::open(&file_path)
.inspect_err(|e| log::error!("Failed to open {file_path:?}: {e}"))
.ok()?;
let text = match fs::read_to_string(&file_path) { let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?;
Ok(text) => text,
Err(e) => {
log::error!("Failed to read {file_path:?}: {e}");
return;
}
};
let editor = FileEditor::from_file(file_path, &text); let mut text = String::new();
let _ = actions_tx.send(Action::OpenFile(editor)); file.read_to_string(&mut text)
.inspect_err(|e| log::error!("Failed to read {file_path:?}: {e}"))
.ok()?;
let editor = FileEditor::from_file(file_path, &text, mtime);
Some(Action::OpenFile(editor))
}); });
} }
if ui.button("Open Folder").clicked() { if ui.button("Open Folder").clicked() {
log::error!("Open Folder not implemented"); self.jobs.start(ui.ctx(), move || {
let path = rfd::FileDialog::new().pick_folder()?;
let name = path.file_name()?.to_string_lossy().to_string();
let folder = Folder::NotLoaded { name, path };
Some(Action::OpenFolder(folder))
});
} }
if ui if ui
@ -237,51 +344,41 @@ impl eframe::App for App {
} }
} }
let open_file = let can_save_file = self
self.open_tab_index .open_tab_index
.and_then(|i| self.tabs.get(i)) .and_then(|i| self.tabs.get(i))
.and_then(|(id, tab)| match tab { .map(|(id, tab)| match tab {
Tab::File(file_editor) => Some((*id, file_editor)), Tab::File(file_editor) => (*id, file_editor),
}); })
.and_then(|(_, file_editor)| file_editor.path().zip(Some(file_editor)))
.is_some();
let open_file_with_path = open_file if ui.add_enabled(can_save_file, Button::new("Save")).clicked() {
.clone() self.save_active_tab(ui.ctx());
.and_then(|(_, file_editor)| file_editor.path().zip(Some(file_editor)));
if ui
.add_enabled(open_file_with_path.is_some(), Button::new("Save"))
.clicked()
{
if let Some((file_path, file_editor)) = open_file_with_path {
let text = file_editor.to_string();
let file_path = file_path.to_owned();
std::thread::spawn(move || {
if let Err(e) = fs::write(file_path, text.as_bytes()) {
log::error!("{e}");
};
});
}
} }
let open_file = self.open_tab_index.and_then(|i| self.tabs.get(i)).map(
|(id, tab)| match tab {
Tab::File(file_editor) => (*id, file_editor),
},
);
#[cfg(not(target_arch = "wasm32"))]
if ui if ui
.add_enabled(open_file.is_some(), Button::new("Save As")) .add_enabled(open_file.is_some(), Button::new("Save As"))
.clicked() .clicked()
{ {
let actions_tx = self.actions_tx(ui.ctx());
let (tab_id, editor) = let (tab_id, editor) =
open_file.expect("We checked that open_file is_some"); open_file.expect("We checked that open_file is_some");
let text = editor.to_string(); let text = editor.to_string();
std::thread::spawn(move || { self.jobs.start(ui.ctx(), move || {
let Some(file_path) = rfd::FileDialog::new().save_file() else { let file_path = rfd::FileDialog::new().save_file()?;
return;
};
if let Err(e) = fs::write(&file_path, text.as_bytes()) { fs::write(&file_path, text.as_bytes())
log::error!("{e}"); .inspect_err(|e| log::error!("{e}"))
return; .ok()?;
};
let _ = actions_tx.send(Action::MoveFile(tab_id, file_path)); Some(Action::MoveFile(tab_id, file_path))
}); });
} }
@ -296,7 +393,18 @@ impl eframe::App for App {
} }
}); });
ui.add_space(16.0); ui.add_space(8.0);
let image = Image::new(include_image!("../assets/collapse-icon.svg"));
let image = image.tint(ui.style().visuals.text_color());
if Button::image(image).ui(ui).clicked() {
self.show_folders = !self.show_folders;
}
if !self.jobs.handles.is_empty() {
ui.add_space(8.0);
ui.spinner();
}
ui.add_space(16.0); ui.add_space(16.0);
@ -305,9 +413,8 @@ impl eframe::App for App {
let selected = self.open_tab_index == Some(i); let selected = self.open_tab_index == Some(i);
let mut button = Button::new(tab.title()).selected(selected); let mut button = Button::new(tab.title()).selected(selected);
let dirty = i == 0; // TODO: mark as dirty when contents hasn't been saved if let Some(symbol) = tab.notice_symbol() {
if dirty { button = button.right_text(RichText::new(symbol).strong())
button = button.right_text(RichText::new("*").strong())
} }
let response = ui.add(button); let response = ui.add(button);
@ -322,19 +429,66 @@ impl eframe::App for App {
}); });
}); });
egui::CentralPanel::default().show(ctx, |ui| { egui::SidePanel::left("file browser")
if let Some(Tab::File(file_editor)) = self .resizable(true)
.open_tab_index .show_animated(ctx, self.show_folders, |ui| {
.and_then(|i| self.tabs.get_mut(i)) if ui.button("refresh").clicked() {
.map(|(_tab_id, tab)| tab) for folder in &mut self.folders {
{ folder.unload();
file_editor.show(ui, &self.preferences); }
} }
ui.with_layout(egui::Layout::bottom_up(Align::LEFT), |ui| { ScrollArea::both().auto_shrink(false).show(ui, |ui| {
egui::warn_if_debug_build(ui); self.folders.retain_mut(|folder| {
let response = folder.show(ui);
if let Some(file_path) = response.open_file {
let file_path = file_path.to_owned();
self.jobs.start(ui.ctx(), move || {
let mut file = fs::File::open(&file_path)
.inspect_err(|e| {
log::error!("Failed to open {file_path:?}: {e}")
})
.ok()?;
let mtime = log_error(eyre!("file_path:?"), || file_mtime(&file))?;
let mut text = String::new();
file.read_to_string(&mut text)
.inspect_err(|e| {
log::error!("Failed to read {file_path:?}: {e}")
})
.ok()?;
let editor = FileEditor::from_file(file_path, &text, mtime);
Some(Action::OpenFile(editor))
});
}
// delete on right-click
!response.clicked_by(PointerButton::Secondary)
});
});
});
egui::CentralPanel::default()
.frame(Frame {
fill: ctx.style().visuals.window_fill(),
..Frame::central_panel(&ctx.style())
})
.show(ctx, |ui| {
if let Some(Tab::File(file_editor)) = self
.open_tab_index
.and_then(|i| self.tabs.get_mut(i))
.map(|(_tab_id, tab)| tab)
{
file_editor.show(ui, &self.preferences);
}
ui.with_layout(egui::Layout::bottom_up(Align::LEFT), |ui| {
egui::warn_if_debug_build(ui);
});
}); });
});
} }
} }
@ -353,5 +507,17 @@ impl App {
let id = self.next_tab_id; let id = self.next_tab_id;
self.next_tab_id += 1; self.next_tab_id += 1;
self.tabs.insert(i, (id, tab)); self.tabs.insert(i, (id, tab));
self.open_tab_index = Some(i);
}
fn save_active_tab(&mut self, ctx: &Context) {
let open_tab = self
.open_tab_index
.and_then(|i| self.tabs.get_mut(i))
.map(|(_id, tab)| tab);
if let Some(open_tab) = open_tab {
open_tab.save(ctx, &mut self.jobs);
}
} }
} }

View File

@ -1,243 +0,0 @@
use egui::text::{CCursorRange, LayoutJob};
use crate::easy_mark::easy_mark_parser;
/// Highlight easymark, memoizing previous output to save CPU.
///
/// In practice, the highlighter is fast enough not to need any caching.
#[derive(Default)]
pub struct MemoizedHighlighter {
style: egui::Style,
code: String,
output: LayoutJob,
}
impl MemoizedHighlighter {
pub fn highlight(
&mut self,
egui_style: &egui::Style,
code: &str,
cursor: Option<CCursorRange>,
) -> LayoutJob {
if (&self.style, self.code.as_str()) != (egui_style, code) {
self.style = egui_style.clone();
code.clone_into(&mut self.code);
self.output = highlight_easymark(egui_style, code, cursor);
}
self.output.clone()
}
}
pub fn highlight_easymark(
egui_style: &egui::Style,
mut text: &str,
// TODO: hide special characters where cursor isn't
_cursor: Option<CCursorRange>,
) -> LayoutJob {
let mut job = LayoutJob::default();
let mut style = easy_mark_parser::Style::default();
let mut start_of_line = true;
const CODE_INDENT: f32 = 10.0;
while !text.is_empty() {
if start_of_line && text.starts_with("```") {
let astyle = format_from_style(
egui_style,
&easy_mark_parser::Style {
code: true,
..Default::default()
},
);
// Render the initial backticks as spaces
text = &text[3..];
job.append(" ", CODE_INDENT, astyle.clone());
match text.find("\n```") {
Some(n) => {
for line in text[..n + 1].lines() {
job.append(line, CODE_INDENT, astyle.clone());
job.append("\n", 0.0, astyle.clone());
}
// Render the final backticks as spaces
job.append(" ", CODE_INDENT, astyle);
text = &text[n + 4..];
}
None => {
job.append(text, 0.0, astyle.clone());
text = "";
}
};
style = Default::default();
continue;
}
if text.starts_with('`') {
style.code = true;
let end = text[1..]
.find(&['`', '\n'][..])
.map_or_else(|| text.len(), |i| i + 2);
job.append(&text[..end], 0.0, format_from_style(egui_style, &style));
text = &text[end..];
style.code = false;
continue;
}
let skip;
// zero-width space
let _zws = "\u{200b}";
let mut apply_basic_style =
|text: &mut &str,
style: &mut easy_mark_parser::Style,
access: fn(&mut easy_mark_parser::Style) -> &mut bool| {
let skip = if *access(style) {
// Include the character that is ending this style:
job.append(&text[..1], 0.0, format_from_style(egui_style, style));
*text = &text[1..];
0
} else {
1
};
*access(style) ^= true;
skip
};
if text.starts_with('*') {
skip = apply_basic_style(&mut text, &mut style, |style| &mut style.strong);
} else if text.starts_with('/') {
skip = apply_basic_style(&mut text, &mut style, |style| &mut style.italics);
} else if text.starts_with('_') {
skip = apply_basic_style(&mut text, &mut style, |style| &mut style.underline);
} else if text.starts_with('$') {
skip = apply_basic_style(&mut text, &mut style, |style| &mut style.small);
} else if text.starts_with('~') {
skip = apply_basic_style(&mut text, &mut style, |style| &mut style.strikethrough);
} else if text.starts_with('^') {
skip = apply_basic_style(&mut text, &mut style, |style| &mut style.raised);
} else if text.starts_with('\\') && text.len() >= 2 {
skip = 2;
} else if start_of_line && text.starts_with(' ') {
// we don't preview indentation, because it is confusing
skip = 1;
} else if start_of_line && text.starts_with("###### ") {
style.heading = true;
skip = 7;
} else if start_of_line && text.starts_with("##### ") {
style.heading = true;
skip = 6;
} else if start_of_line && text.starts_with("#### ") {
style.heading = true;
skip = 5;
} else if start_of_line && text.starts_with("### ") {
style.heading = true;
skip = 4;
} else if start_of_line && text.starts_with("## ") {
style.heading = true;
skip = 3;
} else if start_of_line && text.starts_with("# ") {
style.heading = true;
skip = 2;
} else if start_of_line && text.starts_with("> ") {
style.quoted = true;
skip = 2;
// we don't preview indentation, because it is confusing
} else if start_of_line && text.trim_start().starts_with("- ") {
job.append("", 0.0, format_from_style(egui_style, &style));
text = &text[2..];
skip = 0;
// we don't preview indentation, because it is confusing
} else {
skip = 0;
}
// Note: we don't preview underline, strikethrough and italics because it confuses things.
// Swallow everything up to the next special character:
let line_end = text[skip..]
.find('\n')
.map_or_else(|| text.len(), |i| (skip + i + 1));
let end = text[skip..]
.find(&['*', '`', '~', '_', '/', '$', '^', '\\', '<', '['][..])
.map_or_else(|| text.len(), |i| (skip + i).max(1));
if line_end <= end {
job.append(
&text[..line_end],
0.0,
format_from_style(egui_style, &style),
);
text = &text[line_end..];
start_of_line = true;
style = Default::default();
} else {
job.append(&text[..end], 0.0, format_from_style(egui_style, &style));
text = &text[end..];
start_of_line = false;
}
}
job
}
fn format_from_style(
egui_style: &egui::Style,
emark_style: &easy_mark_parser::Style,
) -> egui::text::TextFormat {
use egui::{Align, Color32, Stroke, TextStyle};
let color = if emark_style.strong || emark_style.heading {
egui_style.visuals.strong_text_color()
} else if emark_style.quoted {
egui_style.visuals.weak_text_color()
} else {
egui_style.visuals.text_color()
};
let text_style = if emark_style.heading {
TextStyle::Heading
} else if emark_style.code {
TextStyle::Monospace
} else if emark_style.small | emark_style.raised {
TextStyle::Small
} else {
TextStyle::Body
};
let background = if emark_style.code {
egui_style.visuals.code_bg_color
} else {
Color32::TRANSPARENT
};
let underline = if emark_style.underline {
Stroke::new(1.0, color)
} else {
Stroke::NONE
};
let strikethrough = if emark_style.strikethrough {
Stroke::new(1.0, color)
} else {
Stroke::NONE
};
let valign = if emark_style.raised {
Align::TOP
} else {
Align::BOTTOM
};
egui::text::TextFormat {
font_id: text_style.resolve(egui_style),
color,
background,
italics: emark_style.italics,
underline,
strikethrough,
valign,
..Default::default()
}
}

View File

@ -1,346 +0,0 @@
//! A parser for `EasyMark`: a very simple markup language.
//!
//! WARNING: `EasyMark` is subject to change.
//
//! # `EasyMark` design goals:
//! 1. easy to parse
//! 2. easy to learn
//! 3. similar to markdown
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Item<'a> {
/// `\n`
// TODO(emilk): add Style here so empty heading still uses up the right amount of space.
Newline,
/// Text
Text(Style, &'a str),
/// title, url
Hyperlink(Style, &'a str, &'a str),
/// leading space before e.g. a [`Self::BulletPoint`].
Indentation(usize),
/// >
QuoteIndent,
/// - a point well made.
BulletPoint,
/// 1. numbered list. The string is the number(s).
NumberedPoint(&'a str),
/// ---
Separator,
/// language, code
CodeBlock(&'a str, &'a str),
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct Style {
/// # heading (large text)
pub heading: bool,
/// > quoted (slightly dimmer color or other font style)
pub quoted: bool,
/// `code` (monospace, some other color)
pub code: bool,
/// self.strong* (emphasized, e.g. bold)
pub strong: bool,
/// _underline_
pub underline: bool,
/// ~strikethrough~
pub strikethrough: bool,
/// /italics/
pub italics: bool,
/// $small$
pub small: bool,
/// ^raised^
pub raised: bool,
}
/// Parser for the `EasyMark` markup language.
pub struct Parser<'a> {
/// The remainder of the input text
s: &'a str,
/// Are we at the start of a line?
start_of_line: bool,
/// Current self.style. Reset after a newline.
style: Style,
}
impl<'a> Parser<'a> {
pub fn new(s: &'a str) -> Self {
Self {
s,
start_of_line: true,
style: Style::default(),
}
}
/// `1. `, `42. ` etc.
fn numbered_list(&mut self) -> Option<Item<'a>> {
let n_digits = self.s.chars().take_while(|c| c.is_ascii_digit()).count();
if n_digits > 0 && self.s.chars().skip(n_digits).take(2).eq(". ".chars()) {
let number = &self.s[..n_digits];
self.s = &self.s[(n_digits + 2)..];
self.start_of_line = false;
return Some(Item::NumberedPoint(number));
}
None
}
// ```{language}\n{code}```
fn code_block(&mut self) -> Option<Item<'a>> {
if let Some(language_start) = self.s.strip_prefix("```") {
if let Some(newline) = language_start.find('\n') {
let language = &language_start[..newline];
let code_start = &language_start[newline + 1..];
if let Some(end) = code_start.find("\n```") {
let code = &code_start[..end].trim();
self.s = &code_start[end + 4..];
self.start_of_line = false;
return Some(Item::CodeBlock(language, code));
} else {
self.s = "";
return Some(Item::CodeBlock(language, code_start));
}
}
}
None
}
// `code`
fn inline_code(&mut self) -> Option<Item<'a>> {
if let Some(rest) = self.s.strip_prefix('`') {
self.s = rest;
self.start_of_line = false;
self.style.code = true;
let rest_of_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())];
if let Some(end) = rest_of_line.find('`') {
let item = Item::Text(self.style, &self.s[..end]);
self.s = &self.s[end + 1..];
self.style.code = false;
return Some(item);
} else {
let end = rest_of_line.len();
let item = Item::Text(self.style, rest_of_line);
self.s = &self.s[end..];
self.style.code = false;
return Some(item);
}
}
None
}
/// `<url>` or `[link](url)`
fn url(&mut self) -> Option<Item<'a>> {
if self.s.starts_with('<') {
let this_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())];
if let Some(url_end) = this_line.find('>') {
let url = &self.s[1..url_end];
self.s = &self.s[url_end + 1..];
self.start_of_line = false;
return Some(Item::Hyperlink(self.style, url, url));
}
}
// [text](url)
if self.s.starts_with('[') {
let this_line = &self.s[..self.s.find('\n').unwrap_or(self.s.len())];
if let Some(bracket_end) = this_line.find(']') {
let text = &this_line[1..bracket_end];
if this_line[bracket_end + 1..].starts_with('(') {
if let Some(parens_end) = this_line[bracket_end + 2..].find(')') {
let parens_end = bracket_end + 2 + parens_end;
let url = &self.s[bracket_end + 2..parens_end];
self.s = &self.s[parens_end + 1..];
self.start_of_line = false;
return Some(Item::Hyperlink(self.style, text, url));
}
}
}
}
None
}
}
impl<'a> Iterator for Parser<'a> {
type Item = Item<'a>;
fn next(&mut self) -> Option<Self::Item> {
loop {
if self.s.is_empty() {
return None;
}
// \n
if self.s.starts_with('\n') {
self.s = &self.s[1..];
self.start_of_line = true;
self.style = Style::default();
return Some(Item::Newline);
}
// Ignore line break (continue on the same line)
if self.s.starts_with("\\\n") && self.s.len() >= 2 {
self.s = &self.s[2..];
self.start_of_line = false;
continue;
}
// \ escape (to show e.g. a backtick)
if self.s.starts_with('\\') && self.s.len() >= 2 {
let text = &self.s[1..2];
self.s = &self.s[2..];
self.start_of_line = false;
return Some(Item::Text(self.style, text));
}
if self.start_of_line {
// leading space (indentation)
if self.s.starts_with(' ') {
let length = self.s.find(|c| c != ' ').unwrap_or(self.s.len());
self.s = &self.s[length..];
self.start_of_line = true; // indentation doesn't count
return Some(Item::Indentation(length));
}
// # Heading
if let Some(after) = self.s.strip_prefix("# ") {
self.s = after;
self.start_of_line = false;
self.style.heading = true;
continue;
}
// > quote
if let Some(after) = self.s.strip_prefix("> ") {
self.s = after;
self.start_of_line = true; // quote indentation doesn't count
self.style.quoted = true;
return Some(Item::QuoteIndent);
}
// - bullet point
if self.s.starts_with("- ") {
self.s = &self.s[2..];
self.start_of_line = false;
return Some(Item::BulletPoint);
}
// `1. `, `42. ` etc.
if let Some(item) = self.numbered_list() {
return Some(item);
}
// --- separator
if let Some(after) = self.s.strip_prefix("---") {
self.s = after.trim_start_matches('-'); // remove extra dashes
self.s = self.s.strip_prefix('\n').unwrap_or(self.s); // remove trailing newline
self.start_of_line = false;
return Some(Item::Separator);
}
// ```{language}\n{code}```
if let Some(item) = self.code_block() {
return Some(item);
}
}
// `code`
if let Some(item) = self.inline_code() {
return Some(item);
}
if let Some(rest) = self.s.strip_prefix('*') {
self.s = rest;
self.start_of_line = false;
self.style.strong = !self.style.strong;
continue;
}
if let Some(rest) = self.s.strip_prefix('_') {
self.s = rest;
self.start_of_line = false;
self.style.underline = !self.style.underline;
continue;
}
if let Some(rest) = self.s.strip_prefix('~') {
self.s = rest;
self.start_of_line = false;
self.style.strikethrough = !self.style.strikethrough;
continue;
}
if let Some(rest) = self.s.strip_prefix('/') {
self.s = rest;
self.start_of_line = false;
self.style.italics = !self.style.italics;
continue;
}
if let Some(rest) = self.s.strip_prefix('$') {
self.s = rest;
self.start_of_line = false;
self.style.small = !self.style.small;
continue;
}
if let Some(rest) = self.s.strip_prefix('^') {
self.s = rest;
self.start_of_line = false;
self.style.raised = !self.style.raised;
continue;
}
// `<url>` or `[link](url)`
if let Some(item) = self.url() {
return Some(item);
}
// Swallow everything up to the next special character:
let end = self
.s
.find(&['*', '`', '~', '_', '/', '$', '^', '\\', '<', '[', '\n'][..])
.map_or_else(|| self.s.len(), |special| special.max(1));
let item = Item::Text(self.style, &self.s[..end]);
self.s = &self.s[end..];
self.start_of_line = false;
return Some(item);
}
}
}
#[test]
fn test_easy_mark_parser() {
let items: Vec<_> = Parser::new("~strikethrough `code`~").collect();
assert_eq!(
items,
vec![
Item::Text(
Style {
strikethrough: true,
..Default::default()
},
"strikethrough "
),
Item::Text(
Style {
code: true,
strikethrough: true,
..Default::default()
},
"code"
),
]
);
}

View File

@ -1,7 +0,0 @@
//! Experimental markup language
mod easy_mark_highlighter;
pub mod easy_mark_parser;
pub use easy_mark_highlighter::{MemoizedHighlighter, highlight_easymark};
pub use easy_mark_parser as parser;

View File

@ -1,53 +1,116 @@
use std::{ use std::{
cmp::Ordering, cmp::Ordering,
fmt::{self, Display}, fmt::{self, Display},
fs::{self, File},
io::Write,
ops::{Div as _, Sub as _}, ops::{Div as _, Sub as _},
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
sync::mpsc,
}; };
use chrono::{DateTime, Local};
use egui::{ use egui::{
Align, Button, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, Widget as _, vec2, Align, Button, Context, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, vec2,
}; };
use eyre::eyre;
use notify::{EventKind, Watcher};
use crate::{ use crate::{
app::Jobs,
custom_code_block::{MdItem, iter_lines_and_code_blocks}, custom_code_block::{MdItem, iter_lines_and_code_blocks},
painting::{self, Handwriting, HandwritingStyle}, handwriting::{self, Handwriting, HandwritingStyle},
preferences::Preferences, preferences::Preferences,
text_editor::MdTextEdit, text_editor::MdTextEdit,
util::{file_mtime, log_error},
}; };
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
pub struct FileEditor { pub struct FileEditor {
title: String, title: String,
pub path: Option<PathBuf>,
path: Option<PathBuf>,
pub buffer: Vec<BufferItem>, 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)] #[derive(serde::Deserialize, serde::Serialize)]
pub enum BufferItem { pub enum BufferItem {
Text(MdTextEdit), Text(Box<MdTextEdit>),
Handwriting(Handwriting), Handwriting(Box<Handwriting>),
} }
impl FileEditor { impl FileEditor {
pub fn new(title: impl Into<String>) -> Self { pub fn new(title: impl Into<String>) -> Self {
let buffer = vec![BufferItem::Text(MdTextEdit::new())]; let buffer = vec![BufferItem::Text(Box::new(MdTextEdit::new()))];
Self { Self {
title: title.into(), title: title.into(),
path: None, path: None,
buffer, 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) -> Self { pub fn from_file(file_path: PathBuf, contents: &str, mtime: DateTime<Local>) -> Self {
let file_title = file_path let file_title = file_path
.file_name() .file_name()
.map(|name| name.to_string_lossy().to_string()) .map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| String::from("untitled.md")); .unwrap_or_else(|| String::from("untitled.md"));
Self { Self {
title: file_title, title: file_title,
path: Some(file_path), path: Some(file_path),
file_mtime: Some(mtime),
buffer_mtime: mtime,
..FileEditor::from(contents) ..FileEditor::from(contents)
} }
} }
@ -60,24 +123,91 @@ impl FileEditor {
self.path.as_deref() 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) { 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.vertical_centered_justified(|ui| {
ui.heading(&self.title); ui.heading(&self.title);
const MAX_NOTE_WIDTH: f32 = 600.0; const MAX_NOTE_WIDTH: f32 = 600.0;
ui.horizontal(|ui| { // distance to scroll when paging up or down.
ui.label("new"); let mut scroll_delta = 0.0;
if ui.button("text").clicked() {
self.buffer.push(BufferItem::Text(Default::default())); ui.input_mut(|input| {
if input.consume_key(egui::Modifiers::NONE, egui::Key::PageUp) {
scroll_delta += self.scroll_delta;
} }
if ui.button("writing").clicked() { if input.consume_key(egui::Modifiers::NONE, egui::Key::PageDown) {
self.buffer scroll_delta -= self.scroll_delta;
.push(BufferItem::Handwriting(Default::default()));
} }
}); });
ScrollArea::vertical().show(ui, |ui| { 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| { ui.horizontal(|ui| {
let side_padding = ui.available_width().sub(MAX_NOTE_WIDTH).max(0.0).div(2.0); let side_padding = ui.available_width().sub(MAX_NOTE_WIDTH).max(0.0).div(2.0);
ui.add_space(side_padding); ui.add_space(side_padding);
@ -88,6 +218,8 @@ impl FileEditor {
ui.add_space(side_padding); ui.add_space(side_padding);
}); });
}); });
self.scroll_delta = scroll_area.inner_rect.height() * 0.5;
}); });
} }
@ -114,54 +246,49 @@ impl FileEditor {
let mut retain = true; let mut retain = true;
if is_dragging { // Createa horizontal area to draw the buffer item. The three things drawn here are:
let (_, drop) = ui.dnd_drop_zone::<DraggingItem, _>(Frame::NONE, |ui| { // - The controls that exist at the left-size of the buffer item, i.e. "up"/"down".
ui.set_min_size(vec2(ui.available_width(), drag_zone_height)); // - The buffer item.
}); // - The controls that exist at the right-size of the buffer item, i.e. "delete".
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| { ui.horizontal(|ui| {
// We don't know how tall the buffer item will be, so we'll reserve // 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 dragger // some horizontal space here and come back to drawing the controls later.
// later. let (_id, mut left_controls_rect) = ui.allocate_space(Vec2::new(20.0, 1.0));
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.. // Leave some space at the end for the delete button.
let w = ui.available_width(); let w = ui.available_width();
let item_size = Vec2::new(w - 20.0, 0.0); let item_size = Vec2::new(w - 20.0, 0.0);
let item_response = ui.allocate_ui(item_size, |ui| match item { let item_response = ui.allocate_ui(item_size, |ui| match item {
BufferItem::Text(text_edit) => { BufferItem::Text(text_edit) => {
text_edit.ui(ui); if text_edit.ui(ui).changed {
self.is_dirty = true;
}
} }
BufferItem::Handwriting(painting) => { BufferItem::Handwriting(handwriting) => {
let style = HandwritingStyle { let style = HandwritingStyle {
animate: preferences.animations, animate: preferences.animations,
hide_cursor: preferences.hide_handwriting_cursor,
..HandwritingStyle::from_theme(ui.ctx().theme()) ..HandwritingStyle::from_theme(ui.ctx().theme())
}; };
painting.ui(&style, ui); if handwriting.ui(&style, ui).changed {
self.is_dirty = true;
}
} }
}); });
// Delete-button // Delete-button
if ui.button("x").clicked() { if ui.button("").clicked() {
retain = false; retain = false;
ui.ctx().request_repaint(); ui.ctx().request_repaint();
} }
// Draw the dragger using the height from the buffer item left_controls_rect.set_height(item_response.response.rect.height());
dragger_rect.set_height(item_response.response.rect.height());
// Controls for moving the buffer item // Controls for moving the buffer item
ui.allocate_new_ui( ui.scope_builder(
UiBuilder::new() UiBuilder::new()
.max_rect(dragger_rect) .max_rect(left_controls_rect)
.layout(Layout::top_down(Align::Center)), .layout(Layout::top_down(Align::Center)),
|ui| { |ui| {
let up_button_response = ui.add_enabled(!is_first, Button::new("")); let up_button_response = ui.add_enabled(!is_first, Button::new(""));
@ -169,17 +296,12 @@ impl FileEditor {
drop_from_to = Some((i, i - 1)); drop_from_to = Some((i, i - 1));
} }
ui.dnd_drag_source(dragger_id, DraggingItem { index: i }, |ui| { // Add some space so that the next button is drawn
Button::new("") // at the bottom of the buffer item.
.min_size( ui.add_space(
// Use all available height, save for the height taken up by left_controls_rect.height()
// the up/down buttons + padding. Assume down-button is the - (up_button_response.rect.height() * 2.0 + 4.0),
// 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() { if ui.add_enabled(!is_last, Button::new("")).clicked() {
drop_from_to = Some((i, i + 2)); drop_from_to = Some((i, i + 2));
@ -211,10 +333,12 @@ impl FileEditor {
Ordering::Greater => { Ordering::Greater => {
let item = self.buffer.remove(from); let item = self.buffer.remove(from);
self.buffer.insert(to, item); self.buffer.insert(to, item);
self.is_dirty = true;
} }
Ordering::Less => { Ordering::Less => {
let item = self.buffer.remove(from); let item = self.buffer.remove(from);
self.buffer.insert(to - 1, item); self.buffer.insert(to - 1, item);
self.is_dirty = true;
} }
Ordering::Equal => {} Ordering::Equal => {}
} }
@ -230,6 +354,38 @@ impl FileEditor {
self.title = title.to_string_lossy().to_string(); self.title = title.to_string_lossy().to_string();
self.path = Some(new_path); 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 { impl Display for BufferItem {
@ -267,7 +423,7 @@ impl From<&str> for FileEditor {
_ => { _ => {
let mut text_edit = MdTextEdit::new(); let mut text_edit = MdTextEdit::new();
text_edit.text.push_str(text); text_edit.text.push_str(text);
buffer.push(BufferItem::Text(text_edit)); buffer.push(BufferItem::Text(Box::new(text_edit)));
} }
}; };
@ -275,7 +431,7 @@ impl From<&str> for FileEditor {
match item { match item {
MdItem::Line(line) => push_text(buffer, line), MdItem::Line(line) => push_text(buffer, line),
MdItem::CodeBlock { key, content, span } => match key { MdItem::CodeBlock { key, content, span } => match key {
painting::CODE_BLOCK_KEY => match Handwriting::from_str(span) { handwriting::CODE_BLOCK_KEY => match Handwriting::from_str(span) {
Ok(handwriting) => { Ok(handwriting) => {
if let Some(BufferItem::Text(text_edit)) = buffer.last_mut() { if let Some(BufferItem::Text(text_edit)) = buffer.last_mut() {
if text_edit.text.ends_with('\n') { if text_edit.text.ends_with('\n') {
@ -285,7 +441,7 @@ impl From<&str> for FileEditor {
} }
} }
}; };
buffer.push(BufferItem::Handwriting(handwriting)) buffer.push(BufferItem::Handwriting(Box::new(handwriting)))
} }
Err(e) => { Err(e) => {
log::error!("Failed to decode handwriting {content:?}: {e}"); log::error!("Failed to decode handwriting {content:?}: {e}");
@ -301,6 +457,40 @@ impl From<&str> for FileEditor {
} }
} }
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)] #[cfg(test)]
mod test { mod test {

272
src/folder.rs Normal file
View File

@ -0,0 +1,272 @@
use std::{
fs::read_dir,
mem,
ops::Deref,
path::{Path, PathBuf},
sync::mpsc,
thread,
};
use egui::{Button, Color32, Response, Stroke, TextWrapMode, Ui, Vec2, Widget};
use eyre::{Context, OptionExt, eyre};
use serde::{Deserialize, Serialize};
pub enum Folder {
NotLoaded {
name: String,
path: PathBuf,
},
Loading {
name: String,
path: PathBuf,
recv: mpsc::Receiver<LoadedFolder>,
},
Loaded(LoadedFolder),
}
pub struct LoadedFolder {
pub name: String,
pub path: PathBuf,
pub child_folders: Vec<Folder>,
pub child_files: Vec<File>,
}
pub struct File {
pub name: String,
pub path: PathBuf,
}
pub struct FolderResponse<'a> {
inner: Response,
pub open_file: Option<&'a Path>,
}
impl Deref for FolderResponse<'_> {
type Target = Response;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl LoadedFolder {
pub fn show<'a>(&'a mut self, ui: &mut Ui) -> FolderResponse<'a> {
let mut open_file = None;
let draw_highlight = |ui: &mut Ui, rect| {
ui.painter().rect(
rect,
2.0,
if ui.visuals().dark_mode {
Color32::from_white_alpha(16)
} else {
Color32::from_black_alpha(16)
},
Stroke::NONE,
egui::StrokeKind::Outside,
);
};
let inner = ui
.collapsing(&self.name, |ui| {
for folder in &mut self.child_folders {
open_file = open_file.or(folder.show(ui).open_file);
}
let w = ui.available_width();
let mut first = true;
for file in &mut self.child_files {
if !first {
ui.add_space(2.0);
}
first = false;
let button = Button::new(&file.name)
.min_size(Vec2::new(w, 0.0))
.wrap_mode(TextWrapMode::Truncate)
.frame(false)
.corner_radius(0.0)
.ui(ui);
if button.hovered() {
draw_highlight(ui, button.rect);
}
if button.clicked() {
open_file = Some(file.path.as_path())
};
}
})
.header_response;
if inner.hovered() {
draw_highlight(ui, inner.rect);
}
FolderResponse { inner, open_file }
}
fn load(path: PathBuf) -> eyre::Result<Self> {
let name = path
.file_name()
.ok_or_eyre("Path is missing a file-name")?
.to_string_lossy()
.to_string();
let mut child_folders = vec![];
let mut child_files = vec![];
for entry in read_dir(&path).with_context(|| eyre!("Couldn't read dir {path:?}"))? {
let entry = entry.with_context(|| eyre!("Couldn't read dir {path:?}"))?;
let path = entry.path();
let name = path
.file_name()
.ok_or_eyre("Path is missing a file-name")?
.to_string_lossy()
.to_string();
let file_type = entry.file_type()?;
if file_type.is_symlink() {
log::error!("Symlinks not yet supported, skipping {path:?}");
continue;
} else if file_type.is_file() {
if filter_file(&name) {
child_files.push(File { name, path });
}
} else if file_type.is_dir() {
if filter_folder(&name) {
child_folders.push(Folder::NotLoaded { name, path });
}
}
}
child_folders.sort_by_key(|folder| folder.name().to_owned());
child_files.sort_by_key(|file| (!file.name.ends_with(".md"), file.name.clone()));
let folder = LoadedFolder {
name,
path,
child_folders,
child_files,
};
Ok(folder)
}
}
impl Folder {
fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> {
if let Folder::NotLoaded { name, path } = self {
let (tx, rx) = mpsc::channel();
{
let path = path.clone();
let ctx = ui.ctx().clone();
thread::spawn(move || match LoadedFolder::load(path) {
Err(e) => log::error!("Failed to load folder: {e}"),
Ok(folder) => {
let _ = tx.send(folder);
ctx.request_repaint();
}
});
}
*self = Folder::Loading {
name: mem::take(name),
path: mem::take(path),
recv: rx,
};
}
if let Folder::Loading { recv, .. } = self {
match recv.try_recv() {
Ok(folder) => *self = Folder::Loaded(folder),
Err(_) => return None,
}
}
let Folder::Loaded(folder) = self else {
unreachable!()
};
Some(folder)
}
pub fn show<'a>(&'a mut self, ui: &mut Ui) -> FolderResponse<'a> {
self.load(ui);
if let Folder::Loaded(folder) = self {
return folder.show(ui);
}
FolderResponse {
inner: ui.label(self.name()),
open_file: None,
}
}
pub fn path(&self) -> &Path {
match self {
Folder::NotLoaded { path, .. } => path,
Folder::Loading { path, .. } => path,
Folder::Loaded(folder) => &folder.path,
}
}
pub fn name(&self) -> &str {
match self {
Folder::NotLoaded { name, .. } => name,
Folder::Loading { name, .. } => name,
Folder::Loaded(folder) => &folder.name,
}
}
pub fn unload(&mut self) {
let (name, path) = match self {
Folder::NotLoaded { .. } => return,
Folder::Loading { name, path, .. } => (name, path),
Folder::Loaded(folder) => (&mut folder.name, &mut folder.path),
};
*self = Folder::NotLoaded {
name: mem::take(name),
path: mem::take(path),
}
}
}
impl Serialize for Folder {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.path().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Folder {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let path = PathBuf::deserialize(deserializer)?;
let name = path
.file_name()
.ok_or(D::Error::custom("Path is missing a file-name"))?
.to_string_lossy()
.to_string();
Ok(Folder::NotLoaded { name, path })
}
}
fn filter_folder(_folder_name: &str) -> bool {
true
}
fn filter_file(file_name: &str) -> bool {
file_name != ".DS_Store"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,176 @@
use std::sync::Arc;
use egui::{
Color32, ColorImage, CornerRadius, Painter, Pos2, Rect, Stroke, StrokeKind, TextureHandle,
Vec2,
ahash::HashMap,
emath::TSTransform,
epaint::{Brush, RectShape, Vertex},
load::SizedTexture,
};
use crate::rasterizer::{PxBoundingBox, rasterize_triangle_onto, triangle_bounding_box};
use super::StrokeBlendMode;
const CHUNK_SIZE: usize = 64;
/// Rasterize onto a resizeable canvas.
#[derive(Default)]
pub struct CanvasRasterizer {
image_size: [usize; 2],
tiles: HashMap<[usize; 2], Tile>,
}
struct Tile {
bounding_box: PxBoundingBox,
image: ColorImage,
texture: Option<TextureHandle>,
texture_is_dirty: bool,
}
impl Tile {
fn new(xi: usize, yi: usize) -> Self {
let x_from = xi * CHUNK_SIZE;
let y_from = yi * CHUNK_SIZE;
let bounding_box = PxBoundingBox {
x_from,
y_from,
x_to: x_from + CHUNK_SIZE,
y_to: y_from + CHUNK_SIZE,
};
Self {
bounding_box,
image: ColorImage::filled([CHUNK_SIZE, CHUNK_SIZE], Color32::TRANSPARENT),
texture: None,
texture_is_dirty: false,
}
}
}
impl CanvasRasterizer {
pub fn set_size(&mut self, width: usize, height: usize) {
self.image_size = [width, height];
self.populate_tiles();
}
pub fn clear(&mut self) {
log::debug!("clearing all tiles");
self.tiles.clear();
self.populate_tiles();
}
fn populate_tiles(&mut self) {
let [width, height] = self.image_size;
// discard tiles that are out of bounds
self.tiles.retain(|_, tile| {
tile.bounding_box.x_from <= width && tile.bounding_box.y_from <= height
});
let chunk = |max: usize| {
(0..)
.step_by(CHUNK_SIZE)
.take_while(move |n| n <= &max)
.enumerate()
};
// create new tiles where we need them
for (xi, _x) in chunk(width) {
for (yi, _y) in chunk(height) {
self.tiles
.entry([xi, yi])
.or_insert_with(|| Tile::new(xi, yi));
}
}
}
pub fn rasterize<'a>(
&mut self,
point_to_pixel: TSTransform,
triangles: impl Iterator<Item = [&'a Vertex; 3]> + Clone,
) {
for triangle in triangles {
let triangle_bounding_box = triangle_bounding_box(&triangle, point_to_pixel);
for chunk in chunks_from_bounding_box(triangle_bounding_box) {
let Some(tile) = self.tiles.get_mut(&chunk) else {
continue;
};
let mut point_to_tile_pixel = point_to_pixel;
point_to_tile_pixel.translation -= Vec2::new(
tile.bounding_box.x_from as f32,
tile.bounding_box.y_from as f32,
);
tile.texture_is_dirty = true;
rasterize_triangle_onto::<StrokeBlendMode>(
&mut tile.image,
point_to_tile_pixel,
triangle,
);
}
}
}
/// `at` defines the location in screen-coordinates where the canvas should be drawn.
pub fn show(&mut self, ctx: &egui::Context, painter: &Painter, at: Rect) {
let pixels_per_point = ctx.pixels_per_point();
let chunk_vec = Vec2::splat(CHUNK_SIZE as f32) / pixels_per_point;
for ([xi, yi], tile) in &mut self.tiles {
if tile.texture_is_dirty {
tile.texture_is_dirty = false;
if let Some(texture) = &mut tile.texture {
texture.set(tile.image.clone(), Default::default());
} else {
tile.texture = Some(ctx.load_texture(
"handwriting",
tile.image.clone(),
Default::default(),
));
}
}
if let Some(texture) = &mut tile.texture {
let texture = SizedTexture::new(texture.id(), texture.size_vec2());
let shape = RectShape {
rect: Rect::from_min_size(
at.min + Vec2::new(*xi as f32, *yi as f32) * chunk_vec,
chunk_vec,
),
corner_radius: CornerRadius::ZERO,
fill: Color32::WHITE,
stroke: Stroke::NONE,
stroke_kind: StrokeKind::Inside,
round_to_pixels: None,
blur_width: 0.0,
brush: Some(Arc::new(Brush {
fill_texture_id: texture.id,
uv: Rect {
min: Pos2::ZERO,
max: Pos2::new(1.0, 1.0),
},
})),
};
painter.add(shape);
}
}
}
}
/// Get all chunk indices that overlaps with a PxBoundingBox.
fn chunks_from_bounding_box(
triangle_bounding_box: PxBoundingBox,
) -> impl Iterator<Item = [usize; 2]> {
let x_from_chunk = triangle_bounding_box.x_from / CHUNK_SIZE;
let y_from_chunk = triangle_bounding_box.y_from / CHUNK_SIZE;
let x_to_chunk = triangle_bounding_box.x_to.saturating_sub(1) / CHUNK_SIZE;
let y_to_chunk = triangle_bounding_box.y_to.saturating_sub(1) / CHUNK_SIZE;
let xs = x_from_chunk..=x_to_chunk;
let ys = y_from_chunk..=y_to_chunk;
ys.flat_map(move |yi| xs.clone().map(move |xi| [xi, yi]))
}

View File

@ -0,0 +1,97 @@
//! see [Packet]
use std::fmt::Display;
use half::f16;
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
/// A `u16` encoded in little-endian.
#[allow(non_camel_case_types)]
#[derive(Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, PartialEq, Eq)]
#[repr(C, packed)]
pub struct u16_le([u8; 2]);
/// An `f16` encoded in little-endian.
#[allow(non_camel_case_types)]
#[derive(Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable)]
#[repr(C, packed)]
pub struct f16_le(u16_le);
/// Top-level type describing the handwriting disk-format.
#[derive(FromBytes, KnownLayout, Immutable)]
#[repr(C, packed)]
pub struct DiskFormat {
pub header: Header,
/// A packed array of [Stroke]s.
pub strokes: [u8],
}
pub const V1: u16_le = u16_le::new(1);
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable)]
#[repr(C, packed)]
pub struct Header {
/// Version of the disk format
pub version: u16_le,
}
#[derive(FromBytes, IntoBytes, KnownLayout, Immutable)]
#[repr(C, packed)]
pub struct RawStrokeHeader {
/// Number of points in the stroke.
pub len: u16_le,
}
#[derive(FromBytes, KnownLayout, Immutable)]
#[repr(C, packed)]
pub struct RawStroke {
pub header: RawStrokeHeader,
pub positions: [f16_le],
}
impl RawStroke {
pub const MIN_LEN: usize = size_of::<RawStrokeHeader>();
}
impl u16_le {
pub const fn new(init: u16) -> Self {
u16_le(init.to_le_bytes())
}
}
impl f16_le {
pub const fn new(init: f16) -> Self {
f16_le(u16_le::new(init.to_bits()))
}
}
impl Display for u16_le {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
u16::from(*self).fmt(f)
}
}
impl From<u16_le> for u16 {
fn from(value: u16_le) -> Self {
u16::from_le_bytes(value.0)
}
}
impl From<f16_le> for f16 {
fn from(value: f16_le) -> Self {
f16::from_bits(u16::from(value.0))
}
}
impl From<u16> for u16_le {
fn from(value: u16) -> Self {
u16_le::new(value)
}
}
impl From<f16> for f16_le {
fn from(value: f16) -> Self {
f16_le::new(value)
}
}

987
src/handwriting/mod.rs Normal file
View File

@ -0,0 +1,987 @@
mod canvas_rasterizer;
mod disk_format;
mod tool;
use std::{
fmt::{self, Display},
iter, mem,
str::FromStr,
sync::Arc,
};
use arboard::Clipboard;
use base64::{Engine, prelude::BASE64_STANDARD};
use canvas_rasterizer::CanvasRasterizer;
use disk_format::{DiskFormat, RawStroke, RawStrokeHeader, f16_le};
use egui::{
Color32, Event, Frame, Id, Mesh, PointerButton, Pos2, Rect, Sense, Shape, Stroke, Theme, Ui,
Vec2,
emath::{self, RectTransform, TSTransform},
epaint::{TessellationOptions, Tessellator, Vertex},
};
use eyre::{Context, bail};
use eyre::{OptionExt, eyre};
use half::f16;
use zerocopy::{FromBytes, IntoBytes};
use crate::{custom_code_block::try_from_custom_code_block, rasterizer};
use crate::{custom_code_block::write_custom_code_block, util::random_id};
use self::tool::{Tool, ToolEvent};
const HANDWRITING_MIN_HEIGHT: f32 = 100.0;
const HANDWRITING_BOTTOM_PADDING: f32 = 80.0;
const HANDWRITING_MARGIN: f32 = 0.05;
const HANDWRITING_LINE_SPACING: f32 = 36.0;
pub const CODE_BLOCK_KEY: &str = "handwriting";
type StrokeBlendMode = rasterizer::blend::Normal;
const TESSELLATION_OPTIONS: TessellationOptions = TessellationOptions {
feathering: true,
feathering_size_in_pixels: 1.0,
coarse_tessellation_culling: true,
prerasterized_discs: true,
round_text_to_pixels: true,
round_line_segments_to_pixels: true,
round_rects_to_pixels: true,
debug_paint_text_rects: false,
debug_paint_clip_rects: false,
debug_ignore_clip_rects: false,
bezier_tolerance: 0.1,
epsilon: 1.0e-5,
parallel_tessellation: true,
validate_meshes: false,
};
pub struct HandwritingStyle {
pub stroke: Stroke,
pub bg_line_stroke: Stroke,
pub bg_color: Color32,
pub animate: bool,
pub hide_cursor: bool,
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct Handwriting {
strokes: Vec<Vec<Pos2>>,
height: f32,
desired_height: f32,
#[serde(skip)]
e: Ephemeral,
}
/// Handwriting data that isn't persisted across restarts.
struct Ephemeral {
id: Id,
canvas_rasterizer: CanvasRasterizer,
tool: Tool,
/// Tool position in canvas space.
tool_position: Option<Pos2>,
/// Tool position last frame, in canvas space.
last_tool_position: Option<Pos2>,
/// The stroke that is currently being drawed.
current_stroke: Vec<Pos2>,
/// The lines that have not been blitted to `texture` yet.
///
/// Each pair of [Pos2]s is the start and end of one line.
unblitted_lines: Vec<[Pos2; 2]>,
tessellator: Option<Tessellator>,
/// Tessellated mesh of all strokes
mesh: Arc<Mesh>,
refresh_texture: bool,
/// Context of the last mesh render.
last_mesh_ctx: Option<MeshContext>,
}
pub struct HandwritingResponse {
pub changed: bool,
}
/// Context of a mesh render.
#[derive(Clone, Copy, PartialEq)]
struct MeshContext {
/// Need to update the mesh when the stroke color changes.
pub ui_theme: Theme,
pub pixels_per_point: f32,
pub stroke: Stroke,
}
impl Default for Handwriting {
fn default() -> Self {
Self {
strokes: Default::default(),
height: HANDWRITING_MIN_HEIGHT,
desired_height: HANDWRITING_MIN_HEIGHT,
e: Default::default(),
}
}
}
impl Default for Ephemeral {
fn default() -> Self {
Self {
id: random_id(),
canvas_rasterizer: Default::default(),
tool: Tool::Pencil,
tool_position: None,
last_tool_position: None,
current_stroke: Default::default(),
tessellator: None,
mesh: Default::default(),
refresh_texture: true,
last_mesh_ctx: None,
unblitted_lines: Default::default(),
}
}
}
impl Handwriting {
pub fn ui_control(
&mut self,
style: Option<&mut HandwritingStyle>,
ui: &mut egui::Ui,
response: &mut HandwritingResponse,
) -> egui::Response {
ui.horizontal(|ui| {
if let Some(style) = style {
ui.label("Stroke:");
ui.add(&mut style.stroke);
ui.separator();
}
if ui.button("clear").clicked() {
self.strokes.clear();
self.e.refresh_texture = true;
response.changed = true;
}
ui.add_enabled_ui(!self.strokes.is_empty(), |ui| {
if ui.button("undo").clicked() {
self.strokes.pop();
self.e.refresh_texture = true;
response.changed = true;
}
});
if ui.button("copy").clicked() {
let text = self.to_string();
// TODO: move to a job
let _ = Clipboard::new().unwrap().set_text(text);
}
let (label, switch_to_tool) = match self.e.tool {
Tool::Pencil => ("eraser", Tool::Eraser),
Tool::Eraser => ("pencil", Tool::Pencil),
};
if ui.button(label).clicked() {
self.e.tool = switch_to_tool;
}
let vertex_count: usize = self.e.mesh.indices.len() / 3;
ui.label(format!("vertices: {vertex_count}"));
})
.response
}
pub fn ui_content(
&mut self,
style: &HandwritingStyle,
ui: &mut Ui,
hw_response: &mut HandwritingResponse,
) -> egui::Response {
if style.animate {
self.height = ui.ctx().animate_value_with_time(
self.e.id.with("height animation"),
self.desired_height,
0.4,
);
} else {
self.height = self.desired_height;
}
let desired_size = Vec2::new(ui.available_width(), self.height);
let (mut response, painter) = ui.allocate_painter(desired_size, Sense::drag());
if style.hide_cursor {
response = response.on_hover_and_drag_cursor(egui::CursorIcon::None);
}
let size = response.rect.size();
// Calculate matrices that convert between screen-space and image-space.
// - image-space: 0,0 is the top-left of the texture.
// - screen-space: 0,0 is the top-left of the window.
// Both spaces use the same logical points, not pixels.
let to_screen =
emath::RectTransform::from_to(Rect::from_min_size(Pos2::ZERO, size), response.rect);
let from_screen = to_screen.inverse();
self.e.last_tool_position = self.e.tool_position;
// Was the user in the process of drawing a stroke last frame?
let was_drawing = !self.e.current_stroke.is_empty();
// Is the user in the process of drawing a stroke now?
let is_drawing = response.interact_pointer_pos().is_some();
if !is_drawing {
if was_drawing {
// commit current line
self.commit_current_line(hw_response);
response.mark_changed();
}
// recalculate how tall the widget should be
let lines_max_y = self
.strokes
.iter()
.flatten()
.map(|p| p.y + HANDWRITING_BOTTOM_PADDING)
.fold(HANDWRITING_MIN_HEIGHT, |max, y| max.max(y));
// Change the height of the handwriting item.
// We don't do this mid-stroke, only when the user e.g. lifts the pen.
if self.desired_height != lines_max_y {
self.desired_height = lines_max_y;
response.mark_changed();
}
} else {
let events = ui.ctx().input(|input| {
// If we are getting both MouseMoved and PointerMoved events, ignore the first.
let mut events = input.raw.events.iter().peekable();
iter::from_fn(move || {
let next = events.next()?;
let Some(peek) = events.peek() else {
return Some(next);
};
match next {
Event::PointerMoved(..) if matches!(peek, Event::MouseMoved(..)) => {
let _ = events.next(); // drop the MouseMoved event
Some(next)
}
Event::MouseMoved(..) if matches!(peek, Event::PointerMoved(..)) => {
// return the peeked PointerMoved instead
Some(events.next().expect("next is some"))
}
_ => Some(next),
}
})
.filter(|event| {
// FIXME: pinenote: PointerMoved are duplicated after the MouseMoved events
cfg!(not(feature = "pinenote")) || !matches!(event, Event::PointerMoved(..))
})
.cloned()
.collect::<Vec<_>>()
});
// Process input events and turn them into strokes
for event in events {
let mut last_tool_position = self.e.last_tool_position;
process_event(&mut last_tool_position, from_screen, &event, |tool_event| {
self.e.tool_position = tool_event.position();
match self.e.tool {
Tool::Pencil => {
hw_response.changed |= tool::pencil::on_tool_event(self, tool_event);
}
Tool::Eraser => {
if tool::eraser::on_tool_event(self, tool_event) {
self.e.refresh_texture = true;
hw_response.changed = true;
}
}
}
});
}
}
// Draw the horizontal ruled lines
(1..)
.map(|n| n as f32 * HANDWRITING_LINE_SPACING)
.take_while(|&y| y < size.y)
.map(|y| {
let l = to_screen * Pos2::new(HANDWRITING_MARGIN * size.x, y);
let r = to_screen * Pos2::new((1.0 - HANDWRITING_MARGIN) * size.x, y);
Shape::hline(l.x..=r.x, l.y, style.bg_line_stroke)
})
.for_each(|shape| {
painter.add(shape);
});
// Get the position and dimensions of the image
let mesh_rect = response
.rect
.with_max_y(response.rect.min.y + self.desired_height);
// These are the values that, if changed, would require the mesh to be re-rendered.
let new_context = MeshContext {
ui_theme: ui.ctx().theme(),
pixels_per_point: ui.pixels_per_point(),
stroke: style.stroke,
};
// Figure out if we need to re-rasterize the mesh.
if Some(&new_context) != self.e.last_mesh_ctx.as_ref() {
self.e.refresh_texture = true;
}
let [px_width, px_height] = {
let Vec2 { x, y } = mesh_rect.size() * new_context.pixels_per_point;
[x, y].map(|f| f.ceil() as usize)
};
self.e.canvas_rasterizer.set_size(px_width, px_height);
if self.e.refresh_texture {
// ...if we do, rasterize the entire texture from scratch
self.refresh_texture(style, new_context);
self.e.unblitted_lines.clear();
} else if !self.e.unblitted_lines.is_empty() {
// ...if we don't, we can get away with only rasterizing the *new* lines onto the
// existing texture.
for [from, to] in std::mem::take(&mut self.e.unblitted_lines) {
self.draw_line_to_texture(from, to, &new_context);
}
self.e.unblitted_lines.clear();
}
// Draw the texture
self.e.canvas_rasterizer.show(ui.ctx(), &painter, mesh_rect);
if let Some(tool_position) = self.e.tool_position
&& let Tool::Eraser = self.e.tool
{
let pos = to_screen * tool_position;
let shape = Shape::circle_stroke(pos, tool::eraser::RADIUS, style.stroke);
painter.add(shape);
}
response
}
fn commit_current_line(&mut self, response: &mut HandwritingResponse) {
debug_assert!(!self.e.current_stroke.is_empty());
self.strokes.push(mem::take(&mut self.e.current_stroke));
response.changed = true;
}
/// Tessellate and rasterize the strokes into a new texture.
fn refresh_texture(&mut self, style: &HandwritingStyle, mesh_context: MeshContext) {
let Ephemeral {
current_stroke,
tessellator,
mesh,
refresh_texture,
last_mesh_ctx,
..
} = &mut self.e;
// TODO: avoid tessellating and rasterizing on the GUI thread
*last_mesh_ctx = Some(mesh_context);
*refresh_texture = false;
#[cfg(not(target_arch = "wasm32"))]
let start_time = std::time::Instant::now();
let mesh = Arc::make_mut(mesh);
mesh.clear();
// TODO: re-use tessellator if pixels_per_point hasn't changed
let tessellator = tessellator.insert(new_tessellator(mesh_context.pixels_per_point));
self.strokes
.iter()
.chain([&*current_stroke])
.filter(|stroke| stroke.len() >= 2)
.map(|stroke| egui::Shape::line(stroke.clone(), style.stroke))
.for_each(|shape| {
tessellator.tessellate_shape(shape, mesh);
});
// sanity-check that tessellation did not produce any NaNs.
// this can happen if the line contains duplicated consecutive positions
//for vertex in &mesh.vertices {
// debug_assert!(vertex.pos.x.is_finite(), "{} must be finite", vertex.pos.x);
// debug_assert!(vertex.pos.y.is_finite(), "{} must be finite", vertex.pos.y);
//}
let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point);
let triangles = mesh_triangles(&self.e.mesh);
self.e.canvas_rasterizer.clear();
self.e
.canvas_rasterizer
.rasterize(point_to_pixel, triangles);
#[cfg(not(target_arch = "wasm32"))]
{
let elapsed = start_time.elapsed();
log::debug!("refreshed mesh in {:.3}s", elapsed.as_secs_f32());
}
}
pub fn ui(&mut self, style: &HandwritingStyle, ui: &mut Ui) -> HandwritingResponse {
let mut response = HandwritingResponse { changed: false };
ui.vertical_centered_justified(|ui| {
self.ui_control(None, ui, &mut response);
Frame::canvas(ui.style())
.corner_radius(20.0)
.stroke(Stroke::new(5.0, Color32::from_black_alpha(40)))
.fill(style.bg_color)
.show(ui, |ui| {
self.ui_content(style, ui, &mut response);
});
});
response
}
/// Append a new [Pos2] to [Self::current_stroke].
///
/// Queue a new line to be drawn onto [Self::texture].
fn push_to_stroke(&mut self, new_canvas_pos: Pos2) {
if let Some(&last_canvas_pos) = self.e.current_stroke.last() {
if last_canvas_pos == new_canvas_pos {
return;
}
self.e
.unblitted_lines
.push([last_canvas_pos, new_canvas_pos]);
}
self.e.current_stroke.push(new_canvas_pos);
}
/// Draw a single line onto the existing texture.
fn draw_line_to_texture(&mut self, from: Pos2, to: Pos2, mesh_context: &MeshContext) {
// INVARIANT: if this function was called, then pixels_per_point is the same as last frame,
// so there's no need to create a new tessellator.
let tessellator = self
.e
.tessellator
.get_or_insert_with(|| new_tessellator(mesh_context.pixels_per_point));
let mut mesh = Mesh::default();
let line = egui::Shape::line_segment([from, to], mesh_context.stroke);
tessellator.tessellate_shape(line, &mut mesh);
self.draw_mesh_to_texture(&mesh, mesh_context);
}
/// Draw a single mesh onto the existing texture.
fn draw_mesh_to_texture(&mut self, mesh: &Mesh, mesh_context: &MeshContext) {
let triangles = mesh_triangles(mesh);
let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point);
self.e
.canvas_rasterizer
.rasterize(point_to_pixel, triangles);
}
pub fn strokes(&self) -> &[Vec<Pos2>] {
&self.strokes
}
#[cfg(test)]
pub fn example() -> Self {
Handwriting {
strokes: vec![
vec![
Pos2::new(-1.0, 1.0),
Pos2::new(3.0, 1.0),
Pos2::new(3.0, 3.0),
Pos2::new(1.5, 2.0),
Pos2::new(0.0, 0.0),
],
vec![
Pos2::new(3.0, 3.0),
Pos2::new(-1.0, 1.0),
Pos2::new(0.0, 0.0),
Pos2::new(3.0, 1.0),
],
],
..Default::default()
}
}
pub fn encode_as_disk_format(&self) -> Box<[u8]> {
let mut bytes = vec![];
let header = disk_format::Header {
version: disk_format::V1,
};
bytes.extend_from_slice(header.as_bytes());
for stroke in &self.strokes {
let Ok(len) = u16::try_from(stroke.len()) else {
log::error!("More than u16::MAX points in a stroke!");
continue;
};
let header = RawStrokeHeader { len: len.into() };
bytes.extend_from_slice(header.as_bytes());
for position in stroke {
for v in [position.x, position.y] {
let v = f16::from_f32(v);
let v = f16_le::from(v);
bytes.extend_from_slice(v.as_bytes());
}
}
}
bytes.into_boxed_slice()
}
}
/// Convert [egui::Event]s to [ToolEvent]s.
fn process_event(
last_canvas_pos: &mut Option<Pos2>,
from_screen: RectTransform,
event: &Event,
mut on_tool_event: impl FnMut(ToolEvent),
) {
match event {
&Event::PointerMoved(new_position) => {
let new_canvas_pos = from_screen * new_position;
if last_canvas_pos.is_some() && *last_canvas_pos != Some(new_canvas_pos) {
*last_canvas_pos = Some(new_canvas_pos);
on_tool_event(ToolEvent::Move { to: new_canvas_pos });
}
}
&Event::MouseMoved(mut delta) => {
if delta.length() == 0.0 {
return;
}
// FIXME: pinenote: MouseMovement delta does *not* take into account screen
// scaling and rotation, so unless you've scaling=1 and no rotation, the
// MouseMoved values will be all wrong.
if cfg!(feature = "pinenote") {
delta /= 1.8;
delta = -delta.rot90();
}
if let Some(pos) = last_canvas_pos {
*pos += delta;
on_tool_event(ToolEvent::Move { to: *pos });
} else {
println!("Got `MouseMoved`, but have no previous pos");
}
}
&Event::PointerButton {
pos,
button,
pressed,
modifiers: _,
} => match (button, pressed) {
(PointerButton::Primary, true) => {
if last_canvas_pos.is_none() {
let pos = from_screen * pos;
*last_canvas_pos = Some(pos);
on_tool_event(ToolEvent::Press { at: pos });
}
}
(PointerButton::Primary, false) => {
if last_canvas_pos.take().is_some() {
let pos = from_screen * pos;
on_tool_event(ToolEvent::Move { to: pos });
on_tool_event(ToolEvent::Release {});
}
}
(_, _) => {}
},
// Stop drawing after pointer disappears or the window is unfocused
Event::PointerGone | Event::WindowFocused(false) => {
if last_canvas_pos.take().is_some() {
on_tool_event(ToolEvent::Release {});
}
}
Event::WindowFocused(true)
| Event::Copy
| Event::Cut
| Event::Paste(..)
| Event::Text(..)
| Event::Key { .. }
| Event::Zoom(..)
| Event::Ime(..)
| Event::Touch { .. }
| Event::MouseWheel { .. }
| Event::Screenshot { .. } => {}
}
}
fn new_tessellator(pixels_per_point: f32) -> Tessellator {
Tessellator::new(
pixels_per_point,
TESSELLATION_OPTIONS,
Default::default(), // we don't tessellate fonts
vec![],
)
}
impl Display for Handwriting {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let raw = self.encode_as_disk_format();
write_custom_code_block(f, CODE_BLOCK_KEY, BASE64_STANDARD.encode(raw))
}
}
impl FromStr for Handwriting {
type Err = eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = try_from_custom_code_block(CODE_BLOCK_KEY, s)
.ok_or_eyre("Not a valid ```handwriting-block")?;
let bytes = BASE64_STANDARD
.decode(s)
.wrap_err("Failed to decode painting data from base64")?;
// HACK: first iteration of disk format did not have version header
//let mut bytes = bytes;
//bytes.insert(0, 0);
//bytes.insert(0, 1);
let disk_format = DiskFormat::ref_from_bytes(&bytes[..]).map_err(|_| eyre!("Too short"))?;
if disk_format.header.version != disk_format::V1 {
bail!(
"Unknown disk_format version: {}",
disk_format.header.version
);
}
let mut raw_strokes = &disk_format.strokes[..];
let mut strokes = vec![];
while !raw_strokes.is_empty() {
if raw_strokes.len() < RawStroke::MIN_LEN {
bail!("Invalid remaining length: {}", raw_strokes.len());
}
let stroke = RawStroke::ref_from_bytes(&raw_strokes[..RawStroke::MIN_LEN])
.expect("length is correct");
// get length as number of points
let len = usize::from(u16::from(stroke.header.len));
// convert to length in bytes
let byte_len = 2 * size_of::<f16_le>() * len;
if raw_strokes.len() < byte_len {
bail!("Invalid remaining length: {}", raw_strokes.len());
}
let (stroke, rest) = raw_strokes.split_at(RawStroke::MIN_LEN + byte_len);
raw_strokes = rest;
let stroke = RawStroke::ref_from_bytes(stroke).expect("length is correct");
debug_assert_eq!(
stroke.positions.len().rem_euclid(2),
0,
"{} must be divisible by 2",
stroke.positions.len()
);
debug_assert_eq!(stroke.positions.len(), len * 2);
let mut last_pos = Pos2::new(f32::NEG_INFINITY, f32::INFINITY);
// positions are encoded as an array of f16s [x, y, x, y, x, y, ..]
let stroke: Vec<Pos2> = stroke
.positions
.chunks_exact(2)
.map(|chunk| [chunk[0], chunk[1]])
.map(|pos| pos.map(f16::from)) // interpret bytes as f16
.map(|pos| pos.map(f32::from)) // widen to f32
.filter(|pos| pos.iter().all(|f| f.is_finite())) // filter out NaNs and Infs
.map(|[x, y]| Pos2::new(x, y))
.filter(|pos| {
let is_duplicate = pos == &last_pos;
last_pos = *pos;
!is_duplicate // skip duplicates
})
.collect();
strokes.push(stroke);
}
Ok(Handwriting {
strokes,
..Default::default()
})
}
}
impl HandwritingStyle {
pub fn from_theme(theme: Theme) -> Self {
let stroke_color;
let bg_color;
let line_color;
match theme {
Theme::Dark => {
stroke_color = Color32::WHITE;
bg_color = Color32::from_gray(30);
line_color = Color32::from_rgb(100, 100, 100);
}
Theme::Light => {
stroke_color = Color32::BLACK;
bg_color = Color32::WHITE;
line_color = Color32::from_rgb(130, 130, 130); // TODO
}
}
HandwritingStyle {
stroke: Stroke::new(1.0, stroke_color),
bg_color,
bg_line_stroke: Stroke::new(0.5, line_color),
animate: true,
hide_cursor: false,
}
}
}
fn mesh_triangles(mesh: &Mesh) -> impl Iterator<Item = [&Vertex; 3]> + Clone {
mesh.indices
.chunks_exact(3)
.map(|chunk| [chunk[0], chunk[1], chunk[2]])
.map(|indices| indices.map(|i| &mesh.vertices[i as usize]))
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, emath::RectTransform};
use super::{Handwriting, process_event};
#[test]
fn serialize_handwriting() {
let handwriting = Handwriting::example();
insta::assert_debug_snapshot!("handwriting example", handwriting.strokes);
let serialized = handwriting.to_string();
insta::assert_snapshot!("serialized handwriting", serialized);
let deserialized =
Handwriting::from_str(&serialized).expect("Handwriting must de/serialize correctly");
insta::assert_debug_snapshot!("deserialized handwriting", deserialized.strokes);
}
const TEST_EVENTS: &[Event] = &[
Event::PointerMoved(Pos2::new(749.9, 225.6)),
Event::PointerButton {
pos: Pos2::new(749.9, 225.6),
button: PointerButton::Primary,
pressed: true,
modifiers: Modifiers::NONE,
},
Event::PointerMoved(Pos2::new(749.9, 225.7)),
Event::PointerMoved(Pos2::new(749.9, 226.4)),
Event::PointerMoved(Pos2::new(750.2, 228.4)),
Event::PointerMoved(Pos2::new(751.0, 231.3)),
Event::PointerMoved(Pos2::new(752.6, 234.4)),
Event::PointerMoved(Pos2::new(754.1, 237.7)),
Event::PointerMoved(Pos2::new(755.8, 241.1)),
Event::PointerMoved(Pos2::new(757.7, 244.4)),
Event::PointerMoved(Pos2::new(759.3, 247.4)),
Event::PointerMoved(Pos2::new(760.8, 250.2)),
Event::PointerMoved(Pos2::new(762.8, 253.4)),
Event::PointerMoved(Pos2::new(765.1, 256.8)),
Event::PointerMoved(Pos2::new(767.7, 260.2)),
Event::PointerMoved(Pos2::new(771.2, 264.3)),
Event::PointerMoved(Pos2::new(774.6, 267.9)),
Event::PointerMoved(Pos2::new(778.2, 271.2)),
Event::PointerMoved(Pos2::new(782.7, 275.2)),
Event::PointerMoved(Pos2::new(786.7, 278.5)),
Event::PointerMoved(Pos2::new(790.4, 280.8)),
Event::PointerMoved(Pos2::new(794.1, 282.6)),
Event::PointerMoved(Pos2::new(797.9, 283.9)),
Event::PointerMoved(Pos2::new(801.9, 284.8)),
Event::PointerMoved(Pos2::new(805.9, 285.5)),
Event::PointerMoved(Pos2::new(810.2, 285.8)),
Event::PointerMoved(Pos2::new(814.5, 285.8)),
Event::PointerMoved(Pos2::new(818.2, 285.6)),
Event::PointerMoved(Pos2::new(821.6, 284.5)),
Event::PointerMoved(Pos2::new(824.7, 283.0)),
Event::PointerMoved(Pos2::new(827.5, 281.4)),
Event::PointerMoved(Pos2::new(830.4, 279.6)),
Event::PointerMoved(Pos2::new(833.4, 277.7)),
Event::PointerMoved(Pos2::new(836.1, 275.7)),
Event::PointerMoved(Pos2::new(838.6, 273.6)),
Event::PointerMoved(Pos2::new(840.9, 271.7)),
Event::PointerMoved(Pos2::new(843.0, 269.6)),
Event::PointerMoved(Pos2::new(845.4, 267.2)),
Event::PointerMoved(Pos2::new(847.7, 265.1)),
Event::PointerMoved(Pos2::new(849.8, 262.7)),
Event::PointerMoved(Pos2::new(852.0, 260.0)),
Event::PointerMoved(Pos2::new(854.3, 256.8)),
Event::PointerMoved(Pos2::new(856.3, 253.4)),
Event::PointerMoved(Pos2::new(858.2, 250.1)),
Event::PointerMoved(Pos2::new(860.0, 247.1)),
Event::PointerMoved(Pos2::new(861.5, 244.3)),
Event::PointerMoved(Pos2::new(862.8, 242.0)),
Event::PointerMoved(Pos2::new(864.1, 240.1)),
Event::PointerMoved(Pos2::new(865.0, 238.2)),
Event::PointerMoved(Pos2::new(865.8, 236.6)),
Event::PointerMoved(Pos2::new(866.5, 234.9)),
Event::PointerMoved(Pos2::new(867.1, 233.1)),
Event::PointerMoved(Pos2::new(867.8, 231.4)),
Event::PointerMoved(Pos2::new(868.4, 229.8)),
Event::PointerMoved(Pos2::new(868.7, 228.4)),
Event::PointerMoved(Pos2::new(868.9, 227.2)),
Event::PointerMoved(Pos2::new(869.1, 226.2)),
Event::PointerMoved(Pos2::new(869.1, 225.1)),
Event::PointerMoved(Pos2::new(869.1, 224.1)),
Event::PointerMoved(Pos2::new(869.1, 223.4)),
Event::PointerMoved(Pos2::new(869.1, 222.8)),
Event::PointerMoved(Pos2::new(869.1, 222.4)),
Event::PointerMoved(Pos2::new(869.1, 222.4)),
// Event::PointerButton {
// pos: Pos2::new(869.1, 222.4),
// button: PointerButton::Primary,
// pressed: false,
// modifiers: Modifiers::NONE,
// },
Event::PointerGone,
Event::PointerMoved(Pos2::new(779.3, 158.6)),
// --
// FIXME: This line looks weird. Probably because of a bug in the rasterizer when the X-coord is all the same.
Event::PointerButton {
pos: Pos2::new(779.3, 158.6),
button: PointerButton::Primary,
pressed: true,
modifiers: Modifiers::NONE,
},
Event::PointerMoved(Pos2::new(779.3, 159.0)),
Event::PointerMoved(Pos2::new(779.3, 160.9)),
Event::PointerMoved(Pos2::new(779.3, 164.6)),
Event::PointerMoved(Pos2::new(779.3, 169.6)),
Event::PointerMoved(Pos2::new(779.3, 175.2)),
Event::PointerMoved(Pos2::new(779.3, 180.3)),
Event::PointerMoved(Pos2::new(779.3, 185.0)),
Event::PointerMoved(Pos2::new(779.3, 189.4)),
Event::PointerMoved(Pos2::new(779.3, 192.8)),
Event::PointerMoved(Pos2::new(779.3, 194.9)),
Event::PointerMoved(Pos2::new(779.3, 196.1)),
Event::PointerMoved(Pos2::new(779.3, 197.0)),
Event::PointerMoved(Pos2::new(779.3, 197.6)),
Event::PointerMoved(Pos2::new(779.3, 198.1)),
Event::PointerMoved(Pos2::new(779.3, 198.5)),
Event::PointerMoved(Pos2::new(779.3, 198.8)),
Event::PointerMoved(Pos2::new(779.3, 199.0)),
Event::PointerMoved(Pos2::new(779.3, 199.2)),
Event::PointerMoved(Pos2::new(779.3, 199.2)),
Event::PointerButton {
pos: Pos2::new(779.3, 199.2),
button: PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
},
// --
Event::PointerMoved(Pos2::new(841.5, 159.2)),
Event::PointerButton {
pos: Pos2::new(841.5, 159.2),
button: PointerButton::Primary,
pressed: true,
modifiers: Modifiers::NONE,
},
Event::PointerMoved(Pos2::new(841.5, 159.3)),
Event::PointerMoved(Pos2::new(841.5, 159.7)),
Event::PointerMoved(Pos2::new(841.5, 160.5)),
Event::PointerMoved(Pos2::new(841.5, 162.4)),
Event::PointerMoved(Pos2::new(841.5, 165.1)),
Event::PointerMoved(Pos2::new(841.5, 168.4)),
Event::PointerMoved(Pos2::new(841.5, 171.6)),
Event::PointerMoved(Pos2::new(841.5, 174.4)),
Event::PointerMoved(Pos2::new(841.5, 177.1)),
Event::PointerMoved(Pos2::new(841.5, 179.3)),
Event::PointerMoved(Pos2::new(841.5, 180.9)),
Event::PointerMoved(Pos2::new(841.5, 182.4)),
Event::PointerMoved(Pos2::new(841.5, 183.5)),
Event::PointerMoved(Pos2::new(841.5, 184.5)),
Event::PointerMoved(Pos2::new(841.5, 185.6)),
Event::PointerMoved(Pos2::new(841.5, 187.0)),
Event::PointerMoved(Pos2::new(841.5, 188.6)),
Event::PointerMoved(Pos2::new(841.5, 190.3)),
Event::PointerMoved(Pos2::new(841.5, 191.8)),
Event::PointerMoved(Pos2::new(841.3, 192.7)),
Event::PointerMoved(Pos2::new(841.0, 193.3)),
Event::PointerMoved(Pos2::new(841.0, 193.7)),
Event::PointerMoved(Pos2::new(841.1, 193.9)),
Event::PointerMoved(Pos2::new(841.3, 193.9)),
Event::PointerMoved(Pos2::new(841.4, 194.0)),
Event::PointerMoved(Pos2::new(841.4, 194.2)),
Event::PointerMoved(Pos2::new(841.4, 194.6)),
Event::PointerMoved(Pos2::new(841.4, 194.9)),
Event::PointerMoved(Pos2::new(841.4, 195.1)),
Event::PointerMoved(Pos2::new(841.4, 195.1)),
Event::PointerButton {
pos: Pos2::new(841.4, 195.1),
button: PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
},
];
fn from_screen() -> RectTransform {
RectTransform::from_to(
Rect::from_two_pos(Pos2::new(570.0, 145.1), Pos2::new(1116.4, 245.1)),
Rect::from_two_pos(Pos2::new(0.0, 0.0), Pos2::new(546.4, 100.0)),
)
}
#[test]
fn handle_input() {
let mut tool_events = vec![];
let mut last_pos = None;
let from_screen = from_screen();
for event in TEST_EVENTS {
process_event(&mut last_pos, from_screen, event, |tool_event| {
tool_events.push(tool_event)
});
}
insta::assert_yaml_snapshot!(tool_events);
}
#[test]
fn input_to_handwriting() {
let mut handwriting = Handwriting::default();
let mut last_pos = None;
let from_screen = from_screen();
for event in TEST_EVENTS {
process_event(&mut last_pos, from_screen, event, |tool_event| {
handwriting.on_tool_event(tool_event);
});
}
let serialized = handwriting.to_string();
insta::assert_snapshot!("input events to handwriting", serialized);
}
}

View File

@ -0,0 +1,19 @@
---
source: src/handwriting/mod.rs
expression: deserialized.strokes
---
[
[
[-1.0 1.0],
[3.0 1.0],
[3.0 3.0],
[1.5 2.0],
[0.0 0.0],
],
[
[3.0 3.0],
[-1.0 1.0],
[0.0 0.0],
[3.0 1.0],
],
]

View File

@ -0,0 +1,455 @@
---
source: src/handwriting/mod.rs
expression: tool_events
---
- Press:
at:
x: 179.90002
y: 80.5
- Move:
to:
x: 179.90002
y: 80.59999
- Move:
to:
x: 179.90002
y: 81.29999
- Move:
to:
x: 180.20001
y: 83.29999
- Move:
to:
x: 181
y: 86.2
- Move:
to:
x: 182.59998
y: 89.29999
- Move:
to:
x: 184.09998
y: 92.59999
- Move:
to:
x: 185.79999
y: 96
- Move:
to:
x: 187.70001
y: 99.29999
- Move:
to:
x: 189.3
y: 102.29999
- Move:
to:
x: 190.8
y: 105.09999
- Move:
to:
x: 192.79999
y: 108.29998
- Move:
to:
x: 195.09998
y: 111.69999
- Move:
to:
x: 197.70001
y: 115.100006
- Move:
to:
x: 201.20001
y: 119.19998
- Move:
to:
x: 204.59998
y: 122.799995
- Move:
to:
x: 208.20001
y: 126.100006
- Move:
to:
x: 212.70001
y: 130.1
- Move:
to:
x: 216.70001
y: 133.4
- Move:
to:
x: 220.40002
y: 135.69998
- Move:
to:
x: 224.09998
y: 137.5
- Move:
to:
x: 227.90002
y: 138.79999
- Move:
to:
x: 231.90002
y: 139.69998
- Move:
to:
x: 235.90002
y: 140.4
- Move:
to:
x: 240.20001
y: 140.69998
- Move:
to:
x: 244.5
y: 140.69998
- Move:
to:
x: 248.20001
y: 140.5
- Move:
to:
x: 251.59996
y: 139.4
- Move:
to:
x: 254.70001
y: 137.9
- Move:
to:
x: 257.5
y: 136.29999
- Move:
to:
x: 260.40002
y: 134.5
- Move:
to:
x: 263.40002
y: 132.6
- Move:
to:
x: 266.09998
y: 130.6
- Move:
to:
x: 268.59998
y: 128.5
- Move:
to:
x: 270.90002
y: 126.600006
- Move:
to:
x: 273
y: 124.5
- Move:
to:
x: 275.40002
y: 122.100006
- Move:
to:
x: 277.70004
y: 120.00001
- Move:
to:
x: 279.8
y: 117.60001
- Move:
to:
x: 282
y: 114.899994
- Move:
to:
x: 284.3
y: 111.69999
- Move:
to:
x: 286.3
y: 108.29998
- Move:
to:
x: 288.2
y: 104.99999
- Move:
to:
x: 290
y: 102
- Move:
to:
x: 291.5
y: 99.2
- Move:
to:
x: 292.8
y: 96.899994
- Move:
to:
x: 294.09995
y: 95
- Move:
to:
x: 295
y: 93.09999
- Move:
to:
x: 295.8
y: 91.5
- Move:
to:
x: 296.5
y: 89.79999
- Move:
to:
x: 297.1
y: 88
- Move:
to:
x: 297.8
y: 86.29999
- Move:
to:
x: 298.40005
y: 84.7
- Move:
to:
x: 298.7
y: 83.29999
- Move:
to:
x: 298.90002
y: 82.09999
- Move:
to:
x: 299.1
y: 81.09999
- Move:
to:
x: 299.1
y: 80
- Move:
to:
x: 299.1
y: 79
- Move:
to:
x: 299.1
y: 78.29999
- Move:
to:
x: 299.1
y: 77.7
- Move:
to:
x: 299.1
y: 77.29999
- Release
- Press:
at:
x: 209.29999
y: 13.500001
- Move:
to:
x: 209.29999
y: 13.899994
- Move:
to:
x: 209.29999
y: 15.799988
- Move:
to:
x: 209.29999
y: 19.5
- Move:
to:
x: 209.29999
y: 24.5
- Move:
to:
x: 209.29999
y: 30.09999
- Move:
to:
x: 209.29999
y: 35.199997
- Move:
to:
x: 209.29999
y: 39.899994
- Move:
to:
x: 209.29999
y: 44.299988
- Move:
to:
x: 209.29999
y: 47.699997
- Move:
to:
x: 209.29999
y: 49.799988
- Move:
to:
x: 209.29999
y: 51
- Move:
to:
x: 209.29999
y: 51.899994
- Move:
to:
x: 209.29999
y: 52.499996
- Move:
to:
x: 209.29999
y: 52.999996
- Move:
to:
x: 209.29999
y: 53.399994
- Move:
to:
x: 209.29999
y: 53.699993
- Move:
to:
x: 209.29999
y: 53.89999
- Move:
to:
x: 209.29999
y: 54.09999
- Move:
to:
x: 209.29999
y: 54.09999
- Release
- Press:
at:
x: 271.5
y: 14.099991
- Move:
to:
x: 271.5
y: 14.199998
- Move:
to:
x: 271.5
y: 14.599991
- Move:
to:
x: 271.5
y: 15.399994
- Move:
to:
x: 271.5
y: 17.299988
- Move:
to:
x: 271.5
y: 20
- Move:
to:
x: 271.5
y: 23.299988
- Move:
to:
x: 271.5
y: 26.499998
- Move:
to:
x: 271.5
y: 29.299986
- Move:
to:
x: 271.5
y: 32
- Move:
to:
x: 271.5
y: 34.199997
- Move:
to:
x: 271.5
y: 35.799988
- Move:
to:
x: 271.5
y: 37.299988
- Move:
to:
x: 271.5
y: 38.399994
- Move:
to:
x: 271.5
y: 39.399994
- Move:
to:
x: 271.5
y: 40.5
- Move:
to:
x: 271.5
y: 41.899994
- Move:
to:
x: 271.5
y: 43.5
- Move:
to:
x: 271.5
y: 45.199997
- Move:
to:
x: 271.5
y: 46.699997
- Move:
to:
x: 271.3
y: 47.59999
- Move:
to:
x: 271
y: 48.199997
- Move:
to:
x: 271
y: 48.59999
- Move:
to:
x: 271.09998
y: 48.799988
- Move:
to:
x: 271.3
y: 48.799988
- Move:
to:
x: 271.40002
y: 48.899994
- Move:
to:
x: 271.40002
y: 49.09999
- Move:
to:
x: 271.40002
y: 49.5
- Move:
to:
x: 271.40002
y: 49.799988
- Move:
to:
x: 271.40002
y: 50
- Move:
to:
x: 271.40002
y: 50
- Release

View File

@ -0,0 +1,19 @@
---
source: src/handwriting/mod.rs
expression: handwriting.strokes
---
[
[
[-1.0 1.0],
[3.0 1.0],
[3.0 3.0],
[1.5 2.0],
[0.0 0.0],
],
[
[3.0 3.0],
[-1.0 1.0],
[0.0 0.0],
[3.0 1.0],
],
]

View File

@ -0,0 +1,7 @@
---
source: src/handwriting/mod.rs
expression: serialized
---
```handwriting
AQA9AJ9ZCFWfWQpVn1kVVaJZNVWoWWNVtVmVVcFZylXOWQBW3lk1VupZZVb2WZJWBlrFVhla+1YuWjJXSlpzV2VarVeCWuJXploRWMZaK1jjWj5YAVtMWB9bVlg/W15YX1tjWIJbZlikW2ZYwltkWN1bW1j2W09YBlxCWBJcNFgeXCVYKFwVWDJcBFg8XOpXRFzIV05coldXXIBXX1xaV2hcLldxXPtWeVzFVoFckFaIXGBWjlwzVpNcDlaYXPBVnFzSVZ9cuFWiXJ1VpFyAVadcZVWqXEtVq1w1VaxcIlWsXBJVrFwAVaxc8FSsXOVUrFzbVKxc1VQTAIpawEqKWvNKilrmS4pa4EyKWiBOilqGT4paZlCKWv1QilqKUYpa9lGKWjpSilpgUopafVKKWpBSilqgUoparVKKWrZSilq9Uopaw1IeAD5cDUs+XBpLPlxNSz5cs0s+XFNMPlwATT5c000+XKBOPlxTTz5cAFA+XEZQPlx6UD5cqlA+XM1QPlztUD5cEFE+XD1RPlxwUT5cplE+XNZRPVzzUTxcBlI8XBNSPFwaUj1cGlI+XB1SPlwjUj5cMFI+XDpSPlxAUg==
```

View File

@ -0,0 +1,7 @@
---
source: src/handwriting/mod.rs
expression: serialized
---
```handwriting
AQAFAAC8ADwAQgA8AEIAQgA+AEAAAAAABAAAQgBCALwAPAAAAAAAQgA8
```

View File

@ -0,0 +1,32 @@
use crate::handwriting::{Handwriting, ToolEvent};
use egui::Pos2;
pub const RADIUS: f32 = 12.0;
/// Handle a [ToolEvent]. Returns true if a stroke was completed.
pub fn on_tool_event(handwriting: &mut Handwriting, tool_event: ToolEvent) -> bool {
match tool_event {
ToolEvent::Press { at } => {
erase(handwriting, at, RADIUS)
}
ToolEvent::Move { to } => {
erase(handwriting, to, RADIUS)
}
ToolEvent::Release => {
false
}
}
}
fn erase(handwriting: &mut Handwriting, at: Pos2, radius: f32) -> bool {
let strokes = handwriting.strokes.len();
handwriting.strokes.retain(|stroke| {
stroke.iter().all(|&point| {
(point - at).length() > radius
})
});
handwriting.strokes.len() < strokes
}

View File

@ -0,0 +1,28 @@
pub mod pencil;
pub mod eraser;
use egui::Pos2;
use serde::Serialize;
pub enum Tool {
Pencil,
Eraser,
}
/// A simple event that can defines how a tool (e.g. the pen) is used on a [Handwriting].
#[derive(Debug, Serialize, Copy, Clone)]
pub enum ToolEvent {
Press { at: Pos2 },
Move { to: Pos2 },
Release,
}
impl ToolEvent {
pub const fn position(&self) -> Option<Pos2> {
match self {
&ToolEvent::Press { at } => Some(at),
&ToolEvent::Move { to } => Some(to),
ToolEvent::Release => None,
}
}
}

View File

@ -0,0 +1,22 @@
use crate::handwriting::{Handwriting, ToolEvent};
use std::mem;
/// Handle a [ToolEvent]. Returns true if a stroke was completed.
pub fn on_tool_event(handwriting: &mut Handwriting, tool_event: ToolEvent) -> bool {
match tool_event {
ToolEvent::Press { at } => {
debug_assert!(handwriting.e.current_stroke.is_empty());
handwriting.push_to_stroke(at);
false
}
ToolEvent::Move { to } => {
handwriting.push_to_stroke(to);
false
}
ToolEvent::Release => {
debug_assert!(!handwriting.e.current_stroke.is_empty());
handwriting.strokes.push(mem::take(&mut handwriting.e.current_stroke));
true
}
}
}

View File

@ -1,14 +1,14 @@
#![warn(clippy::all, rust_2018_idioms)]
pub mod app; pub mod app;
pub mod constants; pub mod constants;
pub mod custom_code_block; pub mod custom_code_block;
pub mod easy_mark;
pub mod file_editor; pub mod file_editor;
pub mod painting; pub mod folder;
pub mod handwriting;
pub mod markdown;
pub mod preferences; pub mod preferences;
pub mod rasterizer; pub mod rasterizer;
pub mod text_editor; pub mod text_editor;
pub mod text_styles;
pub mod util; pub mod util;
pub use app::App; pub use app::App;

View File

@ -49,7 +49,7 @@ fn main() {
.start( .start(
canvas, canvas,
web_options, web_options,
Box::new(|cc| Ok(Box::new(eframe_template::TemplateApp::new(cc)))), Box::new(|cc| Ok(Box::new(inkr::App::new(cc)))),
) )
.await; .await;

54
src/markdown/ast.rs Normal file
View File

@ -0,0 +1,54 @@
use super::span::Span;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Heading {
H1,
H2,
H3,
H4,
H5,
H6,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct Style {
/// # heading (large text)
pub heading: Option<Heading>,
/// > quoted (slightly dimmer color or other font style)
pub quoted: bool,
/// `code` (monospace, some other color)
pub code: bool,
/// self.strong* (emphasized, e.g. bold)
pub strong: bool,
/// _underline_
pub underline: bool,
/// ~strikethrough~
pub strikethrough: bool,
/// /italics/
pub italics: bool,
/// $small$
pub small: bool,
/// ^raised^
pub raised: bool,
}
pub enum Item<'a> {
Text {
span: Span<'a>,
style: Style,
},
CodeBlock {
all: Span<'a>,
language: Span<'a>,
code: Span<'a>,
},
}

View File

@ -0,0 +1,10 @@
use std::str::FromStr;
grammar;
pub Term: i32 = {
<n:Num> => n,
"(" <t:Term> ")" => t,
};
Num: i32 = <s:r"[0-9]+"> => i32::from_str(s).unwrap();

138
src/markdown/highlighter.rs Normal file
View File

@ -0,0 +1,138 @@
use egui::text::{CCursorRange, LayoutJob};
use crate::{
markdown::Heading,
text_styles::{H1, H1_MONO, H2, H2_MONO, H3, H3_MONO, H4, H4_MONO, H5, H5_MONO, H6, H6_MONO},
};
use super::{Item, Style, parse};
/// Highlight markdown, caching previous output to save CPU.
#[derive(Default)]
pub struct MemoizedHighlighter {
style: egui::Style,
code: String,
output: LayoutJob,
}
impl MemoizedHighlighter {
pub fn highlight(
&mut self,
egui_style: &egui::Style,
code: &str,
cursor: Option<CCursorRange>,
) -> LayoutJob {
if (&self.style, self.code.as_str()) != (egui_style, code) {
self.style = egui_style.clone();
code.clone_into(&mut self.code);
self.output = highlight_markdown(egui_style, code, cursor);
}
self.output.clone()
}
}
pub fn highlight_markdown(
egui_style: &egui::Style,
text: &str,
// TODO: hide special characters where cursor isn't
_cursor: Option<CCursorRange>,
) -> LayoutJob {
let mut job = LayoutJob::default();
let code_style = Style {
code: true,
..Default::default()
};
for item in parse(text) {
match item {
Item::Text { span, style } => {
job.append(&span, 0.0, format_from_style(egui_style, &style));
}
Item::CodeBlock {
all,
language: _, // TODO
code: _, // TODO
} => {
job.append(&all, 100.0, format_from_style(egui_style, &code_style));
}
}
}
job
}
fn format_from_style(egui_style: &egui::Style, style: &Style) -> egui::text::TextFormat {
use egui::{Align, Color32, Stroke, TextStyle};
let color = if style.code {
egui_style.visuals.strong_text_color() * Color32::GREEN
} else if style.strong || style.heading.is_some() {
egui_style.visuals.strong_text_color()
} else if style.quoted {
egui_style.visuals.weak_text_color()
} else {
egui_style.visuals.text_color()
};
let text_style = if let Some(heading) = style.heading {
let text_style = match (heading, style.code) {
(Heading::H1, false) => H1,
(Heading::H2, false) => H2,
(Heading::H3, false) => H3,
(Heading::H4, false) => H4,
(Heading::H5, false) => H5,
(Heading::H6, false) => H6,
(Heading::H1, true) => H1_MONO,
(Heading::H2, true) => H2_MONO,
(Heading::H3, true) => H3_MONO,
(Heading::H4, true) => H4_MONO,
(Heading::H5, true) => H5_MONO,
(Heading::H6, true) => H6_MONO,
};
TextStyle::Name(text_style.into())
} else if style.code {
TextStyle::Monospace
} else if style.small | style.raised {
TextStyle::Small
} else {
TextStyle::Body
};
let background = if style.code {
egui_style.visuals.code_bg_color
} else {
Color32::TRANSPARENT
};
let underline = if style.underline {
Stroke::new(1.0, color)
} else {
Stroke::NONE
};
let strikethrough = if style.strikethrough {
Stroke::new(1.0, color)
} else {
Stroke::NONE
};
let valign = if style.raised {
Align::TOP
} else {
Align::BOTTOM
};
egui::text::TextFormat {
font_id: text_style.resolve(egui_style),
color,
background,
italics: style.italics,
underline,
strikethrough,
valign,
..Default::default()
}
}

11
src/markdown/mod.rs Normal file
View File

@ -0,0 +1,11 @@
mod ast;
mod highlighter;
mod parser;
mod span;
mod tokenizer;
pub use ast::*;
pub use highlighter::*;
pub use parser::*;
pub use span::*;
pub use tokenizer::*;

170
src/markdown/parser.rs Normal file
View File

@ -0,0 +1,170 @@
use std::iter::{self, once};
use crate::markdown::Style;
use super::{Item, Span, Token, TokenKind, tokenize};
pub fn parse(text: &str) -> Vec<Item<'_>> {
let tokens: Vec<_> = tokenize(text).collect();
parse_tokens(&tokens)
}
pub fn parse_tokens<'a>(mut tokens: &[Token<'a>]) -> Vec<Item<'a>> {
// pretend that the first token was preceeded by a newline.
// means we don't have to handle the first token as a special case.
let mut prev = TokenKind::Newline;
let mut style = Style::default();
iter::from_fn(move || {
if tokens.is_empty() {
return None;
}
let token = tokens.first().unwrap();
tokens = &tokens[1..];
let start_of_line = prev == TokenKind::Newline;
prev = token.kind;
let mut basic_style: Option<fn(&mut Style) -> &mut bool> = None;
match token.kind {
TokenKind::CodeBlock if start_of_line => {
let language = collect_until(
None,
&mut tokens,
any_of([TokenKind::Newline]),
);
let code = collect_until(
None,
&mut tokens,
series([TokenKind::Newline, TokenKind::CodeBlock]),
);
let all = [
&token.span,
&language,
&code,
].into_iter().fold(Span::empty(), |a, b| a.try_merge(b).unwrap());
let language = language.trim_end_matches("\n");
let code = code.trim_end_matches("\n```");
return Some(Item::CodeBlock { all, language, code });
}
TokenKind::Newline => style = Style::default(),
TokenKind::Strong => basic_style = Some(|s| &mut s.strong),
TokenKind::Italic => basic_style = Some(|s| &mut s.italics),
TokenKind::Strikethrough => basic_style = Some(|s| &mut s.strikethrough),
TokenKind::CodeBlock | TokenKind::Mono => {
let span = collect_until(
Some(token),
&mut tokens,
any_of([TokenKind::Mono, TokenKind::CodeBlock, TokenKind::Newline]),
);
let mut style = style;
style.code = true;
return Some(Item::Text { span, style });
}
// TODO: different heading strengths
TokenKind::Heading(h) if start_of_line => style.heading = Some(h),
TokenKind::Quote if start_of_line => style.quoted = true,
// TODO: replace dashes with dots
//// TODO: indented list entries
//TokenKind::ListEntry if start_of_line => {
// job.append("• ", 0.0, format_from_style(egui_style, &style));
// continue;
//}
TokenKind::Text
// the following tokens are only richly rendered if encountered e.g. at start_of_line.
| TokenKind::Indentation
| TokenKind::ListEntry
| TokenKind::Heading(..)
| TokenKind::Quote => {}
}
// if we encountered a marker for Bold, Italic, or Strikethrough, toggle that style and
// render the token with the style enabled.
if let Some(basic_style) = basic_style {
let mut tmp_style = style;
*basic_style(&mut tmp_style) = true;
*basic_style(&mut style) ^= true; // toggle
return Some(Item::Text {
span: token.span.clone(),
style: tmp_style,
});
}
Some(Item::Text {
span: token.span.clone(),
style,
})
})
.collect()
}
fn series<'a, const N: usize>(of: [TokenKind; N]) -> impl FnMut(&[Token<'a>; N]) -> bool {
move |token| {
of.iter()
.zip(token)
.all(|(kind, token)| kind == &token.kind)
}
}
fn any_of<'a, const N: usize>(these: [TokenKind; N]) -> impl FnMut(&[Token<'a>; 1]) -> bool {
move |[token]| these.contains(&token.kind)
}
/// Collect all tokens up to and including `pattern`, and merge them into a signle span.
///
/// `N` determines how many specific and consecutive tokens we are looking for.
/// i.e. if we were looking for a [TokenKind::Newline] followed by a [TokenKind::Quote], `N`
/// would equal `2`.
///
/// `pattern` is a function that accepts an array of `N` tokens and returns `true` if they match,
/// i.e. if we should stop collecting. [any_of] and [series] can help to construct this function.
///
/// The collected tokens will be split off the head of the slice referred to by `tokens`.
///
/// # Panic
/// Panics if `tokens` does not contain only consecutive adjacent spans.
fn collect_until<'a, const N: usize>(
first_token: Option<&Token<'a>>,
tokens: &mut &[Token<'a>],
pattern: impl FnMut(&[Token<'a>; N]) -> bool,
) -> Span<'a>
where
// &[T; N]: TryFrom<&[T]>
for<'b> &'b [Token<'a>; N]: TryFrom<&'b [Token<'a>]>,
{
let mut windows = tokens.windows(N).map(|slice| {
<&[Token<'a>; N]>::try_from(slice)
.ok()
.expect("`windows` promises to return slices of length N")
});
let split_at = match windows.position(pattern) {
Some(i) => i + N,
None => tokens.len(), // consume everything
};
let (consume, keep) = tokens.split_at(split_at);
*tokens = keep;
once(first_token)
.flatten()
.chain(consume)
.fold(Span::empty(), |span: Span<'_>, token| {
span.try_merge(&token.span).unwrap()
})
}

View File

@ -0,0 +1,46 @@
---
source: src/markdown/tokenizer.rs
expression: examples
---
- string: "just some normal text :D"
tokens:
- "Token { span: Span(0..24, \"just some normal text :D\"), kind: Text }"
- string: normal *bold* normal
tokens:
- "Token { span: Span(0..7, \"normal \"), kind: Text }"
- "Token { span: Span(7..8, \"*\"), kind: Strong }"
- "Token { span: Span(8..12, \"bold\"), kind: Text }"
- "Token { span: Span(12..13, \"*\"), kind: Strong }"
- "Token { span: Span(13..20, \" normal\"), kind: Text }"
- string: normal * maybe bold? * normal
tokens:
- "Token { span: Span(0..7, \"normal \"), kind: Text }"
- "Token { span: Span(7..8, \"*\"), kind: Strong }"
- "Token { span: Span(8..21, \" maybe bold? \"), kind: Text }"
- "Token { span: Span(21..22, \"*\"), kind: Strong }"
- "Token { span: Span(22..29, \" normal\"), kind: Text }"
- string: "```lang\ncode code code\n```"
tokens:
- "Token { span: Span(0..3, \"```\"), kind: CodeBlock }"
- "Token { span: Span(3..7, \"lang\"), kind: Text }"
- "Token { span: Span(7..8, \"\\n\"), kind: Newline }"
- "Token { span: Span(8..22, \"code code code\"), kind: Text }"
- "Token { span: Span(22..23, \"\\n\"), kind: Newline }"
- "Token { span: Span(23..26, \"```\"), kind: CodeBlock }"
- string: "*_``_*"
tokens:
- "Token { span: Span(0..1, \"*\"), kind: Strong }"
- "Token { span: Span(1..2, \"_\"), kind: Italic }"
- "Token { span: Span(2..3, \"`\"), kind: Mono }"
- "Token { span: Span(3..4, \"`\"), kind: Mono }"
- "Token { span: Span(4..5, \"_\"), kind: Italic }"
- "Token { span: Span(5..6, \"*\"), kind: Strong }"
- string: "*_`*_*_"
tokens:
- "Token { span: Span(0..1, \"*\"), kind: Strong }"
- "Token { span: Span(1..2, \"_\"), kind: Italic }"
- "Token { span: Span(2..3, \"`\"), kind: Mono }"
- "Token { span: Span(3..4, \"*\"), kind: Strong }"
- "Token { span: Span(4..5, \"_\"), kind: Italic }"
- "Token { span: Span(5..6, \"*\"), kind: Strong }"
- "Token { span: Span(6..7, \"_\"), kind: Italic }"

115
src/markdown/span.rs Normal file
View File

@ -0,0 +1,115 @@
use std::{
fmt,
ops::{Deref, Range},
};
use eyre::{bail, eyre};
#[derive(Clone, Eq, PartialEq)]
pub struct Span<'a> {
complete_str: &'a str,
range: Range<usize>,
}
impl<'a> Span<'a> {
pub fn new(complete_str: &'a str) -> Self {
Self {
complete_str,
range: 0..complete_str.len(),
}
}
pub const fn empty() -> Self {
Span {
complete_str: "",
range: 0..0,
}
}
pub fn get(&self, slice: Range<usize>) -> Option<Self> {
let start = self.range.start.checked_add(slice.start)?;
let end = self.range.start.checked_add(slice.end)?;
if end > self.range.end || end < start {
return None;
}
Some(Self {
complete_str: self.complete_str,
range: Range { start, end },
})
}
pub fn complete_str(&self) -> Self {
Self::new(self.complete_str)
}
pub fn split_at(&self, i: usize) -> Option<(Self, Self)> {
let head = self.get(0..i)?;
let tail = self.get(i..self.range.len())?;
Some((head, tail))
}
pub fn trim_end_matches(&self, p: &str) -> Self {
if !self.ends_with(p) {
return self.clone();
}
Self {
range: self.range.start..self.range.end - p.len(),
complete_str: self.complete_str,
}
}
/// Try to merge the spans.
///
/// If either spans is empty, this just returns the other one.
/// This only works if spans are pointing into the same backing buffer, and are adjacent.
pub fn try_merge(&self, other: &Self) -> eyre::Result<Self> {
if self.is_empty() {
return Ok(other.clone());
}
if other.is_empty() {
return Ok(self.clone());
}
if self.complete_str.as_ptr() != other.complete_str.as_ptr() {
bail!("Can't merge different strings");
}
if self.range.end == other.range.start {
Ok(Self {
range: self.range.start..other.range.end,
..*self
})
} else if self.range.start == other.range.end {
Ok(Self {
range: other.range.start..self.range.end,
..*self
})
} else {
Err(eyre!("String: {:?}", self.complete_str)
.wrap_err(eyre!("Span 2: {:?}", other.deref()))
.wrap_err(eyre!("Span 1: {:?}", self.deref()))
.wrap_err("Can't merge disjoint string spans"))
}
}
}
impl Deref for Span<'_> {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.complete_str[self.range.clone()]
}
}
impl<'a> fmt::Debug for Span<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Span")
.field(&self.range)
.field(&self.deref())
.finish()
}
}

146
src/markdown/tokenizer.rs Normal file
View File

@ -0,0 +1,146 @@
use std::iter;
use super::{Heading, span::Span};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TokenKind {
/// A newline that isn't a codeblock
Newline,
/// "#" to "######"
Heading(Heading),
/// A newline followed by three `
CodeBlock,
Mono,
Strong,
Italic,
Strikethrough,
/// ">"
Quote,
/// Two spaces
Indentation,
/// "- "
ListEntry,
/// Normal text
Text,
}
const TOKENS: &[(&str, TokenKind)] = &[
("\n", TokenKind::Newline),
("######", TokenKind::Heading(Heading::H6)),
("#####", TokenKind::Heading(Heading::H5)),
("####", TokenKind::Heading(Heading::H4)),
("###", TokenKind::Heading(Heading::H3)),
("##", TokenKind::Heading(Heading::H2)),
("#", TokenKind::Heading(Heading::H1)),
("```", TokenKind::CodeBlock),
("`", TokenKind::Mono),
("*", TokenKind::Strong),
("_", TokenKind::Italic),
("~", TokenKind::Strikethrough),
(">", TokenKind::Quote),
(" ", TokenKind::Indentation),
("- ", TokenKind::ListEntry),
];
#[derive(Debug)]
pub struct Token<'a> {
pub span: Span<'a>,
pub kind: TokenKind,
}
pub fn tokenize<'a>(s: &'a str) -> impl Iterator<Item = Token<'a>> {
let mut s = Span::new(s);
let mut yield_n: usize = 0;
iter::from_fn(move || {
loop {
if s.is_empty() {
return None;
}
if yield_n == s.len() {
let (token, rest) = s.split_at(s.len()).unwrap();
let token = Token {
span: token,
kind: TokenKind::Text,
};
s = rest;
return Some(token);
}
let token = TOKENS.iter().find_map(|(token_str, token_kind)| {
s[yield_n..]
.starts_with(token_str)
.then(|| (*token_kind, token_str.len()))
});
let Some((kind, len)) = token else {
yield_n += s[yield_n..].chars().next().unwrap_or('\0').len_utf8();
continue;
};
if yield_n > 0 {
let (token, rest) = s.split_at(yield_n).unwrap();
let token = Token {
span: token,
kind: TokenKind::Text,
};
s = rest;
yield_n = 0;
return Some(token);
}
let (token, rest) = s.split_at(len).unwrap();
let token = Token { span: token, kind };
s = rest;
return Some(token);
}
})
}
#[cfg(test)]
mod tests {
use serde::Serialize;
use super::tokenize;
#[test]
fn test_tokenize() {
let examples = [
"just some normal text :D",
"normal *bold* normal",
"normal * maybe bold? * normal",
"```lang\ncode code code\n```",
"*_``_*",
"*_`*_*_",
];
#[derive(Serialize)]
struct Result {
pub string: &'static str,
/// Debug-printed tokens
pub tokens: Vec<String>,
}
let examples = examples
.into_iter()
.map(|string| {
let tokens = tokenize(string)
.map(|tokens| format!("{tokens:?}"))
.collect::<Vec<_>>();
Result { string, tokens }
})
.collect::<Vec<_>>();
insta::assert_yaml_snapshot!(examples);
}
}

View File

@ -1,698 +0,0 @@
use std::{
fmt::{self, Display},
iter, mem,
str::FromStr,
sync::Arc,
time::Instant,
};
use base64::{Engine, prelude::BASE64_STANDARD};
use egui::{
Color32, ColorImage, CornerRadius, Event, Frame, Id, Mesh, PointerButton, Pos2, Rect, Sense,
Shape, Stroke, TextureHandle, Theme, Ui, Vec2,
emath::{self, TSTransform},
epaint::{Brush, RectShape, TessellationOptions, Tessellator, Vertex},
load::SizedTexture,
};
use eyre::{Context, bail};
use eyre::{OptionExt, eyre};
use half::f16;
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
use crate::{
custom_code_block::try_from_custom_code_block,
rasterizer::{self, rasterize, rasterize_onto},
};
use crate::{custom_code_block::write_custom_code_block, util::random_id};
const HANDWRITING_MIN_HEIGHT: f32 = 100.0;
const HANDWRITING_BOTTOM_PADDING: f32 = 80.0;
const HANDWRITING_MARGIN: f32 = 0.05;
const HANDWRITING_LINE_SPACING: f32 = 36.0;
pub const CODE_BLOCK_KEY: &str = "handwriting";
type StrokeBlendMode = rasterizer::blend::Normal;
const TESSELATION_OPTIONS: TessellationOptions = TessellationOptions {
feathering: true,
feathering_size_in_pixels: 1.0,
coarse_tessellation_culling: true,
prerasterized_discs: true,
round_text_to_pixels: true,
round_line_segments_to_pixels: true,
round_rects_to_pixels: true,
debug_paint_text_rects: false,
debug_paint_clip_rects: false,
debug_ignore_clip_rects: false,
bezier_tolerance: 0.1,
epsilon: 1.0e-5,
parallel_tessellation: true,
validate_meshes: false,
};
pub struct HandwritingStyle {
pub stroke: Stroke,
pub bg_line_stroke: Stroke,
pub bg_color: Color32,
pub animate: bool,
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct Handwriting {
#[serde(skip, default = "random_id")]
id: Id,
strokes: Vec<Vec<Pos2>>,
/// The stroke that is currently being drawed.
#[serde(skip)]
current_stroke: Vec<Pos2>,
/// The lines that have not been blitted to `texture` yet.
#[serde(skip)]
unblitted_lines: Vec<[Pos2; 2]>,
height: f32,
desired_height: f32,
/// Tesselated mesh of all strokes
#[serde(skip)]
mesh: Arc<Mesh>,
#[serde(skip)]
texture: Option<TextureHandle>,
#[serde(skip)]
image: ColorImage,
#[serde(skip)]
refresh_texture: bool,
/// Context of the last mesh render.
#[serde(skip)]
last_mesh_ctx: Option<MeshContext>,
}
/// Context of a mesh render.
#[derive(Clone, Copy, PartialEq)]
struct MeshContext {
/// Need to update the mesh when the stroke color changes.
pub ui_theme: Theme,
pub pixels_per_point: f32,
/// Canvas size in points
pub size: Vec2,
pub stroke: Stroke,
}
/// Get [Painting::texture], initializing it if necessary.
macro_rules! texture {
($self_:expr, $ui:expr, $mesh_context:expr) => {{
let ui: &Ui = $ui;
let mesh_context: &MeshContext = $mesh_context;
let image_size = mesh_context.pixel_size();
let new_image = || {
let image = ColorImage::new(image_size, Color32::TRANSPARENT);
ui.ctx()
.load_texture("handwriting", image, Default::default())
};
let texture = $self_.texture.get_or_insert_with(new_image);
if texture.size() != image_size {
$self_.refresh_texture = true;
// TODO: don't redraw the entire mesh, just blit the old texture onto the new one
*texture = new_image()
};
texture
}};
}
impl MeshContext {
/// Calculate canvas size in pixels
pub fn pixel_size(&self) -> [usize; 2] {
let Vec2 { x, y } = self.size * self.pixels_per_point;
[x, y].map(|f| f.ceil() as usize)
}
}
impl Default for Handwriting {
fn default() -> Self {
Self {
id: random_id(),
strokes: Default::default(),
current_stroke: Default::default(),
height: HANDWRITING_MIN_HEIGHT,
desired_height: HANDWRITING_MIN_HEIGHT,
mesh: Default::default(),
texture: None,
image: ColorImage::new([0, 0], Color32::WHITE),
refresh_texture: true,
last_mesh_ctx: None,
unblitted_lines: Default::default(),
}
}
}
impl Handwriting {
pub fn ui_control(
&mut self,
style: Option<&mut HandwritingStyle>,
ui: &mut egui::Ui,
) -> egui::Response {
ui.horizontal(|ui| {
if let Some(style) = style {
ui.label("Stroke:");
ui.add(&mut style.stroke);
ui.separator();
}
if ui.button("Clear Painting").clicked() {
self.strokes.clear();
self.refresh_texture = true;
}
ui.add_enabled_ui(!self.strokes.is_empty(), |ui| {
if ui.button("Undo").clicked() {
self.strokes.pop();
self.refresh_texture = true;
}
});
let vertex_count: usize = self.mesh.indices.len() / 3;
ui.label(format!("vertices: {vertex_count}"));
})
.response
}
fn commit_current_line(&mut self) {
debug_assert!(!self.current_stroke.is_empty());
self.strokes.push(mem::take(&mut self.current_stroke));
}
pub fn ui_content(&mut self, style: &HandwritingStyle, ui: &mut Ui) -> egui::Response {
if style.animate {
self.height = ui.ctx().animate_value_with_time(
self.id.with("height animation"),
self.desired_height,
0.4,
);
} else {
self.height = self.desired_height;
}
let size = Vec2::new(ui.available_width(), self.height);
let (response, painter) = ui.allocate_painter(size, Sense::drag());
let mut response = response
//.on_hover_cursor(CursorIcon::Crosshair)
//.on_hover_and_drag_cursor(CursorIcon::None)
;
let size = response.rect.size();
let to_screen = emath::RectTransform::from_to(
//Rect::from_min_size(Pos2::ZERO, response.rect.square_proportions()),
Rect::from_min_size(Pos2::ZERO, size),
response.rect,
);
let from_screen = to_screen.inverse();
let is_drawing = response.interact_pointer_pos().is_some();
let was_drawing = !self.current_stroke.is_empty();
if !is_drawing {
// commit current line
if was_drawing {
self.commit_current_line();
response.mark_changed();
}
// recalculate how tall the widget should be
let lines_max_y = self
.strokes
.iter()
.flatten()
.map(|p| p.y + HANDWRITING_BOTTOM_PADDING)
.fold(HANDWRITING_MIN_HEIGHT, |max, y| max.max(y));
if self.desired_height != lines_max_y {
self.desired_height = lines_max_y;
response.mark_changed();
}
} else {
let events = ui.ctx().input(|input| {
// If we are getting both MouseMoved and PointerMoved events, ignore the first.
let mut events = input.raw.events.iter().peekable();
iter::from_fn(move || {
let next = events.next()?;
let Some(peek) = events.peek() else {
return Some(next);
};
match next {
Event::PointerMoved(..) if matches!(peek, Event::MouseMoved(..)) => {
let _ = events.next(); // drop the MouseMoved event
Some(next)
}
Event::MouseMoved(..) if matches!(peek, Event::PointerMoved(..)) => {
// return the peeked PointerMoved instead
Some(events.next().expect("next is some"))
}
_ => Some(next),
}
})
.cloned()
.filter(|event| {
// FIXME: pinenote: PointerMoved are duplicated after the MouseMoved events
cfg!(not(feature = "pinenote")) || !matches!(event, Event::PointerMoved(..))
})
.collect::<Vec<_>>()
});
for event in events {
let last_canvas_pos = self.current_stroke.last();
match event {
Event::PointerMoved(new_position) => {
let new_canvas_pos = from_screen * new_position;
if let Some(&last_canvas_pos) = last_canvas_pos {
if last_canvas_pos != new_canvas_pos {
self.push_to_stroke(new_canvas_pos);
response.mark_changed();
}
}
}
Event::MouseMoved(mut delta) => {
if delta.length() == 0.0 {
continue;
}
// FIXME: pinenote: MouseMovement delta does *not* take into account screen
// scaling and rotation, so unless you've scaling=1 and no rotation, the
// MouseMoved values will be all wrong.
if cfg!(feature = "pinenote") {
delta /= 1.8;
delta = -delta.rot90();
}
if let Some(&last_canvas_pos) = last_canvas_pos {
self.push_to_stroke(last_canvas_pos + delta);
response.mark_changed();
} else {
println!("Got `MouseMoved`, but have no previous pos");
}
}
Event::PointerButton {
pos,
button,
pressed,
modifiers: _,
} => match (button, pressed) {
(PointerButton::Primary, true) => {
if last_canvas_pos.is_none() {
self.current_stroke.push(from_screen * pos);
}
}
(PointerButton::Primary, false) => {
if last_canvas_pos.is_some() {
self.push_to_stroke(from_screen * pos);
self.commit_current_line();
response.mark_changed();
}
// Stop reading events.
// TODO: In theory, we can get multiple press->draw->release series
// in the same frame. Should handle this.
break;
}
(_, _) => continue,
},
// Stop drawing after pointer disappears or the window is unfocused
// TODO: In theory, we can get multiple press->draw->release series
// in the same frame. Should handle this.
Event::PointerGone | Event::WindowFocused(false) => {
if !self.current_stroke.is_empty() {
self.commit_current_line();
break;
}
}
Event::WindowFocused(true)
| Event::Copy
| Event::Cut
| Event::Paste(..)
| Event::Text(..)
| Event::Key { .. }
| Event::Zoom(..)
| Event::Ime(..)
| Event::Touch { .. }
| Event::MouseWheel { .. }
| Event::Screenshot { .. } => continue,
}
}
}
(1..)
.map(|n| n as f32 * HANDWRITING_LINE_SPACING)
.take_while(|&y| y < size.y)
.map(|y| {
let l = to_screen * Pos2::new(HANDWRITING_MARGIN * size.x, y);
let r = to_screen * Pos2::new((1.0 - HANDWRITING_MARGIN) * size.x, y);
Shape::hline(l.x..=r.x, l.y, style.bg_line_stroke)
})
.for_each(|shape| {
painter.add(shape);
});
let mesh_rect = response
.rect
.with_max_y(response.rect.min.y + self.desired_height);
let new_context = MeshContext {
ui_theme: ui.ctx().theme(),
pixels_per_point: ui.pixels_per_point(),
size: mesh_rect.size(),
stroke: style.stroke,
};
if Some(&new_context) != self.last_mesh_ctx.as_ref() {
self.refresh_texture = true;
}
if self.refresh_texture {
// rasterize the entire texture from scratch
self.refresh_texture(style, new_context, ui);
self.unblitted_lines.clear();
} else if !self.unblitted_lines.is_empty() {
// only rasterize the new lines onto the existing texture
for [from, to] in std::mem::take(&mut self.unblitted_lines) {
self.draw_line_to_texture(from, to, &new_context, ui);
}
self.unblitted_lines.clear();
}
//painter.add(self.mesh.clone());
if let Some(texture) = &self.texture {
let texture = SizedTexture::new(texture.id(), texture.size_vec2());
let shape = RectShape {
rect: mesh_rect,
corner_radius: CornerRadius::ZERO,
fill: Color32::WHITE,
stroke: Stroke::NONE,
stroke_kind: egui::StrokeKind::Inside,
round_to_pixels: None,
blur_width: 0.0,
brush: Some(Arc::new(Brush {
fill_texture_id: texture.id,
uv: Rect {
min: Pos2::ZERO,
max: Pos2::new(1.0, 1.0),
},
})),
};
painter.add(shape);
}
response
}
fn refresh_texture(
&mut self,
style: &HandwritingStyle,
mesh_context: MeshContext,
ui: &mut Ui,
) {
self.last_mesh_ctx = Some(mesh_context);
self.refresh_texture = false;
let start_time = Instant::now();
let mut tesselator = Tessellator::new(
mesh_context.pixels_per_point,
TESSELATION_OPTIONS,
Default::default(), // we don't tesselate fonts
vec![],
);
let mesh = Arc::make_mut(&mut self.mesh);
mesh.clear();
self.strokes
.iter()
.chain([&self.current_stroke])
.filter(|stroke| stroke.len() >= 2)
.map(|stroke| {
//let points: Vec<Pos2> = stroke.iter().map(|&p| to_screen * p).collect();
egui::Shape::line(stroke.clone(), style.stroke)
})
.for_each(|shape| {
tesselator.tessellate_shape(shape, mesh);
});
let texture = texture!(self, ui, &mesh_context);
let triangles = mesh_triangles(&self.mesh);
let [px_x, px_y] = mesh_context.pixel_size();
let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point);
self.image = rasterize::<StrokeBlendMode>(px_x, px_y, point_to_pixel, triangles);
texture.set(self.image.clone(), Default::default());
let elapsed = start_time.elapsed();
println!("refreshed mesh in {:.3}s", elapsed.as_secs_f32());
}
pub fn ui(&mut self, style: &HandwritingStyle, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
self.ui_control(None, ui);
//ui.label("Paint with your mouse/touch!");
Frame::canvas(ui.style())
.corner_radius(20.0)
.stroke(Stroke::new(5.0, Color32::from_black_alpha(40)))
.fill(style.bg_color)
.show(ui, |ui| {
self.ui_content(style, ui);
});
});
}
fn push_to_stroke(&mut self, new_canvas_pos: Pos2) {
if let Some(&last_canvas_pos) = self.current_stroke.last() {
if last_canvas_pos == new_canvas_pos {
return;
}
self.unblitted_lines.push([last_canvas_pos, new_canvas_pos]);
}
self.current_stroke.push(new_canvas_pos);
}
/// Draw a single line onto the existing texture.
fn draw_line_to_texture(
&mut self,
from: Pos2,
to: Pos2,
mesh_context: &MeshContext,
ui: &mut Ui,
) {
let mut tesselator = Tessellator::new(
mesh_context.pixels_per_point,
TESSELATION_OPTIONS,
Default::default(), // we don't tesselate fonts
vec![],
);
let mut mesh = Mesh::default();
let line = egui::Shape::line_segment([from, to], mesh_context.stroke);
tesselator.tessellate_shape(line, &mut mesh);
self.draw_mesh_to_texture(&mesh, mesh_context, ui);
}
/// Draw a single mesh onto the existing texture.
fn draw_mesh_to_texture(&mut self, mesh: &Mesh, mesh_context: &MeshContext, ui: &mut Ui) {
let triangles = mesh_triangles(mesh);
let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point);
rasterize_onto::<StrokeBlendMode>(&mut self.image, point_to_pixel, triangles);
texture!(self, ui, mesh_context).set(self.image.clone(), Default::default());
}
pub fn strokes(&self) -> &[Vec<Pos2>] {
&self.strokes
}
#[cfg(test)]
pub fn example() -> Self {
Handwriting {
strokes: vec![
vec![
Pos2::new(-1.0, 1.0),
Pos2::new(3.0, 1.0),
Pos2::new(3.0, 3.0),
Pos2::new(1.5, 2.0),
Pos2::new(0.0, 0.0),
],
vec![
Pos2::new(3.0, 3.0),
Pos2::new(-1.0, 1.0),
Pos2::new(0.0, 0.0),
Pos2::new(3.0, 1.0),
],
],
..Default::default()
}
}
}
impl Display for Handwriting {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut raw = vec![];
for stroke in &self.strokes {
raw.push((stroke.len() as u16).to_le_bytes());
for position in stroke {
let x = half::f16::from_f32(position.x);
let y = half::f16::from_f32(position.y);
raw.push(x.to_bits().to_le_bytes());
raw.push(y.to_bits().to_le_bytes());
}
}
let raw = raw.as_slice().as_bytes();
write_custom_code_block(f, CODE_BLOCK_KEY, BASE64_STANDARD.encode(raw))
}
}
impl FromStr for Handwriting {
type Err = eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = try_from_custom_code_block(CODE_BLOCK_KEY, s)
.ok_or_eyre("Not a valid ```handwriting-block")?;
let bytes = BASE64_STANDARD
.decode(s)
.wrap_err("Failed to decode painting data from base64")?;
#[allow(non_camel_case_types)]
type u16_le = [u8; 2];
#[allow(non_camel_case_types)]
type f16_le = [u8; 2];
#[derive(FromBytes, KnownLayout, Immutable)]
#[repr(C, packed)]
struct Stroke {
pub len: u16_le,
pub positions: [f16_le],
}
let mut bytes = &bytes[..];
let mut strokes = vec![];
while !bytes.is_empty() {
let header_len = size_of::<u16_le>();
if bytes.len() < header_len {
bail!("Invalid remaining length: {}", bytes.len());
}
let stroke = Stroke::ref_from_bytes(&bytes[..header_len]).expect("length is correct");
let len = usize::from(u16::from_le_bytes(stroke.len));
let len = len * size_of::<f16_le>() * 2;
if bytes.len() < len {
bail!("Invalid remaining length: {}", bytes.len());
}
let (stroke, rest) = bytes.split_at(header_len + len);
bytes = rest;
let stroke = Stroke::ref_from_bytes(stroke)
.map_err(|e| eyre!("Failed to decode stroke bytes: {e}"))?;
let mut positions = stroke
.positions
.iter()
.map(|&position| f16::from_bits(u16::from_le_bytes(position)));
let mut stroke = vec![];
while let Some(x) = positions.next() {
let Some(y) = positions.next() else {
unreachable!("len is a multiple of two");
};
stroke.push(Pos2::new(x.into(), y.into()));
}
strokes.push(stroke);
}
Ok(Handwriting {
strokes,
..Default::default()
})
}
}
impl HandwritingStyle {
pub fn from_theme(theme: Theme) -> Self {
let stroke_color;
let bg_color;
let line_color;
match theme {
Theme::Dark => {
stroke_color = Color32::WHITE;
bg_color = Color32::from_gray(30);
line_color = Color32::from_rgb(100, 100, 100);
}
Theme::Light => {
stroke_color = Color32::BLACK;
bg_color = Color32::WHITE;
line_color = Color32::from_rgb(130, 130, 130); // TODO
}
}
HandwritingStyle {
stroke: Stroke::new(1.6, stroke_color),
bg_color,
bg_line_stroke: Stroke::new(0.5, line_color),
animate: true,
}
}
}
fn mesh_triangles(mesh: &Mesh) -> impl Iterator<Item = [&Vertex; 3]> {
mesh.triangles()
.map(|indices| indices.map(|i| &mesh.vertices[i as usize]))
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use super::Handwriting;
#[test]
fn serialize_handwriting() {
let handwriting = Handwriting::example();
insta::assert_debug_snapshot!("handwriting example", handwriting.strokes);
let serialized = handwriting.to_string();
insta::assert_snapshot!("serialized handwriting", serialized);
let deserialized =
Handwriting::from_str(&serialized).expect("Handwriting must de/serialize correctly");
insta::assert_debug_snapshot!("deserialized handwriting", deserialized.strokes);
}
}

View File

@ -1,4 +1,4 @@
use egui::{Color32, Context, RichText, Theme, Ui, Visuals}; use egui::{Color32, Context, RichText, Theme, Ui, Visuals, style::ScrollAnimation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -10,8 +10,11 @@ pub struct Preferences {
/// Enable high-contrast theme /// Enable high-contrast theme
pub high_contrast: bool, pub high_contrast: bool,
/// Hide the cursor when handwriting
pub hide_handwriting_cursor: bool,
#[serde(skip)] #[serde(skip)]
has_applied_theme: bool, has_applied_prefs: bool,
} }
impl Default for Preferences { impl Default for Preferences {
@ -19,7 +22,8 @@ impl Default for Preferences {
Self { Self {
animations: true, animations: true,
high_contrast: false, high_contrast: false,
has_applied_theme: false, has_applied_prefs: false,
hide_handwriting_cursor: false,
} }
} }
} }
@ -27,16 +31,38 @@ impl Default for Preferences {
impl Preferences { impl Preferences {
/// Apply preferences, if they haven't already been applied. /// Apply preferences, if they haven't already been applied.
pub fn apply(&mut self, ctx: &Context) { pub fn apply(&mut self, ctx: &Context) {
if !self.has_applied_theme { if !self.has_applied_prefs {
self.has_applied_theme = true; self.has_applied_prefs = true;
let scroll_animation = if self.animations {
ScrollAnimation::default()
} else {
ScrollAnimation::none()
};
for theme in [Theme::Dark, Theme::Light] {
ctx.style_mut_of(theme, |style| {
style.scroll_animation = scroll_animation;
});
}
let mut dark_visuals = Visuals::dark();
let mut light_visuals = Visuals::light();
dark_visuals.window_fill = Color32::from_rgb(0x1e, 0x1e, 0x1e);
dark_visuals.panel_fill = Color32::from_rgb(0x26, 0x26, 0x26);
light_visuals.window_fill = Color32::WHITE;
light_visuals.panel_fill = Color32::from_rgb(0xf6, 0xf6, 0xf6);
dark_visuals.code_bg_color = Color32::BLACK;
light_visuals.code_bg_color = Color32::WHITE;
if self.high_contrast { if self.high_contrast {
// widgets.active: color of headers in textedit // widgets.active: color of headers in textedit
// widgets.inactive: color of button labels // widgets.inactive: color of button labels
// widgets.hovered: color of hovered button labels // widgets.hovered: color of hovered button labels
// widgets.noninteractive: color of labels and normal textedit text // widgets.noninteractive: color of labels and normal textedit text
let mut dark_visuals = Visuals::dark();
let mut light_visuals = Visuals::light();
dark_visuals.widgets.noninteractive.fg_stroke.color = Color32::WHITE; dark_visuals.widgets.noninteractive.fg_stroke.color = Color32::WHITE;
dark_visuals.widgets.inactive.fg_stroke.color = Color32::WHITE; dark_visuals.widgets.inactive.fg_stroke.color = Color32::WHITE;
@ -45,13 +71,16 @@ impl Preferences {
light_visuals.widgets.noninteractive.fg_stroke.color = Color32::BLACK; light_visuals.widgets.noninteractive.fg_stroke.color = Color32::BLACK;
light_visuals.widgets.inactive.fg_stroke.color = Color32::BLACK; light_visuals.widgets.inactive.fg_stroke.color = Color32::BLACK;
light_visuals.widgets.hovered.fg_stroke.color = Color32::BLACK; light_visuals.widgets.hovered.fg_stroke.color = Color32::BLACK;
ctx.set_visuals_of(Theme::Dark, dark_visuals);
ctx.set_visuals_of(Theme::Light, light_visuals);
} else { } else {
ctx.set_visuals_of(Theme::Dark, Visuals::dark()); dark_visuals.widgets.noninteractive.fg_stroke.color =
ctx.set_visuals_of(Theme::Light, Visuals::light()); Color32::from_rgb(0xaa, 0xaa, 0xaa);
light_visuals.widgets.noninteractive.fg_stroke.color =
Color32::from_rgb(0x11, 0x11, 0x11);
} }
ctx.set_visuals_of(Theme::Dark, dark_visuals);
ctx.set_visuals_of(Theme::Light, light_visuals);
} }
} }
@ -59,14 +88,20 @@ impl Preferences {
pub fn show(&mut self, ui: &mut Ui) { pub fn show(&mut self, ui: &mut Ui) {
ui.label(RichText::new("Prefs").weak()); ui.label(RichText::new("Prefs").weak());
ui.toggle_value(&mut self.animations, "Animations"); let animations_toggle = ui.toggle_value(&mut self.animations, "Animations");
if animations_toggle.clicked() {
self.has_applied_prefs = false;
self.apply(ui.ctx());
}
let high_contrast_toggle = ui.toggle_value(&mut self.high_contrast, "High Contrast"); let high_contrast_toggle = ui.toggle_value(&mut self.high_contrast, "High Contrast");
if high_contrast_toggle.clicked() { if high_contrast_toggle.clicked() {
self.has_applied_theme = false; self.has_applied_prefs = false;
self.apply(ui.ctx()); self.apply(ui.ctx());
} }
ui.toggle_value(&mut self.hide_handwriting_cursor, "Hide Handwriting Cursor");
egui::widgets::global_theme_preference_buttons(ui); egui::widgets::global_theme_preference_buttons(ui);
} }
} }

View File

@ -1,5 +1,6 @@
use core::f32; use core::f32;
use egui::{Color32, ColorImage, Pos2, Rect, Vec2, emath::TSTransform, epaint::Vertex}; use egui::{Color32, ColorImage, Pos2, Rect, Vec2, emath::TSTransform, epaint::Vertex};
use std::ops::Range;
pub trait BlendFn { pub trait BlendFn {
fn blend(a: Color32, b: Color32) -> Color32; fn blend(a: Color32, b: Color32) -> Color32;
@ -21,7 +22,7 @@ pub fn rasterize<'a, Blend: BlendFn>(
point_to_pixel: TSTransform, point_to_pixel: TSTransform,
triangles: impl Iterator<Item = [&'a Vertex; 3]>, triangles: impl Iterator<Item = [&'a Vertex; 3]>,
) -> ColorImage { ) -> ColorImage {
let mut image = ColorImage::new([width, height], Color32::TRANSPARENT); let mut image = ColorImage::filled([width, height], Color32::TRANSPARENT);
rasterize_onto::<Blend>(&mut image, point_to_pixel, triangles); rasterize_onto::<Blend>(&mut image, point_to_pixel, triangles);
image image
} }
@ -72,15 +73,11 @@ pub fn rasterize_onto<'a, Blend: BlendFn>(
// If the pixel is within the triangle, fill it in. // If the pixel is within the triangle, fill it in.
if point_in_triangle.inside { if point_in_triangle.inside {
let c0 = triangle[0] let [c0, c1, c2] = [0, 1, 2].map(|i| {
.color triangle[i]
.linear_multiply(point_in_triangle.weights[0]); .color
let c1 = triangle[1] .linear_multiply(point_in_triangle.weights[i])
.color });
.linear_multiply(point_in_triangle.weights[1]);
let c2 = triangle[2]
.color
.linear_multiply(point_in_triangle.weights[2]);
let color = c0 + c1 + c2; let color = c0 + c1 + c2;
@ -90,9 +87,20 @@ pub fn rasterize_onto<'a, Blend: BlendFn>(
} }
} }
/// Rasterize a single triangles onto an image,
///
/// Triangle positions must be in image-local point-coords.
pub fn rasterize_triangle_onto<Blend: BlendFn>(
image: &mut ColorImage,
point_to_pixel: TSTransform,
triangle: [&Vertex; 3],
) {
rasterize_onto::<Blend>(image, point_to_pixel, [triangle].into_iter());
}
/// A bounding box, measured in pixels. /// A bounding box, measured in pixels.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
struct PxBoundingBox { pub struct PxBoundingBox {
pub x_from: usize, pub x_from: usize,
pub y_from: usize, pub y_from: usize,
pub x_to: usize, pub x_to: usize,
@ -108,9 +116,41 @@ impl PxBoundingBox {
y_to: self.y_to.min(other.y_to), y_to: self.y_to.min(other.y_to),
} }
} }
pub fn union(&self, other: &PxBoundingBox) -> PxBoundingBox {
PxBoundingBox {
x_from: self.x_from.min(other.x_from),
y_from: self.y_from.min(other.y_from),
x_to: self.x_to.max(other.x_to),
y_to: self.y_to.max(other.y_to),
}
}
pub fn x_range(&self) -> Range<usize> {
self.x_from..self.x_to
}
pub fn y_range(&self) -> Range<usize> {
self.y_from..self.y_to
}
/// Test whether two boxes do NOT overlap
pub fn overlaps_with(&self, other: &PxBoundingBox) -> bool {
!self.is_disjoint_from(other)
}
pub fn is_disjoint_from(&self, other: &PxBoundingBox) -> bool {
self.x_from > other.x_to
|| self.y_from > other.y_to
|| other.x_from > self.x_to
|| other.y_from > self.y_to
}
} }
fn triangle_bounding_box(triangle: &[&Vertex; 3], point_to_pixel: TSTransform) -> PxBoundingBox { pub fn triangle_bounding_box(
triangle: &[&Vertex; 3],
point_to_pixel: TSTransform,
) -> PxBoundingBox {
// calculate bounding box in point coords // calculate bounding box in point coords
let mut rect = Rect::NOTHING; let mut rect = Rect::NOTHING;
for vertex in triangle { for vertex in triangle {
@ -169,6 +209,10 @@ fn point_in_triangle(point: Pos2, triangle: [&Vertex; 3]) -> PointInTriangle {
// Normalize the weights. // Normalize the weights.
let weights = areas.map(|area| area / triangle_area); let weights = areas.map(|area| area / triangle_area);
if cfg!(debug_assertions) && weights.into_iter().any(f32::is_nan) {
panic!("weights must not be NaN! {weights:?} {triangle_area:?} {areas:?} {sides:?}");
}
PointInTriangle { inside, weights } PointInTriangle { inside, weights }
} }

View File

@ -0,0 +1,43 @@
---
source: src/custom_code_block.rs
expression: list
---
[
Line(
"\n",
),
Line(
"# Hello world\n",
),
Line(
"## Subheader\n",
),
Line(
"- 1\n",
),
CodeBlock {
key: "foo",
content: " whatever\n some code\n Hi mom!",
span: "```foo\n whatever\n some code\n Hi mom!\n```",
},
Line(
" \n",
),
Line(
"\n",
),
CodeBlock {
key: "` # wrong number of ticks, but that's ok",
content: " ``` # indented ticks",
span: "```` # wrong number of ticks, but that's ok\n ``` # indented ticks\n```\n",
},
Line(
"\n",
),
Line(
"``` # no closing ticks\n",
),
Line(
" ",
),
]

View File

@ -0,0 +1,7 @@
---
source: src/painting.rs
expression: serialized
---
```handwriting
BQAAvAA8AEIAPABCAEIAPgBAAAAAAAQAAEIAQgC8ADwAAAAAAEIAPA==
```

View File

@ -8,7 +8,7 @@ use egui::{
Color32, InputState, Key, Modifiers, TextBuffer, TextEdit, Ui, Vec2, text::CCursorRange, Color32, InputState, Key, Modifiers, TextBuffer, TextEdit, Ui, Vec2, text::CCursorRange,
}; };
use crate::easy_mark::MemoizedHighlighter; use crate::markdown::MemoizedHighlighter;
#[derive(Default, serde::Deserialize, serde::Serialize)] #[derive(Default, serde::Deserialize, serde::Serialize)]
pub struct MdTextEdit { pub struct MdTextEdit {
@ -24,6 +24,10 @@ pub struct MdTextEdit {
cursor: Option<CCursorRange>, cursor: Option<CCursorRange>,
} }
pub struct MdTextEditOutput {
pub changed: bool,
}
impl MdTextEdit { impl MdTextEdit {
pub fn new() -> Self { pub fn new() -> Self {
MdTextEdit::default() MdTextEdit::default()
@ -36,7 +40,7 @@ impl MdTextEdit {
} }
} }
pub fn ui(&mut self, ui: &mut Ui) { pub fn ui(&mut self, ui: &mut Ui) -> MdTextEditOutput {
let Self { let Self {
text, text,
highlighter, highlighter,
@ -46,8 +50,8 @@ impl MdTextEdit {
let w = ui.available_width(); let w = ui.available_width();
let mut layouter = |ui: &egui::Ui, easymark: &dyn TextBuffer, _wrap_width: f32| { let mut layouter = |ui: &egui::Ui, markdown: &dyn TextBuffer, _wrap_width: f32| {
let mut layout_job = highlighter.highlight(ui.style(), easymark.as_str(), *cursor); let mut layout_job = highlighter.highlight(ui.style(), markdown.as_str(), *cursor);
layout_job.wrap.max_width = w - 10.0; layout_job.wrap.max_width = w - 10.0;
ui.fonts(|f| f.layout_job(layout_job)) ui.fonts(|f| f.layout_job(layout_job))
}; };
@ -72,6 +76,10 @@ impl MdTextEdit {
*cursor = text_edit.cursor_range; *cursor = text_edit.cursor_range;
//ui.ctx().request_repaint(); //ui.ctx().request_repaint();
} }
MdTextEditOutput {
changed: text_edit.response.changed(),
}
} }
} }

14
src/text_styles.rs Normal file
View File

@ -0,0 +1,14 @@
//! Name of custom [egui::TextStyle]s
pub const H1: &str = "H1";
pub const H2: &str = "H2";
pub const H3: &str = "H3";
pub const H4: &str = "H4";
pub const H5: &str = "H5";
pub const H6: &str = "H6";
pub const H1_MONO: &str = "H1-mono";
pub const H2_MONO: &str = "H2-mono";
pub const H3_MONO: &str = "H3-mono";
pub const H4_MONO: &str = "H4-mono";
pub const H5_MONO: &str = "H5-mono";
pub const H6_MONO: &str = "H6-mono";

View File

@ -1,6 +1,8 @@
use std::sync::mpsc; use std::{fs::File, os::unix::fs::MetadataExt as _, sync::mpsc};
use chrono::{DateTime, Local};
use egui::Id; use egui::Id;
use eyre::{Context, ContextCompat};
use rand::{Rng, rng}; use rand::{Rng, rng};
pub fn random_id() -> Id { pub fn random_id() -> Id {
@ -28,3 +30,27 @@ impl<T> GuiSender<T> {
Ok(()) Ok(())
} }
} }
#[track_caller]
pub fn log_error<T>(chain: eyre::Report, f: impl FnOnce() -> eyre::Result<T>) -> Option<T> {
f().map_err(|e| e.wrap_err(chain))
.inspect_err(|e| log::error!("{e}"))
.ok()
}
pub fn file_mtime(file: &File) -> eyre::Result<DateTime<Local>> {
(move || {
let meta = file.metadata().wrap_err("Failed to stat file")?;
let sec = meta.mtime();
let nsec = meta
.mtime_nsec()
.try_into()
.wrap_err("Nanoseconds overflowed")?;
DateTime::from_timestamp(sec, nsec)
.wrap_err("Bad timestamp")
.map(Into::into)
})()
.wrap_err("Failed to get file mtime")
}