Merge upstream/master (COSMIC 1.6) into main

Aligne le fork sur epoch-1.6.0 (63 commits amont) en preservant les 19
commits yoda.

Resolutions notables :
- les cfg `all(feature = "wayland", target_os = "linux")` deviennent
  l alias `wayland_platform` introduit en amont ; il exclut winit comme
  le faisait le patch yoda 45eaaf16, l intention est donc preservee ;
- les features iced `cctk` ont ete renommees `wayland` en amont, la
  feature `wayland` locale est adaptee ; `winit` reste un marqueur vide
  et `iced/winit` est liste explicitement ;
- cctk/ashpd gardent leurs chemins locaux dans la section conditionnelle
  unix introduite en amont ;
- la garde anti-crash c0b653f5 (context_menu non assigne) est conservee
  par-dessus la nouvelle structure amont ;
- traductions ca/oc restaurees depuis amont.

Le submodule iced suit (merge + correctif du cfg cctk residuel).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lionel DARNIS 2026-08-19 21:55:00 +02:00
commit 9535ada4d0
60 changed files with 3187 additions and 1528 deletions

View file

@ -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,11 +71,11 @@ 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")]
#[cfg(xdg_portal)]
DesktopSettings(crate::theme::portal::Desktop),
}

View file

@ -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;
@ -157,8 +157,8 @@ where
) -> iced::Task<crate::Action<T::Message>> {
#[cfg(feature = "surface-message")]
match _surface_message {
#[cfg(all(feature = "wayland", target_os = "linux"))]
crate::surface::Action::AppSubsurface(settings, _live_settings, view) => {
#[cfg(wayland_platform)]
crate::surface::Action::AppSubsurface(settings, live_settings, view) => {
let Some(settings) = std::sync::Arc::try_unwrap(settings)
.ok()
.and_then(|s| s.downcast::<Box<dyn Fn(&mut T) -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + Send + Sync>>().ok()) else {
@ -184,8 +184,8 @@ where
});
self.get_subsurface(settings, view.map(|v| *v))
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
crate::surface::Action::Subsurface(settings, _live_settings, view) => {
#[cfg(wayland_platform)]
crate::surface::Action::Subsurface(settings, live_settings, view) => {
let Some(settings) = std::sync::Arc::try_unwrap(settings)
.ok()
.and_then(|s| s.downcast::<Box<dyn Fn() -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings + Send + Sync>>().ok()) else {
@ -211,7 +211,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()
@ -248,11 +248,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")]
{
@ -263,11 +263,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,
@ -278,7 +278,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()
@ -317,7 +317,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::<Box<dyn Fn(&mut T) -> iced::window::Settings + Send + Sync>>()
@ -355,7 +355,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::<Box<dyn Fn() -> iced::window::Settings + Send + Sync>>()
@ -412,7 +412,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()
@ -450,7 +450,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()
@ -490,7 +490,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)
}
@ -512,7 +512,7 @@ where
&mut self,
message: crate::Action<T::Message>,
) -> iced::Task<crate::Action<T::Message>> {
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(),
@ -529,10 +529,33 @@ 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(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(wayland_platform, target_os = "linux"))]
for req in crate::widget::text_context_menu::take_popup_requests() {
let (live_settings, settings, view) =
crate::widget::text_context_menu::into_popup_view::<T::Message>(req);
task = task.chain(self.get_popup(
settings,
Box::new(move |_| live_settings),
Some(Box::new(move |_| view())),
));
}
#[cfg(all(target_env = "gnu", not(target_os = "windows")))]
crate::malloc::trim(0);
message
task
}
#[cfg(not(feature = "multi-window"))]
@ -548,16 +571,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(),
}
}
}
}
@ -578,7 +601,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)
@ -591,7 +614,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,
)) => {
@ -677,7 +700,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),
@ -696,6 +719,14 @@ where
subscriptions.push(crate::dbus_activation::subscription::<T>());
}
// 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(wayland_platform, target_os = "linux"))]
subscriptions.push(crate::widget::text_context_menu::wake_subscription::<
T::Message,
>());
Subscription::batch(subscriptions)
}
@ -711,6 +742,8 @@ where
#[cfg(feature = "multi-window")]
pub fn view(&self, id: window::Id) -> Element<'_, crate::Action<T::Message>> {
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);
}
@ -737,6 +770,9 @@ where
#[cfg(not(feature = "multi-window"))]
pub fn view(&self) -> Element<crate::Action<T::Message>> {
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")))]
@ -760,8 +796,8 @@ impl<T: Application> Cosmic<T> {
#[allow(clippy::too_many_lines)]
fn cosmic_update(&mut self, message: Action) -> iced::Task<crate::Action<T::Message>> {
match message {
Action::WindowMaximized(_id, _maximized) => {
#[cfg(not(all(feature = "wayland", target_os = "linux")))]
Action::WindowMaximized(id, maximized) => {
#[cfg(not(wayland_platform))]
if self
.app
.core()
@ -791,7 +827,7 @@ impl<T: Application> Cosmic<T> {
});
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
Action::WindowState(id, state) => {
if self
.app
@ -823,7 +859,7 @@ impl<T: Application> Cosmic<T> {
}
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
Action::WmCapabilities(id, capabilities) => {
if self
.app
@ -895,14 +931,7 @@ impl<T: Application> Cosmic<T> {
}
}
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();
@ -910,7 +939,7 @@ impl<T: Application> Cosmic<T> {
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());
@ -940,20 +969,13 @@ impl<T: Application> Cosmic<T> {
}
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();
}
// 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());
@ -985,7 +1007,7 @@ impl<T: Application> Cosmic<T> {
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;
@ -1020,6 +1042,29 @@ impl<T: Application> Cosmic<T> {
cmds.push(corner_radius(*id, cur_rad).discard());
}
let blur = if new_blur {
iced::window::enable_blur
} else {
iced::window::disable_blur
};
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);
}
}
@ -1077,14 +1122,7 @@ impl<T: Application> Cosmic<T> {
} 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();
@ -1095,7 +1133,7 @@ impl<T: Application> Cosmic<T> {
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;
@ -1169,7 +1207,7 @@ impl<T: Application> Cosmic<T> {
// 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(
@ -1180,7 +1218,7 @@ impl<T: Application> Cosmic<T> {
);
}
#[cfg(not(all(feature = "wayland", target_os = "linux")))]
#[cfg(not(wayland_platform))]
{
task = task.chain(iced_runtime::window::gain_focus(id));
}
@ -1226,7 +1264,7 @@ impl<T: Application> Cosmic<T> {
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 {
@ -1259,14 +1297,7 @@ impl<T: Application> Cosmic<T> {
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();
@ -1277,7 +1308,7 @@ impl<T: Application> Cosmic<T> {
// 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
@ -1308,7 +1339,7 @@ impl<T: Application> Cosmic<T> {
}
}
}
#[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);
@ -1337,7 +1368,7 @@ impl<T: Application> Cosmic<T> {
}
}
}
#[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
}
@ -1354,12 +1385,7 @@ impl<T: Application> Cosmic<T> {
}
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(_),
@ -1404,7 +1430,7 @@ impl<T: Application> Cosmic<T> {
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;
@ -1422,14 +1448,7 @@ impl<T: Application> Cosmic<T> {
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);
@ -1487,20 +1506,13 @@ impl<T: Application> Cosmic<T> {
}
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;
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;
@ -1567,7 +1579,7 @@ impl<App: Application> Cosmic<App> {
if matches!(id_wrapper, SurfaceIdWrapper::Window(_)) {
window::enable_blur(id)
} else {
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
{
iced_winit::commands::blur::blur(
id,
@ -1578,7 +1590,7 @@ impl<App: Application> Cosmic<App> {
)
.discard()
}
#[cfg(not(all(feature = "wayland", target_os = "linux")))]
#[cfg(not(wayland_platform))]
{
iced::window::enable_blur(id)
}
@ -1586,11 +1598,11 @@ impl<App: Application> Cosmic<App> {
} else if matches!(id_wrapper, SurfaceIdWrapper::Window(_)) {
window::disable_blur(id)
} else {
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
{
iced_winit::commands::blur::blur(id, None).discard()
}
#[cfg(not(all(feature = "wayland", target_os = "linux")))]
#[cfg(not(wayland_platform))]
{
iced::window::disable_blur(id)
}
@ -1600,7 +1612,7 @@ impl<App: Application> Cosmic<App> {
cmds.push(if matches!(id_wrapper, SurfaceIdWrapper::Window(_)) {
window::enable_blur(id)
} else {
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
{
iced_winit::commands::blur::blur(
id,
@ -1611,13 +1623,13 @@ impl<App: Application> Cosmic<App> {
)
.discard()
}
#[cfg(not(all(feature = "wayland", target_os = "linux")))]
#[cfg(not(wayland_platform))]
{
iced::window::enable_blur(id)
}
});
}
#[cfg(all(feature = "wayland", 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(),
@ -1633,7 +1645,7 @@ impl<App: Application> Cosmic<App> {
);
}
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
if let (SurfaceIdWrapper::LayerSurface(id), Some(padding)) =
(id_wrapper, live_settings.padding)
{
@ -1644,7 +1656,7 @@ impl<App: Application> Cosmic<App> {
Task::batch(cmds)
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
/// Create a subsurface
pub fn get_subsurface(
&mut self,
@ -1659,7 +1671,7 @@ impl<App: Application> Cosmic<App> {
let live_settings_task = self.apply_live_settings(
SurfaceIdWrapper::Subsurface(settings.id),
&LiveSettings {
blur: Some(self.blur_enabled),
blur: None,
corners: None,
padding: None,
},
@ -1676,7 +1688,7 @@ impl<App: Application> Cosmic<App> {
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,
@ -1704,7 +1716,7 @@ impl<App: Application> Cosmic<App> {
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,
@ -1741,7 +1753,7 @@ impl<App: Application> Cosmic<App> {
])
}
#[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,
@ -1770,7 +1782,7 @@ impl<App: Application> Cosmic<App> {
}
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
fn corners(
surface_type: SurfaceIdWrapper,
rounded: bool,

View file

@ -390,12 +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 = "surface-message"
))]
#[cfg(wayland_platform)]
{
nav = nav
.window_id_maybe(self.core().main_window_id())
@ -624,21 +619,23 @@ impl<App: Application> 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({
@ -833,11 +830,7 @@ impl<App: Application> 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: [

View file

@ -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,

View file

@ -1,7 +1,7 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
use std::os::fd::AsFd;
use iced::window;
@ -45,7 +45,7 @@ pub fn toggle_maximize<M>(id: window::Id) -> iced::Task<crate::Action<M>> {
iced_runtime::window::toggle_maximize(id)
}
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
pub fn file_transfer_send(
writeable: bool,
auto_stop: bool,
@ -61,7 +61,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<ashpd::Result<Vec<String>>> {
iced::Task::future(async move {
let file_transfer = ashpd::documents::FileTransfer::new().await?;

View file

@ -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<u16>,
height: f32,
width: f32,
}
@ -168,9 +168,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")]
@ -512,7 +512,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),
@ -529,6 +529,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<Auto>) {
self.auto_corner_radius = auto_corner_radius;
}
@ -560,13 +574,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),
@ -581,7 +595,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,

View file

@ -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) {
@ -173,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))]
@ -604,28 +612,36 @@ fn candidate_desktop_ids(context: &DesktopLookupContext<'_>) -> Vec<String> {
ordered
}
#[cfg(not(windows))]
fn desktop_entries_with_precedence<'a>(
entries: impl Iterator<Item = fde::DesktopEntry> + 'a,
) -> impl Iterator<Item = fde::DesktopEntry> + '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<Item = DesktopEntryData> + '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 +658,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));
@ -915,6 +942,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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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")
@ -980,14 +1175,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");
@ -1101,30 +1295,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()));
}

View file

@ -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")]

View file

@ -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<String>,
@ -39,17 +39,17 @@ pub struct Dialog {
file_name: Option<String>,
/// 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<super::Choice>,
/// Specifies the default file filter.
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
#[setters(skip)]
current_filter: Option<super::FileFilter>,
@ -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<String>) -> 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<super::Choice>) -> 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<super::FileFilter>) -> 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,

View file

@ -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<String>,
/// 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<PathBuf>,
/// Sets the absolute path of the file
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
#[setters(skip)]
current_file: Option<PathBuf>,
/// Adds a list of choices.
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
#[setters(skip)]
choices: Vec<super::Choice>,
/// Specifies the default file filter.
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
#[setters(skip)]
current_filter: Option<super::FileFilter>,
@ -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<String>) -> 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<super::Choice>) -> 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<super::FileFilter>) -> 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;

View file

@ -3,7 +3,7 @@
//! Create dialogs for retrieving user input.
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
pub use ashpd;
pub mod file_chooser;

View file

@ -133,7 +133,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;
@ -170,7 +170,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;

View file

@ -4,34 +4,34 @@
use super::Action;
use crate::Application;
use iced::window;
#[cfg(all(feature = "wayland", target_os = "linux"))]
use iced::{Rectangle, window};
#[cfg(all(wayland_platform, feature = "winit"))]
use iced_runtime::platform_specific::wayland::CornerRadius;
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[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)
@ -39,17 +39,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"))]
#[cfg(wayland_platform)]
/// Override the surface padding value for the surface type.
pub padding: Option<IcedMargin>,
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
/// Override the default corner radius value for the surface type.
pub corners: Option<CornerRadius>,
/// Override the default blur setting for the surface type.
pub blur: Option<bool>,
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
type BoxedView<App> = Option<
Box<
dyn Fn(&App) -> crate::Element<'_, crate::Action<<App as Application>::Message>>
@ -59,7 +59,7 @@ type BoxedView<App> = Option<
>,
>;
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn app_window<App: Application>(
live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static,
@ -91,7 +91,7 @@ pub fn app_window<App: Application>(
}
/// Used to create a window message from within a widget.
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn simple_window<Message: 'static>(
live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static,
@ -128,7 +128,7 @@ pub fn simple_window<Message: 'static>(
)
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn app_popup<App: Application>(
live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static,
@ -161,7 +161,7 @@ pub fn app_popup<App: Application>(
}
/// Used to create a subsurface message from within a widget.
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn simple_subsurface<Message: 'static>(
settings: impl Fn() -> iced_runtime::platform_specific::wayland::subsurface::SctkSubsurfaceSettings
@ -191,7 +191,7 @@ pub fn simple_subsurface<Message: 'static>(
}
/// Used to create a popup message from within a widget.
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn simple_popup<Message: 'static>(
live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static,
@ -227,7 +227,7 @@ pub fn simple_popup<Message: 'static>(
)
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn subsurface<App: Application>(
settings: impl Fn(
@ -261,7 +261,7 @@ pub fn subsurface<App: Application>(
)
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn simple_layer_shell<Message: 'static>(
live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static,
@ -297,7 +297,7 @@ pub fn simple_layer_shell<Message: 'static>(
)
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn app_layer_shell<App: Application>(
live_settings: impl Fn(&App) -> LiveSettings + Send + Sync + 'static,

View file

@ -45,7 +45,7 @@ pub fn rounded_rect_strips(rect: Rectangle<f32>, radius: CornerRadius) -> Vec<Re
x: strip_x,
y: rect.y + y as f32,
width: strip_w,
height: 1.0,
height: if h > 1. { 2. } else { 1. },
});
}
}
@ -62,9 +62,9 @@ pub fn rounded_rect_strips(rect: Rectangle<f32>, radius: CornerRadius) -> Vec<Re
if strip_w > 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. },
});
}
}

View file

@ -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};

View file

@ -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;

View file

@ -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()),

View file

@ -772,12 +772,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()),
},
@ -792,12 +792,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
@ -806,20 +800,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),
},
@ -1384,18 +1367,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),
}
@ -1541,6 +1525,12 @@ pub enum TextEditor<'a> {
Custom(text_editor::StyleFn<'a, Theme>),
}
impl<'a> From<text_editor::StyleFn<'a, Theme>> 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>;

View file

@ -32,7 +32,7 @@ mod text_input;
#[doc(inline)]
pub use self::text_input::TextInput;
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
pub mod tooltip;
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
pub use tooltip::Tooltip;

View file

@ -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(

View file

@ -797,6 +797,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());
@ -826,7 +827,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")]
@ -855,8 +859,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;
}

View file

@ -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)))
@ -755,6 +757,23 @@ where
fn size(&self) -> Size<Length> {
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)]

View file

@ -3,11 +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 = "surface-message"
))]
#[cfg(wayland_platform)]
use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem};
use crate::widget::menu::{
self, CloseCondition, Direction, ItemHeight, ItemWidth, MenuBarState, PathHighlight,
@ -63,11 +59,7 @@ pub struct ContextMenu<'a, Message> {
}
impl<Message: Clone + 'static> ContextMenu<'_, Message> {
#[cfg(all(
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
#[allow(clippy::too_many_lines)]
fn create_popup(
&mut self,
@ -389,11 +381,7 @@ impl<Message: 'static + Clone> Widget<Message, crate::Theme, crate::Renderer>
state.active_root.clear();
state.open = false;
#[cfg(all(
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
&& let Some(id) = state.popup_id.remove(&self.window_id)
{
@ -432,11 +420,7 @@ impl<Message: 'static + Clone> Widget<Message, crate::Theme, crate::Renderer>
state.open = true;
state.view_cursor = cursor;
});
#[cfg(all(
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) {
self.create_popup(layout, cursor, renderer, shell, viewport, state);
}
@ -453,11 +437,7 @@ impl<Message: 'static + Clone> Widget<Message, crate::Theme, crate::Renderer>
state.active_root.clear();
state.open = false;
#[cfg(all(
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
&& let Some(id) = state.popup_id.remove(&self.window_id)
{
@ -491,11 +471,7 @@ impl<Message: 'static + Clone> Widget<Message, crate::Theme, crate::Renderer>
_viewport: &iced::Rectangle,
translation: Vector,
) -> Option<iced_core::overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
#[cfg(all(
feature = "wayland",
target_os = "linux",
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()

View file

@ -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<Box<dyn Fn(DndAction) -> Message>>,
on_data_received: Option<Box<dyn Fn(String, Vec<u8>) -> Message>>,
on_finish: Option<Box<dyn Fn(String, Vec<u8>, DndAction, f64, f64) -> Message>>,
#[cfg(feature = "xdg-portal")]
#[cfg(xdg_portal)]
on_file_transfer: Option<Box<dyn Fn(String) -> 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") {
@ -508,7 +508,7 @@ impl<Message: 'static> Widget<Message, crate::Theme, crate::Renderer>
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())

View file

@ -496,12 +496,30 @@ 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
&& *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));
}
}
*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 { .. }) => {

View file

@ -50,7 +50,7 @@ pub fn popup_dropdown<
let dropdown: Dropdown<'_, S, Message, AppMessage> =
Dropdown::new(selections.into(), selected, on_selected);
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
let dropdown = dropdown.with_popup(_parent_id, _on_surface_action, _map_action);
dropdown

View file

@ -364,7 +364,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 {
@ -375,25 +376,32 @@ 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 {
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();
}
} 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 { .. }) => {

View file

@ -59,7 +59,7 @@ where
action_map: Option<Arc<dyn Fn(Message) -> AppMessage + 'static + Send + Sync>>,
#[setters(strip_option)]
window_id: Option<window::Id>,
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
}
@ -95,14 +95,14 @@ where
text_line_height: text::LineHeight::Relative(1.2),
font: None,
window_id: None,
#[cfg(all(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 = "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<NewAppMessage>(
@ -153,7 +153,7 @@ where
self
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
pub fn with_positioner(
mut self,
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
@ -267,7 +267,7 @@ where
layout,
cursor,
shell,
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
self.positioner.clone(),
self.on_selected.clone(),
self.selected,
@ -345,7 +345,7 @@ where
_viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
if self.window_id.is_some() || self.on_surface_action.is_some() {
return None;
}
@ -544,8 +544,8 @@ pub fn update<
layout: Layout<'_>,
cursor: mouse::Cursor,
shell: &mut Shell<'_, Message>,
#[cfg(all(feature = "wayland", target_os = "linux"))]
_positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
#[cfg(wayland_platform)]
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
on_selected: Arc<dyn Fn(usize) -> Message + Send + Sync + 'static>,
selected: Option<usize>,
selections: &[S],
@ -566,11 +566,12 @@ pub fn update<
state: &mut State,
on_selected: Arc<dyn Fn(usize) -> 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();
state.popup_id = id;
#[cfg(all(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)
@ -660,7 +661,8 @@ pub fn update<
state.close_operation = false;
state.is_open.store(false, Ordering::SeqCst);
if is_open {
#[cfg(all(feature = "wayland", target_os = "linux"))]
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)));
}
@ -683,7 +685,8 @@ 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 = "wayland", target_os = "linux"))]
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)));
}
@ -728,7 +731,7 @@ pub fn mouse_interaction(layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::In
}
}
#[cfg(all(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<

View file

@ -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<Length>,
@ -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)

View file

