From 6cdb4c62ced3e13b0ef9f6894d004e6cbf1a3c27 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 2 Jul 2026 12:38:03 -0400 Subject: [PATCH 01/58] refactor(rectange tracker): relax Copy to Clone --- src/widget/rectangle_tracker/mod.rs | 8 +++---- src/widget/rectangle_tracker/subscription.rs | 24 +++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/widget/rectangle_tracker/mod.rs b/src/widget/rectangle_tracker/mod.rs index 839fa9a7..1fb89ac3 100644 --- a/src/widget/rectangle_tracker/mod.rs +++ b/src/widget/rectangle_tracker/mod.rs @@ -22,15 +22,15 @@ pub fn rectangle_tracking_container<'a, Message, I, T>( tx: UnboundedSender<(I, Rectangle)>, ) -> RectangleTrackingContainer<'a, Message, crate::Renderer, I> where - I: Hash + Copy + Send + Sync + Debug + 'a, + I: Hash + Clone + Send + Sync + Debug + 'a, T: Into>, { RectangleTrackingContainer::new(content, id, tx) } pub fn subscription< - I: 'static + Hash + Copy + Send + Sync + Debug, - R: 'static + Hash + Copy + Send + Sync + Debug + Eq, + I: 'static + Hash + Clone + Send + Sync + Debug, + R: 'static + Hash + Clone + Send + Sync + Debug + Eq, >( id: I, ) -> iced::Subscription<(I, RectangleUpdate)> { @@ -76,7 +76,7 @@ where impl<'a, Message, Renderer, I> RectangleTrackingContainer<'a, Message, Renderer, I> where Renderer: iced_core::Renderer, - I: 'a + Hash + Copy + Send + Sync + Debug, + I: 'a + Hash + Clone + Send + Sync + Debug, { /// Creates an empty [`Container`]. pub(crate) fn new(content: T, id: I, tx: UnboundedSender<(I, Rectangle)>) -> Self diff --git a/src/widget/rectangle_tracker/subscription.rs b/src/widget/rectangle_tracker/subscription.rs index 22b42441..98082547 100644 --- a/src/widget/rectangle_tracker/subscription.rs +++ b/src/widget/rectangle_tracker/subscription.rs @@ -10,14 +10,16 @@ use super::RectangleTracker; #[cold] pub fn rectangle_tracker_subscription< - I: 'static + Hash + Copy + Send + Sync + Debug, - R: 'static + Hash + Copy + Send + Sync + Debug + Eq, + I: 'static + Hash + Clone + Send + Sync + Debug, + R: 'static + Hash + Clone + Send + Sync + Debug + Eq, >( id: I, ) -> Subscription<(I, RectangleUpdate)> { Subscription::run_with(id, |id| { - let id = *id; - stream::unfold(State::Ready, move |state| start_listening(id, state)) + let id = id.clone(); + stream::unfold(State::Ready, move |state| { + start_listening(id.clone(), state) + }) }) } @@ -27,7 +29,7 @@ pub enum State { Finished, } -async fn start_listening( +async fn start_listening( id: I, mut state: State, ) -> Option<((I, RectangleUpdate), State)> { @@ -37,7 +39,7 @@ async fn start_listening 0.1 || (prev.y - new.y).abs() > 0.1 { - map.insert(u.0, new); + map.insert(u.0.clone(), new); ( - Some((id, RectangleUpdate::Rectangle(u))), + Some((id.clone(), RectangleUpdate::Rectangle(u))), State::Waiting(rx, map), ) } else { (None, State::Waiting(rx, map)) } } else { - map.insert(u.0, u.1); + map.insert(u.0.clone(), u.1); ( - Some((id, RectangleUpdate::Rectangle(u))), + Some((id.clone(), RectangleUpdate::Rectangle(u))), State::Waiting(rx, map), ) } @@ -80,7 +82,7 @@ async fn start_listening where - I: 'static + Hash + Copy + Send + Sync + Debug, + I: 'static + Hash + Clone + Send + Sync + Debug, { Rectangle((I, Rectangle)), Init(RectangleTracker), From 9d01c86f25c813411a7631bb07d5c80a1738e05d Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 2 Jul 2026 13:25:37 -0400 Subject: [PATCH 02/58] refactor(rectange tracker): relax Copy to Clone --- src/widget/rectangle_tracker/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/widget/rectangle_tracker/mod.rs b/src/widget/rectangle_tracker/mod.rs index 1fb89ac3..71e44c86 100644 --- a/src/widget/rectangle_tracker/mod.rs +++ b/src/widget/rectangle_tracker/mod.rs @@ -5,7 +5,7 @@ use iced::futures::channel::mpsc::UnboundedSender; use iced::widget::Container; pub use subscription::*; -use iced_core::event::{self, Event}; +use iced_core::event::Event; use iced_core::widget::Tree; use iced_core::{ Alignment, Clipboard, Element, Layout, Length, Padding, Rectangle, Shell, Widget, layout, @@ -44,7 +44,7 @@ pub struct RectangleTracker { impl RectangleTracker where - I: Hash + Copy + Send + Sync + Debug, + I: Hash + Clone + Send + Sync + Debug, { pub fn container<'a, Message: 'static, T>( &self, @@ -185,7 +185,7 @@ impl<'a, Message, Renderer, I> Widget for RectangleTrackingContainer<'a, Message, Renderer, I> where Renderer: iced_core::Renderer, - I: 'a + Hash + Copy + Send + Sync + Debug, + I: 'a + Hash + Clone + Send + Sync + Debug, { fn children(&self) -> Vec { self.container.children() @@ -275,7 +275,7 @@ where cursor_position: mouse::Cursor, viewport: &Rectangle, ) { - let _ = self.tx.unbounded_send((self.id, layout.bounds())); + let _ = self.tx.unbounded_send((self.id.clone(), layout.bounds())); self.container.draw( tree, renderer, @@ -327,7 +327,7 @@ impl<'a, Message, Renderer, I> From, From ee5d965983957271cf759fd76e3e4eed4f188d35 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 2 Jul 2026 14:10:00 -0400 Subject: [PATCH 03/58] fix: disabled menu buttons don't need to be transparent --- src/theme/style/button.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/theme/style/button.rs b/src/theme/style/button.rs index 723d1b6b..76798104 100644 --- a/src/theme/style/button.rs +++ b/src/theme/style/button.rs @@ -218,7 +218,12 @@ impl Catalog for crate::Theme { appearance(self, false, false, true, style, |component| { let mut background = Color::from(component.base); - background.a *= 0.5; + if !matches!( + style, + Button::MenuFolder | Button::MenuItem | Button::MenuRoot + ) { + background.a *= 0.5; + } ( background, Some(component.on_disabled.into()), From 9691870f7266543a2bf7308f11aeb325a44f12f2 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 6 Jul 2026 14:38:59 -0400 Subject: [PATCH 04/58] refactor: option for customizing the border padding, instead of disabling the content container --- src/app/mod.rs | 20 +++++++++----------- src/core.rs | 4 ++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index f757d5be..5ead69fe 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -625,21 +625,23 @@ impl ApplicationExt for App { let content_container = core.window.content_container; let show_context = core.window.show_context; let nav_bar_active = core.nav_bar_active(); - let transparent_header = core.window.transparent_header; let focused = core .focus_chain() .iter() - .any(|i| Some(*i) == self.core().main_window_id()); + .any(|i| Some(*i) == core.main_window_id()); - let border_padding = if maximized { 8 } else { 7 }; + let border_padding = core + .window + .border_padding + .unwrap_or(if maximized { 8 } else { 7 }); - let main_content_padding = if !content_container { - [0, 0, 0, 0] - } else { + let main_content_padding = if content_container { let right_padding = if show_context { 0 } else { border_padding }; let left_padding = if nav_bar_active { 0 } else { border_padding }; [0, right_padding, 0, left_padding] + } else { + [0, 0, 0, 0] }; let content_row = crate::widget::row::with_children({ @@ -834,11 +836,7 @@ impl ApplicationExt for App { let cosmic = theme.cosmic(); container::Style { background: Some(iced::Background::Color( - if transparent_header { - Color::TRANSPARENT - } else { - cosmic.background(theme.transparent).base.into() - }, + cosmic.background(theme.transparent).base.into(), )), border: iced::Border { radius: [ diff --git a/src/core.rs b/src/core.rs index 0ca555e3..b2bf8693 100644 --- a/src/core.rs +++ b/src/core.rs @@ -39,8 +39,8 @@ pub struct Window { pub show_close: bool, pub show_maximize: bool, pub show_minimize: bool, - pub transparent_header: bool, pub is_maximized: bool, + pub border_padding: Option, height: f32, width: f32, } @@ -170,9 +170,9 @@ impl Default for Core { show_minimize: true, show_window_menu: false, is_maximized: false, - transparent_header: false, height: 0., width: 0., + border_padding: None, }, focused_window: Vec::new(), #[cfg(feature = "applet")] From 2a325fa69a1d6e2c2fd344055454fbd70050eb63 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 6 Jul 2026 14:51:29 -0400 Subject: [PATCH 05/58] fix: update blur status after theme change --- src/app/cosmic.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 2d5b10d3..c0e61843 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -1021,6 +1021,29 @@ impl Cosmic { cmds.push(corner_radius(*id, cur_rad).discard()); } + let blur = if new_blur { + iced::window::enable_blur + } else { + iced::window::disable_blur + }; + if self.app.core().blur(&cosmic_theme, None) { + cmds.push(blur( + self.app + .core() + .main_window_id() + .unwrap_or(window::Id::RESERVED), + )); + } + for (id, wrapper, ..) in &self.surface_views { + let overriden = wrapper.2(&self.app); + if self.app.core().blur(&cosmic_theme, Some(wrapper.1)) + && overriden.blur.unwrap_or(true) + { + cmds.push(blur(*id)); + } else if overriden.blur.is_some_and(|b| !b) { + cmds.push(iced::window::disable_blur(*id)); + } + } return Task::batch(cmds); } } From d9b2b38bf4dc8f2af3d7dc7be9fa296ab75c3170 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 6 Jul 2026 18:49:36 -0400 Subject: [PATCH 06/58] fix: update iced popup blur & corner cleanup --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index c781ff61..b5b2b2fc 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit c781ff6199715f5650411c7ac625d4e1929bfdfb +Subproject commit b5b2b2fcc3e58be793624ba3a7dd45485813935a From 5056fc6f9bf4218a940ad64c03229983317ae578 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 8 Jul 2026 11:59:04 -0400 Subject: [PATCH 07/58] improv: overlap corner radius strip regions fixes handling of unfortunate scale values --- src/surface/corner_radius.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/surface/corner_radius.rs b/src/surface/corner_radius.rs index 1eda578d..595b4cf0 100644 --- a/src/surface/corner_radius.rs +++ b/src/surface/corner_radius.rs @@ -45,7 +45,7 @@ pub fn rounded_rect_strips(rect: Rectangle, radius: CornerRadius) -> Vec 1. { 2. } else { 1. }, }); } } @@ -62,9 +62,9 @@ pub fn rounded_rect_strips(rect: Rectangle, radius: CornerRadius) -> Vec 0.0 { out.push(Rectangle { x: strip_x, - y: rect.y + h - max_bottom as f32 + y as f32, + y: rect.y + h - max_bottom as f32 + y as f32 - if h > 1. { 1. } else { 0. }, width: strip_w, - height: 1.0, + height: if h > 1. { 2. } else { 1. }, }); } } From 74b2407342de7de1d9093a7dce34e4f58e6b53cd Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 8 Jul 2026 15:09:57 -0400 Subject: [PATCH 08/58] fix: avoid blurring subsurfaces on creation --- src/app/cosmic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index c0e61843..daad0927 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -1683,7 +1683,7 @@ impl Cosmic { let live_settings_task = self.apply_live_settings( SurfaceIdWrapper::Subsurface(settings.id), &LiveSettings { - blur: Some(self.blur_enabled), + blur: None, corners: None, padding: None, }, From dc11d6a11f601692fb8fd67fdc99775f0a9eb899 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 8 Jul 2026 19:34:10 -0400 Subject: [PATCH 09/58] fix: no text should use the steps array for transparent color --- cosmic-theme/src/model/theme.rs | 35 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs index 9a80d31a..77f94c8f 100644 --- a/cosmic-theme/src/model/theme.rs +++ b/cosmic-theme/src/model/theme.rs @@ -11,7 +11,6 @@ use palette::color_difference::Wcag21RelativeContrast; use palette::rgb::Rgb; use palette::{IntoColor, Oklcha, Srgb, Srgba, WithAlpha}; use serde::{Deserialize, Serialize}; -use std::default; use std::num::NonZeroUsize; /// ID for the current dark `ThemeBuilder` config @@ -1190,7 +1189,7 @@ impl ThemeBuilder { ); let on_transparent_bg_component = get_text( color_index(transparent_bg_component, transparent_bg_steps_array.len()), - &transparent_bg_steps_array, + &step_array, &control_steps_array[8], text_steps_array.as_deref(), actual_alpha, @@ -1254,17 +1253,22 @@ impl ThemeBuilder { }; container_bg.alpha = (container_alpha + if is_dark { 0.3 } else { 0.25 }).min(1.0); - let step_array = steps(container_bg, NonZeroUsize::new(100).unwrap()); - let base_index: usize = color_index(container_bg, step_array.len()); - let component_base = - get_surface_color(base_index, 6, &step_array, is_dark, &control_steps_array[3]); + let t_step_array = steps(container_bg, NonZeroUsize::new(100).unwrap()); + let base_index: usize = color_index(container_bg, t_step_array.len()); + let component_base = get_surface_color( + base_index, + 6, + &t_step_array, + is_dark, + &control_steps_array[3], + ); Container::new( Component::component( component_base, accent, get_text( - color_index(component_base, step_array.len()), + color_index(component_base, t_step_array.len()), &step_array, &control_steps_array[8], text_steps_array.as_deref(), @@ -1368,10 +1372,15 @@ impl ThemeBuilder { get_surface_color(bg_index, 10, &step_array, is_dark, &control_steps_array[2]) }; - let step_array = steps(container_bg, NonZeroUsize::new(100).unwrap()); - let base_index = color_index(container_bg, step_array.len()); - let secondary_component = - get_surface_color(base_index, 3, &step_array, is_dark, &control_steps_array[4]); + let t_step_array = steps(container_bg, NonZeroUsize::new(100).unwrap()); + let base_index = color_index(container_bg, t_step_array.len()); + let secondary_component = get_surface_color( + base_index, + 3, + &t_step_array, + is_dark, + &control_steps_array[4], + ); component_hovered_overlay = if base_index < 91 { control_steps_array[10] @@ -1388,7 +1397,7 @@ impl ThemeBuilder { secondary_component, accent, get_text( - color_index(secondary_component, step_array.len()), + color_index(secondary_component, t_step_array.len()), &step_array, &control_steps_array[8], text_steps_array.as_deref(), @@ -1561,7 +1570,7 @@ impl ThemeBuilder { transparent_bg, get_text( bg_index, - &transparent_bg_steps_array, + &step_array, &control_steps_array[8], text_steps_array.as_deref(), actual_alpha, From 8cb10bf6e24b86a7d2a9cf8c662d3e0154db1b21 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Thu, 9 Jul 2026 17:39:23 -0400 Subject: [PATCH 10/58] fix: config watcher hash & blur --- cosmic-config/src/dbus.rs | 2 ++ src/app/cosmic.rs | 18 +++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/cosmic-config/src/dbus.rs b/cosmic-config/src/dbus.rs index 9b6e8697..76c03ad5 100644 --- a/cosmic-config/src/dbus.rs +++ b/cosmic-config/src/dbus.rs @@ -67,6 +67,8 @@ struct Wrapper( impl std::hash::Hash for Wrapper { fn hash(&self, state: &mut H) { self.0.hash(state); + self.2.hash(state); + self.3.hash(state); } } diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index daad0927..ed81d416 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -941,7 +941,7 @@ impl Cosmic { } Action::SystemThemeChange(keys, mut theme) => { - let cur_is_dark = THEME.lock().unwrap().theme_type.is_dark(); + let cur_is_dark = self.app.core().system_theme_mode.is_dark; // Ignore updates if the current theme mode does not match. if cur_is_dark != theme.cosmic().is_dark { return iced::Task::none(); @@ -1026,14 +1026,14 @@ impl Cosmic { } else { iced::window::disable_blur }; - if self.app.core().blur(&cosmic_theme, None) { - cmds.push(blur( - self.app - .core() - .main_window_id() - .unwrap_or(window::Id::RESERVED), - )); - } + + cmds.push(blur( + self.app + .core() + .main_window_id() + .unwrap_or(window::Id::RESERVED), + )); + for (id, wrapper, ..) in &self.surface_views { let overriden = wrapper.2(&self.app); if self.app.core().blur(&cosmic_theme, Some(wrapper.1)) From a1003f24ec721fc1592b813a12e0f2d12d7ac414 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 9 Jul 2026 17:36:31 -0600 Subject: [PATCH 11/58] chore: update iced to fix corner radius --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index b5b2b2fc..5da11885 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit b5b2b2fcc3e58be793624ba3a7dd45485813935a +Subproject commit 5da1188557b58cdbe1951c72aad4360ebe05f5f4 From 5187dd6d1db7a1a97ffee962dbc1a87f72479f05 Mon Sep 17 00:00:00 2001 From: Eduardo Flores Date: Sat, 11 Jul 2026 21:06:25 -0700 Subject: [PATCH 12/58] fix(segmented_button): remove feature gate --- src/widget/segmented_button/widget.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index f5ca21a8..ce5a6f21 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -1689,13 +1689,6 @@ where } } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] if let Some(on_activate) = self.on_activate.as_ref() { if is_pressed(event) { state.pressed_item = Some(Item::Tab(key)); From 18731e839265eeb534924b4a83e69a0e5043f32b Mon Sep 17 00:00:00 2001 From: name-is-hai <90458368+name-is-hai@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:12:46 +0700 Subject: [PATCH 13/58] fix(desktop): XDG desktop entry precedence Resolve duplicate desktop IDs before applying desktop entry visibility rules This allows user desktop entries to override matching system entries and ensures Hidden, NoDisplay, and NotShowIn overrides do not reveal lower-priority entries Signed-off-by: name-is-hai <90458368+name-is-hai@users.noreply.github.com> --- src/desktop.rs | 266 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 228 insertions(+), 38 deletions(-) diff --git a/src/desktop.rs b/src/desktop.rs index cfa7a668..67002ca5 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -77,9 +77,12 @@ impl DesktopEntryCache { } pub fn refresh(&mut self) { - self.entries = fde::Iter::new(fde::default_paths()) - .filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok()) - .collect(); + self.entries = desktop_entries_with_precedence( + fde::Iter::new(fde::default_paths()) + .filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok()), + ) + .filter(|entry| !entry.hidden()) + .collect(); } pub fn insert(&mut self, entry: fde::DesktopEntry) { @@ -604,28 +607,36 @@ fn candidate_desktop_ids(context: &DesktopLookupContext<'_>) -> Vec { ordered } +#[cfg(not(windows))] +fn desktop_entries_with_precedence<'a>( + entries: impl Iterator + 'a, +) -> impl Iterator + 'a { + let mut seen = HashSet::new(); + + entries.filter(move |entry| seen.insert(entry.id().to_owned())) +} + #[cfg(not(windows))] pub fn load_applications<'a>( locales: &'a [String], include_no_display: bool, only_show_in: Option<&'a str>, ) -> impl Iterator + 'a { - fde::Iter::new(fde::default_paths()) - .filter_map(move |p| fde::DesktopEntry::from_path(p, Some(locales)).ok()) - .filter(move |de| { - (include_no_display || !de.no_display()) - && only_show_in.zip(de.only_show_in()).is_none_or( - |(xdg_current_desktop, only_show_in)| { - only_show_in.contains(&xdg_current_desktop) - }, - ) - && only_show_in.zip(de.not_show_in()).is_none_or( - |(xdg_current_desktop, not_show_in)| { - !not_show_in.contains(&xdg_current_desktop) - }, - ) - }) - .map(move |de| DesktopEntryData::from_desktop_entry(locales, de)) + desktop_entries_with_precedence( + fde::Iter::new(fde::default_paths()) + .filter_map(move |p| fde::DesktopEntry::from_path(p, Some(locales)).ok()), + ) + .filter(move |de| { + !de.hidden() + && (include_no_display || !de.no_display()) + && only_show_in.zip(de.only_show_in()).is_none_or( + |(xdg_current_desktop, only_show_in)| only_show_in.contains(&xdg_current_desktop), + ) + && only_show_in.zip(de.not_show_in()).is_none_or( + |(xdg_current_desktop, not_show_in)| !not_show_in.contains(&xdg_current_desktop), + ) + }) + .map(move |de| DesktopEntryData::from_desktop_entry(locales, de)) } // Create an iterator which filters desktop entries by app IDs. @@ -642,40 +653,51 @@ pub fn load_applications_for_app_ids<'a>( let app_ids = std::rc::Rc::new(std::cell::RefCell::new(app_ids)); let app_ids_ = app_ids.clone(); - let applications = iter + let applications = desktop_entries_with_precedence(iter) .filter(move |de| { + // Match and consume the requested ID before applying visibility + // filters. This prevents a Hidden or NoDisplay override from being + // recreated by fill_missing_ones. + let position = { + let requested = app_ids.borrow(); + + requested + .iter() + .position(|id| de.matches_id(fde::unicase::Ascii::new(*id))) + .or_else(|| { + requested + .iter() + .position(|id| de.matches_name(fde::unicase::Ascii::new(*id))) + }) + }; + + let Some(position) = position else { + return false; + }; + + app_ids.borrow_mut().remove(position); + + if de.hidden() { + return false; + } + if !include_no_display && de.no_display() { return false; } + if only_show_in.zip(de.only_show_in()).is_some_and( |(xdg_current_desktop, only_show_in)| !only_show_in.contains(&xdg_current_desktop), ) { return false; } + if only_show_in.zip(de.not_show_in()).is_some_and( |(xdg_current_desktop, not_show_in)| not_show_in.contains(&xdg_current_desktop), ) { return false; } - // Search by ID first - app_ids - .borrow() - .iter() - .position(|id| de.matches_id(fde::unicase::Ascii::new(*id))) - // Then fall back to search by name - .or_else(|| { - app_ids - .borrow() - .iter() - .position(|id| de.matches_name(fde::unicase::Ascii::new(*id))) - }) - // Remove the app ID if found - .map(|i| { - app_ids.borrow_mut().remove(i); - true - }) - .unwrap_or_default() + true }) .map(move |de| DesktopEntryData::from_desktop_entry(locales, de)); @@ -911,6 +933,174 @@ mod tests { entry } + #[test] + fn desktop_entry_precedence_prefers_first_entry() { + let locales = vec!["en_US.UTF-8".to_string()]; + + let user_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=User Application\n\ + Exec=user-application\n", + &locales, + ); + + let system_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=System Application\n\ + Exec=system-application\n", + &locales, + ); + + let entries = desktop_entries_with_precedence(vec![user_entry, system_entry].into_iter()) + .collect::>(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].exec(), Some("user-application")); + } + + #[test] + fn hidden_override_masks_system_entry_without_fallback() { + let locales = vec!["en_US.UTF-8".to_string()]; + + let user_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Example Application\n\ + Hidden=true\n", + &locales, + ); + + let system_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Example Application\n\ + Exec=system-application\n", + &locales, + ); + + let applications = load_applications_for_app_ids( + vec![user_entry, system_entry].into_iter(), + &locales, + vec!["com.example.App"], + true, + false, + Some("COSMIC"), + ) + .collect::>(); + + assert!(applications.is_empty()); + } + + #[test] + fn no_display_override_masks_system_entry() { + let locales = vec!["en_US.UTF-8".to_string()]; + + let user_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=User Application\n\ + Exec=user-application\n\ + NoDisplay=true\n", + &locales, + ); + + let system_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=System Application\n\ + Exec=system-application\n", + &locales, + ); + + let hidden_from_menu = load_applications_for_app_ids( + vec![user_entry.clone(), system_entry.clone()].into_iter(), + &locales, + vec!["com.example.App"], + true, + false, + Some("COSMIC"), + ) + .collect::>(); + + assert!(hidden_from_menu.is_empty()); + + let included_when_requested = load_applications_for_app_ids( + vec![user_entry, system_entry].into_iter(), + &locales, + vec!["com.example.App"], + true, + true, + Some("COSMIC"), + ) + .collect::>(); + + assert_eq!(included_when_requested.len(), 1); + assert_eq!( + included_when_requested[0].exec.as_deref(), + Some("user-application") + ); + } + + #[test] + fn not_show_in_override_masks_system_entry_for_cosmic() { + let locales = vec!["en_US.UTF-8".to_string()]; + + let user_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=User Application\n\ + Exec=user-application\n\ + NotShowIn=COSMIC;\n", + &locales, + ); + + let system_entry = load_entry( + "com.example.App.desktop", + "[Desktop Entry]\n\ + Type=Application\n\ + Name=System Application\n\ + Exec=system-application\n", + &locales, + ); + + let hidden_in_cosmic = load_applications_for_app_ids( + vec![user_entry.clone(), system_entry.clone()].into_iter(), + &locales, + vec!["com.example.App"], + true, + false, + Some("COSMIC"), + ) + .collect::>(); + + assert!(hidden_in_cosmic.is_empty()); + + let visible_in_gnome = load_applications_for_app_ids( + vec![user_entry, system_entry].into_iter(), + &locales, + vec!["com.example.App"], + true, + false, + Some("GNOME"), + ) + .collect::>(); + + assert_eq!(visible_in_gnome.len(), 1); + assert_eq!( + visible_in_gnome[0].exec.as_deref(), + Some("user-application") + ); + } + #[test] fn candidate_generation_covers_common_variants() { let ctx = DesktopLookupContext::new("com.example.App.desktop") From fc5debccef41cd6bd6731cbc768cb719847d1a2f Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Mon, 13 Jul 2026 16:00:44 +0200 Subject: [PATCH 14/58] fix: matching desktop entries by CRX ID The existing test for this was broken, and the desktop entry cache was being refreshed before a match is fully checked. --- src/desktop.rs | 131 +++++++++++++++++++++++++------------------------ 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/src/desktop.rs b/src/desktop.rs index 67002ca5..2baa4d97 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -176,53 +176,58 @@ pub fn resolve_desktop_entry( options: &DesktopResolveOptions, ) -> fde::DesktopEntry { let app_id = fde::unicase::Ascii::new(context.app_id.as_ref()); + let resolve = |cache: &mut DesktopEntryCache| { + if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) { + return Some(entry.clone()); + } - if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) { + let candidate_ids = candidate_desktop_ids(context); + + if let Some(entry) = try_match_cached(cache.entries(), &candidate_ids) { + return Some(entry); + } + + if let Some(entry) = load_entry_via_app_ids( + cache, + &candidate_ids, + options.include_no_display, + options.xdg_current_desktop.as_deref(), + ) { + cache.insert(entry.clone()); + return Some(entry); + } + + if let Some(entry) = match_startup_wm_class(cache.entries(), context) { + return Some(entry); + } + + // Chromium/CRX heuristic: scan exec/wmclass/icon for a CRX id match. + if let Some(entry) = match_crx_id(cache.entries(), context) { + return Some(entry); + } + + if let Some(entry) = match_exec_basename(cache.entries(), &candidate_ids) { + return Some(entry); + } + + if let Some(entry) = proton_or_wine_fallback(cache, context) { + cache.insert(entry.clone()); + return Some(entry); + } + + None + }; + + if let Some(entry) = resolve(cache) { return entry.clone(); } cache.refresh(); - if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) { - return entry.clone(); - } - - let candidate_ids = candidate_desktop_ids(context); - - if let Some(entry) = try_match_cached(cache.entries(), &candidate_ids) { - return entry; - } - - if let Some(entry) = load_entry_via_app_ids( - cache, - &candidate_ids, - options.include_no_display, - options.xdg_current_desktop.as_deref(), - ) { - cache.insert(entry.clone()); - return entry; - } - - if let Some(entry) = match_startup_wm_class(cache.entries(), context) { - return entry; - } - - // Chromium/CRX heuristic: scan exec/wmclass/icon for a CRX id match. - if let Some(entry) = match_crx_id(cache.entries(), context) { - return entry; - } - - if let Some(entry) = match_exec_basename(cache.entries(), &candidate_ids) { - return entry; - } - - if let Some(entry) = proton_or_wine_fallback(cache, context) { - cache.insert(entry.clone()); - entry - } else { + resolve(cache).unwrap_or_else(|| { let fallback = fallback_entry(context); cache.insert(fallback.clone()); fallback - } + }) } #[cfg(not(windows))] @@ -1166,14 +1171,13 @@ StartupWMClass=crx_jnpecgipniidlgicjocehkhajgdnjekh let apps_dir = temp.path().join("applications"); fs::create_dir_all(&apps_dir).expect("create applications dir"); - let desktop_contents = "\ -[Desktop Entry]\n\ -Version=1.0\n\ -Type=Application\n\ -Name=VMware Workstation\n\ -Exec=/usr/bin/vmware %U\n\ -Icon=vmware-workstation\n\ -"; + let desktop_contents = r#"[Desktop Entry] +Version=1.0 +Type=Application +Name=VMware Workstation +Exec=/usr/bin/vmware %U +Icon=vmware-workstation +"#; let desktop_path = apps_dir.join("vmware-workstation.desktop"); fs::write(desktop_path, desktop_contents).expect("write desktop file"); @@ -1287,30 +1291,29 @@ Icon=vmware-workstation\n\ #[test] fn crx_matcher_by_exec_and_wmclass() { - use std::fs; - let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; - let temp = tempdir().expect("tempdir"); - let apps_dir = temp.path().join("applications"); - fs::create_dir_all(&apps_dir).expect("create applications dir"); - let desktop_contents = format!( - "[Desktop Entry]\nType=Application\nName=ChatGPT\nExec=chromium --app-id={} --profile-directory=Default\nStartupWMClass=crx_{}\nIcon=chrome-{}-Default\n", - id, id, id - ); - let desktop_path = apps_dir.join( - "org.chromium.Chromium.flextop.chrome-cadlkienfkclaiaibeoongdcgmdikeeg-Default.desktop", - ); - fs::write(&desktop_path, desktop_contents).expect("write desktop file"); - - let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path()); let locales = vec!["en_US.UTF-8".to_string()]; + let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; let mut cache = DesktopEntryCache::new(locales.clone()); - cache.refresh(); + cache.insert(fde::DesktopEntry::from_str( + "org.chromium.Chromium.flextop.chrome-cadlkienfkclaiaibeoongdcgmdikeeg-Default.desktop", + &format!( + r#"[Desktop Entry] +Type=Application +Name=Example +Exec=chromium --app-id={id} --profile-directory=Default +StartupWMClass=crx_{id} +Icon=chrome-{id}-Default +"# + ), + Some(&locales), + ) + .unwrap()); let short_id = format!("chrome-{}-Default", id); let ctx = DesktopLookupContext::new(short_id); let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default()); - assert!(resolved.icon().is_some()); assert!(resolved.exec().is_some()); + assert!(resolved.icon().is_some()); let expected = format!("crx_{}", id); assert_eq!(resolved.startup_wm_class(), Some(expected.as_str())); } From c53b43d41bfb487c9deba4cb2afe45f1eb9b0ae6 Mon Sep 17 00:00:00 2001 From: Aadil <211808495+Aadil127@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:45:46 +0530 Subject: [PATCH 15/58] feat(color_picker): add mouse_interaction function (#1326) --- src/widget/color_picker/mod.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/widget/color_picker/mod.rs b/src/widget/color_picker/mod.rs index 4f9249dd..ab0f73fc 100644 --- a/src/widget/color_picker/mod.rs +++ b/src/widget/color_picker/mod.rs @@ -756,6 +756,23 @@ where fn size(&self) -> Size { Size::new(self.width, Length::Shrink) } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + self.inner.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } } #[derive(Debug, Default)] From 96a82045d75b0f4eefcf1f668b305d8397c4f351 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Mon, 13 Jul 2026 20:06:45 +0200 Subject: [PATCH 16/58] fix(segmented_button): crash when enumerating context menu buttons --- src/widget/segmented_button/widget.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index ce5a6f21..fbefce34 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -970,11 +970,12 @@ where let Some((mut bounds, i)) = self .variant_bounds(state, layout.bounds()) - .enumerate() - .find_map(|(i, item)| match item { - ItemBounds::Button(e, bounds) if e == entity => Some((bounds, i)), + .filter_map(|item| match item { + ItemBounds::Button(entity, bounds) => Some((bounds, entity)), _ => None, }) + .enumerate() + .find_map(|(i, (bounds, e))| if e == entity { Some((bounds, i)) } else { None }) else { return; }; @@ -2529,11 +2530,12 @@ where let (mut bounds, i) = self .variant_bounds(state, layout.bounds()) - .enumerate() - .find_map(|(i, item)| match item { - ItemBounds::Button(e, bounds) if e == entity => Some((bounds, i)), + .filter_map(|item| match item { + ItemBounds::Button(entity, bounds) => Some((bounds, entity)), _ => None, - })?; + }) + .enumerate() + .find_map(|(i, (bounds, e))| if e == entity { Some((bounds, i)) } else { None })?; assert!( self.context_menu From 511384f6206527cf87369da67a9164831afbabef Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 13 Jul 2026 16:20:29 -0600 Subject: [PATCH 17/58] chore: update iced to pull in fixes for curved corners --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 5da11885..f6daee14 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 5da1188557b58cdbe1951c72aad4360ebe05f5f4 +Subproject commit f6daee14ac8f7cf090a5413ee1576aa59649a956 From 8e6aea650ac8e9c8b70405a6a4ac493a49bd72e3 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 15 Jul 2026 12:36:30 -0400 Subject: [PATCH 18/58] fix: feature gates --- Cargo.toml | 33 ++++----- build.rs | 1 + iced | 2 +- src/app/action.rs | 6 +- src/app/cosmic.rs | 97 +++++++++++++-------------- src/app/mod.rs | 8 +-- src/app/settings.rs | 4 +- src/core.rs | 4 +- src/lib.rs | 2 +- src/surface/action.rs | 34 +++++----- src/surface/mod.rs | 2 +- src/theme/style/mod.rs | 4 +- src/widget/autosize.rs | 2 +- src/widget/context_menu.rs | 42 ++---------- src/widget/dropdown/mod.rs | 2 +- src/widget/dropdown/widget.rs | 22 +++--- src/widget/menu/menu_bar.rs | 76 +++------------------ src/widget/menu/menu_inner.rs | 34 ++-------- src/widget/mod.rs | 2 +- src/widget/nav_bar.rs | 7 +- src/widget/responsive_menu_bar.rs | 4 +- src/widget/segmented_button/widget.rs | 62 +++-------------- src/widget/text_input/input.rs | 38 +++++------ 23 files changed, 161 insertions(+), 327 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b02b6ca7..3b3871c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ default = [ "a11y", "dbus-config", "x11", - "iced-wayland", + "wayland", "multi-window", ] advanced-shaping = ["iced/advanced-shaping"] @@ -72,6 +72,7 @@ serde-keycode = ["iced_core/serde"] single-instance = ["iced_winit/single-instance", "zbus/blocking-api", "ron"] # smol async runtime smol = ["dep:smol", "iced/smol", "zbus?/async-io", "rfd?/async-std"] +# Tokio async runtime tokio = [ "dep:tokio", "ashpd?/tokio", @@ -80,22 +81,18 @@ tokio = [ "zbus?/tokio", "cosmic-config/tokio", ] -# Tokio async runtime # Wayland window support -iced-wayland = [ - "ashpd?/wayland", +wayland = [ "autosize", + "surface-message", + "cctk", + "multi-window", + "winit", + "ashpd?/wayland", "iced/wayland", "iced_winit/wayland", - "surface-message", -] -wayland = [ - "iced-wayland", - "iced_runtime/cctk", - "iced_winit/cctk", - "iced_wgpu/cctk", - "iced/cctk", - "dep:cctk", + "iced_runtime/wayland", + "iced_wgpu/wayland", ] surface-message = [] # multi-window support @@ -127,7 +124,6 @@ ashpd = { version = "0.12.3", default-features = false, optional = true } async-fs = { version = "2.2", optional = true } async-std = { workspace = true, optional = true } auto_enums = "0.8.8" -cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "32283d7", optional = true } jiff = "0.2" cosmic-config = { path = "cosmic-config" } cosmic-settings-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true } @@ -173,7 +169,8 @@ ron = { workspace = true, optional = true } enumflags2 = "0.7.12" # Enable DBus feature on Linux targets -[target.'cfg(target_os = "linux")'.dependencies] +[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 } cosmic-config = { path = "cosmic-config", features = ["dbus"] } cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings" } zbus.workspace = true @@ -261,3 +258,9 @@ zbus = { version = "5.15", default-features = false } [dev-dependencies] tempfile = "3.27.0" + +[workspace.package] +edition = "2024" + +[build-dependencies] +build_helpers = { path = "./iced/build_helpers" } diff --git a/build.rs b/build.rs index 4ce0aa9e..722d2c57 100644 --- a/build.rs +++ b/build.rs @@ -8,6 +8,7 @@ fn main() { { generate_bundled_icons(); } + build_helpers::cfg_aliases_setup(); } fn generate_bundled_icons() { diff --git a/iced b/iced index f6daee14..7177c92b 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit f6daee14ac8f7cf090a5413ee1576aa59649a956 +Subproject commit 7177c92bb0b10382acafdec1af7441312138c69e diff --git a/src/app/action.rs b/src/app/action.rs index a3b9dd6a..1308335e 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -5,7 +5,7 @@ use crate::config::CosmicTk; use crate::theme::Theme; use crate::widget::nav_bar; use crate::{keyboard_nav, surface}; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] use cctk::sctk::reexports::csd_frame::{WindowManagerCapabilities, WindowState}; use cosmic_theme::ThemeMode; @@ -71,10 +71,10 @@ pub enum Action { /// Updates the tracked window geometry. WindowResize(iced::window::Id, f32, f32), /// Tracks updates to window state. - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] WindowState(iced::window::Id, WindowState), /// Capabilities the window manager supports - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] WmCapabilities(iced::window::Id, WindowManagerCapabilities), #[cfg(feature = "xdg-portal")] DesktopSettings(crate::theme::portal::Desktop), diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index ed81d416..f3f51e05 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -6,20 +6,20 @@ use std::collections::HashMap; use std::sync::Arc; use super::{Action, Application, ApplicationExt, Subscription}; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] use crate::core::Auto; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] use crate::surface::action::LiveSettings; use crate::theme::{THEME, Theme, ThemeType}; use crate::{Core, Element, keyboard_nav}; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] use cctk::sctk::reexports::csd_frame::{WindowManagerCapabilities, WindowState}; use cosmic_theme::ThemeMode; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] use enumflags2::BitFlags; -#[cfg(not(any(feature = "multi-window", feature = "wayland", target_os = "linux")))] +#[cfg(not(any(feature = "multi-window", wayland_platform)))] use iced::Application as IcedApplication; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] use iced::event::wayland; use iced::{Task, theme, window}; use iced_futures::event::listen_with; @@ -158,7 +158,7 @@ where ) -> iced::Task> { #[cfg(feature = "surface-message")] match _surface_message { - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::AppSubsurface(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) .ok() @@ -185,7 +185,7 @@ where }); self.get_subsurface(settings, view.map(|v| *v)) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::Subsurface(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) .ok() @@ -212,7 +212,7 @@ where self.get_subsurface(settings, None) } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::AppPopup(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) .ok() @@ -249,11 +249,11 @@ where self.get_popup(settings, *live_settings, view.map(|v| *v)) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::DestroyPopup(id) => { iced_winit::commands::popup::destroy_popup(id) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::DestroyTooltipPopup => { #[cfg(feature = "applet")] { @@ -264,11 +264,11 @@ where Task::none() } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::DestroySubsurface(id) => { iced_winit::commands::subsurface::destroy_subsurface(id) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::DestroyWindow(id) => iced::window::close(id), crate::surface::Action::ResponsiveMenuBar { menu_bar, @@ -279,7 +279,7 @@ where core.menu_bars.insert(menu_bar, (limits, size)); iced::Task::none() } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::Popup(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) .ok() @@ -318,7 +318,7 @@ where self.get_popup(settings, live_settings, None) } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::AppWindow(id, settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings).ok().and_then(|s| { s.downcast:: iced::window::Settings + Send + Sync>>() @@ -356,7 +356,7 @@ where self.get_window(id, settings, *live_settings, view.map(|v| *v)) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::Window(id, settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings).ok().and_then(|s| { s.downcast:: iced::window::Settings + Send + Sync>>() @@ -413,7 +413,7 @@ where crate::surface::Action::Task(f) => { f().map(|sm| crate::Action::Cosmic(Action::Surface(sm))) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::AppLayerShell(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) .ok() @@ -451,7 +451,7 @@ where self.get_layer_shell(settings, *live_settings, view.map(|v| *v)) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::LayerShell(settings, live_settings, view) => { let Some(settings) = std::sync::Arc::try_unwrap(settings) .ok() @@ -491,7 +491,7 @@ where self.get_layer_shell(settings, live_settings, None) } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] crate::surface::Action::DestroyLayerShell(id) => { iced_winit::commands::layer_surface::destroy_layer_surface(id) } @@ -579,7 +579,7 @@ where } iced::Event::Window(window::Event::Focused) => return Some(Action::Focus(id)), iced::Event::Window(window::Event::Unfocused) => return Some(Action::Unfocus(id)), - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] iced::Event::PlatformSpecific(iced::event::PlatformSpecific::Wayland(event)) => { match event { wayland::Event::Popup(wayland::PopupEvent::Done, _, id) @@ -592,7 +592,7 @@ where ) => { return Some(Action::SuggestedBounds(b)); } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] wayland::Event::Window(iced::event::wayland::WindowEvent::WindowState( s, )) => { @@ -762,7 +762,7 @@ impl Cosmic { fn cosmic_update(&mut self, message: Action) -> iced::Task> { match message { Action::WindowMaximized(id, maximized) => { - #[cfg(not(all(feature = "wayland", target_os = "linux")))] + #[cfg(not(wayland_platform))] if self .app .core() @@ -792,7 +792,7 @@ impl Cosmic { }); } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Action::WindowState(id, state) => { if self .app @@ -824,7 +824,7 @@ impl Cosmic { } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Action::WmCapabilities(id, capabilities) => { if self .app @@ -911,7 +911,7 @@ impl Cosmic { guard.transparent = new_blur; drop(guard); - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] { let core = self.app.core(); let mut cmds = Vec::with_capacity(1 + self.surface_views.len()); @@ -986,7 +986,7 @@ impl Cosmic { cosmic_theme.set_theme(new_theme.theme_type); cosmic_theme.transparent = new_blur; - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] { use iced_winit::platform_specific::commands::corner_radius::corner_radius; @@ -1119,7 +1119,7 @@ impl Cosmic { if let ThemeType::System { .. } = cosmic_theme.theme_type { cosmic_theme.set_theme(new_theme.theme_type); cosmic_theme.transparent = new_blur; - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] { use iced_winit::platform_specific::commands::corner_radius::corner_radius; @@ -1193,7 +1193,7 @@ impl Cosmic { // Unminimize window before requesting to activate it. let mut task = iced_runtime::window::minimize(id, false); - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] { task = task.chain( iced_winit::platform_specific::commands::activation::activate( @@ -1204,7 +1204,7 @@ impl Cosmic { ); } - #[cfg(not(all(feature = "wayland", target_os = "linux")))] + #[cfg(not(wayland_platform))] { task = task.chain(iced_runtime::window::gain_focus(id)); } @@ -1301,7 +1301,7 @@ impl Cosmic { // Only apply update if the theme is set to load a system theme if let ThemeType::System { theme: _, .. } = cosmic_theme.theme_type { let mut cmds = Vec::with_capacity(1); - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] { let blur = if cosmic_theme.transparent { iced::window::enable_blur @@ -1378,12 +1378,7 @@ impl Cosmic { } Action::Focus(f) => { - #[cfg(all( - feature = "wayland", - feature = "multi-window", - feature = "surface-message", - target_os = "linux" - ))] + #[cfg(wayland_platform)] if let Some(( parent, SurfaceIdWrapper::Subsurface(_) | SurfaceIdWrapper::Popup(_), @@ -1428,7 +1423,7 @@ impl Cosmic { core.applet.suggested_bounds = b; } Action::Opened(id) => { - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] { use iced_winit::platform_specific::commands::corner_radius::corner_radius; @@ -1511,7 +1506,7 @@ impl Cosmic { } return iced_runtime::window::run_with_handle(id, init_windowing_system); } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Action::BlurEnabled => { // TODO do this after blur event confirms support instead of for all wayland windows self.blur_enabled = true; @@ -1591,7 +1586,7 @@ impl Cosmic { if matches!(id_wrapper, SurfaceIdWrapper::Window(_)) { window::enable_blur(id) } else { - #[cfg(all(feature = "wayland", feature = "winit", target_os = "linux"))] + #[cfg(wayland_platform)] { iced_winit::commands::blur::blur( id, @@ -1602,7 +1597,7 @@ impl Cosmic { ) .discard() } - #[cfg(not(all(feature = "wayland", feature = "winit", target_os = "linux")))] + #[cfg(not(wayland_platform))] { iced::window::enable_blur(id) } @@ -1610,11 +1605,11 @@ impl Cosmic { } else if matches!(id_wrapper, SurfaceIdWrapper::Window(_)) { window::disable_blur(id) } else { - #[cfg(all(feature = "wayland", feature = "winit", target_os = "linux"))] + #[cfg(wayland_platform)] { iced_winit::commands::blur::blur(id, None).discard() } - #[cfg(not(all(feature = "wayland", feature = "winit", target_os = "linux")))] + #[cfg(not(wayland_platform))] { iced::window::disable_blur(id) } @@ -1624,7 +1619,7 @@ impl Cosmic { cmds.push(if matches!(id_wrapper, SurfaceIdWrapper::Window(_)) { window::enable_blur(id) } else { - #[cfg(all(feature = "wayland", feature = "winit", target_os = "linux"))] + #[cfg(wayland_platform)] { iced_winit::commands::blur::blur( id, @@ -1635,13 +1630,13 @@ impl Cosmic { ) .discard() } - #[cfg(not(all(feature = "wayland", feature = "winit", target_os = "linux")))] + #[cfg(not(wayland_platform))] { iced::window::enable_blur(id) } }); } - #[cfg(all(feature = "wayland", feature = "winit", target_os = "linux"))] + #[cfg(wayland_platform)] if let Some(corners) = live_settings.corners { cmds.push( iced_winit::commands::corner_radius::corner_radius(id, Some(corners)).discard(), @@ -1657,7 +1652,7 @@ impl Cosmic { ); } } - #[cfg(all(feature = "wayland", feature = "winit", target_os = "linux"))] + #[cfg(wayland_platform)] if let (SurfaceIdWrapper::LayerSurface(id), Some(padding)) = (id_wrapper, live_settings.padding) { @@ -1668,7 +1663,7 @@ impl Cosmic { Task::batch(cmds) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] /// Create a subsurface pub fn get_subsurface( &mut self, @@ -1700,7 +1695,7 @@ impl Cosmic { Task::batch([live_settings_task, get_subsurface(settings)]) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] /// Create a subsurface pub fn get_popup( &mut self, @@ -1728,7 +1723,7 @@ impl Cosmic { live_settings_task.chain(get_popup(settings)).discard() } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] /// Create a window surface pub fn get_window( &mut self, @@ -1765,7 +1760,7 @@ impl Cosmic { ]) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] pub fn get_layer_shell( &mut self, settings: iced_runtime::platform_specific::wayland::layer_surface::SctkLayerSurfaceSettings, @@ -1794,7 +1789,7 @@ impl Cosmic { } } -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] fn corners( surface_type: SurfaceIdWrapper, rounded: bool, diff --git a/src/app/mod.rs b/src/app/mod.rs index 5ead69fe..d963b7dd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -390,13 +390,7 @@ where crate::widget::nav_bar(nav_model, |id| crate::Action::Cosmic(Action::NavBar(id))) .on_context(|id| crate::Action::Cosmic(Action::NavBarContext(id))) .context_menu(self.nav_context_menu()); - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] { nav = nav .window_id_maybe(self.core().main_window_id()) diff --git a/src/app/settings.rs b/src/app/settings.rs index 5c903f09..0c101741 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -16,7 +16,7 @@ pub struct Settings { pub(crate) antialiasing: bool, /// Autosize the window to fit its contents - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] pub(crate) autosize: bool, /// Set the application to not create a main window @@ -80,7 +80,7 @@ impl Default for Settings { fn default() -> Self { Self { antialiasing: true, - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] autosize: false, no_main_window: false, client_decorations: true, diff --git a/src/core.rs b/src/core.rs index b2bf8693..9b301fb5 100644 --- a/src/core.rs +++ b/src/core.rs @@ -521,7 +521,7 @@ impl Core { crate::command::toggle_maximize(id) } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] pub fn sync_window_border_radii_to_theme(&self) -> bool { match self.app_type { AppType::Window => self.auto_corner_radius.contains(Auto::Window), @@ -591,7 +591,7 @@ impl Core { /// Calculate suggested corners for each app type main window #[must_use] - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] pub fn corners( &self, theme: &Theme, diff --git a/src/lib.rs b/src/lib.rs index c1646e23..38230db5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -162,7 +162,7 @@ pub(crate) mod malloc; pub mod process; #[doc(inline)] -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] pub use cctk; pub mod surface; diff --git a/src/surface/action.rs b/src/surface/action.rs index 493c9ad0..6620c34f 100644 --- a/src/surface/action.rs +++ b/src/surface/action.rs @@ -6,33 +6,33 @@ use super::Action; use crate::Application; use iced::{Rectangle, window}; -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(all(wayland_platform, feature = "winit"))] use iced_runtime::platform_specific::wayland::CornerRadius; -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[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(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] #[must_use] pub fn destroy_popup(id: iced_core::window::Id) -> Action { Action::DestroyPopup(id) } -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] #[must_use] pub fn destroy_subsurface(id: iced_core::window::Id) -> Action { Action::DestroySubsurface(id) } -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] #[must_use] pub fn destroy_window(id: iced_core::window::Id) -> Action { Action::DestroyWindow(id) } -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] #[must_use] pub fn destroy_layer_shell(id: iced_core::window::Id) -> Action { Action::DestroyLayerShell(id) @@ -40,17 +40,17 @@ pub fn destroy_layer_shell(id: iced_core::window::Id) -> Action { #[derive(Debug, Default, Copy, Clone)] pub struct LiveSettings { - #[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] + #[cfg(wayland_platform)] /// Override the surface padding value for the surface type. pub padding: Option, - #[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] + #[cfg(wayland_platform)] /// Override the default corner radius value for the surface type. pub corners: Option, /// Override the default blur setting for the surface type. pub blur: Option, } -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] type BoxedView = Option< Box< dyn Fn(&App) -> crate::Element<'_, crate::Action<::Message>> @@ -60,7 +60,7 @@ type BoxedView = Option< >, >; -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn app_window( live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static, @@ -92,7 +92,7 @@ pub fn app_window( } /// Used to create a window message from within a widget. -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn simple_window( live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static, @@ -129,7 +129,7 @@ pub fn simple_window( ) } -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn app_popup( live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static, @@ -162,7 +162,7 @@ pub fn app_popup( } /// Used to create a subsurface message from within a widget. -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn simple_subsurface( settings: impl Fn() -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings @@ -192,7 +192,7 @@ pub fn simple_subsurface( } /// Used to create a popup message from within a widget. -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn simple_popup( live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static, @@ -228,7 +228,7 @@ pub fn simple_popup( ) } -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn subsurface( settings: impl Fn( @@ -262,7 +262,7 @@ pub fn subsurface( ) } -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn simple_layer_shell( live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static, @@ -298,7 +298,7 @@ pub fn simple_layer_shell( ) } -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] #[must_use] pub fn app_layer_shell( live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static, diff --git a/src/surface/mod.rs b/src/surface/mod.rs index b3858b97..b0a4a819 100644 --- a/src/surface/mod.rs +++ b/src/surface/mod.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 pub mod action; -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] pub mod corner_radius; use iced::{Limits, Size, Task}; diff --git a/src/theme/style/mod.rs b/src/theme/style/mod.rs index bc648a73..b2a4d62e 100644 --- a/src/theme/style/mod.rs +++ b/src/theme/style/mod.rs @@ -32,7 +32,7 @@ mod text_input; #[doc(inline)] pub use self::text_input::TextInput; -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] pub mod tooltip; -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] pub use tooltip::Tooltip; diff --git a/src/widget/autosize.rs b/src/widget/autosize.rs index c52367db..83c4d97e 100644 --- a/src/widget/autosize.rs +++ b/src/widget/autosize.rs @@ -169,7 +169,7 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if matches!( event, Event::PlatformSpecific(event::PlatformSpecific::Wayland( diff --git a/src/widget/context_menu.rs b/src/widget/context_menu.rs index 4b31fc01..f121bbe2 100644 --- a/src/widget/context_menu.rs +++ b/src/widget/context_menu.rs @@ -3,12 +3,7 @@ //! A context menu is a menu in a graphical user interface that appears upon user interaction, such as a right-click mouse operation. -#[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" -))] +#[cfg(wayland_platform)] use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem}; use crate::widget::menu::{ self, CloseCondition, Direction, ItemHeight, ItemWidth, MenuBarState, PathHighlight, @@ -64,12 +59,7 @@ pub struct ContextMenu<'a, Message> { } impl ContextMenu<'_, Message> { - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] #[allow(clippy::too_many_lines)] fn create_popup( &mut self, @@ -391,12 +381,7 @@ impl Widget state.active_root.clear(); state.open = false; - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && let Some(id) = state.popup_id.remove(&self.window_id) { @@ -435,12 +420,7 @@ impl Widget state.open = true; state.view_cursor = cursor; }); - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { self.create_popup(layout, cursor, renderer, shell, viewport, state); } @@ -457,12 +437,7 @@ impl Widget state.active_root.clear(); state.open = false; - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && let Some(id) = state.popup_id.remove(&self.window_id) { @@ -496,12 +471,7 @@ impl Widget _viewport: &iced::Rectangle, translation: Vector, ) -> Option> { - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && self.window_id != window::Id::NONE && self.on_surface_action.is_some() diff --git a/src/widget/dropdown/mod.rs b/src/widget/dropdown/mod.rs index b5fd4c06..d0ec175a 100644 --- a/src/widget/dropdown/mod.rs +++ b/src/widget/dropdown/mod.rs @@ -50,7 +50,7 @@ pub fn popup_dropdown< let dropdown: Dropdown<'_, S, Message, AppMessage> = Dropdown::new(selections.into(), selected, on_selected); - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] let dropdown = dropdown.with_popup(_parent_id, _on_surface_action, _map_action); dropdown diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index 79f854fb..696e6c0d 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -60,7 +60,7 @@ where action_map: Option AppMessage + 'static + Send + Sync>>, #[setters(strip_option)] window_id: Option, - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, } @@ -96,14 +96,14 @@ where text_line_height: text::LineHeight::Relative(1.2), font: None, window_id: None, - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(), on_surface_action: None, action_map: None, } } - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] /// Handle dropdown requests for popup creation. /// Intended to be used with [`crate::app::message::get_popup`] pub fn with_popup( @@ -154,7 +154,7 @@ where self } - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] pub fn with_positioner( mut self, positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, @@ -268,7 +268,7 @@ where layout, cursor, shell, - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] self.positioner.clone(), self.on_selected.clone(), self.selected, @@ -346,7 +346,7 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if self.window_id.is_some() || self.on_surface_action.is_some() { return None; } @@ -545,7 +545,7 @@ pub fn update< layout: Layout<'_>, cursor: mouse::Cursor, shell: &mut Shell<'_, Message>, - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, on_selected: Arc Message + Send + Sync + 'static>, selected: Option, @@ -571,7 +571,7 @@ pub fn update< *hovered_guard = selected; let id = window::Id::unique(); state.popup_id = id; - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if let Some(((on_surface_action, parent), action_map)) = on_surface_action .as_ref() .zip(_window_id) @@ -661,7 +661,7 @@ pub fn update< state.close_operation = false; state.is_open.store(false, Ordering::SeqCst); if is_open { - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if let Some(ref on_close) = on_surface_action { shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); } @@ -684,7 +684,7 @@ pub fn update< // Event wasn't processed by overlay, so cursor was clicked either outside it's // bounds or on the drop-down, either way we close the overlay. state.is_open.store(false, Ordering::Relaxed); - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if let Some(on_close) = on_surface_action { shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); } @@ -729,7 +729,7 @@ pub fn mouse_interaction(layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::In } } -#[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] /// Returns the current menu widget of a [`Dropdown`]. #[allow(clippy::too_many_arguments)] pub fn menu_widget< diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index fce272e1..84927b67 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -9,13 +9,7 @@ use super::menu_inner::{ }; use super::menu_tree::MenuTree; use crate::Renderer; -#[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" -))] +#[cfg(wayland_platform)] use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem}; use crate::style::menu_bar::StyleSheet; use crate::widget::RcWrapper; @@ -191,12 +185,7 @@ pub struct MenuBar { menu_roots: Vec>, style: ::Style, window_id: window::Id, - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, pub(crate) on_surface_action: Option Message + Send + Sync + 'static>>, @@ -231,12 +220,7 @@ where menu_roots, style: ::Style::default(), window_id: window::Id::RESERVED, - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(), on_surface_action: None, } @@ -330,12 +314,7 @@ where self } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] pub fn with_positioner( mut self, positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, @@ -367,13 +346,7 @@ where self } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] #[allow(clippy::too_many_lines)] fn create_popup( &mut self, @@ -657,12 +630,7 @@ where state.menu_states.clear(); state.active_root.clear(); state.open = false; - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] { let surface_action = self.on_surface_action.as_ref().unwrap(); shell.capture_event(); @@ -680,13 +648,7 @@ where return; } shell.capture_event(); - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { self.create_popup(layout, view_cursor, renderer, shell, viewport, my_state); } @@ -695,13 +657,7 @@ where if open && view_cursor.is_over(layout.bounds()) => { shell.capture_event(); - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { self.create_popup(layout, view_cursor, renderer, shell, viewport, my_state); } @@ -732,13 +688,7 @@ where // draw path highlight if self.path_highlight.is_some() { let mut is_overlay = true; - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && self.on_surface_action.is_some() && self.window_id != window::Id::NONE @@ -792,13 +742,7 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && self.on_surface_action.is_some() && self.window_id != window::Id::NONE diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 53c87415..c0d7f10a 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -6,13 +6,7 @@ use std::sync::Arc; use super::menu_bar::MenuBarState; use super::menu_tree::MenuTree; -#[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" -))] +#[cfg(wayland_platform)] use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem}; use crate::style::menu_bar::StyleSheet; @@ -677,13 +671,7 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { needs_reset |= self.close_condition.click_outside && !is_inside; if needs_reset { - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && let Some(handler) = self.on_surface_action.as_ref() { @@ -990,13 +978,7 @@ impl Widget( }); } -#[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" -))] +#[cfg(wayland_platform)] pub(super) fn init_root_popup_menu( menu: &mut Menu<'_, Message>, renderer: &crate::Renderer, @@ -1572,7 +1548,7 @@ where .as_ref() .is_some_and(|i| *i != new_index && !active_menu[*i].children.is_empty()); - #[cfg(all(feature = "multi-window", feature = "wayland",target_os = "linux", feature = "winit", feature = "surface-message"))] + #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && remove { if let Some(id) = state.popup_id.remove(&menu.window_id) { state.active_root.truncate(menu.depth + 1); diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 5089b997..ab1eef31 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -312,7 +312,7 @@ pub use toggler::{Toggler, toggler}; #[doc(inline)] pub use tooltip::{Tooltip, tooltip}; -#[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] +#[cfg(wayland_platform)] pub mod wayland; pub mod tooltip { diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index d6595359..14790866 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -134,12 +134,7 @@ impl<'a, Message: Clone + 'static> NavBar<'a, Message> { self } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] pub fn with_positioner( mut self, positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, diff --git a/src/widget/responsive_menu_bar.rs b/src/widget/responsive_menu_bar.rs index 2cae68d8..b7b416ef 100644 --- a/src/widget/responsive_menu_bar.rs +++ b/src/widget/responsive_menu_bar.rs @@ -23,7 +23,7 @@ impl Default for ResponsiveMenuBar { fn default() -> ResponsiveMenuBar { ResponsiveMenuBar { collapsed_item_width: { - #[cfg(all(feature = "winit", feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) @@ -32,7 +32,7 @@ impl Default for ResponsiveMenuBar { } else { ItemWidth::Static(84) } - #[cfg(not(all(feature = "winit", feature = "wayland", target_os = "linux")))] + #[cfg(not(wayland_platform))] { ItemWidth::Static(84) } diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index fbefce34..ca877348 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -195,12 +195,7 @@ where pub(super) on_reorder: Option Message + 'static>>, #[setters(skip)] window_id: window::Id, - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, #[setters(skip)] pub(crate) on_surface_action: @@ -259,12 +254,7 @@ where on_drop_hint: None, on_reorder: None, window_id: window::Id::RESERVED, - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(), on_surface_action: None, } @@ -883,12 +873,7 @@ where ) } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - feature = "winit", - target_os = "linux" - ))] + #[cfg(wayland_platform)] pub fn with_positioner( mut self, positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner, @@ -920,13 +905,7 @@ where self } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] #[allow(clippy::too_many_lines)] fn create_popup<'b>( &mut self, @@ -1084,7 +1063,7 @@ where let rad = styling.menu_border_radius; /// Used to create a popup message from within a widget. - #[cfg(all(feature = "wayland", target_os = "linux", feature = "winit"))] + #[cfg(wayland_platform)] #[must_use] pub fn simple_popup( live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static, @@ -1660,13 +1639,7 @@ where state.unfocus(); } - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if is_pressed(event) && let Some(on_context) = self.on_context.as_ref() { @@ -1722,13 +1695,7 @@ where shell.publish(on_context(key)); shell.capture_event(); - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) @@ -1867,12 +1834,7 @@ where .inner .with_data_mut(|ms| ms.popup_id.remove(&self.window_id)) { - #[cfg(all( - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] { let surface_action = self.on_surface_action.as_ref().unwrap(); shell.capture_event(); @@ -2507,13 +2469,7 @@ where _viewport: &iced_core::Rectangle, translation: Vector, ) -> Option> { - #[cfg(all( - feature = "multi-window", - feature = "wayland", - target_os = "linux", - feature = "winit", - feature = "surface-message" - ))] + #[cfg(wayland_platform)] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index 65b84882..ece26192 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -525,7 +525,7 @@ where } /// Sets the start dnd handler of the [`TextInput`]. - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] pub fn on_start_dnd(mut self, on_start_dnd: impl Fn(State) -> Message + 'a) -> Self { self.on_create_dnd_source = Some(Box::new(on_start_dnd)); self @@ -1553,7 +1553,7 @@ pub fn update<'a, Message: Clone + 'static>( click.kind(), state.cursor().state(value), ) { - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] (None, click::Kind::Single, cursor::State::Selection { start, end }) => { let left = start.min(end); let right = end.max(start); @@ -1694,7 +1694,7 @@ pub fn update<'a, Message: Clone + 'static>( | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { cold(); let state = state(); - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if matches!(state.dragging_state, Some(DraggingState::PrepareDnd(_))) { // clear selection and place cursor at click position update_cache(state, value); @@ -1749,7 +1749,7 @@ pub fn update<'a, Message: Clone + 'static>( shell.capture_event(); return; } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] if let Some(DraggingState::PrepareDnd(start_position)) = state.dragging_state { let distance = ((position.x - start_position.x).powi(2) + (position.y - start_position.y).powi(2)) @@ -2206,7 +2206,7 @@ pub fn update<'a, Message: Clone + 'static>( shell.request_redraw(); } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Source(SourceEvent::Finished | SourceEvent::Cancelled)) => { cold(); let state = state(); @@ -2217,7 +2217,7 @@ pub fn update<'a, Message: Clone + 'static>( return; } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer( rectangle, OfferEvent::Enter { @@ -2267,7 +2267,7 @@ pub fn update<'a, Message: Clone + 'static>( return; } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Motion { x, y })) if *rectangle == Some(dnd_id) => { @@ -2295,7 +2295,7 @@ pub fn update<'a, Message: Clone + 'static>( shell.capture_event(); return; } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Drop)) if *rectangle == Some(dnd_id) => { cold(); let state = state(); @@ -2313,9 +2313,9 @@ pub fn update<'a, Message: Clone + 'static>( return; } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer(id, OfferEvent::LeaveDestination)) if Some(dnd_id) != *id => {} - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer( rectangle, OfferEvent::Leave | OfferEvent::LeaveDestination, @@ -2333,7 +2333,7 @@ pub fn update<'a, Message: Clone + 'static>( shell.capture_event(); return; } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Data { data, mime_type })) if *rectangle == Some(dnd_id) => { @@ -2616,9 +2616,9 @@ pub fn draw<'a, Message>( let actual_width = text_width.max(text_bounds.width); let radius_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0.into(); - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] let handling_dnd_offer = !matches!(state.dnd_offer, DndOfferState::None); - #[cfg(not(all(feature = "wayland", target_os = "linux")))] + #[cfg(not(wayland_platform))] let handling_dnd_offer = false; let (cursors, offset, is_selecting) = if let Some(focus) = state.is_focused.filter(|f| f.focused).or_else(|| { @@ -2868,7 +2868,7 @@ pub fn mouse_interaction( #[derive(Debug, Clone)] pub struct TextInputString(pub String); -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] impl AsMimeTypes for TextInputString { fn available(&self) -> Cow<'static, [String]> { Cow::Owned( @@ -2892,13 +2892,13 @@ impl AsMimeTypes for TextInputString { #[derive(Debug, Clone, PartialEq)] pub(crate) enum DraggingState { Selection, - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] PrepareDnd(Point), - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] Dnd(DndAction, String), } -#[cfg(all(feature = "wayland", target_os = "linux"))] +#[cfg(wayland_platform)] #[derive(Debug, Default, Clone)] pub(crate) enum DndOfferState { #[default] @@ -2907,7 +2907,7 @@ pub(crate) enum DndOfferState { Dropped, } #[derive(Debug, Default, Clone)] -#[cfg(not(all(feature = "wayland", target_os = "linux")))] +#[cfg(not(wayland_platform))] pub(crate) struct DndOfferState; /// The state of a [`TextInput`]. @@ -2983,7 +2983,7 @@ impl State { } } - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(wayland_platform)] /// Returns the current value of the dragged text in the [`TextInput`]. #[must_use] pub fn dragged_text(&self) -> Option { From 63568a8d9c5885ca08eff54bd6d3e810a6d6c030 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 15 Jul 2026 15:24:46 -0400 Subject: [PATCH 19/58] improv: feature gate for xdg_portal --- Cargo.toml | 2 +- iced | 2 +- src/app/action.rs | 2 +- src/app/cosmic.rs | 8 ++++---- src/command.rs | 6 +++--- src/dialog/file_chooser/mod.rs | 4 ++-- src/dialog/file_chooser/open.rs | 30 +++++++++++++++--------------- src/dialog/file_chooser/save.rs | 32 ++++++++++++++++---------------- src/dialog/mod.rs | 2 +- src/lib.rs | 2 +- src/theme/mod.rs | 2 +- src/widget/dnd_destination.rs | 14 +++++++------- 12 files changed, 53 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3b3871c7..5268098d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,7 +120,6 @@ x11 = ["iced/x11", "iced_winit/x11"] [dependencies] apply = "0.3.0" -ashpd = { version = "0.12.3", default-features = false, optional = true } async-fs = { version = "2.2", optional = true } async-std = { workspace = true, optional = true } auto_enums = "0.8.8" @@ -174,6 +173,7 @@ cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-c cosmic-config = { path = "cosmic-config", features = ["dbus"] } cosmic-settings-daemon = { git = "https://github.com/pop-os/dbus-settings-bindings" } zbus.workspace = true +ashpd = { version = "0.12.3", default-features = false, optional = true } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] freedesktop-icons = { package = "cosmic-freedesktop-icons", git = "https://github.com/pop-os/freedesktop-icons" } diff --git a/iced b/iced index 7177c92b..7b5d58ec 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 7177c92bb0b10382acafdec1af7441312138c69e +Subproject commit 7b5d58ecbde79b105baf938e8065ab4ba4e5a35d diff --git a/src/app/action.rs b/src/app/action.rs index 1308335e..873622da 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -76,6 +76,6 @@ pub enum Action { /// Capabilities the window manager supports #[cfg(wayland_platform)] WmCapabilities(iced::window::Id, WindowManagerCapabilities), - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] DesktopSettings(crate::theme::portal::Desktop), } diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index f3f51e05..13e3ee83 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -678,7 +678,7 @@ where }) .map(crate::Action::Cosmic), window_events.map(crate::Action::Cosmic), - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] crate::theme::portal::desktop_settings() .map(Action::DesktopSettings) .map(crate::Action::Cosmic), @@ -1250,7 +1250,7 @@ impl Cosmic { return self.app.dbus_connection(conn); } - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] Action::DesktopSettings(crate::theme::portal::Desktop::ColorScheme(s)) => { use ashpd::desktop::settings::ColorScheme; if match THEME.lock().unwrap().theme_type { @@ -1332,7 +1332,7 @@ impl Cosmic { } } } - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] Action::DesktopSettings(crate::theme::portal::Desktop::Accent(c)) => { use palette::Srgba; let c = Srgba::new(c.red() as f32, c.green() as f32, c.blue() as f32, 1.0); @@ -1361,7 +1361,7 @@ impl Cosmic { } } } - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] Action::DesktopSettings(crate::theme::portal::Desktop::Contrast(_)) => { // TODO when high contrast is integrated in settings and all custom themes } diff --git a/src/command.rs b/src/command.rs index 6bb16e8d..64184f46 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,7 +1,7 @@ // Copyright 2023 System76 // SPDX-License-Identifier: MPL-2.0 -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] use std::os::fd::AsFd; use iced::window; @@ -47,7 +47,7 @@ pub fn toggle_maximize(id: window::Id) -> iced::Task> { iced_runtime::window::toggle_maximize(id) } -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub fn file_transfer_send( writeable: bool, auto_stop: bool, @@ -63,7 +63,7 @@ pub fn file_transfer_send( /// Receive the files offered over the xdg share portal using the `key`. /// Returns a list of file paths. -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub fn file_transfer_receive(key: String) -> iced::Task>> { iced::Task::future(async move { let file_transfer = ashpd::documents::FileTransfer::new().await?; diff --git a/src/dialog/file_chooser/mod.rs b/src/dialog/file_chooser/mod.rs index 186f7625..d1b385a0 100644 --- a/src/dialog/file_chooser/mod.rs +++ b/src/dialog/file_chooser/mod.rs @@ -90,7 +90,7 @@ pub mod open; /// Save file dialog. pub mod save; -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub use ashpd::desktop::file_chooser::{Choice, FileFilter}; use thiserror::Error; @@ -139,7 +139,7 @@ pub enum Error { UrlAbsolute, } -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub type DialogError = ashpd::Error; #[cfg(feature = "rfd")] diff --git a/src/dialog/file_chooser/open.rs b/src/dialog/file_chooser/open.rs index f24afda9..c15a5b91 100644 --- a/src/dialog/file_chooser/open.rs +++ b/src/dialog/file_chooser/open.rs @@ -6,7 +6,7 @@ //! Check out the [open-dialog](https://github.com/pop-os/libcosmic/tree/master/examples/open-dialog) //! example in our repository. -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub use portal::{FileResponse, MultiFileResponse, file, files, folder, folders}; #[cfg(feature = "rfd")] @@ -24,7 +24,7 @@ pub struct Dialog { title: String, /// The label for the accept button. Mnemonic underlines are allowed. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] accept_label: Option, @@ -39,17 +39,17 @@ pub struct Dialog { file_name: Option, /// Modal dialogs require user input before continuing the program. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] modal: bool, /// Adds a list of choices. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] choices: Vec, /// Specifies the default file filter. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] current_filter: Option, @@ -62,36 +62,36 @@ impl Dialog { pub const fn new() -> Self { Self { title: String::new(), - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] accept_label: None, directory: None, file_name: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] modal: true, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] current_filter: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] choices: Vec::new(), filters: Vec::new(), } } /// The label for the accept button. Mnemonic underlines are allowed. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn accept_label(mut self, label: impl Into) -> Self { self.accept_label = Some(label.into()); self } /// Adds a choice. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn choice(mut self, choice: impl Into) -> Self { self.choices.push(choice.into()); self } /// Specifies the default file filter. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn current_filter(mut self, filter: impl Into) -> Self { self.current_filter = Some(filter.into()); self @@ -104,7 +104,7 @@ impl Dialog { } /// Modal dialogs require user input before continuing the program. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn modal(mut self, modal: bool) -> Self { self.modal = modal; self @@ -131,7 +131,7 @@ impl Dialog { } } -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] mod portal { use super::Dialog; use crate::dialog::file_chooser::Error; @@ -147,7 +147,7 @@ mod portal { } /// Creates a new file dialog, and begins to await its responses. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub async fn create( dialog: super::Dialog, folders: bool, diff --git a/src/dialog/file_chooser/save.rs b/src/dialog/file_chooser/save.rs index d7a2a34e..aa8c1398 100644 --- a/src/dialog/file_chooser/save.rs +++ b/src/dialog/file_chooser/save.rs @@ -6,7 +6,7 @@ //! Check out the [open-dialog](https://github.com/pop-os/libcosmic/tree/master/examples/open-dialog) //! example in our repository. -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub use portal::{Response, file}; #[cfg(feature = "rfd")] @@ -23,12 +23,12 @@ pub struct Dialog { title: String, /// The label for the accept button. Mnemonic underlines are allowed. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] accept_label: Option, /// Modal dialogs require user input before continuing the program. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] modal: bool, @@ -41,17 +41,17 @@ pub struct Dialog { directory: Option, /// Sets the absolute path of the file - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] current_file: Option, /// Adds a list of choices. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] choices: Vec, /// Specifies the default file filter. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[setters(skip)] current_filter: Option, @@ -64,38 +64,38 @@ impl Dialog { pub const fn new() -> Self { Self { title: String::new(), - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] accept_label: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] modal: true, file_name: None, directory: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] current_file: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] current_filter: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] choices: Vec::new(), filters: Vec::new(), } } /// The label for the accept button. Mnemonic underlines are allowed. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn accept_label(mut self, label: impl Into) -> Self { self.accept_label = Some(label.into()); self } /// Adds a choice. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn choice(mut self, choice: impl Into) -> Self { self.choices.push(choice.into()); self } /// Set the current file filter. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn current_filter(mut self, filter: impl Into) -> Self { self.current_filter = Some(filter.into()); self @@ -108,7 +108,7 @@ impl Dialog { } /// Modal dialogs require user input before continuing the program. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] pub fn modal(mut self, modal: bool) -> Self { self.modal = modal; self @@ -126,7 +126,7 @@ impl Default for Dialog { } } -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] mod portal { use super::Dialog; use crate::dialog::file_chooser::Error; diff --git a/src/dialog/mod.rs b/src/dialog/mod.rs index 66b3cec7..d67b7f3b 100644 --- a/src/dialog/mod.rs +++ b/src/dialog/mod.rs @@ -3,7 +3,7 @@ //! Create dialogs for retrieving user input. -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub use ashpd; pub mod file_chooser; diff --git a/src/lib.rs b/src/lib.rs index 38230db5..c44700e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,7 +136,7 @@ pub use dbus_activation::DbusActivation; #[cfg(feature = "desktop")] pub mod desktop; -#[cfg(any(feature = "xdg-portal", feature = "rfd"))] +#[cfg(any(xdg_portal, feature = "rfd"))] pub mod dialog; pub mod executor; diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 830746a1..65ea9314 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -3,7 +3,7 @@ //! Contains the [`Theme`] type and its widget stylesheet implementations. -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub mod portal; pub mod style; diff --git a/src/widget/dnd_destination.rs b/src/widget/dnd_destination.rs index 0ebff3dc..54d77808 100644 --- a/src/widget/dnd_destination.rs +++ b/src/widget/dnd_destination.rs @@ -29,7 +29,7 @@ pub fn dnd_destination_for_data<'a, T: AllowedMimeTypes, Message: 'static>( static DRAG_ID_COUNTER: AtomicU64 = AtomicU64::new(0); const DND_DEST_LOG_TARGET: &str = "libcosmic::widget::dnd_destination"; -#[cfg(feature = "xdg-portal")] +#[cfg(xdg_portal)] pub const FILE_TRANSFER_MIME: &str = "application/vnd.portal.filetransfer"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -64,7 +64,7 @@ pub struct DndDestination<'a, Message> { on_action_selected: Option Message>>, on_data_received: Option) -> Message>>, on_finish: Option, DndAction, f64, f64) -> Message>>, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] on_file_transfer: Option Message>>, } @@ -92,7 +92,7 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { on_action_selected: None, on_data_received: None, on_finish: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] on_file_transfer: None, } } @@ -119,7 +119,7 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { on_finish: Some(Box::new(move |mime, data, action, _, _| { on_finish(T::try_from((data, mime)).ok(), action) })), - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] on_file_transfer: None, } } @@ -156,7 +156,7 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { on_action_selected: None, on_data_received: None, on_finish: None, - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] on_file_transfer: None, } } @@ -239,7 +239,7 @@ impl<'a, Message: 'static> DndDestination<'a, Message> { /// Add a message that will be emitted instead of [`on_data_received`](Self::on_data_received) if the dropped files /// are offered through the xdg share portal. You can then use [`crate::command::file_transfer_receive`] /// with the key to receive the files. - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] #[must_use] pub fn on_file_transfer(mut self, f: impl Fn(String) -> Message + 'static) -> Self { match self.mime_types.iter().position(|v| v == "text/uri-list") { @@ -514,7 +514,7 @@ impl Widget data.len() ); - #[cfg(feature = "xdg-portal")] + #[cfg(xdg_portal)] if mime_type == FILE_TRANSFER_MIME && let Some(f) = self.on_file_transfer.as_ref() && let Ok(s) = String::from_utf8(data[..data.len() - 1].to_vec()) From 6359a9465b8fe4c3b0ada0cd2b87cddf644ca732 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 15 Jul 2026 16:47:16 -0400 Subject: [PATCH 20/58] chore: update iced --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 7b5d58ec..a7c71f8d 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 7b5d58ecbde79b105baf938e8065ab4ba4e5a35d +Subproject commit a7c71f8dd3914e9f83363bf7552c99cbbb62ab2c From 16d148807c663071ac13f70b4b0cfa7816465046 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 13 Jul 2026 17:56:29 -0600 Subject: [PATCH 21/58] fix: clear paste cache after shift+insert --- src/widget/text_input/input.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index ece26192..c86c5a3e 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -2111,6 +2111,9 @@ pub fn update<'a, Message: Clone + 'static>( keyboard::Key::Character(c) if "v" == c => { state.is_pasting = None; } + keyboard::Key::Named(keyboard::key::Named::Insert) => { + state.is_pasting = None; + } keyboard::Key::Named(keyboard::key::Named::Tab) | keyboard::Key::Named(keyboard::key::Named::ArrowUp) | keyboard::Key::Named(keyboard::key::Named::ArrowDown) => { From ac74ed51d9fa4492d354e7a83261cb39297699cb 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: Thu, 16 Jul 2026 19:25:08 +0200 Subject: [PATCH 22/58] fix(circular): calculate bar height based on the size of the circular This calculates the `bar_height` based on the size of the Circular by default, so that ones with a small size don't have visual issues caused by a fixed height. Also fixes the `track_radius` calculation, which caused the widget to appear smaller than it should be. --- src/widget/progress_bar/circular.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/widget/progress_bar/circular.rs b/src/widget/progress_bar/circular.rs index 3318d7a3..8da4be14 100644 --- a/src/widget/progress_bar/circular.rs +++ b/src/widget/progress_bar/circular.rs @@ -18,7 +18,7 @@ where Theme: StyleSheet, { size: f32, - bar_height: f32, + bar_height: Option, style: Theme::Style, cycle_duration: Duration, period: Duration, @@ -32,8 +32,8 @@ where /// Creates a new [`Circular`] with the given content. pub fn new() -> Self { Circular { - size: 40.0, - bar_height: 4.0, + size: 48.0, + bar_height: None, style: Theme::Style::default(), cycle_duration: Duration::from_millis(1500), period: Duration::from_secs(2), @@ -48,8 +48,9 @@ where } /// Sets the bar height of the [`Circular`]. + /// By default, the height is based on the size of the [`Circular`]. pub fn bar_height(mut self, bar_height: f32) -> Self { - self.bar_height = bar_height; + self.bar_height = Some(bar_height); self } @@ -172,41 +173,41 @@ where let custom_style = Theme::appearance(theme, &self.style, self.progress.is_some(), true); let geometry = state.cache.draw(renderer, bounds.size(), |frame| { - let track_radius = frame.width() / 2.0 - self.bar_height; + let bar_height = self.bar_height.unwrap_or((frame.width() / 12.0).max(2.0)); + let track_radius = (frame.width() - bar_height) / 2.0; if track_radius <= 0.0 { return; } - let track_path = canvas::Path::circle(frame.center(), track_radius); + let track_path = canvas::Path::circle(frame.center(), track_radius); frame.stroke( &track_path, canvas::Stroke::default() .with_color(custom_style.track_color) - .with_width(self.bar_height), + .with_width(bar_height), ); - // Converts a track fraction to an angle in radians, with 0 being top of circle - let to_angle = |t: f32| t * 2.0 * PI - PI / 2.0; let draw_bar = |frame: &mut canvas::Frame, start: f32, end: f32| { let mut builder = canvas::path::Builder::new(); builder.arc(canvas::path::Arc { center: frame.center(), radius: track_radius, - start_angle: Radians(to_angle(start)), - end_angle: Radians(to_angle(end)), + start_angle: Radians(2.0 * PI * start - PI / 2.0), + end_angle: Radians(2.0 * PI * end - PI / 2.0), }); frame.stroke( &builder.build(), canvas::Stroke::default() .with_color(custom_style.bar_color) - .with_width(self.bar_height) + .with_width(bar_height) .with_line_cap(canvas::LineCap::Round), ); }; if self.progress.is_some() { if let Some(border_color) = custom_style.border_color { - for radius_offset in [self.bar_height / 2.0, -(self.bar_height / 2.0)] { + // - 0.5 ensures the border is inside the track + for radius_offset in [bar_height / 2.0 - 0.5, -(bar_height / 2.0 - 0.5)] { let border_path = canvas::Path::circle(frame.center(), track_radius + radius_offset); frame.stroke( From 66263a76d36ec446c03d19c57ac0030fd4785c2b 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: Thu, 16 Jul 2026 13:14:18 +0200 Subject: [PATCH 23/58] fix(toggler): toggle on release --- src/widget/toggler.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widget/toggler.rs b/src/widget/toggler.rs index 2dad2ed9..a610747c 100644 --- a/src/widget/toggler.rs +++ b/src/widget/toggler.rs @@ -249,8 +249,8 @@ impl<'a, Message> Widget for Toggler<'a, } match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { let mouse_over = cursor_position.is_over(layout.bounds()); if mouse_over { From 0eef591171c6c449b99fe0966571cd3ea3507919 Mon Sep 17 00:00:00 2001 From: Adil Hanney Date: Fri, 17 Jul 2026 15:58:48 +0100 Subject: [PATCH 24/58] fix(cosmic-theme): remove kdeglobals theme generation support --- cosmic-theme/src/output/mod.rs | 4 - cosmic-theme/src/output/qt_output.rs | 531 ++---------------- ...put__tests__dark_default_kcolorscheme.snap | 157 ------ ...ut__tests__light_default_kcolorscheme.snap | 157 ------ 4 files changed, 52 insertions(+), 797 deletions(-) delete mode 100644 cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap delete mode 100644 cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap diff --git a/cosmic-theme/src/output/mod.rs b/cosmic-theme/src/output/mod.rs index 04dce39f..93824111 100644 --- a/cosmic-theme/src/output/mod.rs +++ b/cosmic-theme/src/output/mod.rs @@ -34,10 +34,8 @@ impl Theme { /// Apply COSMIC theme exports for GTK and Qt applications. pub fn apply_exports(&self) -> Result<(), OutputError> { let gtk_res = Theme::apply_gtk(self.is_dark); - let qt_res = Theme::apply_qt(self.is_dark); let qt56ct_res = Theme::apply_qt56ct(self.is_dark); gtk_res?; - qt_res?; qt56ct_res?; Ok(()) } @@ -46,10 +44,8 @@ impl Theme { /// Write COSMIC theme exports for GTK and Qt applications. pub fn write_exports(&self) -> Result<(), OutputError> { let gtk_res = self.write_gtk4(); - let qt_res = self.write_qt(); let qt56ct_res = self.write_qt56ct(); gtk_res?; - qt_res?; qt56ct_res?; Ok(()) } diff --git a/cosmic-theme/src/output/qt_output.rs b/cosmic-theme/src/output/qt_output.rs index 84614e7b..92dfad9f 100644 --- a/cosmic-theme/src/output/qt_output.rs +++ b/cosmic-theme/src/output/qt_output.rs @@ -1,280 +1,21 @@ use crate::Theme; use configparser::ini::Ini; -use cosmic_config::CosmicConfigEntry; -use palette::blend::Compose; -use palette::{Mix, Srgba}; -use std::fs::{self, File}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::{fs, io}; -use super::{OutputError, qt_settings_ini_style}; +use super::OutputError; impl Theme { - /// Produces a color scheme ini file for Qt. - /// - /// Some high-level documentation for this file can be found at: - /// - https://api.kde.org/kcolorscheme.html - /// - https://web.archive.org/web/20250402234329/https://docs.kde.org/stable5/en/plasma-workspace/kcontrol/colors/ - #[must_use] - #[cold] - pub fn as_kcolorscheme(&self) -> String { - // Usually, disabled elements will have strongly reduced contrast and are often notably darker or lighter - let disabled_color_effects = IniColorEffects { - color: self.button.disabled, - color_amount: 0.0, - color_effect: ColorEffect::Desaturate, - contrast_amount: 0.65, - contrast_effect: ColorEffect::Fade, - intensity_amount: 0.1, - intensity_effect: IntensityEffect::Lighten, - }; - // Usually, inactive elements will have reduced contrast (text fades slightly into the background) and may have slightly reduced intensity - let inactive_color_effects = IniColorEffects { - color: self.palette.gray_1, - color_amount: 0.025, - color_effect: ColorEffect::Tint, - contrast_amount: 0.1, - contrast_effect: ColorEffect::Tint, - intensity_amount: 0.0, - intensity_effect: IntensityEffect::Shade, - }; - - let bg = self.background.base; - // the background container - let window_colors = IniColors { - background_alternate: bg.mix(self.accent.base, 0.05), - background_normal: bg, - decoration_focus: self.accent_text_color(), - decoration_hover: self.accent_text_color(), - foreground_active: self.accent_text_color(), - foreground_inactive: self.background.on.mix(bg, 0.1), - foreground_link: self.link_button.on, - foreground_negative: self.destructive_text_color(), - foreground_neutral: self.warning_text_color(), - foreground_normal: self.background.on, - foreground_positive: self.success_text_color(), - foreground_visited: self.accent_text_color(), - }; - // components inside the background container - let view_colors = IniColors { - background_alternate: self.background.component.base.mix(self.accent.base, 0.05), - background_normal: self.background.component.base, - ..window_colors - }; - - // selected text and items - let selection_colors = { - // selection colors are swapped to fix menu bar contrast - let selected = self.background.component.selected_text; - let selected_text = self.background.component.selected; - IniColors { - background_alternate: selected.mix(bg, 0.5), - background_normal: selected, - decoration_focus: selected, - decoration_hover: selected, - foreground_active: selected_text, - foreground_inactive: selected_text.mix(selected, 0.5), - foreground_link: self.link_button.base, - foreground_negative: self.destructive_color(), - foreground_neutral: self.warning_color(), - foreground_normal: selected_text, - foreground_positive: self.success_color(), - foreground_visited: self.accent_color(), - } - }; - - let button_colors = IniColors { - background_alternate: self.accent_button.base, - background_normal: self.button.base, - ..view_colors - }; - - // Complementary: Areas of applications with an alternative color scheme; usually with a dark background for light color schemes. - let complementary_colors = { - let dark = if self.is_dark { - self.clone() - } else if cfg!(test) { - // For reproducible results in tests, use the default dark theme - Theme::dark_default() - } else { - Theme::dark_config() - .ok() - .as_ref() - .and_then(|conf| Theme::get_entry(conf).ok()) - .unwrap_or_else(|| self.clone()) - }; - IniColors { - background_alternate: dark.accent.base, - background_normal: dark.background.base, - decoration_focus: dark.accent_text_color(), - decoration_hover: dark.accent_text_color(), - foreground_active: dark.accent_text_color(), - foreground_inactive: dark.background.on.mix(dark.background.base, 0.1), - foreground_link: dark.link_button.on, - foreground_negative: dark.destructive_text_color(), - foreground_neutral: dark.warning_text_color(), - foreground_normal: dark.background.on, - foreground_positive: dark.success_text_color(), - foreground_visited: dark.accent_text_color(), - } - }; - - // headers in cosmic don't have a background - let header_colors = &window_colors; - let header_colors_inactive = &window_colors; - // tool tips, "What's This" tips, and similar elements - let tooltip_colors = &view_colors; - - let general_color_scheme = if self.is_dark { - "CosmicDark" - } else { - "CosmicLight" - }; - let general_name = if self.is_dark { - "COSMIC Dark" - } else { - "COSMIC Light" - }; - // COSMIC icons are stuck in light mode, so use breeze icons instead - let icons_theme = if self.is_dark { - "breeze-dark" - } else { - "breeze" - }; - - format!( - r#"# GENERATED BY COSMIC - -[ColorEffects:Disabled] -{} - -[ColorEffects:Inactive] -ChangeSelectionColor=false -Enable=false -{} - -[Colors:Button] -{} - -[Colors:Complementary] -{} - -[Colors:Header] -{} - -[Colors:Header][Inactive] -{} - -[Colors:Selection] -{} - -[Colors:Tooltip] -{} - -[Colors:View] -{} - -[Colors:Window] -{} - -[General] -ColorScheme={general_color_scheme} -Name={general_name} -shadeSortColumn=true - -[Icons] -Theme={icons_theme} - -[KDE] -contrast=4 -widgetStyle=qt6ct-style - -[WM] -{} -"#, - format_ini_color_effects(&disabled_color_effects, bg), - format_ini_color_effects(&inactive_color_effects, bg), - format_ini_colors(&button_colors, bg), - format_ini_colors(&complementary_colors, bg), - format_ini_colors(&header_colors, bg), - format_ini_colors(&header_colors_inactive, bg), - format_ini_colors(&selection_colors, bg), - format_ini_colors(&tooltip_colors, bg), - format_ini_colors(&view_colors, bg), - format_ini_colors(&window_colors, bg), - format_ini_wm_colors(&window_colors, self.is_dark), - ) - } - - /// Write the color scheme to the appropriate directory. - /// Should be written in `~/.local/share/color-schemes/`. - /// - /// See the docs: https://develop.kde.org/docs/plasma/#color-scheme - /// - /// # Errors - /// - /// Returns an `OutputError` if there is an error writing the colors file. - #[cold] - pub fn write_qt(&self) -> Result<(), OutputError> { - let kcolorscheme = self.as_kcolorscheme(); - let file_path = Self::get_kcolorscheme_path(self.is_dark)?; - let tmp_file_path = file_path.with_extension("colors.new"); - - // Write to tmp_file_path first, then move it to file_path - let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?; - let res = tmp_file - .write_all(kcolorscheme.as_bytes()) - .and_then(|_| tmp_file.flush()) - .and_then(|_| std::fs::rename(&tmp_file_path, file_path)); - if let Err(e) = res { - _ = std::fs::remove_file(&tmp_file_path); - return Err(OutputError::Io(e)); - } - - Ok(()) - } - - /// Apply the color scheme by copying its values to `~/.config/kdeglobals`. - /// - /// See the docs: https://develop.kde.org/docs/plasma/#color-scheme - /// - /// # Errors - /// - /// Returns an `OutputError` if there is an error applying the color scheme. - #[cold] - pub fn apply_qt(is_dark: bool) -> Result<(), OutputError> { - let Some(config_dir) = dirs::config_dir() else { - return Err(OutputError::MissingConfigDir); - }; - let kdeglobals_file = config_dir.join("kdeglobals"); - let mut kdeglobals_ini = Self::read_ini(&kdeglobals_file)?; - - let src_file = Self::get_kcolorscheme_path(is_dark)?; - let src_ini = Self::read_ini(&src_file)?; - - Self::backup_non_cosmic_kdeglobals(&kdeglobals_ini, &kdeglobals_file) - .map_err(OutputError::Io)?; - - for (section, key_value) in src_ini.get_map_ref() { - for (key, value) in key_value { - kdeglobals_ini.set(section, key, value.clone()); - } - } - - kdeglobals_ini - .pretty_write(kdeglobals_file, &qt_settings_ini_style()) - .map_err(OutputError::Io)?; - Ok(()) - } - - /// Reset the applied qt colors by removing color scheme values from the - /// `~/.config/kdeglobals` file. + /// Reset the applied qt colors: + /// - Remove relevant keys from the `~/.config/kdeglobals` file + /// - Delete `~/.local/share/color-schemes/CosmicDark.colors` and `CosmicLight.colors` /// /// This does not restore the backed up kdeglobals file. /// /// # Errors /// - /// Returns an `OutputError` if there is an error resetting the CSS file. + /// Returns an `OutputError` if there is an error resetting the kdeglobals + /// file or deleting the color schemes. #[cold] pub fn reset_qt() -> Result<(), OutputError> { let Some(config_dir) = dirs::config_dir() else { @@ -291,19 +32,29 @@ widgetStyle=qt6ct-style return Ok(()); } - let is_dark = false; // doesn't matter since we're only reading keys - let src_file = Self::get_kcolorscheme_path(is_dark)?; - let src_ini = Self::read_ini(&src_file)?; + let light_scheme = Self::get_kcolorscheme_path(false)?; + let dark_scheme = Self::get_kcolorscheme_path(true)?; + if light_scheme.exists() { + let src_ini = Self::read_ini(&light_scheme)?; - for (section, key_value) in src_ini.get_map_ref() { - for (key, _) in key_value { - kdeglobals_ini.remove_key(section, key); + // Remove color scheme keys from kdeglobals + for (section, key_value) in src_ini.get_map_ref() { + for key in key_value.keys() { + kdeglobals_ini.remove_key(section, key); + } } + + kdeglobals_ini + .write(kdeglobals_file) + .map_err(OutputError::Io)?; } - kdeglobals_ini - .write(kdeglobals_file) - .map_err(OutputError::Io)?; + // Delete now-unused kcolorscheme files + let delete_light_res = fs::remove_file(&light_scheme).map_err(OutputError::Io); + let delete_dark_res = fs::remove_file(&dark_scheme).map_err(OutputError::Io); + delete_light_res?; + delete_dark_res?; + Ok(()) } @@ -312,6 +63,7 @@ widgetStyle=qt6ct-style let Some(mut data_dir) = dirs::data_dir() else { return Err(OutputError::MissingDataDir); }; + data_dir.push("color-schemes"); let file_name = if is_dark { "CosmicDark.colors" @@ -319,11 +71,6 @@ widgetStyle=qt6ct-style "CosmicLight.colors" }; - data_dir.push("color-schemes"); - if !data_dir.exists() { - std::fs::create_dir_all(&data_dir).map_err(OutputError::Io)?; - } - Ok(data_dir.join(file_name)) } @@ -338,15 +85,6 @@ widgetStyle=qt6ct-style Ok(ini) } - #[cold] - fn backup_non_cosmic_kdeglobals(ini: &Ini, path: &Path) -> io::Result<()> { - if !Self::is_cosmic_kdeglobals(&ini)?.unwrap_or(true) { - let backup_path = path.with_extension("bak"); - fs::copy(path, &backup_path)?; - } - Ok(()) - } - #[cold] fn is_cosmic_kdeglobals(ini: &Ini) -> io::Result> { let color_scheme = ini.get("General", "ColorScheme"); @@ -360,208 +98,43 @@ widgetStyle=qt6ct-style } } -/// Formats a color in the form `r,g,b` e.g. `255,255,255`. -/// If the color has transparency, it is mixed with bg first. -fn to_rgb(c: Srgba, bg: Srgba) -> String { - let c_u8: Srgba = c.over(bg).into_format(); - format!("{},{},{}", c_u8.red, c_u8.green, c_u8.blue) -} - -fn format_ini_color_effects(color_effects: &IniColorEffects, bg: Srgba) -> String { - format!( - r#"Color={} -ColorAmount={} -ColorEffect={} -ContrastAmount={} -ContrastEffect={} -IntensityAmount={} -IntensityEffect={}"#, - to_rgb(color_effects.color, bg), - color_effects.color_amount, - color_effects.color_effect.as_u8(), - color_effects.contrast_amount, - color_effects.contrast_effect.as_u8(), - color_effects.intensity_amount, - color_effects.intensity_effect.as_u8(), - ) -} - -fn format_ini_colors(colors: &IniColors, bg: Srgba) -> String { - format!( - r#"BackgroundAlternate={} -BackgroundNormal={} -DecorationFocus={} -DecorationHover={} -ForegroundActive={} -ForegroundInactive={} -ForegroundLink={} -ForegroundNegative={} -ForegroundNeutral={} -ForegroundNormal={} -ForegroundPositive={} -ForegroundVisited={}"#, - to_rgb(colors.background_alternate, bg), - to_rgb(colors.background_normal, bg), - to_rgb(colors.decoration_focus, bg), - to_rgb(colors.decoration_hover, bg), - to_rgb(colors.foreground_active, bg), - to_rgb(colors.foreground_inactive, bg), - to_rgb(colors.foreground_link, bg), - to_rgb(colors.foreground_negative, bg), - to_rgb(colors.foreground_neutral, bg), - to_rgb(colors.foreground_normal, bg), - to_rgb(colors.foreground_positive, bg), - to_rgb(colors.foreground_visited, bg), - ) -} - -/// Sets the colors for the titlebars of active and inactive windows. -fn format_ini_wm_colors(view_colors: &IniColors, is_dark: bool) -> String { - let bg = view_colors.background_normal; - let fg = view_colors.foreground_active; - let blend = if is_dark { fg } else { bg }; - - format!( - r#"activeBackground={} -activeBlend={} -activeForeground={} -inactiveBackground={} -inactiveBlend={} -inactiveForeground={}"#, - to_rgb(bg, bg), - to_rgb(blend, bg), - to_rgb(fg, bg), - to_rgb(bg, bg), - to_rgb(blend, bg), - to_rgb(fg, bg), - ) -} - -struct IniColorEffects { - color: Srgba, - color_amount: f32, - color_effect: ColorEffect, - contrast_amount: f32, - /// Applied to the text, using the background as the reference color. - contrast_effect: ColorEffect, - intensity_amount: f32, - intensity_effect: IntensityEffect, -} -/// Each color set is made up of a number of roles which are available in all other sets. -/// In addition, except for Inactive Text, there is a corresponding background role for each of the text roles. Currently (except for Normal and Alternate Background), these colors are not chosen here but are automatically determined based on Normal Background and the corresponding Text color. -struct IniColors { - /// used when there is a need to subtly change the background to aid in item association. This might be used e.g. as the background of a heading, but is mostly used for alternating rows in lists, especially multi-column lists, to aid in visually tracking rows. - background_alternate: Srgba, - /// Normal background - background_normal: Srgba, - /// Used for drawing lines or shading UI elements to indicate the item which has active input focus. - /// Typically the same as foreground_active. - decoration_focus: Srgba, - /// Used for drawing lines or shading UI elements for mouse-over effects, e.g. the "illumination" effects for buttons. - /// Typically the same as foreground_active. - decoration_hover: Srgba, - /// used to indicate an active element or attract attention, e.g. alerts, notifications; also for hovered hyperlinks - foreground_active: Srgba, - /// used for text which should be unobtrusive, e.g. comments, "subtitles", unimportant information, etc. - foreground_inactive: Srgba, - /// used for hyperlinks or to otherwise indicate "something which may be visited", or to show relationships - foreground_link: Srgba, - /// used for errors, failure notices, notifications that an action may be dangerous (e.g. unsafe web page or security context), etc. - foreground_negative: Srgba, - /// used to draw attention when another role is not appropriate; e.g. warnings, to indicate secure/encrypted content, etc. - foreground_neutral: Srgba, - /// Normal foreground - foreground_normal: Srgba, - /// used for success notices, to indicate trusted content, etc. - foreground_positive: Srgba, - /// used for "something (e.g. a hyperlink) that has been visited", or to indicate something that is "old". - foreground_visited: Srgba, -} - -/// Intensity allows the overall color to be lightened or darkened. -#[allow(dead_code)] -enum IntensityEffect { - /// Makes everything lighter or darker in a controlled manner. - /// - /// intensity_amount increases or decreases the overall intensity (i.e. perceived brightness) by an absolute amount. - Shade, - /// Changes the intensity to a percentage of the initial value. - Darken, - /// Conceptually the opposite of darken; lighten can be thought of as working with "distance from white", where darken works with "distance from black". - Lighten, -} - -impl IntensityEffect { - pub fn as_u8(&self) -> u8 { - match self { - Self::Shade => 0, - Self::Darken => 1, - Self::Lighten => 2, - } - } -} - -/// This also changes the overall color like [IntensityEffect], -/// but is not limited to intensity. -#[allow(dead_code)] -enum ColorEffect { - /// changes the relative chroma - /// - /// This is available for "ColorEffect" but not "ContrastEffect". - Desaturate, - /// smoothly blends the original color into a reference color - Fade, - /// similar to Fade, except that the color (hue and chroma) changes more quickly while the intensity changes more slowly as the amount is increased - Tint, -} - -impl ColorEffect { - pub fn as_u8(&self) -> u8 { - match self { - Self::Desaturate => 0, - Self::Fade => 1, - Self::Tint => 2, - } - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn test_opaque_color_to_rgb() { - let color = Srgba::new(30.0 / 255.0, 50.0 / 255.0, 70.0 / 255.0, 1.0); - let bg = Srgba::new(1.0, 1.0, 1.0, 1.0); - let result = to_rgb(color, bg); - assert_eq!(result, "30,50,70"); + fn test_is_cosmic_kdeglobals_dark() -> io::Result<()> { + let mut ini = Ini::new_cs(); + ini.setstr("General", "ColorScheme", Some("CosmicDark")); + let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?; + assert_eq!(is_cosmic, Some(true)); + Ok(()) } #[test] - fn test_transparent_color_to_rgb() { - let color = Srgba::new(0.0, 0.0, 0.0, 0.0); - let bg = Srgba::new(1.0, 1.0, 1.0, 1.0); - let result = to_rgb(color, bg); - assert_eq!(result, "255,255,255"); + fn test_is_cosmic_kdeglobals_light() -> io::Result<()> { + let mut ini = Ini::new_cs(); + ini.setstr("General", "ColorScheme", Some("CosmicLight")); + let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?; + assert_eq!(is_cosmic, Some(true)); + Ok(()) } #[test] - fn test_translucent_color_to_rgb() { - let color = Srgba::new(0.0, 0.0, 0.0, 0.9); - let bg = Srgba::new(1.0, 1.0, 1.0, 1.0); - let result = to_rgb(color, bg); - assert_eq!(result, "26,26,26"); + fn test_is_cosmic_kdeglobals_breeze() -> io::Result<()> { + let mut ini = Ini::new_cs(); + ini.setstr("General", "ColorScheme", Some("BreezeDark")); + let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?; + assert_eq!(is_cosmic, Some(false)); + Ok(()) } #[test] - fn test_light_default_kcolorscheme() { - let light_default_kcolorscheme = Theme::light_default().as_kcolorscheme(); - insta::assert_snapshot!(light_default_kcolorscheme); - } - - #[test] - fn test_dark_default_kcolorscheme() { - let dark_default_kcolorscheme = Theme::dark_default().as_kcolorscheme(); - insta::assert_snapshot!(dark_default_kcolorscheme); + fn test_is_cosmic_kdeglobals_blank() -> io::Result<()> { + let mut ini = Ini::new_cs(); + ini.setstr("General", "ColorScheme", None); + let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?; + assert_eq!(is_cosmic, None); + Ok(()) } } diff --git a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap deleted file mode 100644 index c50f95dc..00000000 --- a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__dark_default_kcolorscheme.snap +++ /dev/null @@ -1,157 +0,0 @@ ---- -source: cosmic-theme/src/output/qt_output.rs -expression: dark_default_kcolorscheme ---- -# GENERATED BY COSMIC - -[ColorEffects:Disabled] -Color=43,43,43 -ColorAmount=0 -ColorEffect=0 -ContrastAmount=0.65 -ContrastEffect=1 -IntensityAmount=0.1 -IntensityEffect=2 - -[ColorEffects:Inactive] -ChangeSelectionColor=false -Enable=false -Color=27,27,27 -ColorAmount=0.025 -ColorEffect=2 -ContrastAmount=0.1 -ContrastEffect=2 -IntensityAmount=0 -IntensityEffect=0 - -[Colors:Button] -BackgroundAlternate=99,208,223 -BackgroundNormal=60,60,60 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Complementary] -BackgroundAlternate=99,208,223 -BackgroundNormal=27,27,27 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Header] -BackgroundAlternate=31,36,37 -BackgroundNormal=27,27,27 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Header][Inactive] -BackgroundAlternate=31,36,37 -BackgroundNormal=27,27,27 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Selection] -BackgroundAlternate=63,118,125 -BackgroundNormal=99,208,223 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=67,67,67 -ForegroundInactive=83,138,145 -ForegroundLink=27,27,27 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=67,67,67 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Tooltip] -BackgroundAlternate=49,55,55 -BackgroundNormal=46,46,46 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:View] -BackgroundAlternate=49,55,55 -BackgroundNormal=46,46,46 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Window] -BackgroundAlternate=31,36,37 -BackgroundNormal=27,27,27 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[General] -ColorScheme=CosmicDark -Name=COSMIC Dark -shadeSortColumn=true - -[Icons] -Theme=breeze-dark - -[KDE] -contrast=4 -widgetStyle=qt6ct-style - -[WM] -activeBackground=27,27,27 -activeBlend=99,208,223 -activeForeground=99,208,223 -inactiveBackground=27,27,27 -inactiveBlend=99,208,223 -inactiveForeground=99,208,223 diff --git a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap b/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap deleted file mode 100644 index 40363f95..00000000 --- a/cosmic-theme/src/output/snapshots/cosmic_theme__output__qt_output__tests__light_default_kcolorscheme.snap +++ /dev/null @@ -1,157 +0,0 @@ ---- -source: cosmic-theme/src/output/qt_output.rs -expression: light_default_kcolorscheme ---- -# GENERATED BY COSMIC - -[ColorEffects:Disabled] -Color=194,194,194 -ColorAmount=0 -ColorEffect=0 -ContrastAmount=0.65 -ContrastEffect=1 -IntensityAmount=0.1 -IntensityEffect=2 - -[ColorEffects:Inactive] -ChangeSelectionColor=false -Enable=false -Color=215,215,215 -ColorAmount=0.025 -ColorEffect=2 -ContrastAmount=0.1 -ContrastEffect=2 -IntensityAmount=0 -IntensityEffect=0 - -[Colors:Button] -BackgroundAlternate=0,82,90 -BackgroundNormal=173,173,173 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=0,82,90 -ForegroundInactive=38,38,38 -ForegroundLink=0,82,90 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=18,18,18 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[Colors:Complementary] -BackgroundAlternate=99,208,223 -BackgroundNormal=27,27,27 -DecorationFocus=99,208,223 -DecorationHover=99,208,223 -ForegroundActive=99,208,223 -ForegroundInactive=211,211,211 -ForegroundLink=99,208,223 -ForegroundNegative=255,160,154 -ForegroundNeutral=255,163,125 -ForegroundNormal=231,231,231 -ForegroundPositive=94,219,140 -ForegroundVisited=99,208,223 - -[Colors:Header] -BackgroundAlternate=204,208,209 -BackgroundNormal=215,215,215 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=0,82,90 -ForegroundInactive=38,38,38 -ForegroundLink=0,82,90 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=18,18,18 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[Colors:Header][Inactive] -BackgroundAlternate=204,208,209 -BackgroundNormal=215,215,215 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=0,82,90 -ForegroundInactive=38,38,38 -ForegroundLink=0,82,90 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=18,18,18 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[Colors:Selection] -BackgroundAlternate=108,149,153 -BackgroundNormal=0,82,90 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=246,246,246 -ForegroundInactive=123,164,168 -ForegroundLink=215,215,215 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=246,246,246 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[Colors:Tooltip] -BackgroundAlternate=233,237,237 -BackgroundNormal=245,245,245 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=0,82,90 -ForegroundInactive=38,38,38 -ForegroundLink=0,82,90 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=18,18,18 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[Colors:View] -BackgroundAlternate=233,237,237 -BackgroundNormal=245,245,245 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=0,82,90 -ForegroundInactive=38,38,38 -ForegroundLink=0,82,90 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=18,18,18 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[Colors:Window] -BackgroundAlternate=204,208,209 -BackgroundNormal=215,215,215 -DecorationFocus=0,82,90 -DecorationHover=0,82,90 -ForegroundActive=0,82,90 -ForegroundInactive=38,38,38 -ForegroundLink=0,82,90 -ForegroundNegative=137,4,24 -ForegroundNeutral=121,44,0 -ForegroundNormal=18,18,18 -ForegroundPositive=0,87,44 -ForegroundVisited=0,82,90 - -[General] -ColorScheme=CosmicLight -Name=COSMIC Light -shadeSortColumn=true - -[Icons] -Theme=breeze - -[KDE] -contrast=4 -widgetStyle=qt6ct-style - -[WM] -activeBackground=215,215,215 -activeBlend=215,215,215 -activeForeground=0,82,90 -inactiveBackground=215,215,215 -inactiveBlend=215,215,215 -inactiveForeground=0,82,90 From ef162b8e16ba4493e05c169cd56c7b9f77f0fda5 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Sun, 19 Jul 2026 04:48:42 +0200 Subject: [PATCH 25/58] feat(icon): add opacity method --- src/widget/icon/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/widget/icon/mod.rs b/src/widget/icon/mod.rs index 031b4b0c..5685e448 100644 --- a/src/widget/icon/mod.rs +++ b/src/widget/icon/mod.rs @@ -25,6 +25,7 @@ pub fn icon(handle: Handle) -> Icon { handle, height: None, size: 16, + opacity: 1.0, class: crate::theme::Svg::default(), rotation: None, width: None, @@ -45,6 +46,7 @@ pub struct Icon { class: crate::theme::Svg, #[setters(skip)] pub(super) size: u16, + opacity: f32, content_fit: ContentFit, #[setters(strip_option)] width: Option, @@ -65,7 +67,6 @@ impl Icon { None } - #[must_use] pub fn size(mut self, size: u16) -> Self { self.size = size; self @@ -83,6 +84,7 @@ impl Icon { self.height .unwrap_or_else(|| Length::Fixed(f32::from(self.size))), ) + .opacity(self.opacity) .rotation(self.rotation.unwrap_or_default()) .content_fit(self.content_fit) .into() @@ -99,6 +101,7 @@ impl Icon { self.height .unwrap_or_else(|| Length::Fixed(f32::from(self.size))), ) + .opacity(self.opacity) .rotation(self.rotation.unwrap_or_default()) .content_fit(self.content_fit) .symbolic(self.handle.symbolic) From c994f94ea9e0c2046931c30e6d082bf6116f3f21 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Thu, 23 Jul 2026 18:51:12 +0200 Subject: [PATCH 26/58] i18n: translation updates from weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hosted Weblate Co-authored-by: Isaac Subirana Co-authored-by: Nara Díaz Viñolas Co-authored-by: Prasanta-Hembram Translate-URL: https://hosted.weblate.org/projects/pop-os/libcosmic/ca/ Translation: Pop OS/libcosmic --- i18n/ca/libcosmic.ftl | 3 +++ i18n/sat/libcosmic.ftl | 0 2 files changed, 3 insertions(+) create mode 100644 i18n/sat/libcosmic.ftl diff --git a/i18n/ca/libcosmic.ftl b/i18n/ca/libcosmic.ftl index e69de29b..0fbc5ca5 100644 --- a/i18n/ca/libcosmic.ftl +++ b/i18n/ca/libcosmic.ftl @@ -0,0 +1,3 @@ +license = Llicència +close = Tancar +links = Enllaços diff --git a/i18n/sat/libcosmic.ftl b/i18n/sat/libcosmic.ftl new file mode 100644 index 00000000..e69de29b From 7e777077b81e3a1b9c7b723f35399e80f1b2cba5 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 15 Jul 2026 19:40:09 -0600 Subject: [PATCH 27/58] fix: gate frosted transparency on auto_blur --- src/app/cosmic.rs | 54 ++++++----------------------------------------- src/core.rs | 28 ++++++++++++++++++------ 2 files changed, 27 insertions(+), 55 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 13e3ee83..eddf34bd 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -896,14 +896,7 @@ impl Cosmic { } } - let new_blur = self.blur_enabled && { - let t = theme.cosmic(); - match self.app.core().app_type() { - crate::core::AppType::Window => t.frosted_windows, - crate::core::AppType::System => t.frosted_system_interface, - crate::core::AppType::Applet => t.frosted_applets, - } - }; + let new_blur = self.blur_enabled && self.app.core().frosted(theme.cosmic()); theme.transparent = new_blur; let mut guard = THEME.lock().unwrap(); @@ -947,14 +940,7 @@ impl Cosmic { return iced::Task::none(); } // update transparent - let new_blur = self.blur_enabled && { - let t = theme.cosmic(); - match self.app.core().app_type() { - crate::core::AppType::Window => t.frosted_windows, - crate::core::AppType::System => t.frosted_system_interface, - crate::core::AppType::Applet => t.frosted_applets, - } - }; + let new_blur = self.blur_enabled && self.app.core().frosted(theme.cosmic()); theme.transparent = new_blur; let cmd = self.app.system_theme_update(&keys, theme.cosmic()); @@ -1101,14 +1087,7 @@ impl Cosmic { } else { new_theme }; - let new_blur = self.blur_enabled && { - let t = new_theme.cosmic(); - match core.app_type() { - crate::core::AppType::Window => t.frosted_windows, - crate::core::AppType::System => t.frosted_system_interface, - crate::core::AppType::Applet => t.frosted_applets, - } - }; + let new_blur = self.blur_enabled && core.frosted(new_theme.cosmic()); new_theme.transparent = new_blur; core.system_theme = new_theme.clone(); @@ -1283,14 +1262,7 @@ impl Cosmic { crate::theme::system_light() }; if let ThemeType::System { .. } = new_theme.theme_type { - let new_blur = self.blur_enabled && { - let t = new_theme.cosmic(); - match core.app_type() { - crate::core::AppType::Window => t.frosted_windows, - crate::core::AppType::System => t.frosted_system_interface, - crate::core::AppType::Applet => t.frosted_applets, - } - }; + let new_blur = self.blur_enabled && core.frosted(new_theme.cosmic()); new_theme.transparent = new_blur; } core.system_theme = new_theme.clone(); @@ -1441,14 +1413,7 @@ impl Cosmic { SurfaceIdWrapper::Popup(_) | SurfaceIdWrapper::LayerSurface(_) ) }); - let new_blur = self.blur_enabled && { - let t = theme.cosmic(); - match self.app.core().app_type() { - crate::core::AppType::Window => t.frosted_windows, - crate::core::AppType::System => t.frosted_system_interface, - crate::core::AppType::Applet => t.frosted_applets, - } - }; + let new_blur = self.blur_enabled && self.app.core().frosted(theme.cosmic()); let wrapper = self.surface_views.get(&id).map(|s| s.1); @@ -1512,14 +1477,7 @@ impl Cosmic { self.blur_enabled = true; let mut t = THEME.lock().unwrap(); - let new_blur = self.blur_enabled && { - let t = t.cosmic(); - match self.app.core().app_type() { - crate::core::AppType::Window => t.frosted_windows, - crate::core::AppType::System => t.frosted_system_interface, - crate::core::AppType::Applet => t.frosted_applets, - } - }; + let new_blur = self.blur_enabled && self.app.core().frosted(t.cosmic()); t.transparent = new_blur; diff --git a/src/core.rs b/src/core.rs index 9b301fb5..a3483b63 100644 --- a/src/core.rs +++ b/src/core.rs @@ -538,6 +538,20 @@ impl Core { self.auto_blur } + /// Whether the active theme's frosted (blur) styling should apply to this + /// app's surfaces. + #[must_use] + pub fn frosted(&self, theme: &cosmic_theme::Theme) -> bool { + if self.auto_blur.is_empty() { + return false; + } + match self.app_type { + AppType::Window => theme.frosted_windows, + AppType::System => theme.frosted_system_interface, + AppType::Applet => theme.frosted_applets, + } + } + pub fn set_auto_corner_radius(&mut self, auto_corner_radius: BitFlags) { self.auto_corner_radius = auto_corner_radius; } @@ -570,13 +584,13 @@ impl Core { Some(SurfaceIdWrapper::Window(_)) => { theme.frosted_windows && self.auto_blur.contains(Auto::Window) } - Some(SurfaceIdWrapper::Popup(_)) - if matches!(self.app_type, AppType::Window | AppType::System) => - { - theme.frosted_windows && self.auto_blur.contains(Auto::Popup) - } - Some(SurfaceIdWrapper::Popup(_)) if matches!(self.app_type, AppType::Applet) => { - theme.frosted_applets && self.auto_blur.contains(Auto::Popup) + Some(SurfaceIdWrapper::Popup(_)) => { + self.auto_blur.contains(Auto::Popup) + && match self.app_type { + AppType::Window => theme.frosted_windows, + AppType::System => theme.frosted_system_interface, + AppType::Applet => theme.frosted_applets, + } } None => match self.app_type { AppType::Window => theme.frosted_windows && self.auto_blur.contains(Auto::Window), From bdb273389d4134db9564d3aa0363f5efd468deb3 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Mon, 27 Jul 2026 14:11:20 +0200 Subject: [PATCH 28/58] chore: update iced with slider handle improvements --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index a7c71f8d..65b5dbc6 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit a7c71f8dd3914e9f83363bf7552c99cbbb62ab2c +Subproject commit 65b5dbc67172208b0f9af1cf8a5a72f4017734cc From 1f6dc991eaa5115a09be2505cc8a323e5b2b0bff Mon Sep 17 00:00:00 2001 From: Aadil <211808495+Aadil127@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:46:25 +0530 Subject: [PATCH 29/58] fix(slider): adjust handle bounds and size for iced widget slider update --- src/theme/style/iced.rs | 27 +++++---------------------- src/widget/color_picker/mod.rs | 4 +++- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/theme/style/iced.rs b/src/theme/style/iced.rs index 97246369..0a09a179 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -775,12 +775,12 @@ impl slider::Catalog for Theme { handle: slider::Handle { shape: slider::HandleShape::Rectangle { - height: 20, - width: 20, + height: 26, + width: 26, border_radius: cosmic.corner_radii.radius_m.into(), }, border_color: Color::TRANSPARENT, - border_width: 0.0, + border_width: 3.0, background: Background::Color(cosmic.accent.base.into()), }, @@ -795,12 +795,6 @@ impl slider::Catalog for Theme { slider::Status::Active => appearance, slider::Status::Hovered => match class { Slider::Standard => { - appearance.handle.shape = slider::HandleShape::Rectangle { - height: 26, - width: 26, - border_radius: cosmic.corner_radii.radius_m.into(), - }; - appearance.handle.border_width = 3.0; appearance.handle.border_color = self.cosmic().palette.neutral_10.with_alpha(0.1).into(); appearance @@ -809,20 +803,9 @@ impl slider::Catalog for Theme { }, slider::Status::Dragged => match class { Slider::Standard => { - let mut style = { - appearance.handle.shape = slider::HandleShape::Rectangle { - height: 26, - width: 26, - border_radius: cosmic.corner_radii.radius_m.into(), - }; - appearance.handle.border_width = 3.0; - appearance.handle.border_color = - self.cosmic().palette.neutral_10.with_alpha(0.1).into(); - appearance - }; - style.handle.border_color = + appearance.handle.border_color = self.cosmic().palette.neutral_10.with_alpha(0.2).into(); - style + appearance } Slider::Custom { dragging, .. } => dragging(self), }, diff --git a/src/widget/color_picker/mod.rs b/src/widget/color_picker/mod.rs index ab0f73fc..4041c934 100644 --- a/src/widget/color_picker/mod.rs +++ b/src/widget/color_picker/mod.rs @@ -360,7 +360,9 @@ where }) .step(4.0 / 17.0) .shift_step(64.0 / 17.0) - .width(self.width), + .width(self.width) + .handle_height(16) + .handle_width(16), text_input("", self.input_color) .on_input(move |s| on_update(ColorPickerUpdate::Input(s))) .on_paste(move |s| on_update(ColorPickerUpdate::Input(s))) From 0ca1b784c931c946517379795ce87328c6a902b8 Mon Sep 17 00:00:00 2001 From: vacenty <193441458+vacenty@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:52:57 +0200 Subject: [PATCH 30/58] feat: Add option for frozen glass in maximized windows --- cosmic-theme/src/model/theme.rs | 9 +++++++++ src/app/cosmic.rs | 16 ++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs index 77f94c8f..5974523b 100644 --- a/cosmic-theme/src/model/theme.rs +++ b/cosmic-theme/src/model/theme.rs @@ -121,6 +121,9 @@ pub struct Theme { /// frosted applet popups pub frosted_applets: bool, #[serde(default)] + /// frosted apps when maximized + pub frosted_maximized_apps: bool, + #[serde(default)] /// alpha map pub alpha_map: AlphaMap, /// shade color for dialogs @@ -909,6 +912,9 @@ pub struct ThemeBuilder { /// frosted applet popups pub frosted_applets: bool, #[serde(default)] + /// frosted apps when maximized + pub frosted_maximized_apps: bool, + #[serde(default)] /// alpha map pub alpha_map: AlphaMap, } @@ -937,6 +943,7 @@ impl Default for ThemeBuilder { frosted_system_interface: false, frosted_panel: false, frosted_applets: false, + frosted_maximized_apps: false, alpha_map: AlphaMap::default(), } } @@ -1084,6 +1091,7 @@ impl ThemeBuilder { frosted_system_interface, frosted_panel, frosted_applets, + frosted_maximized_apps, alpha_map, } = self; @@ -1556,6 +1564,7 @@ impl ThemeBuilder { frosted_system_interface, frosted_panel, frosted_applets, + frosted_maximized_apps, alpha_map, transparent_background: Container::new( Component::component( diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index eddf34bd..d31b6088 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -549,16 +549,16 @@ where pub fn style(&self, theme: &Theme) -> theme::Style { if let Some(style) = self.app.style() { style - } else if self.app.core().window.is_maximized { - let theme = THEME.lock().unwrap(); - crate::style::iced::application::style(theme.borrow()) } else { let theme = THEME.lock().unwrap(); - - theme::Style { - background_color: iced_core::Color::TRANSPARENT, - icon_color: theme.cosmic().on_bg_color().into(), - text_color: theme.cosmic().on_bg_color().into(), + if self.app.core().window.is_maximized && !theme.cosmic().frosted_maximized_apps { + crate::style::iced::application::style(theme.borrow()) + } else { + theme::Style { + background_color: iced_core::Color::TRANSPARENT, + icon_color: theme.cosmic().on_bg_color().into(), + text_color: theme.cosmic().on_bg_color().into(), + } } } } From abaa06f10f4c03f6122d984e1bf02496937bb20d Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 29 Jul 2026 12:09:27 -0400 Subject: [PATCH 31/58] fix(menu): path highlight corners flipped --- src/widget/menu/menu_inner.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index c0d7f10a..25bbfa7b 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -703,7 +703,7 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { } _ => {} - }; + } None } @@ -811,11 +811,11 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { let rad_0 = theme.cosmic().radius_0(); if start_index != end_index { if 0 == i { - rad[0] = rad_0[0]; - rad[1] = rad_0[1]; + rad[2] = rad_0[0]; + rad[3] = rad_0[1]; } else if i == end_index - start_index { - rad[2] = rad_0[2]; - rad[3] = rad_0[3]; + rad[0] = rad_0[2]; + rad[1] = rad_0[3]; } else { rad = rad_0; } From dc1cf9f00cbe2902a52166492654bb9fee8a73d1 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 29 Jul 2026 14:23:16 -0400 Subject: [PATCH 32/58] chore: update iced --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 65b5dbc6..7346cffd 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 65b5dbc67172208b0f9af1cf8a5a72f4017734cc +Subproject commit 7346cffd2e51e45fbe4dd31bdd42211b8ca0078e From 05be5041740e1a943623e5489bdf831e22d5316c Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Wed, 5 Aug 2026 17:02:13 +0200 Subject: [PATCH 33/58] i18n: translation updates from weblate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hosted Weblate Co-authored-by: Martin Meizoso Co-authored-by: Quentin PAGÈS Translate-URL: https://hosted.weblate.org/projects/pop-os/libcosmic/gl/ Translate-URL: https://hosted.weblate.org/projects/pop-os/libcosmic/oc/ Translation: Pop OS/libcosmic --- i18n/gl/libcosmic.ftl | 34 ++++++++++++++++++++++++++++++++++ i18n/oc/libcosmic.ftl | 14 ++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 i18n/gl/libcosmic.ftl diff --git a/i18n/gl/libcosmic.ftl b/i18n/gl/libcosmic.ftl new file mode 100644 index 00000000..94bc4909 --- /dev/null +++ b/i18n/gl/libcosmic.ftl @@ -0,0 +1,34 @@ +close = Pechar +license = Licenza +links = Enderezos +developers = Desenvolvedores +designers = Deseñadores +artists = Artistas +translators = Traductores +documenters = Documentadores +january = Xaneiro { $year } +february = Febreiro { $year } +march = Marzo { $year } +april = Abril { $year } +may = Maio { $year } +june = Xuño { $year } +july = Xullo { $year } +august = Agosto { $year } +september = Setembro { $year } +october = Outubro { $year } +november = Novembro { $year } +december = Decembro { $year } +monday = Luns +mon = Lun +tuesday = Martes +tue = Mar +wednesday = Mércores +wed = Mér +thursday = Xoves +thu = Xov +friday = Venres +fri = Ven +saturday = Sábado +sat = Sáb +sunday = Domingo +sun = Dom diff --git a/i18n/oc/libcosmic.ftl b/i18n/oc/libcosmic.ftl index e69de29b..e1d98649 100644 --- a/i18n/oc/libcosmic.ftl +++ b/i18n/oc/libcosmic.ftl @@ -0,0 +1,14 @@ +monday = Diluns +mon = Dil +tuesday = Dimars +tue = Dim +wednesday = Dimècres +wed = Dma +thursday = Dijòus +thu = Dij +friday = Divendres +fri = Div +saturday = Dissabte +sat = Dis +sunday = Dimenge +sun = Dim From 1ac21a9d994cc68c8e4cce6101a6bade6a9da89d Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 4 Aug 2026 11:50:12 -0600 Subject: [PATCH 34/58] fix: request redraw when hover state changes --- src/widget/button/widget.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/widget/button/widget.rs b/src/widget/button/widget.rs index a9d44b2b..f5501134 100644 --- a/src/widget/button/widget.rs +++ b/src/widget/button/widget.rs @@ -868,8 +868,21 @@ pub fn update<'a, Message: Clone>( } } } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + let state = state(); + let is_hovered = cursor.is_over(layout.bounds()); + if state.is_hovered != is_hovered { + state.is_hovered = is_hovered; + shell.request_redraw(); + } + } Event::Touch(touch::Event::FingerLost { .. }) | Event::Mouse(mouse::Event::CursorLeft) => { let state = state(); + + if state.is_hovered || state.is_pressed { + shell.request_redraw(); + } state.is_hovered = false; state.is_pressed = false; } From 5fd1ca4220629b961b5f088d4d4aaf857573a3a8 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 4 Aug 2026 13:58:23 -0600 Subject: [PATCH 35/58] fix: request redraw in menus --- src/widget/dropdown/menu/mod.rs | 6 ++++-- src/widget/dropdown/multi/menu.rs | 2 ++ src/widget/menu/menu_bar.rs | 1 + src/widget/menu/menu_inner.rs | 8 +++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/widget/dropdown/menu/mod.rs b/src/widget/dropdown/menu/mod.rs index 0c96c1c6..f18016d8 100644 --- a/src/widget/dropdown/menu/mod.rs +++ b/src/widget/dropdown/menu/mod.rs @@ -499,8 +499,10 @@ where let new_hovered_option = (cursor_position.y / option_height) as usize; let mut hovered_guard = self.hovered_option.lock().unwrap(); - if let Some(on_option_hovered) = self.on_option_hovered { - if *hovered_guard != Some(new_hovered_option) { + if *hovered_guard != Some(new_hovered_option) { + shell.request_redraw(); + + if let Some(on_option_hovered) = self.on_option_hovered { shell.publish(on_option_hovered(new_hovered_option)); } } diff --git a/src/widget/dropdown/multi/menu.rs b/src/widget/dropdown/multi/menu.rs index 0a761097..689fb0de 100644 --- a/src/widget/dropdown/multi/menu.rs +++ b/src/widget/dropdown/multi/menu.rs @@ -382,6 +382,8 @@ where if previous_hover_option.as_ref() == Some(item) { previous_hover_option } else { + shell.request_redraw(); + if let Some(on_option_hovered) = self.on_option_hovered { shell.publish(on_option_hovered(item.clone())); } diff --git a/src/widget/menu/menu_bar.rs b/src/widget/menu/menu_bar.rs index 84927b67..71a39c42 100644 --- a/src/widget/menu/menu_bar.rs +++ b/src/widget/menu/menu_bar.rs @@ -656,6 +656,7 @@ where Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorEntered) if open && view_cursor.is_over(layout.bounds()) => { + shell.request_redraw(); shell.capture_event(); #[cfg(wayland_platform)] if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) { diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 25bbfa7b..58721124 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -1502,7 +1502,9 @@ where // cursor is outside { - last_menu_state.index = None; + if last_menu_state.index.take().is_some() { + shell.request_redraw(); + } shell.capture_event(); return new_menu_root; } @@ -1561,6 +1563,10 @@ where // set new index let old_index = last_menu_state.index.replace(new_index); + if old_index != Some(new_index) { + shell.request_redraw(); + } + // get new active item // * add new menu if the new item is a menu if !item.children.is_empty() && old_index.is_none_or(|i| i != new_index) { From 31ec448bf0fb286c7a794701185becf5426deca9 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 4 Aug 2026 13:58:47 -0600 Subject: [PATCH 36/58] fix: request redraw in text input when needed --- src/widget/text_input/input.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index c86c5a3e..d382a5a8 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -1680,6 +1680,7 @@ pub fn update<'a, Message: Clone + 'static>( state.last_click = Some(click); + shell.request_redraw(); shell.capture_event(); return; } else { @@ -1746,6 +1747,7 @@ pub fn update<'a, Message: Clone + 'static>( .cursor .select_range(state.cursor.start(value), position); + shell.request_redraw(); shell.capture_event(); return; } @@ -2099,6 +2101,7 @@ pub fn update<'a, Message: Clone + 'static>( _ => {} } + shell.request_redraw(); shell.capture_event(); return; } From 142133783111a341c019b4413f99f765b2d6ec69 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 5 Aug 2026 12:14:55 -0600 Subject: [PATCH 37/58] fix: do not publish ResponsiveMenuBar messages in a loop --- src/widget/responsive_container.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/widget/responsive_container.rs b/src/widget/responsive_container.rs index c8925cc2..e75b7a6b 100644 --- a/src/widget/responsive_container.rs +++ b/src/widget/responsive_container.rs @@ -180,13 +180,21 @@ where let state = tree.state.downcast_mut::(); if state.needs_update { - shell.publish((self.on_action)( - crate::surface::Action::ResponsiveMenuBar { - menu_bar: self.id.clone(), - limits: state.limits, - size: state.size, - }, - )); + // `needs_update` stays set while the content cannot fit, don't publish messages in a + // busy loop + let announcement = (state.limits, state.size); + + if state.announced != Some(announcement) { + state.announced = Some(announcement); + shell.publish((self.on_action)( + crate::surface::Action::ResponsiveMenuBar { + menu_bar: self.id.clone(), + limits: announcement.0, + size: announcement.1, + }, + )); + } + state.needs_update = false; } @@ -328,6 +336,8 @@ struct State { limits: Limits, size: Size, needs_update: bool, + /// The `(limits, size)` last announced, so an unsatisfiable layout does not announce forever + announced: Option<(Limits, Size)>, } impl State { @@ -336,6 +346,7 @@ impl State { limits: Limits::NONE, size: Size::new(0., 0.), needs_update: false, + announced: None, } } } From 43a50cb6f5af18fa6cbea535fb46460a3c5cd573 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 5 Aug 2026 13:37:02 -0600 Subject: [PATCH 38/58] chore: Update iced --- iced | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iced b/iced index 7346cffd..f5aa5a6f 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit 7346cffd2e51e45fbe4dd31bdd42211b8ca0078e +Subproject commit f5aa5a6f8cc2db6e2adbbc140c310a5a4af2f8d2 From 834cd366eeaf744d1600a9b383ef5cde78a768f1 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:44 -0600 Subject: [PATCH 39/58] feat: add context menu support to selectable Text widget --- src/theme/style/iced.rs | 13 +- src/widget/text.rs | 525 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 494 insertions(+), 44 deletions(-) diff --git a/src/theme/style/iced.rs b/src/theme/style/iced.rs index 0a09a179..3b0d9794 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -1370,18 +1370,19 @@ 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(); match class { Text::Accent => iced_widget::text::Style { color: Some(self.cosmic().accent_text_color().into()), - ..Default::default() + selected_fill, }, Text::Default => iced_widget::text::Style { color: None, - ..Default::default() + selected_fill, }, Text::Color(c) => iced_widget::text::Style { color: Some(*c), - ..Default::default() + selected_fill, }, Text::Custom(f) => f(self), } @@ -1527,6 +1528,12 @@ pub enum TextEditor<'a> { Custom(text_editor::StyleFn<'a, Theme>), } +impl<'a> From> for TextEditor<'a> { + fn from(style: text_editor::StyleFn<'a, Theme>) -> Self { + Self::Custom(style) + } +} + impl iced_widget::text_editor::Catalog for Theme { type Class<'a> = TextEditor<'a>; diff --git a/src/widget/text.rs b/src/widget/text.rs index 37e85b80..9fe051ac 100644 --- a/src/widget/text.rs +++ b/src/widget/text.rs @@ -1,13 +1,460 @@ use crate::Renderer; -pub use iced::widget::Text; +use crate::widget::menu::MenuBarState; + +use iced_core::event::Event; use iced_core::text::LineHeight; +use iced_core::widget::Widget; +use iced_core::widget::text::{Alignment, Catalog, Ellipsize, Shaping, Style, Wrapping}; +use iced_core::widget::tree::{self, Tree}; +use iced_core::{ + Clipboard, Color, Layout, Length, Pixels, Rectangle, Shell, Size, Vector, alignment, mouse, + overlay, renderer, +}; use std::borrow::Cow; -/// Creates a new [`Text`] widget with the provided content. +type InnerText<'a> = iced_core::widget::text::Text<'a, crate::Theme, Renderer>; + +/// A text widget with optional selection and context menu support. /// -/// [`Text`]: widget::Text -pub fn text<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { - Text::new(text.into()).font(crate::font::default()) +/// By default, text is non-selectable. Call [`.selectable()`](Text::selectable) +/// to enable text selection and a right-click context menu with Copy and +/// Select All. +pub struct Text<'a> { + inner: InnerText<'a>, + context_menu: bool, +} + +impl<'a> Text<'a> { + /// Creates a new [`Text`] widget with the given content. + pub fn new(content: impl iced_core::text::IntoFragment<'a>) -> Self { + Self { + inner: InnerText::new(content), + context_menu: false, + } + } + + /// Enables text selection and a right-click context menu. + pub fn selectable(mut self) -> Self { + self.inner = self.inner.selectable(); + self.context_menu = true; + self + } + + /// Controls whether the right-click context menu is shown. + /// + /// Only meaningful after calling [`.selectable()`](Self::selectable). + /// Pass `false` to keep text selection but disable the context menu. + pub fn context_menu(mut self, enabled: bool) -> Self { + self.context_menu = enabled; + self + } + + fn uses_popup_context_menu(&self) -> bool { + #[cfg(feature = "wayland")] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + return true; + } + false + } + + pub fn size(mut self, size: impl Into) -> Self { + self.inner = self.inner.size(size); + self + } + + pub fn line_height(mut self, line_height: impl Into) -> Self { + self.inner = self.inner.line_height(line_height); + self + } + + pub fn font(mut self, font: impl Into<::Font>) -> Self { + self.inner = self.inner.font(font); + self + } + + pub fn font_maybe( + mut self, + font: Option::Font>>, + ) -> Self { + self.inner = self.inner.font_maybe(font); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.inner = self.inner.width(width); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.inner = self.inner.height(height); + self + } + + pub fn center(mut self) -> Self { + self.inner = self.inner.center(); + self + } + + pub fn align_x(mut self, alignment: impl Into) -> Self { + self.inner = self.inner.align_x(alignment); + self + } + + pub fn align_y(mut self, alignment: impl Into) -> Self { + self.inner = self.inner.align_y(alignment); + self + } + + pub fn shaping(mut self, shaping: Shaping) -> Self { + self.inner = self.inner.shaping(shaping); + self + } + + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.inner = self.inner.wrapping(wrapping); + self + } + + pub fn ellipsize(mut self, ellipsize: Ellipsize) -> Self { + self.inner = self.inner.ellipsize(ellipsize); + self + } + + #[must_use] + pub fn style(mut self, style: impl Fn(&crate::Theme) -> Style + 'a) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.style(style); + self + } + + pub fn color(mut self, color: impl Into) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.color(color); + self + } + + pub fn color_maybe(mut self, color: Option>) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.color_maybe(color); + self + } + + #[must_use] + pub fn class(mut self, class: impl Into<::Class<'a>>) -> Self { + self.inner = self.inner.class(class); + self + } +} + +impl<'a> From> for Text<'a> { + fn from(inner: InnerText<'a>) -> Self { + Self { + inner, + context_menu: false, + } + } +} + +impl<'a> From<&'a str> for Text<'a> { + fn from(content: &'a str) -> Self { + Self::new(content) + } +} + +struct TextWrapperState { + menu_bar_state: MenuBarState, + pending_action: crate::widget::text_context_menu::PendingAction, +} + +fn w<'x, Message>(inner: &'x InnerText<'_>) -> &'x dyn Widget { + inner +} + +fn w_mut<'x, Message>( + inner: &'x mut InnerText<'_>, +) -> &'x mut dyn Widget { + inner +} + +impl<'a, Message: Clone + 'static> Widget for Text<'a> { + fn tag(&self) -> tree::Tag { + if self.context_menu { + tree::Tag::of::() + } else { + w::(&self.inner).tag() + } + } + + fn state(&self) -> tree::State { + if self.context_menu { + tree::State::new(TextWrapperState { + menu_bar_state: MenuBarState::default(), + pending_action: crate::widget::text_context_menu::pending_action(), + }) + } else { + w::(&self.inner).state() + } + } + + fn children(&self) -> Vec { + if self.context_menu { + vec![Tree::new(w::(&self.inner))] + } else { + Vec::new() + } + } + + fn diff(&mut self, tree: &mut Tree) { + if self.context_menu { + if let Some(child) = tree.children.first_mut() { + child.diff(&mut self.inner as &mut dyn Widget); + } + } + } + + fn size(&self) -> Size { + w::(&self.inner).size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + let inner_tree = if self.context_menu { + &mut tree.children[0] + } else { + tree + }; + w_mut::(&mut self.inner).layout(inner_tree, renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &crate::Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let inner_tree = if self.context_menu { + &tree.children[0] + } else { + tree + }; + w::(&self.inner).draw( + inner_tree, renderer, theme, defaults, layout, cursor, viewport, + ); + + // draw a border when the text is focused + use iced_core::renderer::Renderer as RendererExt; + use iced_core::widget::text::State as TextState; + + let state = inner_tree + .state + .downcast_ref::::Paragraph>>(); + if state.is_keyboard_focused() { + let cosmic = theme.cosmic(); + let accent: Color = cosmic.accent.base.into(); + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: iced_core::Border { + color: accent, + width: 1.0, + radius: cosmic.corner_radii.radius_s.into(), + }, + ..renderer::Quad::default() + }, + Color::TRANSPARENT, + ); + } + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if self.context_menu { + w_mut::(&mut self.inner).update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + + #[cfg(feature = "wayland")] + if self.uses_popup_context_menu() { + use iced_core::widget::text::HasSelectableText; + + if self + .inner + .context_menu_position(&tree.children[0]) + .is_some() + { + let selected_text = self.inner.selected_text(&tree.children[0]); + let has_selection = selected_text.is_some(); + let click_position = + self.inner.context_menu_position(&tree.children[0]).unwrap(); + let wrapper_state = tree.state.downcast_ref::(); + let menu_bar_state = wrapper_state.menu_bar_state.clone(); + let pending_action = wrapper_state.pending_action.clone(); + + crate::widget::text_context_menu::create_text_context_popup( + click_position, + selected_text, + false, + has_selection, + &menu_bar_state, + &pending_action, + renderer, + viewport, + cursor, + ); + + self.inner + .set_context_menu_position(&mut tree.children[0], None); + } + + { + let wrapper_state = tree.state.downcast_ref::(); + let pending_action = wrapper_state.pending_action.clone(); + if let Some(action) = + crate::widget::text_context_menu::take_pending_action(&pending_action) + { + match action { + crate::widget::text_context_menu::TextCtxAction::Copy => {} + crate::widget::text_context_menu::TextCtxAction::SelectAll => { + self.inner.select_all(&mut tree.children[0]); + } + _ => {} + } + } + } + + let wrapper_state = tree.state.downcast_ref::(); + let menu_bar_state = wrapper_state.menu_bar_state.clone(); + crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); + } + } else { + w_mut::(&mut self.inner).update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let inner_tree = if self.context_menu { + &tree.children[0] + } else { + tree + }; + w::(&self.inner).mouse_interaction(inner_tree, layout, cursor, viewport, renderer) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + _layout: Layout<'b>, + _renderer: &Renderer, + _viewport: &Rectangle, + translation: Vector, + ) -> Option> { + if self.context_menu && !self.uses_popup_context_menu() { + let menu_bar_state = tree + .state + .downcast_ref::() + .menu_bar_state + .clone(); + let inner_tree = &mut tree.children[0]; + crate::widget::text_context_menu::context_menu_overlay( + &self.inner, + inner_tree, + None, + translation, + menu_bar_state, + ) + } else { + None + } + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced_core::widget::Operation, + ) { + let inner_tree = if self.context_menu { + &mut tree.children[0] + } else { + tree + }; + w_mut::(&mut self.inner).operate(inner_tree, layout, renderer, operation); + } + + #[cfg(feature = "a11y")] + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let inner_state = if self.context_menu { + &state.children[0] + } else { + state + }; + w::(&self.inner).a11y_nodes(layout, inner_state, cursor) + } + + fn id(&self) -> Option { + w::(&self.inner).id() + } + + fn set_id(&mut self, id: iced_core::widget::Id) { + w_mut::(&mut self.inner).set_id(id); + } +} + +impl<'a, Message: Clone + 'static> From> for crate::Element<'a, Message> { + fn from(text: Text<'a>) -> Self { + Self::new(text) + } } /// Available presets for text typography @@ -24,119 +471,115 @@ pub enum Typography { Title4, } +/// Creates a new [`Text`] widget with the provided content. +pub fn text<'a>(content: impl Into> + 'a) -> Text<'a> { + Text::new(content.into()).font(crate::font::default()) +} + /// [`Text`] widget with the Title 1 typography preset. -pub fn title1<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn title1<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(35.0) .line_height(LineHeight::Absolute(52.0.into())) .font(crate::font::semibold()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Title 2 typography preset. -pub fn title2<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn title2<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(29.0) .line_height(LineHeight::Absolute(43.0.into())) .font(crate::font::semibold()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Title 3 typography preset. -pub fn title3<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn title3<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(24.0) .line_height(LineHeight::Absolute(36.0.into())) .font(crate::font::bold()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Title 4 typography preset. -pub fn title4<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn title4<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(20.0) .line_height(LineHeight::Absolute(30.0.into())) .font(crate::font::bold()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Heading typography preset. -pub fn heading<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn heading<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(14.0) .line_height(LineHeight::Absolute(iced::Pixels(21.0))) .font(crate::font::bold()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Caption Heading typography preset. -pub fn caption_heading<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn caption_heading<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(12.0) .line_height(LineHeight::Absolute(iced::Pixels(17.0))) .font(crate::font::semibold()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Body typography preset. -pub fn body<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn body<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(14.0) .line_height(LineHeight::Absolute(21.0.into())) .font(crate::font::default()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Caption typography preset. -pub fn caption<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn caption<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(12.0) .line_height(LineHeight::Absolute(17.0.into())) .font(crate::font::default()) } - - inner(text.into()) + inner(content.into()) } /// [`Text`] widget with the Monotext typography preset. -pub fn monotext<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { +pub fn monotext<'a>(content: impl Into> + 'a) -> Text<'a> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(14.0) .line_height(LineHeight::Absolute(20.0.into())) .font(crate::font::mono()) } - - inner(text.into()) + inner(content.into()) } From 2cd43a8b89a7bd456286ea3729aef778daf33904 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:47 -0600 Subject: [PATCH 40/58] feat: add context menu wrapper for TextEditor --- src/widget/text_editor.rs | 452 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 src/widget/text_editor.rs diff --git a/src/widget/text_editor.rs b/src/widget/text_editor.rs new file mode 100644 index 00000000..9bbe2718 --- /dev/null +++ b/src/widget/text_editor.rs @@ -0,0 +1,452 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! A multi-line text editor + +pub use iced_widget::text_editor::{ + Action, Binding, Catalog, Content, Cursor, Edit, Id, KeyPress, Line, LineEnding, Motion, + Position, Selection, State, Status, Style, StyleFn, +}; + +use crate::widget::menu::MenuBarState; + +use iced_core::event::Event; +use iced_core::text::highlighter; +use iced_core::widget::Widget; +use iced_core::widget::tree::{self, Tree}; +use iced_core::{ + Clipboard, Layout, Length, Rectangle, Shell, Size, Vector, mouse, overlay, renderer, +}; + +type InnerEditor<'a, Message> = + iced_widget::TextEditor<'a, highlighter::PlainText, Message, crate::Theme, crate::Renderer>; + +pub struct TextEditor<'a, Message> { + inner: InnerEditor<'a, Message>, + has_context_menu: bool, +} + +struct EditorWrapperState { + menu_bar_state: MenuBarState, + pending_action: crate::widget::text_context_menu::PendingAction, +} + +impl<'a, Message: Clone + 'static> TextEditor<'a, Message> { + /// Creates a new [`TextEditor`] from the given [`Content`]. + pub fn new(content: &'a Content) -> Self { + Self { + inner: iced_widget::text_editor(content), + has_context_menu: true, + } + } + + /// Controls whether the right-click context menu is shown. + /// + /// The context menu is enabled by default. Pass `false` to disable it. + pub fn context_menu(mut self, enabled: bool) -> Self { + self.has_context_menu = enabled; + self + } + + pub fn id(mut self, id: impl Into) -> Self { + self.inner = self.inner.id(id); + self + } + + pub fn placeholder(mut self, placeholder: impl iced_core::text::IntoFragment<'a>) -> Self { + self.inner = self.inner.placeholder(placeholder); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.inner = self.inner.width(width); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.inner = self.inner.height(height); + self + } + + pub fn min_height(mut self, min_height: impl Into) -> Self { + self.inner = self.inner.min_height(min_height); + self + } + + pub fn max_height(mut self, max_height: impl Into) -> Self { + self.inner = self.inner.max_height(max_height); + self + } + + pub fn on_action(mut self, on_edit: impl Fn(Action) -> Message + 'a) -> Self { + self.inner = self.inner.on_action(on_edit); + self + } + + pub fn font( + mut self, + font: impl Into<::Font>, + ) -> Self { + self.inner = self.inner.font(font); + self + } + + pub fn size(mut self, size: impl Into) -> Self { + self.inner = self.inner.size(size); + self + } + + pub fn line_height(mut self, line_height: impl Into) -> Self { + self.inner = self.inner.line_height(line_height); + self + } + + pub fn padding(mut self, padding: impl Into) -> Self { + self.inner = self.inner.padding(padding); + self + } + + pub fn wrapping(mut self, wrapping: iced_core::text::Wrapping) -> Self { + self.inner = self.inner.wrapping(wrapping); + self + } + + pub fn key_binding( + mut self, + key_binding: impl Fn(KeyPress) -> Option> + 'a, + ) -> Self { + self.inner = self.inner.key_binding(key_binding); + self + } + + #[must_use] + pub fn style(mut self, style: impl Fn(&crate::Theme, Status) -> Style + 'a) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.style(style); + self + } + + fn uses_popup_context_menu(&self) -> bool { + #[cfg(feature = "wayland")] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + return true; + } + false + } +} + +/// Creates a new [`TextEditor`] from the given [`Content`]. +pub fn text_editor<'a, Message: Clone + 'static>( + content: &'a Content, +) -> TextEditor<'a, Message> { + TextEditor::new(content) +} + +fn ew<'x, Message>( + inner: &'x InnerEditor<'_, Message>, +) -> &'x dyn Widget { + inner +} + +fn ew_mut<'x, Message>( + inner: &'x mut InnerEditor<'_, Message>, +) -> &'x mut dyn Widget { + inner +} + +impl<'a, Message: Clone + 'static> Widget + for TextEditor<'a, Message> +{ + fn tag(&self) -> tree::Tag { + if self.has_context_menu { + tree::Tag::of::() + } else { + ew::(&self.inner).tag() + } + } + + fn state(&self) -> tree::State { + if self.has_context_menu { + tree::State::new(EditorWrapperState { + menu_bar_state: MenuBarState::default(), + pending_action: crate::widget::text_context_menu::pending_action(), + }) + } else { + ew::(&self.inner).state() + } + } + + fn children(&self) -> Vec { + if self.has_context_menu { + vec![Tree::new(ew::(&self.inner))] + } else { + ew::(&self.inner).children() + } + } + + fn diff(&mut self, tree: &mut Tree) { + if self.has_context_menu { + if let Some(child) = tree.children.first_mut() { + child.diff( + &mut self.inner as &mut dyn Widget, + ); + } + } else { + ew_mut::(&mut self.inner).diff(tree); + } + } + + fn size(&self) -> Size { + ew::(&self.inner).size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &crate::Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + let inner_tree = if self.has_context_menu { + &mut tree.children[0] + } else { + tree + }; + ew_mut::(&mut self.inner).layout(inner_tree, renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let inner_tree = if self.has_context_menu { + &tree.children[0] + } else { + tree + }; + ew::(&self.inner).draw( + inner_tree, renderer, theme, defaults, layout, cursor, viewport, + ); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if self.has_context_menu { + ew_mut::(&mut self.inner).update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + + use iced_core::widget::text::HasSelectableText; + if self.inner.is_focused(&tree.children[0]) + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(iced_core::touch::Event::FingerPressed { .. }) + ) + && cursor.is_over(layout.bounds()) + { + crate::widget::text_input::notify_focus_change(); + } + + #[cfg(feature = "wayland")] + if self.uses_popup_context_menu() { + if self + .inner + .context_menu_position(&tree.children[0]) + .is_some() + { + let selected_text = self.inner.selected_text(&tree.children[0]); + let has_selection = selected_text.is_some(); + let click_position = + self.inner.context_menu_position(&tree.children[0]).unwrap(); + let wrapper_state = tree.state.downcast_ref::(); + let menu_bar_state = wrapper_state.menu_bar_state.clone(); + let pending_action = wrapper_state.pending_action.clone(); + + crate::widget::text_context_menu::create_text_context_popup( + click_position, + selected_text, + self.inner.is_editable(), + has_selection, + &menu_bar_state, + &pending_action, + renderer, + viewport, + cursor, + ); + + self.inner + .set_context_menu_position(&mut tree.children[0], None); + } + + // Process deferred actions from the popup. + { + let wrapper_state = tree.state.downcast_ref::(); + let pending_action = wrapper_state.pending_action.clone(); + if let Some(action) = + crate::widget::text_context_menu::take_pending_action(&pending_action) + { + match action { + crate::widget::text_context_menu::TextCtxAction::Copy => {} + crate::widget::text_context_menu::TextCtxAction::Cut => { + self.inner.delete_selection(&mut tree.children[0]); + } + crate::widget::text_context_menu::TextCtxAction::Paste => { + let content: String = clipboard + .read(iced_core::clipboard::Kind::Standard) + .unwrap_or_default(); + self.inner.paste_text(&mut tree.children[0], &content); + } + crate::widget::text_context_menu::TextCtxAction::SelectAll => { + self.inner.select_all(&mut tree.children[0]); + } + } + } + } + + // Cleanup popup if menu closed. + let wrapper_state = tree.state.downcast_ref::(); + let menu_bar_state = wrapper_state.menu_bar_state.clone(); + crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); + } + } else { + ew_mut::(&mut self.inner).update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + let inner_tree = if self.has_context_menu { + &tree.children[0] + } else { + tree + }; + ew::(&self.inner).mouse_interaction(inner_tree, layout, cursor, viewport, renderer) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &crate::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + if self.has_context_menu { + if !self.uses_popup_context_menu() { + use iced_core::widget::text::HasSelectableText; + let inner_tree = &mut tree.children[0]; + if self.inner.context_menu_position(inner_tree).is_some() { + let menu_bar_state = tree + .state + .downcast_ref::() + .menu_bar_state + .clone(); + let inner_tree = &mut tree.children[0]; + return crate::widget::text_context_menu::context_menu_overlay( + &self.inner, + inner_tree, + None, + translation, + menu_bar_state, + ); + } + } + + ew_mut::(&mut self.inner).overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } else { + ew_mut::(&mut self.inner).overlay( + tree, + layout, + renderer, + viewport, + translation, + ) + } + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &crate::Renderer, + operation: &mut dyn iced_core::widget::Operation, + ) { + let inner_tree = if self.has_context_menu { + &mut tree.children[0] + } else { + tree + }; + ew_mut::(&mut self.inner).operate(inner_tree, layout, renderer, operation); + } + + #[cfg(feature = "a11y")] + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let inner_state = if self.has_context_menu { + &state.children[0] + } else { + state + }; + ew::(&self.inner).a11y_nodes(layout, inner_state, cursor) + } + + fn id(&self) -> Option { + ew::(&self.inner).id() + } + + fn set_id(&mut self, id: iced_core::widget::Id) { + ew_mut::(&mut self.inner).set_id(id); + } +} + +impl<'a, Message: Clone + 'static> From> for crate::Element<'a, Message> { + fn from(editor: TextEditor<'a, Message>) -> Self { + Self::new(editor) + } +} From c6cf3db29daad1aad74bbae260726301e56ccbc4 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:37 -0600 Subject: [PATCH 41/58] feat: add text context menu (overlay and wayland popup) --- src/widget/menu/menu_inner.rs | 2 +- src/widget/menu/menu_tree.rs | 2 +- src/widget/mod.rs | 7 +- src/widget/settings/item.rs | 8 +- src/widget/text_context_menu.rs | 713 ++++++++++++++++++++++++++++++++ src/widget/text_input/cursor.rs | 6 + 6 files changed, 731 insertions(+), 7 deletions(-) create mode 100644 src/widget/text_context_menu.rs diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 58721124..4ea71bc4 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -237,7 +237,7 @@ pub(super) struct MenuSlice { #[derive(Debug, Clone)] /// Menu bounds in overlay space pub struct MenuBounds { - child_positions: Vec, + pub(crate) child_positions: Vec, child_sizes: Vec, children_bounds: Rectangle, pub parent_bounds: Rectangle, diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index bd4f1e6c..cf99335c 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -194,7 +194,7 @@ pub fn menu_root<'a, Message, Renderer: renderer::Renderer>( ) -> Button<'a, Message> where Element<'a, Message, crate::Theme, Renderer>: From>, - Message: std::clone::Clone + 'a, + Message: std::clone::Clone + 'static, { widget::button::custom(widget::text(label)) .padding([4, 12]) diff --git a/src/widget/mod.rs b/src/widget/mod.rs index ab1eef31..4c33f7e3 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -89,8 +89,9 @@ pub use iced::widget::{Slider, VerticalSlider, slider, vertical_slider}; #[doc(inline)] pub use iced::widget::{Svg, svg}; +pub mod text_editor; #[doc(inline)] -pub use iced::widget::{TextEditor, text_editor}; +pub use text_editor::TextEditor; #[doc(inline)] pub use iced_core::widget::{Id, Operation, Widget}; @@ -295,6 +296,10 @@ pub mod text; #[doc(inline)] pub use text::{Text, text}; +pub mod text_context_menu; +#[doc(inline)] +pub use text_context_menu::HasSelectableText; + pub mod text_input; #[doc(inline)] pub use text_input::{ diff --git a/src/widget/settings/item.rs b/src/widget/settings/item.rs index 4eb5bb17..af4b17ab 100644 --- a/src/widget/settings/item.rs +++ b/src/widget/settings/item.rs @@ -14,12 +14,12 @@ use taffy::AlignContent; /// A settings item aligned in a row #[must_use] #[allow(clippy::module_name_repetitions)] -pub fn item<'a, Message: 'static>( +pub fn item<'a, Message: Clone + 'static>( title: impl Into> + 'a, widget: impl Into> + 'a, ) -> Row<'a, Message, Theme> { #[inline(never)] - fn inner<'a, Message: 'static>( + fn inner<'a, Message: Clone + 'static>( title: Cow<'a, str>, widget: Element<'a, Message>, ) -> Row<'a, Message, Theme> { @@ -45,12 +45,12 @@ pub fn item_row(children: Vec>) -> Row /// A settings item aligned in a flex row #[allow(clippy::module_name_repetitions)] -pub fn flex_item<'a, Message: 'static>( +pub fn flex_item<'a, Message: Clone + 'static>( title: impl Into> + 'a, widget: impl Into> + 'a, ) -> FlexRow<'a, Message> { #[inline(never)] - fn inner<'a, Message: 'static>( + fn inner<'a, Message: Clone + 'static>( title: Cow<'a, str>, widget: Element<'a, Message>, ) -> FlexRow<'a, Message> { diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs new file mode 100644 index 00000000..6bf530c9 --- /dev/null +++ b/src/widget/text_context_menu.rs @@ -0,0 +1,713 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Right-click context menu for widgets with selectable text. +//! +//! Use [`context_menu_overlay`] from your widget's `overlay()` method +//! with any widget that implements +//! [`HasSelectableText`](iced_core::widget::text::HasSelectableText) +//! to get a context menu with Copy, Select All, and optionally Cut/Paste. +//! +//! Internally uses libcosmic's [`Menu`](crate::widget::menu) system for +//! proper rendering, hover effects, and positioning. +//! +//! On Wayland, [`create_text_context_popup`] can be used instead to show +//! the context menu as a native popup surface. + +pub use iced_core::widget::text::HasSelectableText; + +use crate::widget::RcElementWrapper; +use crate::widget::menu::{ + self, CloseCondition, ItemHeight, ItemWidth, Menu, MenuBarState, PathHighlight, menu_roots_diff, +}; +use crate::{theme, widget}; + +use iced_core::layout::Limits; +use iced_core::widget::Tree; +use iced_core::{ + Clipboard, Layout, Point, Rectangle, Shell, Size, Vector, clipboard, mouse, overlay, renderer, +}; +use iced_widget::core::event; +use std::borrow::Cow; +use std::mem; +use std::sync::{Arc, Mutex}; + +/// Shared state for communicating deferred context menu actions +/// from a Wayland popup back to the owning text widget. +pub(crate) type PendingAction = Arc>>; + +/// Creates a new [`PendingAction`] for use with popup-based context menus. +pub(crate) fn pending_action() -> PendingAction { + Arc::new(Mutex::new(None)) +} + +/// Takes a pending action if one was set by a popup menu, and returns it. +pub(crate) fn take_pending_action(pending: &PendingAction) -> Option { + pending.lock().ok().and_then(|mut guard| guard.take()) +} + +use std::cell::Cell; +use std::collections::HashMap; +use std::sync::LazyLock; + +thread_local! { + static CURRENT_WINDOW_ID: Cell = const { Cell::new(iced_core::window::Id::NONE) }; +} + +/// Data needed to construct a popup view. Stored in a global registry +/// so `Cosmic::view()` can build the `Element` with the correct message type. +#[cfg(feature = "wayland")] +struct PopupViewData { + menu: Menu<'static, TextCtxAction>, + selected_text: Option, + pending_action: PendingAction, +} + +#[cfg(feature = "wayland")] +static POPUP_VIEW_REGISTRY: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(crate) fn set_current_window_id(id: iced_core::window::Id) { + CURRENT_WINDOW_ID.set(id); +} + +pub(crate) fn current_window_id() -> iced_core::window::Id { + CURRENT_WINDOW_ID.get() +} + +/// Returns the popup view `Element` for a given popup ID, if one exists. +/// Called by `Cosmic::view()` where `Message` is known. +#[cfg(feature = "wayland")] +pub(crate) fn popup_view_for_id( + id: iced_core::window::Id, +) -> Option>> { + let registry = POPUP_VIEW_REGISTRY.lock().ok()?; + let data = registry.get(&id)?; + let popup_widget: TextContextMenuPopup = TextContextMenuPopup { + menu: data.menu.clone(), + selected_text: data.selected_text.clone(), + pending_action: data.pending_action.clone(), + _phantom: std::marker::PhantomData, + }; + drop(registry); + Some( + crate::Element::from( + crate::widget::container(popup_widget).center(iced_core::Length::Fill), + ) + .map(crate::action::app), + ) +} + +/// Sends a wayland popup action directly to the SCTK event loop, +/// bypassing the iced Task system. +#[cfg(feature = "wayland")] +fn send_popup_direct(settings: iced_runtime::platform_specific::wayland::popup::SctkPopupSettings) { + iced_winit::send_wayland_action_direct( + iced_runtime::platform_specific::wayland::Action::Popup( + iced_runtime::platform_specific::wayland::popup::Action::Popup { popup: settings }, + ), + ); +} + +/// Sends a destroy popup action directly to the SCTK event loop. +#[cfg(feature = "wayland")] +fn send_destroy_popup_direct(id: iced_core::window::Id) { + iced_winit::send_wayland_action_direct( + iced_runtime::platform_specific::wayland::Action::Popup( + iced_runtime::platform_specific::wayland::popup::Action::Destroy { id }, + ), + ); +} + +/// Creates a context menu overlay for any widget implementing +/// [`HasSelectableText`]. +/// +/// Call this from your widget's `overlay()` method. Pass `on_input` for +/// editable widgets so Cut and Paste can publish text-change messages. +/// +/// The `menu_bar_state` parameter must be a persistent [`MenuBarState`] +/// stored in the widget's tree state. +pub(crate) fn context_menu_overlay<'a, W, Message>( + widget: &'a W, + tree: &'a mut Tree, + on_input: Option<&'a dyn Fn(String) -> Message>, + translation: Vector, + menu_bar_state: MenuBarState, +) -> Option> +where + W: HasSelectableText + 'a, + Message: Clone + 'static, +{ + let click_position = widget.context_menu_position(tree)?; + 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()); + menu_roots.iter_mut().for_each(menu::Tree::set_index); + + let item_count = menu_roots[0].children.len(); + menu_bar_state.inner.with_data_mut(|state| { + let stale = state + .menu_states + .first() + .is_some_and(|ms| ms.menu_bounds.child_positions.len() != item_count); + if !state.open || stale { + state.menu_states.clear(); + state.active_root.clear(); + state.open = true; + } + menu_roots_diff(&mut menu_roots, &mut state.tree); + }); + + let offscreen = Rectangle::new(Point::new(-10000.0, -10000.0), Size::ZERO); + + let menu = Menu { + tree: menu_bar_state.clone(), + menu_roots: Cow::Owned(menu_roots), + bounds_expand: 16, + menu_overlays_parent: true, + close_condition: CloseCondition { + leave: false, + click_outside: true, + click_inside: true, + }, + item_width: ItemWidth::Uniform(240), + item_height: ItemHeight::Dynamic(40), + bar_bounds: offscreen, + main_offset: -240, + cross_offset: 0, + root_bounds_list: vec![offscreen], + path_highlight: Some(PathHighlight::MenuActive), + style: Cow::Owned(theme::menu_bar::MenuBarStyle::Default), + position: Point::new(translation.x, translation.y), + is_overlay: true, + window_id: iced::window::Id::NONE, + depth: 0, + on_surface_action: None, + }; + + Some(overlay::Element::new(Box::new(TextMenuOverlay { + menu, + widget, + tree, + on_input, + click_position, + }))) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum TextCtxAction { + Copy, + Cut, + Paste, + SelectAll, +} + +fn build_menu_roots(is_editable: bool, has_selection: bool) -> Vec> { + let mut items = Vec::with_capacity(4); + + 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), + ))); + } + 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(menu::Tree::from(crate::Element::from( + menu::menu_button(vec![widget::text("Select All").into()]) + .on_press(TextCtxAction::SelectAll), + ))); + + vec![menu::Tree::with_children( + RcElementWrapper::new(crate::Element::from(widget::Row::new())), + items, + )] +} + +struct TextMenuOverlay<'a, W, Message: Clone + 'static> { + menu: Menu<'a, TextCtxAction>, + widget: &'a W, + tree: &'a mut Tree, + on_input: Option<&'a dyn Fn(String) -> Message>, + click_position: Point, +} + +impl overlay::Overlay + for TextMenuOverlay<'_, W, Message> +where + W: HasSelectableText, + Message: Clone + 'static, +{ + fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> iced_core::layout::Node { + self.menu.layout( + renderer, + Limits::NONE + .min_width(bounds.width) + .max_width(bounds.width) + .min_height(bounds.height) + .max_height(bounds.height), + ) + } + + fn draw( + &self, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + self.menu.draw(renderer, theme, style, layout, cursor); + } + + fn update( + &mut self, + event: &event::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + // Pre-initialize the menu if it hasn't been yet. + let needs_init = self + .menu + .tree + .inner + .with_data(|state| state.open && state.menu_states.is_empty()); + + if needs_init { + let viewport = layout.bounds(); + let viewport_size = viewport.size(); + let overlay_offset = Point::ORIGIN - viewport.position(); + let overlay_cursor = cursor.position().unwrap_or_default() - overlay_offset; + + let init_bounds = Rectangle { + x: self.click_position.x, + y: self.click_position.y, + width: 240.0, + height: 240.0, + }; + + // Temporarily set real bounds so init_root_menu can find + // root_bounds_list entries that contain the cursor. + let main_offset = self.menu.main_offset as f32; + let saved_bounds = mem::replace(&mut self.menu.bar_bounds, init_bounds); + let saved_roots = mem::replace(&mut self.menu.root_bounds_list, vec![init_bounds]); + + let mut init_messages: Vec = Vec::new(); + let mut init_shell = Shell::new(&mut init_messages); + menu::init_root_menu( + &mut self.menu, + renderer, + &mut init_shell, + overlay_cursor, + viewport_size, + overlay_offset, + init_bounds, + main_offset, + ); + + // Restore offscreen bounds + self.menu.bar_bounds = saved_bounds; + self.menu.root_bounds_list = saved_roots; + + if init_shell.is_layout_invalid() { + shell.invalidate_layout(); + } + } + + // Right-button releases are meaningless for menu interaction + if matches!( + event, + event::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) + ) { + return; + } + + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + self.menu + .update(event, layout, cursor, renderer, clipboard, &mut local_shell); + + if local_shell.is_event_captured() { + shell.capture_event(); + } + shell.request_redraw_at(local_shell.redraw_request()); + if local_shell.is_layout_invalid() { + shell.invalidate_layout(); + } + + for action in local_messages { + match action { + TextCtxAction::Copy => { + self.widget.copy_to_clipboard(self.tree, clipboard); + } + TextCtxAction::Cut => { + self.widget.copy_to_clipboard(self.tree, clipboard); + if let Some(contents) = self.widget.delete_selection(self.tree) { + if let Some(on_input) = self.on_input { + shell.publish((on_input)(contents)); + } + } + } + TextCtxAction::Paste => { + let content: String = clipboard + .read(clipboard::Kind::Standard) + .unwrap_or_default(); + if let Some(contents) = self.widget.paste_text(self.tree, &content) { + if let Some(on_input) = self.on_input { + shell.publish((on_input)(contents)); + } + } + } + TextCtxAction::SelectAll => { + self.widget.select_all(self.tree); + } + } + self.widget.set_context_menu_position(self.tree, None); + } + + let is_open = self.menu.tree.inner.with_data(|state| state.open); + if !is_open { + self.widget.set_context_menu_position(self.tree, None); + } + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &crate::Renderer, + ) -> mouse::Interaction { + if cursor.is_over(layout.bounds()) { + mouse::Interaction::Idle + } else { + mouse::Interaction::None + } + } +} + +/// Creates a Wayland popup surface containing the text context menu. +/// +/// Sends the popup action directly to the SCTK event loop and registers +/// the view data so `Cosmic::view()` can render it. No app-level wiring +/// is needed +#[cfg(feature = "wayland")] +pub(crate) fn create_text_context_popup( + click_position: Point, + selected_text: Option, + is_editable: bool, + has_selection: bool, + menu_bar_state: &MenuBarState, + pending_action: &PendingAction, + renderer: &crate::Renderer, + viewport: &Rectangle, + cursor: mouse::Cursor, +) { + use iced_runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner}; + + let window_id = current_window_id(); + if window_id == iced_core::window::Id::NONE { + return; + } + + let mut menu_roots = build_menu_roots(is_editable, has_selection); + menu_roots.iter_mut().for_each(menu::Tree::set_index); + + let id = menu_bar_state.inner.with_data_mut(|state| { + state.menu_states.clear(); + state.active_root.clear(); + menu_roots_diff(&mut menu_roots, &mut state.tree); + if let Some(id) = state.popup_id.get(&window_id).copied() { + send_destroy_popup_direct(id); + state.view_cursor = cursor; + id + } else { + state.open = true; + state.view_cursor = cursor; + iced::window::Id::unique() + } + }); + + let bounds = Rectangle { + x: click_position.x, + y: click_position.y, + width: 240.0, + height: 240.0, + }; + + let mut popup_menu: Menu<'static, TextCtxAction> = Menu { + tree: menu_bar_state.clone(), + menu_roots: Cow::Owned(menu_roots), + bounds_expand: 16, + menu_overlays_parent: true, + close_condition: CloseCondition { + leave: false, + click_outside: true, + click_inside: true, + }, + item_width: ItemWidth::Uniform(240), + item_height: ItemHeight::Dynamic(40), + bar_bounds: bounds, + main_offset: -(bounds.height as i32), + cross_offset: 0, + root_bounds_list: vec![bounds], + path_highlight: Some(PathHighlight::MenuActive), + style: Cow::Owned(theme::menu_bar::MenuBarStyle::Default), + position: Point::new(0., 0.), + is_overlay: false, + window_id: id, + depth: 0, + on_surface_action: None, + }; + + { + let mut init_messages: Vec = Vec::new(); + let mut init_shell = Shell::new(&mut init_messages); + menu::init_root_menu( + &mut popup_menu, + renderer, + &mut init_shell, + cursor.position().unwrap_or_default(), + viewport.size(), + Vector::new(0., 0.), + bounds, + -(bounds.height), + ); + } + + let anchor_rect = menu_bar_state.inner.with_data_mut(|state| { + state.popup_id.insert(window_id, id); + let pos = cursor.position().unwrap_or_default(); + iced::Rectangle { + x: pos.x as i32, + y: pos.y as i32, + width: 1, + height: 1, + } + }); + + let menu_node = popup_menu.layout(renderer, iced::Limits::NONE.min_width(1.).min_height(1.)); + let popup_size = menu_node.size(); + + let positioner = SctkPositioner { + size: Some(( + popup_size.width.ceil() as u32 + 2, + popup_size.height.ceil() as u32 + 2, + )), + anchor_rect, + anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::None, + gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + reactive: true, + ..Default::default() + }; + + // Register view data so Cosmic::view() can render this popup. + if let Ok(mut registry) = POPUP_VIEW_REGISTRY.lock() { + registry.insert( + id, + PopupViewData { + menu: popup_menu, + selected_text, + pending_action: pending_action.clone(), + }, + ); + } + + // Send the popup creation directly to the SCTK event loop. + send_popup_direct(SctkPopupSettings { + parent: window_id, + id, + positioner, + parent_size: None, + grab: true, + close_with_children: false, + input_zone: None, + }); +} + +/// Checks whether a popup menu has closed and destroys it if needed. +/// Call this from the text widget's `update()` on each frame. +#[cfg(feature = "wayland")] +pub(crate) fn cleanup_text_popup(menu_bar_state: &MenuBarState) { + let window_id = current_window_id(); + let should_destroy = menu_bar_state + .inner + .with_data(|state| !state.open && state.popup_id.contains_key(&window_id)); + if should_destroy { + menu_bar_state.inner.with_data_mut(|state| { + if let Some(popup_id) = state.popup_id.remove(&window_id) { + send_destroy_popup_direct(popup_id); + } + state.reset(); + }); + } +} + +/// Widget that wraps [`Menu`] inside a Wayland popup and intercepts +/// [`TextCtxAction`] messages for clipboard operations. +#[derive(Clone)] +struct TextContextMenuPopup { + menu: Menu<'static, TextCtxAction>, + selected_text: Option, + pending_action: PendingAction, + _phantom: std::marker::PhantomData, +} + +impl iced_core::widget::Widget + for TextContextMenuPopup +{ + fn size(&self) -> Size { + use iced_core::widget::Widget; + Widget::::size(&self.menu) + } + + fn tag(&self) -> iced_core::widget::tree::Tag { + use iced_core::widget::Widget; + Widget::::tag(&self.menu) + } + + fn state(&self) -> iced_core::widget::tree::State { + use iced_core::widget::Widget; + Widget::::state(&self.menu) + } + + fn children(&self) -> Vec { + use iced_core::widget::Widget; + Widget::::children(&self.menu) + } + + fn diff(&mut self, tree: &mut iced_core::widget::Tree) { + use iced_core::widget::Widget; + Widget::::diff(&mut self.menu, tree); + } + + fn layout( + &mut self, + tree: &mut iced_core::widget::Tree, + renderer: &crate::Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + use iced_core::widget::Widget; + Widget::::layout( + &mut self.menu, + tree, + renderer, + limits, + ) + } + + fn draw( + &self, + tree: &iced_core::widget::Tree, + renderer: &mut crate::Renderer, + theme: &crate::Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + use iced_core::widget::Widget; + Widget::::draw( + &self.menu, tree, renderer, theme, style, layout, cursor, viewport, + ); + } + + fn update( + &mut self, + tree: &mut iced_core::widget::Tree, + event: &event::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &crate::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let mut local_messages: Vec = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + { + use iced_core::widget::Widget; + Widget::::update( + &mut self.menu, + tree, + event, + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + viewport, + ); + } + + if local_shell.is_event_captured() { + shell.capture_event(); + } + shell.request_redraw_at(local_shell.redraw_request()); + if local_shell.is_layout_invalid() { + shell.invalidate_layout(); + } + + for action in local_messages { + match action { + TextCtxAction::Copy => { + if let Some(ref text) = self.selected_text { + clipboard.write(clipboard::Kind::Standard, text.clone()); + } + } + TextCtxAction::Cut => { + if let Some(ref text) = self.selected_text { + clipboard.write(clipboard::Kind::Standard, text.clone()); + } + if let Ok(mut guard) = self.pending_action.lock() { + *guard = Some(TextCtxAction::Cut); + } + } + TextCtxAction::Paste => { + if let Ok(mut guard) = self.pending_action.lock() { + *guard = Some(TextCtxAction::Paste); + } + } + TextCtxAction::SelectAll => { + if let Ok(mut guard) = self.pending_action.lock() { + *guard = Some(TextCtxAction::SelectAll); + } + } + } + } + } + + fn mouse_interaction( + &self, + tree: &iced_core::widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &crate::Renderer, + ) -> mouse::Interaction { + use iced_core::widget::Widget; + Widget::::mouse_interaction( + &self.menu, tree, layout, cursor, viewport, renderer, + ) + } +} + +impl From> + for crate::Element<'static, Message> +{ + fn from(popup: TextContextMenuPopup) -> Self { + Self::new(popup) + } +} diff --git a/src/widget/text_input/cursor.rs b/src/widget/text_input/cursor.rs index 3ffb535c..d83d99b1 100644 --- a/src/widget/text_input/cursor.rs +++ b/src/widget/text_input/cursor.rs @@ -76,6 +76,12 @@ impl Cursor { self.state = State::Index(position); } + pub(crate) fn clear_selection(&mut self) { + if let State::Selection { end, .. } = self.state { + self.state = State::Index(end); + } + } + #[inline] pub(crate) fn move_right(&mut self, value: &Value) { self.move_right_by_amount(value, 1); From d751855c74e81b84646b2dedf0bb09393c71b7b2 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:50 -0600 Subject: [PATCH 42/58] feat: add Wayland popup context menu to TextInput --- src/widget/text_context_menu.rs | 10 - src/widget/text_input/input.rs | 380 +++++++++++++++++++++------ src/widget/wayland/tooltip/widget.rs | 2 - 3 files changed, 301 insertions(+), 91 deletions(-) diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index 6bf530c9..c8505c1a 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -109,16 +109,6 @@ fn send_popup_direct(settings: iced_runtime::platform_specific::wayland::popup:: ); } -/// Sends a destroy popup action directly to the SCTK event loop. -#[cfg(feature = "wayland")] -fn send_destroy_popup_direct(id: iced_core::window::Id) { - iced_winit::send_wayland_action_direct( - iced_runtime::platform_specific::wayland::Action::Popup( - iced_runtime::platform_specific::wayland::popup::Action::Destroy { id }, - ), - ); -} - /// Creates a context menu overlay for any widget implementing /// [`HasSelectableText`]. /// diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index d382a5a8..c46f895c 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -43,6 +43,12 @@ thread_local! { static LAST_FOCUS_UPDATE: LazyCell> = LazyCell::new(|| Cell::new(Instant::now())); } +/// Notify all text inputs that a different widget has taken focus. +/// This causes any focused text input to unfocus on its next layout. +pub fn notify_focus_change() { + LAST_FOCUS_UPDATE.with(|x| x.set(Instant::now())); +} + /// Creates a new [`TextInput`]. /// /// [`TextInput`]: widget::TextInput @@ -577,6 +583,17 @@ where self.drag_threshold = drag_threshold; self } + + fn uses_popup_context_menu(&self) -> bool { + #[cfg(feature = "wayland")] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + return true; + } + false + } } impl Widget for TextInput<'_, Message> @@ -843,6 +860,24 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { + if !self.uses_popup_context_menu() { + let has_context_menu = tree + .state + .downcast_ref::() + .context_menu_position + .is_some(); + if has_context_menu { + let menu_bar_state = tree.state.downcast_ref::().menu_bar_state.clone(); + return crate::widget::text_context_menu::context_menu_overlay( + self, + tree, + self.on_input.as_deref(), + translation, + menu_bar_state, + ); + } + } + let mut layout_ = Vec::with_capacity(2); if self.leading_icon.is_some() { let mut children = self.text_layout(layout).children(); @@ -857,7 +892,7 @@ where } layout_.push(children.next().unwrap()); }; - let children = self + let children: Vec> = self .leading_icon .iter_mut() .chain(self.trailing_icon.iter_mut()) @@ -868,7 +903,7 @@ where .as_widget_mut() .overlay(state, layout, renderer, viewport, translation) }) - .collect::>(); + .collect(); (!children.is_empty()).then(|| Group::with_children(children).overlay()) } @@ -936,6 +971,23 @@ where } } + // Unfocus on any click outside widget bounds. + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) && cursor_position.position_over(layout.bounds()).is_none() + { + let state = tree.state.downcast_mut::(); + state.is_focused = None; + state.context_menu_position = None; + state.dragging_state = None; + if let Some(on_unfocus) = self.on_unfocus.as_ref() { + shell.publish(on_unfocus.clone()); + } + return; + } + let state = tree.state.downcast_mut::(); if let Some(on_unfocus) = self.on_unfocus.as_ref() { @@ -977,6 +1029,77 @@ where self.always_active, ); + // On Wayland: if right-click just set context_menu_position, create a popup instead. + #[cfg(feature = "wayland")] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + let state = tree.state.downcast_ref::(); + if state.context_menu_position.is_some() { + let selected_text = state + .cursor() + .selection(&state.tracked_value) + .map(|(start, end)| state.tracked_value.select(start, end).to_string()); + let has_selection = selected_text.is_some(); + let click_position = state.context_menu_position.unwrap(); + let menu_bar_state = state.menu_bar_state.clone(); + let pending_action = state.pending_action.clone(); + + crate::widget::text_context_menu::create_text_context_popup( + click_position, + selected_text, + true, + has_selection, + &menu_bar_state, + &pending_action, + renderer, + viewport, + cursor_position, + ); + + let state = tree.state.downcast_mut::(); + state.context_menu_position = None; + } + + // Process deferred actions from the popup. + let state = tree.state.downcast_ref::(); + let pending_action = state.pending_action.clone(); + if let Some(action) = + crate::widget::text_context_menu::take_pending_action(&pending_action) + { + let state = tree.state.downcast_mut::(); + match action { + crate::widget::text_context_menu::TextCtxAction::Copy => {} + crate::widget::text_context_menu::TextCtxAction::Cut => { + let contents = state.delete_selection(); + if let Some(on_input) = self.on_input.as_deref() { + shell.publish((on_input)(contents)); + } + } + crate::widget::text_context_menu::TextCtxAction::Paste => { + let content: String = clipboard + .read(iced_core::clipboard::Kind::Standard) + .unwrap_or_default(); + let filtered: String = + content.chars().filter(|c| !c.is_control()).collect(); + let contents = state.paste_text(&filtered); + if let Some(on_input) = self.on_input.as_deref() { + shell.publish((on_input)(contents)); + } + } + crate::widget::text_context_menu::TextCtxAction::SelectAll => { + state.select_all(); + } + } + } + + // Cleanup popup if menu closed. + let state = tree.state.downcast_ref::(); + let menu_bar_state = state.menu_bar_state.clone(); + crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); + } + let state = tree.state.downcast_mut::(); let value = if self.is_secure { self.value.secure() @@ -1461,11 +1584,44 @@ pub fn update<'a, Message: Clone + 'static>( #[cold] fn cold() {} + let state = state(); + + // Any click outside clears focus and selection. + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(touch::Event::FingerPressed { .. }) + ) && cursor.position_over(layout.bounds()).is_none() + { + state.is_focused = None; + state.context_menu_position = None; + state.dragging_state = None; + if let Some(on_unfocus) = on_unfocus { + shell.publish(on_unfocus.clone()); + } + return; + } + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(pos) = cursor.position_over(layout.bounds()) { + if !state.is_focused() { + state.focus(); + } + state.context_menu_position = Some(pos); + shell.capture_event(); + return; + } + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { cold(); - let state = state(); + + if state.context_menu_position.take().is_some() { + shell.capture_event(); + return; + } let click_position = if on_input.is_some() || manage_value { cursor.position_over(layout.bounds()) @@ -1694,7 +1850,6 @@ pub fn update<'a, Message: Clone + 'static>( Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { cold(); - let state = state(); #[cfg(wayland_platform)] if matches!(state.dragging_state, Some(DraggingState::PrepareDnd(_))) { // clear selection and place cursor at click position @@ -1722,8 +1877,6 @@ pub fn update<'a, Message: Clone + 'static>( } Event::Mouse(mouse::Event::CursorMoved { position }) | Event::Touch(touch::Event::FingerMoved { position, .. }) => { - let state = state(); - if matches!(state.dragging_state, Some(DraggingState::Selection)) { let target = { let text_bounds = text_layout.bounds(); @@ -1812,7 +1965,6 @@ pub fn update<'a, Message: Clone + 'static>( modifiers, .. }) => { - let state = state(); state.keyboard_modifiers = *modifiers; if let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) { @@ -2107,8 +2259,6 @@ pub fn update<'a, Message: Clone + 'static>( } } Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => { - let state = state(); - if state.is_focused() { match key { keyboard::Key::Character(c) if "v" == c => { @@ -2130,73 +2280,65 @@ pub fn update<'a, Message: Clone + 'static>( } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { - let state = state(); - state.keyboard_modifiers = *modifiers; } - Event::InputMethod(event) => { - let state = state(); - - match event { - input_method::Event::Opened | input_method::Event::Closed => { - state.preedit = matches!(event, input_method::Event::Opened) - .then(input_method::Preedit::new); - shell.capture_event(); - return; - } - input_method::Event::Preedit(content, selection) => { - if state.is_focused() { - state.preedit = Some(input_method::Preedit { - content: content.to_owned(), - selection: selection.clone(), - text_size: Some(size.into()), - }); - shell.capture_event(); - return; - } - } - input_method::Event::Commit(text) => { - let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) else { - return; - }; - let Some(on_input) = on_input else { - return; - }; - if state.is_read_only { - return; - } - - focus.updated_at = Instant::now(); - LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); - - let mut editor = Editor::new(unsecured_value, &mut state.cursor); - editor.paste(Value::new(&text)); - - let contents = editor.contents(); - let unsecured_value = Value::new(&contents); - let message = if let Some(paste) = &on_paste { - (paste)(contents) - } else { - (on_input)(contents) - }; - shell.publish(message); - - state.is_pasting = None; - let value = if is_secure { - unsecured_value.secure() - } else { - unsecured_value - }; - - update_cache(state, &value); + Event::InputMethod(event) => match event { + input_method::Event::Opened | input_method::Event::Closed => { + state.preedit = + matches!(event, input_method::Event::Opened).then(input_method::Preedit::new); + shell.capture_event(); + return; + } + input_method::Event::Preedit(content, selection) => { + if state.is_focused() { + state.preedit = Some(input_method::Preedit { + content: content.to_owned(), + selection: selection.clone(), + text_size: Some(size.into()), + }); shell.capture_event(); return; } } - } - Event::Window(window::Event::RedrawRequested(now)) => { - let state = state(); + input_method::Event::Commit(text) => { + let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) else { + return; + }; + let Some(on_input) = on_input else { + return; + }; + if state.is_read_only { + return; + } + focus.updated_at = Instant::now(); + LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at)); + + let mut editor = Editor::new(unsecured_value, &mut state.cursor); + editor.paste(Value::new(&text)); + + let contents = editor.contents(); + let unsecured_value = Value::new(&contents); + let message = if let Some(paste) = &on_paste { + (paste)(contents) + } else { + (on_input)(contents) + }; + shell.publish(message); + + state.is_pasting = None; + let value = if is_secure { + unsecured_value.secure() + } else { + unsecured_value + }; + + update_cache(state, &value); + shell.capture_event(); + return; + } + }, + Event::Window(window::Event::RedrawRequested(now)) => { if let Some(focus) = state.is_focused.as_mut().filter(|f| f.focused) { focus.now = *now; @@ -2215,7 +2357,6 @@ pub fn update<'a, Message: Clone + 'static>( #[cfg(wayland_platform)] Event::Dnd(DndEvent::Source(SourceEvent::Finished | SourceEvent::Cancelled)) => { cold(); - let state = state(); if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) { // TODO: restore value in text input state.dragging_state = None; @@ -2234,7 +2375,6 @@ pub fn update<'a, Message: Clone + 'static>( }, )) if *rectangle == Some(dnd_id) => { cold(); - let state = state(); let is_clicked = text_layout.bounds().contains(Point { x: *x as f32, y: *y as f32, @@ -2277,8 +2417,6 @@ pub fn update<'a, Message: Clone + 'static>( Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Motion { x, y })) if *rectangle == Some(dnd_id) => { - let state = state(); - let target = { let text_bounds = text_layout.bounds(); @@ -2304,7 +2442,6 @@ pub fn update<'a, Message: Clone + 'static>( #[cfg(wayland_platform)] Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Drop)) if *rectangle == Some(dnd_id) => { cold(); - let state = state(); if let DndOfferState::HandlingOffer(mime_types, _action) = state.dnd_offer.clone() { let Some(mime_type) = SUPPORTED_TEXT_MIME_TYPES .iter() @@ -2327,7 +2464,6 @@ pub fn update<'a, Message: Clone + 'static>( OfferEvent::Leave | OfferEvent::LeaveDestination, )) => { cold(); - let state = state(); // ASHLEY TODO we should be able to reset but for now we don't if we are handling a // drop match state.dnd_offer { @@ -2344,7 +2480,6 @@ pub fn update<'a, Message: Clone + 'static>( if *rectangle == Some(dnd_id) => { cold(); - let state = state(); if matches!(&state.dnd_offer, DndOfferState::Dropped) { state.dnd_offer = DndOfferState::None; if !SUPPORTED_TEXT_MIME_TYPES.contains(&mime_type.as_str()) || data.is_empty() { @@ -2917,7 +3052,7 @@ pub(crate) enum DndOfferState { pub(crate) struct DndOfferState; /// The state of a [`TextInput`]. -#[derive(Debug, Default, Clone)] +#[derive(Default, Clone)] #[must_use] pub struct State { pub tracked_value: Value, @@ -2940,6 +3075,19 @@ pub struct State { preedit: Option, keyboard_modifiers: keyboard::Modifiers, scroll_offset: f32, + context_menu_position: Option, + pub(crate) menu_bar_state: crate::widget::menu::MenuBarState, + pub(crate) pending_action: crate::widget::text_context_menu::PendingAction, +} + +impl std::fmt::Debug for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("State") + .field("is_secure", &self.is_secure) + .field("is_read_only", &self.is_read_only) + .field("dirty", &self.dirty) + .finish_non_exhaustive() + } } #[derive(Debug, Clone, Copy)] @@ -2984,7 +3132,7 @@ impl State { cursor::State::Selection { start, end } => { let left = start.min(end); let right = end.max(start); - Some(text[left..right].to_string()) + Some(value.select(left, right).to_string()) } } } @@ -3022,6 +3170,9 @@ impl State { keyboard_modifiers: keyboard::Modifiers::default(), scroll_offset: 0.0, dirty: false, + context_menu_position: None, + menu_bar_state: crate::widget::menu::MenuBarState::default(), + pending_action: crate::widget::text_context_menu::pending_action(), } } @@ -3066,7 +3217,7 @@ impl State { /// Unfocuses the [`TextInput`]. #[cold] pub(super) fn unfocus(&mut self) { - self.move_cursor_to_front(); + self.cursor.clear_selection(); self.last_click = None; self.is_focused = self.is_focused.map(|mut f| { f.focused = false; @@ -3108,6 +3259,33 @@ impl State { self.cursor.select_range(start, end); } + /// Returns the context menu position, if a context menu should be shown. + pub fn context_menu_position(&self) -> Option { + self.context_menu_position + } + + /// Sets or clears the context menu position. + pub fn set_context_menu_position(&mut self, pos: Option) { + self.context_menu_position = pos; + } + + /// Deletes the current selection and returns the new text content. + pub fn delete_selection(&mut self) -> String { + let mut editor = super::editor::Editor::new(&mut self.tracked_value, &mut self.cursor); + editor.delete(); + editor.contents() + } + + /// Pastes text at the current cursor position and returns the new text content. + pub fn paste_text(&mut self, text: &str) -> String { + let paste_value = super::value::Value::new(text); + let mut editor = super::editor::Editor::new(&mut self.tracked_value, &mut self.cursor); + editor.paste(paste_value); + let contents = editor.contents(); + self.tracked_value = super::value::Value::new(&contents); + contents + } + pub(super) fn setting_selection(&mut self, value: &Value, bounds: Rectangle, target: f32) { let (position, affinity) = find_cursor_position(bounds, value, self, target) .unwrap_or((0, text::Affinity::Before)); @@ -3335,3 +3513,47 @@ fn effective_alignment(paragraph: &impl text::Paragraph) -> alignment::Horizonta alignment::Horizontal::Left } } + +use iced_core::widget::tree::Tree as WidgetTree; + +impl iced_core::widget::text::HasSelectableText + for TextInput<'_, Message> +{ + fn selected_text(&self, tree: &WidgetTree) -> Option { + let state = tree.state.downcast_ref::(); + let (start, end) = state.cursor().selection(&state.tracked_value)?; + Some(state.tracked_value.select(start, end).to_string()) + } + + fn select_all(&self, tree: &mut WidgetTree) { + let state = tree.state.downcast_mut::(); + state.select_all(); + } + + fn is_editable(&self) -> bool { + true + } + + fn is_focused(&self, tree: &WidgetTree) -> bool { + tree.state.downcast_ref::().is_focused() + } + + fn context_menu_position(&self, tree: &WidgetTree) -> Option { + tree.state.downcast_ref::().context_menu_position + } + + fn set_context_menu_position(&self, tree: &mut WidgetTree, pos: Option) { + tree.state.downcast_mut::().context_menu_position = pos; + } + + fn delete_selection(&self, tree: &mut WidgetTree) -> Option { + let state = tree.state.downcast_mut::(); + Some(state.delete_selection()) + } + + fn paste_text(&self, tree: &mut WidgetTree, text: &str) -> Option { + let filtered: String = text.chars().filter(|c| !c.is_control()).collect(); + let state = tree.state.downcast_mut::(); + Some(state.paste_text(&filtered)) + } +} diff --git a/src/widget/wayland/tooltip/widget.rs b/src/widget/wayland/tooltip/widget.rs index a5e4520a..b0287eed 100644 --- a/src/widget/wayland/tooltip/widget.rs +++ b/src/widget/wayland/tooltip/widget.rs @@ -301,8 +301,6 @@ impl<'a, Message: 'static + Clone, TopLevelMessage: 'static + Clone> } let content_layout = layout.children().next().unwrap(); - let state = tree.state.downcast_ref::(); - let styling = theme.style(&self.style); let icon_color = styling.icon_color.unwrap_or(renderer_style.icon_color); From a70feab2bff23d9a6066fa6b4977a028cc5b4b02 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 19 May 2026 19:36:54 -0600 Subject: [PATCH 43/58] feat: integrate text context menu popups into Cosmic framework view system --- src/app/cosmic.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index d31b6088..cc689223 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -513,7 +513,7 @@ where &mut self, message: crate::Action, ) -> iced::Task> { - let message = match message { + let mut task = match message { crate::Action::App(message) => self.app.update(message), crate::Action::Cosmic(message) => self.cosmic_update(message), crate::Action::None => iced::Task::none(), @@ -533,7 +533,7 @@ where #[cfg(all(target_env = "gnu", not(target_os = "windows")))] crate::malloc::trim(0); - message + task } #[cfg(not(feature = "multi-window"))] @@ -712,9 +712,15 @@ where #[cfg(feature = "multi-window")] pub fn view(&self, id: window::Id) -> Element<'_, crate::Action> { + #[cfg(all(feature = "wayland", target_os = "linux"))] if let Some((_, _, _, Some(v))) = self.surface_views.get(&id) { return v(&self.app); } + #[cfg(feature = "wayland")] + if let Some(element) = crate::widget::text_context_menu::popup_view_for_id::(id) + { + return element; + } if self .app .core() @@ -724,6 +730,8 @@ where return self.app.view_window(id).map(crate::Action::App); } + crate::widget::text_context_menu::set_current_window_id(id); + let view = if self.app.core().window.use_template { self.app.view_main() } else { @@ -738,6 +746,9 @@ where #[cfg(not(feature = "multi-window"))] pub fn view(&self) -> Element> { + if let Some(id) = self.app.core().main_window_id() { + crate::widget::text_context_menu::set_current_window_id(id); + } let view = self.app.view_main(); #[cfg(all(target_env = "gnu", not(target_os = "windows")))] From 8b31e83d5827891df54172be7a2d58d84c6bdc8d Mon Sep 17 00:00:00 2001 From: Hojjat Date: Wed, 20 May 2026 10:24:28 -0600 Subject: [PATCH 44/58] fix(test): `--features applet` only fix(test): `--feature wayland` only --- src/applet/mod.rs | 4 ++-- src/widget/text.rs | 2 +- src/widget/text_editor.rs | 2 +- src/widget/text_input/input.rs | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/applet/mod.rs b/src/applet/mod.rs index 37635bf4..b9805c9b 100644 --- a/src/applet/mod.rs +++ b/src/applet/mod.rs @@ -290,7 +290,7 @@ impl Context { ) } - pub fn applet_tooltip<'a, Message: 'static>( + pub fn applet_tooltip<'a, Message: Clone + 'static>( &self, content: impl Into>, tooltip: impl Into>, @@ -502,7 +502,7 @@ impl Context { } } - pub fn text<'a>(&self, msg: impl Into>) -> crate::widget::Text<'a, crate::Theme> { + pub fn text<'a>(&self, msg: impl Into>) -> crate::widget::Text<'a> { let msg = msg.into(); let t = match self.size { Size::Hardcoded(_) => crate::widget::text, diff --git a/src/widget/text.rs b/src/widget/text.rs index 9fe051ac..192adda3 100644 --- a/src/widget/text.rs +++ b/src/widget/text.rs @@ -50,7 +50,7 @@ impl<'a> Text<'a> { } fn uses_popup_context_menu(&self) -> bool { - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) diff --git a/src/widget/text_editor.rs b/src/widget/text_editor.rs index 9bbe2718..368beba2 100644 --- a/src/widget/text_editor.rs +++ b/src/widget/text_editor.rs @@ -130,7 +130,7 @@ impl<'a, Message: Clone + 'static> TextEditor<'a, Message> { } fn uses_popup_context_menu(&self) -> bool { - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index c46f895c..fe5855e5 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -585,7 +585,7 @@ where } fn uses_popup_context_menu(&self) -> bool { - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) @@ -1030,7 +1030,7 @@ where ); // On Wayland: if right-click just set context_menu_position, create a popup instead. - #[cfg(feature = "wayland")] + #[cfg(all(feature = "wayland", feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) From 1de6ce9460b419344f6f5f2c564c7e55350dcd7f Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 29 Jun 2026 16:09:28 -0600 Subject: [PATCH 45/58] fix: dismiss the pop up on click outside or esc --- src/app/cosmic.rs | 36 ++- src/widget/text.rs | 2 +- src/widget/text_context_menu.rs | 397 ++++++++++++++++++++++---------- src/widget/text_editor.rs | 4 +- src/widget/text_input/input.rs | 11 +- 5 files changed, 312 insertions(+), 138 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index cc689223..5a83f66e 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -530,6 +530,29 @@ where } }; + // Drain any text context-menu popup teardown requests queued by + // widgets during `app.update()` and destroy them through the normal + // popup pipeline. Drained before the creation queue so a + // destroy-then-recreate (a second right-click reusing the same id) + // keeps its order. + #[cfg(all(feature = "wayland", target_os = "linux"))] + for id in crate::widget::text_context_menu::take_popup_destroys() { + task = task.chain(iced_winit::commands::popup::destroy_popup(id)); + } + + // Drain any text context-menu popup requests queued by widgets during + // `app.update()` and create them through the normal popup pipeline. + #[cfg(all(feature = "wayland", target_os = "linux"))] + for req in crate::widget::text_context_menu::take_popup_requests() { + let (settings, view) = + crate::widget::text_context_menu::into_popup_view::(req); + task = task.chain(self.get_popup( + settings, + Box::new(|_| LiveSettings::default()), + Some(Box::new(move |_| view())), + )); + } + #[cfg(all(target_env = "gnu", not(target_os = "windows")))] crate::malloc::trim(0); @@ -697,6 +720,14 @@ where subscriptions.push(crate::dbus_activation::subscription::()); } + // Drives the text context-menu popup queues: a right-click queues a + // popup but publishes no message, so this re-emits `Action::None` to + // make `update()` run and drain the queue. + #[cfg(all(feature = "wayland", target_os = "linux"))] + subscriptions.push(crate::widget::text_context_menu::wake_subscription::< + T::Message, + >()); + Subscription::batch(subscriptions) } @@ -716,11 +747,6 @@ where if let Some((_, _, _, Some(v))) = self.surface_views.get(&id) { return v(&self.app); } - #[cfg(feature = "wayland")] - if let Some(element) = crate::widget::text_context_menu::popup_view_for_id::(id) - { - return element; - } if self .app .core() diff --git a/src/widget/text.rs b/src/widget/text.rs index 192adda3..5c490d4d 100644 --- a/src/widget/text.rs +++ b/src/widget/text.rs @@ -360,7 +360,7 @@ impl<'a, Message: Clone + 'static> Widget for T let wrapper_state = tree.state.downcast_ref::(); let menu_bar_state = wrapper_state.menu_bar_state.clone(); - crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); + crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); } } else { w_mut::(&mut self.inner).update( diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index c8505c1a..7912e37c 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -29,7 +29,6 @@ use iced_core::{ }; use iced_widget::core::event; use std::borrow::Cow; -use std::mem; use std::sync::{Arc, Mutex}; /// Shared state for communicating deferred context menu actions @@ -47,25 +46,30 @@ pub(crate) fn take_pending_action(pending: &PendingAction) -> Option = const { Cell::new(iced_core::window::Id::NONE) }; } -/// Data needed to construct a popup view. Stored in a global registry -/// so `Cosmic::view()` can build the `Element` with the correct message type. #[cfg(feature = "wayland")] -struct PopupViewData { +use iced_runtime::platform_specific::wayland::popup::SctkPopupSettings; + +/// A request to create a text context-menu popup surface, queued by a widget +/// during `update()` and drained by `Cosmic::update()` so the popup goes +/// through the normal `get_popup()` Task + `surface_views` pipeline. +#[cfg(feature = "wayland")] +pub(crate) struct PopupRequest { + settings: SctkPopupSettings, menu: Menu<'static, TextCtxAction>, selected_text: Option, pending_action: PendingAction, } #[cfg(feature = "wayland")] -static POPUP_VIEW_REGISTRY: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); +thread_local! { + static PENDING_POPUP_REQUESTS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} pub(crate) fn set_current_window_id(id: iced_core::window::Id) { CURRENT_WINDOW_ID.set(id); @@ -75,38 +79,108 @@ pub(crate) fn current_window_id() -> iced_core::window::Id { CURRENT_WINDOW_ID.get() } -/// Returns the popup view `Element` for a given popup ID, if one exists. -/// Called by `Cosmic::view()` where `Message` is known. +/// Drains all popup requests queued by widgets this frame. #[cfg(feature = "wayland")] -pub(crate) fn popup_view_for_id( - id: iced_core::window::Id, -) -> Option>> { - let registry = POPUP_VIEW_REGISTRY.lock().ok()?; - let data = registry.get(&id)?; - let popup_widget: TextContextMenuPopup = TextContextMenuPopup { - menu: data.menu.clone(), - selected_text: data.selected_text.clone(), - pending_action: data.pending_action.clone(), - _phantom: std::marker::PhantomData, - }; - drop(registry); - Some( - crate::Element::from( - crate::widget::container(popup_widget).center(iced_core::Length::Fill), - ) - .map(crate::action::app), - ) +pub(crate) fn take_popup_requests() -> Vec { + PENDING_POPUP_REQUESTS.with(|q| std::mem::take(&mut *q.borrow_mut())) } -/// Sends a wayland popup action directly to the SCTK event loop, -/// bypassing the iced Task system. +/// Consumes a [`PopupRequest`], returning the popup settings plus a view +/// builder. The builder rebuilds the menu element each frame from the +/// captured content, independent of app state. #[cfg(feature = "wayland")] -fn send_popup_direct(settings: iced_runtime::platform_specific::wayland::popup::SctkPopupSettings) { - iced_winit::send_wayland_action_direct( - iced_runtime::platform_specific::wayland::Action::Popup( - iced_runtime::platform_specific::wayland::popup::Action::Popup { popup: settings }, - ), - ); +#[allow(clippy::type_complexity)] +pub(crate) fn into_popup_view( + req: PopupRequest, +) -> ( + SctkPopupSettings, + Box crate::Element<'static, crate::Action> + Send + Sync>, +) { + let PopupRequest { + settings, + menu, + selected_text, + pending_action, + } = req; + + let view = Box::new(move || { + let popup_widget: TextContextMenuPopup = TextContextMenuPopup { + menu: menu.clone(), + selected_text: selected_text.clone(), + pending_action: pending_action.clone(), + _phantom: std::marker::PhantomData, + }; + crate::Element::from(crate::widget::container(popup_widget).center(iced_core::Length::Fill)) + .map(crate::action::app) + }); + + (settings, view) +} + +#[cfg(feature = "wayland")] +thread_local! { + static PENDING_POPUP_DESTROYS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Queues a context-menu popup for teardown. +/// +/// Mirrors [`create_text_context_popup`]'s request queue: widgets running +/// inside `update()` can't reach `Cosmic` to issue a Task, so they push the +/// id here and `Cosmic::update()` drains it into a `destroy_popup` Task that +/// flows through the normal surface pipeline. +#[cfg(feature = "wayland")] +fn queue_destroy_popup(id: iced_core::window::Id) { + PENDING_POPUP_DESTROYS.with(|q| q.borrow_mut().push(id)); + wake_runtime(); +} + +#[cfg(feature = "wayland")] +static WAKE_TX: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Stable identity for [`wake_subscription`]. +#[cfg(feature = "wayland")] +struct PopupWake; + +/// Nudges the runtime so `Cosmic::update()` runs and drains the popup queues. +#[cfg(feature = "wayland")] +fn wake_runtime() { + if let Some(tx) = WAKE_TX.get() { + let _ = tx.clone().try_send(()); + } +} + +/// Subscription that backs [`wake_runtime`]: it owns the receiving end of the +/// wake channel and re-emits each ping as [`crate::Action::None`]. Add it to +/// the app's subscriptions (done by `Cosmic::subscription`) so popup creation +/// and teardown queued from widget `update()` get drained promptly. +#[cfg(feature = "wayland")] +pub(crate) fn wake_subscription() +-> iced_futures::Subscription> { + use iced_futures::futures::{SinkExt, StreamExt}; + iced_futures::Subscription::run_with(std::any::TypeId::of::(), |_| { + iced::stream::channel( + 16, + |mut output: iced_futures::futures::channel::mpsc::Sender>| async move { + let (tx, mut rx) = iced_futures::futures::channel::mpsc::channel(16); + let _ = WAKE_TX.set(tx); + while rx.next().await.is_some() { + let _ = output.send(crate::Action::None).await; + } + }, + ) + }) +} + +/// Drains all popup teardown requests queued by widgets this frame. +/// +/// Called by `Cosmic::update()`, which turns each id into a `destroy_popup()` +/// Task. Drained before the creation queue so a destroy-then-recreate (a +/// second right-click reusing the same id) keeps its order. +#[cfg(wayland_platform)] +pub(crate) fn take_popup_destroys() -> Vec { + PENDING_POPUP_DESTROYS.with(|q| std::mem::take(&mut *q.borrow_mut())) } /// Creates a context menu overlay for any widget implementing @@ -135,12 +209,20 @@ where let mut menu_roots = build_menu_roots(is_editable, selected_text.is_some()); menu_roots.iter_mut().for_each(menu::Tree::set_index); + let bounds = Rectangle { + x: click_position.x, + y: click_position.y, + width: 240.0, + height: 240.0, + }; + let item_count = menu_roots[0].children.len(); menu_bar_state.inner.with_data_mut(|state| { - let stale = state - .menu_states - .first() - .is_some_and(|ms| ms.menu_bounds.child_positions.len() != item_count); + let stale = state.menu_states.first().is_some_and(|ms| { + ms.menu_bounds.child_positions.len() != item_count + || (ms.menu_bounds.parent_bounds.x - bounds.x).abs() > 0.5 + || (ms.menu_bounds.parent_bounds.y - bounds.y).abs() > 0.5 + }); if !state.open || stale { state.menu_states.clear(); state.active_root.clear(); @@ -149,8 +231,6 @@ where menu_roots_diff(&mut menu_roots, &mut state.tree); }); - let offscreen = Rectangle::new(Point::new(-10000.0, -10000.0), Size::ZERO); - let menu = Menu { tree: menu_bar_state.clone(), menu_roots: Cow::Owned(menu_roots), @@ -163,10 +243,10 @@ where }, item_width: ItemWidth::Uniform(240), item_height: ItemHeight::Dynamic(40), - bar_bounds: offscreen, - main_offset: -240, + bar_bounds: bounds, + main_offset: -(bounds.height as i32), cross_offset: 0, - root_bounds_list: vec![offscreen], + root_bounds_list: vec![bounds], path_highlight: Some(PathHighlight::MenuActive), style: Cow::Owned(theme::menu_bar::MenuBarStyle::Default), position: Point::new(translation.x, translation.y), @@ -181,7 +261,6 @@ where widget, tree, on_input, - click_position, }))) } @@ -227,7 +306,6 @@ struct TextMenuOverlay<'a, W, Message: Clone + 'static> { widget: &'a W, tree: &'a mut Tree, on_input: Option<&'a dyn Fn(String) -> Message>, - click_position: Point, } impl overlay::Overlay @@ -237,6 +315,34 @@ where Message: Clone + 'static, { fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> iced_core::layout::Node { + // Initialise the menu before the first draw so it appears at the click + // position immediately + let needs_init = self + .menu + .tree + .inner + .with_data(|state| state.open && state.menu_states.is_empty()); + + if needs_init { + let overlay_offset = Point::ORIGIN - self.menu.position; + let bar_bounds = self.menu.bar_bounds; + let main_offset = self.menu.main_offset as f32; + let overlay_cursor = bar_bounds.center(); + + let mut init_messages: Vec = Vec::new(); + let mut init_shell = Shell::new(&mut init_messages); + menu::init_root_menu( + &mut self.menu, + renderer, + &mut init_shell, + overlay_cursor, + bounds, + overlay_offset, + bar_bounds, + main_offset, + ); + } + self.menu.layout( renderer, Limits::NONE @@ -267,59 +373,27 @@ where clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) { - // Pre-initialize the menu if it hasn't been yet. - let needs_init = self - .menu - .tree - .inner - .with_data(|state| state.open && state.menu_states.is_empty()); - - if needs_init { - let viewport = layout.bounds(); - let viewport_size = viewport.size(); - let overlay_offset = Point::ORIGIN - viewport.position(); - let overlay_cursor = cursor.position().unwrap_or_default() - overlay_offset; - - let init_bounds = Rectangle { - x: self.click_position.x, - y: self.click_position.y, - width: 240.0, - height: 240.0, - }; - - // Temporarily set real bounds so init_root_menu can find - // root_bounds_list entries that contain the cursor. - let main_offset = self.menu.main_offset as f32; - let saved_bounds = mem::replace(&mut self.menu.bar_bounds, init_bounds); - let saved_roots = mem::replace(&mut self.menu.root_bounds_list, vec![init_bounds]); - - let mut init_messages: Vec = Vec::new(); - let mut init_shell = Shell::new(&mut init_messages); - menu::init_root_menu( - &mut self.menu, - renderer, - &mut init_shell, - overlay_cursor, - viewport_size, - overlay_offset, - init_bounds, - main_offset, - ); - - // Restore offscreen bounds - self.menu.bar_bounds = saved_bounds; - self.menu.root_bounds_list = saved_roots; - - if init_shell.is_layout_invalid() { - shell.invalidate_layout(); - } - } - - // Right-button releases are meaningless for menu interaction + // Right-clicks are not menu interactions. A right-click *on* the menu + // (notably the press/release that opened it) is swallowed so it does + // not close the menu. A right-click *outside* closes it — clearing the + // menu position lets the widget reopen it at the new point on the same + // event. Initialization happens in `layout()`. if matches!( event, - event::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) + event::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) + | event::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) ) { + let over_menu = cursor + .position() + .is_some_and(|p| layout.bounds().contains(p)); + if !over_menu { + self.menu.tree.inner.with_data_mut(|state| { + state.menu_states.clear(); + state.active_root.clear(); + state.open = false; + }); + self.widget.set_context_menu_position(self.tree, None); + } return; } @@ -387,11 +461,11 @@ where } } -/// Creates a Wayland popup surface containing the text context menu. +/// Queues a Wayland popup surface containing the text context menu. /// -/// Sends the popup action directly to the SCTK event loop and registers -/// the view data so `Cosmic::view()` can render it. No app-level wiring -/// is needed +/// Pushes a [`PopupRequest`] onto the request queue; `Cosmic::update()` +/// drains it and creates the popup through `get_popup()`, so it flows +/// through the normal Task + `surface_views` pipeline. #[cfg(feature = "wayland")] pub(crate) fn create_text_context_popup( click_position: Point, @@ -419,7 +493,7 @@ pub(crate) fn create_text_context_popup( state.active_root.clear(); menu_roots_diff(&mut menu_roots, &mut state.tree); if let Some(id) = state.popup_id.get(&window_id).copied() { - send_destroy_popup_direct(id); + queue_destroy_popup(id); state.view_cursor = cursor; id } else { @@ -502,20 +576,10 @@ pub(crate) fn create_text_context_popup( ..Default::default() }; - // Register view data so Cosmic::view() can render this popup. - if let Ok(mut registry) = POPUP_VIEW_REGISTRY.lock() { - registry.insert( - id, - PopupViewData { - menu: popup_menu, - selected_text, - pending_action: pending_action.clone(), - }, - ); - } - - // Send the popup creation directly to the SCTK event loop. - send_popup_direct(SctkPopupSettings { + // Queue the request. `Cosmic::update()` drains it and creates the popup + // through `get_popup()`, so it flows through the normal Task + + // `surface_views` pipeline (rendering and teardown included). + let settings = SctkPopupSettings { parent: window_id, id, positioner, @@ -523,24 +587,46 @@ pub(crate) fn create_text_context_popup( grab: true, close_with_children: false, input_zone: None, + }; + + PENDING_POPUP_REQUESTS.with(|q| { + q.borrow_mut().push(PopupRequest { + settings, + menu: popup_menu, + selected_text, + pending_action: pending_action.clone(), + }); }); + wake_runtime(); } -/// Checks whether a popup menu has closed and destroys it if needed. -/// Call this from the text widget's `update()` on each frame. +/// Dismisses this widget's open context-menu popup on an outside click, +/// touch, or Escape. #[cfg(feature = "wayland")] -pub(crate) fn cleanup_text_popup(menu_bar_state: &MenuBarState) { +pub(crate) fn dismiss_popup_on_event(menu_bar_state: &MenuBarState, event: &event::Event) { + let is_dismiss = matches!( + event, + event::Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left | mouse::Button::Middle + )) | event::Event::Keyboard(iced_core::keyboard::Event::KeyPressed { + key: iced_core::keyboard::Key::Named(iced_core::keyboard::key::Named::Escape), + .. + }) | event::Event::Touch(iced_core::touch::Event::FingerPressed { .. }) + ); + if !is_dismiss { + return; + } + let window_id = current_window_id(); - let should_destroy = menu_bar_state + let popup_id = menu_bar_state .inner - .with_data(|state| !state.open && state.popup_id.contains_key(&window_id)); - if should_destroy { + .with_data(|state| state.popup_id.get(&window_id).copied()); + if let Some(popup_id) = popup_id { menu_bar_state.inner.with_data_mut(|state| { - if let Some(popup_id) = state.popup_id.remove(&window_id) { - send_destroy_popup_direct(popup_id); - } + state.popup_id.retain(|_, v| *v != popup_id); state.reset(); }); + queue_destroy_popup(popup_id); } } @@ -624,6 +710,50 @@ impl iced_core::widget::Widget, viewport: &Rectangle, ) { + #[cfg(feature = "wayland")] + { + use iced_core::event::wayland::PopupEvent; + let popup_event = match event { + event::Event::PlatformSpecific(iced_core::event::PlatformSpecific::Wayland( + iced_core::event::wayland::Event::Popup(e, _, _), + )) => Some(e), + _ => None, + }; + if matches!(popup_event, Some(PopupEvent::Done | PopupEvent::Unfocused)) { + let popup_id = self.menu.window_id; + self.menu.tree.inner.with_data_mut(|state| { + state.popup_id.retain(|_, v| *v != popup_id); + state.reset(); + }); + // `Done` means the surface was already destroyed by the + // compositor; only `Unfocused` needs an explicit destroy. + if matches!(popup_event, Some(PopupEvent::Unfocused)) { + queue_destroy_popup(popup_id); + } + return; + } + } + + // Escape dismisses the popup. Under the grab, keyboard input is + // delivered to the popup surface, so handle it here. + #[cfg(feature = "wayland")] + if matches!( + event, + event::Event::Keyboard(iced_core::keyboard::Event::KeyPressed { + key: iced_core::keyboard::Key::Named(iced_core::keyboard::key::Named::Escape), + .. + }) + ) { + let popup_id = self.menu.window_id; + self.menu.tree.inner.with_data_mut(|state| { + state.popup_id.retain(|_, v| *v != popup_id); + state.reset(); + }); + queue_destroy_popup(popup_id); + shell.capture_event(); + return; + } + let mut local_messages: Vec = Vec::new(); let mut local_shell = Shell::new(&mut local_messages); @@ -677,6 +807,23 @@ impl iced_core::widget::Widget Widget(); let menu_bar_state = wrapper_state.menu_bar_state.clone(); - crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); + crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); } } else { ew_mut::(&mut self.inner).update( diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index fe5855e5..95c97d15 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -919,6 +919,12 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { + #[cfg(all(feature = "wayland", feature = "winit"))] + if self.uses_popup_context_menu() { + let menu_bar_state = tree.state.downcast_ref::().menu_bar_state.clone(); + crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); + } + let text_layout = self.text_layout(layout); let mut trailing_icon_layout = None; let font = self.font.unwrap_or_else(|| renderer.default_font()); @@ -1093,11 +1099,6 @@ where } } } - - // Cleanup popup if menu closed. - let state = tree.state.downcast_ref::(); - let menu_bar_state = state.menu_bar_state.clone(); - crate::widget::text_context_menu::cleanup_text_popup(&menu_bar_state); } let state = tree.state.downcast_mut::(); From 8a03f063c015bee0a0031f24c1082a086791e4a9 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 29 Jun 2026 16:49:35 -0600 Subject: [PATCH 46/58] refactor: rename to `SelectableText` instead of `Text` This avoids API breakage --- src/applet/mod.rs | 4 +- src/widget/menu/menu_tree.rs | 2 +- src/widget/mod.rs | 4 + src/widget/selectable_text.rs | 505 ++++++++++++++++++++++++++++++++ src/widget/settings/item.rs | 8 +- src/widget/text.rs | 525 +++------------------------------- 6 files changed, 557 insertions(+), 491 deletions(-) create mode 100644 src/widget/selectable_text.rs diff --git a/src/applet/mod.rs b/src/applet/mod.rs index b9805c9b..37635bf4 100644 --- a/src/applet/mod.rs +++ b/src/applet/mod.rs @@ -290,7 +290,7 @@ impl Context { ) } - pub fn applet_tooltip<'a, Message: Clone + 'static>( + pub fn applet_tooltip<'a, Message: 'static>( &self, content: impl Into>, tooltip: impl Into>, @@ -502,7 +502,7 @@ impl Context { } } - pub fn text<'a>(&self, msg: impl Into>) -> crate::widget::Text<'a> { + pub fn text<'a>(&self, msg: impl Into>) -> crate::widget::Text<'a, crate::Theme> { let msg = msg.into(); let t = match self.size { Size::Hardcoded(_) => crate::widget::text, diff --git a/src/widget/menu/menu_tree.rs b/src/widget/menu/menu_tree.rs index cf99335c..bd4f1e6c 100644 --- a/src/widget/menu/menu_tree.rs +++ b/src/widget/menu/menu_tree.rs @@ -194,7 +194,7 @@ pub fn menu_root<'a, Message, Renderer: renderer::Renderer>( ) -> Button<'a, Message> where Element<'a, Message, crate::Theme, Renderer>: From>, - Message: std::clone::Clone + 'static, + Message: std::clone::Clone + 'a, { widget::button::custom(widget::text(label)) .padding([4, 12]) diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 4c33f7e3..ed6f55d7 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -296,6 +296,10 @@ pub mod text; #[doc(inline)] pub use text::{Text, text}; +pub mod selectable_text; +#[doc(inline)] +pub use selectable_text::{SelectableText, selectable_text}; + pub mod text_context_menu; #[doc(inline)] pub use text_context_menu::HasSelectableText; diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs new file mode 100644 index 00000000..824077c7 --- /dev/null +++ b/src/widget/selectable_text.rs @@ -0,0 +1,505 @@ +use crate::Renderer; +use crate::widget::menu::MenuBarState; + +use iced_core::event::Event; +use iced_core::text::LineHeight; +use iced_core::widget::Widget; +use iced_core::widget::text::{Alignment, Catalog, Ellipsize, Shaping, Style, Wrapping}; +use iced_core::widget::tree::{self, Tree}; +use iced_core::{ + Clipboard, Color, Layout, Length, Pixels, Rectangle, Shell, Size, Vector, alignment, mouse, + overlay, renderer, +}; +use std::borrow::Cow; + +type InnerText<'a> = iced_core::widget::text::Text<'a, crate::Theme, Renderer>; + +/// A selectable text widget with right-click context menu support. +/// +/// Wraps the plain [`Text`](crate::widget::Text) widget to add text selection +/// and a context menu (Copy / Select All), both **on by default**. Created +/// with [`selectable_text`]; the plain [`text`](crate::widget::text) +/// constructor and the typography presets return the unwrapped +/// [`Text`](crate::widget::Text), so non-selectable text keeps iced's looser +/// trait bounds. +/// +/// Use [`.selectable(false)`](Self::selectable) for plain, non-interactive +/// text, or [`.context_menu(false)`](Self::context_menu) to keep selection +/// without the menu. +pub struct SelectableText<'a> { + inner: InnerText<'a>, + selectable: bool, + context_menu: bool, +} + +impl<'a> SelectableText<'a> { + /// Creates a new [`SelectableText`] widget with the given content. + /// + /// Selection and the context menu are enabled by default. + pub fn new(content: impl iced_core::text::IntoFragment<'a>) -> Self { + Self { + inner: InnerText::new(content), + selectable: true, + context_menu: true, + } + } + + /// Controls whether the text is selectable. On by default. + /// + /// Pass `false` to render plain, non-interactive text (this also + /// suppresses the context menu, which has nothing to act on). + pub fn selectable(mut self, enabled: bool) -> Self { + self.selectable = enabled; + self + } + + /// Controls whether the right-click context menu is shown. On by default. + /// + /// Pass `false` to keep text selection but disable the context menu. + pub fn context_menu(mut self, enabled: bool) -> Self { + self.context_menu = enabled; + self + } + + /// Whether the wrapper should install its context-menu machinery: only + /// when the menu is enabled *and* the text is selectable (a menu over + /// non-selectable text would have nothing to act on). + fn shows_context_menu(&self) -> bool { + self.selectable && self.context_menu + } + + fn uses_popup_context_menu(&self) -> bool { + #[cfg(all(feature = "wayland", feature = "winit"))] + if matches!( + crate::app::cosmic::WINDOWING_SYSTEM.get(), + Some(crate::app::cosmic::WindowingSystem::Wayland) + ) { + return true; + } + false + } + + pub fn size(mut self, size: impl Into) -> Self { + self.inner = self.inner.size(size); + self + } + + pub fn line_height(mut self, line_height: impl Into) -> Self { + self.inner = self.inner.line_height(line_height); + self + } + + pub fn font(mut self, font: impl Into<::Font>) -> Self { + self.inner = self.inner.font(font); + self + } + + pub fn font_maybe( + mut self, + font: Option::Font>>, + ) -> Self { + self.inner = self.inner.font_maybe(font); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.inner = self.inner.width(width); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.inner = self.inner.height(height); + self + } + + pub fn center(mut self) -> Self { + self.inner = self.inner.center(); + self + } + + pub fn align_x(mut self, alignment: impl Into) -> Self { + self.inner = self.inner.align_x(alignment); + self + } + + pub fn align_y(mut self, alignment: impl Into) -> Self { + self.inner = self.inner.align_y(alignment); + self + } + + pub fn shaping(mut self, shaping: Shaping) -> Self { + self.inner = self.inner.shaping(shaping); + self + } + + pub fn wrapping(mut self, wrapping: Wrapping) -> Self { + self.inner = self.inner.wrapping(wrapping); + self + } + + pub fn ellipsize(mut self, ellipsize: Ellipsize) -> Self { + self.inner = self.inner.ellipsize(ellipsize); + self + } + + #[must_use] + pub fn style(mut self, style: impl Fn(&crate::Theme) -> Style + 'a) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.style(style); + self + } + + pub fn color(mut self, color: impl Into) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.color(color); + self + } + + pub fn color_maybe(mut self, color: Option>) -> Self + where + ::Class<'a>: + From>, + { + self.inner = self.inner.color_maybe(color); + self + } + + #[must_use] + pub fn class(mut self, class: impl Into<::Class<'a>>) -> Self { + self.inner = self.inner.class(class); + self + } +} + +impl<'a> From> for SelectableText<'a> { + fn from(inner: InnerText<'a>) -> Self { + Self { + inner, + selectable: true, + context_menu: true, + } + } +} + +impl<'a> From<&'a str> for SelectableText<'a> { + fn from(content: &'a str) -> Self { + Self::new(content) + } +} + +struct TextWrapperState { + menu_bar_state: MenuBarState, + pending_action: crate::widget::text_context_menu::PendingAction, +} + +fn w<'x, Message>(inner: &'x InnerText<'_>) -> &'x dyn Widget { + inner +} + +fn w_mut<'x, Message>( + inner: &'x mut InnerText<'_>, +) -> &'x mut dyn Widget { + inner +} + +impl<'a, Message: Clone + 'static> Widget for SelectableText<'a> { + fn tag(&self) -> tree::Tag { + if self.shows_context_menu() { + tree::Tag::of::() + } else { + w::(&self.inner).tag() + } + } + + fn state(&self) -> tree::State { + if self.shows_context_menu() { + tree::State::new(TextWrapperState { + menu_bar_state: MenuBarState::default(), + pending_action: crate::widget::text_context_menu::pending_action(), + }) + } else { + w::(&self.inner).state() + } + } + + fn children(&self) -> Vec { + if self.shows_context_menu() { + vec![Tree::new(w::(&self.inner))] + } else { + Vec::new() + } + } + + fn diff(&mut self, tree: &mut Tree) { + if self.shows_context_menu() { + if let Some(child) = tree.children.first_mut() { + child.diff(&mut self.inner as &mut dyn Widget); + } + } + } + + fn size(&self) -> Size { + w::(&self.inner).size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + let inner_tree = if self.shows_context_menu() { + &mut tree.children[0] + } else { + tree + }; + w_mut::(&mut self.inner).layout(inner_tree, renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &crate::Theme, + defaults: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let inner_tree = if self.shows_context_menu() { + &tree.children[0] + } else { + tree + }; + w::(&self.inner).draw( + inner_tree, renderer, theme, defaults, layout, cursor, viewport, + ); + + // draw a border when the text is focused + use iced_core::renderer::Renderer as RendererExt; + use iced_core::widget::text::State as TextState; + + let state = inner_tree + .state + .downcast_ref::::Paragraph>>(); + if state.is_keyboard_focused() { + let cosmic = theme.cosmic(); + let accent: Color = cosmic.accent.base.into(); + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: iced_core::Border { + color: accent, + width: 1.0, + radius: cosmic.corner_radii.radius_s.into(), + }, + ..renderer::Quad::default() + }, + Color::TRANSPARENT, + ); + } + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if self.shows_context_menu() { + w_mut::(&mut self.inner).update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + + #[cfg(feature = "wayland")] + if self.uses_popup_context_menu() { + use iced_core::widget::text::HasSelectableText; + + if self + .inner + .context_menu_position(&tree.children[0]) + .is_some() + { + let selected_text = self.inner.selected_text(&tree.children[0]); + let has_selection = selected_text.is_some(); + let click_position = + self.inner.context_menu_position(&tree.children[0]).unwrap(); + let wrapper_state = tree.state.downcast_ref::(); + let menu_bar_state = wrapper_state.menu_bar_state.clone(); + let pending_action = wrapper_state.pending_action.clone(); + + crate::widget::text_context_menu::create_text_context_popup( + click_position, + selected_text, + false, + has_selection, + &menu_bar_state, + &pending_action, + renderer, + viewport, + cursor, + ); + + self.inner + .set_context_menu_position(&mut tree.children[0], None); + } + + { + let wrapper_state = tree.state.downcast_ref::(); + let pending_action = wrapper_state.pending_action.clone(); + if let Some(action) = + crate::widget::text_context_menu::take_pending_action(&pending_action) + { + match action { + crate::widget::text_context_menu::TextCtxAction::Copy => {} + crate::widget::text_context_menu::TextCtxAction::SelectAll => { + self.inner.select_all(&mut tree.children[0]); + } + _ => {} + } + } + } + + let wrapper_state = tree.state.downcast_ref::(); + let menu_bar_state = wrapper_state.menu_bar_state.clone(); + crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); + } + } else { + w_mut::(&mut self.inner).update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let inner_tree = if self.shows_context_menu() { + &tree.children[0] + } else { + tree + }; + w::(&self.inner).mouse_interaction(inner_tree, layout, cursor, viewport, renderer) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + _layout: Layout<'b>, + _renderer: &Renderer, + _viewport: &Rectangle, + translation: Vector, + ) -> Option> { + if self.shows_context_menu() && !self.uses_popup_context_menu() { + let menu_bar_state = tree + .state + .downcast_ref::() + .menu_bar_state + .clone(); + let inner_tree = &mut tree.children[0]; + crate::widget::text_context_menu::context_menu_overlay( + &self.inner, + inner_tree, + None, + translation, + menu_bar_state, + ) + } else { + None + } + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced_core::widget::Operation, + ) { + let inner_tree = if self.shows_context_menu() { + &mut tree.children[0] + } else { + tree + }; + w_mut::(&mut self.inner).operate(inner_tree, layout, renderer, operation); + } + + #[cfg(feature = "a11y")] + fn a11y_nodes( + &self, + layout: Layout<'_>, + state: &Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + let inner_state = if self.shows_context_menu() { + &state.children[0] + } else { + state + }; + w::(&self.inner).a11y_nodes(layout, inner_state, cursor) + } + + fn id(&self) -> Option { + w::(&self.inner).id() + } + + fn set_id(&mut self, id: iced_core::widget::Id) { + w_mut::(&mut self.inner).set_id(id); + } +} + +impl<'a, Message: Clone + 'static> From> for crate::Element<'a, Message> { + fn from(text: SelectableText<'a>) -> Self { + let SelectableText { + inner, + selectable, + context_menu, + } = text; + // Apply selection to the inner widget here, at the last moment before + // it becomes part of the tree. iced's `Text::selectable()` is one-way + // (it can only set the flag), so deferring until conversion lets + // `selectable(false)` work without ever having set it. + let inner = if selectable { + inner.selectable() + } else { + inner + }; + Self::new(SelectableText { + inner, + selectable, + context_menu, + }) + } +} + +/// Creates a [`SelectableText`] widget: text the user can select, with a +/// right-click context menu (Copy / Select All). +/// +/// Use [`.context_menu(false)`](SelectableText::context_menu) to keep +/// selection but suppress the menu. +pub fn selectable_text<'a>(content: impl Into> + 'a) -> SelectableText<'a> { + SelectableText::new(content.into()).font(crate::font::default()) +} diff --git a/src/widget/settings/item.rs b/src/widget/settings/item.rs index af4b17ab..4eb5bb17 100644 --- a/src/widget/settings/item.rs +++ b/src/widget/settings/item.rs @@ -14,12 +14,12 @@ use taffy::AlignContent; /// A settings item aligned in a row #[must_use] #[allow(clippy::module_name_repetitions)] -pub fn item<'a, Message: Clone + 'static>( +pub fn item<'a, Message: 'static>( title: impl Into> + 'a, widget: impl Into> + 'a, ) -> Row<'a, Message, Theme> { #[inline(never)] - fn inner<'a, Message: Clone + 'static>( + fn inner<'a, Message: 'static>( title: Cow<'a, str>, widget: Element<'a, Message>, ) -> Row<'a, Message, Theme> { @@ -45,12 +45,12 @@ pub fn item_row(children: Vec>) -> Row /// A settings item aligned in a flex row #[allow(clippy::module_name_repetitions)] -pub fn flex_item<'a, Message: Clone + 'static>( +pub fn flex_item<'a, Message: 'static>( title: impl Into> + 'a, widget: impl Into> + 'a, ) -> FlexRow<'a, Message> { #[inline(never)] - fn inner<'a, Message: Clone + 'static>( + fn inner<'a, Message: 'static>( title: Cow<'a, str>, widget: Element<'a, Message>, ) -> FlexRow<'a, Message> { diff --git a/src/widget/text.rs b/src/widget/text.rs index 5c490d4d..37e85b80 100644 --- a/src/widget/text.rs +++ b/src/widget/text.rs @@ -1,460 +1,13 @@ use crate::Renderer; -use crate::widget::menu::MenuBarState; - -use iced_core::event::Event; +pub use iced::widget::Text; use iced_core::text::LineHeight; -use iced_core::widget::Widget; -use iced_core::widget::text::{Alignment, Catalog, Ellipsize, Shaping, Style, Wrapping}; -use iced_core::widget::tree::{self, Tree}; -use iced_core::{ - Clipboard, Color, Layout, Length, Pixels, Rectangle, Shell, Size, Vector, alignment, mouse, - overlay, renderer, -}; use std::borrow::Cow; -type InnerText<'a> = iced_core::widget::text::Text<'a, crate::Theme, Renderer>; - -/// A text widget with optional selection and context menu support. +/// Creates a new [`Text`] widget with the provided content. /// -/// By default, text is non-selectable. Call [`.selectable()`](Text::selectable) -/// to enable text selection and a right-click context menu with Copy and -/// Select All. -pub struct Text<'a> { - inner: InnerText<'a>, - context_menu: bool, -} - -impl<'a> Text<'a> { - /// Creates a new [`Text`] widget with the given content. - pub fn new(content: impl iced_core::text::IntoFragment<'a>) -> Self { - Self { - inner: InnerText::new(content), - context_menu: false, - } - } - - /// Enables text selection and a right-click context menu. - pub fn selectable(mut self) -> Self { - self.inner = self.inner.selectable(); - self.context_menu = true; - self - } - - /// Controls whether the right-click context menu is shown. - /// - /// Only meaningful after calling [`.selectable()`](Self::selectable). - /// Pass `false` to keep text selection but disable the context menu. - pub fn context_menu(mut self, enabled: bool) -> Self { - self.context_menu = enabled; - self - } - - fn uses_popup_context_menu(&self) -> bool { - #[cfg(all(feature = "wayland", feature = "winit"))] - if matches!( - crate::app::cosmic::WINDOWING_SYSTEM.get(), - Some(crate::app::cosmic::WindowingSystem::Wayland) - ) { - return true; - } - false - } - - pub fn size(mut self, size: impl Into) -> Self { - self.inner = self.inner.size(size); - self - } - - pub fn line_height(mut self, line_height: impl Into) -> Self { - self.inner = self.inner.line_height(line_height); - self - } - - pub fn font(mut self, font: impl Into<::Font>) -> Self { - self.inner = self.inner.font(font); - self - } - - pub fn font_maybe( - mut self, - font: Option::Font>>, - ) -> Self { - self.inner = self.inner.font_maybe(font); - self - } - - pub fn width(mut self, width: impl Into) -> Self { - self.inner = self.inner.width(width); - self - } - - pub fn height(mut self, height: impl Into) -> Self { - self.inner = self.inner.height(height); - self - } - - pub fn center(mut self) -> Self { - self.inner = self.inner.center(); - self - } - - pub fn align_x(mut self, alignment: impl Into) -> Self { - self.inner = self.inner.align_x(alignment); - self - } - - pub fn align_y(mut self, alignment: impl Into) -> Self { - self.inner = self.inner.align_y(alignment); - self - } - - pub fn shaping(mut self, shaping: Shaping) -> Self { - self.inner = self.inner.shaping(shaping); - self - } - - pub fn wrapping(mut self, wrapping: Wrapping) -> Self { - self.inner = self.inner.wrapping(wrapping); - self - } - - pub fn ellipsize(mut self, ellipsize: Ellipsize) -> Self { - self.inner = self.inner.ellipsize(ellipsize); - self - } - - #[must_use] - pub fn style(mut self, style: impl Fn(&crate::Theme) -> Style + 'a) -> Self - where - ::Class<'a>: - From>, - { - self.inner = self.inner.style(style); - self - } - - pub fn color(mut self, color: impl Into) -> Self - where - ::Class<'a>: - From>, - { - self.inner = self.inner.color(color); - self - } - - pub fn color_maybe(mut self, color: Option>) -> Self - where - ::Class<'a>: - From>, - { - self.inner = self.inner.color_maybe(color); - self - } - - #[must_use] - pub fn class(mut self, class: impl Into<::Class<'a>>) -> Self { - self.inner = self.inner.class(class); - self - } -} - -impl<'a> From> for Text<'a> { - fn from(inner: InnerText<'a>) -> Self { - Self { - inner, - context_menu: false, - } - } -} - -impl<'a> From<&'a str> for Text<'a> { - fn from(content: &'a str) -> Self { - Self::new(content) - } -} - -struct TextWrapperState { - menu_bar_state: MenuBarState, - pending_action: crate::widget::text_context_menu::PendingAction, -} - -fn w<'x, Message>(inner: &'x InnerText<'_>) -> &'x dyn Widget { - inner -} - -fn w_mut<'x, Message>( - inner: &'x mut InnerText<'_>, -) -> &'x mut dyn Widget { - inner -} - -impl<'a, Message: Clone + 'static> Widget for Text<'a> { - fn tag(&self) -> tree::Tag { - if self.context_menu { - tree::Tag::of::() - } else { - w::(&self.inner).tag() - } - } - - fn state(&self) -> tree::State { - if self.context_menu { - tree::State::new(TextWrapperState { - menu_bar_state: MenuBarState::default(), - pending_action: crate::widget::text_context_menu::pending_action(), - }) - } else { - w::(&self.inner).state() - } - } - - fn children(&self) -> Vec { - if self.context_menu { - vec![Tree::new(w::(&self.inner))] - } else { - Vec::new() - } - } - - fn diff(&mut self, tree: &mut Tree) { - if self.context_menu { - if let Some(child) = tree.children.first_mut() { - child.diff(&mut self.inner as &mut dyn Widget); - } - } - } - - fn size(&self) -> Size { - w::(&self.inner).size() - } - - fn layout( - &mut self, - tree: &mut Tree, - renderer: &Renderer, - limits: &iced_core::layout::Limits, - ) -> iced_core::layout::Node { - let inner_tree = if self.context_menu { - &mut tree.children[0] - } else { - tree - }; - w_mut::(&mut self.inner).layout(inner_tree, renderer, limits) - } - - fn draw( - &self, - tree: &Tree, - renderer: &mut Renderer, - theme: &crate::Theme, - defaults: &renderer::Style, - layout: Layout<'_>, - cursor: mouse::Cursor, - viewport: &Rectangle, - ) { - let inner_tree = if self.context_menu { - &tree.children[0] - } else { - tree - }; - w::(&self.inner).draw( - inner_tree, renderer, theme, defaults, layout, cursor, viewport, - ); - - // draw a border when the text is focused - use iced_core::renderer::Renderer as RendererExt; - use iced_core::widget::text::State as TextState; - - let state = inner_tree - .state - .downcast_ref::::Paragraph>>(); - if state.is_keyboard_focused() { - let cosmic = theme.cosmic(); - let accent: Color = cosmic.accent.base.into(); - renderer.fill_quad( - renderer::Quad { - bounds: layout.bounds(), - border: iced_core::Border { - color: accent, - width: 1.0, - radius: cosmic.corner_radii.radius_s.into(), - }, - ..renderer::Quad::default() - }, - Color::TRANSPARENT, - ); - } - } - - fn update( - &mut self, - tree: &mut Tree, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - renderer: &Renderer, - clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - viewport: &Rectangle, - ) { - if self.context_menu { - w_mut::(&mut self.inner).update( - &mut tree.children[0], - event, - layout, - cursor, - renderer, - clipboard, - shell, - viewport, - ); - - #[cfg(feature = "wayland")] - if self.uses_popup_context_menu() { - use iced_core::widget::text::HasSelectableText; - - if self - .inner - .context_menu_position(&tree.children[0]) - .is_some() - { - let selected_text = self.inner.selected_text(&tree.children[0]); - let has_selection = selected_text.is_some(); - let click_position = - self.inner.context_menu_position(&tree.children[0]).unwrap(); - let wrapper_state = tree.state.downcast_ref::(); - let menu_bar_state = wrapper_state.menu_bar_state.clone(); - let pending_action = wrapper_state.pending_action.clone(); - - crate::widget::text_context_menu::create_text_context_popup( - click_position, - selected_text, - false, - has_selection, - &menu_bar_state, - &pending_action, - renderer, - viewport, - cursor, - ); - - self.inner - .set_context_menu_position(&mut tree.children[0], None); - } - - { - let wrapper_state = tree.state.downcast_ref::(); - let pending_action = wrapper_state.pending_action.clone(); - if let Some(action) = - crate::widget::text_context_menu::take_pending_action(&pending_action) - { - match action { - crate::widget::text_context_menu::TextCtxAction::Copy => {} - crate::widget::text_context_menu::TextCtxAction::SelectAll => { - self.inner.select_all(&mut tree.children[0]); - } - _ => {} - } - } - } - - let wrapper_state = tree.state.downcast_ref::(); - let menu_bar_state = wrapper_state.menu_bar_state.clone(); - crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); - } - } else { - w_mut::(&mut self.inner).update( - tree, event, layout, cursor, renderer, clipboard, shell, viewport, - ); - } - } - - fn mouse_interaction( - &self, - tree: &Tree, - layout: Layout<'_>, - cursor: mouse::Cursor, - viewport: &Rectangle, - renderer: &Renderer, - ) -> mouse::Interaction { - let inner_tree = if self.context_menu { - &tree.children[0] - } else { - tree - }; - w::(&self.inner).mouse_interaction(inner_tree, layout, cursor, viewport, renderer) - } - - fn overlay<'b>( - &'b mut self, - tree: &'b mut Tree, - _layout: Layout<'b>, - _renderer: &Renderer, - _viewport: &Rectangle, - translation: Vector, - ) -> Option> { - if self.context_menu && !self.uses_popup_context_menu() { - let menu_bar_state = tree - .state - .downcast_ref::() - .menu_bar_state - .clone(); - let inner_tree = &mut tree.children[0]; - crate::widget::text_context_menu::context_menu_overlay( - &self.inner, - inner_tree, - None, - translation, - menu_bar_state, - ) - } else { - None - } - } - - fn operate( - &mut self, - tree: &mut Tree, - layout: Layout<'_>, - renderer: &Renderer, - operation: &mut dyn iced_core::widget::Operation, - ) { - let inner_tree = if self.context_menu { - &mut tree.children[0] - } else { - tree - }; - w_mut::(&mut self.inner).operate(inner_tree, layout, renderer, operation); - } - - #[cfg(feature = "a11y")] - fn a11y_nodes( - &self, - layout: Layout<'_>, - state: &Tree, - cursor: mouse::Cursor, - ) -> iced_accessibility::A11yTree { - let inner_state = if self.context_menu { - &state.children[0] - } else { - state - }; - w::(&self.inner).a11y_nodes(layout, inner_state, cursor) - } - - fn id(&self) -> Option { - w::(&self.inner).id() - } - - fn set_id(&mut self, id: iced_core::widget::Id) { - w_mut::(&mut self.inner).set_id(id); - } -} - -impl<'a, Message: Clone + 'static> From> for crate::Element<'a, Message> { - fn from(text: Text<'a>) -> Self { - Self::new(text) - } +/// [`Text`]: widget::Text +pub fn text<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { + Text::new(text.into()).font(crate::font::default()) } /// Available presets for text typography @@ -471,115 +24,119 @@ pub enum Typography { Title4, } -/// Creates a new [`Text`] widget with the provided content. -pub fn text<'a>(content: impl Into> + 'a) -> Text<'a> { - Text::new(content.into()).font(crate::font::default()) -} - /// [`Text`] widget with the Title 1 typography preset. -pub fn title1<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn title1<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(35.0) .line_height(LineHeight::Absolute(52.0.into())) .font(crate::font::semibold()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Title 2 typography preset. -pub fn title2<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn title2<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(29.0) .line_height(LineHeight::Absolute(43.0.into())) .font(crate::font::semibold()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Title 3 typography preset. -pub fn title3<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn title3<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(24.0) .line_height(LineHeight::Absolute(36.0.into())) .font(crate::font::bold()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Title 4 typography preset. -pub fn title4<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn title4<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(20.0) .line_height(LineHeight::Absolute(30.0.into())) .font(crate::font::bold()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Heading typography preset. -pub fn heading<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn heading<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(14.0) .line_height(LineHeight::Absolute(iced::Pixels(21.0))) .font(crate::font::bold()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Caption Heading typography preset. -pub fn caption_heading<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn caption_heading<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(12.0) .line_height(LineHeight::Absolute(iced::Pixels(17.0))) .font(crate::font::semibold()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Body typography preset. -pub fn body<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn body<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(14.0) .line_height(LineHeight::Absolute(21.0.into())) .font(crate::font::default()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Caption typography preset. -pub fn caption<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn caption<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(12.0) .line_height(LineHeight::Absolute(17.0.into())) .font(crate::font::default()) } - inner(content.into()) + + inner(text.into()) } /// [`Text`] widget with the Monotext typography preset. -pub fn monotext<'a>(content: impl Into> + 'a) -> Text<'a> { +pub fn monotext<'a>(text: impl Into> + 'a) -> Text<'a, crate::Theme, Renderer> { #[inline(never)] - fn inner(text: Cow) -> Text { + fn inner(text: Cow) -> Text { Text::new(text) .size(14.0) .line_height(LineHeight::Absolute(20.0.into())) .font(crate::font::mono()) } - inner(content.into()) + + inner(text.into()) } From 654ae0bf5d7165be7919d8cfd502184bdf0af0a6 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 29 Jun 2026 18:12:16 -0600 Subject: [PATCH 47/58] chore: add typography presets for selectable_text --- src/widget/selectable_text.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs index 824077c7..5bd85ed4 100644 --- a/src/widget/selectable_text.rs +++ b/src/widget/selectable_text.rs @@ -503,3 +503,23 @@ impl<'a, Message: Clone + 'static> From> for crate::Element<' pub fn selectable_text<'a>(content: impl Into> + 'a) -> SelectableText<'a> { SelectableText::new(content.into()).font(crate::font::default()) } + +// Typography presets, mirroring [`crate::widget::text`] +macro_rules! selectable_preset { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + pub fn $name<'a>(content: impl Into> + 'a) -> SelectableText<'a> { + SelectableText::from(crate::widget::text::$name(content)) + } + }; +} + +selectable_preset!(title1); +selectable_preset!(title2); +selectable_preset!(title3); +selectable_preset!(title4); +selectable_preset!(heading); +selectable_preset!(caption_heading); +selectable_preset!(body); +selectable_preset!(caption); +selectable_preset!(monotext); From 03a66751ea5356909ee1fad4ded5fd368eb98be4 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 20 Jul 2026 11:25:44 -0400 Subject: [PATCH 48/58] chore: feature gates --- src/app/cosmic.rs | 8 ++++---- src/widget/selectable_text.rs | 4 ++-- src/widget/text_context_menu.rs | 32 ++++++++++++++++---------------- src/widget/text_editor.rs | 4 ++-- src/widget/text_input/input.rs | 6 +++--- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index 5a83f66e..b559ab4a 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -535,14 +535,14 @@ where // popup pipeline. Drained before the creation queue so a // destroy-then-recreate (a second right-click reusing the same id) // keeps its order. - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(all(wayland_platform, target_os = "linux"))] for id in crate::widget::text_context_menu::take_popup_destroys() { task = task.chain(iced_winit::commands::popup::destroy_popup(id)); } // Drain any text context-menu popup requests queued by widgets during // `app.update()` and create them through the normal popup pipeline. - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(all(wayland_platform, target_os = "linux"))] for req in crate::widget::text_context_menu::take_popup_requests() { let (settings, view) = crate::widget::text_context_menu::into_popup_view::(req); @@ -723,7 +723,7 @@ where // Drives the text context-menu popup queues: a right-click queues a // popup but publishes no message, so this re-emits `Action::None` to // make `update()` run and drain the queue. - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(all(wayland_platform, target_os = "linux"))] subscriptions.push(crate::widget::text_context_menu::wake_subscription::< T::Message, >()); @@ -743,7 +743,7 @@ where #[cfg(feature = "multi-window")] pub fn view(&self, id: window::Id) -> Element<'_, crate::Action> { - #[cfg(all(feature = "wayland", target_os = "linux"))] + #[cfg(all(wayland_platform, target_os = "linux"))] if let Some((_, _, _, Some(v))) = self.surface_views.get(&id) { return v(&self.app); } diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs index 5bd85ed4..4b47cfd4 100644 --- a/src/widget/selectable_text.rs +++ b/src/widget/selectable_text.rs @@ -69,7 +69,7 @@ impl<'a> SelectableText<'a> { } fn uses_popup_context_menu(&self) -> bool { - #[cfg(all(feature = "wayland", feature = "winit"))] + #[cfg(all(wayland_platform, feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) @@ -329,7 +329,7 @@ impl<'a, Message: Clone + 'static> Widget for S viewport, ); - #[cfg(feature = "wayland")] + #[cfg(wayland_platform)] if self.uses_popup_context_menu() { use iced_core::widget::text::HasSelectableText; diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index 7912e37c..6fdea222 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -51,13 +51,13 @@ thread_local! { static CURRENT_WINDOW_ID: Cell = const { Cell::new(iced_core::window::Id::NONE) }; } -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] use iced_runtime::platform_specific::wayland::popup::SctkPopupSettings; /// A request to create a text context-menu popup surface, queued by a widget /// during `update()` and drained by `Cosmic::update()` so the popup goes /// through the normal `get_popup()` Task + `surface_views` pipeline. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] pub(crate) struct PopupRequest { settings: SctkPopupSettings, menu: Menu<'static, TextCtxAction>, @@ -65,7 +65,7 @@ pub(crate) struct PopupRequest { pending_action: PendingAction, } -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] thread_local! { static PENDING_POPUP_REQUESTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; @@ -80,7 +80,7 @@ pub(crate) fn current_window_id() -> iced_core::window::Id { } /// Drains all popup requests queued by widgets this frame. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] pub(crate) fn take_popup_requests() -> Vec { PENDING_POPUP_REQUESTS.with(|q| std::mem::take(&mut *q.borrow_mut())) } @@ -88,7 +88,7 @@ pub(crate) fn take_popup_requests() -> Vec { /// Consumes a [`PopupRequest`], returning the popup settings plus a view /// builder. The builder rebuilds the menu element each frame from the /// captured content, independent of app state. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] #[allow(clippy::type_complexity)] pub(crate) fn into_popup_view( req: PopupRequest, @@ -117,7 +117,7 @@ pub(crate) fn into_popup_view( (settings, view) } -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] thread_local! { static PENDING_POPUP_DESTROYS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; @@ -129,22 +129,22 @@ thread_local! { /// inside `update()` can't reach `Cosmic` to issue a Task, so they push the /// id here and `Cosmic::update()` drains it into a `destroy_popup` Task that /// flows through the normal surface pipeline. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] fn queue_destroy_popup(id: iced_core::window::Id) { PENDING_POPUP_DESTROYS.with(|q| q.borrow_mut().push(id)); wake_runtime(); } -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] static WAKE_TX: std::sync::OnceLock> = std::sync::OnceLock::new(); /// Stable identity for [`wake_subscription`]. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] struct PopupWake; /// Nudges the runtime so `Cosmic::update()` runs and drains the popup queues. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] fn wake_runtime() { if let Some(tx) = WAKE_TX.get() { let _ = tx.clone().try_send(()); @@ -155,7 +155,7 @@ fn wake_runtime() { /// wake channel and re-emits each ping as [`crate::Action::None`]. Add it to /// the app's subscriptions (done by `Cosmic::subscription`) so popup creation /// and teardown queued from widget `update()` get drained promptly. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] pub(crate) fn wake_subscription() -> iced_futures::Subscription> { use iced_futures::futures::{SinkExt, StreamExt}; @@ -466,7 +466,7 @@ where /// Pushes a [`PopupRequest`] onto the request queue; `Cosmic::update()` /// drains it and creates the popup through `get_popup()`, so it flows /// through the normal Task + `surface_views` pipeline. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] pub(crate) fn create_text_context_popup( click_position: Point, selected_text: Option, @@ -602,7 +602,7 @@ pub(crate) fn create_text_context_popup( /// Dismisses this widget's open context-menu popup on an outside click, /// touch, or Escape. -#[cfg(feature = "wayland")] +#[cfg(wayland_platform)] pub(crate) fn dismiss_popup_on_event(menu_bar_state: &MenuBarState, event: &event::Event) { let is_dismiss = matches!( event, @@ -710,7 +710,7 @@ impl iced_core::widget::Widget, viewport: &Rectangle, ) { - #[cfg(feature = "wayland")] + #[cfg(wayland_platform)] { use iced_core::event::wayland::PopupEvent; let popup_event = match event { @@ -736,7 +736,7 @@ impl iced_core::widget::Widget iced_core::widget::Widget TextEditor<'a, Message> { } fn uses_popup_context_menu(&self) -> bool { - #[cfg(all(feature = "wayland", feature = "winit"))] + #[cfg(all(wayland_platform, feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) @@ -275,7 +275,7 @@ impl<'a, Message: Clone + 'static> Widget bool { - #[cfg(all(feature = "wayland", feature = "winit"))] + #[cfg(all(wayland_platform, feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) @@ -919,7 +919,7 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { - #[cfg(all(feature = "wayland", feature = "winit"))] + #[cfg(all(wayland_platform, feature = "winit"))] if self.uses_popup_context_menu() { let menu_bar_state = tree.state.downcast_ref::().menu_bar_state.clone(); crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); @@ -1036,7 +1036,7 @@ where ); // On Wayland: if right-click just set context_menu_position, create a popup instead. - #[cfg(all(feature = "wayland", feature = "winit"))] + #[cfg(all(wayland_platform, feature = "winit"))] if matches!( crate::app::cosmic::WINDOWING_SYSTEM.get(), Some(crate::app::cosmic::WindowingSystem::Wayland) From b0b9bf16f4681de3f1a8617d35af2311ee7181dc Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 20 Jul 2026 11:26:46 -0400 Subject: [PATCH 49/58] fix: multi-window example headerbar the headerbar is transparent, but a wrapping container applies the right color --- examples/multi-window/src/window.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/examples/multi-window/src/window.rs b/examples/multi-window/src/window.rs index a6d40d36..c6e69d7e 100644 --- a/examples/multi-window/src/window.rs +++ b/examples/multi-window/src/window.rs @@ -5,7 +5,7 @@ use cosmic::iced::core::{id, Alignment, Length, Point}; use cosmic::iced::widget::{column, container, scrollable, text}; use cosmic::iced::{self, event, window, Subscription}; use cosmic::prelude::*; -use cosmic::widget::{button, header_bar}; +use cosmic::widget::{button, header_bar, selectable_text}; #[derive(Debug, Clone, PartialEq)] pub enum Message { @@ -132,10 +132,14 @@ impl cosmic::Application for MultiWindow { let new_window_button = button::custom(text("New Window")).on_press(Message::NewWindow); let content = scrollable( - column![input, new_window_button] - .spacing(50) - .width(Length::Fill) - .align_x(Alignment::Center), + column![ + input, + new_window_button, + selectable_text::title1("I'm selectable...") + ] + .spacing(50) + .width(Length::Fill) + .align_x(Alignment::Center), ); let window_content = container(container(content).center_x(Length::Fixed(200.))) @@ -146,7 +150,14 @@ impl cosmic::Application for MultiWindow { if id == self.core.main_window_id().unwrap() { window_content.into() } else { - column![header_bar().focused(focused), window_content].into() + container(column![ + header_bar() + .on_close(Message::CloseWindow(id)) + .focused(focused), + window_content + ]) + .class(cosmic::theme::Container::WindowBackground) + .into() } } From 324ef6e9056b540346c0de141fd2c6ffcd2d80dc Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 20 Jul 2026 14:46:35 -0400 Subject: [PATCH 50/58] chore: update selectable text menu corner radius --- src/app/cosmic.rs | 4 ++-- src/widget/text_context_menu.rs | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index b559ab4a..d1fffa33 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -544,11 +544,11 @@ where // `app.update()` and create them through the normal popup pipeline. #[cfg(all(wayland_platform, target_os = "linux"))] for req in crate::widget::text_context_menu::take_popup_requests() { - let (settings, view) = + let (live_settings, settings, view) = crate::widget::text_context_menu::into_popup_view::(req); task = task.chain(self.get_popup( settings, - Box::new(|_| LiveSettings::default()), + Box::new(move |_| live_settings), Some(Box::new(move |_| view())), )); } diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index 6fdea222..11df26d7 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -15,8 +15,14 @@ //! the context menu as a native popup surface. pub use iced_core::widget::text::HasSelectableText; +#[cfg(wayland_platform)] +use iced_runtime::platform_specific::wayland::CornerRadius; +#[cfg(wayland_platform)] +use crate::surface::action::LiveSettings; use crate::widget::RcElementWrapper; +#[cfg(wayland_platform)] +use crate::widget::menu::StyleSheet; use crate::widget::menu::{ self, CloseCondition, ItemHeight, ItemWidth, Menu, MenuBarState, PathHighlight, menu_roots_diff, }; @@ -59,6 +65,7 @@ use iced_runtime::platform_specific::wayland::popup::SctkPopupSettings; /// through the normal `get_popup()` Task + `surface_views` pipeline. #[cfg(wayland_platform)] pub(crate) struct PopupRequest { + live_settings: LiveSettings, settings: SctkPopupSettings, menu: Menu<'static, TextCtxAction>, selected_text: Option, @@ -93,6 +100,7 @@ pub(crate) fn take_popup_requests() -> Vec { pub(crate) fn into_popup_view( req: PopupRequest, ) -> ( + LiveSettings, SctkPopupSettings, Box crate::Element<'static, crate::Action> + Send + Sync>, ) { @@ -101,6 +109,7 @@ pub(crate) fn into_popup_view( menu, selected_text, pending_action, + live_settings, } = req; let view = Box::new(move || { @@ -114,7 +123,7 @@ pub(crate) fn into_popup_view( .map(crate::action::app) }); - (settings, view) + (live_settings, settings, view) } #[cfg(wayland_platform)] @@ -589,12 +598,25 @@ pub(crate) fn create_text_context_popup( input_zone: None, }; + let t = crate::theme::THEME.lock().unwrap(); + let styling = t.appearance(&crate::theme::menu_bar::MenuBarStyle::Default, false); + drop(t); + let rad = styling.menu_border_radius; PENDING_POPUP_REQUESTS.with(|q| { q.borrow_mut().push(PopupRequest { settings, menu: popup_menu, selected_text, pending_action: pending_action.clone(), + live_settings: LiveSettings { + corners: Some(CornerRadius { + top_left: rad[0] as u32, + top_right: rad[1] as u32, + bottom_left: rad[2] as u32, + bottom_right: rad[3] as u32, + }), + ..Default::default() + }, }); }); wake_runtime(); From 8a017a15ee7753241c0a631b4294a68edf79b13b Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 5 Aug 2026 21:20:51 +0200 Subject: [PATCH 51/58] fix: window id handling for multi-window --- src/app/cosmic.rs | 3 +-- src/widget/selectable_text.rs | 13 ++++++++++++- src/widget/text_context_menu.rs | 11 ++++++++--- src/widget/text_editor.rs | 11 +++++++++-- src/widget/text_input/input.rs | 9 ++++++++- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/app/cosmic.rs b/src/app/cosmic.rs index d1fffa33..ce8a4154 100644 --- a/src/app/cosmic.rs +++ b/src/app/cosmic.rs @@ -743,6 +743,7 @@ where #[cfg(feature = "multi-window")] pub fn view(&self, id: window::Id) -> Element<'_, crate::Action> { + crate::widget::text_context_menu::set_current_window_id(id); #[cfg(all(wayland_platform, target_os = "linux"))] if let Some((_, _, _, Some(v))) = self.surface_views.get(&id) { return v(&self.app); @@ -756,8 +757,6 @@ where return self.app.view_window(id).map(crate::Action::App); } - crate::widget::text_context_menu::set_current_window_id(id); - let view = if self.app.core().window.use_template { self.app.view_main() } else { diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs index 4b47cfd4..1b65d774 100644 --- a/src/widget/selectable_text.rs +++ b/src/widget/selectable_text.rs @@ -1,6 +1,7 @@ use crate::Renderer; use crate::widget::menu::MenuBarState; +use iced::window; use iced_core::event::Event; use iced_core::text::LineHeight; use iced_core::widget::Widget; @@ -30,6 +31,7 @@ pub struct SelectableText<'a> { inner: InnerText<'a>, selectable: bool, context_menu: bool, + window_id: window::Id, } impl<'a> SelectableText<'a> { @@ -41,6 +43,7 @@ impl<'a> SelectableText<'a> { inner: InnerText::new(content), selectable: true, context_menu: true, + window_id: crate::widget::text_context_menu::current_window_id(), } } @@ -183,6 +186,7 @@ impl<'a> From> for SelectableText<'a> { inner, selectable: true, context_menu: true, + window_id: crate::widget::text_context_menu::current_window_id(), } } } @@ -356,6 +360,7 @@ impl<'a, Message: Clone + 'static> Widget for S renderer, viewport, cursor, + self.window_id, ); self.inner @@ -380,7 +385,11 @@ impl<'a, Message: Clone + 'static> Widget for S let wrapper_state = tree.state.downcast_ref::(); let menu_bar_state = wrapper_state.menu_bar_state.clone(); - crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); + crate::widget::text_context_menu::dismiss_popup_on_event( + &menu_bar_state, + event, + self.window_id, + ); } } else { w_mut::(&mut self.inner).update( @@ -477,6 +486,7 @@ impl<'a, Message: Clone + 'static> From> for crate::Element<' inner, selectable, context_menu, + window_id, } = text; // Apply selection to the inner widget here, at the last moment before // it becomes part of the tree. iced's `Text::selectable()` is one-way @@ -491,6 +501,7 @@ impl<'a, Message: Clone + 'static> From> for crate::Element<' inner, selectable, context_menu, + window_id, }) } } diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index 11df26d7..c156d869 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -16,6 +16,8 @@ pub use iced_core::widget::text::HasSelectableText; #[cfg(wayland_platform)] +use iced_core::window; +#[cfg(wayland_platform)] use iced_runtime::platform_specific::wayland::CornerRadius; #[cfg(wayland_platform)] @@ -486,10 +488,10 @@ pub(crate) fn create_text_context_popup( renderer: &crate::Renderer, viewport: &Rectangle, cursor: mouse::Cursor, + window_id: window::Id, ) { use iced_runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner}; - let window_id = current_window_id(); if window_id == iced_core::window::Id::NONE { return; } @@ -625,7 +627,11 @@ pub(crate) fn create_text_context_popup( /// Dismisses this widget's open context-menu popup on an outside click, /// touch, or Escape. #[cfg(wayland_platform)] -pub(crate) fn dismiss_popup_on_event(menu_bar_state: &MenuBarState, event: &event::Event) { +pub(crate) fn dismiss_popup_on_event( + menu_bar_state: &MenuBarState, + event: &event::Event, + window_id: window::Id, +) { let is_dismiss = matches!( event, event::Event::Mouse(mouse::Event::ButtonPressed( @@ -639,7 +645,6 @@ pub(crate) fn dismiss_popup_on_event(menu_bar_state: &MenuBarState, event: &even return; } - let window_id = current_window_id(); let popup_id = menu_bar_state .inner .with_data(|state| state.popup_id.get(&window_id).copied()); diff --git a/src/widget/text_editor.rs b/src/widget/text_editor.rs index 9c617363..f25cb277 100644 --- a/src/widget/text_editor.rs +++ b/src/widget/text_editor.rs @@ -15,7 +15,7 @@ use iced_core::text::highlighter; use iced_core::widget::Widget; use iced_core::widget::tree::{self, Tree}; use iced_core::{ - Clipboard, Layout, Length, Rectangle, Shell, Size, Vector, mouse, overlay, renderer, + Clipboard, Layout, Length, Rectangle, Shell, Size, Vector, mouse, overlay, renderer, window, }; type InnerEditor<'a, Message> = @@ -24,6 +24,7 @@ type InnerEditor<'a, Message> = pub struct TextEditor<'a, Message> { inner: InnerEditor<'a, Message>, has_context_menu: bool, + window_id: window::Id, } struct EditorWrapperState { @@ -37,6 +38,7 @@ impl<'a, Message: Clone + 'static> TextEditor<'a, Message> { Self { inner: iced_widget::text_editor(content), has_context_menu: true, + window_id: crate::widget::text_context_menu::current_window_id(), } } @@ -300,6 +302,7 @@ impl<'a, Message: Clone + 'static> Widget Widget(); let menu_bar_state = wrapper_state.menu_bar_state.clone(); - crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); + crate::widget::text_context_menu::dismiss_popup_on_event( + &menu_bar_state, + event, + self.window_id, + ); } } else { ew_mut::(&mut self.inner).update( diff --git a/src/widget/text_input/input.rs b/src/widget/text_input/input.rs index d970e435..37db1756 100644 --- a/src/widget/text_input/input.rs +++ b/src/widget/text_input/input.rs @@ -218,6 +218,7 @@ pub struct TextInput<'a, Message> { /// The text input tracks and manages the input value in its state. manage_value: bool, drag_threshold: f32, + window_id: window::Id, } impl<'a, Message> TextInput<'a, Message> @@ -268,6 +269,7 @@ where always_active: false, manage_value: false, drag_threshold: 20.0, + window_id: crate::widget::text_context_menu::current_window_id(), } } @@ -922,7 +924,11 @@ where #[cfg(all(wayland_platform, feature = "winit"))] if self.uses_popup_context_menu() { let menu_bar_state = tree.state.downcast_ref::().menu_bar_state.clone(); - crate::widget::text_context_menu::dismiss_popup_on_event(&menu_bar_state, event); + crate::widget::text_context_menu::dismiss_popup_on_event( + &menu_bar_state, + event, + self.window_id, + ); } let text_layout = self.text_layout(layout); @@ -1062,6 +1068,7 @@ where renderer, viewport, cursor_position, + self.window_id, ); let state = tree.state.downcast_mut::(); From 030a7dbe02dea922a782aa7606d5057b8d1b5be3 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Fri, 7 Aug 2026 13:45:34 -0600 Subject: [PATCH 52/58] fix: request redraw in selectable text and context menu --- iced | 2 +- src/widget/selectable_text.rs | 1 + src/widget/text_context_menu.rs | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/iced b/iced index f5aa5a6f..7918b282 160000 --- a/iced +++ b/iced @@ -1 +1 @@ -Subproject commit f5aa5a6f8cc2db6e2adbbc140c310a5a4af2f8d2 +Subproject commit 7918b2826ec8ad4f9e0032f6a21286bac431e482 diff --git a/src/widget/selectable_text.rs b/src/widget/selectable_text.rs index 1b65d774..18328c40 100644 --- a/src/widget/selectable_text.rs +++ b/src/widget/selectable_text.rs @@ -377,6 +377,7 @@ impl<'a, Message: Clone + 'static> Widget for S crate::widget::text_context_menu::TextCtxAction::Copy => {} crate::widget::text_context_menu::TextCtxAction::SelectAll => { self.inner.select_all(&mut tree.children[0]); + shell.request_redraw(); } _ => {} } diff --git a/src/widget/text_context_menu.rs b/src/widget/text_context_menu.rs index c156d869..2e908331 100644 --- a/src/widget/text_context_menu.rs +++ b/src/widget/text_context_menu.rs @@ -404,6 +404,7 @@ where state.open = false; }); self.widget.set_context_menu_position(self.tree, None); + shell.request_redraw(); } return; } @@ -450,6 +451,8 @@ where } } self.widget.set_context_menu_position(self.tree, None); + // The menu closes and the selection may have changed. + shell.request_redraw(); } let is_open = self.menu.tree.inner.with_data(|state| state.open); From 633e6ab83fa019a75f7c7c94c55d04dc5650bd7b Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 10 Aug 2026 10:13:19 -0600 Subject: [PATCH 53/58] fix: request redraw if hovered state changes in segmented_button --- src/widget/segmented_button/widget.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index ca877348..29c26362 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -1244,6 +1244,8 @@ where let my_bounds = layout.bounds(); let state = tree.state.downcast_mut::(); + let hovered_before = state.hovered; + let my_id = self.get_drag_id(); if let Event::Dnd(e) = &mut event { @@ -1916,6 +1918,10 @@ where shell.capture_event(); } } + + if hovered_before != state.hovered { + shell.request_redraw(); + } } fn operate( @@ -2764,7 +2770,7 @@ pub struct LocalState { drop_hint: Option, } -#[derive(Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] enum Item { NextButton, #[default] From 5da2e943379fd460561002cadf62243886ea972d Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 10 Aug 2026 12:12:18 -0600 Subject: [PATCH 54/58] fix: request redraw when menu item hover state changes --- src/widget/dropdown/multi/menu.rs | 23 ++++++++++------------- src/widget/menu/menu_inner.rs | 5 +++++ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/widget/dropdown/multi/menu.rs b/src/widget/dropdown/multi/menu.rs index 689fb0de..e699890b 100644 --- a/src/widget/dropdown/multi/menu.rs +++ b/src/widget/dropdown/multi/menu.rs @@ -366,7 +366,8 @@ where let mut current_offset = 0.0; - let previous_hover_option = self.hovered_option.take(); + let previous_hover_option = self.hovered_option.clone(); + *self.hovered_option = None; for (element, elem_height) in self.options.elements().zip(heights) { let bounds = Rectangle { @@ -377,27 +378,23 @@ where }; if bounds.contains(cursor_position) { - *self.hovered_option = if let OptionElement::Option((_, item)) = element - { - if previous_hover_option.as_ref() == Some(item) { - previous_hover_option - } else { - shell.request_redraw(); - + if let OptionElement::Option((_, item)) = element { + *self.hovered_option = Some(item.clone()); + if previous_hover_option.as_ref() != Some(item) { if let Some(on_option_hovered) = self.on_option_hovered { shell.publish(on_option_hovered(item.clone())); } - - Some(item.clone()) } - } else { - None - }; + } break; } current_offset += elem_height; } + + if *self.hovered_option != previous_hover_option { + shell.request_redraw(); + } } } Event::Touch(touch::Event::FingerPressed { .. }) => { diff --git a/src/widget/menu/menu_inner.rs b/src/widget/menu/menu_inner.rs index 4ea71bc4..291d7db0 100644 --- a/src/widget/menu/menu_inner.rs +++ b/src/widget/menu/menu_inner.rs @@ -634,6 +634,10 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> { shell, ); + if view_cursor.is_over(viewport) { + shell.request_redraw(); + } + if self.is_overlay && view_cursor.is_over(viewport) { shell.capture_event(); } @@ -1699,6 +1703,7 @@ fn process_scroll_events( } } shell.capture_event(); + shell.request_redraw(); }); } From 06337516337d3bb6e7e47bdf325013f366b1bd6d Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 10 Aug 2026 12:22:38 -0600 Subject: [PATCH 55/58] fix: request redraw if pressed state changes in button widget --- src/widget/button/widget.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/widget/button/widget.rs b/src/widget/button/widget.rs index f5501134..91e88624 100644 --- a/src/widget/button/widget.rs +++ b/src/widget/button/widget.rs @@ -806,6 +806,7 @@ pub fn update<'a, Message: Clone>( if cursor.is_over(bounds) { state.is_pressed = true; + shell.request_redraw(); if let Some(on_press_down) = on_press_down { let msg = (on_press_down)(layout.virtual_offset(), layout.bounds()); @@ -837,7 +838,10 @@ pub fn update<'a, Message: Clone>( } } else if on_press_down.is_some() { let state = state(); - state.is_pressed = false; + if state.is_pressed { + state.is_pressed = false; + shell.request_redraw(); + } } } #[cfg(feature = "a11y")] From 1f8d8786940cc6db59d412153c5a03f69a20375c Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 10 Aug 2026 12:25:40 -0600 Subject: [PATCH 56/58] fix: request redraw when hover state changes in toggle widget --- src/widget/toggler.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/widget/toggler.rs b/src/widget/toggler.rs index a610747c..5c36a5b6 100644 --- a/src/widget/toggler.rs +++ b/src/widget/toggler.rs @@ -266,6 +266,17 @@ impl<'a, Message> Widget for Toggler<'a, shell.request_redraw(); } } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + let toggler_bounds = layout + .children() + .nth(1) + .map_or_else(|| layout.bounds(), |l| l.bounds()); + let hovered = cursor_position.is_over(toggler_bounds); + if state.hovered != hovered { + state.hovered = hovered; + shell.request_redraw(); + } + } _ => {} } } @@ -442,4 +453,5 @@ pub struct State { text: widget::text::State<::Paragraph>, anim: anim::State, prev_toggled: bool, + hovered: bool, } From 75e9ee7e65a00f168ac8b1123d69d2d648dc388c Mon Sep 17 00:00:00 2001 From: Paul <33877645+iampaulmata@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:51:29 -0400 Subject: [PATCH 57/58] fix: request redraw on dropdown open/close in the in-window overlay path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Dropdown::new` (the plain constructor used by any non-applet consumer) leaves `window_id`/`on_surface_action` unset, which routes the widget's `overlay()` impl to the in-window overlay path rather than the surface-action-based popup path. On that path, `update()`'s `open` closure and its close counterparts only ever requested a redraw inside the `#[cfg(wayland_platform)] if let Some(...)` block gated on those fields being set — so for the common case, opening or closing a dropdown flipped `is_open` with no redraw requested at all. The stale frame stuck around until some unrelated later event (e.g. cursor motion landing on the now-open/closed menu's bounds) forced a repaint, making the menu appear to not open until the mouse moved, and not close until the same thing happened again on the way out. Add an unconditional `shell.request_redraw()` at each of the three `is_open` transitions that don't already publish a Message of their own (an actual option selection already triggers a normal Message-driven redraw through the app's own update cycle, so that path needed no change): - `open`: right after `is_open` is set true, ahead of the wayland-only surface-action block. - the `close_operation` branch of the open/close operation state machine (`Id`-based programmatic close). - the click-outside-closes-it branch of the mouse/touch press handler. Fixes #1395 --- src/widget/dropdown/widget.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index 696e6c0d..8d80fb4a 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -661,6 +661,7 @@ pub fn update< state.close_operation = false; state.is_open.store(false, Ordering::SeqCst); if is_open { + shell.request_redraw(); #[cfg(wayland_platform)] if let Some(ref on_close) = on_surface_action { shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); @@ -684,6 +685,7 @@ pub fn update< // Event wasn't processed by overlay, so cursor was clicked either outside it's // bounds or on the drop-down, either way we close the overlay. state.is_open.store(false, Ordering::Relaxed); + shell.request_redraw(); #[cfg(wayland_platform)] if let Some(on_close) = on_surface_action { shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); From a5267e623f356d1792cba7f480dd671cc8b52406 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 18 Aug 2026 18:39:52 -0600 Subject: [PATCH 58/58] fix: request redraw on hover state change in dropdown widget This also add a missing request when dropdown opens --- src/widget/dropdown/menu/mod.rs | 15 +++++++++++++++ src/widget/dropdown/multi/menu.rs | 9 +++++++++ src/widget/dropdown/widget.rs | 1 + 3 files changed, 25 insertions(+) diff --git a/src/widget/dropdown/menu/mod.rs b/src/widget/dropdown/menu/mod.rs index f18016d8..f6fbb218 100644 --- a/src/widget/dropdown/menu/mod.rs +++ b/src/widget/dropdown/menu/mod.rs @@ -508,6 +508,21 @@ where } *hovered_guard = Some(new_hovered_option); + } else { + let mut hovered_guard = self.hovered_option.lock().unwrap(); + + if hovered_guard.is_some() { + *hovered_guard = None; + shell.request_redraw(); + } + } + } + Event::Mouse(mouse::Event::CursorLeft) => { + let mut hovered_guard = self.hovered_option.lock().unwrap(); + + if hovered_guard.is_some() { + *hovered_guard = None; + shell.request_redraw(); } } Event::Touch(touch::Event::FingerPressed { .. }) => { diff --git a/src/widget/dropdown/multi/menu.rs b/src/widget/dropdown/multi/menu.rs index e699890b..fe1369c3 100644 --- a/src/widget/dropdown/multi/menu.rs +++ b/src/widget/dropdown/multi/menu.rs @@ -395,6 +395,15 @@ where if *self.hovered_option != previous_hover_option { shell.request_redraw(); } + } else if self.hovered_option.is_some() { + *self.hovered_option = None; + shell.request_redraw(); + } + } + Event::Mouse(mouse::Event::CursorLeft) => { + if self.hovered_option.is_some() { + *self.hovered_option = None; + shell.request_redraw(); } } Event::Touch(touch::Event::FingerPressed { .. }) => { diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index 8d80fb4a..e38be851 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -567,6 +567,7 @@ pub fn update< state: &mut State, on_selected: Arc Message + Send + Sync + 'static>| { state.is_open.store(true, Ordering::Relaxed); + shell.request_redraw(); let mut hovered_guard = state.hovered_option.lock().unwrap(); *hovered_guard = selected; let id = window::Id::unique();