Skip to content

Commit 1d6cfb8

Browse files
author
yggverse
committed
implement context menu for the gemtext viewer link tags
1 parent 0357edc commit 1d6cfb8

1 file changed

Lines changed: 202 additions & 52 deletions

File tree

  • src/app/browser/window/tab/item/page/content/text

src/app/browser/window/tab/item/page/content/text/gemini.rs

Lines changed: 202 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,23 @@ mod icon;
55
mod syntax;
66
mod tag;
77

8-
pub use error::Error;
9-
use gutter::Gutter;
10-
use icon::Icon;
11-
use syntax::Syntax;
12-
use tag::Tag;
13-
148
use super::{ItemAction, WindowAction};
159
use crate::app::browser::window::action::Position;
10+
pub use error::Error;
1611
use gtk::{
1712
EventControllerMotion, GestureClick, TextBuffer, TextTag, TextView, TextWindowType,
1813
UriLauncher, Window, WrapMode,
19-
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, RGBA},
20-
gio::Cancellable,
21-
glib::Uri,
22-
prelude::{TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
14+
gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY, BUTTON_SECONDARY, RGBA},
15+
gio::{Cancellable, SimpleAction, SimpleActionGroup},
16+
glib::{Uri, uuid_string_random},
17+
prelude::{PopoverExt, TextBufferExt, TextBufferExtManual, TextTagExt, TextViewExt, WidgetExt},
2318
};
19+
use gutter::Gutter;
20+
use icon::Icon;
21+
use sourceview::prelude::{ActionExt, ActionMapExt, DisplayExt, ToVariant};
2422
use std::{cell::Cell, collections::HashMap, rc::Rc};
23+
use syntax::Syntax;
24+
use tag::Tag;
2525

2626
pub const NEW_LINE: &str = "\n";
2727

@@ -284,14 +284,113 @@ impl Gemini {
284284
buffer.insert(&mut buffer.end_iter(), NEW_LINE);
285285
}
286286

287+
// Context menu
288+
let action_link_tab =
289+
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
290+
action_link_tab.connect_activate({
291+
let window_action = window_action.clone();
292+
move |this, _| {
293+
open_link_in_new_tab(
294+
&this.state().unwrap().get::<String>().unwrap(),
295+
&window_action,
296+
)
297+
}
298+
});
299+
let action_link_copy =
300+
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
301+
action_link_copy.connect_activate(|this, _| {
302+
gtk::gdk::Display::default()
303+
.unwrap()
304+
.clipboard()
305+
.set_text(&this.state().unwrap().get::<String>().unwrap())
306+
});
307+
let action_link_download =
308+
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
309+
action_link_download.connect_activate({
310+
let window_action = window_action.clone();
311+
move |this, _| {
312+
open_link_in_new_tab(
313+
&link_prefix(
314+
this.state().unwrap().get::<String>().unwrap(),
315+
LINK_PREFIX_DOWNLOAD,
316+
),
317+
&window_action,
318+
)
319+
}
320+
});
321+
let action_link_source =
322+
SimpleAction::new_stateful(&uuid_string_random(), None, &String::new().to_variant());
323+
action_link_source.connect_activate({
324+
let window_action = window_action.clone();
325+
move |this, _| {
326+
open_link_in_new_tab(
327+
&link_prefix(
328+
this.state().unwrap().get::<String>().unwrap(),
329+
LINK_PREFIX_SOURCE,
330+
),
331+
&window_action,
332+
)
333+
}
334+
});
335+
let link_context_group_id = uuid_string_random();
336+
text_view.insert_action_group(
337+
&link_context_group_id,
338+
Some(&{
339+
let g = SimpleActionGroup::new();
340+
g.add_action(&action_link_tab);
341+
g.add_action(&action_link_copy);
342+
g.add_action(&action_link_download);
343+
g.add_action(&action_link_source);
344+
g
345+
}),
346+
);
347+
let link_context = gtk::PopoverMenu::from_model(Some(&{
348+
let m = gtk::gio::Menu::new();
349+
m.append(
350+
Some("Open Link in New Tab"),
351+
Some(&format!(
352+
"{link_context_group_id}.{}",
353+
action_link_tab.name()
354+
)),
355+
);
356+
m.append(
357+
Some("Copy Link"),
358+
Some(&format!(
359+
"{link_context_group_id}.{}",
360+
action_link_copy.name()
361+
)),
362+
);
363+
m.append(
364+
Some("Download Link"),
365+
Some(&format!(
366+
"{link_context_group_id}.{}",
367+
action_link_download.name()
368+
)),
369+
);
370+
m.append(
371+
Some("View Link as Source"),
372+
Some(&format!(
373+
"{link_context_group_id}.{}",
374+
action_link_source.name()
375+
)),
376+
);
377+
m
378+
}));
379+
link_context.set_parent(&text_view);
380+
287381
// Init additional controllers
288-
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
289382
let middle_button_controller = GestureClick::builder().button(BUTTON_MIDDLE).build();
383+
let primary_button_controller = GestureClick::builder().button(BUTTON_PRIMARY).build();
384+
let secondary_button_controller = GestureClick::builder()
385+
.button(BUTTON_SECONDARY)
386+
.propagation_phase(gtk::PropagationPhase::Capture)
387+
.build();
290388
let motion_controller = EventControllerMotion::new();
291389

