From 120b4fdb1220afab7f98b53e7adc1ad1195f050b Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 8 Sep 2026 13:49:17 -0600 Subject: [PATCH] feat: use libcosmic's context_menu widget for the terminal context menu --- src/main.rs | 228 ++++++++------------------------------------ src/menu.rs | 157 ++++++++++-------------------- src/terminal_box.rs | 26 ++--- 3 files changed, 95 insertions(+), 316 deletions(-) diff --git a/src/main.rs b/src/main.rs index 19ff49e..7aa65fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -425,7 +425,7 @@ pub enum Message { ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize), ProfileTabTitle(ProfileId, String), ReorderTab(Pane, ReorderEvent), - Surface(surface::Action), + Surface(surface::Action), SelectAll(Option), ShowAdvancedFontSettings(bool), ShowHeaderBar(bool), @@ -455,7 +455,6 @@ pub enum Message { ZoomIn, ZoomOut, ZoomReset, - ContextMenuPopupClosed(window::Id), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -526,14 +525,6 @@ pub struct App { shortcut_search_regex: Option, shortcut_search_value: String, modifiers: Modifiers, - context_menu_popup: Option<( - window::Id, - pane_grid::Pane, - segmented_button::Entity, - Option, - widget::Id, - cosmic::iced::Point, - )>, #[cfg(feature = "password_manager")] password_mgr: password_manager::PasswordManager, } @@ -1903,7 +1894,6 @@ impl Application for App { shortcut_search_regex: None, shortcut_search_value: String::new(), modifiers: Modifiers::empty(), - context_menu_popup: None, #[cfg(feature = "password_manager")] password_mgr: Default::default(), }; @@ -2884,16 +2874,7 @@ impl Application for App { return self.update_title(None); } Message::TabContextAction(entity, action) => { - // Close context menu popup 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 if let Some(tab_model) = self.pane_model.active() && let Some(terminal) = tab_model.data::>(entity) @@ -2902,11 +2883,8 @@ impl Application for App { //Some actions need the menu_state, //so only clear the position for them. match action { - Action::LaunchUrlByMenu | Action::CopyUrlByMenu => { - if let Some(context_menu) = terminal.context_menu.as_mut() { - context_menu.position = None; - } - } + // these read the link from the menu state and clear it themselves + Action::LaunchUrlByMenu | Action::CopyUrlByMenu => {} _ => { terminal.context_menu = None; } @@ -2916,94 +2894,29 @@ impl Application for App { return cosmic::Task::batch(tasks); } 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 for (_, tab_model) in self.pane_model.panes.iter() { for entity in tab_model.iter() { if let Some(terminal) = tab_model.data::>(entity) { 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 { - if let Some(_position) = menu_state.position { - let local_position = menu_state.local_position.unwrap_or(_position); - if let Some(tab_model) = self.pane_model.panes.get(pane) { - let entity = tab_model.active(); - let link = menu_state.link.clone(); - let popup_id = window::Id::unique(); - - if let Some(terminal) = tab_model.data::>(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), - ))); - } - } + // A right press records what is under the cursor + if let Some(menu_state) = menu_state + && let Some(tab_model) = self.pane_model.panes.get(pane) + { + let entity = tab_model.active(); + if let Some(terminal) = tab_model.data::>(entity) { + terminal.lock().unwrap().context_menu = Some(menu_state); } self.pane_model.set_focus(pane); } - - return cosmic::Task::batch(tasks); } Message::TabNew => { return self.create_and_focus_new_terminal( @@ -3291,26 +3204,8 @@ impl Application for App { self.reset_active_pane_zoom(); 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::>(*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) => { - return cosmic::task::message(cosmic::Action::Cosmic( - cosmic::app::Action::Surface(a), - )); + return cosmic::task::message(cosmic::Action::Surface(a)); } Message::ReorderTab( pane, @@ -3413,26 +3308,7 @@ impl Application for App { ] } - fn on_close_requested(&self, id: window::Id) -> Option { - 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> { - 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 { Some(dialog) => dialog.view(window_id), 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)); } - // If a context menu popup is active for this pane, inform the - // terminal_box so it will emit on_context_menu(None) on click - // to dismiss the popup. - if self.context_menu_popup.is_some() { - terminal_box = terminal_box.context_menu(cosmic::iced::Point::ORIGIN); + // The terminal records what was under the right press; the widget opens the + // menu on release, so build it from that state + let context_link = terminal + .lock() + .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 use_wayland_popup = { - #[cfg(feature = "wayland")] - { - is_wayland() - } - #[cfg(not(feature = "wayland"))] - { - false - } - }; - - let tab_element: Element<'_, Message> = if !use_wayland_popup { - // 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); + let mut context_menu = widget::context_menu( + terminal_box, + context_link.map(|link| { + menu::context_menu(&self.config, &self.key_binds, entity, link) + }), + ) + .item_width(widget::menu::ItemWidth::Uniform(360)) + .on_close(Message::TabContextMenu(pane, None)) + .on_surface_action(Message::Surface); + if let Some(window_id) = self.core.main_window_id() { + context_menu = context_menu.window_id(window_id); + } + tab_column = tab_column.push(context_menu); } //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. /// /// When blur is active the transparent container must be used, otherwise the diff --git a/src/menu.rs b/src/menu.rs index 1383436..5cebe50 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -1,17 +1,15 @@ // 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::{Item as MenuItem, menu_button}; -use cosmic::widget::space; use cosmic::{ Element, app::Core, iced::core::Border, - iced::{Background, Length, advanced::widget::text::Style as TextStyle}, - theme, + iced::{Background, Length}, widget::{ - self, divider, + self, menu::{ItemHeight, ItemWidth}, responsive_menu_bar, segmented_button, }, @@ -23,127 +21,68 @@ use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl}; static MENU_ID: LazyLock = LazyLock::new(|| cosmic::widget::Id::new("responsive-menu")); +/// What the terminal found under the right-click #[derive(Debug, Clone)] pub struct MenuState { - pub position: Option, - pub local_position: Option, pub link: Option, } -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, key_binds: &HashMap, entity: segmented_button::Entity, link: Option, -) -> Element<'a, Message> { - let find_key = |action: &Action| -> String { - for (key_bind, key_action) in key_binds { - 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() - } - } +) -> Vec> { + let item = + |label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action)); - let menu_item = |label, action| { - let key = find_key(&action); - menu_button(vec![ - widget::text(label).into(), - space::horizontal().into(), - widget::text(key) - .class(theme::Text::Custom(key_style)) - .into(), - ]) - .on_press(Message::TabContextAction(entity, action)) - }; - - let menu_checkbox = |label, value, action| { - 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)), + let mut items = vec![ + item(fl!("copy"), Action::Copy), + item(fl!("paste"), Action::Paste), + item(fl!("select-all"), Action::SelectAll), + MenuItem::Divider, + item(fl!("clear-scrollback"), Action::ClearScrollback), + MenuItem::Divider, + item(fl!("split-horizontal"), Action::PaneSplitHorizontal), + item(fl!("split-vertical"), Action::PaneSplitVertical), + item(fl!("pane-toggle-maximize"), Action::PaneToggleMaximized), + MenuItem::Divider, + item(fl!("new-tab"), Action::TabNew), + item(fl!("menu-settings"), Action::Settings), ]; #[cfg(feature = "password_manager")] - { - rows.push(Element::from(menu_item( - fl!("menu-password-manager"), - Action::PasswordManager, - ))); - } - rows.push(Element::from(menu_checkbox( + items.push(item(fl!("menu-password-manager"), Action::PasswordManager)); + items.push(MenuItem::CheckBox( fl!("show-headerbar"), + None, config.show_headerbar, - Action::ShowHeaderBar(!config.show_headerbar), - ))); + TabAction(entity, Action::ShowHeaderBar(!config.show_headerbar)), + )); - //If we have a link - //prepend the Open Link item + // If we have a link, prepend the link items if link.is_some() { - rows.insert( - 0, - Element::from(menu_item(fl!("open-link"), Action::LaunchUrlByMenu)), - ); - rows.insert( - 1, - Element::from(menu_item(fl!("copy-link"), Action::CopyUrlByMenu)), - ); - rows.insert(2, Element::from(divider::horizontal::light())); + items.insert(0, item(fl!("open-link"), Action::LaunchUrlByMenu)); + items.insert(1, item(fl!("copy-link"), Action::CopyUrlByMenu)); + items.insert(2, MenuItem::Divider); } - let content = widget::menu::menu_column::MenuColumn::with_children(rows); - widget::container(content) - .padding(1) - //TODO: move style to libcosmic - .style(|theme| { - let cosmic = theme.cosmic(); - 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() + + let key_binds: HashMap = key_binds + .iter() + .map(|(key_bind, action)| (key_bind.clone(), TabAction(entity, *action))) + .collect(); + widget::menu::items(&key_binds, items) } pub fn color_scheme_menu<'a>( diff --git a/src/terminal_box.rs b/src/terminal_box.rs index 64b9490..d3b743a 100644 --- a/src/terminal_box.rs +++ b/src/terminal_box.rs @@ -112,7 +112,7 @@ pub struct TerminalBox<'a, Message> { show_headerbar: bool, pane_border_radius: Option, click_timing: Duration, - context_menu: Option, + context_menu_open: bool, on_context_menu: Option) -> Message + 'a>>, on_mouse_enter: Option Message + 'a>>, opacity: Option, @@ -139,7 +139,7 @@ where show_headerbar: true, pane_border_radius: None, click_timing: Duration::from_millis(500), - context_menu: None, + context_menu_open: false, on_context_menu: None, on_mouse_enter: None, opacity: None, @@ -184,8 +184,8 @@ where self } - pub fn context_menu(mut self, position: Point) -> Self { - self.context_menu = Some(position); + pub fn context_menu_open(mut self, open: bool) -> Self { + self.context_menu_open = open; self } @@ -249,7 +249,7 @@ where if !state.is_focused { return InputMethod::Disabled; } - if self.context_menu.is_some() { + if self.context_menu_open { return InputMethod::Disabled; } @@ -1353,11 +1353,11 @@ where } // Update context menu state if let Some(on_context_menu) = &self.on_context_menu { - match self.context_menu { - Some(_) => { + match self.context_menu_open { + true => { shell.publish(on_context_menu(None)); } - None => { + false => { if *button == Button::Right { let x = p.x - self.padding.left; let y = p.y - self.padding.top; @@ -1375,15 +1375,7 @@ where None, ); let link = get_hyperlink(&terminal, location); - let abs = cosmic::iced::Point::new( - 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, - }))); + shell.publish(on_context_menu(Some(MenuState { link }))); } } }