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

@ -74,6 +74,7 @@ serde-keycode = ["iced_core/serde"]
single-instance = ["zbus/blocking-api", "ron"]
# smol async runtime
smol = ["dep:smol", "iced/smol", "zbus?/async-io", "rfd?/async-std"]
# Tokio async runtime
tokio = [
"dep:tokio",
"ashpd?/tokio",
@ -87,19 +88,22 @@ tokio = [
# We still need iced/winit because pop-os/iced hosts the runtime dispatcher
# (`iced_winit as shell`) there — the name is a misnomer, it's the same crate
# that provides both the winit path AND the sctk/cctk wayland path.
# COSMIC 1.5/1.6 renamed the iced `cctk` features to `wayland`; the local
# `winit` feature stays empty in this fork, so iced/winit is listed explicitly
# instead of going through it.
wayland = [
"ashpd?/wayland",
"autosize",
"multi-window",
"surface-message",
"winit",
"iced/winit",
"iced/wayland",
"iced_winit/wayland",
"iced_runtime/cctk",
"iced_winit/cctk",
"iced_wgpu/cctk",
"iced/cctk",
"iced_runtime/wayland",
"iced_wgpu/wayland",
"dep:iced_winit",
"dep:cctk",
"surface-message",
]
surface-message = []
# multi-window support
@ -126,11 +130,9 @@ async-std = [
[dependencies]
apply = "0.3.0"
ashpd = { version = "0.12.3", default-features = false, optional = true }
async-fs = { version = "2.2", optional = true }
async-std = { workspace = true, optional = true }
auto_enums = "0.8.8"
cctk = { path = "../cosmic-protocols/client-toolkit", package = "cosmic-client-toolkit", optional = true }
jiff = "0.2"
cosmic-config = { path = "cosmic-config" }
cosmic-settings-config = { path = "../cosmic-settings-daemon/config", optional = true }
@ -176,7 +178,9 @@ ron = { workspace = true, optional = true }
enumflags2 = "0.7.12"
# Enable DBus feature on Linux targets
[target.'cfg(target_os = "linux")'.dependencies]
[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_vendor = "apple"))))'.dependencies]
cctk = { path = "../cosmic-protocols/client-toolkit", package = "cosmic-client-toolkit", optional = true }
ashpd = { version = "0.12.3", default-features = false, optional = true }
cosmic-config = { path = "cosmic-config", features = ["dbus"] }
cosmic-settings-daemon = { path = "../dbus-settings-bindings/cosmic-settings-daemon" }
zbus = { version = "5.14.0", default-features = false }
@ -302,3 +306,9 @@ opt-level = 3
[dev-dependencies]
tempfile = "3.27.0"
[workspace.package]
edition = "2024"
[build-dependencies]
build_helpers = { path = "./iced/build_helpers" }

View file

