Compare commits

..

40 commits

Author SHA1 Message Date
ebecfa6b7d chore: refresh lockfile post-upstream rebase
Some checks failed
Continuous Integration / formatting (push) Has been cancelled
Continuous Integration / linting (push) Has been cancelled
Leyoda 2026 – GPLv3
2026-07-04 11:20:22 +02:00
adc97dc8f1 fix: adapt to cosmic-theme background(transparent) accessor
Leyoda 2026 – GPLv3
2026-07-04 11:19:28 +02:00
ced6eb267e Fix status menu build after upstream rebase 2026-07-04 11:19:28 +02:00
04acc50e4d feat: add themed launcher icon catalog 2026-07-04 11:19:28 +02:00
8d56d0b500 fix: open launcher editor in existing popup 2026-07-04 11:19:28 +02:00
d58fe9d74c feat: add editable dock launchers 2026-07-04 11:19:28 +02:00
9677159db6 chore: fix applets redeploy path 2026-07-04 11:19:28 +02:00
3c360142ae chore: use local COSMIC support crates 2026-07-04 11:19:28 +02:00
ebfaa4eb7b chore: use local cosmic-text checkout 2026-07-04 11:19:28 +02:00
0023a462ba yoda: use local dbus settings bindings 2026-07-04 11:19:28 +02:00
b4fbfe168a chore: align applets with local stack 2026-07-04 11:02:35 +02:00
Votre Nom
c117f172cb fix(audio): accumuler les rafales scroll Pixels au lieu de signum()
Avec Wayland axis_v120 (scroll haute-résolution sur souris HID modernes),
un cran physique génère 5–8 events ScrollDelta::Pixels (~15–20px chacun).
L'ancien code passait chaque sub-event par .signum() puis -1/+1 à sink_volume,
donc un seul cran physique faisait varier le volume de 5 à 40% — résultat :
scroll up sur l'icône audio panel / dock coupait le son si le volume était
déjà bas.

Fix : thread_local accumulator des deltas Pixels, émission seulement
au passage du seuil de 15px par cran logique. Lines (souris classique
sans axis_v120) reste proportionnel y * WHEEL_STEP. round() au lieu de
truncation finale pour ne pas perdre les fractions de pourcent.

Leyoda 2026 - GPLv3
2026-07-04 10:55:18 +02:00
Votre Nom
66392fc42a chore: add redeploy.sh for /usr/local/bin install
Builds workspace release, backs up existing binaries, installs
cosmic-applets/cosmic-app-list/cosmic-panel-button to /usr/local/bin
(precedence over pacman package via $PATH).

Leyoda 2026 – GPLv3
2026-07-04 10:55:18 +02:00
Votre Nom
c15a879c31 fix(wayland): graceful exit on compositor disconnect
Replace 3 panicking unwrap() in cosmic-app-list/wayland_handler.rs
(event loop dispatch + 2 conn.flush in screencopy) with logged
errors that break/return None instead.

Wrap cosmic-applets/main.rs entry point in panic::catch_unwind to
catch panics propagating from libcosmic/iced/winit (which we cannot
patch locally without forking) when the COSMIC compositor closes
the Wayland connection at logout. This eliminates the cascade of
~12 SIGABRT coredumps observed at session shutdown.

Panic strategy is unwind (default), catch_unwind is sound here.

Leyoda 2026 – GPLv3
2026-07-04 10:55:18 +02:00
d5c2a19e95 yoda: smooth animated fisheye for dock hover (phase B v3, closes b)
Inter-icon hover changes were snapping because icon_scale_for read the
hovered icon's real rectangle directly. This adds a small animation
layer so the bell center lerps toward the target and the whole effect
fades in/out at the dock's edges.

CosmicAppList gains three fields:
- anim_hover_center: Option<(f32, f32)> — virtual cursor position,
  chases the hovered icon's center across ticks.
- anim_hover_intensity: f32 (0..1) — global fade-in/out of the
  fisheye. Target 1.0 while a dock icon is hovered, 0.0 otherwise.
- anim_last_tick: Option<Instant> — for dt-based exponential smoothing
  (time-constant tau = 60ms, ~99% of target reached in ~120ms).

A new Message::AnimTick(Instant) is emitted at ~60 fps by a
conditional iced::time::every subscription — only active when the
pointer is over a dock icon OR intensity hasn't faded back to ~0 yet,
so the panel stays idle when no one is hovering the dock.

icon_scale_for now reads anim_hover_center instead of rectangles[hovered]
and multiplies the bell's peak by anim_hover_intensity. Behaviour:
- Pointer slides A → B: bell glides continuously, both icons animate.
- Pointer enters dock: icons inflate smoothly over ~120 ms.
- Pointer leaves dock: icons deflate smoothly over ~120 ms.

Fallback paths (first frame, missing rectangles) still respond
instantly so the feature never looks 'stuck' before the animation
kicks in.
2026-07-04 10:55:18 +02:00
6f88c6fea4 yoda: fisheye magnification for dock hover (phase B v2 / c)
Replaces the binary 1.3× hover with a true gaussian bell curve — the
hovered icon still peaks at ~1.35×, but the ±1 neighbours also bulge
noticeably, ±2 a bit, and ±3+ relax to 1.0×. Footprint ~5 icons wide,
matching the macOS Dock fisheye feel.

Implementation in fn icon_scale_for(id):
- Reads the hovered icon's and the current icon's bounds from
  self.rectangles (already populated by the existing RectangleTracker
  subscription — no new plumbing).
- Distance = |this_center - hovered_center| along the panel's long axis
  (horizontal for Top/Bottom anchors, vertical for Left/Right).
- sigma = hovered_extent * 1.4 so the bell's half-width matches one
  icon width (neighbors clearly pulled, far icons untouched).
- scale = 1.0 + PEAK * exp(-(d/sigma)²) with PEAK = 0.35.
- Falls back to binary 1.35×/1.0× when rectangle data isn't populated
  yet (first render / resize) — visibly responsive even before the
  tracker catches up.

No widget signature changes vs v1, just a smarter formula. All five
as_icon call sites already pass the result of icon_scale_for so this
update propagates everywhere.

Still on the TODO list: smooth animation (b). Right now icon→icon
transitions snap instantly; a smoothed_hover_center + tick subscription
would lerp it. Deferred to a follow-up commit.
2026-07-04 10:55:18 +02:00
1ce1d39ea3 yoda: dock icon hover magnification (macOS Tahoe-style, phase B v1)
First pass at the signature macOS Dock effect — the icon under the
pointer grows, adjacent icons stay at base size. Full fisheye (smooth
bell-curve scaling on neighbors) can be a later iteration.

Changes in cosmic-app-list/src/app.rs:
- CosmicAppList gains a hovered_dock_item: Option<DockItemId>
  auto-initialized to None via #[derive(Default)].
- New Message::DockItemHover(Option<DockItemId>) handled in update()
  by just writing the field; view() then reads it to decide scale.
- DockItem::as_icon gains an icon_scale: f32 parameter. Inside it the
  cosmic_icon width/height = (base_icon_size * icon_scale) clamped
  to u16; indicator dot and other surrounding layout stay at base
  size so only the icon visually bulges.
- New App::icon_scale_for(id) helper: 1.3 if Some(id) == hovered,
  1.0 otherwise. Single place to tune the magnification factor.
- The two main dock rows (favorites + filtered_active_list) wrap
  their rendered applet_tooltip in widget::mouse_area with
  on_enter(DockItemHover(Some(id))) / on_exit(DockItemHover(None))
  and call icon_scale_for before rendering.
- The three remaining as_icon call sites (DnD preview, favorites
  overflow popup, active overflow popup) pass icon_scale = 1.0 —
  hover magnification on those surfaces would look jittery and isn't
  needed anyway.

