implement context menu for the header tags (including fragment URL copy)

This commit is contained in:
yggverse 2026-03-11 03:31:52 +02:00
parent 12edd5a4f4
commit a1d9c080d1
3 changed files with 171 additions and 32 deletions

View file

@ -4,8 +4,8 @@ mod tags;
use super::{ItemAction, WindowAction}; use super::{ItemAction, WindowAction};
use crate::{app::browser::window::action::Position, profile::Profile}; use crate::{app::browser::window::action::Position, profile::Profile};
use gtk::{ use gtk::{
EventControllerMotion, GestureClick, TextBuffer, TextSearchFlags, TextTag, TextTagTable, EventControllerMotion, GestureClick, PopoverMenu, TextBuffer, TextSearchFlags, TextTag,
TextView, TextWindowType, UriLauncher, Window, WrapMode, TextTagTable, TextView, TextWindowType, UriLauncher, Window, WrapMode,
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA}, gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup}, gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
glib::{ControlFlow, GString, Uri, idle_add_local, uri_unescape_string, uuid_string_random}, glib::{ControlFlow, GString, Uri, idle_add_local, uri_unescape_string, uuid_string_random},
@ -33,6 +33,7 @@ impl Markdown {
) -> Self { ) -> Self {
// Init HashMap storage (for event controllers) // Init HashMap storage (for event controllers)
let mut links: HashMap<TextTag, Uri> = HashMap::new(); let mut links: HashMap<TextTag, Uri> = HashMap::new();
let mut headers: HashMap<TextTag, (String, Uri)> = HashMap::new();
// Init hovered tag storage for `links` // Init hovered tag storage for `links`
// * maybe less expensive than update entire HashMap by iter // * maybe less expensive than update entire HashMap by iter
@ -75,9 +76,72 @@ impl Markdown {
let gutter = Gutter::build(&text_view); let gutter = Gutter::build(&text_view);
// Render markdown tags // Render markdown tags
let title = tags.render(&buffer, base, &link_color.0, &mut links); let title = tags.render(&buffer, base, &link_color.0, &mut links, &mut headers);
// Context menu // Headers context menu (fragment capture)
let action_header_copy_url =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_header_copy_url.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_header_copy_text =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_header_copy_text.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_header_copy_text_selected =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_header_copy_text_selected.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let header_context_group_id = uuid_string_random();
text_view.insert_action_group(
&header_context_group_id,
Some(&{
let g = SimpleActionGroup::new();
g.add_action(&action_header_copy_url);
g.add_action(&action_header_copy_text);
g.add_action(&action_header_copy_text_selected);
g
}),
);
let header_context = PopoverMenu::from_model(Some(&{
let m = Menu::new();
m.append(
Some("Copy Header Link"),
Some(&format!(
"{header_context_group_id}.{}",
action_header_copy_url.name()
)),
);
m.append(
Some("Copy Header Text"),
Some(&format!(
"{header_context_group_id}.{}",
action_header_copy_text.name()
)),
);
m.append(
Some("Copy Text Selected"),
Some(&format!(
"{header_context_group_id}.{}",
action_header_copy_text_selected.name()
)),
);
m
}));
header_context.set_parent(&text_view);
// Link context menu
let action_link_tab = let action_link_tab =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant()); SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_tab.connect_activate({ action_link_tab.connect_activate({
@ -165,7 +229,7 @@ impl Markdown {
g g
}), }),
); );
let link_context = gtk::PopoverMenu::from_model(Some(&{ let link_context = PopoverMenu::from_model(Some(&{
let m = Menu::new(); let m = Menu::new();
m.append( m.append(
Some("Open Link in New Tab"), Some("Open Link in New Tab"),
@ -191,7 +255,7 @@ impl Markdown {
)), )),
); );
m_copy.append( m_copy.append(
Some("Copy Link Text Selected"), Some("Copy Text Selected"),
Some(&format!( Some(&format!(
"{link_context_group_id}.{}", "{link_context_group_id}.{}",
action_link_copy_text_selected.name() action_link_copy_text_selected.name()
@ -244,6 +308,7 @@ impl Markdown {
// Init shared reference container for HashTable collected // Init shared reference container for HashTable collected
let links = Rc::new(links); let links = Rc::new(links);
let headers = Rc::new(headers);
// Init events // Init events
primary_button_controller.connect_released({ primary_button_controller.connect_released({
@ -274,6 +339,7 @@ impl Markdown {
secondary_button_controller.connect_pressed({ secondary_button_controller.connect_pressed({
let links = links.clone(); let links = links.clone();
let headers = headers.clone();
let text_view = text_view.clone(); let text_view = text_view.clone();
let link_context = link_context.clone(); let link_context = link_context.clone();
move |_, _, window_x, window_y| { move |_, _, window_x, window_y| {
@ -288,16 +354,18 @@ impl Markdown {
if let Some(uri) = links.get(&tag) { if let Some(uri) = links.get(&tag) {
let request_str = uri.to_str(); let request_str = uri.to_str();
let request_var = request_str.to_variant(); let request_var = request_str.to_variant();
let is_prefix_link = is_prefix_link(&request_str);
// Open in the new tab // Open in the new tab
action_link_tab.set_state(&request_var); 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_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 start_iter = iter;
let mut end_iter = iter; let mut end_iter = iter;
if !start_iter.starts_tag(Some(&tag)) { if !start_iter.starts_tag(Some(&tag)) {
@ -318,33 +386,64 @@ impl Markdown {
} }
// Copy link text (if) selected // Copy link text (if) selected
if let Some((sel_start, sel_end)) = buffer.selection_bounds() { action_link_copy_text_selected.set_enabled(
let selected_tag_text = buffer.text(&sel_start, &sel_end, false); if let Some((start, end)) = buffer.selection_bounds() {
action_link_copy_text_selected let selected = buffer.text(&start, &end, false);
.set_state(&selected_tag_text.to_variant()); action_link_copy_text_selected
action_link_copy_text_selected .set_state(&selected.to_variant());
.set_enabled(!selected_tag_text.is_empty()); !selected.is_empty()
} else { } else {
action_link_copy_text_selected.set_enabled(false); false
} },
);
// Bookmark // Bookmark
action_link_bookmark.set_state(&request_var); 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) // Download (new tab)
action_link_download.set_state(&request_var); 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) // View as Source (new tab)
action_link_source.set_state(&request_var); 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 // Toggle
link_context link_context
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1))); .set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
link_context.popup() 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(&gtk::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:") || 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("gemini://")
|| request.starts_with("nex://") || request.starts_with("nex://")
|| request.starts_with("file://") || request.starts_with("file://")

View file

@ -53,13 +53,14 @@ impl Tags {
base: &Uri, base: &Uri,
link_color: &RGBA, link_color: &RGBA,
links: &mut HashMap<TextTag, Uri>, links: &mut HashMap<TextTag, Uri>,
headers: &mut HashMap<TextTag, (String, Uri)>,
) -> Option<String> { ) -> Option<String> {
// Collect all code blocks first, // Collect all code blocks first,
// and temporarily replace them with placeholder ID // and temporarily replace them with placeholder ID
self.code.collect(buffer); self.code.collect(buffer);
// Keep in order! // Keep in order!
let title = self.header.render(buffer); let title = self.header.render(buffer, base, headers);
list::render(buffer); list::render(buffer);

View file

@ -1,8 +1,10 @@
use gtk::{ use gtk::{
TextBuffer, TextTag, WrapMode, TextBuffer, TextTag, WrapMode,
glib::Uri,
prelude::{TextBufferExt, TextBufferExtManual}, prelude::{TextBufferExt, TextBufferExtManual},
}; };
use regex::Regex; use regex::Regex;
use std::collections::HashMap;
const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$"; const REGEX_HEADER: &str = r"(?m)^(?P<level>#{1,6})\s+(?P<title>.*)$";
@ -71,7 +73,12 @@ impl Header {
} }
/// Apply title `Tag` to given `TextBuffer` /// 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 mut raw_title = None;
let table = buffer.tag_table(); let table = buffer.tag_table();
@ -105,6 +112,7 @@ impl Header {
let mut start_iter = buffer.iter_at_offset(start_char_offset); let mut start_iter = buffer.iter_at_offset(start_char_offset);
let mut end_iter = buffer.iter_at_offset(end_char_offset); let mut end_iter = buffer.iter_at_offset(end_char_offset);
// Skip escaped entries
if start_char_offset > 0 if start_char_offset > 0
&& buffer && buffer
.text( .text(
@ -117,19 +125,50 @@ impl Header {
continue; 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); buffer.delete(&mut start_iter, &mut end_iter);
match cap["level"].chars().count() { match cap["level"].chars().count() {
1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h1]), 1 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h1]),
2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h2]), 2 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h2]),
3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h3]), 3 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h3]),
4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h4]), 4 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h4]),
5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h5]), 5 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h5]),
6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&self.h6]), 6 => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[&h, &self.h6]),
_ => buffer.insert_with_tags(&mut start_iter, &cap["title"], &[]), _ => 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 raw_title
} }
} }