292-
text_view.add_controller(primary_button_controller.clone());
293390
text_view.add_controller(middle_button_controller.clone());
294391
text_view.add_controller(motion_controller.clone());
392+
text_view.add_controller(primary_button_controller.clone());
393+
text_view.add_controller(secondary_button_controller.clone());
295394

296395
// Init shared reference container for HashTable collected
297396
let links = Rc::new(links);
@@ -308,27 +407,46 @@ impl Gemini {
308407
window_x as i32,
309408
window_y as i32,
310409
);
410+
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
411+
for tag in iter.tags() {
412+
// Tag is link
413+
if let Some(uri) = links.get(&tag) {
414+
return open_link_in_current_tab(&uri.to_string(), &item_action);
415+
}
416+
}
417+
}
418+
}
419+
});
311420

421+
secondary_button_controller.connect_pressed({
422+
let links = links.clone();
423+
let text_view = text_view.clone();
424+
let link_context = link_context.clone();
425+
move |_, _, window_x, window_y| {
426+
let x = window_x as i32;
427+
let y = window_y as i32;
428+
// Detect tag match current coords hovered
429+
let (buffer_x, buffer_y) =
430+
text_view.window_to_buffer_coords(TextWindowType::Widget, x, y);
312431
if let Some(iter) = text_view.iter_at_location(buffer_x, buffer_y) {
313432
for tag in iter.tags() {
314433
// Tag is link
315434
if let Some(uri) = links.get(&tag) {
316-
// Select link handler by scheme
317-
return match uri.scheme().as_str() {
318-
"gemini" | "titan" | "nex" | "file" => {
319-
item_action.load.activate(Some(&uri.to_str()), true, false)
320-
}
321-
// Scheme not supported, delegate
322-
_ => UriLauncher::new(&uri.to_str()).launch(
323-
Window::NONE,
324-
Cancellable::NONE,
325-
|result| {
326-
if let Err(e) = result {
327-
println!("{e}")
328-
}
329-
},
330-
),
331-
}; // @TODO common handler?
435+
let request_str = uri.to_str();
436+
let request_var = request_str.to_variant();
437+
438+
action_link_tab.set_state(&request_var);
439+
action_link_copy.set_state(&request_var);
440+
441+
action_link_download.set_state(&request_var);
442+
action_link_download.set_enabled(is_prefixable_link(&request_str));
443+
444+
action_link_source.set_state(&request_var);
445+
action_link_source.set_enabled(is_prefixable_link(&request_str));
446+
447+
link_context
448+
.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x, y, 1, 1)));
449+
link_context.popup();
332450
}
333451
}
334452
}
@@ -350,30 +468,7 @@ impl Gemini {
350468
for tag in iter.tags() {
351469
// Tag is link
352470
if let Some(uri) = links.get(&tag) {
353-
// Select link handler by scheme
354-
return match uri.scheme().as_str() {
355-
"gemini" | "titan" | "nex" | "file" => {
356-
// Open new page in browser
357-
window_action.append.activate_stateful_once(
358-
Position::After,
359-
Some(uri.to_string()),
360-
false,
361-
false,
362-
true,
363-
true,
364-
);
365-
}
366-
// Scheme not supported, delegate
367-
_ => UriLauncher::new(&uri.to_str()).launch(
368-
Window::NONE,
369-
Cancellable::NONE,
370-
|result| {
371-
if let Err(e) = result {
372-
println!("{e}")
373-
}
374-
},
375-
),
376-
}; // @TODO common handler?
471+
return open_link_in_new_tab(&uri.to_string(), &window_action);
377472
}
378473
}
379474
}
@@ -432,3 +527,58 @@ impl Gemini {
432527
}
433528
}
434529
}
530+
531+
fn is_internal_link(request: &str) -> bool {
532+
// schemes
533+
request.starts_with("gemini://")
534+
|| request.starts_with("titan://")
535+
|| request.starts_with("nex://")
536+
|| request.starts_with("file://")
537+
// prefix
538+
|| request.starts_with("download:")
539+
|| request.starts_with("source:")
540+
}
541+
542+
fn is_prefixable_link(request: &str) -> bool {
543+
request.starts_with("gemini://")
544+
|| request.starts_with("nex://")
545+
|| request.starts_with("file://")
546+
}
547+
548+
fn open_link_in_external_app(request: &str) {
549+
UriLauncher::new(request).launch(Window::NONE, Cancellable::NONE, |r| {
550+
if let Err(e) = r {
551+
println!("{e}") // @TODO use warn macro
552+
}
553+
})
554+
}
555+
556+
fn open_link_in_current_tab(request: &str, item_action: &ItemAction) {
557+
if is_internal_link(request) {
558+
item_action.load.activate(Some(request), true, false)
559+
} else {
560+
open_link_in_external_app(request)
561+
}
562+
}
563+
564+
fn open_link_in_new_tab(request: &str, window_action: &WindowAction) {
565+
if is_internal_link(request) {
566+
window_action.append.activate_stateful_once(
567+
Position::After,
568+
Some(request.into()),
569+
false,
570+
false,
571+
true,
572+
true,
573+
);
574+
} else {
575+
open_link_in_external_app(request)
576+
}
577+
}
578+
579+
fn link_prefix(request: String, prefix: &str) -> String {
580+
format!("{prefix}{}", request.trim_start_matches(prefix))
581+
}
582+
583+
const LINK_PREFIX_DOWNLOAD: &str = "download:";
584+
const LINK_PREFIX_SOURCE: &str = "source:";

0 commit comments

Comments
 (0)