feat: use libcosmic's context_menu widget for the terminal context menu

This commit is contained in:
Hojjat 2026-09-08 13:49:17 -06:00 • committed by Ashley Wulber
parent 4cd8f8d605
commit 120b4fdb12
3 changed files with 95 additions and 316 deletions

View file

@ -425,7 +425,7 @@ pub enum Message {
ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize), ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize),
ProfileTabTitle(ProfileId, String), ProfileTabTitle(ProfileId, String),
ReorderTab(Pane, ReorderEvent), ReorderTab(Pane, ReorderEvent),
Surface(surface::Action), Surface(surface::Action<Message>),
SelectAll(Option<segmented_button::Entity>), SelectAll(Option<segmented_button::Entity>),
ShowAdvancedFontSettings(bool), ShowAdvancedFontSettings(bool),
ShowHeaderBar(bool), ShowHeaderBar(bool),
@ -455,7 +455,6 @@ pub enum Message {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
ZoomReset, ZoomReset,
ContextMenuPopupClosed(window::Id),
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -526,14 +525,6 @@ pub struct App {
shortcut_search_regex: Option<regex::Regex>, shortcut_search_regex: Option<regex::Regex>,
shortcut_search_value: String, shortcut_search_value: String,
modifiers: Modifiers, modifiers: Modifiers,
context_menu_popup: Option<(
window::Id,
pane_grid::Pane,
segmented_button::Entity,
Option<String>,
widget::Id,
cosmic::iced::Point,
)>,
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
password_mgr: password_manager::PasswordManager, password_mgr: password_manager::PasswordManager,
} }
@ -1903,7 +1894,6 @@ impl Application for App {
shortcut_search_regex: None, shortcut_search_regex: None,
shortcut_search_value: String::new(), shortcut_search_value: String::new(),
modifiers: Modifiers::empty(), modifiers: Modifiers::empty(),
context_menu_popup: None,
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
password_mgr: Default::default(), password_mgr: Default::default(),
}; };
@ -2884,16 +2874,7 @@ impl Application for App {
return self.update_title(None); return self.update_title(None);
} }
Message::TabContextAction(entity, action) => { Message::TabContextAction(entity, action) => {
// Close context menu popup
let mut tasks = Vec::new(); let mut tasks = Vec::new();
if let Some((_popup_id, _, _, _, _, _)) = self.context_menu_popup.take() {
#[cfg(feature = "wayland")]
if is_wayland() {
tasks.push(cosmic::task::message(Message::Surface(
cosmic::surface::action::destroy_popup(_popup_id),
)));
}
}
// Close terminal context menu state // Close terminal context menu state
if let Some(tab_model) = self.pane_model.active() if let Some(tab_model) = self.pane_model.active()
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) && let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
@ -2902,11 +2883,8 @@ impl Application for App {
//Some actions need the menu_state, //Some actions need the menu_state,
//so only clear the position for them. //so only clear the position for them.
match action { match action {
Action::LaunchUrlByMenu | Action::CopyUrlByMenu => { // these read the link from the menu state and clear it themselves
if let Some(context_menu) = terminal.context_menu.as_mut() { Action::LaunchUrlByMenu | Action::CopyUrlByMenu => {}
context_menu.position = None;
}
}
_ => { _ => {
terminal.context_menu = None; terminal.context_menu = None;
} }
@ -2916,94 +2894,29 @@ impl Application for App {
return cosmic::Task::batch(tasks); return cosmic::Task::batch(tasks);
} }
Message::TabContextMenu(pane, menu_state) => { Message::TabContextMenu(pane, menu_state) => {
#[allow(unused_mut)]
let mut tasks = Vec::new();
// Close existing context menu popup if any
if let Some((_popup_id, _, _, _, _, _)) = self.context_menu_popup.take() {
#[cfg(feature = "wayland")]
if is_wayland() {
tasks.push(cosmic::task::message(Message::Surface(
cosmic::surface::action::destroy_popup(_popup_id),
)));
}
}
// Clear all terminal context_menu state // Clear all terminal context_menu state
for (_, tab_model) in self.pane_model.panes.iter() { for (_, tab_model) in self.pane_model.panes.iter() {
for entity in tab_model.iter() { for entity in tab_model.iter() {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap(); let mut terminal = terminal.lock().unwrap();
terminal.context_menu = None; if terminal.context_menu.take().is_some() {
terminal.active_regex_match = None;
terminal.needs_update = true;
}
} }
} }
} }
if let Some(menu_state) = menu_state { // A right press records what is under the cursor
if let Some(_position) = menu_state.position { if let Some(menu_state) = menu_state
let local_position = menu_state.local_position.unwrap_or(_position); && let Some(tab_model) = self.pane_model.panes.get(pane)
if let Some(tab_model) = self.pane_model.panes.get(pane) { {
let entity = tab_model.active(); let entity = tab_model.active();
let link = menu_state.link.clone(); if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let popup_id = window::Id::unique(); terminal.lock().unwrap().context_menu = Some(menu_state);
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.context_menu = Some(menu_state);
}
self.context_menu_popup = Some((
popup_id,
pane,
entity,
link,
widget::Id::unique(),
local_position,
));
#[cfg(feature = "wayland")]
if is_wayland() {
let main_window = self.core.main_window_id().unwrap();
let pos_x = _position.x as i32;
let pos_y = _position.y as i32;
tasks.push(cosmic::task::message(Message::Surface(
cosmic::surface::action::app_popup(
|_| Default::default(),
move |_app: &mut Self| {
use cosmic::cctk::wayland_protocols::xdg::shell::client::xdg_positioner::{Anchor, Gravity};
use cosmic::iced::runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner};
SctkPopupSettings {
parent: main_window,
id: popup_id,
positioner: SctkPositioner {
size: None,
anchor_rect: cosmic::iced::Rectangle {
x: pos_x,
y: pos_y,
width: 1,
height: 1,
},
anchor: Anchor::None,
gravity: Gravity::BottomRight,
reactive: true,
..Default::default()
},
parent_size: None,
grab: true,
close_with_children: false,
input_zone: None,
}
}, None),
)));
}
}
} }
self.pane_model.set_focus(pane); self.pane_model.set_focus(pane);
} }
return cosmic::Task::batch(tasks);
} }
Message::TabNew => { Message::TabNew => {
return self.create_and_focus_new_terminal( return self.create_and_focus_new_terminal(
@ -3291,26 +3204,8 @@ impl Application for App {
self.reset_active_pane_zoom(); self.reset_active_pane_zoom();
return self.update_config(); return self.update_config();
} }
Message::ContextMenuPopupClosed(id) => {
if let Some((popup_id, pane, entity, _, _, _)) = &self.context_menu_popup
&& id == *popup_id
{
// Clear link underline on the terminal
if let Some(tab_model) = self.pane_model.panes.get(*pane)
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(*entity)
{
let mut terminal = terminal.lock().unwrap();
terminal.context_menu = None;
terminal.active_regex_match = None;
terminal.needs_update = true;
}
self.context_menu_popup = None;
}
}
Message::Surface(a) => { Message::Surface(a) => {
return cosmic::task::message(cosmic::Action::Cosmic( return cosmic::task::message(cosmic::Action::Surface(a));
cosmic::app::Action::Surface(a),
));
} }
Message::ReorderTab( Message::ReorderTab(
pane, pane,
@ -3413,26 +3308,7 @@ impl Application for App {
] ]
} }
fn on_close_requested(&self, id: window::Id) -> Option<Self::Message> {
if let Some((popup_id, _, _, _, _, _)) = &self.context_menu_popup
&& id == *popup_id
{
return Some(Message::ContextMenuPopupClosed(id));
}
None
}
fn view_window(&self, window_id: window::Id) -> Element<'_, Message> { fn view_window(&self, window_id: window::Id) -> Element<'_, Message> {
if let Some((popup_id, _pane, entity, ref link, ref autosize_id, _)) =
self.context_menu_popup
&& window_id == popup_id
{
return widget::autosize::autosize(
menu::context_menu(&self.config, &self.key_binds, entity, link.clone()),
autosize_id.clone(),
)
.into();
}
match &self.dialog_opt { match &self.dialog_opt {
Some(dialog) => dialog.view(window_id), Some(dialog) => dialog.view(window_id),
None => widget::text("Unknown window ID").into(), None => widget::text("Unknown window ID").into(),
@ -3521,50 +3397,30 @@ impl Application for App {
terminal_box = terminal_box.on_mouse_enter(move || Message::MouseEnter(pane)); terminal_box = terminal_box.on_mouse_enter(move || Message::MouseEnter(pane));
} }
// If a context menu popup is active for this pane, inform the // The terminal records what was under the right press; the widget opens the
// terminal_box so it will emit on_context_menu(None) on click // menu on release, so build it from that state
// to dismiss the popup. let context_link = terminal
if self.context_menu_popup.is_some() { .lock()
terminal_box = terminal_box.context_menu(cosmic::iced::Point::ORIGIN); .unwrap()
.context_menu
.as_ref()
.map(|menu_state| menu_state.link.clone());
if context_link.is_some() {
terminal_box = terminal_box.context_menu_open(true);
} }
let mut context_menu = widget::context_menu(
let use_wayland_popup = { terminal_box,
#[cfg(feature = "wayland")] context_link.map(|link| {
{ menu::context_menu(&self.config, &self.key_binds, entity, link)
is_wayland() }),
} )
#[cfg(not(feature = "wayland"))] .item_width(widget::menu::ItemWidth::Uniform(360))
{ .on_close(Message::TabContextMenu(pane, None))
false .on_surface_action(Message::Surface);
} if let Some(window_id) = self.core.main_window_id() {
}; context_menu = context_menu.window_id(window_id);
}
let tab_element: Element<'_, Message> = if !use_wayland_popup { tab_column = tab_column.push(context_menu);
// Fallback: render context menu as an inline popover
if let Some((_, popup_pane, popup_entity, ref link, _, point)) =
self.context_menu_popup
{
if pane == popup_pane {
let mut popover = widget::popover(terminal_box.context_menu(point));
popover = popover
.popup(menu::context_menu(
&self.config,
&self.key_binds,
popup_entity,
link.clone(),
))
.position(widget::popover::Position::Point(point));
popover.into()
} else {
terminal_box.into()
}
} else {
terminal_box.into()
}
} else {
terminal_box.into()
};
tab_column = tab_column.push(tab_element);
} }
//Only draw find in the currently focused pane //Only draw find in the currently focused pane
@ -3728,14 +3584,6 @@ impl Application for App {
} }
} }
#[cfg(feature = "wayland")]
fn is_wayland() -> bool {
matches!(
cosmic::app::cosmic::windowing_system(),
Some(cosmic::app::cosmic::WindowingSystem::Wayland)
)
}
/// Divider color painted behind the pane grid to form pane borders. /// Divider color painted behind the pane grid to form pane borders.
/// ///
/// When blur is active the transparent container must be used, otherwise the /// When blur is active the transparent container must be used, otherwise the