@ -9,12 +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 = "surface-message"
))]
#[cfg(wayland_platform)]
use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem};
use crate::style::menu_bar::StyleSheet;
use crate::widget::RcWrapper;
@ -178,11 +173,7 @@ pub struct MenuBar<Message> {
menu_roots: Vec<MenuTree<Message>>,
style: <crate::Theme as StyleSheet>::Style,
window_id: window::Id,
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
pub(crate) on_surface_action:
Option<Arc<dyn Fn(crate::surface::Action) -> Message + Send + Sync + 'static>>,
@ -217,11 +208,7 @@ where
menu_roots,
style: <crate::Theme as StyleSheet>::Style::default(),
window_id: window::Id::RESERVED,
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(),
on_surface_action: None,
}
@ -315,11 +302,7 @@ where
self
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
pub fn with_positioner(
mut self,
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
@ -351,12 +334,7 @@ where
self
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
#[allow(clippy::too_many_lines)]
fn create_popup(
&mut self,
@ -638,11 +616,7 @@ where
state.menu_states.clear();
state.active_root.clear();
state.open = false;
#[cfg(all(
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
{
let surface_action = self.on_surface_action.as_ref().unwrap();
shell.capture_event();
@ -660,12 +634,7 @@ where
return;
}
shell.capture_event();
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
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);
}
@ -673,13 +642,9 @@ where
Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorEntered)
if open && view_cursor.is_over(layout.bounds()) =>
{
shell.request_redraw();
shell.capture_event();
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
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);
}
@ -710,12 +675,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 = "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
@ -769,12 +729,7 @@ where
_viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, crate::Theme, Renderer>> {
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
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

View file

@ -6,12 +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 = "surface-message"
))]
#[cfg(wayland_platform)]
use crate::app::cosmic::{WINDOWING_SYSTEM, WindowingSystem};
use crate::style::menu_bar::StyleSheet;
@ -242,7 +237,7 @@ pub(super) struct MenuSlice {
#[derive(Debug, Clone)]
/// Menu bounds in overlay space
pub struct MenuBounds {
child_positions: Vec<f32>,
pub(crate) child_positions: Vec<f32>,
child_sizes: Vec<Size>,
children_bounds: Rectangle,
pub parent_bounds: Rectangle,
@ -639,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();
}
@ -676,12 +675,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 = "surface-message"
))]
#[cfg(wayland_platform)]
if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
&& let Some(handler) = self.on_surface_action.as_ref()
{
@ -713,7 +707,7 @@ impl<'b, Message: Clone + 'static> Menu<'b, Message> {
}
_ => {}
};
}
None
}
@ -821,11 +815,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;
}
@ -988,12 +982,7 @@ impl<Message: std::clone::Clone + 'static> Widget<Message, crate::Theme, crate::
) {
let new_root = self.update(event, layout, cursor, renderer, clipboard, shell);
#[cfg(all(
feature = "multi-window",
feature = "wayland",
feature = "surface-message",
target_os = "linux"
))]
#[cfg(wayland_platform)]
if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland))
&& let Some((new_root, _new_ms)) = new_root
{
@ -1268,12 +1257,7 @@ pub(crate) fn init_root_menu<Message: Clone>(
});
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
pub(super) fn init_root_popup_menu<Message>(
menu: &mut Menu<'_, Message>,
renderer: &crate::Renderer,
@ -1522,7 +1506,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;
}
@ -1568,18 +1554,23 @@ 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 = "surface-message"))]
if matches!(WINDOWING_SYSTEM.get(), Some(WindowingSystem::Wayland)) && remove
&& let Some(id) = state.popup_id.remove(&menu.window_id) {
#[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);
shell.publish((menu.on_surface_action.as_ref().unwrap())({
crate::surface::action::destroy_popup(id)
}));
}
}
let item = &active_menu[new_index];
// 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) {
@ -1712,6 +1703,7 @@ fn process_scroll_events<Message>(
}
}
shell.capture_event();
shell.request_redraw();
});
}

View file

@ -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};
@ -293,6 +294,14 @@ 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;
pub mod text_input;
#[doc(inline)]
pub use text_input::{
@ -310,7 +319,7 @@ pub use toggler::{Toggler, toggler};
#[doc(inline)]
pub use tooltip::{Tooltip, tooltip};
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
pub mod wayland;
pub mod tooltip {

View file

@ -134,11 +134,7 @@ impl<'a, Message: Clone + 'static> NavBar<'a, Message> {
self
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
pub fn with_positioner(
mut self,
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,

View file

@ -18,7 +18,7 @@ where
Theme: StyleSheet,
{
size: f32,
bar_height: f32,
bar_height: Option<f32>,
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(

View file

@ -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<Element<'a, Message, crate::Theme, crate::Renderer>>,
{
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<R>)> {
@ -44,7 +44,7 @@ pub struct RectangleTracker<I> {
impl<I> RectangleTracker<I>
where
I: Hash + Copy + Send + Sync + Debug,
I: Hash + Clone + Send + Sync + Debug,
{
pub fn container<'a, Message: 'static, T>(
&self,
@ -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<T>(content: T, id: I, tx: UnboundedSender<(I, Rectangle)>) -> Self
@ -185,7 +185,7 @@ impl<'a, Message, Renderer, I> Widget<Message, crate::Theme, Renderer>
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<Tree> {
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<RectangleTrackingContainer<'a, Message, Rend
where
Message: 'a,
Renderer: 'a + iced_core::Renderer,
I: 'a + Hash + Copy + Send + Sync + Debug,
I: 'a + Hash + Clone + Send + Sync + Debug,
{
fn from(
column: RectangleTrackingContainer<'a, Message, Renderer, I>,

View file

@ -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<R>)> {
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<I> {
Finished,
}
async fn start_listening<I: Copy, R: 'static + Hash + Copy + Send + Sync + Debug + Eq>(
async fn start_listening<I: Clone, R: 'static + Hash + Clone + Send + Sync + Debug + Eq>(
id: I,
mut state: State<R>,
) -> Option<((I, RectangleUpdate<R>), State<R>)> {
@ -37,7 +39,7 @@ async fn start_listening<I: Copy, R: 'static + Hash + Copy + Send + Sync + Debug
let (tx, rx) = unbounded();
(
Some((id, RectangleUpdate::Init(RectangleTracker { tx }))),
Some((id.clone(), RectangleUpdate::Init(RectangleTracker { tx }))),
State::Waiting(rx, HashMap::new()),
)
}
@ -50,18 +52,18 @@ async fn start_listening<I: Copy, R: 'static + Hash + Copy + Send + Sync + Debug
|| (prev.x - new.x).abs() > 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<I: Copy, R: 'static + Hash + Copy + Send + Sync + Debug
#[derive(Clone, Debug)]
pub enum RectangleUpdate<I>
where
I: 'static + Hash + Copy + Send + Sync + Debug,
I: 'static + Hash + Clone + Send + Sync + Debug,
{
Rectangle((I, Rectangle)),
Init(RectangleTracker<I>),

View file

@ -180,13 +180,21 @@ where
let state = tree.state.downcast_mut::<State>();
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,
}
}
}

View file

@ -23,7 +23,7 @@ impl Default for ResponsiveMenuBar {
fn default() -> ResponsiveMenuBar {
ResponsiveMenuBar {
collapsed_item_width: {
#[cfg(all(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 = "wayland", target_os = "linux")))]
#[cfg(not(wayland_platform))]
{
ItemWidth::Static(84)
}

View file

@ -199,11 +199,7 @@ where
pub(super) on_reorder: Option<Box<dyn Fn(ReorderEvent) -> Message + 'static>>,
#[setters(skip)]
window_id: window::Id,
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
#[setters(skip)]
pub(crate) on_surface_action:
@ -263,11 +259,7 @@ where
on_drop_hint: None,
on_reorder: None,
window_id: window::Id::RESERVED,
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner::default(),
on_surface_action: None,
}
@ -896,11 +888,7 @@ where
)
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux"
))]
#[cfg(wayland_platform)]
pub fn with_positioner(
mut self,
positioner: iced_runtime::platform_specific::wayland::popup::SctkPositioner,
@ -932,12 +920,7 @@ where
self
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
#[allow(clippy::too_many_lines)]
fn create_popup<'b>(
&mut self,
@ -1095,7 +1078,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"))]
#[cfg(wayland_platform)]
#[must_use]
pub fn simple_popup<Message: 'static>(
live_settings: impl Fn() -> LiveSettings + Send + Sync + 'static,
@ -1277,6 +1260,8 @@ where
let my_bounds = layout.bounds();
let state = tree.state.downcast_mut::<LocalState>();
let hovered_before = state.hovered;
let my_id = self.get_drag_id();
if let Event::Dnd(e) = &mut event {
@ -1680,12 +1665,7 @@ where
state.unfocus();
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
if is_pressed(event)
&& let Some(_on_context) = self.on_context.as_ref()
{
@ -1709,12 +1689,6 @@ where
}
}
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
if let Some(on_activate) = self.on_activate.as_ref() {
if is_pressed(event) {
state.pressed_item = Some(Item::Tab(key));
@ -1779,12 +1753,7 @@ where
shell.publish(on_context(key));
shell.capture_event();
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
if self.context_menu.is_some()
&& matches!(
crate::app::cosmic::WINDOWING_SYSTEM.get(),
@ -1925,11 +1894,7 @@ where
.inner
.with_data_mut(|ms| ms.popup_id.remove(&self.window_id))
{
#[cfg(all(
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
{
let surface_action = self.on_surface_action.as_ref().unwrap();
shell.capture_event();
@ -2011,6 +1976,10 @@ where
shell.capture_event();
}
}
if hovered_before != state.hovered {
shell.request_redraw();
}
}
fn operate(
@ -2579,12 +2548,7 @@ where
_viewport: &iced_core::Rectangle,
translation: Vector,
) -> Option<iced_core::overlay::Element<'b, Message, crate::Theme, Renderer>> {
#[cfg(all(
feature = "multi-window",
feature = "wayland",
target_os = "linux",
feature = "surface-message"
))]
#[cfg(wayland_platform)]
if matches!(
crate::app::cosmic::WINDOWING_SYSTEM.get(),
Some(crate::app::cosmic::WindowingSystem::Wayland)
@ -2881,7 +2845,7 @@ pub struct LocalState {
drop_hint: Option<DropHint>,
}
#[derive(Debug, Default, PartialEq)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Item {
NextButton,
#[default]

View file

@ -0,0 +1,537 @@
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;
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,
window_id: window::Id,
}
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,
window_id: crate::widget::text_context_menu::current_window_id(),
}
}
/// 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(wayland_platform, 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<Pixels>) -> Self {
self.inner = self.inner.size(size);
self
}
pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
self.inner = self.inner.line_height(line_height);
self
}
pub fn font(mut self, font: impl Into<<Renderer as iced_core::text::Renderer>::Font>) -> Self {
self.inner = self.inner.font(font);
self
}
pub fn font_maybe(
mut self,
font: Option<impl Into<<Renderer as iced_core::text::Renderer>::Font>>,
) -> Self {
self.inner = self.inner.font_maybe(font);
self
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.inner = self.inner.width(width);
self
}
pub fn height(mut self, height: impl Into<Length>) -> 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<Alignment>) -> Self {
self.inner = self.inner.align_x(alignment);
self
}
pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> 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
<crate::Theme as Catalog>::Class<'a>:
From<iced_core::widget::text::StyleFn<'a, crate::Theme>>,
{
self.inner = self.inner.style(style);
self
}
pub fn color(mut self, color: impl Into<Color>) -> Self
where
<crate::Theme as Catalog>::Class<'a>:
From<iced_core::widget::text::StyleFn<'a, crate::Theme>>,
{
self.inner = self.inner.color(color);
self
}
pub fn color_maybe(mut self, color: Option<impl Into<Color>>) -> Self
where
<crate::Theme as Catalog>::Class<'a>:
From<iced_core::widget::text::StyleFn<'a, crate::Theme>>,
{
self.inner = self.inner.color_maybe(color);
self
}
#[must_use]
pub fn class(mut self, class: impl Into<<crate::Theme as Catalog>::Class<'a>>) -> Self {
self.inner = self.inner.class(class);
self
}
}
impl<'a> From<InnerText<'a>> for SelectableText<'a> {
fn from(inner: InnerText<'a>) -> Self {
Self {
inner,
selectable: true,
context_menu: true,
window_id: crate::widget::text_context_menu::current_window_id(),
}
}
}
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<Message, crate::Theme, Renderer> {
inner
}
fn w_mut<'x, Message>(
inner: &'x mut InnerText<'_>,
) -> &'x mut dyn Widget<Message, crate::Theme, Renderer> {
inner
}
impl<'a, Message: Clone + 'static> Widget<Message, crate::Theme, Renderer> for SelectableText<'a> {
fn tag(&self) -> tree::Tag {
if self.shows_context_menu() {
tree::Tag::of::<TextWrapperState>()
} else {
w::<Message>(&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::<Message>(&self.inner).state()
}
}
fn children(&self) -> Vec<Tree> {
if self.shows_context_menu() {
vec![Tree::new(w::<Message>(&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<Message, crate::Theme, Renderer>);
}
}
}
fn size(&self) -> Size<Length> {
w::<Message>(&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::<Message>(&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::<Message>(&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::<TextState<<Renderer as iced_core::text::Renderer>::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::<Message>(&mut self.inner).update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
#[cfg(wayland_platform)]
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::<TextWrapperState>();
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.window_id,
);
self.inner
.set_context_menu_position(&mut tree.children[0], None);
}
{
let wrapper_state = tree.state.downcast_ref::<TextWrapperState>();
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]);
shell.request_redraw();
}
_ => {}
}
}
}
let wrapper_state = tree.state.downcast_ref::<TextWrapperState>();
let menu_bar_state = wrapper_state.menu_bar_state.clone();
crate::widget::text_context_menu::dismiss_popup_on_event(
&menu_bar_state,
event,
self.window_id,
);
}
} else {
w_mut::<Message>(&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::<Message>(&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<overlay::Element<'b, Message, crate::Theme, Renderer>> {
if self.shows_context_menu() && !self.uses_popup_context_menu() {
let menu_bar_state = tree
.state
.downcast_ref::<TextWrapperState>()
.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::<Message>(&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::<Message>(&self.inner).a11y_nodes(layout, inner_state, cursor)
}
fn id(&self) -> Option<iced_core::widget::Id> {
w::<Message>(&self.inner).id()
}
fn set_id(&mut self, id: iced_core::widget::Id) {
w_mut::<Message>(&mut self.inner).set_id(id);
}
}
impl<'a, Message: Clone + 'static> From<SelectableText<'a>> for crate::Element<'a, Message> {
fn from(text: SelectableText<'a>) -> Self {
let SelectableText {
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
// (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,
window_id,
})
}
}
/// 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<Cow<'a, str>> + '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<Cow<'a, str>> + '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);

View file

@ -0,0 +1,880 @@
// Copyright 2025 System76 <info@system76.com>
// 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;
#[cfg(wayland_platform)]
use iced_core::window;
#[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,
};
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::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<Mutex<Option<TextCtxAction>>>;
/// 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<TextCtxAction> {
pending.lock().ok().and_then(|mut guard| guard.take())
}
use std::cell::Cell;
thread_local! {
static CURRENT_WINDOW_ID: Cell<iced_core::window::Id> = const { Cell::new(iced_core::window::Id::NONE) };
}
#[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(wayland_platform)]
pub(crate) struct PopupRequest {
live_settings: LiveSettings,
settings: SctkPopupSettings,
menu: Menu<'static, TextCtxAction>,
selected_text: Option<String>,
pending_action: PendingAction,
}
#[cfg(wayland_platform)]
thread_local! {
static PENDING_POPUP_REQUESTS: std::cell::RefCell<Vec<PopupRequest>> =
const { std::cell::RefCell::new(Vec::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()
}
/// Drains all popup requests queued by widgets this frame.
#[cfg(wayland_platform)]
pub(crate) fn take_popup_requests() -> Vec<PopupRequest> {
PENDING_POPUP_REQUESTS.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// 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(wayland_platform)]
#[allow(clippy::type_complexity)]
pub(crate) fn into_popup_view<Message: Clone + 'static>(
req: PopupRequest,
) -> (
LiveSettings,
SctkPopupSettings,
Box<dyn Fn() -> crate::Element<'static, crate::Action<Message>> + Send + Sync>,
) {
let PopupRequest {
settings,
menu,
selected_text,
pending_action,
live_settings,
} = req;
let view = Box::new(move || {
let popup_widget: TextContextMenuPopup<Message> = 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)
});
(live_settings, settings, view)
}
#[cfg(wayland_platform)]
thread_local! {
static PENDING_POPUP_DESTROYS: std::cell::RefCell<Vec<iced_core::window::Id>> =
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(wayland_platform)]
fn queue_destroy_popup(id: iced_core::window::Id) {
PENDING_POPUP_DESTROYS.with(|q| q.borrow_mut().push(id));
wake_runtime();
}
#[cfg(wayland_platform)]
static WAKE_TX: std::sync::OnceLock<iced_futures::futures::channel::mpsc::Sender<()>> =
std::sync::OnceLock::new();
/// Stable identity for [`wake_subscription`].
#[cfg(wayland_platform)]
struct PopupWake;
/// Nudges the runtime so `Cosmic::update()` runs and drains the popup queues.
#[cfg(wayland_platform)]
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(wayland_platform)]
pub(crate) fn wake_subscription<Message: Send + 'static>()
-> iced_futures::Subscription<crate::Action<Message>> {
use iced_futures::futures::{SinkExt, StreamExt};
iced_futures::Subscription::run_with(std::any::TypeId::of::<PopupWake>(), |_| {
iced::stream::channel(
16,
|mut output: iced_futures::futures::channel::mpsc::Sender<crate::Action<Message>>| 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<iced_core::window::Id> {
PENDING_POPUP_DESTROYS.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// 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<overlay::Element<'a, Message, crate::Theme, crate::Renderer>>
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 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
|| (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();
state.open = true;
}
menu_roots_diff(&mut menu_roots, &mut state.tree);
});
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: 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(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,
})))
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TextCtxAction {
Copy,
Cut,
Paste,
SelectAll,
}
fn build_menu_roots(is_editable: bool, has_selection: bool) -> Vec<menu::Tree<TextCtxAction>> {
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>,
}
impl<W, Message> overlay::Overlay<Message, crate::Theme, crate::Renderer>
for TextMenuOverlay<'_, W, Message>
where
W: HasSelectableText,
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<TextCtxAction> = 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
.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>,
) {
// 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::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);
shell.request_redraw();
}
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);
// The menu closes and the selection may have changed.
shell.request_redraw();
}
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
}
}
}
/// Queues a Wayland popup surface containing the text context menu.
///
/// 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(wayland_platform)]
pub(crate) fn create_text_context_popup(
click_position: Point,
selected_text: Option<String>,
is_editable: bool,
has_selection: bool,
menu_bar_state: &MenuBarState,
pending_action: &PendingAction,
renderer: &crate::Renderer,
viewport: &Rectangle,
cursor: mouse::Cursor,
window_id: window::Id,
) {
use iced_runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner};
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() {
queue_destroy_popup(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<TextCtxAction> = 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()
};
// 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,
parent_size: None,
grab: true,
close_with_children: false,
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();
}
/// 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,
window_id: window::Id,
) {
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 popup_id = menu_bar_state
.inner
.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| {
state.popup_id.retain(|_, v| *v != popup_id);
state.reset();
});
queue_destroy_popup(popup_id);
}
}
/// Widget that wraps [`Menu`] inside a Wayland popup and intercepts
/// [`TextCtxAction`] messages for clipboard operations.
#[derive(Clone)]
struct TextContextMenuPopup<Message: Clone + 'static> {
menu: Menu<'static, TextCtxAction>,
selected_text: Option<String>,
pending_action: PendingAction,
_phantom: std::marker::PhantomData<Message>,
}
impl<Message: Clone + 'static> iced_core::widget::Widget<Message, crate::Theme, crate::Renderer>
for TextContextMenuPopup<Message>
{
fn size(&self) -> Size<iced_core::Length> {
use iced_core::widget::Widget;
Widget::<TextCtxAction, crate::Theme, crate::Renderer>::size(&self.menu)
}
fn tag(&self) -> iced_core::widget::tree::Tag {
use iced_core::widget::Widget;
Widget::<TextCtxAction, crate::Theme, crate::Renderer>::tag(&self.menu)
}
fn state(&self) -> iced_core::widget::tree::State {
use iced_core::widget::Widget;
Widget::<TextCtxAction, crate::Theme, crate::Renderer>::state(&self.menu)
}
fn children(&self) -> Vec<iced_core::widget::Tree> {
use iced_core::widget::Widget;
Widget::<TextCtxAction, crate::Theme, crate::Renderer>::children(&self.menu)
}
fn diff(&mut self, tree: &mut iced_core::widget::Tree) {
use iced_core::widget::Widget;
Widget::<TextCtxAction, crate::Theme, crate::Renderer>::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::<TextCtxAction, crate::Theme, crate::Renderer>::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::<TextCtxAction, crate::Theme, crate::Renderer>::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,
) {
#[cfg(wayland_platform)]
{
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(wayland_platform)]
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<TextCtxAction> = Vec::new();
let mut local_shell = Shell::new(&mut local_messages);
{
use iced_core::widget::Widget;
Widget::<TextCtxAction, crate::Theme, crate::Renderer>::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);
}
}
}
}
// Under the popup grab the parent widget receives no events, so the
// popup must tear itself down once its menu has closed — whether an
// item was chosen (`click_inside`) or the user clicked away
// (`click_outside`).
#[cfg(wayland_platform)]
{
let menu_closed = self.menu.tree.inner.with_data(|state| !state.open);
if menu_closed {
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);
}
}
}
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::<TextCtxAction, crate::Theme, crate::Renderer>::mouse_interaction(
&self.menu, tree, layout, cursor, viewport, renderer,
)
}
}
impl<Message: Clone + 'static> From<TextContextMenuPopup<Message>>
for crate::Element<'static, Message>
{
fn from(popup: TextContextMenuPopup<Message>) -> Self {
Self::new(popup)
}
}

