From 0304f1d92e2d2db4b05624d9ba827cca03c0407d Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Mon, 7 Sep 2026 12:42:21 +0200 Subject: [PATCH 01/35] fix: detect SVG icons by content --- Cargo.toml | 1 + src/widget/icon/handle.rs | 58 +++++++++++++++++++++++++++++++++++---- src/widget/icon/named.rs | 7 +---- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5268098d..0ab3aaa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,6 +166,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/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] = &[]; From 8aac70d894d2410a88395a18c4b0d7f9adaf4cb1 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Wed, 9 Sep 2026 17:51:37 +0200 Subject: [PATCH 02/35] i18n: translation updates from weblate Co-authored-by: Blauen Co-authored-by: Hosted Weblate Co-authored-by: Kurt Harren Catindig Translate-URL: https://hosted.weblate.org/projects/pop-os/libcosmic/th/ Translation: Pop OS/libcosmic --- i18n/fil/libcosmic.ftl | 0 i18n/th/libcosmic.ftl | 1 + 2 files changed, 1 insertion(+) create mode 100644 i18n/fil/libcosmic.ftl diff --git a/i18n/fil/libcosmic.ftl b/i18n/fil/libcosmic.ftl new file mode 100644 index 00000000..e69de29b diff --git a/i18n/th/libcosmic.ftl b/i18n/th/libcosmic.ftl index e69de29b..a94a2b12 100644 --- a/i18n/th/libcosmic.ftl +++ b/i18n/th/libcosmic.ftl @@ -0,0 +1 @@ +close = ปิด From 37c392a4700b81de519259fc51f1c3443c61ffd2 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 2 Sep 2026 17:17:22 -0600 Subject: [PATCH 03/35] fix(context_menu): don't panic when no menu is set --- src/widget/context_menu.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index f121bbe2..6ee253fc 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -269,9 +269,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 From 645ca8b90661ad4254b9c142e47a696f9b4274d8 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 2 Sep 2026 18:01:55 -0600 Subject: [PATCH 04/35] fix(context_menu): place the context_menu in the correct place in X11 --- src/widget/context_menu.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 6ee253fc..c006ef01 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -427,6 +427,7 @@ impl Widget self.create_popup(layout, cursor, renderer, shell, viewport, state); } + shell.request_redraw(); shell.capture_event(); return; } else if !was_open && right_button_released(event) @@ -489,9 +490,8 @@ impl Widget return None; } - let mut bounds = layout.bounds(); - bounds.x = state.context_cursor.x; - bounds.y = state.context_cursor.y; + // 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)); Some( crate::widget::menu::Menu { tree: state.menu_bar_state.clone(), @@ -506,7 +506,7 @@ impl Widget item_width: ItemWidth::Uniform(240), item_height: ItemHeight::Dynamic(40), bar_bounds: bounds, - main_offset: -(bounds.height as i32), + main_offset: 0, cross_offset: 0, root_bounds_list: vec![bounds], path_highlight: Some(PathHighlight::MenuActive), From 2fb052d134cd3ebdf7648ca0a2ec554d449670e7 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 2 Sep 2026 18:27:59 -0600 Subject: [PATCH 05/35] feat(context_menu): add item_width setter --- src/widget/context_menu.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index c006ef01..b5cf6837 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -33,6 +33,7 @@ pub fn context_menu<'a, Message: 'static + Clone>( }), close_on_escape: true, window_id: window::Id::RESERVED, + item_width: ItemWidth::Uniform(240), on_surface_action: None, }; @@ -53,6 +54,8 @@ 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, #[setters(skip)] pub(crate) on_surface_action: Option Message + Send + Sync + 'static>>, @@ -116,7 +119,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), @@ -503,7 +506,7 @@ impl Widget 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: 0, From aac833d37d6090bf9bee3b53c5c379af088e2508 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 2 Sep 2026 19:03:07 -0600 Subject: [PATCH 06/35] feat(menu): add Item::Entry with a reservable icon slot --- src/widget/menu.rs | 4 +- src/widget/menu/menu_tree.rs | 281 +++++++++++++++++++++-------------- 2 files changed, 170 insertions(+), 115 deletions(-) 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_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(); From fd20f097291dd029e468c7748596ff7b111dfc79 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 8 Sep 2026 12:49:18 -0600 Subject: [PATCH 07/35] feat(context_menu): report open/close and reset on compositor dismissal --- src/theme/style/button.rs | 8 ++++++ src/widget/context_menu.rs | 39 +++++++++++++++++++++++++++ src/widget/menu/menu_bar.rs | 15 +++++++++++ src/widget/segmented_button/widget.rs | 23 ++++++++++++++++ 4 files changed, 85 insertions(+) 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 b5cf6837..b4665f8f 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -34,6 +34,8 @@ 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, }; @@ -56,12 +58,31 @@ pub struct ContextMenu<'a, Message> { 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>>, } 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( @@ -238,6 +259,7 @@ impl Widget context_cursor: Point::default(), fingers_pressed: Default::default(), menu_bar_state: Default::default(), + reported_open: false, }) } @@ -351,6 +373,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 @@ -432,6 +468,7 @@ impl Widget 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)) @@ -467,6 +504,7 @@ impl Widget shell, viewport, ); + self.report_open_state(tree.state.downcast_mut::(), shell); } fn overlay<'b>( @@ -565,4 +603,5 @@ pub struct LocalState { context_cursor: Point, fingers_pressed: HashSet, menu_bar_state: MenuBarState, + reported_open: bool, } diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index ba2a8991..f82a46e8 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -593,6 +593,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/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index ea1c1a31..4abf873a 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -1244,6 +1244,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(); From 376f1b8850a80b48ef992c1214137840a0fe04b4 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 8 Sep 2026 12:46:39 -0600 Subject: [PATCH 08/35] fix(segmented_button): read the live menu state when drawing the context item --- src/widget/segmented_button/widget.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index 4abf873a..822d0ea5 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -2205,12 +2205,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); From a8fe59f3885ce2bbdc61b82f8e283cc6c2db5255 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 8 Sep 2026 13:04:15 -0600 Subject: [PATCH 09/35] refactor(surface): type surface actions on the message they carry --- examples/applet/src/window.rs | 6 +- examples/application/src/main.rs | 6 +- examples/context-menu/src/main.rs | 6 +- examples/open-dialog/src/main.rs | 6 +- src/action.rs | 40 ++++++++++ src/app/action.rs | 2 - src/app/cosmic.rs | 58 ++------------ src/app/mod.rs | 2 +- src/applet/mod.rs | 2 +- src/surface/action.rs | 59 +++++---------- src/surface/mod.rs | 104 +++++++++++++++++++++----- src/widget/context_menu.rs | 4 +- src/widget/dropdown/mod.rs | 2 +- src/widget/dropdown/widget.rs | 9 ++- src/widget/menu/menu_bar.rs | 4 +- src/widget/menu/menu_inner.rs | 2 +- src/widget/nav_bar.rs | 2 +- src/widget/responsive_container.rs | 6 +- src/widget/responsive_menu_bar.rs | 6 +- src/widget/segmented_button/widget.rs | 13 ++-- src/widget/wayland/tooltip/widget.rs | 36 ++------- 21 files changed, 194 insertions(+), 181 deletions(-) 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/src/action.rs b/src/action.rs index b7162896..6321de28 100644 --- a/src/action.rs +++ b/src/action.rs @@ -18,6 +18,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 { @@ -29,10 +34,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 ce8a4154..c070f3e4 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -154,7 +154,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 { @@ -195,18 +195,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) @@ -302,17 +291,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) @@ -378,17 +357,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( @@ -410,9 +379,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) @@ -475,17 +442,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) @@ -516,6 +473,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) => { @@ -1228,8 +1186,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 d963b7dd..84cf57f8 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 6620c34f..a4308306 100644 --- a/src/surface/action.rs +++ b/src/surface/action.rs @@ -1,7 +1,7 @@ // Copyright 2025 System76 // SPDX-License-Identifier: MPL-2.0 -use super::Action; +use super::{Action, View}; #[cfg(feature = "winit")] use crate::Application; @@ -11,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) } @@ -66,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> = @@ -100,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); @@ -115,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), ), ) } @@ -138,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 @@ -172,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 @@ -184,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), ) } @@ -203,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 @@ -218,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), ) } @@ -240,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, @@ -274,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 @@ -288,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), ) } @@ -311,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 b0a4a819..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,15 +75,74 @@ pub enum Action { }, Ignore, SyncLiveSettings(iced::window::Id), - Task(Arc Task + Send + Sync>), + Task(Arc Task> + Send + Sync>), +} + +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 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::Cosmic(crate::app::Action::Surface(action))) +pub fn surface_task(action: Action) -> Task> { + crate::task::message(crate::Action::Surface(action)) } -impl std::fmt::Debug for Action { +impl std::fmt::Debug for Action { #[cold] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -84,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() @@ -99,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(), @@ -125,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(), @@ -140,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/widget/context_menu.rs b/src/widget/context_menu.rs index b4665f8f..87e3ebed 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -66,7 +66,7 @@ pub struct ContextMenu<'a, Message> { pub on_close: Option, #[setters(skip)] pub(crate) on_surface_action: - Option Message + Send + Sync + 'static>>, + Option) -> Message + Send + Sync + 'static>>, } impl ContextMenu<'_, Message> { @@ -239,7 +239,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 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 e38be851..cbdf9b5b 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -55,7 +55,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)] @@ -109,7 +110,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 { @@ -552,7 +553,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/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index f82a46e8..14766603 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -188,7 +188,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 @@ -340,7 +340,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 diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 0ec6110b..f01682b1 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/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/responsive_container.rs b/src/widget/responsive_container.rs index e75b7a6b..8e89d9ec 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 822d0ea5..a2f5e19c 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -199,7 +199,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, @@ -899,7 +899,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 @@ -1073,7 +1073,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< @@ -1092,11 +1092,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 }), ) } 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)); } From 6081a48cd38245f99c1c6c7b531532ebd826e16b Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 8 Sep 2026 14:31:41 -0600 Subject: [PATCH 10/35] fix(app): count a popup closed once now that Done also reaches its parent --- src/app/cosmic.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index c070f3e4..62a70342 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -563,9 +563,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( From 9f634b0ec22a5abe2dfbfecd193e53ff6dd3f61f Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 8 Sep 2026 15:06:11 -0600 Subject: [PATCH 11/35] fix(menu): give each popup instance its own id --- src/widget/context_menu.rs | 14 +++++--------- src/widget/menu/menu_bar.rs | 11 +++++------ src/widget/segmented_button/widget.rs | 5 ++--- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 87e3ebed..2aa0d118 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -115,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; diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index 14766603..88727818 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -389,13 +389,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 { diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index a2f5e19c..9c41b89c 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -938,10 +938,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; From 521f3c2baf1229313f1c3a8351fc8bc1baf1c650 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 9 Sep 2026 21:39:21 -0600 Subject: [PATCH 12/35] fix(context_menu): pass through the content's overlays, cursor and drop targets --- src/widget/context_menu.rs | 114 ++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 34 deletions(-) diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 2aa0d118..a2f0c8eb 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -342,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, @@ -506,56 +538,70 @@ impl Widget 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; } // 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)); - 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: 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, + 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")] From 7cc116803b18d7b888eb511b009f775f036c3da7 Mon Sep 17 00:00:00 2001 From: Alex Klepov Date: Thu, 10 Sep 2026 18:33:57 +0300 Subject: [PATCH 13/35] fix(text_input): font color in selected mode (#1409) --- src/widget/text_input/input.rs | 148 +++++++++++++++++++++++++++------ 1 file changed, 124 insertions(+), 24 deletions(-) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index 37db1756..19e50cb3 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; @@ -2561,6 +2562,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. /// @@ -2769,7 +2827,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 { @@ -2843,7 +2901,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| { @@ -2897,7 +2954,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 { @@ -2914,33 +2970,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. From eec2f7619d20e9dc6385a4a747b7b344ba253412 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 10 Sep 2026 11:53:22 -0600 Subject: [PATCH 14/35] Revert "fix(text_input): font color in selected mode (#1409)" This reverts commit 7cc116803b18d7b888eb511b009f775f036c3da7. --- src/widget/text_input/input.rs | 148 ++++++--------------------------- 1 file changed, 24 insertions(+), 124 deletions(-) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index 19e50cb3..37db1756 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -7,7 +7,6 @@ //! 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; @@ -2562,63 +2561,6 @@ 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. /// @@ -2827,7 +2769,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, _) = if let Some(focus) = + let (cursors, offset, is_selecting) = if let Some(focus) = state.is_focused.filter(|f| f.focused).or_else(|| { let now = Instant::now(); handling_dnd_offer.then_some(Focus { @@ -2901,6 +2843,7 @@ 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| { @@ -2954,6 +2897,7 @@ 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 { @@ -2970,77 +2914,33 @@ pub fn draw<'a, Message>( width: actual_width, ..text_bounds }; - let tcolor = if text.is_empty() { + let color = if text.is_empty() { appearance.placeholder_color } else { text_color }; - 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, + renderer.fill_text( + Text { + content: if text.is_empty() { + placeholder.to_string() + } else { + text.clone() }, - bounds.position(), - tcolor, - text_bounds, - ); - } + 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, + ); }; // FIXME: we always must clip with a layer because of what appears to be a tiny-skia text clipping issue. From ff7c753302efa5f41397f6239d42bb9861950ee6 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 10 Sep 2026 11:54:00 -0600 Subject: [PATCH 15/35] fix(text_input): draw selected text in the theme's selected_text_color --- src/widget/text_input/input.rs | 52 +++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index 37db1756..d8edc1b6 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -2920,27 +2920,39 @@ pub fn draw<'a, Message>( text_color }; - 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, + let text = Text { + content: if text.is_empty() { + placeholder.to_string() + } else { + text.clone() }, - bounds.position(), - color, - text_bounds, - ); + 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, + }; + renderer.fill_text(text.clone(), bounds.position(), color, text_bounds); + + // Redraw the same text in the selected color, clipped to the selection quads, + // so glyph shaping and positioning stay identical to the unselected pass. + if is_selecting { + let shift = Vector::new(alignment_offset - offset, 0.0); + for (quad, _) in &cursors { + renderer.with_layer(quad.bounds + shift, |renderer| { + renderer.fill_text( + text.clone(), + bounds.position(), + appearance.selected_text_color, + text_bounds, + ); + }); + } + } }; // FIXME: we always must clip with a layer because of what appears to be a tiny-skia text clipping issue. From a401af8b1c54a8abd393b8c5b7c8809402f83850 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 10 Sep 2026 10:41:27 -0600 Subject: [PATCH 16/35] fix(wayland): deliver popup Done to the parent window's widgets too --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 51118067..ffe1f1db 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 511180679b3792387f9b5eabd6d11f4c1bc4ade6 +Subproject commit ffe1f1dbe3cbfd313f9b5fe8e36a4af462cae5d7 From 56a210bb4eca148c2ef118fd53118a4aa323aa5e Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 10 Sep 2026 15:30:05 -0600 Subject: [PATCH 17/35] feat(text_context_menu): always show items, but disable them --- iced | 2 +- src/widget/selectable_text.rs | 2 ++ src/widget/text_context_menu.rs | 49 +++++++++++++++++++-------------- src/widget/text_editor.rs | 2 ++ src/widget/text_input/input.rs | 15 ++++++++++ 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/iced b/iced index ffe1f1db..2e12eba5 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit ffe1f1dbe3cbfd313f9b5fe8e36a4af462cae5d7 +Subproject commit 2e12eba5cd9ddf9995e113fbc9fa0ebf8f0f0f97 diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs index 18328c40..67645d38 100644 --- a/src/widget/selectable_text.rs +++ b/src/widget/selectable_text.rs @@ -355,6 +355,8 @@ impl<'a, Message: Clone + 'static> Widget for S selected_text, false, has_selection, + self.inner.has_text(&tree.children[0]), + self.inner.clipboard_has_text(&tree.children[0]), &menu_bar_state, &pending_action, renderer, diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index 2e908331..0bf1eca9 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -217,7 +217,12 @@ where let selected_text = widget.selected_text(tree); let is_editable = widget.is_editable(); - let mut menu_roots = build_menu_roots(is_editable, selected_text.is_some()); + let mut menu_roots = build_menu_roots( + is_editable, + selected_text.is_some(), + widget.has_text(tree), + widget.clipboard_has_text(tree), + ); menu_roots.iter_mut().for_each(menu::Tree::set_index); let bounds = Rectangle { @@ -283,28 +288,28 @@ pub(crate) enum TextCtxAction { SelectAll, } -fn build_menu_roots(is_editable: bool, has_selection: bool) -> Vec> { - let mut items = Vec::with_capacity(4); +fn build_menu_roots( + is_editable: bool, + has_selection: bool, + has_text: bool, + clipboard_has_text: bool, +) -> Vec> { + let item = |label: &'static str, action: TextCtxAction, enabled: bool| { + menu::Tree::from(crate::Element::from( + menu::menu_button(vec![widget::text(label).into()]) + .on_press_maybe(enabled.then_some(action)), + )) + }; - if is_editable && has_selection { - items.push(menu::Tree::from(crate::Element::from( - menu::menu_button(vec![widget::text("Cut").into()]).on_press(TextCtxAction::Cut), - ))); - } - if has_selection { - items.push(menu::Tree::from(crate::Element::from( - menu::menu_button(vec![widget::text("Copy").into()]).on_press(TextCtxAction::Copy), - ))); - } + let mut items = Vec::with_capacity(4); if is_editable { - items.push(menu::Tree::from(crate::Element::from( - menu::menu_button(vec![widget::text("Paste").into()]).on_press(TextCtxAction::Paste), - ))); + items.push(item("Cut", TextCtxAction::Cut, has_selection)); } - items.push(menu::Tree::from(crate::Element::from( - menu::menu_button(vec![widget::text("Select All").into()]) - .on_press(TextCtxAction::SelectAll), - ))); + items.push(item("Copy", TextCtxAction::Copy, has_selection)); + if is_editable { + items.push(item("Paste", TextCtxAction::Paste, clipboard_has_text)); + } + items.push(item("Select All", TextCtxAction::SelectAll, has_text)); vec![menu::Tree::with_children( RcElementWrapper::new(crate::Element::from(widget::Row::new())), @@ -486,6 +491,8 @@ pub(crate) fn create_text_context_popup( selected_text: Option, is_editable: bool, has_selection: bool, + has_text: bool, + clipboard_has_text: bool, menu_bar_state: &MenuBarState, pending_action: &PendingAction, renderer: &crate::Renderer, @@ -499,7 +506,7 @@ pub(crate) fn create_text_context_popup( return; } - let mut menu_roots = build_menu_roots(is_editable, has_selection); + let mut menu_roots = build_menu_roots(is_editable, has_selection, has_text, clipboard_has_text); menu_roots.iter_mut().for_each(menu::Tree::set_index); let id = menu_bar_state.inner.with_data_mut(|state| { diff --git a/src/widget/text_editor.rs b/src/widget/text_editor.rs index f25cb277..1d303faf 100644 --- a/src/widget/text_editor.rs +++ b/src/widget/text_editor.rs @@ -297,6 +297,8 @@ impl<'a, Message: Clone + 'static> Widget( state.focus(); } state.context_menu_position = Some(pos); + state.clipboard_has_text = iced_core::widget::text::clipboard_has_text(clipboard); shell.capture_event(); return; } @@ -3096,6 +3101,7 @@ pub struct State { keyboard_modifiers: keyboard::Modifiers, scroll_offset: f32, context_menu_position: Option, + clipboard_has_text: bool, pub(crate) menu_bar_state: crate::widget::menu::MenuBarState, pub(crate) pending_action: crate::widget::text_context_menu::PendingAction, } @@ -3191,6 +3197,7 @@ impl State { scroll_offset: 0.0, dirty: false, context_menu_position: None, + clipboard_has_text: false, menu_bar_state: crate::widget::menu::MenuBarState::default(), pending_action: crate::widget::text_context_menu::pending_action(), } @@ -3554,6 +3561,14 @@ impl iced_core::widget::text::HasSelectableText true } + fn has_text(&self, tree: &WidgetTree) -> bool { + !tree.state.downcast_ref::().tracked_value.is_empty() + } + + fn clipboard_has_text(&self, tree: &WidgetTree) -> bool { + tree.state.downcast_ref::().clipboard_has_text + } + fn is_focused(&self, tree: &WidgetTree) -> bool { tree.state.downcast_ref::().is_focused() } From 53314201b08b7ff3758b5d1222c4d214abfb5dd8 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Fri, 11 Sep 2026 11:10:36 -0600 Subject: [PATCH 18/35] fix(theme): draw selected label text in the on-accent color --- iced | 2 +- src/theme/style/iced.rs | 4 ++++ src/widget/text_input/input.rs | 13 ++++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/iced b/iced index 2e12eba5..9ef36c96 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 2e12eba5cd9ddf9995e113fbc9fa0ebf8f0f0f97 +Subproject commit 9ef36c9604c8eb1d34a4e4f9df438a1da0f9e8a2 diff --git a/src/theme/style/iced.rs b/src/theme/style/iced.rs index 3b0d9794..bd4706c6 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -1371,18 +1371,22 @@ impl iced_widget::text::Catalog for Theme { fn style(&self, class: &Self::Class<'_>) -> iced_widget::text::Style { let selected_fill = self.cosmic().accent.base.into(); + let selected_text_color = Some(self.cosmic().on_accent_color().into()); match class { Text::Accent => iced_widget::text::Style { color: Some(self.cosmic().accent_text_color().into()), selected_fill, + selected_text_color, }, Text::Default => iced_widget::text::Style { color: None, selected_fill, + selected_text_color, }, Text::Color(c) => iced_widget::text::Style { color: Some(*c), selected_fill, + selected_text_color, }, Text::Custom(f) => f(self), } diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index 07c0714f..9cf4b2e3 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -2903,14 +2903,17 @@ pub fn draw<'a, Message>( effective_alignment(state.value.raw()), ); - if cursors.is_empty() { - renderer.with_translation(Vector::ZERO, |_| {}); - } else { - renderer.with_translation(Vector::new(alignment_offset - offset, 0.0), |renderer| { + let shift = Vector::new(alignment_offset - offset, 0.0); + let fill_cursors = |renderer: &mut crate::Renderer| { + renderer.with_translation(shift, |renderer| { for (quad, color) in &cursors { renderer.fill_quad(*quad, *color); } }); + }; + + if !is_selecting { + fill_cursors(renderer); } let bounds = Rectangle { @@ -2946,7 +2949,7 @@ pub fn draw<'a, Message>( // Redraw the same text in the selected color, clipped to the selection quads, // so glyph shaping and positioning stay identical to the unselected pass. if is_selecting { - let shift = Vector::new(alignment_offset - offset, 0.0); + fill_cursors(renderer); for (quad, _) in &cursors { renderer.with_layer(quad.bounds + shift, |renderer| { renderer.fill_text( From 7e82198ab1aba712640b0167e58ad37bf03012ca Mon Sep 17 00:00:00 2001 From: Ian Douglas Scott Date: Fri, 11 Sep 2026 12:09:05 -0700 Subject: [PATCH 19/35] chore: update iced Adds support for `cosmic-session-lock-layer-v1` protocol: https://github.com/pop-os/iced/pull/388 --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 9ef36c96..87e1787f 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 9ef36c9604c8eb1d34a4e4f9df438a1da0f9e8a2 +Subproject commit 87e1787fbb9b924576e6f6ea2ef2f45d0729e63d From e793c4552b75b9ced6a69bc7ff565d6a07477137 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 14 Sep 2026 12:20:13 -0600 Subject: [PATCH 20/35] Revert "fix: don't pass keyboard events to content if popover modal is open" This reverts commit 9cc82abd9ca5601bb0341edd12a9acbc97e77801. --- src/widget/popover.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/widget/popover.rs b/src/widget/popover.rs index 253cc25a..4368925e 100644 --- a/src/widget/popover.rs +++ b/src/widget/popover.rs @@ -157,17 +157,9 @@ where ) { if self.popup.is_some() { if self.modal { - 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; - } - _ => {} + if matches!(event, Event::Mouse(_) | Event::Touch(_)) { + shell.capture_event(); + return; } } else if let Some(on_close) = self.on_close.as_ref() { if matches!( From 23f5d63fed2a370f3c2f54b90ec8f0896fe82105 Mon Sep 17 00:00:00 2001 From: jgrasso Date: Mon, 14 Sep 2026 13:30:10 -0600 Subject: [PATCH 21/35] chore: update cosmic-protocols --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0ab3aaa6..3e29f458 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,7 +170,7 @@ 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] -cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "32283d7", optional = true } +cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "c0cff4d", optional = true } cosmic-config = { path = "cosmic-config", features = ["dbus"] } cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings" } zbus.workspace = true From a29ab33d662f05bad91ece7dc2eb052ac451a604 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Tue, 15 Sep 2026 13:56:37 +0200 Subject: [PATCH 22/35] fix(cosmic-config): handle io::ErrorKind::NotFound as Error::NotFound Fixes excess logs and inability to fallback to system default of config --- cosmic-config/src/lib.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/cosmic-config/src/lib.rs b/cosmic-config/src/lib.rs index e51b4f6a..71f3d2d2 100644 --- a/cosmic-config/src/lib.rs +++ b/cosmic-config/src/lib.rs @@ -4,7 +4,7 @@ use notify::event::{EventKind, ModifyKind, RenameMode}; use notify::{RecommendedWatcher, Watcher}; use serde::Serialize; use serde::de::DeserializeOwned; -use std::io::Write; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::{env, fmt, fs}; @@ -417,10 +417,11 @@ impl ConfigGet for Config { match self.key_path(key) { Ok(key_path) if key_path.is_file() => { // Load user override - let data = fs::read_to_string(key_path) - .map_err(|err| Error::GetKey(key.to_string(), err))?; - - Ok(ron::from_str(&data)?) + match fs::read_to_string(key_path) { + Ok(data) => Ok(ron::from_str(&data)?), + Err(why) if why.kind() == io::ErrorKind::NotFound => Err(Error::NotFound), + Err(why) => Err(Error::GetKey(key.to_string(), why)), + } } _ => { @@ -436,9 +437,11 @@ impl ConfigGet for Config { fn get_system_default(&self, key: &str) -> Result { // Load system default let default_path = self.default_path(key)?; - let data = - fs::read_to_string(default_path).map_err(|err| Error::GetKey(key.to_string(), err))?; - Ok(ron::from_str(&data)?) + match fs::read_to_string(default_path) { + Ok(data) => Ok(ron::from_str(&data)?), + Err(why) if why.kind() == io::ErrorKind::NotFound => Err(Error::NotFound), + Err(why) => Err(Error::GetKey(key.to_string(), why)), + } } } From d77e99fb1555e2728297677de61e23ada4d9d09b Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Tue, 15 Sep 2026 14:36:17 +0200 Subject: [PATCH 23/35] fix(cosmic-config): in `Config::get_entry`, do not push to errors vec if config is `NotFound` --- cosmic-config-derive/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cosmic-config-derive/src/lib.rs b/cosmic-config-derive/src/lib.rs index b49cf447..1c9461b8 100644 --- a/cosmic-config-derive/src/lib.rs +++ b/cosmic-config-derive/src/lib.rs @@ -100,16 +100,18 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { Ok(value) => { default.#field_name = value.into(); } - Err(why) if matches!(why, cosmic_config::Error::NoConfigDirectory) => (), - Err(e) => errors.push(e), + Err(why) => if why.is_err() { + errors.push(why); + } } } } else { quote! { match cosmic_config::ConfigGet::get::<#field_type>(config, stringify!(#field_name)) { Ok(#field_name) => default.#field_name = #field_name, - Err(why) if matches!(why, cosmic_config::Error::NoConfigDirectory) => (), - Err(e) => errors.push(e), + Err(why) => if why.is_err() { + errors.push(why); + } } } } From d9431dc3670575602385e1e2523600ba5315508c Mon Sep 17 00:00:00 2001 From: Frederic Laing Date: Tue, 8 Sep 2026 21:16:15 +0200 Subject: [PATCH 24/35] chore: resolve cargo fmt and clippy findings --- cosmic-config-derive/src/lib.rs | 1 - cosmic-config/src/subscription.rs | 2 +- cosmic-theme/src/model/theme.rs | 30 +++++++++++++----------- cosmic-theme/src/output/gtk4_output.rs | 20 +++++++--------- cosmic-theme/src/output/qt56ct_output.rs | 7 ++---- cosmic-theme/src/output/vs_code.rs | 2 ++ 6 files changed, 30 insertions(+), 32 deletions(-) diff --git a/cosmic-config-derive/src/lib.rs b/cosmic-config-derive/src/lib.rs index 1c9461b8..1ca2a3f6 100644 --- a/cosmic-config-derive/src/lib.rs +++ b/cosmic-config-derive/src/lib.rs @@ -1,6 +1,5 @@ use proc_macro::TokenStream; use quote::quote; -use syn; #[proc_macro_derive(CosmicConfigEntry, attributes(version, id, cosmic_config_entry))] pub fn cosmic_config_entry_derive(input: TokenStream) -> TokenStream { diff --git a/cosmic-config/src/subscription.rs b/cosmic-config/src/subscription.rs index f0387877..124b432a 100644 --- a/cosmic-config/src/subscription.rs +++ b/cosmic-config/src/subscription.rs @@ -1,5 +1,5 @@ +use iced_futures::futures::SinkExt; use iced_futures::futures::channel::mpsc; -use iced_futures::futures::{SinkExt, Stream}; use iced_futures::stream; use notify::RecommendedWatcher; use std::borrow::Cow; diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs index 5974523b..98cb209c 100644 --- a/cosmic-theme/src/model/theme.rs +++ b/cosmic-theme/src/model/theme.rs @@ -756,6 +756,7 @@ impl Theme { } /// Get the active theme based on the current theme mode. + #[allow(clippy::result_large_err)] pub fn get_active() -> Result, Self)> { (|| { (if ThemeMode::is_dark(&Config::new(Self::id(), Self::VERSION)?)? { @@ -768,7 +769,6 @@ impl Theme { .and_then(|theme_config| Self::get_entry(&theme_config)) } - #[must_use] /// Rebuild the current theme with the provided accent pub fn with_accent(&self, c: Srgba) -> Self { let mut oklcha: Oklcha = c.into_color(); @@ -1097,7 +1097,7 @@ impl ThemeBuilder { let container_alpha = alpha_map.blurred_alpha(frosted); let actual_alpha = - if (frosted_windows || frosted_system_interface || frosted_panel || frosted_applets) { + if frosted_windows || frosted_system_interface || frosted_panel || frosted_applets { container_alpha } else { 1.0 @@ -1167,9 +1167,14 @@ impl ThemeBuilder { control_steps_array[0] }; component_hovered_overlay.alpha = 0.1; - + let mut background_pressed_overlay = component_hovered_overlay; + background_pressed_overlay.alpha = 0.2; + #[allow(unused_assignments)] let mut component_pressed_overlay = component_hovered_overlay; - component_pressed_overlay.alpha = 0.2; + #[allow(unused_assignments)] + { + component_pressed_overlay.alpha = 0.2; + } // Standard button background is neutral 7 with 25% opacity let button_bg = control_steps_array[7].with_alpha(0.25); @@ -1204,7 +1209,7 @@ impl ThemeBuilder { ); let primary = { - let mut container_bg = if let Some(primary_container_bg_color) = primary_container_bg { + let container_bg = if let Some(primary_container_bg_color) = primary_container_bg { primary_container_bg_color } else { get_surface_color(bg_index, 5, &step_array, is_dark, &control_steps_array[1]) @@ -1222,7 +1227,7 @@ impl ThemeBuilder { }; component_hovered_overlay.alpha = 0.1; - component_pressed_overlay = component_hovered_overlay; + let mut component_pressed_overlay = component_hovered_overlay; component_pressed_overlay.alpha = 0.2; Container::new( @@ -1357,7 +1362,7 @@ impl ThemeBuilder { accent, on_bg_component, component_hovered_overlay, - component_pressed_overlay, + background_pressed_overlay, is_high_contrast, control_steps_array[8], ), @@ -1653,7 +1658,7 @@ impl ThemeBuilder { /// but this represents the strength of the blur effect. #[allow(missing_docs)] #[repr(u8)] -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Default)] pub enum BlurStrength { ExtremelyLow, ExtremelyLow2, @@ -1661,6 +1666,7 @@ pub enum BlurStrength { VeryLow2, Low, Low2, + #[default] Medium, Medium2, High, @@ -1671,12 +1677,6 @@ pub enum BlurStrength { ExtremelyHigh2, } -impl Default for BlurStrength { - fn default() -> Self { - Self::Medium - } -} - impl TryFrom for BlurStrength { type Error = (); @@ -1701,6 +1701,7 @@ impl TryFrom for BlurStrength { } } +#[allow(missing_docs)] #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct AlphaMap { pub extremely_low: f32, @@ -1719,6 +1720,7 @@ pub struct AlphaMap { pub extremely_high_2: f32, } +#[allow(missing_docs)] impl AlphaMap { pub fn blurred_alpha(&self, blur: BlurStrength) -> f32 { match blur { diff --git a/cosmic-theme/src/output/gtk4_output.rs b/cosmic-theme/src/output/gtk4_output.rs index 16a3c36e..74b7e266 100644 --- a/cosmic-theme/src/output/gtk4_output.rs +++ b/cosmic-theme/src/output/gtk4_output.rs @@ -262,17 +262,15 @@ impl Theme { return Ok(None); } - if let Ok(metadata) = fs::symlink_metadata(path) { - if metadata.file_type().is_symlink() { - if let Ok(actual_cosmic_css) = fs::read_link(path) { - let canonical_target = fs::canonicalize(&actual_cosmic_css)?; - let canonical_base = fs::canonicalize(cosmic_css)?; - return Ok(Some( - canonical_target == canonical_base - || canonical_target.starts_with(&canonical_base), - )); - } - } + if let Ok(metadata) = fs::symlink_metadata(path) + && metadata.file_type().is_symlink() + && let Ok(actual_cosmic_css) = fs::read_link(path) + { + let canonical_target = fs::canonicalize(&actual_cosmic_css)?; + let canonical_base = fs::canonicalize(cosmic_css)?; + return Ok(Some( + canonical_target == canonical_base || canonical_target.starts_with(&canonical_base), + )); } Ok(Some(false)) } diff --git a/cosmic-theme/src/output/qt56ct_output.rs b/cosmic-theme/src/output/qt56ct_output.rs index 16df211f..a4c0b54b 100644 --- a/cosmic-theme/src/output/qt56ct_output.rs +++ b/cosmic-theme/src/output/qt56ct_output.rs @@ -111,7 +111,6 @@ inactive_colors={} qt6ct_res?; Ok(()) } - #[must_use] #[cold] fn write_ct(&self, ct: &str, qpalette: &str) -> Result<(), OutputError> { let file_path = Self::get_qpalette_path(ct, self.is_dark)?; @@ -139,7 +138,6 @@ inactive_colors={} qt6ct_res?; Ok(()) } - #[must_use] #[cold] fn apply_ct(ct: &str, is_dark: bool) -> Result<(), OutputError> { let path = Self::get_conf_path(ct)?; @@ -213,7 +211,6 @@ inactive_colors={} qt6ct_res?; Ok(()) } - #[must_use] #[cold] fn reset_ct(ct: &str) -> Result<(), OutputError> { let path = Self::get_conf_path(ct)?; @@ -249,7 +246,7 @@ inactive_colors={} let Some(mut config_dir) = dirs::config_dir() else { return Err(OutputError::MissingConfigDir); }; - config_dir.push(&ct); + config_dir.push(ct); if !config_dir.exists() { fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; } @@ -272,7 +269,7 @@ inactive_colors={} let Some(mut config_dir) = dirs::config_dir() else { return Err(OutputError::MissingConfigDir); }; - config_dir.push(&ct); + config_dir.push(ct); config_dir.push("colors"); if !config_dir.exists() { fs::create_dir_all(&config_dir).map_err(OutputError::Io)?; diff --git a/cosmic-theme/src/output/vs_code.rs b/cosmic-theme/src/output/vs_code.rs index 43c36bb6..f0a5168e 100644 --- a/cosmic-theme/src/output/vs_code.rs +++ b/cosmic-theme/src/output/vs_code.rs @@ -267,6 +267,7 @@ impl From for VsTheme { impl Theme { #[cold] + /// Applies the theme to VS Code's user settings. pub fn apply_vs_code(self) -> Result<(), OutputError> { let vs_theme = VsTheme::from(self); let mut config_dir = dirs::config_dir().ok_or(OutputError::MissingConfigDir)?; @@ -292,6 +293,7 @@ impl Theme { } #[cold] + /// Removes COSMIC theme settings from VS Code's user settings. pub fn reset_vs_code() -> Result<(), OutputError> { let mut config_dir = dirs::config_dir().ok_or(OutputError::MissingConfigDir)?; config_dir.extend(["Code", "User", "settings.json"]); From d921602cde8248c070b493ce43b0edd388e19b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vuka=C5=A1in=20Vojinovi=C4=87?= <150025636+git-f0x@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:37:20 +0200 Subject: [PATCH 25/35] feat(progress_bar): custom style --- src/widget/progress_bar/circular.rs | 42 +++++++--- src/widget/progress_bar/linear.rs | 48 +++++++++--- src/widget/progress_bar/style.rs | 116 +++++++++++++++------------- 3 files changed, 128 insertions(+), 78 deletions(-) diff --git a/src/widget/progress_bar/circular.rs b/src/widget/progress_bar/circular.rs index 8da4be14..b1fd8836 100644 --- a/src/widget/progress_bar/circular.rs +++ b/src/widget/progress_bar/circular.rs @@ -1,6 +1,6 @@ //! Show a circular progress indicator. use super::animation::{Animation, Progress}; -use super::style::StyleSheet; +use super::style::{self, Catalog}; use iced::advanced::widget::tree::{self, Tree}; use iced::advanced::{self, Clipboard, Layout, Shell, Widget, layout, renderer}; use iced::widget::canvas; @@ -15,11 +15,11 @@ const MAX_WRAP: f32 = 1.0 - MIN_GAP_ANGLE.0 / (2.0 * PI); #[must_use] pub struct Circular where - Theme: StyleSheet, + Theme: Catalog, { size: f32, bar_height: Option, - style: Theme::Style, + class: Theme::Class, cycle_duration: Duration, period: Duration, progress: Option, @@ -27,14 +27,14 @@ where impl Circular where - Theme: StyleSheet, + Theme: Catalog, { /// Creates a new [`Circular`] with the given content. pub fn new() -> Self { Circular { size: 48.0, bar_height: None, - style: Theme::Style::default(), + class: Theme::Class::default(), cycle_duration: Duration::from_millis(1500), period: Duration::from_secs(2), progress: None, @@ -54,9 +54,9 @@ where self } - /// Sets the style variant of this [`Circular`]. - pub fn style(mut self, style: Theme::Style) -> Self { - self.style = style; + /// Sets the style class of this [`Circular`]. + pub fn class(mut self, class: Theme::Class) -> Self { + self.class = class; self } @@ -78,11 +78,29 @@ where self.progress = Some(progress.clamp(0.0, 1.0)); self } + + /// Sets the track color of this [`Circular`]. + pub fn track_color(mut self, color: impl Into) -> Self { + self.class = self.class.track_color(color); + self + } + + /// Sets the bar color of this [`Circular`]. + pub fn bar_color(mut self, color: impl Into) -> Self { + self.class = self.class.bar_color(color); + self + } + + /// Sets the border color of this [`Circular`]. + pub fn border_color(mut self, color: impl Into) -> Self { + self.class = self.class.border_color(color); + self + } } impl Default for Circular where - Theme: StyleSheet, + Theme: Catalog, { fn default() -> Self { Self::new() @@ -99,7 +117,7 @@ struct State { impl Widget for Circular where Message: Clone, - Theme: StyleSheet, + Theme: Catalog, { fn tag(&self) -> tree::Tag { tree::Tag::of::() @@ -170,7 +188,7 @@ where let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); - let custom_style = Theme::appearance(theme, &self.style, self.progress.is_some(), true); + let custom_style = theme.style(&self.class, self.progress.is_some(), true); let geometry = state.cache.draw(renderer, bounds.size(), |frame| { let bar_height = self.bar_height.unwrap_or((frame.width() / 12.0).max(2.0)); @@ -240,7 +258,7 @@ where impl<'a, Message, Theme> From> for Element<'a, Message, Theme, Renderer> where Message: Clone + 'a, - Theme: StyleSheet + 'a, + Theme: Catalog + 'a, { fn from(circular: Circular) -> Self { Self::new(circular) diff --git a/src/widget/progress_bar/linear.rs b/src/widget/progress_bar/linear.rs index 6c06e2f7..c5072b4d 100644 --- a/src/widget/progress_bar/linear.rs +++ b/src/widget/progress_bar/linear.rs @@ -1,6 +1,6 @@ //! Show a linear progress indicator. use super::animation::{Animation, Progress}; -use super::style::StyleSheet; +use super::style::{self, Catalog}; use iced::advanced::widget::tree::{self, Tree}; use iced::advanced::{self, Clipboard, Layout, Shell, Widget, layout, renderer}; use iced::{Border, Color, Element, Event, Length, Pixels, Rectangle, Size, mouse, window}; @@ -13,11 +13,11 @@ const WRAP_LENGTH: f32 = 0.618; // avoids animation repetition #[must_use] pub struct Linear where - Theme: StyleSheet, + Theme: Catalog, { width: Length, girth: Length, - style: Theme::Style, + class: Theme::Class, cycle_duration: Duration, period: Duration, progress: Option, @@ -27,14 +27,14 @@ where impl Linear where - Theme: StyleSheet, + Theme: Catalog, { /// Creates a new [`Linear`] with the given content. pub fn new() -> Self { Linear { width: Length::Fixed(100.0), girth: Length::Fixed(4.0), - style: Theme::Style::default(), + class: Theme::Class::default(), cycle_duration: Duration::from_millis(1500), period: Duration::from_secs(2), progress: None, @@ -55,9 +55,9 @@ where self } - /// Sets the style variant of this [`Linear`]. - pub fn style(mut self, style: impl Into) -> Self { - self.style = style.into(); + /// Sets the style class of this [`Linear`]. + pub fn class(mut self, class: Theme::Class) -> Self { + self.class = class; self } @@ -99,11 +99,35 @@ where self.segment_spacing = spacing.into().0.max(1.0); self } + + /// Sets the track color of this [`Linear`]. + pub fn track_color(mut self, color: impl Into) -> Self { + self.class = self.class.track_color(color); + self + } + + /// Sets the bar color of this [`Linear`]. + pub fn bar_color(mut self, color: impl Into) -> Self { + self.class = self.class.bar_color(color); + self + } + + /// Sets the border color of this [`Linear`]. + pub fn border_color(mut self, color: impl Into) -> Self { + self.class = self.class.border_color(color); + self + } + + /// Sets the border radius of this [`Linear`]. + pub fn border_radius(mut self, radius: f32) -> Self { + self.class = self.class.border_radius(radius); + self + } } impl Default for Linear where - Theme: StyleSheet, + Theme: Catalog, { fn default() -> Self { Self::new() @@ -119,7 +143,7 @@ struct State { impl Widget for Linear where Message: Clone, - Theme: StyleSheet, + Theme: Catalog, Renderer: advanced::Renderer, { fn tag(&self) -> tree::Tag { @@ -186,7 +210,7 @@ where _viewport: &Rectangle, ) { let bounds = layout.bounds(); - let custom_style = theme.appearance(&self.style, self.progress.is_some(), false); + let custom_style = theme.style(&self.class, self.progress.is_some(), false); let state = tree.state.downcast_ref::(); let border_width = if custom_style.border_color.is_some() { @@ -307,7 +331,7 @@ where impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: Clone + 'a, - Theme: StyleSheet + 'a, + Theme: Catalog + 'a, Renderer: iced::advanced::Renderer + 'a, { fn from(linear: Linear) -> Self { diff --git a/src/widget/progress_bar/style.rs b/src/widget/progress_bar/style.rs index db2fe64d..5447fdec 100644 --- a/src/widget/progress_bar/style.rs +++ b/src/widget/progress_bar/style.rs @@ -1,7 +1,8 @@ use iced::Color; +use palette::WithAlpha; -#[derive(Debug, Clone, Copy)] -pub struct Appearance { +#[derive(Clone, Copy, Debug)] +pub struct Style { /// The track [`Color`] of the progress indicator. pub track_color: Color, /// The bar [`Color`] of the progress indicator. @@ -12,94 +13,101 @@ pub struct Appearance { pub border_radius: f32, } -impl std::default::Default for Appearance { - fn default() -> Self { - Self { - track_color: Color::TRANSPARENT, - bar_color: Color::BLACK, - border_color: None, - border_radius: 0.0, +/// [`Style`] field overrides +#[derive(Clone, Copy, Debug, Default)] +pub struct Class { + pub track_color: Option, + pub bar_color: Option, + pub border_color: Option, + pub border_radius: Option, +} + +impl Class { + pub fn track_color(mut self, color: impl Into) -> Self { + self.track_color = Some(color.into()); + self + } + + pub fn bar_color(mut self, color: impl Into) -> Self { + self.bar_color = Some(color.into()); + self + } + + pub fn border_color(mut self, color: impl Into) -> Self { + self.border_color = Some(color.into()); + self + } + + pub fn border_radius(mut self, radius: f32) -> Self { + self.border_radius = Some(radius); + self + } + + fn resolve(&self, base: Style) -> Style { + Style { + track_color: self.track_color.unwrap_or(base.track_color), + bar_color: self.bar_color.unwrap_or(base.bar_color), + border_color: self.border_color.or(base.border_color), + border_radius: self.border_radius.unwrap_or(base.border_radius), } } } /// A set of rules that dictate the style of an indicator. -pub trait StyleSheet { - /// The supported style of the [`StyleSheet`]. - type Style: Default; +pub trait Catalog: Sized { + /// The supported class of the [`Catalog`]. + type Class: Default; - /// Produces the active [`Appearance`] of a indicator. - fn appearance( - &self, - style: &Self::Style, - is_determinate: bool, - is_circular: bool, - ) -> Appearance; + /// Produces the active [`Style`] of an indicator. + fn style(&self, class: &Self::Class, is_determinate: bool, is_circular: bool) -> Style; } -impl StyleSheet for iced::Theme { - type Style = (); +impl Catalog for iced::Theme { + type Class = Class; - fn appearance( - &self, - _style: &Self::Style, - _is_determinate: bool, - _is_circular: bool, - ) -> Appearance { + fn style(&self, class: &Self::Class, _is_determinate: bool, _is_circular: bool) -> Style { let palette = self.extended_palette(); - - Appearance { + class.resolve(Style { track_color: palette.background.weak.color, bar_color: palette.primary.base.color, border_color: None, border_radius: 0.0, - } + }) } } -impl StyleSheet for crate::Theme { - type Style = (); +impl Catalog for crate::Theme { + type Class = Class; - fn appearance( - &self, - _style: &Self::Style, - is_determinate: bool, - is_circular: bool, - ) -> Appearance { - let cur = self.current_container(); - let mut cur_divider = cur.divider; - cur_divider.alpha = 0.5; + fn style(&self, class: &Self::Class, is_determinate: bool, is_circular: bool) -> Style { let theme = self.cosmic(); - let (mut track_color, bar_color) = if theme.is_dark && theme.is_high_contrast { - ( + let (mut track_color, bar_color) = match (theme.is_dark, theme.is_high_contrast) { + (true, true) => ( theme.palette.neutral_6.into(), theme.accent_text_color().into(), - ) - } else if theme.is_dark { - (theme.palette.neutral_5.into(), theme.accent_color().into()) - } else if theme.is_high_contrast { - ( + ), + (true, false) => (theme.palette.neutral_5.into(), theme.accent_color().into()), + (false, true) => ( theme.palette.neutral_4.into(), theme.accent_text_color().into(), - ) - } else { - (theme.palette.neutral_3.into(), theme.accent_color().into()) + ), + (false, false) => (theme.palette.neutral_3.into(), theme.accent_color().into()), }; if !is_determinate && is_circular { track_color = Color::TRANSPARENT; } - Appearance { + class.resolve(Style { track_color, bar_color, border_color: if is_determinate && theme.is_high_contrast { - Some(cur_divider.into()) + Some(self.current_container().divider.with_alpha(0.5).into()) } else { None }, border_radius: theme.corner_radii.radius_xl[0], - } + }) } } From 8cb86195ec576064ac8e59e9200315714d17dc55 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 15 Sep 2026 17:28:53 -0600 Subject: [PATCH 26/35] chore: do not report unfocused if focus moved to one's own popup --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 87e1787f..5fc4372d 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 87e1787fbb9b924576e6f6ea2ef2f45d0729e63d +Subproject commit 5fc4372d877ad2ca6eaab1c0ca5e424f238b9258 From 3664828bf8cf39d7bbf5792b63257bcb6f560bd5 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Tue, 15 Sep 2026 18:53:27 -0400 Subject: [PATCH 27/35] chore: session lock action --- src/app/cosmic.rs | 55 +++++++++++++++++++++++++++++++++++++++++++ src/surface/action.rs | 22 ++++++++++++++++- src/surface/mod.rs | 24 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 62a70342..0b31606d 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -452,6 +452,33 @@ where crate::surface::Action::DestroyLayerShell(id) => { iced_winit::commands::layer_surface::destroy_layer_surface(id) } + #[cfg(wayland_platform)] + crate::surface::Action::Lock(id, output, live_settings, view) => { + let Some(live_settings) = + std::sync::Arc::try_unwrap(live_settings) + .ok() + .and_then(|s| { + s.downcast:: LiveSettings + Send + Sync>>() + .ok() + }) + else { + tracing::error!("Invalid live settings for popup"); + return Task::none(); + }; + + let live_settings = live_settings(); + let live_settings = Box::new(move |_app: &T| live_settings); + + if let Some(view) = view { + self.get_lock(id, output, live_settings, Some(Box::new(move |_| view()))) + } else { + self.get_lock(id, output, live_settings, None) + } + } + #[cfg(wayland_platform)] + crate::surface::Action::DestroyLock(id) => { + iced_winit::commands::session_lock::destroy_lock_surface(id) + } crate::surface::Action::SyncLiveSettings(id) => { if let Some((_, id, live_settings, _)) = self.surface_views.get(&id) { let live_settings = live_settings(&self.app); @@ -1741,6 +1768,34 @@ impl Cosmic { ); Task::batch([live_settings_task, get_layer_surface(settings)]) } + + #[cfg(wayland_platform)] + pub fn get_lock( + &mut self, + id: window::Id, + output: cctk::wayland_client::protocol::wl_output::WlOutput, + live_settings: Box Fn(&'a App) -> LiveSettings + Send + Sync>, + view: Option< + Box Fn(&'a App) -> Element<'a, crate::Action> + Send + Sync>, + >, + ) -> Task> { + use iced_winit::SurfaceIdWrapper; + use iced_winit::platform_specific::commands::session_lock::get_lock_surface; + *self.opened_surfaces.entry(id).or_insert(0) += 1; + let live_settings_task = + self.apply_live_settings(SurfaceIdWrapper::SessionLock(id), &live_settings(&self.app)); + self.surface_views.insert( + id, + ( + None, // TODO parent for layer shell, platform specific option maybe? + SurfaceIdWrapper::SessionLock(id), + live_settings, + view, + ), + ); + + live_settings_task.chain(get_lock_surface(id, output)) + } } #[cfg(wayland_platform)] diff --git a/src/surface/action.rs b/src/surface/action.rs index a4308306..48f01a82 100644 --- a/src/surface/action.rs +++ b/src/surface/action.rs @@ -5,7 +5,7 @@ use super::{Action, View}; #[cfg(feature = "winit")] use crate::Application; -use iced::{Rectangle, window}; +use iced::window; #[cfg(all(wayland_platform, feature = "winit"))] use iced_runtime::platform_specific::wayland::CornerRadius; #[cfg(wayland_platform)] @@ -275,6 +275,26 @@ pub fn simple_layer_shell( ) } +#[cfg(wayland_platform)] +#[must_use] +pub fn lock( + live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static, + id: window::Id, + output: cctk::wayland_client::protocol::wl_output::WlOutput, + view: Option< + impl Fn() -> crate::Element<'static, crate::Action> + Send + Sync + 'static, + >, +) -> Action { + let boxed_live: Box LiveSettings + Send + Sync + 'static> = Box::new(live_settings); + let boxed_live: Box = Box::new(boxed_live); + Action::Lock( + id, + output, + Arc::new(boxed_live), + view.map(|view| Arc::new(view) as View), + ) +} + #[cfg(wayland_platform)] #[must_use] pub fn app_layer_shell( diff --git a/src/surface/mod.rs b/src/surface/mod.rs index 09243da3..75439b88 100644 --- a/src/surface/mod.rs +++ b/src/surface/mod.rs @@ -64,6 +64,18 @@ pub enum Action { /// Destroy a layer shell surface DestroyLayerShell(iced::window::Id), + #[cfg(wayland_platform)] + /// Create a lock shell surface with a view function + Lock( + iced::window::Id, + cctk::wayland_client::protocol::wl_output::WlOutput, + BoxedSetting, + Option>, + ), + + /// Destroy a lock surface + DestroyLock(iced::window::Id), + /// Responsive menu bar update ResponsiveMenuBar { /// Id of the menu bar @@ -111,6 +123,9 @@ impl Action { 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), + #[cfg(wayland_platform)] + Action::Lock(id, output, a, view) => Action::Lock(id, output, a, map_view(view)), + Action::DestroyLock(id) => Action::DestroyLock(id), Action::ResponsiveMenuBar { menu_bar, limits, @@ -217,6 +232,15 @@ impl std::fmt::Debug for Action { Self::DestroyLayerShell(arg0) => { f.debug_tuple("DestroyLayerShell").field(arg0).finish() } + #[cfg(wayland_platform)] + Self::Lock(id, output, arg0, view) => f + .debug_tuple("Lock") + .field(id) + .field(output) + .field(arg0) + .field(&view.as_ref().map(|_| "view")) + .finish(), + Self::DestroyLock(arg0) => f.debug_tuple("DestroyLock").field(arg0).finish(), Self::SyncLiveSettings(arg0) => f.debug_tuple("SyncLiveSettings").field(arg0).finish(), } } From 87ab8179e1bd9880239c340855ae8862034bd0e8 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Tue, 15 Sep 2026 18:56:15 -0400 Subject: [PATCH 28/35] chain tasks where not already done --- src/app/cosmic.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 0b31606d..c2521b3f 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -1673,7 +1673,7 @@ impl Cosmic { view, ), ); - Task::batch([live_settings_task, get_subsurface(settings)]) + live_settings_task.chain(get_subsurface(settings)) } #[cfg(wayland_platform)] @@ -1729,16 +1729,13 @@ impl Cosmic { view, ), ); - Task::batch([ - iced_runtime::task::oneshot(|channel| { - iced_runtime::Action::Window(iced_runtime::window::Action::Open( - id, settings, channel, - )) - }) - .discard(), - // We don't control window creation in the same way - live_settings_task, - ]) + + // We don't control window creation in the same way + iced_runtime::task::oneshot(|channel| { + iced_runtime::Action::Window(iced_runtime::window::Action::Open(id, settings, channel)) + }) + .discard() + .chain(live_settings_task) } #[cfg(wayland_platform)] @@ -1766,7 +1763,7 @@ impl Cosmic { view, ), ); - Task::batch([live_settings_task, get_layer_surface(settings)]) + live_settings_task.chain(get_layer_surface(settings)) } #[cfg(wayland_platform)] From eaa7a5a24c4359ebeaab507855388942e8984d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vuka=C5=A1in=20Vojinovi=C4=87?= <150025636+git-f0x@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:04:54 +0200 Subject: [PATCH 29/35] chore: update iced Adapts to scrollable changes --- iced | 2 +- src/widget/scrollable/scrollable.rs | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/iced b/iced index 5fc4372d..5e2b5807 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 5fc4372d877ad2ca6eaab1c0ca5e424f238b9258 +Subproject commit 5e2b580791e994cb0132c94b32ba4b00bc9ced21 diff --git a/src/widget/scrollable/scrollable.rs b/src/widget/scrollable/scrollable.rs index a3fa4edd..6565279f 100644 --- a/src/widget/scrollable/scrollable.rs +++ b/src/widget/scrollable/scrollable.rs @@ -2,30 +2,28 @@ // SPDX-License-Identifier: MPL-2.0 use crate::{Element, Renderer}; -use iced::widget; +use iced::widget::scrollable::{Direction, Scrollable, Scrollbar}; pub fn scrollable<'a, Message>( element: impl Into>, -) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { +) -> Scrollable<'a, Message, crate::Theme, Renderer> { vertical(element) } pub fn vertical<'a, Message>( element: impl Into>, -) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { - widget::scrollable(element) - .scroller_width(8.0) - .scrollbar_width(8.0) - .scrollbar_padding(8.0) +) -> Scrollable<'a, Message, crate::Theme, Renderer> { + iced::widget::scrollable(element) + .padding(8.0) + .direction(Direction::Vertical( + Scrollbar::new().width(8.0).scroller_width(8.0), + )) } pub fn horizontal<'a, Message>( element: impl Into>, -) -> widget::Scrollable<'a, Message, crate::Theme, Renderer> { - widget::scrollable(element) - .direction(widget::scrollable::Direction::Horizontal( - widget::scrollable::Scrollbar::new(), - )) - .scroller_width(8.0) - .scrollbar_width(8.0) +) -> Scrollable<'a, Message, crate::Theme, Renderer> { + iced::widget::scrollable(element).direction(Direction::Horizontal( + Scrollbar::new().width(8.0).scroller_width(8.0), + )) } From 34caca138738af92f4b8fc60db52a82812161345 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 21 Sep 2026 15:08:05 -0600 Subject: [PATCH 30/35] fix(context_menu): refresh an open popup's items from the latest view --- src/widget/context_menu.rs | 76 ++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index a2f0c8eb..735653b5 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -17,6 +17,8 @@ use iced_core::{Length, Point, Size, mouse, touch}; use std::collections::HashSet; use std::sync::Arc; +use crate::widget::RcWrapper; + /// A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. pub fn context_menu<'a, Message: 'static + Clone>( content: impl Into>, @@ -71,7 +73,11 @@ pub struct ContextMenu<'a, Message> { 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>) { + 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; @@ -92,7 +98,7 @@ impl ContextMenu<'_, Message> { renderer: &crate::Renderer, shell: &mut iced_core::Shell<'_, Message>, viewport: &iced::Rectangle, - my_state: &mut LocalState, + my_state: &mut LocalState, ) { if self.window_id != window::Id::NONE && self.on_surface_action.is_some() { use crate::surface::action::{LiveSettings, destroy_popup}; @@ -197,6 +203,7 @@ impl ContextMenu<'_, Message> { ..Default::default() }; let parent = self.window_id; + let roots = my_state.roots.clone(); let t = THEME.lock().unwrap(); let styling = t.appearance(&crate::theme::menu_bar::MenuBarStyle::Default, false); drop(t); @@ -223,8 +230,12 @@ impl ContextMenu<'_, Message> { input_zone: None, }, Some(move || { + // Latest roots from the owner widget + let mut popup_menu = popup_menu.clone(); + popup_menu.menu_roots = + std::borrow::Cow::Owned(roots.with_data(Clone::clone)); crate::Element::from( - crate::widget::container(popup_menu.clone()).center(Length::Fill), + crate::widget::container(popup_menu).center(Length::Fill), ) .map(crate::action::app) }), @@ -246,7 +257,7 @@ impl Widget for ContextMenu<'_, Message> { fn tag(&self) -> tree::Tag { - tree::Tag::of::() + tree::Tag::of::>() } fn state(&self) -> tree::State { @@ -256,6 +267,8 @@ impl Widget fingers_pressed: Default::default(), menu_bar_state: Default::default(), reported_open: false, + roots: RcWrapper::new(self.context_menu.clone().unwrap_or_default()), + reshape: false, }) } @@ -289,11 +302,31 @@ 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::(); + let state = tree.state.downcast_mut::>(); if let Some(context_menu) = self.context_menu.as_mut() { + // The popup's item slots were measured at open from the item widgets. Items of + // another kind or count, such as a divider in a new place, do not fit those slots, + // so rebuild the popup on the next `update`. Same-shaped items, such as a + // relabeled button, update in place. + let popup_open = state + .menu_bar_state + .inner + .with_data(|d| !d.popup_id.is_empty()); + let shape = |roots: &Vec>| -> Vec { + roots.first().map_or_else(Vec::new, |root| { + root.flattern().iter().map(|mt| mt.item.tag()).collect() + }) + }; + if popup_open && state.roots.with_data(shape) != shape(context_menu) { + state.reshape = true; + return; + } state.menu_bar_state.inner.with_data_mut(|inner| { menu_roots_diff(context_menu, &mut inner.tree); }); + state + .roots + .with_data_mut(|roots| roots.clone_from(context_menu)); } // if let Some(ref mut context_menus) = self.context_menu { @@ -398,7 +431,7 @@ impl Widget shell: &mut iced_core::Shell<'_, Message>, viewport: &iced::Rectangle, ) { - let state = tree.state.downcast_mut::(); + let state = tree.state.downcast_mut::>(); let bounds = layout.bounds(); // The compositor dismissed our popup: nothing else tells this state about it. @@ -415,6 +448,24 @@ impl Widget }); } + // The menu changed shape while open + #[cfg(wayland_platform)] + if state.reshape { + state.reshape = false; + if state.menu_bar_state.inner.with_data(|d| d.open) { + 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); + }); + state + .roots + .with_data_mut(|roots| roots.clone_from(context_menu)); + } + let view_cursor = state.menu_bar_state.inner.with_data(|d| d.view_cursor); + self.create_popup(layout, view_cursor, renderer, shell, viewport, state); + } + } + // 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 @@ -484,7 +535,7 @@ impl Widget && (right_button_released(event) || (touch_lifted(event) && fingers_pressed == 2)) { state.context_cursor = cursor.position().unwrap_or_default(); - let state = tree.state.downcast_mut::(); + let state = tree.state.downcast_mut::>(); state.menu_bar_state.inner.with_data_mut(|state| { state.open = true; state.view_cursor = cursor; @@ -496,7 +547,7 @@ impl Widget shell.request_redraw(); shell.capture_event(); - self.report_open_state(tree.state.downcast_mut::(), shell); + self.report_open_state(tree.state.downcast_mut::>(), shell); return; } else if !was_open && right_button_released(event) || (touch_lifted(event)) @@ -532,7 +583,7 @@ impl Widget shell, viewport, ); - self.report_open_state(tree.state.downcast_mut::(), shell); + self.report_open_state(tree.state.downcast_mut::>(), shell); } fn overlay<'b>( @@ -560,7 +611,7 @@ impl Widget return content; } - let state = tree.state.downcast_ref::(); + let state = tree.state.downcast_ref::>(); let Some(context_menu) = self.context_menu.as_mut() else { return content; }; @@ -641,9 +692,12 @@ fn touch_lifted(event: &Event) -> bool { matches!(event, Event::Touch(touch::Event::FingerLifted { .. })) } -pub struct LocalState { +pub struct LocalState { context_cursor: Point, fingers_pressed: HashSet, menu_bar_state: MenuBarState, reported_open: bool, + roots: RcWrapper>>, + /// menu shape has changed, rebuild it on the next `update` + reshape: bool, } From 8c0e57835b3220733aab51ab4f4636d259996611 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 21 Sep 2026 15:49:40 -0600 Subject: [PATCH 31/35] fix: don't allow for leading dividers in the menu --- src/widget/menu/menu_tree.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index 36943759..35150a39 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -445,7 +445,8 @@ pub fn menu_items< )); } MenuItem::Divider => { - if i != size - 1 { + // A divider at either end separates nothing + if i != 0 && i != size - 1 { trees.push(MenuTree::::from(Element::from( widget::divider::horizontal::light(), ))); From 52b3f0728f742d1a83e0095fd29ea7733080cf6b Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 21 Sep 2026 17:45:18 -0600 Subject: [PATCH 32/35] chore(iced): shared modifier state --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 5e2b5807..6f1b3a71 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 5e2b580791e994cb0132c94b32ba4b00bc9ced21 +Subproject commit 6f1b3a71704c4074fad03b3bd6a4203f69556c9e From f0eed6e0c35eedf8947773af11f31657033d8d2f Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 22 Sep 2026 11:17:42 -0600 Subject: [PATCH 33/35] fix: close inner most menu on `esc` --- src/widget/menu/menu_bar.rs | 18 ++++++ src/widget/menu/menu_inner.rs | 115 +++++++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 21 deletions(-) diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index 88727818..938f80e1 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use super::menu_inner::{ CloseCondition, Direction, ItemHeight, ItemWidth, Menu, MenuState, PathHighlight, + close_innermost, }; use super::menu_tree::MenuTree; use crate::Renderer; @@ -626,6 +627,23 @@ where }); match event { + event::Event::Keyboard(iced_widget::core::keyboard::Event::KeyPressed { + key: + iced_widget::core::keyboard::Key::Named( + iced_widget::core::keyboard::key::Named::Escape, + ), + .. + }) if open => { + my_state.inner.with_data_mut(|state| { + let handler = self.on_surface_action.as_ref(); + close_innermost(state, &mut |id| { + if let Some(handler) = handler { + shell.publish((handler)(crate::surface::Action::DestroyPopup(id))); + } + }); + }); + shell.capture_event(); + } Mouse(mouse::Event::ButtonPressed(Left)) | Touch(touch::Event::FingerPressed { .. }) if view_cursor.is_over(layout.bounds()) => diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index f01682b1..ef4523b6 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -546,7 +546,48 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { }) } + /// Close every menu and, on Wayland, destroy the root popup this menu belongs to. + /// Used when clicking outside a menu. #[allow(clippy::too_many_lines)] + fn close_all( + &self, + state: &mut super::menu_bar::MenuBarStateInner, + shell: &mut Shell<'_, Message>, + ) { + #[cfg(wayland_platform)] + if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) + && let Some(handler) = self.on_surface_action.as_ref() + { + let mut root = self.window_id; + let mut depth = self.depth; + while let Some(parent) = state.popup_id.iter().find(|(_, v)| **v == root) { + if depth == 0 { + break; + } + root = *parent.0; + depth = depth.saturating_sub(1); + } + shell.publish((handler)(crate::surface::Action::DestroyPopup(root))); + } + + state.reset(); + } + + /// Close the innermost open menu, see [`close_innermost`]. + /// Used when pressing `esc`. + fn close( + &self, + state: &mut super::menu_bar::MenuBarStateInner, + shell: &mut Shell<'_, Message>, + ) { + let handler = self.on_surface_action.as_ref(); + close_innermost(state, &mut |id| { + if let Some(handler) = handler { + shell.publish((handler)(crate::surface::Action::DestroyPopup(id))); + } + }); + } + fn update( &mut self, event: &event::Event, @@ -601,6 +642,19 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { ); match event { + event::Event::Keyboard(iced_widget::core::keyboard::Event::KeyPressed { + key: + iced_widget::core::keyboard::Key::Named( + iced_widget::core::keyboard::key::Named::Escape, + ), + .. + }) if !self.is_overlay => { + self.tree + .inner + .with_data_mut(|state| self.close(state, shell)); + shell.capture_event(); + } + Mouse(WheelScrolled { delta }) => process_scroll_events( self, shell, @@ -676,27 +730,7 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { needs_reset |= self.close_condition.click_outside && !is_inside; if needs_reset { - #[cfg(wayland_platform)] - if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) - && let Some(handler) = self.on_surface_action.as_ref() - { - let mut root = self.window_id; - let mut depth = self.depth; - while let Some(parent) = - state.popup_id.iter().find(|(_, v)| **v == root) - { - // parent of root popup is the window, so we stop. - if depth == 0 { - break; - } - root = *parent.0; - depth = depth.saturating_sub(1); - } - shell - .publish((handler)(crate::surface::Action::DestroyPopup(root))); - } - - state.reset(); + self.close_all(state, shell); } } @@ -1782,6 +1816,45 @@ fn get_children_layout( (Size::new(width, height), child_positions, child_sizes) } +/// Close the innermost open menu level +pub(super) fn close_innermost( + state: &mut super::menu_bar::MenuBarStateInner, + destroy: &mut dyn FnMut(window::Id), +) { + let outermost = state + .popup_id + .iter() + .find(|(parent, _)| !state.popup_id.values().any(|id| id == *parent)) + .map(|(_, id)| *id); + let innermost = state + .popup_id + .values() + .find(|id| !state.popup_id.contains_key(id)) + .copied(); + + let levels = state.menu_states.len(); + if levels <= 1 { + if let Some(id) = outermost { + destroy(id); + } + state.popup_id.clear(); + state.reset(); + return; + } + + state.active_root.truncate(levels - 1); + state.menu_states.truncate(levels - 1); + + // Forget the parent's hovered item, so hovering it again reopens the submenu. + if let Some(parent) = state.menu_states.last_mut() { + parent.index = None; + } + if let Some(id) = innermost { + state.popup_id.retain(|_, popup| *popup != id); + destroy(id); + } +} + fn search_bound( default: usize, default_left: usize, From 3d3e4391f1824019daa035a9d0754b3a100798f2 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 22 Sep 2026 11:21:51 -0600 Subject: [PATCH 34/35] fix: make the keyboard bindings consistent in context menu --- src/widget/menu/menu_tree.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index 35150a39..901d50ae 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -296,10 +296,23 @@ fn entry_tree< action, } = entry; let spacing = crate::theme::spacing(); + // Several bindings may map to one action + // choose based on these rules: + // - the fewest modifiers win (Delete vs Ctrl+D) + // - if equal then character key over a named one (Ctrl+C over Ctrl+Insert) + // - if equal then `Ord` to stay consistent since hashmap is arbitrary + let rank = |k: &KeyBind| { + ( + k.modifiers.len(), + matches!(k.key, iced_core::keyboard::Key::Named(_)), + ) + }; let key = key_binds .iter() - .find(|(_, a)| **a == action) - .map_or_else(String::new, |(k, _)| k.to_string()); + .filter(|(_, a)| **a == action) + .map(|(k, _)| k) + .min_by(|a, b| rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))) + .map_or_else(String::new, ToString::to_string); let mut items: Vec> = Vec::with_capacity(7); From 08a09e3dfa1f729d52585fcedd6e731130235363 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 22 Sep 2026 11:22:11 -0600 Subject: [PATCH 35/35] chore(iced): fix arrow keys in text editor on a layer_surface --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 6f1b3a71..0968a10f 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 6f1b3a71704c4074fad03b3bd6a4203f69556c9e +Subproject commit 0968a10fc165984080799551124a96adf6be1fb8