@ -8,6 +8,7 @@ fn main() {
{
generate_bundled_icons();
}
build_helpers::cfg_aliases_setup();
}
fn generate_bundled_icons() {

View file

@ -67,6 +67,8 @@ struct Wrapper(
impl std::hash::Hash for Wrapper {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
self.2.hash(state);
self.3.hash(state);
}
}

View file

@ -11,7 +11,6 @@ use palette::color_difference::Wcag21RelativeContrast;
use palette::rgb::Rgb;
use palette::{IntoColor, Oklcha, Srgb, Srgba, WithAlpha};
use serde::{Deserialize, Serialize};
use std::default;
use std::num::NonZeroUsize;
/// ID for the current dark `ThemeBuilder` config
@ -122,6 +121,9 @@ pub struct Theme {
/// frosted applet popups
pub frosted_applets: bool,
#[serde(default)]
/// frosted apps when maximized
pub frosted_maximized_apps: bool,
#[serde(default)]
/// alpha map
pub alpha_map: AlphaMap,
/// shade color for dialogs
@ -910,6 +912,9 @@ pub struct ThemeBuilder {
/// frosted applet popups
pub frosted_applets: bool,
#[serde(default)]
/// frosted apps when maximized
pub frosted_maximized_apps: bool,
#[serde(default)]
/// alpha map
pub alpha_map: AlphaMap,
}
@ -938,6 +943,7 @@ impl Default for ThemeBuilder {
frosted_system_interface: false,
frosted_panel: false,
frosted_applets: false,
frosted_maximized_apps: false,
alpha_map: AlphaMap::default(),
}
}
@ -1091,6 +1097,7 @@ impl ThemeBuilder {
frosted_system_interface,
frosted_panel,
frosted_applets,
frosted_maximized_apps,
alpha_map,
} = self;
@ -1196,7 +1203,7 @@ impl ThemeBuilder {
);
let on_transparent_bg_component = get_text(
color_index(transparent_bg_component, transparent_bg_steps_array.len()),
&transparent_bg_steps_array,
&step_array,
&control_steps_array[8],
text_steps_array.as_deref(),
actual_alpha,
@ -1260,17 +1267,22 @@ impl ThemeBuilder {
};
container_bg.alpha = (container_alpha + if is_dark { 0.3 } else { 0.25 }).min(1.0);
let step_array = steps(container_bg, NonZeroUsize::new(100).unwrap());
let base_index: usize = color_index(container_bg, step_array.len());
let component_base =
get_surface_color(base_index, 6, &step_array, is_dark, &control_steps_array[3]);
let t_step_array = steps(container_bg, NonZeroUsize::new(100).unwrap());
let base_index: usize = color_index(container_bg, t_step_array.len());
let component_base = get_surface_color(
base_index,
6,
&t_step_array,
is_dark,
&control_steps_array[3],
);
Container::new(
Component::component(
component_base,
accent,
get_text(
color_index(component_base, step_array.len()),
color_index(component_base, t_step_array.len()),
&step_array,
&control_steps_array[8],
text_steps_array.as_deref(),
@ -1374,10 +1386,15 @@ impl ThemeBuilder {
get_surface_color(bg_index, 10, &step_array, is_dark, &control_steps_array[2])
};
let step_array = steps(container_bg, NonZeroUsize::new(100).unwrap());
let base_index = color_index(container_bg, step_array.len());
let secondary_component =
get_surface_color(base_index, 3, &step_array, is_dark, &control_steps_array[4]);
let t_step_array = steps(container_bg, NonZeroUsize::new(100).unwrap());
let base_index = color_index(container_bg, t_step_array.len());
let secondary_component = get_surface_color(
base_index,
3,
&t_step_array,
is_dark,
&control_steps_array[4],
);
component_hovered_overlay = if base_index < 91 {
control_steps_array[10]
@ -1394,7 +1411,7 @@ impl ThemeBuilder {
secondary_component,
accent,
get_text(
color_index(secondary_component, step_array.len()),
color_index(secondary_component, t_step_array.len()),
&step_array,
&control_steps_array[8],
text_steps_array.as_deref(),
@ -1553,6 +1570,7 @@ impl ThemeBuilder {
frosted_system_interface,
frosted_panel,
frosted_applets,
frosted_maximized_apps,
alpha_map,
transparent_background: Container::new(
Component::component(
@ -1567,7 +1585,7 @@ impl ThemeBuilder {
transparent_bg,
get_text(
bg_index,
&transparent_bg_steps_array,
&step_array,
&control_steps_array[8],
text_steps_array.as_deref(),
actual_alpha,

View file

@ -34,10 +34,8 @@ impl Theme {
/// Apply COSMIC theme exports for GTK and Qt applications.
pub fn apply_exports(&self) -> Result<(), OutputError> {
let gtk_res = Theme::apply_gtk(self.is_dark);
let qt_res = Theme::apply_qt(self.is_dark);
let qt56ct_res = Theme::apply_qt56ct(self.is_dark);
gtk_res?;
qt_res?;
qt56ct_res?;
Ok(())
}
@ -46,10 +44,8 @@ impl Theme {
/// Write COSMIC theme exports for GTK and Qt applications.
pub fn write_exports(&self) -> Result<(), OutputError> {
let gtk_res = self.write_gtk4();
let qt_res = self.write_qt();
let qt56ct_res = self.write_qt56ct();
gtk_res?;
qt_res?;
qt56ct_res?;
Ok(())
}

View file

@ -1,280 +1,21 @@
use crate::Theme;
use configparser::ini::Ini;
use cosmic_config::CosmicConfigEntry;
use palette::blend::Compose;
use palette::{Mix, Srgba};
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::{fs, io};
use super::{OutputError, qt_settings_ini_style};
use super::OutputError;
impl Theme {
/// Produces a color scheme ini file for Qt.
///
/// Some high-level documentation for this file can be found at:
/// - https://api.kde.org/kcolorscheme.html
/// - https://web.archive.org/web/20250402234329/https://docs.kde.org/stable5/en/plasma-workspace/kcontrol/colors/
#[must_use]
#[cold]
pub fn as_kcolorscheme(&self) -> String {
// Usually, disabled elements will have strongly reduced contrast and are often notably darker or lighter
let disabled_color_effects = IniColorEffects {
color: self.button.disabled,
color_amount: 0.0,
color_effect: ColorEffect::Desaturate,
contrast_amount: 0.65,
contrast_effect: ColorEffect::Fade,
intensity_amount: 0.1,
intensity_effect: IntensityEffect::Lighten,
};
// Usually, inactive elements will have reduced contrast (text fades slightly into the background) and may have slightly reduced intensity
let inactive_color_effects = IniColorEffects {
color: self.palette.gray_1,
color_amount: 0.025,
color_effect: ColorEffect::Tint,
contrast_amount: 0.1,
contrast_effect: ColorEffect::Tint,
intensity_amount: 0.0,
intensity_effect: IntensityEffect::Shade,
};
let bg = self.background.base;
// the background container
let window_colors = IniColors {
background_alternate: bg.mix(self.accent.base, 0.05),
background_normal: bg,
decoration_focus: self.accent_text_color(),
decoration_hover: self.accent_text_color(),
foreground_active: self.accent_text_color(),
foreground_inactive: self.background.on.mix(bg, 0.1),
foreground_link: self.link_button.on,
foreground_negative: self.destructive_text_color(),
foreground_neutral: self.warning_text_color(),
foreground_normal: self.background.on,
foreground_positive: self.success_text_color(),
foreground_visited: self.accent_text_color(),
};
// components inside the background container
let view_colors = IniColors {
background_alternate: self.background.component.base.mix(self.accent.base, 0.05),
background_normal: self.background.component.base,
..window_colors
};
// selected text and items
let selection_colors = {
// selection colors are swapped to fix menu bar contrast
let selected = self.background.component.selected_text;
let selected_text = self.background.component.selected;
IniColors {
background_alternate: selected.mix(bg, 0.5),
background_normal: selected,
decoration_focus: selected,
decoration_hover: selected,
foreground_active: selected_text,
foreground_inactive: selected_text.mix(selected, 0.5),
foreground_link: self.link_button.base,
foreground_negative: self.destructive_color(),
foreground_neutral: self.warning_color(),
foreground_normal: selected_text,
foreground_positive: self.success_color(),
foreground_visited: self.accent_color(),
}
};
let button_colors = IniColors {
background_alternate: self.accent_button.base,
background_normal: self.button.base,
..view_colors
};
// Complementary: Areas of applications with an alternative color scheme; usually with a dark background for light color schemes.
let complementary_colors = {
let dark = if self.is_dark {
self.clone()
} else if cfg!(test) {
// For reproducible results in tests, use the default dark theme
Theme::dark_default()
} else {
Theme::dark_config()
.ok()
.as_ref()
.and_then(|conf| Theme::get_entry(conf).ok())
.unwrap_or_else(|| self.clone())
};
IniColors {
background_alternate: dark.accent.base,
background_normal: dark.background.base,
decoration_focus: dark.accent_text_color(),
decoration_hover: dark.accent_text_color(),
foreground_active: dark.accent_text_color(),
foreground_inactive: dark.background.on.mix(dark.background.base, 0.1),
foreground_link: dark.link_button.on,
foreground_negative: dark.destructive_text_color(),
foreground_neutral: dark.warning_text_color(),
foreground_normal: dark.background.on,
foreground_positive: dark.success_text_color(),
foreground_visited: dark.accent_text_color(),
}
};
// headers in cosmic don't have a background
let header_colors = &window_colors;
let header_colors_inactive = &window_colors;
// tool tips, "What's This" tips, and similar elements
let tooltip_colors = &view_colors;
let general_color_scheme = if self.is_dark {
"CosmicDark"
} else {
"CosmicLight"
};
let general_name = if self.is_dark {
"COSMIC Dark"
} else {
"COSMIC Light"
};
// COSMIC icons are stuck in light mode, so use breeze icons instead
let icons_theme = if self.is_dark {
"breeze-dark"
} else {
"breeze"
};
format!(
r#"# GENERATED BY COSMIC
[ColorEffects:Disabled]
{}
[ColorEffects:Inactive]
ChangeSelectionColor=false
Enable=false
{}
[Colors:Button]
{}
[Colors:Complementary]
{}
[Colors:Header]
{}
[Colors:Header][Inactive]
{}
[Colors:Selection]
{}
[Colors:Tooltip]
{}
[Colors:View]
{}
[Colors:Window]
{}
[General]
ColorScheme={general_color_scheme}
Name={general_name}
shadeSortColumn=true
[Icons]
Theme={icons_theme}
[KDE]
contrast=4
widgetStyle=qt6ct-style
[WM]
{}
"#,
format_ini_color_effects(&disabled_color_effects, bg),
format_ini_color_effects(&inactive_color_effects, bg),
format_ini_colors(&button_colors, bg),
format_ini_colors(&complementary_colors, bg),
format_ini_colors(&header_colors, bg),
format_ini_colors(&header_colors_inactive, bg),
format_ini_colors(&selection_colors, bg),
format_ini_colors(&tooltip_colors, bg),
format_ini_colors(&view_colors, bg),
format_ini_colors(&window_colors, bg),
format_ini_wm_colors(&window_colors, self.is_dark),
)
}
/// Write the color scheme to the appropriate directory.
/// Should be written in `~/.local/share/color-schemes/`.
///
/// See the docs: https://develop.kde.org/docs/plasma/#color-scheme
///
/// # Errors
///
/// Returns an `OutputError` if there is an error writing the colors file.
#[cold]
pub fn write_qt(&self) -> Result<(), OutputError> {
let kcolorscheme = self.as_kcolorscheme();
let file_path = Self::get_kcolorscheme_path(self.is_dark)?;
let tmp_file_path = file_path.with_extension("colors.new");
// Write to tmp_file_path first, then move it to file_path
let mut tmp_file = File::create(&tmp_file_path).map_err(OutputError::Io)?;
let res = tmp_file
.write_all(kcolorscheme.as_bytes())
.and_then(|_| tmp_file.flush())
.and_then(|_| std::fs::rename(&tmp_file_path, file_path));
if let Err(e) = res {
_ = std::fs::remove_file(&tmp_file_path);
return Err(OutputError::Io(e));
}
Ok(())
}
/// Apply the color scheme by copying its values to `~/.config/kdeglobals`.
///
/// See the docs: https://develop.kde.org/docs/plasma/#color-scheme
///
/// # Errors
///
/// Returns an `OutputError` if there is an error applying the color scheme.
#[cold]
pub fn apply_qt(is_dark: bool) -> Result<(), OutputError> {
let Some(config_dir) = dirs::config_dir() else {
return Err(OutputError::MissingConfigDir);
};
let kdeglobals_file = config_dir.join("kdeglobals");
let mut kdeglobals_ini = Self::read_ini(&kdeglobals_file)?;
let src_file = Self::get_kcolorscheme_path(is_dark)?;
let src_ini = Self::read_ini(&src_file)?;
Self::backup_non_cosmic_kdeglobals(&kdeglobals_ini, &kdeglobals_file)
.map_err(OutputError::Io)?;
for (section, key_value) in src_ini.get_map_ref() {
for (key, value) in key_value {
kdeglobals_ini.set(section, key, value.clone());
}
}
kdeglobals_ini
.pretty_write(kdeglobals_file, &qt_settings_ini_style())
.map_err(OutputError::Io)?;
Ok(())
}
/// Reset the applied qt colors by removing color scheme values from the
/// `~/.config/kdeglobals` file.
/// Reset the applied qt colors:
/// - Remove relevant keys from the `~/.config/kdeglobals` file
/// - Delete `~/.local/share/color-schemes/CosmicDark.colors` and `CosmicLight.colors`
///
/// This does not restore the backed up kdeglobals file.
///
/// # Errors
///
/// Returns an `OutputError` if there is an error resetting the CSS file.
/// Returns an `OutputError` if there is an error resetting the kdeglobals
/// file or deleting the color schemes.
#[cold]
pub fn reset_qt() -> Result<(), OutputError> {
let Some(config_dir) = dirs::config_dir() else {
@ -291,19 +32,29 @@ widgetStyle=qt6ct-style
return Ok(());
}
let is_dark = false; // doesn't matter since we're only reading keys
let src_file = Self::get_kcolorscheme_path(is_dark)?;
let src_ini = Self::read_ini(&src_file)?;
let light_scheme = Self::get_kcolorscheme_path(false)?;
let dark_scheme = Self::get_kcolorscheme_path(true)?;
if light_scheme.exists() {
let src_ini = Self::read_ini(&light_scheme)?;
for (section, key_value) in src_ini.get_map_ref() {
for (key, _) in key_value {
kdeglobals_ini.remove_key(section, key);
// Remove color scheme keys from kdeglobals
for (section, key_value) in src_ini.get_map_ref() {
for key in key_value.keys() {
kdeglobals_ini.remove_key(section, key);
}
}
kdeglobals_ini
.write(kdeglobals_file)
.map_err(OutputError::Io)?;
}
kdeglobals_ini
.write(kdeglobals_file)
.map_err(OutputError::Io)?;
// Delete now-unused kcolorscheme files
let delete_light_res = fs::remove_file(&light_scheme).map_err(OutputError::Io);
let delete_dark_res = fs::remove_file(&dark_scheme).map_err(OutputError::Io);
delete_light_res?;
delete_dark_res?;
Ok(())
}
@ -312,6 +63,7 @@ widgetStyle=qt6ct-style
let Some(mut data_dir) = dirs::data_dir() else {
return Err(OutputError::MissingDataDir);
};
data_dir.push("color-schemes");
let file_name = if is_dark {
"CosmicDark.colors"
@ -319,11 +71,6 @@ widgetStyle=qt6ct-style
"CosmicLight.colors"
};
data_dir.push("color-schemes");
if !data_dir.exists() {
std::fs::create_dir_all(&data_dir).map_err(OutputError::Io)?;
}
Ok(data_dir.join(file_name))
}
@ -338,15 +85,6 @@ widgetStyle=qt6ct-style
Ok(ini)
}
#[cold]
fn backup_non_cosmic_kdeglobals(ini: &Ini, path: &Path) -> io::Result<()> {
if !Self::is_cosmic_kdeglobals(&ini)?.unwrap_or(true) {
let backup_path = path.with_extension("bak");
fs::copy(path, &backup_path)?;
}
Ok(())
}
#[cold]
fn is_cosmic_kdeglobals(ini: &Ini) -> io::Result<Option<bool>> {
let color_scheme = ini.get("General", "ColorScheme");
@ -360,208 +98,43 @@ widgetStyle=qt6ct-style
}
}
/// Formats a color in the form `r,g,b` e.g. `255,255,255`.
/// If the color has transparency, it is mixed with bg first.
fn to_rgb(c: Srgba, bg: Srgba) -> String {
let c_u8: Srgba<u8> = c.over(bg).into_format();
format!("{},{},{}", c_u8.red, c_u8.green, c_u8.blue)
}
fn format_ini_color_effects(color_effects: &IniColorEffects, bg: Srgba) -> String {
format!(
r#"Color={}
ColorAmount={}
ColorEffect={}
ContrastAmount={}
ContrastEffect={}
IntensityAmount={}
IntensityEffect={}"#,
to_rgb(color_effects.color, bg),
color_effects.color_amount,
color_effects.color_effect.as_u8(),
color_effects.contrast_amount,
color_effects.contrast_effect.as_u8(),
color_effects.intensity_amount,
color_effects.intensity_effect.as_u8(),
)
}
fn format_ini_colors(colors: &IniColors, bg: Srgba) -> String {
format!(
r#"BackgroundAlternate={}
BackgroundNormal={}
DecorationFocus={}
DecorationHover={}
ForegroundActive={}
ForegroundInactive={}
ForegroundLink={}
ForegroundNegative={}
ForegroundNeutral={}
ForegroundNormal={}
ForegroundPositive={}
ForegroundVisited={}"#,
to_rgb(colors.background_alternate, bg),
to_rgb(colors.background_normal, bg),
to_rgb(colors.decoration_focus, bg),
to_rgb(colors.decoration_hover, bg),
to_rgb(colors.foreground_active, bg),
to_rgb(colors.foreground_inactive, bg),
to_rgb(colors.foreground_link, bg),
to_rgb(colors.foreground_negative, bg),
to_rgb(colors.foreground_neutral, bg),
to_rgb(colors.foreground_normal, bg),
to_rgb(colors.foreground_positive, bg),
to_rgb(colors.foreground_visited, bg),
)
}
/// Sets the colors for the titlebars of active and inactive windows.
fn format_ini_wm_colors(view_colors: &IniColors, is_dark: bool) -> String {
let bg = view_colors.background_normal;
let fg = view_colors.foreground_active;
let blend = if is_dark { fg } else { bg };
format!(
r#"activeBackground={}
activeBlend={}
activeForeground={}
inactiveBackground={}
inactiveBlend={}
inactiveForeground={}"#,
to_rgb(bg, bg),
to_rgb(blend, bg),
to_rgb(fg, bg),
to_rgb(bg, bg),
to_rgb(blend, bg),
to_rgb(fg, bg),
)
}
struct IniColorEffects {
color: Srgba,
color_amount: f32,
color_effect: ColorEffect,
contrast_amount: f32,
/// Applied to the text, using the background as the reference color.
contrast_effect: ColorEffect,
intensity_amount: f32,
intensity_effect: IntensityEffect,
}
/// Each color set is made up of a number of roles which are available in all other sets.
/// In addition, except for Inactive Text, there is a corresponding background role for each of the text roles. Currently (except for Normal and Alternate Background), these colors are not chosen here but are automatically determined based on Normal Background and the corresponding Text color.
struct IniColors {
/// used when there is a need to subtly change the background to aid in item association. This might be used e.g. as the background of a heading, but is mostly used for alternating rows in lists, especially multi-column lists, to aid in visually tracking rows.
background_alternate: Srgba,
/// Normal background
background_normal: Srgba,
/// Used for drawing lines or shading UI elements to indicate the item which has active input focus.
/// Typically the same as foreground_active.
decoration_focus: Srgba,
/// Used for drawing lines or shading UI elements for mouse-over effects, e.g. the "illumination" effects for buttons.
/// Typically the same as foreground_active.
decoration_hover: Srgba,
/// used to indicate an active element or attract attention, e.g. alerts, notifications; also for hovered hyperlinks
foreground_active: Srgba,
/// used for text which should be unobtrusive, e.g. comments, "subtitles", unimportant information, etc.
foreground_inactive: Srgba,
/// used for hyperlinks or to otherwise indicate "something which may be visited", or to show relationships
foreground_link: Srgba,
/// used for errors, failure notices, notifications that an action may be dangerous (e.g. unsafe web page or security context), etc.
foreground_negative: Srgba,
/// used to draw attention when another role is not appropriate; e.g. warnings, to indicate secure/encrypted content, etc.
foreground_neutral: Srgba,
/// Normal foreground
foreground_normal: Srgba,
/// used for success notices, to indicate trusted content, etc.
foreground_positive: Srgba,
/// used for "something (e.g. a hyperlink) that has been visited", or to indicate something that is "old".
foreground_visited: Srgba,
}
/// Intensity allows the overall color to be lightened or darkened.
#[allow(dead_code)]
enum IntensityEffect {
/// Makes everything lighter or darker in a controlled manner.
///
/// intensity_amount increases or decreases the overall intensity (i.e. perceived brightness) by an absolute amount.
Shade,
/// Changes the intensity to a percentage of the initial value.
Darken,
/// Conceptually the opposite of darken; lighten can be thought of as working with "distance from white", where darken works with "distance from black".
Lighten,
}
impl IntensityEffect {
pub fn as_u8(&self) -> u8 {
match self {
Self::Shade => 0,
Self::Darken => 1,
Self::Lighten => 2,
}
}
}
/// This also changes the overall color like [IntensityEffect],
/// but is not limited to intensity.
#[allow(dead_code)]
enum ColorEffect {
/// changes the relative chroma
///
/// This is available for "ColorEffect" but not "ContrastEffect".
Desaturate,
/// smoothly blends the original color into a reference color
Fade,
/// similar to Fade, except that the color (hue and chroma) changes more quickly while the intensity changes more slowly as the amount is increased
Tint,
}
impl ColorEffect {
pub fn as_u8(&self) -> u8 {
match self {
Self::Desaturate => 0,
Self::Fade => 1,
Self::Tint => 2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_opaque_color_to_rgb() {
let color = Srgba::new(30.0 / 255.0, 50.0 / 255.0, 70.0 / 255.0, 1.0);
let bg = Srgba::new(1.0, 1.0, 1.0, 1.0);
let result = to_rgb(color, bg);
assert_eq!(result, "30,50,70");
fn test_is_cosmic_kdeglobals_dark() -> io::Result<()> {
let mut ini = Ini::new_cs();
ini.setstr("General", "ColorScheme", Some("CosmicDark"));
let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?;
assert_eq!(is_cosmic, Some(true));
Ok(())
}
#[test]
fn test_transparent_color_to_rgb() {
let color = Srgba::new(0.0, 0.0, 0.0, 0.0);
let bg = Srgba::new(1.0, 1.0, 1.0, 1.0);
let result = to_rgb(color, bg);
assert_eq!(result, "255,255,255");
fn test_is_cosmic_kdeglobals_light() -> io::Result<()> {
let mut ini = Ini::new_cs();
ini.setstr("General", "ColorScheme", Some("CosmicLight"));
let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?;
assert_eq!(is_cosmic, Some(true));
Ok(())
}
#[test]
fn test_translucent_color_to_rgb() {
let color = Srgba::new(0.0, 0.0, 0.0, 0.9);
let bg = Srgba::new(1.0, 1.0, 1.0, 1.0);
let result = to_rgb(color, bg);
assert_eq!(result, "26,26,26");
fn test_is_cosmic_kdeglobals_breeze() -> io::Result<()> {
let mut ini = Ini::new_cs();
ini.setstr("General", "ColorScheme", Some("BreezeDark"));
let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?;
assert_eq!(is_cosmic, Some(false));
Ok(())
}
#[test]
fn test_light_default_kcolorscheme() {
let light_default_kcolorscheme = Theme::light_default().as_kcolorscheme();
insta::assert_snapshot!(light_default_kcolorscheme);
}
#[test]
fn test_dark_default_kcolorscheme() {
let dark_default_kcolorscheme = Theme::dark_default().as_kcolorscheme();
insta::assert_snapshot!(dark_default_kcolorscheme);
fn test_is_cosmic_kdeglobals_blank() -> io::Result<()> {
let mut ini = Ini::new_cs();
ini.setstr("General", "ColorScheme", None);
let is_cosmic = Theme::is_cosmic_kdeglobals(&ini)?;
assert_eq!(is_cosmic, None);
Ok(())
}
}

View file

@ -1,157 +0,0 @@
---
source: cosmic-theme/src/output/qt_output.rs
expression: dark_default_kcolorscheme
---
# GENERATED BY COSMIC
[ColorEffects:Disabled]
Color=43,43,43
ColorAmount=0
ColorEffect=0
ContrastAmount=0.65
ContrastEffect=1
IntensityAmount=0.1
IntensityEffect=2
[ColorEffects:Inactive]
ChangeSelectionColor=false
Enable=false
Color=27,27,27
ColorAmount=0.025
ColorEffect=2
ContrastAmount=0.1
ContrastEffect=2
IntensityAmount=0
IntensityEffect=0
[Colors:Button]
BackgroundAlternate=99,208,223
BackgroundNormal=60,60,60
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Complementary]
BackgroundAlternate=99,208,223
BackgroundNormal=27,27,27
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Header]
BackgroundAlternate=31,36,37
BackgroundNormal=27,27,27
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Header][Inactive]
BackgroundAlternate=31,36,37
BackgroundNormal=27,27,27
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Selection]
BackgroundAlternate=63,118,125
BackgroundNormal=99,208,223
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=67,67,67
ForegroundInactive=83,138,145
ForegroundLink=27,27,27
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=67,67,67
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Tooltip]
BackgroundAlternate=49,55,55
BackgroundNormal=46,46,46
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:View]
BackgroundAlternate=49,55,55
BackgroundNormal=46,46,46
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Window]
BackgroundAlternate=31,36,37
BackgroundNormal=27,27,27
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[General]
ColorScheme=CosmicDark
Name=COSMIC Dark
shadeSortColumn=true
[Icons]
Theme=breeze-dark
[KDE]
contrast=4
widgetStyle=qt6ct-style
[WM]
activeBackground=27,27,27
activeBlend=99,208,223
activeForeground=99,208,223
inactiveBackground=27,27,27
inactiveBlend=99,208,223
inactiveForeground=99,208,223

View file

@ -1,157 +0,0 @@
---
source: cosmic-theme/src/output/qt_output.rs
expression: light_default_kcolorscheme
---
# GENERATED BY COSMIC
[ColorEffects:Disabled]
Color=194,194,194
ColorAmount=0
ColorEffect=0
ContrastAmount=0.65
ContrastEffect=1
IntensityAmount=0.1
IntensityEffect=2
[ColorEffects:Inactive]
ChangeSelectionColor=false
Enable=false
Color=215,215,215
ColorAmount=0.025
ColorEffect=2
ContrastAmount=0.1
ContrastEffect=2
IntensityAmount=0
IntensityEffect=0
[Colors:Button]
BackgroundAlternate=0,82,90
BackgroundNormal=173,173,173
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=0,82,90
ForegroundInactive=38,38,38
ForegroundLink=0,82,90
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=18,18,18
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[Colors:Complementary]
BackgroundAlternate=99,208,223
BackgroundNormal=27,27,27
DecorationFocus=99,208,223
DecorationHover=99,208,223
ForegroundActive=99,208,223
ForegroundInactive=211,211,211
ForegroundLink=99,208,223
ForegroundNegative=255,160,154
ForegroundNeutral=255,163,125
ForegroundNormal=231,231,231
ForegroundPositive=94,219,140
ForegroundVisited=99,208,223
[Colors:Header]
BackgroundAlternate=204,208,209
BackgroundNormal=215,215,215
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=0,82,90
ForegroundInactive=38,38,38
ForegroundLink=0,82,90
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=18,18,18
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[Colors:Header][Inactive]
BackgroundAlternate=204,208,209
BackgroundNormal=215,215,215
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=0,82,90
ForegroundInactive=38,38,38
ForegroundLink=0,82,90
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=18,18,18
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[Colors:Selection]
BackgroundAlternate=108,149,153
BackgroundNormal=0,82,90
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=246,246,246
ForegroundInactive=123,164,168
ForegroundLink=215,215,215
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=246,246,246
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[Colors:Tooltip]
BackgroundAlternate=233,237,237
BackgroundNormal=245,245,245
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=0,82,90
ForegroundInactive=38,38,38
ForegroundLink=0,82,90
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=18,18,18
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[Colors:View]
BackgroundAlternate=233,237,237
BackgroundNormal=245,245,245
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=0,82,90
ForegroundInactive=38,38,38
ForegroundLink=0,82,90
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=18,18,18
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[Colors:Window]
BackgroundAlternate=204,208,209
BackgroundNormal=215,215,215
DecorationFocus=0,82,90
DecorationHover=0,82,90
ForegroundActive=0,82,90
ForegroundInactive=38,38,38
ForegroundLink=0,82,90
ForegroundNegative=137,4,24
ForegroundNeutral=121,44,0
ForegroundNormal=18,18,18
ForegroundPositive=0,87,44
ForegroundVisited=0,82,90
[General]
ColorScheme=CosmicLight
Name=COSMIC Light
shadeSortColumn=true
[Icons]
Theme=breeze
[KDE]
contrast=4
widgetStyle=qt6ct-style
[WM]
activeBackground=215,215,215
activeBlend=215,215,215
activeForeground=0,82,90
inactiveBackground=215,215,215
inactiveBlend=215,215,215
inactiveForeground=0,82,90

View file

@ -5,7 +5,7 @@ use cosmic::iced::core::{id, Alignment, Length, Point};
use cosmic::iced::widget::{column, container, scrollable, text};
use cosmic::iced::{self, event, window, Subscription};
use cosmic::prelude::*;
use cosmic::widget::{button, header_bar};
use cosmic::widget::{button, header_bar, selectable_text};
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
@ -132,10 +132,14 @@ impl cosmic::Application for MultiWindow {
let new_window_button = button::custom(text("New Window")).on_press(Message::NewWindow);
let content = scrollable(
column![input, new_window_button]
.spacing(50)
.width(Length::Fill)
.align_x(Alignment::Center),
column![
input,
new_window_button,
selectable_text::title1("I'm selectable...")
]
.spacing(50)
.width(Length::Fill)
.align_x(Alignment::Center),
);
let window_content = container(container(content).center_x(Length::Fixed(200.)))
@ -146,7 +150,14 @@ impl cosmic::Application for MultiWindow {
if id == self.core.main_window_id().unwrap() {
window_content.into()
} else {
column![header_bar().focused(focused), window_content].into()
container(column![
header_bar()
.on_close(Message::CloseWindow(id))
.focused(focused),
window_content
])
.class(cosmic::theme::Container::WindowBackground)
.into()
}
}

3
i18n/ca/libcosmic.ftl Normal file
View file

@ -0,0 +1,3 @@
license = Llicència
close = Tancar
links = Enllaços

34
i18n/gl/libcosmic.ftl Normal file
View file

@ -0,0 +1,34 @@
close = Pechar
license = Licenza
links = Enderezos
developers = Desenvolvedores
designers = Deseñadores
artists = Artistas
translators = Traductores
documenters = Documentadores
january = Xaneiro { $year }
february = Febreiro { $year }
march = Marzo { $year }
april = Abril { $year }
may = Maio { $year }
june = Xuño { $year }
july = Xullo { $year }
august = Agosto { $year }
september = Setembro { $year }
october = Outubro { $year }
november = Novembro { $year }
december = Decembro { $year }
monday = Luns
mon = Lun
tuesday = Martes
tue = Mar
wednesday = Mércores
wed = Mér
thursday = Xoves
thu = Xov
friday = Venres
fri = Ven
saturday = Sábado
sat = Sáb
sunday = Domingo
sun = Dom

14
i18n/oc/libcosmic.ftl Normal file
View file

@ -0,0 +1,14 @@
monday = Diluns
mon = Dil
tuesday = Dimars
tue = Dim
wednesday = Dimècres
wed = Dma
thursday = Dijòus
thu = Dij
friday = Divendres
fri = Div
saturday = Dissabte
sat = Dis
sunday = Dimenge
sun = Dim

0
i18n/sat/libcosmic.ftl Normal file
View file

2
iced

@ -1 +1 @@
Subproject commit 1a57634add6e820c299f1c59664cd98098a9c348
Subproject commit c34af044311c986e5f8458cf61bab2508aca48fa

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