18 Commits

Author SHA1 Message Date
6e59cb86dc Tweak text colors 2025-07-06 19:57:14 +02:00
98a4f50031 Lower handwriting stroke width 2025-07-06 19:35:11 +02:00
0a19462b0f Add pref to hide cursor when handwriting 2025-06-29 18:55:20 +02:00
cfed4fd5ed Fix performance on large canvases 2025-06-23 22:18:57 +02:00
eaf0c3cb55 handwritin: Grow and shrink the canvas without refreshing 2025-06-23 21:50:37 +02:00
61669e15bd Split CanvasRasterizer image into many small tiles 2025-06-23 21:35:01 +02:00
f2556f7125 Split Image handling from handwriting/mod.rs 2025-06-23 20:34:02 +02:00
7494dc6b75 Add snapshots 2025-06-21 17:21:59 +02:00
43afb9dfd3 Re-use tessellator between frames 2025-06-21 17:20:00 +02:00
6b5bbfbc54 Make folder-list collapsible 2025-06-21 16:28:12 +02:00
b39419888b Add folder tree 2025-06-19 23:09:41 +02:00
4e9eacc7b0 Improve handwriting disk-format and decoding 2025-06-18 22:56:33 +02:00
8251937be9 Open tabs when opened 2025-06-18 22:55:09 +02:00
5ca9dfabb8 Rename painting module to handwriting 2025-06-15 12:53:23 +02:00
1df81509df Add some comments to the handwriting code 2025-06-15 12:52:13 +02:00
7d234641cb Add Ctrl+S and indicate when files are dirty 2025-06-15 12:39:52 +02:00
1ed278cc55 Add sketchy PKGBUILD 2025-06-14 23:09:03 +02:00
2a830f0539 Add janky spinner 2025-06-13 23:06:45 +02:00
36 changed files with 1575 additions and 332 deletions

318
Cargo.lock generated
View File

