diff --git a/Cargo.lock b/Cargo.lock index 56732b8b..8a467b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Yoda" -version = "0.12.7" +version = "0.12.10" dependencies = [ "ansi-parser", "anyhow", @@ -20,8 +20,10 @@ dependencies = [ "plurify", "r2d2", "r2d2_sqlite", + "regex", "rusqlite", "sourceview5", + "strip-tags", "syntect", ] @@ -31,6 +33,15 @@ 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" @@ -111,9 +122,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -121,9 +132,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.6" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -135,6 +146,26 @@ 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" @@ -355,18 +386,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -375,7 +394,8 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", + "rand_core", "wasip2", "wasip3", ] @@ -677,9 +697,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" @@ -730,9 +750,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libspelling" @@ -856,9 +876,9 @@ checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "onig" @@ -884,9 +904,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ "bitflags", "cfg-if", @@ -910,9 +930,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -1004,15 +1024,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1025,9 +1036,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -1052,19 +1063,13 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1095,32 +1100,20 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", + "chacha20", + "getrandom", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "redox_syscall" @@ -1131,6 +1124,29 @@ 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" @@ -1336,6 +1352,12 @@ 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" @@ -1447,10 +1469,10 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1463,31 +1485,40 @@ dependencies = [ ] [[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +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", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "unicode-ident" @@ -1503,11 +1534,11 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.2", + "getrandom", "js-sys", "rand", "wasm-bindgen", @@ -1658,9 +1689,15 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -1762,26 +1799,6 @@ dependencies = [ "linked-hash-map", ] -[[package]] -name = "zerocopy" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 5ad72c3a..75c5b126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.7" +version = "0.12.10" edition = "2024" license = "MIT" readme = "README.md" @@ -41,6 +41,8 @@ openssl = "0.10.72" plurify = "0.2.0" r2d2 = "0.8.10" 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 146e4461..72e41dc8 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,9 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati #### Text * [x] `text/gemini` - * [x] `text/plain` + * [x] `text/markdown` * [x] `text/nex` + * [x] `text/plain` #### Images * [x] `image/gif` @@ -165,7 +166,7 @@ The Gemini protocol was designed as a minimalistic, tracking-resistant alternati * Glib `2.80+` * Gtk `4.14+` * GtkSourceView `5.14+` -* libadwaita `1.5+` (Ubuntu 24.04+) +* libadwaita `1.5+` (Ubuntu `24.04+`) * libspelling `0.1+` #### Debian @@ -234,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/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/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 8a30f9f4..40f33ad1 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -357,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 } 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 da3b2231..919c8869 100644 --- a/src/app/browser/window/tab/item/client/driver/nex.rs +++ b/src/app/browser/window/tab/item/client/driver/nex.rs @@ -299,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) }; 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 a89e2fff..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 @@ -6,13 +6,13 @@ mod syntax; mod 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, BUTTON_SECONDARY, RGBA}, - gio::{Cancellable, SimpleAction, SimpleActionGroup}, + 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}, }; @@ -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); @@ -296,14 +287,39 @@ impl Gemini { ) } }); - let action_link_copy = + let action_link_copy_url = SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); - action_link_copy.connect_activate(|this, _| { - gtk::gdk::Display::default() + 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({ @@ -338,14 +354,17 @@ impl Gemini { Some(&{ let g = SimpleActionGroup::new(); g.add_action(&action_link_tab); - g.add_action(&action_link_copy); + 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 = gtk::gio::Menu::new(); + let m = Menu::new(); m.append( Some("Open Link in New Tab"), Some(&format!( @@ -353,27 +372,56 @@ impl Gemini { action_link_tab.name() )), ); - m.append( - Some("Copy Link"), - Some(&format!( - "{link_context_group_id}.{}", - action_link_copy.name() - )), - ); - m.append( - Some("Download Link"), - Some(&format!( - "{link_context_group_id}.{}", - action_link_download.name() - )), - ); - m.append( - Some("View Link as Source"), - Some(&format!( - "{link_context_group_id}.{}", - action_link_source.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); @@ -434,19 +482,65 @@ impl Gemini { 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_copy.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_prefixable_link(&request_str)); + 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_prefixable_link(&request_str)); + 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(); + link_context.popup() } } } @@ -539,7 +633,7 @@ fn is_internal_link(request: &str) -> bool { || request.starts_with("source:") } -fn is_prefixable_link(request: &str) -> bool { +fn is_prefix_link(request: &str) -> bool { request.starts_with("gemini://") || request.starts_with("nex://") || request.starts_with("file://") @@ -580,5 +674,6 @@ 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"); +}