View file

@ -1,17 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
use cosmic::iced::Point; use cosmic::widget::menu::action::MenuAction;
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::widget::space;
use cosmic::{ use cosmic::{
Element, Element,
app::Core, app::Core,
iced::core::Border, iced::core::Border,
iced::{Background, Length, advanced::widget::text::Style as TextStyle}, iced::{Background, Length},
theme,
widget::{ widget::{
self, divider, self,
menu::{ItemHeight, ItemWidth}, menu::{ItemHeight, ItemWidth},
responsive_menu_bar, segmented_button, responsive_menu_bar, segmented_button,
}, },
@ -23,127 +21,68 @@ 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"));
/// What the terminal found under the right-click
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MenuState { pub struct MenuState {
pub position: Option<Point>,
pub local_position: Option<Point>,
pub link: Option<String>, pub link: Option<String>,
} }
pub fn context_menu<'a>( /// A context menu action dispatched to the tab the menu was opened on.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TabAction(pub segmented_button::Entity, pub Action);
impl MenuAction for TabAction {
type Message = Message;
fn message(&self) -> Message {
Message::TabContextAction(self.0, self.1)
}
}
pub fn context_menu(
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>, link: Option<String>,
) -> Element<'a, Message> { ) -> Vec<widget::menu::Tree<Message>> {
let find_key = |action: &Action| -> String { let item =
for (key_bind, key_action) in key_binds { |label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action));
if action == key_action {
return key_bind.to_string();
}
}
String::new()
};
fn key_style(theme: &cosmic::Theme) -> TextStyle {
let mut color = theme.cosmic().background(theme.transparent).component.on;
color.alpha *= 0.75;
TextStyle {
color: Some(color.into()),
..Default::default()
}
}
let menu_item = |label, action| { let mut items = vec![
let key = find_key(&action); item(fl!("copy"), Action::Copy),
menu_button(vec![ item(fl!("paste"), Action::Paste),
widget::text(label).into(), item(fl!("select-all"), Action::SelectAll),
space::horizontal().into(), MenuItem::Divider,
widget::text(key) item(fl!("clear-scrollback"), Action::ClearScrollback),
.class(theme::Text::Custom(key_style)) MenuItem::Divider,
.into(), item(fl!("split-horizontal"), Action::PaneSplitHorizontal),
]) item(fl!("split-vertical"), Action::PaneSplitVertical),
.on_press(Message::TabContextAction(entity, action)) item(fl!("pane-toggle-maximize"), Action::PaneToggleMaximized),
}; MenuItem::Divider,
item(fl!("new-tab"), Action::TabNew),
let menu_checkbox = |label, value, action| { item(fl!("menu-settings"), Action::Settings),
menu_button(vec![
widget::text(label).into(),
widget::space::horizontal().into(),
widget::toggler(value)
.on_toggle(move |_| Message::TabContextAction(entity, action))
.size(16.0)
.into(),
])
.on_press(Message::TabContextAction(entity, action))
};
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")] #[cfg(feature = "password_manager")]
{ items.push(item(fl!("menu-password-manager"), Action::PasswordManager));
rows.push(Element::from(menu_item( items.push(MenuItem::CheckBox(
fl!("menu-password-manager"),
Action::PasswordManager,
)));
}
rows.push(Element::from(menu_checkbox(
fl!("show-headerbar"), fl!("show-headerbar"),
None,
config.show_headerbar, config.show_headerbar,
Action::ShowHeaderBar(!config.show_headerbar), TabAction(entity, Action::ShowHeaderBar(!config.show_headerbar)),
))); ));
//If we have a link // If we have a link, prepend the link items
//prepend the Open Link item
if link.is_some() { if link.is_some() {
rows.insert( items.insert(0, item(fl!("open-link"), Action::LaunchUrlByMenu));
0, items.insert(1, item(fl!("copy-link"), Action::CopyUrlByMenu));
Element::from(menu_item(fl!("open-link"), Action::LaunchUrlByMenu)), items.insert(2, MenuItem::Divider);
);
rows.insert(
1,
Element::from(menu_item(fl!("copy-link"), Action::CopyUrlByMenu)),
);
rows.insert(2, Element::from(divider::horizontal::light()));
} }
let content = widget::menu::menu_column::MenuColumn::with_children(rows);
widget::container(content) let key_binds: HashMap<KeyBind, TabAction> = key_binds
.padding(1) .iter()
//TODO: move style to libcosmic .map(|(key_bind, action)| (key_bind.clone(), TabAction(entity, *action)))
.style(|theme| { .collect();
let cosmic = theme.cosmic(); widget::menu::items(&key_binds, items)
let component = &cosmic.background(theme.transparent).component;
widget::container::Style {
icon_color: Some(component.on.into()),
text_color: Some(component.on.into()),
background: Some(Background::Color(component.base.into())),
border: Border {
radius: cosmic.radius_s().map(|x| x + 1.0).into(),
width: 1.0,
color: component.divider.into(),
},
..Default::default()
}
})
.width(Length::Fixed(360.0))
.into()
} }
pub fn color_scheme_menu<'a>( pub fn color_scheme_menu<'a>(

View file

@ -112,7 +112,7 @@ pub struct TerminalBox<'a, Message> {
show_headerbar: bool, show_headerbar: bool,
pane_border_radius: Option<Radius>, pane_border_radius: Option<Radius>,
click_timing: Duration, click_timing: Duration,
context_menu: Option<Point>, context_menu_open: bool,
on_context_menu: Option<Box<dyn Fn(Option<MenuState>) -> Message + 'a>>, on_context_menu: Option<Box<dyn Fn(Option<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>,
@ -139,7 +139,7 @@ where
show_headerbar: true, show_headerbar: true,
pane_border_radius: None, pane_border_radius: None,
click_timing: Duration::from_millis(500), click_timing: Duration::from_millis(500),
context_menu: None, context_menu_open: false,
on_context_menu: None, on_context_menu: None,
on_mouse_enter: None, on_mouse_enter: None,
opacity: None, opacity: None,
@ -184,8 +184,8 @@ where
self self
} }
pub fn context_menu(mut self, position: Point) -> Self { pub fn context_menu_open(mut self, open: bool) -> Self {
self.context_menu = Some(position); self.context_menu_open = open;
self self
} }
@ -249,7 +249,7 @@ where
if !state.is_focused { if !state.is_focused {
return InputMethod::Disabled; return InputMethod::Disabled;
} }
if self.context_menu.is_some() { if self.context_menu_open {
return InputMethod::Disabled; return InputMethod::Disabled;
} }
@ -1353,11 +1353,11 @@ 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 {
match self.context_menu { match self.context_menu_open {
Some(_) => { true => {
shell.publish(on_context_menu(None)); shell.publish(on_context_menu(None));
} }
None => { false => {
if *button == Button::Right { if *button == Button::Right {
let x = p.x - self.padding.left; let x = p.x - self.padding.left;
let y = p.y - self.padding.top; let y = p.y - self.padding.top;
@ -1375,15 +1375,7 @@ where
None, None,
); );
let link = get_hyperlink(&terminal, location); let link = get_hyperlink(&terminal, location);
let abs = cosmic::iced::Point::new( shell.publish(on_context_menu(Some(MenuState { link })));
layout.bounds().x + p.x,
layout.bounds().y + p.y,
);
shell.publish(on_context_menu(Some(MenuState {
position: Some(abs),
local_position: Some(p),
link,
})));
} }
} }
} }