diff --git a/examples/application/src/main.rs b/examples/application/src/main.rs index 48270c69..0eba0a86 100644 --- a/examples/application/src/main.rs +++ b/examples/application/src/main.rs @@ -41,12 +41,6 @@ fn main() -> Result<(), Box> { ]; let settings = Settings::default() - .antialiasing(true) - .client_decorations(true) - .debug(false) - .default_icon_theme("Pop") - .default_text_size(16.0) - .scale_factor(1.0) .size(Size::new(1024., 768.)); cosmic::app::run::(settings, input)?; diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index d09375e9..6ef9097d 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -491,7 +491,7 @@ impl Application for Window { let mut widgets = Vec::with_capacity(2); if nav_bar_toggled { - let mut nav_bar = nav_bar(&self.nav_bar, Message::NavBar); + let mut nav_bar = nav_bar(&self.nav_bar, Message::NavBar).into_container(); if !self.is_condensed() { nav_bar = nav_bar.max_width(300); diff --git a/examples/nav-context/Cargo.toml b/examples/nav-context/Cargo.toml new file mode 100644 index 00000000..5ddad7fc --- /dev/null +++ b/examples/nav-context/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nav-context" +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/nav-context/src/main.rs b/examples/nav-context/src/main.rs new file mode 100644 index 00000000..a608cc22 --- /dev/null +++ b/examples/nav-context/src/main.rs @@ -0,0 +1,211 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Application API example + +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::{executor, iced, ApplicationExt, Element}; + +#[derive(Clone, Copy)] +pub enum Page { + Page1, + Page2, + Page3, + Page4, +} + +impl Page { + const fn as_str(self) -> &'static str { + match self { + Page::Page1 => "Page 1", + Page::Page2 => "Page 2", + Page::Page3 => "Page 3", + Page::Page4 => "Page 4", + } + } +} + +/// Runs application with these settings +#[rustfmt::skip] +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + let input = vec![ + (Page::Page1, "🖖 Hello from libcosmic.".into()), + (Page::Page2, "🌟 This is an example application.".into()), + (Page::Page3, "🚧 The libcosmic API is not stable yet.".into()), + (Page::Page4, "🚀 Copy the source code and experiment today!".into()), + ]; + + let settings = Settings::default() + .antialiasing(true) + .client_decorations(true) + .debug(false) + .default_icon_theme("Pop") + .default_text_size(16.0) + .scale_factor(1.0) + .size(Size::new(1024., 768.)); + + cosmic::app::run::(settings, input)?; + + Ok(()) +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + NavMenuAction(NavMenuAction), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum NavMenuAction { + MoveUp(nav_bar::Id), + MoveDown(nav_bar::Id), + Delete(nav_bar::Id), +} + +impl MenuAction for NavMenuAction { + type Message = cosmic::app::Message; + + fn message(&self, _entity: Option) -> Self::Message { + cosmic::app::Message::App(Message::NavMenuAction(*self)) + } +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + nav_model: nav_bar::Model, +} + +/// 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 = Vec<(Page, String)>; + + /// 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.AppDemo"; + + 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 nav_model = nav_bar::Model::default(); + + for (title, content) in input { + nav_model.insert().text(title.as_str()).data(content); + } + + nav_model.activate_position(0); + + let mut app = App { core, nav_model }; + + let command = app.update_title(); + + (app, command) + } + + /// Allows COSMIC to integrate with your application's [`nav_bar::Model`]. + fn nav_model(&self) -> Option<&nav_bar::Model> { + Some(&self.nav_model) + } + + /// The context menu to display for the given nav bar item ID. + fn nav_context_menu( + &self, + id: nav_bar::Id, + ) -> Option, cosmic::Renderer>>> { + 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)), + ], + )) + } + + /// Called when a navigation item is selected. + fn on_nav_select(&mut self, id: nav_bar::Id) -> Command { + self.nav_model.activate(id); + self.update_title() + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Command { + match message { + Message::NavMenuAction(message) => match message { + NavMenuAction::Delete(id) => self.nav_model.remove(id), + NavMenuAction::MoveUp(id) => { + if let Some(pos) = self.nav_model.position(id) { + if pos != 0 { + self.nav_model.position_set(id, pos - 1); + } + } + } + NavMenuAction::MoveDown(id) => { + if let Some(pos) = self.nav_model.position(id) { + self.nav_model.position_set(id, pos + 1); + } + } + }, + } + + Command::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + let page_content = self + .nav_model + .active_data::() + .map_or("No page selected", String::as_str); + + let text = cosmic::widget::text(page_content); + + let centered = cosmic::widget::container(text) + .width(iced::Length::Fill) + .height(iced::Length::Shrink) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + + Element::from(centered) + } +} + +impl App +where + Self: cosmic::Application, +{ + fn active_page_title(&mut self) -> &str { + self.nav_model + .text(self.nav_model.active()) + .unwrap_or("Unknown Page") + } + + fn update_title(&mut self) -> Command { + let header_title = self.active_page_title().to_owned(); + let window_title = format!("{header_title} — COSMIC AppDemo"); + self.set_header_title(header_title); + self.set_window_title(window_title) + } +} diff --git a/src/app/core.rs b/src/app/core.rs index 4fa48953..cd0c2b77 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -3,12 +3,13 @@ use std::collections::HashMap; -use crate::config::CosmicTk; +use crate::{config::CosmicTk, widget::nav_bar}; use cosmic_config::CosmicConfigEntry; use cosmic_theme::ThemeMode; use iced::window; use iced_core::window::Id; use palette::Srgba; +use slotmap::Key; use crate::Theme; @@ -16,6 +17,7 @@ use crate::Theme; #[derive(Clone)] pub struct NavBar { active: bool, + context_id: crate::widget::nav_bar::Id, toggled: bool, toggled_condensed: bool, } @@ -103,6 +105,7 @@ impl Default for Core { keyboard_nav: true, nav_bar: NavBar { active: true, + context_id: crate::widget::nav_bar::Id::null(), toggled: true, toggled_condensed: true, }, @@ -225,6 +228,14 @@ impl Core { self.nav_bar_set_toggled_condensed(!self.nav_bar.toggled_condensed); } + pub(crate) fn nav_bar_context(&self) -> nav_bar::Id { + self.nav_bar.context_id + } + + pub(crate) fn nav_bar_set_context(&mut self, id: nav_bar::Id) { + self.nav_bar.context_id = id; + } + pub fn nav_bar_set_toggled(&mut self, toggled: bool) { self.nav_bar.toggled = toggled; self.nav_bar_set_toggled_condensed(self.nav_bar.toggled); diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index ae7429df..8bcd98c2 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -48,6 +48,8 @@ pub enum Message { Minimize, /// Activates a navigation element from the nav bar. NavBar(nav_bar::Id), + /// Activates a context menu for an item from the nav bar. + NavBarContext(nav_bar::Id), /// Set scaling factor ScaleFactor(f32), /// Notification of system theme changes. @@ -367,7 +369,6 @@ impl Cosmic { Message::ContextDrawer(show) => { self.app.core_mut().window.show_context = show; - return self.app.on_context_drawer(); } Message::Drag => return command::drag(Some(self.app.main_window_id())), @@ -381,6 +382,11 @@ impl Cosmic { return self.app.on_nav_select(key); } + Message::NavBarContext(key) => { + self.app.core_mut().nav_bar_set_context(key); + return self.app.on_nav_context(key); + } + Message::ToggleNavBar => { self.app.core_mut().nav_bar_toggle(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 3fd4ad66..5274ffa3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -52,6 +52,7 @@ use iced::Subscription; use iced::{multi_window::Application as IcedApplication, window}; #[cfg(any(not(feature = "winit"), not(feature = "multi-window")))] use iced::{window, Application as IcedApplication}; +use iced_core::mouse; pub use message::Message; use url::Url; #[cfg(feature = "single-instance")] @@ -447,7 +448,7 @@ where window::Id::MAIN } - /// Allows overriding the default nav bar widget + /// Allows overriding the default nav bar widget. fn nav_bar(&self) -> Option>> { if !self.core().nav_bar_active() { return None; @@ -455,18 +456,28 @@ where let nav_model = self.nav_model()?; - let mut nav = crate::widget::nav_bar(nav_model, |entity| { - Message::Cosmic(cosmic::Message::NavBar(entity)) - }); + let mut nav = + crate::widget::nav_bar(nav_model, |id| Message::Cosmic(cosmic::Message::NavBar(id))) + .on_context(|id| Message::Cosmic(cosmic::Message::NavBarContext(id))) + .context_menu(self.nav_context_menu(self.core().nav_bar_context())) + .into_container() + // XXX both must be shrink to avoid flex layout from ignoring it + .width(iced::Length::Shrink) + .height(iced::Length::Shrink); if !self.core().is_condensed() { nav = nav.max_width(280); } - Some(Element::from( - // XXX both must be shrink to avoid flex layout from ignoring it - nav.width(iced::Length::Shrink).height(iced::Length::Shrink), - )) + Some(Element::from(nav)) + } + + /// Shows a context menu for the active nav bar item. + fn nav_context_menu( + &self, + id: nav_bar::Id, + ) -> Option, crate::Renderer>>> { + None } /// Allows COSMIC to integrate with your application's [`nav_bar::Model`]. @@ -482,11 +493,6 @@ where None } - // Called when context drawer is toggled - fn on_context_drawer(&mut self) -> iced::Command> { - iced::Command::none() - } - /// Called when the escape key is pressed. fn on_escape(&mut self) -> iced::Command> { iced::Command::none() @@ -497,6 +503,11 @@ where iced::Command::none() } + /// Called when a context menu is requested for a navigation item. + fn on_nav_context(&mut self, id: nav_bar::Id) -> iced::Command> { + iced::Command::none() + } + /// Called when the search function is requested. fn on_search(&mut self) -> iced::Command> { iced::Command::none() diff --git a/src/widget/menu.rs b/src/widget/menu.rs index b5ebac9f..2aab965a 100644 --- a/src/widget/menu.rs +++ b/src/widget/menu.rs @@ -64,6 +64,7 @@ pub mod menu_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/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index ff5feedc..14192b40 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -19,14 +19,14 @@ use iced_widget::core::{ Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget, }; -pub(super) struct MenuBarState { - pub(super) pressed: bool, - pub(super) view_cursor: Cursor, - pub(super) open: bool, - pub(super) active_root: Option, - pub(super) horizontal_direction: Direction, - pub(super) vertical_direction: Direction, - pub(super) menu_states: Vec, +pub(crate) struct MenuBarState { + pub(crate) pressed: bool, + pub(crate) view_cursor: Cursor, + pub(crate) open: bool, + pub(crate) active_root: Option, + pub(crate) horizontal_direction: Direction, + pub(crate) vertical_direction: Direction, + pub(crate) menu_states: Vec, } impl MenuBarState { pub(super) fn get_trimmed_indices(&self) -> impl Iterator + '_ { @@ -422,6 +422,7 @@ where tree, menu_roots: &mut self.menu_roots, bounds_expand: self.bounds_expand, + menu_overlays_parent: false, close_condition: self.close_condition, item_width: self.item_width, item_height: self.item_height, diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 3bb158cd..74c44606 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -221,11 +221,11 @@ impl Aod { /// only items inside the viewport will be displayed, /// when scrolling happens, this should be updated #[derive(Debug, Clone, Copy)] -struct MenuSlice { - start_index: usize, - end_index: usize, - lower_bound_rel: f32, - upper_bound_rel: f32, +pub(super) struct MenuSlice { + pub(super) start_index: usize, + pub(super) end_index: usize, + pub(super) lower_bound_rel: f32, + pub(super) upper_bound_rel: f32, } /// Menu bounds in overlay space @@ -295,7 +295,7 @@ pub(super) struct MenuState { menu_bounds: MenuBounds, } impl MenuState { - fn layout( + pub(super) fn layout( &self, overlay_offset: Vector, slice: MenuSlice, @@ -373,7 +373,7 @@ impl MenuState { )) } - fn slice( + pub(super) fn slice( &self, viewport_size: Size, overlay_offset: Vector, @@ -427,28 +427,30 @@ impl MenuState { } } -pub(super) struct Menu<'a, 'b, Message, Renderer> +pub(crate) struct Menu<'a, 'b, Message, Renderer> where Renderer: renderer::Renderer, { - pub(super) tree: &'b mut Tree, - pub(super) menu_roots: &'b mut Vec>, - pub(super) bounds_expand: u16, - pub(super) close_condition: CloseCondition, - pub(super) item_width: ItemWidth, - pub(super) item_height: ItemHeight, - pub(super) bar_bounds: Rectangle, - pub(super) main_offset: i32, - pub(super) cross_offset: i32, - pub(super) root_bounds_list: Vec, - pub(super) path_highlight: Option, - pub(super) style: &'b ::Style, + pub(crate) tree: &'b mut Tree, + pub(crate) menu_roots: &'b mut Vec>, + pub(crate) bounds_expand: u16, + /// Allows menu overlay items to overlap the parent + pub(crate) menu_overlays_parent: bool, + pub(crate) close_condition: CloseCondition, + pub(crate) item_width: ItemWidth, + pub(crate) item_height: ItemHeight, + pub(crate) bar_bounds: Rectangle, + pub(crate) main_offset: i32, + pub(crate) cross_offset: i32, + pub(crate) root_bounds_list: Vec, + pub(crate) path_highlight: Option, + pub(crate) style: &'b ::Style, } impl<'a, 'b, Message, Renderer> Menu<'a, 'b, Message, Renderer> where Renderer: renderer::Renderer, { - pub(super) fn overlay(self) -> overlay::Element<'b, Message, crate::Theme, Renderer> { + pub(crate) fn overlay(self) -> overlay::Element<'b, Message, crate::Theme, Renderer> { overlay::Element::new(Point::ORIGIN, Box::new(self)) } } @@ -746,7 +748,7 @@ fn pad_rectangle(rect: Rectangle, padding: Padding) -> Rectangle { } } -fn init_root_menu( +pub(super) fn init_root_menu( menu: &mut Menu<'_, '_, Message, Renderer>, renderer: &Renderer, shell: &mut Shell<'_, Message>, @@ -986,7 +988,7 @@ where let last_parent_bounds = last_menu_bounds.parent_bounds; let last_children_bounds = last_menu_bounds.children_bounds; - if last_parent_bounds.contains(overlay_cursor) + if (!menu.menu_overlays_parent && last_parent_bounds.contains(overlay_cursor)) // cursor is in the parent part || !last_children_bounds.contains(overlay_cursor) // cursor is outside diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index 16349cbf..dfb4f8fa 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -25,16 +25,16 @@ use crate::{theme, widget}; pub struct MenuTree<'a, Message, Renderer = crate::Renderer> { /// The menu tree will be flatten into a vector to build a linear widget tree, /// the `index` field is the index of the item in that vector - pub(super) index: usize, + pub(crate) index: usize, /// The item of the menu tree - pub(super) item: Element<'a, Message, crate::Theme, Renderer>, + pub(crate) item: Element<'a, Message, crate::Theme, Renderer>, /// The children of the menu tree - pub(super) children: Vec>, + pub(crate) children: Vec>, /// The width of the menu tree - pub(super) width: Option, + pub(crate) width: Option, /// The height of the menu tree - pub(super) height: Option, + pub(crate) height: Option, } impl<'a, Message, Renderer> MenuTree<'a, Message, Renderer> @@ -89,7 +89,7 @@ where /* Keep `set_index()` and `flattern()` recurse in the same order */ /// Set the index of each item - pub(super) fn set_index(&mut self) { + pub(crate) fn set_index(&mut self) { /// inner counting function. fn rec(mt: &mut MenuTree<'_, Message, Renderer>, count: &mut usize) { // keep items under the same menu line up @@ -108,7 +108,7 @@ where } /// Flatten the menu tree - pub(super) fn flattern(&'a self) -> Vec<&Self> { + pub(crate) fn flattern(&'a self) -> Vec<&Self> { /// Inner flattening function fn rec<'a, Message, Renderer>( mt: &'a MenuTree<'a, Message, Renderer>, diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index 7924e32a..eed0f836 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -13,6 +13,7 @@ use iced::{ }; use iced_core::{Border, Color, Shadow}; +use crate::widget::Container; use crate::{theme, widget::segmented_button, Theme}; use super::dnd_destination::DragId; @@ -23,30 +24,13 @@ pub type Model = segmented_button::SingleSelectModel; /// Navigation side panel for switching between views. /// /// For details on the model, see the [`segmented_button`] module for more details. -pub fn nav_bar( +pub fn nav_bar( model: &segmented_button::SingleSelectModel, on_activate: fn(segmented_button::Entity) -> Message, -) -> iced::widget::Container -where - Message: Clone + 'static, -{ - let theme = crate::theme::active(); - let space_s = theme.cosmic().space_s(); - let space_xxs = theme.cosmic().space_xxs(); - - segmented_button::vertical(model) - .button_height(32) - .button_padding([space_s, space_xxs, space_s, space_xxs]) - .button_spacing(space_xxs) - .spacing(space_xxs) - .on_activate(on_activate) - .style(crate::theme::SegmentedButton::TabBar) - .apply(scrollable) - .height(Length::Fill) - .apply(container) - .padding(space_xxs) - .height(Length::Fill) - .style(theme::Container::custom(nav_bar_style)) +) -> NavBar { + NavBar { + segmented_button: segmented_button::vertical(model).on_activate(on_activate), + } } /// Navigation side panel for switching between views. @@ -58,33 +42,104 @@ pub fn nav_bar_dnd( on_dnd_leave: impl Fn(segmented_button::Entity) -> Message + 'static, on_dnd_drop: impl Fn(segmented_button::Entity, Option, DndAction) -> Message + 'static, id: DragId, -) -> iced::widget::Container +) -> NavBar where Message: Clone + 'static, { - let theme = crate::theme::active(); - let space_s = theme.cosmic().space_s(); - let space_xxs = theme.cosmic().space_xxs(); + NavBar { + segmented_button: segmented_button::vertical(model) + .on_activate(on_activate) + .on_dnd_enter(on_dnd_enter) + .on_dnd_leave(on_dnd_leave) + .on_dnd_drop(on_dnd_drop) + .drag_id(id), + } +} - let nav_buttons = segmented_button::vertical(model) - .button_height(32) - .button_padding([space_s, space_xxs, space_s, space_xxs]) - .button_spacing(space_xxs) - .spacing(space_xxs) - .on_activate(on_activate) - .style(crate::theme::SegmentedButton::TabBar) - .on_dnd_enter(on_dnd_enter) - .on_dnd_leave(on_dnd_leave) - .on_dnd_drop(on_dnd_drop) - .drag_id(id); +#[must_use] +pub struct NavBar<'a, Message> { + segmented_button: + segmented_button::VerticalSegmentedButton<'a, segmented_button::SingleSelect, Message>, +} - nav_buttons - .apply(scrollable) - .height(Length::Fill) - .apply(container) - .padding(space_xxs) - .height(Length::Fill) - .style(theme::Container::custom(nav_bar_style)) +impl<'a, Message: Clone + 'static> NavBar<'a, Message> { + pub fn context_menu( + mut self, + context_menu: Option>>, + ) -> Self { + self.segmented_button = self.segmented_button.context_menu(context_menu); + self + } + + pub fn drag_id(mut self, id: DragId) -> Self { + self.segmented_button = self.segmented_button.drag_id(id); + self + } + + /// Pre-convert this widget into the [`Container`] widget that it becomes. + #[must_use] + pub fn into_container(self) -> Container<'a, Message, crate::Theme, crate::Renderer> { + Container::from(self) + } + + /// Emitted when a button is right-clicked. + pub fn on_context(mut self, on_context: T) -> Self + where + T: Fn(Id) -> Message + 'static, + { + self.segmented_button = self.segmented_button.on_context(on_context); + self + } + + /// Handle the dnd drop event. + pub fn on_dnd_drop( + mut self, + handler: impl Fn(Id, Option, DndAction) -> Message + 'static, + ) -> Self { + self.segmented_button = self.segmented_button.on_dnd_drop(handler); + self + } + + /// Handle the dnd enter event. + pub fn on_dnd_enter(mut self, handler: impl Fn(Id, Vec) -> Message + 'static) -> Self { + self.segmented_button = self.segmented_button.on_dnd_enter(handler); + self + } + + /// Handle the dnd leave event. + pub fn on_dnd_leave(mut self, handler: impl Fn(Id) -> Message + 'static) -> Self { + self.segmented_button = self.segmented_button.on_dnd_leave(handler); + self + } +} + +impl<'a, Message: Clone + 'static> From> + for Container<'a, Message, crate::Theme, crate::Renderer> +{ + fn from(this: NavBar<'a, Message>) -> Self { + let theme = crate::theme::active(); + let space_s = theme.cosmic().space_s(); + let space_xxs = theme.cosmic().space_xxs(); + + this.segmented_button + .button_height(32) + .button_padding([space_s, space_xxs, space_s, space_xxs]) + .button_spacing(space_xxs) + .spacing(space_xxs) + .style(crate::theme::SegmentedButton::TabBar) + .apply(scrollable) + .height(Length::Fill) + .apply(container) + .padding(space_xxs) + .height(Length::Fill) + .style(theme::Container::custom(nav_bar_style)) + } +} + +impl<'a, Message: Clone + 'static> From> for crate::Element<'a, Message> { + fn from(this: NavBar<'a, Message>) -> Self { + Container::from(this).into() + } } #[must_use] diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index bda9fcb9..68e6dd76 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -5,11 +5,14 @@ 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::{icon, Icon}; use crate::{Element, Renderer}; use derive_setters::Setters; use iced::clipboard::dnd::{self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent}; use iced::clipboard::mime::AllowedMimeTypes; +use iced::touch::Finger; use iced::{ alignment, event, keyboard, mouse, touch, Alignment, Background, Color, Command, Event, Length, Padding, Rectangle, Size, @@ -22,6 +25,7 @@ use iced_core::{Border, Gradient, Point, Renderer as IcedRenderer, Shadow, Text} use slotmap::{Key, SecondaryMap}; use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use std::mem; @@ -118,12 +122,18 @@ where /// Style to draw the widget in. #[setters(into)] pub(super) style: Style, + /// The context menu to display when a context is activated + #[setters(skip)] + pub(super) context_menu: + Option>>, /// Emits the ID of the item that was activated. #[setters(skip)] pub(super) on_activate: Option Message + 'static>>, #[setters(skip)] pub(super) on_close: Option Message + 'static>>, #[setters(skip)] + pub(super) on_context: Option Message + 'static>>, + #[setters(skip)] pub(super) on_dnd_drop: Option, String, DndAction) -> Message + 'static>>, pub(super) mimes: Vec, @@ -169,8 +179,10 @@ where spacing: 0, line_height: LineHeight::default(), style: Style::default(), + context_menu: None, on_activate: None, on_close: None, + on_context: None, on_dnd_drop: None, on_dnd_enter: None, on_dnd_leave: None, @@ -180,6 +192,27 @@ where } } + pub fn context_menu( + mut self, + context_menu: Option>>, + ) -> Self + where + Message: 'static, + { + self.context_menu = context_menu.map(|menus| { + vec![MenuTree::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); + } + + self + } + /// Emitted when a tab is pressed. pub fn on_activate(mut self, on_activate: T) -> Self where @@ -198,6 +231,15 @@ where self } + /// Emitted when a button is right-clicked. + pub fn on_context(mut self, on_context: T) -> Self + where + T: Fn(Entity) -> Message + 'static, + { + self.on_context = Some(Box::new(on_context)); + self + } + /// Check if an item is enabled. fn is_enabled(&self, key: Entity) -> bool { self.model.items.get(key).map_or(false, |item| item.enabled) @@ -211,7 +253,7 @@ where self.on_dnd_drop = Some(Box::new(move |entity, data, mime, action| { dnd_drop_handler(entity, D::try_from((data, mime)).ok(), action) })); - self.mimes = D::allowed().iter().map(|mime| mime.to_string()).collect(); + self.mimes = D::allowed().iter().cloned().collect(); self } @@ -501,20 +543,61 @@ where SelectionMode: Default, Message: 'static + Clone, { + fn children(&self) -> Vec { + let mut children = Vec::new(); + + // 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 tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { + #[allow(clippy::default_trait_access)] tree::State::new(LocalState { paragraphs: SecondaryMap::new(), text_hashes: SecondaryMap::new(), - ..LocalState::default() + buttons_visible: Default::default(), + buttons_offset: Default::default(), + collapsed: Default::default(), + focused: Default::default(), + focused_item: Default::default(), + hovered: Default::default(), + known_length: Default::default(), + internal_layout: Default::default(), + context_cursor: Point::default(), + show_context: Default::default(), + wheel_timestamp: Default::default(), + dnd_state: Default::default(), + fingers_pressed: Default::default(), }) } fn diff(&mut self, tree: &mut Tree) { let state = tree.state.downcast_mut::(); + for key in self.model.order.iter().copied() { if let Some(text) = self.model.text.get(key) { let (font, button_state) = @@ -557,6 +640,8 @@ where } } } + + // TODO: diff the context menu } fn size(&self) -> Size { @@ -708,6 +793,20 @@ where } if cursor_position.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); + } + + _ => (), + } + // Check for clicks on the previous and next tab buttons, when tabs are collapsed. if state.collapsed { // Check if the prev tab button was clicked. @@ -756,15 +855,11 @@ where if let Some(on_close) = self.on_close.as_ref() { if cursor_position .is_over(close_bounds(bounds, f32::from(self.close_icon.size))) + && (left_button_released(&event) + || (touch_lifted(&event) && fingers_pressed == 1)) { - if let Event::Mouse(mouse::Event::ButtonReleased( - mouse::Button::Left, - )) - | Event::Touch(touch::Event::FingerLifted { .. }) = event - { - shell.publish(on_close(key)); - return event::Status::Captured; - } + shell.publish(on_close(key)); + return event::Status::Captured; } // Emit close message if the tab is middle clicked. @@ -786,6 +881,27 @@ where return event::Status::Captured; } } + + // Present a context menu on a right click event. + if let Some(on_context) = self.on_context.as_ref() { + if right_button_released(&event) + || (touch_lifted(&event) && fingers_pressed == 2) + { + state.show_context = Some(key); + state.context_cursor = + cursor_position.position().unwrap_or_default(); + state.focused = true; + state.focused_item = Item::Tab(key); + + let menu_state = + tree.children[0].state.downcast_mut::(); + menu_state.open = true; + menu_state.view_cursor = cursor_position; + + shell.publish(on_context(key)); + return event::Status::Captured; + } + } } break; @@ -1355,11 +1471,59 @@ where fn overlay<'b>( &'b mut self, - _tree: &'b mut Tree, - _layout: iced_core::Layout<'_>, + tree: &'b mut Tree, + layout: iced_core::Layout<'_>, _renderer: &Renderer, ) -> Option> { - None + let state = tree.state.downcast_ref::(); + + let Some(entity) = state.show_context else { + return None; + }; + + let bounds = self + .variant_bounds(state, layout.bounds()) + .find_map(|item| match item { + ItemBounds::Button(e, bounds) if e == entity => Some(bounds), + _ => None, + }); + let Some(mut bounds) = bounds else { + return None; + }; + + let Some(context_menu) = self.context_menu.as_mut() else { + return None; + }; + + if !tree.children[0].state.downcast_ref::().open { + return None; + } + + bounds.x = state.context_cursor.x; + bounds.y = state.context_cursor.y; + + Some( + crate::widget::menu::Menu { + tree: &mut tree.children[0], + 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(), + ) } fn drag_destinations( @@ -1406,7 +1570,6 @@ where } /// State that is maintained by each individual widget. -#[derive(Default)] pub struct LocalState { /// Defines how many buttons to show at a time. pub(super) buttons_visible: usize, @@ -1428,10 +1591,16 @@ pub struct LocalState { paragraphs: SecondaryMap, /// Used to detect changes in text. text_hashes: SecondaryMap, + /// Location of cursor when context menu was opened. + context_cursor: Point, + /// Track whether an item is currently showing a context menu. + show_context: Option, /// Time since last tab activation from wheel movements. wheel_timestamp: Option, /// Dnd state pub dnd_state: crate::widget::dnd_destination::State, + /// Tracks multi-touch events + fingers_pressed: HashSet, } #[derive(Debug, Default, PartialEq)] @@ -1457,6 +1626,7 @@ impl operation::Focusable for LocalState { fn unfocus(&mut self) { self.focused = false; self.focused_item = Item::None; + self.show_context = None; } } @@ -1550,3 +1720,21 @@ fn draw_icon( viewport, ); } + +fn left_button_released(event: &Event) -> bool { + matches!( + event, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left,)) + ) +} + +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 { .. })) +}