Merge branch 'master' into bulgarian

This commit is contained in:
Jeremy Soller 2026-01-27 10:38:05 -07:00 • committed by GitHub
commit 055befb30a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
272 changed files with 4273 additions and 4972 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "cosmic-applet-status-area"
version = "0.1.0"
version = "1.0.2"
edition = "2024"
license = "GPL-3.0-only"

View file

@ -11,6 +11,7 @@ Name[pt]=Área de Notificações
Name[sk]=Panel oznámení
Name[sv]=Fält för programikoner
Name[es]=Bandeja de notificaciones
Name[it]=Area notifiche
Type=Application
Exec=cosmic-applet-status-area
Terminal=false

View file

@ -7,7 +7,7 @@ use cosmic::{
applet::token::subscription::{TokenRequest, TokenUpdate, activation_token_subscription},
cctk::sctk::reexports::calloop,
iced::{
self, Subscription,
self, Length, Subscription,
platform_specific::shell::commands::popup::{destroy_popup, get_popup},
window,
},
@ -20,6 +20,8 @@ use crate::{components::status_menu, subscriptions::status_notifier_watcher};
#[derive(Clone, Debug)]
pub enum Msg {
None,
Activate(usize),
Closed(window::Id),
// XXX don't use index (unique window id? or I guess that's created and destroyed)
StatusMenu((usize, status_menu::Msg)),
@ -56,7 +58,7 @@ impl App {
fn resize_window(&self) -> app::Task<Msg> {
let icon_size = self.core.applet.suggested_size(true).0 as u32
+ self.core.applet.suggested_padding(true) as u32 * 2;
+ self.core.applet.suggested_padding(true).1 as u32 * 2;
let n = self.menus.len() as u32;
window::resize(
self.core.main_window_id().unwrap(),
@ -73,8 +75,8 @@ impl App {
}
})?;
let button_total_size =
self.core.applet.suggested_size(true).0 + self.core.applet.suggested_padding(true) * 2;
let button_total_size = self.core.applet.suggested_size(true).0
+ self.core.applet.suggested_padding(true).1 * 2;
let menu_count = self.menus.len();
@ -95,22 +97,11 @@ impl App {
let overflow_index = self.overflow_index().unwrap_or(0);
let children = self.menus.iter().skip(overflow_index).map(|(id, menu)| {
mouse_area(
match menu.icon_pixmap() {
Some(icon) if menu.icon_name() == "" => self
.core
.applet
.icon_button_from_handle(icon.clone().symbolic(true)),
_ => self.core.applet.icon_button(menu.icon_name()),
}
.on_press_down(Msg::TogglePopup(*id)),
menu_icon_button(&self.core.applet, &menu).on_press_down(Msg::TogglePopup(*id)),
)
.on_enter(Msg::Hovered(*id))
.into()
});
let theme = self.core.system_theme();
let cosmic = theme.cosmic();
let corners = cosmic.corner_radii;
let pad = corners.radius_m[0];
self.core
.applet
@ -158,6 +149,26 @@ impl cosmic::Application for App {
fn update(&mut self, message: Msg) -> app::Task<Msg> {
match message {
Msg::None => Task::none(),
Msg::Activate(id) => {
if let Some(token_tx) = self.token_tx.as_ref() {
let _ = token_tx.send(TokenRequest {
app_id: Self::APP_ID.to_string(),
exec: format!("activate:{}", id),
});
} else {
if let Some(menu) = self.menus.get(&id) {
let item_proxy = menu.item.item_proxy().clone();
return Task::future(async move {
match item_proxy.activate(0, 0).await {
Ok(_) => cosmic::action::app(Msg::None),
Err(_) => cosmic::action::app(Msg::TogglePopup(id)),
}
});
}
}
Task::none()
}
Msg::Closed(surface) => {
if self.popup == Some(surface) {
self.popup = None;
@ -237,7 +248,7 @@ impl cosmic::Application for App {
Some((i, self.core.main_window_id().unwrap()))
}
})
.unwrap_or((0, self.core.main_window_id().unwrap()));
.unwrap_or((i, self.core.main_window_id().unwrap()));
let mut popup_settings = self
.core
@ -250,11 +261,11 @@ impl cosmic::Application for App {
PanelAnchor::Left | PanelAnchor::Right
) {
let suggested_size = self.core.applet.suggested_size(false).1
+ 2 * self.core.applet.suggested_padding(false);
+ 2 * self.core.applet.suggested_padding(false).1;
popup_settings.positioner.anchor_rect.y = i as i32 * suggested_size as i32;
} else {
let suggested_size = self.core.applet.suggested_size(false).0
+ 2 * self.core.applet.suggested_padding(false);
+ 2 * self.core.applet.suggested_padding(false).1;
popup_settings.positioner.anchor_rect.x = i as i32 * suggested_size as i32;
}
cmds.push(get_popup(popup_settings));
@ -276,6 +287,36 @@ impl cosmic::Application for App {
return Task::none();
}
TokenUpdate::ActivationToken { token, exec: id } => {
if let Some(id_str) = id.strip_prefix("activate:") {
if let Ok(real_id) = id_str.parse::<usize>() {
if let Some(menu) = self.menus.get(&real_id) {
let item_proxy = menu.item.item_proxy().clone();
let token = token.clone();
let id = real_id;
return Task::future(async move {
if let Some(t) = token {
match item_proxy.provide_xdg_activation_token(t).await {
Ok(_) => {
println!("Token provided successfully to {}", id)
}
Err(e) => eprintln!(
"Failed to provide token to {}: {}",
id, e
),
}
}
match item_proxy.activate(0, 0).await {
Ok(_) => cosmic::action::app(Msg::None),
Err(err) => {
eprintln!("Activate failed: {}", err);
cosmic::action::app(Msg::TogglePopup(id))
}
}
});
}
}
return Task::none();
}
if let Some(((state, id), token)) = str::parse(&id)
.ok()
.and_then(|id: usize| self.menus.get_mut(&id).map(|m| (m, id)))
@ -319,7 +360,7 @@ impl cosmic::Application for App {
Some((i, self.core.main_window_id().unwrap()))
}
})
.unwrap_or((0, self.core.main_window_id().unwrap()));
.unwrap_or((i, self.core.main_window_id().unwrap()));
let mut popup_settings = self
.core
@ -331,12 +372,12 @@ impl cosmic::Application for App {
self.core.applet.anchor,
PanelAnchor::Left | PanelAnchor::Right
) {
let suggested_size = self.core.applet.suggested_size(false).1
+ 2 * self.core.applet.suggested_padding(false);
let suggested_size = self.core.applet.suggested_size(true).1
+ 2 * self.core.applet.suggested_padding(true).1;
popup_settings.positioner.anchor_rect.y = i as i32 * suggested_size as i32;
} else {
let suggested_size = self.core.applet.suggested_size(false).0
+ 2 * self.core.applet.suggested_padding(false);
let suggested_size = self.core.applet.suggested_size(true).0
+ 2 * self.core.applet.suggested_padding(true).0;
popup_settings.positioner.anchor_rect.x = i as i32 * suggested_size as i32;
}
cmds.push(get_popup(popup_settings));
@ -368,13 +409,13 @@ impl cosmic::Application for App {
self.core.applet.anchor,
PanelAnchor::Left | PanelAnchor::Right
) {
let suggested_size = self.core.applet.suggested_size(false).1
+ 2 * self.core.applet.suggested_padding(false);
let suggested_size = self.core.applet.suggested_size(true).1
+ 2 * self.core.applet.suggested_padding(true).1;
popup_settings.positioner.anchor_rect.y =
overflow_index as i32 * suggested_size as i32;
} else {
let suggested_size = self.core.applet.suggested_size(false).0
+ 2 * self.core.applet.suggested_padding(false);
let suggested_size = self.core.applet.suggested_size(true).0
+ 2 * self.core.applet.suggested_padding(true).0;
popup_settings.positioner.anchor_rect.x =
overflow_index as i32 * suggested_size as i32;
}
@ -418,11 +459,11 @@ impl cosmic::Application for App {
PanelAnchor::Left | PanelAnchor::Right
) {
let suggested_size = self.core.applet.suggested_size(false).1
+ 2 * self.core.applet.suggested_padding(false);
+ 2 * self.core.applet.suggested_padding(false).1;
popup_settings.positioner.anchor_rect.y = i as i32 * suggested_size as i32;
} else {
let suggested_size = self.core.applet.suggested_size(false).0
+ 2 * self.core.applet.suggested_padding(false);
+ 2 * self.core.applet.suggested_padding(false).1;
popup_settings.positioner.anchor_rect.x = i as i32 * suggested_size as i32;
}
cmds.push(get_popup(popup_settings));
@ -452,18 +493,10 @@ impl cosmic::Application for App {
.iter()
.take(overflow_index.unwrap_or(self.menus.len()))
.map(|(id, menu)| {
mouse_area(
match menu.icon_pixmap() {
Some(icon) if menu.icon_name() == "" => self
.core
.applet
.icon_button_from_handle(icon.clone().symbolic(true)),
_ => self.core.applet.icon_button(menu.icon_name()),
}
.on_press_down(Msg::TogglePopup(*id)),
)
.on_enter(Msg::Hovered(*id))
.into()
mouse_area(menu_icon_button(&self.core.applet, &menu).on_press(Msg::Activate(*id)))
.on_right_press(Msg::TogglePopup(*id))
.on_enter(Msg::Hovered(*id))
.into()
});
self.core
@ -530,6 +563,47 @@ impl cosmic::Application for App {
}
}
fn menu_icon_button<'a>(
applet: &'a cosmic::applet::Context,
menu: &'a status_menu::State,
) -> cosmic::widget::Button<'a, Msg> {
let icon = menu.icon_handle().clone();
let theme = cosmic::theme::active();
let theme = theme.cosmic();
let suggested = applet.suggested_size(true);
let padding = applet.suggested_padding(true).1;
// let (major_padding, applet_padding_minor_axis) = applet.suggested_padding(true);
// let (horizontal_padding, vertical_padding) = if applet.is_horizontal() {
// (major_padding, applet_padding_minor_axis)
// } else {
// (applet_padding_minor_axis, major_padding)
// };
let symbolic = icon.symbolic;
cosmic::widget::button::custom(
cosmic::widget::layer_container(
cosmic::widget::icon(icon)
.class(if symbolic {
cosmic::theme::Svg::Custom(std::rc::Rc::new(|theme| {
cosmic::iced_widget::svg::Style {
color: Some(theme.cosmic().background.on.into()),
}
}))
} else {
cosmic::theme::Svg::default()
})
.width(Length::Fixed(suggested.0 as f32))
.height(Length::Fixed(suggested.1 as f32)),
)
.center(Length::Fill),
)
.width(Length::Fixed((suggested.0 + 2 * padding) as f32))
.height(Length::Fixed((suggested.1 + 2 * padding) as f32))
.class(cosmic::theme::Button::AppletIcon)
}
pub fn main() -> iced::Result {
cosmic::applet::run::<App>(())
}

View file

@ -6,8 +6,9 @@ use cosmic::{
applet::{menu_button, token::subscription::TokenRequest},
cctk::sctk::reexports::calloop,
iced,
widget::icon,
widget::icon::{self, IconFallback},
};
use std::path::{Path, PathBuf};
use crate::subscriptions::status_notifier_item::{IconUpdate, Layout, StatusNotifierItem};
@ -20,12 +21,11 @@ pub enum Msg {
}
pub struct State {
item: StatusNotifierItem,
pub item: StatusNotifierItem,
layout: Option<Layout>,
expanded: Option<i32>,
icon_name: String,
// TODO handle icon with multiple sizes?
icon_pixmap: Option<icon::Handle>,
icon_handle: icon::Handle,
click_event: Option<(i32, bool)>,
}
@ -36,8 +36,9 @@ impl State {
item,
layout: None,
expanded: None,
icon_name: String::new(),
icon_pixmap: None,
icon_handle: icon::from_name("application-default")
.prefer_svg(true)
.handle(),
click_event: None,
},
iced::Task::none(),
@ -61,30 +62,46 @@ impl State {
iced::Task::none()
}
Msg::Icon(update) => {
match update {
IconUpdate::Name(name) => {
self.icon_name = name;
}
IconUpdate::Pixmap(icons) => {
self.icon_pixmap = icons
.into_iter()
.max_by_key(|i| (i.width, i.height))
.map(|mut i| {
if i.width <= 0 || i.height <= 0 || i.bytes.is_empty() {
// App sent invalid icon data during initialization - show placeholder until NewIcon signal
eprintln!("Skipping invalid icon: {}x{} with {} bytes, app may still be initializing",
i.width, i.height, i.bytes.len());
return icon::from_name("dialog-question").symbolic(true).handle();
}
// Convert ARGB to RGBA
for pixel in i.bytes.chunks_exact_mut(4) {
pixel.rotate_left(1);
}
icon::from_raster_pixels(i.width as u32, i.height as u32, i.bytes)
});
let icon_name = update.name.unwrap_or_default();
// Use the icon pixmap if an icon was not defined by name.
if icon_name.is_empty() {
let icon_pixmap = update.pixmap.and_then(|icons| icons
.into_iter()
.max_by_key(|i| (i.width, i.height))
.map(|mut i| {
if i.width <= 0 || i.height <= 0 || i.bytes.is_empty() {
// App sent invalid icon data during initialization - show placeholder until NewIcon signal
tracing::debug!("Skipping invalid icon: {}x{} with {} bytes, app may still be initializing",
i.width, i.height, i.bytes.len());
return icon::from_name("dialog-question").symbolic(true).handle();
}
// Convert ARGB to RGBA
for pixel in i.bytes.chunks_exact_mut(4) {
pixel.rotate_left(1);
}
icon::from_raster_pixels(i.width as u32, i.height as u32, i.bytes)
}));
if let Some(icon) = icon_pixmap {
self.icon_handle = icon.clone();
return iced::Task::none();
}
}
// Load icon by path if the name is a path.
self.icon_handle = if Path::new(&icon_name).exists() {
icon::from_path(Path::new(&icon_name).to_path_buf()).symbolic(true)
} else {
icon::from_name(icon_name)
.prefer_svg(true)
.fallback(Some(IconFallback::Names(vec![
"application-default".into(),
"application-x-executable".into(),
])))
.handle()
};
iced::Task::none()
}
Msg::Click(id, is_submenu) => {
@ -101,9 +118,19 @@ impl State {
let Some((id, is_submenu)) = self.click_event else {
return iced::Task::none();
};
let menu_proxy = self.item.menu_proxy().clone();
let item_proxy = self.item.item_proxy().clone();
let Some(menu_proxy) = self.item.menu_proxy().cloned() else {
tokio::spawn(async move {
let _ = item_proxy.provide_xdg_activation_token(token).await;
if let Err(err) = item_proxy.activate(0, 0).await {
tracing::error!(
"Error activating status notifier item without menu proxy: {err:?}"
);
}
});
return iced::Task::none();
};
tokio::spawn(async move {
let _ = item_proxy.provide_xdg_activation_token(token).await;
let _ = menu_proxy.event(id, "clicked", &0.into(), 0).await;
@ -126,12 +153,8 @@ impl State {
self.item.name()
}
pub fn icon_name(&self) -> &str {
&self.icon_name
}
pub fn icon_pixmap(&self) -> Option<&icon::Handle> {
self.icon_pixmap.as_ref()
pub fn icon_handle(&self) -> &icon::Handle {
&self.icon_handle
}
pub fn popup_view(&self) -> cosmic::Element<'_, Msg> {
@ -150,7 +173,9 @@ impl State {
}
pub fn opened(&self) {
let menu_proxy = self.item.menu_proxy().clone();
let Some(menu_proxy) = self.item.menu_proxy().cloned() else {
return;
};
tokio::spawn(async move {
let _ = menu_proxy.event(0, "opened", &0i32.into(), 0).await;
let _ = menu_proxy.about_to_show(0).await;
@ -158,7 +183,9 @@ impl State {
}
pub fn closed(&self) {
let menu_proxy = self.item.menu_proxy().clone();
let Some(menu_proxy) = self.item.menu_proxy().cloned() else {
return;
};
tokio::spawn(async move {
let _ = menu_proxy.event(0, "closed", &0i32.into(), 0).await;
});

View file

@ -4,13 +4,14 @@
use cosmic::iced::{self, Subscription};
use futures::{FutureExt, StreamExt};
use rustc_hash::FxHashMap;
use std::path::PathBuf;
use zbus::zvariant::{self, OwnedValue};
#[derive(Clone, Debug)]
pub struct StatusNotifierItem {
name: String,
item_proxy: StatusNotifierItemProxy<'static>,
menu_proxy: DBusMenuProxy<'static>,
menu_proxy: Option<DBusMenuProxy<'static>>,
}
#[derive(Clone, Debug, zvariant::Value)]
@ -21,9 +22,10 @@ pub struct Icon {
}
#[derive(Clone, Debug)]
pub enum IconUpdate {
Name(String),
Pixmap(Vec<Icon>),
pub struct IconUpdate {
pub name: Option<String>,
pub pixmap: Option<Vec<Icon>>,
// pub theme_path: Option<PathBuf>,
}
impl StatusNotifierItem {
@ -42,7 +44,22 @@ impl StatusNotifierItem {
.build()
.await?;
let menu_path = item_proxy.menu().await?;
// XX: some items will not implement this but have a menu anyway
let is_menu = item_proxy.item_is_menu().await;
let menu_path = item_proxy.menu().await;
// Why would an item say it has no menu but provide a menu path? Slack does this.
let is_menu = menu_path.is_ok() || is_menu.unwrap_or(false);
if !is_menu {
return Ok(Self {
name,
item_proxy,
menu_proxy: None,
});
}
let menu_path = menu_path?;
let menu_proxy = DBusMenuProxy::builder(connection)
.destination(dest.to_string())?
.path(menu_path)?
@ -52,7 +69,7 @@ impl StatusNotifierItem {
Ok(Self {
name,
item_proxy,
menu_proxy,
menu_proxy: Some(menu_proxy),
})
}
@ -62,13 +79,22 @@ impl StatusNotifierItem {
// TODO: Only fetch changed part of layout, if that's any faster
pub fn layout_subscription(&self) -> iced::Subscription<Result<Layout, String>> {
let menu_proxy = self.menu_proxy.clone();
let Some(menu_proxy) = self.menu_proxy.clone() else {
return Subscription::none();
};
Subscription::run_with_id(
format!("status-notifier-item-layout-{}", &self.name),
async move {
let initial = futures::stream::once(get_layout(menu_proxy.clone()));
let layout_updated_stream = menu_proxy.receive_layout_updated().await.unwrap();
let updates = layout_updated_stream.then(move |_| get_layout(menu_proxy.clone()));
let layout_updated = menu_proxy.receive_layout_updated().await.unwrap();
let props_updated = menu_proxy.receive_items_properties_updated().await.unwrap();
// Merge both streams - any update triggers a layout refetch
let updates =
futures::stream_select!(layout_updated.map(|_| ()), props_updated.map(|_| ()))
.then(move |()| get_layout(menu_proxy.clone()));
initial.chain(updates)
}
.flatten_stream(),
@ -76,22 +102,15 @@ impl StatusNotifierItem {
}
pub fn icon_subscription(&self) -> iced::Subscription<IconUpdate> {
fn icon_events(
item_proxy: StatusNotifierItemProxy<'static>,
) -> impl futures::Stream<Item = IconUpdate> + 'static {
async move {
let icon_name = item_proxy.icon_name().await;
let icon_pixmap = item_proxy.icon_pixmap().await;
futures::stream::iter(
[
icon_name.map(IconUpdate::Name),
icon_pixmap.map(IconUpdate::Pixmap),
]
.into_iter()
.filter_map(Result::ok),
)
async fn icon_events(item_proxy: StatusNotifierItemProxy<'static>) -> IconUpdate {
let icon_name = item_proxy.icon_name().await;
let icon_pixmap = item_proxy.icon_pixmap().await;
// let icon_theme_path = item_proxy.icon_theme_path().await.map(PathBuf::from);
IconUpdate {
name: icon_name.ok(),
pixmap: icon_pixmap.ok(),
// theme_path: icon_theme_path.ok().filter(|x| !x.as_os_str().is_empty()),
}
.flatten_stream()
}
let item_proxy = self.item_proxy.clone();
@ -101,14 +120,14 @@ impl StatusNotifierItem {
let new_icon_stream = item_proxy.receive_new_icon().await.unwrap();
futures::stream::once(async {})
.chain(new_icon_stream.map(|_| ()))
.flat_map(move |()| icon_events(item_proxy.clone()))
.then(move |()| icon_events(item_proxy.clone()))
}
.flatten_stream(),
)
}
pub fn menu_proxy(&self) -> &DBusMenuProxy<'static> {
&self.menu_proxy
pub fn menu_proxy(&self) -> Option<&DBusMenuProxy<'static>> {
self.menu_proxy.as_ref()
}
pub fn item_proxy(&self) -> &StatusNotifierItemProxy<'static> {
@ -128,6 +147,9 @@ pub trait StatusNotifierItem {
#[zbus(property)]
fn icon_name(&self) -> zbus::Result<String>;
#[zbus(property)]
fn icon_theme_path(&self) -> zbus::Result<String>;
// https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/Icons
#[zbus(property)]
fn icon_pixmap(&self) -> zbus::Result<Vec<Icon>>;
@ -135,10 +157,17 @@ pub trait StatusNotifierItem {
#[zbus(property)]
fn menu(&self) -> zbus::Result<zvariant::OwnedObjectPath>;
#[zbus(property)]
fn item_is_menu(&self) -> zbus::Result<bool>;
#[zbus(signal)]
fn new_icon(&self) -> zbus::Result<()>;
fn provide_xdg_activation_token(&self, token: String) -> zbus::Result<()>;
fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>;
}
#[derive(Clone, Debug)]
@ -261,4 +290,11 @@ pub trait DBusMenu {
#[zbus(signal)]
fn layout_updated(&self, revision: u32, parent: i32) -> zbus::Result<()>;
#[zbus(signal)]
fn items_properties_updated(
&self,
updated_props: Vec<(i32, std::collections::HashMap<String, zvariant::OwnedValue>)>,
removed_props: Vec<(i32, Vec<String>)>,
) -> zbus::Result<()>;
}