implement copy link text, selected text, add link to the bookmarks (context menu) items; group menu items

This commit is contained in:
yggverse 2026-03-11 01:41:48 +02:00
parent c95cb6e756
commit bb08b7cb9a
7 changed files with 295 additions and 77 deletions

View file

@ -21,7 +21,7 @@ impl Text {
.info
.borrow_mut()
.set_mime(Some("text/gemini".to_string()));
page.content.to_text_gemini(uri, data)
page.content.to_text_gemini(&page.profile, uri, data)
}),
Self::Markdown(uri, data) => (uri, {
page.navigation
@ -29,7 +29,7 @@ impl Text {
.info
.borrow_mut()
.set_mime(Some("text/markdown".to_string()));
page.content.to_text_markdown(uri, data)
page.content.to_text_markdown(&page.profile, uri, data)
}),
Self::Plain(uri, data) => (uri, page.content.to_text_plain(data)),
Self::Source(uri, data) => (uri, page.content.to_text_source(data)),

View file

@ -357,8 +357,8 @@ fn handle(
page.content.to_text_source(data)
} else {
match m.as_str() {
"text/gemini" => page.content.to_text_gemini(&uri, data),
"text/markdown" => page.content.to_text_markdown(&uri, data),
"text/gemini" => page.content.to_text_gemini(&page.profile, &uri, data),
"text/markdown" => page.content.to_text_markdown(&page.profile, &uri, data),
"text/plain" => page.content.to_text_plain(data),
_ => panic!() // unexpected
}

View file

@ -299,7 +299,7 @@ fn render(
} else if q.ends_with("/") {
p.content.to_text_nex(&u, d)
} else if q.ends_with(".gmi") || q.ends_with(".gemini") {
p.content.to_text_gemini(&u, d)
p.content.to_text_gemini(&p.profile, &u, d)
} else {
p.content.to_text_plain(d)
};

View file

@ -7,6 +7,8 @@ use directory::Directory;
use image::Image;
use text::Text;
use crate::profile::Profile;
use super::{ItemAction, TabAction, WindowAction};
use adw::StatusPage;
use gtk::{
@ -126,9 +128,14 @@ impl Content {
}
/// `text/gemini`
pub fn to_text_gemini(&self, base: &Uri, data: &str) -> Text {
pub fn to_text_gemini(&self, profile: &Rc<Profile>, base: &Uri, data: &str) -> Text {
self.clean();
match Text::gemini((&self.window_action, &self.item_action), base, data) {
match Text::gemini(
(&self.window_action, &self.item_action),
profile,
base,
data,
) {
Ok(text) => {
self.g_box.append(&text.scrolled_window);
text
@ -155,9 +162,14 @@ impl Content {
}
/// `text/markdown`
pub fn to_text_markdown(&self, base: &Uri, data: &str) -> Text {
pub fn to_text_markdown(&self, profile: &Rc<Profile>, base: &Uri, data: &str) -> Text {
self.clean();
let m = Text::markdown((&self.window_action, &self.item_action), base, data);
let m = Text::markdown(
(&self.window_action, &self.item_action),
profile,
base,
data,
);
self.g_box.append(&m.scrolled_window);
m
}

View file

@ -4,6 +4,8 @@ mod nex;
mod plain;
mod source;
use crate::profile::Profile;
use super::{ItemAction, WindowAction};
use adw::ClampScrollable;
use gemini::Gemini;
@ -27,10 +29,11 @@ pub struct Text {
impl Text {
pub fn gemini(
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri,
gemtext: &str,
) -> Result<Self, (String, Option<Self>)> {
match Gemini::build(actions, base, gemtext) {
match Gemini::build(actions, profile, base, gemtext) {
Ok(widget) => Ok(Self {
scrolled_window: reader(&widget.text_view),
text_view: widget.text_view,
@ -55,10 +58,11 @@ impl Text {
pub fn markdown(
actions: (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri,
gemtext: &str,
) -> Self {
let markdown = Markdown::build(actions, base, gemtext);
let markdown = Markdown::build(actions, profile, base, gemtext);
Self {
scrolled_window: reader(&markdown.text_view),
text_view: markdown.text_view,

View file

@ -6,13 +6,13 @@ mod syntax;
mod tag;
use super::{ItemAction, WindowAction};
use crate::app::browser::window::action::Position;
use crate::{app::browser::window::action::Position, profile::Profile};
pub use error::Error;
use gtk::{
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
UriLauncher, Window, WrapMode,
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA},
gio::{Cancellable, SimpleAction, SimpleActionGroup},
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
glib::{Uri, uuid_string_random},
prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
};
@ -36,6 +36,7 @@ impl Gemini {
/// Build new `Self`
pub fn build(
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri,
gemtext: &str,
) -> Result<Self, Error> {
@ -220,7 +221,7 @@ impl Gemini {
let mut alt = Vec::with_capacity(2);
if uri.scheme() != base.scheme() {
alt.push("".to_string());
alt.push(LINK_EXTERNAL_INDICATOR.to_string());
}
alt.push(match link.alt {
@ -235,9 +236,7 @@ impl Gemini {
.wrap_mode(WrapMode::Word)
.build();
if !tag.text_tag_table.add(&a) {
panic!()
}
assert!(tag.text_tag_table.add(&a));
buffer.insert_with_tags(&mut buffer.end_iter(), &alt.join(" "), &[&a]);
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
@ -296,14 +295,39 @@ impl Gemini {
)
}
});
let action_link_copy =
let action_link_copy_url =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy.connect_activate(|this, _| {
gtk::gdk::Display::default()
action_link_copy_url.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text_selected =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text_selected.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_bookmark =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_bookmark.connect_activate({
let p = profile.clone();
move |this, _| {
let state = this.state().unwrap().get::<String>().unwrap();
p.bookmark.toggle(&state, None).unwrap();
}
});
let action_link_download =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_download.connect_activate({
@ -338,14 +362,17 @@ impl Gemini {
Some(&{
let g = SimpleActionGroup::new();
g.add_action(&action_link_tab);
g.add_action(&action_link_copy);
g.add_action(&action_link_copy_url);
g.add_action(&action_link_copy_text);
g.add_action(&action_link_copy_text_selected);
g.add_action(&action_link_bookmark);
g.add_action(&action_link_download);
g.add_action(&action_link_source);
g
}),
);
let link_context = gtk::PopoverMenu::from_model(Some(&{
let m = gtk::gio::Menu::new();
let m = Menu::new();
m.append(
Some("Open Link in New Tab"),
Some(&format!(
@ -353,27 +380,56 @@ impl Gemini {
action_link_tab.name()
)),
);
m.append(
Some("Copy Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy.name()
)),
);
m.append(
Some("Download Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_download.name()
)),
);
m.append(
Some("View Link as Source"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_source.name()
)),
);
m.append_section(None, &{
let m_copy = Menu::new();
m_copy.append(
Some("Copy Link URL"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_url.name()
)),
);
m_copy.append(
Some("Copy Link Text"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text.name()
)),
);
m_copy.append(
Some("Copy Link Text Selected"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text_selected.name()
)),
);
m_copy
});
m.append_section(None, &{
let m_other = Menu::new();
m_other.append(
Some("Bookmark Link"), // @TODO highlight state
Some(&format!(
"{link_context_group_id}.{}",
action_link_bookmark.name()
)),
);
m_other.append(
Some("Download Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_download.name()
)),
);
m_other.append(
Some("View Link as Source"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_source.name()
)),
);
m_other
});
m
}));
link_context.set_parent(&text_view);
@ -435,18 +491,61 @@ impl Gemini {
let request_str = uri.to_str();
let request_var = request_str.to_variant();
// Open in the new tab
action_link_tab.set_state(&request_var);
action_link_copy.set_state(&request_var);
action_link_copy_text.set_enabled(!request_str.is_empty());
action_link_copy_url.set_state(&request_var);
action_link_copy_text.set_enabled(!request_str.is_empty());
{
// Copy link text
let mut start_iter = iter;
let mut end_iter = iter;
if !start_iter.starts_tag(Some(&tag)) {
start_iter.backward_to_tag_toggle(Some(&tag));
}
if !end_iter.ends_tag(Some(&tag)) {
end_iter.forward_to_tag_toggle(Some(&tag));
}
let tagged_text = text_view
.buffer()
.text(&start_iter, &end_iter, false)
.replace(LINK_EXTERNAL_INDICATOR, "")
.trim()
.to_string();
action_link_copy_text.set_state(&tagged_text.to_variant());
action_link_copy_text.set_enabled(!tagged_text.is_empty());
}
// Copy link text (if) selected
if let Some((sel_start, sel_end)) = buffer.selection_bounds() {
let selected_tag_text = buffer.text(&sel_start, &sel_end, false);
action_link_copy_text_selected
.set_state(&selected_tag_text.to_variant());
action_link_copy_text_selected
.set_enabled(!selected_tag_text.is_empty());
} else {
action_link_copy_text_selected.set_enabled(false);
}
// Bookmark
action_link_bookmark.set_state(&request_var);
action_link_bookmark.set_enabled(is_prefixable_link(&request_str));
// Download (new tab)
action_link_download.set_state(&request_var);
action_link_download.set_enabled(is_prefixable_link(&request_str));
// View as Source (new tab)
action_link_source.set_state(&request_var);
action_link_source.set_enabled(is_prefixable_link(&request_str));
// Toggle
link_context
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
link_context.popup();
link_context.popup()
}
}
}
@ -580,5 +679,6 @@ fn link_prefix(request: String, prefix: &str) -> String {
format!("{prefix}{}", request.trim_start_matches(prefix))
}
const LINK_EXTERNAL_INDICATOR: &str = "";
const LINK_PREFIX_DOWNLOAD: &str = "download:";
const LINK_PREFIX_SOURCE: &str = "source:";

View file

@ -2,12 +2,12 @@ mod gutter;
mod tags;
use super::{ItemAction, WindowAction};
use crate::app::browser::window::action::Position;
use crate::{app::browser::window::action::Position, profile::Profile};
use gtk::{
EventControllerMotion, GestureClick, TextBuffer, TextSearchFlags, TextTag, TextTagTable,
TextView, TextWindowType, UriLauncher, Window, WrapMode,
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA},
gio::{Cancellable, SimpleAction, SimpleActionGroup},
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, Display, RGBA},
gio::{Cancellable, Menu, SimpleAction, SimpleActionGroup},
glib::{ControlFlow, GString, Uri, idle_add_local, uri_unescape_string, uuid_string_random},
prelude::{PopoverExt, TextBufferExt, TextTagExt, TextViewExt, WidgetExt},
};
@ -27,6 +27,7 @@ impl Markdown {
/// Build new `Self`
pub fn build(
(window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>),
profile: &Rc<Profile>,
base: &Uri,
markdown: &str,
) -> Self {
@ -88,14 +89,39 @@ impl Markdown {
)
}
});
let action_link_copy =
let action_link_copy_url =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy.connect_activate(|this, _| {
gtk::gdk::Display::default()
action_link_copy_url.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_copy_text_selected =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_copy_text_selected.connect_activate(|this, _| {
Display::default()
.unwrap()
.clipboard()
.set_text(&this.state().unwrap().get::<String>().unwrap())
});
let action_link_bookmark =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_bookmark.connect_activate({
let p = profile.clone();
move |this, _| {
let state = this.state().unwrap().get::<String>().unwrap();
p.bookmark.toggle(&state, None).unwrap();
}
});
let action_link_download =
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
action_link_download.connect_activate({
@ -130,14 +156,17 @@ impl Markdown {
Some(&{
let g = SimpleActionGroup::new();
g.add_action(&action_link_tab);
g.add_action(&action_link_copy);
g.add_action(&action_link_copy_url);
g.add_action(&action_link_copy_text);
g.add_action(&action_link_copy_text_selected);
g.add_action(&action_link_bookmark);
g.add_action(&action_link_download);
g.add_action(&action_link_source);
g
}),
);
let link_context = gtk::PopoverMenu::from_model(Some(&{
let m = gtk::gio::Menu::new();
let m = Menu::new();
m.append(
Some("Open Link in New Tab"),
Some(&format!(
@ -145,27 +174,56 @@ impl Markdown {
action_link_tab.name()
)),
);
m.append(
Some("Copy Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy.name()
)),
);
m.append(
Some("Download Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_download.name()
)),
);
m.append(
Some("View Link as Source"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_source.name()
)),
);
m.append_section(None, &{
let m_copy = Menu::new();
m_copy.append(
Some("Copy Link URL"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_url.name()
)),
);
m_copy.append(
Some("Copy Link Text"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text.name()
)),
);
m_copy.append(
Some("Copy Link Text Selected"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_copy_text_selected.name()
)),
);
m_copy
});
m.append_section(None, &{
let m_other = Menu::new();
m_other.append(
Some("Bookmark Link"), // @TODO highlight state
Some(&format!(
"{link_context_group_id}.{}",
action_link_bookmark.name()
)),
);
m_other.append(
Some("Download Link"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_download.name()
)),
);
m_other.append(
Some("View Link as Source"),
Some(&format!(
"{link_context_group_id}.{}",
action_link_source.name()
)),
);
m_other
});
m
}));
link_context.set_parent(&text_view);
@ -231,18 +289,61 @@ impl Markdown {
let request_str = uri.to_str();
let request_var = request_str.to_variant();
// Open in the new tab
action_link_tab.set_state(&request_var);
action_link_copy.set_state(&request_var);
action_link_copy_text.set_enabled(!request_str.is_empty());
action_link_copy_url.set_state(&request_var);
action_link_copy_text.set_enabled(!request_str.is_empty());
{
// Copy link text
let mut start_iter = iter;
let mut end_iter = iter;
if !start_iter.starts_tag(Some(&tag)) {
start_iter.backward_to_tag_toggle(Some(&tag));
}
if !end_iter.ends_tag(Some(&tag)) {
end_iter.forward_to_tag_toggle(Some(&tag));
}
let tagged_text = text_view
.buffer()
.text(&start_iter, &end_iter, false)
.replace(LINK_EXTERNAL_INDICATOR, "")
.trim()
.to_string();
action_link_copy_text.set_state(&tagged_text.to_variant());
action_link_copy_text.set_enabled(!tagged_text.is_empty());
}
// Copy link text (if) selected
if let Some((sel_start, sel_end)) = buffer.selection_bounds() {
let selected_tag_text = buffer.text(&sel_start, &sel_end, false);
action_link_copy_text_selected
.set_state(&selected_tag_text.to_variant());
action_link_copy_text_selected
.set_enabled(!selected_tag_text.is_empty());
} else {
action_link_copy_text_selected.set_enabled(false);
}
// Bookmark
action_link_bookmark.set_state(&request_var);
action_link_bookmark.set_enabled(is_prefixable_link(&request_str));
// Download (new tab)
action_link_download.set_state(&request_var);
action_link_download.set_enabled(is_prefixable_link(&request_str));
// View as Source (new tab)
action_link_source.set_state(&request_var);
action_link_source.set_enabled(is_prefixable_link(&request_str));
// Toggle
link_context
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
link_context.popup();
link_context.popup()
}
}
}
@ -405,5 +506,6 @@ fn link_prefix(request: String, prefix: &str) -> String {
format!("{prefix}{}", request.trim_start_matches(prefix))
}
const LINK_EXTERNAL_INDICATOR: &str = "";
const LINK_PREFIX_DOWNLOAD: &str = "download:";
const LINK_PREFIX_SOURCE: &str = "source:";