Build: cargo build --release -p cosmic-app-list (≈ 7s). Binary
installed at /usr/local/bin/cosmic-app-list, backup kept as
.pre-magnification.
2026-07-04 10:55:18 +02:00
Ilia Malanin
d726dd890e fix(bluetooth): do not filter out already found devices 2026-07-02 12:46:20 -06:00
William Fish
fddf3d569b
fix(status-area): forward SNI scroll events (#1431)
StatusNotifierItems can expose a Scroll(int delta, string orientation) method for tray hosts to call when the user wheels over an item. The status-area applet handled activation and context menus, but did not forward wheel events to that method.

Track the hovered tray item only while the pointer is over an icon, ignore wheel events already captured by widgets, accumulate fine-grained wheel deltas into discrete scroll steps, and call Scroll on the item proxy. Add unit coverage for the delta/orientation mapping and pixel accumulation.

Signed-off-by: William Fish <william.michael.fish@gmail.com>
2026-07-02 18:02:40 +02:00
William Fish
0441c67b10
fix(battery): show plug icon without battery (#1432) 2026-06-29 16:58:04 -04:00
Fred
1f7c59fb2b
feat(bluetooth): use spinner widget from libcosmic for connecting and pairing states 2026-06-26 23:56:47 +02:00
Fred
c2a00a36a8
feat(network): use libcosmic spinner for connecting states 2026-06-26 18:30:06 +02:00
LKramer
1473e1f008
fix(network): register secret agent at NetworkManager's expected object path
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:05:36 +02:00
Michael Murphy
2c95057716
i18n: translation update from Hosted Weblate (#1418) 2026-06-23 17:11:07 +02:00
Hosted Weblate
f022b6c1a9
i18n: translation updates from weblate
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hugo Carvalho <hugokarvalho@hotmail.com>
Co-authored-by: Isaac Subirana <isaacsubiranac@gmail.com>
Co-authored-by: Jun Hwi Ku <siguning@gmail.com>
Co-authored-by: Walter William Beckerleg Bruckman <spayk.99@protonmail.com>
Co-authored-by: Yuya Furukuwa <junclegrow@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
Co-authored-by: 김유빈 <k.sein1016@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-app-list/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-a11y/ja/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-a11y/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-audio/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-audio/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-battery/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-battery/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-bluetooth/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-bluetooth/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-network/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-network/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-notifications/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-notifications/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-power/ca/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-power/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-power/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-tiling/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-tiling/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-time/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/ja/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/ko/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/pt/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/uk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-input-sources-applet/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-workspaces-applet/kmr/
Translation: Pop OS/COSMIC Accessibility Applet
Translation: Pop OS/COSMIC App List Applet
Translation: Pop OS/COSMIC Applets Desktop Entries
Translation: Pop OS/COSMIC Audio Applet
Translation: Pop OS/COSMIC Battery Applet
Translation: Pop OS/COSMIC Bluetooth Applet
Translation: Pop OS/COSMIC Input Sources Applet
Translation: Pop OS/COSMIC Network Applet
Translation: Pop OS/COSMIC Notifications Applet
Translation: Pop OS/COSMIC Power Applet
Translation: Pop OS/COSMIC Tiling Applet
Translation: Pop OS/COSMIC Time Applet
Translation: Pop OS/COSMIC Workspaces Applet
2026-06-23 08:02:17 +02:00
Ilia Malanin
f97dc5a31e fix(minimize): cache captured data for minimized toplevels 2026-06-22 16:58:50 -06:00
Ashley Wulber
456d052535 improv(audio): adjust popup size based on window size 2026-06-22 22:09:48 +02:00
Michael Murphy
95eda40878
feat(sound): use cosmic-settings-daemon's varlink API 2026-06-15 21:09:25 +02:00
BrianHotopp
8981b0b48e
fix(network): skip assumed connections in VPN list
`cosmic-applet-network` listed every connection from `nm.list_saved_connections()` in the VPN dropdown, including the in-memory-only profiles NetworkManager auto-generates for externally-managed interfaces (e.g. a `wg-quick@wg0.service` tunnel). Toggling such an "assumed" connection off deletes it from NM — it was never persisted — leaving a dead toggle with no way back through the applet or `nmcli con up`.

Skip connections flagged `unsaved` (in-memory only) at the `load_vpns()` site so they no longer get a togglable entry. The active-connections section still shows the interface as connected (read-only). One-line change in `cosmic-applet-network/src/app.rs`.

## Test plan

- Built on Pop!_OS and ran the patched `cosmic-applet-network`: with `wg-quick@wg0` up and NM tracking `wg0` as connected-externally, the VPN dropdown no longer shows the destructive `wg0` toggle; the active-connections section still shows `wg0`; Wi-Fi toggling is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:23:45 +02:00
Michael Aaron Murphy
5428a86370 chore: bump versions to 1.0.15 2026-05-29 17:15:35 +02:00
Michael Aaron Murphy
61bd9b0dfd chore: update dependencies with fix for battery applet panic 2026-05-29 17:15:35 +02:00
Michael Aaron Murphy
c86daef96a build: use cargo vendor --locked 2026-05-29 17:15:35 +02:00
Priyam Sarkar
a50c3d499c
fix(bluetooth): bluetooth toggle is off despite active connection 2026-05-29 17:13:51 +02:00
Ashley Wulber
a4e6ecb280 fix(network): align name with spec 2026-05-29 16:20:40 +02:00
Jeremy Soller
ee85d07c48
chore: update cosmic-freedesktop-icons to fix dropbox tray icon (#1419) 2026-05-27 13:10:00 -06:00
Hojjat
010931d6d0 chore: update cosmic-freedesktop-icons to fix dropbox tray icon 2026-05-26 13:31:21 -06:00
Jeremy Soller
377f129611
i18n: translation update from Hosted Weblate (#1403) 2026-05-26 08:33:04 -06:00
Hojjat
46a8b9182b chore: update libcosmic 2026-05-26 10:04:40 -04:00
Hojjat
d73ef7dcfb fix: search the theme path provided by the app to find status-area icons 2026-05-26 10:04:40 -04:00
Hosted Weblate
88da7797bd
i18n: translation updates from weblate
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Isaac Subirana <isaacsubiranac@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-audio/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-battery/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-bluetooth/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-network/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-notifications/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-power/ca/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-power/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applet-tiling/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-applets-desktop-entries/uk/
Translation: Pop OS/COSMIC Applets Desktop Entries
Translation: Pop OS/COSMIC Audio Applet
Translation: Pop OS/COSMIC Battery Applet
Translation: Pop OS/COSMIC Bluetooth Applet
Translation: Pop OS/COSMIC Network Applet
Translation: Pop OS/COSMIC Notifications Applet
Translation: Pop OS/COSMIC Power Applet
Translation: Pop OS/COSMIC Tiling Applet
2026-05-25 11:13:15 +00:00
49 changed files with 1265 additions and 587 deletions

544
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,7 @@ tokio = { version = "1.49.0", features = ["full"] }
# cosmic-config = { path = "../libcosmic/cosmic-config" } # cosmic-config = { path = "../libcosmic/cosmic-config" }
cosmic-config = { path = "../libcosmic/cosmic-config" } cosmic-config = { path = "../libcosmic/cosmic-config" }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
zlink = "0.5.0"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-app-list" name = "cosmic-app-list"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-app-list-config" name = "cosmic-app-list-config"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View file

@ -0,0 +1,8 @@
run = Bixebitîne
run-on = Li ser { $gpu } bixebitîne
run-on-default = (Berdest)
pin = Bo destgeha sepanan bi dar bixe
quit = Biqedîne
quit-all = Hemûyan biqedîne
new-window = Çarçoveya nû
cosmic-app-list = Destgeha sepanan

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-applet-a11y" name = "cosmic-applet-a11y"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View file

@ -2,5 +2,5 @@ screen-reader = スクリーンリーダー
magnifier = 拡大鏡 magnifier = 拡大鏡
settings = アクセシビリティ設定… settings = アクセシビリティ設定…
invert-colors = 色を反転 invert-colors = 色を反転
filter-colors = 色のフィルター filter-colors = カラーフィルター
high-contrast = ハイコントラスト high-contrast = ハイコントラスト

View file

@ -0,0 +1,6 @@
screen-reader = Xwînerê dîmenderê
magnifier = Mezinker
invert-colors = Rengan berevajî bike
settings = Sazkariyên gihîştinê...
filter-colors = Rengan parzûn bike
high-contrast = Dijbiriya bilind

View file

@ -1,10 +1,11 @@
[package] [package]
name = "cosmic-applet-audio" name = "cosmic-applet-audio"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"
[dependencies] [dependencies]
futures.workspace = true
i18n-embed-fl.workspace = true i18n-embed-fl.workspace = true
i18n-embed.workspace = true i18n-embed.workspace = true
cosmic.workspace = true cosmic.workspace = true
@ -18,6 +19,9 @@ tracing.workspace = true
url = "2" url = "2"
urlencoding = "2.1.3" urlencoding = "2.1.3"
zbus.workspace = true zbus.workspace = true
zlink.workspace = true
intmap = "3.1.3"
[dependencies.cosmic-settings-sound-subscription] [dependencies.cosmic-settings-audio-client]
path = "../../cosmic-settings/subscriptions/sound" path = "../../cosmic-settings-daemon/audio-client"
features = ["codec"]

View file

@ -0,0 +1,7 @@
output = Deran
input = Ketan
unknown-artist = Nenas
sound-settings = Sazkariyên dengê...
disconnected = Girêdana PulseAudio qut bû
no-device = Tu amûr nehatine hilbijartin
show-media-controls = Kontrolên mediyayê li ser destgehê nîşan bide

View file

@ -2,12 +2,13 @@
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
mod localize; mod localize;
mod model;
mod mouse_area; mod mouse_area;
use crate::localize::localize; use crate::localize::localize;
use config::{AudioAppletConfig, amplification_sink, amplification_source}; use config::{AudioAppletConfig, amplification_sink, amplification_source};
use cosmic::{ use cosmic::{
Element, Renderer, Task, Theme, app, Apply, Element, Renderer, Task, Theme, app,
applet::{ applet::{
column as applet_column, column as applet_column,
cosmic_panel_config::PanelAnchor, cosmic_panel_config::PanelAnchor,
@ -18,18 +19,24 @@ use cosmic::{
cosmic_config::CosmicConfigEntry, cosmic_config::CosmicConfigEntry,
cosmic_theme::Spacing, cosmic_theme::Spacing,
iced::{ iced::{
self, Alignment, Length, Subscription, self, Alignment, Length, Rectangle, Subscription,
futures::StreamExt, futures::StreamExt,
widget::{self, column, row, slider}, widget::{self, column, row, slider},
window, window,
}, },
surface, theme, surface, theme,
widget::{Row, button, container, divider, icon, space, text, toggler}, widget::{
Row, button, container, divider, icon,
rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription},
space, text, toggler,
},
}; };
use cosmic_settings_sound_subscription as css; use cosmic_settings_audio_client::{self as audio_client, CosmicAudioProxy};
use futures::SinkExt;
use iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup}; use iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup};
use mpris_subscription::{MprisRequest, MprisUpdate}; use mpris_subscription::{MprisRequest, MprisUpdate};
use mpris2_zbus::player::PlaybackStatus; use mpris2_zbus::player::PlaybackStatus;
use std::{cell::RefCell, rc::Rc, sync::Arc};
mod config; mod config;
mod mpris_subscription; mod mpris_subscription;
@ -50,8 +57,10 @@ pub struct Audio {
core: cosmic::app::Core, core: cosmic::app::Core,
/// Track the applet's popup window. /// Track the applet's popup window.
popup: Option<window::Id>, popup: Option<window::Id>,
/// The model from cosmic-settings for managing pipewire devices. /// Varlink connection to `com.system76.CosmicSettings.Audio`.
model: css::Model, audio_client: Option<Rc<RefCell<audio_client::Client>>>,
/// Known audio device state
model: model::Model,
/// Whether to expand the revealer of a source or sink device. /// Whether to expand the revealer of a source or sink device.
is_open: IsOpen, is_open: IsOpen,
/// Max slider volume for the sink device, as determined by the amplification property. /// Max slider volume for the sink device, as determined by the amplification property.
@ -68,12 +77,14 @@ pub struct Audio {
player_status: Option<mpris_subscription::PlayerStatus>, player_status: Option<mpris_subscription::PlayerStatus>,
/// Used to request an activation token for opening cosmic-settings. /// Used to request an activation token for opening cosmic-settings.
token_tx: Option<calloop::channel::Sender<TokenRequest>>, token_tx: Option<calloop::channel::Sender<TokenRequest>>,
rectangle_tracker: Option<RectangleTracker<u32>>,
rectangle: Option<iced::Rectangle>,
} }
impl Audio { impl Audio {
fn output_icon_name(&self) -> &'static str { fn output_icon_name(&self) -> &'static str {
let volume = self.model.sink_volume; let volume = self.model.active_sink.volume;
let mute = self.model.sink_mute; let mute = self.model.active_sink.mute;
if mute || volume == 0 { if mute || volume == 0 {
"audio-volume-muted-symbolic" "audio-volume-muted-symbolic"
} else if volume < 33 { } else if volume < 33 {
@ -88,8 +99,8 @@ impl Audio {
} }
fn input_icon_name(&self) -> &'static str { fn input_icon_name(&self) -> &'static str {
let volume = self.model.source_volume; let volume = self.model.active_source.volume;
let mute = self.model.source_mute; let mute = self.model.active_source.mute;
if mute || volume == 0 { if mute || volume == 0 {
"microphone-sensitivity-muted-symbolic" "microphone-sensitivity-muted-symbolic"
} else if volume < 33 { } else if volume < 33 {
@ -110,8 +121,10 @@ enum IsOpen {
Input, Input,
} }
#[derive(Debug, Clone)] #[derive(Clone, Debug)]
pub enum Message { pub enum Message {
/// Connection to `com.system76.CosmicSettings`.
Client(Arc<audio_client::Client>),
Ignore, Ignore,
SetSinkVolume(u32), SetSinkVolume(u32),
SetSourceVolume(u32), SetSourceVolume(u32),
@ -129,8 +142,9 @@ pub enum Message {
MprisRequest(MprisRequest), MprisRequest(MprisRequest),
Token(TokenUpdate), Token(TokenUpdate),
OpenSettings, OpenSettings,
Subscription(css::Message), Subscription(audio_client::Event),
Surface(surface::Action), Surface(surface::Action),
Rectangle(RectangleUpdate<u32>),
} }
// TODO // TODO
@ -242,15 +256,9 @@ impl cosmic::Application for Audio {
const APP_ID: &'static str = "com.system76.CosmicAppletAudio"; const APP_ID: &'static str = "com.system76.CosmicAppletAudio";
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, app::Task<Message>) { fn init(core: cosmic::app::Core, _flags: ()) -> (Self, app::Task<Message>) {
let mut model = css::Model::default();
model.unplugged_text = "Unplugged".into();
model.hd_audio_text = "HD Audio".into();
model.usb_audio_text = "USB Audio".into();
( (
Self { Self {
core, core,
model,
..Default::default() ..Default::default()
}, },
Task::none(), Task::none(),
@ -271,6 +279,14 @@ impl cosmic::Application for Audio {
fn update(&mut self, message: Message) -> app::Task<Message> { fn update(&mut self, message: Message) -> app::Task<Message> {
match message { match message {
Message::Rectangle(u) => match u {
RectangleUpdate::Rectangle(r) => {
self.rectangle = Some(r.1);
}
RectangleUpdate::Init(tracker) => {
self.rectangle_tracker.replace(tracker);
}
},
Message::Ignore => {} Message::Ignore => {}
Message::TogglePopup => { Message::TogglePopup => {
if let Some(p) = self.popup.take() { if let Some(p) = self.popup.take() {
@ -291,14 +307,21 @@ impl cosmic::Application for Audio {
(100, &[][..]) (100, &[][..])
}; };
let popup_settings = self.core.applet.get_popup_settings( let mut popup_settings = self.core.applet.get_popup_settings(
self.core.main_window_id().unwrap(), self.core.main_window_id().unwrap(),
new_id, new_id,
None, None,
None, None,
None, None,
); );
if let Some(r) = self.rectangle {
popup_settings.positioner.anchor_rect = Rectangle {
x: r.x as i32,
y: r.y as i32,
width: r.width as i32,
height: r.height as i32,
};
}
return get_popup(popup_settings); return get_popup(popup_settings);
} }
} }
@ -318,54 +341,76 @@ impl cosmic::Application for Audio {
} }
} }
Message::Subscription(message) => { Message::Subscription(message) => {
return self self.model.update(message);
.model
.update(message)
.map(|message| Message::Subscription(message).into());
} }
Message::SetDefaultSink(pos) => { Message::SetDefaultSink(pos) => {
return self if let Some(&pos) = self.model.sinks.sorted_index.get(pos)
.model && let Some(&node_id) = self.model.sinks.id.get(pos as usize)
.set_default_sink(pos) && let Some(client) = self.audio_client.as_mut()
.map(|message| Message::Subscription(message).into()); {
futures::executor::block_on(async {
_ = client.borrow_mut().conn.set_default(node_id, true).await;
});
}
} }
Message::SetDefaultSource(pos) => { Message::SetDefaultSource(pos) => {
return self if let Some(&pos) = self.model.sources.sorted_index.get(pos)
.model && let Some(&node_id) = self.model.sources.id.get(pos as usize)
.set_default_source(pos) && let Some(client) = self.audio_client.as_mut()
.map(|message| Message::Subscription(message).into()); {
futures::executor::block_on(async {
_ = client.borrow_mut().conn.set_default(node_id, true).await;
});
}
} }
Message::ToggleSinkMute => self.model.toggle_sink_mute(), Message::ToggleSinkMute => {
if let Some(ref mut client) = self.audio_client {
futures::executor::block_on(async {
_ = client.borrow_mut().conn.sink_mute_toggle().await;
});
}
}
Message::ToggleSourceMute => self.model.toggle_source_mute(), Message::ToggleSourceMute => {
if let Some(ref mut client) = self.audio_client {
futures::executor::block_on(async {
_ = client.borrow_mut().conn.source_mute_toggle().await;
});
}
}
Message::SetSinkVolume(volume) => { Message::SetSinkVolume(volume) => {
return self if let Some(ref mut client) = self.audio_client {
.model self.model.active_sink.volume = volume;
.set_sink_volume(volume) self.model.active_sink.volume_text = volume.to_string();
.map(|message| Message::Subscription(message).into()); futures::executor::block_on(async {
_ = client.borrow_mut().conn.set_sink_volume(volume).await;
});
}
} }
Message::SetSourceVolume(volume) => { Message::SetSourceVolume(volume) => {
return self if let Some(ref mut client) = self.audio_client {
.model self.model.active_source.volume = volume;
.set_source_volume(volume) self.model.active_source.volume_text = volume.to_string();
.map(|message| Message::Subscription(message).into()); futures::executor::block_on(async {
_ = client.borrow_mut().conn.set_source_volume(volume).await;
});
}
} }
Message::ToggleMediaControlsInTopPanel(enabled) => { Message::ToggleMediaControlsInTopPanel(enabled) => {
self.config.show_media_controls_in_top_panel = enabled; self.config.show_media_controls_in_top_panel = enabled;
if let Ok(helper) = if let Ok(helper) =
cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::VERSION) cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::VERSION)
&& let Err(err) = self.config.write_entry(&helper)
{ {
if let Err(err) = self.config.write_entry(&helper) {
tracing::error!(?err, "Error writing config"); tracing::error!(?err, "Error writing config");
} }
} }
}
Message::CloseRequested(id) => { Message::CloseRequested(id) => {
if Some(id) == self.popup { if Some(id) == self.popup {
self.popup = None; self.popup = None;
@ -461,6 +506,12 @@ impl cosmic::Application for Audio {
cosmic::app::Action::Surface(a), cosmic::app::Action::Surface(a),
)); ));
} }
Message::Client(client) => {
if let Some(client) = Arc::into_inner(client) {
self.audio_client = Some(Rc::new(RefCell::new(client)));
self.model = model::Model::default();
}
}
} }
Task::none() Task::none()
@ -476,7 +527,50 @@ impl cosmic::Application for Audio {
}), }),
mpris_subscription::mpris_subscription(0).map(Message::Mpris), mpris_subscription::mpris_subscription(0).map(Message::Mpris),
activation_token_subscription(0).map(Message::Token), activation_token_subscription(0).map(Message::Token),
Subscription::run(|| css::watch().map(Message::Subscription)), Subscription::run(|| {
iced::stream::channel(
1,
move |mut emitter: futures::channel::mpsc::Sender<_>| async move {
loop {
let mut client = match audio_client::connect().await {
Ok(client) => client,
Err(why) => {
if let zlink::Error::Io(ref why) = why
&& why.kind() == std::io::ErrorKind::NotFound
{
tracing::error!(
"cosmic-settings-daemon varlink service not found."
);
} else {
tracing::error!(
?why,
"failed to connect to cosmic-settings's varlink service"
);
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
};
if let Ok(Ok(mut stream)) = client.recv_events().await {
_ = emitter.send(Message::Client(Arc::new(client))).await;
while let Some(message) = stream.next().await {
match message {
Ok(event) => {
_ = emitter.send(Message::Subscription(event)).await;
}
Err(why) => {
tracing::error!(?why, "event error");
}
}
}
}
}
},
)
}),
rectangle_tracker_subscription(0).map(|update| Message::Rectangle(update.1)),
]) ])
} }
@ -517,24 +611,23 @@ impl cosmic::Application for Audio {
return Message::Ignore; return Message::Ignore;
} }
let new_volume = (self.model.sink_volume as f64 + (scroll_vector as f64)) let new_volume = (self.model.active_sink.volume as f64 + (scroll_vector as f64))
.clamp(0.0, self.max_sink_volume as f64); .clamp(0.0, self.max_sink_volume as f64);
Message::SetSinkVolume(new_volume.round() as u32) Message::SetSinkVolume(new_volume.round() as u32)
}); });
let mut has_playback_buttons = false;
let playback_buttons = (!self.core.applet.suggested_bounds.as_ref().is_some_and(|c| { let playback_buttons = (!self.core.applet.suggested_bounds.as_ref().is_some_and(|c| {
// if we have a configure for width and height, we're in a overflow popup // if we have a configure for width and height, we're in a overflow popup
c.width > 0. && c.height > 0. c.width > 0. && c.height > 0.
})) }))
.then(|| self.playback_buttons()); .then(|| self.playback_buttons());
self.core let mut ret = if let Some(playback_buttons) = playback_buttons
.applet
.autosize_window(
if let Some(playback_buttons) = playback_buttons
&& !playback_buttons.is_empty() && !playback_buttons.is_empty()
{ {
match self.core.applet.anchor { has_playback_buttons = true;
Element::from(match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => Element::from( PanelAnchor::Left | PanelAnchor::Right => Element::from(
applet_column::Column::with_children(playback_buttons) applet_column::Column::with_children(playback_buttons)
.push(btn) .push(btn)
@ -558,12 +651,24 @@ impl cosmic::Application for Audio {
) )
.into() .into()
} }
} })
} else { } else {
btn.into() btn.into()
}, };
)
.into() if let Some(tracker) = self.rectangle_tracker.as_ref()
&& has_playback_buttons
{
ret = tracker.container(0, ret).into()
}
if !self.core.applet.suggested_bounds.as_ref().is_some_and(|c| {
// if we have a configure for width and height, we're in a overflow popup
c.width > 0. && c.height > 0.
}) {
ret = self.core.applet.autosize_window(ret).into();
}
ret
} }
fn view_window(&self, _id: window::Id) -> Element<'_, Message> { fn view_window(&self, _id: window::Id) -> Element<'_, Message> {
@ -571,19 +676,21 @@ impl cosmic::Application for Audio {
space_xxs, space_s, .. space_xxs, space_s, ..
} = theme::active().cosmic().spacing; } = theme::active().cosmic().spacing;
let sink = self let sink: Option<&str> = self
.model .model
.active_sink() .sinks
.and_then(|pos| self.model.sinks().get(pos)); .active()
let source = self .map(|pos| self.model.sinks.sorted_display[pos].as_ref());
let source: Option<&str> = self
.model .model
.active_source() .sources
.and_then(|pos| self.model.sources().get(pos)); .active()
.map(|pos| self.model.sources.sorted_display[pos].as_ref());
let mut audio_content = { let mut audio_content = {
let output_slider = slider( let output_slider = slider(
0..=self.max_sink_volume, 0..=self.max_sink_volume,
self.model.sink_volume, self.model.active_sink.volume,
Message::SetSinkVolume, Message::SetSinkVolume,
) )
.width(Length::FillPortion(5)) .width(Length::FillPortion(5))
@ -591,7 +698,7 @@ impl cosmic::Application for Audio {
let input_slider = slider( let input_slider = slider(
0..=self.max_source_volume, 0..=self.max_source_volume,
self.model.source_volume, self.model.active_source.volume,
Message::SetSourceVolume, Message::SetSourceVolume,
) )
.width(Length::FillPortion(5)) .width(Length::FillPortion(5))
@ -610,7 +717,7 @@ impl cosmic::Application for Audio {
.line_height(24) .line_height(24)
.on_press(Message::ToggleSinkMute), .on_press(Message::ToggleSinkMute),
output_slider, output_slider,
container(text(&self.model.sink_volume_text).size(16)) container(text(&self.model.active_sink.volume_text).size(16))
.width(Length::FillPortion(1)) .width(Length::FillPortion(1))
.align_x(Alignment::End) .align_x(Alignment::End)
] ]
@ -629,7 +736,7 @@ impl cosmic::Application for Audio {
.line_height(24) .line_height(24)
.on_press(Message::ToggleSourceMute), .on_press(Message::ToggleSourceMute),
input_slider, input_slider,
container(text(&self.model.source_volume_text).size(16)) container(text(&self.model.active_source.volume_text).size(16))
.width(Length::FillPortion(1)) .width(Length::FillPortion(1))
.align_x(Alignment::End) .align_x(Alignment::End)
] ]
@ -644,7 +751,7 @@ impl cosmic::Application for Audio {
Some(sink) => sink.to_owned(), Some(sink) => sink.to_owned(),
None => fl!("no-device"), None => fl!("no-device"),
}, },
self.model.sinks(), &self.model.sinks.sorted_display,
Message::OutputToggle, Message::OutputToggle,
Message::SetDefaultSink, Message::SetDefaultSink,
), ),
@ -655,7 +762,7 @@ impl cosmic::Application for Audio {
Some(source) => source.to_owned(), Some(source) => source.to_owned(),
None => fl!("no-device"), None => fl!("no-device"),
}, },
self.model.sources(), &self.model.sources.sorted_display,
Message::InputToggle, Message::InputToggle,
Message::SetDefaultSource, Message::SetDefaultSource,
) )
@ -711,11 +818,7 @@ impl cosmic::Application for Audio {
} }
if let Some(play) = self.is_play() { if let Some(play) = self.is_play() {
control_elements.push( control_elements.push(
button::icon( button::icon(icon::from_name(if play { PLAY } else { PAUSE }).symbolic(true))
icon::from_name(if play { PLAY } else { PAUSE })
.size(32)
.symbolic(true),
)
.extra_small() .extra_small()
.class(cosmic::theme::Button::AppletIcon) .class(cosmic::theme::Button::AppletIcon)
.on_press(if play { .on_press(if play {
@ -774,20 +877,21 @@ impl cosmic::Application for Audio {
} }
} }
fn revealer( fn revealer<'a>(
open: bool, open: bool,
title: String, title: String,
selected: String, selected: String,
devices: &[String], devices: &'a [Arc<str>],
toggle: Message, toggle: Message,
mut change: impl FnMut(usize) -> Message + 'static, mut change: impl FnMut(usize) -> Message + 'static,
) -> widget::Column<'static, Message, crate::Theme, Renderer> { ) -> widget::Column<'a, Message, crate::Theme, Renderer> {
if open { if open {
devices.iter().cloned().enumerate().fold( devices.iter().enumerate().fold(
column![revealer_head(open, title, selected, toggle)].width(Length::Fill), column![revealer_head(open, title, selected, toggle)].width(Length::Fill),
|col, (id, name)| { move |col, (id, name)| {
col.push( col.push(
menu_button(text::body(name)) text::body(name.as_ref())
.apply(menu_button)
.on_press(change(id)) .on_press(change(id))
.width(Length::Fill) .width(Length::Fill)
.padding([8, 48]), .padding([8, 48]),
@ -805,9 +909,9 @@ fn revealer_head(
selected: String, selected: String,
toggle: Message, toggle: Message,
) -> cosmic::widget::Button<'static, Message> { ) -> cosmic::widget::Button<'static, Message> {
menu_button(column![ cosmic::widget::column::with_capacity(2)
text::body(title).width(Length::Fill), .push(text::body(title).width(Length::Fill))
text::caption(selected), .push(text::caption(selected))
]) .apply(menu_button)
.on_press(toggle) .on_press(toggle)
} }

View file

@ -0,0 +1,393 @@
// Copyright 2026 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::sync::Arc;
use cosmic_settings_audio_client::{self as audio_client, Availability, RouteInfo};
use intmap::IntMap;
pub type DeviceId = u32;
pub type NodeId = u32;
#[derive(Debug, Default)]
pub struct Model {
pub device_routes: IntMap<DeviceId, Vec<RouteInfo>>,
pub node_devices: IntMap<NodeId, Option<u32>>,
pub sinks: Nodes,
pub sources: Nodes,
pub active_sink: ActiveNode,
pub active_source: ActiveNode,
pub default_sink: Option<NodeId>,
pub default_source: Option<NodeId>,
}
#[derive(Debug, Default)]
pub struct Nodes {
active: Option<usize>,
pub sorted_display: Box<[Arc<str>]>,
pub sorted_index: Box<[u16]>,
pub balance: Vec<Option<f32>>,
pub card_profile_device: Vec<Option<u32>>,
pub description: Vec<String>,
pub devices: Vec<Option<NodeId>>,
pub display: Vec<Arc<str>>,
pub mute: Vec<bool>,
pub name: Vec<String>,
pub id: Vec<NodeId>,
pub volume: Vec<u32>,
}
impl Nodes {
pub fn active(&self) -> Option<usize> {
self.active.and_then(|active| {
self.sorted_index
.iter()
.position(|idx| *idx as usize == active)
})
}
pub fn dropdown_sort(&mut self) {
let mut enumerated_displays = self
.display
.clone()
.into_iter()
.enumerate()
.map(|(index, display)| (index as u16, display))
.collect::<Vec<_>>();
enumerated_displays.sort_by_key(|v| v.1.clone());
let (indexes, displays): (Vec<_>, Vec<_>) = enumerated_displays.into_iter().unzip();
self.sorted_display = displays.into_boxed_slice();
self.sorted_index = indexes.into_boxed_slice();
}
pub fn remove(&mut self, node_id: u32) -> bool {
let Some(pos) = self.id.iter().position(|id| node_id == *id) else {
return false;
};
self.balance.remove(pos);
self.card_profile_device.remove(pos);
self.description.remove(pos);
self.devices.remove(pos);
self.display.remove(pos);
self.mute.remove(pos);
self.name.remove(pos);
self.id.remove(pos);
self.volume.remove(pos);
self.dropdown_sort();
if self.active == Some(pos) {
self.active = None;
}
true
}
}
#[derive(Debug, Default)]
pub struct ActiveNode {
pub volume_text: String,
pub volume: u32,
pub mute: bool,
}
impl Model {
pub fn update(&mut self, event: audio_client::Event) {
tracing::debug!(?event, "update");
match event {
audio_client::Event::NodeMute(node_id, mute) => {
if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) {
self.sinks.mute[pos] = mute;
if self.sinks.active == Some(pos) {
self.active_sink.mute = mute;
}
} else if let Some(pos) = self.sources.id.iter().position(|id| node_id == *id) {
self.sources.mute[pos] = mute;
if self.sources.active == Some(pos) {
self.active_source.mute = mute;
}
}
}
audio_client::Event::NodeVolume(node_id, volume, balance) => {
if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) {
self.sinks.volume[pos] = volume;
self.sinks.balance[pos] = balance;
if self.default_sink.as_ref().is_some_and(|&id| id == node_id)
&& let Some(pos) = self.sinks.active
{
self.active_sink.mute = self.sinks.mute[pos];
self.active_sink.volume = self.sinks.volume[pos];
self.active_sink.volume_text = self.active_sink.volume.to_string();
}
} else if let Some(pos) = self.sources.id.iter().position(|id| node_id == *id) {
self.sources.volume[pos] = volume;
self.sources.balance[pos] = balance;
if self
.default_source
.as_ref()
.is_some_and(|&id| id == node_id)
&& let Some(pos) = self.sources.active
{
self.active_source.mute = self.sources.mute[pos];
self.active_source.volume = self.sources.volume[pos];
self.active_source.volume_text = self.active_source.volume.to_string();
}
}
}
audio_client::Event::DefaultSink(node_id) => {
self.default_sink = Some(node_id);
if let Some(pos) = self.sinks.id.iter().position(|&id| id == node_id) {
self.sinks.active = Some(pos);
self.active_sink.mute = self.sinks.mute[pos];
self.active_sink.volume = self.sinks.volume[pos];
self.active_sink.volume_text = self.active_sink.volume.to_string();
}
}
audio_client::Event::DefaultSource(node_id) => {
self.default_source = Some(node_id);
if let Some(pos) = self.sources.id.iter().position(|&id| id == node_id) {
self.sources.active = Some(pos);
self.active_source.mute = self.sources.mute[pos];
self.active_source.volume = self.sources.volume[pos];
self.active_source.volume_text = self.active_source.volume.to_string();
}
}
audio_client::Event::Node(node_id, node) => {
self.node_devices.insert(node_id, node.device_id);
if node.is_sink {
let pos = if let Some(pos) = self.sinks.id.iter().position(|&id| id == node_id)
{
self.sinks.description[pos] = self.translate(&node.description);
self.sinks.name[pos] = node.name;
self.sinks.card_profile_device[pos] = node.card_profile_device;
pos
} else {
self.sinks.display.push(Arc::default());
self.sinks
.description
.push(self.translate(&node.description));
self.sinks.id.push(node_id);
self.sinks.volume.push(0);
self.sinks.balance.push(None);
self.sinks.mute.push(false);
self.sinks.name.push(node.name);
self.sinks.devices.push(node.device_id);
self.sinks
.card_profile_device
.push(node.card_profile_device);
self.sinks.id.len() - 1
};
self.sinks.display[pos] = node
.device_id
.zip(node.card_profile_device)
.and_then(|(device_id, node_card_profile_device)| {
let routes = self.device_routes.get(device_id)?;
for route in routes {
if matches!(route.availability, Availability::No) || !route.is_sink
{
continue;
}
if route.devices.contains(&node_card_profile_device) {
return Some(node_name(
&self.translate(&route.description),
&self.sinks.description[pos],
));
}
}
None
})
.unwrap_or_else(|| {
node_name(
&node.device_profile_description,
&self.sinks.description[pos],
)
});
self.sinks.dropdown_sort();
if let Some(default_node_id) = self.default_sink
&& default_node_id == node_id
{
self.sinks.active = Some(pos);
self.active_sink.mute = self.sinks.mute[pos];
self.active_sink.volume = self.sinks.volume[pos];
self.active_sink.volume_text = self.active_sink.volume.to_string();
}
} else {
let pos =
if let Some(pos) = self.sources.id.iter().position(|&id| id == node_id) {
self.sources.description[pos] = self.translate(&node.description);
self.sources.name[pos] = node.name;
self.sources.card_profile_device[pos] = node.card_profile_device;
pos
} else {
self.sources
.description
.push(self.translate(&node.description));
self.sources.display.push(Arc::default());
self.sources.id.push(node_id);
self.sources.volume.push(0);
self.sources.balance.push(None);
self.sources.mute.push(false);
self.sources.name.push(node.name);
self.sources.devices.push(node.device_id);
self.sources
.card_profile_device
.push(node.card_profile_device);
self.sources.id.len() - 1
};
if let Some(name) = node
.device_id
.zip(node.card_profile_device)
.map(|(device_id, node_card_profile_device)| {
let routes = self.device_routes.get(device_id)?;
for route in routes {
if route.is_sink || matches!(route.availability, Availability::No) {
continue;
}
if route.devices.contains(&node_card_profile_device) {
return Some(node_name(
&self.translate(&route.description),
&self.sources.description[pos],
));
}
}
None
})
.unwrap_or_else(|| {
Some(node_name(
&node.device_profile_description,
&self.sources.description[pos],
))
})
{
self.sources.display[pos] = name;
self.sources.dropdown_sort();
} else {
// Remove sources that are unplugged.
self.sources.remove(node_id);
return;
}
if let Some(default_node_id) = self.default_source
&& default_node_id == node_id
{
self.sources.active = Some(pos);
self.active_source.mute = self.sources.mute[pos];
self.active_source.volume = self.sources.volume[pos];
self.active_source.volume_text = self.active_source.volume.to_string();
}
}
}
audio_client::Event::ActiveRoute(device_id, _index, route) => {
self.update_device_names(device_id, &route);
}
audio_client::Event::Route(device_id, index, route) => {
let routes = self.device_routes.entry(device_id).or_default();
if index == 0 {
*routes = vec![route];
} else {
if routes.len() < index as usize + 1 {
let additional = (index as usize + 1) - routes.capacity();
routes.reserve_exact(additional);
routes.extend(std::iter::repeat_n(RouteInfo::default(), additional));
}
routes[index as usize] = route;
}
}
audio_client::Event::RemoveNode(node_id) => {
self.node_devices.remove(node_id);
if !self.sinks.remove(node_id) {
self.sources.remove(node_id);
}
}
audio_client::Event::RemoveDevice(device_id) => {
self.device_routes.remove(device_id);
}
_ => (),
}
}
fn update_device_names(&mut self, device_id: DeviceId, route: &RouteInfo) {
if matches!(route.availability, Availability::No) {
return;
}
let compatible_nodes = self.node_devices.iter().filter_map(|(node, &dev_id)| {
if dev_id? == device_id {
Some(node)
} else {
None
}
});
if route.is_sink {
for n_id in compatible_nodes {
let Some(pos) = self.sinks.id.iter().position(|&node| node == n_id) else {
continue;
};
let Some(card_profile_device) = self.sinks.card_profile_device[pos] else {
continue;
};
if route.devices.contains(&card_profile_device) {
self.sinks.display[pos] = node_name(
&self.translate(&route.description),
&self.sinks.description[pos],
);
self.sinks.dropdown_sort();
break;
}
}
} else {
for n_id in compatible_nodes {
let Some(pos) = self.sources.id.iter().position(|&node| node == n_id) else {
continue;
};
let Some(card_profile_device) = self.sources.card_profile_device[pos] else {
continue;
};
if route.devices.contains(&card_profile_device) {
self.sources.display[pos] = node_name(
&self.translate(&route.description),
&self.sources.description[pos],
);
self.sources.dropdown_sort();
break;
}
}
}
}
pub fn translate(&self, description: &str) -> String {
description
.replace("High Definition", "HD")
.replace("DisplayPort", "DP")
.replace("Controller", "")
}
}
fn node_name(route: &str, node: &str) -> Arc<str> {
if route.is_empty() {
node.to_owned()
} else {
[route, " - ", node].concat()
}
.into()
}

View file

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

View file

@ -0,0 +1,14 @@
battery = Betarî
seconds = ç
minutes = x
hours = d
until-empty = Heya vala dibe
power-settings = Sazkariyên hêz û betariyê...
battery-desc = Bikaranîna hêzê kêmkirî û performans.
balanced = Hevseng
balanced-desc = Performansa standard û bikaranîna betariyê.
performance = Performansa bilind
performance-desc = Performansa bilind û bikaranîna hêzê.
max-charge = Jiyana betariyê xwe dirêj bike bi riya danîna sînorê herî mezin yê tijîkrinê %80
dgpu-running = YKG a veqetandî çalak e û dikare jiyana betariyê kêm bike
dgpu-applications = Sepanên ku YKG a veqetandî ya { $gpu_name } bi kar tînin

View file

@ -169,6 +169,20 @@ impl CosmicBatteryApplet {
self.charging_limit = Some(limit); self.charging_limit = Some(limit);
self.update_battery(self.battery_percent, self.on_battery); self.update_battery(self.battery_percent, self.on_battery);
} }
fn panel_icon_name(&self) -> &str {
if self.no_battery {
if self.screen_brightness.is_some() {
self.display_icon_name.as_str()
} else if self.kbd_brightness.is_some() {
"keyboard-brightness-symbolic"
} else {
"plugged-into-power-symbolic"
}
} else {
self.icon_name.as_str()
}
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -522,17 +536,7 @@ impl cosmic::Application for CosmicBatteryApplet {
let applet_padding = self.core.applet.suggested_padding(true); let applet_padding = self.core.applet.suggested_padding(true);
let mut children = vec![ let mut children = vec![
icon::from_name(if self.no_battery { icon::from_name(self.panel_icon_name())
if self.screen_brightness.is_some() {
self.display_icon_name.as_str()
} else if self.kbd_brightness.is_some() {
"keyboard-brightness-symbolic"
} else {
self.icon_name.as_str()
}
} else {
self.icon_name.as_str()
})
.size(suggested_size.0) .size(suggested_size.0)
.into(), .into(),
]; ];

View file

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

View file

@ -0,0 +1,12 @@
cancel = Têk bibe
bluetooth = Bluetooth
connected = Hat girêdan
other-devices = Amûrên Bluetooth ên din
settings = Sazkariyên Bluetooth...
confirm-pin = Tika ye piştrast bike ku PIN a jêrîn bi ya ku li ser { $deviceName } tê xuyakirin re li hev tê
confirm = Bipejirîne
check-device = Piştrast bike ku { $deviceName } çalak e, di hundir rêjeyê de ye, û amade ye ji bo cotkirinê.
try-again = Dîsa hewl bide
discoverable = Dîtbar
pairable = Cotbar
unsuccessful = Cotkirin têkçûyî ye

View file

@ -21,7 +21,7 @@ use cosmic::{
widget::{Column, column, container, row}, widget::{Column, column, container, row},
}, },
theme, theme,
widget::{button, divider, icon, scrollable, text}, widget::{button, divider, icon, indeterminate_circular, scrollable, text},
}; };
use futures::FutureExt; use futures::FutureExt;
use std::{collections::HashMap, time::Duration}; use std::{collections::HashMap, time::Duration};
@ -368,14 +368,12 @@ impl cosmic::Application for CosmicBluetoothApplet {
); );
} }
BluerDeviceStatus::Paired => {} BluerDeviceStatus::Paired => {}
BluerDeviceStatus::Connecting | BluerDeviceStatus::Disconnecting => { BluerDeviceStatus::Connecting
row = row.push( | BluerDeviceStatus::Disconnecting
icon::from_name("process-working-symbolic") | BluerDeviceStatus::Pairing => {
.size(24) row = row.push(indeterminate_circular().size(24.0));
.symbolic(true),
);
} }
BluerDeviceStatus::Disconnected | BluerDeviceStatus::Pairing => continue, BluerDeviceStatus::Disconnected => continue,
} }
known_bluetooth.push( known_bluetooth.push(

View file

@ -567,22 +567,21 @@ impl BluerSessionState {
tick(&mut interval).await; tick(&mut interval).await;
let new_status = adapter_clone.is_powered().await.unwrap_or_default(); let new_status = adapter_clone.is_powered().await.unwrap_or_default();
devices = build_device_list(devices, &adapter_clone).await; devices = build_device_list(devices, &adapter_clone).await;
if new_status != status { let power_changed = new_status != status;
status = new_status; status = new_status;
let state = BluerState { let state = BluerState {
devices: devices.clone(), devices: devices.clone(),
bluetooth_enabled: status, bluetooth_enabled: status,
}; };
if state.bluetooth_enabled { if power_changed && state.bluetooth_enabled {
for d in &state.devices { for d in &state.devices {
if d.paired_and_trusted() { if d.paired_and_trusted() {
_ = req_tx.send(BluerRequest::ConnectDevice(d.address)).await; _ = req_tx.send(BluerRequest::ConnectDevice(d.address)).await;
} }
} }
}
_ = wake_up_discover_tx.send(()).await; _ = wake_up_discover_tx.send(()).await;
let _ = tx.send(BluerSessionEvent::ChangesProcessed(state)).await;
} }
let _ = tx.send(BluerSessionEvent::ChangesProcessed(state)).await;
} }
}); });
} }
@ -673,6 +672,9 @@ impl BluerSessionState {
} }
} }
// Always refresh power state from the adapter
// to avoid stale cached is_powered values
is_powered = adapter_clone.is_powered().await.unwrap_or_default();
let _ = tx let _ = tx
.send(BluerSessionEvent::ChangesProcessed(BluerState { .send(BluerSessionEvent::ChangesProcessed(BluerState {
devices: devices.clone(), devices: devices.clone(),
@ -852,13 +854,7 @@ async fn bluer_state(adapter: &Adapter) -> BluerState {
#[inline(never)] #[inline(never)]
async fn build_device_list(mut devices: Vec<BluerDevice>, adapter: &Adapter) -> Vec<BluerDevice> { async fn build_device_list(mut devices: Vec<BluerDevice>, adapter: &Adapter) -> Vec<BluerDevice> {
let addrs: Vec<Address> = adapter let addrs = adapter.device_addresses().await.unwrap_or_default();
.device_addresses()
.await
.unwrap_or_default()
.into_iter()
.filter(|addr| !devices.iter().any(|d| d.address == *addr))
.collect();
devices.clear(); devices.clear();
if addrs.len() > devices.capacity() { if addrs.len() > devices.capacity() {

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-applet-input-sources" name = "cosmic-applet-input-sources"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -0,0 +1,2 @@
show-keyboard-layout = Awaya kilîtdankê nîşan bide...
keyboard-settings = Sazkariyên kilîtdankê...

View file

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

View file

@ -121,6 +121,7 @@ struct AppData {
toplevel_info_state: ToplevelInfoState, toplevel_info_state: ToplevelInfoState,
toplevel_manager_state: ToplevelManagerState, toplevel_manager_state: ToplevelManagerState,
seat_state: SeatState, seat_state: SeatState,
captured_toplevels: std::collections::HashSet<ExtForeignToplevelHandleV1>,
} }
struct CaptureData { struct CaptureData {
@ -351,6 +352,36 @@ impl AppData {
} }
}); });
} }
fn handle_toplevel(&mut self, toplevel: &ExtForeignToplevelHandleV1, is_new: bool) {
let Some(info) = self.toplevel_info_state.info(toplevel) else {
return;
};
if info
.state
.contains(&zcosmic_toplevel_handle_v1::State::Minimized)
{
// Capture a thumbnail once, on the transition into minimized.
if self.captured_toplevels.insert(toplevel.clone()) {
self.send_image(toplevel.clone());
}
let update = if is_new {
ToplevelUpdate::Add(info.clone())
} else {
ToplevelUpdate::Update(info.clone())
};
let _ = futures::executor::block_on(self.tx.send(WaylandUpdate::Toplevel(update)));
} else {
self.remove_toplevel(toplevel);
}
}
fn remove_toplevel(&mut self, toplevel: &ExtForeignToplevelHandleV1) {
self.captured_toplevels.remove(toplevel);
let _ = futures::executor::block_on(self.tx.send(WaylandUpdate::Toplevel(
ToplevelUpdate::Remove(toplevel.clone()),
)));
}
} }
impl ToplevelInfoHandler for AppData { impl ToplevelInfoHandler for AppData {
@ -364,23 +395,7 @@ impl ToplevelInfoHandler for AppData {
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
toplevel: &ExtForeignToplevelHandleV1, toplevel: &ExtForeignToplevelHandleV1,
) { ) {
if let Some(info) = self.toplevel_info_state.info(toplevel) { self.handle_toplevel(toplevel, true);
if info
.state
.contains(&zcosmic_toplevel_handle_v1::State::Minimized)
{
// spawn thread for sending the image
self.send_image(toplevel.clone());
let _ = futures::executor::block_on(
self.tx
.send(WaylandUpdate::Toplevel(ToplevelUpdate::Add(info.clone()))),
);
} else {
let _ = futures::executor::block_on(self.tx.send(WaylandUpdate::Toplevel(
ToplevelUpdate::Remove(toplevel.clone()),
)));
}
}
} }
fn update_toplevel( fn update_toplevel(
@ -389,21 +404,7 @@ impl ToplevelInfoHandler for AppData {
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
toplevel: &ExtForeignToplevelHandleV1, toplevel: &ExtForeignToplevelHandleV1,
) { ) {
if let Some(info) = self.toplevel_info_state.info(toplevel) { self.handle_toplevel(toplevel, false);
if info
.state
.contains(&zcosmic_toplevel_handle_v1::State::Minimized)
{
self.send_image(toplevel.clone());
let _ = futures::executor::block_on(self.tx.send(WaylandUpdate::Toplevel(
ToplevelUpdate::Update(info.clone()),
)));
} else {
let _ = futures::executor::block_on(self.tx.send(WaylandUpdate::Toplevel(
ToplevelUpdate::Remove(toplevel.clone()),
)));
}
}
} }
fn toplevel_closed( fn toplevel_closed(
@ -412,9 +413,7 @@ impl ToplevelInfoHandler for AppData {
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
toplevel: &ExtForeignToplevelHandleV1, toplevel: &ExtForeignToplevelHandleV1,
) { ) {
let _ = futures::executor::block_on(self.tx.send(WaylandUpdate::Toplevel( self.remove_toplevel(toplevel);
ToplevelUpdate::Remove(toplevel.clone()),
)));
} }
} }
@ -482,6 +481,7 @@ pub(crate) fn wayland_handler(
toplevel_manager_state: ToplevelManagerState::new(&registry_state, &qh), toplevel_manager_state: ToplevelManagerState::new(&registry_state, &qh),
queue_handle: qh, queue_handle: qh,
registry_state, registry_state,
captured_toplevels: std::collections::HashSet::new(),
}; };
loop { loop {

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-applet-network" name = "cosmic-applet-network"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View file

@ -0,0 +1,8 @@
<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy context="default">
<allow own_prefix="com.system76.CosmicSettings.Applet"/>
</policy>
</busconfig>

View file

@ -0,0 +1,24 @@
cancel = Têk bibe
connect = Girê bide
connected = Hat girêdan
network = Tor
airplane-mode = Awaya balafirê
airplane-mode-on = Awaya balafirê vekirî ye
turn-off-airplane-mode = Ji bo çalakirina Wi-Fi, Bluetooth û torên mobayîl, awaya balafirê bigire.
wifi = Wi-Fi
identity = Nasname
ipv4 = IPv4
ipv6 = IPv6
mac = MAC
megabits-per-second = Mbps
gigabits-per-second = Gbps
terabits-per-second = Tbps
connecting = Tê girêdan
settings = Sazkariyên torê...
visible-wireless-networks = Torên bêtêl ên xuya
vpn-connections = Girêdanên VPN
enter-password = Borînpeyvê yan jî kilîtê şîfrekirinê têxîne
router-wps-button = Tu dikarî bi pêlkirina bişkoka "WPS" ya li ser routerê jî girê bidî
unable-to-connect = Nikare bi torê ve were girêdan
check-wifi-connection = Piştrast bike ku Wi-Fi bi înternetê ve girêdayî ye û borînpeyv rast e
reset = Ji nû ve saz bike

View file

@ -35,7 +35,7 @@ use cosmic::{
widget::{ widget::{
Id, button, column, container, divider, Id, button, column, container, divider,
icon::{self, from_name}, icon::{self, from_name},
row, scrollable, secure_input, text, text_input, toggler, indeterminate_circular, row, scrollable, secure_input, text, text_input, toggler,
}, },
}; };
use cosmic_dbus_networkmanager::interface::enums::{ use cosmic_dbus_networkmanager::interface::enums::{
@ -405,6 +405,7 @@ fn secret_agent_task(identifier: String) -> Task<NmAgentEvent> {
cosmic::Task::stream(async_fn_stream::fn_stream(move |emitter| async move { cosmic::Task::stream(async_fn_stream::fn_stream(move |emitter| async move {
let registration = SecretAgent::builder() let registration = SecretAgent::builder()
.with_identifier(identifier) .with_identifier(identifier)
.with_object_path("/org/freedesktop/NetworkManager/SecretAgent")
.with_capabilities(SecretAgentCapabilities::VPN_HINTS) .with_capabilities(SecretAgentCapabilities::VPN_HINTS)
.register() .register()
.await; .await;
@ -664,6 +665,13 @@ fn load_vpns(_conn: zbus::Connection) -> Task<crate::app::Message> {
let mut map: IndexMap<UUID, ConnectionSettings> = IndexMap::new(); let mut map: IndexMap<UUID, ConnectionSettings> = IndexMap::new();
for c in saved { for c in saved {
// Skip in-memory-only NM connections — assumed connections that NM
// auto-generated from externally-managed interfaces (e.g. one
// brought up by wg-quick@wg0.service) report unsaved=true and
// evaporate on deactivate, leaving the applet's toggle dead.
if c.unsaved {
continue;
}
let uuid: UUID = Arc::from(c.uuid.as_str()); let uuid: UUID = Arc::from(c.uuid.as_str());
let entry = match c.summary { let entry = match c.summary {
SettingsSummary::WireGuard { .. } => ConnectionSettings::Wireguard { id: c.id }, SettingsSummary::WireGuard { .. } => ConnectionSettings::Wireguard { id: c.id },
@ -726,9 +734,10 @@ impl cosmic::Application for CosmicNetworkApplet {
tasks.push(update_state(conn.clone())); tasks.push(update_state(conn.clone()));
tasks.push(update_devices(conn.clone())); tasks.push(update_devices(conn.clone()));
tasks.push(load_vpns(conn)); tasks.push(load_vpns(conn));
let uuid = uuid::Uuid::new_v4().to_string().replace("-", "_");
let my_id = format!( let my_id = format!(
"com.system76.CosmicSettings.Applet.{}.NetworkManager.SecretAgent", "com.system76.CosmicSettings.Applet._{uuid}.NetworkManager.SecretAgent",
uuid::Uuid::new_v4()
); );
tasks.push(secret_agent_task(my_id).map(Message::SecretAgent)); tasks.push(secret_agent_task(my_id).map(Message::SecretAgent));
} }
@ -1507,12 +1516,7 @@ impl cosmic::Application for CosmicNetworkApplet {
]; ];
match state { match state {
ActiveConnectionState::Activating | ActiveConnectionState::Deactivating => { ActiveConnectionState::Activating | ActiveConnectionState::Deactivating => {
btn_content.push( btn_content.push(indeterminate_circular().size(24.0).into());
icon::from_name("process-working-symbolic")
.size(24)
.symbolic(true)
.into(),
);
} }
ActiveConnectionState::Activated => btn_content.push( ActiveConnectionState::Activated => btn_content.push(
text::body(fl!("connected")) text::body(fl!("connected"))
@ -1720,12 +1724,7 @@ impl cosmic::Application for CosmicNetworkApplet {
.into(), .into(),
); );
btn_content.push(ssid.into()); btn_content.push(ssid.into());
btn_content.push( btn_content.push(indeterminate_circular().size(24.0).into());
icon::from_name("process-working-symbolic")
.size(24)
.symbolic(true)
.into(),
);
} else if matches!(known.state, DeviceState::Unavailable) { } else if matches!(known.state, DeviceState::Unavailable) {
btn_content.push( btn_content.push(
icon::from_name("network-wireless-disconnected-symbolic") icon::from_name("network-wireless-disconnected-symbolic")
@ -1897,10 +1896,7 @@ impl cosmic::Application for CosmicNetworkApplet {
let connecting = padded_control( let connecting = padded_control(
row::with_children([ row::with_children([
Element::from(id), Element::from(id),
icon::from_name("process-working-symbolic") indeterminate_circular().size(24.0).into(),
.size(24)
.symbolic(true)
.into(),
]) ])
.spacing(8), .spacing(8),
); );

View file

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

View file

@ -0,0 +1,19 @@
hours-ago =
{ $duration ->
[0] Niha
[one] 1 demjimêr berê
*[other] { $duration } demjimêr berê
}
minutes-ago =
{ $duration ->
[0] Niha
[one] 1 xulek berê
*[other] { $duration } xulek berê
}
show-less = Kêmtir nîşan bide
show-more = Bêtir { $more } nîşan bide
clear-group = Komê pak bike
clear-all = Hemû agahdariyan pak bike
do-not-disturb = Rawestandina balkêşiyê
notification-settings = Sazkariyên agahdariyê....
no-notifications = Agahdarî tune ne

View file

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

View file

@ -0,0 +1,36 @@
cancel = Têk bibe
confirm = Bipejirîne
power = Hêz
restart = Ji nû ve bide destpêkirin
settings = Sazkarî...
shutdown = Vemrîne
suspend = Rawestîne
log-out = Derkeve
lock-screen = Dîmenderê kilît bike
lock-screen-shortcut = Super + Escape
log-out-shortcut = Super + Shift + Escape
confirm-button =
{ $action ->
[restart] { restart }
[suspend] { suspend }
[shutdown] Vemrîne
[log-out] { log-out }
*[other] { confirm }
}
confirm-title =
{ $action ->
[restart] { restart }
[suspend] { suspend }
[shutdown] { shutdown }
[log-out] Hemû sepanan bigire û derkeve
*[other] Çalakiyê hilbijartî bisepîne
} niha?
confirm-body =
Pergal wê xweber were { $action ->
[restart] jinûvedestpêkkirin
[suspend] rawestandin
[shutdown] vemirandin
[lock-screen] kilîtkirin
[log-out] derketin
*[other] çalakiyê hilbijartî bisepîne
}di { $countdown } çirke de.

View file

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

View file

@ -9,10 +9,14 @@ use cosmic::{
}, },
cctk::sctk::reexports::calloop, cctk::sctk::reexports::calloop,
iced::{ iced::{
self, Length, Subscription, self,
Event::Mouse,
Length, Subscription, event,
mouse::{self, ScrollDelta},
platform_specific::shell::commands::popup::{destroy_popup, get_popup}, platform_specific::shell::commands::popup::{destroy_popup, get_popup},
window, window,
}, },
scroll::{DiscreteScrollDelta, DiscreteScrollState},
surface, surface,
widget::{container, mouse_area}, widget::{container, mouse_area},
}; };
@ -33,7 +37,8 @@ pub enum Msg {
StatusMenu((usize, status_menu::Msg)), StatusMenu((usize, status_menu::Msg)),
StatusNotifier(status_notifier_watcher::Event), StatusNotifier(status_notifier_watcher::Event),
TogglePopup(usize), TogglePopup(usize),
Hovered(usize), Hovered(Option<usize>),
WheelScrolled(ScrollDelta),
Surface(surface::Action), Surface(surface::Action),
ToggleOverflow, ToggleOverflow,
HoveredOverflow, HoveredOverflow,
@ -49,6 +54,8 @@ pub(crate) struct App {
max_menu_id: usize, max_menu_id: usize,
popup: Option<window::Id>, popup: Option<window::Id>,
overflow_popup: Option<window::Id>, overflow_popup: Option<window::Id>,
hovered_menu: Option<usize>,
scroll_states: BTreeMap<usize, DiscreteScrollState>,
token_tx: Option<calloop::channel::Sender<TokenRequest>>, token_tx: Option<calloop::channel::Sender<TokenRequest>>,
} }
@ -103,9 +110,10 @@ impl App {
let overflow_index = self.overflow_index().unwrap_or(0); let overflow_index = self.overflow_index().unwrap_or(0);
let children = self.menus.iter().skip(overflow_index).map(|(id, menu)| { let children = self.menus.iter().skip(overflow_index).map(|(id, menu)| {
mouse_area( mouse_area(
menu_icon_button(&self.core.applet, &menu).on_press_down(Msg::TogglePopup(*id)), menu_icon_button(&self.core.applet, menu).on_press_down(Msg::TogglePopup(*id)),
) )
.on_enter(Msg::Hovered(*id)) .on_enter(Msg::Hovered(Some(*id)))
.on_exit(Msg::Hovered(None))
.into() .into()
}); });
@ -210,6 +218,7 @@ impl cosmic::Application for App {
{ {
let id = *id; let id = *id;
self.menus.remove(&id); self.menus.remove(&id);
self.scroll_states.remove(&id);
if self.open_menu == Some(id) { if self.open_menu == Some(id) {
self.open_menu = None; self.open_menu = None;
if let Some(popup_id) = self.popup { if let Some(popup_id) = self.popup {
@ -312,6 +321,10 @@ impl cosmic::Application for App {
} }
}, },
Msg::Hovered(id) => { Msg::Hovered(id) => {
self.hovered_menu = id;
let Some(id) = id else {
return Task::none();
};
let mut cmds = Vec::new(); let mut cmds = Vec::new();
if let Some(old_id) = self.open_menu.take() { if let Some(old_id) = self.open_menu.take() {
if old_id != id { if old_id != id {
@ -361,6 +374,19 @@ impl cosmic::Application for App {
cmds.push(get_popup(popup_settings)); cmds.push(get_popup(popup_settings));
Task::batch(cmds) Task::batch(cmds)
} }
Msg::WheelScrolled(delta) => {
let Some(id) = self.hovered_menu else {
return Task::none();
};
let discrete_delta = self.scroll_states.entry(id).or_default().update(delta);
let Some((delta, orientation)) = discrete_scroll_delta(discrete_delta) else {
return Task::none();
};
let Some(menu) = self.menus.get(&id) else {
return Task::none();
};
scroll(id, menu.item.item_proxy().clone(), delta, orientation)
}
Msg::Surface(a) => { Msg::Surface(a) => {
return cosmic::task::message(cosmic::Action::Cosmic( return cosmic::task::message(cosmic::Action::Cosmic(
cosmic::app::Action::Surface(a), cosmic::app::Action::Surface(a),
@ -405,6 +431,7 @@ impl cosmic::Application for App {
} }
} }
Msg::HoveredOverflow => { Msg::HoveredOverflow => {
self.hovered_menu = None;
let mut cmds = Vec::new(); let mut cmds = Vec::new();
if self.overflow_popup.is_some() { if self.overflow_popup.is_some() {
// If we already have an overflow popup, do nothing // If we already have an overflow popup, do nothing
@ -460,6 +487,12 @@ impl cosmic::Application for App {
subscriptions.push(menu.subscription(is_open).with(*id).map(Msg::StatusMenu)); subscriptions.push(menu.subscription(is_open).with(*id).map(Msg::StatusMenu));
} }
subscriptions.push(activation_token_subscription(0).map(Msg::Token)); subscriptions.push(activation_token_subscription(0).map(Msg::Token));
subscriptions.push(event::listen_with(|e, status, _| match (e, status) {
(Mouse(mouse::Event::WheelScrolled { delta }), event::Status::Ignored) => {
Some(Msg::WheelScrolled(delta))
}
_ => None,
}));
iced::Subscription::batch(subscriptions) iced::Subscription::batch(subscriptions)
} }
@ -472,9 +505,10 @@ impl cosmic::Application for App {
.iter() .iter()
.take(overflow_index.unwrap_or(self.menus.len())) .take(overflow_index.unwrap_or(self.menus.len()))
.map(|(id, menu)| { .map(|(id, menu)| {
mouse_area(menu_icon_button(&self.core.applet, &menu).on_press(Msg::Activate(*id))) mouse_area(menu_icon_button(&self.core.applet, menu).on_press(Msg::Activate(*id)))
.on_right_press(Msg::TogglePopup(*id)) .on_right_press(Msg::TogglePopup(*id))
.on_enter(Msg::Hovered(*id)) .on_enter(Msg::Hovered(Some(*id)))
.on_exit(Msg::Hovered(None))
.into() .into()
}); });
@ -570,6 +604,33 @@ fn activate(
}) })
} }
fn scroll(
id: usize,
item_proxy: crate::subscriptions::status_notifier_item::StatusNotifierItemProxy<'static>,
delta: i32,
orientation: &'static str,
) -> Task<cosmic::Action<Msg>> {
Task::future(async move {
match item_proxy.scroll(delta, orientation).await {
Ok(_) => cosmic::action::app(Msg::None),
Err(err) => {
tracing::error!("Scroll failed for {}: {}", id, err);
cosmic::action::app(Msg::None)
}
}
})
}
fn discrete_scroll_delta(delta: DiscreteScrollDelta) -> Option<(i32, &'static str)> {
if delta.y != 0 {
Some((delta.y as i32, "vertical"))
} else if delta.x != 0 {
Some((delta.x as i32, "horizontal"))
} else {
None
}
}
fn menu_icon_button<'a>( fn menu_icon_button<'a>(
applet: &'a cosmic::applet::Context, applet: &'a cosmic::applet::Context,
menu: &'a status_menu::State, menu: &'a status_menu::State,
@ -614,3 +675,43 @@ fn menu_icon_button<'a>(
pub fn main() -> iced::Result { pub fn main() -> iced::Result {
cosmic::applet::run::<App>(()) cosmic::applet::run::<App>(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discrete_scroll_prefers_vertical_delta() {
assert_eq!(
discrete_scroll_delta(cosmic::scroll::DiscreteScrollDelta { x: 4, y: -2 }),
Some((-2, "vertical"))
);
}
#[test]
fn discrete_scroll_uses_horizontal_delta_when_vertical_is_zero() {
assert_eq!(
discrete_scroll_delta(cosmic::scroll::DiscreteScrollDelta { x: 3, y: 0 }),
Some((3, "horizontal"))
);
}
#[test]
fn discrete_scroll_ignores_zero_delta() {
assert_eq!(
discrete_scroll_delta(cosmic::scroll::DiscreteScrollDelta { x: 0, y: 0 }),
None
);
}
#[test]
fn pixel_scroll_accumulates_before_emitting_discrete_delta() {
let mut state = cosmic::scroll::DiscreteScrollState::default();
let first = state.update(ScrollDelta::Pixels { x: 0.0, y: 12.0 });
assert_eq!(discrete_scroll_delta(first), None);
let second = state.update(ScrollDelta::Pixels { x: 0.0, y: 12.0 });
assert_eq!(discrete_scroll_delta(second), Some((1, "vertical")));
}
}

View file

@ -202,6 +202,8 @@ pub trait StatusNotifierItem {
fn activate(&self, x: i32, y: i32) -> zbus::Result<()>; fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>; fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>;
fn scroll(&self, delta: i32, orientation: &str) -> zbus::Result<()>;
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View file

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

View file

@ -0,0 +1,20 @@
tile-windows = Çarçoveyan xweber berhev bike
tile-current = Qada xebatê heyî berhev bike
shortcuts = Kurterê
navigate-windows = Di nav çarçoveyan de bigere
move-window = Çarçoveyê bilivîne
toggle-floating-window = Rewşê çarçoveyê herikbar biguhêrîne
view-all-shortcuts = Hemû kurteriyan nîşan bide...
active-hint = Pêşniyara çalak
gaps = Valahî
floating-window-exceptions = Awarteyên çarçoveyê herikbar...
window-management-settings = Sazkariyên rêveberiya çarçoveyê...
all-workspaces = Hemû qadên xebatê
per-workspace = Li gorî qada xebatê
super = Super
shift = Shift
arrow-keys = tîr
tiled = Berhevkirî
floating = Herikbar
autotile-behavior = Li ser qadên xebatê çarçoveyan berhev bike
new-workspace = Tevgera qada xebatê ya nû

View file

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

View file

@ -0,0 +1 @@
datetime-settings = Sazkariyên dîrok, dem û salnameyê...

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-applet-workspaces" name = "cosmic-applet-workspaces"
version = "1.0.2" version = "1.0.15"
authors = ["Ashley Wulber <ashley@system76.com>"] authors = ["Ashley Wulber <ashley@system76.com>"]
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -0,0 +1 @@
cosmic-applet-workspaces = Qadên xebatê yên COSMIC

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-applets-config" name = "cosmic-applets-config"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-applets" name = "cosmic-applets"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-panel-button" name = "cosmic-panel-button"
version = "1.0.2" version = "1.0.15"
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"

6
debian/changelog vendored
View file

@ -1,3 +1,9 @@
cosmic-applets (1.0.15) noble; urgency=medium
* Update changelog
-- Michael Murphy <mmstick@pop-os> Thu, 28 May 2026 19:33:02 +0200
cosmic-applets (1.0.12) noble; urgency=medium cosmic-applets (1.0.12) noble; urgency=medium
* Update changelog * Update changelog

View file

@ -0,0 +1 @@
cosmic-applet-a11y = アクセシビリティ

View file

@ -0,0 +1,51 @@
cosmic-applet-audio = Deng
cosmic-app-list = Destgeha sepanan
cosmic-applet-a11y = Gihîştin
cosmic-applet-bluetooth = Bluetooth
cosmic-applet-bluetooth-comment = Amûrên Bluetooth bi rê ve bibe
cosmic-applet-bluetooth-keywords = COSMIC;Sepanok;Bluetooth;
cosmic-applet-input-sources = Çavkaniyên ketanê
cosmic-applet-input-sources-comment = Di navbera çavkaniyên ketanê de biguhêrîne
cosmic-applet-input-sources-keywords = COSMIC;Sepanok;Ketan;Çavkanî;
cosmic-app-list-comment = Sepanên darxistî veke û çarçoveyên vekirî bi rê ve bibe
cosmic-app-list-keywords = COSMIC;Sepanok;Sepan;Destgeh;Lîste;Erk;Bend;
cosmic-applet-a11y-comment = Sazkariyên gihîştinê ji destgehê saz bike
cosmic-applet-a11y-keywords = COSMIC;Sepanok;Gihîştin;Dîmender;A11y;Xwîner;Dîmender;Mezinker;Bijberî;Reng;
cosmic-applet-audio-comment = Hilbijartina amûra dengê, kontrola dengê, û kontrolên mediyaya MPRIS
cosmic-applet-audio-keywords = COSMIC;Sepanok;Deng;Deng;MPRIS;
cosmic-applet-battery = Hêz & Betarî
cosmic-applet-battery-comment = Awayên hêzê û vebijêrkên parastina hêzê
cosmic-applet-battery-keywords = COSMIC;Sepanok;Hêz;Betarî;
cosmic-applet-minimize = Çarçoveyên biçûk
cosmic-applet-minimize-comment = Çarçoveyên biçûk bi rê ve bibe
cosmic-applet-minimize-keywords = COSMIC;Sepanok;Biçûkkirin;
cosmic-applet-network = Tor
cosmic-applet-network-comment = Girêdanên torê bi rê ve bibe
cosmic-applet-network-keywords = COSMIC;Sepanok;Tor;
cosmic-applet-notifications = Navenda Agahdariyan
cosmic-applet-notifications-comment = Agahdarî û rawestandina balkişiyê bi rê ve bibe
cosmic-applet-notifications-keywords = COSMIC;Sepanok;Agahdarî;
cosmic-applet-power = Danaşîna bikarhêner
cosmic-applet-power-comment = Dîmenderê kilît bike, derkeve, rawestîne, ji nû ve bide destpêkirin, û vemrîne
cosmic-applet-power-keywords = COSMIC;Sepanok;Bikarhêner;Danaşîn;Kilîtkirin;Derketin;Destpêkirin;Vemirandin;Rawestandin;
cosmic-applet-status-area = Desgeha agahdariyan
cosmic-applet-status-area-comment = Nîşanderên sepanan ên ku dibe ku menuyan bo destgehê derxînin
cosmic-applet-status-area-keywords = COSMIC;Sepanok;Sepan;Nîşander;Agahdarî;Destgeh;Rewş;
cosmic-applet-tiling = Berhevkirin
cosmic-applet-tiling-comment = Berhevkirina xweber a çalak, ya niha û ya her qada xebatê bi rê ve bibe
cosmic-applet-tiling-keywords = COSMIC;Sepanok;Berhevkirin;Nîşander;Navenda xebatê;
cosmic-applet-time = Dîrok, dem & Salname
cosmic-applet-time-comment = Demê niha li ser destgehê bi salnameyeke vebûyî nîşan bide
cosmic-applet-time-keywords = COSMIC;Sepanok;Dîrok;Dem;Salname;
cosmic-applet-workspaces = Qada xebatê jimarkirî
cosmic-applet-workspaces-comment = Di navbera qada xebatê jimarkirî de di destgehê de biguherîne
cosmic-applet-workspaces-keywords = COSMIC;Sepanok;Qada xebatê;
cosmic-panel-app-button = Bişkoka pirtûkxaneya sepanan
cosmic-panel-app-button-comment = Pirtûkxaneya sepanan ji bo vekirina sepanên sazkirî veke
cosmic-panel-app-button-keywords = COSMIC;Sepanok;Sepan;Pirtûkxane;Destgeh;
cosmic-panel-launcher-button = Bişkoka destpêker
cosmic-panel-launcher-button-comment = Destpêkerê ji bo lêgerîna sepanan û xebitandina fermanan veke
cosmic-panel-launcher-button-keywords = COSMIC;Sepanok;Destpêker;Rêveber;
cosmic-panel-workspaces-button = Bişkoka qadên xebatê
cosmic-panel-workspaces-button-comment = Pêşdîtina qadên xebatê veke ji bo bi rê ve bibî û biguhêrînî
cosmic-panel-workspaces-button-keywords = COSMIC;Sepan;Qada xebatê;Pêşdîtin;

View file

@ -46,3 +46,6 @@ cosmic-panel-launcher-button-keywords = COSMIC;애플릿;런처;실행기;
cosmic-panel-workspaces-button = 작업 공간 버튼 cosmic-panel-workspaces-button = 작업 공간 버튼
cosmic-panel-workspaces-button-comment = 작업 공간 관리 및 전환을 위해 작업 공간 개요 열기 cosmic-panel-workspaces-button-comment = 작업 공간 관리 및 전환을 위해 작업 공간 개요 열기
cosmic-panel-workspaces-button-keywords = COSMIC;애플릿;작업 공간;개요; cosmic-panel-workspaces-button-keywords = COSMIC;애플릿;작업 공간;개요;
cosmic-applet-workspaces-comment = 패널에서 번호가 지정된 작업 공간 간 전환
cosmic-applet-tiling-comment = 활성 창 강조, 현재 및 작업 공간별 자동 타일링 관리
cosmic-applet-workspaces = 번호가 지정된 작업 공간

View file

@ -20,3 +20,8 @@ cosmic-applet-workspaces-comment = Alternar entre áreas de trabalho numeradas n
cosmic-applet-workspaces-keywords = COSMIC;Miniaplicação;Applet;Área;Trabalho; cosmic-applet-workspaces-keywords = COSMIC;Miniaplicação;Applet;Área;Trabalho;
cosmic-panel-workspaces-button-comment = Abrir a visão geral das áreas de trabalho para gerir e alternar entre elas cosmic-panel-workspaces-button-comment = Abrir a visão geral das áreas de trabalho para gerir e alternar entre elas
cosmic-panel-workspaces-button-keywords = COSMIC;Miniaplicação;Applet;Área;Trabalho;Visão;Geral; cosmic-panel-workspaces-button-keywords = COSMIC;Miniaplicação;Applet;Área;Trabalho;Visão;Geral;
cosmic-applet-a11y-comment = Configurar definições de acessibilidade a partir do painel
cosmic-applet-battery = Energia e Bateria
cosmic-app-list-keywords = COSMIC;Miniaplicação;Applet;Aplicação;Bandeja;Lista;Tarefa;Barra;
cosmic-applet-a11y-keywords = COSMIC;Miniaplicação;Applet;Acessibilidade;A11y;Ecrã;Leitor;Lupa;Ampliador;Contraste;Cor;
cosmic-applet-audio-keywords = COSMIC;Miniaplicação;Applet;Som;Áudio;MPRIS;

View file

@ -59,13 +59,16 @@ _install_status_notifier_watcher:
sed "s|@bindir@|{{ prefixdir }}|" cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service.in > cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service sed "s|@bindir@|{{ prefixdir }}|" cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service.in > cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service
install -Dm0644 cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service {{ libdir }}/systemd/user/com.system76.CosmicStatusNotifierWatcher.service install -Dm0644 cosmic-applet-status-area/data/com.system76.CosmicStatusNotifierWatcher.service {{ libdir }}/systemd/user/com.system76.CosmicStatusNotifierWatcher.service
_install_secret_agent_policy:
install -Dm0644 cosmic-applet-network/data/dbus-1/system.d/com.system76.CosmicSettings.Applet.NetworkManager.SecretAgent.conf {{ sharedir }}/dbus-1/system.d/com.system76.CosmicSettings.Applet.NetworkManager.SecretAgent.conf
# Installs files into the system # Installs files into the system
install: (_install_bin 'cosmic-applets') (_link_applet 'cosmic-panel-button') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletA11y' 'cosmic-applet-a11y') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_install_applet 'com.system76.CosmicAppletInputSources' 'cosmic-applet-input-sources') (_install_applet 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') (_install_applet 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') (_install_applet 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') (_install_applet 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') (_install_applet 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') (_install_applet 'com.system76.CosmicAppletPower' 'cosmic-applet-power') (_install_applet 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') (_install_applet 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling') (_install_applet 'com.system76.CosmicAppletTime' 'cosmic-applet-time') (_install_applet 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelLauncherButton' 'cosmic-panel-launcher-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') _install_metainfo _install_status_notifier_watcher install: (_install_bin 'cosmic-applets') (_link_applet 'cosmic-panel-button') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletA11y' 'cosmic-applet-a11y') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_install_applet 'com.system76.CosmicAppletInputSources' 'cosmic-applet-input-sources') (_install_applet 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') (_install_applet 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') (_install_applet 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') (_install_applet 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') (_install_applet 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') (_install_applet 'com.system76.CosmicAppletPower' 'cosmic-applet-power') (_install_applet 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area') (_install_applet 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling') (_install_applet 'com.system76.CosmicAppletTime' 'cosmic-applet-time') (_install_applet 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelLauncherButton' 'cosmic-panel-launcher-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') _install_metainfo _install_status_notifier_watcher _install_secret_agent_policy
# Vendor Cargo dependencies locally # Vendor Cargo dependencies locally
vendor: vendor:
mkdir -p .cargo mkdir -p .cargo
cargo vendor | head -n -1 > .cargo/config cargo vendor --locked | head -n -1 > .cargo/config
echo 'directory = "vendor"' >> .cargo/config echo 'directory = "vendor"' >> .cargo/config
tar pcf vendor.tar vendor tar pcf vendor.tar vendor
rm -rf vendor rm -rf vendor