@ -163,6 +163,12 @@ dependencies = [
"x11rb", "x11rb",
] ]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@ -523,6 +529,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991"
[[package]]
name = "data-url"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@ -696,6 +708,21 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "egui_extras"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624659a2e972a46f4d5f646557906c55f1cd5a0836eddbe610fdf1afba1b4226"
dependencies = [
"ahash",
"egui",
"enum-map",
"log",
"mime_guess2",
"profiling",
"resvg",
]
[[package]] [[package]]
name = "egui_glow" name = "egui_glow"
version = "0.31.1" version = "0.31.1"
@ -745,6 +772,27 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "enum-map"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
dependencies = [
"enum-map-derive",
"serde",
]
[[package]]
name = "enum-map-derive"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "enumn" name = "enumn"
version = "0.1.14" version = "0.1.14"
@ -853,6 +901,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@ -1326,6 +1380,12 @@ dependencies = [
"tiff", "tiff",
] ]
[[package]]
name = "imagesize"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284"
[[package]] [[package]]
name = "indenter" name = "indenter"
version = "0.3.3" version = "0.3.3"
@ -1344,11 +1404,12 @@ dependencies = [
[[package]] [[package]]
name = "inkr" name = "inkr"
version = "0.1.0" version = "1.0.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"eframe", "eframe",
"egui", "egui",
"egui_extras",
"egui_glow 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)", "egui_glow 0.31.1 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger", "env_logger",
"eyre", "eyre",
@ -1356,7 +1417,7 @@ dependencies = [
"half", "half",
"insta", "insta",
"log", "log",
"rand", "rand 0.9.1",
"rfd", "rfd",
"serde", "serde",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@ -1471,6 +1532,15 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kurbo"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.172"
@ -1580,6 +1650,24 @@ dependencies = [
"paste", "paste",
] ]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [
"mime",
"phf",
"phf_shared",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.8" version = "0.8.8"
@ -2047,6 +2135,56 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
"unicase",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher 1.0.1",
"unicase",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.10" version = "1.1.10"
@ -2179,6 +2317,15 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.1" version = "0.9.1"
@ -2186,7 +2333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core", "rand_core 0.9.3",
] ]
[[package]] [[package]]
@ -2196,9 +2343,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.9.3",
] ]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.3" version = "0.9.3"
@ -2214,6 +2367,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rctree"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.4.1" version = "0.4.1"
@ -2267,6 +2426,20 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "resvg"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cadccb3d99a9efb8e5e00c16fbb732cbe400db2ec7fc004697ee7d97d86cf1f4"
dependencies = [
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
]
[[package]] [[package]]
name = "rfd" name = "rfd"
version = "0.15.3" version = "0.15.3"
@ -2291,6 +2464,15 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "rgb"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ron" name = "ron"
version = "0.8.1" version = "0.8.1"
@ -2303,6 +2485,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "roxmltree"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@ -2396,6 +2584,27 @@ version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -2486,6 +2695,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.26.3" version = "0.26.3"
@ -2508,6 +2726,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "svgtypes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e44e288cd960318917cbd540340968b90becc8bc81f171345d706e7a89d9d70"
dependencies = [
"kurbo",
"siphasher 0.3.11",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.100" version = "2.0.100"
@ -2609,6 +2837,32 @@ dependencies = [
"weezl", "weezl",
] ]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.7.6" version = "0.7.6"
@ -2684,6 +2938,12 @@ dependencies = [
"rustc-hash", "rustc-hash",
] ]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@ -2719,6 +2979,50 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "usvg"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b0a51b72ab80ca511d126b77feeeb4fb1e972764653e61feac30adc161a756"
dependencies = [
"base64 0.21.7",
"log",
"pico-args",
"usvg-parser",
"usvg-tree",
"xmlwriter",
]
[[package]]
name = "usvg-parser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd4e3c291f45d152929a31f0f6c819245e2921bfd01e7bd91201a9af39a2bdc"
dependencies = [
"data-url",
"flate2",
"imagesize",
"kurbo",
"log",
"roxmltree",
"simplecss",
"siphasher 0.3.11",
"svgtypes",
"usvg-tree",
]
[[package]]
name = "usvg-tree"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee3d202ebdb97a6215604b8f5b4d6ef9024efd623cf2e373a6416ba976ec7d3"
dependencies = [
"rctree",
"strict-num",
"svgtypes",
"tiny-skia-path",
]
[[package]] [[package]]
name = "utf16_iter" name = "utf16_iter"
version = "1.0.5" version = "1.0.5"
@ -3525,6 +3829,12 @@ version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda" checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.7.5" version = "0.7.5"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "inkr" name = "inkr"
version = "0.1.0" version = "1.0.0"
authors = [] authors = []
edition = "2024" edition = "2024"
@ -13,6 +13,7 @@ pinenote = []
[dependencies] [dependencies]
egui = "0.31" egui = "0.31"
egui_extras = { version = "0.31", features = ["svg"] }
eframe = { version = "0.31", default-features = false, features = [ eframe = { version = "0.31", default-features = false, features = [
"glow", # alt: "wgpu". "glow", # alt: "wgpu".
"persistence", "persistence",

27
PKGBUILD Normal file
View File

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

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

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

After

Width:  |  Height:  |  Size: 2.8 KiB

9
assets/inkr.desktop Executable file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2,11 +2,14 @@ use std::{
fs, fs,
path::PathBuf, path::PathBuf,
sync::{Arc, mpsc}, sync::{Arc, mpsc},
thread::JoinHandle,
time::{Duration, Instant},
}; };
use crate::{file_editor::FileEditor, preferences::Preferences, util::GuiSender}; use crate::{file_editor::FileEditor, folder::Folder, preferences::Preferences, util::GuiSender};
use egui::{ use egui::{
Align, Button, Color32, FontData, FontDefinitions, PointerButton, RichText, ScrollArea, Stroke, Align, Button, Context, FontData, FontDefinitions, Image, Key, Modifiers, PointerButton,
RichText, ScrollArea, Widget, include_image,
}; };
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
@ -18,13 +21,46 @@ pub struct App {
actions_tx: mpsc::Sender<Action>, actions_tx: mpsc::Sender<Action>,
#[serde(skip)] #[serde(skip)]
actions_rx: mpsc::Receiver<Action>, actions_rx: mpsc::Receiver<Action>,
#[serde(skip)]
jobs: Jobs,
tabs: Vec<(TabId, Tab)>, tabs: Vec<(TabId, Tab)>,
show_folders: bool,
folders: Vec<Folder>,
open_tab_index: Option<usize>, open_tab_index: Option<usize>,
next_tab_id: TabId, next_tab_id: TabId,
} }
pub struct Jobs {
handles: Vec<JoinHandle<()>>,
actions_tx: mpsc::Sender<Action>,
}
impl Jobs {
fn start(&mut self, ctx: &Context, job: impl FnOnce() -> Option<Action> + Send + 'static) {
let ctx = ctx.clone();
let actions_tx = self.actions_tx.clone();
self.handles.push(std::thread::spawn(move || {
// start rendering the spinner thingy
ctx.request_repaint();
let start = Instant::now();
if let Some(action) = job() {
let _ = actions_tx.send(action);
ctx.request_repaint();
};
// Make sure that task takes at least 250ms to run, so that the spinner won't blink
let sleep_for = Duration::from_millis(250).saturating_sub(start.elapsed());
std::thread::sleep(sleep_for);
}));
}
}
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
enum Tab { enum Tab {
File(FileEditor), File(FileEditor),
@ -36,12 +72,19 @@ impl Tab {
Tab::File(file_editor) => file_editor.title(), Tab::File(file_editor) => file_editor.title(),
} }
} }
pub fn is_dirty(&self) -> bool {
match self {
Tab::File(file_editor) => file_editor.is_dirty,
}
}
} }
pub type TabId = usize; pub type TabId = usize;
pub enum Action { pub enum Action {
OpenFile(FileEditor), OpenFile(FileEditor),
OpenFolder(Folder),
MoveFile(TabId, PathBuf), MoveFile(TabId, PathBuf),
CloseTab(TabId), CloseTab(TabId),
// TODO // TODO
@ -55,11 +98,17 @@ impl Default for App {
let (actions_tx, actions_rx) = mpsc::channel(); let (actions_tx, actions_rx) = mpsc::channel();
Self { Self {
preferences: Preferences::default(), preferences: Preferences::default(),
actions_tx, actions_tx: actions_tx.clone(/* this is silly, i know */),
actions_rx, actions_rx,
jobs: Jobs {
handles: Default::default(),
actions_tx,
},
tabs: vec![(1, Tab::File(FileEditor::new("note.md")))], tabs: vec![(1, Tab::File(FileEditor::new("note.md")))],
open_tab_index: None, open_tab_index: None,
next_tab_id: 2, next_tab_id: 2,
show_folders: false,
folders: vec![],
} }
} }
} }
@ -133,11 +182,8 @@ impl App {
cc.egui_ctx.set_fonts(fonts); cc.egui_ctx.set_fonts(fonts);
cc.egui_ctx.style_mut(|style| { // enable features on egui_extras to add more image types
// TODO: change color of text in TextEdit egui_extras::install_image_loaders(&cc.egui_ctx);
style.visuals.widgets.noninteractive.fg_stroke =
Stroke::new(1.0, Color32::from_rgb(200, 200, 200));
});
if let Some(storage) = cc.storage { if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
@ -146,12 +192,24 @@ impl App {
Default::default() Default::default()
} }
fn actions_tx(&self, ctx: &egui::Context) -> GuiSender<Action> { fn actions_tx(&self, ctx: &Context) -> GuiSender<Action> {
GuiSender::new(self.actions_tx.clone(), ctx) GuiSender::new(self.actions_tx.clone(), ctx)
} }
fn handle_action(&mut self, action: Action) { fn handle_action(&mut self, action: Action) {
match action { match action {
Action::OpenFolder(new_folder) => {
if let Some(folder) = self
.folders
.iter_mut()
.find(|folder| folder.path() == new_folder.path())
{
*folder = new_folder;
} else {
self.folders.push(new_folder);
self.folders.sort_by(|a, b| a.name().cmp(b.name()));
}
}
Action::OpenFile(file_editor) => { Action::OpenFile(file_editor) => {
self.open_tab(Tab::File(file_editor)); self.open_tab(Tab::File(file_editor));
} }
@ -175,9 +233,11 @@ impl eframe::App for App {
eframe::set_value(storage, eframe::APP_KEY, self); eframe::set_value(storage, eframe::APP_KEY, self);
} }
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
self.preferences.apply(ctx); self.preferences.apply(ctx);
self.jobs.handles.retain(|job| !job.is_finished());
while let Ok(action) = self.actions_rx.try_recv() { while let Ok(action) = self.actions_rx.try_recv() {
self.handle_action(action); self.handle_action(action);
} }
@ -186,11 +246,11 @@ impl eframe::App for App {
self.open_tab_index = Some(self.tabs.len().saturating_sub(1)); self.open_tab_index = Some(self.tabs.len().saturating_sub(1));
} }
//ctx.input_mut(|input| { ctx.input_mut(|input| {
// if input.consume_key(Modifiers::CTRL, Key::H) { if input.consume_key(Modifiers::CTRL, Key::S) {
// self.buffer.push(BufferItem::Painting(Default::default())); self.save_active_tab(ctx);
// } }
//}); });
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
egui::containers::menu::Bar::new().ui(ui, |ui| { egui::containers::menu::Bar::new().ui(ui, |ui| {
@ -205,27 +265,25 @@ impl eframe::App for App {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
if ui.button("Open File").clicked() { if ui.button("Open File").clicked() {
let actions_tx = self.actions_tx(ui.ctx()); self.jobs.start(ui.ctx(), move || {
std::thread::spawn(move || { let file_path = rfd::FileDialog::new().pick_file()?;
let file = rfd::FileDialog::new().pick_file();
let Some(file_path) = file else { return }; let text = fs::read_to_string(&file_path)
.inspect_err(|e| log::error!("Failed to read {file_path:?}: {e}"))
let text = match fs::read_to_string(&file_path) { .ok()?;
Ok(text) => text,
Err(e) => {
log::error!("Failed to read {file_path:?}: {e}");
return;
}
};
let editor = FileEditor::from_file(file_path, &text); let editor = FileEditor::from_file(file_path, &text);
let _ = actions_tx.send(Action::OpenFile(editor)); Some(Action::OpenFile(editor))
}); });
} }
if ui.button("Open Folder").clicked() { if ui.button("Open Folder").clicked() {
log::error!("Open Folder not implemented"); self.jobs.start(ui.ctx(), move || {
let path = rfd::FileDialog::new().pick_folder()?;
let name = path.file_name()?.to_string_lossy().to_string();
let folder = Folder::NotLoaded { name, path };
Some(Action::OpenFolder(folder))
});
} }
if ui if ui
@ -237,52 +295,41 @@ impl eframe::App for App {
} }
} }
let open_file = let can_save_file = self
self.open_tab_index .open_tab_index
.and_then(|i| self.tabs.get(i)) .and_then(|i| self.tabs.get(i))
.and_then(|(id, tab)| match tab { .map(|(id, tab)| match tab {
Tab::File(file_editor) => Some((*id, file_editor)), Tab::File(file_editor) => (*id, file_editor),
}); })
.and_then(|(_, file_editor)| file_editor.path().zip(Some(file_editor)))
.is_some();
let open_file_with_path = open_file if ui.add_enabled(can_save_file, Button::new("Save")).clicked() {
.clone() self.save_active_tab(ui.ctx());
.and_then(|(_, file_editor)| file_editor.path().zip(Some(file_editor))); }
if ui let open_file = self.open_tab_index.and_then(|i| self.tabs.get(i)).map(
.add_enabled(open_file_with_path.is_some(), Button::new("Save")) |(id, tab)| match tab {
.clicked() Tab::File(file_editor) => (*id, file_editor),
{ },
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"))] #[cfg(not(target_arch = "wasm32"))]
if ui if ui
.add_enabled(open_file.is_some(), Button::new("Save As")) .add_enabled(open_file.is_some(), Button::new("Save As"))
.clicked() .clicked()
{ {
let actions_tx = self.actions_tx(ui.ctx());
let (tab_id, editor) = let (tab_id, editor) =
open_file.expect("We checked that open_file is_some"); open_file.expect("We checked that open_file is_some");
let text = editor.to_string(); let text = editor.to_string();
std::thread::spawn(move || { self.jobs.start(ui.ctx(), move || {
let Some(file_path) = rfd::FileDialog::new().save_file() else { let file_path = rfd::FileDialog::new().save_file()?;
return;
};
if let Err(e) = fs::write(&file_path, text.as_bytes()) { fs::write(&file_path, text.as_bytes())
log::error!("{e}"); .inspect_err(|e| log::error!("{e}"))
return; .ok()?;
};
let _ = actions_tx.send(Action::MoveFile(tab_id, file_path)); Some(Action::MoveFile(tab_id, file_path))
}); });
} }
@ -297,7 +344,18 @@ impl eframe::App for App {
} }
}); });
ui.add_space(16.0); ui.add_space(8.0);
let image = Image::new(include_image!("../assets/collapse-icon.svg"));
let image = image.tint(ui.style().visuals.text_color());
if Button::image(image).ui(ui).clicked() {
self.show_folders = !self.show_folders;
}
if !self.jobs.handles.is_empty() {
ui.add_space(8.0);
ui.spinner();
}
ui.add_space(16.0); ui.add_space(16.0);
@ -306,8 +364,7 @@ impl eframe::App for App {
let selected = self.open_tab_index == Some(i); let selected = self.open_tab_index == Some(i);
let mut button = Button::new(tab.title()).selected(selected); let mut button = Button::new(tab.title()).selected(selected);
let dirty = i == 0; // TODO: mark as dirty when contents hasn't been saved if tab.is_dirty() {
if dirty {
button = button.right_text(RichText::new("*").strong()) button = button.right_text(RichText::new("*").strong())
} }
@ -323,6 +380,39 @@ impl eframe::App for App {
}); });
}); });
egui::SidePanel::left("file browser")
.resizable(true)
.show_animated(ctx, self.show_folders, |ui| {
if ui.button("refresh").clicked() {
for folder in &mut self.folders {
folder.unload();
}
}
ScrollArea::both().auto_shrink(false).show(ui, |ui| {
self.folders.retain_mut(|folder| {
let response = folder.show(ui);
if let Some(file_path) = response.open_file {
let file_path = file_path.to_owned();
self.jobs.start(ui.ctx(), move || {
let text = fs::read_to_string(&file_path)
.inspect_err(|e| {
log::error!("Failed to read {file_path:?}: {e}")
})
.ok()?;
let editor = FileEditor::from_file(file_path, &text);
Some(Action::OpenFile(editor))
});
}
// delete on right-click
!response.clicked_by(PointerButton::Secondary)
});
});
});
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
if let Some(Tab::File(file_editor)) = self if let Some(Tab::File(file_editor)) = self
.open_tab_index .open_tab_index
@ -354,5 +444,33 @@ impl App {
let id = self.next_tab_id; let id = self.next_tab_id;
self.next_tab_id += 1; self.next_tab_id += 1;
self.tabs.insert(i, (id, tab)); self.tabs.insert(i, (id, tab));
self.open_tab_index = Some(i);
}
fn save_active_tab(&mut self, ctx: &Context) {
let open_file = self
.open_tab_index
.and_then(|i| self.tabs.get_mut(i))
.map(|(id, tab)| match tab {
Tab::File(file_editor) => (*id, file_editor),
})
.and_then(|(_, file_editor)| {
file_editor
.path()
.map(ToOwned::to_owned)
.zip(Some(file_editor))
});
if let Some((file_path, file_editor)) = open_file {
file_editor.is_dirty = false;
let text = file_editor.to_string();
let file_path = file_path.to_owned();
self.jobs.start(ctx, move || {
if let Err(e) = fs::write(file_path, text.as_bytes()) {
log::error!("{e}");
};
None
});
}
} }
} }

