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/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` diff --git a/src/app/browser/window/tab/item/client/driver/file.rs b/src/app/browser/window/tab/item/client/driver/file.rs index 2dd3abf9..c56edbab 100644 --- a/src/app/browser/window/tab/item/client/driver/file.rs +++ b/src/app/browser/window/tab/item/client/driver/file.rs @@ -71,6 +71,31 @@ impl File { .set_mime(Some(content_type.to_string())); } match content_type.as_str() { + "text/gemini" => { + if matches!(*feature, Feature::Source) { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Source(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } else { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Gemini(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } + } "text/plain" => { if matches!(*feature, Feature::Source) { load_contents_async(file, cancellable, move |result| { @@ -94,6 +119,18 @@ impl File { } } }); + } else if url.ends_with(".md") || url.ends_with(".markdown") + { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Markdown(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) } else { load_contents_async(file, cancellable, move |result| { match result { @@ -107,6 +144,31 @@ impl File { }) } } + "text/markdown" => { + if matches!(*feature, Feature::Source) { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Source(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } else { + load_contents_async(file, cancellable, move |result| { + match result { + Ok(data) => { + Text::Markdown(uri, data).handle(&page) + } + Err(message) => { + Status::Failure(message).handle(&page) + } + } + }) + } + } "image/png" | "image/gif" | "image/jpeg" | "image/webp" => { match gtk::gdk::Texture::from_file(&file) { Ok(texture) => { diff --git a/src/app/browser/window/tab/item/client/driver/file/text.rs b/src/app/browser/window/tab/item/client/driver/file/text.rs index b7f8aa31..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..fc88758d 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -154,6 +154,14 @@ impl Content { } } + /// `text/markdown` + pub fn to_text_markdown(&self, base: &Uri, data: &str) -> Text { + self.clean(); + let m = Text::markdown((&self.window_action, &self.item_action), base, data); + self.g_box.append(&m.scrolled_window); + m + } + /// `text/plain` pub fn to_text_plain(&self, data: &str) -> Text { self.clean(); diff --git a/src/app/browser/window/tab/item/page/content/directory/column/format.rs b/src/app/browser/window/tab/item/page/content/directory/column/format.rs index ba027dfb..328048c0 100644 --- a/src/app/browser/window/tab/item/page/content/directory/column/format.rs +++ b/src/app/browser/window/tab/item/page/content/directory/column/format.rs @@ -15,6 +15,8 @@ impl Format for FileInfo { if content_type == "text/plain" { if display_name.ends_with(".gmi") || display_name.ends_with(".gemini") { "text/gemini".into() + } else if display_name.ends_with(".md") || display_name.ends_with(".markdown") { + "text/markdown".into() } else { content_type } diff --git a/src/app/browser/window/tab/item/page/content/text.rs b/src/app/browser/window/tab/item/page/content/text.rs index d47c9e65..9a634185 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,21 @@ impl Text { } } + pub fn markdown( + actions: (&Rc, &Rc), + base: &Uri, + gemtext: &str, + ) -> 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, + }, + } + } + pub fn plain(data: &str) -> Self { let text_view = TextView::plain(data); Self { diff --git a/src/app/browser/window/tab/item/page/content/text/gemini.rs b/src/app/browser/window/tab/item/page/content/text/gemini.rs index a89e2fff..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()); 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..ebcaf16e --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -0,0 +1,368 @@ +mod gutter; +mod tags; + +use super::{ItemAction, WindowAction}; +use crate::app::browser::window::action::Position; +use gtk::{ + EventControllerMotion, GestureClick, TextBuffer, TextTag, TextTagTable, TextView, + TextWindowType, UriLauncher, Window, WrapMode, + gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA}, + gio::{Cancellable, SimpleAction, SimpleActionGroup}, + glib::{Uri, uuid_string_random}, + prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt}, +}; +use gutter::Gutter; +use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant}; +use std::{cell::Cell, collections::HashMap, rc::Rc}; +use tags::Tags; + +pub struct Markdown { + pub title: Option, + pub text_view: TextView, +} + +impl Markdown { + // Constructors + + /// Build new `Self` + pub fn build( + (window_action, item_action): (&Rc, &Rc), + base: &Uri, + markdown: &str, + ) -> Self { + // 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 colors + // @TODO use accent colors in adw 1.6 / ubuntu 24.10+ + let link_color = ( + RGBA::new(0.208, 0.518, 0.894, 1.0), + RGBA::new(0.208, 0.518, 0.894, 0.9), + ); + + // Init tags + let mut tags = Tags::new(); + + // Init new text buffer + let buffer = TextBuffer::new(Some(&TextTagTable::new())); + buffer.set_text(markdown); + + // Init main widget + let text_view = { + const MARGIN: i32 = 8; + TextView::builder() + .bottom_margin(MARGIN) + .buffer(&buffer) + .cursor_visible(false) + .editable(false) + .left_margin(MARGIN) + .right_margin(MARGIN) + .top_margin(MARGIN) + .vexpand(true) + .wrap_mode(WrapMode::Word) + .build() + }; + + // Init gutter widget (the tooltip on URL tags hover) + let gutter = Gutter::build(&text_view); + + // Render markdown tags + let title = tags.render(&buffer, base, &link_color.0, &mut links); + + // 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? + + 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/gutter.rs b/src/app/browser/window/tab/item/page/content/text/markdown/gutter.rs new file mode 100644 index 00000000..6a558ef2 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/gutter.rs @@ -0,0 +1,68 @@ +use gtk::{ + Align, Label, TextView, TextWindowType, + glib::{Uri, timeout_add_local_once}, + pango::EllipsizeMode, + prelude::{TextViewExt, WidgetExt}, +}; +use std::{cell::Cell, rc::Rc, time::Duration}; + +pub struct Gutter { + pub label: Label, + is_active: Rc>, +} + +impl Gutter { + pub fn build(text_view: &TextView) -> Self { + const MARGIN_X: i32 = 8; + const MARGIN_Y: i32 = 2; + let label = Label::builder() + .css_classes(["caption", "dim-label"]) + .ellipsize(EllipsizeMode::Middle) + .halign(Align::Start) + .margin_bottom(MARGIN_Y) + .margin_end(MARGIN_X) + .margin_start(MARGIN_X) + .margin_top(MARGIN_Y) + .visible(false) + .build(); + + text_view.set_gutter(TextWindowType::Bottom, Some(&label)); + text_view + .gutter(TextWindowType::Bottom) + .unwrap() + .set_css_classes(&["view"]); // @TODO unspecified patch + + Self { + is_active: Rc::new(Cell::new(false)), + label, + } + } + + pub fn set_uri(&self, uri: Option<&Uri>) { + match uri { + Some(uri) => { + if !self.label.is_visible() { + if !self.is_active.replace(true) { + timeout_add_local_once(Duration::from_millis(250), { + let label = self.label.clone(); + let is_active = self.is_active.clone(); + let uri = uri.clone(); + move || { + if is_active.replace(false) { + label.set_label(&uri.to_string()); + label.set_visible(true) + } + } + }); + } + } else { + self.label.set_label(&uri.to_string()) + } + } + None => { + self.is_active.replace(false); + self.label.set_visible(false) + } + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/parser.rs b/src/app/browser/window/tab/item/page/content/text/markdown/parser.rs new file mode 100644 index 00000000..fd708509 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/parser.rs @@ -0,0 +1,5 @@ +pub mod code; +pub mod header; +pub mod link; +pub mod list; +pub mod quote; diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs new file mode 100644 index 00000000..cd25684c --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs @@ -0,0 +1,88 @@ +mod bold; +mod code; +mod header; +mod list; +mod pre; +mod quote; +mod reference; +mod strike; +mod underline; + +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; + +pub struct Tags { + pub bold: Bold, + pub code: Code, + pub header: Header, + pub pre: Pre, + pub quote: Quote, + pub strike: Strike, + pub underline: Underline, +} + +impl Default for Tags { + fn default() -> Self { + Self::new() + } +} + +impl Tags { + // Construct + pub fn new() -> Self { + Self { + bold: Bold::new(), + code: Code::new(), + header: Header::new(), + pre: Pre::new(), + quote: Quote::new(), + strike: Strike::new(), + underline: Underline::new(), + } + } + pub fn render( + &mut self, + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap, + ) -> Option { + // 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); + + list::render(buffer); + + self.quote.render(buffer); + + self.bold.render(buffer); + self.pre.render(buffer); + self.strike.render(buffer); + self.underline.render(buffer); + + reference::render_images_links(buffer, base, link_color, links); + reference::render_images(buffer, base, link_color, links); + reference::render_links(buffer, base, link_color, links); + + self.code.render(buffer); + + // 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); + 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..5ff97e4f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/bold.rs @@ -0,0 +1,78 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_BOLD: &str = r"\*\*(?P[^*]+)\*\*"; + +pub struct Bold(TextTag); + +impl Bold { + pub fn new() -> Self { + Self(TextTag::builder().weight(600).wrap_mode(Word).build()) + } + + /// Apply **bold** `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) { + 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) { + 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.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 new file mode 100644 index 00000000..5d79041f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code.rs @@ -0,0 +1,128 @@ +mod ansi; +mod syntax; + +use gtk::{ + TextBuffer, TextSearchFlags, TextTag, WrapMode, + glib::{GString, uuid_string_random}, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; +use syntax::Syntax; + +const REGEX_CODE: &str = r"(?s)```[ \t]*(?P.*?)\n(?P.*?)```"; + +struct Entry { + alt: Option, + data: String, +} + +pub struct Code { + index: HashMap, + alt: TextTag, +} + +impl Code { + pub fn new() -> Self { + Self { + index: HashMap::new(), + alt: TextTag::builder() + .pixels_above_lines(4) + .pixels_below_lines(8) + .weight(500) + .wrap_mode(WrapMode::None) + .build(), + } + } + + /// Collect all code blocks into `Self.index` (to prevent formatting) + pub fn collect(&mut self, buffer: &TextBuffer) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_CODE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let id = uuid_string_random(); + + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + + buffer.insert_with_tags(&mut start_iter, &id, &[]); + assert!( + self.index + .insert( + id, + Entry { + alt: alt(cap["alt"].into()).map(|s| s.into()), + data: cap["data"].into(), + }, + ) + .is_none() + ) + } + } + + /// Apply code `Tag` to given `TextBuffer` using `Self.index` + pub fn render(&mut self, buffer: &TextBuffer) { + let syntax = Syntax::new(); + assert!(buffer.tag_table().add(&self.alt)); + for (k, v) in self.index.iter() { + while let Some((mut m_start, mut m_end)) = + buffer + .start_iter() + .forward_search(k, TextSearchFlags::VISIBLE_ONLY, None) + { + buffer.delete(&mut m_start, &mut m_end); + if let Some(ref alt) = v.alt { + buffer.insert_with_tags(&mut m_start, &format!("{alt}\n"), &[&self.alt]) + } + match syntax.highlight(&v.data, v.alt.as_ref()) { + Ok(highlight) => { + for (syntax_tag, entity) in highlight { + assert!(buffer.tag_table().add(&syntax_tag)); + buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag]) + } + } + Err(_) => { + // Try ANSI/SGR format (terminal emulation) @TODO optional + for (syntax_tag, entity) in ansi::format(&v.data) { + assert!(buffer.tag_table().add(&syntax_tag)); + buffer.insert_with_tags(&mut m_start, &entity, &[&syntax_tag]) + } + } + } + } + } + } +} + +fn alt(value: Option<&str>) -> Option<&str> { + value.map(|m| m.trim()).filter(|s| !s.is_empty()) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_CODE) + .unwrap() + .captures_iter("Some ``` alt text\ncode line 1\ncode line 2``` and ```\ncode line 3\ncode line 4``` with ![img](https://link.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(alt(first.name("alt").map(|m| m.as_str())), Some("alt text")); + assert_eq!(&first["data"], "code line 1\ncode line 2"); + + let second = cap.get(1).unwrap(); + assert_eq!(alt(second.name("alt").map(|m| m.as_str())), None); + assert_eq!(&second["data"], "code line 3\ncode line 4"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs new file mode 100644 index 00000000..b617b69a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi.rs @@ -0,0 +1,33 @@ +mod rgba; +mod tag; + +use tag::Tag; + +use ansi_parser::{AnsiParser, AnsiSequence, Output}; +use gtk::{TextTag, prelude::TextTagExt}; + +/// Apply ANSI/SGR format to new buffer +pub fn format(source_code: &str) -> Vec<(TextTag, String)> { + let mut buffer = Vec::new(); + let mut tag = Tag::new(); + + for ref entity in source_code.ansi_parse() { + if let Output::Escape(AnsiSequence::SetGraphicsMode(color)) = entity + && color.len() > 1 + { + if color[0] == 38 { + tag.text_tag + .set_foreground_rgba(rgba::default(*color.last().unwrap()).as_ref()); + } else { + tag.text_tag + .set_background_rgba(rgba::default(*color.last().unwrap()).as_ref()); + } + } + if let Output::TextBlock(text) = entity { + buffer.push((tag.text_tag, text.to_string())); + tag = Tag::new(); + } + } + + buffer +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs new file mode 100644 index 00000000..d1398d2f --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/rgba.rs @@ -0,0 +1,256 @@ +use gtk::gdk::RGBA; + +/// Default RGBa palette for ANSI terminal emulation +pub fn default(color: u8) -> Option { + match color { + 7 => Some(RGBA::new(0.854, 0.854, 0.854, 1.0)), + 8 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)), + 10 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)), + 11 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)), + 12 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)), + 13 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)), + 14 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)), + 15 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)), + 16 => Some(RGBA::new(0.0, 0.0, 0.0, 1.0)), + 17 => Some(RGBA::new(0.0, 0.020, 0.373, 1.0)), + 18 => Some(RGBA::new(0.0, 0.031, 0.529, 1.0)), + 19 => Some(RGBA::new(0.0, 0.0, 0.686, 1.0)), + 20 => Some(RGBA::new(0.0, 0.0, 0.823, 1.0)), + 21 => Some(RGBA::new(0.0, 0.0, 1.0, 1.0)), + 22 => Some(RGBA::new(0.0, 0.373, 0.0, 1.0)), + 23 => Some(RGBA::new(0.0, 0.373, 0.373, 1.0)), + 24 => Some(RGBA::new(0.0, 0.373, 0.529, 1.0)), + 25 => Some(RGBA::new(0.0, 0.373, 0.686, 1.0)), + 26 => Some(RGBA::new(0.0, 0.373, 0.823, 1.0)), + 27 => Some(RGBA::new(0.0, 0.373, 1.0, 1.0)), + 28 => Some(RGBA::new(0.0, 0.533, 0.0, 1.0)), + 29 => Some(RGBA::new(0.0, 0.533, 0.373, 1.0)), + 30 => Some(RGBA::new(0.0, 0.533, 0.533, 1.0)), + 31 => Some(RGBA::new(0.0, 0.533, 0.686, 1.0)), + 32 => Some(RGBA::new(0.0, 0.533, 0.823, 1.0)), + 33 => Some(RGBA::new(0.0, 0.533, 1.0, 1.0)), + 34 => Some(RGBA::new(0.039, 0.686, 0.0, 1.0)), + 35 => Some(RGBA::new(0.039, 0.686, 0.373, 1.0)), + 36 => Some(RGBA::new(0.039, 0.686, 0.529, 1.0)), + 37 => Some(RGBA::new(0.039, 0.686, 0.686, 1.0)), + 38 => Some(RGBA::new(0.039, 0.686, 0.823, 1.0)), + 39 => Some(RGBA::new(0.039, 0.686, 1.0, 1.0)), + 40 => Some(RGBA::new(0.0, 0.843, 0.0, 1.0)), + 41 => Some(RGBA::new(0.0, 0.843, 0.373, 1.0)), + 42 => Some(RGBA::new(0.0, 0.843, 0.529, 1.0)), + 43 => Some(RGBA::new(0.0, 0.843, 0.686, 1.0)), + 44 => Some(RGBA::new(0.0, 0.843, 0.843, 1.0)), + 45 => Some(RGBA::new(0.0, 0.843, 1.0, 1.0)), + 46 => Some(RGBA::new(0.0, 1.0, 0.0, 1.0)), + 47 => Some(RGBA::new(0.0, 1.0, 0.373, 1.0)), + 48 => Some(RGBA::new(0.0, 1.0, 0.529, 1.0)), + 49 => Some(RGBA::new(0.0, 1.0, 0.686, 1.0)), + 50 => Some(RGBA::new(0.0, 1.0, 0.843, 1.0)), + 51 => Some(RGBA::new(0.0, 1.0, 1.0, 1.0)), + 52 => Some(RGBA::new(0.373, 0.0, 0.0, 1.0)), + 53 => Some(RGBA::new(0.373, 0.0, 0.373, 1.0)), + 54 => Some(RGBA::new(0.373, 0.0, 0.529, 1.0)), + 55 => Some(RGBA::new(0.373, 0.0, 0.686, 1.0)), + 56 => Some(RGBA::new(0.373, 0.0, 0.843, 1.0)), + 57 => Some(RGBA::new(0.373, 0.0, 1.0, 1.0)), + 58 => Some(RGBA::new(0.373, 0.373, 0.0, 1.0)), + 59 => Some(RGBA::new(0.373, 0.373, 0.373, 1.0)), + 60 => Some(RGBA::new(0.373, 0.373, 0.529, 1.0)), + 61 => Some(RGBA::new(0.373, 0.373, 0.686, 1.0)), + 62 => Some(RGBA::new(0.373, 0.373, 0.843, 1.0)), + 63 => Some(RGBA::new(0.373, 0.373, 1.0, 1.0)), + 64 => Some(RGBA::new(0.373, 0.529, 0.0, 1.0)), + 65 => Some(RGBA::new(0.373, 0.529, 0.373, 1.0)), + 66 => Some(RGBA::new(0.373, 0.529, 0.529, 1.0)), + 67 => Some(RGBA::new(0.373, 0.529, 0.686, 1.0)), + 68 => Some(RGBA::new(0.373, 0.529, 0.843, 1.0)), + 69 => Some(RGBA::new(0.373, 0.529, 1.0, 1.0)), + 70 => Some(RGBA::new(0.373, 0.686, 0.0, 1.0)), + 71 => Some(RGBA::new(0.373, 0.686, 0.373, 1.0)), + 72 => Some(RGBA::new(0.373, 0.686, 0.529, 1.0)), + 73 => Some(RGBA::new(0.373, 0.686, 0.686, 1.0)), + 74 => Some(RGBA::new(0.373, 0.686, 0.843, 1.0)), + 75 => Some(RGBA::new(0.373, 0.686, 1.0, 1.0)), + 76 => Some(RGBA::new(0.373, 0.843, 0.0, 1.0)), + 77 => Some(RGBA::new(0.373, 0.843, 0.373, 1.0)), + 78 => Some(RGBA::new(0.373, 0.843, 0.529, 1.0)), + 79 => Some(RGBA::new(0.373, 0.843, 0.686, 1.0)), + 80 => Some(RGBA::new(0.373, 0.843, 0.843, 1.0)), + 81 => Some(RGBA::new(0.373, 0.843, 1.0, 1.0)), + 82 => Some(RGBA::new(0.373, 1.0, 0.0, 1.0)), + 83 => Some(RGBA::new(0.373, 1.0, 0.373, 1.0)), + 84 => Some(RGBA::new(0.373, 1.0, 0.529, 1.0)), + 85 => Some(RGBA::new(0.373, 1.0, 0.686, 1.0)), + 86 => Some(RGBA::new(0.373, 1.0, 0.843, 1.0)), + 87 => Some(RGBA::new(0.373, 1.0, 1.0, 1.0)), + 88 => Some(RGBA::new(0.529, 0.0, 0.0, 1.0)), + 89 => Some(RGBA::new(0.529, 0.0, 0.373, 1.0)), + 90 => Some(RGBA::new(0.529, 0.0, 0.529, 1.0)), + 91 => Some(RGBA::new(0.529, 0.0, 0.686, 1.0)), + 92 => Some(RGBA::new(0.529, 0.0, 0.843, 1.0)), + 93 => Some(RGBA::new(0.529, 0.0, 1.0, 1.0)), + 94 => Some(RGBA::new(0.529, 0.373, 0.0, 1.0)), + 95 => Some(RGBA::new(0.529, 0.373, 0.373, 1.0)), + 96 => Some(RGBA::new(0.529, 0.373, 0.529, 1.0)), + 97 => Some(RGBA::new(0.529, 0.373, 0.686, 1.0)), + 98 => Some(RGBA::new(0.529, 0.373, 0.843, 1.0)), + 99 => Some(RGBA::new(0.529, 0.373, 1.0, 1.0)), + 100 => Some(RGBA::new(0.529, 0.529, 0.0, 1.0)), + 101 => Some(RGBA::new(0.529, 0.529, 0.373, 1.0)), + 102 => Some(RGBA::new(0.529, 0.529, 0.529, 1.0)), + 103 => Some(RGBA::new(0.529, 0.529, 0.686, 1.0)), + 104 => Some(RGBA::new(0.529, 0.529, 0.843, 1.0)), + 105 => Some(RGBA::new(0.529, 0.529, 1.0, 1.0)), + 106 => Some(RGBA::new(0.533, 0.686, 0.0, 1.0)), + 107 => Some(RGBA::new(0.533, 0.686, 0.373, 1.0)), + 108 => Some(RGBA::new(0.533, 0.686, 0.529, 1.0)), + 109 => Some(RGBA::new(0.533, 0.686, 0.686, 1.0)), + 110 => Some(RGBA::new(0.533, 0.686, 0.843, 1.0)), + 111 => Some(RGBA::new(0.533, 0.686, 1.0, 1.0)), + 112 => Some(RGBA::new(0.533, 0.843, 0.0, 1.0)), + 113 => Some(RGBA::new(0.533, 0.843, 0.373, 1.0)), + 114 => Some(RGBA::new(0.533, 0.843, 0.529, 1.0)), + 115 => Some(RGBA::new(0.533, 0.843, 0.686, 1.0)), + 116 => Some(RGBA::new(0.533, 0.843, 0.843, 1.0)), + 117 => Some(RGBA::new(0.533, 0.843, 1.0, 1.0)), + 118 => Some(RGBA::new(0.533, 1.0, 0.0, 1.0)), + 119 => Some(RGBA::new(0.533, 1.0, 0.373, 1.0)), + 120 => Some(RGBA::new(0.533, 1.0, 0.529, 1.0)), + 121 => Some(RGBA::new(0.533, 1.0, 0.686, 1.0)), + 122 => Some(RGBA::new(0.533, 1.0, 0.843, 1.0)), + 123 => Some(RGBA::new(0.533, 1.0, 1.0, 1.0)), + 124 => Some(RGBA::new(0.686, 0.0, 0.0, 1.0)), + 125 => Some(RGBA::new(0.686, 0.0, 0.373, 1.0)), + 126 => Some(RGBA::new(0.686, 0.0, 0.529, 1.0)), + 127 => Some(RGBA::new(0.686, 0.0, 0.686, 1.0)), + 128 => Some(RGBA::new(0.686, 0.0, 0.843, 1.0)), + 129 => Some(RGBA::new(0.686, 0.0, 1.0, 1.0)), + 130 => Some(RGBA::new(0.686, 0.373, 0.0, 1.0)), + 131 => Some(RGBA::new(0.686, 0.373, 0.373, 1.0)), + 132 => Some(RGBA::new(0.686, 0.373, 0.529, 1.0)), + 133 => Some(RGBA::new(0.686, 0.373, 0.686, 1.0)), + 134 => Some(RGBA::new(0.686, 0.373, 0.843, 1.0)), + 135 => Some(RGBA::new(0.686, 0.373, 1.0, 1.0)), + 136 => Some(RGBA::new(0.686, 0.529, 0.0, 1.0)), + 137 => Some(RGBA::new(0.686, 0.529, 0.373, 1.0)), + 138 => Some(RGBA::new(0.686, 0.529, 0.529, 1.0)), + 139 => Some(RGBA::new(0.686, 0.529, 0.686, 1.0)), + 140 => Some(RGBA::new(0.686, 0.529, 0.843, 1.0)), + 141 => Some(RGBA::new(0.686, 0.529, 1.0, 1.0)), + 142 => Some(RGBA::new(0.686, 0.686, 0.0, 1.0)), + 143 => Some(RGBA::new(0.686, 0.686, 0.373, 1.0)), + 144 => Some(RGBA::new(0.686, 0.686, 0.529, 1.0)), + 145 => Some(RGBA::new(0.686, 0.686, 0.686, 1.0)), + 146 => Some(RGBA::new(0.686, 0.686, 0.843, 1.0)), + 147 => Some(RGBA::new(0.686, 0.686, 1.0, 1.0)), + 148 => Some(RGBA::new(0.686, 0.843, 0.0, 1.0)), + 149 => Some(RGBA::new(0.686, 0.843, 0.373, 1.0)), + 150 => Some(RGBA::new(0.686, 0.843, 0.529, 1.0)), + 151 => Some(RGBA::new(0.686, 0.843, 0.686, 1.0)), + 152 => Some(RGBA::new(0.686, 0.843, 0.843, 1.0)), + 153 => Some(RGBA::new(0.686, 0.843, 1.0, 1.0)), + 154 => Some(RGBA::new(0.686, 1.0, 0.0, 1.0)), + 155 => Some(RGBA::new(0.686, 1.0, 0.373, 1.0)), + 156 => Some(RGBA::new(0.686, 1.0, 0.529, 1.0)), + 157 => Some(RGBA::new(0.686, 1.0, 0.686, 1.0)), + 158 => Some(RGBA::new(0.686, 1.0, 0.843, 1.0)), + 159 => Some(RGBA::new(0.686, 1.0, 1.0, 1.0)), + 160 => Some(RGBA::new(0.847, 0.0, 0.0, 1.0)), + 161 => Some(RGBA::new(0.847, 0.0, 0.373, 1.0)), + 162 => Some(RGBA::new(0.847, 0.0, 0.529, 1.0)), + 163 => Some(RGBA::new(0.847, 0.0, 0.686, 1.0)), + 164 => Some(RGBA::new(0.847, 0.0, 0.843, 1.0)), + 165 => Some(RGBA::new(0.847, 0.0, 1.0, 1.0)), + 166 => Some(RGBA::new(0.847, 0.373, 0.0, 1.0)), + 167 => Some(RGBA::new(0.847, 0.373, 0.373, 1.0)), + 168 => Some(RGBA::new(0.847, 0.373, 0.529, 1.0)), + 169 => Some(RGBA::new(0.847, 0.373, 0.686, 1.0)), + 170 => Some(RGBA::new(0.847, 0.373, 0.843, 1.0)), + 171 => Some(RGBA::new(0.847, 0.373, 1.0, 1.0)), + 172 => Some(RGBA::new(0.847, 0.529, 0.0, 1.0)), + 173 => Some(RGBA::new(0.847, 0.529, 0.373, 1.0)), + 174 => Some(RGBA::new(0.847, 0.529, 0.529, 1.0)), + 175 => Some(RGBA::new(0.847, 0.529, 0.686, 1.0)), + 176 => Some(RGBA::new(0.847, 0.529, 0.843, 1.0)), + 177 => Some(RGBA::new(0.847, 0.529, 1.0, 1.0)), + 178 => Some(RGBA::new(0.847, 0.686, 0.0, 1.0)), + 179 => Some(RGBA::new(0.847, 0.686, 0.373, 1.0)), + 180 => Some(RGBA::new(0.847, 0.686, 0.529, 1.0)), + 181 => Some(RGBA::new(0.847, 0.686, 0.686, 1.0)), + 182 => Some(RGBA::new(0.847, 0.686, 0.843, 1.0)), + 183 => Some(RGBA::new(0.847, 0.686, 1.0, 1.0)), + 184 => Some(RGBA::new(0.847, 0.843, 0.0, 1.0)), + 185 => Some(RGBA::new(0.847, 0.843, 0.373, 1.0)), + 186 => Some(RGBA::new(0.847, 0.843, 0.529, 1.0)), + 187 => Some(RGBA::new(0.847, 0.843, 0.686, 1.0)), + 188 => Some(RGBA::new(0.847, 0.843, 0.843, 1.0)), + 189 => Some(RGBA::new(0.847, 0.843, 1.0, 1.0)), + 190 => Some(RGBA::new(0.847, 1.0, 0.0, 1.0)), + 191 => Some(RGBA::new(0.847, 1.0, 0.373, 1.0)), + 192 => Some(RGBA::new(0.847, 1.0, 0.529, 1.0)), + 193 => Some(RGBA::new(0.847, 1.0, 0.686, 1.0)), + 194 => Some(RGBA::new(0.847, 1.0, 0.843, 1.0)), + 195 => Some(RGBA::new(0.847, 1.0, 1.0, 1.0)), + 196 => Some(RGBA::new(1.0, 0.0, 0.0, 1.0)), + 197 => Some(RGBA::new(1.0, 0.0, 0.373, 1.0)), + 198 => Some(RGBA::new(1.0, 0.0, 0.529, 1.0)), + 199 => Some(RGBA::new(1.0, 0.0, 0.686, 1.0)), + 200 => Some(RGBA::new(1.0, 0.0, 0.843, 1.0)), + 201 => Some(RGBA::new(1.0, 0.0, 1.0, 1.0)), + 202 => Some(RGBA::new(1.0, 0.373, 0.0, 1.0)), + 203 => Some(RGBA::new(1.0, 0.373, 0.373, 1.0)), + 204 => Some(RGBA::new(1.0, 0.373, 0.529, 1.0)), + 205 => Some(RGBA::new(1.0, 0.373, 0.686, 1.0)), + 206 => Some(RGBA::new(1.0, 0.373, 0.843, 1.0)), + 207 => Some(RGBA::new(1.0, 0.373, 1.0, 1.0)), + 208 => Some(RGBA::new(1.0, 0.529, 0.0, 1.0)), + 209 => Some(RGBA::new(1.0, 0.529, 0.373, 1.0)), + 210 => Some(RGBA::new(1.0, 0.529, 0.529, 1.0)), + 211 => Some(RGBA::new(1.0, 0.529, 0.686, 1.0)), + 212 => Some(RGBA::new(1.0, 0.529, 0.843, 1.0)), + 213 => Some(RGBA::new(1.0, 0.529, 1.0, 1.0)), + 214 => Some(RGBA::new(1.0, 0.686, 0.0, 1.0)), + 215 => Some(RGBA::new(1.0, 0.686, 0.373, 1.0)), + 216 => Some(RGBA::new(1.0, 0.686, 0.529, 1.0)), + 217 => Some(RGBA::new(1.0, 0.686, 0.686, 1.0)), + 218 => Some(RGBA::new(1.0, 0.686, 0.843, 1.0)), + 219 => Some(RGBA::new(1.0, 0.686, 1.0, 1.0)), + 220 => Some(RGBA::new(1.0, 0.843, 0.0, 1.0)), + 221 => Some(RGBA::new(1.0, 0.843, 0.373, 1.0)), + 222 => Some(RGBA::new(1.0, 0.843, 0.529, 1.0)), + 223 => Some(RGBA::new(1.0, 0.843, 0.686, 1.0)), + 224 => Some(RGBA::new(1.0, 0.843, 0.843, 1.0)), + 225 => Some(RGBA::new(1.0, 0.843, 1.0, 1.0)), + 226 => Some(RGBA::new(1.0, 1.0, 0.0, 1.0)), + 227 => Some(RGBA::new(1.0, 1.0, 0.373, 1.0)), + 228 => Some(RGBA::new(1.0, 1.0, 0.529, 1.0)), + 229 => Some(RGBA::new(1.0, 1.0, 0.686, 1.0)), + 230 => Some(RGBA::new(1.0, 1.0, 0.843, 1.0)), + 231 => Some(RGBA::new(1.0, 1.0, 1.0, 1.0)), + 232 => Some(RGBA::new(0.031, 0.031, 0.031, 1.0)), + 233 => Some(RGBA::new(0.071, 0.071, 0.071, 1.0)), + 234 => Some(RGBA::new(0.110, 0.110, 0.110, 1.0)), + 235 => Some(RGBA::new(0.149, 0.149, 0.149, 1.0)), + 236 => Some(RGBA::new(0.188, 0.188, 0.188, 1.0)), + 237 => Some(RGBA::new(0.227, 0.227, 0.227, 1.0)), + 238 => Some(RGBA::new(0.267, 0.267, 0.267, 1.0)), + 239 => Some(RGBA::new(0.306, 0.306, 0.306, 1.0)), + 240 => Some(RGBA::new(0.345, 0.345, 0.345, 1.0)), + 241 => Some(RGBA::new(0.384, 0.384, 0.384, 1.0)), + 242 => Some(RGBA::new(0.424, 0.424, 0.424, 1.0)), + 243 => Some(RGBA::new(0.462, 0.462, 0.462, 1.0)), + 244 => Some(RGBA::new(0.502, 0.502, 0.502, 1.0)), + 245 => Some(RGBA::new(0.541, 0.541, 0.541, 1.0)), + 246 => Some(RGBA::new(0.580, 0.580, 0.580, 1.0)), + 247 => Some(RGBA::new(0.620, 0.620, 0.620, 1.0)), + 248 => Some(RGBA::new(0.659, 0.659, 0.659, 1.0)), + 249 => Some(RGBA::new(0.694, 0.694, 0.694, 1.0)), + 250 => Some(RGBA::new(0.733, 0.733, 0.733, 1.0)), + 251 => Some(RGBA::new(0.777, 0.777, 0.777, 1.0)), + 252 => Some(RGBA::new(0.816, 0.816, 0.816, 1.0)), + 253 => Some(RGBA::new(0.855, 0.855, 0.855, 1.0)), + 254 => Some(RGBA::new(0.890, 0.890, 0.890, 1.0)), + 255 => Some(RGBA::new(0.933, 0.933, 0.933, 1.0)), + _ => None, + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs new file mode 100644 index 00000000..7154b1f3 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/ansi/tag.rs @@ -0,0 +1,29 @@ +use gtk::{TextTag, WrapMode}; + +/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset +/// for ANSI buffer +pub struct Tag { + pub text_tag: TextTag, +} + +impl Default for Tag { + fn default() -> Self { + Self::new() + } +} + +impl Tag { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + text_tag: TextTag::builder() + .family("monospace") // @TODO + .left_margin(28) + .scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO + .wrap_mode(WrapMode::None) + .build(), + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs new file mode 100644 index 00000000..50de853d --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax.rs @@ -0,0 +1,152 @@ +pub mod error; +mod tag; + +pub use error::Error; +use tag::Tag; + +use adw::StyleManager; +use gtk::{ + TextTag, + gdk::RGBA, + pango::{Style, Underline}, + prelude::TextTagExt, +}; +use syntect::{ + easy::HighlightLines, + highlighting::{Color, FontStyle, ThemeSet}, + parsing::{SyntaxReference, SyntaxSet}, +}; + +/* Default theme + @TODO make optional + base16-ocean.dark + base16-eighties.dark + base16-mocha.dark + base16-ocean.light + InspiredGitHub + Solarized (dark) + Solarized (light) +*/ +pub const DEFAULT_THEME_DARK: &str = "base16-eighties.dark"; +pub const DEFAULT_THEME_LIGHT: &str = "InspiredGitHub"; + +pub struct Syntax { + syntax_set: SyntaxSet, + theme_set: ThemeSet, +} + +impl Default for Syntax { + fn default() -> Self { + Self::new() + } +} + +impl Syntax { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), + } + } + + // Actions + + /// Apply `Syntect` highlight to new buffer returned, + /// according to given `alt` and `source_code` content + pub fn highlight( + &self, + source_code: &str, + alt: Option<&String>, + ) -> Result, Error> { + if let Some(value) = alt { + if let Some(reference) = self.syntax_set.find_syntax_by_name(value) { + return self.buffer(source_code, reference); + } + + if let Some(reference) = self.syntax_set.find_syntax_by_token(value) { + return self.buffer(source_code, reference); + } + + if let Some(reference) = self.syntax_set.find_syntax_by_path(value) { + return self.buffer(source_code, reference); + } + } + + if let Some(reference) = self.syntax_set.find_syntax_by_first_line(source_code) { + return self.buffer(source_code, reference); + } + + Err(Error::Parse) + } + + fn buffer( + &self, + source: &str, + syntax_reference: &SyntaxReference, + ) -> Result, Error> { + // Init new line buffer + let mut buffer = Vec::new(); + + // Apply syntect decorator + let mut ranges = HighlightLines::new( + syntax_reference, + &self.theme_set.themes[if StyleManager::default().is_dark() { + DEFAULT_THEME_DARK + } else { + DEFAULT_THEME_LIGHT + }], // @TODO apply on env change + ); + + match ranges.highlight_line(source, &self.syntax_set) { + Ok(result) => { + // Build tags + for (style, entity) in result { + // Create new tag from default preset + let tag = Tag::new(); + + // Tuneup using syntect conversion + // tag.set_background_rgba(Some(&color_to_rgba(style.background))); + tag.text_tag + .set_foreground_rgba(Some(&color_to_rgba(style.foreground))); + tag.text_tag + .set_style(font_style_to_style(style.font_style)); + tag.text_tag + .set_underline(font_style_to_underline(style.font_style)); + + // Append + buffer.push((tag.text_tag, entity.to_string())); + } + Ok(buffer) + } + Err(e) => Err(Error::Syntect(e)), + } + } +} + +// Tools + +fn color_to_rgba(color: Color) -> RGBA { + RGBA::new( + color.r as f32 / 255.0, + color.g as f32 / 255.0, + color.b as f32 / 255.0, + color.a as f32 / 255.0, + ) +} + +fn font_style_to_style(font_style: FontStyle) -> Style { + match font_style { + FontStyle::ITALIC => Style::Italic, + _ => Style::Normal, + } +} + +fn font_style_to_underline(font_style: FontStyle) -> Underline { + match font_style { + FontStyle::UNDERLINE => Underline::Single, + _ => Underline::None, + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs new file mode 100644 index 00000000..ae9bfdb6 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/error.rs @@ -0,0 +1,18 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Parse, + Syntect(syntect::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Parse => write!(f, "Parse error"), + Self::Syntect(e) => { + write!(f, "Syntect error: {e}") + } + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs new file mode 100644 index 00000000..4b2011b8 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/code/syntax/tag.rs @@ -0,0 +1,29 @@ +use gtk::{TextTag, WrapMode}; + +/// Default [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) preset +/// for syntax highlight buffer +pub struct Tag { + pub text_tag: TextTag, +} + +impl Default for Tag { + fn default() -> Self { + Self::new() + } +} + +impl Tag { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + text_tag: TextTag::builder() + .family("monospace") // @TODO + .left_margin(28) + .scale(0.81) // * the rounded `0.8` value crops text for some reason @TODO + .wrap_mode(WrapMode::None) + .build(), + } + } +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs new file mode 100644 index 00000000..9de1b20c --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs @@ -0,0 +1,129 @@ +use gtk::{ + TextBuffer, TextTag, WrapMode, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_HEADER: &str = r"(?m)^(?P#{1,6})\s+(?P.*)$"; + +pub struct Header { + h1: TextTag, + h2: TextTag, + h3: TextTag, + h4: TextTag, + h5: TextTag, + h6: TextTag, +} + +impl Header { + pub fn new() -> Self { + 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) -> Option<String> { + let mut raw_title = None; + + let table = buffer.tag_table(); + + assert!(table.add(&self.h1)); + assert!(table.add(&self.h2)); + assert!(table.add(&self.h3)); + assert!(table.add(&self.h4)); + assert!(table.add(&self.h5)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_HEADER) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.iter() { + if raw_title.is_none() && !cap["title"].trim().is_empty() { + raw_title = Some(cap["title"].into()) + } + } + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + 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"], &[]), + } + } + + raw_title + } +} + +#[test] +fn test_regex_title() { + let cap: Vec<_> = Regex::new(REGEX_HEADER) + .unwrap() + .captures_iter(r"## Header ![alt](https://link.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(&first[0], "## Header ![alt](https://link.com)"); + assert_eq!(&first["level"], "##"); + assert_eq!(&first["title"], "Header ![alt](https://link.com)"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/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"); + } +} 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..02825949 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/pre.rs @@ -0,0 +1,93 @@ +use gtk::{ + TextBuffer, TextTag, + WrapMode::Word, + gdk::RGBA, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; + +const REGEX_PRE: &str = r"`(?P<text>[^`]+)`"; +const TAG_FONT: &str = "monospace"; // @TODO +const TAG_SCALE: f64 = 0.9; + +pub struct Pre(TextTag); + +impl Pre { + pub fn new() -> Self { + Self(if adw::StyleManager::default().is_dark() { + TextTag::builder() + .background_rgba(&RGBA::new(255., 255., 255., 0.05)) + .family(TAG_FONT) + .foreground("#e8e8e8") + .scale(TAG_SCALE) + .wrap_mode(Word) + .build() + } else { + TextTag::builder() + .background_rgba(&RGBA::new(0., 0., 0., 0.06)) + .family(TAG_FONT) + .scale(TAG_SCALE) + .wrap_mode(Word) + .build() + }) + } + + /// Apply preformatted `Tag` to given `TextBuffer` + pub fn render(&self, buffer: &TextBuffer) { + assert!(buffer.tag_table().add(&self.0)); + + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_PRE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + buffer.insert_with_tags(&mut start_iter, &cap["text"], &[&self.0]) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some `pre 1` and `pre 2` with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_PRE).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!(result, "Some pre 1 and pre 2 with ![img](https://link.com)") +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_PRE) + .unwrap() + .captures_iter(r"Some `pre 1` and `pre 2` with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["text"], "pre 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "pre 2"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/quote.rs new file mode 100644 index 00000000..0c932189 --- /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.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 new file mode 100644 index 00000000..8b7f534d --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/reference.rs @@ -0,0 +1,308 @@ +use gtk::{ + TextBuffer, TextIter, TextTag, WrapMode, + gdk::RGBA, + glib::{Uri, UriFlags}, + prelude::{TextBufferExt, TextBufferExtManual}, +}; +use regex::Regex; +use std::collections::HashMap; + +const REGEX_LINK: &str = r"\[(?P<text>[^\]]*)\]\((?P<url>[^\)]+)\)"; +const REGEX_IMAGE: &str = r"!\[(?P<alt>[^\]]*)\]\((?P<url>[^\)]+)\)"; +const REGEX_IMAGE_LINK: &str = + r"\[(?P<is_img>!)\[(?P<alt>[^\]]*)\]\((?P<img_url>[^\)]+)\)\]\((?P<link_url>[^\)]+)\)"; + +struct Reference { + uri: Uri, + alt: String, +} + +impl Reference { + /// Try construct new `Self` with given options + fn parse(address: &str, alt: Option<&str>, base: &Uri) -> Option<Self> { + // Convert address to the valid URI, + // resolve to absolute URL format if the target is relative + match Uri::resolve_relative( + Some(&base.to_string()), + // Relative scheme patch + // https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + &match address.strip_prefix("//") { + Some(p) => { + let s = p.trim_start_matches(":"); + format!( + "{}://{}", + base.scheme(), + if s.is_empty() { + format!("{}/", base.host().unwrap_or_default()) + } else { + s.into() + } + ) + } + None => address.into(), + }, + UriFlags::NONE, + ) { + Ok(ref url) => match Uri::parse(url, UriFlags::NONE) { + Ok(uri) => { + let mut a: Vec<&str> = Vec::with_capacity(2); + if uri.scheme() != base.scheme() { + a.push("⇖"); + } + match alt { + Some(text) => a.push(text), + None => a.push(url), + } + Some(Self { + uri, + alt: a.join(" "), + }) + } + Err(_) => todo!(), + }, + Err(_) => None, + } + } + + /// Insert `Self` into the given `TextBuffer` by registering new `TextTag` created + fn into_buffer( + self, + buffer: &TextBuffer, + position: &mut TextIter, + link_color: &RGBA, + is_annotation: bool, + links: &mut HashMap<TextTag, Uri>, + ) { + let a = if is_annotation { + buffer.insert_with_tags(position, " ", &[]); + TextTag::builder() + .foreground_rgba(link_color) + // .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) + // @TODO adw 1.6 / ubuntu 24.10+ + .pixels_above_lines(4) + .pixels_below_lines(4) + .rise(5000) + .scale(0.8) + .wrap_mode(WrapMode::Word) + .build() + } else { + TextTag::builder() + .foreground_rgba(link_color) + // .foreground_rgba(&adw::StyleManager::default().accent_color_rgba()) + // @TODO adw 1.6 / ubuntu 24.10+ + .sentence(true) + .wrap_mode(WrapMode::Word) + .build() + }; + assert!(buffer.tag_table().add(&a)); + buffer.insert_with_tags(position, &self.alt, &[&a]); + links.insert(a, self.uri); + } +} + +/// Image links `[![]()]()` +pub fn render_images_links( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_IMAGE_LINK) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(this) = Reference::parse( + &cap["img_url"], + if cap["alt"].is_empty() { + None + } else { + Some(&cap["alt"]) + }, + base, + ) { + this.into_buffer(buffer, &mut start_iter, link_color, false, links) + } + if let Some(this) = Reference::parse(&cap["link_url"], Some("1"), base) { + this.into_buffer(buffer, &mut start_iter, link_color, true, links) + } + } +} +/// Image tags `![]()` +pub fn render_images( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_IMAGE) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(this) = Reference::parse( + &cap["url"], + if cap["alt"].is_empty() { + None + } else { + Some(&cap["alt"]) + }, + base, + ) { + this.into_buffer(buffer, &mut start_iter, link_color, false, links) + } + } +} +/// Links `[]()` +pub fn render_links( + buffer: &TextBuffer, + base: &Uri, + link_color: &RGBA, + links: &mut HashMap<TextTag, Uri>, +) { + let (start, end) = buffer.bounds(); + let full_content = buffer.text(&start, &end, true).to_string(); + + let matches: Vec<_> = Regex::new(REGEX_LINK) + .unwrap() + .captures_iter(&full_content) + .collect(); + + for cap in matches.into_iter().rev() { + let full_match = cap.get(0).unwrap(); + + let start_char_offset = full_content[..full_match.start()].chars().count() as i32; + let end_char_offset = full_content[..full_match.end()].chars().count() as i32; + + let mut start_iter = buffer.iter_at_offset(start_char_offset); + let mut end_iter = buffer.iter_at_offset(end_char_offset); + + buffer.delete(&mut start_iter, &mut end_iter); + + if let Some(this) = Reference::parse( + &cap["url"], + if cap["text"].is_empty() { + None + } else { + Some(&cap["text"]) + }, + base, + ) { + this.into_buffer(buffer, &mut start_iter, link_color, false, links) + } + } +} + +pub fn strip_tags(value: &str) -> String { + let mut result = String::from(value); + for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(value) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some text [link1](https://link1.com) [link2](https://link2.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_LINK).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!(result, "Some text link1 link2") +} + +#[test] +fn test_regex_link() { + let cap: Vec<_> = Regex::new(REGEX_LINK) + .unwrap() + .captures_iter(r"[link1](https://link1.com) [link2](https://link2.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(&first[0], "[link1](https://link1.com)"); + assert_eq!(&first["text"], "link1"); + assert_eq!(&first["url"], "https://link1.com"); + + let second = cap.get(1).unwrap(); + assert_eq!(&second[0], "[link2](https://link2.com)"); + assert_eq!(&second["text"], "link2"); + assert_eq!(&second["url"], "https://link2.com"); +} + +#[test] +fn test_regex_image_link() { + let cap: Vec<_> = Regex::new( + REGEX_IMAGE_LINK, + ) + .unwrap().captures_iter( + r"[![image1](https://image1.com)](https://image2.com) [![image3](https://image3.com)](https://image4.com)" + ).collect(); + + let first = cap.first().unwrap(); + assert_eq!( + &first[0], + "[![image1](https://image1.com)](https://image2.com)" + ); + assert_eq!(&first["alt"], "image1"); + assert_eq!(&first["img_url"], "https://image1.com"); + assert_eq!(&first["link_url"], "https://image2.com"); + + let second = cap.get(1).unwrap(); + assert_eq!( + &second[0], + "[![image3](https://image3.com)](https://image4.com)" + ); + assert_eq!(&second["alt"], "image3"); + assert_eq!(&second["img_url"], "https://image3.com"); + assert_eq!(&second["link_url"], "https://image4.com"); +} + +#[test] +fn test_regex_image() { + let cap: Vec<_> = Regex::new(REGEX_IMAGE) + .unwrap() + .captures_iter(r"![image1](https://image1.com) ![image2](https://image2.com)") + .collect(); + + let first = cap.first().unwrap(); + assert_eq!(&first[0], "![image1](https://image1.com)"); + assert_eq!(&first["alt"], "image1"); + assert_eq!(&first["url"], "https://image1.com"); + + let second = cap.get(1).unwrap(); + assert_eq!(&second[0], "![image2](https://image2.com)"); + assert_eq!(&second["alt"], "image2"); + assert_eq!(&second["url"], "https://image2.com"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs new file mode 100644 index 00000000..02945a4c --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/strike.rs @@ -0,0 +1,83 @@ +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) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_STRIKE).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!( + result, + "Some strike 1 and strike 2 with ![img](https://link.com)" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_STRIKE) + .unwrap() + .captures_iter(r"Some ~~strike 1~~ and ~~strike 2~~ with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["text"], "strike 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "strike 2"); +} diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs new file mode 100644 index 00000000..b2c41c86 --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/underline.rs @@ -0,0 +1,79 @@ +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) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + result +} + +#[test] +fn test_strip_tags() { + const VALUE: &str = r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)"; + let mut result = String::from(VALUE); + for cap in Regex::new(REGEX_UNDERLINE).unwrap().captures_iter(VALUE) { + if let Some(m) = cap.get(0) { + result = result.replace(m.as_str(), &cap["text"]); + } + } + assert_eq!( + result, + "Some underline 1 and underline 2 with ![img](https://link.com)" + ) +} + +#[test] +fn test_regex() { + let cap: Vec<_> = Regex::new(REGEX_UNDERLINE) + .unwrap() + .captures_iter(r"Some _underline 1_ and _underline 2_ with ![img](https://link.com)") + .collect(); + + assert_eq!(&cap.first().unwrap()["text"], "underline 1"); + assert_eq!(&cap.get(1).unwrap()["text"], "underline 2"); +}