From 3077c3b033ad69577d26e6b3d484379f27936eb0 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 02:24:54 +0200 Subject: [PATCH 01/69] 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 02/69] 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 03/69] 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 04/69] 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 05/69] 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 06/69] 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 07/69] 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 08/69] 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 09/69] 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 10/69] 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 11/69] 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 12/69] 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 13/69] 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 14/69] 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 15/69] 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 16/69] 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 17/69] 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 18/69] 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 19/69] 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 20/69] 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 21/69] 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 22/69] 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 23/69] 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 24/69] 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 25/69] 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 26/69] 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 27/69] 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 28/69] 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 29/69] 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 30/69] 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 31/69] 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 32/69] 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 33/69] 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 34/69] 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 35/69] 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 36/69] 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 37/69] 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 38/69] 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 39/69] 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 40/69] 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 41/69] 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 42/69] 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 43/69] 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 44/69] 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 45/69] 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 46/69] 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 47/69] 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 48/69] 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 49/69] 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 50/69] 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 51/69] 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 52/69] 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 53/69] 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 54/69] 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 55/69] 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 56/69] 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 57/69] 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 58/69] 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 59/69] 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 60/69] 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 61/69] 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 62/69] 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 63/69] 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 64/69] 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 65/69] 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 66/69] 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 67/69] 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 68/69] 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 69/69] 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", ]