diff --git a/examples/context-menu/Cargo.toml b/examples/context-menu/Cargo.toml new file mode 100644 index 00000000..88a491e0 --- /dev/null +++ b/examples/context-menu/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "context-menu" +version = "0.1.0" +edition = "2021" + +[dependencies] +tracing = "0.1.37" +tracing-subscriber = "0.3.17" +tracing-log = "0.2.0" + +[dependencies.libcosmic] +path = "../../" +default-features = false +features = ["debug", "winit", "tokio", "xdg-portal"] diff --git a/examples/context-menu/src/main.rs b/examples/context-menu/src/main.rs new file mode 100644 index 00000000..5c4fea8b --- /dev/null +++ b/examples/context-menu/src/main.rs @@ -0,0 +1,144 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +use cosmic::app::{Command, Core, Settings}; +use cosmic::iced_core::Size; +use cosmic::widget::{menu, segmented_button}; +use cosmic::{executor, iced, ApplicationExt, Element}; +use std::collections::HashMap; + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let settings = Settings::default() + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, ())?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + Clicked, + ShowContext, + WindowClose, + ShowWindowMenu, + ToggleHideContent, + WindowNew, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + button_label: String, + show_context: bool, + hide_content: bool, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = (); + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "org.cosmic.ContextMenuDemo"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits command on initialize. + fn init(core: Core, _input: Self::Flags) -> (Self, Command) { + let mut app = App { + core, + button_label: String::from("Right click me"), + hide_content: false, + show_context: false, + }; + + app.set_header_title("COSMIC Context Menu Demo".into()); + let command = app.set_window_title("COSMIC Context Menu Demo".into()); + + (app, command) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Command { + self.button_label = format!("Clicked {message:?}"); + + Command::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + let widget = cosmic::widget::context_menu( + cosmic::widget::button::text(&self.button_label).on_press(Message::Clicked), + self.context_menu(), + ); + + let centered = cosmic::widget::container(widget) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + + Element::from(centered) + } +} + +impl App { + fn context_menu(&self) -> Option>> { + Some(menu::items( + &HashMap::new(), + vec![ + menu::Item::Button("New window", ContextMenuAction::WindowNew), + menu::Item::Divider, + menu::Item::Folder( + "View", + vec![menu::Item::CheckBox( + "Hide content", + self.hide_content, + ContextMenuAction::ToggleHideContent, + )], + ), + menu::Item::Divider, + menu::Item::Button("Quit", ContextMenuAction::WindowClose), + ], + )) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContextMenuAction { + WindowClose, + ToggleHideContent, + WindowNew, +} + +impl menu::Action for ContextMenuAction { + type Message = Message; + fn message(&self, _entity_opt: Option) -> Self::Message { + match self { + ContextMenuAction::WindowClose => Message::WindowClose, + ContextMenuAction::ToggleHideContent => Message::ToggleHideContent, + ContextMenuAction::WindowNew => Message::WindowNew, + } + } +} diff --git a/examples/nav-context/src/main.rs b/examples/nav-context/src/main.rs index a608cc22..7382fb7e 100644 --- a/examples/nav-context/src/main.rs +++ b/examples/nav-context/src/main.rs @@ -7,9 +7,7 @@ use std::collections::HashMap; use cosmic::app::{Command, Core, Settings}; use cosmic::iced_core::Size; -use cosmic::widget::menu::action::MenuAction; -use cosmic::widget::menu::menu_tree::{menu_items, MenuItem, MenuTree}; -use cosmic::widget::nav_bar; +use cosmic::widget::{menu, nav_bar}; use cosmic::{executor, iced, ApplicationExt, Element}; #[derive(Clone, Copy)] @@ -71,7 +69,7 @@ pub enum NavMenuAction { Delete(nav_bar::Id), } -impl MenuAction for NavMenuAction { +impl menu::Action for NavMenuAction { type Message = cosmic::app::Message; fn message(&self, _entity: Option) -> Self::Message { @@ -133,13 +131,13 @@ impl cosmic::Application for App { fn nav_context_menu( &self, id: nav_bar::Id, - ) -> Option, cosmic::Renderer>>> { - Some(menu_items( + ) -> Option>>> { + Some(menu::items( &HashMap::new(), vec![ - MenuItem::Button("Move Up", NavMenuAction::MoveUp(id)), - MenuItem::Button("Move Down", NavMenuAction::MoveDown(id)), - MenuItem::Button("Delete", NavMenuAction::Delete(id)), + menu::Item::Button("Move Up", NavMenuAction::MoveUp(id)), + menu::Item::Button("Move Down", NavMenuAction::MoveDown(id)), + menu::Item::Button("Delete", NavMenuAction::Delete(id)), ], )) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5274ffa3..93a91a70 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -45,7 +45,7 @@ pub use self::settings::Settings; use crate::config::CosmicTk; use crate::prelude::*; use crate::theme::THEME; -use crate::widget::{context_drawer, nav_bar, popover}; +use crate::widget::{context_drawer, menu, nav_bar, popover}; use apply::Apply; use iced::Subscription; #[cfg(all(feature = "winit", feature = "multi-window"))] @@ -473,10 +473,7 @@ where } /// Shows a context menu for the active nav bar item. - fn nav_context_menu( - &self, - id: nav_bar::Id, - ) -> Option, crate::Renderer>>> { + fn nav_context_menu(&self, id: nav_bar::Id) -> Option>>> { None } diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs new file mode 100644 index 00000000..dfec06d1 --- /dev/null +++ b/src/widget/context_menu.rs @@ -0,0 +1,276 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. + +use crate::widget::menu::{ + self, CloseCondition, ItemHeight, ItemWidth, MenuBarState, PathHighlight, +}; +use derive_setters::Setters; +use iced::touch::Finger; +use iced::Event; +use iced_core::widget::{tree, Tree, Widget}; +use iced_core::{event, mouse, touch, Length, Point, Size}; +use std::collections::HashSet; + +/// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. +pub fn context_menu<'a, Message: 'a>( + content: impl Into> + 'a, + // on_context: Message, + context_menu: Option>>, +) -> ContextMenu<'a, Message> { + let mut this = ContextMenu { + content: content.into(), + context_menu: context_menu.map(|menus| { + vec![menu::Tree::with_children( + crate::widget::row::<'static, Message>(), + menus, + )] + }), + }; + + if let Some(ref mut context_menu) = this.context_menu { + context_menu.iter_mut().for_each(menu::Tree::set_index); + } + + this +} + +/// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. +#[derive(Setters)] +#[must_use] +pub struct ContextMenu<'a, Message> { + #[setters(skip)] + content: crate::Element<'a, Message>, + #[setters(skip)] + context_menu: Option>>, +} + +impl<'a, Message: Clone> Widget + for ContextMenu<'a, Message> +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + #[allow(clippy::default_trait_access)] + tree::State::new(LocalState { + context_cursor: Point::default(), + fingers_pressed: Default::default(), + }) + } + + fn children(&self) -> Vec { + let mut children = Vec::with_capacity(if self.context_menu.is_some() { 2 } else { 1 }); + + children.push(Tree::new(self.content.as_widget())); + + // Assign the context menu's elements as this widget's children. + if let Some(ref context_menu) = self.context_menu { + let mut tree = Tree::empty(); + tree.state = tree::State::new(MenuBarState::default()); + tree.children = context_menu + .iter() + .map(|root| { + let mut tree = Tree::empty(); + let flat = root + .flattern() + .iter() + .map(|mt| Tree::new(mt.item.as_widget())) + .collect(); + tree.children = flat; + tree + }) + .collect(); + + children.push(tree); + } + + children + } + + fn diff(&mut self, tree: &mut Tree) { + self.content.as_widget_mut().diff(&mut tree.children[0]); + + // if let Some(ref mut context_menus) = self.context_menu { + // for (menu, tree) in context_menus + // .iter_mut() + // .zip(tree.children[1].children.iter_mut()) + // { + // menu.item.as_widget_mut().diff(tree); + // } + // } + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &crate::Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &iced_core::renderer::Style, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &iced::Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn operate( + &self, + tree: &mut Tree, + layout: iced_core::Layout<'_>, + renderer: &crate::Renderer, + operation: &mut dyn iced_core::widget::Operation< + iced_core::widget::OperationOutputWrapper, + >, + ) { + self.content + .as_widget() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: iced::Event, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn iced_core::Clipboard, + shell: &mut iced_core::Shell<'_, Message>, + viewport: &iced::Rectangle, + ) -> iced_core::event::Status { + let state = tree.state.downcast_mut::(); + let bounds = layout.bounds(); + + if cursor.is_over(bounds) { + let fingers_pressed = state.fingers_pressed.len(); + + match event { + Event::Touch(touch::Event::FingerPressed { id, .. }) => { + state.fingers_pressed.insert(id); + } + + Event::Touch(touch::Event::FingerLifted { id, .. }) => { + state.fingers_pressed.remove(&id); + } + + _ => (), + } + + // Present a context menu on a right click event. + if self.context_menu.is_some() + && (right_button_released(&event) || (touch_lifted(&event) && fingers_pressed == 2)) + { + state.context_cursor = cursor.position().unwrap_or_default(); + + let menu_state = tree.children[1].state.downcast_mut::(); + menu_state.open = true; + menu_state.view_cursor = cursor; + + return event::Status::Captured; + } + } + + self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: iced_core::Layout<'_>, + _renderer: &crate::Renderer, + ) -> Option> { + let state = tree.state.downcast_ref::(); + + let Some(context_menu) = self.context_menu.as_mut() else { + return None; + }; + + if !tree.children[1].state.downcast_ref::().open { + return None; + } + + let mut bounds = layout.bounds(); + bounds.x = state.context_cursor.x; + bounds.y = state.context_cursor.y; + + Some( + crate::widget::menu::Menu { + tree: &mut tree.children[1], + menu_roots: context_menu, + bounds_expand: 16, + menu_overlays_parent: true, + close_condition: CloseCondition { + leave: false, + click_outside: true, + click_inside: true, + }, + item_width: ItemWidth::Uniform(240), + item_height: ItemHeight::Dynamic(40), + bar_bounds: bounds, + main_offset: -(bounds.height as i32), + cross_offset: 0, + root_bounds_list: vec![bounds], + path_highlight: Some(PathHighlight::MenuActive), + style: &crate::theme::menu_bar::MenuBarStyle::Default, + } + .overlay(), + ) + } +} + +impl<'a, Message: Clone + 'a> From> for crate::Element<'a, Message> { + fn from(widget: ContextMenu<'a, Message>) -> Self { + Self::new(widget) + } +} + +fn right_button_released(event: &Event) -> bool { + matches!( + event, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right,)) + ) +} + +fn touch_lifted(event: &Event) -> bool { + matches!(event, Event::Touch(touch::Event::FingerLifted { .. })) +} + +pub struct LocalState { + context_cursor: Point, + fingers_pressed: HashSet, +} diff --git a/src/widget/menu.rs b/src/widget/menu.rs index 2aab965a..cd30a17c 100644 --- a/src/widget/menu.rs +++ b/src/widget/menu.rs @@ -55,16 +55,19 @@ //! pub mod action; +pub use action::MenuAction as Action; + mod flex; pub mod key_bind; -pub mod menu_bar; + +mod menu_bar; +pub use menu_bar::MenuBar; +pub(crate) use menu_bar::MenuBarState; + mod menu_inner; -pub mod menu_tree; +mod menu_tree; +pub use menu_tree::{menu_items as items, menu_root as root, MenuItem as Item, MenuTree as Tree}; pub use crate::style::menu_bar::{Appearance, StyleSheet}; -/// A `MenuBar` collects `MenuTree`s and handles -pub type MenuBar<'a, Message, Renderer> = menu_bar::MenuBar<'a, Message, Renderer>; pub(crate) use menu_inner::Menu; pub use menu_inner::{CloseCondition, ItemHeight, ItemWidth, PathHighlight}; -/// Nested menu is essentially a tree of items, a menu is a collection of items -pub type MenuTree<'a, Message, Renderer> = menu_tree::MenuTree<'a, Message, Renderer>; diff --git a/src/widget/mod.rs b/src/widget/mod.rs index ed0e430c..501eb351 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -61,6 +61,9 @@ pub mod column { pub mod layer_container; pub use layer_container::{layer_container, LayerContainer}; +pub mod context_menu; +pub use context_menu::{context_menu, ContextMenu}; + pub mod dialog; pub use dialog::{dialog, Dialog}; diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index eed0f836..764f8050 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -8,13 +8,12 @@ use apply::Apply; use iced::{ clipboard::{dnd::DndAction, mime::AllowedMimeTypes}, - widget::{container, scrollable}, Background, Length, }; use iced_core::{Border, Color, Shadow}; -use crate::widget::Container; -use crate::{theme, widget::segmented_button, Theme}; +use crate::widget::{container, menu, scrollable, segmented_button, Container}; +use crate::{theme, Theme}; use super::dnd_destination::DragId; @@ -63,10 +62,7 @@ pub struct NavBar<'a, Message> { } impl<'a, Message: Clone + 'static> NavBar<'a, Message> { - pub fn context_menu( - mut self, - context_menu: Option>>, - ) -> Self { + pub fn context_menu(mut self, context_menu: Option>>) -> Self { self.segmented_button = self.segmented_button.context_menu(context_menu); self } diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index 6582b7a4..73f7b365 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -5,8 +5,9 @@ use super::model::{Entity, Model, Selectable}; use crate::iced_core::id::Internal; use crate::theme::{SegmentedButton as Style, THEME}; use crate::widget::dnd_destination::DragId; -use crate::widget::menu::menu_bar::{MenuBar, MenuBarState}; -use crate::widget::menu::{CloseCondition, ItemHeight, ItemWidth, MenuTree, PathHighlight}; +use crate::widget::menu::{ + self, CloseCondition, ItemHeight, ItemWidth, MenuBarState, PathHighlight, +}; use crate::widget::{icon, Icon}; use crate::{Element, Renderer}; use derive_setters::Setters; @@ -124,8 +125,7 @@ where pub(super) style: Style, /// The context menu to display when a context is activated #[setters(skip)] - pub(super) context_menu: - Option>>, + pub(super) context_menu: Option>>, /// Emits the ID of the item that was activated. #[setters(skip)] pub(super) on_activate: Option Message + 'static>>, @@ -192,22 +192,19 @@ where } } - pub fn context_menu( - mut self, - context_menu: Option>>, - ) -> Self + pub fn context_menu(mut self, context_menu: Option>>) -> Self where Message: 'static, { self.context_menu = context_menu.map(|menus| { - vec![MenuTree::with_children( + vec![menu::Tree::with_children( crate::widget::row::<'static, Message>(), menus, )] }); if let Some(ref mut context_menu) = self.context_menu { - context_menu.iter_mut().for_each(MenuTree::set_index); + context_menu.iter_mut().for_each(menu::Tree::set_index); } self