Compare commits
4
Commits
a91cafa74a
...
all-fonts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d880cb7fb
|
||
|
|
83ad2068e0
|
||
|
|
3908e6d913
|
||
|
|
27728fc431
|
@@ -0,0 +1,2 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
target
|
||||
Generated
+3633
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "inkr"
|
||||
version = "0.1.0"
|
||||
authors = []
|
||||
edition = "2024"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
|
||||
|
||||
[features]
|
||||
pinenote = []
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31"
|
||||
eframe = { version = "0.31", default-features = false, features = [
|
||||
"glow", # alt: "wgpu".
|
||||
"persistence",
|
||||
"wayland",
|
||||
] }
|
||||
log = "0.4.27"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
egui_glow = "0.31.1"
|
||||
rfd = { version = "0.15.3", default-features = false, features = ["gtk3"] }
|
||||
rand = "0.9.1"
|
||||
eyre = "0.6.12"
|
||||
half = "2.6.0"
|
||||
zerocopy = { version = "0.8.25", features = ["derive", "std"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
env_logger = "0.11.8"
|
||||
rand = "0.9.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
wasm-bindgen-futures = "0.4.50"
|
||||
web-sys = "0.3.77"
|
||||
|
||||
[patch.crates-io]
|
||||
egui = { git = "https://github.com/emilk/egui", rev = "f2ce6424f3a32f47308fb9871d540c01377b2cd9" }
|
||||
eframe = { git = "https://github.com/emilk/egui", rev = "f2ce6424f3a32f47308fb9871d540c01377b2cd9" }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.43.1", features = ["yaml"] }
|
||||
# egui = { path = "../egui/crates/egui" }
|
||||
# eframe = { path = "../egui/crates/eframe" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2 # fast and small wasm
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2 # optimize dependencies in debug-builds
|
||||
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
filehash = false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,62 @@
|
||||
<?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.733333"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
sodipodi:docname="icon.svg"
|
||||
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"
|
||||
showguides="false"
|
||||
inkscape:zoom="1.6249158"
|
||||
inkscape:cx="147.69996"
|
||||
inkscape:cy="131.69913"
|
||||
inkscape:window-width="1664"
|
||||
inkscape:window-height="1123"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1"><rect
|
||||
x="-8.0004144"
|
||||
y="1.8462495"
|
||||
width="264.01368"
|
||||
height="253.5516"
|
||||
id="rect3" /><rect
|
||||
x="-1.8462495"
|
||||
y="-3.0770825"
|
||||
width="256.01326"
|
||||
height="257.85951"
|
||||
id="rect2" /></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><circle
|
||||
style="fill:#110320;stroke-width:1.5875;fill-opacity:1;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none"
|
||||
id="path1"
|
||||
cx="-33.866669"
|
||||
cy="33.866669"
|
||||
transform="scale(-1,1)"
|
||||
r="29.633333" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.726546;stroke-dasharray:none"
|
||||
d="m 24.357358,51.75417 c 1.677138,0.1954 3.012712,-1.943399 0.697783,-2.233805 -0.43693,-0.05481 -1.094737,-0.196393 -1.469913,0.112888 -0.725035,0.597691 -0.05602,2.024431 0.77213,2.120918 M 44.55508,47.458562 c -2.036319,-1.300371 -6.036944,-3.772356 -2.853754,-6.546895 2.728243,-2.377997 6.477766,1.248147 8.186012,3.109607 0.979637,1.0675 2.466472,1.861233 3.67434,0.654289 0.51717,-0.516775 0.968121,-1.312455 1.189759,-2.005947 1.591482,-4.979623 -4.179579,-4.744659 -7.417698,-4.633956 -0.951483,0.03253 -2.61236,0.303404 -3.212234,-0.656953 -0.427092,-0.683746 -0.135095,-1.484022 0.474312,-1.93208 1.360421,-1.000262 3.587682,-0.457196 4.652612,-1.849352 0.828528,-1.083114 0.951,-4.818847 -1.22072,-3.350234 -1.443858,0.976401 -2.006307,1.916665 -3.873526,2.324531 0.222633,-0.929454 1.778989,-2.100224 1.499021,-3.032882 -0.386705,-1.28822 -2.097275,-0.0512 -2.89635,-0.174431 -1.301969,-0.200779 -1.776328,-2.582496 -1.679169,-3.637966 0.211543,-2.29809 1.939272,-4.771313 3.673637,-6.185849 1.3339,-1.087912 3.458366,-2.233404 3.97281,-3.99081 0.963508,-3.291457 -2.687111,-5.514838 -5.437351,-4.032242 -3.193287,1.721432 -2.389588,8.096059 -3.623982,11.147297 -0.364454,0.900877 -0.970432,2.880491 -2.042274,3.173114 -0.649242,0.177246 -0.922121,-0.598875 -0.994604,-1.093721 -0.156259,-1.066832 0.596766,-3.104217 -0.406279,-3.871285 -1.925963,-1.472853 -2.118198,3.230061 -3.387645,2.716025 -0.51512,-0.208588 -0.592573,-0.957611 -0.557124,-1.427206 0.09125,-1.208752 1.39216,-7.538591 -2.172458,-5.21215 -2.924945,1.90896 1.702668,6.085542 -0.527528,7.633266 -1.553108,1.077833 -2.569377,-0.643592 -3.218833,-1.834516 -0.888912,-1.630017 -1.577648,-3.577817 -1.50393,-5.458634 0.05507,-1.40513 0.84855,-3.098296 0.08945,-4.419774 -1.28002,-2.228324 -5.084134,-1.932106 -6.097163,0.32796 -1.494817,3.334892 1.146497,4.662242 3.098072,6.621665 1.013668,1.01774 1.832017,2.31355 2.489201,3.583834 0.365325,0.706145 1.458044,2.439306 1.13037,3.248518 -0.272203,0.672224 -1.201216,-0.06939 -1.483304,-0.328274 -0.923006,-0.847094 -2.934861,-3.048331 -3.902644,-0.972527 -1.582374,3.394047 7.74979,3.046066 4.025122,6.811266 -1.165609,1.178293 -2.619518,0.377498 -3.890096,-0.119487 -1.802787,-0.705155 -3.350441,-0.965609 -4.797851,0.545082 -0.403375,0.42101 -0.774148,0.921607 -0.840012,1.518039 -0.175061,1.585323 2.03783,3.007543 3.477426,2.714219 1.11212,-0.2266 2.010389,-1.009378 3.175375,-1.139827 1.241591,-0.139028 2.045938,0.790409 1.832938,2.042238 -0.224989,1.322281 -2.006141,1.492584 -3.065696,1.516282 -2.728138,0.06102 -8.365686,0.740019 -7.421897,5.0174 0.427713,1.938463 3.289772,2.755106 4.919438,1.986954 2.399018,-1.130846 2.82494,-4.428861 5.323211,-5.680952 0.728974,-0.365349 2.079221,-1.095803 2.829431,-0.432104 2.412658,2.134442 -2.819355,5.586848 0.0509,8.076797 0.940133,0.815568 1.796677,-0.187134 2.072547,-1.076718 0.424858,-1.370002 -0.241091,-5.437488 2.687209,-4.531871 0.942342,0.29143 1.676656,1.468989 2.20772,2.230322 1.180467,1.692317 1.943623,3.555791 2.23459,5.603278 0.241802,1.701573 0.05628,3.87755 1.015729,5.366084 1.767129,2.741609 5.0248,0.99903 5.770174,-1.581327 0.278479,-0.964043 0.244035,-1.97941 -0.06571,-2.93006 -0.203472,-0.624482 -0.837385,-1.594523 -0.485287,-2.259042 0.560913,-1.058617 2.500975,-0.205104 3.323697,-1.568951 M 11.282216,39.999731 c -1.089731,-0.383555 -3.0034339,-0.530935 -3.8017272,0.534465 -0.5463955,0.729209 0.1590305,1.777793 1.0382558,1.536416 0.8474681,-0.232658 1.9728694,-1.554161 2.7634714,-2.070881 m 5.328926,-2.404732 c -0.753761,-0.138097 -1.837599,-0.08651 -2.477713,-0.539231 -0.767733,-0.542982 -0.921334,-1.592688 -1.957202,-1.817154 -1.363887,-0.295546 -2.8379814,1.425438 -2.086435,2.701244 1.0566,1.793659 5.358164,0.731178 6.52135,-0.344859 m 28.117712,10.970905 -0.144296,0.356349 c 0.64822,0.812599 4.681381,4.096531 5.227817,1.72372 0.359489,-1.561022 -4.071741,-2.094883 -5.083523,-2.080066 M 18.806334,25.003554 c 0.15582,-1.480274 -1.948974,-3.996407 -3.550889,-3.979179 -1.724018,0.01854 -0.620047,1.668489 0.138592,2.162822 0.434608,0.283193 0.887821,0.53677 1.33694,0.795641 0.67922,0.391499 1.348677,0.725904 2.075355,1.020716 m 33.510001,10.458337 c 1.688214,0.682313 4.022777,1.141407 5.653158,-4.78e-4 1.057186,-0.740431 1.253424,-3.177239 -0.284082,-3.519836 -1.561492,-0.347939 -1.996578,1.718043 -2.991458,2.390466 -0.75837,0.512568 -1.672781,0.516416 -2.377622,1.129849 m -0.653258,-3.515868 c 1.605596,0.313842 2.298441,-2.135314 0.575747,-2.430759 -1.593158,-0.273232 -2.264698,2.100623 -0.575747,2.430759 m -5.829793,-4.023772 c 2.878313,1.130708 7.741256,-4.494597 4.037077,-5.882019 -2.692662,-1.008547 -2.519327,1.463896 -3.454303,2.94427 -0.457826,0.724892 -2.297067,2.264312 -0.582774,2.937749 M 32.659319,15.259489 c 1.43128,-0.213437 1.900284,-3.437075 0.09929,-3.084237 -1.390128,0.272344 -1.94476,3.359442 -0.09929,3.084237 z"
|
||||
id="path2" /><text
|
||||
xml:space="preserve"
|
||||
transform="scale(0.26458333)"
|
||||
id="text2"
|
||||
style="fill:#1e0026;text-orientation:auto;text-align:start;writing-mode:lr-tb;direction:ltr;fill-opacity:1;stroke:#200029;stroke-opacity:1;stroke-width:2.746;stroke-dasharray:none;white-space:pre;shape-inside:url(#rect2)" /></g></svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Inkr",
|
||||
"short_name": "inkr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./assets/icon-256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./assets/icon-1024.png",
|
||||
"sizes": "1024x1024",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"lang": "en-US",
|
||||
"id": "/index.html",
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "black",
|
||||
"theme_color": "black"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
var cacheName = 'inkr';
|
||||
var filesToCache = [
|
||||
'./',
|
||||
'./index.html',
|
||||
'./inkr.js',
|
||||
'./inkr.wasm',
|
||||
];
|
||||
|
||||
/* Start the service worker and cache all of the app's content */
|
||||
self.addEventListener('install', function(e) {
|
||||
e.waitUntil(
|
||||
caches.open(cacheName).then(function(cache) {
|
||||
return cache.addAll(filesToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/* Serve cached content when offline */
|
||||
self.addEventListener('fetch', function(e) {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(function(response) {
|
||||
return response || fetch(e.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
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.
Binary file not shown.
Binary file not shown.
+135
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
|
||||
<!-- Disable zooming: -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
|
||||
<head>
|
||||
<title>Inkr</title>
|
||||
|
||||
<link data-trunk rel="rust" data-wasm-opt="2" />
|
||||
<base data-trunk-public-url />
|
||||
|
||||
<link data-trunk rel="icon" href="assets/icon.svg">
|
||||
|
||||
|
||||
<link data-trunk rel="copy-file" href="assets/sw.js"/>
|
||||
<link data-trunk rel="copy-file" href="assets/manifest.json"/>
|
||||
<link data-trunk rel="copy-file" href="assets/icon-256.png" data-target-path="assets"/>
|
||||
<link data-trunk rel="copy-file" href="assets/icon-1024.png" data-target-path="assets"/>
|
||||
|
||||
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#404040">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white">
|
||||
|
||||
<style>
|
||||
html {
|
||||
touch-action: manipulation; /* Remove touch delay: */
|
||||
}
|
||||
|
||||
body {
|
||||
background: #909090;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #404040;
|
||||
}
|
||||
}
|
||||
|
||||
/* Allow canvas to fill entire web page: */
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Make canvas fill entire document: */
|
||||
canvas {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.centered {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #f0f0f0;
|
||||
font-size: 24px;
|
||||
font-family: Ubuntu-Light, Helvetica, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- */
|
||||
/* Loading animation from https://loading.io/css/ */
|
||||
.lds-dual-ring {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.lds-dual-ring:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
border-color: #fff transparent #fff transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- The WASM code will resize the canvas dynamically -->
|
||||
<!-- the id is hardcoded in main.rs . so, make sure both match. -->
|
||||
<canvas id="the_canvas_id"></canvas>
|
||||
|
||||
<!-- the loading spinner will be removed in main.rs -->
|
||||
<div class="centered" id="loading_text">
|
||||
<p style="font-size:16px">
|
||||
Loading…
|
||||
</p>
|
||||
<div class="lds-dual-ring"></div>
|
||||
</div>
|
||||
|
||||
<!--Register Service Worker. this will cache the wasm / js scripts for offline use (for PWA functionality). -->
|
||||
<!-- Force refresh (Ctrl + F5) to load the latest files instead of cached files -->
|
||||
<script>
|
||||
// We disable caching during development so that we always view the latest version.
|
||||
if ('serviceWorker' in navigator && window.location.hash !== "#dev") {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('sw.js');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::{Arc, mpsc},
|
||||
};
|
||||
|
||||
use crate::{file_editor::FileEditor, preferences::Preferences, util::GuiSender};
|
||||
use egui::{
|
||||
Align, Button, Color32, FontData, FontDefinitions, PointerButton, RichText, ScrollArea, Stroke,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct App {
|
||||
preferences: Preferences,
|
||||
|
||||
#[serde(skip)]
|
||||
actions_tx: mpsc::Sender<Action>,
|
||||
#[serde(skip)]
|
||||
actions_rx: mpsc::Receiver<Action>,
|
||||
|
||||
tabs: Vec<(TabId, Tab)>,
|
||||
open_tab_index: Option<usize>,
|
||||
|
||||
next_tab_id: TabId,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
enum Tab {
|
||||
File(FileEditor),
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub fn title(&self) -> &str {
|
||||
match self {
|
||||
Tab::File(file_editor) => file_editor.title(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type TabId = usize;
|
||||
|
||||
pub enum Action {
|
||||
OpenFile(FileEditor),
|
||||
MoveFile(TabId, PathBuf),
|
||||
CloseTab(TabId),
|
||||
// TODO
|
||||
//ShowError {
|
||||
// error: RichText
|
||||
//},
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
let (actions_tx, actions_rx) = mpsc::channel();
|
||||
Self {
|
||||
preferences: Preferences::default(),
|
||||
actions_tx,
|
||||
actions_rx,
|
||||
tabs: vec![(1, Tab::File(FileEditor::new("note.md")))],
|
||||
open_tab_index: None,
|
||||
next_tab_id: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Called once before the first frame.
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let mut fonts = FontDefinitions::empty();
|
||||
fonts.font_data = [
|
||||
//(
|
||||
// "IosevkaAile-Thin",
|
||||
// include_bytes!("../fonts/IosevkaAile-Thin.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "IosevkaAile-ExtraLight",
|
||||
// include_bytes!("../fonts/IosevkaAile-ExtraLight.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "IosevkaAile-Light",
|
||||
// include_bytes!("../fonts/IosevkaAile-Light.ttc").as_slice(),
|
||||
//),
|
||||
(
|
||||
"IosevkaAile-Regular",
|
||||
include_bytes!("../fonts/IosevkaAile-Regular.ttc").as_slice(),
|
||||
),
|
||||
//(
|
||||
// "IosevkaAile-Medium",
|
||||
// include_bytes!("../fonts/IosevkaAile-Medium.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "IosevkaAile-Bold",
|
||||
// include_bytes!("../fonts/IosevkaAile-Bold.ttc").as_slice(),
|
||||
//),
|
||||
(
|
||||
"Iosevka-Thin",
|
||||
include_bytes!("../fonts/Iosevka-Thin.ttc").as_slice(),
|
||||
),
|
||||
//(
|
||||
// "Iosevka-ExtraLight",
|
||||
// include_bytes!("../fonts/Iosevka-ExtraLight.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "Iosevka-Light",
|
||||
// include_bytes!("../fonts/Iosevka-Light.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "Iosevka-Medium",
|
||||
// include_bytes!("../fonts/Iosevka-Medium.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "Iosevka-Regular",
|
||||
// include_bytes!("../fonts/Iosevka-Regular.ttc").as_slice(),
|
||||
//),
|
||||
//(
|
||||
// "Iosevka-Heavy",
|
||||
// include_bytes!("../fonts/Iosevka-Heavy.ttc").as_slice(),
|
||||
//),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, data)| (name.to_string(), Arc::new(FontData::from_static(data))))
|
||||
.collect();
|
||||
|
||||
fonts.families.insert(
|
||||
egui::FontFamily::Proportional,
|
||||
vec!["IosevkaAile-Regular".into()],
|
||||
);
|
||||
|
||||
fonts
|
||||
.families
|
||||
.insert(egui::FontFamily::Monospace, vec!["Iosevka-Thin".into()]);
|
||||
|
||||
cc.egui_ctx.set_fonts(fonts);
|
||||
|
||||
cc.egui_ctx.style_mut(|style| {
|
||||
// TODO: change color of text in TextEdit
|
||||
style.visuals.widgets.noninteractive.fg_stroke =
|
||||
Stroke::new(1.0, Color32::from_rgb(200, 200, 200));
|
||||
});
|
||||
|
||||
if let Some(storage) = cc.storage {
|
||||
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
|
||||
}
|
||||
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn actions_tx(&self, ctx: &egui::Context) -> GuiSender<Action> {
|
||||
GuiSender::new(self.actions_tx.clone(), ctx)
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: Action) {
|
||||
match action {
|
||||
Action::OpenFile(file_editor) => {
|
||||
self.open_tab(Tab::File(file_editor));
|
||||
}
|
||||
Action::MoveFile(tab_id, new_path) => {
|
||||
let tab = self.tabs.iter_mut().find(|(id, _)| &tab_id == id);
|
||||
let Some((_, tab)) = tab else { return };
|
||||
let Tab::File(editor) = tab; // else { return };
|
||||
|
||||
editor.set_path(new_path);
|
||||
}
|
||||
Action::CloseTab(id) => {
|
||||
// TODO: check if the file is dirty and ask to save it first?
|
||||
self.tabs.retain(|(tab_id, _)| &id != tab_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
eframe::set_value(storage, eframe::APP_KEY, self);
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.preferences.apply(ctx);
|
||||
|
||||
while let Ok(action) = self.actions_rx.try_recv() {
|
||||
self.handle_action(action);
|
||||
}
|
||||
|
||||
if self.open_tab_index >= Some(self.tabs.len()) {
|
||||
self.open_tab_index = Some(self.tabs.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
//ctx.input_mut(|input| {
|
||||
// if input.consume_key(Modifiers::CTRL, Key::H) {
|
||||
// self.buffer.push(BufferItem::Painting(Default::default()));
|
||||
// }
|
||||
//});
|
||||
|
||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||
egui::containers::menu::Bar::new().ui(ui, |ui| {
|
||||
// NOTE: no File->Quit on web pages!
|
||||
ui.menu_button("Menu ⚙", |ui| {
|
||||
ui.label(RichText::new("Action").weak());
|
||||
|
||||
if ui.button("New File").clicked() {
|
||||
let file = FileEditor::new("note.md");
|
||||
self.open_tab(Tab::File(file));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui.button("Open File").clicked() {
|
||||
let actions_tx = self.actions_tx(ui.ctx());
|
||||
std::thread::spawn(move || {
|
||||
let file = rfd::FileDialog::new().pick_file();
|
||||
|
||||
let Some(file_path) = file else { return };
|
||||
|
||||
let text = match fs::read_to_string(&file_path) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::error!("Failed to read {file_path:?}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let editor = FileEditor::from_file(file_path, &text);
|
||||
let _ = actions_tx.send(Action::OpenFile(editor));
|
||||
});
|
||||
}
|
||||
|
||||
if ui.button("Open Folder").clicked() {
|
||||
log::error!("Open Folder not implemented");
|
||||
}
|
||||
|
||||
if ui
|
||||
.add_enabled(self.open_tab_index.is_some(), Button::new("Close File"))
|
||||
.clicked()
|
||||
{
|
||||
if let Some(i) = self.open_tab_index.take() {
|
||||
self.tabs.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
let open_file =
|
||||
self.open_tab_index
|
||||
.and_then(|i| self.tabs.get(i))
|
||||
.and_then(|(id, tab)| match tab {
|
||||
Tab::File(file_editor) => Some((*id, file_editor)),
|
||||
});
|
||||
|
||||
let open_file_with_path = open_file
|
||||
.clone()
|
||||
.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}");
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui
|
||||
.add_enabled(open_file.is_some(), Button::new("Save As"))
|
||||
.clicked()
|
||||
{
|
||||
let actions_tx = self.actions_tx(ui.ctx());
|
||||
let (tab_id, editor) =
|
||||
open_file.expect("We checked that open_file is_some");
|
||||
let text = editor.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let Some(file_path) = rfd::FileDialog::new().save_file() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = fs::write(&file_path, text.as_bytes()) {
|
||||
log::error!("{e}");
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = actions_tx.send(Action::MoveFile(tab_id, file_path));
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
self.preferences.show(ui);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
if cfg!(not(target_arch = "wasm32")) && ui.button("Quit").clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
ScrollArea::horizontal().show(ui, |ui| {
|
||||
for (i, (tab_id, tab)) in self.tabs.iter().enumerate() {
|
||||
let selected = self.open_tab_index == Some(i);
|
||||
let mut button = Button::new(tab.title()).selected(selected);
|
||||
|
||||
let dirty = i == 0; // TODO: mark as dirty when contents hasn't been saved
|
||||
if dirty {
|
||||
button = button.right_text(RichText::new("*").strong())
|
||||
}
|
||||
|
||||
let response = ui.add(button);
|
||||
|
||||
if response.clicked() {
|
||||
self.open_tab_index = Some(i);
|
||||
} else if response.clicked_by(PointerButton::Secondary) {
|
||||
let _ = self.actions_tx(ui.ctx()).send(Action::CloseTab(*tab_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Figure out where we should insert the next tab.
|
||||
fn insert_tab_at(&self) -> usize {
|
||||
match self.open_tab_index {
|
||||
None => 0,
|
||||
Some(i) => (i + 1).min(self.tabs.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a [Tab].
|
||||
fn open_tab(&mut self, tab: Tab) {
|
||||
let i = self.insert_tab_at();
|
||||
let id = self.next_tab_id;
|
||||
self.next_tab_id += 1;
|
||||
self.tabs.insert(i, (id, tab));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub const MAX_NOTE_WIDTH: f32 = 600.0;
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::{
|
||||
fmt::{self, Display, Write},
|
||||
iter,
|
||||
};
|
||||
|
||||
const TICKS: &str = "```";
|
||||
const NL_TICKS: &str = "\n```";
|
||||
|
||||
/// Wrap a [Display] in markdown code-block ticks ([TICKS])
|
||||
pub fn to_custom_code_block(key: &str, content: impl Display) -> String {
|
||||
let mut out = String::new();
|
||||
write_custom_code_block(&mut out, key, content).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a [Display] in markdown code-block ticks ([TICKS])
|
||||
pub fn write_custom_code_block(mut w: impl Write, key: &str, content: impl Display) -> fmt::Result {
|
||||
write!(w, "{TICKS}{key}\n{content}\n{TICKS}")
|
||||
}
|
||||
|
||||
/// Try to unwrap a string from within markdown code-block ticks ([TICKS])
|
||||
pub fn try_from_custom_code_block<'a>(key: &str, code_block: &'a str) -> Option<&'a str> {
|
||||
code_block
|
||||
.trim()
|
||||
.strip_prefix(TICKS)?
|
||||
.strip_prefix(key)?
|
||||
.strip_prefix("\n")?
|
||||
.strip_suffix(TICKS)?
|
||||
.strip_suffix("\n")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MdItem<'a> {
|
||||
/// A line of regular markdown, but not a code block.
|
||||
Line(&'a str),
|
||||
|
||||
/// A markdown code block
|
||||
CodeBlock {
|
||||
/// The key or language of the code block.
|
||||
key: &'a str,
|
||||
|
||||
/// Everything in-between the ticks.
|
||||
content: &'a str,
|
||||
|
||||
/// The entire code-block, including ticks.
|
||||
span: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Iterate over code-blocks in a markdown string
|
||||
pub fn iter_lines_and_code_blocks(mut md: &str) -> impl Iterator<Item = MdItem<'_>> {
|
||||
iter::from_fn(move || {
|
||||
if md.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !md.starts_with(TICKS) {
|
||||
// line does not start with ticks, return a normal line.
|
||||
let line;
|
||||
if let Some(i) = md.find('\n') {
|
||||
let i = i + 1;
|
||||
line = &md[..i];
|
||||
md = &md[i..];
|
||||
} else {
|
||||
line = md;
|
||||
md = "";
|
||||
}
|
||||
return Some(MdItem::Line(line));
|
||||
}
|
||||
|
||||
let mut i = TICKS.len();
|
||||
let from_key = &md[i..];
|
||||
|
||||
let Some((key, from_content)) = from_key.split_once('\n') else {
|
||||
// no more newlines, return the remaining string as the final line.
|
||||
let rest = md;
|
||||
md = "";
|
||||
return Some(MdItem::Line(rest));
|
||||
};
|
||||
i += key.len() + "\n".len();
|
||||
|
||||
let Some(end) = from_content.find(NL_TICKS) else {
|
||||
// no closing ticks, return a line instead.
|
||||
let line;
|
||||
if let Some(i) = md.find('\n') {
|
||||
let i = i + 1;
|
||||
line = &md[..i];
|
||||
md = &md[i..];
|
||||
} else {
|
||||
line = md;
|
||||
md = "";
|
||||
}
|
||||
return Some(MdItem::Line(line));
|
||||
};
|
||||
let content = &from_content[..end];
|
||||
i += end + NL_TICKS.len();
|
||||
|
||||
if md[i..].starts_with("\n") {
|
||||
i += 1;
|
||||
};
|
||||
let span = &md[..i];
|
||||
md = &md[i..];
|
||||
|
||||
Some(MdItem::CodeBlock { key, content, span })
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::iter_lines_and_code_blocks;
|
||||
|
||||
#[test]
|
||||
fn iter_markdown() {
|
||||
let markdown = r#"
|
||||
# Hello world
|
||||
## Subheader
|
||||
- 1
|
||||
```foo
|
||||
whatever
|
||||
some code
|
||||
Hi mom!
|
||||
```
|
||||
|
||||
```` # wrong number of ticks, but that's ok
|
||||
``` # indented ticks
|
||||
```
|
||||
|
||||
``` # no closing ticks
|
||||
"#;
|
||||
|
||||
let list: Vec<_> = iter_lines_and_code_blocks(markdown).collect();
|
||||
insta::assert_snapshot!(markdown);
|
||||
insta::assert_debug_snapshot!(list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 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"
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,356 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
fmt::{self, Display},
|
||||
ops::{Div as _, Sub as _},
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use egui::{
|
||||
Align, Button, DragAndDrop, Frame, Layout, ScrollArea, Ui, UiBuilder, Vec2, Widget as _, vec2,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
custom_code_block::{MdItem, iter_lines_and_code_blocks},
|
||||
painting::{self, Handwriting, HandwritingStyle},
|
||||
preferences::Preferences,
|
||||
text_editor::MdTextEdit,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
pub struct FileEditor {
|
||||
title: String,
|
||||
pub path: Option<PathBuf>,
|
||||
pub buffer: Vec<BufferItem>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
pub enum BufferItem {
|
||||
Text(MdTextEdit),
|
||||
Handwriting(Handwriting),
|
||||
}
|
||||
|
||||
impl FileEditor {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
let buffer = vec![BufferItem::Text(MdTextEdit::new())];
|
||||
Self {
|
||||
title: title.into(),
|
||||
path: None,
|
||||
buffer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_file(file_path: PathBuf, contents: &str) -> Self {
|
||||
let file_title = file_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| String::from("untitled.md"));
|
||||
Self {
|
||||
title: file_title,
|
||||
path: Some(file_path),
|
||||
..FileEditor::from(contents)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ui: &mut Ui, preferences: &Preferences) {
|
||||
ui.vertical_centered_justified(|ui| {
|
||||
ui.heading(&self.title);
|
||||
|
||||
const MAX_NOTE_WIDTH: f32 = 600.0;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("new");
|
||||
if ui.button("text").clicked() {
|
||||
self.buffer.push(BufferItem::Text(Default::default()));
|
||||
}
|
||||
if ui.button("writing").clicked() {
|
||||
self.buffer
|
||||
.push(BufferItem::Handwriting(Default::default()));
|
||||
}
|
||||
});
|
||||
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let side_padding = ui.available_width().sub(MAX_NOTE_WIDTH).max(0.0).div(2.0);
|
||||
ui.add_space(side_padding);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(MAX_NOTE_WIDTH);
|
||||
self.show_contents(ui, preferences);
|
||||
});
|
||||
ui.add_space(side_padding);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn show_contents(&mut self, ui: &mut Ui, preferences: &Preferences) {
|
||||
if self.buffer.is_empty() {
|
||||
self.buffer.push(BufferItem::Text(Default::default()));
|
||||
}
|
||||
|
||||
struct DraggingItem {
|
||||
index: usize,
|
||||
}
|
||||
|
||||
let mut drop_from_to: Option<(usize, usize)> = None;
|
||||
let is_dragging = DragAndDrop::has_payload_of_type::<DraggingItem>(ui.ctx());
|
||||
let drag_zone_height = 10.0;
|
||||
|
||||
// Iterate over buffer items using `retain` so that we can handle deletions
|
||||
let mut i = 0usize..;
|
||||
let len = self.buffer.len();
|
||||
self.buffer.retain_mut(|item| {
|
||||
let i = i.next().unwrap();
|
||||
let is_first = i == 0;
|
||||
let is_last = i == len - 1;
|
||||
|
||||
let mut retain = true;
|
||||
|
||||
if is_dragging {
|
||||
let (_, drop) = ui.dnd_drop_zone::<DraggingItem, _>(Frame::NONE, |ui| {
|
||||
ui.set_min_size(vec2(ui.available_width(), drag_zone_height));
|
||||
});
|
||||
if let Some(drop) = drop {
|
||||
drop_from_to = Some((drop.index, i));
|
||||
}
|
||||
} else {
|
||||
// the dnd_drop_zone adds 3pts work of extra space
|
||||
ui.add_space(drag_zone_height + 3.0);
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// We don't know how tall the buffer item will be, so we'll reserve
|
||||
// some horizontal space here and come back to drawing the dragger
|
||||
// later.
|
||||
let (dragger_id, mut dragger_rect) = ui.allocate_space(Vec2::new(20.0, 1.0));
|
||||
|
||||
// Leave some space at the end for the delete button..
|
||||
let w = ui.available_width();
|
||||
let item_size = Vec2::new(w - 20.0, 0.0);
|
||||
|
||||
let item_response = ui.allocate_ui(item_size, |ui| match item {
|
||||
BufferItem::Text(text_edit) => {
|
||||
text_edit.ui(ui);
|
||||
}
|
||||
BufferItem::Handwriting(painting) => {
|
||||
let style = HandwritingStyle {
|
||||
animate: preferences.animations,
|
||||
..HandwritingStyle::from_theme(ui.ctx().theme())
|
||||
};
|
||||
painting.ui(&style, ui);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete-button
|
||||
if ui.button("x").clicked() {
|
||||
retain = false;
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
// Draw the dragger using the height from the buffer item
|
||||
dragger_rect.set_height(item_response.response.rect.height());
|
||||
|
||||
// Controls for moving the buffer item
|
||||
ui.allocate_new_ui(
|
||||
UiBuilder::new()
|
||||
.max_rect(dragger_rect)
|
||||
.layout(Layout::top_down(Align::Center)),
|
||||
|ui| {
|
||||
let up_button_response = ui.add_enabled(!is_first, Button::new("⇡"));
|
||||
if up_button_response.clicked() {
|
||||
drop_from_to = Some((i, i - 1));
|
||||
}
|
||||
|
||||
ui.dnd_drag_source(dragger_id, DraggingItem { index: i }, |ui| {
|
||||
Button::new("≡")
|
||||
.min_size(
|
||||
// Use all available height, save for the height taken up by
|
||||
// the up/down buttons + padding. Assume down-button is the
|
||||
// equally tall as the up-button.
|
||||
dragger_rect.size()
|
||||
- Vec2::Y * (up_button_response.rect.height() * 2.0 + 4.0),
|
||||
)
|
||||
.ui(ui);
|
||||
});
|
||||
|
||||
if ui.add_enabled(!is_last, Button::new("⇣")).clicked() {
|
||||
drop_from_to = Some((i, i + 2));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
retain
|
||||
});
|
||||
|
||||
if is_dragging {
|
||||
let (_, drop) = ui.dnd_drop_zone::<DraggingItem, _>(Frame::NONE, |ui| {
|
||||
ui.set_min_size(vec2(ui.available_width(), drag_zone_height));
|
||||
});
|
||||
if let Some(drop) = drop {
|
||||
drop_from_to = Some((drop.index, self.buffer.len()));
|
||||
}
|
||||
} else {
|
||||
// the dnd_drop_zone adds 3.0pts work of extra space
|
||||
ui.add_space(drag_zone_height + 3.0);
|
||||
}
|
||||
|
||||
// Handle drag-and-dropping buffer items
|
||||
// TODO: make sure nothing was removed from self.buffer this frame
|
||||
if let Some((from, to)) = drop_from_to {
|
||||
if from < self.buffer.len() {
|
||||
match from.cmp(&to) {
|
||||
Ordering::Greater => {
|
||||
let item = self.buffer.remove(from);
|
||||
self.buffer.insert(to, item);
|
||||
}
|
||||
Ordering::Less => {
|
||||
let item = self.buffer.remove(from);
|
||||
self.buffer.insert(to - 1, item);
|
||||
}
|
||||
Ordering::Equal => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_path(&mut self, new_path: PathBuf) {
|
||||
let Some(title) = new_path.file_name() else {
|
||||
log::error!("No filename in path {new_path:?}");
|
||||
return;
|
||||
};
|
||||
self.title = title.to_string_lossy().to_string();
|
||||
self.path = Some(new_path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for BufferItem {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BufferItem::Text(md_text_edit) => Display::fmt(md_text_edit, f),
|
||||
BufferItem::Handwriting(handwriting) => Display::fmt(handwriting, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for FileEditor {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut first = true;
|
||||
for item in &self.buffer {
|
||||
if !first {
|
||||
writeln!(f)?;
|
||||
}
|
||||
|
||||
first = false;
|
||||
|
||||
write!(f, "{item}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for FileEditor {
|
||||
fn from(s: &str) -> Self {
|
||||
let mut editor = FileEditor::new("note.md");
|
||||
let buffer = &mut editor.buffer;
|
||||
|
||||
let push_text = |buffer: &mut Vec<BufferItem>, text| match buffer.last_mut() {
|
||||
Some(BufferItem::Text(text_edit)) => text_edit.text.push_str(text),
|
||||
_ => {
|
||||
let mut text_edit = MdTextEdit::new();
|
||||
text_edit.text.push_str(text);
|
||||
buffer.push(BufferItem::Text(text_edit));
|
||||
}
|
||||
};
|
||||
|
||||
for item in iter_lines_and_code_blocks(s) {
|
||||
match item {
|
||||
MdItem::Line(line) => push_text(buffer, line),
|
||||
MdItem::CodeBlock { key, content, span } => match key {
|
||||
painting::CODE_BLOCK_KEY => match Handwriting::from_str(span) {
|
||||
Ok(handwriting) => {
|
||||
if let Some(BufferItem::Text(text_edit)) = buffer.last_mut() {
|
||||
if text_edit.text.ends_with('\n') {
|
||||
text_edit.text.pop();
|
||||
if text_edit.text.is_empty() {
|
||||
buffer.pop();
|
||||
}
|
||||
}
|
||||
};
|
||||
buffer.push(BufferItem::Handwriting(handwriting))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to decode handwriting {content:?}: {e}");
|
||||
push_text(buffer, span);
|
||||
}
|
||||
},
|
||||
_ => push_text(buffer, span),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
editor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use crate::file_editor::BufferItem;
|
||||
|
||||
use super::FileEditor;
|
||||
|
||||
#[test]
|
||||
fn from_str_and_back_1() {
|
||||
let markdown = r#"
|
||||
# Hello world!
|
||||
|
||||
This is some text.
|
||||
Here's some handwriting:
|
||||
```handwriting
|
||||
DgB0UUlNeFFJTX9RUE2pUYZNDlIATotSjk4AUwxPaFODT89T608UVBtQL1QqUDtULlBDVDFQSVQuUA==
|
||||
```
|
||||
|
||||
And here's some more text :D
|
||||
```
|
||||
with a regular code-block!
|
||||
```"#;
|
||||
|
||||
println!("{markdown}");
|
||||
println!();
|
||||
println!();
|
||||
println!("{markdown:?}");
|
||||
println!();
|
||||
println!();
|
||||
|
||||
let file_editor = FileEditor::from(markdown);
|
||||
|
||||
for item in &file_editor.buffer {
|
||||
match item {
|
||||
BufferItem::Text(md_text_edit) => {
|
||||
println!("{:?}", md_text_edit.text);
|
||||
}
|
||||
BufferItem::Handwriting(_) => {
|
||||
println!("<handwriting>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!();
|
||||
|
||||
let serialized = file_editor.to_string();
|
||||
assert_eq!(
|
||||
markdown, serialized,
|
||||
"FileEditor should preserve formatting"
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#![warn(clippy::all, rust_2018_idioms)]
|
||||
|
||||
pub mod app;
|
||||
pub mod constants;
|
||||
pub mod custom_code_block;
|
||||
pub mod easy_mark;
|
||||
pub mod file_editor;
|
||||
pub mod painting;
|
||||
pub mod preferences;
|
||||
pub mod rasterizer;
|
||||
pub mod text_editor;
|
||||
pub mod util;
|
||||
|
||||
pub use app::App;
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
||||
|
||||
// When compiling natively:
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
env_logger::init();
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([400.0, 300.0])
|
||||
.with_min_inner_size([300.0, 220.0])
|
||||
.with_icon(
|
||||
// NOTE: Adding an icon is optional
|
||||
eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..])
|
||||
.expect("Failed to load icon"),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"Inkr",
|
||||
native_options,
|
||||
Box::new(|cc| Ok(Box::new(inkr::App::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
// When compiling to web using trunk:
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use eframe::wasm_bindgen::JsCast as _;
|
||||
|
||||
// Redirect `log` message to `console.log` and friends:
|
||||
eframe::WebLogger::init(log::LevelFilter::Debug).ok();
|
||||
|
||||
let web_options = eframe::WebOptions::default();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let document = web_sys::window()
|
||||
.expect("No window")
|
||||
.document()
|
||||
.expect("No document");
|
||||
|
||||
let canvas = document
|
||||
.get_element_by_id("the_canvas_id")
|
||||
.expect("Failed to find the_canvas_id")
|
||||
.dyn_into::<web_sys::HtmlCanvasElement>()
|
||||
.expect("the_canvas_id was not a HtmlCanvasElement");
|
||||
|
||||
let start_result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
web_options,
|
||||
Box::new(|cc| Ok(Box::new(inkr::App::new(cc)))),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Remove the loading text and spinner:
|
||||
if let Some(loading_text) = document.get_element_by_id("loading_text") {
|
||||
match start_result {
|
||||
Ok(_) => {
|
||||
loading_text.remove();
|
||||
}
|
||||
Err(e) => {
|
||||
loading_text.set_inner_html(
|
||||
"<p> The app has crashed. See the developer console for details. </p>",
|
||||
);
|
||||
panic!("Failed to start eframe: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+701
@@ -0,0 +1,701 @@
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
iter, mem,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let start_time = std::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());
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use egui::{Color32, Context, RichText, Theme, Ui, Visuals};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Preferences {
|
||||
/// Enable animations
|
||||
pub animations: bool,
|
||||
|
||||
/// Enable high-contrast theme
|
||||
pub high_contrast: bool,
|
||||
|
||||
#[serde(skip)]
|
||||
has_applied_theme: bool,
|
||||
}
|
||||
|
||||
impl Default for Preferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
animations: true,
|
||||
high_contrast: false,
|
||||
has_applied_theme: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
/// Apply preferences, if they haven't already been applied.
|
||||
pub fn apply(&mut self, ctx: &Context) {
|
||||
if !self.has_applied_theme {
|
||||
self.has_applied_theme = true;
|
||||
|
||||
if self.high_contrast {
|
||||
// widgets.active: color of headers in textedit
|
||||
// widgets.inactive: color of button labels
|
||||
// widgets.hovered: color of hovered button labels
|
||||
// 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.inactive.fg_stroke.color = Color32::WHITE;
|
||||
dark_visuals.widgets.hovered.fg_stroke.color = Color32::WHITE;
|
||||
|
||||
light_visuals.widgets.noninteractive.fg_stroke.color = Color32::BLACK;
|
||||
light_visuals.widgets.inactive.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 {
|
||||
ctx.set_visuals_of(Theme::Dark, Visuals::dark());
|
||||
ctx.set_visuals_of(Theme::Light, Visuals::light());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show preference switches
|
||||
pub fn show(&mut self, ui: &mut Ui) {
|
||||
ui.label(RichText::new("Prefs").weak());
|
||||
|
||||
ui.toggle_value(&mut self.animations, "Animations");
|
||||
|
||||
let high_contrast_toggle = ui.toggle_value(&mut self.high_contrast, "High Contrast");
|
||||
if high_contrast_toggle.clicked() {
|
||||
self.has_applied_theme = false;
|
||||
self.apply(ui.ctx());
|
||||
}
|
||||
|
||||
egui::widgets::global_theme_preference_buttons(ui);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use core::f32;
|
||||
use egui::{Color32, ColorImage, Pos2, Rect, Vec2, emath::TSTransform, epaint::Vertex};
|
||||
|
||||
pub trait BlendFn {
|
||||
fn blend(a: Color32, b: Color32) -> Color32;
|
||||
}
|
||||
|
||||
pub mod blend {
|
||||
pub struct Normal;
|
||||
pub struct Add;
|
||||
pub struct Multiply;
|
||||
}
|
||||
|
||||
/// Rasterize some triangles onto a new image,
|
||||
///
|
||||
/// Triangle positions must be in image-local point-coords.
|
||||
/// `width` and `height` are in pixel coords.
|
||||
pub fn rasterize<'a, Blend: BlendFn>(
|
||||
width: usize,
|
||||
height: usize,
|
||||
point_to_pixel: TSTransform,
|
||||
triangles: impl Iterator<Item = [&'a Vertex; 3]>,
|
||||
) -> ColorImage {
|
||||
let mut image = ColorImage::new([width, height], Color32::TRANSPARENT);
|
||||
rasterize_onto::<Blend>(&mut image, point_to_pixel, triangles);
|
||||
image
|
||||
}
|
||||
|
||||
/// Rasterize some triangles onto an image,
|
||||
///
|
||||
/// Triangle positions must be in image-local point-coords.
|
||||
pub fn rasterize_onto<'a, Blend: BlendFn>(
|
||||
image: &mut ColorImage,
|
||||
point_to_pixel: TSTransform,
|
||||
triangles: impl Iterator<Item = [&'a Vertex; 3]>,
|
||||
) {
|
||||
let width = image.width();
|
||||
let height = image.height();
|
||||
|
||||
let mut set_pixel = |x: usize, y: usize, color| {
|
||||
let pixel = &mut image.pixels[y * width + x];
|
||||
*pixel = Blend::blend(*pixel, color);
|
||||
};
|
||||
|
||||
let image_box = PxBoundingBox {
|
||||
x_from: 0,
|
||||
y_from: 0,
|
||||
x_to: width,
|
||||
y_to: height,
|
||||
};
|
||||
|
||||
let pixel_to_point = point_to_pixel.inverse();
|
||||
|
||||
for triangle in triangles {
|
||||
let [a, b, c] = triangle;
|
||||
if triangle_area(a.pos, b.pos, c.pos) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check all pixels within the triangle's bounding box.
|
||||
let bounding_box =
|
||||
triangle_bounding_box(&triangle, point_to_pixel).intersection(&image_box);
|
||||
|
||||
// TODO: consider subdividing the triangle if it's very large.
|
||||
let pixels = pixels_in_box(bounding_box);
|
||||
|
||||
for [x, y] in pixels {
|
||||
// Calculate point-coordinate of the pixel
|
||||
let pt_pos = pixel_to_point * Pos2::new(x as f32, y as f32);
|
||||
|
||||
let point_in_triangle = point_in_triangle(pt_pos, triangle);
|
||||
|
||||
// If the pixel is within the triangle, fill it in.
|
||||
if point_in_triangle.inside {
|
||||
let c0 = triangle[0]
|
||||
.color
|
||||
.linear_multiply(point_in_triangle.weights[0]);
|
||||
let c1 = triangle[1]
|
||||
.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;
|
||||
|
||||
set_pixel(x, y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounding box, measured in pixels.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct PxBoundingBox {
|
||||
pub x_from: usize,
|
||||
pub y_from: usize,
|
||||
pub x_to: usize,
|
||||
pub y_to: usize,
|
||||
}
|
||||
|
||||
impl PxBoundingBox {
|
||||
pub fn intersection(&self, other: &PxBoundingBox) -> PxBoundingBox {
|
||||
PxBoundingBox {
|
||||
x_from: self.x_from.max(other.x_from),
|
||||
y_from: self.y_from.max(other.y_from),
|
||||
x_to: self.x_to.min(other.x_to),
|
||||
y_to: self.y_to.min(other.y_to),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn triangle_bounding_box(triangle: &[&Vertex; 3], point_to_pixel: TSTransform) -> PxBoundingBox {
|
||||
// calculate bounding box in point coords
|
||||
let mut rect = Rect::NOTHING;
|
||||
for vertex in triangle {
|
||||
rect.min = rect.min.min(vertex.pos);
|
||||
rect.max = rect.max.max(vertex.pos);
|
||||
}
|
||||
|
||||
// convert bounding box to pixel coords
|
||||
let rect = point_to_pixel.mul_rect(rect);
|
||||
PxBoundingBox {
|
||||
x_from: rect.min.x.floor() as usize,
|
||||
y_from: rect.min.y.floor() as usize,
|
||||
x_to: rect.max.x.ceil() as usize,
|
||||
y_to: rect.max.y.ceil() as usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the perpendicular vector (90 degrees from the given vector)
|
||||
fn perpendicular(v: Vec2) -> Vec2 {
|
||||
Vec2::new(v.y, -v.x)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PointInTriangle {
|
||||
/// Is the point inside the triangle?
|
||||
inside: bool,
|
||||
|
||||
/// Normalized weights describing the vicinity between the point and the three verticies of
|
||||
/// thre triangle.
|
||||
weights: [f32; 3],
|
||||
}
|
||||
|
||||
/// Calculate whether a point is within a triangle, and the relative vicinities between the point
|
||||
/// and each triangle vertex. The triangle must have a non-zero area.
|
||||
fn point_in_triangle(point: Pos2, triangle: [&Vertex; 3]) -> PointInTriangle {
|
||||
let [a, b, c] = triangle;
|
||||
|
||||
let sides = [[b, c], [c, a], [a, b]];
|
||||
|
||||
// For each side of the triangle, imagine a new triangle consisting of the side and `point`.
|
||||
// Calculate the areas of those triangles.
|
||||
let areas = sides.map(|[start, end]| signed_triangle_area(start.pos, end.pos, point));
|
||||
|
||||
// Use the areas to determine the side of the line at which the point exists.
|
||||
// If the area is positive, the point is on the right side of the triangle line.
|
||||
let [side_ab, side_bc, side_ca] = areas.map(|area| area >= 0.0);
|
||||
|
||||
// Total area of the traingle.
|
||||
let triangle_area: f32 = areas.iter().sum();
|
||||
|
||||
// egui does not wind the triangles in a consistent order, otherwise we might check if the
|
||||
// point is on a *specific* side of each line. As it is, we just check if the point is on the
|
||||
// same side of each line.
|
||||
let inside = side_ab == side_bc && side_bc == side_ca;
|
||||
|
||||
// Normalize the weights.
|
||||
let weights = areas.map(|area| area / triangle_area);
|
||||
|
||||
PointInTriangle { inside, weights }
|
||||
}
|
||||
|
||||
/// Calculate the area of a triangle.
|
||||
fn triangle_area(a: Pos2, b: Pos2, c: Pos2) -> f32 {
|
||||
signed_triangle_area(a, b, c).abs()
|
||||
}
|
||||
|
||||
/// Calculate the area of a triangle.
|
||||
///
|
||||
/// The area will be positive if the triangle is wound clockwise, and negative otherwise.
|
||||
fn signed_triangle_area(a: Pos2, b: Pos2, c: Pos2) -> f32 {
|
||||
// Vector of an arbitrary "base" side of the triangle.
|
||||
let base = c - a;
|
||||
let base_perp = perpendicular(base);
|
||||
let diagonal = c - b;
|
||||
base_perp.dot(diagonal) / 2.0
|
||||
}
|
||||
|
||||
/// Iterate over every pixel coordinate in a box.
|
||||
#[inline(always)]
|
||||
fn pixels_in_box(
|
||||
PxBoundingBox {
|
||||
x_from,
|
||||
y_from,
|
||||
x_to,
|
||||
y_to,
|
||||
}: PxBoundingBox,
|
||||
) -> impl ExactSizeIterator<Item = [usize; 2]> {
|
||||
struct IterWithLen<I>(I, usize);
|
||||
impl<I: Iterator> ExactSizeIterator for IterWithLen<I> {}
|
||||
impl<I: Iterator> Iterator for IterWithLen<I> {
|
||||
type Item = I::Item;
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
(self.1, Some(self.1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next()
|
||||
}
|
||||
}
|
||||
|
||||
let len = (x_from..x_to).len() * (y_from..y_to).len();
|
||||
let iter = (x_from..x_to).flat_map(move |x| (y_from..y_to).map(move |y| [x, y]));
|
||||
|
||||
debug_assert_eq!(len, iter.clone().count());
|
||||
|
||||
IterWithLen(iter, len)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use egui::{Color32, Pos2, Vec2, emath::TSTransform, epaint::Vertex};
|
||||
|
||||
use super::triangle_bounding_box;
|
||||
|
||||
#[test]
|
||||
fn px_bounding_box() {
|
||||
let triangle = [
|
||||
Vertex {
|
||||
pos: Pos2::new(56.3, 18.9),
|
||||
uv: Default::default(),
|
||||
color: Color32::WHITE,
|
||||
},
|
||||
Vertex {
|
||||
pos: Pos2::new(56.4, 19.6),
|
||||
uv: Default::default(),
|
||||
color: Color32::WHITE,
|
||||
},
|
||||
Vertex {
|
||||
pos: Pos2::new(55.8, 20.5),
|
||||
uv: Default::default(),
|
||||
color: Color32::WHITE,
|
||||
},
|
||||
];
|
||||
|
||||
let pixels_per_point = 2.0;
|
||||
let point_to_pixel = TSTransform {
|
||||
scaling: pixels_per_point,
|
||||
translation: Vec2::new(-55.8, -18.9) * pixels_per_point,
|
||||
};
|
||||
|
||||
let bounding_box = triangle_bounding_box(&triangle.each_ref(), point_to_pixel);
|
||||
|
||||
insta::assert_debug_snapshot!((triangle, point_to_pixel, bounding_box));
|
||||
}
|
||||
}
|
||||
|
||||
impl BlendFn for blend::Normal {
|
||||
fn blend(a: Color32, b: Color32) -> Color32 {
|
||||
a.blend(b)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlendFn for blend::Add {
|
||||
fn blend(a: Color32, b: Color32) -> Color32 {
|
||||
a + b
|
||||
}
|
||||
}
|
||||
|
||||
impl BlendFn for blend::Multiply {
|
||||
fn blend(a: Color32, b: Color32) -> Color32 {
|
||||
a * b
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: src/custom_code_block.rs
|
||||
expression: markdown
|
||||
---
|
||||
# Hello world
|
||||
## Subheader
|
||||
- 1
|
||||
```foo
|
||||
whatever
|
||||
some code
|
||||
Hi mom!
|
||||
```
|
||||
|
||||
```` # wrong number of ticks, but that's ok
|
||||
``` # indented ticks
|
||||
```
|
||||
|
||||
``` # no closing ticks
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: src/painting.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],
|
||||
],
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: src/rasterizer.rs
|
||||
expression: "(triangle, point_to_pixel, bounding_box)"
|
||||
---
|
||||
(
|
||||
[
|
||||
Vertex {
|
||||
pos: [56.3 18.9],
|
||||
uv: [0.0 0.0],
|
||||
color: #FF_FF_FF_FF,
|
||||
},
|
||||
Vertex {
|
||||
pos: [56.4 19.6],
|
||||
uv: [0.0 0.0],
|
||||
color: #FF_FF_FF_FF,
|
||||
},
|
||||
Vertex {
|
||||
pos: [55.8 20.5],
|
||||
uv: [0.0 0.0],
|
||||
color: #FF_FF_FF_FF,
|
||||
},
|
||||
],
|
||||
TSTransform {
|
||||
scaling: 2.0,
|
||||
translation: [-111.6 -37.8],
|
||||
},
|
||||
PxBoundingBox {
|
||||
x_from: 0,
|
||||
y_from: 0,
|
||||
x_to: 2,
|
||||
y_to: 4,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
fmt::{self, Display},
|
||||
iter::repeat_n,
|
||||
};
|
||||
|
||||
use egui::{
|
||||
Color32, InputState, Key, Modifiers, TextBuffer, TextEdit, Ui, Vec2, text::CCursorRange,
|
||||
};
|
||||
|
||||
use crate::easy_mark::MemoizedHighlighter;
|
||||
|
||||
#[derive(Default, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MdTextEdit {
|
||||
pub text: String,
|
||||
|
||||
#[serde(skip)]
|
||||
highlighter: MemoizedHighlighter,
|
||||
|
||||
#[serde(skip)]
|
||||
focused: bool,
|
||||
|
||||
#[serde(skip)]
|
||||
cursor: Option<CCursorRange>,
|
||||
}
|
||||
|
||||
impl MdTextEdit {
|
||||
pub fn new() -> Self {
|
||||
MdTextEdit::default()
|
||||
}
|
||||
|
||||
pub fn from_text(text: String) -> Self {
|
||||
MdTextEdit {
|
||||
text,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut Ui) {
|
||||
let Self {
|
||||
text,
|
||||
highlighter,
|
||||
focused,
|
||||
cursor,
|
||||
} = self;
|
||||
|
||||
let w = ui.available_width();
|
||||
|
||||
let mut layouter = |ui: &egui::Ui, easymark: &dyn TextBuffer, _wrap_width: f32| {
|
||||
let mut layout_job = highlighter.highlight(ui.style(), easymark.as_str(), *cursor);
|
||||
layout_job.wrap.max_width = w - 10.0;
|
||||
ui.fonts(|f| f.layout_job(layout_job))
|
||||
};
|
||||
|
||||
if *focused {
|
||||
ui.input_mut(|input| {
|
||||
handle_tab_input(text, input, *cursor);
|
||||
});
|
||||
}
|
||||
|
||||
let text_edit = TextEdit::multiline(text)
|
||||
.layouter(&mut layouter)
|
||||
.background_color(Color32::TRANSPARENT)
|
||||
.desired_rows(1)
|
||||
.min_size(Vec2::new(w, 0.0))
|
||||
.lock_focus(true)
|
||||
.show(ui);
|
||||
|
||||
*focused = text_edit.response.has_focus();
|
||||
|
||||
if *cursor != text_edit.cursor_range {
|
||||
*cursor = text_edit.cursor_range;
|
||||
//ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tab_input(
|
||||
text: &mut String,
|
||||
input: &mut InputState,
|
||||
cursor: Option<CCursorRange>,
|
||||
) -> Option<Infallible> {
|
||||
let break_if_not = || ();
|
||||
let cursor = cursor.and_then(|c| c.single())?;
|
||||
|
||||
let do_unindent = input.consume_key(Modifiers::SHIFT, Key::Tab);
|
||||
let do_indent = input.consume_key(Modifiers::NONE, Key::Tab);
|
||||
|
||||
(do_unindent || do_indent).then(break_if_not)?;
|
||||
|
||||
let row_n = text
|
||||
.chars()
|
||||
.take(cursor.index)
|
||||
.filter(|&c| c == '\n')
|
||||
.count();
|
||||
|
||||
let row = text.lines().nth(row_n)?;
|
||||
let (indent, content) = row.split_once("- ")?;
|
||||
indent.trim().is_empty().then(break_if_not)?;
|
||||
|
||||
let indents = indent.chars().count() / 2;
|
||||
let indents = if do_indent {
|
||||
indents + 1
|
||||
} else if indents == 0 {
|
||||
return None;
|
||||
} else {
|
||||
indents.saturating_sub(1)
|
||||
};
|
||||
*text = text
|
||||
.lines()
|
||||
.take(row_n)
|
||||
.flat_map(|line| [line, "\n"])
|
||||
.chain(repeat_n(" ", indents))
|
||||
.chain([format!("- {content}\n").as_str()])
|
||||
.chain(text.lines().skip(row_n + 1).flat_map(|line| [line, "\n"]))
|
||||
.collect();
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
impl Display for MdTextEdit {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.text)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use egui::Id;
|
||||
use rand::{Rng, rng};
|
||||
|
||||
pub fn random_id() -> Id {
|
||||
Id::new(rng().random::<u64>())
|
||||
}
|
||||
|
||||
/// An [mpsc::Sender] where the receiver is the GUI.
|
||||
#[derive(Clone)]
|
||||
pub struct GuiSender<T> {
|
||||
tx: mpsc::Sender<T>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl<T> GuiSender<T> {
|
||||
pub fn new(tx: mpsc::Sender<T>, ctx: &egui::Context) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
ctx: ctx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, t: T) -> Result<(), mpsc::SendError<T>> {
|
||||
self.tx.send(t)?;
|
||||
self.ctx.request_repaint();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user