diff --git a/.gitignore b/.gitignore index fe21aa37..2f3e926d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *flatpak* build -Cargo.lock repo target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..8a467b6f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1806 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Yoda" +version = "0.12.10" +dependencies = [ + "ansi-parser", + "anyhow", + "ggemini", + "ggemtext", + "gtk4", + "indexmap", + "itertools", + "libadwaita", + "libspelling", + "maxminddb", + "openssl", + "plurify", + "r2d2", + "r2d2_sqlite", + "regex", + "rusqlite", + "sourceview5", + "strip-tags", + "syntect", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi-parser" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43e7fd8284f025d0bd143c2855618ecdf697db55bde39211e5c9faec7669173" +dependencies = [ + "heapless", + "nom", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cairo-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "ggemini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c455ed1aa0c9e3d17431a14797ca1c5950dca1e02a8f07b2faf6d6c696f12f" +dependencies = [ + "gio", + "glib", +] + +[[package]] +name = "ggemtext" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc3f1aec690916cf0c078039df3e28647ffe0e883ec013c080a4d1e0f0fae13" +dependencies = [ + "glib", +] + +[[package]] +name = "gio" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libadwaita" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb09e12bf8f73342b3315c839d0a7668cc0ccebd78490c49fec48bab15d5484b" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7f94227ba87eb596fecada2491f04e357d507324142f77bf76d9e6be4a3e31" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libspelling" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc92df39e5bcfed67e8263756b1f4d4f839370baf8a493cbd958e192944c85c2" +dependencies = [ + "gio", + "glib", + "gtk4", + "libc", + "libspelling-sys", + "sourceview5", +] + +[[package]] +name = "libspelling-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "286fffcc712568745b5c7f74063f84d99b2094c49345966b1fc12ff1a216fa5f" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "sourceview5-sys", + "system-deps", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "maxminddb" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76371bd37ce742f8954daabd0fde7f1594ee43ac2200e20c003ba5c3d65e2192" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", + "thiserror", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pango" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "plurify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0642d69883c3a79565ea9b89a9cd8a845c79a7be17fd4fb8c6347b329f03455b" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sourceview5" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4acb02162917d7b689c84f4ed710c22302c67c7e61da28a4e8ac548c04442c" +dependencies = [ + "futures-channel", + "futures-core", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libc", + "pango", + "sourceview5-sys", +] + +[[package]] +name = "sourceview5-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c1529de5df2653788828ef71d44b113bb80e496bc17315f4122106bd6eebae" +dependencies = [ + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strip-tags" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd2b127e68202f5f285a116f616d5d11735cca5e4befaea0347becd445b05b2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom", + "js-sys", + "rand", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index c6f13d3b..75c5b126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.2" +version = "0.12.10" edition = "2024" license = "MIT" readme = "README.md" @@ -22,7 +22,7 @@ features = ["gnome_46"] [dependencies.sqlite] package = "rusqlite" -version = "0.37.0" +version = "0.38.0" [dependencies.sourceview] package = "sourceview5" @@ -31,16 +31,18 @@ version = "0.10.0" [dependencies] ansi-parser = "0.9.1" anyhow = "1.0.97" -ggemini = "0.19.0" +ggemini = "0.20.0" ggemtext = "0.7.0" indexmap = "2.10.0" itertools = "0.14.0" libspelling = "0.4.1" -maxminddb = "0.26.0" +maxminddb = "0.27.3" openssl = "0.10.72" plurify = "0.2.0" r2d2 = "0.8.10" -r2d2_sqlite = "0.31.0" +r2d2_sqlite = "0.32.0" +regex = "1.12.3" +strip-tags = "0.1.0" syntect = "5.2.0" # development diff --git a/README.md b/README.md index 72badf12..72e41dc8 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # Yoda - Browser for [Gemini protocol](https://geminiprotocol.net) -GTK 4 / Libadwaita client written in Rust +![Yoda browser logo](https://raw.githubusercontent.com/YGGverse/Yoda/refs/heads/master/data/io.github.yggverse.Yoda.svg) + +Privacy-oriented GTK 4 / Libadwaita client written in Rust. + +The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. It also includes useful tools, such as connection details, optional DNS/Geo-IP features, flexible proxy configuration for use with modern IPv6 mesh networks like Yggdrasil, Mycelium, CJDNS, and others. + +Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. + +The Gemini protocol was designed as a minimalistic, tracking-resistant alternative to the Web, and Yoda embraces this philosophy by providing a straightforward graphical user interface (GUI) that is partially inspired by the Firefox UI, making it intuitively comfortable for regular users. > [!IMPORTANT] -> Project in development, for stable version use crates.io release! +> Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)! > ![image](https://github.com/user-attachments/assets/cfbbc3fb-61d2-4afd-a21f-8e36ee329941) @@ -127,8 +135,9 @@ GTK 4 / Libadwaita client written in Rust #### Text * [x] `text/gemini` - * [x] `text/plain` + * [x] `text/markdown` * [x] `text/nex` + * [x] `text/plain` #### Images * [x] `image/gif` @@ -152,13 +161,13 @@ GTK 4 / Libadwaita client written in Rust ### Requirements -* Cairo `1.18` -* GdkPixBuf `2.42` -* Glib `2.80` -* Gtk `4.14` -* GtkSourceView `5.14` -* libadwaita `1.5` (Ubuntu 24.04+) -* libspelling `0.1` +* Cairo `1.18+` +* GdkPixBuf `2.42+` +* Glib `2.80+` +* Gtk `4.14+` +* GtkSourceView `5.14+` +* libadwaita `1.5+` (Ubuntu `24.04+`) +* libspelling `0.1+` #### Debian @@ -226,7 +235,7 @@ flatpak-builder --force-clean build\ #### Contributors -![wakatime](https://wakatime.com/badge/user/0b7fe6c1-b091-4c98-b930-75cfee17c7a5/project/018ebca8-4d22-4f9e-b557-186be6553d9a.svg) ![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg) +![StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg) ### Localization diff --git a/data/io.github.yggverse.Yoda.desktop b/data/io.github.yggverse.Yoda.desktop index b083f234..ed910e4b 100644 --- a/data/io.github.yggverse.Yoda.desktop +++ b/data/io.github.yggverse.Yoda.desktop @@ -3,7 +3,7 @@ Categories=GNOME;GTK;Network Comment=Browser for Gemini protocol Exec=Yoda GenericName=Browser -#Icon=io.github.yggverse.Yoda +Icon=io.github.yggverse.Yoda Keywords=Gnome;GTK;Gemini;Browser Name=Yoda StartupNotify=true diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg new file mode 100644 index 00000000..22a52952 --- /dev/null +++ b/data/io.github.yggverse.Yoda.svg @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/io.github.yggverse.Yoda.yaml b/io.github.yggverse.Yoda.yaml index 63837ff3..5a523096 100644 --- a/io.github.yggverse.Yoda.yaml +++ b/io.github.yggverse.Yoda.yaml @@ -43,7 +43,7 @@ modules: post-install: - "install -Dm755 ./target/release/Yoda -t /app/bin" - "install -Dm644 ./data/${FLATPAK_ID}.desktop -t /app/share/applications" -# - "install -Dm644 ./data/${FLATPAK_ID}.svg -t /app/share/icons/hicolor/symbolic/apps" + - "install -Dm644 ./data/${FLATPAK_ID}.svg -t /app/share/icons/hicolor/symbolic/apps" sources: - type: "dir" path: "." \ No newline at end of file diff --git a/src/app/browser/about.rs b/src/app/browser/about.rs index 015e0828..d334032f 100644 --- a/src/app/browser/about.rs +++ b/src/app/browser/about.rs @@ -28,6 +28,7 @@ impl About for adw::AboutDialog { ]; adw::AboutDialog::builder() + .application_icon("io.github.yggverse.Yoda") .application_name(env!("CARGO_PKG_NAME")) .debug_info(debug.join("\n")) .developer_name(env!("CARGO_PKG_DESCRIPTION")) diff --git a/src/app/browser/window/header/bar.rs b/src/app/browser/window/header/bar.rs index dc0c8d65..a066d741 100644 --- a/src/app/browser/window/header/bar.rs +++ b/src/app/browser/window/header/bar.rs @@ -27,10 +27,20 @@ impl Bar for Box { .orientation(Orientation::Horizontal) .spacing(8) .build(); - - g_box.append(&TabBar::tab(window_action, view)); - g_box.append(&MenuButton::menu((browser_action, window_action))); - g_box.append(&Control::new().window_controls); + // left controls placement + if gtk::Settings::default().is_some_and(|s| { + s.gtk_decoration_layout() + .is_some_and(|l| l.starts_with("close")) + }) { + g_box.append(&Control::left().window_controls); + g_box.append(&MenuButton::menu((browser_action, window_action))); + g_box.append(&TabBar::tab(window_action, view)) + // default layout + } else { + g_box.append(&TabBar::tab(window_action, view)); + g_box.append(&MenuButton::menu((browser_action, window_action))); + g_box.append(&Control::right().window_controls) + } g_box } } diff --git a/src/app/browser/window/header/bar/control.rs b/src/app/browser/window/header/bar/control.rs index 05848ac8..41917aec 100644 --- a/src/app/browser/window/header/bar/control.rs +++ b/src/app/browser/window/header/bar/control.rs @@ -8,13 +8,12 @@ pub struct Control { impl Default for Control { fn default() -> Self { - Self::new() + Self::right() } } impl Control { - // Construct - pub fn new() -> Self { + pub fn right() -> Self { Self { window_controls: WindowControls::builder() .margin_end(MARGIN) @@ -22,4 +21,12 @@ impl Control { .build(), } } + pub fn left() -> Self { + Self { + window_controls: WindowControls::builder() + .margin_end(MARGIN) + .side(PackType::Start) + .build(), + } + } } diff --git a/src/app/browser/window/header/bar/tab.rs b/src/app/browser/window/header/bar/tab.rs index ae6ca0b2..8fd17c67 100644 --- a/src/app/browser/window/header/bar/tab.rs +++ b/src/app/browser/window/header/bar/tab.rs @@ -14,8 +14,12 @@ impl Tab for TabBar { fn tab(window_action: &Rc, view: &TabView) -> Self { TabBar::builder() .autohide(false) - .expand_tabs(false) .end_action_widget(&Button::append(window_action)) // @TODO find solution to append after tabs + .expand_tabs(false) + .inverted(gtk::Settings::default().is_some_and(|s| { + s.gtk_decoration_layout() + .is_some_and(|l| l.starts_with("close")) + })) // show `x` button at left by respecting the env settings .view(view) .build() } diff --git a/src/app/browser/window/tab.rs b/src/app/browser/window/tab.rs index c9dd25cb..60d54b86 100644 --- a/src/app/browser/window/tab.rs +++ b/src/app/browser/window/tab.rs @@ -10,7 +10,6 @@ use adw::{TabPage, TabView}; use anyhow::Result; use gtk::{ Box, Orientation, - gio::Icon, glib::Propagation, prelude::{ActionExt, EditableExt, EntryExt, WidgetExt}, }; @@ -44,13 +43,6 @@ impl Tab { .menu_model(>k::gio::Menu::menu(window_action)) .build(); - // Change default icon (if available in the system icon set) - // * visible for pinned tabs only - // * @TODO not default GTK behavior, make this feature optional - if let Ok(default_icon) = Icon::for_string("view-pin-symbolic") { - tab_view.set_default_icon(&default_icon); - } - // Init events tab_view.connect_setup_menu({ let index = index.clone(); diff --git a/src/app/browser/window/tab/item.rs b/src/app/browser/window/tab/item.rs index d2e3bb03..73c34972 100644 --- a/src/app/browser/window/tab/item.rs +++ b/src/app/browser/window/tab/item.rs @@ -83,21 +83,35 @@ impl Item { let page = page.clone(); move |this, _| { this.set_enabled(false); - if let Some(uri) = page.navigation.request.home() { - let request = uri.to_string(); - page.navigation.request.entry.set_text(&request); - client.handle(&request, true, false); + match page.navigation.request.home() { + Some(uri) => { + let request = uri.to_string(); + // prevent `changed` event extra emission + // but make sure the entry is always up-to-date + if page.navigation.request.entry.text() != request { + page.navigation.request.entry.set_text(&request) + } + client.handle(&request, true, false) + } + None => panic!(), // unexpected } } }); action.load.connect_activate({ - let page = page.clone(); - let client = client.clone(); + let c = client.clone(); + let e = page.navigation.request.entry.clone(); move |request, is_snap_history, is_redirect| { - if let Some(request) = request { - page.navigation.request.entry.set_text(&request); - client.handle(&request, is_snap_history, is_redirect); + match request { + Some(request) => { + // prevent `changed` event extra emission + // but make sure the entry is always up-to-date + if e.text() != request { + e.set_text(&request) + } + c.handle(&request, is_snap_history, is_redirect) + } + None => panic!(), // unexpected } } }); @@ -144,11 +158,13 @@ impl Item { }); // Handle immediately on request - if let Some(request) = request { - page.navigation.request.entry.set_text(request); - if is_load { - client.handle(request, true, false) + if is_load && let Some(request) = request { + // prevent `changed` event extra emission + // but make sure the entry is always up-to-date + if page.navigation.request.entry.text() != request { + page.navigation.request.entry.set_text(request) } + client.handle(request, true, false) } Self { diff --git a/src/app/browser/window/tab/item/client/driver/file.rs b/src/app/browser/window/tab/item/client/driver/file.rs index 2dd3abf9..c56edbab 100644 --- a/src/app/browser/window/tab/item/client/driver/file.rs +++ b/src/app/browser/window/tab/item/client/driver/file.rs @@ -71,6 +71,31 @@ impl File { .set_mime(Some(content_type.to_string())); } match content_type.as_str() { + "text/gemini" => { + if matches!(*feature, Feature::Source) { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Source(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } else { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Gemini(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } + } "text/plain" => { if matches!(*feature, Feature::Source) { load_contents_async(file, cancellable, move |result| { @@ -94,6 +119,18 @@ impl File { } } }); + } else if url.ends_with(".md") || url.ends_with(".markdown") + { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Markdown(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) } else { load_contents_async(file, cancellable, move |result| { match result { @@ -107,6 +144,31 @@ impl File { }) } } + "text/markdown" => { + if matches!(*feature, Feature::Source) { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Source(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } else { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Markdown(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } + } "image/png" | "image/gif" | "image/jpeg" | "image/webp" => { match gtk::gdk::Texture::from_file(&file) { Ok(texture) => { diff --git a/src/app/browser/window/tab/item/client/driver/file/text.rs b/src/app/browser/window/tab/item/client/driver/file/text.rs index b7f8aa31..8be84797 100644 --- a/src/app/browser/window/tab/item/client/driver/file/text.rs +++ b/src/app/browser/window/tab/item/client/driver/file/text.rs @@ -2,12 +2,13 @@ use gtk::glib::Uri; pub enum Text { Gemini(Uri, String), + Markdown(Uri, String), Plain(Uri, String), Source(Uri, String), } impl Text { - pub fn handle(&self, page: &super::Page) { + pub fn handle(&self, page: &std::rc::Rc) { page.navigation .request .info @@ -20,7 +21,15 @@ impl Text { .info .borrow_mut() .set_mime(Some("text/gemini".to_string())); - page.content.to_text_gemini(uri, data) + page.content.to_text_gemini(&page.profile, uri, data) + }), + Self::Markdown(uri, data) => (uri, { + page.navigation + .request + .info + .borrow_mut() + .set_mime(Some("text/markdown".to_string())); + page.content.to_text_markdown(page, uri, data) }), Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)), Self::Source(uri, data) => (uri, page.content.to_text_source(data)), diff --git a/src/app/browser/window/tab/item/client/driver/gemini.rs b/src/app/browser/window/tab/item/client/driver/gemini.rs index 91cd08cb..40f33ad1 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -282,11 +282,7 @@ fn handle( file_output_stream, cancellable.clone(), Priority::DEFAULT, - file_output_stream::Size { - chunk: 0x100000, // 1M bytes per chunk - limit: None, // unlimited - total: 0, // initial totals - }, + file_output_stream::Size::default(), ( // on chunk { @@ -336,9 +332,8 @@ fn handle( Priority::DEFAULT, cancellable.clone(), memory_input_stream::Size { - chunk: 0x400, // 1024 bytes chunk limit: 0xfffff, // 1M limit - total: 0, // initial totals + ..memory_input_stream::Size::default() }, ( |_, _| {}, // on chunk (maybe nothing to count yet @TODO) @@ -362,7 +357,8 @@ fn handle( page.content.to_text_source(data) } else { match m.as_str() { - "text/gemini" => page.content.to_text_gemini(&uri, data), + "text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data), + "text/markdown" => page.content.to_text_markdown(&page, &uri, data), "text/plain" => page.content.to_text_plain(data), _ => panic!() // unexpected } @@ -440,9 +436,8 @@ fn handle( Priority::DEFAULT, cancellable.clone(), memory_input_stream::Size { - chunk: 0x400, // 1024 bytes chunk limit: 0xA00000, // 10M limit - total: 0, // initial totals + ..memory_input_stream::Size::default() }, ( move |_, total| status.set_description(Some(&format!("Download: {}", total.bytes()))), @@ -560,7 +555,7 @@ fn handle( .build(); b.connect_clicked({ let p = page.clone(); - move |_| p.item_action.load.activate(Some(&u), false, true) + move |_| p.item_action.load.activate(Some(&u), true, true) }); b })); @@ -584,7 +579,7 @@ fn handle( Redirect::Temporary { .. } => i.into_temporary_redirect(), }); } - page.item_action.load.activate(Some(&t), false, true); + page.item_action.load.activate(Some(&t), true, true); } } Err(e) => { diff --git a/src/app/browser/window/tab/item/client/driver/nex.rs b/src/app/browser/window/tab/item/client/driver/nex.rs index b5c713b7..919c8869 100644 --- a/src/app/browser/window/tab/item/client/driver/nex.rs +++ b/src/app/browser/window/tab/item/client/driver/nex.rs @@ -193,9 +193,8 @@ impl Nex { Priority::DEFAULT, cancellable.clone(), ggemini::gio::memory_input_stream::Size { - chunk: 0x400, // 1024 bytes chunk limit: 0xA00000, // 10M limit - total: 0, // initial totals + ..ggemini::gio::memory_input_stream::Size::default() }, ( { @@ -300,7 +299,7 @@ fn render( } else if q.ends_with("/") { p.content.to_text_nex(&u, d) } else if q.ends_with(".gmi") || q.ends_with(".gemini") { - p.content.to_text_gemini(&u, d) + p.content.to_text_gemini(&p.profile, &u, d) } else { p.content.to_text_plain(d) }; @@ -343,11 +342,7 @@ fn download(s: SocketConnection, (p, u): (Rc, Uri), c: Cancellable) { file_output_stream, c.clone(), Priority::DEFAULT, - file_output_stream::Size { - chunk: 0x100000, // 1M bytes per chunk - limit: None, // unlimited - total: 0, // initial totals - }, + file_output_stream::Size::default(), ( // on chunk { diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index 016121dd..2f6b9551 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -7,6 +7,8 @@ use directory::Directory; use image::Image; use text::Text; +use crate::{app::browser::window::tab::item::page::Page, profile::Profile}; + use super::{ItemAction, TabAction, WindowAction}; use adw::StatusPage; use gtk::{ @@ -126,9 +128,14 @@ impl Content { } /// `text/gemini` - pub fn to_text_gemini(&self, base: &Uri, data: &str) -> Text { + pub fn to_text_gemini(&self, profile: &Rc, base: &Uri, data: &str) -> Text { self.clean(); - match Text::gemini((&self.window_action, &self.item_action), base, data) { + match Text::gemini( + (&self.window_action, &self.item_action), + profile, + base, + data, + ) { Ok(text) => { self.g_box.append(&text.scrolled_window); text @@ -154,6 +161,14 @@ impl Content { } } + /// `text/markdown` + pub fn to_text_markdown(&self, page: &Rc, base: &Uri, data: &str) -> Text { + self.clean(); + let m = Text::markdown((&self.window_action, &self.item_action), page, base, data); + self.g_box.append(&m.scrolled_window); + m + } + /// `text/plain` pub fn to_text_plain(&self, data: &str) -> Text { self.clean(); diff --git a/src/app/browser/window/tab/item/page/content/directory/column/format.rs b/src/app/browser/window/tab/item/page/content/directory/column/format.rs index ba027dfb..328048c0 100644 --- a/src/app/browser/window/tab/item/page/content/directory/column/format.rs +++ b/src/app/browser/window/tab/item/page/content/directory/column/format.rs @@ -15,6 +15,8 @@ impl Format for FileInfo { if content_type == "text/plain" { if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") { "text/gemini".into() + } else if display_name.ends_with(".md") || display_name.ends_with(".markdown") { + "text/markdown".into() } else { content_type } diff --git a/src/app/browser/window/tab/item/page/content/text.rs b/src/app/browser/window/tab/item/page/content/text.rs index d47c9e65..f400591c 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -1,12 +1,16 @@ mod gemini; +mod markdown; mod nex; mod plain; mod source; +use crate::{app::browser::window::tab::item::page::Page, profile::Profile}; + use super::{ItemAction, WindowAction}; use adw::ClampScrollable; use gemini::Gemini; use gtk::{ScrolledWindow, TextView, glib::Uri}; +use markdown::Markdown; use nex::Nex; use plain::Plain; use source::Source; @@ -25,10 +29,11 @@ pub struct Text { impl Text { pub fn gemini( actions: (&Rc, &Rc), + profile: &Rc, base: &Uri, gemtext: &str, ) -> Result)> { - match Gemini::build(actions, base, gemtext) { + match Gemini::build(actions, profile, base, gemtext) { Ok(widget) => Ok(Self { scrolled_window: reader(&widget.text_view), text_view: widget.text_view, @@ -51,6 +56,22 @@ impl Text { } } + pub fn markdown( + actions: (&Rc, &Rc), + page: &Rc, + base: &Uri, + gemtext: &str, + ) -> Self { + let markdown = Markdown::build(actions, page, base, gemtext); + Self { + scrolled_window: reader(&markdown.text_view), + text_view: markdown.text_view, + meta: Meta { + title: markdown.title, + }, + } + } + pub fn plain(data: &str) -> Self { let text_view = TextView::plain(data); Self { diff --git a/src/app/browser/window/tab/item/page/content/text/gemini.rs b/src/app/browser/window/tab/item/page/content/text/gemini.rs index a5e58a1c..ebb90175 100644 --- a/src/app/browser/window/tab/item/page/content/text/gemini.rs +++ b/src/app/browser/window/tab/item/page/content/text/gemini.rs @@ -5,23 +5,23 @@ mod icon; mod syntax; mod tag; -pub use error::Error; -use gutter::Gutter; -use icon::Icon; -use syntax::Syntax; -use tag::Tag; - use super::{ItemAction, WindowAction}; -use crate::app::browser::window::action::Position; +use crate::{app::browser::window::action::Position, profile::Profile}; +pub use error::Error; use gtk::{ EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType, UriLauncher, Window, WrapMode, - gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA}, - gio::Cancellable, - glib::Uri, - prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, + gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA}, + gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup}, + glib::{Uri, uuid_string_random}, + prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, }; +use gutter::Gutter; +use icon::Icon; +use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; +use syntax::Syntax; +use tag::Tag; pub const NEW_LINE: &str = "\n"; @@ -36,6 +36,7 @@ impl Gemini { /// Build new `Self` pub fn build( (window_action, item_action): (&Rc, &Rc), + profile: &Rc, base: &Uri, gemtext: &str, ) -> Result { @@ -150,11 +151,7 @@ impl Gemini { match syntax.highlight(&c.value, alt) { Ok(highlight) => { for (syntax_tag, entity) in highlight { - // Register new tag - if !tag.text_tag_table.add(&syntax_tag) { - todo!() - } - // Append tag to buffer + assert!(tag.text_tag_table.add(&syntax_tag)); buffer.insert_with_tags( &mut buffer.end_iter(), &entity, @@ -165,11 +162,7 @@ impl Gemini { Err(_) => { // Try ANSI/SGR format (terminal emulation) @TODO optional for (syntax_tag, entity) in ansi::format(&c.value) { - // Register new tag - if !tag.text_tag_table.add(&syntax_tag) { - todo!() - } - // Append tag to buffer + assert!(tag.text_tag_table.add(&syntax_tag)); buffer.insert_with_tags( &mut buffer.end_iter(), &entity, @@ -186,7 +179,7 @@ impl Gemini { // Skip other actions for this line continue; } - Err(_) => todo!(), + Err(_) => panic!(), } } } @@ -217,10 +210,10 @@ impl Gemini { // Is link if let Some(link) = ggemtext::line::Link::parse(line) { if let Some(uri) = link.uri(Some(base)) { - let mut alt = Vec::new(); + let mut alt = Vec::with_capacity(2); if uri.scheme() != base.scheme() { - alt.push("⇖".to_string()); + alt.push(LINK_EXTERNAL_INDICATOR.to_string()); } alt.push(match link.alt { @@ -235,9 +228,7 @@ impl Gemini { .wrap_mode(WrapMode::Word) .build(); - if !tag.text_tag_table.add(&a) { - panic!() - } + assert!(tag.text_tag_table.add(&a)); buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]); buffer.insert(&mut buffer.end_iter(), NEW_LINE); @@ -284,14 +275,170 @@ impl Gemini { buffer.insert(&mut buffer.end_iter(), NEW_LINE); } + // Context menu + let action_link_tab = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_tab.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &this.state().unwrap().get::().unwrap(), + &window_action, + ) + } + }); + let action_link_copy_url = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy_url.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_link_copy_text = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy_text.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_link_copy_text_selected = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy_text_selected.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_link_bookmark = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_bookmark.connect_activate({ + let p = profile.clone(); + move |this, _| { + let state = this.state().unwrap().get::().unwrap(); + p.bookmark.toggle(&state, None).unwrap(); + } + }); + let action_link_download = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_download.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &link_prefix( + this.state().unwrap().get::().unwrap(), + LINK_PREFIX_DOWNLOAD, + ), + &window_action, + ) + } + }); + let action_link_source = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_source.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &link_prefix( + this.state().unwrap().get::().unwrap(), + LINK_PREFIX_SOURCE, + ), + &window_action, + ) + } + }); + let link_context_group_id = uuid_string_random(); + text_view.insert_action_group( + &link_context_group_id, + Some(&{ + let g = SimpleActionGroup::new(); + g.add_action(&action_link_tab); + g.add_action(&action_link_copy_url); + g.add_action(&action_link_copy_text); + g.add_action(&action_link_copy_text_selected); + g.add_action(&action_link_bookmark); + g.add_action(&action_link_download); + g.add_action(&action_link_source); + g + }), + ); + let link_context = gtk::PopoverMenu::from_model(Some(&{ + let m = Menu::new(); + m.append( + Some("Open Link in New Tab"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_tab.name() + )), + ); + m.append_section(None, &{ + let m_copy = Menu::new(); + m_copy.append( + Some("Copy Link URL"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_copy_url.name() + )), + ); + m_copy.append( + Some("Copy Link Text"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_copy_text.name() + )), + ); + m_copy.append( + Some("Copy Text Selected"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_copy_text_selected.name() + )), + ); + m_copy + }); + m.append_section(None, &{ + let m_other = Menu::new(); + m_other.append( + Some("Bookmark Link"), // @TODO highlight state + Some(&format!( + "{link_context_group_id}.{}", + action_link_bookmark.name() + )), + ); + m_other.append( + Some("Download Link"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_download.name() + )), + ); + m_other.append( + Some("View Link as Source"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_source.name() + )), + ); + m_other + }); + m + })); + link_context.set_parent(&text_view); + // Init additional controllers - let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build(); let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build(); + let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build(); + let secondary_button_controller = GestureClick::builder() + .button(BUTTON_SECONDARY) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); let motion_controller = EventControllerMotion::new(); - text_view.add_controller(primary_button_controller.clone()); text_view.add_controller(middle_button_controller.clone()); text_view.add_controller(motion_controller.clone()); + text_view.add_controller(primary_button_controller.clone()); + text_view.add_controller(secondary_button_controller.clone()); // Init shared reference container for HashTable collected let links = Rc::new(links); @@ -308,27 +455,92 @@ impl Gemini { window_x as i32, window_y as i32, ); - if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { for tag in iter.tags() { // Tag is link if let Some(uri) = links.get(&tag) { - // Select link handler by scheme - return match uri.scheme().as_str() { - "gemini" | "titan" | "nex" | "file" => { - item_action.load.activate(Some(&uri.to_str()), true, false) + return open_link_in_current_tab(&uri.to_string(), &item_action); + } + } + } + } + }); + + secondary_button_controller.connect_pressed({ + let links = links.clone(); + let text_view = text_view.clone(); + let link_context = link_context.clone(); + move |_, _, window_x, window_y| { + let x = window_x as i32; + let y = window_y as i32; + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = + text_view.window_to_buffer_coords(TextWindowType::Widget, x, y); + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + let request_str = uri.to_str(); + let request_var = request_str.to_variant(); + let is_prefix_link = is_prefix_link(&request_str); + + // Open in the new tab + action_link_tab.set_state(&request_var); + action_link_tab.set_enabled(!request_str.is_empty()); + + // Copy link to the clipboard + action_link_copy_url.set_state(&request_var); + action_link_copy_url.set_enabled(!request_str.is_empty()); + + // Copy link text + { + let mut start_iter = iter; + let mut end_iter = iter; + if !start_iter.starts_tag(Some(&tag)) { + start_iter.backward_to_tag_toggle(Some(&tag)); } - // Scheme not supported, delegate - _ => UriLauncher::new(&uri.to_str()).launch( - Window::NONE, - Cancellable::NONE, - |result| { - if let Err(e) = result { - println!("{e}") - } - }, - ), - }; // @TODO common handler? + if !end_iter.ends_tag(Some(&tag)) { + end_iter.forward_to_tag_toggle(Some(&tag)); + } + let tagged_text = text_view + .buffer() + .text(&start_iter, &end_iter, false) + .replace(LINK_EXTERNAL_INDICATOR, "") + .trim() + .to_string(); + + action_link_copy_text.set_state(&tagged_text.to_variant()); + action_link_copy_text.set_enabled(!tagged_text.is_empty()); + } + + // Copy link text (if) selected + action_link_copy_text_selected.set_enabled( + if let Some((start, end)) = buffer.selection_bounds() { + let selected = buffer.text(&start, &end, false); + action_link_copy_text_selected + .set_state(&selected.to_variant()); + !selected.is_empty() + } else { + false + }, + ); + + // Bookmark + action_link_bookmark.set_state(&request_var); + action_link_bookmark.set_enabled(is_prefix_link); + + // Download (new tab) + action_link_download.set_state(&request_var); + action_link_download.set_enabled(is_prefix_link); + + // View as Source (new tab) + action_link_source.set_state(&request_var); + action_link_source.set_enabled(is_prefix_link); + + // Toggle + link_context + .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); + link_context.popup() } } } @@ -350,30 +562,7 @@ impl Gemini { for tag in iter.tags() { // Tag is link if let Some(uri) = links.get(&tag) { - // Select link handler by scheme - return match uri.scheme().as_str() { - "gemini" | "titan" | "nex" | "file" => { - // Open new page in browser - window_action.append.activate_stateful_once( - Position::After, - Some(uri.to_string()), - false, - false, - true, - true, - ); - } - // Scheme not supported, delegate - _ => UriLauncher::new(&uri.to_str()).launch( - Window::NONE, - Cancellable::NONE, - |result| { - if let Err(e) = result { - println!("{e}") - } - }, - ), - }; // @TODO common handler? + return open_link_in_new_tab(&uri.to_string(), &window_action); } } } @@ -432,3 +621,59 @@ impl Gemini { } } } + +fn is_internal_link(request: &str) -> bool { + // schemes + request.starts_with("gemini://") + || request.starts_with("titan://") + || request.starts_with("nex://") + || request.starts_with("file://") + // prefix + || request.starts_with("download:") + || request.starts_with("source:") +} + +fn is_prefix_link(request: &str) -> bool { + request.starts_with("gemini://") + || request.starts_with("nex://") + || request.starts_with("file://") +} + +fn open_link_in_external_app(request: &str) { + UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| { + if let Err(e) = r { + println!("{e}") // @TODO use warn macro + } + }) +} + +fn open_link_in_current_tab(request: &str, item_action: &ItemAction) { + if is_internal_link(request) { + item_action.load.activate(Some(request), true, false) + } else { + open_link_in_external_app(request) + } +} + +fn open_link_in_new_tab(request: &str, window_action: &WindowAction) { + if is_internal_link(request) { + window_action.append.activate_stateful_once( + Position::After, + Some(request.into()), + false, + false, + true, + true, + ); + } else { + open_link_in_external_app(request) + } +} + +fn link_prefix(request: String, prefix: &str) -> String { + format!("{prefix}{}", request.trim_start_matches(prefix)) +} + +const LINK_EXTERNAL_INDICATOR: &str = "⇖"; +const LINK_PREFIX_DOWNLOAD: &str = "download:"; +const LINK_PREFIX_SOURCE: &str = "source:"; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown.rs b/src/app/browser/window/tab/item/page/content/text/markdown.rs new file mode 100644 index 00000000..e845bc0b --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -0,0 +1,610 @@ +mod gutter; +mod tags; + +use super::{ItemAction, WindowAction}; +use crate::app::browser::window::{action::Position, tab::item::page::Page}; +use gtk::{ + EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextTag, TextTagTable, TextView, + TextWindowType, UriLauncher, Window, WrapMode, + gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA}, + gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup}, + glib::{ControlFlow, GString, Uri, idle_add_local, uuid_string_random}, + prelude::{EditableExt, PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, +}; +use gutter::Gutter; +use regex::Regex; +use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; +use std::{cell::Cell, collections::HashMap, rc::Rc}; +use strip_tags::*; +use tags::Tags; + +pub struct Markdown { + pub title: Option, + pub text_view: TextView, +} + +impl Markdown { + // Constructors + + /// Build new `Self` + pub fn build( + (window_action, item_action): (&Rc, &Rc), + page: &Rc, + base: &Uri, + markdown: &str, + ) -> Self { + // Init HashMap storage (for event controllers) + let mut links: HashMap = HashMap::new(); + let mut headers: HashMap = HashMap::new(); + + // Init hovered tag storage for `links` + // * maybe less expensive than update entire HashMap by iter + let hover: Rc>> = Rc::new(Cell::new(None)); + + // Init colors + // @TODO use accent colors in adw 1.6 / ubuntu 24.10+ + let link_color = ( + RGBA::new(0.208, 0.518, 0.894, 1.0), + RGBA::new(0.208, 0.518, 0.894, 0.9), + ); + + // Init tags + let mut tags = Tags::new(); + + // Init new text buffer + let buffer = TextBuffer::new(Some(&TextTagTable::new())); + buffer.set_text( + Regex::new(r"\n{3,}") + .unwrap() + .replace_all(&strip_tags(markdown), "\n\n") + .trim(), + ); // @TODO extract `` tags? + + // Init main widget + let text_view = { + const MARGIN: i32 = 8; + TextView::builder() + .bottom_margin(MARGIN) + .buffer(&buffer) + .cursor_visible(false) + .editable(false) + .left_margin(MARGIN) + .right_margin(MARGIN) + .top_margin(MARGIN) + .vexpand(true) + .wrap_mode(WrapMode::Word) + .build() + }; + + // Init gutter widget (the tooltip on URL tags hover) + let gutter = Gutter::build(&text_view); + + // Render markdown tags + let title = tags.render(&text_view, base, &link_color.0, &mut links, &mut headers); + + // Headers context menu (fragment capture) + let action_header_copy_url = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_header_copy_url.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_header_copy_text = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_header_copy_text.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_header_copy_text_selected = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_header_copy_text_selected.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let header_context_group_id = uuid_string_random(); + text_view.insert_action_group( + &header_context_group_id, + Some(&{ + let g = SimpleActionGroup::new(); + g.add_action(&action_header_copy_url); + g.add_action(&action_header_copy_text); + g.add_action(&action_header_copy_text_selected); + g + }), + ); + let header_context = PopoverMenu::from_model(Some(&{ + let m = Menu::new(); + m.append( + Some("Copy Header Link"), + Some(&format!( + "{header_context_group_id}.{}", + action_header_copy_url.name() + )), + ); + m.append( + Some("Copy Header Text"), + Some(&format!( + "{header_context_group_id}.{}", + action_header_copy_text.name() + )), + ); + m.append( + Some("Copy Text Selected"), + Some(&format!( + "{header_context_group_id}.{}", + action_header_copy_text_selected.name() + )), + ); + m + })); + header_context.set_parent(&text_view); + + // Link context menu + let action_link_tab = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_tab.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &this.state().unwrap().get::().unwrap(), + &window_action, + ) + } + }); + let action_link_copy_url = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy_url.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_link_copy_text = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy_text.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_link_copy_text_selected = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy_text_selected.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_link_bookmark = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_bookmark.connect_activate({ + let p = page.profile.clone(); + move |this, _| { + let state = this.state().unwrap().get::().unwrap(); + p.bookmark.toggle(&state, None).unwrap(); + } + }); + let action_link_download = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_download.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &link_prefix( + this.state().unwrap().get::().unwrap(), + LINK_PREFIX_DOWNLOAD, + ), + &window_action, + ) + } + }); + let action_link_source = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_source.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &link_prefix( + this.state().unwrap().get::().unwrap(), + LINK_PREFIX_SOURCE, + ), + &window_action, + ) + } + }); + let link_context_group_id = uuid_string_random(); + text_view.insert_action_group( + &link_context_group_id, + Some(&{ + let g = SimpleActionGroup::new(); + g.add_action(&action_link_tab); + g.add_action(&action_link_copy_url); + g.add_action(&action_link_copy_text); + g.add_action(&action_link_copy_text_selected); + g.add_action(&action_link_bookmark); + g.add_action(&action_link_download); + g.add_action(&action_link_source); + g + }), + ); + let link_context = PopoverMenu::from_model(Some(&{ + let m = Menu::new(); + m.append( + Some("Open Link in New Tab"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_tab.name() + )), + ); + m.append_section(None, &{ + let m_copy = Menu::new(); + m_copy.append( + Some("Copy Link URL"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_copy_url.name() + )), + ); + m_copy.append( + Some("Copy Link Text"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_copy_text.name() + )), + ); + m_copy.append( + Some("Copy Text Selected"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_copy_text_selected.name() + )), + ); + m_copy + }); + m.append_section(None, &{ + let m_other = Menu::new(); + m_other.append( + Some("Bookmark Link"), // @TODO highlight state + Some(&format!( + "{link_context_group_id}.{}", + action_link_bookmark.name() + )), + ); + m_other.append( + Some("Download Link"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_download.name() + )), + ); + m_other.append( + Some("View Link as Source"), + Some(&format!( + "{link_context_group_id}.{}", + action_link_source.name() + )), + ); + m_other + }); + m + })); + link_context.set_parent(&text_view); + + // Init additional controllers + let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build(); + let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build(); + let secondary_button_controller = GestureClick::builder() + .button(BUTTON_SECONDARY) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let motion_controller = EventControllerMotion::new(); + + text_view.add_controller(middle_button_controller.clone()); + text_view.add_controller(motion_controller.clone()); + text_view.add_controller(primary_button_controller.clone()); + text_view.add_controller(secondary_button_controller.clone()); + + // Init shared reference container for HashTable collected + let links = Rc::new(links); + let headers = Rc::new(headers); + + // Init events + primary_button_controller.connect_released({ + let headers = headers.clone(); + let item_action = item_action.clone(); + let links = links.clone(); + let page = page.clone(); + let text_view = text_view.clone(); + move |_, _, window_x, window_y| { + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = text_view.window_to_buffer_coords( + TextWindowType::Widget, + window_x as i32, + window_y as i32, + ); + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + return if let Some(fragment) = uri.fragment() { + scroll_to_anchor(&page, &text_view, &headers, fragment); + } else { + open_link_in_current_tab(&uri.to_string(), &item_action); + }; + } + } + } + } + }); + + secondary_button_controller.connect_pressed({ + let headers = headers.clone(); + let link_context = link_context.clone(); + let links = links.clone(); + let text_view = text_view.clone(); + move |_, _, window_x, window_y| { + let x = window_x as i32; + let y = window_y as i32; + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = + text_view.window_to_buffer_coords(TextWindowType::Widget, x, y); + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + let request_str = uri.to_str(); + let request_var = request_str.to_variant(); + let is_prefix_link = is_prefix_link(&request_str); + + // Open in the new tab + action_link_tab.set_state(&request_var); + action_link_tab.set_enabled(!request_str.is_empty()); + + // Copy link to the clipboard + action_link_copy_url.set_state(&request_var); + action_link_copy_url.set_enabled(!request_str.is_empty()); + + // Copy link text + { + let mut start_iter = iter; + let mut end_iter = iter; + if !start_iter.starts_tag(Some(&tag)) { + start_iter.backward_to_tag_toggle(Some(&tag)); + } + if !end_iter.ends_tag(Some(&tag)) { + end_iter.forward_to_tag_toggle(Some(&tag)); + } + let tagged_text = text_view + .buffer() + .text(&start_iter, &end_iter, false) + .replace(LINK_EXTERNAL_INDICATOR, "") + .trim() + .to_string(); + + action_link_copy_text.set_state(&tagged_text.to_variant()); + action_link_copy_text.set_enabled(!tagged_text.is_empty()); + } + + // Copy link text (if) selected + action_link_copy_text_selected.set_enabled( + if let Some((start, end)) = buffer.selection_bounds() { + let selected = buffer.text(&start, &end, false); + action_link_copy_text_selected + .set_state(&selected.to_variant()); + !selected.is_empty() + } else { + false + }, + ); + + // Bookmark + action_link_bookmark.set_state(&request_var); + action_link_bookmark.set_enabled(is_prefix_link); + + // Download (new tab) + action_link_download.set_state(&request_var); + action_link_download.set_enabled(is_prefix_link); + + // View as Source (new tab) + action_link_source.set_state(&request_var); + action_link_source.set_enabled(is_prefix_link); + + // Toggle + link_context + .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); + link_context.popup() + } + // Tag is header + if let Some((title, uri)) = headers.get(&tag) { + let request_str = uri.to_str(); + let request_var = request_str.to_variant(); + + // Copy link to the clipboard + action_header_copy_url.set_state(&request_var); + action_header_copy_url.set_enabled(!request_str.is_empty()); + + // Copy header text + action_header_copy_text.set_state(&title.to_variant()); + action_header_copy_text.set_enabled(!title.is_empty()); + + // Copy header text (if) selected + action_header_copy_text_selected.set_enabled( + if let Some((start, end)) = buffer.selection_bounds() { + let selected = buffer.text(&start, &end, false); + action_header_copy_text_selected + .set_state(&selected.to_variant()); + !selected.is_empty() + } else { + false + }, + ); + + // Toggle + header_context + .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); + header_context.popup() + } + } + } + } + }); + + middle_button_controller.connect_pressed({ + let links = links.clone(); + let text_view = text_view.clone(); + let window_action = window_action.clone(); + move |_, _, window_x, window_y| { + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = text_view.window_to_buffer_coords( + TextWindowType::Widget, + window_x as i32, + window_y as i32, + ); + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + return open_link_in_new_tab(&uri.to_string(), &window_action); + } + } + } + } + }); // for a note: this action sensitive to focus out + + motion_controller.connect_motion({ + let text_view = text_view.clone(); + let links = links.clone(); + let hover = hover.clone(); + move |_, window_x, window_y| { + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = text_view.window_to_buffer_coords( + TextWindowType::Widget, + window_x as i32, + window_y as i32, + ); + // Reset link colors to default + if let Some(tag) = hover.replace(None) { + tag.set_foreground_rgba(Some(&link_color.0)); + } + // Apply hover effect + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + // Toggle color + tag.set_foreground_rgba(Some(&link_color.1)); + // Keep hovered tag in memory + hover.replace(Some(tag.clone())); + // Show tooltip + gutter.set_uri(Some(uri)); + // Toggle cursor + text_view.set_cursor_from_name(Some("pointer")); + // Redraw required to apply changes immediately + text_view.queue_draw(); + return; + } + } + } + // Restore defaults + gutter.set_uri(None); + text_view.set_cursor_from_name(Some("text")); + text_view.queue_draw(); + } + }); // @TODO may be expensive for CPU, add timeout? + + // Anchor auto-scroll behavior + idle_add_local({ + let base = base.clone(); + let page = page.clone(); + let text_view = text_view.clone(); + move || { + if let Some(fragment) = base.fragment() { + scroll_to_anchor(&page, &text_view, &headers, fragment); + } + ControlFlow::Break + } + }); + + Self { text_view, title } + } +} + +fn scroll_to_anchor( + page: &Rc, + text_view: &TextView, + headers: &HashMap, + fragment: GString, +) { + if let Some((tag, (_, uri))) = headers.iter().find(|(_, (_, uri))| { + uri.fragment() + .is_some_and(|f| fragment == tags::format_header_fragment(&f)) + }) { + let mut iter = text_view.buffer().start_iter(); + if iter.starts_tag(Some(tag)) || iter.forward_to_tag_toggle(Some(tag)) { + text_view.scroll_to_iter(&mut iter, 0.0, true, 0.0, 0.0); + } + page.navigation.request.entry.set_text(&uri.to_string()) + } +} + +fn is_internal_link(request: &str) -> bool { + // schemes + request.starts_with("gemini://") + || request.starts_with("titan://") + || request.starts_with("nex://") + || request.starts_with("file://") + // prefix + || request.starts_with("download:") + || request.starts_with("source:") +} + +fn is_prefix_link(request: &str) -> bool { + request.starts_with("gemini://") + || request.starts_with("nex://") + || request.starts_with("file://") +} + +fn open_link_in_external_app(request: &str) { + UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| { + if let Err(e) = r { + println!("{e}") // @TODO use warn macro + } + }) +} + +fn open_link_in_current_tab(request: &str, item_action: &ItemAction) { + if is_internal_link(request) { + item_action.load.activate(Some(request), true, false) + } else { + open_link_in_external_app(request) + } +} + +fn open_link_in_new_tab(request: &str, window_action: &WindowAction) { + if is_internal_link(request) { + window_action.append.activate_stateful_once( + Position::After, + Some(request.into()), + false, + false, + true, + true, + ); + } else { + open_link_in_external_app(request) + } +} + +fn link_prefix(request: String, prefix: &str) -> String { + format!("{prefix}{}", request.trim_start_matches(prefix)) +} + +const LINK_EXTERNAL_INDICATOR: &str = "⇖"; +const LINK_PREFIX_DOWNLOAD: &str = "download:"; +const LINK_PREFIX_SOURCE: &str = "source:"; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/gutter.rs b/src/app/browser/window/tab/item/page/content/text/markdown/gutter.rs new file mode 100644 index 00000000..6a558ef2 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/gutter.rs @@ -0,0 +1,68 @@ +use gtk::{ + Align, Label, TextView, TextWindowType, + glib::{Uri, timeout_add_local_once}, + pango::EllipsizeMode, + prelude::{TextViewExt, WidgetExt}, +}; +use std::{cell::Cell, rc::Rc, time::Duration}; + +pub struct Gutter { + pub label: Label, + is_active: Rc>, +} + +impl Gutter { + pub fn build(text_view: &TextView) -> Self { + const MARGIN_X: i32 = 8; + const MARGIN_Y: i32 = 2; + let label = Label::builder() + .css_classes(["caption", "dim-label"]) + .ellipsize(EllipsizeMode::Middle) + .halign(Align::Start) + .margin_bottom(MARGIN_Y) + .margin_end(MARGIN_X) + .margin_start(MARGIN_X) + .margin_top(MARGIN_Y) + .visible(false) + .build(); + + text_view.set_gutter(TextWindowType::Bottom, Some(&label)); + text_view + .gutter(TextWindowType::Bottom) + .unwrap() + .set_css_classes(&["view"]); // @TODO unspecified patch + + Self { + is_active: Rc::new(Cell::new(false)), + label, + } + } + + pub fn set_uri(&self, uri: Option<&Uri>) { + match uri { + Some(uri) => { + if !self.label.is_visible() { + if !self.is_active.replace(true) { + timeout_add_local_once(Duration::from_millis(250), { + let label = self.label.clone(); + let is_active = self.is_active.clone(); + let uri = uri.clone(); + move || { + if is_active.replace(false) { + label.set_label(&uri.to_string()); + label.set_visible(true) + } + } + }); + } + } else { + self.label.set_label(&uri.to_string()) + } + } + None => { + self.is_active.replace(false); + self.label.set_visible(false) + } + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/parser.rs b/src/app/browser/window/tab/item/page/content/text/markdown/parser.rs new file mode 100644 index 00000000..fd708509 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/parser.rs @@ -0,0 +1,5 @@ +pub mod code; +pub mod header; +pub mod link; +pub mod list; +pub mod quote; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs new file mode 100644 index 00000000..ece2ccf5 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs @@ -0,0 +1,139 @@ +mod bold; +mod code; +mod header; +mod hr; +mod italic; +mod list; +mod pre; +mod quote; +mod reference; +mod strike; +mod underline; + +use bold::Bold; +use code::Code; +use gtk::{ + TextSearchFlags, TextTag, TextView, + gdk::RGBA, + glib::{GString, Uri}, + prelude::{TextBufferExt, TextViewExt}, +}; +use header::Header; +use italic::Italic; +use pre::Pre; +use quote::Quote; +use std::collections::HashMap; +use strike::Strike; +use underline::Underline; + +pub struct Tags { + pub bold: Bold, + pub code: Code, + pub header: Header, + pub italic: Italic, + pub pre: Pre, + pub quote: Quote, + pub strike: Strike, + pub underline: Underline, +} + +impl Default for Tags { + fn default() -> Self { + Self::new() + } +} + +impl Tags { + // Construct + pub fn new() -> Self { + Self { + bold: Bold::new(), + code: Code::new(), + header: Header::new(), + italic: Italic::new(), + pre: Pre::new(), + quote: Quote::new(), + strike: Strike::new(), + underline: Underline::new(), + } + } + pub fn render( + &mut self, + text_view: &TextView, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, + headers: &mut HashMap, + ) -> Option { + let buffer = text_view.buffer(); + + // Collect all code blocks first, + // and temporarily replace them with placeholder ID + self.code.collect(&buffer); + + // Keep in order! + let title = self.header.render(&buffer, base, headers); + + list::render(&buffer); + + self.quote.render(&buffer); + + self.bold.render(&buffer); + self.italic.render(&buffer); + self.pre.render(&buffer); + self.strike.render(&buffer); + self.underline.render(&buffer); + + reference::render(&buffer, base, link_color, links); + hr::render(text_view); + + // Cleanup unformatted escape chars + for e in ESCAPE_ENTRIES { + let mut cursor = buffer.start_iter(); + while let Some((mut match_start, mut match_end)) = + cursor.forward_search(e, TextSearchFlags::CASE_INSENSITIVE, None) + { + if match_end.backward_cursor_positions(1) { + buffer.delete(&mut match_start, &mut match_end) + } + cursor = match_end; + } + } + + // Render placeholders + self.code.render(&buffer); + + // Format document title string + title.map(|mut s| { + s = bold::strip_tags(&s); + s = hr::strip_tags(&s); + s = italic::strip_tags(&s); + s = pre::strip_tags(&s); + s = reference::strip_tags(&s); + s = strike::strip_tags(&s); + s = underline::strip_tags(&s); + for e in ESCAPE_ENTRIES { + s = s.replace(e, &e[1..]); + } + s + }) + } +} + +/// Shared URL #fragment logic (for the Header tags ref) +pub fn format_header_fragment(value: &str) -> GString { + Uri::escape_string(&value.to_lowercase().replace(" ", "-"), None, true) +} + +const ESCAPE_ENTRIES: &[&str] = &[ + "\\\n", "\\\\", "\\>", "\\`", "\\!", "\\[", "\\]", "\\(", "\\)", "\\*", "\\#", "\\~", "\\_", + "\\-", +]; +#[test] +fn test_escape_entries() { + let mut set = std::collections::HashSet::new(); + for e in ESCAPE_ENTRIES { + assert_eq!(e.len(), 2); + assert!(set.insert(*e)) + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/bold.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/bold.rs new file mode 100644 index 00000000..013f930a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/bold.rs @@ -0,0 +1,104 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_BOLD: &str = r"(\*\*|__)(?P[^\*_]*)(\*\*|__)"; + +pub struct Bold(TextTag); + +impl Bold { + pub fn new() -> Self { + Self(TextTag::builder().weight(600).wrap_mode(Word).build()) + } + + /// Apply **bold**/__bold__ `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_BOLD) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + let mut tags = start_iter.tags(); + tags.push(self.0.clone()); + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::>(), + ) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = + "Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!( + result, + "Some bold 1 and bold 2 and bold 3 and *italic 1* and _italic 2_" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_BOLD) + .unwrap() + .captures_iter( + "Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_", + ) + .collect(); + + assert_eq!(cap.len(), 3); + + let mut c = cap.into_iter(); + assert_eq!(&c.next().unwrap()["text"], "bold 1"); + assert_eq!(&c.next().unwrap()["text"], "bold 2"); + assert_eq!(&c.next().unwrap()["text"], "bold 3"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code.rs new file mode 100644 index 00000000..5d79041f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code.rs @@ -0,0 +1,128 @@ +mod ansi; +mod syntax; + +use gtk::{ + TextBuffer, TextSearchFlags, TextTag, WrapMode, + glib::{GString, uuid_string_random}, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; +use syntax::Syntax; + +const REGEX_CODE: &str = r"(?s)```[ \t]*(?P.*?)\n(?P.*?)```"; + +struct Entry { + alt: Option, + data: String, +} + +pub struct Code { + index: HashMap, + alt: TextTag, +} + +impl Code { + pub fn new() -> Self { + Self { + index: HashMap::new(), + alt: TextTag::builder() + .pixels_above_lines(4) + .pixels_below_lines(8) + .weight(500) + .wrap_mode(WrapMode::None) + .build(), + } + } + + /// Collect all code blocks into `Self.index` (to prevent formatting) + pub fn collect(&mut self, buffer: &TextBuffer) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_CODE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let id = uuid_string_random(); + + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + + buffer.insert_with_tags(&mut start_iter, &id, &[]); + assert!( + self.index + .insert( + id, + Entry { + alt: alt(cap["alt"].into()).map(|s| s.into()), + data: cap["data"].into(), + }, + ) + .is_none() + ) + } + } + + /// Apply code `Tag` to given `TextBuffer` using `Self.index` + pub fn render(&mut self, buffer: &TextBuffer) { + let syntax = Syntax::new(); + assert!(buffer.tag_table().add(&self.alt)); + for (k, v) in self.index.iter() { + while let Some((mut m_start, mut m_end)) = + buffer + .start_iter() + .forward_search(k, TextSearchFlags::VISIBLE_ONLY, None) + { + buffer.delete(&mut m_start, &mut m_end); + if let Some(ref alt) = v.alt { + buffer.insert_with_tags(&mut m_start, &format!("{alt}\n"), &[&self.alt]) + } + match syntax.highlight(&v.data, v.alt.as_ref()) { + Ok(highlight) => { + for (syntax_tag, entity) in highlight { + assert!(buffer.tag_table().add(&syntax_tag)); + buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag]) + } + } + Err(_) => { + // Try ANSI/SGR format (terminal emulation) @TODO optional + for (syntax_tag, entity) in ansi::format(&v.data) { + assert!(buffer.tag_table().add(&syntax_tag)); + buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag]) + } + } + } + } + } + } +} + +fn alt(value: Option<&str>) -> Option<&str> { + value.map(|m| m.trim()).filter(|s| !s.is_empty()) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_CODE) + .unwrap() + .captures_iter("Some ``` alt text\ncode line 1\ncode line 2``` and ```\ncode line 3\ncode line 4``` with ![img](https://link.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(alt(first.name("alt").map(|m| m.as_str())), Some("alt text")); + assert_eq!(&first["data"], "code line 1\ncode line 2"); + + let second = cap.get(1).unwrap(); + assert_eq!(alt(second.name("alt").map(|m| m.as_str())), None); + assert_eq!(&second["data"], "code line 3\ncode line 4"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs new file mode 100644 index 00000000..b617b69a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs @@ -0,0 +1,33 @@ +mod rgba; +mod tag; + +use tag::Tag; + +use ansi_parser::{AnsiParser, AnsiSequence, Output}; +use gtk::{TextTag, prelude::TextTagExt}; + +/// Apply ANSI/SGR format to new buffer +pub fn format(source_code: &str) -> Vec<(TextTag, String)> { + let mut buffer = Vec::new(); + let mut tag = Tag::new(); + + for ref entity in source_code.ansi_parse() { + if let Output::Escape(AnsiSequence::SetGraphicsMode(color)) = entity + && color.len() > 1 + { + if color[0] == 38 { + tag.text_tag + .set_foreground_rgba(rgba::default(*color.last().unwrap()).as_ref()); + } else { + tag.text_tag + .set_background_rgba(rgba::default(*color.last().unwrap()).as_ref()); + } + } + if let Output::TextBlock(text) = entity { + buffer.push((tag.text_tag, text.to_string())); + tag = Tag::new(); + } + } + + buffer +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs new file mode 100644 index 00000000..d1398d2f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs @@ -0,0 +1,256 @@ +use gtk::gdk::RGBA; + +/// Default RGBa palette for ANSI terminal emulation +pub fn default(color: u8) -> Option { + match color { + 7 => Some(RGBA::new(0.854, 0.854, 0.854, 1.0)), + 8 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)), + 10 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)), + 11 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)), + 12 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)), + 13 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)), + 14 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)), + 15 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)), + 16 => Some(RGBA::new(0.0, 0.0, 0.0, 1.0)), + 17 => Some(RGBA::new(0.0, 0.020, 0.373, 1.0)), + 18 => Some(RGBA::new(0.0, 0.031, 0.529, 1.0)), + 19 => Some(RGBA::new(0.0, 0.0, 0.686, 1.0)), + 20 => Some(RGBA::new(0.0, 0.0, 0.823, 1.0)), + 21 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)), + 22 => Some(RGBA::new(0.0, 0.373, 0.0, 1.0)), + 23 => Some(RGBA::new(0.0, 0.373, 0.373, 1.0)), + 24 => Some(RGBA::new(0.0, 0.373, 0.529, 1.0)), + 25 => Some(RGBA::new(0.0, 0.373, 0.686, 1.0)), + 26 => Some(RGBA::new(0.0, 0.373, 0.823, 1.0)), + 27 => Some(RGBA::new(0.0, 0.373, 1.0, 1.0)), + 28 => Some(RGBA::new(0.0, 0.533, 0.0, 1.0)), + 29 => Some(RGBA::new(0.0, 0.533, 0.373, 1.0)), + 30 => Some(RGBA::new(0.0, 0.533, 0.533, 1.0)), + 31 => Some(RGBA::new(0.0, 0.533, 0.686, 1.0)), + 32 => Some(RGBA::new(0.0, 0.533, 0.823, 1.0)), + 33 => Some(RGBA::new(0.0, 0.533, 1.0, 1.0)), + 34 => Some(RGBA::new(0.039, 0.686, 0.0, 1.0)), + 35 => Some(RGBA::new(0.039, 0.686, 0.373, 1.0)), + 36 => Some(RGBA::new(0.039, 0.686, 0.529, 1.0)), + 37 => Some(RGBA::new(0.039, 0.686, 0.686, 1.0)), + 38 => Some(RGBA::new(0.039, 0.686, 0.823, 1.0)), + 39 => Some(RGBA::new(0.039, 0.686, 1.0, 1.0)), + 40 => Some(RGBA::new(0.0, 0.843, 0.0, 1.0)), + 41 => Some(RGBA::new(0.0, 0.843, 0.373, 1.0)), + 42 => Some(RGBA::new(0.0, 0.843, 0.529, 1.0)), + 43 => Some(RGBA::new(0.0, 0.843, 0.686, 1.0)), + 44 => Some(RGBA::new(0.0, 0.843, 0.843, 1.0)), + 45 => Some(RGBA::new(0.0, 0.843, 1.0, 1.0)), + 46 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)), + 47 => Some(RGBA::new(0.0, 1.0, 0.373, 1.0)), + 48 => Some(RGBA::new(0.0, 1.0, 0.529, 1.0)), + 49 => Some(RGBA::new(0.0, 1.0, 0.686, 1.0)), + 50 => Some(RGBA::new(0.0, 1.0, 0.843, 1.0)), + 51 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)), + 52 => Some(RGBA::new(0.373, 0.0, 0.0, 1.0)), + 53 => Some(RGBA::new(0.373, 0.0, 0.373, 1.0)), + 54 => Some(RGBA::new(0.373, 0.0, 0.529, 1.0)), + 55 => Some(RGBA::new(0.373, 0.0, 0.686, 1.0)), + 56 => Some(RGBA::new(0.373, 0.0, 0.843, 1.0)), + 57 => Some(RGBA::new(0.373, 0.0, 1.0, 1.0)), + 58 => Some(RGBA::new(0.373, 0.373, 0.0, 1.0)), + 59 => Some(RGBA::new(0.373, 0.373, 0.373, 1.0)), + 60 => Some(RGBA::new(0.373, 0.373, 0.529, 1.0)), + 61 => Some(RGBA::new(0.373, 0.373, 0.686, 1.0)), + 62 => Some(RGBA::new(0.373, 0.373, 0.843, 1.0)), + 63 => Some(RGBA::new(0.373, 0.373, 1.0, 1.0)), + 64 => Some(RGBA::new(0.373, 0.529, 0.0, 1.0)), + 65 => Some(RGBA::new(0.373, 0.529, 0.373, 1.0)), + 66 => Some(RGBA::new(0.373, 0.529, 0.529, 1.0)), + 67 => Some(RGBA::new(0.373, 0.529, 0.686, 1.0)), + 68 => Some(RGBA::new(0.373, 0.529, 0.843, 1.0)), + 69 => Some(RGBA::new(0.373, 0.529, 1.0, 1.0)), + 70 => Some(RGBA::new(0.373, 0.686, 0.0, 1.0)), + 71 => Some(RGBA::new(0.373, 0.686, 0.373, 1.0)), + 72 => Some(RGBA::new(0.373, 0.686, 0.529, 1.0)), + 73 => Some(RGBA::new(0.373, 0.686, 0.686, 1.0)), + 74 => Some(RGBA::new(0.373, 0.686, 0.843, 1.0)), + 75 => Some(RGBA::new(0.373, 0.686, 1.0, 1.0)), + 76 => Some(RGBA::new(0.373, 0.843, 0.0, 1.0)), + 77 => Some(RGBA::new(0.373, 0.843, 0.373, 1.0)), + 78 => Some(RGBA::new(0.373, 0.843, 0.529, 1.0)), + 79 => Some(RGBA::new(0.373, 0.843, 0.686, 1.0)), + 80 => Some(RGBA::new(0.373, 0.843, 0.843, 1.0)), + 81 => Some(RGBA::new(0.373, 0.843, 1.0, 1.0)), + 82 => Some(RGBA::new(0.373, 1.0, 0.0, 1.0)), + 83 => Some(RGBA::new(0.373, 1.0, 0.373, 1.0)), + 84 => Some(RGBA::new(0.373, 1.0, 0.529, 1.0)), + 85 => Some(RGBA::new(0.373, 1.0, 0.686, 1.0)), + 86 => Some(RGBA::new(0.373, 1.0, 0.843, 1.0)), + 87 => Some(RGBA::new(0.373, 1.0, 1.0, 1.0)), + 88 => Some(RGBA::new(0.529, 0.0, 0.0, 1.0)), + 89 => Some(RGBA::new(0.529, 0.0, 0.373, 1.0)), + 90 => Some(RGBA::new(0.529, 0.0, 0.529, 1.0)), + 91 => Some(RGBA::new(0.529, 0.0, 0.686, 1.0)), + 92 => Some(RGBA::new(0.529, 0.0, 0.843, 1.0)), + 93 => Some(RGBA::new(0.529, 0.0, 1.0, 1.0)), + 94 => Some(RGBA::new(0.529, 0.373, 0.0, 1.0)), + 95 => Some(RGBA::new(0.529, 0.373, 0.373, 1.0)), + 96 => Some(RGBA::new(0.529, 0.373, 0.529, 1.0)), + 97 => Some(RGBA::new(0.529, 0.373, 0.686, 1.0)), + 98 => Some(RGBA::new(0.529, 0.373, 0.843, 1.0)), + 99 => Some(RGBA::new(0.529, 0.373, 1.0, 1.0)), + 100 => Some(RGBA::new(0.529, 0.529, 0.0, 1.0)), + 101 => Some(RGBA::new(0.529, 0.529, 0.373, 1.0)), + 102 => Some(RGBA::new(0.529, 0.529, 0.529, 1.0)), + 103 => Some(RGBA::new(0.529, 0.529, 0.686, 1.0)), + 104 => Some(RGBA::new(0.529, 0.529, 0.843, 1.0)), + 105 => Some(RGBA::new(0.529, 0.529, 1.0, 1.0)), + 106 => Some(RGBA::new(0.533, 0.686, 0.0, 1.0)), + 107 => Some(RGBA::new(0.533, 0.686, 0.373, 1.0)), + 108 => Some(RGBA::new(0.533, 0.686, 0.529, 1.0)), + 109 => Some(RGBA::new(0.533, 0.686, 0.686, 1.0)), + 110 => Some(RGBA::new(0.533, 0.686, 0.843, 1.0)), + 111 => Some(RGBA::new(0.533, 0.686, 1.0, 1.0)), + 112 => Some(RGBA::new(0.533, 0.843, 0.0, 1.0)), + 113 => Some(RGBA::new(0.533, 0.843, 0.373, 1.0)), + 114 => Some(RGBA::new(0.533, 0.843, 0.529, 1.0)), + 115 => Some(RGBA::new(0.533, 0.843, 0.686, 1.0)), + 116 => Some(RGBA::new(0.533, 0.843, 0.843, 1.0)), + 117 => Some(RGBA::new(0.533, 0.843, 1.0, 1.0)), + 118 => Some(RGBA::new(0.533, 1.0, 0.0, 1.0)), + 119 => Some(RGBA::new(0.533, 1.0, 0.373, 1.0)), + 120 => Some(RGBA::new(0.533, 1.0, 0.529, 1.0)), + 121 => Some(RGBA::new(0.533, 1.0, 0.686, 1.0)), + 122 => Some(RGBA::new(0.533, 1.0, 0.843, 1.0)), + 123 => Some(RGBA::new(0.533, 1.0, 1.0, 1.0)), + 124 => Some(RGBA::new(0.686, 0.0, 0.0, 1.0)), + 125 => Some(RGBA::new(0.686, 0.0, 0.373, 1.0)), + 126 => Some(RGBA::new(0.686, 0.0, 0.529, 1.0)), + 127 => Some(RGBA::new(0.686, 0.0, 0.686, 1.0)), + 128 => Some(RGBA::new(0.686, 0.0, 0.843, 1.0)), + 129 => Some(RGBA::new(0.686, 0.0, 1.0, 1.0)), + 130 => Some(RGBA::new(0.686, 0.373, 0.0, 1.0)), + 131 => Some(RGBA::new(0.686, 0.373, 0.373, 1.0)), + 132 => Some(RGBA::new(0.686, 0.373, 0.529, 1.0)), + 133 => Some(RGBA::new(0.686, 0.373, 0.686, 1.0)), + 134 => Some(RGBA::new(0.686, 0.373, 0.843, 1.0)), + 135 => Some(RGBA::new(0.686, 0.373, 1.0, 1.0)), + 136 => Some(RGBA::new(0.686, 0.529, 0.0, 1.0)), + 137 => Some(RGBA::new(0.686, 0.529, 0.373, 1.0)), + 138 => Some(RGBA::new(0.686, 0.529, 0.529, 1.0)), + 139 => Some(RGBA::new(0.686, 0.529, 0.686, 1.0)), + 140 => Some(RGBA::new(0.686, 0.529, 0.843, 1.0)), + 141 => Some(RGBA::new(0.686, 0.529, 1.0, 1.0)), + 142 => Some(RGBA::new(0.686, 0.686, 0.0, 1.0)), + 143 => Some(RGBA::new(0.686, 0.686, 0.373, 1.0)), + 144 => Some(RGBA::new(0.686, 0.686, 0.529, 1.0)), + 145 => Some(RGBA::new(0.686, 0.686, 0.686, 1.0)), + 146 => Some(RGBA::new(0.686, 0.686, 0.843, 1.0)), + 147 => Some(RGBA::new(0.686, 0.686, 1.0, 1.0)), + 148 => Some(RGBA::new(0.686, 0.843, 0.0, 1.0)), + 149 => Some(RGBA::new(0.686, 0.843, 0.373, 1.0)), + 150 => Some(RGBA::new(0.686, 0.843, 0.529, 1.0)), + 151 => Some(RGBA::new(0.686, 0.843, 0.686, 1.0)), + 152 => Some(RGBA::new(0.686, 0.843, 0.843, 1.0)), + 153 => Some(RGBA::new(0.686, 0.843, 1.0, 1.0)), + 154 => Some(RGBA::new(0.686, 1.0, 0.0, 1.0)), + 155 => Some(RGBA::new(0.686, 1.0, 0.373, 1.0)), + 156 => Some(RGBA::new(0.686, 1.0, 0.529, 1.0)), + 157 => Some(RGBA::new(0.686, 1.0, 0.686, 1.0)), + 158 => Some(RGBA::new(0.686, 1.0, 0.843, 1.0)), + 159 => Some(RGBA::new(0.686, 1.0, 1.0, 1.0)), + 160 => Some(RGBA::new(0.847, 0.0, 0.0, 1.0)), + 161 => Some(RGBA::new(0.847, 0.0, 0.373, 1.0)), + 162 => Some(RGBA::new(0.847, 0.0, 0.529, 1.0)), + 163 => Some(RGBA::new(0.847, 0.0, 0.686, 1.0)), + 164 => Some(RGBA::new(0.847, 0.0, 0.843, 1.0)), + 165 => Some(RGBA::new(0.847, 0.0, 1.0, 1.0)), + 166 => Some(RGBA::new(0.847, 0.373, 0.0, 1.0)), + 167 => Some(RGBA::new(0.847, 0.373, 0.373, 1.0)), + 168 => Some(RGBA::new(0.847, 0.373, 0.529, 1.0)), + 169 => Some(RGBA::new(0.847, 0.373, 0.686, 1.0)), + 170 => Some(RGBA::new(0.847, 0.373, 0.843, 1.0)), + 171 => Some(RGBA::new(0.847, 0.373, 1.0, 1.0)), + 172 => Some(RGBA::new(0.847, 0.529, 0.0, 1.0)), + 173 => Some(RGBA::new(0.847, 0.529, 0.373, 1.0)), + 174 => Some(RGBA::new(0.847, 0.529, 0.529, 1.0)), + 175 => Some(RGBA::new(0.847, 0.529, 0.686, 1.0)), + 176 => Some(RGBA::new(0.847, 0.529, 0.843, 1.0)), + 177 => Some(RGBA::new(0.847, 0.529, 1.0, 1.0)), + 178 => Some(RGBA::new(0.847, 0.686, 0.0, 1.0)), + 179 => Some(RGBA::new(0.847, 0.686, 0.373, 1.0)), + 180 => Some(RGBA::new(0.847, 0.686, 0.529, 1.0)), + 181 => Some(RGBA::new(0.847, 0.686, 0.686, 1.0)), + 182 => Some(RGBA::new(0.847, 0.686, 0.843, 1.0)), + 183 => Some(RGBA::new(0.847, 0.686, 1.0, 1.0)), + 184 => Some(RGBA::new(0.847, 0.843, 0.0, 1.0)), + 185 => Some(RGBA::new(0.847, 0.843, 0.373, 1.0)), + 186 => Some(RGBA::new(0.847, 0.843, 0.529, 1.0)), + 187 => Some(RGBA::new(0.847, 0.843, 0.686, 1.0)), + 188 => Some(RGBA::new(0.847, 0.843, 0.843, 1.0)), + 189 => Some(RGBA::new(0.847, 0.843, 1.0, 1.0)), + 190 => Some(RGBA::new(0.847, 1.0, 0.0, 1.0)), + 191 => Some(RGBA::new(0.847, 1.0, 0.373, 1.0)), + 192 => Some(RGBA::new(0.847, 1.0, 0.529, 1.0)), + 193 => Some(RGBA::new(0.847, 1.0, 0.686, 1.0)), + 194 => Some(RGBA::new(0.847, 1.0, 0.843, 1.0)), + 195 => Some(RGBA::new(0.847, 1.0, 1.0, 1.0)), + 196 => Some(RGBA::new(1.0, 0.0, 0.0, 1.0)), + 197 => Some(RGBA::new(1.0, 0.0, 0.373, 1.0)), + 198 => Some(RGBA::new(1.0, 0.0, 0.529, 1.0)), + 199 => Some(RGBA::new(1.0, 0.0, 0.686, 1.0)), + 200 => Some(RGBA::new(1.0, 0.0, 0.843, 1.0)), + 201 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)), + 202 => Some(RGBA::new(1.0, 0.373, 0.0, 1.0)), + 203 => Some(RGBA::new(1.0, 0.373, 0.373, 1.0)), + 204 => Some(RGBA::new(1.0, 0.373, 0.529, 1.0)), + 205 => Some(RGBA::new(1.0, 0.373, 0.686, 1.0)), + 206 => Some(RGBA::new(1.0, 0.373, 0.843, 1.0)), + 207 => Some(RGBA::new(1.0, 0.373, 1.0, 1.0)), + 208 => Some(RGBA::new(1.0, 0.529, 0.0, 1.0)), + 209 => Some(RGBA::new(1.0, 0.529, 0.373, 1.0)), + 210 => Some(RGBA::new(1.0, 0.529, 0.529, 1.0)), + 211 => Some(RGBA::new(1.0, 0.529, 0.686, 1.0)), + 212 => Some(RGBA::new(1.0, 0.529, 0.843, 1.0)), + 213 => Some(RGBA::new(1.0, 0.529, 1.0, 1.0)), + 214 => Some(RGBA::new(1.0, 0.686, 0.0, 1.0)), + 215 => Some(RGBA::new(1.0, 0.686, 0.373, 1.0)), + 216 => Some(RGBA::new(1.0, 0.686, 0.529, 1.0)), + 217 => Some(RGBA::new(1.0, 0.686, 0.686, 1.0)), + 218 => Some(RGBA::new(1.0, 0.686, 0.843, 1.0)), + 219 => Some(RGBA::new(1.0, 0.686, 1.0, 1.0)), + 220 => Some(RGBA::new(1.0, 0.843, 0.0, 1.0)), + 221 => Some(RGBA::new(1.0, 0.843, 0.373, 1.0)), + 222 => Some(RGBA::new(1.0, 0.843, 0.529, 1.0)), + 223 => Some(RGBA::new(1.0, 0.843, 0.686, 1.0)), + 224 => Some(RGBA::new(1.0, 0.843, 0.843, 1.0)), + 225 => Some(RGBA::new(1.0, 0.843, 1.0, 1.0)), + 226 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)), + 227 => Some(RGBA::new(1.0, 1.0, 0.373, 1.0)), + 228 => Some(RGBA::new(1.0, 1.0, 0.529, 1.0)), + 229 => Some(RGBA::new(1.0, 1.0, 0.686, 1.0)), + 230 => Some(RGBA::new(1.0, 1.0, 0.843, 1.0)), + 231 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)), + 232 => Some(RGBA::new(0.031, 0.031, 0.031, 1.0)), + 233 => Some(RGBA::new(0.071, 0.071, 0.071, 1.0)), + 234 => Some(RGBA::new(0.110, 0.110, 0.110, 1.0)), + 235 => Some(RGBA::new(0.149, 0.149, 0.149, 1.0)), + 236 => Some(RGBA::new(0.188, 0.188, 0.188, 1.0)), + 237 => Some(RGBA::new(0.227, 0.227, 0.227, 1.0)), + 238 => Some(RGBA::new(0.267, 0.267, 0.267, 1.0)), + 239 => Some(RGBA::new(0.306, 0.306, 0.306, 1.0)), + 240 => Some(RGBA::new(0.345, 0.345, 0.345, 1.0)), + 241 => Some(RGBA::new(0.384, 0.384, 0.384, 1.0)), + 242 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)), + 243 => Some(RGBA::new(0.462, 0.462, 0.462, 1.0)), + 244 => Some(RGBA::new(0.502, 0.502, 0.502, 1.0)), + 245 => Some(RGBA::new(0.541, 0.541, 0.541, 1.0)), + 246 => Some(RGBA::new(0.580, 0.580, 0.580, 1.0)), + 247 => Some(RGBA::new(0.620, 0.620, 0.620, 1.0)), + 248 => Some(RGBA::new(0.659, 0.659, 0.659, 1.0)), + 249 => Some(RGBA::new(0.694, 0.694, 0.694, 1.0)), + 250 => Some(RGBA::new(0.733, 0.733, 0.733, 1.0)), + 251 => Some(RGBA::new(0.777, 0.777, 0.777, 1.0)), + 252 => Some(RGBA::new(0.816, 0.816, 0.816, 1.0)), + 253 => Some(RGBA::new(0.855, 0.855, 0.855, 1.0)), + 254 => Some(RGBA::new(0.890, 0.890, 0.890, 1.0)), + 255 => Some(RGBA::new(0.933, 0.933, 0.933, 1.0)), + _ => None, + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs new file mode 100644 index 00000000..7154b1f3 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs @@ -0,0 +1,29 @@ +use gtk::{TextTag, WrapMode}; + +/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset +/// for ANSI buffer +pub struct Tag { + pub text_tag: TextTag, +} + +impl Default for Tag { + fn default() -> Self { + Self::new() + } +} + +impl Tag { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + text_tag: TextTag::builder() + .family("monospace") // @TODO + .left_margin(28) + .scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO + .wrap_mode(WrapMode::None) + .build(), + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs new file mode 100644 index 00000000..50de853d --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs @@ -0,0 +1,152 @@ +pub mod error; +mod tag; + +pub use error::Error; +use tag::Tag; + +use adw::StyleManager; +use gtk::{ + TextTag, + gdk::RGBA, + pango::{Style, Underline}, + prelude::TextTagExt, +}; +use syntect::{ + easy::HighlightLines, + highlighting::{Color, FontStyle, ThemeSet}, + parsing::{SyntaxReference, SyntaxSet}, +}; + +/* Default theme + @TODO make optional + base16-ocean.dark + base16-eighties.dark + base16-mocha.dark + base16-ocean.light + InspiredGitHub + Solarized (dark) + Solarized (light) +*/ +pub const DEFAULT_THEME_DARK: &str = "base16-eighties.dark"; +pub const DEFAULT_THEME_LIGHT: &str = "InspiredGitHub"; + +pub struct Syntax { + syntax_set: SyntaxSet, + theme_set: ThemeSet, +} + +impl Default for Syntax { + fn default() -> Self { + Self::new() + } +} + +impl Syntax { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), + } + } + + // Actions + + /// Apply `Syntect` highlight to new buffer returned, + /// according to given `alt` and `source_code` content + pub fn highlight( + &self, + source_code: &str, + alt: Option<&String>, + ) -> Result, Error> { + if let Some(value) = alt { + if let Some(reference) = self.syntax_set.find_syntax_by_name(value) { + return self.buffer(source_code, reference); + } + + if let Some(reference) = self.syntax_set.find_syntax_by_token(value) { + return self.buffer(source_code, reference); + } + + if let Some(reference) = self.syntax_set.find_syntax_by_path(value) { + return self.buffer(source_code, reference); + } + } + + if let Some(reference) = self.syntax_set.find_syntax_by_first_line(source_code) { + return self.buffer(source_code, reference); + } + + Err(Error::Parse) + } + + fn buffer( + &self, + source: &str, + syntax_reference: &SyntaxReference, + ) -> Result, Error> { + // Init new line buffer + let mut buffer = Vec::new(); + + // Apply syntect decorator + let mut ranges = HighlightLines::new( + syntax_reference, + &self.theme_set.themes[if StyleManager::default().is_dark() { + DEFAULT_THEME_DARK + } else { + DEFAULT_THEME_LIGHT + }], // @TODO apply on env change + ); + + match ranges.highlight_line(source, &self.syntax_set) { + Ok(result) => { + // Build tags + for (style, entity) in result { + // Create new tag from default preset + let tag = Tag::new(); + + // Tuneup using syntect conversion + // tag.set_background_rgba(Some(&color_to_rgba(style.background))); + tag.text_tag + .set_foreground_rgba(Some(&color_to_rgba(style.foreground))); + tag.text_tag + .set_style(font_style_to_style(style.font_style)); + tag.text_tag + .set_underline(font_style_to_underline(style.font_style)); + + // Append + buffer.push((tag.text_tag, entity.to_string())); + } + Ok(buffer) + } + Err(e) => Err(Error::Syntect(e)), + } + } +} + +// Tools + +fn color_to_rgba(color: Color) -> RGBA { + RGBA::new( + color.r as f32 / 255.0, + color.g as f32 / 255.0, + color.b as f32 / 255.0, + color.a as f32 / 255.0, + ) +} + +fn font_style_to_style(font_style: FontStyle) -> Style { + match font_style { + FontStyle::ITALIC => Style::Italic, + _ => Style::Normal, + } +} + +fn font_style_to_underline(font_style: FontStyle) -> Underline { + match font_style { + FontStyle::UNDERLINE => Underline::Single, + _ => Underline::None, + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs new file mode 100644 index 00000000..ae9bfdb6 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs @@ -0,0 +1,18 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Parse, + Syntect(syntect::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Parse => write!(f, "Parse error"), + Self::Syntect(e) => { + write!(f, "Syntect error: {e}") + } + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs new file mode 100644 index 00000000..4b2011b8 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs @@ -0,0 +1,29 @@ +use gtk::{TextTag, WrapMode}; + +/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset +/// for syntax highlight buffer +pub struct Tag { + pub text_tag: TextTag, +} + +impl Default for Tag { + fn default() -> Self { + Self::new() + } +} + +impl Tag { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + text_tag: TextTag::builder() + .family("monospace") // @TODO + .left_margin(28) + .scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO + .wrap_mode(WrapMode::None) + .build(), + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs new file mode 100644 index 00000000..681d73ae --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs @@ -0,0 +1,170 @@ +use gtk::{ + TextBuffer, TextTag, WrapMode, + glib::Uri, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; + +const REGEX_HEADER: &str = r"(?m)^(?P#{1,6})\s+(?P.*)$"; + +pub struct Header { + h1: TextTag, + h2: TextTag, + h3: TextTag, + h4: TextTag, + h5: TextTag, + h6: TextTag, +} + +impl Header { + pub fn new() -> Self { + // * important to give the tag name here as used in the fragment search + Self { + h1: TextTag::builder() + .foreground("#2190a4") // @TODO optional + .name("h1") + .scale(1.6) + .sentence(true) + .weight(500) + .wrap_mode(WrapMode::Word) + .build(), + h2: TextTag::builder() + .foreground("#d56199") // @TODO optional + .name("h2") + .scale(1.4) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h3: TextTag::builder() + .foreground("#c88800") // @TODO optional + .name("h3") + .scale(1.2) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h4: TextTag::builder() + .foreground("#c88800") // @TODO optional + .name("h4") + .scale(1.1) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h5: TextTag::builder() + .foreground("#c88800") // @TODO optional + .name("h5") + .scale(1.0) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h6: TextTag::builder() + .foreground("#c88800") // @TODO optional + .name("h6") + .scale(1.0) + .sentence(true) + .weight(300) + .wrap_mode(WrapMode::Word) + .build(), + } + } + + /// Apply title `Tag` to given `TextBuffer` + pub fn render( + &self, + buffer: &TextBuffer, + base: &Uri, + headers: &mut HashMap<TextTag, (String, Uri)>, + ) -> Option<String> { + let mut raw_title = None; + + let table = buffer.tag_table(); + + assert!(table.add(&self.h1)); + assert!(table.add(&self.h2)); + assert!(table.add(&self.h3)); + assert!(table.add(&self.h4)); + assert!(table.add(&self.h5)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_HEADER) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.iter() { + if raw_title.is_none() && !cap["title"].trim().is_empty() { + raw_title = Some(cap["title"].into()) + } + } + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + // Create unique phantom tag for each header + // * for the #fragment references implementation + let h = TextTag::new(Some(&format!("h{}", gtk::glib::uuid_string_random()))); + assert!(table.add(&h)); + + // Render header in text buffer + buffer.delete(&mut start_iter, &mut end_iter); + + match cap["level"].chars().count() { + 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h1]), + 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h2]), + 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h3]), + 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h4]), + 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h5]), + 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h6]), + _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h]), // unexpected + } + + // Register fragment reference + assert!( + headers + .insert( + h, + ( + cap["title"].into(), + Uri::build( + base.flags(), + &base.scheme(), + base.userinfo().as_deref(), + base.host().as_deref(), + base.port(), + &base.path(), + base.query().as_deref(), + Some(&super::format_header_fragment(&cap["title"])), + ) + ), + ) + .is_none() + ) + } + raw_title + } +} + +#[test] +fn test_regex_title() { + let cap: Vec<_> = Regex::new(REGEX_HEADER) + .unwrap() + .captures_iter(r"## Header ![alt](https://link.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(&first[0], "## Header ![alt](https://link.com)"); + assert_eq!(&first["level"], "##"); + assert_eq!(&first["title"], "Header ![alt](https://link.com)"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/hr.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/hr.rs new file mode 100644 index 00000000..8cfcc683 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/hr.rs @@ -0,0 +1,93 @@ +use gtk::{ + Orientation, Separator, TextView, + glib::{ControlFlow, idle_add_local}, + prelude::*, +}; +use regex::Regex; + +const REGEX_HR: &str = r"(?m)^(?P<hr>\\?[-]{3,})$"; + +/// Apply --- `Tag` to given `TextBuffer` +pub fn render(text_view: &TextView) { + let separator = Separator::builder() + .orientation(Orientation::Horizontal) + .build(); + idle_add_local({ + let text_view = text_view.clone(); + let separator = separator.clone(); + move || { + separator.set_width_request(text_view.width() - 18); + ControlFlow::Break + } + }); + + let buffer = text_view.buffer(); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_HR) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + buffer.delete(&mut start_iter, &mut end_iter); + text_view.add_child_at_anchor(&separator, &buffer.create_child_anchor(&mut end_iter)); + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_HR).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["hr"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = "Some line\n---\nSome another-line with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_HR).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), ""); + } + } + assert_eq!( + result, + "Some line\n\nSome another-line with ![img](https://link.com)" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_HR) + .unwrap() + .captures_iter("Some line\n---\nSome another-line with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["hr"], "---"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/italic.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/italic.rs new file mode 100644 index 00000000..9c485ad8 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/italic.rs @@ -0,0 +1,141 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + pango::Style, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_ITALIC_1: &str = r"\*(?P<text>[^\*]*)\*"; +const REGEX_ITALIC_2: &str = r"\b_(?P<text>[^_]*)_\b"; + +pub struct Italic(TextTag); + +impl Italic { + pub fn new() -> Self { + Self( + TextTag::builder() + .style(Style::Italic) + .wrap_mode(Word) + .build(), + ) + } + + /// Apply *italic*/_italic_ `Tag` to given `TextBuffer` + /// * run after `Bold` tag! + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + render(self, buffer, REGEX_ITALIC_1); + render(self, buffer, REGEX_ITALIC_2); + } +} + +fn render(this: &Italic, buffer: &TextBuffer, regex: &str) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(regex) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + let mut tags = start_iter.tags(); + tags.push(this.0.clone()); + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::<Vec<&TextTag>>(), + ) + } +} + +/// * run after `Bold` tag! +pub fn strip_tags(value: &str) -> String { + let mut s = String::from(value); + for cap in Regex::new(REGEX_ITALIC_1).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + s = s.replace(m.as_str(), &cap["text"]); + } + } + for cap in Regex::new(REGEX_ITALIC_2).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + s = s.replace(m.as_str(), &cap["text"]); + } + } + s +} + +#[test] +fn test_strip_tags() { + const S: &str = "Some *italic 1*\nand *italic 2* and _italic 3_"; + { + let mut result = String::from(S); + for cap in Regex::new(REGEX_ITALIC_1).unwrap().captures_iter(S) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!(result, "Some italic 1\nand italic 2 and _italic 3_") + } + { + let mut result = String::from(S); + for cap in Regex::new(REGEX_ITALIC_2).unwrap().captures_iter(S) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!(result, "Some *italic 1*\nand *italic 2* and italic 3") + } +} + +#[test] +fn test_regex() { + const S: &str = "Some *italic 1*\nand *italic 2* and _italic 3_"; + { + let cap: Vec<_> = Regex::new(REGEX_ITALIC_1) + .unwrap() + .captures_iter(S) + .collect(); + + assert_eq!(cap.len(), 2); + + let mut c = cap.into_iter(); + assert_eq!(&c.next().unwrap()["text"], "italic 1"); + assert_eq!(&c.next().unwrap()["text"], "italic 2"); + } + { + let cap: Vec<_> = Regex::new(REGEX_ITALIC_2) + .unwrap() + .captures_iter(S) + .collect(); + + assert_eq!(cap.len(), 1); + + let mut c = cap.into_iter(); + assert_eq!(&c.next().unwrap()["text"], "italic 3"); + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs new file mode 100644 index 00000000..fc142275 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs @@ -0,0 +1,150 @@ +use gtk::{ + TextBuffer, TextTag, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_LIST: &str = + r"(?m)^(?P<level>[ \t]*)\*[ \t]+(?:(?P<state>\[[ xX]\])[ \t]+)?(?P<text>.*)"; + +struct State(bool); + +impl State { + fn parse(value: Option<&str>) -> Option<Self> { + if let Some(state) = value + && (state.starts_with("[ ]") || state.starts_with("[x]")) + { + return Some(Self(state.starts_with("[x]"))); + } + None + } + fn is_checked(&self) -> bool { + self.0 + } +} + +struct Item { + pub level: usize, + pub state: Option<State>, + pub text: String, +} + +impl Item { + fn parse(level: &str, state: Option<&str>, text: String) -> Self { + Self { + level: level.chars().count(), + state: State::parse(state), + text, + } + } +} + +/// Apply * list item `Tag` to given `TextBuffer` +pub fn render(buffer: &TextBuffer) { + let state_tag = TextTag::builder().family("monospace").build(); + assert!(buffer.tag_table().add(&state_tag)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_LIST) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + + let item = Item::parse( + &cap["level"], + cap.name("state").map(|m| m.as_str()), + cap["text"].into(), + ); + + buffer.insert_with_tags( + &mut start_iter, + &format!("{}• ", " ".repeat(item.level)), + &[], + ); + if let Some(state) = item.state { + buffer.insert_with_tags( + &mut start_iter, + if state.is_checked() { "[x] " } else { "[ ] " }, + &[&state_tag], + ); + } + buffer.insert_with_tags(&mut start_iter, &item.text, &[]); + } +} + +#[test] +fn test_regex() { + fn item(cap: &Vec<regex::Captures<'_>>, n: usize) -> Item { + let c = cap.get(n).unwrap(); + Item::parse( + &c["level"], + c.name("state").map(|m| m.as_str()), + c["text"].into(), + ) + } + let cap: Vec<_> = Regex::new(REGEX_LIST) + .unwrap() + .captures_iter("Some\n* list item 1\n * list item 1.1\n * list item 1.2\n* list item 2\nand\n* list item 3\n * [x] list item 3.1\n * [ ] list item 3.2\n* list item 4\n") + .collect(); + { + let item = item(&cap, 0); + assert_eq!(item.level, 0); + assert!(item.state.is_none()); + assert_eq!(item.text, "list item 1"); + } + { + let item = item(&cap, 1); + assert_eq!(item.level, 2); + assert!(item.state.is_none()); + assert_eq!(item.text, "list item 1.1"); + } + { + let item = item(&cap, 2); + assert_eq!(item.level, 2); + assert!(item.state.is_none()); + assert_eq!(item.text, "list item 1.2"); + } + { + let item = item(&cap, 3); + assert_eq!(item.level, 0); + assert!(item.state.is_none()); + assert_eq!(item.text, "list item 2"); + } + { + let item = item(&cap, 4); + assert_eq!(item.level, 0); + assert!(item.state.is_none()); + assert_eq!(item.text, "list item 3"); + } + { + let item = item(&cap, 5); + assert_eq!(item.level, 2); + assert!(item.state.is_some_and(|this| this.is_checked())); + assert_eq!(item.text, "list item 3.1"); + } + { + let item = item(&cap, 6); + assert_eq!(item.level, 2); + assert!(item.state.is_some_and(|this| !this.is_checked())); + assert_eq!(item.text, "list item 3.2"); + } + { + let item = item(&cap, 7); + assert_eq!(item.level, 0); + assert!(item.state.is_none()); + assert_eq!(item.text, "list item 4"); + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs new file mode 100644 index 00000000..0ff09dc0 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs @@ -0,0 +1,105 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + gdk::RGBA, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_PRE: &str = r"`(?P<text>[^`]*)`"; +const TAG_FONT: &str = "monospace"; // @TODO +const TAG_SCALE: f64 = 0.9; + +pub struct Pre(TextTag); + +impl Pre { + pub fn new() -> Self { + Self(if adw::StyleManager::default().is_dark() { + TextTag::builder() + .background_rgba(&RGBA::new(255., 255., 255., 0.05)) + .family(TAG_FONT) + .foreground("#e8e8e8") + .scale(TAG_SCALE) + .wrap_mode(Word) + .build() + } else { + TextTag::builder() + .background_rgba(&RGBA::new(0., 0., 0., 0.06)) + .family(TAG_FONT) + .scale(TAG_SCALE) + .wrap_mode(Word) + .build() + }) + } + + /// Apply preformatted `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_PRE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0]) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some `pre 1` and `pre 2` with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!(result, "Some pre 1 and pre 2 with ![img](https://link.com)") +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_PRE) + .unwrap() + .captures_iter(r"Some `pre 1` and `pre 2` with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["text"], "pre 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "pre 2"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs new file mode 100644 index 00000000..6b7a8b74 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs @@ -0,0 +1,66 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + pango::Style::Italic, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_QUOTE: &str = r"(?m)^>(?:[ \t]*(?P<text>.*))?$"; + +pub struct Quote(TextTag); + +impl Quote { + pub fn new() -> Self { + Self( + TextTag::builder() + .left_margin(28) + .wrap_mode(Word) + .style(Italic) // conflicts the italic tags decoration @TODO + .build(), + ) + } + + /// Apply quote `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_QUOTE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0]) + } + } +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_QUOTE).unwrap().captures_iter( + "> Some quote 1 with ![img](https://link.com)\n>\n> 2\\)Some quote 2 with text\nplain text\n> Some quote 3" + ).collect(); + + let mut i = cap.into_iter(); + + assert_eq!( + &i.next().unwrap()["text"], + "Some quote 1 with ![img](https://link.com)" + ); + assert!(&i.next().unwrap()["text"].is_empty()); + assert_eq!(&i.next().unwrap()["text"], "2\\)Some quote 2 with text"); + assert_eq!(&i.next().unwrap()["text"], "Some quote 3"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs new file mode 100644 index 00000000..0ce45980 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs @@ -0,0 +1,361 @@ +use gtk::{ + TextBuffer, TextIter, TextTag, WrapMode, + gdk::RGBA, + glib::{Uri, UriFlags}, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; + +const REGEX_LINK: &str = r"\[(?P<text>[^\]]*)\]\((?P<url>[^\)]+)\)"; +const REGEX_IMAGE: &str = r"!\[(?P<alt>[^\]]*)\]\((?P<url>[^\)]+)\)"; +const REGEX_IMAGE_LINK: &str = + r"\[(?P<is_img>!)\[(?P<alt>[^\]]*)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)"; + +struct Reference { + uri: Uri, + alt: String, +} + +impl Reference { + /// Try construct new `Self` with given options + fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option<Self> { + // Convert address to the valid URI, + // resolve to absolute URL format if the target is relative + match Uri::resolve_relative( + Some(&base.to_string()), + // Relative scheme patch + // https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + &match address.strip_prefix("//") { + Some(p) => { + let s = p.trim_start_matches(":"); + format!( + "{}://{}", + base.scheme(), + if s.is_empty() { + format!("{}/", base.host().unwrap_or_default()) + } else { + s.into() + } + ) + } + None => address.into(), + }, + UriFlags::NONE, + ) { + Ok(ref url) => match Uri::parse(url, UriFlags::NONE) { + Ok(uri) => { + let mut a: Vec<&str> = Vec::with_capacity(2); + if uri.scheme() != base.scheme() { + a.push("⇖"); + } + match alt { + Some(text) => a.push(text), + None => a.push(url), + } + Some(Self { + uri, + alt: a.join(" "), + }) + } + Err(_) => todo!(), + }, + Err(_) => None, + } + } + + /// Insert `Self` into the given `TextBuffer` by registering new `TextTag` created + fn into_buffer( + self, + buffer: &TextBuffer, + position: &mut TextIter, + link_color: &RGBA, + is_annotation: bool, + links: &mut HashMap<TextTag, Uri>, + ) { + let a = if is_annotation { + buffer.insert_with_tags(position, " ", &[]); + TextTag::builder() + .foreground_rgba(link_color) + // .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) + // @TODO adw 1.6 / ubuntu 24.10+ + .pixels_above_lines(4) + .pixels_below_lines(4) + .rise(5000) + .scale(0.8) + .wrap_mode(WrapMode::Word) + .build() + } else { + TextTag::builder() + .foreground_rgba(link_color) + // .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) + // @TODO adw 1.6 / ubuntu 24.10+ + .sentence(true) + .wrap_mode(WrapMode::Word) + .build() + }; + assert!(buffer.tag_table().add(&a)); + + let mut tags = position.tags(); // @TODO seems does not work :) + tags.push(a.clone()); + + buffer.insert_with_tags(position, &self.alt, &tags.iter().collect::<Vec<&TextTag>>()); + + links.insert(a, self.uri); + } +} + +/// Image links `[![]()]()` +fn render_images_links( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_IMAGE_LINK) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(this) = Reference::parse( + &cap["img_url"], + if cap["alt"].is_empty() { + None + } else { + Some(&cap["alt"]) + }, + base, + ) { + this.into_buffer(buffer, &mut start_iter, link_color, false, links) + } + if let Some(this) = Reference::parse(&cap["link_url"], Some("1"), base) { + this.into_buffer(buffer, &mut start_iter, link_color, true, links) + } + } +} + +pub fn render( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + render_images_links(buffer, base, link_color, links); + render_images(buffer, base, link_color, links); + render_links(buffer, base, link_color, links) +} + +/// Image tags `![]()` +fn render_images( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_IMAGE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(this) = Reference::parse( + &cap["url"], + if cap["alt"].is_empty() { + None + } else { + Some(&cap["alt"]) + }, + base, + ) { + this.into_buffer(buffer, &mut start_iter, link_color, false, links) + } + } +} +/// Links `[]()` +fn render_links( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_LINK) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(this) = Reference::parse( + &cap["url"], + if cap["text"].is_empty() { + None + } else { + Some(&cap["text"]) + }, + base, + ) { + this.into_buffer(buffer, &mut start_iter, link_color, false, links) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some text [link1](https://link1.com) [link2](https://link2.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!(result, "Some text link1 link2") +} + +#[test] +fn test_regex_link() { + let cap: Vec<_> = Regex::new(REGEX_LINK) + .unwrap() + .captures_iter(r"[link1](https://link1.com) [link2](https://link2.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(&first[0], "[link1](https://link1.com)"); + assert_eq!(&first["text"], "link1"); + assert_eq!(&first["url"], "https://link1.com"); + + let second = cap.get(1).unwrap(); + assert_eq!(&second[0], "[link2](https://link2.com)"); + assert_eq!(&second["text"], "link2"); + assert_eq!(&second["url"], "https://link2.com"); +} + +#[test] +fn test_regex_image_link() { + let cap: Vec<_> = Regex::new( + REGEX_IMAGE_LINK, + ) + .unwrap().captures_iter( + r"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)" + ).collect(); + + let first = cap.first().unwrap(); + assert_eq!( + &first[0], + "[![image1](https://image1.com)](https://image2.com)" + ); + assert_eq!(&first["alt"], "image1"); + assert_eq!(&first["img_url"], "https://image1.com"); + assert_eq!(&first["link_url"], "https://image2.com"); + + let second = cap.get(1).unwrap(); + assert_eq!( + &second[0], + "[![image3](https://image3.com)](https://image4.com)" + ); + assert_eq!(&second["alt"], "image3"); + assert_eq!(&second["img_url"], "https://image3.com"); + assert_eq!(&second["link_url"], "https://image4.com"); +} + +#[test] +fn test_regex_image() { + let cap: Vec<_> = Regex::new(REGEX_IMAGE) + .unwrap() + .captures_iter(r"![image1](https://image1.com) ![image2](https://image2.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(&first[0], "![image1](https://image1.com)"); + assert_eq!(&first["alt"], "image1"); + assert_eq!(&first["url"], "https://image1.com"); + + let second = cap.get(1).unwrap(); + assert_eq!(&second[0], "![image2](https://image2.com)"); + assert_eq!(&second["alt"], "image2"); + assert_eq!(&second["url"], "https://image2.com"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs new file mode 100644 index 00000000..7c0efb71 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs @@ -0,0 +1,102 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_STRIKE: &str = r"~~(?P<text>[^~]*)~~"; + +pub struct Strike(TextTag); + +impl Strike { + pub fn new() -> Self { + Self( + TextTag::builder() + .strikethrough(true) + .wrap_mode(Word) + .build(), + ) + } + + /// Apply ~~strike~~ `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_STRIKE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + let mut tags = start_iter.tags(); + tags.push(self.0.clone()); + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::<Vec<&TextTag>>(), + ) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!( + result, + "Some strike 1 and strike 2 with ![img](https://link.com)" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_STRIKE) + .unwrap() + .captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["text"], "strike 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "strike 2"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs new file mode 100644 index 00000000..9357208a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs @@ -0,0 +1,98 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + pango::Underline::Single, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]*)_\b"; + +pub struct Underline(TextTag); + +impl Underline { + pub fn new() -> Self { + Self(TextTag::builder().underline(Single).wrap_mode(Word).build()) + } + + /// Apply _underline_ `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_UNDERLINE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + if start_char_offset > 0 + && buffer + .text( + &buffer.iter_at_offset(start_char_offset - 1), + &end_iter, + false, + ) + .contains("\\") + { + continue; + } + + let mut tags = start_iter.tags(); + tags.push(self.0.clone()); + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::<Vec<&TextTag>>(), + ) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!( + result, + "Some underline 1 and underline 2 with ![img](https://link.com)" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_UNDERLINE) + .unwrap() + .captures_iter(r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["text"], "underline 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "underline 2"); +} diff --git a/src/app/browser/window/tab/item/page/input.rs b/src/app/browser/window/tab/item/page/input.rs index eab68b34..e13eb2c8 100644 --- a/src/app/browser/window/tab/item/page/input.rs +++ b/src/app/browser/window/tab/item/page/input.rs @@ -75,6 +75,9 @@ impl Input { } pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box<dyn Fn()>) + 'static) { - self.update(Some(>k::Box::titan(on_send))); + self.update(Some(>k::Box::titan(MAX_CONTENT_HEIGHT, on_send))); } } + +/// @TODO optional, maybe relative to the current window height in % +const MAX_CONTENT_HEIGHT: i32 = 280; diff --git a/src/app/browser/window/tab/item/page/input/response.rs b/src/app/browser/window/tab/item/page/input/response.rs index e56c25dc..2c602616 100644 --- a/src/app/browser/window/tab/item/page/input/response.rs +++ b/src/app/browser/window/tab/item/page/input/response.rs @@ -47,8 +47,8 @@ impl Response for Box { .margin_end(MARGIN) .margin_start(MARGIN) .margin_top(MARGIN) - .spacing(SPACING) .orientation(Orientation::Vertical) + .spacing(SPACING) .build(); g_box.append(&title); diff --git a/src/app/browser/window/tab/item/page/input/response/form.rs b/src/app/browser/window/tab/item/page/input/response/form.rs index 378174f9..3200e818 100644 --- a/src/app/browser/window/tab/item/page/input/response/form.rs +++ b/src/app/browser/window/tab/item/page/input/response/form.rs @@ -23,8 +23,11 @@ impl Form for TextView { // Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling) let checker = Checker::default(); - let adapter = TextBufferAdapter::new(&buffer, &checker); - adapter.set_enabled(true); + let adapter = TextBufferAdapter::builder() + .buffer(&buffer) + .checker(&checker) + .enabled(true) + .build(); // Init main widget let text_view = TextView::builder() @@ -36,11 +39,12 @@ impl Form for TextView { .margin_bottom(MARGIN / 4) .right_margin(MARGIN) .top_margin(MARGIN) + .valign(gtk::Align::BaselineCenter) .wrap_mode(WrapMode::Word) .build(); text_view.insert_action_group("spelling", Some(&adapter)); - text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635) + text_view.set_size_request(-1, 36); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635) // Init events text_view.connect_realize(|this| { diff --git a/src/app/browser/window/tab/item/page/input/titan.rs b/src/app/browser/window/tab/item/page/input/titan.rs index cc89f239..62231c18 100644 --- a/src/app/browser/window/tab/item/page/input/titan.rs +++ b/src/app/browser/window/tab/item/page/input/titan.rs @@ -16,11 +16,17 @@ use tab::Tab; use text::Text; pub trait Titan { - fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self; + fn titan( + max_content_height: i32, + callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static, + ) -> Self; } impl Titan for gtk::Box { - fn titan(callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static) -> Self { + fn titan( + max_content_height: i32, + callback: impl Fn(Header, Bytes, Box<dyn Fn()>) + 'static, + ) -> Self { use gtk::{Label, glib::uuid_string_random, prelude::ButtonExt}; use std::rc::Rc; @@ -35,7 +41,15 @@ impl Titan for gtk::Box { .show_border(false) .build(); - notebook.append_page(&text.text_view, Some(&Label::tab("Text"))); + notebook.append_page( + >k::ScrolledWindow::builder() + .child(&text.text_view) + .max_content_height(max_content_height) + .propagate_natural_height(true) + .build(), + Some(&Label::tab("Text")), + ); + notebook.append_page(&file.button, Some(&Label::tab("File"))); notebook.connect_switch_page({ diff --git a/src/app/browser/window/tab/item/page/input/titan/text/form.rs b/src/app/browser/window/tab/item/page/input/titan/text/form.rs index 114c0c99..51df4521 100644 --- a/src/app/browser/window/tab/item/page/input/titan/text/form.rs +++ b/src/app/browser/window/tab/item/page/input/titan/text/form.rs @@ -16,8 +16,11 @@ impl Form for TextView { // Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling) let checker = Checker::default(); - let adapter = TextBufferAdapter::new(&buffer, &checker); - adapter.set_enabled(true); + let adapter = TextBufferAdapter::builder() + .buffer(&buffer) + .checker(&checker) + .enabled(true) + .build(); // Init main widget @@ -31,12 +34,12 @@ impl Form for TextView { .left_margin(MARGIN) .right_margin(MARGIN) .top_margin(MARGIN) + .valign(gtk::Align::Fill) .wrap_mode(WrapMode::Word) .build() }; text_view.insert_action_group("spelling", Some(&adapter)); - text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635) // Init events text_view.connect_realize(|this| { diff --git a/src/app/browser/window/tab/item/page/navigation/request.rs b/src/app/browser/window/tab/item/page/navigation/request.rs index 936e1bdc..940d1aa9 100644 --- a/src/app/browser/window/tab/item/page/navigation/request.rs +++ b/src/app/browser/window/tab/item/page/navigation/request.rs @@ -60,10 +60,10 @@ impl Request { }; let c = gtk::EventControllerKey::builder().build(); c.connect_key_pressed({ - let entry = entry.clone(); - let suggestion = suggestion.clone(); + let e = entry.clone(); + let s = suggestion.clone(); move |_, k, _, m| { - if suggestion.is_visible() + if s.is_visible() && !matches!( m, ModifierType::SHIFT_MASK @@ -72,20 +72,21 @@ impl Request { ) { if matches!(k, Key::Up | Key::KP_Up | Key::Page_Up | Key::KP_Page_Up) { - if !suggestion.back() { - entry.error_bell() + if !s.back() { + e.error_bell() } return Propagation::Stop; } else if matches!( k, Key::Down | Key::KP_Down | Key::Page_Down | Key::KP_Page_Down ) { - if !suggestion.next() { - entry.error_bell() + if !s.next() { + e.error_bell() } return Propagation::Stop; } } + s.hide(); Propagation::Proceed } }); @@ -116,7 +117,15 @@ impl Request { entry.connect_has_focus_notify({ let i = info.clone(); - move |e| update_secondary_icon(e, &i.borrow()) + let s = suggestion.clone(); + move |this| { + // toggle 'go to' button + update_secondary_icon(this, &i.borrow()); + // hide suggestions on focus left this entry + if this.focus_child().is_none_or(|child| !child.has_focus()) { + s.hide() + } + } }); suggestion @@ -128,60 +137,47 @@ impl Request { let p = profile.clone(); let r = proxy_resolver.clone(); let s = suggestion.clone(); - move |e| { + move |this| { // Allocate once - let t = e.text(); + let t = this.text(); // Update actions a.reload.set_enabled(!t.is_empty()); - a.home.set_enabled(home(e).is_some()); + a.home.set_enabled(home(this).is_some()); // Update icons - update_primary_icon(e, &p); - update_secondary_icon(e, &i.borrow()); + update_primary_icon(this, &p); + update_secondary_icon(this, &i.borrow()); + refresh_proxy_resolver(this, &p, &r); // Show search suggestions - if e.focus_child().is_some() { - s.update(Some(50)); // @TODO optional + if this.focus_child().is_some() { + s.update(Some(50)) // @TODO optional } - refresh_proxy_resolver(e, &p, &r) } })); // `suggestion` wants `signal_handler_id` to block this event on autocomplete navigation entry.connect_activate({ let a = item_action.clone(); let s = suggestion.clone(); - move |_| { - use gtk::prelude::ActionExt; - a.reload.activate(None); + move |this| { s.hide(); + a.load.activate(Some(&this.text()), true, false) } }); - entry.connect_has_focus_notify({ - let s = suggestion.clone(); - move |_| { - if s.is_visible() { - s.hide() - } - } - }); - + // Select entire text on first click (release) + // this behavior implemented in most web-browsers, + // to simply overwrite current request with new value + // Note: + // * Custom GestureClick is not an option here, as GTK Entry has default controller + // * This is experimental feature does not follow native GTK behavior @TODO make optional entry.connect_state_flags_changed({ - // Define last focus state container - let has_focus = Cell::new(false); + let had_focus = Cell::new(false); move |this, state| { - // Select entire text on first click (release) - // this behavior implemented in most web-browsers, - // to simply overwrite current request with new value - // Note: - // * Custom GestureClick is not an option here, as GTK Entry has default controller - // * This is experimental feature does not follow native GTK behavior @TODO make optional - if !has_focus.take() + if !had_focus.replace(state.contains(StateFlags::FOCUS_WITHIN)) && state.contains(StateFlags::ACTIVE | StateFlags::FOCUS_WITHIN) && this.selection_bounds().is_none() { this.select_region(0, -1) } - // Update last focus state - has_focus.replace(state.contains(StateFlags::FOCUS_WITHIN)); } }); diff --git a/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs b/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs index 89ee25db..e3d56d8a 100644 --- a/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs +++ b/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs @@ -121,7 +121,7 @@ impl Dialog for PreferencesDialog { /// Lookup [MaxMind](https://www.maxmind.com) database fn l(profile: &Profile, socket_address: &SocketAddress) -> Option<String> { use maxminddb::{ - MaxMindDbError, Reader, + Reader, geoip2::{/*City,*/ Country}, }; if !matches!( @@ -136,26 +136,16 @@ impl Dialog for PreferencesDialog { Reader::open_readfile(c) } .ok()?; - let lookup = { - let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap(); - let lookup: std::result::Result<Option<Country>, MaxMindDbError> = - db.lookup(a.ip()); - lookup + let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap(); + let c: Country = db.lookup(a.ip()).ok()?.decode().ok()??; + let mut b = Vec::new(); + if let Some(iso_code) = c.country.iso_code { + b.push(iso_code); } - .ok()??; - lookup.country.map(|c| { - let mut b = Vec::new(); - if let Some(iso_code) = c.iso_code { - b.push(iso_code) - } - if let Some(n) = c.names - && let Some(s) = n.get("en") - { - b.push(s) - } // @TODO multi-lang - // @TODO city DB - b.join(", ") - }) + if let Some(name_en) = c.country.names.english { + b.push(name_en); + } + b.join(", ").into() } p.add(&{ let g = PreferencesGroup::builder().title("Remote").build(); diff --git a/src/app/browser/window/tab/item/page/navigation/request/suggestion.rs b/src/app/browser/window/tab/item/page/navigation/request/suggestion.rs index 06b87247..e7a945f2 100644 --- a/src/app/browser/window/tab/item/page/navigation/request/suggestion.rs +++ b/src/app/browser/window/tab/item/page/navigation/request/suggestion.rs @@ -207,7 +207,7 @@ impl Suggestion { } }); } else { - self.popover.popdown(); + self.popover.popdown() } } diff --git a/src/profile/history/database.rs b/src/profile/history/database.rs index ec77a840..ed6fd1ea 100644 --- a/src/profile/history/database.rs +++ b/src/profile/history/database.rs @@ -151,7 +151,7 @@ pub fn select( //profile_id: row.get(1)?, opened: Event { time: DateTime::from_unix_local(row.get(2)?).unwrap(), - count: row.get(3)?, + count: row.get::<_, i64>(3)? as usize, }, closed: closed(row.get(4)?, row.get(5)?), request: row.get::<_, String>(6)?.into(),