From fc6cce80726150f577dc8d697aaaffc80ab2bff0 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 02:53:33 +0200 Subject: [PATCH 01/32] 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 02/32] 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 03/32] 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 04/32] 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 05/32] 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 22c50161af9348ee1f114e8f6efc3141da4ac97c Mon Sep 17 00:00:00 2001 From: yggverse Date: Sun, 8 Mar 2026 17:25:27 +0200 Subject: [PATCH 06/32] 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 07/32] 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 08/32] 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 09/32] 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 10/32] 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 11/32] 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 12/32] 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 13/32] 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 14/32] 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 15/32] 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 16/32] 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 17/32] 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 18/32] 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 19/32] 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 20/32] 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 21/32] 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 22/32] 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 23/32] 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 24/32] 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 25/32] 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 26/32] 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 27/32] 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 28/32] 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 29/32] 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 30/32] 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 31/32] 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 32/32] 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"); }