View File

@ -188,7 +188,9 @@ fn format_from_style(
) -> egui::text::TextFormat { ) -> egui::text::TextFormat {
use egui::{Align, Color32, Stroke, TextStyle}; use egui::{Align, Color32, Stroke, TextStyle};
let color = if emark_style.strong || emark_style.heading { let color = if emark_style.code {
egui_style.visuals.strong_text_color() * Color32::from_rgb(0x44, 0xff, 0x44)
} else if emark_style.strong || emark_style.heading {
egui_style.visuals.strong_text_color() egui_style.visuals.strong_text_color()
} else if emark_style.quoted { } else if emark_style.quoted {
egui_style.visuals.weak_text_color() egui_style.visuals.weak_text_color()

View File

@ -12,7 +12,7 @@ use egui::{
use crate::{ use crate::{
custom_code_block::{MdItem, iter_lines_and_code_blocks}, custom_code_block::{MdItem, iter_lines_and_code_blocks},
painting::{self, Handwriting, HandwritingStyle}, handwriting::{self, Handwriting, HandwritingStyle},
preferences::Preferences, preferences::Preferences,
text_editor::MdTextEdit, text_editor::MdTextEdit,
}; };
@ -22,12 +22,15 @@ pub struct FileEditor {
title: String, title: String,
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
pub buffer: Vec<BufferItem>, pub buffer: Vec<BufferItem>,
/// Whether the file has been edited since it was laste saved to disk.
pub is_dirty: bool,
} }
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
pub enum BufferItem { pub enum BufferItem {
Text(MdTextEdit), Text(MdTextEdit),
Handwriting(Handwriting), Handwriting(Box<Handwriting>),
} }
impl FileEditor { impl FileEditor {
@ -37,6 +40,7 @@ impl FileEditor {
title: title.into(), title: title.into(),
path: None, path: None,
buffer, buffer,
is_dirty: false,
} }
} }
@ -69,9 +73,11 @@ impl FileEditor {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("new"); ui.label("new");
if ui.button("text").clicked() { if ui.button("text").clicked() {
self.is_dirty = true;
self.buffer.push(BufferItem::Text(Default::default())); self.buffer.push(BufferItem::Text(Default::default()));
} }
if ui.button("writing").clicked() { if ui.button("writing").clicked() {
self.is_dirty = true;
self.buffer self.buffer
.push(BufferItem::Handwriting(Default::default())); .push(BufferItem::Handwriting(Default::default()));
} }
@ -138,14 +144,19 @@ impl FileEditor {
let item_response = ui.allocate_ui(item_size, |ui| match item { let item_response = ui.allocate_ui(item_size, |ui| match item {
BufferItem::Text(text_edit) => { BufferItem::Text(text_edit) => {
text_edit.ui(ui); if text_edit.ui(ui).changed {
self.is_dirty = true;
} }
BufferItem::Handwriting(painting) => { }
BufferItem::Handwriting(handwriting) => {
let style = HandwritingStyle { let style = HandwritingStyle {
animate: preferences.animations, animate: preferences.animations,
hide_cursor: preferences.hide_handwriting_cursor,
..HandwritingStyle::from_theme(ui.ctx().theme()) ..HandwritingStyle::from_theme(ui.ctx().theme())
}; };
painting.ui(&style, ui); if handwriting.ui(&style, ui).changed {
self.is_dirty = true;
}
} }
}); });
@ -211,10 +222,12 @@ impl FileEditor {
Ordering::Greater => { Ordering::Greater => {
let item = self.buffer.remove(from); let item = self.buffer.remove(from);
self.buffer.insert(to, item); self.buffer.insert(to, item);
self.is_dirty = true;
} }
Ordering::Less => { Ordering::Less => {
let item = self.buffer.remove(from); let item = self.buffer.remove(from);
self.buffer.insert(to - 1, item); self.buffer.insert(to - 1, item);
self.is_dirty = true;
} }
Ordering::Equal => {} Ordering::Equal => {}
} }
@ -275,7 +288,7 @@ impl From<&str> for FileEditor {
match item { match item {
MdItem::Line(line) => push_text(buffer, line), MdItem::Line(line) => push_text(buffer, line),
MdItem::CodeBlock { key, content, span } => match key { MdItem::CodeBlock { key, content, span } => match key {
painting::CODE_BLOCK_KEY => match Handwriting::from_str(span) { handwriting::CODE_BLOCK_KEY => match Handwriting::from_str(span) {
Ok(handwriting) => { Ok(handwriting) => {
if let Some(BufferItem::Text(text_edit)) = buffer.last_mut() { if let Some(BufferItem::Text(text_edit)) = buffer.last_mut() {
if text_edit.text.ends_with('\n') { if text_edit.text.ends_with('\n') {
@ -285,7 +298,7 @@ impl From<&str> for FileEditor {
} }
} }
}; };
buffer.push(BufferItem::Handwriting(handwriting)) buffer.push(BufferItem::Handwriting(Box::new(handwriting)))
} }
Err(e) => { Err(e) => {
log::error!("Failed to decode handwriting {content:?}: {e}"); log::error!("Failed to decode handwriting {content:?}: {e}");

218
src/folder.rs Normal file
View File

@ -0,0 +1,218 @@
use std::{
fs::read_dir,
mem,
ops::Deref,
path::{Path, PathBuf},
sync::mpsc,
thread,
};
use egui::{Response, Ui};
use eyre::{Context, OptionExt, eyre};
use serde::{Deserialize, Serialize};
pub enum Folder {
NotLoaded {
name: String,
path: PathBuf,
},
Loading {
name: String,
path: PathBuf,
recv: mpsc::Receiver<LoadedFolder>,
},
Loaded(LoadedFolder),
}
pub struct LoadedFolder {
pub name: String,
pub path: PathBuf,
pub child_folders: Vec<Folder>,
pub child_files: Vec<File>,
}
pub struct File {
pub name: String,
pub path: PathBuf,
}
pub struct FolderResponse<'a> {
inner: Response,
pub open_file: Option<&'a Path>,
}
impl Deref for FolderResponse<'_> {
type Target = Response;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl LoadedFolder {
pub fn show<'a>(&'a mut self, ui: &mut Ui) -> FolderResponse<'a> {
let mut open_file = None;
let inner = ui
.collapsing(&self.name, |ui| {
for folder in &mut self.child_folders {
open_file = open_file.or(folder.show(ui).open_file);
}
for file in &mut self.child_files {
if ui.button(&file.name).clicked() {
open_file = Some(file.path.as_path())
};
}
})
.header_response;
FolderResponse { inner, open_file }
}
fn load(path: PathBuf) -> eyre::Result<Self> {
let name = path
.file_name()
.ok_or_eyre("Path is missing a file-name")?
.to_string_lossy()
.to_string();
let mut child_folders = vec![];
let mut child_files = vec![];
for entry in read_dir(&path).with_context(|| eyre!("Couldn't read dir {path:?}"))? {
let entry = entry.with_context(|| eyre!("Couldn't read dir {path:?}"))?;
let path = entry.path();
let name = path
.file_name()
.ok_or_eyre("Path is missing a file-name")?
.to_string_lossy()
.to_string();
let file_type = entry.file_type()?;
if file_type.is_symlink() {
log::error!("Symlinks not yet supported, skipping {path:?}");
continue;
} else if file_type.is_file() {
child_files.push(File { name, path });
} else if file_type.is_dir() {
child_folders.push(Folder::NotLoaded { name, path });
}
}
let folder = LoadedFolder {
name,
path,
child_folders,
child_files,
};
Ok(folder)
}
}
impl Folder {
fn load(&mut self, ui: &mut Ui) -> Option<&mut LoadedFolder> {
if let Folder::NotLoaded { name, path } = self {
let (tx, rx) = mpsc::channel();
{
let path = path.clone();
let ctx = ui.ctx().clone();
thread::spawn(move || match LoadedFolder::load(path) {
Err(e) => log::error!("Failed to load folder: {e}"),
Ok(folder) => {
let _ = tx.send(folder);
ctx.request_repaint();
}
});
}
*self = Folder::Loading {
name: mem::take(name),
path: mem::take(path),
recv: rx,
};
}
if let Folder::Loading { recv, .. } = self {
match recv.try_recv() {
Ok(folder) => *self = Folder::Loaded(folder),
Err(_) => return None,
}
}
let Folder::Loaded(folder) = self else {
unreachable!()
};
Some(folder)
}
pub fn show<'a>(&'a mut self, ui: &mut Ui) -> FolderResponse<'a> {
self.load(ui);
if let Folder::Loaded(folder) = self {
return folder.show(ui);
}
FolderResponse {
inner: ui.label(self.name()),
open_file: None,
}
}
pub fn path(&self) -> &Path {
match self {
Folder::NotLoaded { path, .. } => path,
Folder::Loading { path, .. } => path,
Folder::Loaded(folder) => &folder.path,
}
}
pub fn name(&self) -> &str {
match self {
Folder::NotLoaded { name, .. } => name,
Folder::Loading { name, .. } => name,
Folder::Loaded(folder) => &folder.name,
}
}
pub fn unload(&mut self) {
let (name, path) = match self {
Folder::NotLoaded { .. } => return,
Folder::Loading { name, path, .. } => (name, path),
Folder::Loaded(folder) => (&mut folder.name, &mut folder.path),
};
*self = Folder::NotLoaded {
name: mem::take(name),
path: mem::take(path),
}
}
}
impl Serialize for Folder {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.path().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Folder {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let path = PathBuf::deserialize(deserializer)?;
let name = path
.file_name()
.ok_or(D::Error::custom("Path is missing a file-name"))?
.to_string_lossy()
.to_string();
Ok(Folder::NotLoaded { name, path })
}
}

View File

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

View File

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

View File

@ -1,3 +1,6 @@
mod canvas_rasterizer;
mod disk_format;
use std::{ use std::{
fmt::{self, Display}, fmt::{self, Display},
iter, mem, iter, mem,
@ -6,22 +9,20 @@ use std::{
}; };
use base64::{Engine, prelude::BASE64_STANDARD}; use base64::{Engine, prelude::BASE64_STANDARD};
use canvas_rasterizer::CanvasRasterizer;
use disk_format::{DiskFormat, RawStroke, RawStrokeHeader, f16_le};
use egui::{ use egui::{
Color32, ColorImage, CornerRadius, Event, Frame, Id, Mesh, PointerButton, Pos2, Rect, Sense, Color32, Event, Frame, Id, Mesh, PointerButton, Pos2, Rect, Sense, Shape, Stroke, Theme, Ui,
Shape, Stroke, TextureHandle, Theme, Ui, Vec2, Vec2,
emath::{self, TSTransform}, emath::{self, TSTransform},
epaint::{Brush, RectShape, TessellationOptions, Tessellator, Vertex}, epaint::{TessellationOptions, Tessellator, Vertex},
load::SizedTexture,
}; };
use eyre::{Context, bail}; use eyre::{Context, bail};
use eyre::{OptionExt, eyre}; use eyre::{OptionExt, eyre};
use half::f16; use half::f16;
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; use zerocopy::{FromBytes, IntoBytes};
use crate::{ use crate::{custom_code_block::try_from_custom_code_block, rasterizer};
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}; use crate::{custom_code_block::write_custom_code_block, util::random_id};
const HANDWRITING_MIN_HEIGHT: f32 = 100.0; const HANDWRITING_MIN_HEIGHT: f32 = 100.0;
@ -33,7 +34,7 @@ pub const CODE_BLOCK_KEY: &str = "handwriting";
type StrokeBlendMode = rasterizer::blend::Normal; type StrokeBlendMode = rasterizer::blend::Normal;
const TESSELATION_OPTIONS: TessellationOptions = TessellationOptions { const TESSELLATION_OPTIONS: TessellationOptions = TessellationOptions {
feathering: true, feathering: true,
feathering_size_in_pixels: 1.0, feathering_size_in_pixels: 1.0,
coarse_tessellation_culling: true, coarse_tessellation_culling: true,
@ -55,45 +56,48 @@ pub struct HandwritingStyle {
pub bg_line_stroke: Stroke, pub bg_line_stroke: Stroke,
pub bg_color: Color32, pub bg_color: Color32,
pub animate: bool, pub animate: bool,
pub hide_cursor: bool,
} }
#[derive(serde::Deserialize, serde::Serialize)] #[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] #[serde(default)]
pub struct Handwriting { pub struct Handwriting {
#[serde(skip, default = "random_id")]
id: Id,
strokes: Vec<Vec<Pos2>>, 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, height: f32,
desired_height: f32, desired_height: f32,
/// Tesselated mesh of all strokes
#[serde(skip)] #[serde(skip)]
e: Ephemeral,
}
/// Handwriting data that isn't persisted across restarts.
struct Ephemeral {
id: Id,
canvas_rasterizer: CanvasRasterizer,
/// The stroke that is currently being drawed.
current_stroke: Vec<Pos2>,
/// The lines that have not been blitted to `texture` yet.
unblitted_lines: Vec<[Pos2; 2]>,
tessellator: Option<Tessellator>,
/// Tessellated mesh of all strokes
mesh: Arc<Mesh>, mesh: Arc<Mesh>,
#[serde(skip)]
texture: Option<TextureHandle>,
#[serde(skip)]
image: ColorImage,
#[serde(skip)]
refresh_texture: bool, refresh_texture: bool,
/// Context of the last mesh render. /// Context of the last mesh render.
#[serde(skip)]
last_mesh_ctx: Option<MeshContext>, last_mesh_ctx: Option<MeshContext>,
} }
pub struct HandwritingResponse {
pub changed: bool,
}
/// Context of a mesh render. /// Context of a mesh render.
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
struct MeshContext { struct MeshContext {
@ -102,56 +106,28 @@ struct MeshContext {
pub pixels_per_point: f32, pub pixels_per_point: f32,
/// Canvas size in points
pub size: Vec2,
pub stroke: Stroke, 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 { impl Default for Handwriting {
fn default() -> Self { fn default() -> Self {
Self { Self {
id: random_id(),
strokes: Default::default(), strokes: Default::default(),
current_stroke: Default::default(),
height: HANDWRITING_MIN_HEIGHT, height: HANDWRITING_MIN_HEIGHT,
desired_height: HANDWRITING_MIN_HEIGHT, desired_height: HANDWRITING_MIN_HEIGHT,
e: Default::default(),
}
}
}
impl Default for Ephemeral {
fn default() -> Self {
Self {
id: random_id(),
canvas_rasterizer: Default::default(),
current_stroke: Default::default(),
tessellator: None,
mesh: Default::default(), mesh: Default::default(),
texture: None,
image: ColorImage::new([0, 0], Color32::WHITE),
refresh_texture: true, refresh_texture: true,
last_mesh_ctx: None, last_mesh_ctx: None,
unblitted_lines: Default::default(), unblitted_lines: Default::default(),
@ -164,6 +140,7 @@ impl Handwriting {
&mut self, &mut self,
style: Option<&mut HandwritingStyle>, style: Option<&mut HandwritingStyle>,
ui: &mut egui::Ui, ui: &mut egui::Ui,
response: &mut HandwritingResponse,
) -> egui::Response { ) -> egui::Response {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if let Some(style) = style { if let Some(style) = style {
@ -172,33 +149,35 @@ impl Handwriting {
ui.separator(); ui.separator();
} }
if ui.button("Clear Painting").clicked() { if ui.button("clear").clicked() {
self.strokes.clear(); self.strokes.clear();
self.refresh_texture = true; self.e.refresh_texture = true;
response.changed = true;
} }
ui.add_enabled_ui(!self.strokes.is_empty(), |ui| { ui.add_enabled_ui(!self.strokes.is_empty(), |ui| {
if ui.button("Undo").clicked() { if ui.button("undo").clicked() {
self.strokes.pop(); self.strokes.pop();
self.refresh_texture = true; self.e.refresh_texture = true;
response.changed = true;
} }
}); });
let vertex_count: usize = self.mesh.indices.len() / 3; let vertex_count: usize = self.e.mesh.indices.len() / 3;
ui.label(format!("vertices: {vertex_count}")); ui.label(format!("vertices: {vertex_count}"));
}) })
.response .response
} }
fn commit_current_line(&mut self) { pub fn ui_content(
debug_assert!(!self.current_stroke.is_empty()); &mut self,
self.strokes.push(mem::take(&mut self.current_stroke)); style: &HandwritingStyle,
} ui: &mut Ui,
hw_response: &mut HandwritingResponse,
pub fn ui_content(&mut self, style: &HandwritingStyle, ui: &mut Ui) -> egui::Response { ) -> egui::Response {
if style.animate { if style.animate {
self.height = ui.ctx().animate_value_with_time( self.height = ui.ctx().animate_value_with_time(
self.id.with("height animation"), self.e.id.with("height animation"),
self.desired_height, self.desired_height,
0.4, 0.4,
); );
@ -206,30 +185,33 @@ impl Handwriting {
self.height = self.desired_height; self.height = self.desired_height;
} }
let size = Vec2::new(ui.available_width(), self.height); let desired_size = Vec2::new(ui.available_width(), self.height);
let (response, painter) = ui.allocate_painter(size, Sense::drag()); let (mut response, painter) = ui.allocate_painter(desired_size, Sense::drag());
let mut response = response if style.hide_cursor {
//.on_hover_cursor(CursorIcon::Crosshair) response = response.on_hover_and_drag_cursor(egui::CursorIcon::None);
//.on_hover_and_drag_cursor(CursorIcon::None) }
;
let size = response.rect.size(); let size = response.rect.size();
let to_screen = emath::RectTransform::from_to( // Calculate matrices that convert between screen-space and image-space.
//Rect::from_min_size(Pos2::ZERO, response.rect.square_proportions()), // - image-space: 0,0 is the top-left of the texture.
Rect::from_min_size(Pos2::ZERO, size), // - screen-space: 0,0 is the top-left of the window.
response.rect, // Both spaces use the same logical points, not pixels.
); let to_screen =
emath::RectTransform::from_to(Rect::from_min_size(Pos2::ZERO, size), response.rect);
let from_screen = to_screen.inverse(); let from_screen = to_screen.inverse();
// Was the user in the process of drawing a stroke last frame?
let was_drawing = !self.e.current_stroke.is_empty();
// Is the user in the process of drawing a stroke now?
let is_drawing = response.interact_pointer_pos().is_some(); let is_drawing = response.interact_pointer_pos().is_some();
let was_drawing = !self.current_stroke.is_empty();
if !is_drawing { if !is_drawing {
// commit current line
if was_drawing { if was_drawing {
self.commit_current_line(); // commit current line
self.commit_current_line(hw_response);
response.mark_changed(); response.mark_changed();
} }
@ -241,6 +223,8 @@ impl Handwriting {
.map(|p| p.y + HANDWRITING_BOTTOM_PADDING) .map(|p| p.y + HANDWRITING_BOTTOM_PADDING)
.fold(HANDWRITING_MIN_HEIGHT, |max, y| max.max(y)); .fold(HANDWRITING_MIN_HEIGHT, |max, y| max.max(y));
// Change the height of the handwriting item.
// We don't do this mid-stroke, only when the user e.g. lifts the pen.
if self.desired_height != lines_max_y { if self.desired_height != lines_max_y {
self.desired_height = lines_max_y; self.desired_height = lines_max_y;
response.mark_changed(); response.mark_changed();
@ -267,16 +251,17 @@ impl Handwriting {
_ => Some(next), _ => Some(next),
} }
}) })
.cloned()
.filter(|event| { .filter(|event| {
// FIXME: pinenote: PointerMoved are duplicated after the MouseMoved events // FIXME: pinenote: PointerMoved are duplicated after the MouseMoved events
cfg!(not(feature = "pinenote")) || !matches!(event, Event::PointerMoved(..)) cfg!(not(feature = "pinenote")) || !matches!(event, Event::PointerMoved(..))
}) })
.cloned()
.collect::<Vec<_>>() .collect::<Vec<_>>()
}); });
// Process input events and turn them into strokes
for event in events { for event in events {
let last_canvas_pos = self.current_stroke.last(); let last_canvas_pos = self.e.current_stroke.last();
match event { match event {
Event::PointerMoved(new_position) => { Event::PointerMoved(new_position) => {
@ -318,13 +303,13 @@ impl Handwriting {
} => match (button, pressed) { } => match (button, pressed) {
(PointerButton::Primary, true) => { (PointerButton::Primary, true) => {
if last_canvas_pos.is_none() { if last_canvas_pos.is_none() {
self.current_stroke.push(from_screen * pos); self.e.current_stroke.push(from_screen * pos);
} }
} }
(PointerButton::Primary, false) => { (PointerButton::Primary, false) => {
if last_canvas_pos.is_some() { if last_canvas_pos.is_some() {
self.push_to_stroke(from_screen * pos); self.push_to_stroke(from_screen * pos);
self.commit_current_line(); self.commit_current_line(hw_response);
response.mark_changed(); response.mark_changed();
} }
@ -340,8 +325,8 @@ impl Handwriting {
// TODO: In theory, we can get multiple press->draw->release series // TODO: In theory, we can get multiple press->draw->release series
// in the same frame. Should handle this. // in the same frame. Should handle this.
Event::PointerGone | Event::WindowFocused(false) => { Event::PointerGone | Event::WindowFocused(false) => {
if !self.current_stroke.is_empty() { if !self.e.current_stroke.is_empty() {
self.commit_current_line(); self.commit_current_line(hw_response);
break; break;
} }
} }
@ -361,6 +346,7 @@ impl Handwriting {
} }
} }
// Draw the horizontal ruled lines
(1..) (1..)
.map(|n| n as f32 * HANDWRITING_LINE_SPACING) .map(|n| n as f32 * HANDWRITING_LINE_SPACING)
.take_while(|&y| y < size.y) .take_while(|&y| y < size.y)
@ -373,111 +359,119 @@ impl Handwriting {
painter.add(shape); painter.add(shape);
}); });
// Get the position and dimensions of the image
let mesh_rect = response let mesh_rect = response
.rect .rect
.with_max_y(response.rect.min.y + self.desired_height); .with_max_y(response.rect.min.y + self.desired_height);
// These are the values that, if changed, would require the mesh to be re-rendered.
let new_context = MeshContext { let new_context = MeshContext {
ui_theme: ui.ctx().theme(), ui_theme: ui.ctx().theme(),
pixels_per_point: ui.pixels_per_point(), pixels_per_point: ui.pixels_per_point(),
size: mesh_rect.size(),
stroke: style.stroke, stroke: style.stroke,
}; };
if Some(&new_context) != self.last_mesh_ctx.as_ref() { // Figure out if we need to re-rasterize the mesh.
self.refresh_texture = true; if Some(&new_context) != self.e.last_mesh_ctx.as_ref() {
self.e.refresh_texture = true;
} }
if self.refresh_texture { let [px_width, px_height] = {
// rasterize the entire texture from scratch let Vec2 { x, y } = mesh_rect.size() * new_context.pixels_per_point;
self.refresh_texture(style, new_context, ui); [x, y].map(|f| f.ceil() as usize)
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);
self.e.canvas_rasterizer.set_size(px_width, px_height);
if self.e.refresh_texture {
// ...if we do, rasterize the entire texture from scratch
self.refresh_texture(style, new_context);
self.e.unblitted_lines.clear();
} else if !self.e.unblitted_lines.is_empty() {
// ...if we don't, we can get away with only rasterizing the *new* lines onto the
// existing texture.
for [from, to] in std::mem::take(&mut self.e.unblitted_lines) {
self.draw_line_to_texture(from, to, &new_context);
} }
self.e.unblitted_lines.clear();
}
// Draw the texture
self.e.canvas_rasterizer.show(ui.ctx(), &painter, mesh_rect);
response response
} }
fn refresh_texture( fn commit_current_line(&mut self, response: &mut HandwritingResponse) {
&mut self, debug_assert!(!self.e.current_stroke.is_empty());
style: &HandwritingStyle, self.strokes.push(mem::take(&mut self.e.current_stroke));
mesh_context: MeshContext, response.changed = true;
ui: &mut Ui, }
) {
self.last_mesh_ctx = Some(mesh_context);
self.refresh_texture = false; /// Tessellate and rasterize the strokes into a new texture.
fn refresh_texture(&mut self, style: &HandwritingStyle, mesh_context: MeshContext) {
let Ephemeral {
current_stroke,
tessellator,
mesh,
refresh_texture,
last_mesh_ctx,
..
} = &mut self.e;
// TODO: don't tessellate and rasterize on the GUI thread
*last_mesh_ctx = Some(mesh_context);
*refresh_texture = false;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let start_time = std::time::Instant::now(); let start_time = std::time::Instant::now();
let mut tesselator = Tessellator::new( let mesh = Arc::make_mut(mesh);
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(); mesh.clear();
// TODO: re-use tessellator if pixels_per_point hasn't changed
let tessellator = tessellator.insert(new_tessellator(mesh_context.pixels_per_point));
self.strokes self.strokes
.iter() .iter()
.chain([&self.current_stroke]) .chain([&*current_stroke])
.filter(|stroke| stroke.len() >= 2) .filter(|stroke| stroke.len() >= 2)
.map(|stroke| { .map(|stroke| {
//let points: Vec<Pos2> = stroke.iter().map(|&p| to_screen * p).collect(); //let points: Vec<Pos2> = stroke.iter().map(|&p| to_screen * p).collect();
egui::Shape::line(stroke.clone(), style.stroke) egui::Shape::line(stroke.clone(), style.stroke)
}) })
.for_each(|shape| { .for_each(|shape| {
tesselator.tessellate_shape(shape, mesh); tessellator.tessellate_shape(shape, mesh);
}); });
let texture = texture!(self, ui, &mesh_context); // sanity-check that tessellation did not produce any NaNs.
let triangles = mesh_triangles(&self.mesh); // this can happen if the line contains duplicated consecutive positions
//for vertex in &mesh.vertices {
// debug_assert!(vertex.pos.x.is_finite(), "{} must be finite", vertex.pos.x);
// debug_assert!(vertex.pos.y.is_finite(), "{} must be finite", vertex.pos.y);
//}
let [px_x, px_y] = mesh_context.pixel_size();
let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point); let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point);
self.image = rasterize::<StrokeBlendMode>(px_x, px_y, point_to_pixel, triangles); let triangles = mesh_triangles(&self.e.mesh);
texture.set(self.image.clone(), Default::default());
self.e.canvas_rasterizer.clear();
self.e
.canvas_rasterizer
.rasterize(point_to_pixel, triangles);
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let elapsed = start_time.elapsed(); let elapsed = start_time.elapsed();
println!("refreshed mesh in {:.3}s", elapsed.as_secs_f32()); log::debug!("refreshed mesh in {:.3}s", elapsed.as_secs_f32());
} }
} }
pub fn ui(&mut self, style: &HandwritingStyle, ui: &mut Ui) { pub fn ui(&mut self, style: &HandwritingStyle, ui: &mut Ui) -> HandwritingResponse {
let mut response = HandwritingResponse { changed: false };
ui.vertical_centered_justified(|ui| { ui.vertical_centered_justified(|ui| {
self.ui_control(None, ui); self.ui_control(None, ui, &mut response);
//ui.label("Paint with your mouse/touch!"); //ui.label("Paint with your mouse/touch!");
Frame::canvas(ui.style()) Frame::canvas(ui.style())
@ -485,51 +479,53 @@ impl Handwriting {
.stroke(Stroke::new(5.0, Color32::from_black_alpha(40))) .stroke(Stroke::new(5.0, Color32::from_black_alpha(40)))
.fill(style.bg_color) .fill(style.bg_color)
.show(ui, |ui| { .show(ui, |ui| {
self.ui_content(style, ui); self.ui_content(style, ui, &mut response);
}); });
}); });
response
} }
/// Append a new [Pos2] to [Self::current_stroke].
///
/// Queue a new line to be drawn onto [Self::texture].
fn push_to_stroke(&mut self, new_canvas_pos: Pos2) { fn push_to_stroke(&mut self, new_canvas_pos: Pos2) {
if let Some(&last_canvas_pos) = self.current_stroke.last() { if let Some(&last_canvas_pos) = self.e.current_stroke.last() {
if last_canvas_pos == new_canvas_pos { if last_canvas_pos == new_canvas_pos {
return; return;
} }
self.unblitted_lines.push([last_canvas_pos, new_canvas_pos]); self.e
.unblitted_lines
.push([last_canvas_pos, new_canvas_pos]);
} }
self.current_stroke.push(new_canvas_pos); self.e.current_stroke.push(new_canvas_pos);
} }
/// Draw a single line onto the existing texture. /// Draw a single line onto the existing texture.
fn draw_line_to_texture( fn draw_line_to_texture(&mut self, from: Pos2, to: Pos2, mesh_context: &MeshContext) {
&mut self, // INVARIANT: if this function was called, then pixels_per_point is the same as last frame,
from: Pos2, // so there's no need to create a new tessellator.
to: Pos2, let tessellator = self
mesh_context: &MeshContext, .e
ui: &mut Ui, .tessellator
) { .get_or_insert_with(|| new_tessellator(mesh_context.pixels_per_point));
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 mut mesh = Mesh::default();
let line = egui::Shape::line_segment([from, to], mesh_context.stroke); let line = egui::Shape::line_segment([from, to], mesh_context.stroke);
tesselator.tessellate_shape(line, &mut mesh); tessellator.tessellate_shape(line, &mut mesh);
self.draw_mesh_to_texture(&mesh, mesh_context, ui); self.draw_mesh_to_texture(&mesh, mesh_context);
} }
/// Draw a single mesh onto the existing texture. /// Draw a single mesh onto the existing texture.
fn draw_mesh_to_texture(&mut self, mesh: &Mesh, mesh_context: &MeshContext, ui: &mut Ui) { fn draw_mesh_to_texture(&mut self, mesh: &Mesh, mesh_context: &MeshContext) {
let triangles = mesh_triangles(mesh); let triangles = mesh_triangles(mesh);
let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point); let point_to_pixel = TSTransform::from_scaling(mesh_context.pixels_per_point);
rasterize_onto::<StrokeBlendMode>(&mut self.image, point_to_pixel, triangles); self.e
texture!(self, ui, mesh_context).set(self.image.clone(), Default::default()); .canvas_rasterizer
.rasterize(point_to_pixel, triangles);
} }
pub fn strokes(&self) -> &[Vec<Pos2>] { pub fn strokes(&self) -> &[Vec<Pos2>] {
@ -557,24 +553,49 @@ impl Handwriting {
..Default::default() ..Default::default()
} }
} }
pub fn encode_as_disk_format(&self) -> Box<[u8]> {
let mut bytes = vec![];
let header = disk_format::Header {
version: disk_format::V1,
};
bytes.extend_from_slice(header.as_bytes());
for stroke in &self.strokes {
let Ok(len) = u16::try_from(stroke.len()) else {
log::error!("More than u16::MAX points in a stroke!");
continue;
};
let header = RawStrokeHeader { len: len.into() };
bytes.extend_from_slice(header.as_bytes());
for position in stroke {
for v in [position.x, position.y] {
let v = f16::from_f32(v);
let v = f16_le::from(v);
bytes.extend_from_slice(v.as_bytes());
}
}
}
bytes.into_boxed_slice()
}
}
fn new_tessellator(pixels_per_point: f32) -> Tessellator {
Tessellator::new(
pixels_per_point,
TESSELLATION_OPTIONS,
Default::default(), // we don't tessellate fonts
vec![],
)
} }
impl Display for Handwriting { impl Display for Handwriting {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut raw = vec![]; let raw = self.encode_as_disk_format();
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)) write_custom_code_block(f, CODE_BLOCK_KEY, BASE64_STANDARD.encode(raw))
} }
} }
@ -590,53 +611,72 @@ impl FromStr for Handwriting {
.decode(s) .decode(s)
.wrap_err("Failed to decode painting data from base64")?; .wrap_err("Failed to decode painting data from base64")?;
#[allow(non_camel_case_types)] // HACK: first iteration of disk format did not have version header
type u16_le = [u8; 2]; //let mut bytes = bytes;
//bytes.insert(0, 0);
//bytes.insert(0, 1);
#[allow(non_camel_case_types)] let disk_format = DiskFormat::ref_from_bytes(&bytes[..]).map_err(|_| eyre!("Too short"))?;
type f16_le = [u8; 2];
#[derive(FromBytes, KnownLayout, Immutable)] if disk_format.header.version != disk_format::V1 {
#[repr(C, packed)] bail!(
struct Stroke { "Unknown disk_format version: {}",
pub len: u16_le, disk_format.header.version
pub positions: [f16_le], );
} }
let mut bytes = &bytes[..]; let mut raw_strokes = &disk_format.strokes[..];
let mut strokes = vec![]; let mut strokes = vec![];
while !bytes.is_empty() { while !raw_strokes.is_empty() {
let header_len = size_of::<u16_le>(); if raw_strokes.len() < RawStroke::MIN_LEN {
if bytes.len() < header_len { bail!("Invalid remaining length: {}", raw_strokes.len());
bail!("Invalid remaining length: {}", bytes.len());
} }
let stroke = Stroke::ref_from_bytes(&bytes[..header_len]).expect("length is correct"); let stroke = RawStroke::ref_from_bytes(&raw_strokes[..RawStroke::MIN_LEN])
let len = usize::from(u16::from_le_bytes(stroke.len)); .expect("length is correct");
let len = len * size_of::<f16_le>() * 2;
if bytes.len() < len { // get length as number of points
bail!("Invalid remaining length: {}", bytes.len()); let len = usize::from(u16::from(stroke.header.len));
// convert to length in bytes
let byte_len = 2 * size_of::<f16_le>() * len;
if raw_strokes.len() < byte_len {
bail!("Invalid remaining length: {}", raw_strokes.len());
} }
let (stroke, rest) = bytes.split_at(header_len + len); let (stroke, rest) = raw_strokes.split_at(RawStroke::MIN_LEN + byte_len);
bytes = rest; raw_strokes = rest;
let stroke = Stroke::ref_from_bytes(stroke)
.map_err(|e| eyre!("Failed to decode stroke bytes: {e}"))?;
let mut positions = stroke let stroke = RawStroke::ref_from_bytes(stroke).expect("length is correct");
debug_assert_eq!(
stroke.positions.len().rem_euclid(2),
0,
"{} must be divisible by 2",
stroke.positions.len()
);
debug_assert_eq!(stroke.positions.len(), len * 2);
let mut last_pos = Pos2::new(f32::NEG_INFINITY, f32::INFINITY);
// positions are encoded as an array of f16s [x, y, x, y, x, y, ..]
let stroke: Vec<Pos2> = stroke
.positions .positions
.iter() .chunks_exact(2)
.map(|&position| f16::from_bits(u16::from_le_bytes(position))); .map(|chunk| [chunk[0], chunk[1]])
.map(|pos| pos.map(f16::from)) // interpret bytes as f16
.map(|pos| pos.map(f32::from)) // widen to f32
.filter(|pos| pos.iter().all(|f| f.is_finite())) // filter out NaNs and Infs
.map(|[x, y]| Pos2::new(x, y))
.filter(|pos| {
let is_duplicate = pos == &last_pos;
last_pos = *pos;
!is_duplicate // skip duplicates
})
.collect();
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); strokes.push(stroke);
} }
@ -667,16 +707,19 @@ impl HandwritingStyle {
} }
HandwritingStyle { HandwritingStyle {
stroke: Stroke::new(1.6, stroke_color), stroke: Stroke::new(1.0, stroke_color),
bg_color, bg_color,
bg_line_stroke: Stroke::new(0.5, line_color), bg_line_stroke: Stroke::new(0.5, line_color),
animate: true, animate: true,
hide_cursor: false,
} }
} }
} }
fn mesh_triangles(mesh: &Mesh) -> impl Iterator<Item = [&Vertex; 3]> { fn mesh_triangles(mesh: &Mesh) -> impl Iterator<Item = [&Vertex; 3]> + Clone {
mesh.triangles() mesh.indices
.chunks_exact(3)
.map(|chunk| [chunk[0], chunk[1], chunk[2]])
.map(|indices| indices.map(|i| &mesh.vertices[i as usize])) .map(|indices| indices.map(|i| &mesh.vertices[i as usize]))
} }

