From 31c9d4bab4bda33ea0c3dcc0dc7cee5feb148cfc Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 12 Oct 2025 16:10:23 +0300 Subject: [PATCH 001/110] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 72badf12..153e9bdc 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ GTK 4 / Libadwaita client written in Rust > [!IMPORTANT] -> Project in development, for stable version use crates.io release! +> Project in development, for stable version use [release](https://github.com/YGGverse/Yoda/releases)! > ![image](https://github.com/user-attachments/assets/cfbbc3fb-61d2-4afd-a21f-8e36ee329941) From 0b43b85905be372c19dd3e18765e8a2aff4596d5 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 12 Oct 2025 16:14:08 +0300 Subject: [PATCH 002/110] update api versions --- .github/workflows/flatpak.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index babcb93d..9e4f4765 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Flatpak run: | @@ -23,7 +23,7 @@ jobs: flatpak build-bundle repo Yoda.flatpak io.github.yggverse.Yoda - name: Upload Flatpak Bundle - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Yoda.flatpak path: Yoda.flatpak \ No newline at end of file From 9cf9e8916fa3090d748b5d0996a348e56044738f Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 12 Oct 2025 16:20:41 +0300 Subject: [PATCH 003/110] remove unsupported action --- .github/workflows/flatpak.yml | 29 ----------------------------- .gitignore | 1 - 2 files changed, 30 deletions(-) delete mode 100644 .github/workflows/flatpak.yml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml deleted file mode 100644 index 9e4f4765..00000000 --- a/.github/workflows/flatpak.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Publish Flatpak on Release - -on: - release: - types: [created] - -jobs: - publish: - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Set up Flatpak - run: | - sudo apt install flatpak - flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - - - name: Build Flatpak Bundle - run: | - flatpak-builder --force-clean build --install-deps-from=flathub --repo=repo --user io.github.yggverse.Yoda.yaml - flatpak build-bundle repo Yoda.flatpak io.github.yggverse.Yoda - - - name: Upload Flatpak Bundle - uses: actions/upload-artifact@v4 - with: - name: Yoda.flatpak - path: Yoda.flatpak \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9046e577..fe21aa37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *flatpak* -!flatpak.yml build Cargo.lock repo From 1a9b4802fa9042fd8891f7017c7e64bba12048f5 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 12 Oct 2025 16:20:56 +0300 Subject: [PATCH 004/110] update version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a7a45710..e6275aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.3" +version = "0.12.4" edition = "2024" license = "MIT" readme = "README.md" From e123c1c1cd5177c35c4e0b919f10477dc1d3b2d3 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 19 Oct 2025 22:43:17 +0300 Subject: [PATCH 005/110] test `ggemini` v0.20.0 features --- Cargo.toml | 6 +++--- .../browser/window/tab/item/client/driver/gemini.rs | 12 +++--------- src/app/browser/window/tab/item/client/driver/nex.rs | 9 ++------- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e6275aa5..781fe593 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ version = "0.10.0" [dependencies] ansi-parser = "0.9.1" anyhow = "1.0.97" -ggemini = "0.19.0" +ggemini = "0.20.0" ggemtext = "0.7.0" indexmap = "2.10.0" itertools = "0.14.0" @@ -44,8 +44,8 @@ r2d2_sqlite = "0.31.0" syntect = "5.2.0" # development -# [patch.crates-io] -# ggemini = { git = "https://github.com/YGGverse/ggemini.git" } +[patch.crates-io] +ggemini = { git = "https://github.com/YGGverse/ggemini.git" } # ggemtext = { git = "https://github.com/YGGverse/ggemtext.git" } # plurify = { git = "https://github.com/YGGverse/plurify.git" } # libspelling = { git = "https://github.com/YGGverse/libspelling-rs.git", branch = "yoda-0.12"} 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 0ed4dc4f..8a30f9f4 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -282,11 +282,7 @@ fn handle( file_output_stream, cancellable.clone(), Priority::DEFAULT, - file_output_stream::Size { - chunk: 0x100000, // 1M bytes per chunk - limit: None, // unlimited - total: 0, // initial totals - }, + file_output_stream::Size::default(), ( // on chunk { @@ -336,9 +332,8 @@ fn handle( Priority::DEFAULT, cancellable.clone(), memory_input_stream::Size { - chunk: 0x400, // 1024 bytes chunk limit: 0xfffff, // 1M limit - total: 0, // initial totals + ..memory_input_stream::Size::default() }, ( |_, _| {}, // on chunk (maybe nothing to count yet @TODO) @@ -440,9 +435,8 @@ fn handle( Priority::DEFAULT, cancellable.clone(), memory_input_stream::Size { - chunk: 0x400, // 1024 bytes chunk limit: 0xA00000, // 10M limit - total: 0, // initial totals + ..memory_input_stream::Size::default() }, ( move |_, total| status.set_description(Some(&format!("Download: {}", total.bytes()))), diff --git a/src/app/browser/window/tab/item/client/driver/nex.rs b/src/app/browser/window/tab/item/client/driver/nex.rs index b5c713b7..da3b2231 100644 --- a/src/app/browser/window/tab/item/client/driver/nex.rs +++ b/src/app/browser/window/tab/item/client/driver/nex.rs @@ -193,9 +193,8 @@ impl Nex { Priority::DEFAULT, cancellable.clone(), ggemini::gio::memory_input_stream::Size { - chunk: 0x400, // 1024 bytes chunk limit: 0xA00000, // 10M limit - total: 0, // initial totals + ..ggemini::gio::memory_input_stream::Size::default() }, ( { @@ -343,11 +342,7 @@ fn download(s: SocketConnection, (p, u): (Rc, Uri), c: Cancellable) { file_output_stream, c.clone(), Priority::DEFAULT, - file_output_stream::Size { - chunk: 0x100000, // 1M bytes per chunk - limit: None, // unlimited - total: 0, // initial totals - }, + file_output_stream::Size::default(), ( // on chunk { From 4905d55e8af40aa90ebf46d877c091094a1a7caf Mon Sep 17 00:00:00 2001 From: yggverse Date: Fri, 7 Nov 2025 19:44:53 +0200 Subject: [PATCH 006/110] fix max-height overflow on input long text --- src/app/browser/window/tab/item/page/input/response.rs | 8 +++++++- src/app/browser/window/tab/item/page/input/titan.rs | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/app/browser/window/tab/item/page/input/response.rs b/src/app/browser/window/tab/item/page/input/response.rs index e56c25dc..c2636b7c 100644 --- a/src/app/browser/window/tab/item/page/input/response.rs +++ b/src/app/browser/window/tab/item/page/input/response.rs @@ -52,7 +52,13 @@ impl Response for Box { .build(); g_box.append(&title); - g_box.append(&text_view); + g_box.append( + >k::ScrolledWindow::builder() + .child(&text_view) + .max_content_height(320) + .propagate_natural_height(true) + .build(), + ); g_box.append(&control.g_box); // Init events diff --git a/src/app/browser/window/tab/item/page/input/titan.rs b/src/app/browser/window/tab/item/page/input/titan.rs index cc89f239..801c8ba4 100644 --- a/src/app/browser/window/tab/item/page/input/titan.rs +++ b/src/app/browser/window/tab/item/page/input/titan.rs @@ -35,7 +35,15 @@ impl Titan for gtk::Box { .show_border(false) .build(); - notebook.append_page(&text.text_view, Some(&Label::tab("Text"))); + notebook.append_page( + >k::ScrolledWindow::builder() + .child(&text.text_view) + .max_content_height(320) + .propagate_natural_height(true) + .build(), + Some(&Label::tab("Text")), + ); + notebook.append_page(&file.button, Some(&Label::tab("File"))); notebook.connect_switch_page({ From e813cdeafa0043f4bf7db91971eb0af45236f0ef Mon Sep 17 00:00:00 2001 From: yggverse Date: Fri, 7 Nov 2025 20:59:06 +0200 Subject: [PATCH 007/110] use shared input max height value --- src/app/browser/window/tab/item/page/input.rs | 13 +++++++++++-- .../browser/window/tab/item/page/input/response.rs | 4 +++- src/app/browser/window/tab/item/page/input/titan.rs | 12 +++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/browser/window/tab/item/page/input.rs b/src/app/browser/window/tab/item/page/input.rs index eab68b34..f03994e1 100644 --- a/src/app/browser/window/tab/item/page/input.rs +++ b/src/app/browser/window/tab/item/page/input.rs @@ -61,7 +61,13 @@ impl Input { title: Option<&str>, size_limit: Option, ) { - self.update(Some(>k::Box::response(action, base, title, size_limit))); + self.update(Some(>k::Box::response( + action, + base, + title, + size_limit, + MAX_CONTENT_HEIGHT, + ))); } pub fn set_new_sensitive( @@ -75,6 +81,9 @@ impl Input { } pub fn set_new_titan(&self, on_send: impl Fn(titan::Header, Bytes, Box) + 'static) { - self.update(Some(>k::Box::titan(on_send))); + self.update(Some(>k::Box::titan(MAX_CONTENT_HEIGHT, on_send))); } } + +/// @TODO optional, maybe relative to the current window height in % +const MAX_CONTENT_HEIGHT: i32 = 280; diff --git a/src/app/browser/window/tab/item/page/input/response.rs b/src/app/browser/window/tab/item/page/input/response.rs index c2636b7c..32f6a365 100644 --- a/src/app/browser/window/tab/item/page/input/response.rs +++ b/src/app/browser/window/tab/item/page/input/response.rs @@ -23,6 +23,7 @@ pub trait Response { base: Uri, title: Option<&str>, size_limit: Option, + max_content_height: i32, ) -> Self; } @@ -35,6 +36,7 @@ impl Response for Box { base: Uri, title: Option<&str>, size_limit: Option, + max_content_height: i32, ) -> Self { // Init components let control = Rc::new(Control::build()); @@ -55,7 +57,7 @@ impl Response for Box { g_box.append( >k::ScrolledWindow::builder() .child(&text_view) - .max_content_height(320) + .max_content_height(max_content_height) .propagate_natural_height(true) .build(), ); diff --git a/src/app/browser/window/tab/item/page/input/titan.rs b/src/app/browser/window/tab/item/page/input/titan.rs index 801c8ba4..62231c18 100644 --- a/src/app/browser/window/tab/item/page/input/titan.rs +++ b/src/app/browser/window/tab/item/page/input/titan.rs @@ -16,11 +16,17 @@ use tab::Tab; use text::Text; pub trait Titan { - fn titan(callback: impl Fn(Header, Bytes, Box) + 'static) -> Self; + fn titan( + max_content_height: i32, + callback: impl Fn(Header, Bytes, Box) + 'static, + ) -> Self; } impl Titan for gtk::Box { - fn titan(callback: impl Fn(Header, Bytes, Box) + 'static) -> Self { + fn titan( + max_content_height: i32, + callback: impl Fn(Header, Bytes, Box) + 'static, + ) -> Self { use gtk::{Label, glib::uuid_string_random, prelude::ButtonExt}; use std::rc::Rc; @@ -38,7 +44,7 @@ impl Titan for gtk::Box { notebook.append_page( >k::ScrolledWindow::builder() .child(&text.text_view) - .max_content_height(320) + .max_content_height(max_content_height) .propagate_natural_height(true) .build(), Some(&Label::tab("Text")), From 3d7171298914405ef602830e00ccbe3644c53772 Mon Sep 17 00:00:00 2001 From: yggverse Date: Fri, 7 Nov 2025 21:19:07 +0200 Subject: [PATCH 008/110] use stable `ggemini` version --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 781fe593..a36e5929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,8 +44,8 @@ r2d2_sqlite = "0.31.0" syntect = "5.2.0" # development -[patch.crates-io] -ggemini = { git = "https://github.com/YGGverse/ggemini.git" } +# [patch.crates-io] +# ggemini = { git = "https://github.com/YGGverse/ggemini.git" } # ggemtext = { git = "https://github.com/YGGverse/ggemtext.git" } # plurify = { git = "https://github.com/YGGverse/plurify.git" } # libspelling = { git = "https://github.com/YGGverse/libspelling-rs.git", branch = "yoda-0.12"} From ac3c92e0651aa91af916945f5a18244a82f71512 Mon Sep 17 00:00:00 2001 From: yggverse Date: Fri, 7 Nov 2025 21:41:20 +0200 Subject: [PATCH 009/110] update version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a36e5929..e7aff286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.4" +version = "0.12.5" edition = "2024" license = "MIT" readme = "README.md" From 2df80b35a9214d4f5eaf220cc2e7095b01ba3e3d Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 12:02:37 +0200 Subject: [PATCH 010/110] update readme --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 153e9bdc..ff95925b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Yoda - Browser for [Gemini protocol](https://geminiprotocol.net) -GTK 4 / Libadwaita client written in Rust +Privacy-oriented GTK 4 / Libadwaita client written in Rust. + +The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow redirection to external resources by default, which is not clearly specified at this time. > [!IMPORTANT] > Project in development, for stable version use [release](https://github.com/YGGverse/Yoda/releases)! @@ -152,13 +154,13 @@ GTK 4 / Libadwaita client written in Rust ### Requirements -* Cairo `1.18` -* GdkPixBuf `2.42` -* Glib `2.80` -* Gtk `4.14` -* GtkSourceView `5.14` -* libadwaita `1.5` (Ubuntu 24.04+) -* libspelling `0.1` +* Cairo `1.18+` +* GdkPixBuf `2.42+` +* Glib `2.80+` +* Gtk `4.14+` +* GtkSourceView `5.14+` +* libadwaita `1.5+` (Ubuntu 24.04+) +* libspelling `0.1+` #### Debian From 28abc26f362a3bc944f88e590873768e24b22388 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 12:19:34 +0200 Subject: [PATCH 011/110] update readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff95925b..69229ce1 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,12 @@ Privacy-oriented GTK 4 / Libadwaita client written in Rust. The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow redirection to external resources by default, which is not clearly specified at this time. +Yoda browser is primarily designed by and for experienced network users who care about their network fingerprints and prefer to control every action manually. It does not run background connections, does not incorporate web-like media preloading without user initiation (unlike some clients), nor does it automatically check for updates from unexpected network-related servers. + +The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a graphical user interface (GUI) for seamless navigation in Geminispace, partially inspired by the Firefox UI. + > [!IMPORTANT] -> Project in development, for stable version use [release](https://github.com/YGGverse/Yoda/releases)! +> Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)! > ![image](https://github.com/user-attachments/assets/cfbbc3fb-61d2-4afd-a21f-8e36ee329941) From a4b3a4896f2aaf84f124dd12b6a9c9815cfc581b Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 12:23:42 +0200 Subject: [PATCH 012/110] update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69229ce1..bf499757 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Privacy-oriented GTK 4 / Libadwaita client written in Rust. -The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow redirection to external resources by default, which is not clearly specified at this time. +The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. -Yoda browser is primarily designed by and for experienced network users who care about their network fingerprints and prefer to control every action manually. It does not run background connections, does not incorporate web-like media preloading without user initiation (unlike some clients), nor does it automatically check for updates from unexpected network-related servers. +Yoda browser is primarily designed by and for experienced network users who care about their network fingerprints and prefer to control every action manually. It does not run background connections, does not incorporate web-like media preloading without user initiation (unlike some other clients), nor does it automatically check for updates from unexpected network-related servers. The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a graphical user interface (GUI) for seamless navigation in Geminispace, partially inspired by the Firefox UI. From 938a2f057e050386db3b1006de14b0ffab7fc504 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 12:35:43 +0200 Subject: [PATCH 013/110] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf499757..884c09c8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Privacy-oriented GTK 4 / Libadwaita client written in Rust. The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. -Yoda browser is primarily designed by and for experienced network users who care about their network fingerprints and prefer to control every action manually. It does not run background connections, does not incorporate web-like media preloading without user initiation (unlike some other clients), nor does it automatically check for updates from unexpected network-related servers. +Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation (unlike some other clients), and does not automatically check for updates, even from 'official' servers. The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a graphical user interface (GUI) for seamless navigation in Geminispace, partially inspired by the Firefox UI. From a1302ba9be08c6c16c1adcfd0e622978bd4bbfa3 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 12:37:15 +0200 Subject: [PATCH 014/110] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 884c09c8..205f2ee4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Privacy-oriented GTK 4 / Libadwaita client written in Rust. The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. -Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation (unlike some other clients), and does not automatically check for updates, even from 'official' servers. +Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a graphical user interface (GUI) for seamless navigation in Geminispace, partially inspired by the Firefox UI. From d9414969d383e164c879629bfe2389717503ea96 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 12:39:41 +0200 Subject: [PATCH 015/110] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 205f2ee4..181e7cd0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol spe Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. -The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a graphical user interface (GUI) for seamless navigation in Geminispace, partially inspired by the Firefox UI. +The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a straightforward graphical user interface (GUI), partially inspired by the Firefox UI. > [!IMPORTANT] > Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)! From 9f4232038e9f1ce7fd6210ed1cc3319b6756cc14 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 13:02:38 +0200 Subject: [PATCH 016/110] update readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 181e7cd0..b941635f 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ Privacy-oriented GTK 4 / Libadwaita client written in Rust. -The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. The Yoda browser also includes useful tools, such as flexible proxy configuration, out of the box for use with mesh networks like Yggdrasil, Mycelium, CJDNS, and others. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. +The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. It also includes useful tools, such as connection details, optional DNS/Geo-IP features, flexible proxy configuration for use with modern IPv6 mesh networks like Yggdrasil, Mycelium, CJDNS, and others. -Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. +Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. -The Gemini protocol was designed as a simple, private alternative to the Web, and Yoda follows this philosophy by providing a straightforward graphical user interface (GUI), partially inspired by the Firefox UI. +The Gemini protocol was designed as a minimalistic, tracking-resistant alternative to the Web, and Yoda simply follows this philosophy by providing a straightforward graphical user interface (GUI) that is partially inspired by the Firefox UI. > [!IMPORTANT] > Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)! From 69f2d7c779665d64814f977596393b3d4c73e86d Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 13:10:54 +0200 Subject: [PATCH 017/110] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b941635f..11e0599e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol spe Yoda browser is primarily designed by and for experienced network users who care about their fingerprints and prefer to control every action manually. It does not preload tab content on app opening, does not run any background connections, does not incorporate web-like media preloading without user initiation, and does not automatically check for updates, even from 'official' servers. Additionally, it prevents auto-follow external redirection by default and requires manual confirmation, which is currently not clearly specified. -The Gemini protocol was designed as a minimalistic, tracking-resistant alternative to the Web, and Yoda simply follows this philosophy by providing a straightforward graphical user interface (GUI) that is partially inspired by the Firefox UI. +The Gemini protocol was designed as a minimalistic, tracking-resistant alternative to the Web, and Yoda embraces this philosophy by providing a straightforward graphical user interface (GUI) that is partially inspired by the Firefox UI, making it intuitively comfortable for regular users. > [!IMPORTANT] > Project in development, for stable version use checkpoint [releases](https://github.com/YGGverse/Yoda/releases)! From 3e4b056ca68ba002ba0bbd9f2ff0ae603154a82a Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 15:12:03 +0200 Subject: [PATCH 018/110] add initial logo implementation --- data/io.github.yggverse.Yoda.svg | 229 +++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 data/io.github.yggverse.Yoda.svg diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg new file mode 100644 index 00000000..eee23c7b --- /dev/null +++ b/data/io.github.yggverse.Yoda.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 4551dc6d65ac3a0c0a6f90eea870621474c60d93 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 15:15:28 +0200 Subject: [PATCH 019/110] add logo --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 11e0599e..59761d4f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Yoda - Browser for [Gemini protocol](https://geminiprotocol.net) +Yoda browser logo + Privacy-oriented GTK 4 / Libadwaita client written in Rust. The term _Privacy-oriented_ means that Yoda complies to the [Gemini protocol specification](https://geminiprotocol.net/docs/protocol-specification.gmi) and excludes third-party connections, that making it safe to use in combination with I2P. It also includes useful tools, such as connection details, optional DNS/Geo-IP features, flexible proxy configuration for use with modern IPv6 mesh networks like Yggdrasil, Mycelium, CJDNS, and others. From 4341d3673a8a0e61de447a512dd0a2d37f476ea3 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 15:51:10 +0200 Subject: [PATCH 020/110] update sodipodi:docname --- data/io.github.yggverse.Yoda.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg index eee23c7b..9a550d3a 100644 --- a/data/io.github.yggverse.Yoda.svg +++ b/data/io.github.yggverse.Yoda.svg @@ -9,7 +9,7 @@ version="1.1" id="svg1" inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" - sodipodi:docname="drawing3.svg" + sodipodi:docname="io.github.yggverse.Yoda.svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" From bd8ff00a42a780e61fde9f7b354bd7c4582d8616 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 16:22:47 +0200 Subject: [PATCH 021/110] apply minor corrections --- data/io.github.yggverse.Yoda.svg | 64 ++++++++++---------------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg index 9a550d3a..4388ba37 100644 --- a/data/io.github.yggverse.Yoda.svg +++ b/data/io.github.yggverse.Yoda.svg @@ -1,11 +1,12 @@ + - - - + gradientTransform="translate(-14.289374,-32.738466)" /> + id="layer1" + transform="translate(-12.659744,-33.027687)"> From bd5ada8960b36d2784bfb3f1645fd56a24c0f607 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 16:23:08 +0200 Subject: [PATCH 022/110] enable desktop launcher icon --- data/io.github.yggverse.Yoda.desktop | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/io.github.yggverse.Yoda.desktop b/data/io.github.yggverse.Yoda.desktop index b083f234..ed910e4b 100644 --- a/data/io.github.yggverse.Yoda.desktop +++ b/data/io.github.yggverse.Yoda.desktop @@ -3,7 +3,7 @@ Categories=GNOME;GTK;Network Comment=Browser for Gemini protocol Exec=Yoda GenericName=Browser -#Icon=io.github.yggverse.Yoda +Icon=io.github.yggverse.Yoda Keywords=Gnome;GTK;Gemini;Browser Name=Yoda StartupNotify=true From 3a3bf23e18dd208144db5c10effdeb05d9cd3880 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 16:23:30 +0200 Subject: [PATCH 023/110] enable icon import --- io.github.yggverse.Yoda.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/io.github.yggverse.Yoda.yaml b/io.github.yggverse.Yoda.yaml index 63837ff3..5a523096 100644 --- a/io.github.yggverse.Yoda.yaml +++ b/io.github.yggverse.Yoda.yaml @@ -43,7 +43,7 @@ modules: post-install: - "install -Dm755 ./target/release/Yoda -t /app/bin" - "install -Dm644 ./data/${FLATPAK_ID}.desktop -t /app/share/applications" -# - "install -Dm644 ./data/${FLATPAK_ID}.svg -t /app/share/icons/hicolor/symbolic/apps" + - "install -Dm644 ./data/${FLATPAK_ID}.svg -t /app/share/icons/hicolor/symbolic/apps" sources: - type: "dir" path: "." \ No newline at end of file From 9e2bf7d933fc0402a16d84f4d0a8d568af23518b Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 16:35:34 +0200 Subject: [PATCH 024/110] apply horizontal center correction --- data/io.github.yggverse.Yoda.svg | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg index 4388ba37..94abeaf8 100644 --- a/data/io.github.yggverse.Yoda.svg +++ b/data/io.github.yggverse.Yoda.svg @@ -48,7 +48,7 @@ fy="128.50734" r="95.479652" gradientUnits="userSpaceOnUse" - gradientTransform="translate(-236.64674,-20.367943)" /> + gradientTransform="translate(-236.64674,-15.819246)" /> + gradientTransform="translate(-9.7406772,-32.738466)" /> From aac60bfa03c1935e16ca9269c8096dafbb2da6ac Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 16:50:16 +0200 Subject: [PATCH 025/110] reduce default size to 160 --- data/io.github.yggverse.Yoda.svg | 154 +++++++++++++++---------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg index 94abeaf8..04c7955f 100644 --- a/data/io.github.yggverse.Yoda.svg +++ b/data/io.github.yggverse.Yoda.svg @@ -4,9 +4,9 @@ + gradientTransform="matrix(0.81378396,0,0,0.81378396,-198.7296,-11.501767)" /> + gradientTransform="matrix(0.81378396,0,0,0.81378396,-8.9125751,-26.642049)" /> + transform="translate(-12.659747,-33.027698)"> + transform="rotate(-90)" + r="69.561974" /> + cx="74.791603" + cy="68.597336" + inkscape:label="node-l" + r="3.8494754" /> + cx="114.99722" + cy="78.434883" + inkscape:label="node-k" + r="5.1326337" /> + cx="91.258804" + cy="88.058563" + inkscape:label="node-b" + r="2.352457" /> + cx="80.780128" + cy="80.117638" + inkscape:label="node-center" + r="14.648112" /> + cx="81.539597" + cy="145.35188" + inkscape:label="node-bottom" + r="14.648112" /> + cx="24.39591" + cy="41.427452" + inkscape:label="node-top-left" + r="14.648112" /> + cx="137.51427" + cy="41.843121" + inkscape:label="node-top-right" + r="14.648112" /> From acc38d39f2ed3f39c2b53b08350184d15be36894 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 16:51:20 +0200 Subject: [PATCH 026/110] use markdown format for image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59761d4f..146e4461 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Yoda - Browser for [Gemini protocol](https://geminiprotocol.net) -Yoda browser logo +![Yoda browser logo](https://raw.githubusercontent.com/YGGverse/Yoda/refs/heads/master/data/io.github.yggverse.Yoda.svg) Privacy-oriented GTK 4 / Libadwaita client written in Rust. From 04a71e0527d1a237b36a91e5d2ef7b7064d47c90 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 10 Nov 2025 17:21:20 +0200 Subject: [PATCH 027/110] fix horizontal center --- data/io.github.yggverse.Yoda.svg | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/data/io.github.yggverse.Yoda.svg b/data/io.github.yggverse.Yoda.svg index 04c7955f..22a52952 100644 --- a/data/io.github.yggverse.Yoda.svg +++ b/data/io.github.yggverse.Yoda.svg @@ -48,7 +48,7 @@ fy="128.50734" r="95.479652" gradientUnits="userSpaceOnUse" - gradientTransform="matrix(0.81378396,0,0,0.81378396,-198.7296,-11.501767)" /> + gradientTransform="matrix(0.81378396,0,0,0.81378396,-198.7296,-11.940724)" /> + gradientTransform="matrix(0.81378396,0,0,0.81378396,-9.3515326,-26.642049)" /> From 63b5692d763d72d5b9836fab457d2b479b69b615 Mon Sep 17 00:00:00 2001 From: yggverse Date: Tue, 11 Nov 2025 09:16:16 +0200 Subject: [PATCH 028/110] set application icon --- src/app/browser/about.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/browser/about.rs b/src/app/browser/about.rs index 015e0828..d334032f 100644 --- a/src/app/browser/about.rs +++ b/src/app/browser/about.rs @@ -28,6 +28,7 @@ impl About for adw::AboutDialog { ]; adw::AboutDialog::builder() + .application_icon("io.github.yggverse.Yoda") .application_name(env!("CARGO_PKG_NAME")) .debug_info(debug.join("\n")) .developer_name(env!("CARGO_PKG_DESCRIPTION")) From 3671983372f4cfaeb0917bfaa7395be62f97767c Mon Sep 17 00:00:00 2001 From: yggverse Date: Tue, 11 Nov 2025 09:45:31 +0200 Subject: [PATCH 029/110] update version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e7aff286..58bc5c8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.5" +version = "0.12.6" edition = "2024" license = "MIT" readme = "README.md" From 4d06c727d1752fb5b1a3713626cef5890c24673d Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 15 Nov 2025 17:37:43 +0200 Subject: [PATCH 030/110] remove `set_size_request` solution as fixed --- src/app/browser/window/tab/item/page/input/response/form.rs | 1 - src/app/browser/window/tab/item/page/input/titan/text/form.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/app/browser/window/tab/item/page/input/response/form.rs b/src/app/browser/window/tab/item/page/input/response/form.rs index 378174f9..1feaa884 100644 --- a/src/app/browser/window/tab/item/page/input/response/form.rs +++ b/src/app/browser/window/tab/item/page/input/response/form.rs @@ -40,7 +40,6 @@ impl Form for TextView { .build(); text_view.insert_action_group("spelling", Some(&adapter)); - text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635) // Init events text_view.connect_realize(|this| { diff --git a/src/app/browser/window/tab/item/page/input/titan/text/form.rs b/src/app/browser/window/tab/item/page/input/titan/text/form.rs index 114c0c99..210943d7 100644 --- a/src/app/browser/window/tab/item/page/input/titan/text/form.rs +++ b/src/app/browser/window/tab/item/page/input/titan/text/form.rs @@ -36,7 +36,6 @@ impl Form for TextView { }; text_view.insert_action_group("spelling", Some(&adapter)); - text_view.set_size_request(-1, 38); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635) // Init events text_view.connect_realize(|this| { From 33c7cc5926e694a1c6a92479ad05043c7b5c3761 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 15 Nov 2025 17:58:41 +0200 Subject: [PATCH 031/110] construct with builder --- .../browser/window/tab/item/page/input/response/form.rs | 7 +++++-- .../browser/window/tab/item/page/input/titan/text/form.rs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/browser/window/tab/item/page/input/response/form.rs b/src/app/browser/window/tab/item/page/input/response/form.rs index 1feaa884..aa3af0e9 100644 --- a/src/app/browser/window/tab/item/page/input/response/form.rs +++ b/src/app/browser/window/tab/item/page/input/response/form.rs @@ -23,8 +23,11 @@ impl Form for TextView { // Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling) let checker = Checker::default(); - let adapter = TextBufferAdapter::new(&buffer, &checker); - adapter.set_enabled(true); + let adapter = TextBufferAdapter::builder() + .buffer(&buffer) + .checker(&checker) + .enabled(true) + .build(); // Init main widget let text_view = TextView::builder() diff --git a/src/app/browser/window/tab/item/page/input/titan/text/form.rs b/src/app/browser/window/tab/item/page/input/titan/text/form.rs index 210943d7..bf461456 100644 --- a/src/app/browser/window/tab/item/page/input/titan/text/form.rs +++ b/src/app/browser/window/tab/item/page/input/titan/text/form.rs @@ -16,8 +16,11 @@ impl Form for TextView { // Init [libspelling](https://gitlab.gnome.org/GNOME/libspelling) let checker = Checker::default(); - let adapter = TextBufferAdapter::new(&buffer, &checker); - adapter.set_enabled(true); + let adapter = TextBufferAdapter::builder() + .buffer(&buffer) + .checker(&checker) + .enabled(true) + .build(); // Init main widget From 812553af499fb7da60ca7350d52d0fd329902b4d Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 15 Nov 2025 19:15:22 +0200 Subject: [PATCH 032/110] init TextView as the single-line entry --- src/app/browser/window/tab/item/page/input/response/form.rs | 2 +- src/app/browser/window/tab/item/page/input/titan/text/form.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/browser/window/tab/item/page/input/response/form.rs b/src/app/browser/window/tab/item/page/input/response/form.rs index aa3af0e9..0e0f8f0d 100644 --- a/src/app/browser/window/tab/item/page/input/response/form.rs +++ b/src/app/browser/window/tab/item/page/input/response/form.rs @@ -36,9 +36,9 @@ impl Form for TextView { .css_classes(["frame", "view"]) .extra_menu(&adapter.menu_model()) .left_margin(MARGIN) - .margin_bottom(MARGIN / 4) .right_margin(MARGIN) .top_margin(MARGIN) + .valign(gtk::Align::BaselineCenter) .wrap_mode(WrapMode::Word) .build(); diff --git a/src/app/browser/window/tab/item/page/input/titan/text/form.rs b/src/app/browser/window/tab/item/page/input/titan/text/form.rs index bf461456..44d2cbf5 100644 --- a/src/app/browser/window/tab/item/page/input/titan/text/form.rs +++ b/src/app/browser/window/tab/item/page/input/titan/text/form.rs @@ -34,6 +34,7 @@ impl Form for TextView { .left_margin(MARGIN) .right_margin(MARGIN) .top_margin(MARGIN) + .valign(gtk::Align::BaselineCenter) .wrap_mode(WrapMode::Word) .build() }; From ffb1474c7e0de3600c354c3fb4dc73520da04604 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 15 Nov 2025 19:41:47 +0200 Subject: [PATCH 033/110] show multi-line entry for the titan input by default --- src/app/browser/window/tab/item/page/input/titan/text/form.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/browser/window/tab/item/page/input/titan/text/form.rs b/src/app/browser/window/tab/item/page/input/titan/text/form.rs index 44d2cbf5..51df4521 100644 --- a/src/app/browser/window/tab/item/page/input/titan/text/form.rs +++ b/src/app/browser/window/tab/item/page/input/titan/text/form.rs @@ -34,7 +34,7 @@ impl Form for TextView { .left_margin(MARGIN) .right_margin(MARGIN) .top_margin(MARGIN) - .valign(gtk::Align::BaselineCenter) + .valign(gtk::Align::Fill) .wrap_mode(WrapMode::Word) .build() }; From 4a94cd416112457eaa96181c6bb8e09c0fbacc08 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 15 Nov 2025 19:54:22 +0200 Subject: [PATCH 034/110] remove ugly gnome's ScrolledWindow + TextView integration --- src/app/browser/window/tab/item/page/input.rs | 8 +------- .../browser/window/tab/item/page/input/response.rs | 12 ++---------- .../window/tab/item/page/input/response/form.rs | 2 ++ 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/app/browser/window/tab/item/page/input.rs b/src/app/browser/window/tab/item/page/input.rs index f03994e1..e13eb2c8 100644 --- a/src/app/browser/window/tab/item/page/input.rs +++ b/src/app/browser/window/tab/item/page/input.rs @@ -61,13 +61,7 @@ impl Input { title: Option<&str>, size_limit: Option, ) { - self.update(Some(>k::Box::response( - action, - base, - title, - size_limit, - MAX_CONTENT_HEIGHT, - ))); + self.update(Some(>k::Box::response(action, base, title, size_limit))); } pub fn set_new_sensitive( diff --git a/src/app/browser/window/tab/item/page/input/response.rs b/src/app/browser/window/tab/item/page/input/response.rs index 32f6a365..2c602616 100644 --- a/src/app/browser/window/tab/item/page/input/response.rs +++ b/src/app/browser/window/tab/item/page/input/response.rs @@ -23,7 +23,6 @@ pub trait Response { base: Uri, title: Option<&str>, size_limit: Option, - max_content_height: i32, ) -> Self; } @@ -36,7 +35,6 @@ impl Response for Box { base: Uri, title: Option<&str>, size_limit: Option, - max_content_height: i32, ) -> Self { // Init components let control = Rc::new(Control::build()); @@ -49,18 +47,12 @@ impl Response for Box { .margin_end(MARGIN) .margin_start(MARGIN) .margin_top(MARGIN) - .spacing(SPACING) .orientation(Orientation::Vertical) + .spacing(SPACING) .build(); g_box.append(&title); - g_box.append( - >k::ScrolledWindow::builder() - .child(&text_view) - .max_content_height(max_content_height) - .propagate_natural_height(true) - .build(), - ); + g_box.append(&text_view); g_box.append(&control.g_box); // Init events diff --git a/src/app/browser/window/tab/item/page/input/response/form.rs b/src/app/browser/window/tab/item/page/input/response/form.rs index 0e0f8f0d..3200e818 100644 --- a/src/app/browser/window/tab/item/page/input/response/form.rs +++ b/src/app/browser/window/tab/item/page/input/response/form.rs @@ -36,6 +36,7 @@ impl Form for TextView { .css_classes(["frame", "view"]) .extra_menu(&adapter.menu_model()) .left_margin(MARGIN) + .margin_bottom(MARGIN / 4) .right_margin(MARGIN) .top_margin(MARGIN) .valign(gtk::Align::BaselineCenter) @@ -43,6 +44,7 @@ impl Form for TextView { .build(); text_view.insert_action_group("spelling", Some(&adapter)); + text_view.set_size_request(-1, 36); // @TODO [#635](https://gitlab.gnome.org/GNOME/pygobject/-/issues/635) // Init events text_view.connect_realize(|this| { From 8e56daa2437a4738e941f1cb5ea57deb337ba604 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 15 Nov 2025 20:33:22 +0200 Subject: [PATCH 035/110] update version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 58bc5c8a..1af99689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.6" +version = "0.12.7" edition = "2024" license = "MIT" readme = "README.md" From fd6b9edb3550f600f4d39f11fa2fce95e33078b6 Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 8 Dec 2025 09:49:46 +0200 Subject: [PATCH 036/110] remove custom icon (multi-platform defaults issue) --- src/app/browser/window/tab.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/app/browser/window/tab.rs b/src/app/browser/window/tab.rs index c9dd25cb..60d54b86 100644 --- a/src/app/browser/window/tab.rs +++ b/src/app/browser/window/tab.rs @@ -10,7 +10,6 @@ use adw::{TabPage, TabView}; use anyhow::Result; use gtk::{ Box, Orientation, - gio::Icon, glib::Propagation, prelude::{ActionExt, EditableExt, EntryExt, WidgetExt}, }; @@ -44,13 +43,6 @@ impl Tab { .menu_model(>k::gio::Menu::menu(window_action)) .build(); - // Change default icon (if available in the system icon set) - // * visible for pinned tabs only - // * @TODO not default GTK behavior, make this feature optional - if let Ok(default_icon) = Icon::for_string("view-pin-symbolic") { - tab_view.set_default_icon(&default_icon); - } - // Init events tab_view.connect_setup_menu({ let index = index.clone(); From 47e686dc29bd26b18a00cbba6f44e2609dfc5573 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 1 Mar 2026 14:56:45 +0200 Subject: [PATCH 037/110] add `Cargo.lock` --- .gitignore | 1 - Cargo.lock | 1751 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1751 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock diff --git a/.gitignore b/.gitignore index fe21aa37..2f3e926d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *flatpak* build -Cargo.lock repo target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..16f7ce0a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1751 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Yoda" +version = "0.12.7" +dependencies = [ + "ansi-parser", + "anyhow", + "ggemini", + "ggemtext", + "gtk4", + "indexmap", + "itertools", + "libadwaita", + "libspelling", + "maxminddb", + "openssl", + "plurify", + "r2d2", + "r2d2_sqlite", + "rusqlite", + "sourceview5", + "syntect", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ansi-parser" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43e7fd8284f025d0bd143c2855618ecdf697db55bde39211e5c9faec7669173" +dependencies = [ + "heapless", + "nom", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cairo-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ggemini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c455ed1aa0c9e3d17431a14797ca1c5950dca1e02a8f07b2faf6d6c696f12f" +dependencies = [ + "gio", + "glib", +] + +[[package]] +name = "ggemtext" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bc3f1aec690916cf0c078039df3e28647ffe0e883ec013c080a4d1e0f0fae13" +dependencies = [ + "glib", +] + +[[package]] +name = "gio" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libadwaita" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb09e12bf8f73342b3315c839d0a7668cc0ccebd78490c49fec48bab15d5484b" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7f94227ba87eb596fecada2491f04e357d507324142f77bf76d9e6be4a3e31" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libspelling" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc92df39e5bcfed67e8263756b1f4d4f839370baf8a493cbd958e192944c85c2" +dependencies = [ + "gio", + "glib", + "gtk4", + "libc", + "libspelling-sys", + "sourceview5", +] + +[[package]] +name = "libspelling-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "286fffcc712568745b5c7f74063f84d99b2094c49345966b1fc12ff1a216fa5f" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "sourceview5-sys", + "system-deps", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "maxminddb" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a197e44322788858682406c74b0b59bf8d9b4954fe1f224d9a25147f1880bba" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", + "thiserror", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pango" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "plurify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0642d69883c3a79565ea9b89a9cd8a845c79a7be17fd4fb8c6347b329f03455b" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63417e83dc891797eea3ad379f52a5986da4bca0d6ef28baf4d14034dd111b0c" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +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", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sourceview5" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4acb02162917d7b689c84f4ed710c22302c67c7e61da28a4e8ac548c04442c" +dependencies = [ + "futures-channel", + "futures-core", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libc", + "pango", + "sourceview5-sys", +] + +[[package]] +name = "sourceview5-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c1529de5df2653788828ef71d44b113bb80e496bc17315f4122106bd6eebae" +dependencies = [ + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "rand", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" From 0357edccfe5cfa636ed7aac71aecf13c612519f0 Mon Sep 17 00:00:00 2001 From: yggverse Date: Tue, 3 Mar 2026 23:44:30 +0200 Subject: [PATCH 038/110] update versions --- Cargo.lock | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16f7ce0a..bf1cdd33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,19 +357,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1056,6 +1056,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "r2d2" version = "0.8.10" @@ -1469,7 +1475,7 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "rand", "wasm-bindgen", From 1d6cfb88ef5d97a1d69507464c6c93c16d3bf53a Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 4 Mar 2026 00:26:56 +0200 Subject: [PATCH 039/110] implement context menu for the gemtext viewer link tags --- .../tab/item/page/content/text/gemini.rs | 256 ++++++++++++++---- 1 file changed, 203 insertions(+), 53 deletions(-) diff --git a/src/app/browser/window/tab/item/page/content/text/gemini.rs b/src/app/browser/window/tab/item/page/content/text/gemini.rs index a5e58a1c..a89e2fff 100644 --- a/src/app/browser/window/tab/item/page/content/text/gemini.rs +++ b/src/app/browser/window/tab/item/page/content/text/gemini.rs @@ -5,23 +5,23 @@ mod icon; mod syntax; mod tag; -pub use error::Error; -use gutter::Gutter; -use icon::Icon; -use syntax::Syntax; -use tag::Tag; - use super::{ItemAction, WindowAction}; use crate::app::browser::window::action::Position; +pub use error::Error; use gtk::{ EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType, UriLauncher, Window, WrapMode, - gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA}, - gio::Cancellable, - glib::Uri, - prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, + gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, + gio::{Cancellable, SimpleAction, SimpleActionGroup}, + glib::{Uri, uuid_string_random}, + prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, }; +use gutter::Gutter; +use icon::Icon; +use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; +use syntax::Syntax; +use tag::Tag; pub const NEW_LINE: &str = "\n"; @@ -284,14 +284,113 @@ impl Gemini { buffer.insert(&mut buffer.end_iter(), NEW_LINE); } + // Context menu + let action_link_tab = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_tab.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &this.state().unwrap().get::().unwrap(), + &window_action, + ) + } + }); + let action_link_copy = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy.connect_activate(|this, _| { + gtk::gdk::Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().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); + 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(); + m.append( + Some("Open Link in New Tab"), + Some(&format!( + "{link_context_group_id}.{}", + 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 + })); + link_context.set_parent(&text_view); + // Init additional controllers - let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build(); let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build(); + let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build(); + let secondary_button_controller = GestureClick::builder() + .button(BUTTON_SECONDARY) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); let motion_controller = EventControllerMotion::new(); - text_view.add_controller(primary_button_controller.clone()); text_view.add_controller(middle_button_controller.clone()); text_view.add_controller(motion_controller.clone()); + text_view.add_controller(primary_button_controller.clone()); + text_view.add_controller(secondary_button_controller.clone()); // Init shared reference container for HashTable collected let links = Rc::new(links); @@ -308,27 +407,46 @@ impl Gemini { window_x as i32, window_y as i32, ); - if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { for tag in iter.tags() { // Tag is link if let Some(uri) = links.get(&tag) { - // Select link handler by scheme - return match uri.scheme().as_str() { - "gemini" | "titan" | "nex" | "file" => { - item_action.load.activate(Some(&uri.to_str()), true, false) - } - // Scheme not supported, delegate - _ => UriLauncher::new(&uri.to_str()).launch( - Window::NONE, - Cancellable::NONE, - |result| { - if let Err(e) = result { - println!("{e}") - } - }, - ), - }; // @TODO common handler? + return open_link_in_current_tab(&uri.to_string(), &item_action); + } + } + } + } + }); + + secondary_button_controller.connect_pressed({ + let links = links.clone(); + let text_view = text_view.clone(); + let link_context = link_context.clone(); + move |_, _, window_x, window_y| { + let x = window_x as i32; + let y = window_y as i32; + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = + text_view.window_to_buffer_coords(TextWindowType::Widget, x, y); + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + let request_str = uri.to_str(); + let request_var = request_str.to_variant(); + + action_link_tab.set_state(&request_var); + action_link_copy.set_state(&request_var); + + action_link_download.set_state(&request_var); + action_link_download.set_enabled(is_prefixable_link(&request_str)); + + action_link_source.set_state(&request_var); + action_link_source.set_enabled(is_prefixable_link(&request_str)); + + link_context + .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); + link_context.popup(); } } } @@ -350,30 +468,7 @@ impl Gemini { for tag in iter.tags() { // Tag is link if let Some(uri) = links.get(&tag) { - // Select link handler by scheme - return match uri.scheme().as_str() { - "gemini" | "titan" | "nex" | "file" => { - // Open new page in browser - window_action.append.activate_stateful_once( - Position::After, - Some(uri.to_string()), - false, - false, - true, - true, - ); - } - // Scheme not supported, delegate - _ => UriLauncher::new(&uri.to_str()).launch( - Window::NONE, - Cancellable::NONE, - |result| { - if let Err(e) = result { - println!("{e}") - } - }, - ), - }; // @TODO common handler? + return open_link_in_new_tab(&uri.to_string(), &window_action); } } } @@ -432,3 +527,58 @@ impl Gemini { } } } + +fn is_internal_link(request: &str) -> bool { + // schemes + request.starts_with("gemini://") + || request.starts_with("titan://") + || request.starts_with("nex://") + || request.starts_with("file://") + // prefix + || request.starts_with("download:") + || request.starts_with("source:") +} + +fn is_prefixable_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_PREFIX_DOWNLOAD: &str = "download:"; +const LINK_PREFIX_SOURCE: &str = "source:"; From f4416c7af9a5795368c629cf213fac8d3c708cb6 Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 4 Mar 2026 13:02:25 +0200 Subject: [PATCH 040/110] update some dependencies --- Cargo.lock | 58 ++++++++++++++++++++++++++++++++++++++++++------------ Cargo.toml | 6 +++--- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf1cdd33..56732b8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,6 +215,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -599,7 +605,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -607,14 +613,17 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -756,9 +765,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "pkg-config", "vcpkg", @@ -787,9 +796,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "maxminddb" -version = "0.26.0" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a197e44322788858682406c74b0b59bf8d9b4954fe1f224d9a25147f1880bba" +checksum = "76371bd37ce742f8954daabd0fde7f1594ee43ac2200e20c003ba5c3d65e2192" dependencies = [ "ipnetwork", "log", @@ -1075,9 +1084,9 @@ dependencies = [ [[package]] name = "r2d2_sqlite" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63417e83dc891797eea3ad379f52a5986da4bca0d6ef28baf4d14034dd111b0c" +checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" dependencies = [ "r2d2", "rusqlite", @@ -1129,10 +1138,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] -name = "rusqlite" -version = "0.37.0" +name = "rsqlite-vfs" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags", "fallible-iterator", @@ -1140,6 +1159,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -1298,6 +1318,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 1af99689..5ad72c3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ features = ["gnome_46"] [dependencies.sqlite] package = "rusqlite" -version = "0.37.0" +version = "0.38.0" [dependencies.sourceview] package = "sourceview5" @@ -36,11 +36,11 @@ ggemtext = "0.7.0" indexmap = "2.10.0" itertools = "0.14.0" libspelling = "0.4.1" -maxminddb = "0.26.0" +maxminddb = "0.27.3" openssl = "0.10.72" plurify = "0.2.0" r2d2 = "0.8.10" -r2d2_sqlite = "0.31.0" +r2d2_sqlite = "0.32.0" syntect = "5.2.0" # development From d512e94db157a021a0c690b5a0ecb38fdb1822c1 Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 4 Mar 2026 13:34:51 +0200 Subject: [PATCH 041/110] update crates api --- .../page/navigation/request/info/dialog.rs | 30 +++++++------------ src/profile/history/database.rs | 2 +- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs b/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs index 89ee25db..e3d56d8a 100644 --- a/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs +++ b/src/app/browser/window/tab/item/page/navigation/request/info/dialog.rs @@ -121,7 +121,7 @@ impl Dialog for PreferencesDialog { /// Lookup [MaxMind](https://www.maxmind.com) database fn l(profile: &Profile, socket_address: &SocketAddress) -> Option { use maxminddb::{ - MaxMindDbError, Reader, + Reader, geoip2::{/*City,*/ Country}, }; if !matches!( @@ -136,26 +136,16 @@ impl Dialog for PreferencesDialog { Reader::open_readfile(c) } .ok()?; - let lookup = { - let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap(); - let lookup: std::result::Result, MaxMindDbError> = - db.lookup(a.ip()); - lookup + let a: std::net::SocketAddr = socket_address.to_string().parse().unwrap(); + let c: Country = db.lookup(a.ip()).ok()?.decode().ok()??; + let mut b = Vec::new(); + if let Some(iso_code) = c.country.iso_code { + b.push(iso_code); } - .ok()??; - lookup.country.map(|c| { - let mut b = Vec::new(); - if let Some(iso_code) = c.iso_code { - b.push(iso_code) - } - if let Some(n) = c.names - && let Some(s) = n.get("en") - { - b.push(s) - } // @TODO multi-lang - // @TODO city DB - b.join(", ") - }) + if let Some(name_en) = c.country.names.english { + b.push(name_en); + } + b.join(", ").into() } p.add(&{ let g = PreferencesGroup::builder().title("Remote").build(); diff --git a/src/profile/history/database.rs b/src/profile/history/database.rs index ec77a840..ed6fd1ea 100644 --- a/src/profile/history/database.rs +++ b/src/profile/history/database.rs @@ -151,7 +151,7 @@ pub fn select( //profile_id: row.get(1)?, opened: Event { time: DateTime::from_unix_local(row.get(2)?).unwrap(), - count: row.get(3)?, + count: row.get::<_, i64>(3)? as usize, }, closed: closed(row.get(4)?, row.get(5)?), request: row.get::<_, String>(6)?.into(), From 3077c3b033ad69577d26e6b3d484379f27936eb0 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 02:24:54 +0200 Subject: [PATCH 042/110] update version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5ad72c3a..a419e8d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.7" +version = "0.12.8" edition = "2024" license = "MIT" readme = "README.md" From 6fb7e70213e872cc7b47951526a24610606f62bc Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 02:46:18 +0200 Subject: [PATCH 043/110] update `Cargo.lock` --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 56732b8b..d07af96f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Yoda" -version = "0.12.7" +version = "0.12.8" dependencies = [ "ansi-parser", "anyhow", From fc6cce80726150f577dc8d697aaaffc80ab2bff0 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 02:53:33 +0200 Subject: [PATCH 044/110] init `text/markdown` parser (based on `text/gemini`) --- .../window/tab/item/client/driver/file.rs | 12 + .../tab/item/client/driver/file/text.rs | 9 + .../window/tab/item/client/driver/gemini.rs | 1 + .../browser/window/tab/item/page/content.rs | 29 + .../page/content/directory/column/format.rs | 2 + .../window/tab/item/page/content/text.rs | 30 + .../tab/item/page/content/text/markdown.rs | 584 ++++++++++++++++++ .../item/page/content/text/markdown/ansi.rs | 33 + .../page/content/text/markdown/ansi/rgba.rs | 256 ++++++++ .../page/content/text/markdown/ansi/tag.rs | 29 + .../item/page/content/text/markdown/error.rs | 3 + .../item/page/content/text/markdown/gutter.rs | 68 ++ .../item/page/content/text/markdown/icon.rs | 31 + .../item/page/content/text/markdown/parser.rs | 5 + .../item/page/content/text/markdown/syntax.rs | 152 +++++ .../content/text/markdown/syntax/error.rs | 18 + .../page/content/text/markdown/syntax/tag.rs | 29 + .../item/page/content/text/markdown/tag.rs | 67 ++ .../page/content/text/markdown/tag/header.rs | 37 ++ .../page/content/text/markdown/tag/list.rs | 16 + .../page/content/text/markdown/tag/plain.rs | 11 + .../page/content/text/markdown/tag/quote.rs | 14 + .../page/content/text/markdown/tag/title.rs | 16 + 23 files changed, 1452 insertions(+) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/ansi.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/ansi/rgba.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/ansi/tag.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/error.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/gutter.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/icon.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/parser.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/syntax.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/syntax/error.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/syntax/tag.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/list.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/plain.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/title.rs 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..8321539d 100644 --- a/src/app/browser/window/tab/item/client/driver/file.rs +++ b/src/app/browser/window/tab/item/client/driver/file.rs @@ -94,6 +94,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 { 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..36dba3dc 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,6 +2,7 @@ use gtk::glib::Uri; pub enum Text { Gemini(Uri, String), + Markdown(Uri, String), Plain(Uri, String), Source(Uri, String), } @@ -22,6 +23,14 @@ impl Text { .set_mime(Some("text/gemini".to_string())); page.content.to_text_gemini(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(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..1c34dc73 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -358,6 +358,7 @@ fn handle( } else { match m.as_str() { "text/gemini" => page.content.to_text_gemini(&uri, data), + "text/markdown" => page.content.to_text_markdown(&uri, data), "text/plain" => page.content.to_text_plain(data), _ => panic!() // unexpected } diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index 016121dd..05247888 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -154,6 +154,35 @@ impl Content { } } + /// `text/markdown` + pub fn to_text_markdown(&self, base: &Uri, data: &str) -> Text { + self.clean(); + match Text::markdown((&self.window_action, &self.item_action), base, data) { + Ok(text) => { + self.g_box.append(&text.scrolled_window); + text + } + Err((message, text)) => { + self.g_box.append(&{ + let banner = adw::Banner::builder() + .title(message) + .revealed(true) + .button_label("Ok") + .build(); + banner.connect_button_clicked(|this| this.set_revealed(false)); + banner + }); + match text { + Some(text) => { + self.g_box.append(&text.scrolled_window); + text + } + None => todo!(), + } + } + } + } + /// `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..53730409 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -1,4 +1,5 @@ mod gemini; +mod markdown; mod nex; mod plain; mod source; @@ -7,6 +8,7 @@ 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; @@ -51,6 +53,34 @@ impl Text { } } + pub fn markdown( + actions: (&Rc, &Rc), + base: &Uri, + gemtext: &str, + ) -> Result)> { + match Markdown::build(actions, base, gemtext) { + Ok(widget) => Ok(Self { + scrolled_window: reader(&widget.text_view), + text_view: widget.text_view, + meta: Meta { + title: widget.title, + }, + }), + Err(e) => match e { + markdown::Error::Markup(message, widget) => Err(( + message, + Some(Self { + scrolled_window: reader(&widget.text_view), + text_view: widget.text_view, + meta: Meta { + title: widget.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/markdown.rs b/src/app/browser/window/tab/item/page/content/text/markdown.rs new file mode 100644 index 00000000..30c57661 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -0,0 +1,584 @@ +mod ansi; +pub mod error; +mod gutter; +mod icon; +mod syntax; +mod tag; + +use super::{ItemAction, WindowAction}; +use crate::app::browser::window::action::Position; +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}, + glib::{Uri, uuid_string_random}, + prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, +}; +use gutter::Gutter; +use icon::Icon; +use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; +use std::{cell::Cell, collections::HashMap, rc::Rc}; +use syntax::Syntax; +use tag::Tag; + +pub const NEW_LINE: &str = "\n"; + +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), + base: &Uri, + gemtext: &str, + ) -> Result { + // Init default values + let mut title = None; + + // Init HashMap storage (for event controllers) + let mut links: 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 code features + let mut code = None; + + // Init quote icon feature + let mut is_line_after_quote = false; + + // 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 syntect highlight features + let syntax = Syntax::new(); + + // Init icons + let icon = Icon::new(); + + // Init tags + let tag = Tag::new(); + + // Init new text buffer + let buffer = TextBuffer::new(Some(&tag.text_tag_table)); + + // 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); + + // Disable code format on at least one closing tag not found + // gemini://bbs.geminispace.org/s/Gemini/26031 + let is_code_enabled = { + use ggemtext::line::code::{self}; + let mut t: usize = 0; + for l in gemtext.lines() { + if l.starts_with(code::TAG) { + t += 1; + } + } + t == 0 || t.is_multiple_of(2) + }; + + // Parse gemtext lines + for line in gemtext.lines() { + if is_code_enabled { + use ggemtext::line::Code; + match code { + None => { + // Open tag found + if let Some(c) = Code::begin_from(line) { + // Begin next lines collection into the code buffer + code = Some(c); + + // Skip other actions for this line + continue; + } + } + Some(ref mut c) => { + match c.continue_from(line) { + Ok(()) => { + // Close tag found: + if c.is_completed { + // Is alt provided + let alt = match c.alt { + Some(ref alt) => { + // Insert alt value to the main buffer + buffer.insert_with_tags( + &mut buffer.end_iter(), + alt.as_str(), + &[&tag.title], + ); + + // Append new line after alt text + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + + // Return value as wanted also for syntax highlight detection + Some(alt) + } + None => None, + }; + + // Begin code block construction + // Try auto-detect code syntax for given `value` and `alt` @TODO optional + 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 + buffer.insert_with_tags( + &mut buffer.end_iter(), + &entity, + &[&syntax_tag], + ); + } + } + 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 + buffer.insert_with_tags( + &mut buffer.end_iter(), + &entity, + &[&syntax_tag], + ); + } + } // @TODO handle + } + + // Reset + code = None; + } + + // Skip other actions for this line + continue; + } + Err(_) => todo!(), + } + } + } + } + + // Is header + { + use ggemtext::line::{Header, header::Level}; + if let Some(header) = Header::parse(line) { + buffer.insert_with_tags( + &mut buffer.end_iter(), + &header.value, + &[match header.level { + Level::H1 => &tag.h1, + Level::H2 => &tag.h2, + Level::H3 => &tag.h3, + }], + ); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + + if title.is_none() { + title = Some(header.value.clone()); + } + continue; + } + } + + // Is link + if let Some(link) = ggemtext::line::Link::parse(line) { + if let Some(uri) = link.uri(Some(base)) { + let mut alt = Vec::new(); + + if uri.scheme() != base.scheme() { + alt.push("⇖".to_string()); + } + + alt.push(match link.alt { + Some(alt) => alt, + None => uri.to_string(), + }); + + let a = TextTag::builder() + .foreground_rgba(&link_color.0) + // .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) @TODO adw 1.6 / ubuntu 24.10+ + .sentence(true) + .wrap_mode(WrapMode::Word) + .build(); + + if !tag.text_tag_table.add(&a) { + panic!() + } + + buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + + links.insert(a, uri); + } + continue; + } + + // Is list + + if let Some(value) = ggemtext::line::list::Gemtext::as_value(line) { + buffer.insert_with_tags( + &mut buffer.end_iter(), + &format!("• {value}"), + &[&tag.list], + ); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + continue; + } + + // Is quote + + if let Some(quote) = ggemtext::line::quote::Gemtext::as_value(line) { + // Show quote indicator if last line is not quote (to prevent duplicates) + if !is_line_after_quote { + // Show only if the icons resolved for default `Display` + if let Some(ref icon) = icon { + buffer.insert_paintable(&mut buffer.end_iter(), &icon.quote); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + } + } + buffer.insert_with_tags(&mut buffer.end_iter(), quote, &[&tag.quote]); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + is_line_after_quote = true; + continue; + } else { + is_line_after_quote = false; + } + + // Nothing match custom tags above, + // just append plain text covered in empty tag (to handle controller events properly) + buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + } + + // Context menu + let action_link_tab = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_tab.connect_activate({ + let window_action = window_action.clone(); + move |this, _| { + open_link_in_new_tab( + &this.state().unwrap().get::().unwrap(), + &window_action, + ) + } + }); + let action_link_copy = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_link_copy.connect_activate(|this, _| { + gtk::gdk::Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().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); + 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(); + m.append( + Some("Open Link in New Tab"), + Some(&format!( + "{link_context_group_id}.{}", + 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 + })); + 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); + + // Init events + primary_button_controller.connect_released({ + let item_action = item_action.clone(); + let links = links.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 open_link_in_current_tab(&uri.to_string(), &item_action); + } + } + } + } + }); + + secondary_button_controller.connect_pressed({ + let links = links.clone(); + let text_view = text_view.clone(); + let link_context = link_context.clone(); + move |_, _, window_x, window_y| { + let x = window_x as i32; + let y = window_y as i32; + // Detect tag match current coords hovered + let (buffer_x, buffer_y) = + text_view.window_to_buffer_coords(TextWindowType::Widget, x, y); + if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) { + for tag in iter.tags() { + // Tag is link + if let Some(uri) = links.get(&tag) { + let request_str = uri.to_str(); + let request_var = request_str.to_variant(); + + action_link_tab.set_state(&request_var); + action_link_copy.set_state(&request_var); + + action_link_download.set_state(&request_var); + action_link_download.set_enabled(is_prefixable_link(&request_str)); + + action_link_source.set_state(&request_var); + action_link_source.set_enabled(is_prefixable_link(&request_str)); + + link_context + .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); + link_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? + + // Result + if is_code_enabled { + Ok(Self { text_view, title }) + } else { + Err(Error::Markup( + "Invalid multiline markup! Gemtext format partially ignored.".to_string(), + Self { text_view, title }, + )) + } + } +} + +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_prefixable_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_PREFIX_DOWNLOAD: &str = "download:"; +const LINK_PREFIX_SOURCE: &str = "source:"; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/ansi.rs b/src/app/browser/window/tab/item/page/content/text/markdown/ansi.rs new file mode 100644 index 00000000..b617b69a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/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/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/markdown/ansi/rgba.rs new file mode 100644 index 00000000..d1398d2f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/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/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/ansi/tag.rs new file mode 100644 index 00000000..7154b1f3 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/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/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/error.rs new file mode 100644 index 00000000..e2b6650a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/error.rs @@ -0,0 +1,3 @@ +pub enum Error { + Markup(String, super::Markdown), +} 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/icon.rs b/src/app/browser/window/tab/item/page/content/text/markdown/icon.rs new file mode 100644 index 00000000..a85ec38f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/icon.rs @@ -0,0 +1,31 @@ +use gtk::{IconLookupFlags, IconPaintable, IconTheme, TextDirection, gdk::Display}; + +const SIZE: i32 = 16; + +/// Indication icons asset (for tag blocks decoration) +pub struct Icon { + pub quote: IconPaintable, + // @TODO other tags.. +} + +impl Icon { + pub fn new() -> Option { + Display::default().map(|display| { + let theme = IconTheme::for_display(&display); + Self { + quote: icon(&theme, "mail-forward-symbolic"), + } + }) + } +} + +fn icon(theme: &IconTheme, name: &str) -> IconPaintable { + theme.lookup_icon( + name, + &[], // @TODO + SIZE, + SIZE, + TextDirection::None, + IconLookupFlags::FORCE_SYMBOLIC, + ) +} 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/syntax.rs b/src/app/browser/window/tab/item/page/content/text/markdown/syntax.rs new file mode 100644 index 00000000..50de853d --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/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/syntax/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/syntax/error.rs new file mode 100644 index 00000000..ae9bfdb6 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/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/syntax/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/syntax/tag.rs new file mode 100644 index 00000000..4b2011b8 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/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/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs new file mode 100644 index 00000000..f917b5f7 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs @@ -0,0 +1,67 @@ +mod header; +mod list; +mod plain; +mod quote; +mod title; + +use gtk::{TextTag, TextTagTable}; +use header::Header; +use list::List; +use plain::Plain; +use quote::Quote; +use title::Title; + +pub struct Tag { + pub text_tag_table: TextTagTable, + // Tags + pub h1: TextTag, + pub h2: TextTag, + pub h3: TextTag, + pub list: TextTag, + pub quote: TextTag, + pub title: TextTag, + pub plain: TextTag, +} + +impl Default for Tag { + fn default() -> Self { + Self::new() + } +} + +impl Tag { + // Construct + pub fn new() -> Self { + // Init components + let h1 = TextTag::h1(); + let h2 = TextTag::h2(); + let h3 = TextTag::h3(); + let list = TextTag::list(); + let quote = TextTag::quote(); + let title = TextTag::title(); + let plain = TextTag::plain(); + + // Init tag table + let text_tag_table = TextTagTable::new(); + + text_tag_table.add(&h1); + text_tag_table.add(&h2); + text_tag_table.add(&h3); + text_tag_table.add(&title); + text_tag_table.add(&list); + text_tag_table.add("e); + text_tag_table.add(&plain); + + Self { + text_tag_table, + // Tags + h1, + h2, + h3, + list, + quote, + title, + plain, + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs new file mode 100644 index 00000000..8f4c992b --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs @@ -0,0 +1,37 @@ +use gtk::{TextTag, WrapMode}; + +pub trait Header { + fn h1() -> Self; + fn h2() -> Self; + fn h3() -> Self; +} + +impl Header for TextTag { + fn h1() -> Self { + TextTag::builder() + .foreground("#2190a4") // @TODO optional + .scale(1.6) + .sentence(true) + .weight(500) + .wrap_mode(WrapMode::Word) + .build() + } + fn h2() -> Self { + TextTag::builder() + .foreground("#d56199") // @TODO optional + .scale(1.4) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build() + } + fn h3() -> Self { + TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.2) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build() + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/list.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/list.rs new file mode 100644 index 00000000..cba74053 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/list.rs @@ -0,0 +1,16 @@ +use gtk::{TextTag, WrapMode}; + +pub trait List { + fn list() -> Self; +} + +impl List for TextTag { + fn list() -> Self { + TextTag::builder() + .left_margin(28) + .pixels_above_lines(4) + .pixels_below_lines(4) + .wrap_mode(WrapMode::Word) + .build() + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/plain.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/plain.rs new file mode 100644 index 00000000..dfc7374c --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/plain.rs @@ -0,0 +1,11 @@ +use gtk::{TextTag, WrapMode}; + +pub trait Plain { + fn plain() -> Self; +} + +impl Plain for TextTag { + fn plain() -> Self { + TextTag::builder().wrap_mode(WrapMode::Word).build() + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs new file mode 100644 index 00000000..58d41e28 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs @@ -0,0 +1,14 @@ +use gtk::{TextTag, WrapMode}; + +pub trait Quote { + fn quote() -> Self; +} + +impl Quote for TextTag { + fn quote() -> Self { + TextTag::builder() + .left_margin(28) + .wrap_mode(WrapMode::Word) + .build() + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/title.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/title.rs new file mode 100644 index 00000000..ed0072fe --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/title.rs @@ -0,0 +1,16 @@ +use gtk::{TextTag, WrapMode}; + +pub trait Title { + fn title() -> Self; +} + +impl Title for TextTag { + fn title() -> Self { + TextTag::builder() + .pixels_above_lines(4) + .pixels_below_lines(8) + .weight(500) + .wrap_mode(WrapMode::None) + .build() + } +} From 191057cc503aa16ac7f1bac818806761ba7e5401 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 03:02:00 +0200 Subject: [PATCH 045/110] fix content detection rules --- .../window/tab/item/client/driver/file.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 8321539d..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| { @@ -119,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) => { From 31346d1d63da8b875318995a2ac85a1105065d29 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 05:04:50 +0200 Subject: [PATCH 046/110] implement 1-6 level header tags --- .../tab/item/page/content/text/markdown.rs | 62 ++++++++++++------- .../item/page/content/text/markdown/tag.rs | 12 ++++ .../page/content/text/markdown/tag/header.rs | 30 +++++++++ 3 files changed, 82 insertions(+), 22 deletions(-) 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 index 30c57661..0435dd64 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -37,8 +37,22 @@ impl Markdown { pub fn build( (window_action, item_action): (&Rc, &Rc), base: &Uri, - gemtext: &str, + markdown: &str, ) -> Result { + /// Header tag + fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option { + if let Some(h) = line.trim_start().strip_prefix(pattern) + && !h.starts_with(pattern) + { + let header = h.trim(); + buffer.insert_with_tags(&mut buffer.end_iter(), header, &[tag]); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + Some(header.into()) + } else { + None + } + } + // Init default values let mut title = None; @@ -98,7 +112,7 @@ impl Markdown { let is_code_enabled = { use ggemtext::line::code::{self}; let mut t: usize = 0; - for l in gemtext.lines() { + for l in markdown.lines() { if l.starts_with(code::TAG) { t += 1; } @@ -106,8 +120,8 @@ impl Markdown { t == 0 || t.is_multiple_of(2) }; - // Parse gemtext lines - for line in gemtext.lines() { + // Parse markdown lines + 'l: for line in markdown.lines() { if is_code_enabled { use ggemtext::line::Code; match code { @@ -192,25 +206,27 @@ impl Markdown { } } - // Is header - { - use ggemtext::line::{Header, header::Level}; - if let Some(header) = Header::parse(line) { - buffer.insert_with_tags( - &mut buffer.end_iter(), - &header.value, - &[match header.level { - Level::H1 => &tag.h1, - Level::H2 => &tag.h2, - Level::H3 => &tag.h3, - }], - ); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - + // Is 1-6 level header + for level in 1..=6 { + if let Some(t) = header( + &buffer, + match level { + 1 => &tag.h1, + 2 => &tag.h2, + 3 => &tag.h3, + 4 => &tag.h4, + 5 => &tag.h5, + 6 => &tag.h6, + _ => unreachable!(), + }, + line, + &H.repeat(level), + ) { + // Update document title by tag, if not set before if title.is_none() { - title = Some(header.value.clone()); + title = Some(t); } - continue; + continue 'l; } } @@ -521,7 +537,7 @@ impl Markdown { Ok(Self { text_view, title }) } else { Err(Error::Markup( - "Invalid multiline markup! Gemtext format partially ignored.".to_string(), + "Invalid multiline markup! Markdown format partially ignored.".to_string(), Self { text_view, title }, )) } @@ -582,3 +598,5 @@ fn link_prefix(request: String, prefix: &str) -> String { const LINK_PREFIX_DOWNLOAD: &str = "download:"; const LINK_PREFIX_SOURCE: &str = "source:"; + +const H: &str = "#"; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs index f917b5f7..1ff62227 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs @@ -17,6 +17,9 @@ pub struct Tag { pub h1: TextTag, pub h2: TextTag, pub h3: TextTag, + pub h4: TextTag, + pub h5: TextTag, + pub h6: TextTag, pub list: TextTag, pub quote: TextTag, pub title: TextTag, @@ -36,6 +39,9 @@ impl Tag { let h1 = TextTag::h1(); let h2 = TextTag::h2(); let h3 = TextTag::h3(); + let h4 = TextTag::h4(); + let h5 = TextTag::h5(); + let h6 = TextTag::h6(); let list = TextTag::list(); let quote = TextTag::quote(); let title = TextTag::title(); @@ -47,6 +53,9 @@ impl Tag { text_tag_table.add(&h1); text_tag_table.add(&h2); text_tag_table.add(&h3); + text_tag_table.add(&h4); + text_tag_table.add(&h5); + text_tag_table.add(&h6); text_tag_table.add(&title); text_tag_table.add(&list); text_tag_table.add("e); @@ -58,6 +67,9 @@ impl Tag { h1, h2, h3, + h4, + h5, + h6, list, quote, title, diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs index 8f4c992b..2a376692 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs @@ -4,6 +4,9 @@ pub trait Header { fn h1() -> Self; fn h2() -> Self; fn h3() -> Self; + fn h4() -> Self; + fn h5() -> Self; + fn h6() -> Self; } impl Header for TextTag { @@ -34,4 +37,31 @@ impl Header for TextTag { .wrap_mode(WrapMode::Word) .build() } + fn h4() -> Self { + TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.1) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build() + } + fn h5() -> Self { + TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.0) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build() + } + fn h6() -> Self { + TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.0) + .sentence(true) + .weight(300) + .wrap_mode(WrapMode::Word) + .build() + } } From c5f9690967efaa924e132ab08849aeba71f248fd Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 05:11:10 +0200 Subject: [PATCH 047/110] define expected capacity --- src/app/browser/window/tab/item/page/content/text/gemini.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..8eae79b9 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 @@ -217,7 +217,7 @@ 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()); From 266b8bfa95f500fa0dfcd3782719f893c697b8ae Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 06:48:24 +0200 Subject: [PATCH 048/110] draft links parser --- Cargo.lock | 33 ++++ Cargo.toml | 1 + .../tab/item/page/content/text/markdown.rs | 156 ++++++++++++------ 3 files changed, 141 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d07af96f..ba2dd8fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "plurify", "r2d2", "r2d2_sqlite", + "regex", "rusqlite", "sourceview5", "syntect", @@ -31,6 +32,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" @@ -1131,6 +1141,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" diff --git a/Cargo.toml b/Cargo.toml index a419e8d3..4a529faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ openssl = "0.10.72" plurify = "0.2.0" r2d2 = "0.8.10" r2d2_sqlite = "0.32.0" +regex = "1.12.3" syntect = "5.2.0" # development 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 index 0435dd64..1e051762 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -13,11 +13,12 @@ use gtk::{ UriLauncher, Window, WrapMode, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, gio::{Cancellable, SimpleAction, SimpleActionGroup}, - glib::{Uri, uuid_string_random}, + glib::{Uri, UriFlags, uuid_string_random}, prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; use icon::Icon; +use regex::Regex; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; use syntax::Syntax; @@ -39,20 +40,6 @@ impl Markdown { base: &Uri, markdown: &str, ) -> Result { - /// Header tag - fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option { - if let Some(h) = line.trim_start().strip_prefix(pattern) - && !h.starts_with(pattern) - { - let header = h.trim(); - buffer.insert_with_tags(&mut buffer.end_iter(), header, &[tag]); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - Some(header.into()) - } else { - None - } - } - // Init default values let mut title = None; @@ -120,7 +107,7 @@ impl Markdown { t == 0 || t.is_multiple_of(2) }; - // Parse markdown lines + // Parse single-line markdown tags 'l: for line in markdown.lines() { if is_code_enabled { use ggemtext::line::Code; @@ -230,39 +217,6 @@ impl Markdown { } } - // Is link - if let Some(link) = ggemtext::line::Link::parse(line) { - if let Some(uri) = link.uri(Some(base)) { - let mut alt = Vec::new(); - - if uri.scheme() != base.scheme() { - alt.push("⇖".to_string()); - } - - alt.push(match link.alt { - Some(alt) => alt, - None => uri.to_string(), - }); - - let a = TextTag::builder() - .foreground_rgba(&link_color.0) - // .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) @TODO adw 1.6 / ubuntu 24.10+ - .sentence(true) - .wrap_mode(WrapMode::Word) - .build(); - - if !tag.text_tag_table.add(&a) { - panic!() - } - - buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - - links.insert(a, uri); - } - continue; - } - // Is list if let Some(value) = ggemtext::line::list::Gemtext::as_value(line) { @@ -300,6 +254,10 @@ impl Markdown { buffer.insert(&mut buffer.end_iter(), NEW_LINE); } + // Parse in-line markdown tags + + link(&buffer, &tag, base, &link_color.0, &mut links); + // Context menu let action_link_tab = SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); @@ -596,6 +554,106 @@ fn link_prefix(request: String, prefix: &str) -> String { format!("{prefix}{}", request.trim_start_matches(prefix)) } +/// Link +fn link( + buffer: &TextBuffer, + tag: &Tag, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, +) { + let start_iter = buffer.start_iter(); + let end_iter = buffer.end_iter(); + let full_content = buffer.text(&start_iter, &end_iter, true).to_string(); + + buffer.set_text(""); + + let mut last_pos = 0; + for cap in Regex::new(r"(?P!?)\[(?P[^\]]+)\]\((?P[^\)]+)\)") + .unwrap() + .captures_iter(&full_content) + { + let full_match = cap.get(0).unwrap(); + let before = &full_content[last_pos..full_match.start()]; + if !before.is_empty() { + buffer.insert(&mut buffer.end_iter(), before); + } + // Relative scheme patch + // https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + let unresolved_url = match cap["url"].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 => &cap["url"], + }; + // Convert address to the valid URI, + // resolve to absolute URL format if the target is relative + match Uri::resolve_relative(Some(&base.to_string()), unresolved_url, UriFlags::NONE) { + Ok(url) => match Uri::parse(&url, UriFlags::NONE) { + Ok(uri) => { + let alt = { + let mut a: Vec<&str> = Vec::with_capacity(2); + if uri.scheme() != base.scheme() { + a.push("⇖"); + } + if cap["text"].is_empty() { + a.push(&cap["url"]); + } else { + a.push(&cap["text"]); + } + a.join(" ") + }; + + let a = 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(); + + if !tag.text_tag_table.add(&a) { + panic!() + } + + buffer.insert_with_tags(&mut buffer.end_iter(), &alt, &[&a]); + links.insert(a, uri); + } + Err(_) => todo!(), + }, + Err(_) => continue, + } + last_pos = full_match.end(); + } + let after = &full_content[last_pos..]; + if !after.is_empty() { + buffer.insert(&mut buffer.end_iter(), after); + } +} + +/// Header tag +fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option { + if let Some(h) = line.trim_start().strip_prefix(pattern) + && !h.starts_with(pattern) + { + let header = h.trim(); + buffer.insert_with_tags(&mut buffer.end_iter(), header, &[tag]); + buffer.insert(&mut buffer.end_iter(), NEW_LINE); + Some(header.into()) + } else { + None + } +} + const LINK_PREFIX_DOWNLOAD: &str = "download:"; const LINK_PREFIX_SOURCE: &str = "source:"; From 81b57f92ac3c4aca3ae045609ec446cfa9ac2567 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 16:21:56 +0200 Subject: [PATCH 049/110] add left window controls placement support --- src/app/browser/window/header/bar.rs | 18 ++++++++++++++---- src/app/browser/window/header/bar/control.rs | 13 ++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) 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(), + } + } } From 22c50161af9348ee1f114e8f6efc3141da4ac97c Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 17:25:27 +0200 Subject: [PATCH 050/110] separate Reference impl --- .../tab/item/page/content/text/markdown.rs | 81 +++++++------------ .../page/content/text/markdown/reference.rs | 53 ++++++++++++ 2 files changed, 80 insertions(+), 54 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/reference.rs 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 index 1e051762..57c25fa4 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -2,6 +2,7 @@ mod ansi; pub mod error; mod gutter; mod icon; +mod reference; mod syntax; mod tag; @@ -13,11 +14,12 @@ use gtk::{ UriLauncher, Window, WrapMode, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, gio::{Cancellable, SimpleAction, SimpleActionGroup}, - glib::{Uri, UriFlags, uuid_string_random}, + glib::{Uri, uuid_string_random}, prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; use icon::Icon; +use reference::Reference; use regex::Regex; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; @@ -569,7 +571,7 @@ fn link( buffer.set_text(""); let mut last_pos = 0; - for cap in Regex::new(r"(?P!?)\[(?P[^\]]+)\]\((?P[^\)]+)\)") + for cap in Regex::new(r"(?P!)\[(?P[^\]]+)\]\((?P[^\)]+)\)") .unwrap() .captures_iter(&full_content) { @@ -578,59 +580,30 @@ fn link( if !before.is_empty() { buffer.insert(&mut buffer.end_iter(), before); } - // Relative scheme patch - // https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 - let unresolved_url = match cap["url"].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 => &cap["url"], - }; - // Convert address to the valid URI, - // resolve to absolute URL format if the target is relative - match Uri::resolve_relative(Some(&base.to_string()), unresolved_url, UriFlags::NONE) { - Ok(url) => match Uri::parse(&url, UriFlags::NONE) { - Ok(uri) => { - let alt = { - let mut a: Vec<&str> = Vec::with_capacity(2); - if uri.scheme() != base.scheme() { - a.push("⇖"); - } - if cap["text"].is_empty() { - a.push(&cap["url"]); - } else { - a.push(&cap["text"]); - } - a.join(" ") - }; - - let a = 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(); - - if !tag.text_tag_table.add(&a) { - panic!() - } - - buffer.insert_with_tags(&mut buffer.end_iter(), &alt, &[&a]); - links.insert(a, uri); - } - Err(_) => todo!(), + match Reference::parse( + &cap["url"], + if cap["text"].is_empty() { + None + } else { + Some(&cap["text"]) }, - Err(_) => continue, + base, + ) { + Some(link) => { + let a = 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(); + if !tag.text_tag_table.add(&a) { + panic!() + } + buffer.insert_with_tags(&mut buffer.end_iter(), &link.alt, &[&a]); + links.insert(a, link.uri); + } + None => continue, } last_pos = full_match.end(); } diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs new file mode 100644 index 00000000..3ff177f3 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs @@ -0,0 +1,53 @@ +use gtk::glib::{Uri, UriFlags}; + +pub struct Reference { + pub uri: Uri, + pub alt: String, +} + +impl Reference { + pub fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option { + // 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 => String::new(), + }, + 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, + } + } +} From 25e505c9fbeda5a514ac7bb92c44b562aeecd307 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 17:45:23 +0200 Subject: [PATCH 051/110] implement `Reference` bufferizer, draft `image_link` method --- .../tab/item/page/content/text/markdown.rs | 61 ++++++++++++++----- .../page/content/text/markdown/reference.rs | 21 +++++++ 2 files changed, 66 insertions(+), 16 deletions(-) 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 index 57c25fa4..9e4d211b 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -258,6 +258,7 @@ impl Markdown { // Parse in-line markdown tags + image_link(&buffer, &tag, base, &link_color.0, &mut links); link(&buffer, &tag, base, &link_color.0, &mut links); // Context menu @@ -556,6 +557,48 @@ fn link_prefix(request: String, prefix: &str) -> String { format!("{prefix}{}", request.trim_start_matches(prefix)) } +/// Link +fn image_link( + buffer: &TextBuffer, + tag: &Tag, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, +) { + let start_iter = buffer.start_iter(); + let end_iter = buffer.end_iter(); + let full_content = buffer.text(&start_iter, &end_iter, true).to_string(); + + buffer.set_text(""); + + let mut last_pos = 0; + for cap in Regex::new(r"(?P\[(?P!|)?\[(?P[^\]]+)\]\((?P[^\)]+)\)\]\((?P[^\)]+)\))") + .unwrap() + .captures_iter(&full_content) + { + let full_match = cap.get(0).unwrap(); + let before = &full_content[last_pos..full_match.start()]; + if !before.is_empty() { + buffer.insert(&mut buffer.end_iter(), before); + } + if let Some(link) = Reference::parse( + &cap["link_url"], + None, + base, + ) { + link.into_buffer(buffer, + link_color, + tag, + links) + } + last_pos = full_match.end(); + } + let after = &full_content[last_pos..]; + if !after.is_empty() { + buffer.insert(&mut buffer.end_iter(), after); + } +} + /// Link fn link( buffer: &TextBuffer, @@ -580,7 +623,7 @@ fn link( if !before.is_empty() { buffer.insert(&mut buffer.end_iter(), before); } - match Reference::parse( + if let Some(link) = Reference::parse( &cap["url"], if cap["text"].is_empty() { None @@ -589,21 +632,7 @@ fn link( }, base, ) { - Some(link) => { - let a = 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(); - if !tag.text_tag_table.add(&a) { - panic!() - } - buffer.insert_with_tags(&mut buffer.end_iter(), &link.alt, &[&a]); - links.insert(a, link.uri); - } - None => continue, + link.into_buffer(buffer, link_color, tag, links) } last_pos = full_match.end(); } diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs index 3ff177f3..eddd8db0 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs @@ -50,4 +50,25 @@ impl Reference { Err(_) => None, } } + pub fn into_buffer( + self, + buffer: >k::TextBuffer, + link_color: >k::gdk::RGBA, + tag: &super::Tag, + links: &mut std::collections::HashMap, + ) { + use gtk::prelude::{TextBufferExt, TextBufferExtManual}; + let a = gtk::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(gtk::WrapMode::Word) + .build(); + if !tag.text_tag_table.add(&a) { + panic!() + } + buffer.insert_with_tags(&mut buffer.end_iter(), &self.alt, &[&a]); + links.insert(a, self.uri); + } } From e61b6c400a774f49ff01113399853972b0db1c74 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 18:33:42 +0200 Subject: [PATCH 052/110] fix default value --- .../window/tab/item/page/content/text/markdown/reference.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs index eddd8db0..f35952bd 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs @@ -26,7 +26,7 @@ impl Reference { } ) } - None => String::new(), + None => address.into(), }, UriFlags::NONE, ) { From 5675809320edbfb51301c538c31b809584a1a6b5 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 20:44:46 +0200 Subject: [PATCH 053/110] move regex logic, add annotation tag, add some tests --- .../page/content/text/markdown/reference.rs | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs index f35952bd..d9c5583d 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs @@ -1,5 +1,10 @@ use gtk::glib::{Uri, UriFlags}; +pub const REGEX_LINK: &str = r"\[(?P[^\]]+)\]\((?P[^\)]+)\)"; + +pub const REGEX_IMAGE_LINK: &str = + r"\[(?P!)\[(?P[^\]]+)\]\((?P[^\)]+)\)\]\((?P[^\)]+)\)"; + pub struct Reference { pub uri: Uri, pub alt: String, @@ -53,22 +58,74 @@ impl Reference { pub fn into_buffer( self, buffer: >k::TextBuffer, + position: &mut gtk::TextIter, link_color: >k::gdk::RGBA, tag: &super::Tag, + is_annotation: bool, links: &mut std::collections::HashMap, ) { - use gtk::prelude::{TextBufferExt, TextBufferExtManual}; - let a = gtk::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(gtk::WrapMode::Word) - .build(); + use gtk::{TextTag, WrapMode, prelude::TextBufferExtManual}; + 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() + }; if !tag.text_tag_table.add(&a) { panic!() } - buffer.insert_with_tags(&mut buffer.end_iter(), &self.alt, &[&a]); + buffer.insert_with_tags(position, &self.alt, &[&a]); links.insert(a, self.uri); } } + +#[test] +fn test_regex_link() { + let cap: Vec<_> = regex::Regex::new(REGEX_LINK) + .unwrap() + .captures_iter(r#"[link1](https://link1.com) [link2](https://link2.com)"#) + .collect(); + + let first = cap.get(0).unwrap(); + assert_eq!(&first["text"], "link1"); + assert_eq!(&first["url"], "https://link1.com"); + + let second = cap.get(1).unwrap(); + assert_eq!(&second["text"], "link2"); + assert_eq!(&second["url"], "https://link2.com"); +} + +#[test] +fn test_regex_image_link() { + let cap: Vec<_> = regex::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.get(0).unwrap(); + 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["alt"], "image3"); + assert_eq!(&second["img_url"], "https://image3.com"); + assert_eq!(&second["link_url"], "https://image4.com"); +} From 9843d49326154eebe76759fdb8ebc9da4b6f79da Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 23:41:20 +0200 Subject: [PATCH 054/110] implement link, linked images, and images parser; temporarily disable header impl --- .../tab/item/page/content/text/markdown.rs | 106 ++-------- .../page/content/text/markdown/reference.rs | 185 ++++++++++++++++-- 2 files changed, 183 insertions(+), 108 deletions(-) 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 index 9e4d211b..3ffef743 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -19,8 +19,6 @@ use gtk::{ }; use gutter::Gutter; use icon::Icon; -use reference::Reference; -use regex::Regex; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; use syntax::Syntax; @@ -53,7 +51,7 @@ impl Markdown { let hover: Rc>> = Rc::new(Cell::new(None)); // Init code features - let mut code = None; + //let mut code = None; // Init quote icon feature let mut is_line_after_quote = false; @@ -76,6 +74,7 @@ impl Markdown { // Init new text buffer let buffer = TextBuffer::new(Some(&tag.text_tag_table)); + buffer.set_text(markdown); // Init main widget let text_view = { @@ -109,8 +108,15 @@ impl Markdown { t == 0 || t.is_multiple_of(2) }; + // Parse in-line markdown tags + // * keep order! + + reference::image_link(&buffer, &tag, base, &link_color.0, &mut links); + reference::image(&buffer, &tag, base, &link_color.0, &mut links); + reference::link(&buffer, &tag, base, &link_color.0, &mut links); + // Parse single-line markdown tags - 'l: for line in markdown.lines() { + /*'l: for line in markdown.lines() { if is_code_enabled { use ggemtext::line::Code; match code { @@ -254,12 +260,7 @@ impl Markdown { // just append plain text covered in empty tag (to handle controller events properly) buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]); buffer.insert(&mut buffer.end_iter(), NEW_LINE); - } - - // Parse in-line markdown tags - - image_link(&buffer, &tag, base, &link_color.0, &mut links); - link(&buffer, &tag, base, &link_color.0, &mut links); + }*/ // Context menu let action_link_tab = @@ -557,91 +558,6 @@ fn link_prefix(request: String, prefix: &str) -> String { format!("{prefix}{}", request.trim_start_matches(prefix)) } -/// Link -fn image_link( - buffer: &TextBuffer, - tag: &Tag, - base: &Uri, - link_color: &RGBA, - links: &mut HashMap, -) { - let start_iter = buffer.start_iter(); - let end_iter = buffer.end_iter(); - let full_content = buffer.text(&start_iter, &end_iter, true).to_string(); - - buffer.set_text(""); - - let mut last_pos = 0; - for cap in Regex::new(r"(?P\[(?P!|)?\[(?P[^\]]+)\]\((?P[^\)]+)\)\]\((?P[^\)]+)\))") - .unwrap() - .captures_iter(&full_content) - { - let full_match = cap.get(0).unwrap(); - let before = &full_content[last_pos..full_match.start()]; - if !before.is_empty() { - buffer.insert(&mut buffer.end_iter(), before); - } - if let Some(link) = Reference::parse( - &cap["link_url"], - None, - base, - ) { - link.into_buffer(buffer, - link_color, - tag, - links) - } - last_pos = full_match.end(); - } - let after = &full_content[last_pos..]; - if !after.is_empty() { - buffer.insert(&mut buffer.end_iter(), after); - } -} - -/// Link -fn link( - buffer: &TextBuffer, - tag: &Tag, - base: &Uri, - link_color: &RGBA, - links: &mut HashMap, -) { - let start_iter = buffer.start_iter(); - let end_iter = buffer.end_iter(); - let full_content = buffer.text(&start_iter, &end_iter, true).to_string(); - - buffer.set_text(""); - - let mut last_pos = 0; - for cap in Regex::new(r"(?P!)\[(?P[^\]]+)\]\((?P[^\)]+)\)") - .unwrap() - .captures_iter(&full_content) - { - let full_match = cap.get(0).unwrap(); - let before = &full_content[last_pos..full_match.start()]; - if !before.is_empty() { - buffer.insert(&mut buffer.end_iter(), before); - } - if let Some(link) = Reference::parse( - &cap["url"], - if cap["text"].is_empty() { - None - } else { - Some(&cap["text"]) - }, - base, - ) { - link.into_buffer(buffer, link_color, tag, links) - } - last_pos = full_match.end(); - } - let after = &full_content[last_pos..]; - if !after.is_empty() { - buffer.insert(&mut buffer.end_iter(), after); - } -} - /// Header tag fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option { if let Some(h) = line.trim_start().strip_prefix(pattern) diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs index d9c5583d..b1b027b5 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs @@ -1,8 +1,16 @@ -use gtk::glib::{Uri, UriFlags}; +use super::Tag; +use gtk::{ + TextBuffer, TextIter, TextTag, WrapMode, + gdk::RGBA, + glib::{Uri, UriFlags}, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; -pub const REGEX_LINK: &str = r"\[(?P[^\]]+)\]\((?P[^\)]+)\)"; - -pub const REGEX_IMAGE_LINK: &str = +const REGEX_LINK: &str = r"\[(?P[^\]]+)\]\((?P[^\)]+)\)"; +const REGEX_IMAGE: &str = r"!\[(?P[^\]]+)\]\((?P[^\)]+)\)"; +const REGEX_IMAGE_LINK: &str = r"\[(?P!)\[(?P[^\]]+)\]\((?P[^\)]+)\)\]\((?P[^\)]+)\)"; pub struct Reference { @@ -11,7 +19,7 @@ pub struct Reference { } impl Reference { - pub fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option { + fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option { // Convert address to the valid URI, // resolve to absolute URL format if the target is relative match Uri::resolve_relative( @@ -55,16 +63,15 @@ impl Reference { Err(_) => None, } } - pub fn into_buffer( + fn into_buffer( self, - buffer: >k::TextBuffer, - position: &mut gtk::TextIter, - link_color: >k::gdk::RGBA, + buffer: &TextBuffer, + position: &mut TextIter, + link_color: &RGBA, tag: &super::Tag, is_annotation: bool, - links: &mut std::collections::HashMap, + links: &mut HashMap, ) { - use gtk::{TextTag, WrapMode, prelude::TextBufferExtManual}; let a = if is_annotation { buffer.insert_with_tags(position, " ", &[]); TextTag::builder() @@ -94,25 +101,151 @@ impl Reference { } } +/// Image links `[![]()]()` +pub fn image_link( + buffer: &TextBuffer, + tag: &Tag, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, +) { + 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); + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(reference) = Reference::parse( + &cap["img_url"], + if cap["alt"].is_empty() { + None + } else { + Some(&cap["alt"]) + }, + base, + ) { + reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links) + } + if let Some(reference) = Reference::parse(&cap["link_url"], Some("1"), base) { + reference.into_buffer(buffer, &mut start_iter, link_color, tag, true, links) + } + } +} +/// Image tags `![]()` +pub fn image( + buffer: &TextBuffer, + tag: &Tag, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, +) { + 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); + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(reference) = Reference::parse( + &cap["url"], + if cap["alt"].is_empty() { + None + } else { + Some(&cap["alt"]) + }, + base, + ) { + reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links) + } + } +} +/// Links `[]()` +pub fn link( + buffer: &TextBuffer, + tag: &Tag, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, +) { + 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); + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(reference) = Reference::parse( + &cap["url"], + if cap["text"].is_empty() { + None + } else { + Some(&cap["text"]) + }, + base, + ) { + reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links) + } + } +} + #[test] fn test_regex_link() { - let cap: Vec<_> = regex::Regex::new(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.get(0).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::Regex::new( + let cap: Vec<_> = Regex::new( REGEX_IMAGE_LINK, ) .unwrap().captures_iter( @@ -120,12 +253,38 @@ fn test_regex_image_link() { ).collect(); let first = cap.get(0).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.get(0).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"); +} From e653675fa10e1c84de7c1f84526b36a2c181a64f Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 9 Mar 2026 01:53:57 +0200 Subject: [PATCH 055/110] remove extra ns --- .../window/tab/item/page/content/text/markdown/reference.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs index b1b027b5..4400eb21 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs @@ -68,7 +68,7 @@ impl Reference { buffer: &TextBuffer, position: &mut TextIter, link_color: &RGBA, - tag: &super::Tag, + tag: &Tag, is_annotation: bool, links: &mut HashMap, ) { From c7329644944e6a10e71ea7581bf0a5923ed9546b Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 9 Mar 2026 02:40:42 +0200 Subject: [PATCH 056/110] add header tags renderer --- .../tab/item/page/content/text/markdown.rs | 30 ++-------- .../item/page/content/text/markdown/tag.rs | 56 ++++++++++++++++++- 2 files changed, 59 insertions(+), 27 deletions(-) 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 index 3ffef743..5dd62197 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -108,8 +108,10 @@ impl Markdown { t == 0 || t.is_multiple_of(2) }; - // Parse in-line markdown tags - // * keep order! + // Render markdown tags + // * keep in order! + + tag::header(&buffer, &tag); reference::image_link(&buffer, &tag, base, &link_color.0, &mut links); reference::image(&buffer, &tag, base, &link_color.0, &mut links); @@ -201,30 +203,6 @@ impl Markdown { } } - // Is 1-6 level header - for level in 1..=6 { - if let Some(t) = header( - &buffer, - match level { - 1 => &tag.h1, - 2 => &tag.h2, - 3 => &tag.h3, - 4 => &tag.h4, - 5 => &tag.h5, - 6 => &tag.h6, - _ => unreachable!(), - }, - line, - &H.repeat(level), - ) { - // Update document title by tag, if not set before - if title.is_none() { - title = Some(t); - } - continue 'l; - } - } - // Is list if let Some(value) = ggemtext::line::list::Gemtext::as_value(line) { diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs index 1ff62227..3a814541 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs @@ -4,11 +4,15 @@ mod plain; mod quote; mod title; -use gtk::{TextTag, TextTagTable}; +use gtk::{ + TextBuffer, TextTag, TextTagTable, + prelude::{TextBufferExt, TextBufferExtManual}, +}; use header::Header; use list::List; use plain::Plain; use quote::Quote; +use regex::Regex; use title::Title; pub struct Tag { @@ -77,3 +81,53 @@ impl Tag { } } } + +// Headers `#`, `##`, etc. + +const REGEX_HEADER: &str = r"(?m)^(?P#{1,6})\s+(?P.*)$"; + +/// Apply header `Tag` to given `TextBuffer` +pub fn header(buffer: &TextBuffer, tag: &Tag) { + 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.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); + + match cap["level"].chars().count() { + 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h1]), + 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h2]), + 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h3]), + 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h4]), + 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h5]), + 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h6]), + _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]), + } + } +} + +#[test] +fn test_regex_header() { + let cap: Vec<_> = Regex::new(REGEX_HEADER) + .unwrap() + .captures_iter(r"## Title ![alt](https://link.com)") + .collect(); + + let first = cap.get(0).unwrap(); + assert_eq!(&first[0], "## Title ![alt](https://link.com)"); + assert_eq!(&first["level"], "##"); + assert_eq!(&first["title"], "Title ![alt](https://link.com)"); +} From cab1610e1fd3018a114df53f55513cf18d154e83 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 03:00:58 +0200 Subject: [PATCH 057/110] add quote support --- .../tab/item/page/content/text/markdown.rs | 1 + .../item/page/content/text/markdown/tag.rs | 40 +++++++++++++++++++ .../page/content/text/markdown/tag/quote.rs | 5 ++- 3 files changed, 44 insertions(+), 2 deletions(-) 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 index 5dd62197..508a93d6 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -112,6 +112,7 @@ impl Markdown { // * keep in order! tag::header(&buffer, &tag); + tag::quote(&buffer, &tag); reference::image_link(&buffer, &tag, base, &link_color.0, &mut links); reference::image(&buffer, &tag, base, &link_color.0, &mut links); diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs index 3a814541..cc298c95 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs @@ -131,3 +131,43 @@ fn test_regex_header() { assert_eq!(&first["level"], "##"); assert_eq!(&first["title"], "Title ![alt](https://link.com)"); } + +// Quotes + +const REGEX_QUOTE: &str = r"(?m)^>\s+(?P<text>.*)$"; + +/// Apply quote `Tag` to given `TextBuffer` +pub fn quote(buffer: &TextBuffer, tag: &Tag) { + 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"], &[&tag.quote]) + } +} + +#[test] +fn test_regex_quote() { + let cap: Vec<_> = Regex::new(REGEX_QUOTE) + .unwrap() + .captures_iter(r"> Some quote with ![img](https://link.com)") + .collect(); + + let first = cap.get(0).unwrap(); + assert_eq!(&first[0], "> Some quote with ![img](https://link.com)"); + assert_eq!(&first["text"], "Some quote with ![img](https://link.com)"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs index 58d41e28..8b937a76 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs @@ -1,4 +1,4 @@ -use gtk::{TextTag, WrapMode}; +use gtk::{TextTag, WrapMode::Word, pango::Style::Italic}; pub trait Quote { fn quote() -> Self; @@ -8,7 +8,8 @@ impl Quote for TextTag { fn quote() -> Self { TextTag::builder() .left_margin(28) - .wrap_mode(WrapMode::Word) + .wrap_mode(Word) + .style(Italic) // what about the italic tags decoration? @TODO .build() } } From 7220398492f65be7bac5f461ca77c4f3265933b2 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 04:18:56 +0200 Subject: [PATCH 058/110] reorganize some tags --- .../tab/item/page/content/text/markdown.rs | 16 +- .../item/page/content/text/markdown/tag.rs | 173 ------------------ .../page/content/text/markdown/tag/header.rs | 67 ------- .../page/content/text/markdown/tag/quote.rs | 15 -- .../item/page/content/text/markdown/tags.rs | 78 ++++++++ .../page/content/text/markdown/tags/header.rs | 119 ++++++++++++ .../text/markdown/{tag => tags}/list.rs | 0 .../text/markdown/{tag => tags}/plain.rs | 0 .../page/content/text/markdown/tags/quote.rs | 61 ++++++ .../text/markdown/{ => tags}/reference.rs | 38 ++-- .../text/markdown/{tag => tags}/title.rs | 0 11 files changed, 280 insertions(+), 287 deletions(-) delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag.rs delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs rename src/app/browser/window/tab/item/page/content/text/markdown/{tag => tags}/list.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/{tag => tags}/plain.rs (100%) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags}/reference.rs (90%) rename src/app/browser/window/tab/item/page/content/text/markdown/{tag => tags}/title.rs (100%) 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 index 508a93d6..34be39c0 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -2,9 +2,8 @@ mod ansi; pub mod error; mod gutter; mod icon; -mod reference; mod syntax; -mod tag; +mod tags; use super::{ItemAction, WindowAction}; use crate::app::browser::window::action::Position; @@ -22,7 +21,7 @@ use icon::Icon; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; use syntax::Syntax; -use tag::Tag; +use tags::Tags; pub const NEW_LINE: &str = "\n"; @@ -70,10 +69,10 @@ impl Markdown { let icon = Icon::new(); // Init tags - let tag = Tag::new(); + let tags = Tags::new(); // Init new text buffer - let buffer = TextBuffer::new(Some(&tag.text_tag_table)); + let buffer = TextBuffer::new(Some(&tags.text_tag_table)); buffer.set_text(markdown); // Init main widget @@ -111,12 +110,7 @@ impl Markdown { // Render markdown tags // * keep in order! - tag::header(&buffer, &tag); - tag::quote(&buffer, &tag); - - reference::image_link(&buffer, &tag, base, &link_color.0, &mut links); - reference::image(&buffer, &tag, base, &link_color.0, &mut links); - reference::link(&buffer, &tag, base, &link_color.0, &mut links); + tags.render(&buffer, &base, &link_color.0, &mut links); // Parse single-line markdown tags /*'l: for line in markdown.lines() { diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs deleted file mode 100644 index cc298c95..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag.rs +++ /dev/null @@ -1,173 +0,0 @@ -mod header; -mod list; -mod plain; -mod quote; -mod title; - -use gtk::{ - TextBuffer, TextTag, TextTagTable, - prelude::{TextBufferExt, TextBufferExtManual}, -}; -use header::Header; -use list::List; -use plain::Plain; -use quote::Quote; -use regex::Regex; -use title::Title; - -pub struct Tag { - pub text_tag_table: TextTagTable, - // Tags - pub h1: TextTag, - pub h2: TextTag, - pub h3: TextTag, - pub h4: TextTag, - pub h5: TextTag, - pub h6: TextTag, - pub list: TextTag, - pub quote: TextTag, - pub title: TextTag, - pub plain: TextTag, -} - -impl Default for Tag { - fn default() -> Self { - Self::new() - } -} - -impl Tag { - // Construct - pub fn new() -> Self { - // Init components - let h1 = TextTag::h1(); - let h2 = TextTag::h2(); - let h3 = TextTag::h3(); - let h4 = TextTag::h4(); - let h5 = TextTag::h5(); - let h6 = TextTag::h6(); - let list = TextTag::list(); - let quote = TextTag::quote(); - let title = TextTag::title(); - let plain = TextTag::plain(); - - // Init tag table - let text_tag_table = TextTagTable::new(); - - text_tag_table.add(&h1); - text_tag_table.add(&h2); - text_tag_table.add(&h3); - text_tag_table.add(&h4); - text_tag_table.add(&h5); - text_tag_table.add(&h6); - text_tag_table.add(&title); - text_tag_table.add(&list); - text_tag_table.add("e); - text_tag_table.add(&plain); - - Self { - text_tag_table, - // Tags - h1, - h2, - h3, - h4, - h5, - h6, - list, - quote, - title, - plain, - } - } -} - -// Headers `#`, `##`, etc. - -const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$"; - -/// Apply header `Tag` to given `TextBuffer` -pub fn header(buffer: &TextBuffer, tag: &Tag) { - 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.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); - - match cap["level"].chars().count() { - 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h1]), - 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h2]), - 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h3]), - 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h4]), - 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h5]), - 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&tag.h6]), - _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]), - } - } -} - -#[test] -fn test_regex_header() { - let cap: Vec<_> = Regex::new(REGEX_HEADER) - .unwrap() - .captures_iter(r"## Title ![alt](https://link.com)") - .collect(); - - let first = cap.get(0).unwrap(); - assert_eq!(&first[0], "## Title ![alt](https://link.com)"); - assert_eq!(&first["level"], "##"); - assert_eq!(&first["title"], "Title ![alt](https://link.com)"); -} - -// Quotes - -const REGEX_QUOTE: &str = r"(?m)^>\s+(?P<text>.*)$"; - -/// Apply quote `Tag` to given `TextBuffer` -pub fn quote(buffer: &TextBuffer, tag: &Tag) { - 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"], &[&tag.quote]) - } -} - -#[test] -fn test_regex_quote() { - let cap: Vec<_> = Regex::new(REGEX_QUOTE) - .unwrap() - .captures_iter(r"> Some quote with ![img](https://link.com)") - .collect(); - - let first = cap.get(0).unwrap(); - assert_eq!(&first[0], "> Some quote with ![img](https://link.com)"); - assert_eq!(&first["text"], "Some quote with ![img](https://link.com)"); -} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs deleted file mode 100644 index 2a376692..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag/header.rs +++ /dev/null @@ -1,67 +0,0 @@ -use gtk::{TextTag, WrapMode}; - -pub trait Header { - fn h1() -> Self; - fn h2() -> Self; - fn h3() -> Self; - fn h4() -> Self; - fn h5() -> Self; - fn h6() -> Self; -} - -impl Header for TextTag { - fn h1() -> Self { - TextTag::builder() - .foreground("#2190a4") // @TODO optional - .scale(1.6) - .sentence(true) - .weight(500) - .wrap_mode(WrapMode::Word) - .build() - } - fn h2() -> Self { - TextTag::builder() - .foreground("#d56199") // @TODO optional - .scale(1.4) - .sentence(true) - .weight(400) - .wrap_mode(WrapMode::Word) - .build() - } - fn h3() -> Self { - TextTag::builder() - .foreground("#c88800") // @TODO optional - .scale(1.2) - .sentence(true) - .weight(400) - .wrap_mode(WrapMode::Word) - .build() - } - fn h4() -> Self { - TextTag::builder() - .foreground("#c88800") // @TODO optional - .scale(1.1) - .sentence(true) - .weight(400) - .wrap_mode(WrapMode::Word) - .build() - } - fn h5() -> Self { - TextTag::builder() - .foreground("#c88800") // @TODO optional - .scale(1.0) - .sentence(true) - .weight(400) - .wrap_mode(WrapMode::Word) - .build() - } - fn h6() -> Self { - TextTag::builder() - .foreground("#c88800") // @TODO optional - .scale(1.0) - .sentence(true) - .weight(300) - .wrap_mode(WrapMode::Word) - .build() - } -} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs deleted file mode 100644 index 8b937a76..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tag/quote.rs +++ /dev/null @@ -1,15 +0,0 @@ -use gtk::{TextTag, WrapMode::Word, pango::Style::Italic}; - -pub trait Quote { - fn quote() -> Self; -} - -impl Quote for TextTag { - fn quote() -> Self { - TextTag::builder() - .left_margin(28) - .wrap_mode(Word) - .style(Italic) // what about the italic tags decoration? @TODO - .build() - } -} 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..494db800 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs @@ -0,0 +1,78 @@ +mod header; +mod list; +mod plain; +mod quote; +mod reference; +mod title; + +use std::collections::HashMap; + +use gtk::{ + TextBuffer, TextTag, TextTagTable, + gdk::RGBA, + glib::Uri, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use header::Header; +use list::List; +use plain::Plain; +use quote::Quote; +use reference::Reference; +use title::Title; + +pub struct Tags { + pub text_tag_table: TextTagTable, + // Tags + pub header: Header, + pub list: TextTag, + pub plain: TextTag, + pub quote: Quote, + pub title: TextTag, +} + +impl Default for Tags { + fn default() -> Self { + Self::new() + } +} + +impl Tags { + // Construct + pub fn new() -> Self { + // Init tag table + let text_tag_table = TextTagTable::new(); + + // Init components + let list = TextTag::list(); + let plain = TextTag::plain(); + let title = TextTag::title(); + text_tag_table.add(&title); + text_tag_table.add(&list); + text_tag_table.add(&plain); + + Self { + text_tag_table, + // Tags + header: Header::new(), + list, + plain, + quote: Quote::new(), + title, + } + } + pub fn render( + &self, + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, + ) { + // * keep in order! + self.header.render(buffer); + self.quote.render(buffer); + + reference::render_images_links(&buffer, base, &link_color, links); + reference::render_images(&buffer, base, &link_color, links); + reference::render_links(&buffer, base, &link_color, links); + } +} 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..44ae5f68 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs @@ -0,0 +1,119 @@ +use gtk::{ + TextBuffer, TextTag, WrapMode, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$"; + +pub struct Header { + h1: TextTag, + h2: TextTag, + h3: TextTag, + h4: TextTag, + h5: TextTag, + h6: TextTag, +} + +impl Header { + pub fn new() -> Self { + Self { + h1: TextTag::builder() + .foreground("#2190a4") // @TODO optional + .scale(1.6) + .sentence(true) + .weight(500) + .wrap_mode(WrapMode::Word) + .build(), + h2: TextTag::builder() + .foreground("#d56199") // @TODO optional + .scale(1.4) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h3: TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.2) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h4: TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.1) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h5: TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.0) + .sentence(true) + .weight(400) + .wrap_mode(WrapMode::Word) + .build(), + h6: TextTag::builder() + .foreground("#c88800") // @TODO optional + .scale(1.0) + .sentence(true) + .weight(300) + .wrap_mode(WrapMode::Word) + .build(), + } + } + + /// Apply title `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + 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.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); + + match cap["level"].chars().count() { + 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h1]), + 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h2]), + 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h3]), + 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h4]), + 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h5]), + 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h6]), + _ => buffer.insert_with_tags(&mut start_iter, &cap["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.get(0).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/tag/list.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tag/list.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/plain.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/plain.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tag/plain.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/plain.rs 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..17db7bb5 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs @@ -0,0 +1,61 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + pango::Style::Italic, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_QUOTE: &str = r"(?m)^>\s+(?P<text>.*)$"; + +pub struct Quote(TextTag); + +impl Quote { + pub fn new() -> Self { + Self( + TextTag::builder() + .left_margin(28) + .wrap_mode(Word) + .style(Italic) // what about 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(r"> Some quote with ![img](https://link.com)") + .collect(); + + let first = cap.get(0).unwrap(); + assert_eq!(&first[0], "> Some quote with ![img](https://link.com)"); + assert_eq!(&first["text"], "Some quote with ![img](https://link.com)"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs similarity index 90% rename from src/app/browser/window/tab/item/page/content/text/markdown/reference.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs index 4400eb21..ef0f80d6 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/reference.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs @@ -1,4 +1,3 @@ -use super::Tag; use gtk::{ TextBuffer, TextIter, TextTag, WrapMode, gdk::RGBA, @@ -14,11 +13,12 @@ const REGEX_IMAGE_LINK: &str = r"\[(?P<is_img>!)\[(?P<alt>[^\]]+)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)"; pub struct Reference { - pub uri: Uri, - pub alt: String, + 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 @@ -63,12 +63,13 @@ impl Reference { 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, - tag: &Tag, is_annotation: bool, links: &mut HashMap<TextTag, Uri>, ) { @@ -93,18 +94,15 @@ impl Reference { .wrap_mode(WrapMode::Word) .build() }; - if !tag.text_tag_table.add(&a) { - panic!() - } + assert!(buffer.tag_table().add(&a)); buffer.insert_with_tags(position, &self.alt, &[&a]); links.insert(a, self.uri); } } /// Image links `[![]()]()` -pub fn image_link( +pub fn render_images_links( buffer: &TextBuffer, - tag: &Tag, base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, @@ -128,7 +126,7 @@ pub fn image_link( buffer.delete(&mut start_iter, &mut end_iter); - if let Some(reference) = Reference::parse( + if let Some(this) = Reference::parse( &cap["img_url"], if cap["alt"].is_empty() { None @@ -137,17 +135,16 @@ pub fn image_link( }, base, ) { - reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links) + this.into_buffer(buffer, &mut start_iter, link_color, false, links) } - if let Some(reference) = Reference::parse(&cap["link_url"], Some("1"), base) { - reference.into_buffer(buffer, &mut start_iter, link_color, tag, true, links) + if let Some(this) = Reference::parse(&cap["link_url"], Some("1"), base) { + this.into_buffer(buffer, &mut start_iter, link_color, true, links) } } } /// Image tags `![]()` -pub fn image( +pub fn render_images( buffer: &TextBuffer, - tag: &Tag, base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, @@ -171,7 +168,7 @@ pub fn image( buffer.delete(&mut start_iter, &mut end_iter); - if let Some(reference) = Reference::parse( + if let Some(this) = Reference::parse( &cap["url"], if cap["alt"].is_empty() { None @@ -180,14 +177,13 @@ pub fn image( }, base, ) { - reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links) + this.into_buffer(buffer, &mut start_iter, link_color, false, links) } } } /// Links `[]()` -pub fn link( +pub fn render_links( buffer: &TextBuffer, - tag: &Tag, base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, @@ -211,7 +207,7 @@ pub fn link( buffer.delete(&mut start_iter, &mut end_iter); - if let Some(reference) = Reference::parse( + if let Some(this) = Reference::parse( &cap["url"], if cap["text"].is_empty() { None @@ -220,7 +216,7 @@ pub fn link( }, base, ) { - reference.into_buffer(buffer, &mut start_iter, link_color, tag, false, links) + this.into_buffer(buffer, &mut start_iter, link_color, false, links) } } } diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tag/title.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/title.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tag/title.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/title.rs From 1af7d31d75923e266cdb85f61091faa4ee86edb5 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 04:21:28 +0200 Subject: [PATCH 059/110] remove `Plain` tag from the renderer asset --- .../tab/item/page/content/text/markdown/tags.rs | 9 +-------- .../tab/item/page/content/text/markdown/tags/plain.rs | 11 ----------- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/plain.rs 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 index 494db800..d911b98c 100644 --- 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 @@ -1,6 +1,5 @@ mod header; mod list; -mod plain; mod quote; mod reference; mod title; @@ -15,9 +14,7 @@ use gtk::{ }; use header::Header; use list::List; -use plain::Plain; use quote::Quote; -use reference::Reference; use title::Title; pub struct Tags { @@ -25,7 +22,6 @@ pub struct Tags { // Tags pub header: Header, pub list: TextTag, - pub plain: TextTag, pub quote: Quote, pub title: TextTag, } @@ -42,20 +38,17 @@ impl Tags { // Init tag table let text_tag_table = TextTagTable::new(); - // Init components + // Init shared tags members let list = TextTag::list(); - let plain = TextTag::plain(); let title = TextTag::title(); text_tag_table.add(&title); text_tag_table.add(&list); - text_tag_table.add(&plain); Self { text_tag_table, // Tags header: Header::new(), list, - plain, quote: Quote::new(), title, } diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/plain.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/plain.rs deleted file mode 100644 index dfc7374c..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags/plain.rs +++ /dev/null @@ -1,11 +0,0 @@ -use gtk::{TextTag, WrapMode}; - -pub trait Plain { - fn plain() -> Self; -} - -impl Plain for TextTag { - fn plain() -> Self { - TextTag::builder().wrap_mode(WrapMode::Word).build() - } -} From 8400ed2b6a708a50a09eceae1deed747f3047d6c Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 04:25:02 +0200 Subject: [PATCH 060/110] make `Reference` tag private --- .../tab/item/page/content/text/markdown/tags/reference.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ef0f80d6..277e6458 100644 --- 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 @@ -12,7 +12,7 @@ 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>[^\)]+)\)"; -pub struct Reference { +struct Reference { uri: Uri, alt: String, } From 3df4a79e0aec6a45b1b2487c07ba5d0c0cb71a8f Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 04:50:35 +0200 Subject: [PATCH 061/110] define title based on first `Header` tag match --- .../window/tab/item/page/content/text/markdown.rs | 5 +---- .../tab/item/page/content/text/markdown/tags.rs | 6 ++++-- .../item/page/content/text/markdown/tags/header.rs | 12 +++++++++++- 3 files changed, 16 insertions(+), 7 deletions(-) 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 index 34be39c0..d45af4ce 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -39,9 +39,6 @@ impl Markdown { base: &Uri, markdown: &str, ) -> Result<Self, Error> { - // Init default values - let mut title = None; - // Init HashMap storage (for event controllers) let mut links: HashMap<TextTag, Uri> = HashMap::new(); @@ -110,7 +107,7 @@ impl Markdown { // Render markdown tags // * keep in order! - tags.render(&buffer, &base, &link_color.0, &mut links); + let title = tags.render(&buffer, &base, &link_color.0, &mut links); // Parse single-line markdown tags /*'l: for line in markdown.lines() { 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 index d911b98c..7b1d065f 100644 --- 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 @@ -59,13 +59,15 @@ impl Tags { base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, - ) { + ) -> Option<String> { // * keep in order! - self.header.render(buffer); + let title = self.header.render(buffer); // @TODO strip raw tags self.quote.render(buffer); reference::render_images_links(&buffer, base, &link_color, links); reference::render_images(&buffer, base, &link_color, links); reference::render_links(&buffer, base, &link_color, links); + + title } } 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 index 44ae5f68..b171cc82 100644 --- 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 @@ -64,7 +64,9 @@ impl Header { } /// Apply title `Tag` to given `TextBuffer` - pub fn render(&self, buffer: &TextBuffer) { + pub fn render(&self, buffer: &TextBuffer) -> Option<String> { + let mut raw_title = None; + let table = buffer.tag_table(); assert!(table.add(&self.h1)); @@ -81,6 +83,12 @@ impl Header { .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(); @@ -102,6 +110,8 @@ impl Header { _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]), } } + + raw_title } } From 43f348e9bbe17ecb1ed123e726051e996391e149 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 05:20:10 +0200 Subject: [PATCH 062/110] implement strip_tags filter --- .../item/page/content/text/markdown/tags.rs | 5 +-- .../content/text/markdown/tags/reference.rs | 36 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) 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 index 7b1d065f..d3799ee8 100644 --- 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 @@ -61,13 +61,14 @@ impl Tags { links: &mut HashMap<TextTag, Uri>, ) -> Option<String> { // * keep in order! - let title = self.header.render(buffer); // @TODO strip raw tags + let title = self.header.render(buffer); + self.quote.render(buffer); reference::render_images_links(&buffer, base, &link_color, links); reference::render_images(&buffer, base, &link_color, links); reference::render_links(&buffer, base, &link_color, links); - title + title.map(|ref s| reference::strip_tags(s)) // @TODO other tags } } 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 index 277e6458..9019e22f 100644 --- 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 @@ -221,11 +221,41 @@ pub fn render_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) + .into_iter() + { + 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) + .into_iter() + { + 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)"#) + .captures_iter(r"[link1](https://link1.com) [link2](https://link2.com)") .collect(); let first = cap.get(0).unwrap(); @@ -245,7 +275,7 @@ fn test_regex_image_link() { REGEX_IMAGE_LINK, ) .unwrap().captures_iter( - r#"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)"# + r"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)" ).collect(); let first = cap.get(0).unwrap(); @@ -271,7 +301,7 @@ fn test_regex_image_link() { fn test_regex_image() { let cap: Vec<_> = Regex::new(REGEX_IMAGE) .unwrap() - .captures_iter(r#"![image1](https://image1.com) ![image2](https://image2.com)"#) + .captures_iter(r"![image1](https://image1.com) ![image2](https://image2.com)") .collect(); let first = cap.get(0).unwrap(); From b8b85873ab5eeca07a94315119d5e707dc93fd3b Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 05:28:45 +0200 Subject: [PATCH 063/110] allow empty alt --- .../tab/item/page/content/text/markdown/tags/reference.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 9019e22f..5ee0eaaa 100644 --- 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 @@ -7,10 +7,10 @@ use gtk::{ 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_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>[^\)]+)\)"; + r"\[(?P<is_img>!)\[(?P<alt>[^\]]*)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)"; struct Reference { uri: Uri, From ea2f4656a07d8570b7d4f8d13718953e6bdddc23 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 05:55:37 +0200 Subject: [PATCH 064/110] add `bold` tag support --- .../item/page/content/text/markdown/tags.rs | 12 ++- .../page/content/text/markdown/tags/bold.rs | 86 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/bold.rs 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 index d3799ee8..9748680b 100644 --- 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 @@ -1,3 +1,4 @@ +mod bold; mod header; mod list; mod quote; @@ -6,6 +7,7 @@ mod title; use std::collections::HashMap; +use bold::Bold; use gtk::{ TextBuffer, TextTag, TextTagTable, gdk::RGBA, @@ -20,6 +22,7 @@ use title::Title; pub struct Tags { pub text_tag_table: TextTagTable, // Tags + pub bold: Bold, pub header: Header, pub list: TextTag, pub quote: Quote, @@ -47,6 +50,7 @@ impl Tags { Self { text_tag_table, // Tags + bold: Bold::new(), header: Header::new(), list, quote: Quote::new(), @@ -65,10 +69,16 @@ impl Tags { self.quote.render(buffer); + self.bold.render(buffer); + reference::render_images_links(&buffer, base, &link_color, links); reference::render_images(&buffer, base, &link_color, links); reference::render_links(&buffer, base, &link_color, links); - title.map(|ref s| reference::strip_tags(s)) // @TODO other tags + title.map(|mut s| { + s = reference::strip_tags(&s); + s = bold::strip_tags(&s); + s // @TODO other tags + }) } } 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..8060c6ad --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/bold.rs @@ -0,0 +1,86 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_BOLD: &str = r"\*\*(?P<text>[^*]+)\*\*"; + +pub struct Bold(TextTag); + +impl Bold { + pub fn new() -> Self { + Self(TextTag::builder().weight(600).wrap_mode(Word).build()) + } + + /// Apply **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); + + 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_BOLD) + .unwrap() + .captures_iter(&value) + .into_iter() + { + 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 **bold 1** and **bold 2** with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_BOLD) + .unwrap() + .captures_iter(VALUE) + .into_iter() + { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!( + result, + "Some bold 1 and bold 2 with ![img](https://link.com)" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_BOLD) + .unwrap() + .captures_iter(r"Some **bold 1** and **bold 2** with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.get(0).unwrap()["text"], "bold 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "bold 2"); +} From 5b8a469b5bec1a247db7dc899afcab3df3526b88 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 06:09:47 +0200 Subject: [PATCH 065/110] add `underline` tag support --- .../item/page/content/text/markdown/tags.rs | 6 ++ .../content/text/markdown/tags/underline.rs | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs 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 index 9748680b..29f0f524 100644 --- 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 @@ -4,6 +4,7 @@ mod list; mod quote; mod reference; mod title; +mod underline; use std::collections::HashMap; @@ -18,6 +19,7 @@ use header::Header; use list::List; use quote::Quote; use title::Title; +use underline::Underline; pub struct Tags { pub text_tag_table: TextTagTable, @@ -27,6 +29,7 @@ pub struct Tags { pub list: TextTag, pub quote: Quote, pub title: TextTag, + pub underline: Underline, } impl Default for Tags { @@ -55,6 +58,7 @@ impl Tags { list, quote: Quote::new(), title, + underline: Underline::new(), } } pub fn render( @@ -70,6 +74,7 @@ impl Tags { self.quote.render(buffer); self.bold.render(buffer); + self.underline.render(buffer); reference::render_images_links(&buffer, base, &link_color, links); reference::render_images(&buffer, base, &link_color, links); @@ -78,6 +83,7 @@ impl Tags { title.map(|mut s| { s = reference::strip_tags(&s); s = bold::strip_tags(&s); + s = underline::strip_tags(&s); s // @TODO other tags }) } 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..0b04115e --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs @@ -0,0 +1,87 @@ +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); + + 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_UNDERLINE) + .unwrap() + .captures_iter(&value) + .into_iter() + { + 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) + .into_iter() + { + 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.get(0).unwrap()["text"], "underline 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "underline 2"); +} From c6661aa6565ce06b327373bb1456f55e6a65219d Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 06:15:38 +0200 Subject: [PATCH 066/110] add `strike` tag support --- .../item/page/content/text/markdown/tags.rs | 8 +- .../page/content/text/markdown/tags/strike.rs | 91 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs 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 index 29f0f524..bec94ad9 100644 --- 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 @@ -3,6 +3,7 @@ mod header; mod list; mod quote; mod reference; +mod strike; mod title; mod underline; @@ -18,6 +19,7 @@ use gtk::{ use header::Header; use list::List; use quote::Quote; +use strike::Strike; use title::Title; use underline::Underline; @@ -28,6 +30,7 @@ pub struct Tags { pub header: Header, pub list: TextTag, pub quote: Quote, + pub strike: Strike, pub title: TextTag, pub underline: Underline, } @@ -57,6 +60,7 @@ impl Tags { header: Header::new(), list, quote: Quote::new(), + strike: Strike::new(), title, underline: Underline::new(), } @@ -74,6 +78,7 @@ impl Tags { self.quote.render(buffer); self.bold.render(buffer); + self.strike.render(buffer); self.underline.render(buffer); reference::render_images_links(&buffer, base, &link_color, links); @@ -81,8 +86,9 @@ impl Tags { reference::render_links(&buffer, base, &link_color, links); title.map(|mut s| { - s = reference::strip_tags(&s); s = bold::strip_tags(&s); + s = reference::strip_tags(&s); + s = strike::strip_tags(&s); s = underline::strip_tags(&s); s // @TODO other tags }) 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..406ee80b --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs @@ -0,0 +1,91 @@ +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); + + 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_STRIKE) + .unwrap() + .captures_iter(&value) + .into_iter() + { + 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) + .into_iter() + { + 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.get(0).unwrap()["text"], "strike 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "strike 2"); +} From 9e787468acebf8079b3acf658abc62b4bb63fef3 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 16:41:45 +0200 Subject: [PATCH 067/110] add markdown support, reorder asc --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 146e4461..d2b344e2 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` From 1706f14e96d859de161d8b5242c1559d2765bf2b Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 16:47:38 +0200 Subject: [PATCH 068/110] remove some extra members --- .../tab/item/page/content/text/markdown.rs | 70 ++----------------- .../item/page/content/text/markdown/icon.rs | 31 -------- .../item/page/content/text/markdown/tags.rs | 28 +------- .../page/content/text/markdown/tags/list.rs | 16 ----- .../page/content/text/markdown/tags/title.rs | 16 ----- 5 files changed, 5 insertions(+), 156 deletions(-) delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/icon.rs delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/title.rs 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 index d45af4ce..b06c372c 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -1,7 +1,6 @@ mod ansi; pub mod error; mod gutter; -mod icon; mod syntax; mod tags; @@ -9,22 +8,19 @@ use super::{ItemAction, WindowAction}; use crate::app::browser::window::action::Position; pub use error::Error; use gtk::{ - EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType, - UriLauncher, Window, WrapMode, + EventControllerMotion, GestureClick, TextBuffer, TextTag, TextTagTable, TextView, + TextWindowType, UriLauncher, Window, WrapMode, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, gio::{Cancellable, SimpleAction, SimpleActionGroup}, glib::{Uri, uuid_string_random}, - prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt}, + prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; -use icon::Icon; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; use syntax::Syntax; use tags::Tags; -pub const NEW_LINE: &str = "\n"; - pub struct Markdown { pub title: Option<String>, pub text_view: TextView, @@ -49,9 +45,6 @@ impl Markdown { // Init code features //let mut code = None; - // Init quote icon feature - let mut is_line_after_quote = false; - // Init colors // @TODO use accent colors in adw 1.6 / ubuntu 24.10+ let link_color = ( @@ -62,14 +55,11 @@ impl Markdown { // Init syntect highlight features let syntax = Syntax::new(); - // Init icons - let icon = Icon::new(); - // Init tags let tags = Tags::new(); // Init new text buffer - let buffer = TextBuffer::new(Some(&tags.text_tag_table)); + let buffer = TextBuffer::new(Some(&TextTagTable::new())); buffer.set_text(markdown); // Init main widget @@ -194,42 +184,6 @@ impl Markdown { } } } - - // Is list - - if let Some(value) = ggemtext::line::list::Gemtext::as_value(line) { - buffer.insert_with_tags( - &mut buffer.end_iter(), - &format!("• {value}"), - &[&tag.list], - ); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - continue; - } - - // Is quote - - if let Some(quote) = ggemtext::line::quote::Gemtext::as_value(line) { - // Show quote indicator if last line is not quote (to prevent duplicates) - if !is_line_after_quote { - // Show only if the icons resolved for default `Display` - if let Some(ref icon) = icon { - buffer.insert_paintable(&mut buffer.end_iter(), &icon.quote); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - } - } - buffer.insert_with_tags(&mut buffer.end_iter(), quote, &[&tag.quote]); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - is_line_after_quote = true; - continue; - } else { - is_line_after_quote = false; - } - - // Nothing match custom tags above, - // just append plain text covered in empty tag (to handle controller events properly) - buffer.insert_with_tags(&mut buffer.end_iter(), line, &[&tag.plain]); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); }*/ // Context menu @@ -528,21 +482,5 @@ fn link_prefix(request: String, prefix: &str) -> String { format!("{prefix}{}", request.trim_start_matches(prefix)) } -/// Header tag -fn header(buffer: &TextBuffer, tag: &TextTag, line: &str, pattern: &str) -> Option<String> { - if let Some(h) = line.trim_start().strip_prefix(pattern) - && !h.starts_with(pattern) - { - let header = h.trim(); - buffer.insert_with_tags(&mut buffer.end_iter(), header, &[tag]); - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - Some(header.into()) - } else { - None - } -} - const LINK_PREFIX_DOWNLOAD: &str = "download:"; const LINK_PREFIX_SOURCE: &str = "source:"; - -const H: &str = "#"; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/icon.rs b/src/app/browser/window/tab/item/page/content/text/markdown/icon.rs deleted file mode 100644 index a85ec38f..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/icon.rs +++ /dev/null @@ -1,31 +0,0 @@ -use gtk::{IconLookupFlags, IconPaintable, IconTheme, TextDirection, gdk::Display}; - -const SIZE: i32 = 16; - -/// Indication icons asset (for tag blocks decoration) -pub struct Icon { - pub quote: IconPaintable, - // @TODO other tags.. -} - -impl Icon { - pub fn new() -> Option<Self> { - Display::default().map(|display| { - let theme = IconTheme::for_display(&display); - Self { - quote: icon(&theme, "mail-forward-symbolic"), - } - }) - } -} - -fn icon(theme: &IconTheme, name: &str) -> IconPaintable { - theme.lookup_icon( - name, - &[], // @TODO - SIZE, - SIZE, - TextDirection::None, - IconLookupFlags::FORCE_SYMBOLIC, - ) -} 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 index bec94ad9..ec3d0aa4 100644 --- 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 @@ -1,37 +1,24 @@ mod bold; mod header; -mod list; mod quote; mod reference; mod strike; -mod title; mod underline; use std::collections::HashMap; use bold::Bold; -use gtk::{ - TextBuffer, TextTag, TextTagTable, - gdk::RGBA, - glib::Uri, - prelude::{TextBufferExt, TextBufferExtManual}, -}; +use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri}; use header::Header; -use list::List; use quote::Quote; use strike::Strike; -use title::Title; use underline::Underline; pub struct Tags { - pub text_tag_table: TextTagTable, - // Tags pub bold: Bold, pub header: Header, - pub list: TextTag, pub quote: Quote, pub strike: Strike, - pub title: TextTag, pub underline: Underline, } @@ -44,24 +31,11 @@ impl Default for Tags { impl Tags { // Construct pub fn new() -> Self { - // Init tag table - let text_tag_table = TextTagTable::new(); - - // Init shared tags members - let list = TextTag::list(); - let title = TextTag::title(); - text_tag_table.add(&title); - text_tag_table.add(&list); - Self { - text_tag_table, - // Tags bold: Bold::new(), header: Header::new(), - list, quote: Quote::new(), strike: Strike::new(), - title, underline: Underline::new(), } } 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 deleted file mode 100644 index cba74053..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs +++ /dev/null @@ -1,16 +0,0 @@ -use gtk::{TextTag, WrapMode}; - -pub trait List { - fn list() -> Self; -} - -impl List for TextTag { - fn list() -> Self { - TextTag::builder() - .left_margin(28) - .pixels_above_lines(4) - .pixels_below_lines(4) - .wrap_mode(WrapMode::Word) - .build() - } -} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/title.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/title.rs deleted file mode 100644 index ed0072fe..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags/title.rs +++ /dev/null @@ -1,16 +0,0 @@ -use gtk::{TextTag, WrapMode}; - -pub trait Title { - fn title() -> Self; -} - -impl Title for TextTag { - fn title() -> Self { - TextTag::builder() - .pixels_above_lines(4) - .pixels_below_lines(8) - .weight(500) - .wrap_mode(WrapMode::None) - .build() - } -} From d674edc7d0068680c0d6871e6b3e8badf4659096 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 16:53:42 +0200 Subject: [PATCH 069/110] remove extras --- .../browser/window/tab/item/page/content.rs | 27 +++---------------- .../window/tab/item/page/content/text.rs | 27 +++++-------------- .../tab/item/page/content/text/markdown.rs | 27 ++----------------- 3 files changed, 12 insertions(+), 69 deletions(-) diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index 05247888..fc88758d 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -157,30 +157,9 @@ impl Content { /// `text/markdown` pub fn to_text_markdown(&self, base: &Uri, data: &str) -> Text { self.clean(); - match Text::markdown((&self.window_action, &self.item_action), base, data) { - Ok(text) => { - self.g_box.append(&text.scrolled_window); - text - } - Err((message, text)) => { - self.g_box.append(&{ - let banner = adw::Banner::builder() - .title(message) - .revealed(true) - .button_label("Ok") - .build(); - banner.connect_button_clicked(|this| this.set_revealed(false)); - banner - }); - match text { - Some(text) => { - self.g_box.append(&text.scrolled_window); - text - } - None => todo!(), - } - } - } + let m = Text::markdown((&self.window_action, &self.item_action), base, data); + self.g_box.append(&m.scrolled_window); + m } /// `text/plain` 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 53730409..9a634185 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -57,26 +57,13 @@ impl Text { actions: (&Rc<WindowAction>, &Rc<ItemAction>), base: &Uri, gemtext: &str, - ) -> Result<Self, (String, Option<Self>)> { - match Markdown::build(actions, base, gemtext) { - Ok(widget) => Ok(Self { - scrolled_window: reader(&widget.text_view), - text_view: widget.text_view, - meta: Meta { - title: widget.title, - }, - }), - Err(e) => match e { - markdown::Error::Markup(message, widget) => Err(( - message, - Some(Self { - scrolled_window: reader(&widget.text_view), - text_view: widget.text_view, - meta: Meta { - title: widget.title, - }, - }), - )), + ) -> Self { + let markdown = Markdown::build(actions, base, gemtext); + Self { + scrolled_window: reader(&markdown.text_view), + text_view: markdown.text_view, + meta: Meta { + title: markdown.title, }, } } 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 index b06c372c..fb15924b 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -34,7 +34,7 @@ impl Markdown { (window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>), base: &Uri, markdown: &str, - ) -> Result<Self, Error> { + ) -> Self { // Init HashMap storage (for event controllers) let mut links: HashMap<TextTag, Uri> = HashMap::new(); @@ -81,22 +81,7 @@ impl Markdown { // Init gutter widget (the tooltip on URL tags hover) let gutter = Gutter::build(&text_view); - // Disable code format on at least one closing tag not found - // gemini://bbs.geminispace.org/s/Gemini/26031 - let is_code_enabled = { - use ggemtext::line::code::{self}; - let mut t: usize = 0; - for l in markdown.lines() { - if l.starts_with(code::TAG) { - t += 1; - } - } - t == 0 || t.is_multiple_of(2) - }; - // Render markdown tags - // * keep in order! - let title = tags.render(&buffer, &base, &link_color.0, &mut links); // Parse single-line markdown tags @@ -418,15 +403,7 @@ impl Markdown { } }); // @TODO may be expensive for CPU, add timeout? - // Result - if is_code_enabled { - Ok(Self { text_view, title }) - } else { - Err(Error::Markup( - "Invalid multiline markup! Markdown format partially ignored.".to_string(), - Self { text_view, title }, - )) - } + Self { text_view, title } } } From a8d25e695f58b74a114dfac7d08413fa77482beb Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 17:46:02 +0200 Subject: [PATCH 070/110] draft basic multi-line code tags impl --- .../tab/item/page/content/text/markdown.rs | 3 +- .../item/page/content/text/markdown/tags.rs | 14 ++- .../page/content/text/markdown/tags/pre.rs | 106 ++++++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs 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 index fb15924b..78a7df54 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -6,7 +6,6 @@ mod tags; use super::{ItemAction, WindowAction}; use crate::app::browser::window::action::Position; -pub use error::Error; use gtk::{ EventControllerMotion, GestureClick, TextBuffer, TextTag, TextTagTable, TextView, TextWindowType, UriLauncher, Window, WrapMode, @@ -56,7 +55,7 @@ impl Markdown { let syntax = Syntax::new(); // Init tags - let tags = Tags::new(); + let mut tags = Tags::new(); // Init new text buffer let buffer = TextBuffer::new(Some(&TextTagTable::new())); 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 index ec3d0aa4..779c236d 100644 --- 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 @@ -1,5 +1,6 @@ mod bold; mod header; +mod pre; mod quote; mod reference; mod strike; @@ -10,6 +11,7 @@ use std::collections::HashMap; use bold::Bold; use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri}; use header::Header; +use pre::Pre; use quote::Quote; use strike::Strike; use underline::Underline; @@ -17,6 +19,7 @@ use underline::Underline; pub struct Tags { pub bold: Bold, pub header: Header, + pub pre: Pre, pub quote: Quote, pub strike: Strike, pub underline: Underline, @@ -34,19 +37,23 @@ impl Tags { Self { bold: Bold::new(), header: Header::new(), + pre: Pre::new(), quote: Quote::new(), strike: Strike::new(), underline: Underline::new(), } } pub fn render( - &self, + &mut self, buffer: &TextBuffer, base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, ) -> Option<String> { - // * keep in order! + // Collect all preformatted blocks first, and replace them with tmp macro ID + self.pre.collect(buffer); + + // Keep in order! let title = self.header.render(buffer); self.quote.render(buffer); @@ -59,6 +66,9 @@ impl Tags { reference::render_images(&buffer, base, &link_color, links); reference::render_links(&buffer, base, &link_color, links); + self.pre.render(buffer); + + // Format document title string title.map(|mut s| { s = bold::strip_tags(&s); s = reference::strip_tags(&s); 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..dd0df5c0 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs @@ -0,0 +1,106 @@ +use gtk::{ + TextBuffer, TextSearchFlags, TextTag, + WrapMode::Word, + glib::{GString, uuid_string_random}, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; + +const REGEX_PRE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```"; + +struct Entry { + alt: Option<String>, + data: String, +} + +pub struct Pre { + index: HashMap<GString, Entry>, + tag: TextTag, +} + +impl Pre { + pub fn new() -> Self { + Self { + index: HashMap::new(), + tag: TextTag::builder().wrap_mode(Word).build(), // @TODO + } + } + + /// Collect all preformatted 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_PRE) + .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 preformatted `Tag` to given `TextBuffer` using `Self.index` + pub fn render(&mut self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.tag)); + 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); + + let alt_text = v.alt.as_deref().unwrap_or(""); + let display_text = format!("{} |\n {}", alt_text, v.data); + + buffer.insert_with_tags(&mut m_start, &display_text, &[&self.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_PRE) + .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.get(0).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"); +} From 0cc9c694380d5248f54ff3f4e757f24e30c17258 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 18:22:46 +0200 Subject: [PATCH 071/110] implement code highlight and ansi features for the `preformatted` tag --- .../tab/item/page/content/text/markdown.rs | 96 +------ .../item/page/content/text/markdown/error.rs | 3 - .../item/page/content/text/markdown/tags.rs | 6 +- .../page/content/text/markdown/tags/bold.rs | 12 +- .../page/content/text/markdown/tags/pre.rs | 42 ++- .../text/markdown/{ => tags/pre}/ansi.rs | 0 .../text/markdown/tags/pre/ansi/rgba.rs | 256 ++++++++++++++++++ .../text/markdown/tags/pre/ansi/tag.rs | 29 ++ .../text/markdown/{ => tags/pre}/syntax.rs | 0 .../markdown/{ => tags/pre}/syntax/error.rs | 0 .../markdown/{ => tags/pre}/syntax/tag.rs | 0 .../content/text/markdown/tags/reference.rs | 12 +- .../page/content/text/markdown/tags/strike.rs | 12 +- .../content/text/markdown/tags/underline.rs | 12 +- 14 files changed, 329 insertions(+), 151 deletions(-) delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/error.rs rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags/pre}/ansi.rs (100%) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/tag.rs rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags/pre}/syntax.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags/pre}/syntax/error.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags/pre}/syntax/tag.rs (100%) 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 index 78a7df54..ebcaf16e 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -1,7 +1,4 @@ -mod ansi; -pub mod error; mod gutter; -mod syntax; mod tags; use super::{ItemAction, WindowAction}; @@ -17,7 +14,6 @@ use gtk::{ use gutter::Gutter; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; -use syntax::Syntax; use tags::Tags; pub struct Markdown { @@ -51,9 +47,6 @@ impl Markdown { RGBA::new(0.208, 0.518, 0.894, 0.9), ); - // Init syntect highlight features - let syntax = Syntax::new(); - // Init tags let mut tags = Tags::new(); @@ -81,94 +74,7 @@ impl Markdown { let gutter = Gutter::build(&text_view); // Render markdown tags - let title = tags.render(&buffer, &base, &link_color.0, &mut links); - - // Parse single-line markdown tags - /*'l: for line in markdown.lines() { - if is_code_enabled { - use ggemtext::line::Code; - match code { - None => { - // Open tag found - if let Some(c) = Code::begin_from(line) { - // Begin next lines collection into the code buffer - code = Some(c); - - // Skip other actions for this line - continue; - } - } - Some(ref mut c) => { - match c.continue_from(line) { - Ok(()) => { - // Close tag found: - if c.is_completed { - // Is alt provided - let alt = match c.alt { - Some(ref alt) => { - // Insert alt value to the main buffer - buffer.insert_with_tags( - &mut buffer.end_iter(), - alt.as_str(), - &[&tag.title], - ); - - // Append new line after alt text - buffer.insert(&mut buffer.end_iter(), NEW_LINE); - - // Return value as wanted also for syntax highlight detection - Some(alt) - } - None => None, - }; - - // Begin code block construction - // Try auto-detect code syntax for given `value` and `alt` @TODO optional - 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 - buffer.insert_with_tags( - &mut buffer.end_iter(), - &entity, - &[&syntax_tag], - ); - } - } - 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 - buffer.insert_with_tags( - &mut buffer.end_iter(), - &entity, - &[&syntax_tag], - ); - } - } // @TODO handle - } - - // Reset - code = None; - } - - // Skip other actions for this line - continue; - } - Err(_) => todo!(), - } - } - } - } - }*/ + let title = tags.render(&buffer, base, &link_color.0, &mut links); // Context menu let action_link_tab = diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/error.rs deleted file mode 100644 index e2b6650a..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/error.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub enum Error { - Markup(String, super::Markdown), -} 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 index 779c236d..30829775 100644 --- 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 @@ -62,9 +62,9 @@ impl Tags { self.strike.render(buffer); self.underline.render(buffer); - reference::render_images_links(&buffer, base, &link_color, links); - reference::render_images(&buffer, base, &link_color, links); - reference::render_links(&buffer, base, &link_color, links); + reference::render_images_links(buffer, base, link_color, links); + reference::render_images(buffer, base, link_color, links); + reference::render_links(buffer, base, link_color, links); self.pre.render(buffer); 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 index 8060c6ad..a1f04579 100644 --- 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 @@ -43,11 +43,7 @@ impl Bold { pub fn strip_tags(value: &str) -> String { let mut result = String::from(value); - for cap in Regex::new(REGEX_BOLD) - .unwrap() - .captures_iter(&value) - .into_iter() - { + 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"]); } @@ -59,11 +55,7 @@ pub fn strip_tags(value: &str) -> String { fn test_strip_tags() { const VALUE: &str = r"Some **bold 1** and **bold 2** with ![img](https://link.com)"; let mut result = String::from(VALUE); - for cap in Regex::new(REGEX_BOLD) - .unwrap() - .captures_iter(VALUE) - .into_iter() - { + 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"]); } 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 index dd0df5c0..2f7e47d1 100644 --- 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 @@ -1,11 +1,14 @@ +mod ansi; +mod syntax; + use gtk::{ - TextBuffer, TextSearchFlags, TextTag, - WrapMode::Word, + TextBuffer, TextSearchFlags, TextTag, WrapMode, glib::{GString, uuid_string_random}, prelude::{TextBufferExt, TextBufferExtManual}, }; use regex::Regex; use std::collections::HashMap; +use syntax::Syntax; const REGEX_PRE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```"; @@ -16,14 +19,19 @@ struct Entry { pub struct Pre { index: HashMap<GString, Entry>, - tag: TextTag, + alt: TextTag, } impl Pre { pub fn new() -> Self { Self { index: HashMap::new(), - tag: TextTag::builder().wrap_mode(Word).build(), // @TODO + alt: TextTag::builder() + .pixels_above_lines(4) + .pixels_below_lines(8) + .weight(500) + .wrap_mode(WrapMode::None) + .build(), } } @@ -67,7 +75,8 @@ impl Pre { /// Apply preformatted `Tag` to given `TextBuffer` using `Self.index` pub fn render(&mut self, buffer: &TextBuffer) { - assert!(buffer.tag_table().add(&self.tag)); + 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 @@ -75,11 +84,24 @@ impl Pre { .forward_search(k, TextSearchFlags::VISIBLE_ONLY, None) { buffer.delete(&mut m_start, &mut m_end); - - let alt_text = v.alt.as_deref().unwrap_or(""); - let display_text = format!("{} |\n {}", alt_text, v.data); - - buffer.insert_with_tags(&mut m_start, &display_text, &[&self.tag]); + 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]) + } + } + } } } } diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/ansi.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/ansi.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs new file mode 100644 index 00000000..d1398d2f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs @@ -0,0 +1,256 @@ +use gtk::gdk::RGBA; + +/// Default RGBa palette for ANSI terminal emulation +pub fn default(color: u8) -> Option<RGBA> { + 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/pre/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/tag.rs new file mode 100644 index 00000000..7154b1f3 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/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/syntax.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/syntax.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/syntax/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/error.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/syntax/error.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/error.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/syntax/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/tag.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/syntax/tag.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/tag.rs 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 index 5ee0eaaa..c7b828e6 100644 --- 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 @@ -223,11 +223,7 @@ pub fn render_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) - .into_iter() - { + 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"]); } @@ -239,11 +235,7 @@ pub fn strip_tags(value: &str) -> String { 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) - .into_iter() - { + 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"]); } 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 index 406ee80b..1ec48f7c 100644 --- 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 @@ -48,11 +48,7 @@ impl Strike { pub fn strip_tags(value: &str) -> String { let mut result = String::from(value); - for cap in Regex::new(REGEX_STRIKE) - .unwrap() - .captures_iter(&value) - .into_iter() - { + 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"]); } @@ -64,11 +60,7 @@ pub fn strip_tags(value: &str) -> String { 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) - .into_iter() - { + 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"]); } 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 index 0b04115e..8f8f25e2 100644 --- 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 @@ -44,11 +44,7 @@ impl Underline { pub fn strip_tags(value: &str) -> String { let mut result = String::from(value); - for cap in Regex::new(REGEX_UNDERLINE) - .unwrap() - .captures_iter(&value) - .into_iter() - { + 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"]); } @@ -60,11 +56,7 @@ pub fn strip_tags(value: &str) -> String { 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) - .into_iter() - { + 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"]); } From 0f53a899ad5cd992e2abe617a2c330a3ea05daa1 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 19:20:58 +0200 Subject: [PATCH 072/110] rename `pre` to `code`, cleanup extra components --- .../item/page/content/text/markdown/tags.rs | 14 +- .../text/markdown/tags/{pre.rs => code.rs} | 14 +- .../text/markdown/tags/{pre => code}/ansi.rs | 0 .../markdown/{ => tags/code}/ansi/rgba.rs | 0 .../text/markdown/{ => tags/code}/ansi/tag.rs | 0 .../markdown/tags/{pre => code}/syntax.rs | 0 .../tags/{pre => code}/syntax/error.rs | 0 .../markdown/tags/{pre => code}/syntax/tag.rs | 0 .../text/markdown/tags/pre/ansi/rgba.rs | 256 ------------------ .../text/markdown/tags/pre/ansi/tag.rs | 29 -- 10 files changed, 14 insertions(+), 299 deletions(-) rename src/app/browser/window/tab/item/page/content/text/markdown/tags/{pre.rs => code.rs} (91%) rename src/app/browser/window/tab/item/page/content/text/markdown/tags/{pre => code}/ansi.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags/code}/ansi/rgba.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/{ => tags/code}/ansi/tag.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/tags/{pre => code}/syntax.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/tags/{pre => code}/syntax/error.rs (100%) rename src/app/browser/window/tab/item/page/content/text/markdown/tags/{pre => code}/syntax/tag.rs (100%) delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs delete mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/tag.rs 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 index 30829775..d529f3c3 100644 --- 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 @@ -1,6 +1,6 @@ mod bold; +mod code; mod header; -mod pre; mod quote; mod reference; mod strike; @@ -9,17 +9,17 @@ mod underline; use std::collections::HashMap; use bold::Bold; +use code::Code; use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri}; use header::Header; -use pre::Pre; use quote::Quote; use strike::Strike; use underline::Underline; pub struct Tags { pub bold: Bold, + pub code: Code, pub header: Header, - pub pre: Pre, pub quote: Quote, pub strike: Strike, pub underline: Underline, @@ -36,8 +36,8 @@ impl Tags { pub fn new() -> Self { Self { bold: Bold::new(), + code: Code::new(), header: Header::new(), - pre: Pre::new(), quote: Quote::new(), strike: Strike::new(), underline: Underline::new(), @@ -50,8 +50,8 @@ impl Tags { link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, ) -> Option<String> { - // Collect all preformatted blocks first, and replace them with tmp macro ID - self.pre.collect(buffer); + // Collect all code blocks first, and replace them with tmp macro ID + self.code.collect(buffer); // Keep in order! let title = self.header.render(buffer); @@ -66,7 +66,7 @@ impl Tags { reference::render_images(buffer, base, link_color, links); reference::render_links(buffer, base, link_color, links); - self.pre.render(buffer); + self.code.render(buffer); // Format document title string title.map(|mut s| { 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/code.rs similarity index 91% rename from src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code.rs index 2f7e47d1..2678d41c 100644 --- 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/code.rs @@ -10,19 +10,19 @@ use regex::Regex; use std::collections::HashMap; use syntax::Syntax; -const REGEX_PRE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```"; +const REGEX_CODE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```"; struct Entry { alt: Option<String>, data: String, } -pub struct Pre { +pub struct Code { index: HashMap<GString, Entry>, alt: TextTag, } -impl Pre { +impl Code { pub fn new() -> Self { Self { index: HashMap::new(), @@ -35,12 +35,12 @@ impl Pre { } } - /// Collect all preformatted blocks into `Self.index` (to prevent formatting) + /// 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_PRE) + let matches: Vec<_> = Regex::new(REGEX_CODE) .unwrap() .captures_iter(&full_content) .collect(); @@ -73,7 +73,7 @@ impl Pre { } } - /// Apply preformatted `Tag` to given `TextBuffer` using `Self.index` + /// 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)); @@ -113,7 +113,7 @@ fn alt(value: Option<&str>) -> Option<&str> { #[test] fn test_regex() { - let cap: Vec<_> = Regex::new(REGEX_PRE) + 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(); diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/ansi/rgba.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/ansi/tag.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/error.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs similarity index 100% rename from src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/syntax/tag.rs rename to src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs deleted file mode 100644 index d1398d2f..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/rgba.rs +++ /dev/null @@ -1,256 +0,0 @@ -use gtk::gdk::RGBA; - -/// Default RGBa palette for ANSI terminal emulation -pub fn default(color: u8) -> Option<RGBA> { - 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/pre/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/tag.rs deleted file mode 100644 index 7154b1f3..00000000 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre/ansi/tag.rs +++ /dev/null @@ -1,29 +0,0 @@ -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(), - } - } -} From fb7e00758b7d813c2b115774f1d65dcaa55c333d Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 19:53:11 +0200 Subject: [PATCH 073/110] implement `pre` tag --- .../item/page/content/text/markdown/tags.rs | 6 ++ .../page/content/text/markdown/tags/pre.rs | 89 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs 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 index d529f3c3..52b26a9a 100644 --- 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 @@ -1,6 +1,7 @@ mod bold; mod code; mod header; +mod pre; mod quote; mod reference; mod strike; @@ -12,6 +13,7 @@ use bold::Bold; use code::Code; use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri}; use header::Header; +use pre::Pre; use quote::Quote; use strike::Strike; use underline::Underline; @@ -20,6 +22,7 @@ pub struct Tags { pub bold: Bold, pub code: Code, pub header: Header, + pub pre: Pre, pub quote: Quote, pub strike: Strike, pub underline: Underline, @@ -38,6 +41,7 @@ impl Tags { bold: Bold::new(), code: Code::new(), header: Header::new(), + pre: Pre::new(), quote: Quote::new(), strike: Strike::new(), underline: Underline::new(), @@ -59,6 +63,7 @@ impl Tags { self.quote.render(buffer); self.bold.render(buffer); + self.pre.render(buffer); self.strike.render(buffer); self.underline.render(buffer); @@ -71,6 +76,7 @@ impl Tags { // Format document title string title.map(|mut s| { s = bold::strip_tags(&s); + s = pre::strip_tags(&s); s = reference::strip_tags(&s); s = strike::strip_tags(&s); s = underline::strip_tags(&s); 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..77384fdc --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs @@ -0,0 +1,89 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + gdk::RGBA, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_PRE: &str = r"`(?P<text>[^`]+)`"; + +pub struct Pre(TextTag); + +impl Pre { + pub fn new() -> Self { + Self(if adw::StyleManager::default().is_dark() { + TextTag::builder() + .background_rgba(&RGBA::new(0.0, 0.0, 0.0, 1.)) + .foreground("#ccc") + .family("monospace") // @TODO + .wrap_mode(Word) + .build() + } else { + TextTag::builder() + .background_rgba(&RGBA::new(0.0, 0.0, 0.0, 0.06)) + .family("monospace") // @TODO + .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); + + 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.get(0).unwrap()["text"], "pre 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "pre 2"); +} From 666aa5caf80c56a76cedc9ddfa9515ad1792fe78 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 20:26:13 +0200 Subject: [PATCH 074/110] update preformatted tag style --- .../item/page/content/text/markdown/tags/pre.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 index 77384fdc..c2bada28 100644 --- 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 @@ -7,6 +7,8 @@ use gtk::{ 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); @@ -14,15 +16,17 @@ impl Pre { pub fn new() -> Self { Self(if adw::StyleManager::default().is_dark() { TextTag::builder() - .background_rgba(&RGBA::new(0.0, 0.0, 0.0, 1.)) - .foreground("#ccc") - .family("monospace") // @TODO + .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, 0.0, 0.06)) - .family("monospace") // @TODO + .background_rgba(&RGBA::new(0., 0., 0., 0.06)) + .family(TAG_FONT) + .scale(TAG_SCALE) .wrap_mode(Word) .build() }) From 722a6c8bb8fabeb42d52b7c793b702ef084b52e2 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 22:05:34 +0200 Subject: [PATCH 075/110] implement listing tag --- .../item/page/content/text/markdown/tags.rs | 6 +- .../page/content/text/markdown/tags/list.rs | 152 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs 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 index 52b26a9a..cd25684c 100644 --- 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 @@ -1,20 +1,20 @@ mod bold; mod code; mod header; +mod list; mod pre; mod quote; mod reference; mod strike; mod underline; -use std::collections::HashMap; - use bold::Bold; use code::Code; use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri}; use header::Header; use pre::Pre; use quote::Quote; +use std::collections::HashMap; use strike::Strike; use underline::Underline; @@ -60,6 +60,8 @@ impl Tags { // Keep in order! let title = self.header.render(buffer); + list::render(buffer); + self.quote.render(buffer); self.bold.render(buffer); 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..d05548df --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/list.rs @@ -0,0 +1,152 @@ +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 { + pub is_checked: bool, + //tag: TextTag, +} + +impl State { + fn parse(value: Option<&str>) -> Option<Self> { + if let Some(state) = value + && (state.starts_with("[ ]") || state.starts_with("[x]")) + { + return Some(Self { + is_checked: state.starts_with("[x]"), + }); + } + None + } +} + +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"); + } +} From 12a557eb02b6b795a2dfb63a8fb25419d9f04627 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 9 Mar 2026 22:19:30 +0200 Subject: [PATCH 076/110] shut up clippy --- .../window/tab/item/page/content/text/markdown/tags/bold.rs | 2 +- .../window/tab/item/page/content/text/markdown/tags/code.rs | 2 +- .../tab/item/page/content/text/markdown/tags/header.rs | 2 +- .../window/tab/item/page/content/text/markdown/tags/pre.rs | 2 +- .../tab/item/page/content/text/markdown/tags/quote.rs | 2 +- .../tab/item/page/content/text/markdown/tags/reference.rs | 6 +++--- .../tab/item/page/content/text/markdown/tags/strike.rs | 2 +- .../tab/item/page/content/text/markdown/tags/underline.rs | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) 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 index a1f04579..5ff97e4f 100644 --- 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 @@ -73,6 +73,6 @@ fn test_regex() { .captures_iter(r"Some **bold 1** and **bold 2** with ![img](https://link.com)") .collect(); - assert_eq!(&cap.get(0).unwrap()["text"], "bold 1"); + assert_eq!(&cap.first().unwrap()["text"], "bold 1"); assert_eq!(&cap.get(1).unwrap()["text"], "bold 2"); } 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 index 2678d41c..5d79041f 100644 --- 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 @@ -118,7 +118,7 @@ fn test_regex() { .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.get(0).unwrap(); + 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"); 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 index b171cc82..9de1b20c 100644 --- 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 @@ -122,7 +122,7 @@ fn test_regex_title() { .captures_iter(r"## Header ![alt](https://link.com)") .collect(); - let first = cap.get(0).unwrap(); + 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/pre.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs index c2bada28..02825949 100644 --- 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 @@ -88,6 +88,6 @@ fn test_regex() { .captures_iter(r"Some `pre 1` and `pre 2` with ![img](https://link.com)") .collect(); - assert_eq!(&cap.get(0).unwrap()["text"], "pre 1"); + 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 index 17db7bb5..0c932189 100644 --- 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 @@ -55,7 +55,7 @@ fn test_regex() { .captures_iter(r"> Some quote with ![img](https://link.com)") .collect(); - let first = cap.get(0).unwrap(); + let first = cap.first().unwrap(); assert_eq!(&first[0], "> Some quote with ![img](https://link.com)"); assert_eq!(&first["text"], "Some quote with ![img](https://link.com)"); } 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 index c7b828e6..8b7f534d 100644 --- 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 @@ -250,7 +250,7 @@ fn test_regex_link() { .captures_iter(r"[link1](https://link1.com) [link2](https://link2.com)") .collect(); - let first = cap.get(0).unwrap(); + 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"); @@ -270,7 +270,7 @@ fn test_regex_image_link() { r"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)" ).collect(); - let first = cap.get(0).unwrap(); + let first = cap.first().unwrap(); assert_eq!( &first[0], "[![image1](https://image1.com)](https://image2.com)" @@ -296,7 +296,7 @@ fn test_regex_image() { .captures_iter(r"![image1](https://image1.com) ![image2](https://image2.com)") .collect(); - let first = cap.get(0).unwrap(); + 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"); 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 index 1ec48f7c..02945a4c 100644 --- 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 @@ -78,6 +78,6 @@ fn test_regex() { .captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)") .collect(); - assert_eq!(&cap.get(0).unwrap()["text"], "strike 1"); + 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 index 8f8f25e2..b2c41c86 100644 --- 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 @@ -74,6 +74,6 @@ fn test_regex() { .captures_iter(r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)") .collect(); - assert_eq!(&cap.get(0).unwrap()["text"], "underline 1"); + assert_eq!(&cap.first().unwrap()["text"], "underline 1"); assert_eq!(&cap.get(1).unwrap()["text"], "underline 2"); } From 7d8bce152b93216f62a4bc27595e4efe9fb5098e Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 02:36:51 +0200 Subject: [PATCH 077/110] implement anchor auto-scroll behavior (on page load) --- .../tab/item/page/content/text/markdown.rs | 34 +++++++++++++++++-- .../page/content/text/markdown/tags/header.rs | 7 ++++ 2 files changed, 38 insertions(+), 3 deletions(-) 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 index ebcaf16e..dd3f0cbc 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -4,11 +4,11 @@ mod tags; use super::{ItemAction, WindowAction}; use crate::app::browser::window::action::Position; use gtk::{ - EventControllerMotion, GestureClick, TextBuffer, TextTag, TextTagTable, TextView, - TextWindowType, UriLauncher, Window, WrapMode, + EventControllerMotion, GestureClick, TextBuffer, TextSearchFlags, TextTag, TextTagTable, + TextView, TextWindowType, UriLauncher, Window, WrapMode, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, gio::{Cancellable, SimpleAction, SimpleActionGroup}, - glib::{Uri, uuid_string_random}, + glib::{ControlFlow, Uri, idle_add_local, uri_unescape_string, uuid_string_random}, prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; @@ -308,6 +308,34 @@ impl Markdown { } }); // @TODO may be expensive for CPU, add timeout? + // Anchor auto-scroll behavior (@TODO navigate without page reload) + idle_add_local({ + let base = base.clone(); + let text_view = text_view.clone(); + move || { + if let Some(fragment) = base.fragment() { + let query = uri_unescape_string(&fragment, None::<&str>) + .unwrap_or(fragment) + .replace("-", " "); + let mut cursor = text_view.buffer().start_iter(); + while let Some((mut match_start, match_end)) = + cursor.forward_search(&query, TextSearchFlags::CASE_INSENSITIVE, None) + { + if match_start + .tags() + .iter() + .any(|t| t.name().is_some_and(|n| n.starts_with("h"))) + { + text_view.scroll_to_iter(&mut match_start, 0.0, true, 0.0, 0.0); + break; + } + cursor = match_end; + } + } + ControlFlow::Break + } + }); + Self { text_view, title } } } 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 index 9de1b20c..5d558eb4 100644 --- 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 @@ -17,9 +17,11 @@ pub struct Header { 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) @@ -27,6 +29,7 @@ impl Header { .build(), h2: TextTag::builder() .foreground("#d56199") // @TODO optional + .name("h2") .scale(1.4) .sentence(true) .weight(400) @@ -34,6 +37,7 @@ impl Header { .build(), h3: TextTag::builder() .foreground("#c88800") // @TODO optional + .name("h3") .scale(1.2) .sentence(true) .weight(400) @@ -41,6 +45,7 @@ impl Header { .build(), h4: TextTag::builder() .foreground("#c88800") // @TODO optional + .name("h4") .scale(1.1) .sentence(true) .weight(400) @@ -48,6 +53,7 @@ impl Header { .build(), h5: TextTag::builder() .foreground("#c88800") // @TODO optional + .name("h5") .scale(1.0) .sentence(true) .weight(400) @@ -55,6 +61,7 @@ impl Header { .build(), h6: TextTag::builder() .foreground("#c88800") // @TODO optional + .name("h6") .scale(1.0) .sentence(true) .weight(300) From 02bfc90a39aedecfe5187d2074cba424dd3d524d Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 03:15:24 +0200 Subject: [PATCH 078/110] remove wakatime tracker as not in use --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d2b344e2..5b9cb28f 100644 --- a/README.md +++ b/README.md @@ -235,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 From 36568004e8ae1899b034aeff9db05f4daa641c1e Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 03:26:43 +0200 Subject: [PATCH 079/110] implement scroll to anchor without page load --- .../tab/item/page/content/text/markdown.rs | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) 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 index dd3f0cbc..cdce89de 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -8,7 +8,7 @@ use gtk::{ TextView, TextWindowType, UriLauncher, Window, WrapMode, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, gio::{Cancellable, SimpleAction, SimpleActionGroup}, - glib::{ControlFlow, Uri, idle_add_local, uri_unescape_string, uuid_string_random}, + glib::{ControlFlow, GString, Uri, idle_add_local, uri_unescape_string, uuid_string_random}, prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; @@ -203,7 +203,11 @@ impl Markdown { for tag in iter.tags() { // Tag is link if let Some(uri) = links.get(&tag) { - return open_link_in_current_tab(&uri.to_string(), &item_action); + return if let Some(fragment) = uri.fragment() { + scroll_to_anchor(&text_view, fragment); + } else { + open_link_in_current_tab(&uri.to_string(), &item_action); + }; } } } @@ -308,29 +312,13 @@ impl Markdown { } }); // @TODO may be expensive for CPU, add timeout? - // Anchor auto-scroll behavior (@TODO navigate without page reload) + // Anchor auto-scroll behavior idle_add_local({ let base = base.clone(); let text_view = text_view.clone(); move || { if let Some(fragment) = base.fragment() { - let query = uri_unescape_string(&fragment, None::<&str>) - .unwrap_or(fragment) - .replace("-", " "); - let mut cursor = text_view.buffer().start_iter(); - while let Some((mut match_start, match_end)) = - cursor.forward_search(&query, TextSearchFlags::CASE_INSENSITIVE, None) - { - if match_start - .tags() - .iter() - .any(|t| t.name().is_some_and(|n| n.starts_with("h"))) - { - text_view.scroll_to_iter(&mut match_start, 0.0, true, 0.0, 0.0); - break; - } - cursor = match_end; - } + scroll_to_anchor(&text_view, fragment); } ControlFlow::Break } @@ -340,6 +328,26 @@ impl Markdown { } } +fn scroll_to_anchor(text_view: &TextView, fragment: GString) -> bool { + let query = uri_unescape_string(&fragment, None::<&str>) + .unwrap_or(fragment) + .replace("-", " "); + let mut cursor = text_view.buffer().start_iter(); + while let Some((mut match_start, match_end)) = + cursor.forward_search(&query, TextSearchFlags::CASE_INSENSITIVE, None) + { + if match_start + .tags() + .iter() + .any(|t| t.name().is_some_and(|n| n.starts_with("h"))) + { + return text_view.scroll_to_iter(&mut match_start, 0.0, true, 0.0, 0.0); + } + cursor = match_end; + } + false +} + fn is_internal_link(request: &str) -> bool { // schemes request.starts_with("gemini://") From 9612c988cc568f258f5051a0841a5b3aba2bb433 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 04:18:17 +0200 Subject: [PATCH 080/110] try few search scenarios on result fail --- .../tab/item/page/content/text/markdown.rs | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) 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 index cdce89de..fb1c8c6c 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -329,23 +329,28 @@ impl Markdown { } fn scroll_to_anchor(text_view: &TextView, fragment: GString) -> bool { - let query = uri_unescape_string(&fragment, None::<&str>) - .unwrap_or(fragment) - .replace("-", " "); - let mut cursor = text_view.buffer().start_iter(); - while let Some((mut match_start, match_end)) = - cursor.forward_search(&query, TextSearchFlags::CASE_INSENSITIVE, None) - { - if match_start - .tags() - .iter() - .any(|t| t.name().is_some_and(|n| n.starts_with("h"))) + fn try_scroll(text_view: &TextView, query: &str) -> bool { + let mut cursor = text_view.buffer().start_iter(); + while let Some((mut match_start, match_end)) = + cursor.forward_search(query, TextSearchFlags::CASE_INSENSITIVE, None) { - return text_view.scroll_to_iter(&mut match_start, 0.0, true, 0.0, 0.0); + if match_start + .tags() + .iter() + .any(|t| t.name().is_some_and(|n| n.starts_with("h"))) + { + return text_view.scroll_to_iter(&mut match_start, 0.0, true, 0.0, 0.0); + } + cursor = match_end; } - cursor = match_end; + false } - false + let query = uri_unescape_string(&fragment, None::<&str>).unwrap_or(fragment); + let result = try_scroll(text_view, &query); // exact match + if !result { + return try_scroll(text_view, &query.replace("-", " ")); // unstable @TODO + } + result } fn is_internal_link(request: &str) -> bool { From d40eab57ec132a24169641263f1e0c730317bd1a Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 17:52:16 +0200 Subject: [PATCH 081/110] fix quote expression --- .../page/content/text/markdown/tags/quote.rs | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) 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 index 0c932189..3c854a1d 100644 --- 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 @@ -6,7 +6,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_QUOTE: &str = r"(?m)^>\s+(?P<text>.*)$"; +const REGEX_QUOTE: &str = r"(?m)>\s*(?P<text>.*)$"; pub struct Quote(TextTag); @@ -50,12 +50,19 @@ impl Quote { #[test] fn test_regex() { - let cap: Vec<_> = Regex::new(REGEX_QUOTE) - .unwrap() - .captures_iter(r"> Some quote with ![img](https://link.com)") - .collect(); - - let first = cap.first().unwrap(); - assert_eq!(&first[0], "> Some quote with ![img](https://link.com)"); - assert_eq!(&first["text"], "Some quote with ![img](https://link.com)"); + let cap: Vec<_> = Regex::new(REGEX_QUOTE).unwrap().captures_iter( + "> Some quote 1 with ![img](https://link.com)\n> Some quote 2 with text\nplain text\n> Some quote 3" + ).collect(); + { + let m = cap.first().unwrap(); + assert_eq!(&m["text"], "Some quote 1 with ![img](https://link.com)"); + } + { + let m = cap.get(1).unwrap(); + assert_eq!(&m["text"], "Some quote 2 with text"); + } + { + let m = cap.get(2).unwrap(); + assert_eq!(&m["text"], "Some quote 3"); + } } From 0eebd1c85d6c052a954dab6251cadb7da3ab4a67 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 18:31:12 +0200 Subject: [PATCH 082/110] update list `State` api --- .../page/content/text/markdown/tags/list.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 index d05548df..fc142275 100644 --- 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 @@ -7,22 +7,20 @@ use regex::Regex; const REGEX_LIST: &str = r"(?m)^(?P<level>[ \t]*)\*[ \t]+(?:(?P<state>\[[ xX]\])[ \t]+)?(?P<text>.*)"; -struct State { - pub is_checked: bool, - //tag: TextTag, -} +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 { - is_checked: state.starts_with("[x]"), - }); + return Some(Self(state.starts_with("[x]"))); } None } + fn is_checked(&self) -> bool { + self.0 + } } struct Item { @@ -79,7 +77,7 @@ pub fn render(buffer: &TextBuffer) { if let Some(state) = item.state { buffer.insert_with_tags( &mut start_iter, - if state.is_checked { "[x] " } else { "[ ] " }, + if state.is_checked() { "[x] " } else { "[ ] " }, &[&state_tag], ); } @@ -134,13 +132,13 @@ fn test_regex() { { let item = item(&cap, 5); assert_eq!(item.level, 2); - assert!(item.state.is_some_and(|this| this.is_checked)); + 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!(item.state.is_some_and(|this| !this.is_checked())); assert_eq!(item.text, "list item 3.2"); } { From c64f2d9a9b17caac221f7d4a6e5ea16118be2cd2 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 20:03:25 +0200 Subject: [PATCH 083/110] append some tags to existing tags instead of overwrite --- .../tab/item/page/content/text/markdown/tags/bold.rs | 9 ++++++++- .../item/page/content/text/markdown/tags/reference.rs | 7 ++++++- .../tab/item/page/content/text/markdown/tags/strike.rs | 9 ++++++++- .../item/page/content/text/markdown/tags/underline.rs | 9 ++++++++- 4 files changed, 30 insertions(+), 4 deletions(-) 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 index 5ff97e4f..8925ef1c 100644 --- 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 @@ -35,8 +35,15 @@ impl Bold { let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset); + 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"], &[&self.0]) + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::<Vec<&TextTag>>(), + ) } } } 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 index 8b7f534d..7bceaac3 100644 --- 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 @@ -95,7 +95,12 @@ impl Reference { .build() }; assert!(buffer.tag_table().add(&a)); - buffer.insert_with_tags(position, &self.alt, &[&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); } } 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 index 02945a4c..8379c600 100644 --- 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 @@ -40,8 +40,15 @@ impl Strike { let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset); + 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"], &[&self.0]) + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::<Vec<&TextTag>>(), + ) } } } 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 index b2c41c86..794b12bf 100644 --- 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 @@ -36,8 +36,15 @@ impl Underline { let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset); + 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"], &[&self.0]) + buffer.insert_with_tags( + &mut start_iter, + &cap["text"], + &tags.iter().collect::<Vec<&TextTag>>(), + ) } } } From 88a3e94f42cb5ec50a91fefbae6282025e70e429 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 20:40:52 +0200 Subject: [PATCH 084/110] skip escaped tags --- .../page/content/text/markdown/tags/bold.rs | 12 +++++++ .../page/content/text/markdown/tags/header.rs | 12 +++++++ .../page/content/text/markdown/tags/list.rs | 12 +++++++ .../page/content/text/markdown/tags/pre.rs | 12 +++++++ .../page/content/text/markdown/tags/quote.rs | 12 +++++++ .../content/text/markdown/tags/reference.rs | 36 +++++++++++++++++++ .../page/content/text/markdown/tags/strike.rs | 12 +++++++ .../content/text/markdown/tags/underline.rs | 12 +++++++ 8 files changed, 120 insertions(+) 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 index 8925ef1c..56bd09c3 100644 --- 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 @@ -35,6 +35,18 @@ impl Bold { 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()); 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 index 5d558eb4..35be5cb3 100644 --- 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 @@ -105,6 +105,18 @@ impl Header { 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); match cap["level"].chars().count() { 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 index fc142275..304167c8 100644 --- 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 @@ -61,6 +61,18 @@ pub fn render(buffer: &TextBuffer) { 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); let item = Item::parse( 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 index 02825949..473067dc 100644 --- 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 @@ -53,6 +53,18 @@ impl Pre { 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]) } 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 index 3c854a1d..e7a79849 100644 --- 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 @@ -42,6 +42,18 @@ impl Quote { 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]) } 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 index 7bceaac3..b69622b1 100644 --- 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 @@ -129,6 +129,18 @@ pub fn render_images_links( 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( @@ -171,6 +183,18 @@ pub fn render_images( 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( @@ -210,6 +234,18 @@ pub fn render_links( 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( 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 index 8379c600..13b4ef08 100644 --- 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 @@ -40,6 +40,18 @@ impl Strike { 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()); 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 index 794b12bf..fe7dbd3f 100644 --- 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 @@ -36,6 +36,18 @@ impl Underline { 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()); From e4c62ca3b342efef229117a270f746bf3c85da94 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 21:22:42 +0200 Subject: [PATCH 085/110] cleanup unformatted escape chars --- .../tab/item/page/content/text/markdown/tags.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 index cd25684c..0644bbfe 100644 --- 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 @@ -10,7 +10,7 @@ mod underline; use bold::Bold; use code::Code; -use gtk::{TextBuffer, TextTag, gdk::RGBA, glib::Uri}; +use gtk::{TextBuffer, TextSearchFlags, TextTag, gdk::RGBA, glib::Uri, prelude::TextBufferExt}; use header::Header; use pre::Pre; use quote::Quote; @@ -54,7 +54,8 @@ impl Tags { link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, ) -> Option<String> { - // Collect all code blocks first, and replace them with tmp macro ID + // Collect all code blocks first, + // and temporarily replace them with placeholder ID self.code.collect(buffer); // Keep in order! @@ -73,6 +74,16 @@ impl Tags { reference::render_images(buffer, base, link_color, links); reference::render_links(buffer, base, link_color, links); + // Cleanup unformatted escape chars + let mut cursor = buffer.start_iter(); + while let Some((mut match_start, mut match_end)) = + cursor.forward_search("\\", TextSearchFlags::CASE_INSENSITIVE, None) + { + buffer.delete(&mut match_start, &mut match_end); + cursor = match_end; + } + + // Render placeholders self.code.render(buffer); // Format document title string From 9a3cb77fe7dbacdbe8140b201d4287fb86f60b4a Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 21:29:02 +0200 Subject: [PATCH 086/110] define shared ESC const, add filter for title --- .../window/tab/item/page/content/text/markdown/tags.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 0644bbfe..503e6f9a 100644 --- 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 @@ -77,7 +77,7 @@ impl Tags { // Cleanup unformatted escape chars let mut cursor = buffer.start_iter(); while let Some((mut match_start, mut match_end)) = - cursor.forward_search("\\", TextSearchFlags::CASE_INSENSITIVE, None) + cursor.forward_search(ESC, TextSearchFlags::CASE_INSENSITIVE, None) { buffer.delete(&mut match_start, &mut match_end); cursor = match_end; @@ -93,7 +93,9 @@ impl Tags { s = reference::strip_tags(&s); s = strike::strip_tags(&s); s = underline::strip_tags(&s); - s // @TODO other tags + s.replace(ESC, "") }) } } + +const ESC: &str = "\\"; From c95cb6e7565f64cd265f00034b6b957964491772 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 10 Mar 2026 21:34:13 +0200 Subject: [PATCH 087/110] escape version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b9cb28f..72e41dc8 100644 --- a/README.md +++ b/README.md @@ -166,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 From bb08b7cb9acc6b1091fdb2505c4ae59f5631e823 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 01:41:48 +0200 Subject: [PATCH 088/110] implement copy link text, selected text, add link to the bookmarks (context menu) items; group menu items --- .../tab/item/client/driver/file/text.rs | 4 +- .../window/tab/item/client/driver/gemini.rs | 4 +- .../window/tab/item/client/driver/nex.rs | 2 +- .../browser/window/tab/item/page/content.rs | 20 ++- .../window/tab/item/page/content/text.rs | 8 +- .../tab/item/page/content/text/gemini.rs | 170 ++++++++++++++---- .../tab/item/page/content/text/markdown.rs | 164 +++++++++++++---- 7 files changed, 295 insertions(+), 77 deletions(-) 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 36dba3dc..5a0d99f6 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 @@ -21,7 +21,7 @@ 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 @@ -29,7 +29,7 @@ impl Text { .info .borrow_mut() .set_mime(Some("text/markdown".to_string())); - page.content.to_text_markdown(uri, data) + page.content.to_text_markdown(&page.profile, 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 1c34dc73..3830c605 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -357,8 +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/markdown" => page.content.to_text_markdown(&uri, data), + "text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data), + "text/markdown" => page.content.to_text_markdown(&page.profile, &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 fc88758d..aadd1b88 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::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<Profile>, 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 @@ -155,9 +162,14 @@ impl Content { } /// `text/markdown` - pub fn to_text_markdown(&self, base: &Uri, data: &str) -> Text { + pub fn to_text_markdown(&self, profile: &Rc<Profile>, base: &Uri, data: &str) -> Text { self.clean(); - let m = Text::markdown((&self.window_action, &self.item_action), base, data); + let m = Text::markdown( + (&self.window_action, &self.item_action), + profile, + base, + data, + ); self.g_box.append(&m.scrolled_window); m } 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 9a634185..d5f91393 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -4,6 +4,8 @@ mod nex; mod plain; mod source; +use crate::profile::Profile; + use super::{ItemAction, WindowAction}; use adw::ClampScrollable; use gemini::Gemini; @@ -27,10 +29,11 @@ pub struct Text { impl Text { pub fn gemini( actions: (&Rc<WindowAction>, &Rc<ItemAction>), + profile: &Rc<Profile>, base: &Uri, gemtext: &str, ) -> Result<Self, (String, Option<Self>)> { - 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, @@ -55,10 +58,11 @@ impl Text { pub fn markdown( actions: (&Rc<WindowAction>, &Rc<ItemAction>), + profile: &Rc<Profile>, base: &Uri, gemtext: &str, ) -> Self { - let markdown = Markdown::build(actions, base, gemtext); + let markdown = Markdown::build(actions, profile, base, gemtext); Self { scrolled_window: reader(&markdown.text_view), text_view: markdown.text_view, 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 8eae79b9..3cf654cb 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<WindowAction>, &Rc<ItemAction>), + profile: &Rc<Profile>, base: &Uri, gemtext: &str, ) -> Result<Self, Error> { @@ -220,7 +221,7 @@ impl Gemini { 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 +236,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 +295,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::<String>().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::<String>().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::<String>().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::<String>().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 +362,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 +380,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 Link 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); @@ -435,18 +491,61 @@ impl Gemini { let request_str = uri.to_str(); let request_var = request_str.to_variant(); + // Open in the new tab action_link_tab.set_state(&request_var); - action_link_copy.set_state(&request_var); + action_link_copy_text.set_enabled(!request_str.is_empty()); + action_link_copy_url.set_state(&request_var); + action_link_copy_text.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 + if let Some((sel_start, sel_end)) = buffer.selection_bounds() { + let selected_tag_text = buffer.text(&sel_start, &sel_end, false); + action_link_copy_text_selected + .set_state(&selected_tag_text.to_variant()); + action_link_copy_text_selected + .set_enabled(!selected_tag_text.is_empty()); + } else { + action_link_copy_text_selected.set_enabled(false); + } + + // Bookmark + action_link_bookmark.set_state(&request_var); + action_link_bookmark.set_enabled(is_prefixable_link(&request_str)); + + // Download (new tab) action_link_download.set_state(&request_var); action_link_download.set_enabled(is_prefixable_link(&request_str)); + // View as Source (new tab) action_link_source.set_state(&request_var); action_link_source.set_enabled(is_prefixable_link(&request_str)); + // Toggle link_context .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); - link_context.popup(); + link_context.popup() } } } @@ -580,5 +679,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 index fb1c8c6c..cf4e8871 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -2,12 +2,12 @@ mod gutter; mod tags; use super::{ItemAction, WindowAction}; -use crate::app::browser::window::action::Position; +use crate::{app::browser::window::action::Position, profile::Profile}; use gtk::{ EventControllerMotion, GestureClick, TextBuffer, TextSearchFlags, TextTag, TextTagTable, 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::{ControlFlow, GString, Uri, idle_add_local, uri_unescape_string, uuid_string_random}, prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, }; @@ -27,6 +27,7 @@ impl Markdown { /// Build new `Self` pub fn build( (window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>), + profile: &Rc<Profile>, base: &Uri, markdown: &str, ) -> Self { @@ -88,14 +89,39 @@ impl Markdown { ) } }); - 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::<String>().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::<String>().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::<String>().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::<String>().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({ @@ -130,14 +156,17 @@ impl Markdown { 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!( @@ -145,27 +174,56 @@ impl Markdown { 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 Link 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); @@ -231,18 +289,61 @@ impl Markdown { let request_str = uri.to_str(); let request_var = request_str.to_variant(); + // Open in the new tab action_link_tab.set_state(&request_var); - action_link_copy.set_state(&request_var); + action_link_copy_text.set_enabled(!request_str.is_empty()); + action_link_copy_url.set_state(&request_var); + action_link_copy_text.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 + if let Some((sel_start, sel_end)) = buffer.selection_bounds() { + let selected_tag_text = buffer.text(&sel_start, &sel_end, false); + action_link_copy_text_selected + .set_state(&selected_tag_text.to_variant()); + action_link_copy_text_selected + .set_enabled(!selected_tag_text.is_empty()); + } else { + action_link_copy_text_selected.set_enabled(false); + } + + // Bookmark + action_link_bookmark.set_state(&request_var); + action_link_bookmark.set_enabled(is_prefixable_link(&request_str)); + + // Download (new tab) action_link_download.set_state(&request_var); action_link_download.set_enabled(is_prefixable_link(&request_str)); + // View as Source (new tab) action_link_source.set_state(&request_var); action_link_source.set_enabled(is_prefixable_link(&request_str)); + // Toggle link_context .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); - link_context.popup(); + link_context.popup() } } } @@ -405,5 +506,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:"; From f8afa8e08573e5342c6586552f8cd042258210a8 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 01:58:18 +0200 Subject: [PATCH 089/110] fix alternative fragment syntax --- src/app/browser/window/tab/item/page/content/text/markdown.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index cf4e8871..39e8e0a1 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -449,7 +449,7 @@ fn scroll_to_anchor(text_view: &TextView, fragment: GString) -> bool { let query = uri_unescape_string(&fragment, None::<&str>).unwrap_or(fragment); let result = try_scroll(text_view, &query); // exact match if !result { - return try_scroll(text_view, &query.replace("-", " ")); // unstable @TODO + return try_scroll(text_view, &query.replace(" ", "-")); // alt syntax } result } From 0a9b2385aa17d5a66699427b388adbe191296e62 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 02:40:10 +0200 Subject: [PATCH 090/110] fix action targets --- .../browser/window/tab/item/page/content/text/gemini.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 3cf654cb..9b3f67ba 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 @@ -493,13 +493,14 @@ impl Gemini { // Open in the new tab action_link_tab.set_state(&request_var); - action_link_copy_text.set_enabled(!request_str.is_empty()); + 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_text.set_enabled(!request_str.is_empty()); + action_link_copy_url.set_enabled(!request_str.is_empty()); + // Copy link text { - // Copy link text let mut start_iter = iter; let mut end_iter = iter; if !start_iter.starts_tag(Some(&tag)) { From bf039dd9471324c34aa7788c6e8a917f36092a74 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 02:48:51 +0200 Subject: [PATCH 091/110] show `x` button at left by respecting the env settings --- src/app/browser/window/header/bar/tab.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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<WindowAction>, 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() } From 12edd5a4f4a02a34a7ac4397c9250d7a9431650f Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 03:30:42 +0200 Subject: [PATCH 092/110] minor optimizations --- .../tab/item/page/content/text/gemini.rs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) 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 9b3f67ba..6a09513c 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 @@ -397,7 +397,7 @@ impl Gemini { )), ); m_copy.append( - Some("Copy Link Text Selected"), + Some("Copy Text Selected"), Some(&format!( "{link_context_group_id}.{}", action_link_copy_text_selected.name() @@ -490,6 +490,7 @@ 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); @@ -521,27 +522,28 @@ impl Gemini { } // Copy link text (if) selected - if let Some((sel_start, sel_end)) = buffer.selection_bounds() { - let selected_tag_text = buffer.text(&sel_start, &sel_end, false); - action_link_copy_text_selected - .set_state(&selected_tag_text.to_variant()); - action_link_copy_text_selected - .set_enabled(!selected_tag_text.is_empty()); - } else { - action_link_copy_text_selected.set_enabled(false); - } + 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_prefixable_link(&request_str)); + 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 @@ -639,7 +641,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://") From a1d9c080d16d31d80d4d03ff80a2b5f44f549d81 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 03:31:52 +0200 Subject: [PATCH 093/110] implement context menu for the header tags (including fragment URL copy) --- .../tab/item/page/content/text/markdown.rs | 143 +++++++++++++++--- .../item/page/content/text/markdown/tags.rs | 3 +- .../page/content/text/markdown/tags/header.rs | 57 +++++-- 3 files changed, 171 insertions(+), 32 deletions(-) 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 index 39e8e0a1..d059c605 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -4,8 +4,8 @@ mod tags; use super::{ItemAction, WindowAction}; use crate::{app::browser::window::action::Position, profile::Profile}; use gtk::{ - EventControllerMotion, GestureClick, TextBuffer, TextSearchFlags, TextTag, TextTagTable, - TextView, TextWindowType, UriLauncher, Window, WrapMode, + EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextSearchFlags, 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, uri_unescape_string, uuid_string_random}, @@ -33,6 +33,7 @@ impl Markdown { ) -> Self { // Init HashMap storage (for event controllers) let mut links: HashMap<TextTag, Uri> = HashMap::new(); + let mut headers: HashMap<TextTag, (String, Uri)> = HashMap::new(); // Init hovered tag storage for `links` // * maybe less expensive than update entire HashMap by iter @@ -75,9 +76,72 @@ impl Markdown { let gutter = Gutter::build(&text_view); // Render markdown tags - let title = tags.render(&buffer, base, &link_color.0, &mut links); + let title = tags.render(&buffer, base, &link_color.0, &mut links, &mut headers); - // Context menu + // 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::<String>().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::<String>().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::<String>().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({ @@ -165,7 +229,7 @@ impl Markdown { g }), ); - let link_context = gtk::PopoverMenu::from_model(Some(&{ + let link_context = PopoverMenu::from_model(Some(&{ let m = Menu::new(); m.append( Some("Open Link in New Tab"), @@ -191,7 +255,7 @@ impl Markdown { )), ); m_copy.append( - Some("Copy Link Text Selected"), + Some("Copy Text Selected"), Some(&format!( "{link_context_group_id}.{}", action_link_copy_text_selected.name() @@ -244,6 +308,7 @@ impl Markdown { // Init shared reference container for HashTable collected let links = Rc::new(links); + let headers = Rc::new(headers); // Init events primary_button_controller.connect_released({ @@ -274,6 +339,7 @@ impl Markdown { secondary_button_controller.connect_pressed({ let links = links.clone(); + let headers = headers.clone(); let text_view = text_view.clone(); let link_context = link_context.clone(); move |_, _, window_x, window_y| { @@ -288,16 +354,18 @@ impl Markdown { 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_text.set_enabled(!request_str.is_empty()); + 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_text.set_enabled(!request_str.is_empty()); + action_link_copy_url.set_enabled(!request_str.is_empty()); + // Copy link text { - // Copy link text let mut start_iter = iter; let mut end_iter = iter; if !start_iter.starts_tag(Some(&tag)) { @@ -318,33 +386,64 @@ impl Markdown { } // Copy link text (if) selected - if let Some((sel_start, sel_end)) = buffer.selection_bounds() { - let selected_tag_text = buffer.text(&sel_start, &sel_end, false); - action_link_copy_text_selected - .set_state(&selected_tag_text.to_variant()); - action_link_copy_text_selected - .set_enabled(!selected_tag_text.is_empty()); - } else { - action_link_copy_text_selected.set_enabled(false); - } + 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_prefixable_link(&request_str)); + 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() } + // 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() + } } } } @@ -465,7 +564,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://") 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 index 503e6f9a..96b590c9 100644 --- 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 @@ -53,13 +53,14 @@ impl Tags { base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, + headers: &mut HashMap<TextTag, (String, Uri)>, ) -> Option<String> { // 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); + let title = self.header.render(buffer, base, headers); list::render(buffer); 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 index 35be5cb3..8a2e125e 100644 --- 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 @@ -1,8 +1,10 @@ use gtk::{ TextBuffer, TextTag, WrapMode, + glib::Uri, prelude::{TextBufferExt, TextBufferExtManual}, }; use regex::Regex; +use std::collections::HashMap; const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$"; @@ -71,7 +73,12 @@ impl Header { } /// Apply title `Tag` to given `TextBuffer` - pub fn render(&self, buffer: &TextBuffer) -> Option<String> { + 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(); @@ -105,6 +112,7 @@ impl Header { let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset); + // Skip escaped entries if start_char_offset > 0 && buffer .text( @@ -117,19 +125,50 @@ impl Header { continue; } + // Create unique phantom tag for each header + // * it is required for context menu relationships + let h = TextTag::builder().build(); + 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"], &[&self.h1]), - 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h2]), - 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h3]), - 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h4]), - 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h5]), - 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h6]), - _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]), + 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(&Uri::escape_string( + &cap["title"].to_lowercase().replace(" ", "-"), + None, + true + )), + ) + ), + ) + .is_none() + ) + } raw_title } } From 6a491751b6b879fb0585d4fd4fe2aa0c4c49f5e1 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 04:33:50 +0200 Subject: [PATCH 094/110] fix buffer tag search by the fragment --- .../tab/item/page/content/text/markdown.rs | 48 ++++++++----------- .../item/page/content/text/markdown/tags.rs | 12 ++++- .../page/content/text/markdown/tags/header.rs | 10 ++-- 3 files changed, 34 insertions(+), 36 deletions(-) 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 index d059c605..a701f0e0 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -4,11 +4,11 @@ mod tags; use super::{ItemAction, WindowAction}; use crate::{app::browser::window::action::Position, profile::Profile}; use gtk::{ - EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextSearchFlags, TextTag, - TextTagTable, TextView, TextWindowType, UriLauncher, Window, WrapMode, + 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, uri_unescape_string, uuid_string_random}, + glib::{ControlFlow, GString, Uri, idle_add_local, uuid_string_random}, prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; @@ -312,6 +312,7 @@ impl Markdown { // Init events primary_button_controller.connect_released({ + let headers = headers.clone(); let item_action = item_action.clone(); let links = links.clone(); let text_view = text_view.clone(); @@ -327,7 +328,7 @@ impl Markdown { // Tag is link if let Some(uri) = links.get(&tag) { return if let Some(fragment) = uri.fragment() { - scroll_to_anchor(&text_view, fragment); + scroll_to_anchor(&text_view, &headers, fragment); } else { open_link_in_current_tab(&uri.to_string(), &item_action); }; @@ -338,10 +339,10 @@ impl Markdown { }); secondary_button_controller.connect_pressed({ - let links = links.clone(); let headers = headers.clone(); - let text_view = text_view.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; @@ -518,7 +519,7 @@ impl Markdown { let text_view = text_view.clone(); move || { if let Some(fragment) = base.fragment() { - scroll_to_anchor(&text_view, fragment); + scroll_to_anchor(&text_view, &headers, fragment); } ControlFlow::Break } @@ -528,29 +529,20 @@ impl Markdown { } } -fn scroll_to_anchor(text_view: &TextView, fragment: GString) -> bool { - fn try_scroll(text_view: &TextView, query: &str) -> bool { - let mut cursor = text_view.buffer().start_iter(); - while let Some((mut match_start, match_end)) = - cursor.forward_search(query, TextSearchFlags::CASE_INSENSITIVE, None) - { - if match_start - .tags() - .iter() - .any(|t| t.name().is_some_and(|n| n.starts_with("h"))) - { - return text_view.scroll_to_iter(&mut match_start, 0.0, true, 0.0, 0.0); - } - cursor = match_end; +fn scroll_to_anchor( + text_view: &TextView, + headers: &HashMap<TextTag, (String, Uri)>, + fragment: GString, +) { + if let Some((tag, _)) = 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); } - false } - let query = uri_unescape_string(&fragment, None::<&str>).unwrap_or(fragment); - let result = try_scroll(text_view, &query); // exact match - if !result { - return try_scroll(text_view, &query.replace(" ", "-")); // alt syntax - } - result } fn is_internal_link(request: &str) -> bool { 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 index 96b590c9..e33ed470 100644 --- 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 @@ -10,7 +10,12 @@ mod underline; use bold::Bold; use code::Code; -use gtk::{TextBuffer, TextSearchFlags, TextTag, gdk::RGBA, glib::Uri, prelude::TextBufferExt}; +use gtk::{ + TextBuffer, TextSearchFlags, TextTag, + gdk::RGBA, + glib::{GString, Uri}, + prelude::TextBufferExt, +}; use header::Header; use pre::Pre; use quote::Quote; @@ -99,4 +104,9 @@ impl Tags { } } +/// 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 ESC: &str = "\\"; 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 index 8a2e125e..e399d92c 100644 --- 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 @@ -126,8 +126,8 @@ impl Header { } // Create unique phantom tag for each header - // * it is required for context menu relationships - let h = TextTag::builder().build(); + // * 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 @@ -158,11 +158,7 @@ impl Header { base.port(), &base.path(), base.query().as_deref(), - Some(&Uri::escape_string( - &cap["title"].to_lowercase().replace(" ", "-"), - None, - true - )), + Some(&super::format_header_fragment(&cap["title"])), ) ), ) From 905eee0aab18c0f09652ee8c13359f1e08500370 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 15:50:57 +0200 Subject: [PATCH 095/110] update navigation entry on fragment change --- .../window/tab/item/client/driver/file/text.rs | 4 ++-- .../window/tab/item/client/driver/gemini.rs | 2 +- .../browser/window/tab/item/page/content.rs | 11 +++-------- .../window/tab/item/page/content/text.rs | 6 +++--- .../tab/item/page/content/text/markdown.rs | 18 +++++++++++------- 5 files changed, 20 insertions(+), 21 deletions(-) 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 5a0d99f6..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 @@ -8,7 +8,7 @@ pub enum Text { } impl Text { - pub fn handle(&self, page: &super::Page) { + pub fn handle(&self, page: &std::rc::Rc<super::Page>) { page.navigation .request .info @@ -29,7 +29,7 @@ impl Text { .info .borrow_mut() .set_mime(Some("text/markdown".to_string())); - page.content.to_text_markdown(&page.profile, uri, data) + 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 3830c605..40f33ad1 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -358,7 +358,7 @@ fn handle( } else { match m.as_str() { "text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data), - "text/markdown" => page.content.to_text_markdown(&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/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index aadd1b88..2f6b9551 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -7,7 +7,7 @@ use directory::Directory; use image::Image; use text::Text; -use crate::profile::Profile; +use crate::{app::browser::window::tab::item::page::Page, profile::Profile}; use super::{ItemAction, TabAction, WindowAction}; use adw::StatusPage; @@ -162,14 +162,9 @@ impl Content { } /// `text/markdown` - pub fn to_text_markdown(&self, profile: &Rc<Profile>, base: &Uri, data: &str) -> Text { + pub fn to_text_markdown(&self, page: &Rc<Page>, base: &Uri, data: &str) -> Text { self.clean(); - let m = Text::markdown( - (&self.window_action, &self.item_action), - profile, - base, - data, - ); + let m = Text::markdown((&self.window_action, &self.item_action), page, base, data); self.g_box.append(&m.scrolled_window); m } 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 d5f91393..f400591c 100644 --- a/src/app/browser/window/tab/item/page/content/text.rs +++ b/src/app/browser/window/tab/item/page/content/text.rs @@ -4,7 +4,7 @@ mod nex; mod plain; mod source; -use crate::profile::Profile; +use crate::{app::browser::window::tab::item::page::Page, profile::Profile}; use super::{ItemAction, WindowAction}; use adw::ClampScrollable; @@ -58,11 +58,11 @@ impl Text { pub fn markdown( actions: (&Rc<WindowAction>, &Rc<ItemAction>), - profile: &Rc<Profile>, + page: &Rc<Page>, base: &Uri, gemtext: &str, ) -> Self { - let markdown = Markdown::build(actions, profile, base, gemtext); + let markdown = Markdown::build(actions, page, base, gemtext); Self { scrolled_window: reader(&markdown.text_view), text_view: markdown.text_view, 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 index a701f0e0..5f11f788 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -2,14 +2,14 @@ mod gutter; mod tags; use super::{ItemAction, WindowAction}; -use crate::{app::browser::window::action::Position, profile::Profile}; +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::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, + prelude::{EditableExt, PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, }; use gutter::Gutter; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; @@ -27,7 +27,7 @@ impl Markdown { /// Build new `Self` pub fn build( (window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>), - profile: &Rc<Profile>, + page: &Rc<Page>, base: &Uri, markdown: &str, ) -> Self { @@ -180,7 +180,7 @@ impl Markdown { let action_link_bookmark = SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); action_link_bookmark.connect_activate({ - let p = profile.clone(); + let p = page.profile.clone(); move |this, _| { let state = this.state().unwrap().get::<String>().unwrap(); p.bookmark.toggle(&state, None).unwrap(); @@ -315,6 +315,7 @@ impl Markdown { 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 @@ -328,7 +329,7 @@ impl Markdown { // Tag is link if let Some(uri) = links.get(&tag) { return if let Some(fragment) = uri.fragment() { - scroll_to_anchor(&text_view, &headers, fragment); + scroll_to_anchor(&page, &text_view, &headers, fragment); } else { open_link_in_current_tab(&uri.to_string(), &item_action); }; @@ -516,10 +517,11 @@ impl Markdown { // 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(&text_view, &headers, fragment); + scroll_to_anchor(&page, &text_view, &headers, fragment); } ControlFlow::Break } @@ -530,11 +532,12 @@ impl Markdown { } fn scroll_to_anchor( + page: &Rc<Page>, text_view: &TextView, headers: &HashMap<TextTag, (String, Uri)>, fragment: GString, ) { - if let Some((tag, _)) = headers.iter().find(|(_, (_, uri))| { + if let Some((tag, (_, uri))) = headers.iter().find(|(_, (_, uri))| { uri.fragment() .is_some_and(|f| fragment == tags::format_header_fragment(&f)) }) { @@ -542,6 +545,7 @@ fn scroll_to_anchor( 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()) } } From ca29f68f6942a8e2aa1c67a16f63698ce821e339 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Wed, 11 Mar 2026 20:21:37 +0200 Subject: [PATCH 096/110] handle escape for defined matches only --- .../item/page/content/text/markdown/tags.rs | 38 +++++++++++++++---- .../page/content/text/markdown/tags/bold.rs | 2 + .../page/content/text/markdown/tags/code.rs | 3 ++ .../page/content/text/markdown/tags/header.rs | 2 + .../page/content/text/markdown/tags/list.rs | 3 ++ .../page/content/text/markdown/tags/pre.rs | 2 + .../page/content/text/markdown/tags/quote.rs | 2 + .../content/text/markdown/tags/reference.rs | 2 + .../page/content/text/markdown/tags/strike.rs | 2 + .../content/text/markdown/tags/underline.rs | 2 + 10 files changed, 50 insertions(+), 8 deletions(-) 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 index e33ed470..c17f4aad 100644 --- 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 @@ -81,12 +81,16 @@ impl Tags { reference::render_links(buffer, base, link_color, links); // Cleanup unformatted escape chars - let mut cursor = buffer.start_iter(); - while let Some((mut match_start, mut match_end)) = - cursor.forward_search(ESC, TextSearchFlags::CASE_INSENSITIVE, None) - { - buffer.delete(&mut match_start, &mut match_end); - cursor = match_end; + for escapes in ESCAPES { + for escape in *escapes { + let mut cursor = buffer.start_iter(); + while let Some((mut match_start, mut match_end)) = + cursor.forward_search(escape, TextSearchFlags::CASE_INSENSITIVE, None) + { + buffer.delete(&mut match_start, &mut match_end); + cursor = match_end; + } + } } // Render placeholders @@ -99,7 +103,12 @@ impl Tags { s = reference::strip_tags(&s); s = strike::strip_tags(&s); s = underline::strip_tags(&s); - s.replace(ESC, "") + for escapes in ESCAPES { + for escape in *escapes { + s = s.replace(escape, ""); + } + } + s }) } } @@ -109,4 +118,17 @@ pub fn format_header_fragment(value: &str) -> GString { Uri::escape_string(&value.to_lowercase().replace(" ", "-"), None, true) } -const ESC: &str = "\\"; +const ESCAPES: &[&[&str]] = &[ + &["\\\n"], + bold::ESCAPES, + // same with pre + // code::ESCAPES, + header::ESCAPES, + // same with bold and reference + // list::ESCAPES, + pre::ESCAPES, + quote::ESCAPES, + reference::ESCAPES, + strike::ESCAPES, + underline::ESCAPES, +]; 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 index 56bd09c3..70436faa 100644 --- 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 @@ -7,6 +7,8 @@ use regex::Regex; const REGEX_BOLD: &str = r"\*\*(?P<text>[^*]+)\*\*"; +pub const ESCAPES: &[&str] = &["\\*"]; // same with list + pub struct Bold(TextTag); impl Bold { 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 index 5d79041f..501fa270 100644 --- 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 @@ -12,6 +12,9 @@ use syntax::Syntax; const REGEX_CODE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```"; +// same with pre +// pub const ESCAPES: &[&str] = &["\\`"]; + struct Entry { alt: Option<String>, data: String, 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 index e399d92c..471f2a57 100644 --- 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 @@ -8,6 +8,8 @@ use std::collections::HashMap; const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$"; +pub const ESCAPES: &[&str] = &["\\#"]; + pub struct Header { h1: TextTag, h2: TextTag, 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 index 304167c8..4796e99a 100644 --- 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 @@ -7,6 +7,9 @@ use regex::Regex; const REGEX_LIST: &str = r"(?m)^(?P<level>[ \t]*)\*[ \t]+(?:(?P<state>\[[ xX]\])[ \t]+)?(?P<text>.*)"; +// same with bold and reference +// pub const ESCAPES: &[&str] = &["\\*","\\[","\\]"]; + struct State(bool); impl State { 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 index 473067dc..3d867761 100644 --- 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 @@ -10,6 +10,8 @@ const REGEX_PRE: &str = r"`(?P<text>[^`]+)`"; const TAG_FONT: &str = "monospace"; // @TODO const TAG_SCALE: f64 = 0.9; +pub const ESCAPES: &[&str] = &["\\`"]; // same with code + pub struct Pre(TextTag); impl Pre { 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 index e7a79849..e6161b3b 100644 --- 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 @@ -8,6 +8,8 @@ use regex::Regex; const REGEX_QUOTE: &str = r"(?m)>\s*(?P<text>.*)$"; +pub const ESCAPES: &[&str] = &["\\>"]; + pub struct Quote(TextTag); impl Quote { 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 index b69622b1..57eb3546 100644 --- 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 @@ -12,6 +12,8 @@ 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>[^\)]+)\)"; +pub const ESCAPES: &[&str] = &["\\!", "\\[", "\\]", "\\(", "\\)"]; + struct Reference { uri: Uri, alt: String, 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 index 13b4ef08..cdabe70b 100644 --- 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 @@ -7,6 +7,8 @@ use regex::Regex; const REGEX_STRIKE: &str = r"~~(?P<text>.+?)~~"; +pub const ESCAPES: &[&str] = &["\\~"]; + pub struct Strike(TextTag); impl Strike { 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 index fe7dbd3f..242291e6 100644 --- 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 @@ -8,6 +8,8 @@ use regex::Regex; const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]+)_\b"; +pub const ESCAPES: &[&str] = &["\\_"]; + pub struct Underline(TextTag); impl Underline { From 84167ad7453c2e82937025783adfa551f994be2b Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Thu, 12 Mar 2026 00:47:30 +0200 Subject: [PATCH 097/110] update escapes removing logic --- .../item/page/content/text/markdown/tags.rs | 43 ++++++++----------- .../page/content/text/markdown/tags/bold.rs | 2 - .../page/content/text/markdown/tags/code.rs | 3 -- .../page/content/text/markdown/tags/header.rs | 2 - .../page/content/text/markdown/tags/list.rs | 3 -- .../page/content/text/markdown/tags/pre.rs | 2 - .../page/content/text/markdown/tags/quote.rs | 2 - .../content/text/markdown/tags/reference.rs | 2 - .../page/content/text/markdown/tags/strike.rs | 2 - .../content/text/markdown/tags/underline.rs | 2 - 10 files changed, 18 insertions(+), 45 deletions(-) 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 index c17f4aad..15cb3354 100644 --- 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 @@ -81,15 +81,15 @@ impl Tags { reference::render_links(buffer, base, link_color, links); // Cleanup unformatted escape chars - for escapes in ESCAPES { - for escape in *escapes { - let mut cursor = buffer.start_iter(); - while let Some((mut match_start, mut match_end)) = - cursor.forward_search(escape, TextSearchFlags::CASE_INSENSITIVE, None) - { - buffer.delete(&mut match_start, &mut match_end); - cursor = match_end; + 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; } } @@ -103,10 +103,8 @@ impl Tags { s = reference::strip_tags(&s); s = strike::strip_tags(&s); s = underline::strip_tags(&s); - for escapes in ESCAPES { - for escape in *escapes { - s = s.replace(escape, ""); - } + for e in ESCAPE_ENTRIES { + s = s.replace(e, &e[1..]); } s }) @@ -118,17 +116,12 @@ pub fn format_header_fragment(value: &str) -> GString { Uri::escape_string(&value.to_lowercase().replace(" ", "-"), None, true) } -const ESCAPES: &[&[&str]] = &[ - &["\\\n"], - bold::ESCAPES, - // same with pre - // code::ESCAPES, - header::ESCAPES, - // same with bold and reference - // list::ESCAPES, - pre::ESCAPES, - quote::ESCAPES, - reference::ESCAPES, - strike::ESCAPES, - underline::ESCAPES, +const ESCAPE_ENTRIES: &[&str] = &[ + "\\\n", "\\\\", "\\>", "\\`", "\\!", "\\[", "\\]", "\\(", "\\)", "\\*", "\\#", "\\~", "\\_", ]; +#[test] +fn test_escape_entries() { + for e in ESCAPE_ENTRIES { + assert_eq!(e.len(), 2) + } +} 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 index 70436faa..56bd09c3 100644 --- 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 @@ -7,8 +7,6 @@ use regex::Regex; const REGEX_BOLD: &str = r"\*\*(?P<text>[^*]+)\*\*"; -pub const ESCAPES: &[&str] = &["\\*"]; // same with list - pub struct Bold(TextTag); impl Bold { 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 index 501fa270..5d79041f 100644 --- 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 @@ -12,9 +12,6 @@ use syntax::Syntax; const REGEX_CODE: &str = r"(?s)```[ \t]*(?P<alt>.*?)\n(?P<data>.*?)```"; -// same with pre -// pub const ESCAPES: &[&str] = &["\\`"]; - struct Entry { alt: Option<String>, data: String, 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 index 471f2a57..e399d92c 100644 --- 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 @@ -8,8 +8,6 @@ use std::collections::HashMap; const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$"; -pub const ESCAPES: &[&str] = &["\\#"]; - pub struct Header { h1: TextTag, h2: TextTag, 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 index 4796e99a..304167c8 100644 --- 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 @@ -7,9 +7,6 @@ use regex::Regex; const REGEX_LIST: &str = r"(?m)^(?P<level>[ \t]*)\*[ \t]+(?:(?P<state>\[[ xX]\])[ \t]+)?(?P<text>.*)"; -// same with bold and reference -// pub const ESCAPES: &[&str] = &["\\*","\\[","\\]"]; - struct State(bool); impl State { 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 index 3d867761..473067dc 100644 --- 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 @@ -10,8 +10,6 @@ const REGEX_PRE: &str = r"`(?P<text>[^`]+)`"; const TAG_FONT: &str = "monospace"; // @TODO const TAG_SCALE: f64 = 0.9; -pub const ESCAPES: &[&str] = &["\\`"]; // same with code - pub struct Pre(TextTag); impl Pre { 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 index e6161b3b..e7a79849 100644 --- 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 @@ -8,8 +8,6 @@ use regex::Regex; const REGEX_QUOTE: &str = r"(?m)>\s*(?P<text>.*)$"; -pub const ESCAPES: &[&str] = &["\\>"]; - pub struct Quote(TextTag); impl Quote { 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 index 57eb3546..b69622b1 100644 --- 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 @@ -12,8 +12,6 @@ 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>[^\)]+)\)"; -pub const ESCAPES: &[&str] = &["\\!", "\\[", "\\]", "\\(", "\\)"]; - struct Reference { uri: Uri, alt: String, 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 index cdabe70b..13b4ef08 100644 --- 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 @@ -7,8 +7,6 @@ use regex::Regex; const REGEX_STRIKE: &str = r"~~(?P<text>.+?)~~"; -pub const ESCAPES: &[&str] = &["\\~"]; - pub struct Strike(TextTag); impl Strike { 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 index 242291e6..fe7dbd3f 100644 --- 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 @@ -8,8 +8,6 @@ use regex::Regex; const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]+)_\b"; -pub const ESCAPES: &[&str] = &["\\_"]; - pub struct Underline(TextTag); impl Underline { From 13e20f0df3ce5ed8fd5231f9383e03db24dad4e9 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Thu, 12 Mar 2026 01:46:45 +0200 Subject: [PATCH 098/110] update regular expressions, ignore backslash skip for header / list / quote tags as inline raw --- .../item/page/content/text/markdown/tags/bold.rs | 2 +- .../page/content/text/markdown/tags/header.rs | 13 ------------- .../item/page/content/text/markdown/tags/list.rs | 12 ------------ .../item/page/content/text/markdown/tags/pre.rs | 2 +- .../page/content/text/markdown/tags/quote.rs | 16 ++-------------- .../page/content/text/markdown/tags/strike.rs | 2 +- .../page/content/text/markdown/tags/underline.rs | 2 +- 7 files changed, 6 insertions(+), 43 deletions(-) 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 index 56bd09c3..9641f77b 100644 --- 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 @@ -5,7 +5,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_BOLD: &str = r"\*\*(?P<text>[^*]+)\*\*"; +const REGEX_BOLD: &str = r"\*\*(?P<text>[^\*]*)\*\*"; pub struct Bold(TextTag); 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 index e399d92c..681d73ae 100644 --- 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 @@ -112,19 +112,6 @@ impl Header { let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset); - // Skip escaped entries - if start_char_offset > 0 - && buffer - .text( - &buffer.iter_at_offset(start_char_offset - 1), - &end_iter, - false, - ) - .contains("\\") - { - continue; - } - // Create unique phantom tag for each header // * for the #fragment references implementation let h = TextTag::new(Some(&format!("h{}", gtk::glib::uuid_string_random()))); 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 index 304167c8..fc142275 100644 --- 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 @@ -61,18 +61,6 @@ pub fn render(buffer: &TextBuffer) { 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); let item = Item::parse( 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 index 473067dc..0ff09dc0 100644 --- 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 @@ -6,7 +6,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_PRE: &str = r"`(?P<text>[^`]+)`"; +const REGEX_PRE: &str = r"`(?P<text>[^`]*)`"; const TAG_FONT: &str = "monospace"; // @TODO const TAG_SCALE: f64 = 0.9; 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 index e7a79849..cd011fd7 100644 --- 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 @@ -42,18 +42,6 @@ impl Quote { 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]) } @@ -63,7 +51,7 @@ impl Quote { #[test] fn test_regex() { let cap: Vec<_> = Regex::new(REGEX_QUOTE).unwrap().captures_iter( - "> Some quote 1 with ![img](https://link.com)\n> Some quote 2 with text\nplain text\n> Some quote 3" + "> Some quote 1 with ![img](https://link.com)\n> 2\\)Some quote 2 with text\nplain text\n> Some quote 3" ).collect(); { let m = cap.first().unwrap(); @@ -71,7 +59,7 @@ fn test_regex() { } { let m = cap.get(1).unwrap(); - assert_eq!(&m["text"], "Some quote 2 with text"); + assert_eq!(&m["text"], "2\\)Some quote 2 with text"); } { let m = cap.get(2).unwrap(); 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 index 13b4ef08..7c0efb71 100644 --- 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 @@ -5,7 +5,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_STRIKE: &str = r"~~(?P<text>.+?)~~"; +const REGEX_STRIKE: &str = r"~~(?P<text>[^~]*)~~"; pub struct Strike(TextTag); 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 index fe7dbd3f..9357208a 100644 --- 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 @@ -6,7 +6,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]+)_\b"; +const REGEX_UNDERLINE: &str = r"\b_(?P<text>[^_]*)_\b"; pub struct Underline(TextTag); From 86ce8ceff5f4dd153788feea00e12c859ff4db25 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Fri, 13 Mar 2026 04:58:56 +0200 Subject: [PATCH 099/110] update version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba2dd8fc..faef56f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Yoda" -version = "0.12.8" +version = "0.12.9" dependencies = [ "ansi-parser", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4a529faf..cef34946 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.8" +version = "0.12.9" edition = "2024" license = "MIT" readme = "README.md" From b6b8f96bba55c9287507f8127e7ec51c51177be4 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Fri, 13 Mar 2026 17:29:42 +0200 Subject: [PATCH 100/110] add missed hr tag support, minor reference api updates --- .../tab/item/page/content/text/markdown.rs | 2 +- .../item/page/content/text/markdown/tags.rs | 38 ++++---- .../page/content/text/markdown/tags/hr.rs | 93 +++++++++++++++++++ .../content/text/markdown/tags/reference.rs | 18 +++- 4 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/hr.rs 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 index 5f11f788..49a69990 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -76,7 +76,7 @@ impl Markdown { let gutter = Gutter::build(&text_view); // Render markdown tags - let title = tags.render(&buffer, base, &link_color.0, &mut links, &mut headers); + let title = tags.render(&text_view, base, &link_color.0, &mut links, &mut headers); // Headers context menu (fragment capture) let action_header_copy_url = 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 index 15cb3354..5085b91d 100644 --- 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 @@ -1,6 +1,7 @@ mod bold; mod code; mod header; +mod hr; mod list; mod pre; mod quote; @@ -11,10 +12,10 @@ mod underline; use bold::Bold; use code::Code; use gtk::{ - TextBuffer, TextSearchFlags, TextTag, + TextSearchFlags, TextTag, TextView, gdk::RGBA, glib::{GString, Uri}, - prelude::TextBufferExt, + prelude::{TextBufferExt, TextViewExt}, }; use header::Header; use pre::Pre; @@ -54,31 +55,32 @@ impl Tags { } pub fn render( &mut self, - buffer: &TextBuffer, + text_view: &TextView, base: &Uri, link_color: &RGBA, links: &mut HashMap<TextTag, Uri>, headers: &mut HashMap<TextTag, (String, Uri)>, ) -> Option<String> { + let buffer = text_view.buffer(); + // Collect all code blocks first, // and temporarily replace them with placeholder ID - self.code.collect(buffer); + self.code.collect(&buffer); // Keep in order! - let title = self.header.render(buffer, base, headers); + let title = self.header.render(&buffer, base, headers); - list::render(buffer); + list::render(&buffer); - self.quote.render(buffer); + self.quote.render(&buffer); - self.bold.render(buffer); - self.pre.render(buffer); - self.strike.render(buffer); - self.underline.render(buffer); + self.bold.render(&buffer); + self.pre.render(&buffer); + self.strike.render(&buffer); + self.underline.render(&buffer); - reference::render_images_links(buffer, base, link_color, links); - reference::render_images(buffer, base, link_color, links); - reference::render_links(buffer, base, link_color, links); + reference::render(&buffer, base, link_color, links); + hr::render(text_view); // Cleanup unformatted escape chars for e in ESCAPE_ENTRIES { @@ -94,11 +96,12 @@ impl Tags { } // Render placeholders - self.code.render(buffer); + self.code.render(&buffer); // Format document title string title.map(|mut s| { s = bold::strip_tags(&s); + s = hr::strip_tags(&s); s = pre::strip_tags(&s); s = reference::strip_tags(&s); s = strike::strip_tags(&s); @@ -118,10 +121,13 @@ pub fn format_header_fragment(value: &str) -> GString { 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_eq!(e.len(), 2); + assert!(set.insert(*e)) } } 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/reference.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs index b69622b1..0ce45980 100644 --- 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 @@ -106,7 +106,7 @@ impl Reference { } /// Image links `[![]()]()` -pub fn render_images_links( +fn render_images_links( buffer: &TextBuffer, base: &Uri, link_color: &RGBA, @@ -159,8 +159,20 @@ pub fn render_images_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 `![]()` -pub fn render_images( +fn render_images( buffer: &TextBuffer, base: &Uri, link_color: &RGBA, @@ -211,7 +223,7 @@ pub fn render_images( } } /// Links `[]()` -pub fn render_links( +fn render_links( buffer: &TextBuffer, base: &Uri, link_color: &RGBA, From 563b228e9ebd79ee935116ee4e12b07fccddaa24 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Fri, 13 Mar 2026 20:41:51 +0200 Subject: [PATCH 101/110] strip xml tags from the markdown source --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + .../browser/window/tab/item/page/content/text/markdown.rs | 6 ++---- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index faef56f9..c9572365 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ dependencies = [ "regex", "rusqlite", "sourceview5", + "strip-tags", "syntect", ] @@ -1369,6 +1370,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" diff --git a/Cargo.toml b/Cargo.toml index cef34946..b6bec1c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ 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/src/app/browser/window/tab/item/page/content/text/markdown.rs b/src/app/browser/window/tab/item/page/content/text/markdown.rs index 49a69990..9eb450d1 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -14,6 +14,7 @@ use gtk::{ use gutter::Gutter; use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; use std::{cell::Cell, collections::HashMap, rc::Rc}; +use strip_tags::*; use tags::Tags; pub struct Markdown { @@ -39,9 +40,6 @@ impl Markdown { // * maybe less expensive than update entire HashMap by iter let hover: Rc<Cell<Option<TextTag>>> = Rc::new(Cell::new(None)); - // Init code features - //let mut code = None; - // Init colors // @TODO use accent colors in adw 1.6 / ubuntu 24.10+ let link_color = ( @@ -54,7 +52,7 @@ impl Markdown { // Init new text buffer let buffer = TextBuffer::new(Some(&TextTagTable::new())); - buffer.set_text(markdown); + buffer.set_text(&strip_tags(markdown)); // @TODO extract `<img>` tags? // Init main widget let text_view = { From 3358a897354104b99282a6179a2443efc111f025 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Fri, 13 Mar 2026 20:50:50 +0200 Subject: [PATCH 102/110] remove extra nl separators --- .../browser/window/tab/item/page/content/text/markdown.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 9eb450d1..ed21ac9f 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -12,6 +12,7 @@ use gtk::{ 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::*; @@ -52,7 +53,12 @@ impl Markdown { // Init new text buffer let buffer = TextBuffer::new(Some(&TextTagTable::new())); - buffer.set_text(&strip_tags(markdown)); // @TODO extract `<img>` tags? + buffer.set_text( + Regex::new(r"\n{3,}") + .unwrap() + .replace_all(&strip_tags(markdown), "\n") + .trim(), + ); // @TODO extract `<img>` tags? // Init main widget let text_view = { From 3bdabbe1b873c6c40d4aaf0d7005b0cdcd79b222 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Fri, 13 Mar 2026 23:05:30 +0200 Subject: [PATCH 103/110] add missed nl --- src/app/browser/window/tab/item/page/content/text/markdown.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ed21ac9f..e845bc0b 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -56,7 +56,7 @@ impl Markdown { buffer.set_text( Regex::new(r"\n{3,}") .unwrap() - .replace_all(&strip_tags(markdown), "\n") + .replace_all(&strip_tags(markdown), "\n\n") .trim(), ); // @TODO extract `<img>` tags? From 416c0ac4345535537525fc7e98ca22c0806b536f Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 16 Mar 2026 06:58:17 +0200 Subject: [PATCH 104/110] add alternative bold tags --- .../page/content/text/markdown/tags/bold.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 index 9641f77b..2ba656aa 100644 --- 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 @@ -5,7 +5,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_BOLD: &str = r"\*\*(?P<text>[^\*]*)\*\*"; +const REGEX_BOLD: &str = r"(\*\*|__)(?P<text>[^\*_]*)(\*\*|__)"; pub struct Bold(TextTag); @@ -72,7 +72,7 @@ pub fn strip_tags(value: &str) -> String { #[test] fn test_strip_tags() { - const VALUE: &str = r"Some **bold 1** and **bold 2** with ![img](https://link.com)"; + const VALUE: &str = "Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_ with ![img](https://link.com)"; let mut result = String::from(VALUE); for cap in Regex::new(REGEX_BOLD).unwrap().captures_iter(VALUE) { if let Some(m) = cap.get(0) { @@ -81,7 +81,7 @@ fn test_strip_tags() { } assert_eq!( result, - "Some bold 1 and bold 2 with ![img](https://link.com)" + "Some bold 1 and bold 2 and bold 3 and *italic 1* and _italic 2_ with ![img](https://link.com)" ) } @@ -89,9 +89,15 @@ fn test_strip_tags() { fn test_regex() { let cap: Vec<_> = Regex::new(REGEX_BOLD) .unwrap() - .captures_iter(r"Some **bold 1** and **bold 2** with ![img](https://link.com)") + .captures_iter( + "Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_ with ![img](https://link.com)" + ) .collect(); - assert_eq!(&cap.first().unwrap()["text"], "bold 1"); - assert_eq!(&cap.get(1).unwrap()["text"], "bold 2"); + 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"); } From 2ef5e52079470d76ab495de526f9551472484f1d Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 16 Mar 2026 08:35:12 +0200 Subject: [PATCH 105/110] update version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9572365..5284ec1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Yoda" -version = "0.12.9" +version = "0.12.10" dependencies = [ "ansi-parser", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index b6bec1c4..75c5b126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.12.9" +version = "0.12.10" edition = "2024" license = "MIT" readme = "README.md" From ca9c2058edadc912de1c6b7b4508c17619b7506a Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 16 Mar 2026 09:06:31 +0200 Subject: [PATCH 106/110] implement italic tag --- .../item/page/content/text/markdown/tags.rs | 6 + .../page/content/text/markdown/tags/bold.rs | 9 +- .../page/content/text/markdown/tags/italic.rs | 141 ++++++++++++++++++ .../page/content/text/markdown/tags/quote.rs | 2 +- 4 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/text/markdown/tags/italic.rs 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 index 5085b91d..ece2ccf5 100644 --- 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 @@ -2,6 +2,7 @@ mod bold; mod code; mod header; mod hr; +mod italic; mod list; mod pre; mod quote; @@ -18,6 +19,7 @@ use gtk::{ prelude::{TextBufferExt, TextViewExt}, }; use header::Header; +use italic::Italic; use pre::Pre; use quote::Quote; use std::collections::HashMap; @@ -28,6 +30,7 @@ pub struct Tags { pub bold: Bold, pub code: Code, pub header: Header, + pub italic: Italic, pub pre: Pre, pub quote: Quote, pub strike: Strike, @@ -47,6 +50,7 @@ impl Tags { bold: Bold::new(), code: Code::new(), header: Header::new(), + italic: Italic::new(), pre: Pre::new(), quote: Quote::new(), strike: Strike::new(), @@ -75,6 +79,7 @@ impl Tags { self.quote.render(&buffer); self.bold.render(&buffer); + self.italic.render(&buffer); self.pre.render(&buffer); self.strike.render(&buffer); self.underline.render(&buffer); @@ -102,6 +107,7 @@ impl Tags { 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); 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 index 2ba656aa..013f930a 100644 --- 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 @@ -14,7 +14,7 @@ impl Bold { Self(TextTag::builder().weight(600).wrap_mode(Word).build()) } - /// Apply **bold** `Tag` to given `TextBuffer` + /// Apply **bold**/__bold__ `Tag` to given `TextBuffer` pub fn render(&self, buffer: &TextBuffer) { assert!(buffer.tag_table().add(&self.0)); @@ -72,7 +72,8 @@ pub fn strip_tags(value: &str) -> String { #[test] fn test_strip_tags() { - const VALUE: &str = "Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_ with ![img](https://link.com)"; + 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) { @@ -81,7 +82,7 @@ fn test_strip_tags() { } assert_eq!( result, - "Some bold 1 and bold 2 and bold 3 and *italic 1* and _italic 2_ with ![img](https://link.com)" + "Some bold 1 and bold 2 and bold 3 and *italic 1* and _italic 2_" ) } @@ -90,7 +91,7 @@ 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_ with ![img](https://link.com)" + "Some **bold 1** and **bold 2** and __bold 3__ and *italic 1* and _italic 2_", ) .collect(); 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/quote.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs index cd011fd7..4c43fbfc 100644 --- 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 @@ -16,7 +16,7 @@ impl Quote { TextTag::builder() .left_margin(28) .wrap_mode(Word) - .style(Italic) // what about the italic tags decoration? @TODO + .style(Italic) // conflicts the italic tags decoration @TODO .build(), ) } From 2891d73b37035a5f2f3fc2e44e84701ef3048563 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 16 Mar 2026 09:09:29 +0200 Subject: [PATCH 107/110] update some dependencies --- Cargo.lock | 155 ++++++++++++++++++++++------------------------------- 1 file changed, 63 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5284ec1d..ebad5ffc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,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", @@ -132,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", @@ -146,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" @@ -366,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" @@ -386,7 +394,8 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", + "rand_core", "wasip2", "wasip3", ] @@ -741,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" @@ -867,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" @@ -895,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", @@ -921,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", @@ -1015,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" @@ -1036,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", ] @@ -1063,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" @@ -1106,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" @@ -1487,7 +1469,7 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -1503,13 +1485,22 @@ dependencies = [ ] [[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "winnow", ] @@ -1543,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", @@ -1698,9 +1689,9 @@ 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" dependencies = [ "memchr", ] @@ -1802,26 +1793,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" From e92eb318b3094046724f6c745d532d04fc9dec8d Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Tue, 17 Mar 2026 21:38:36 +0200 Subject: [PATCH 108/110] allow empty quote lines, update tests logic --- .../page/content/text/markdown/tags/quote.rs | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) 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 index 4c43fbfc..6b7a8b74 100644 --- 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 @@ -6,7 +6,7 @@ use gtk::{ }; use regex::Regex; -const REGEX_QUOTE: &str = r"(?m)>\s*(?P<text>.*)$"; +const REGEX_QUOTE: &str = r"(?m)^>(?:[ \t]*(?P<text>.*))?$"; pub struct Quote(TextTag); @@ -51,18 +51,16 @@ impl Quote { #[test] fn test_regex() { let cap: Vec<_> = Regex::new(REGEX_QUOTE).unwrap().captures_iter( - "> Some quote 1 with ![img](https://link.com)\n> 2\\)Some quote 2 with text\nplain text\n> Some quote 3" + "> Some quote 1 with ![img](https://link.com)\n>\n> 2\\)Some quote 2 with text\nplain text\n> Some quote 3" ).collect(); - { - let m = cap.first().unwrap(); - assert_eq!(&m["text"], "Some quote 1 with ![img](https://link.com)"); - } - { - let m = cap.get(1).unwrap(); - assert_eq!(&m["text"], "2\\)Some quote 2 with text"); - } - { - let m = cap.get(2).unwrap(); - assert_eq!(&m["text"], "Some quote 3"); - } + + 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"); } From 38f9cca42204e3563d4b11cf1f984fe57df9f02a Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 23 Mar 2026 02:34:35 +0200 Subject: [PATCH 109/110] minor syntax optimizations --- .../window/tab/item/page/content/text/gemini.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) 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 6a09513c..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 @@ -151,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, @@ -166,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, @@ -187,7 +179,7 @@ impl Gemini { // Skip other actions for this line continue; } - Err(_) => todo!(), + Err(_) => panic!(), } } } From ac83ace83bbf3be3a2c9c8e32c23fe61743b6b26 Mon Sep 17 00:00:00 2001 From: yggverse <yggverse@project> Date: Mon, 23 Mar 2026 02:34:56 +0200 Subject: [PATCH 110/110] update dependencies --- Cargo.lock | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebad5ffc..8a467b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,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" @@ -1472,7 +1472,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -1486,39 +1486,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "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" @@ -1692,6 +1692,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ]