Add context menu for opening links

This commit is contained in:
Mattias Eriksson 2025-10-16 09:27:00 +02:00
parent 8c409f5be3
commit e7e47d5bac
6 changed files with 154 additions and 41 deletions

View file

@ -86,6 +86,9 @@ select-all = Select all
find = Find find = Find
clear-scrollback = Clear scrollback clear-scrollback = Clear scrollback
## Open
open-link = Open Link
## View ## View
view = View view = View
zoom-in = Larger text zoom-in = Larger text

View file

@ -94,6 +94,9 @@ paste = Klistra in
select-all = Välj alla select-all = Välj alla
find = Sök find = Sök
## Öppna
open-link = Öppna länk
## Visa ## Visa
view = Visa view = Visa

View file

@ -12,7 +12,7 @@ use cosmic::{
cosmic_config::{self, ConfigSet, CosmicConfigEntry}, cosmic_config::{self, ConfigSet, CosmicConfigEntry},
cosmic_theme, executor, cosmic_theme, executor,
iced::{ iced::{
self, Alignment, Color, Event, Length, Limits, Padding, Point, Subscription, self, Alignment, Color, Event, Length, Limits, Padding, Subscription,
advanced::graphics::text::font_system, advanced::graphics::text::font_system,
clipboard, event, clipboard, event,
futures::SinkExt, futures::SinkExt,
@ -63,6 +63,7 @@ mod terminal;
use terminal_box::terminal_box; use terminal_box::terminal_box;
use crate::dnd::DndDrop; use crate::dnd::DndDrop;
use crate::menu::MenuState;
mod terminal_box; mod terminal_box;
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
@ -225,6 +226,7 @@ pub enum Action {
CopyOrSigint, CopyOrSigint,
CopyPrimary, CopyPrimary,
Find, Find,
LaunchUrlByMenu,
PaneFocusDown, PaneFocusDown,
PaneFocusLeft, PaneFocusLeft,
PaneFocusRight, PaneFocusRight,
@ -275,6 +277,7 @@ impl Action {
Self::CopyOrSigint => Message::CopyOrSigint(entity_opt), Self::CopyOrSigint => Message::CopyOrSigint(entity_opt),
Self::CopyPrimary => Message::CopyPrimary(entity_opt), Self::CopyPrimary => Message::CopyPrimary(entity_opt),
Self::Find => Message::Find(true), Self::Find => Message::Find(true),
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down), Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right), Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
@ -359,6 +362,7 @@ pub enum Message {
FocusFollowMouse(bool), FocusFollowMouse(bool),
Key(Modifiers, Key), Key(Modifiers, Key),
LaunchUrl(String), LaunchUrl(String),
LaunchUrlByMenu,
Modifiers(Modifiers), Modifiers(Modifiers),
MouseEnter(pane_grid::Pane), MouseEnter(pane_grid::Pane),
Opacity(u8), Opacity(u8),
@ -396,7 +400,7 @@ pub enum Message {
TabActivateJump(usize), TabActivateJump(usize),
TabClose(Option<segmented_button::Entity>), TabClose(Option<segmented_button::Entity>),
TabContextAction(segmented_button::Entity, Action), TabContextAction(segmented_button::Entity, Action),
TabContextMenu(pane_grid::Pane, Option<Point>), TabContextMenu(pane_grid::Pane, MenuState),
TabNew, TabNew,
TabNewNoProfile, TabNewNoProfile,
TabNext, TabNext,
@ -2118,6 +2122,23 @@ impl Application for App {
log::warn!("failed to open {:?}: {}", url, err); log::warn!("failed to open {:?}: {}", url, err);
} }
} }
Message::LaunchUrlByMenu => {
if let Some(tab_model) = self.pane_model.active() {
let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
// Update context menu position
let mut terminal = terminal.lock().unwrap();
if let Some(url) =
terminal.context_menu.as_ref().and_then(|m| m.link.as_ref())
{
if let Err(err) = open::that_detached(url) {
log::warn!("failed to open {:?}: {}", url, err);
}
}
terminal.context_menu = None;
}
}
}
Message::Modifiers(modifiers) => { Message::Modifiers(modifiers) => {
self.modifiers = modifiers; self.modifiers = modifiers;
} }
@ -2413,14 +2434,25 @@ impl Application for App {
// Close context menu // Close context menu
{ {
let mut terminal = terminal.lock().unwrap(); let mut terminal = terminal.lock().unwrap();
//Some actions need the menu_state,
//so only clear the position for them.
match action {
Action::LaunchUrlByMenu => {
if let Some(context_menu) = terminal.context_menu.as_mut() {
context_menu.position = None;
}
}
_ => {
terminal.context_menu = None; terminal.context_menu = None;
} }
}
}
// Run action's message // Run action's message
return self.update(action.message(Some(entity))); return self.update(action.message(Some(entity)));
} }
} }
} }
Message::TabContextMenu(pane, position_opt) => { Message::TabContextMenu(pane, menu_state) => {
// Close any existing context menues // Close any existing context menues
let panes: Vec<_> = self.pane_model.panes.iter().collect(); let panes: Vec<_> = self.pane_model.panes.iter().collect();
for (_pane, tab_model) in panes { for (_pane, tab_model) in panes {
@ -2437,7 +2469,7 @@ impl Application for App {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
// Update context menu position // Update context menu position
let mut terminal = terminal.lock().unwrap(); let mut terminal = terminal.lock().unwrap();
terminal.context_menu = position_opt; terminal.context_menu = Some(menu_state);
} }
} }
@ -2797,9 +2829,7 @@ impl Application for App {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal_box = terminal_box(terminal) let mut terminal_box = terminal_box(terminal)
.id(terminal_id) .id(terminal_id)
.on_context_menu(move |position_opt| { .on_context_menu(move |menu_state| Message::TabContextMenu(pane, menu_state))
Message::TabContextMenu(pane, position_opt)
})
.on_middle_click(move || Message::MiddleClick(pane, Some(entity_middle_click))) .on_middle_click(move || Message::MiddleClick(pane, Some(entity_middle_click)))
.on_open_hyperlink(Some(Box::new(Message::LaunchUrl))) .on_open_hyperlink(Some(Box::new(Message::LaunchUrl)))
.on_window_focused(|| Message::WindowFocused) .on_window_focused(|| Message::WindowFocused)
@ -2815,15 +2845,23 @@ impl Application for App {
let context_menu = { let context_menu = {
let terminal = terminal.lock().unwrap(); let terminal = terminal.lock().unwrap();
terminal.context_menu terminal.context_menu.clone()
}; };
let tab_element: Element<'_, Message> = match context_menu { let tab_element: Element<'_, Message> = match context_menu {
Some(menu_state) => match menu_state.position {
Some(point) => widget::popover(terminal_box.context_menu(point)) Some(point) => widget::popover(terminal_box.context_menu(point))
.popup(menu::context_menu(&self.config, &self.key_binds, entity)) .popup(menu::context_menu(
&self.config,
&self.key_binds,
entity,
menu_state.link,
))
.position(widget::popover::Position::Point(point)) .position(widget::popover::Position::Point(point))
.into(), .into(),
None => terminal_box.into(), None => terminal_box.into(),
},
None => terminal_box.into(),
}; };
tab_column = tab_column.push(tab_element); tab_column = tab_column.push(tab_element);
} }