View File

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

View File

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

View File

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

View File

@ -5,7 +5,8 @@ pub mod constants;
pub mod custom_code_block; pub mod custom_code_block;
pub mod easy_mark; pub mod easy_mark;
pub mod file_editor; pub mod file_editor;
pub mod painting; pub mod folder;
pub mod handwriting;
pub mod preferences; pub mod preferences;
pub mod rasterizer; pub mod rasterizer;
pub mod text_editor; pub mod text_editor;

View File

@ -10,6 +10,9 @@ pub struct Preferences {
/// Enable high-contrast theme /// Enable high-contrast theme
pub high_contrast: bool, pub high_contrast: bool,
/// Hide the cursor when handwriting
pub hide_handwriting_cursor: bool,
#[serde(skip)] #[serde(skip)]
has_applied_theme: bool, has_applied_theme: bool,
} }
@ -20,6 +23,7 @@ impl Default for Preferences {
animations: true, animations: true,
high_contrast: false, high_contrast: false,
has_applied_theme: false, has_applied_theme: false,
hide_handwriting_cursor: false,
} }
} }
} }
@ -30,13 +34,18 @@ impl Preferences {
if !self.has_applied_theme { if !self.has_applied_theme {
self.has_applied_theme = true; self.has_applied_theme = true;
let mut dark_visuals = Visuals::dark();
let mut light_visuals = Visuals::light();
dark_visuals.code_bg_color = Color32::BLACK;
dark_visuals.code_bg_color = Color32::BLACK;
light_visuals.code_bg_color = Color32::WHITE;
if self.high_contrast { if self.high_contrast {
// widgets.active: color of headers in textedit // widgets.active: color of headers in textedit
// widgets.inactive: color of button labels // widgets.inactive: color of button labels
// widgets.hovered: color of hovered button labels // widgets.hovered: color of hovered button labels
// widgets.noninteractive: color of labels and normal textedit text // widgets.noninteractive: color of labels and normal textedit text
let mut dark_visuals = Visuals::dark();
let mut light_visuals = Visuals::light();
dark_visuals.widgets.noninteractive.fg_stroke.color = Color32::WHITE; dark_visuals.widgets.noninteractive.fg_stroke.color = Color32::WHITE;
dark_visuals.widgets.inactive.fg_stroke.color = Color32::WHITE; dark_visuals.widgets.inactive.fg_stroke.color = Color32::WHITE;
@ -45,13 +54,16 @@ impl Preferences {
light_visuals.widgets.noninteractive.fg_stroke.color = Color32::BLACK; light_visuals.widgets.noninteractive.fg_stroke.color = Color32::BLACK;
light_visuals.widgets.inactive.fg_stroke.color = Color32::BLACK; light_visuals.widgets.inactive.fg_stroke.color = Color32::BLACK;
light_visuals.widgets.hovered.fg_stroke.color = Color32::BLACK; light_visuals.widgets.hovered.fg_stroke.color = Color32::BLACK;
} else {
dark_visuals.widgets.noninteractive.fg_stroke.color =
Color32::from_rgb(0xaa, 0xaa, 0xaa);
light_visuals.widgets.noninteractive.fg_stroke.color =
Color32::from_rgb(0x11, 0x11, 0x11);
}
ctx.set_visuals_of(Theme::Dark, dark_visuals); ctx.set_visuals_of(Theme::Dark, dark_visuals);
ctx.set_visuals_of(Theme::Light, light_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());
}
} }
} }
@ -67,6 +79,8 @@ impl Preferences {
self.apply(ui.ctx()); self.apply(ui.ctx());
} }
ui.toggle_value(&mut self.hide_handwriting_cursor, "Hide Handwriting Cursor");
egui::widgets::global_theme_preference_buttons(ui); egui::widgets::global_theme_preference_buttons(ui);
} }
} }

View File

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

View File

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

View File

@ -24,6 +24,10 @@ pub struct MdTextEdit {
cursor: Option<CCursorRange>, cursor: Option<CCursorRange>,
} }
pub struct MdTextEditOutput {
pub changed: bool,
}
impl MdTextEdit { impl MdTextEdit {
pub fn new() -> Self { pub fn new() -> Self {
MdTextEdit::default() MdTextEdit::default()
@ -36,7 +40,7 @@ impl MdTextEdit {
} }
} }
pub fn ui(&mut self, ui: &mut Ui) { pub fn ui(&mut self, ui: &mut Ui) -> MdTextEditOutput {
let Self { let Self {
text, text,
highlighter, highlighter,
@ -72,6 +76,10 @@ impl MdTextEdit {
*cursor = text_edit.cursor_range; *cursor = text_edit.cursor_range;
//ui.ctx().request_repaint(); //ui.ctx().request_repaint();
} }
MdTextEditOutput {
changed: text_edit.response.changed(),
}
} }
} }