diff --git a/Cargo.toml b/Cargo.toml index 07b73c19..23ceccb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -176,6 +176,7 @@ zbus = { workspace = true, optional = true } float-cmp = "0.10.0" ron = { workspace = true, optional = true } enumflags2 = "0.7.12" +roxmltree = "0.20" # Enable DBus feature on Linux targets [target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_vendor = "apple"))))'.dependencies] diff --git a/cosmic-icons b/cosmic-icons index 52520957..343c007f 160000 --- a/cosmic-icons +++ b/cosmic-icons @@ -1 +1 @@ -Subproject commit 5252095787cc96e2aed64604158f94e450703455 +Subproject commit 343c007f37cd71716e68f01c43ecf2764b7f7c47 diff --git a/examples/applet/src/window.rs b/examples/applet/src/window.rs index f77bf512..e1163f1b 100644 --- a/examples/applet/src/window.rs +++ b/examples/applet/src/window.rs @@ -35,7 +35,7 @@ pub enum Message { PopupClosed(Id), ToggleExampleRow(bool), Selected(usize), - Surface(cosmic::surface::Action), + Surface(cosmic::surface::Action), Toggle(bool), } @@ -76,9 +76,7 @@ impl cosmic::Application for Window { self.example_row = toggled; } Message::Surface(a) => { - return cosmic::task::message(cosmic::Action::Cosmic( - cosmic::app::Action::Surface(a), - )); + return cosmic::task::message(cosmic::Action::Surface(a)); } Message::Selected(i) => { self.selected = Some(i); diff --git a/examples/application/src/main.rs b/examples/application/src/main.rs index af4200c9..18ed32a4 100644 --- a/examples/application/src/main.rs +++ b/examples/application/src/main.rs @@ -98,7 +98,7 @@ pub enum Message { Input2(String), Ignore, ToggleHide, - Surface(cosmic::surface::Action), + Surface(cosmic::surface::Action), Hi, Hi2, Hi3, @@ -255,9 +255,7 @@ impl cosmic::Application for App { self.hidden = !self.hidden; } Message::Surface(a) => { - return cosmic::task::message(cosmic::Action::Cosmic( - cosmic::app::Action::Surface(a), - )); + return cosmic::task::message(cosmic::Action::Surface(a)); } Message::Hi => { dbg!("hi"); diff --git a/examples/context-menu/src/main.rs b/examples/context-menu/src/main.rs index 50c76bfb..e10bb48c 100644 --- a/examples/context-menu/src/main.rs +++ b/examples/context-menu/src/main.rs @@ -28,7 +28,7 @@ fn main() -> Result<(), Box> { pub enum Message { Clicked, WindowClose, - Surface(cosmic::surface::Action), + Surface(cosmic::surface::Action), ToggleHideContent, ToggleSomeAction, WindowNew, @@ -89,9 +89,7 @@ impl cosmic::Application for App { self.button_label = format!("Clicked {message:?}"); } Message::Surface(action) => { - return cosmic::task::message(cosmic::Action::Cosmic( - cosmic::app::Action::Surface(action), - )); + return cosmic::task::message(cosmic::Action::Surface(action)); } Message::WindowClose | Message::ToggleHideContent diff --git a/examples/open-dialog/src/main.rs b/examples/open-dialog/src/main.rs index b4b5343f..3a12aaa5 100644 --- a/examples/open-dialog/src/main.rs +++ b/examples/open-dialog/src/main.rs @@ -34,7 +34,7 @@ pub enum Message { OpenError(Arc), OpenFile, Selected(Url), - Surface(cosmic::surface::Action), + Surface(cosmic::surface::Action), } /// The [`App`] stores application-specific state. @@ -187,9 +187,7 @@ impl cosmic::Application for App { self.error_status = None; } Message::Surface(action) => { - return cosmic::task::message(cosmic::Action::Cosmic( - cosmic::app::Action::Surface(action), - )); + return cosmic::task::message(cosmic::Action::Surface(action)); } } diff --git a/iced b/iced index e891f34b..d12bb8e0 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit e891f34b3224b25fc4bdf286b3f9dba26c8abe5b +Subproject commit d12bb8e056712ab87efbdd4296261331772ec413 diff --git a/src/action.rs b/src/action.rs index 19e228b8..a3d583a7 100644 --- a/src/action.rs +++ b/src/action.rs @@ -16,6 +16,11 @@ pub const fn none() -> Action { Action::None } +/// Wrap a surface action, typically produced by a widget, to be handled by libcosmic. +pub const fn surface(action: crate::surface::Action) -> Action { + Action::Surface(action) +} + #[derive(Clone, Debug)] #[must_use] pub enum Action { @@ -26,10 +31,45 @@ pub enum Action { #[cfg(feature = "single-instance")] /// Dbus activation messages DbusActivation(dbus_activation::Message), + /// Surface (popup, subsurface, window, layer shell) requests, handled by libcosmic. + Surface(crate::surface::Action), /// Do nothing None, } +impl Action { + /// Map the application message inside, leaving libcosmic's own variants untouched. + #[must_use] + pub fn map(self, f: impl Fn(M) -> N + Clone + Send + Sync + 'static) -> Action { + match self { + Action::App(message) => Action::App(f(message)), + #[cfg(feature = "winit")] + Action::Cosmic(action) => Action::Cosmic(action), + #[cfg(feature = "single-instance")] + Action::DbusActivation(message) => Action::DbusActivation(message), + Action::Surface(action) => Action::Surface(action.map(f)), + Action::None => Action::None, + } + } +} + +impl Action> { + /// Collapse a doubly wrapped action, as produced by widgets whose message type is already + /// an [`Action`], into a single one. + #[must_use] + pub fn flatten(self) -> Action { + match self { + Action::App(action) => action, + #[cfg(feature = "winit")] + Action::Cosmic(action) => Action::Cosmic(action), + #[cfg(feature = "single-instance")] + Action::DbusActivation(message) => Action::DbusActivation(message), + Action::Surface(action) => Action::Surface(action.flatten()), + Action::None => Action::None, + } + } +} + impl From for Action { fn from(value: M) -> Self { Self::App(value) diff --git a/src/app/action.rs b/src/app/action.rs index 873622da..bf240dda 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -45,8 +45,6 @@ pub enum Action { /// Tracks updates to window suggested size. #[cfg(feature = "applet")] SuggestedBounds(Option), - /// Internal surface message - Surface(surface::Action), /// Notifies that a surface was closed. /// Any data relating to the surface should be cleaned up. SurfaceClosed(iced::window::Id), diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 90f6c1a7..22f07a08 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -153,7 +153,7 @@ where #[allow(clippy::too_many_lines)] pub fn surface_update( &mut self, - _surface_message: crate::surface::Action, + _surface_message: crate::surface::Action, ) -> iced::Task> { #[cfg(feature = "surface-message")] match _surface_message { @@ -194,18 +194,7 @@ where }; let settings = settings(); - if let Some(view) = view.and_then(|view| { - match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, - >>() { - Ok(v) => Some(v), - Err(err) => { - tracing::error!("Invalid view for subsurface view: {err:?}"); - - None - } - } - }) { + if let Some(view) = view { self.get_subsurface(settings, Some(Box::new(move |_| view()))) } else { self.get_subsurface(settings, None) @@ -301,17 +290,7 @@ where let settings = settings(); let live_settings = Box::new(move |_: &T| live_settings()); - if let Some(view) = view.and_then(|view| { - match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, - >>() { - Ok(v) => Some(v), - Err(err) => { - tracing::error!("Invalid view for subsurface view: {err:?}"); - None - } - } - }) { + if let Some(view) = view { self.get_popup(settings, live_settings, Some(Box::new(move |_| view()))) } else { self.get_popup(settings, live_settings, None) @@ -377,17 +356,7 @@ where return Task::none(); }; - if let Some(view) = view.and_then(|view| { - match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, - >>() { - Ok(v) => Some(v), - Err(err) => { - tracing::error!("Invalid view for Window: {err:?}"); - None - } - } - }) { + if let Some(view) = view { let settings = settings(); self.get_window( @@ -409,9 +378,7 @@ where } crate::surface::Action::Ignore => iced::Task::none(), - crate::surface::Action::Task(f) => { - f().map(|sm| crate::Action::Cosmic(Action::Surface(sm))) - } + crate::surface::Action::Task(f) => f().map(crate::Action::Surface), #[cfg(wayland_platform)] crate::surface::Action::AppLayerShell(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) @@ -474,17 +441,7 @@ where let live_settings = live_settings(); let live_settings = Box::new(move |_app: &T| live_settings); - if let Some(view) = view.and_then(|view| { - match std::sync::Arc::try_unwrap(view).ok()?.downcast:: Element<'static, crate::Action> + Send + Sync, - >>() { - Ok(v) => Some(v), - Err(err) => { - tracing::error!("Invalid view for layer surface: {err:?}"); - None - } - } - }) { + if let Some(view) = view { self.get_layer_shell(settings, live_settings, Some(Box::new(move |_| view()))) } else { self.get_layer_shell(settings, live_settings, None) @@ -515,6 +472,7 @@ where let mut task = match message { crate::Action::App(message) => self.app.update(message), crate::Action::Cosmic(message) => self.cosmic_update(message), + crate::Action::Surface(action) => self.surface_update(action), crate::Action::None => iced::Task::none(), #[cfg(feature = "single-instance")] crate::Action::DbusActivation(message) => { @@ -604,9 +562,13 @@ where #[cfg(wayland_platform)] iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland(event)) => { match event { - wayland::Event::Popup(wayland::PopupEvent::Done, _, id) - | wayland::Event::Layer(wayland::LayerEvent::Done, _, id) => { - return Some(Action::SurfaceClosed(id)); + wayland::Event::Popup(wayland::PopupEvent::Done, _, popup) => { + if popup == id { + return Some(Action::SurfaceClosed(popup)); + } + } + wayland::Event::Layer(wayland::LayerEvent::Done, _, layer) => { + return Some(Action::SurfaceClosed(layer)); } #[cfg(feature = "applet")] wayland::Event::Window( @@ -1227,8 +1189,6 @@ impl Cosmic { } } - Action::Surface(action) => return self.surface_update(action), - Action::SurfaceClosed(id) => { if self.opened_surfaces.get_mut(&id).is_some_and(|v| { *v = v.saturating_sub(1); diff --git a/src/app/mod.rs b/src/app/mod.rs index ded57543..91da0a16 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -394,7 +394,7 @@ where { nav = nav .window_id_maybe(self.core().main_window_id()) - .on_surface_action(|m| crate::Action::Cosmic(crate::app::Action::Surface(m))) + .on_surface_action(|action| crate::Action::Surface(action.flatten())) } let mut nav = nav .into_container() diff --git a/src/applet/mod.rs b/src/applet/mod.rs index 37635bf4..555ec837 100644 --- a/src/applet/mod.rs +++ b/src/applet/mod.rs @@ -295,7 +295,7 @@ impl Context { content: impl Into>, tooltip: impl Into>, has_popup: bool, - on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static, + on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static, parent_id: Option, ) -> crate::widget::wayland::tooltip::widget::Tooltip<'a, Message, Message> { let window_id = *TOOLTIP_WINDOW_ID; diff --git a/src/surface/action.rs b/src/surface/action.rs index b2864d52..a4308306 100644 --- a/src/surface/action.rs +++ b/src/surface/action.rs @@ -1,7 +1,8 @@ // Copyright 2025 System76 // SPDX-License-Identifier: MPL-2.0 -use super::Action; +use super::{Action, View}; +#[cfg(feature = "winit")] use crate::Application; use iced::{Rectangle, window}; @@ -10,30 +11,31 @@ use iced_runtime::platform_specific::wayland::CornerRadius; #[cfg(wayland_platform)] use iced_runtime::platform_specific::wayland::layer_surface::IcedMargin; use std::any::Any; + use std::sync::Arc; /// Used to produce a destroy popup message from within a widget. #[cfg(wayland_platform)] #[must_use] -pub fn destroy_popup(id: iced_core::window::Id) -> Action { +pub fn destroy_popup(id: iced_core::window::Id) -> Action { Action::DestroyPopup(id) } #[cfg(wayland_platform)] #[must_use] -pub fn destroy_subsurface(id: iced_core::window::Id) -> Action { +pub fn destroy_subsurface(id: iced_core::window::Id) -> Action { Action::DestroySubsurface(id) } #[cfg(wayland_platform)] #[must_use] -pub fn destroy_window(id: iced_core::window::Id) -> Action { +pub fn destroy_window(id: iced_core::window::Id) -> Action { Action::DestroyWindow(id) } #[cfg(wayland_platform)] #[must_use] -pub fn destroy_layer_shell(id: iced_core::window::Id) -> Action { +pub fn destroy_layer_shell(id: iced_core::window::Id) -> Action { Action::DestroyLayerShell(id) } @@ -65,7 +67,7 @@ pub fn app_window( live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static, settings: impl Fn(&mut App) -> window::Settings + Send + Sync + 'static, view: BoxedView, -) -> (window::Id, Action) { +) -> (window::Id, Action) { let id = window::Id::unique(); let boxed: Box window::Settings + Send + Sync + 'static> = @@ -99,7 +101,7 @@ pub fn simple_window( view: Option< impl Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, >, -) -> (window::Id, Action) { +) -> (window::Id, Action) { let id = window::Id::unique(); let boxed: Box window::Settings + Send + Sync + 'static> = Box::new(settings); @@ -114,16 +116,7 @@ pub fn simple_window( id, Arc::new(boxed), Arc::new(boxed_live), - view.map(|view| { - let boxed: Box< - dyn Fn() -> crate::Element<'static, crate::Action> - + Send - + Sync - + 'static, - > = Box::new(view); - let boxed: Box = Box::new(boxed); - Arc::new(boxed) - }), + view.map(|view| Arc::new(view) as View), ), ) } @@ -137,7 +130,7 @@ pub fn app_popup( + Sync + 'static, view: BoxedView, -) -> Action { +) -> Action { let boxed: Box< dyn Fn(&mut App) -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + Send @@ -171,7 +164,7 @@ pub fn simple_subsurface( view: Option< Box crate::Element<'static, crate::Action> + Send + Sync + 'static>, >, -) -> Action { +) -> Action { let boxed: Box< dyn Fn() -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + Send @@ -183,10 +176,7 @@ pub fn simple_subsurface( Action::Subsurface( Arc::new(boxed), Arc::new(Box::new(LiveSettings::default)), - view.map(|view| { - let boxed: Box = Box::new(view); - Arc::new(boxed) - }), + view.map(|view| Arc::from(view) as View), ) } @@ -202,7 +192,7 @@ pub fn simple_popup( view: Option< impl Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, >, -) -> Action { +) -> Action { let boxed: Box< dyn Fn() -> iced_runtime::platform_specific::wayland::popup::SctkPopupSettings + Send @@ -217,13 +207,7 @@ pub fn simple_popup( Action::Popup( Arc::new(boxed), Arc::new(boxed_live), - view.map(|view| { - let boxed: Box< - dyn Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, - > = Box::new(view); - let boxed: Box = Box::new(boxed); - Arc::new(boxed) - }), + view.map(|view| Arc::new(view) as View), ) } @@ -239,7 +223,7 @@ pub fn subsurface( + 'static, // XXX Boxed trait object is required for less cumbersome type inference, but we box it anyways. view: BoxedView, -) -> Action { +) -> Action { let boxed: Box< dyn Fn( &mut App, @@ -273,7 +257,7 @@ pub fn simple_layer_shell( view: Option< impl Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, >, -) -> Action { +) -> Action { let boxed: Box< dyn Fn() -> iced_runtime::platform_specific::wayland::layer_surface::SctkLayerSurfaceSettings @@ -287,13 +271,7 @@ pub fn simple_layer_shell( Action::LayerShell( Arc::new(boxed), Arc::new(boxed_live), - view.map(|view| { - let boxed: Box< - dyn Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, - > = Box::new(view); - let boxed: Box = Box::new(boxed); - Arc::new(boxed) - }), + view.map(|view| Arc::new(view) as View), ) } @@ -310,7 +288,7 @@ pub fn app_layer_shell( + 'static, // XXX Boxed trait object is required for less cumbersome type inference, but we box it anyways. view: BoxedView, -) -> Action { +) -> Action { let boxed: Box< dyn Fn( &mut App, diff --git a/src/surface/mod.rs b/src/surface/mod.rs index 7cb2476c..09243da3 100644 --- a/src/surface/mod.rs +++ b/src/surface/mod.rs @@ -11,19 +11,28 @@ use std::sync::Arc; type BoxedSetting = Arc>; +/// Produces the content of a surface created from within a widget. +/// +/// Typed on the message the widget publishes. +pub type View = + Arc crate::Element<'static, crate::Action> + Send + Sync + 'static>; + /// Ignore this message in your application. It will be intercepted. +/// +/// `M` is the message type of whoever created the action. The ones prefixed with `App` take the +/// application itself and are type-erased, the others carry a [`View`] typed on `M`. #[derive(Clone)] -pub enum Action { +pub enum Action { /// Create a subsurface with a view function accepting the App as a parameter AppSubsurface(BoxedSetting, BoxedSetting, Option), /// Create a subsurface with a view function - Subsurface(BoxedSetting, BoxedSetting, Option), + Subsurface(BoxedSetting, BoxedSetting, Option>), /// Destroy a subsurface with a view function DestroySubsurface(iced::window::Id), /// Create a popup with a view function accepting the App as a parameter AppPopup(BoxedSetting, BoxedSetting, Option), /// Create a popup - Popup(BoxedSetting, BoxedSetting, Option), + Popup(BoxedSetting, BoxedSetting, Option>), /// Destroy a subsurface with a view function DestroyPopup(iced::window::Id), /// Destroys the global tooltip popup subsurface @@ -41,7 +50,7 @@ pub enum Action { iced::window::Id, BoxedSetting, BoxedSetting, - Option, + Option>, ), /// Destroy a window DestroyWindow(iced::window::Id), @@ -50,7 +59,7 @@ pub enum Action { AppLayerShell(BoxedSetting, BoxedSetting, Option), /// Create a layer shell surface with a view function - LayerShell(BoxedSetting, BoxedSetting, Option), + LayerShell(BoxedSetting, BoxedSetting, Option>), /// Destroy a layer shell surface DestroyLayerShell(iced::window::Id), @@ -66,14 +75,74 @@ pub enum Action { }, Ignore, SyncLiveSettings(iced::window::Id), - Task(Arc Task + Send + Sync>), + Task(Arc Task> + Send + Sync>), } -pub fn surface_task(action: Action) -> Task> { - crate::task::message(crate::Action::Cosmic(crate::app::Action::Surface(action))) +impl Action { + /// Re-type the action for a component whose messages are wrapped by `f`. + /// + /// Similar to [`iced::Element::map`]. A component that maps a widget's messages must + /// map the widget's surface actions too. + #[must_use] + pub fn map(self, f: impl Fn(M) -> N + Clone + Send + Sync + 'static) -> Action { + self.map_actions(move |action| action.map(f.clone())) + } + + fn map_actions( + self, + g: impl Fn(crate::Action) -> crate::Action + Clone + Send + Sync + 'static, + ) -> Action { + let map_view = |view: Option>| -> Option> { + let view = view?; + let g = g.clone(); + Some(Arc::new(move || view().map(g.clone()))) + }; + match self { + Action::AppSubsurface(a, b, c) => Action::AppSubsurface(a, b, c), + Action::Subsurface(a, b, view) => Action::Subsurface(a, b, map_view(view)), + Action::DestroySubsurface(id) => Action::DestroySubsurface(id), + Action::AppPopup(a, b, c) => Action::AppPopup(a, b, c), + Action::Popup(a, b, view) => Action::Popup(a, b, map_view(view)), + Action::DestroyPopup(id) => Action::DestroyPopup(id), + Action::DestroyTooltipPopup => Action::DestroyTooltipPopup, + Action::AppWindow(id, a, b, c) => Action::AppWindow(id, a, b, c), + Action::Window(id, a, b, view) => Action::Window(id, a, b, map_view(view)), + Action::DestroyWindow(id) => Action::DestroyWindow(id), + Action::AppLayerShell(a, b, c) => Action::AppLayerShell(a, b, c), + Action::LayerShell(a, b, view) => Action::LayerShell(a, b, map_view(view)), + Action::DestroyLayerShell(id) => Action::DestroyLayerShell(id), + Action::ResponsiveMenuBar { + menu_bar, + limits, + size, + } => Action::ResponsiveMenuBar { + menu_bar, + limits, + size, + }, + Action::Ignore => Action::Ignore, + Action::SyncLiveSettings(id) => Action::SyncLiveSettings(id), + Action::Task(task) => Action::Task(Arc::new(move || { + let g = g.clone(); + task().map(move |action| action.map_actions(g.clone())) + })), + } + } } -impl std::fmt::Debug for Action { +impl Action> { + #[must_use] + pub fn flatten(self) -> Action { + self.map_actions(crate::Action::flatten) + } +} + +#[cfg(feature = "winit")] +pub fn surface_task(action: Action) -> Task> { + crate::task::message(crate::Action::Surface(action)) +} + +impl std::fmt::Debug for Action { #[cold] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -83,11 +152,11 @@ impl std::fmt::Debug for Action { .field(arg1) .field(arg2) .finish(), - Self::Subsurface(arg0, arg1, arg2) => f + Self::Subsurface(arg0, arg1, view) => f .debug_tuple("Subsurface") .field(arg0) .field(arg1) - .field(arg2) + .field(&view.as_ref().map(|_| "view")) .finish(), Self::DestroySubsurface(arg0) => { f.debug_tuple("DestroySubsurface").field(arg0).finish() @@ -98,11 +167,11 @@ impl std::fmt::Debug for Action { .field(arg1) .field(arg2) .finish(), - Self::Popup(arg0, arg1, arg2) => f + Self::Popup(arg0, arg1, view) => f .debug_tuple("Popup") .field(arg0) .field(arg1) - .field(arg2) + .field(&view.as_ref().map(|_| "view")) .finish(), Self::DestroyPopup(arg0) => f.debug_tuple("DestroyPopup").field(arg0).finish(), Self::DestroyTooltipPopup => f.debug_tuple("DestroyTooltipPopup").finish(), @@ -124,12 +193,12 @@ impl std::fmt::Debug for Action { .field(arg1) .field(arg2) .finish(), - Self::Window(id, arg0, arg1, arg2) => f + Self::Window(id, arg0, arg1, view) => f .debug_tuple("Window") .field(id) .field(arg0) .field(arg1) - .field(arg2) + .field(&view.as_ref().map(|_| "view")) .finish(), Self::DestroyWindow(arg0) => f.debug_tuple("DestroyWindow").field(arg0).finish(), Self::Task(_) => f.debug_tuple("Future").finish(), @@ -139,11 +208,11 @@ impl std::fmt::Debug for Action { .field(arg1) .field(arg2) .finish(), - Self::LayerShell(arg, arg1, arg2) => f + Self::LayerShell(arg0, arg1, view) => f .debug_tuple("LayerShell") - .field(arg) + .field(arg0) .field(arg1) - .field(arg2) + .field(&view.as_ref().map(|_| "view")) .finish(), Self::DestroyLayerShell(arg0) => { f.debug_tuple("DestroyLayerShell").field(arg0).finish() diff --git a/src/theme/style/button.rs b/src/theme/style/button.rs index 76798104..f361512d 100644 --- a/src/theme/style/button.rs +++ b/src/theme/style/button.rs @@ -25,6 +25,7 @@ pub enum Button { IconVertical, Image, Link, + LinkActive, ListItem([f32; 4]), MenuFolder, MenuItem, @@ -121,6 +122,13 @@ pub fn appearance( corner_radii = &cosmic.corner_radii.radius_0; } + Button::LinkActive => { + appearance.background = Some(Background::Color(cosmic.text_button.hover.into())); + appearance.icon_color = Some(cosmic.accent_text_color().into()); + appearance.text_color = Some(cosmic.accent_text_color().into()); + corner_radii = &cosmic.corner_radii.radius_xs; + } + Button::Custom { .. } => (), Button::AppletMenu => { let (background, _, _) = color(&cosmic.text_button); diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 15c78ef8..2169c5ba 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -33,6 +33,9 @@ pub fn context_menu<'a, Message: 'static + Clone>( }), close_on_escape: true, window_id: window::Id::RESERVED, + item_width: ItemWidth::Uniform(240), + on_open: None, + on_close: None, on_surface_action: None, }; @@ -53,12 +56,33 @@ pub struct ContextMenu<'a, Message> { context_menu: Option>>, pub window_id: window::Id, pub close_on_escape: bool, + /// Width of each menu item, and therefore of the menu. + pub item_width: ItemWidth, + /// Emitted when the menu opens, so the application can mark what was right-clicked. + #[setters(strip_option)] + pub on_open: Option, + /// Emitted when the menu closes by any path, including the compositor dismissing it. + #[setters(strip_option)] + pub on_close: Option, #[setters(skip)] pub(crate) on_surface_action: - Option Message + Send + Sync + 'static>>, + Option) -> Message + Send + Sync + 'static>>, } impl ContextMenu<'_, Message> { + /// Publish `on_open`/`on_close` when the open state changed since the last report. + fn report_open_state(&self, state: &mut LocalState, shell: &mut iced_core::Shell<'_, Message>) { + let open = state.menu_bar_state.inner.with_data(|d| d.open); + if open == state.reported_open { + return; + } + state.reported_open = open; + let message = if open { &self.on_open } else { &self.on_close }; + if let Some(message) = message.clone() { + shell.publish(message); + } + } + #[cfg(wayland_platform)] #[allow(clippy::too_many_lines)] fn create_popup( @@ -91,16 +115,12 @@ impl ContextMenu<'_, Message> { shell.publish(self.on_surface_action.as_ref().unwrap()(destroy_popup(id))); state.view_cursor = view_cursor; - ( - id, - layout.children().map(|lo| lo.bounds()).collect::>(), - ) - } else { - ( - window::Id::unique(), - layout.children().map(|lo| lo.bounds()).collect(), - ) } + // A fresh id per popup, so the old popup's Done cannot be mistaken for the new one's + ( + window::Id::unique(), + layout.children().map(|lo| lo.bounds()).collect::>(), + ) }); let Some(context_menu) = self.context_menu.as_mut() else { return; @@ -116,7 +136,7 @@ impl ContextMenu<'_, Message> { click_outside: true, click_inside: true, }, - item_width: ItemWidth::Uniform(240), + item_width: self.item_width, item_height: ItemHeight::Dynamic(40), bar_bounds: bounds, main_offset: -(bounds.height as i32), @@ -215,7 +235,7 @@ impl ContextMenu<'_, Message> { pub fn on_surface_action( mut self, - handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, + handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, ) -> Self { self.on_surface_action = Some(Arc::new(handler)); self @@ -235,6 +255,7 @@ impl Widget context_cursor: Point::default(), fingers_pressed: Default::default(), menu_bar_state: Default::default(), + reported_open: false, }) } @@ -269,9 +290,11 @@ impl Widget fn diff(&mut self, tree: &mut Tree) { tree.diff_children(std::slice::from_mut(&mut self.content)); let state = tree.state.downcast_mut::(); - state.menu_bar_state.inner.with_data_mut(|inner| { - menu_roots_diff(self.context_menu.as_mut().unwrap(), &mut inner.tree); - }); + if let Some(context_menu) = self.context_menu.as_mut() { + state.menu_bar_state.inner.with_data_mut(|inner| { + menu_roots_diff(context_menu, &mut inner.tree); + }); + } // if let Some(ref mut context_menus) = self.context_menu { // for (menu, tree) in context_menus @@ -319,6 +342,38 @@ impl Widget ); } + fn mouse_interaction( + &self, + tree: &Tree, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &iced::Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn drag_destinations( + &self, + tree: &Tree, + layout: iced_core::Layout<'_>, + renderer: &crate::Renderer, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, + ) { + self.content.as_widget().drag_destinations( + &tree.children[0], + layout, + renderer, + dnd_rectangles, + ); + } + fn operate( &mut self, tree: &mut Tree, @@ -346,6 +401,20 @@ impl Widget let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); + // The compositor dismissed our popup: nothing else tells this state about it. + #[cfg(wayland_platform)] + if let iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland( + iced::event::wayland::Event::Popup(iced::event::wayland::PopupEvent::Done, _, popup), + )) = event + { + state.menu_bar_state.inner.with_data_mut(|d| { + if d.popup_id.get(&self.window_id) == Some(popup) { + d.popup_id.remove(&self.window_id); + d.reset(); + } + }); + } + // XXX this should reset the state if there are no other copies of the state, which implies no dropdown menus open. let reset = self.window_id != window::Id::NONE && state @@ -425,7 +494,9 @@ impl Widget self.create_popup(layout, cursor, renderer, shell, viewport, state); } + shell.request_redraw(); shell.capture_event(); + self.report_open_state(tree.state.downcast_mut::(), shell); return; } else if !was_open && right_button_released(event) || (touch_lifted(event)) @@ -461,62 +532,76 @@ impl Widget shell, viewport, ); + self.report_open_state(tree.state.downcast_mut::(), shell); } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, - layout: iced_core::Layout<'_>, - _renderer: &crate::Renderer, - _viewport: &iced::Rectangle, + layout: iced_core::Layout<'b>, + renderer: &crate::Renderer, + viewport: &iced::Rectangle, translation: Vector, ) -> Option> { + // The wrapped content's overlays (tooltips, dropdowns, ...) always pass through + let content = self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ); + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && self.window_id != window::Id::NONE && self.on_surface_action.is_some() { - return None; + return content; } let state = tree.state.downcast_ref::(); - - let context_menu = self.context_menu.as_mut()?; - + let Some(context_menu) = self.context_menu.as_mut() else { + return content; + }; if !state.menu_bar_state.inner.with_data(|state| state.open) { - return None; + return content; } - let mut bounds = layout.bounds(); - bounds.x = state.context_cursor.x; - bounds.y = state.context_cursor.y; - Some( - crate::widget::menu::Menu { - tree: state.menu_bar_state.clone(), - menu_roots: std::borrow::Cow::Owned(context_menu.clone()), - 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: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default), - position: Point::new(translation.x, translation.y), - is_overlay: true, - window_id: window::Id::NONE, - depth: 0, - on_surface_action: None, + // Anchor the menu to a 1x1 rectangle at the click, like the popup path does + let bounds = iced::Rectangle::new(state.context_cursor, Size::new(1.0, 1.0)); + let menu = crate::widget::menu::Menu { + tree: state.menu_bar_state.clone(), + menu_roots: std::borrow::Cow::Owned(context_menu.clone()), + bounds_expand: 16, + menu_overlays_parent: true, + close_condition: CloseCondition { + leave: false, + click_outside: true, + click_inside: true, + }, + item_width: self.item_width, + item_height: ItemHeight::Dynamic(40), + bar_bounds: bounds, + main_offset: 0, + cross_offset: 0, + root_bounds_list: vec![bounds], + path_highlight: Some(PathHighlight::MenuActive), + style: std::borrow::Cow::Borrowed(&crate::theme::menu_bar::MenuBarStyle::Default), + position: Point::new(translation.x, translation.y), + is_overlay: true, + window_id: window::Id::NONE, + depth: 0, + on_surface_action: None, + } + .overlay(); + + Some(match content { + Some(content) => { + iced_core::overlay::Group::with_children(vec![content, menu]).overlay() } - .overlay(), - ) + None => menu, + }) } #[cfg(feature = "a11y")] @@ -560,4 +645,5 @@ pub struct LocalState { context_cursor: Point, fingers_pressed: HashSet, menu_bar_state: MenuBarState, + reported_open: bool, } diff --git a/src/widget/dropdown/mod.rs b/src/widget/dropdown/mod.rs index d0ec175a..a5f0162b 100644 --- a/src/widget/dropdown/mod.rs +++ b/src/widget/dropdown/mod.rs @@ -44,7 +44,7 @@ pub fn popup_dropdown< selected: Option, on_selected: impl Fn(usize) -> Message + Send + Sync + 'static, _parent_id: window::Id, - _on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, + _on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, _map_action: impl Fn(Message) -> AppMessage + Send + Sync + 'static, ) -> Dropdown<'a, S, Message, AppMessage> { let dropdown: Dropdown<'_, S, Message, AppMessage> = diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index 994b28dd..7d8d1dfc 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -54,7 +54,8 @@ where #[setters(strip_option)] font: Option, #[setters(skip)] - on_surface_action: Option Message + Send + Sync + 'static>>, + on_surface_action: + Option) -> Message + Send + Sync + 'static>>, #[setters(skip)] action_map: Option AppMessage + 'static + Send + Sync>>, #[setters(strip_option)] @@ -108,7 +109,7 @@ where pub fn with_popup( self, parent_id: window::Id, - on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, + on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, action_map: impl Fn(Message) -> NewAppMessage + Send + Sync + 'static, ) -> Dropdown<'a, S, Message, NewAppMessage> { let Self { @@ -551,7 +552,9 @@ pub fn update< selections: &[S], state: impl FnOnce() -> &'a mut State, _window_id: Option, - on_surface_action: Option Message + Send + Sync + 'static>>, + on_surface_action: Option< + Arc) -> Message + Send + Sync + 'static>, + >, action_map: Option AppMessage + Send + Sync + 'static>>, icons: &[icon::Handle], gap: f32, diff --git a/src/widget/icon/handle.rs b/src/widget/icon/handle.rs index 7e0bab02..1fe334f7 100644 --- a/src/widget/icon/handle.rs +++ b/src/widget/icon/handle.rs @@ -5,8 +5,10 @@ use super::Icon; use crate::widget::{image, svg}; use std::borrow::Cow; use std::ffi::OsStr; +use std::fs::File; use std::hash::Hash; -use std::path::PathBuf; +use std::io::Read; +use std::path::{Path, PathBuf}; #[must_use] #[derive(Clone, Debug, Hash, derive_setters::Setters)] @@ -31,6 +33,52 @@ pub enum Data { Svg(svg::Handle), } +enum SvgSource { + Path, + Bytes(Vec), +} + +fn svg_source(path: &Path) -> Option { + if path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) + { + return Some(SvgSource::Path); + } + + let Ok(mut file) = File::open(path) else { + return None; + }; + + let Ok(metadata) = file.metadata() else { + return None; + }; + const MAX_SVG_SIZE: u64 = 16 * 1024 * 1024; + if !metadata.file_type().is_file() || metadata.len() > MAX_SVG_SIZE { + return None; + } + + let mut prefix = [0; 32]; + let Ok(length) = file.read(&mut prefix) else { + return None; + }; + let prefix = &prefix[..length]; + + if ::image::guess_format(prefix).is_ok() { + return None; + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + bytes.extend_from_slice(prefix); + if file.read_to_end(&mut bytes).is_err() { + return None; + } + + let document = roxmltree::Document::parse(std::str::from_utf8(&bytes).ok()?).ok()?; + (document.root_element().tag_name().name() == "svg").then_some(SvgSource::Bytes(bytes)) +} + /// Create an icon handle from its path. pub fn from_path(path: PathBuf) -> Handle { Handle { @@ -38,10 +86,10 @@ pub fn from_path(path: PathBuf) -> Handle { .file_stem() .and_then(OsStr::to_str) .is_some_and(|name| name.ends_with("-symbolic")), - data: if path.extension().is_some_and(|ext| ext == OsStr::new("svg")) { - Data::Svg(svg::Handle::from_path(path)) - } else { - Data::Image(image::Handle::from_path(path)) + data: match svg_source(&path) { + Some(SvgSource::Path) => Data::Svg(svg::Handle::from_path(path)), + Some(SvgSource::Bytes(bytes)) => Data::Svg(svg::Handle::from_memory(bytes)), + None => Data::Image(image::Handle::from_path(path)), }, } } diff --git a/src/widget/icon/named.rs b/src/widget/icon/named.rs index 89050301..118b4f12 100644 --- a/src/widget/icon/named.rs +++ b/src/widget/icon/named.rs @@ -3,7 +3,6 @@ use super::{Handle, Icon}; use std::borrow::Cow; -use std::ffi::OsStr; use std::path::PathBuf; use std::sync::Arc; @@ -138,11 +137,7 @@ impl Named { Handle { symbolic: self.symbolic, data: if let Some(path) = self.path() { - if path.extension().is_some_and(|ext| ext == OsStr::new("svg")) { - super::Data::Svg(iced_core::svg::Handle::from_path(path)) - } else { - super::Data::Image(iced_core::image::Handle::from_path(path)) - } + super::from_path(path).data } else { super::bundle::get(&name).unwrap_or_else(|| { let bytes: &'static [u8] = &[]; diff --git a/src/widget/menu.rs b/src/widget/menu.rs index 17d47cdd..6ddb8f37 100644 --- a/src/widget/menu.rs +++ b/src/widget/menu.rs @@ -71,8 +71,8 @@ pub mod menu_column; mod menu_inner; mod menu_tree; pub use menu_tree::{ - MenuItem as Item, MenuTree as Tree, menu_button, menu_items as items, menu_root as root, - nav_context, + Entry, IconSlot, MenuItem as Item, MenuTree as Tree, menu_button, menu_items as items, + menu_root as root, nav_context, }; pub use crate::style::menu_bar::{Appearance, StyleSheet}; diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index 50dc9b64..b20fc94a 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -176,7 +176,7 @@ pub struct MenuBar { #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, pub(crate) on_surface_action: - Option Message + Send + Sync + 'static>>, + Option) -> Message + Send + Sync + 'static>>, } impl MenuBar @@ -328,7 +328,7 @@ where #[must_use] pub fn on_surface_action( mut self, - handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, + handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, ) -> Self { self.on_surface_action = Some(Arc::new(handler)); self @@ -377,13 +377,12 @@ where state.active_root.clear(); shell.publish(surface_action(destroy_popup(id))); state.view_cursor = view_cursor; - (id, layout.children().map(|lo| lo.bounds()).collect()) - } else { - ( - window::Id::unique(), - layout.children().map(|lo| lo.bounds()).collect(), - ) } + // A fresh id per popup, so the old popup's Done cannot be mistaken for the new one's + ( + window::Id::unique(), + layout.children().map(|lo| lo.bounds()).collect(), + ) }); let mut popup_menu: Menu<'static, _> = Menu { @@ -581,6 +580,21 @@ where let my_state = tree.state.downcast_mut::(); + // The compositor dismissed our popup: nothing else tells this state about it. + #[cfg(wayland_platform)] + if let iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland( + iced::event::wayland::Event::Popup(iced::event::wayland::PopupEvent::Done, _, popup), + )) = event + { + my_state.inner.with_data_mut(|d| { + if d.popup_id.get(&self.window_id) == Some(popup) { + // submenus were dismissed with it + d.popup_id.clear(); + d.reset(); + } + }); + } + // XXX this should reset the state if there are no other copies of the state, which implies no dropdown menus open. let reset = self.window_id != window::Id::NONE && my_state diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 0bc3f6cf..e9e585ba 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -455,7 +455,7 @@ pub(crate) struct Menu<'b, Message: std::clone::Clone> { pub(crate) window_id: window::Id, pub(crate) depth: usize, pub(crate) on_surface_action: - Option Message + Send + Sync + 'static>>, + Option) -> Message + Send + Sync + 'static>>, } impl<'b, Message: Clone + 'static> Menu<'b, Message> { pub(crate) fn overlay(self) -> overlay::Element<'b, Message, crate::Theme, crate::Renderer> { diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index bd4f1e6c..36943759 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -180,6 +180,84 @@ pub enum MenuItem>> { Folder(L, Vec>), /// Represents a divider between menu items. Divider, + /// A menu entry with every option available; see [`Entry`]. + Entry(Entry), +} + +impl>> MenuItem { + /// Create an [`Entry`] menu item, configure it with the builder methods on [`Entry`]. + pub fn entry(label: L, action: A) -> Self { + MenuItem::Entry(Entry::new(label, action)) + } +} + +/// The leading icon column of a menu entry. +#[derive(Clone, Debug, Default)] +pub enum IconSlot { + /// No icon and no space reserved for one. + #[default] + None, + /// No icon, but the space for an icon is reserved (indented entry) + Reserved, + /// An icon. + Icon(icon::Handle), +} + +impl From> for IconSlot { + fn from(icon: Option) -> Self { + icon.map_or(IconSlot::None, IconSlot::Icon) + } +} + +/// A menu entry: label, optional leading icon, optional check column, enabled state and action. +#[derive(Clone)] +pub struct Entry { + label: L, + icon: IconSlot, + /// `Some` draws the check column + checked: Option, + enabled: bool, + action: A, +} + +impl Entry { + pub fn new(label: L, action: A) -> Self { + Self { + label, + icon: IconSlot::None, + checked: None, + enabled: true, + action, + } + } + + /// Draw a leading icon + #[must_use] + pub fn icon(mut self, icon: icon::Handle) -> Self { + self.icon = IconSlot::Icon(icon); + self + } + + /// Draw no icon, but resever the space + #[must_use] + pub fn reserve_icon(mut self) -> Self { + self.icon = IconSlot::Reserved; + self + } + + /// Show a check column, ticked when `checked` is true, empty sapce when false + #[must_use] + pub fn checked(mut self, checked: bool) -> Self { + self.checked = Some(checked); + self + } + + /// Disabled entries are drawn dimmed and do not react to presses + #[must_use] + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } } /// Create a root menu item. @@ -201,6 +279,84 @@ where .class(theme::Button::MenuRoot) } +fn entry_tree< + A: MenuAction, + L: Into>, + Message: Clone + 'static, +>( + entry: Entry, + key_binds: &HashMap, + key_class: theme::Text, +) -> MenuTree { + let Entry { + label, + icon, + checked, + enabled, + action, + } = entry; + let spacing = crate::theme::spacing(); + let key = key_binds + .iter() + .find(|(_, a)| **a == action) + .map_or_else(String::new, |(k, _)| k.to_string()); + + let mut items: Vec> = Vec::with_capacity(7); + + if let Some(checked) = checked { + items.push(if checked { + widget::icon::from_name("object-select-symbolic") + .size(16) + .icon() + .class(theme::Svg::Custom(Rc::new(|theme| { + iced_widget::svg::Style { + color: Some(theme.cosmic().accent_text_color().into()), + } + }))) + .width(Length::Fixed(16.0)) + .into() + } else { + widget::space::horizontal() + .width(Length::Fixed(16.0)) + .into() + }); + items.push(widget::space::horizontal().width(spacing.space_xxs).into()); + } + + match icon { + IconSlot::Icon(icon) => { + items.push(widget::icon::icon(icon).size(14).into()); + items.push(widget::space::horizontal().width(spacing.space_xxs).into()); + } + IconSlot::Reserved => { + items.push( + widget::space::horizontal() + .width(Length::Fixed(14.0)) + .into(), + ); + items.push(widget::space::horizontal().width(spacing.space_xxs).into()); + } + IconSlot::None => {} + } + + let ellipsize = + iced_core::text::Ellipsize::Middle(iced_core::text::EllipsizeHeightLimit::Lines(1)); + items.push(widget::text(label.into()).ellipsize(ellipsize).into()); + items.push(widget::space::horizontal().into()); + items.push( + widget::text(key) + .class(key_class) + .ellipsize(ellipsize) + .into(), + ); + + let mut button = menu_button(items); + if enabled { + button = button.on_press(action.message()); + } + MenuTree::from(Element::from(button)) +} + /// Create a list of menu items from a vector of `MenuItem`. /// /// The `MenuItem` can be either an action or a separator. @@ -220,15 +376,6 @@ pub fn menu_items< key_binds: &HashMap, children: Vec>, ) -> Vec> { - fn find_key(action: &A, key_binds: &HashMap) -> String { - for (key_bind, key_action) in key_binds { - if action == key_action { - return key_bind.to_string(); - } - } - String::new() - } - fn key_style(theme: &crate::Theme) -> TextStyle { let mut color = theme.cosmic().background(theme.transparent).component.on; color.alpha *= 0.75; @@ -246,117 +393,25 @@ pub fn menu_items< .enumerate() .flat_map(|(i, item)| { let mut trees = vec![]; - let spacing = crate::theme::spacing(); match item { MenuItem::Button(label, icon, action) => { - let l: Cow<'static, str> = label.into(); - let key = find_key(&action, key_binds); - let mut items = vec![ - widget::text(l) - .ellipsize(iced_core::text::Ellipsize::Middle( - iced_core::text::EllipsizeHeightLimit::Lines(1), - )) - .into(), - widget::space::horizontal().into(), - widget::text(key) - .class(key_class) - .ellipsize(iced_core::text::Ellipsize::Middle( - iced_core::text::EllipsizeHeightLimit::Lines(1), - )) - .into(), - ]; - - if let Some(icon) = icon { - items.insert(0, widget::icon::icon(icon).size(14).into()); - items.insert( - 1, - widget::space::horizontal().width(spacing.space_xxs).into(), - ); - } - - let menu_button = menu_button(items).on_press(action.message()); - - trees.push(MenuTree::::from(Element::from(menu_button))); + let mut entry = Entry::new(label, action); + entry.icon = icon.into(); + trees.push(entry_tree(entry, key_binds, key_class.clone())); } MenuItem::ButtonDisabled(label, icon, action) => { - let l: Cow<'static, str> = label.into(); - - let key = find_key(&action, key_binds); - - let mut items = vec![ - widget::text(l) - .ellipsize(iced_core::text::Ellipsize::Middle( - iced_core::text::EllipsizeHeightLimit::Lines(1), - )) - .into(), - widget::space::horizontal().into(), - widget::text(key) - .ellipsize(iced_core::text::Ellipsize::Middle( - iced_core::text::EllipsizeHeightLimit::Lines(1), - )) - .class(key_class) - .into(), - ]; - - if let Some(icon) = icon { - items.insert(0, widget::icon::icon(icon).size(14).into()); - items.insert( - 1, - widget::space::horizontal().width(spacing.space_xxs).into(), - ); - } - - let menu_button = menu_button(items); - - trees.push(MenuTree::::from(Element::from(menu_button))); + let mut entry = Entry::new(label, action).enabled(false); + entry.icon = icon.into(); + trees.push(entry_tree(entry, key_binds, key_class.clone())); } MenuItem::CheckBox(label, icon, value, action) => { - let key = find_key(&action, key_binds); - let mut items = vec![ - if value { - widget::icon::from_name("object-select-symbolic") - .size(16) - .icon() - .class(theme::Svg::Custom(Rc::new(|theme| { - iced_widget::svg::Style { - color: Some(theme.cosmic().accent_text_color().into()), - } - }))) - .width(Length::Fixed(16.0)) - .into() - } else { - widget::space::horizontal() - .width(Length::Fixed(16.0)) - .into() - }, - widget::space::horizontal().width(spacing.space_xxs).into(), - widget::text(label) - .ellipsize(iced_core::text::Ellipsize::Middle( - iced_core::text::EllipsizeHeightLimit::Lines(1), - )) - .align_x(iced::Alignment::Start) - .into(), - widget::space::horizontal().into(), - widget::text(key) - .class(key_class) - .ellipsize(iced_core::text::Ellipsize::Middle( - iced_core::text::EllipsizeHeightLimit::Lines(1), - )) - .into(), - ]; - - if let Some(icon) = icon { - items.insert( - 1, - widget::space::horizontal().width(spacing.space_xxs).into(), - ); - items.insert(2, widget::icon::icon(icon).size(14).into()); - } - - trees.push(MenuTree::from(Element::from( - menu_button(items).on_press(action.message()), - ))); + let mut entry = Entry::new(label, action).checked(value); + entry.icon = icon.into(); + trees.push(entry_tree(entry, key_binds, key_class.clone())); + } + MenuItem::Entry(entry) => { + trees.push(entry_tree(entry, key_binds, key_class.clone())); } MenuItem::Folder(label, children) => { let l: Cow<'static, str> = label.into(); diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index 14790866..84dd4a08 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -159,7 +159,7 @@ impl<'a, Message: Clone + 'static> NavBar<'a, Message> { #[must_use] pub fn on_surface_action( mut self, - handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, + handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, ) -> Self { self.segmented_button = self.segmented_button.on_surface_action(handler); self diff --git a/src/widget/popover.rs b/src/widget/popover.rs index b9704244..fb7fea66 100644 --- a/src/widget/popover.rs +++ b/src/widget/popover.rs @@ -157,9 +157,17 @@ where ) { if self.popup.is_some() { if self.modal { - if matches!(event, Event::Mouse(_) | Event::Touch(_)) { - shell.capture_event(); - return; + match event { + Event::Mouse(_) | Event::Touch(_) => { + shell.capture_event(); + return; + } + // app-level keyboard navigation (Tab focus cycling, Escape) + // still receives keyboard events + Event::Keyboard(_) => { + return; + } + _ => {} } } else if let Some(on_close) = self.on_close.as_ref() && matches!( diff --git a/src/widget/responsive_container.rs b/src/widget/responsive_container.rs index d200a444..4560114c 100644 --- a/src/widget/responsive_container.rs +++ b/src/widget/responsive_container.rs @@ -11,7 +11,7 @@ use iced_core::{ pub(crate) fn responsive_container<'a, Message: 'static, Theme, E>( content: E, id: Id, - on_action: impl Fn(crate::surface::Action) -> Message + 'static, + on_action: impl Fn(crate::surface::Action) -> Message + 'static, ) -> ResponsiveContainer<'a, Message, Theme, crate::Renderer> where E: Into>, @@ -32,7 +32,7 @@ where content: Element<'a, Message, Theme, Renderer>, id: Id, size: Option, - on_action: Box Message>, + on_action: Box) -> Message>, } impl<'a, Message, Theme, Renderer> ResponsiveContainer<'a, Message, Theme, Renderer> @@ -43,7 +43,7 @@ where pub(crate) fn new( content: T, id: Id, - on_action: impl Fn(crate::surface::Action) -> Message + 'static, + on_action: impl Fn(crate::surface::Action) -> Message + 'static, ) -> Self where T: Into>, diff --git a/src/widget/responsive_menu_bar.rs b/src/widget/responsive_menu_bar.rs index b7b416ef..c698f571 100644 --- a/src/widget/responsive_menu_bar.rs +++ b/src/widget/responsive_menu_bar.rs @@ -79,7 +79,11 @@ impl ResponsiveMenuBar { core: &Core, key_binds: &HashMap, id: crate::widget::Id, - action_message: impl Fn(crate::surface::Action) -> Message + Send + Sync + Clone + 'static, + action_message: impl Fn(crate::surface::Action) -> Message + + Send + + Sync + + Clone + + 'static, trees: Vec<(S, Vec>)>, ) -> Element<'a, Message> { use crate::widget::id_container; diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index ed90328f..1a6466b7 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -203,7 +203,7 @@ where positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, #[setters(skip)] pub(crate) on_surface_action: - Option Message + Send + Sync + 'static>>, + Option) -> Message + Send + Sync + 'static>>, /// Defines the implementation of this struct variant: PhantomData, @@ -914,7 +914,7 @@ where #[must_use] pub fn on_surface_action( mut self, - handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, + handler: impl Fn(crate::surface::Action) -> Message + Send + Sync + 'static, ) -> Self { self.on_surface_action = Some(Arc::new(handler)); self @@ -953,10 +953,9 @@ where state.active_root.clear(); shell.publish(surface_action(destroy_popup(id))); state.view_cursor = view_cursor; - id - } else { - window::Id::unique() } + // A fresh id per popup, so the old popup's Done cannot be mistaken for the new one's + window::Id::unique() }); let Some(entity) = state.show_context else { return; @@ -1088,7 +1087,7 @@ where + Sync + 'static, view: Option crate::Element<'static, Message> + Send + Sync + 'static>, - ) -> crate::surface::Action { + ) -> crate::surface::Action { use std::any::Any; let boxed: Box< @@ -1107,11 +1106,8 @@ where Arc::new(boxed), Arc::new(boxed_live), view.map(|view| { - let boxed: Box< - dyn Fn() -> crate::Element<'static, Message> + Send + Sync + 'static, - > = Box::new(view); - let boxed: Box = Box::new(boxed); - Arc::new(boxed) + Arc::new(move || view().map(crate::Action::App)) + as crate::surface::View }), ) } @@ -1260,6 +1256,29 @@ where let my_bounds = layout.bounds(); let state = tree.state.downcast_mut::(); + // The compositor dismissed our context menu popup: nothing else tells this state about it. + #[cfg(wayland_platform)] + if let iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland( + iced::event::wayland::Event::Popup(iced::event::wayland::PopupEvent::Done, _, popup), + )) = &event + { + let dismissed = state.menu_state.inner.with_data_mut(|data| { + if data.popup_id.get(&self.window_id) == Some(popup) { + data.popup_id.clear(); + data.reset(); + true + } else { + false + } + }); + if dismissed { + state.show_context = None; + for key in self.model.order.iter().copied() { + self.update_entity_paragraph(state, key); + } + } + } + let hovered_before = state.hovered; let my_id = self.get_drag_id(); @@ -2240,12 +2259,7 @@ where let menu_open = || { state.show_context == Some(key) - && !tree.children.is_empty() - && tree.children[0] - .state - .downcast_ref::() - .inner - .with_data(|data| data.open) + && state.menu_state.inner.with_data(|data| data.open) }; let key_is_active = self.model.is_active(key); diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index d7a3634a..262d9389 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -7,6 +7,7 @@ //! A [`TextInput`] has some local [`State`]. use std::borrow::Cow; use std::cell::{Cell, LazyCell}; +use unicode_segmentation::UnicodeSegmentation; use crate::ext::ColorExt; use crate::theme::THEME; @@ -2539,6 +2540,63 @@ fn input_method<'b>( } } +#[allow(clippy::too_many_arguments)] +fn render_graphemes( + value: &Value, + state: &State, + left: usize, + right: usize, + text: &str, + text_color: Color, + bounds: Rectangle, + size: f32, + renderer: &mut crate::Renderer, + font: iced_core::Font, +) { + let lo_byte = value.byte_index_at_grapheme(left); + let hi_byte = value.byte_index_at_grapheme(right); + + let rects = state.value.raw().highlight( + 0, + (lo_byte, text::Affinity::After), + (hi_byte, text::Affinity::Before), + ); + + if !rects.is_empty() { + let grapheme_range = text[lo_byte..hi_byte].to_string(); + let origin = bounds.position(); + + let start_pos = origin + (rects.first().unwrap().position() - Point::ORIGIN); + + for rect in rects { + let absolute_rect = Rectangle { + x: rect.x + origin.x, + y: rect.y + origin.y, + width: rect.width, + height: rect.height, + }; + + renderer.fill_text( + Text { + content: grapheme_range.clone(), + font, + bounds: bounds.size(), + size: iced::Pixels(size), + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Center, + line_height: text::LineHeight::default(), + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, + }, + start_pos, + text_color, + absolute_rect, + ); + } + } +} + /// Draws the [`TextInput`] with the given [`Renderer`], overriding its /// [`Value`] if provided. /// @@ -2747,7 +2805,7 @@ pub fn draw<'a, Message>( let handling_dnd_offer = !matches!(state.dnd_offer, DndOfferState::None); #[cfg(not(wayland_platform))] let handling_dnd_offer = false; - let (cursors, offset, _is_selecting) = if let Some(focus) = + let (cursors, offset, _) = if let Some(focus) = state.is_focused.filter(|f| f.focused).or_else(|| { let now = Instant::now(); handling_dnd_offer.then_some(Focus { @@ -2821,7 +2879,6 @@ pub fn draw<'a, Message>( (lo_byte, text::Affinity::After), (hi_byte, text::Affinity::Before), ); - let cursors: Vec<(renderer::Quad, Color)> = rects .into_iter() .map(|r| { @@ -2875,7 +2932,6 @@ pub fn draw<'a, Message>( state.value.raw().min_width(), effective_alignment(state.value.raw()), ); - if cursors.is_empty() { renderer.with_translation(Vector::ZERO, |_| {}); } else { @@ -2892,33 +2948,77 @@ pub fn draw<'a, Message>( width: actual_width, ..text_bounds }; - let color = if text.is_empty() { + let tcolor = if text.is_empty() { appearance.placeholder_color } else { text_color }; - renderer.fill_text( - Text { - content: if text.is_empty() { - placeholder.to_string() - } else { - text.clone() + if let cursor::State::Selection { start, end } = state.cursor.state(value) + && state.is_focused() + { + let left = start.min(end); + let right = end.max(start); + let grapheme_len = text.graphemes(true).count(); + + if left > 0 { + render_graphemes( + value, state, 0, left, &text, tcolor, bounds, size, renderer, font, + ); + } + + if grapheme_len >= right { + render_graphemes( + value, + state, + left, + right, + &text, + appearance.selected_text_color, + bounds, + size, + renderer, + font, + ); + } + + if left < grapheme_len { + render_graphemes( + value, + state, + right, + grapheme_len, + &text, + tcolor, + bounds, + size, + renderer, + font, + ); + } + } else { + renderer.fill_text( + Text { + content: if text.is_empty() { + placeholder.to_string() + } else { + text.clone() + }, + font, + bounds: bounds.size(), + size: iced::Pixels(size), + align_x: text::Alignment::Default, + align_y: alignment::Vertical::Center, + line_height: text::LineHeight::default(), + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + ellipsize: text::Ellipsize::None, }, - font, - bounds: bounds.size(), - size: iced::Pixels(size), - align_x: text::Alignment::Default, - align_y: alignment::Vertical::Center, - line_height: text::LineHeight::default(), - shaping: text::Shaping::Advanced, - wrapping: text::Wrapping::None, - ellipsize: text::Ellipsize::None, - }, - bounds.position(), - color, - text_bounds, - ); + bounds.position(), + tcolor, + text_bounds, + ); + } }; // FIXME: we always must clip with a layer because of what appears to be a tiny-skia text clipping issue. diff --git a/src/widget/toaster/widget.rs b/src/widget/toaster/widget.rs index 03bdf1bd..7b7bbc08 100644 --- a/src/widget/toaster/widget.rs +++ b/src/widget/toaster/widget.rs @@ -142,23 +142,26 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { - //TODO: this hides the overlay of the content during the toast + let (content_state, toasts_state) = state.children.split_at_mut(1); + let content = self.content.as_widget_mut().overlay( + &mut content_state[0], + layout, + renderer, + viewport, + translation, + ); if self.is_empty { - self.content.as_widget_mut().overlay( - &mut state.children[0], - layout, - renderer, - viewport, - translation, - ) - } else { - let _bounds = layout.bounds(); - - Some(overlay::Element::new(Box::new(ToasterOverlay::new( - &mut state.children[1], - &mut self.toasts, - )))) + return content; } + // keep the content's overlays (e.g. popovers, menus) visible while a toast is shown + let toaster = overlay::Element::new(Box::new(ToasterOverlay::new( + &mut toasts_state[0], + &mut self.toasts, + ))); + Some(match content { + Some(content) => overlay::Group::with_children(vec![content, toaster]).overlay(), + None => toaster, + }) } fn drag_destinations( diff --git a/src/widget/wayland/tooltip/widget.rs b/src/widget/wayland/tooltip/widget.rs index b0287eed..c621d966 100644 --- a/src/widget/wayland/tooltip/widget.rs +++ b/src/widget/wayland/tooltip/widget.rs @@ -40,7 +40,7 @@ pub struct Tooltip<'a, Message, TopLevelMessage> { label: Option>, content: crate::Element<'a, Message>, on_leave: Message, - on_surface_action: Box Message>, + on_surface_action: Box) -> Message>, width: Length, height: Length, padding: Padding, @@ -75,7 +75,7 @@ impl<'a, Message, TopLevelMessage> Tooltip<'a, Message, TopLevelMessage> { + Sync + 'static, on_leave: Message, - on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static, + on_surface_action: impl Fn(crate::surface::Action) -> Message + 'static, ) -> Self { Self { id: Id::unique(), @@ -440,7 +440,7 @@ pub fn update<'a, Message: Clone + 'static, TopLevelMessage: Clone + 'static>( >, delay: Option, on_leave: &Message, - on_surface_action: &dyn Fn(crate::surface::Action) -> Message, + on_surface_action: &dyn Fn(crate::surface::Action) -> Message, state: impl FnOnce() -> &'a mut State, ) { match event { @@ -534,19 +534,8 @@ pub fn update<'a, Message: Clone + 'static, TopLevelMessage: Clone + 'static>( crate::surface::Action::Popup( Arc::new(boxed), Arc::new(boxed_live), - Some({ - let boxed: Box< - dyn Fn() -> crate::Element< - 'static, - crate::Action, - > + Send - + Sync - + 'static, - > = Box::new(move || view()); - let boxed: Box = - Box::new(boxed); - Arc::new(boxed) - }), + Some(Arc::new(move || view()) + as crate::surface::View), ) }) })); @@ -583,19 +572,8 @@ pub fn update<'a, Message: Clone + 'static, TopLevelMessage: Clone + 'static>( let sm = crate::surface::Action::Popup( Arc::new(boxed), Arc::new(boxed_live), - Some({ - let boxed: Box< - dyn Fn() -> crate::Element< - 'static, - crate::Action, - > + Send - + Sync - + 'static, - > = Box::new(move || view()); - let boxed: Box = - Box::new(boxed); - Arc::new(boxed) - }), + Some(Arc::new(move || view()) + as crate::surface::View), ); shell.publish((on_surface_action)(sm)); }