View file

@ -1,5 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
use cosmic::iced::Point;
use cosmic::widget::menu::key_bind::KeyBind; use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::menu::{Item as MenuItem, menu_button}; use cosmic::widget::menu::{Item as MenuItem, menu_button};
use cosmic::{ use cosmic::{
@ -25,10 +26,17 @@ use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl};
static MENU_ID: LazyLock<cosmic::widget::Id> = static MENU_ID: LazyLock<cosmic::widget::Id> =
LazyLock::new(|| cosmic::widget::Id::new("responsive-menu")); LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
#[derive(Debug, Clone)]
pub struct MenuState {
pub position: Option<Point>,
pub link: Option<String>,
}
pub fn context_menu<'a>( pub fn context_menu<'a>(
config: &Config, config: &Config,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
entity: segmented_button::Entity, entity: segmented_button::Entity,
link: Option<String>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let find_key = |action: &Action| -> String { let find_key = |action: &Action| -> String {
for (key_bind, key_action) in key_binds { for (key_bind, key_action) in key_binds {
@ -58,6 +66,21 @@ pub fn context_menu<'a>(
.on_press(Message::TabContextAction(entity, action)) .on_press(Message::TabContextAction(entity, action))
}; };
let menu_item_conditional = |label, action, condition| {
let key = find_key(&action);
let mut button = menu_button(vec![
widget::text(label).into(),
horizontal_space().into(),
widget::text(key)
.class(theme::Text::Custom(key_style))
.into(),
]);
if condition {
button = button.on_press(Message::TabContextAction(entity, action));
}
button
};
let menu_checkbox = |label, value, action| { let menu_checkbox = |label, value, action| {
menu_button(vec![ menu_button(vec![
widget::text(label).into(), widget::text(label).into(),
@ -75,6 +98,8 @@ pub fn context_menu<'a>(
menu_item(fl!("paste"), Action::Paste), menu_item(fl!("paste"), Action::Paste),
menu_item(fl!("select-all"), Action::SelectAll), menu_item(fl!("select-all"), Action::SelectAll),
divider::horizontal::light(), divider::horizontal::light(),
menu_item_conditional(fl!("open-link"), Action::LaunchUrlByMenu, link.is_some()),
divider::horizontal::light(),
menu_item(fl!("clear-scrollback"), Action::ClearScrollback), menu_item(fl!("clear-scrollback"), Action::ClearScrollback),
divider::horizontal::light(), divider::horizontal::light(),
menu_item(fl!("split-horizontal"), Action::PaneSplitHorizontal), menu_item(fl!("split-horizontal"), Action::PaneSplitHorizontal),

View file

@ -41,6 +41,7 @@ pub use alacritty_terminal::grid::Scroll as TerminalScroll;
use crate::{ use crate::{
config::{ColorSchemeKind, Config as AppConfig, ProfileId}, config::{ColorSchemeKind, Config as AppConfig, ProfileId},
menu::MenuState,
mouse_reporter::MouseReporter, mouse_reporter::MouseReporter,
}; };
@ -236,7 +237,7 @@ impl Metadata {
} }
pub struct Terminal { pub struct Terminal {
pub context_menu: Option<cosmic::iced::Point>, pub context_menu: Option<MenuState>,
pub metadata_set: IndexSet<Metadata>, pub metadata_set: IndexSet<Metadata>,
pub needs_update: bool, pub needs_update: bool,
pub profile_id_opt: Option<ProfileId>, pub profile_id_opt: Option<ProfileId>,

View file

@ -44,8 +44,8 @@ use std::{
}; };
use crate::{ use crate::{
Action, Terminal, TerminalScroll, key_bind::key_binds, mouse_reporter::MouseReporter, Action, Terminal, TerminalScroll, key_bind::key_binds, menu::MenuState,
terminal::Metadata, mouse_reporter::MouseReporter, terminal::Metadata,
}; };
pub struct TerminalBox<'a, Message> { pub struct TerminalBox<'a, Message> {
@ -56,7 +56,7 @@ pub struct TerminalBox<'a, Message> {
show_headerbar: bool, show_headerbar: bool,
click_timing: Duration, click_timing: Duration,
context_menu: Option<Point>, context_menu: Option<Point>,
on_context_menu: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>, on_context_menu: Option<Box<dyn Fn(MenuState) -> Message + 'a>>,
on_mouse_enter: Option<Box<dyn Fn() -> Message + 'a>>, on_mouse_enter: Option<Box<dyn Fn() -> Message + 'a>>,
opacity: Option<f32>, opacity: Option<f32>,
mouse_inside_boundary: Option<bool>, mouse_inside_boundary: Option<bool>,
@ -124,10 +124,7 @@ where
self self
} }
pub fn on_context_menu( pub fn on_context_menu(mut self, on_context_menu: impl Fn(MenuState) -> Message + 'a) -> Self {
mut self,
on_context_menu: impl Fn(Option<Point>) -> Message + 'a,
) -> Self {
self.on_context_menu = Some(Box::new(on_context_menu)); self.on_context_menu = Some(Box::new(on_context_menu));
self self
} }
@ -927,7 +924,7 @@ where
} else { } else {
None None
}; };
update_active_regex_match(&mut terminal, location, &state.modifiers); update_active_regex_match(&mut terminal, location, Some(&state.modifiers));
} }
} }
Event::Keyboard(KeyEvent::KeyPressed { Event::Keyboard(KeyEvent::KeyPressed {
@ -1101,10 +1098,37 @@ where
// Update context menu state // Update context menu state
if let Some(on_context_menu) = &self.on_context_menu { if let Some(on_context_menu) = &self.on_context_menu {
shell.publish((on_context_menu)(match self.context_menu { shell.publish((on_context_menu)(match self.context_menu {
Some(_) => None, Some(_) => MenuState {
position: None,
link: None,
},
None => match button { None => match button {
Button::Right => Some(p), Button::Right => {
_ => None, let x = p.x - self.padding.left;
let y = p.y - self.padding.top;
//TODO: better calculation of position
let col = x / terminal.size().cell_width;
let row = y / terminal.size().cell_height;
let location = terminal.viewport_to_point(TermPoint::new(
row as usize,
TermColumn(col as usize),
));
update_active_regex_match(
&mut terminal,
Some(location),
None,
);
let link = get_hyperlink(&terminal, location);
MenuState {
position: Some(p),
link,
}
}
_ => MenuState {
position: None,
link: None,
},
}, },
})); }));
} }
@ -1125,14 +1149,7 @@ where
.viewport_to_point(TermPoint::new(row as usize, TermColumn(col as usize))); .viewport_to_point(TermPoint::new(row as usize, TermColumn(col as usize)));
if state.modifiers.control() { if state.modifiers.control() {
if let Some(on_open_hyperlink) = &self.on_open_hyperlink { if let Some(on_open_hyperlink) = &self.on_open_hyperlink {
if let Some(match_) = terminal if let Some(hyperlink) = get_hyperlink(&terminal, location) {
.regex_matches
.iter()
.find(|bounds| bounds.contains(&location))
{
let term = terminal.term.lock();
let hyperlink =
term.bounds_to_string(*match_.start(), *match_.end());
shell.publish(on_open_hyperlink(hyperlink)); shell.publish(on_open_hyperlink(hyperlink));
status = Status::Captured; status = Status::Captured;
} }
@ -1182,7 +1199,11 @@ where
let row = y / terminal.size().cell_height; let row = y / terminal.size().cell_height;
let location = terminal let location = terminal
.viewport_to_point(TermPoint::new(row as usize, TermColumn(col as usize))); .viewport_to_point(TermPoint::new(row as usize, TermColumn(col as usize)));
update_active_regex_match(&mut terminal, Some(location), &state.modifiers); update_active_regex_match(
&mut terminal,
Some(location),
Some(&state.modifiers),
);
if is_mouse_mode { if is_mouse_mode {
terminal.report_mouse(event, &state.modifiers, col as u32, row as u32); terminal.report_mouse(event, &state.modifiers, col as u32, row as u32);
@ -1283,7 +1304,11 @@ where
row as usize, row as usize,
TermColumn(col as usize), TermColumn(col as usize),
)); ));
update_active_regex_match(&mut terminal, Some(location), &state.modifiers); update_active_regex_match(
&mut terminal,
Some(location),
Some(&state.modifiers),
);
} }
} }
} }
@ -1294,11 +1319,28 @@ where
} }
} }
fn get_hyperlink(
terminal: &std::sync::MutexGuard<'_, Terminal>,
location: TermPoint,
) -> Option<String> {
if let Some(match_) = terminal
.regex_matches
.iter()
.find(|bounds| bounds.contains(&location))
{
let term = terminal.term.lock();
Some(term.bounds_to_string(*match_.start(), *match_.end()))
} else {
None
}
}
fn update_active_regex_match( fn update_active_regex_match(
terminal: &mut std::sync::MutexGuard<'_, Terminal>, terminal: &mut std::sync::MutexGuard<'_, Terminal>,
location: Option<TermPoint>, location: Option<TermPoint>,
modifiers: &Modifiers, modifiers: Option<&Modifiers>,
) { ) {
if let Some(modifiers) = modifiers {
if !modifiers.contains(Modifiers::CTRL) { if !modifiers.contains(Modifiers::CTRL) {
if terminal.active_regex_match.is_some() { if terminal.active_regex_match.is_some() {
terminal.active_regex_match = None; terminal.active_regex_match = None;
@ -1306,6 +1348,7 @@ fn update_active_regex_match(
} }
return; return;
} }
}
let Some(location) = location else { let Some(location) = location else {
if terminal.active_regex_match.is_some() { if terminal.active_regex_match.is_some() {
terminal.active_regex_match = None; terminal.active_regex_match = None;