Merge pull request #599 from snaggen/open_link_menu

Add context menu for opening links
This commit is contained in:
Jeremy Soller 2025-10-23 11:54:31 -06:00 committed by GitHub
commit 96d38604f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 181 additions and 63 deletions

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::iced::Point;
use cosmic::widget::Column;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::menu::{Item as MenuItem, menu_button};
use cosmic::{
Element,
app::Core,
iced::{
Background, Length,
advanced::widget::text::Style as TextStyle,
widget::{column, horizontal_space},
Background, Length, advanced::widget::text::Style as TextStyle, widget::horizontal_space,
},
iced_core::Border,
theme,
@ -25,10 +25,17 @@ use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl};
static MENU_ID: LazyLock<cosmic::widget::Id> =
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>(
config: &Config,
key_binds: &HashMap<KeyBind, Action>,
entity: segmented_button::Entity,
link: Option<String>,
) -> Element<'a, Message> {
let find_key = |action: &Action| -> String {
for (key_bind, key_action) in key_binds {
@ -70,32 +77,49 @@ pub fn context_menu<'a>(
.on_press(Message::TabContextAction(entity, action))
};
let mut content = column!(
menu_item(fl!("copy"), Action::Copy),
menu_item(fl!("paste"), Action::Paste),
menu_item(fl!("select-all"), Action::SelectAll),
divider::horizontal::light(),
menu_item(fl!("clear-scrollback"), Action::ClearScrollback),
divider::horizontal::light(),
menu_item(fl!("split-horizontal"), Action::PaneSplitHorizontal),
menu_item(fl!("split-vertical"), Action::PaneSplitVertical),
menu_item(fl!("pane-toggle-maximize"), Action::PaneToggleMaximized),
divider::horizontal::light(),
menu_item(fl!("new-tab"), Action::TabNew),
menu_item(fl!("menu-settings"), Action::Settings),
);
let mut rows = vec![
Element::from(menu_item(fl!("copy"), Action::Copy)),
Element::from(menu_item(fl!("paste"), Action::Paste)),
Element::from(menu_item(fl!("select-all"), Action::SelectAll)),
Element::from(divider::horizontal::light()),
Element::from(menu_item(fl!("clear-scrollback"), Action::ClearScrollback)),
Element::from(divider::horizontal::light()),
Element::from(menu_item(
fl!("split-horizontal"),
Action::PaneSplitHorizontal,
)),
Element::from(menu_item(fl!("split-vertical"), Action::PaneSplitVertical)),
Element::from(menu_item(
fl!("pane-toggle-maximize"),
Action::PaneToggleMaximized,
)),
Element::from(divider::horizontal::light()),
Element::from(menu_item(fl!("new-tab"), Action::TabNew)),
Element::from(menu_item(fl!("menu-settings"), Action::Settings)),
];
#[cfg(feature = "password_manager")]
{
content = content.push(menu_item(
rows.push(Element::from(menu_item(
fl!("menu-password-manager"),
Action::PasswordManager,
));
)));
}
content = content.push(menu_checkbox(
rows.push(Element::from(menu_checkbox(
fl!("show-headerbar"),
config.show_headerbar,
Action::ShowHeaderBar(!config.show_headerbar),
));
)));
//If we have a link
//prepend the Open Link item
if link.is_some() {
rows.insert(
0,
Element::from(menu_item(fl!("open-link"), Action::LaunchUrlByMenu)),
);
rows.insert(1, Element::from(divider::horizontal::light()));
}
let content = Column::with_children(rows);
widget::container(content)
.padding(1)
//TODO: move style to libcosmic

View file

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

View file

@ -44,8 +44,8 @@ use std::{
};
use crate::{
Action, Terminal, TerminalScroll, key_bind::key_binds, mouse_reporter::MouseReporter,
terminal::Metadata,
Action, Terminal, TerminalScroll, key_bind::key_binds, menu::MenuState,
mouse_reporter::MouseReporter, terminal::Metadata,
};
pub struct TerminalBox<'a, Message> {
@ -56,7 +56,7 @@ pub struct TerminalBox<'a, Message> {
show_headerbar: bool,
click_timing: Duration,
context_menu: Option<Point>,
on_context_menu: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_context_menu: Option<Box<dyn Fn(Option<MenuState>) -> Message + 'a>>,
on_mouse_enter: Option<Box<dyn Fn() -> Message + 'a>>,
opacity: Option<f32>,
mouse_inside_boundary: Option<bool>,
@ -126,7 +126,7 @@ where
pub fn on_context_menu(
mut self,
on_context_menu: impl Fn(Option<Point>) -> Message + 'a,
on_context_menu: impl Fn(Option<MenuState>) -> Message + 'a,
) -> Self {
self.on_context_menu = Some(Box::new(on_context_menu));
self
@ -927,7 +927,7 @@ where
} else {
None
};
update_active_regex_match(&mut terminal, location, &state.modifiers);
update_active_regex_match(&mut terminal, location, Some(&state.modifiers));
}
}
Event::Keyboard(KeyEvent::KeyPressed {
@ -1100,13 +1100,35 @@ where
}
// Update context menu state
if let Some(on_context_menu) = &self.on_context_menu {
shell.publish((on_context_menu)(match self.context_menu {
Some(_) => None,
None => match button {
Button::Right => Some(p),
_ => None,
},
}));
match self.context_menu {
Some(_) => {
shell.publish(on_context_menu(None));
}
None => {
if button == Button::Right {
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);
shell.publish(on_context_menu(Some(MenuState {
position: Some(p),
link,
})));
}
}
}
}
status = Status::Captured;
}
@ -1125,14 +1147,7 @@ where
.viewport_to_point(TermPoint::new(row as usize, TermColumn(col as usize)));
if state.modifiers.control() {
if let Some(on_open_hyperlink) = &self.on_open_hyperlink {
if let Some(match_) = terminal
.regex_matches
.iter()
.find(|bounds| bounds.contains(&location))
{
let term = terminal.term.lock();
let hyperlink =
term.bounds_to_string(*match_.start(), *match_.end());
if let Some(hyperlink) = get_hyperlink(&terminal, location) {
shell.publish(on_open_hyperlink(hyperlink));
status = Status::Captured;
}
@ -1182,7 +1197,11 @@ where
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), &state.modifiers);
update_active_regex_match(
&mut terminal,
Some(location),
Some(&state.modifiers),
);
if is_mouse_mode {
terminal.report_mouse(event, &state.modifiers, col as u32, row as u32);
@ -1283,7 +1302,11 @@ where
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),
);
}
}
}
@ -1294,11 +1317,36 @@ 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(
terminal: &mut std::sync::MutexGuard<'_, Terminal>,
location: Option<TermPoint>,
modifiers: &Modifiers,
modifiers: Option<&Modifiers>,
) {
//Do not update any highlights if
//there is a context_menu shown
//to the user
if terminal.context_menu.is_some() {
return;
}
//Require CTRL for keyboard and mouse interaction
if let Some(modifiers) = modifiers {
if !modifiers.contains(Modifiers::CTRL) {
if terminal.active_regex_match.is_some() {
terminal.active_regex_match = None;
@ -1306,6 +1354,7 @@ fn update_active_regex_match(
}
return;
}
}
let Some(location) = location else {
if terminal.active_regex_match.is_some() {
terminal.active_regex_match = None;