diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 799674f3bd..964132fc5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Install core dependencies run: | sudo dnf upgrade --refresh -y - sudo dnf install -y gcc gcc-c++ clang python3 make cmake meson git kernel-devel gtk4-devel libadwaita-devel poppler-glib-devel poppler-data alsa-lib-devel appstream-devel desktop-file-utils + sudo dnf install -y gcc gcc-c++ clang python3 make cmake meson git kernel-devel gtk4-devel libadwaita-devel poppler-glib-devel poppler-data alsa-lib-devel appstream-devel enchant2-devel desktop-file-utils - name: Install additional tools uses: taiki-e/install-action@v2 with: diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index b2858db2cf..95ae1e992c 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -15,7 +15,7 @@ jobs: - name: Install dependencies run: | sudo dnf upgrade --refresh -y - sudo dnf install -y gcc gcc-c++ clang python3 make cmake meson git gh kernel-devel gtk4-devel libadwaita-devel poppler-glib-devel poppler-data alsa-lib-devel appstream-devel desktop-file-utils + sudo dnf install -y gcc gcc-c++ clang python3 make cmake meson git gh kernel-devel gtk4-devel libadwaita-devel poppler-glib-devel poppler-data alsa-lib-devel appstream-devel enchant2-devel desktop-file-utils - name: Install toolchain id: toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index e7522a5c6e..b2867a0275 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -27,7 +27,7 @@ jobs: git mingw-w64-x86_64-xz mingw-w64-x86_64-pkgconf mingw-w64-x86_64-gcc mingw-w64-x86_64-clang mingw-w64-x86_64-toolchain mingw-w64-x86_64-autotools mingw-w64-x86_64-make mingw-w64-x86_64-cmake mingw-w64-x86_64-meson mingw-w64-x86_64-diffutils mingw-w64-x86_64-desktop-file-utils mingw-w64-x86_64-appstream mingw-w64-x86_64-gtk4 mingw-w64-x86_64-libadwaita - mingw-w64-x86_64-poppler mingw-w64-x86_64-poppler-data mingw-w64-x86_64-angleproject + mingw-w64-x86_64-poppler mingw-w64-x86_64-poppler-data mingw-w64-x86_64-angleproject mingw-w64-x86_64-enchant - name: Remove libpthread.dll.a run: rm /mingw64/lib/libpthread.dll.a continue-on-error: true diff --git a/Cargo.lock b/Cargo.lock index 3121e0ddc1..611b8d018e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,9 +332,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62" +checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" dependencies = [ "arrayvec", ] @@ -482,9 +482,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.14" +version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" dependencies = [ "jobserver", "libc", @@ -544,16 +544,16 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -569,9 +569,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.30" +version = "4.5.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" +checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" dependencies = [ "clap_builder", "clap_derive", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.30" +version = "4.5.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" +checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" dependencies = [ "anstream", "anstyle", @@ -931,9 +931,9 @@ dependencies = [ [[package]] name = "either" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d" [[package]] name = "ena" @@ -944,6 +944,24 @@ dependencies = [ "log", ] +[[package]] +name = "enchant" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafd018f9ff5933b0cf9d89a095c31125890f2246952749cbaad230b537644db" +dependencies = [ + "enchant-sys", +] + +[[package]] +name = "enchant-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97af59f7a6d8a217695598f23dc12051734322e60b58e5ca2f3a2fffa59ba34f" +dependencies = [ + "pkg-config", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1080,9 +1098,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.35" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" dependencies = [ "crc32fast", "miniz_oxide", @@ -1759,9 +1777,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "137d96353afc8544d437e8a99eceb10ab291352699573b0de5b08bda38c78c60" dependencies = [ "displaydoc", "yoke", @@ -1769,11 +1787,31 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_displaynames" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726c0d83ff52f05907275f39e5bb7949a92fa3d09538de60cf73ccf8ee89a613" +dependencies = [ + "icu_displaynames_data", + "icu_locid", + "icu_locid_transform", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_displaynames_data" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af40e6b723e5e6d9359cf0bb4e4ed6dfb9d6ab16b73b5c82b61f947e88bb30f6" + [[package]] name = "icu_locid" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "5c0aa2536adc14c07e2a521e95512b75ed8ef832f0fdf9299d4a0a45d2be2a9d" dependencies = [ "displaydoc", "litemap", @@ -1784,9 +1822,9 @@ dependencies = [ [[package]] name = "icu_locid_transform" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +checksum = "57c17d8f6524fdca4471101dd71f0a132eb6382b5d6d7f2970441cb25f6f435a" dependencies = [ "displaydoc", "icu_locid", @@ -1798,15 +1836,15 @@ dependencies = [ [[package]] name = "icu_locid_transform_data" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" +checksum = "545c6c3e8bf9580e2dafee8de6f9ec14826aaf359787789c7724f1f85f47d3dc" [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "accb85c5b2e76f8dade22978b3795ae1e550198c6cfc7e915144e17cd6e2ab56" dependencies = [ "displaydoc", "icu_collections", @@ -1822,15 +1860,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "e3744fecc0df9ce19999cdaf1f9f3a48c253431ce1d67ef499128fe9d0b607ab" [[package]] name = "icu_properties" -version = "1.5.1" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "db9e559598096627aeca8cdfb98138a70eb4078025f8d1d5f2416a361241f756" dependencies = [ "displaydoc", "icu_collections", @@ -1843,15 +1881,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "e70a8b51ee5dd4ff8f20ee9b1dd1bc07afc110886a3747b1fec04cc6e5a15815" [[package]] name = "icu_provider" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "ba58e782287eb6950247abbf11719f83f5d4e4a5c1f2cd490d30a334bc47c2f4" dependencies = [ "displaydoc", "icu_locid", @@ -1866,9 +1904,9 @@ dependencies = [ [[package]] name = "icu_provider_macros" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +checksum = "d2abdd3a62551e8337af119c5899e600ca0c88ec8f23a46c60ba216c803dcf1a" dependencies = [ "proc-macro2", "quote", @@ -2225,9 +2263,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" [[package]] name = "libfuzzer-sys" @@ -2310,9 +2348,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "litemap" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "locale_config" @@ -2339,9 +2377,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "loop9" @@ -2457,9 +2495,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", "simd-adler32", @@ -3163,9 +3201,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] name = "ppv-lite86" @@ -3261,8 +3299,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.1", - "zerocopy 0.8.20", + "rand_core 0.9.2", + "zerocopy 0.8.21", ] [[package]] @@ -3282,7 +3320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.1", + "rand_core 0.9.2", ] [[package]] @@ -3296,12 +3334,12 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88e0da7a2c97baa202165137c158d0a2e824ac465d13d81046727b34cb247d3" +checksum = "7a509b1a2ffbe92afab0e55c8fd99dea1c280e8171bd2d88682bb20bc41cbc2c" dependencies = [ "getrandom 0.3.1", - "zerocopy 0.8.20", + "zerocopy 0.8.21", ] [[package]] @@ -3320,7 +3358,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b48ac3f7ffaab7fac4d2376632268aa5f89abdb55f7ebf8f4d11fffccb2320f7" dependencies = [ - "rand_core 0.9.1", + "rand_core 0.9.2", ] [[package]] @@ -3407,9 +3445,9 @@ checksum = "e03e7866abec1101869ffa8e2c8355c4c2419d0214ece0cc3e428e5b94dea6e9" [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ "bitflags 2.8.0", ] @@ -3481,6 +3519,8 @@ dependencies = [ "gettext-rs", "glib-build-tools", "gtk4", + "icu_displaynames", + "icu_locid", "ijson", "image", "itertools 0.14.0", @@ -3576,6 +3616,7 @@ dependencies = [ "cairo-rs", "chrono", "clap", + "enchant", "flate2", "futures", "geo", @@ -3969,9 +4010,9 @@ dependencies = [ [[package]] name = "spade" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f5ef1f863aca7d1d7dda7ccfc36a0a4279bd6d3c375176e5e0712e25cb4889" +checksum = "8a7f89cb9a80ac939dedb9ad42720cbe112424b6f6597a2a2e9c5e5b684cd4f7" dependencies = [ "hashbrown 0.14.5", "num-traits", @@ -4829,6 +4870,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-result" version = "0.1.2" @@ -5085,11 +5132,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde3bb8c68a8f3f1ed4ac9221aad6b10cece3e60a8e2ea54a6a2dec806d0084c" +checksum = "dcf01143b2dd5d134f11f545cf9f1431b13b749695cb33bcce051e7568f99478" dependencies = [ - "zerocopy-derive 0.8.20", + "zerocopy-derive 0.8.21", ] [[package]] @@ -5105,9 +5152,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eea57037071898bf96a6da35fd626f4f27e9cee3ead2a6c703cf09d472b2e700" +checksum = "712c8386f4f4299382c9abee219bee7084f78fb939d88b6840fcc1320d5f6da2" dependencies = [ "proc-macro2", "quote", @@ -5116,18 +5163,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index e1ef511047..61e206d435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,9 @@ gio = "0.20.1" glib = "0.20.3" glib-build-tools = "0.20.0" gtk4 = { version = "0.9.1", features = ["v4_16"] } +enchant = "0.3.0" +icu_displaynames = "0.11.2" +icu_locid = "1.4.0" ijson = "0.1.3" image = "0.25.2" indicatif = "0.17.8" diff --git a/build-aux/inno_build.py b/build-aux/inno_build.py index 50356ea452..58bcdd07c8 100644 --- a/build-aux/inno_build.py +++ b/build-aux/inno_build.py @@ -57,6 +57,12 @@ def run_command(command, error_message): f"Collecting pixbuf-loader ({loader}) DLLs failed" ) +for enchant_provider in glob.glob(f"{build_environment_path}/lib/enchant-2/*.dll"): + run_command( + f"ldd {enchant_provider} | grep '\\/mingw.*\.dll' -o | xargs -i cp {{}} {dlls_dir}", + f"Collecting enchant provider ({enchant_provider}) DLLs failed", + ) + for angle_dll in itertools.chain( glob.glob(f"{build_environment_path}/bin/libEGL*.dll"), glob.glob(f"{build_environment_path}/bin/libGLES*.dll"), diff --git a/build-aux/rnote_inno.iss.in b/build-aux/rnote_inno.iss.in index f719e4db0a..d49efd071a 100644 --- a/build-aux/rnote_inno.iss.in +++ b/build-aux/rnote_inno.iss.in @@ -57,6 +57,8 @@ Source: "{#meson_build_root}\dlls\*.dll"; DestDir: "{app}\bin"; Flags: ignorever ; gdk-pixbuf loaders Source: "{#build_environment_path}\lib\gdk-pixbuf-2.0\*"; DestDir: "{app}\lib\gdk-pixbuf-2.0"; Flags: ignoreversion recursesubdirs createallsubdirs +; enchant providers +Source: "{#build_environment_path}\lib\enchant-2\*"; DestDir: "{app}\lib\enchant-2"; Flags: ignoreversion recursesubdirs createallsubdirs ; poppler-data Source: "{#build_environment_path}\share\poppler\*"; DestDir: "{app}\share\poppler"; Flags: ignoreversion recursesubdirs createallsubdirs ; Settings GSchema diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml index c8add5331c..3d7c3bbe69 100644 --- a/crates/rnote-engine/Cargo.toml +++ b/crates/rnote-engine/Cargo.toml @@ -22,6 +22,7 @@ futures = { workspace = true } geo = { workspace = true } gio = { workspace = true } glib = { workspace = true } +enchant = { workspace = true } ijson = { workspace = true } image = { workspace = true } itertools = { workspace = true } diff --git a/crates/rnote-engine/src/document/mod.rs b/crates/rnote-engine/src/document/mod.rs index 53ccee1a58..6234c7c932 100644 --- a/crates/rnote-engine/src/document/mod.rs +++ b/crates/rnote-engine/src/document/mod.rs @@ -7,6 +7,7 @@ pub use background::Background; pub use format::Format; // Imports +use crate::engine::SPELLCHECK_DEFAULT_LANGUAGE; use crate::{Camera, CloneConfig, StrokeStore, WidgetFlags}; use core::fmt::Display; use p2d::bounding_volume::{Aabb, BoundingVolume}; @@ -88,6 +89,35 @@ impl Layout { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpellcheckOptions { + pub enabled: bool, + pub language: Option, +} + +impl Default for SpellcheckOptions { + fn default() -> Self { + Self { + enabled: true, + language: SPELLCHECK_DEFAULT_LANGUAGE.clone(), + } + } +} + +impl SpellcheckOptions { + pub fn dictionary(&self, broker: &mut enchant::Broker) -> Option { + if self.enabled { + if let Some(language) = &self.language { + broker.request_dict(language).ok() + } else { + None + } + } else { + None + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename = "document")] pub struct Document { @@ -107,6 +137,8 @@ pub struct Document { pub layout: Layout, #[serde(rename = "snap_positions")] pub snap_positions: bool, + #[serde(rename = "spellcheck_options")] + pub spellcheck_options: SpellcheckOptions, } impl Default for Document { @@ -120,6 +152,7 @@ impl Default for Document { background: Background::default(), layout: Layout::default(), snap_positions: false, + spellcheck_options: SpellcheckOptions::default(), } } } diff --git a/crates/rnote-engine/src/engine/import.rs b/crates/rnote-engine/src/engine/import.rs index e208153452..8ef4e7e10b 100644 --- a/crates/rnote-engine/src/engine/import.rs +++ b/crates/rnote-engine/src/engine/import.rs @@ -172,6 +172,7 @@ impl Engine { .penholder .reinstall_pen_current_style(&mut engine_view_mut!(self)); widget_flags |= self.doc_resize_to_fit_content(); + widget_flags |= self.refresh_spellcheck_language(); widget_flags.redraw = true; widget_flags.refresh_ui = true; widget_flags diff --git a/crates/rnote-engine/src/engine/mod.rs b/crates/rnote-engine/src/engine/mod.rs index bb7a92bdfc..07ffcf64a0 100644 --- a/crates/rnote-engine/src/engine/mod.rs +++ b/crates/rnote-engine/src/engine/mod.rs @@ -25,17 +25,71 @@ use crate::strokes::textstroke::{TextAttribute, TextStyle}; use crate::{AudioPlayer, CloneConfig, SelectionCollision, WidgetFlags, render}; use crate::{Camera, Document, PenHolder, StrokeStore}; use futures::channel::{mpsc, oneshot}; +use once_cell::sync::Lazy; use p2d::bounding_volume::{Aabb, BoundingVolume}; use rnote_compose::eventresult::EventPropagation; use rnote_compose::ext::AabbExt; use rnote_compose::penevent::{PenEvent, ShortcutKey}; use rnote_compose::{Color, SplitOrder}; use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tracing::{debug, error}; +thread_local! { + static SPELLCHECK_BROKER: RefCell = RefCell::new(enchant::Broker::new()); +} + +pub static SPELLCHECK_AVAILABLE_LANGUAGES: Lazy> = Lazy::new(|| { + SPELLCHECK_BROKER.with_borrow_mut(|broker| { + broker + .list_dicts() + .iter() + .map(|dict| dict.lang.to_owned()) + .collect() + }) +}); + +pub static SPELLCHECK_DEFAULT_LANGUAGE: Lazy> = Lazy::new(|| { + for system_language in glib::language_names() { + for available_language in SPELLCHECK_AVAILABLE_LANGUAGES.iter() { + if system_language.contains(available_language) { + debug!( + "found default spellcheck language: {:?}", + available_language + ); + + return Some(available_language.to_string()); + } + } + } + + None +}); + +#[derive(Default)] +pub struct Spellcheck { + pub dict: Option, +} + +impl Debug for Spellcheck { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Spellcheck") + .field( + "dict", + &self + .dict + .as_ref() + .map(|dict| format!("Some({})", dict.get_lang())) + .unwrap_or(String::from("None")), + ) + .finish() + } +} + /// An immutable view into the engine, excluding the penholder. #[derive(Debug)] pub struct EngineView<'a> { @@ -46,6 +100,7 @@ pub struct EngineView<'a> { pub camera: &'a Camera, pub audioplayer: &'a Option, pub animation: &'a Animation, + pub spellcheck: &'a Spellcheck, } /// Constructs an `EngineView` from an identifier containing an `Engine` instance. @@ -60,6 +115,7 @@ macro_rules! engine_view { camera: &$engine.camera, audioplayer: &$engine.audioplayer, animation: &$engine.animation, + spellcheck: &$engine.spellcheck, } }; } @@ -74,6 +130,7 @@ pub struct EngineViewMut<'a> { pub camera: &'a mut Camera, pub audioplayer: &'a mut Option, pub animation: &'a mut Animation, + pub spellcheck: &'a mut Spellcheck, } /// Constructs an `EngineViewMut` from an identifier containing an `Engine` instance. @@ -88,6 +145,7 @@ macro_rules! engine_view_mut { camera: &mut $engine.camera, audioplayer: &mut $engine.audioplayer, animation: &mut $engine.animation, + spellcheck: &mut $engine.spellcheck, } }; } @@ -103,6 +161,7 @@ impl EngineViewMut<'_> { camera: self.camera, audioplayer: self.audioplayer, animation: self.animation, + spellcheck: self.spellcheck, } } } @@ -245,6 +304,8 @@ pub struct Engine { #[serde(skip)] pub animation: Animation, #[serde(skip)] + spellcheck: Spellcheck, + #[serde(skip)] visual_debug: bool, // the task sender. Must not be modified, only cloned. #[serde(skip)] @@ -282,6 +343,7 @@ impl Default for Engine { audioplayer: None, animation: Animation::default(), + spellcheck: Spellcheck::default(), visual_debug: false, tasks_tx: EngineTaskSender(tasks_tx), tasks_rx: Some(EngineTaskReceiver(tasks_rx)), @@ -337,6 +399,41 @@ impl Engine { } } + pub fn refresh_spellcheck_language(&mut self) -> WidgetFlags { + let mut widget_flags = WidgetFlags::default(); + + self.spellcheck.dict = SPELLCHECK_BROKER + .with_borrow_mut(|broker| self.document.spellcheck_options.dictionary(broker)); + + if let Pen::Typewriter(typewriter) = self.penholder.current_pen_ref() { + typewriter.refresh_spellcheck_cache_in_modifying_stroke(&mut engine_view_mut!(self)); + + widget_flags.redraw = true; + } + + widget_flags + } + + pub fn get_spellcheck_corrections(&self) -> Option> { + if let Pen::Typewriter(typewriter) = self.penholder.current_pen_ref() { + return typewriter + .get_spellcheck_correction_in_modifying_stroke(&mut engine_view!(self)); + } + + None + } + + pub fn apply_spellcheck_correction(&mut self, correction: &str) -> WidgetFlags { + if let Pen::Typewriter(typewriter) = self.penholder.current_pen_mut() { + return typewriter.apply_spellcheck_correction_in_modifying_stroke( + correction, + &mut engine_view_mut!(self), + ); + } + + WidgetFlags::default() + } + pub fn optimize_epd(&self) -> bool { self.optimize_epd } @@ -410,6 +507,7 @@ impl Engine { | self.doc_resize_autoexpand() | self.current_pen_update_state() | self.update_rendering_current_viewport() + | self.refresh_spellcheck_language() } /// Redo the latest changes. @@ -418,6 +516,7 @@ impl Engine { | self.doc_resize_autoexpand() | self.current_pen_update_state() | self.update_rendering_current_viewport() + | self.refresh_spellcheck_language() } pub fn can_undo(&self) -> bool { diff --git a/crates/rnote-engine/src/pens/penholder.rs b/crates/rnote-engine/src/pens/penholder.rs index 1dd04caa61..60c419a522 100644 --- a/crates/rnote-engine/src/pens/penholder.rs +++ b/crates/rnote-engine/src/pens/penholder.rs @@ -140,7 +140,7 @@ impl PenHolder { self.progress } - pub fn current_pen_ref(&mut self) -> &Pen { + pub fn current_pen_ref(&self) -> &Pen { &self.current_pen } diff --git a/crates/rnote-engine/src/pens/typewriter/mod.rs b/crates/rnote-engine/src/pens/typewriter/mod.rs index 24960843e8..db25ae79c9 100644 --- a/crates/rnote-engine/src/pens/typewriter/mod.rs +++ b/crates/rnote-engine/src/pens/typewriter/mod.rs @@ -203,6 +203,18 @@ impl DrawableOnDoc for Typewriter { ); } + // Draw error ranges + for (start_index, length) in &textstroke.spellcheck_result.errors { + textstroke.text_style.draw_text_error( + cx, + textstroke.text.clone(), + *start_index, + *start_index + *length, + &textstroke.transform, + engine_view.camera, + ); + } + // Draw the cursor if self.cursor_visible { textstroke.text_style.draw_cursor( @@ -495,6 +507,7 @@ impl PenBehaviour for Typewriter { cursor, selection_cursor, String::from("").as_str(), + engine_view.spellcheck, ); // Update stroke @@ -667,7 +680,10 @@ impl Typewriter { let text_len = text.len(); text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new(text, pos, text_style); + + let mut textstroke = TextStroke::new(text, pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellcheck); + let cursor = GraphemeCursor::new(text_len, textstroke.text.len(), true); let stroke_key = engine_view @@ -694,7 +710,10 @@ impl Typewriter { let text_len = text.len(); text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new(text, *pos, text_style); + + let mut textstroke = TextStroke::new(text, *pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellcheck); + let cursor = GraphemeCursor::new(text_len, textstroke.text.len(), true); let stroke_key = engine_view @@ -733,6 +752,7 @@ impl Typewriter { cursor, selection_cursor, text.as_str(), + engine_view.spellcheck, ); engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( @@ -759,7 +779,12 @@ impl Typewriter { if let Some(Stroke::TextStroke(textstroke)) = engine_view.store.get_stroke_mut(*stroke_key) { - textstroke.insert_text_after_cursor(text.as_str(), cursor); + textstroke.insert_text_after_cursor( + text.as_str(), + cursor, + engine_view.spellcheck, + ); + engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( *stroke_key, @@ -815,6 +840,72 @@ impl Typewriter { widget_flags } + pub(crate) fn refresh_spellcheck_cache_in_modifying_stroke( + &self, + engine_view: &mut EngineViewMut, + ) { + if let TypewriterState::Modifying { stroke_key, .. } = self.state { + if let Some(Stroke::TextStroke(textstroke)) = + engine_view.store.get_stroke_mut(stroke_key) + { + textstroke.check_spelling_refresh_cache(engine_view.spellcheck); + } + } + } + + pub(crate) fn get_spellcheck_correction_in_modifying_stroke( + &self, + engine_view: &EngineView, + ) -> Option> { + if let TypewriterState::Modifying { + stroke_key, cursor, .. + } = &self.state + { + if let Some(Stroke::TextStroke(textstroke)) = + engine_view.store.get_stroke_ref(*stroke_key) + { + return textstroke.get_spellcheck_corrections_at_index( + engine_view.spellcheck, + cursor.cur_cursor(), + ); + } + } + + None + } + + pub(crate) fn apply_spellcheck_correction_in_modifying_stroke( + &mut self, + correction: &str, + engine_view: &mut EngineViewMut, + ) -> WidgetFlags { + let mut widget_flags = WidgetFlags::default(); + + if let TypewriterState::Modifying { + stroke_key, cursor, .. + } = &mut self.state + { + if let Some(Stroke::TextStroke(textstroke)) = + engine_view.store.get_stroke_mut(*stroke_key) + { + textstroke.apply_spellcheck_correction_at_cursor(cursor, correction); + + engine_view.store.update_geometry_for_stroke(*stroke_key); + engine_view.store.regenerate_rendering_for_stroke( + *stroke_key, + engine_view.camera.viewport(), + engine_view.camera.image_scale(), + ); + + widget_flags |= engine_view.store.record(Instant::now()); + widget_flags.redraw = true; + widget_flags.store_modified = true; + } + } + + widget_flags + } + pub(crate) fn toggle_text_attribute_current_selection( &mut self, text_attribute: TextAttribute, diff --git a/crates/rnote-engine/src/pens/typewriter/penevents.rs b/crates/rnote-engine/src/pens/typewriter/penevents.rs index 2d85e508ec..5301a6c415 100644 --- a/crates/rnote-engine/src/pens/typewriter/penevents.rs +++ b/crates/rnote-engine/src/pens/typewriter/penevents.rs @@ -38,7 +38,7 @@ impl Typewriter { { // When clicked on a textstroke, we start modifying it if let Some(Stroke::TextStroke(textstroke)) = - engine_view.store.get_stroke_ref(stroke_key) + engine_view.store.get_stroke_mut(stroke_key) { let cursor = if let Ok(new_cursor) = // get the cursor for the current position @@ -49,6 +49,7 @@ impl Typewriter { GraphemeCursor::new(0, textstroke.text.len(), true) }; + textstroke.check_spelling_refresh_cache(engine_view.spellcheck); engine_view.store.update_chrono_to_last(stroke_key); new_state = TypewriterState::Modifying { @@ -535,7 +536,11 @@ impl Typewriter { KeyboardKey::Unicode(keychar) => { text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new(String::from(keychar), *pos, text_style); + + let mut textstroke = + TextStroke::new(String::from(keychar), *pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellcheck); + let mut cursor = GraphemeCursor::new(0, textstroke.text.len(), true); textstroke.move_cursor_forward(&mut cursor); @@ -631,6 +636,7 @@ impl Typewriter { textstroke.insert_text_after_cursor( keychar.to_string().as_str(), cursor, + engine_view.spellcheck, ); update_stroke(engine_view.store, keychar.is_whitespace()); } @@ -643,9 +649,15 @@ impl Typewriter { } KeyboardKey::BackSpace => { if modifier_keys.contains(&ModifierKey::KeyboardCtrl) { - textstroke.remove_word_before_cursor(cursor); + textstroke.remove_word_before_cursor( + cursor, + engine_view.spellcheck, + ); } else { - textstroke.remove_grapheme_before_cursor(cursor); + textstroke.remove_grapheme_before_cursor( + cursor, + engine_view.spellcheck, + ); } update_stroke(engine_view.store, false); @@ -656,7 +668,11 @@ impl Typewriter { } } KeyboardKey::HorizontalTab => { - textstroke.insert_text_after_cursor("\t", cursor); + textstroke.insert_text_after_cursor( + "\t", + cursor, + engine_view.spellcheck, + ); update_stroke(engine_view.store, false); EventResult { @@ -666,7 +682,11 @@ impl Typewriter { } } KeyboardKey::CarriageReturn | KeyboardKey::Linefeed => { - textstroke.insert_text_after_cursor("\n", cursor); + textstroke.insert_text_after_cursor( + "\n", + cursor, + engine_view.spellcheck, + ); update_stroke(engine_view.store, true); EventResult { @@ -677,9 +697,15 @@ impl Typewriter { } KeyboardKey::Delete => { if modifier_keys.contains(&ModifierKey::KeyboardCtrl) { - textstroke.remove_word_after_cursor(cursor); + textstroke.remove_word_after_cursor( + cursor, + engine_view.spellcheck, + ); } else { - textstroke.remove_grapheme_after_cursor(cursor); + textstroke.remove_grapheme_after_cursor( + cursor, + engine_view.spellcheck, + ); } update_stroke(engine_view.store, false); @@ -898,6 +924,7 @@ impl Typewriter { cursor, selection_cursor, String::from(keychar).as_str(), + engine_view.spellcheck, ); update_stroke(engine_view.store); quit_selecting = true; @@ -1003,6 +1030,7 @@ impl Typewriter { cursor, selection_cursor, "\n", + engine_view.spellcheck, ); update_stroke(engine_view.store); quit_selecting = true; @@ -1017,6 +1045,7 @@ impl Typewriter { cursor, selection_cursor, "", + engine_view.spellcheck, ); update_stroke(engine_view.store); quit_selecting = true; @@ -1031,6 +1060,7 @@ impl Typewriter { cursor, selection_cursor, "\t", + engine_view.spellcheck, ); update_stroke(engine_view.store); quit_selecting = true; @@ -1115,7 +1145,10 @@ impl Typewriter { text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); let text_len = text.len(); - let textstroke = TextStroke::new(text, *pos, text_style); + + let mut textstroke = TextStroke::new(text, *pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellcheck); + let cursor = GraphemeCursor::new(text_len, text_len, true); let stroke_key = engine_view @@ -1157,7 +1190,12 @@ impl Typewriter { if let Some(Stroke::TextStroke(textstroke)) = engine_view.store.get_stroke_mut(*stroke_key) { - textstroke.insert_text_after_cursor(&text, cursor); + textstroke.insert_text_after_cursor( + &text, + cursor, + engine_view.spellcheck, + ); + engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( *stroke_key, @@ -1203,6 +1241,7 @@ impl Typewriter { cursor, selection_cursor, text.as_str(), + engine_view.spellcheck, ); engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( diff --git a/crates/rnote-engine/src/strokes/textstroke.rs b/crates/rnote-engine/src/strokes/textstroke.rs index 2ca3b3e28d..876d45f04c 100644 --- a/crates/rnote-engine/src/strokes/textstroke.rs +++ b/crates/rnote-engine/src/strokes/textstroke.rs @@ -1,8 +1,9 @@ // Imports use super::Content; +use crate::engine::Spellcheck; use crate::{Camera, Drawable}; use itertools::Itertools; -use kurbo::Shape; +use kurbo::{BezPath, Shape}; use p2d::bounding_volume::Aabb; use piet::{RenderContext, TextLayout, TextLayoutBuilder}; use rnote_compose::ext::{AabbExt, Affine2Ext, Vector2Ext}; @@ -10,6 +11,7 @@ use rnote_compose::shapes::Shapeable; use rnote_compose::transform::Transformable; use rnote_compose::{Color, Transform, color}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::ops::Range; use tracing::error; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; @@ -337,20 +339,20 @@ impl TextStyle { Ok(text_layout.hit_test_text_position(cursor.cur_cursor())) } - pub fn get_selection_rects_for_cursors( + pub fn get_rects_for_indices( &self, text: String, - cursor: &GraphemeCursor, - selection_cursor: &GraphemeCursor, + start_index: usize, + end_index: usize, ) -> anyhow::Result> { let text_layout = self .build_text_layout(&mut piet_cairo::CairoText::new(), text) .map_err(|e| anyhow::anyhow!("Building text layout failed, Err: {e:?}"))?; - let range = if selection_cursor.cur_cursor() >= cursor.cur_cursor() { - cursor.cur_cursor()..selection_cursor.cur_cursor() + let range = if end_index >= start_index { + start_index..end_index } else { - selection_cursor.cur_cursor()..cursor.cur_cursor() + end_index..start_index }; Ok(text_layout.rects_for_range(range)) @@ -403,6 +405,42 @@ impl TextStyle { Ok(()) } + pub fn draw_text_error( + &self, + cx: &mut impl piet::RenderContext, + text: String, + start_index: usize, + end_index: usize, + transform: &Transform, + camera: &Camera, + ) { + const ERROR_COLOR: piet::Color = color::GNOME_REDS[2]; + const STYLE: piet::StrokeStyle = piet::StrokeStyle::new().line_cap(piet::LineCap::Round); + + let scale = 1.0 / camera.total_zoom(); + + if let Ok(selection_rects) = + self.get_rects_for_indices(text.clone(), start_index, end_index) + { + // Get baseline for the current line. Really unnecessary to do this for every error since the font size is uniform, + // but piet does not provide any other way to get the baseline. + + if let Ok(line_metric) = self.cursor_line_metric(cx.text(), text, start_index) { + for selection_rect in selection_rects { + let width = selection_rect.width(); + let origin = transform.to_kurbo() + * kurbo::Point::new( + selection_rect.x0, + selection_rect.y0 + line_metric.baseline + 2.0, + ); + + let path = create_wavy_line(origin, width, scale); + cx.stroke_styled(path, &ERROR_COLOR, 1.5 * scale, &STYLE); + } + } + } + } + pub fn draw_text_selection( &self, cx: &mut impl piet::RenderContext, @@ -417,7 +455,7 @@ impl TextStyle { let outline_width = 1.5 / camera.total_zoom(); if let Ok(selection_rects) = - self.get_selection_rects_for_cursors(text, cursor, selection_cursor) + self.get_rects_for_indices(text, cursor.cur_cursor(), selection_cursor.cur_cursor()) { for selection_rect in selection_rects { let outline = transform.to_kurbo() * selection_rect.to_path(0.5); @@ -429,6 +467,12 @@ impl TextStyle { } } +#[derive(Debug, Clone, Default)] +pub struct SpellcheckResult { + pub language: Option, + pub errors: BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename = "textstroke")] pub struct TextStroke { @@ -441,6 +485,8 @@ pub struct TextStroke { pub transform: Transform, #[serde(rename = "text_style")] pub text_style: TextStyle, + #[serde(skip)] + pub spellcheck_result: SpellcheckResult, } impl Default for TextStroke { @@ -449,6 +495,7 @@ impl Default for TextStroke { text: String::default(), transform: Transform::default(), text_style: TextStyle::default(), + spellcheck_result: SpellcheckResult::default(), } } } @@ -545,6 +592,7 @@ impl TextStroke { text, transform: Transform::new_w_isometry(na::Isometry2::new(upper_left_pos, 0.0)), text_style, + spellcheck_result: SpellcheckResult::default(), } } @@ -579,16 +627,148 @@ impl TextStroke { )) } - pub fn insert_text_after_cursor(&mut self, text: &str, cursor: &mut GraphemeCursor) { - self.text.insert_str(cursor.cur_cursor(), text); + fn check_spelling_words(&mut self, words: Vec<(usize, String)>, dict: &enchant::Dict) { + for (word_start_index, word) in words { + if let Ok(valid_word) = dict.check(word.as_str()) { + let word_end_index = word_start_index + word.len(); + let word_range = word_start_index..word_end_index; + + self.spellcheck_result + .errors + .retain(|key, _| !word_range.contains(key)); + + // TODO: maybe faster for large texts + // let keys_to_remove = self + // .error_words + // .range(word_range) + // .map(|(&key, _)| key) + // .collect_vec(); + + // for existing_word in keys_to_remove { + // self.error_words.remove(&existing_word); + // } + + if !valid_word { + self.spellcheck_result + .errors + .insert(word_start_index, word.len()); + } + } else { + error!("Failed to check spelling for word '{word}'"); + } + } + } + + pub fn check_spelling_refresh_cache(&mut self, spellcheck: &Spellcheck) { + if let Some(dict) = &spellcheck.dict { + let language = dict.get_lang(); + + let language_changed = self + .spellcheck_result + .language + .clone() + .is_none_or(|cached_language| cached_language != language); + + if language_changed { + self.spellcheck_result.errors.clear(); + self.spellcheck_result.language = Some(language.to_owned()); + + let words = self + .text + .unicode_word_indices() + .map(|(index, word)| (index, word.to_owned())) + .collect_vec(); + + self.check_spelling_words(words, dict); + } + } else { + self.spellcheck_result.errors.clear(); + self.spellcheck_result.language = None; + } + } + + pub fn get_spellcheck_corrections_at_index( + &self, + spellcheck: &Spellcheck, + index: usize, + ) -> Option> { + let Some(dict) = &spellcheck.dict else { + return None; + }; + + let start_index = self.get_prev_word_start_index(index); + + if let Some(length) = self.spellcheck_result.errors.get(&start_index) { + let word = self.get_text_slice_for_range(start_index..start_index + length); + return Some(dict.suggest(word)); + } + + None + } + + pub fn apply_spellcheck_correction_at_cursor( + &mut self, + cursor: &mut GraphemeCursor, + correction: &str, + ) { + let cur_pos = cursor.cur_cursor(); + let start_index = self.get_prev_word_start_index(cur_pos); + + if let Some(length) = self.spellcheck_result.errors.get(&start_index) { + let old_length = *length; + let new_length = correction.len(); + + self.text + .replace_range(start_index..start_index + old_length, correction); + + self.spellcheck_result.errors.remove(&start_index); + + // translate the text attributes + self.translate_attrs_after_cursor( + start_index + old_length, + (new_length as i32) - (old_length as i32), + ); + + *cursor = GraphemeCursor::new(start_index + new_length, self.text.len(), true); + } + } + + pub fn check_spelling_range( + &mut self, + start_index: usize, + end_index: usize, + spellcheck: &Spellcheck, + ) { + if let Some(dict) = &spellcheck.dict { + let words = self.get_surrounding_words(start_index, end_index); + self.check_spelling_words(words, dict); + } + } + + pub fn insert_text_after_cursor( + &mut self, + text: &str, + cursor: &mut GraphemeCursor, + spellcheck: &Spellcheck, + ) { + let cur_pos = cursor.cur_cursor(); + let next_pos = cur_pos + text.len(); + + self.text.insert_str(cur_pos, text); // translate the text attributes - self.translate_attrs_after_cursor(cursor.cur_cursor(), text.len() as i32); + self.translate_attrs_after_cursor(cur_pos, text.len() as i32); + + self.check_spelling_range(cur_pos, next_pos, spellcheck); - *cursor = GraphemeCursor::new(cursor.cur_cursor() + text.len(), self.text.len(), true); + *cursor = GraphemeCursor::new(next_pos, self.text.len(), true); } - pub fn remove_grapheme_before_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_grapheme_before_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellcheck: &Spellcheck, + ) { if !self.text.is_empty() && self.text.len() >= cursor.cur_cursor() { let cur_pos = cursor.cur_cursor(); @@ -600,6 +780,8 @@ impl TextStroke { prev_pos, prev_pos as i32 - cur_pos as i32 + "".len() as i32, ); + + self.check_spelling_range(prev_pos, cur_pos, spellcheck); } // New text length, new cursor @@ -607,7 +789,11 @@ impl TextStroke { } } - pub fn remove_grapheme_after_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_grapheme_after_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellcheck: &Spellcheck, + ) { if !self.text.is_empty() && self.text.len() > cursor.cur_cursor() { let cur_pos = cursor.cur_cursor(); @@ -619,6 +805,8 @@ impl TextStroke { cur_pos, -(next_pos as i32 - cur_pos as i32) + "".len() as i32, ); + + self.check_spelling_range(cur_pos, next_pos, spellcheck); } // New text length, new cursor @@ -626,7 +814,11 @@ impl TextStroke { } } - pub fn remove_word_before_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_word_before_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellcheck: &Spellcheck, + ) { let cur_pos = cursor.cur_cursor(); let prev_pos = self.get_prev_word_start_index(cur_pos); @@ -639,12 +831,18 @@ impl TextStroke { prev_pos as i32 - cur_pos as i32 + "".len() as i32, ); + self.check_spelling_range(prev_pos, cur_pos, spellcheck); + // New text length, new cursor *cursor = GraphemeCursor::new(prev_pos, self.text.len(), true); } } - pub fn remove_word_after_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_word_after_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellcheck: &Spellcheck, + ) { let cur_pos = cursor.cur_cursor(); let next_pos = self.get_next_word_end_index(cur_pos); @@ -657,6 +855,8 @@ impl TextStroke { -(next_pos as i32 - cur_pos as i32) + "".len() as i32, ); + self.check_spelling_range(cur_pos, next_pos, spellcheck); + // New text length, new cursor *cursor = GraphemeCursor::new(cur_pos, self.text.len(), true); } @@ -667,6 +867,7 @@ impl TextStroke { cursor: &mut GraphemeCursor, selection_cursor: &mut GraphemeCursor, replace_text: &str, + spellcheck: &Spellcheck, ) { let cursor_pos = cursor.cur_cursor(); let selection_cursor_pos = selection_cursor.cur_cursor(); @@ -694,12 +895,43 @@ impl TextStroke { cursor.cur_cursor(), -(cursor_range.end as i32 - cursor_range.start as i32) + replace_text.len() as i32, ); + + self.check_spelling_range( + cursor_range.start, + cursor_range.start + replace_text.len(), + spellcheck, + ); } /// Translate the ranged text attributes after the given cursor. /// /// Overlapping ranges are extended / shrunk + /// + /// * `from_pos` is always the start of the range to translate. + /// * `offset` is the translation. The end of the range is calculated by adding the **absolute** value of the offset. fn translate_attrs_after_cursor(&mut self, from_pos: usize, offset: i32) { + let translated_words = if offset < 0 { + let to_pos = from_pos.saturating_add(offset.unsigned_abs() as usize); + self.spellcheck_result + .errors + .split_off(&from_pos) + .split_off(&to_pos) + } else { + self.spellcheck_result.errors.split_off(&from_pos) + }; + + for (word_start, word_length) in translated_words { + let Some(new_word_start) = word_start.checked_add_signed(offset as isize) else { + continue; + }; + + if new_word_start >= from_pos { + self.spellcheck_result + .errors + .insert(new_word_start, word_length); + } + } + for attr in self.text_style.ranged_text_attributes.iter_mut() { if attr.range.start > from_pos { if offset >= 0 { @@ -830,6 +1062,22 @@ impl TextStroke { selection_cursor.set_cursor(0); } + fn get_surrounding_words(&self, start_index: usize, end_index: usize) -> Vec<(usize, String)> { + let mut words = Vec::new(); + + for (word_start, word) in self.text.unicode_word_indices() { + let word_end = word_start + word.len(); + + if word_end >= start_index && word_start <= end_index { + words.push((word_start, word.to_owned())); + } + } + + // debug!("surrounding words: {words:?}"); + + words + } + fn get_prev_word_start_index(&self, current_char_index: usize) -> usize { for (start_index, _) in self.text.unicode_word_indices().rev() { if start_index < current_char_index { @@ -1056,3 +1304,35 @@ fn remove_intersecting_attrs_in_range( .filter(|attr| !attr.range.is_empty()) .collect::>() } + +fn create_wavy_line(origin: kurbo::Point, max_width: f64, scale: f64) -> BezPath { + const WIDTH: f64 = 3.5; + const HEIGHT: f64 = 4.0; + + if !max_width.is_finite() { + return BezPath::new(); + } + + let width = WIDTH * scale; + let half_height = (HEIGHT / 2.0) * scale; + + let mut path = BezPath::new(); + path.move_to(origin + (0.0, half_height)); + + let mut x = 0.0; + let mut direction = 1.0; + + while x < max_width { + let center_point = origin + (x, half_height); + + let stationary_point = center_point + (width / 2.0, half_height * direction); + let next_center_point = center_point + (width, 0.0); + + path.quad_to(stationary_point, next_center_point); + + x += width; + direction = -direction; + } + + path +} diff --git a/crates/rnote-ui/Cargo.toml b/crates/rnote-ui/Cargo.toml index 426d8e9e00..0e1d6c41b3 100644 --- a/crates/rnote-ui/Cargo.toml +++ b/crates/rnote-ui/Cargo.toml @@ -23,6 +23,8 @@ fs_extra = { workspace = true } futures = { workspace = true } gettext-rs = { workspace = true } gtk4 = { workspace = true } +icu_displaynames = { workspace = true } +icu_locid = { workspace = true } ijson = { workspace = true } image = { workspace = true } itertools = { workspace = true } diff --git a/crates/rnote-ui/data/icons/scalable/actions/text-squiggly-symbolic.svg b/crates/rnote-ui/data/icons/scalable/actions/text-squiggly-symbolic.svg new file mode 100644 index 0000000000..b920855b0f --- /dev/null +++ b/crates/rnote-ui/data/icons/scalable/actions/text-squiggly-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/rnote-ui/data/meson.build b/crates/rnote-ui/data/meson.build index a187fba6f8..65a3bf06bf 100644 --- a/crates/rnote-ui/data/meson.build +++ b/crates/rnote-ui/data/meson.build @@ -276,6 +276,7 @@ rnote_ui_gresources_icons_files = files( 'icons/scalable/actions/text-indent-less-symbolic.svg', 'icons/scalable/actions/text-indent-more-symbolic.svg', 'icons/scalable/actions/text-italic-symbolic.svg', + 'icons/scalable/actions/text-squiggly-symbolic.svg', 'icons/scalable/actions/text-strikethrough-symbolic.svg', 'icons/scalable/actions/text-underline-symbolic.svg', 'icons/scalable/actions/touch-two-finger-long-press-symbolic.svg', diff --git a/crates/rnote-ui/data/resources.gresource.xml b/crates/rnote-ui/data/resources.gresource.xml index 356b29244b..5e6518f832 100644 --- a/crates/rnote-ui/data/resources.gresource.xml +++ b/crates/rnote-ui/data/resources.gresource.xml @@ -147,6 +147,7 @@ icons/scalable/actions/text-indent-less-symbolic.svg icons/scalable/actions/text-indent-more-symbolic.svg icons/scalable/actions/text-italic-symbolic.svg + icons/scalable/actions/text-squiggly-symbolic.svg icons/scalable/actions/text-strikethrough-symbolic.svg icons/scalable/actions/text-underline-symbolic.svg icons/scalable/actions/touch-two-finger-long-press-symbolic.svg diff --git a/crates/rnote-ui/data/ui/penssidebar/typewriterpage.ui b/crates/rnote-ui/data/ui/penssidebar/typewriterpage.ui index a3d2685ea4..a0d4027973 100644 --- a/crates/rnote-ui/data/ui/penssidebar/typewriterpage.ui +++ b/crates/rnote-ui/data/ui/penssidebar/typewriterpage.ui @@ -51,6 +51,99 @@ right + + + left + Spellcheck Corrections + text-squiggly-symbolic + + + + + + + true + + + + + + + + vertical + 12 + 18 + 18 + 18 + 18 + + + text-squiggly-symbolic + 64 + + + + + + center + No Corrections +Available + + + + + + center + Move cursor over underlined +words to get suggestions. + + + + + + + + + + vertical + 12 + 18 + 18 + 18 + 18 + + + text-squiggly-symbolic + 64 + + + + + + center + No Corrections +Found + + + + + + vertical diff --git a/crates/rnote-ui/data/ui/settingspanel.ui b/crates/rnote-ui/data/ui/settingspanel.ui index e1c205a8e7..61605d93a9 100644 --- a/crates/rnote-ui/data/ui/settingspanel.ui +++ b/crates/rnote-ui/data/ui/settingspanel.ui @@ -394,6 +394,23 @@ gets disabled. + + + Spellcheck + Enable or disable spellcheck + + + + + Spellcheck Language + Choose the language used by spellcheck + true + substring + + + + + diff --git a/crates/rnote-ui/src/appwindow/imp.rs b/crates/rnote-ui/src/appwindow/imp.rs index 581a26986e..8543c07780 100644 --- a/crates/rnote-ui/src/appwindow/imp.rs +++ b/crates/rnote-ui/src/appwindow/imp.rs @@ -635,6 +635,11 @@ impl RnAppWindow { .typewriter_page() .emojichooser_menubutton() .set_direction(ArrowType::Right); + obj.overlays() + .penssidebar() + .typewriter_page() + .spellcheck_corrections_menubutton() + .set_direction(ArrowType::Right); obj.overlays() .penssidebar() .eraser_page() @@ -762,6 +767,11 @@ impl RnAppWindow { .typewriter_page() .emojichooser_menubutton() .set_direction(ArrowType::Left); + obj.overlays() + .penssidebar() + .typewriter_page() + .spellcheck_corrections_menubutton() + .set_direction(ArrowType::Left); obj.overlays() .penssidebar() .eraser_page() diff --git a/crates/rnote-ui/src/penssidebar/typewriterpage.rs b/crates/rnote-ui/src/penssidebar/typewriterpage.rs index f1d7c981e2..f19660f019 100644 --- a/crates/rnote-ui/src/penssidebar/typewriterpage.rs +++ b/crates/rnote-ui/src/penssidebar/typewriterpage.rs @@ -1,8 +1,11 @@ // Imports use crate::{RnAppWindow, RnCanvasWrapper}; +use gtk4::ListView; +use gtk4::Popover; use gtk4::{ - Button, CompositeTemplate, EmojiChooser, FontDialog, MenuButton, SpinButton, ToggleButton, - glib, glib::clone, pango, prelude::*, subclass::prelude::*, + Button, CompositeTemplate, ConstantExpression, EmojiChooser, FontDialog, ListItem, MenuButton, + NoSelection, PropertyExpression, SignalListItemFactory, SpinButton, StringList, StringObject, + ToggleButton, Widget, glib, glib::clone, pango, prelude::*, subclass::prelude::*, }; use rnote_engine::strokes::textstroke::{FontStyle, TextAlignment, TextAttribute, TextStyle}; use std::cell::RefCell; @@ -25,6 +28,16 @@ mod imp { #[template_child] pub(crate) emojichooser: TemplateChild, #[template_child] + pub(crate) spellcheck_corrections_menubutton: TemplateChild, + #[template_child] + pub(crate) spellcheck_corrections_empty: TemplateChild, + #[template_child] + pub(crate) spellcheck_corrections_unavailable: TemplateChild, + #[template_child] + pub(crate) spellcheck_corrections: TemplateChild, + #[template_child] + pub(crate) spellcheck_corrections_listview: TemplateChild, + #[template_child] pub(crate) text_reset_button: TemplateChild