459
src/widget/text_editor.rs Normal file
View file

@ -0,0 +1,459 @@
// Copyright 2025 System76 <info@system76.com>
// 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, window,
};
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,
window_id: window::Id,
}
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<crate::Renderer>) -> Self {
Self {
inner: iced_widget::text_editor(content),
has_context_menu: true,
window_id: crate::widget::text_context_menu::current_window_id(),
}
}
/// 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<iced_core::widget::Id>) -> 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<iced_core::Pixels>) -> Self {
self.inner = self.inner.width(width);
self
}
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.inner = self.inner.height(height);
self
}
pub fn min_height(mut self, min_height: impl Into<iced_core::Pixels>) -> Self {
self.inner = self.inner.min_height(min_height);
self
}
pub fn max_height(mut self, max_height: impl Into<iced_core::Pixels>) -> 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<<crate::Renderer as iced_core::text::Renderer>::Font>,
) -> Self {
self.inner = self.inner.font(font);
self
}
pub fn size(mut self, size: impl Into<iced_core::Pixels>) -> Self {
self.inner = self.inner.size(size);
self
}
pub fn line_height(mut self, line_height: impl Into<iced_core::text::LineHeight>) -> Self {
self.inner = self.inner.line_height(line_height);
self
}
pub fn padding(mut self, padding: impl Into<iced_core::Padding>) -> 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<Binding<Message>> + '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
<crate::Theme as Catalog>::Class<'a>:
From<iced_widget::text_editor::StyleFn<'a, crate::Theme>>,
{
self.inner = self.inner.style(style);
self
}
fn uses_popup_context_menu(&self) -> bool {
#[cfg(all(wayland_platform, feature = "winit"))]
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<crate::Renderer>,
) -> TextEditor<'a, Message> {
TextEditor::new(content)
}
fn ew<'x, Message>(
inner: &'x InnerEditor<'_, Message>,
) -> &'x dyn Widget<Message, crate::Theme, crate::Renderer> {
inner
}
fn ew_mut<'x, Message>(
inner: &'x mut InnerEditor<'_, Message>,
) -> &'x mut dyn Widget<Message, crate::Theme, crate::Renderer> {
inner
}
impl<'a, Message: Clone + 'static> Widget<Message, crate::Theme, crate::Renderer>
for TextEditor<'a, Message>
{
fn tag(&self) -> tree::Tag {
if self.has_context_menu {
tree::Tag::of::<EditorWrapperState>()
} else {
ew::<Message>(&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::<Message>(&self.inner).state()
}
}
fn children(&self) -> Vec<Tree> {
if self.has_context_menu {
vec![Tree::new(ew::<Message>(&self.inner))]
} else {
ew::<Message>(&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<Message, crate::Theme, crate::Renderer>,
);
}
} else {
ew_mut::<Message>(&mut self.inner).diff(tree);
}
}
fn size(&self) -> Size<Length> {
ew::<Message>(&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::<Message>(&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::<Message>(&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::<Message>(&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(wayland_platform)]
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::<EditorWrapperState>();
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.window_id,
);
self.inner
.set_context_menu_position(&mut tree.children[0], None);
}
// Process deferred actions from the popup.
{
let wrapper_state = tree.state.downcast_ref::<EditorWrapperState>();
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]);
}
}
}
}
// Dismiss popup on outside click / Escape.
let wrapper_state = tree.state.downcast_ref::<EditorWrapperState>();
let menu_bar_state = wrapper_state.menu_bar_state.clone();
crate::widget::text_context_menu::dismiss_popup_on_event(
&menu_bar_state,
event,
self.window_id,
);
}
} else {
ew_mut::<Message>(&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::<Message>(&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<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
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::<EditorWrapperState>()
.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::<Message>(&mut self.inner).overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
} else {
ew_mut::<Message>(&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::<Message>(&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::<Message>(&self.inner).a11y_nodes(layout, inner_state, cursor)
}
fn id(&self) -> Option<iced_core::widget::Id> {
ew::<Message>(&self.inner).id()
}
fn set_id(&mut self, id: iced_core::widget::Id) {
ew_mut::<Message>(&mut self.inner).set_id(id);
}
}
impl<'a, Message: Clone + 'static> From<TextEditor<'a, Message>> for crate::Element<'a, Message> {
fn from(editor: TextEditor<'a, Message>) -> Self {
Self::new(editor)
}
}

View file

@ -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);

View file

@ -43,6 +43,12 @@ thread_local! {
static LAST_FOCUS_UPDATE: LazyCell<Cell<Instant>> = 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
@ -212,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>
@ -262,6 +269,7 @@ where
always_active: false,
manage_value: false,
drag_threshold: 20.0,
window_id: crate::widget::text_context_menu::current_window_id(),
}
}
@ -525,7 +533,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
@ -577,6 +585,17 @@ where
self.drag_threshold = drag_threshold;
self
}
fn uses_popup_context_menu(&self) -> bool {
#[cfg(all(wayland_platform, feature = "winit"))]
if matches!(
crate::app::cosmic::WINDOWING_SYSTEM.get(),
Some(crate::app::cosmic::WindowingSystem::Wayland)
) {
return true;
}
false
}
}
impl<Message> Widget<Message, crate::Theme, crate::Renderer> for TextInput<'_, Message>
@ -840,6 +859,24 @@ where
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
if !self.uses_popup_context_menu() {
let has_context_menu = tree
.state
.downcast_ref::<State>()
.context_menu_position
.is_some();
if has_context_menu {
let menu_bar_state = tree.state.downcast_ref::<State>().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();
@ -854,7 +891,7 @@ where
}
layout_.push(children.next().unwrap());
};
let children = self
let children: Vec<overlay::Element<'_, Message, crate::Theme, crate::Renderer>> = self
.leading_icon
.iter_mut()
.chain(self.trailing_icon.iter_mut())
@ -865,7 +902,7 @@ where
.as_widget_mut()
.overlay(state, layout, renderer, viewport, translation)
})
.collect::<Vec<_>>();
.collect();
(!children.is_empty()).then(|| Group::with_children(children).overlay())
}
@ -881,6 +918,16 @@ where
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
#[cfg(all(wayland_platform, feature = "winit"))]
if self.uses_popup_context_menu() {
let menu_bar_state = tree.state.downcast_ref::<State>().menu_bar_state.clone();
crate::widget::text_context_menu::dismiss_popup_on_event(
&menu_bar_state,
event,
self.window_id,
);
}
let text_layout = self.text_layout(layout);
let mut trailing_icon_layout = None;
let font = self.font.unwrap_or_else(|| renderer.default_font());
@ -931,6 +978,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>();
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::<State>();
if let Some(on_unfocus) = self.on_unfocus.as_ref()
@ -971,6 +1035,73 @@ where
self.always_active,
);
// On Wayland: if right-click just set context_menu_position, create a popup instead.
#[cfg(all(wayland_platform, feature = "winit"))]
if matches!(
crate::app::cosmic::WINDOWING_SYSTEM.get(),
Some(crate::app::cosmic::WindowingSystem::Wayland)
) {
let state = tree.state.downcast_ref::<State>();
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,
self.window_id,
);
let state = tree.state.downcast_mut::<State>();
state.context_menu_position = None;
}
// Process deferred actions from the popup.
let state = tree.state.downcast_ref::<State>();
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::<State>();
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();
}
}
}
}
let state = tree.state.downcast_mut::<State>();
let value = if self.is_secure {
self.value.secure()
@ -1455,11 +1586,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())
@ -1547,7 +1711,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);
@ -1673,6 +1837,7 @@ pub fn update<'a, Message: Clone + 'static>(
state.last_click = Some(click);
shell.request_redraw();
shell.capture_event();
} else {
state.unfocus();
@ -1685,8 +1850,7 @@ 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(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);
@ -1712,8 +1876,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();
@ -1737,10 +1899,11 @@ pub fn update<'a, Message: Clone + 'static>(
.cursor
.select_range(state.cursor.start(value), position);
shell.request_redraw();
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))
@ -1800,7 +1963,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) {
@ -2088,17 +2250,19 @@ pub fn update<'a, Message: Clone + 'static>(
_ => {}
}
shell.request_redraw();
shell.capture_event();
}
}
Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => {
let state = state();
if state.is_focused() {
match key {
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) => {
@ -2111,70 +2275,64 @@ 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();
}
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();
}
}
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();
}
}
}
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;
@ -2190,17 +2348,16 @@ 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();
if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) {
// TODO: restore value in text input
state.dragging_state = None;
shell.capture_event();
}
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
#[cfg(wayland_platform)]
Event::Dnd(DndEvent::Offer(
rectangle,
OfferEvent::Enter {
@ -2211,8 +2368,7 @@ pub fn update<'a, Message: Clone + 'static>(
},
)) if *rectangle == Some(dnd_id) => {
cold();
let state = state();
let _is_clicked = text_layout.bounds().contains(Point {
let is_clicked = text_layout.bounds().contains(Point {
x: *x as f32,
y: *y as f32,
});
@ -2249,12 +2405,10 @@ pub fn update<'a, Message: Clone + 'static>(
shell.capture_event();
}
}
#[cfg(all(feature = "wayland", target_os = "linux"))]
Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Motion { x, y: _ }))
#[cfg(wayland_platform)]
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();
@ -2276,10 +2430,9 @@ pub fn update<'a, Message: Clone + 'static>(
state.cursor.move_to(position);
shell.capture_event();
}
#[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();
if let DndOfferState::HandlingOffer(mime_types, _action) = state.dnd_offer.clone() {
let Some(_mime_type) = SUPPORTED_TEXT_MIME_TYPES
.iter()
@ -2292,15 +2445,14 @@ pub fn update<'a, Message: Clone + 'static>(
state.dnd_offer = DndOfferState::Dropped;
}
}
#[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,
)) => {
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 {
@ -2311,12 +2463,11 @@ pub fn update<'a, Message: Clone + 'static>(
};
shell.capture_event();
}
#[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) =>
{
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() {
@ -2592,9 +2743,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(|| {
@ -2844,7 +2995,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(
@ -2868,13 +3019,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]
@ -2883,11 +3034,11 @@ 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`].
#[derive(Debug, Default, Clone)]
#[derive(Default, Clone)]
#[must_use]
pub struct State {
pub tracked_value: Value,
@ -2910,6 +3061,19 @@ pub struct State {
preedit: Option<Preedit>,
keyboard_modifiers: keyboard::Modifiers,
scroll_offset: f32,
context_menu_position: Option<iced_core::Point>,
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)]
@ -2954,12 +3118,12 @@ 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())
}
}
}
#[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<String> {
@ -2992,6 +3156,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(),
}
}
@ -3036,7 +3203,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;
@ -3078,6 +3245,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<iced_core::Point> {
self.context_menu_position
}
/// Sets or clears the context menu position.
pub fn set_context_menu_position(&mut self, pos: Option<iced_core::Point>) {
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<f32>, target: f32) {
let (position, affinity) = find_cursor_position(bounds, value, self, target)
.unwrap_or((0, text::Affinity::Before));
@ -3305,3 +3499,47 @@ fn effective_alignment(paragraph: &impl text::Paragraph) -> alignment::Horizonta
alignment::Horizontal::Left
}
}
use iced_core::widget::tree::Tree as WidgetTree;
impl<Message: Clone + 'static> iced_core::widget::text::HasSelectableText
for TextInput<'_, Message>
{
fn selected_text(&self, tree: &WidgetTree) -> Option<String> {
let state = tree.state.downcast_ref::<State>();
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>();
state.select_all();
}
fn is_editable(&self) -> bool {
true
}
fn is_focused(&self, tree: &WidgetTree) -> bool {
tree.state.downcast_ref::<State>().is_focused()
}
fn context_menu_position(&self, tree: &WidgetTree) -> Option<iced_core::Point> {
tree.state.downcast_ref::<State>().context_menu_position
}
fn set_context_menu_position(&self, tree: &mut WidgetTree, pos: Option<iced_core::Point>) {
tree.state.downcast_mut::<State>().context_menu_position = pos;
}
fn delete_selection(&self, tree: &mut WidgetTree) -> Option<String> {
let state = tree.state.downcast_mut::<State>();
Some(state.delete_selection())
}
fn paste_text(&self, tree: &mut WidgetTree, text: &str) -> Option<String> {
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
let state = tree.state.downcast_mut::<State>();
Some(state.paste_text(&filtered))
}
}

View file

@ -248,8 +248,8 @@ impl<'a, Message> Widget<Message, crate::Theme, crate::Renderer> 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 {
@ -265,6 +265,17 @@ impl<'a, Message> Widget<Message, crate::Theme, crate::Renderer> 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();
}
}
_ => {}
}
}
@ -441,4 +452,5 @@ pub struct State {
text: widget::text::State<<crate::Renderer as iced_core::text::Renderer>::Paragraph>,
anim: anim::State,
prev_toggled: bool,
hovered: bool,
}

View file

@ -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::<State>();
let styling = theme.style(&self.style);
let icon_color = styling.icon_color.unwrap_or(renderer_style.icon_color);