From a1d9c080d16d31d80d4d03ff80a2b5f44f549d81 Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 11 Mar 2026 03:31:52 +0200 Subject: [PATCH] implement context menu for the header tags (including fragment URL copy) --- .../tab/item/page/content/text/markdown.rs | 143 +++++++++++++++--- .../item/page/content/text/markdown/tags.rs | 3 +- .../page/content/text/markdown/tags/header.rs | 57 +++++-- 3 files changed, 171 insertions(+), 32 deletions(-) diff --git a/src/app/browser/window/tab/item/page/content/text/markdown.rs b/src/app/browser/window/tab/item/page/content/text/markdown.rs index 39e8e0a1..d059c605 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown.rs @@ -4,8 +4,8 @@ mod tags; use super::{ItemAction, WindowAction}; use crate::{app::browser::window::action::Position, profile::Profile}; use gtk::{ - EventControllerMotion, GestureClick, TextBuffer, TextSearchFlags, TextTag, TextTagTable, - TextView, TextWindowType, UriLauncher, Window, WrapMode, + EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextSearchFlags, TextTag, + TextTagTable, TextView, TextWindowType, UriLauncher, Window, WrapMode, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA}, gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup}, glib::{ControlFlow, GString, Uri, idle_add_local, uri_unescape_string, uuid_string_random}, @@ -33,6 +33,7 @@ impl Markdown { ) -> Self { // Init HashMap storage (for event controllers) let mut links: HashMap = HashMap::new(); + let mut headers: HashMap = HashMap::new(); // Init hovered tag storage for `links` // * maybe less expensive than update entire HashMap by iter @@ -75,9 +76,72 @@ impl Markdown { let gutter = Gutter::build(&text_view); // Render markdown tags - let title = tags.render(&buffer, base, &link_color.0, &mut links); + let title = tags.render(&buffer, base, &link_color.0, &mut links, &mut headers); - // Context menu + // Headers context menu (fragment capture) + let action_header_copy_url = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_header_copy_url.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_header_copy_text = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_header_copy_text.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let action_header_copy_text_selected = + SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); + action_header_copy_text_selected.connect_activate(|this, _| { + Display::default() + .unwrap() + .clipboard() + .set_text(&this.state().unwrap().get::().unwrap()) + }); + let header_context_group_id = uuid_string_random(); + text_view.insert_action_group( + &header_context_group_id, + Some(&{ + let g = SimpleActionGroup::new(); + g.add_action(&action_header_copy_url); + g.add_action(&action_header_copy_text); + g.add_action(&action_header_copy_text_selected); + g + }), + ); + let header_context = PopoverMenu::from_model(Some(&{ + let m = Menu::new(); + m.append( + Some("Copy Header Link"), + Some(&format!( + "{header_context_group_id}.{}", + action_header_copy_url.name() + )), + ); + m.append( + Some("Copy Header Text"), + Some(&format!( + "{header_context_group_id}.{}", + action_header_copy_text.name() + )), + ); + m.append( + Some("Copy Text Selected"), + Some(&format!( + "{header_context_group_id}.{}", + action_header_copy_text_selected.name() + )), + ); + m + })); + header_context.set_parent(&text_view); + + // Link context menu let action_link_tab = SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); action_link_tab.connect_activate({ @@ -165,7 +229,7 @@ impl Markdown { g }), ); - let link_context = gtk::PopoverMenu::from_model(Some(&{ + let link_context = PopoverMenu::from_model(Some(&{ let m = Menu::new(); m.append( Some("Open Link in New Tab"), @@ -191,7 +255,7 @@ impl Markdown { )), ); m_copy.append( - Some("Copy Link Text Selected"), + Some("Copy Text Selected"), Some(&format!( "{link_context_group_id}.{}", action_link_copy_text_selected.name() @@ -244,6 +308,7 @@ impl Markdown { // Init shared reference container for HashTable collected let links = Rc::new(links); + let headers = Rc::new(headers); // Init events primary_button_controller.connect_released({ @@ -274,6 +339,7 @@ impl Markdown { secondary_button_controller.connect_pressed({ let links = links.clone(); + let headers = headers.clone(); let text_view = text_view.clone(); let link_context = link_context.clone(); move |_, _, window_x, window_y| { @@ -288,16 +354,18 @@ impl Markdown { if let Some(uri) = links.get(&tag) { let request_str = uri.to_str(); let request_var = request_str.to_variant(); + let is_prefix_link = is_prefix_link(&request_str); // Open in the new tab action_link_tab.set_state(&request_var); - action_link_copy_text.set_enabled(!request_str.is_empty()); + action_link_tab.set_enabled(!request_str.is_empty()); + // Copy link to the clipboard action_link_copy_url.set_state(&request_var); - action_link_copy_text.set_enabled(!request_str.is_empty()); + action_link_copy_url.set_enabled(!request_str.is_empty()); + // Copy link text { - // Copy link text let mut start_iter = iter; let mut end_iter = iter; if !start_iter.starts_tag(Some(&tag)) { @@ -318,33 +386,64 @@ impl Markdown { } // Copy link text (if) selected - if let Some((sel_start, sel_end)) = buffer.selection_bounds() { - let selected_tag_text = buffer.text(&sel_start, &sel_end, false); - action_link_copy_text_selected - .set_state(&selected_tag_text.to_variant()); - action_link_copy_text_selected - .set_enabled(!selected_tag_text.is_empty()); - } else { - action_link_copy_text_selected.set_enabled(false); - } + action_link_copy_text_selected.set_enabled( + if let Some((start, end)) = buffer.selection_bounds() { + let selected = buffer.text(&start, &end, false); + action_link_copy_text_selected + .set_state(&selected.to_variant()); + !selected.is_empty() + } else { + false + }, + ); // Bookmark action_link_bookmark.set_state(&request_var); - action_link_bookmark.set_enabled(is_prefixable_link(&request_str)); + action_link_bookmark.set_enabled(is_prefix_link); // Download (new tab) action_link_download.set_state(&request_var); - action_link_download.set_enabled(is_prefixable_link(&request_str)); + action_link_download.set_enabled(is_prefix_link); // View as Source (new tab) action_link_source.set_state(&request_var); - action_link_source.set_enabled(is_prefixable_link(&request_str)); + action_link_source.set_enabled(is_prefix_link); // Toggle link_context .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); link_context.popup() } + // Tag is header + if let Some((title, uri)) = headers.get(&tag) { + let request_str = uri.to_str(); + let request_var = request_str.to_variant(); + + // Copy link to the clipboard + action_header_copy_url.set_state(&request_var); + action_header_copy_url.set_enabled(!request_str.is_empty()); + + // Copy header text + action_header_copy_text.set_state(&title.to_variant()); + action_header_copy_text.set_enabled(!title.is_empty()); + + // Copy header text (if) selected + action_header_copy_text_selected.set_enabled( + if let Some((start, end)) = buffer.selection_bounds() { + let selected = buffer.text(&start, &end, false); + action_header_copy_text_selected + .set_state(&selected.to_variant()); + !selected.is_empty() + } else { + false + }, + ); + + // Toggle + header_context + .set_pointing_to(Some(>k::gdk::Rectangle::new(x, y, 1, 1))); + header_context.popup() + } } } } @@ -465,7 +564,7 @@ fn is_internal_link(request: &str) -> bool { || request.starts_with("source:") } -fn is_prefixable_link(request: &str) -> bool { +fn is_prefix_link(request: &str) -> bool { request.starts_with("gemini://") || request.starts_with("nex://") || request.starts_with("file://") diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs index 503e6f9a..96b590c9 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags.rs @@ -53,13 +53,14 @@ impl Tags { base: &Uri, link_color: &RGBA, links: &mut HashMap, + headers: &mut HashMap, ) -> Option { // Collect all code blocks first, // and temporarily replace them with placeholder ID self.code.collect(buffer); // Keep in order! - let title = self.header.render(buffer); + let title = self.header.render(buffer, base, headers); list::render(buffer); diff --git a/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs index 35be5cb3..8a2e125e 100644 --- a/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs +++ b/src/app/browser/window/tab/item/page/content/text/markdown/tags/header.rs @@ -1,8 +1,10 @@ use gtk::{ TextBuffer, TextTag, WrapMode, + glib::Uri, prelude::{TextBufferExt, TextBufferExtManual}, }; use regex::Regex; +use std::collections::HashMap; const REGEX_HEADER: &str = r"(?m)^(?P#{1,6})\s+(?P.*)$"; @@ -71,7 +73,12 @@ impl Header { } /// Apply title `Tag` to given `TextBuffer` - pub fn render(&self, buffer: &TextBuffer) -> Option<String> { + pub fn render( + &self, + buffer: &TextBuffer, + base: &Uri, + headers: &mut HashMap<TextTag, (String, Uri)>, + ) -> Option<String> { let mut raw_title = None; let table = buffer.tag_table(); @@ -105,6 +112,7 @@ impl Header { let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset); + // Skip escaped entries if start_char_offset > 0 && buffer .text( @@ -117,19 +125,50 @@ impl Header { continue; } + // Create unique phantom tag for each header + // * it is required for context menu relationships + let h = TextTag::builder().build(); + assert!(table.add(&h)); + + // Render header in text buffer buffer.delete(&mut start_iter, &mut end_iter); match cap["level"].chars().count() { - 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h1]), - 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h2]), - 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h3]), - 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h4]), - 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h5]), - 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h6]), - _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]), + 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h1]), + 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h2]), + 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h3]), + 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h4]), + 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h5]), + 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h6]), + _ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h]), // unexpected } - } + // Register fragment reference + assert!( + headers + .insert( + h, + ( + cap["title"].into(), + Uri::build( + base.flags(), + &base.scheme(), + base.userinfo().as_deref(), + base.host().as_deref(), + base.port(), + &base.path(), + base.query().as_deref(), + Some(&Uri::escape_string( + &cap["title"].to_lowercase().replace(" ", "-"), + None, + true + )), + ) + ), + ) + .is_none() + ) + } raw_title } }