refactor: compile applets as multicall binary

This commit is contained in:
Michael Aaron Murphy 2024-03-14 18:47:41 +01:00 committed by Michael Murphy
parent 4099624499
commit 3c4acdacd7
48 changed files with 2393 additions and 2256 deletions

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Cargo
.cargo.
.cargo
target/
build/
_build/

102
Cargo.lock generated
View file

@ -115,9 +115,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.80"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247"
[[package]]
name = "apply"
@ -583,9 +583,9 @@ checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa"
[[package]]
name = "bytemuck"
version = "1.14.3"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2ef034f05691a48569bd920a96c81b9d91bbad1ab5ac7c4616c1f6ef36cb79f"
checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15"
dependencies = [
"bytemuck_derive",
]
@ -854,7 +854,7 @@ dependencies = [
"futures-util",
"i18n-embed 0.13.9",
"i18n-embed-fl 0.6.7",
"itertools 0.10.5",
"itertools 0.12.1",
"libcosmic",
"log",
"nix 0.26.4",
@ -937,6 +937,7 @@ dependencies = [
"rust-embed 6.8.1",
"slotmap",
"tokio",
"tracing",
"tracing-log",
"tracing-subscriber",
]
@ -981,6 +982,7 @@ dependencies = [
"rust-embed-utils 7.8.1",
"slotmap",
"tokio",
"tracing",
"tracing-log",
"tracing-subscriber",
"zbus",
@ -1027,6 +1029,9 @@ dependencies = [
"rust-embed 6.8.1",
"rust-embed-utils 7.8.1",
"tokio",
"tracing",
"tracing-log",
"tracing-subscriber",
"zbus",
]
@ -1038,6 +1043,9 @@ dependencies = [
"libcosmic",
"serde",
"tokio",
"tracing",
"tracing-log",
"tracing-subscriber",
"zbus",
]
@ -1057,6 +1065,8 @@ dependencies = [
"rust-embed 8.3.0",
"tokio",
"tracing",
"tracing-log",
"tracing-subscriber",
]
[[package]]
@ -1073,6 +1083,8 @@ dependencies = [
"rust-embed 6.8.1",
"tokio",
"tracing",
"tracing-log",
"tracing-subscriber",
]
[[package]]
@ -1096,6 +1108,28 @@ dependencies = [
"xdg",
]
[[package]]
name = "cosmic-applets"
version = "0.1.1"
dependencies = [
"cosmic-app-list",
"cosmic-applet-audio",
"cosmic-applet-battery",
"cosmic-applet-bluetooth",
"cosmic-applet-minimize",
"cosmic-applet-network",
"cosmic-applet-notifications",
"cosmic-applet-power",
"cosmic-applet-status-area",
"cosmic-applet-tiling",
"cosmic-applet-time",
"cosmic-applet-workspaces",
"libcosmic",
"tracing",
"tracing-log",
"tracing-subscriber",
]
[[package]]
name = "cosmic-client-toolkit"
version = "0.1.0"
@ -1119,7 +1153,7 @@ dependencies = [
[[package]]
name = "cosmic-config"
version = "0.1.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"atomicwrites",
"cosmic-config-derive",
@ -1139,7 +1173,7 @@ dependencies = [
[[package]]
name = "cosmic-config-derive"
version = "0.1.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"quote",
"syn 1.0.109",
@ -1192,7 +1226,7 @@ dependencies = [
[[package]]
name = "cosmic-panel-config"
version = "0.1.0"
source = "git+https://github.com/pop-os/cosmic-panel#07fcaee64f80d9aa498be53d40077bc0a510437b"
source = "git+https://github.com/pop-os/cosmic-panel#f3fd857536bf8947a2ac765b01ed7078f452a767"
dependencies = [
"anyhow",
"cosmic-config",
@ -1250,7 +1284,7 @@ dependencies = [
[[package]]
name = "cosmic-theme"
version = "0.1.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"almost",
"cosmic-config",
@ -2782,7 +2816,7 @@ dependencies = [
[[package]]
name = "iced"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"iced_accessibility",
"iced_core",
@ -2797,7 +2831,7 @@ dependencies = [
[[package]]
name = "iced_accessibility"
version = "0.1.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"accesskit",
"accesskit_unix",
@ -2806,7 +2840,7 @@ dependencies = [
[[package]]
name = "iced_core"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"bitflags 1.3.2",
"iced_accessibility",
@ -2825,7 +2859,7 @@ dependencies = [
[[package]]
name = "iced_futures"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"futures",
"iced_core",
@ -2838,7 +2872,7 @@ dependencies = [
[[package]]
name = "iced_graphics"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"bitflags 1.3.2",
"bytemuck",
@ -2862,7 +2896,7 @@ dependencies = [
[[package]]
name = "iced_renderer"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"iced_graphics",
"iced_tiny_skia",
@ -2874,7 +2908,7 @@ dependencies = [
[[package]]
name = "iced_runtime"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"iced_accessibility",
"iced_core",
@ -2886,7 +2920,7 @@ dependencies = [
[[package]]
name = "iced_sctk"
version = "0.1.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"enum-repr",
"float-cmp",
@ -2911,7 +2945,7 @@ dependencies = [
[[package]]
name = "iced_style"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"iced_core",
"once_cell",
@ -2921,7 +2955,7 @@ dependencies = [
[[package]]
name = "iced_tiny_skia"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"bytemuck",
"cosmic-text",
@ -2938,7 +2972,7 @@ dependencies = [
[[package]]
name = "iced_wgpu"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"bitflags 1.3.2",
"bytemuck",
@ -2957,7 +2991,7 @@ dependencies = [
[[package]]
name = "iced_widget"
version = "0.12.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"iced_renderer",
"iced_runtime",
@ -3242,7 +3276,7 @@ checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
[[package]]
name = "libcosmic"
version = "0.1.0"
source = "git+https://github.com/pop-os/libcosmic#64ecb0ea48f262e13b1036757211b70432fd42e5"
source = "git+https://github.com/pop-os/libcosmic#bf331cad7ff2512b01e31ea593f885932166b0e7"
dependencies = [
"apply",
"ashpd 0.7.0",
@ -4234,9 +4268,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.78"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e"
dependencies = [
"unicode-ident",
]
@ -4379,9 +4413,9 @@ checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f"
[[package]]
name = "read-fonts"
version = "0.15.6"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ea23eedb4d938031b6d4343222444608727a6aa68ec355e13588d9947ffe92"
checksum = "81c524658d3b77930a391f559756d91dbe829ab6cf4687083f615d395df99722"
dependencies = [
"font-types",
]
@ -5042,9 +5076,9 @@ dependencies = [
[[package]]
name = "swash"
version = "0.1.12"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d06ff4664af8923625604261c645f5c4cc610cc83c84bec74b50d76237089de7"
checksum = "9af636fb90d39858650cae1088a37e2862dab4e874a0bb49d6dfb5b2dacf0e24"
dependencies = [
"read-fonts",
"yazi",
@ -5104,9 +5138,9 @@ dependencies = [
[[package]]
name = "system-deps"
version = "6.2.0"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331"
checksum = "e8e9199467bcbc77c6a13cc6e32a6af21721ab8c96aa0261856c4fda5a4433f0"
dependencies = [
"cfg-expr",
"heck",
@ -5161,18 +5195,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.57"
version = "1.0.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b"
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.57"
version = "1.0.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
dependencies = [
"proc-macro2",
"quote",

View file

@ -1,7 +1,8 @@
[workspace]
default-members = ["cosmic-applets", "cosmic-panel-button"]
members = [
"cosmic-app-list",
"cosmic-applets",
"cosmic-applet-audio",
"cosmic-applet-battery",
"cosmic-applet-bluetooth",

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: MPL-2.0-only
mod app;
mod config;
mod localize;
mod wayland_handler;
mod wayland_subscription;
use localize::localize;
pub fn run() -> cosmic::iced::Result {
localize();
app::run()
}

View file

@ -1,23 +1,10 @@
// SPDX-License-Identifier: MPL-2.0-only
mod app;
mod config;
mod localize;
mod wayland_handler;
mod wayland_subscription;
use log::info;
use localize::localize;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
// Initialize logger
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
info!("Iced Workspaces Applet ({VERSION})");
// Prepare i18n
localize();
app::run()
tracing::info!("Starting cosmic-app-list with version {VERSION}");
cosmic_app_list::run()
}

View file

@ -11,7 +11,7 @@ libpulse-glib-binding = "2.28.1"
tokio = { version = "1.20.1", features=["full"] }
libcosmic.workspace = true
cosmic-time.workspace = true
tracing = "0.1.40"
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true
# Application i18n

View file

@ -0,0 +1,859 @@
mod localize;
use crate::localize::localize;
use crate::pulse::DeviceInfo;
use config::AudioAppletConfig;
use cosmic::app::Command;
use cosmic::applet::cosmic_panel_config::PanelAnchor;
use cosmic::applet::menu_button;
use cosmic::applet::menu_control_padding;
use cosmic::applet::padded_control;
use cosmic::applet::token::subscription::{
activation_token_subscription, TokenRequest, TokenUpdate,
};
use cosmic::cctk::sctk::reexports::calloop;
use cosmic::cosmic_config::CosmicConfigEntry;
use cosmic::iced::widget;
use cosmic::iced::Limits;
use cosmic::iced::{
self,
widget::{column, row, slider, text},
window, Alignment, Length, Subscription,
};
use cosmic::iced_runtime::core::alignment::Horizontal;
use cosmic::iced_style::application;
use cosmic::widget::button;
use cosmic::widget::horizontal_space;
use cosmic::widget::Column;
use cosmic::widget::Row;
use cosmic::widget::{divider, icon};
use cosmic::Renderer;
use cosmic::{Element, Theme};
use cosmic_time::{anim, chain, id, once_cell::sync::Lazy, Instant, Timeline};
use iced::wayland::popup::{destroy_popup, get_popup};
use iced::widget::container;
use libpulse_binding::volume::VolumeLinear;
use mpris2_zbus::player::PlaybackStatus;
use mpris_subscription::MprisRequest;
use mpris_subscription::MprisUpdate;
mod config;
mod mpris_subscription;
mod pipewire;
mod pulse;
static SHOW_MEDIA_CONTROLS: Lazy<id::Toggler> = Lazy::new(id::Toggler::unique);
const GO_BACK: &str = "media-skip-backward-symbolic";
const GO_NEXT: &str = "media-skip-forward-symbolic";
const PAUSE: &str = "media-playback-pause-symbolic";
const PLAY: &str = "media-playback-start-symbolic";
pub fn run() -> cosmic::iced::Result {
localize();
cosmic::applet::run::<Audio>(true, ())
}
#[derive(Default)]
pub struct Audio {
core: cosmic::app::Core,
is_open: IsOpen,
current_output: Option<DeviceInfo>,
current_input: Option<DeviceInfo>,
outputs: Vec<DeviceInfo>,
inputs: Vec<DeviceInfo>,
pulse_state: PulseState,
icon_name: String,
input_icon_name: String,
popup: Option<window::Id>,
timeline: Timeline,
config: AudioAppletConfig,
player_status: Option<mpris_subscription::PlayerStatus>,
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
}
impl Audio {
fn update_output(&mut self, output: Option<DeviceInfo>) {
self.current_output = output;
self.apply_output_volume();
}
fn apply_output_volume(&mut self) {
let Some(output) = self.current_output.as_ref() else {
self.icon_name = "audio-volume-muted-symbolic".to_string();
return;
};
let volume = output.volume.avg();
let output_volume = VolumeLinear::from(volume).0;
if volume.is_muted() {
self.icon_name = "audio-volume-muted-symbolic".to_string();
} else if output_volume < 0.25 {
self.icon_name = "audio-volume-low-symbolic".to_string();
} else if output_volume < 0.5 {
self.icon_name = "audio-volume-medium-symbolic".to_string();
} else if output_volume < 0.75 {
self.icon_name = "audio-volume-high-symbolic".to_string();
} else {
self.icon_name = "audio-volume-overamplified-symbolic".to_string();
}
}
fn update_input(&mut self, input: Option<DeviceInfo>) {
self.current_input = input;
self.apply_input_volume();
}
fn apply_input_volume(&mut self) {
let Some(input) = self.current_input.as_ref() else {
self.input_icon_name = "microphone-sensitivity-muted-symbolic".to_string();
return;
};
let volume = input.volume.avg();
let input_volume = VolumeLinear::from(volume).0;
if volume.is_muted() {
self.input_icon_name = "microphone-sensitivity-muted-symbolic".to_string();
} else if input_volume < 0.33 {
self.input_icon_name = "microphone-sensitivity-low-symbolic".to_string();
} else if input_volume < 0.66 {
self.input_icon_name = "microphone-sensitivity-medium-symbolic".to_string();
} else {
self.input_icon_name = "microphone-sensitivity-high-symbolic".to_string();
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum IsOpen {
None,
Output,
Input,
}
#[derive(Debug, Clone)]
pub enum Message {
SetOutputVolume(f64),
SetInputVolume(f64),
OutputToggle,
InputToggle,
OutputChanged(String),
InputChanged(String),
Pulse(pulse::Event),
TogglePopup,
CloseRequested(window::Id),
ToggleMediaControlsInTopPanel(chain::Toggler, bool),
Frame(Instant),
ConfigChanged(AudioAppletConfig),
Mpris(mpris_subscription::MprisUpdate),
MprisRequest(MprisRequest),
Token(TokenUpdate),
OpenSettings,
}
impl Audio {
fn playback_buttons(&self) -> Option<Element<Message>> {
if self.player_status.is_some() && self.config.show_media_controls_in_top_panel {
let mut elements = Vec::with_capacity(3);
if self
.player_status
.as_ref()
.map(|s| s.can_go_previous)
.unwrap_or_default()
{
elements.push(self.core.applet.icon_button(GO_BACK).into())
}
if let Some(play) = self.is_play() {
elements.push(
self.core
.applet
.icon_button(if play { PLAY } else { PAUSE })
.on_press(if play {
Message::MprisRequest(MprisRequest::Play)
} else {
Message::MprisRequest(MprisRequest::Pause)
})
.into(),
);
}
if self
.player_status
.as_ref()
.map(|s| s.can_go_next)
.unwrap_or_default()
{
elements.push(self.core.applet.icon_button(GO_NEXT).into())
}
Some(match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => Column::with_children(elements)
.align_items(Alignment::Center)
.into(),
PanelAnchor::Top | PanelAnchor::Bottom => Row::with_children(elements)
.align_items(Alignment::Center)
.into(),
})
} else {
None
}
}
fn go_previous(&self, icon_size: u16) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| {
if s.can_go_previous {
Some(
button::icon(icon::from_name(GO_BACK).size(icon_size).symbolic(true))
.extra_small()
.style(cosmic::theme::Button::AppletIcon)
.on_press(Message::MprisRequest(MprisRequest::Previous))
.into(),
)
} else {
None
}
})
}
fn go_next(&self, icon_size: u16) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| {
if s.can_go_next {
Some(
button::icon(icon::from_name(GO_NEXT).size(icon_size).symbolic(true))
.extra_small()
.style(cosmic::theme::Button::AppletIcon)
.on_press(Message::MprisRequest(MprisRequest::Next))
.into(),
)
} else {
None
}
})
}
fn is_play(&self) -> Option<bool> {
self.player_status.as_ref().and_then(|s| match s.status {
PlaybackStatus::Playing => {
if s.can_pause {
Some(false)
} else {
None
}
}
PlaybackStatus::Paused | PlaybackStatus::Stopped => {
if s.can_play {
Some(true)
} else {
None
}
}
})
}
}
impl cosmic::Application for Audio {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletAudio";
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command<Message>) {
(
Self {
core,
is_open: IsOpen::None,
current_output: None,
current_input: None,
outputs: vec![],
inputs: vec![],
icon_name: "audio-volume-high-symbolic".to_string(),
input_icon_name: "audio-input-microphone-symbolic".to_string(),
token_tx: None,
..Default::default()
},
Command::none(),
)
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Frame(now) => self.timeline.now(now),
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
return destroy_popup(p);
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::UpdateConnection);
}
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
window::Id::MAIN,
new_id,
None,
None,
None,
);
popup_settings.positioner.size_limits = Limits::NONE
.min_height(1.0)
.min_width(1.0)
.max_width(400.0)
.max_height(1080.0);
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetDefaultSink);
conn.send(pulse::Message::GetDefaultSource);
conn.send(pulse::Message::GetSinks);
conn.send(pulse::Message::GetSources);
}
return get_popup(popup_settings);
}
}
Message::SetOutputVolume(vol) => {
self.current_output.as_mut().map(|o| {
o.volume
.set(o.volume.len(), VolumeLinear(vol / 100.0).into())
});
self.apply_output_volume();
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_output {
if let Some(name) = &device.name {
connection.send(pulse::Message::SetSinkVolumeByName(
name.clone(),
device.volume,
))
}
}
}
}
Message::SetInputVolume(vol) => {
self.current_input.as_mut().map(|i| {
i.volume
.set(i.volume.len(), VolumeLinear(vol / 100.0).into())
});
self.apply_input_volume();
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_input {
if let Some(name) = &device.name {
tracing::info!("increasing volume of {}", name);
connection.send(pulse::Message::SetSourceVolumeByName(
name.clone(),
device.volume,
))
}
}
}
}
Message::OutputChanged(val) => {
if let Some(conn) = self.pulse_state.connection() {
if let Some(val) = self.outputs.iter().find(|o| o.name.as_ref() == Some(&val)) {
conn.send(pulse::Message::SetDefaultSink(val.clone()));
}
}
}
Message::InputChanged(val) => {
if let Some(conn) = self.pulse_state.connection() {
if let Some(val) = self.inputs.iter().find(|i| i.name.as_ref() == Some(&val)) {
conn.send(pulse::Message::SetDefaultSource(val.clone()));
}
}
}
Message::OutputToggle => {
self.is_open = if self.is_open == IsOpen::Output {
IsOpen::None
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSinks);
}
IsOpen::Output
}
}
Message::InputToggle => {
self.is_open = if self.is_open == IsOpen::Input {
IsOpen::None
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSources);
}
IsOpen::Input
}
}
Message::Pulse(event) => match event {
pulse::Event::Init(mut conn) => {
conn.send(pulse::Message::UpdateConnection);
self.pulse_state = PulseState::Disconnected(conn);
}
pulse::Event::Connected => {
self.pulse_state.connected();
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSinks);
conn.send(pulse::Message::GetSources);
conn.send(pulse::Message::GetDefaultSink);
conn.send(pulse::Message::GetDefaultSource);
}
}
pulse::Event::MessageReceived(msg) => {
match msg {
// This is where we match messages from the subscription to app state
pulse::Message::SetSinks(sinks) => self.outputs = sinks,
pulse::Message::SetSources(sources) => {
self.inputs = sources
.into_iter()
.filter(|source| {
!source
.name
.as_ref()
.unwrap_or(&String::from("Generic"))
.contains("monitor")
})
.collect()
}
pulse::Message::SetDefaultSink(sink) => {
self.update_output(Some(sink));
}
pulse::Message::SetDefaultSource(source) => {
self.update_input(Some(source));
}
pulse::Message::Disconnected => {
panic!("Subscription error handling is bad. This should never happen.")
}
_ => {
tracing::trace!("Received misc message")
}
}
}
pulse::Event::Disconnected => {
self.pulse_state.disconnected();
if let Some(mut conn) = self.pulse_state.connection().cloned() {
_ = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
conn.send(pulse::Message::UpdateConnection);
});
}
}
},
Message::ToggleMediaControlsInTopPanel(chain, enabled) => {
self.timeline.set_chain(chain).start();
self.config.show_media_controls_in_top_panel = enabled;
if let Ok(helper) =
cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::VERSION)
{
if let Err(err) = self.config.write_entry(&helper) {
tracing::error!(?err, "Error writing config");
}
}
}
Message::CloseRequested(id) => {
if Some(id) == self.popup {
self.popup = None;
}
}
Message::ConfigChanged(c) => {
self.config = c;
}
Message::Mpris(mpris_subscription::MprisUpdate::Player(p)) => {
self.player_status = Some(p);
}
Message::Mpris(MprisUpdate::Finished) => {
self.player_status = None;
}
Message::Mpris(MprisUpdate::Setup) => {
self.player_status = None;
}
Message::MprisRequest(r) => {
let Some(player_status) = self.player_status.as_ref() else {
tracing::error!("No player found");
return Command::none();
};
let player = player_status.player.clone();
match r {
MprisRequest::Play => tokio::spawn(async move {
let res = player.play().await;
if let Err(err) = res {
tracing::error!("Error playing: {}", err);
}
}),
MprisRequest::Pause => tokio::spawn(async move {
let res = player.pause().await;
if let Err(err) = res {
tracing::error!("Error pausing: {}", err);
}
}),
MprisRequest::Next => tokio::spawn(async move {
let res = player.next().await;
if let Err(err) = res {
tracing::error!("Error playing next: {}", err);
}
}),
MprisRequest::Previous => tokio::spawn(async move {
let res = player.previous().await;
if let Err(err) = res {
tracing::error!("Error playing previous: {}", err);
}
}),
};
}
Message::OpenSettings => {
let exec = "cosmic-settings sound".to_string();
if let Some(tx) = self.token_tx.as_ref() {
let _ = tx.send(TokenRequest {
app_id: Self::APP_ID.to_string(),
exec,
});
} else {
tracing::error!("Wayland tx is None");
};
}
Message::Token(u) => match u {
TokenUpdate::Init(tx) => {
self.token_tx = Some(tx);
}
TokenUpdate::Finished => {
self.token_tx = None;
}
TokenUpdate::ActivationToken { token, .. } => {
let mut cmd = std::process::Command::new("cosmic-settings");
cmd.arg("sound");
if let Some(token) = token {
cmd.env("XDG_ACTIVATION_TOKEN", &token);
cmd.env("DESKTOP_STARTUP_ID", &token);
}
cosmic::process::spawn(cmd);
}
},
};
Command::none()
}
fn subscription(&self) -> Subscription<Message> {
Subscription::batch(vec![
pulse::connect().map(Message::Pulse),
self.timeline
.as_subscription()
.map(|(_, now)| Message::Frame(now)),
self.core.watch_config(Self::APP_ID).map(|u| {
for err in u.errors {
tracing::error!(?err, "Error watching config");
}
Message::ConfigChanged(u.config)
}),
mpris_subscription::mpris_subscription(0).map(Message::Mpris),
activation_token_subscription(0).map(Message::Token),
])
}
fn view(&self) -> Element<Message> {
let btn = self
.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup);
if let Some(playback_buttons) = self.playback_buttons() {
match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => {
Column::with_children(vec![playback_buttons, btn.into()])
.align_items(Alignment::Center)
.into()
}
PanelAnchor::Top | PanelAnchor::Bottom => {
Row::with_children(vec![playback_buttons, btn.into()])
.align_items(Alignment::Center)
.into()
}
}
} else {
btn.into()
}
}
fn view_window(&self, _id: window::Id) -> Element<Message> {
let audio_disabled = matches!(self.pulse_state, PulseState::Disconnected(_));
let out_f64 = VolumeLinear::from(
self.current_output
.as_ref()
.map(|o| o.volume.avg())
.unwrap_or_default(),
)
.0 * 100.0;
let in_f64 = VolumeLinear::from(
self.current_input
.as_ref()
.map(|o| o.volume.avg())
.unwrap_or_default(),
)
.0 * 100.0;
let mut audio_content = if audio_disabled {
column![padded_control(
text(fl!("disconnected"))
.width(Length::Fill)
.horizontal_alignment(Horizontal::Center)
.size(24)
)]
} else {
column![
padded_control(
row![
icon::from_name(self.icon_name.as_str())
.size(24)
.symbolic(true),
slider(0.0..=100.0, out_f64, Message::SetOutputVolume)
.width(Length::FillPortion(5)),
text(format!("{}%", out_f64.round()))
.size(16)
.width(Length::FillPortion(1))
.horizontal_alignment(Horizontal::Right)
]
.spacing(12)
.align_items(Alignment::Center)
),
padded_control(
row![
icon::from_name(self.input_icon_name.as_str())
.size(24)
.symbolic(true),
slider(0.0..=100.0, in_f64, Message::SetInputVolume)
.width(Length::FillPortion(5)),
text(format!("{}%", in_f64.round()))
.size(16)
.width(Length::FillPortion(1))
.horizontal_alignment(Horizontal::Right)
]
.spacing(12)
.align_items(Alignment::Center)
),
padded_control(divider::horizontal::default()),
revealer(
self.is_open == IsOpen::Output,
fl!("output"),
match &self.current_output {
Some(output) => pretty_name(output.description.clone()),
None => String::from("No device selected"),
},
self.outputs
.clone()
.into_iter()
.map(|output| (
output.name.clone().unwrap_or_default(),
pretty_name(output.description)
))
.collect(),
Message::OutputToggle,
Message::OutputChanged,
),
revealer(
self.is_open == IsOpen::Input,
fl!("input"),
match &self.current_input {
Some(input) => pretty_name(input.description.clone()),
None => fl!("no-device"),
},
self.inputs
.clone()
.into_iter()
.map(|input| (
input.name.clone().unwrap_or_default(),
pretty_name(input.description)
))
.collect(),
Message::InputToggle,
Message::InputChanged,
)
]
.align_items(Alignment::Start)
};
if let Some(s) = self.player_status.as_ref() {
let mut elements = Vec::with_capacity(5);
if let Some(icon_path) = s.icon.clone() {
elements.push(icon(icon::from_path(icon_path)).size(36).into());
}
let title = if let Some(title) = s.title.as_ref() {
if title.chars().count() > 15 {
let mut title_trunc = title.chars().take(15).collect::<String>();
title_trunc.push_str("...");
title_trunc
} else {
title.to_string()
}
} else {
String::new()
};
let artists = if let Some(artists) = s.artists.as_ref() {
let artists = artists.join(", ");
if artists.chars().count() > 15 {
let mut artists_trunc = artists.chars().take(15).collect::<String>();
artists_trunc.push_str("...");
artists_trunc
} else {
artists
}
} else {
fl!("unknown-artist")
};
elements.push(column![text(title).size(14), text(artists).size(10),].into());
elements.push(horizontal_space(Length::Fill).into());
if let Some(go_prev) = self.go_previous(32) {
elements.push(go_prev);
}
if let Some(play) = self.is_play() {
elements.push(
button::icon(
icon::from_name(if play { PLAY } else { PAUSE })
.size(32)
.symbolic(true),
)
.extra_small()
.style(cosmic::theme::Button::AppletIcon)
.on_press(if play {
Message::MprisRequest(MprisRequest::Play)
} else {
Message::MprisRequest(MprisRequest::Pause)
})
.into(),
);
}
if let Some(go_next) = self.go_next(32) {
elements.push(go_next);
}
audio_content = audio_content.push(padded_control(divider::horizontal::default()));
audio_content = audio_content.push(
Row::with_children(elements)
.align_items(Alignment::Center)
.spacing(8)
.padding(menu_control_padding()),
);
}
let content = column![
audio_content,
padded_control(divider::horizontal::default()),
container(
anim!(
// toggler
SHOW_MEDIA_CONTROLS,
&self.timeline,
Some(fl!("show-media-controls")),
self.config.show_media_controls_in_top_panel,
Message::ToggleMediaControlsInTopPanel,
)
.text_size(14)
.width(Length::Fill)
)
.padding([0, 24]),
padded_control(divider::horizontal::default()),
menu_button(text(fl!("sound-settings")).size(14)).on_press(Message::OpenSettings)
]
.align_items(Alignment::Start)
.padding([8, 0]);
self.core.applet.popup_container(container(content)).into()
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::CloseRequested(id))
}
}
fn revealer(
open: bool,
title: String,
selected: String,
options: Vec<(String, String)>,
toggle: Message,
mut change: impl FnMut(String) -> Message + 'static,
) -> widget::Column<'static, Message, crate::Theme, Renderer> {
if open {
options.iter().fold(
column![revealer_head(open, title, selected, toggle)].width(Length::Fill),
|col, (id, name)| {
col.push(
menu_button(text(name).size(14))
.on_press(change(id.clone()))
.width(Length::Fill)
.padding([8, 48]),
)
},
)
} else {
column![revealer_head(open, title, selected, toggle)]
}
}
fn revealer_head(
_open: bool,
title: String,
selected: String,
toggle: Message,
) -> cosmic::widget::Button<'static, Message, cosmic::Theme, Renderer> {
menu_button(column![
text(title).width(Length::Fill).size(14),
text(selected).size(10),
])
.on_press(toggle)
}
fn pretty_name(name: Option<String>) -> String {
match name {
Some(n) => n,
None => String::from("Generic"),
}
}
#[derive(Default)]
enum PulseState {
#[default]
Init,
Disconnected(pulse::Connection),
Connected(pulse::Connection),
}
impl PulseState {
fn connection(&mut self) -> Option<&mut pulse::Connection> {
match self {
Self::Disconnected(c) => Some(c),
Self::Connected(c) => Some(c),
Self::Init => None,
}
}
fn connected(&mut self) {
if let Self::Disconnected(c) = self {
*self = Self::Connected(c.clone());
}
}
fn disconnected(&mut self) {
if let Self::Connected(c) = self {
*self = Self::Disconnected(c.clone());
}
}
}
impl Default for IsOpen {
fn default() -> Self {
Self::None
}
}

View file

@ -1,868 +1,10 @@
mod localize;
use crate::localize::localize;
use crate::pulse::DeviceInfo;
use config::AudioAppletConfig;
use cosmic::app::Command;
use cosmic::applet::cosmic_panel_config::PanelAnchor;
use cosmic::applet::menu_button;
use cosmic::applet::menu_control_padding;
use cosmic::applet::padded_control;
use cosmic::applet::token::subscription::{
activation_token_subscription, TokenRequest, TokenUpdate,
};
use cosmic::cctk::sctk::reexports::calloop;
use cosmic::cosmic_config::CosmicConfigEntry;
use cosmic::iced::widget;
use cosmic::iced::Limits;
use cosmic::iced::{
self,
widget::{column, row, slider, text},
window, Alignment, Length, Subscription,
};
use cosmic::iced_runtime::core::alignment::Horizontal;
use cosmic::iced_style::application;
use cosmic::widget::button;
use cosmic::widget::horizontal_space;
use cosmic::widget::Column;
use cosmic::widget::Row;
use cosmic::widget::{divider, icon};
use cosmic::Renderer;
use cosmic::{Element, Theme};
use cosmic_time::{anim, chain, id, once_cell::sync::Lazy, Instant, Timeline};
use iced::wayland::popup::{destroy_popup, get_popup};
use iced::widget::container;
use libpulse_binding::volume::VolumeLinear;
use mpris2_zbus::player::PlaybackStatus;
use mpris_subscription::MprisRequest;
use mpris_subscription::MprisUpdate;
mod config;
mod mpris_subscription;
mod pipewire;
mod pulse;
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn main() -> cosmic::iced::Result {
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
// Prepare i18n
localize();
tracing::info!("Starting audio applet with version {VERSION}");
cosmic::applet::run::<Audio>(true, ())
}
static SHOW_MEDIA_CONTROLS: Lazy<id::Toggler> = Lazy::new(id::Toggler::unique);
const GO_BACK: &str = "media-skip-backward-symbolic";
const GO_NEXT: &str = "media-skip-forward-symbolic";
const PAUSE: &str = "media-playback-pause-symbolic";
const PLAY: &str = "media-playback-start-symbolic";
#[derive(Default)]
struct Audio {
core: cosmic::app::Core,
is_open: IsOpen,
current_output: Option<DeviceInfo>,
current_input: Option<DeviceInfo>,
outputs: Vec<DeviceInfo>,
inputs: Vec<DeviceInfo>,
pulse_state: PulseState,
icon_name: String,
input_icon_name: String,
popup: Option<window::Id>,
timeline: Timeline,
config: AudioAppletConfig,
player_status: Option<mpris_subscription::PlayerStatus>,
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
}
impl Audio {
fn update_output(&mut self, output: Option<DeviceInfo>) {
self.current_output = output;
self.apply_output_volume();
}
fn apply_output_volume(&mut self) {
let Some(output) = self.current_output.as_ref() else {
self.icon_name = "audio-volume-muted-symbolic".to_string();
return;
};
let volume = output.volume.avg();
let output_volume = VolumeLinear::from(volume).0;
if volume.is_muted() {
self.icon_name = "audio-volume-muted-symbolic".to_string();
} else if output_volume < 0.25 {
self.icon_name = "audio-volume-low-symbolic".to_string();
} else if output_volume < 0.5 {
self.icon_name = "audio-volume-medium-symbolic".to_string();
} else if output_volume < 0.75 {
self.icon_name = "audio-volume-high-symbolic".to_string();
} else {
self.icon_name = "audio-volume-overamplified-symbolic".to_string();
}
}
fn update_input(&mut self, input: Option<DeviceInfo>) {
self.current_input = input;
self.apply_input_volume();
}
fn apply_input_volume(&mut self) {
let Some(input) = self.current_input.as_ref() else {
self.input_icon_name = "microphone-sensitivity-muted-symbolic".to_string();
return;
};
let volume = input.volume.avg();
let input_volume = VolumeLinear::from(volume).0;
if volume.is_muted() {
self.input_icon_name = "microphone-sensitivity-muted-symbolic".to_string();
} else if input_volume < 0.33 {
self.input_icon_name = "microphone-sensitivity-low-symbolic".to_string();
} else if input_volume < 0.66 {
self.input_icon_name = "microphone-sensitivity-medium-symbolic".to_string();
} else {
self.input_icon_name = "microphone-sensitivity-high-symbolic".to_string();
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum IsOpen {
None,
Output,
Input,
}
#[derive(Debug, Clone)]
enum Message {
SetOutputVolume(f64),
SetInputVolume(f64),
OutputToggle,
InputToggle,
OutputChanged(String),
InputChanged(String),
Pulse(pulse::Event),
TogglePopup,
CloseRequested(window::Id),
ToggleMediaControlsInTopPanel(chain::Toggler, bool),
Frame(Instant),
ConfigChanged(AudioAppletConfig),
Mpris(mpris_subscription::MprisUpdate),
MprisRequest(MprisRequest),
Token(TokenUpdate),
OpenSettings,
}
impl Audio {
fn playback_buttons(&self) -> Option<Element<Message>> {
if self.player_status.is_some() && self.config.show_media_controls_in_top_panel {
let mut elements = Vec::with_capacity(3);
if self
.player_status
.as_ref()
.map(|s| s.can_go_previous)
.unwrap_or_default()
{
elements.push(self.core.applet.icon_button(GO_BACK).into())
}
if let Some(play) = self.is_play() {
elements.push(
self.core
.applet
.icon_button(if play { PLAY } else { PAUSE })
.on_press(if play {
Message::MprisRequest(MprisRequest::Play)
} else {
Message::MprisRequest(MprisRequest::Pause)
})
.into(),
);
}
if self
.player_status
.as_ref()
.map(|s| s.can_go_next)
.unwrap_or_default()
{
elements.push(self.core.applet.icon_button(GO_NEXT).into())
}
Some(match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => Column::with_children(elements)
.align_items(Alignment::Center)
.into(),
PanelAnchor::Top | PanelAnchor::Bottom => Row::with_children(elements)
.align_items(Alignment::Center)
.into(),
})
} else {
None
}
}
fn go_previous(&self, icon_size: u16) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| {
if s.can_go_previous {
Some(
button::icon(icon::from_name(GO_BACK).size(icon_size).symbolic(true))
.extra_small()
.style(cosmic::theme::Button::AppletIcon)
.on_press(Message::MprisRequest(MprisRequest::Previous))
.into(),
)
} else {
None
}
})
}
fn go_next(&self, icon_size: u16) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| {
if s.can_go_next {
Some(
button::icon(icon::from_name(GO_NEXT).size(icon_size).symbolic(true))
.extra_small()
.style(cosmic::theme::Button::AppletIcon)
.on_press(Message::MprisRequest(MprisRequest::Next))
.into(),
)
} else {
None
}
})
}
fn is_play(&self) -> Option<bool> {
self.player_status.as_ref().and_then(|s| match s.status {
PlaybackStatus::Playing => {
if s.can_pause {
Some(false)
} else {
None
}
}
PlaybackStatus::Paused | PlaybackStatus::Stopped => {
if s.can_play {
Some(true)
} else {
None
}
}
})
}
}
impl cosmic::Application for Audio {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletAudio";
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command<Message>) {
(
Self {
core,
is_open: IsOpen::None,
current_output: None,
current_input: None,
outputs: vec![],
inputs: vec![],
icon_name: "audio-volume-high-symbolic".to_string(),
input_icon_name: "audio-input-microphone-symbolic".to_string(),
token_tx: None,
..Default::default()
},
Command::none(),
)
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Frame(now) => self.timeline.now(now),
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
return destroy_popup(p);
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::UpdateConnection);
}
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
window::Id::MAIN,
new_id,
None,
None,
None,
);
popup_settings.positioner.size_limits = Limits::NONE
.min_height(1.0)
.min_width(1.0)
.max_width(400.0)
.max_height(1080.0);
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetDefaultSink);
conn.send(pulse::Message::GetDefaultSource);
conn.send(pulse::Message::GetSinks);
conn.send(pulse::Message::GetSources);
}
return get_popup(popup_settings);
}
}
Message::SetOutputVolume(vol) => {
self.current_output.as_mut().map(|o| {
o.volume
.set(o.volume.len(), VolumeLinear(vol / 100.0).into())
});
self.apply_output_volume();
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_output {
if let Some(name) = &device.name {
connection.send(pulse::Message::SetSinkVolumeByName(
name.clone(),
device.volume,
))
}
}
}
}
Message::SetInputVolume(vol) => {
self.current_input.as_mut().map(|i| {
i.volume
.set(i.volume.len(), VolumeLinear(vol / 100.0).into())
});
self.apply_input_volume();
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_input {
if let Some(name) = &device.name {
tracing::info!("increasing volume of {}", name);
connection.send(pulse::Message::SetSourceVolumeByName(
name.clone(),
device.volume,
))
}
}
}
}
Message::OutputChanged(val) => {
if let Some(conn) = self.pulse_state.connection() {
if let Some(val) = self.outputs.iter().find(|o| o.name.as_ref() == Some(&val)) {
conn.send(pulse::Message::SetDefaultSink(val.clone()));
}
}
}
Message::InputChanged(val) => {
if let Some(conn) = self.pulse_state.connection() {
if let Some(val) = self.inputs.iter().find(|i| i.name.as_ref() == Some(&val)) {
conn.send(pulse::Message::SetDefaultSource(val.clone()));
}
}
}
Message::OutputToggle => {
self.is_open = if self.is_open == IsOpen::Output {
IsOpen::None
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSinks);
}
IsOpen::Output
}
}
Message::InputToggle => {
self.is_open = if self.is_open == IsOpen::Input {
IsOpen::None
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSources);
}
IsOpen::Input
}
}
Message::Pulse(event) => match event {
pulse::Event::Init(mut conn) => {
conn.send(pulse::Message::UpdateConnection);
self.pulse_state = PulseState::Disconnected(conn);
}
pulse::Event::Connected => {
self.pulse_state.connected();
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSinks);
conn.send(pulse::Message::GetSources);
conn.send(pulse::Message::GetDefaultSink);
conn.send(pulse::Message::GetDefaultSource);
}
}
pulse::Event::MessageReceived(msg) => {
match msg {
// This is where we match messages from the subscription to app state
pulse::Message::SetSinks(sinks) => self.outputs = sinks,
pulse::Message::SetSources(sources) => {
self.inputs = sources
.into_iter()
.filter(|source| {
!source
.name
.as_ref()
.unwrap_or(&String::from("Generic"))
.contains("monitor")
})
.collect()
}
pulse::Message::SetDefaultSink(sink) => {
self.update_output(Some(sink));
}
pulse::Message::SetDefaultSource(source) => {
self.update_input(Some(source));
}
pulse::Message::Disconnected => {
panic!("Subscription error handling is bad. This should never happen.")
}
_ => {
tracing::trace!("Received misc message")
}
}
}
pulse::Event::Disconnected => {
self.pulse_state.disconnected();
if let Some(mut conn) = self.pulse_state.connection().cloned() {
_ = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
conn.send(pulse::Message::UpdateConnection);
});
}
}
},
Message::ToggleMediaControlsInTopPanel(chain, enabled) => {
self.timeline.set_chain(chain).start();
self.config.show_media_controls_in_top_panel = enabled;
if let Ok(helper) =
cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::VERSION)
{
if let Err(err) = self.config.write_entry(&helper) {
tracing::error!(?err, "Error writing config");
}
}
}
Message::CloseRequested(id) => {
if Some(id) == self.popup {
self.popup = None;
}
}
Message::ConfigChanged(c) => {
self.config = c;
}
Message::Mpris(mpris_subscription::MprisUpdate::Player(p)) => {
self.player_status = Some(p);
}
Message::Mpris(MprisUpdate::Finished) => {
self.player_status = None;
}
Message::Mpris(MprisUpdate::Setup) => {
self.player_status = None;
}
Message::MprisRequest(r) => {
let Some(player_status) = self.player_status.as_ref() else {
tracing::error!("No player found");
return Command::none();
};
let player = player_status.player.clone();
match r {
MprisRequest::Play => tokio::spawn(async move {
let res = player.play().await;
if let Err(err) = res {
tracing::error!("Error playing: {}", err);
}
}),
MprisRequest::Pause => tokio::spawn(async move {
let res = player.pause().await;
if let Err(err) = res {
tracing::error!("Error pausing: {}", err);
}
}),
MprisRequest::Next => tokio::spawn(async move {
let res = player.next().await;
if let Err(err) = res {
tracing::error!("Error playing next: {}", err);
}
}),
MprisRequest::Previous => tokio::spawn(async move {
let res = player.previous().await;
if let Err(err) = res {
tracing::error!("Error playing previous: {}", err);
}
}),
};
}
Message::OpenSettings => {
let exec = "cosmic-settings sound".to_string();
if let Some(tx) = self.token_tx.as_ref() {
let _ = tx.send(TokenRequest {
app_id: Self::APP_ID.to_string(),
exec,
});
} else {
tracing::error!("Wayland tx is None");
};
}
Message::Token(u) => match u {
TokenUpdate::Init(tx) => {
self.token_tx = Some(tx);
}
TokenUpdate::Finished => {
self.token_tx = None;
}
TokenUpdate::ActivationToken { token, .. } => {
let mut cmd = std::process::Command::new("cosmic-settings");
cmd.arg("sound");
if let Some(token) = token {
cmd.env("XDG_ACTIVATION_TOKEN", &token);
cmd.env("DESKTOP_STARTUP_ID", &token);
}
cosmic::process::spawn(cmd);
}
},
};
Command::none()
}
fn subscription(&self) -> Subscription<Message> {
Subscription::batch(vec![
pulse::connect().map(Message::Pulse),
self.timeline
.as_subscription()
.map(|(_, now)| Message::Frame(now)),
self.core.watch_config(Self::APP_ID).map(|u| {
for err in u.errors {
tracing::error!(?err, "Error watching config");
}
Message::ConfigChanged(u.config)
}),
mpris_subscription::mpris_subscription(0).map(Message::Mpris),
activation_token_subscription(0).map(Message::Token),
])
}
fn view(&self) -> Element<Message> {
let btn = self
.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup);
if let Some(playback_buttons) = self.playback_buttons() {
match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => {
Column::with_children(vec![playback_buttons, btn.into()])
.align_items(Alignment::Center)
.into()
}
PanelAnchor::Top | PanelAnchor::Bottom => {
Row::with_children(vec![playback_buttons, btn.into()])
.align_items(Alignment::Center)
.into()
}
}
} else {
btn.into()
}
}
fn view_window(&self, _id: window::Id) -> Element<Message> {
let audio_disabled = matches!(self.pulse_state, PulseState::Disconnected(_));
let out_f64 = VolumeLinear::from(
self.current_output
.as_ref()
.map(|o| o.volume.avg())
.unwrap_or_default(),
)
.0 * 100.0;
let in_f64 = VolumeLinear::from(
self.current_input
.as_ref()
.map(|o| o.volume.avg())
.unwrap_or_default(),
)
.0 * 100.0;
let mut audio_content = if audio_disabled {
column![padded_control(
text(fl!("disconnected"))
.width(Length::Fill)
.horizontal_alignment(Horizontal::Center)
.size(24)
)]
} else {
column![
padded_control(
row![
icon::from_name(self.icon_name.as_str())
.size(24)
.symbolic(true),
slider(0.0..=100.0, out_f64, Message::SetOutputVolume)
.width(Length::FillPortion(5)),
text(format!("{}%", out_f64.round()))
.size(16)
.width(Length::FillPortion(1))
.horizontal_alignment(Horizontal::Right)
]
.spacing(12)
.align_items(Alignment::Center)
),
padded_control(
row![
icon::from_name(self.input_icon_name.as_str())
.size(24)
.symbolic(true),
slider(0.0..=100.0, in_f64, Message::SetInputVolume)
.width(Length::FillPortion(5)),
text(format!("{}%", in_f64.round()))
.size(16)
.width(Length::FillPortion(1))
.horizontal_alignment(Horizontal::Right)
]
.spacing(12)
.align_items(Alignment::Center)
),
padded_control(divider::horizontal::default()),
revealer(
self.is_open == IsOpen::Output,
fl!("output"),
match &self.current_output {
Some(output) => pretty_name(output.description.clone()),
None => String::from("No device selected"),
},
self.outputs
.clone()
.into_iter()
.map(|output| (
output.name.clone().unwrap_or_default(),
pretty_name(output.description)
))
.collect(),
Message::OutputToggle,
Message::OutputChanged,
),
revealer(
self.is_open == IsOpen::Input,
fl!("input"),
match &self.current_input {
Some(input) => pretty_name(input.description.clone()),
None => fl!("no-device"),
},
self.inputs
.clone()
.into_iter()
.map(|input| (
input.name.clone().unwrap_or_default(),
pretty_name(input.description)
))
.collect(),
Message::InputToggle,
Message::InputChanged,
)
]
.align_items(Alignment::Start)
};
if let Some(s) = self.player_status.as_ref() {
let mut elements = Vec::with_capacity(5);
if let Some(icon_path) = s.icon.clone() {
elements.push(icon(icon::from_path(icon_path)).size(36).into());
}
let title = if let Some(title) = s.title.as_ref() {
if title.chars().count() > 15 {
let mut title_trunc = title.chars().take(15).collect::<String>();
title_trunc.push_str("...");
title_trunc
} else {
title.to_string()
}
} else {
String::new()
};
let artists = if let Some(artists) = s.artists.as_ref() {
let artists = artists.join(", ");
if artists.chars().count() > 15 {
let mut artists_trunc = artists.chars().take(15).collect::<String>();
artists_trunc.push_str("...");
artists_trunc
} else {
artists
}
} else {
fl!("unknown-artist")
};
elements.push(column![text(title).size(14), text(artists).size(10),].into());
elements.push(horizontal_space(Length::Fill).into());
if let Some(go_prev) = self.go_previous(32) {
elements.push(go_prev);
}
if let Some(play) = self.is_play() {
elements.push(
button::icon(
icon::from_name(if play { PLAY } else { PAUSE })
.size(32)
.symbolic(true),
)
.extra_small()
.style(cosmic::theme::Button::AppletIcon)
.on_press(if play {
Message::MprisRequest(MprisRequest::Play)
} else {
Message::MprisRequest(MprisRequest::Pause)
})
.into(),
);
}
if let Some(go_next) = self.go_next(32) {
elements.push(go_next);
}
audio_content = audio_content.push(padded_control(divider::horizontal::default()));
audio_content = audio_content.push(
Row::with_children(elements)
.align_items(Alignment::Center)
.spacing(8)
.padding(menu_control_padding()),
);
}
let content = column![
audio_content,
padded_control(divider::horizontal::default()),
container(
anim!(
// toggler
SHOW_MEDIA_CONTROLS,
&self.timeline,
Some(fl!("show-media-controls")),
self.config.show_media_controls_in_top_panel,
Message::ToggleMediaControlsInTopPanel,
)
.text_size(14)
.width(Length::Fill)
)
.padding([0, 24]),
padded_control(divider::horizontal::default()),
menu_button(text(fl!("sound-settings")).size(14)).on_press(Message::OpenSettings)
]
.align_items(Alignment::Start)
.padding([8, 0]);
self.core.applet.popup_container(container(content)).into()
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::CloseRequested(id))
}
}
fn revealer(
open: bool,
title: String,
selected: String,
options: Vec<(String, String)>,
toggle: Message,
mut change: impl FnMut(String) -> Message + 'static,
) -> widget::Column<'static, Message, crate::Theme, Renderer> {
if open {
options.iter().fold(
column![revealer_head(open, title, selected, toggle)].width(Length::Fill),
|col, (id, name)| {
col.push(
menu_button(text(name).size(14))
.on_press(change(id.clone()))
.width(Length::Fill)
.padding([8, 48]),
)
},
)
} else {
column![revealer_head(open, title, selected, toggle)]
}
}
fn revealer_head(
_open: bool,
title: String,
selected: String,
toggle: Message,
) -> cosmic::widget::Button<'static, Message, cosmic::Theme, Renderer> {
menu_button(column![
text(title).width(Length::Fill).size(14),
text(selected).size(10),
])
.on_press(toggle)
}
fn pretty_name(name: Option<String>) -> String {
match name {
Some(n) => n,
None => String::from("Generic"),
}
}
#[derive(Default)]
enum PulseState {
#[default]
Init,
Disconnected(pulse::Connection),
Connected(pulse::Connection),
}
impl PulseState {
fn connection(&mut self) -> Option<&mut pulse::Connection> {
match self {
Self::Disconnected(c) => Some(c),
Self::Connected(c) => Some(c),
Self::Init => None,
}
}
fn connected(&mut self) {
if let Self::Disconnected(c) = self {
*self = Self::Connected(c.clone());
}
}
fn disconnected(&mut self) {
if let Self::Connected(c) = self {
*self = Self::Disconnected(c.clone());
}
}
}
impl Default for IsOpen {
fn default() -> Self {
Self::None
}
cosmic_applet_audio::run()
}

View file

@ -1,3 +1 @@
pub const APP_ID: &str = "com.system76.CosmicAppletButton";
pub const PROFILE: &str = "";
pub const VERSION: &str = "0.1.0";

View file

@ -0,0 +1,17 @@
#[rustfmt::skip]
mod backlight;
mod app;
mod config;
mod dgpu;
mod localize;
mod power_daemon;
mod upower;
mod upower_device;
mod upower_kbdbacklight;
use localize::localize;
pub fn run() -> cosmic::iced::Result {
localize();
app::run()
}

View file

@ -1,29 +1,10 @@
#[rustfmt::skip]
mod backlight;
mod app;
mod config;
mod dgpu;
mod localize;
mod power_daemon;
mod upower;
mod upower_device;
mod upower_kbdbacklight;
use config::APP_ID;
use log::info;
use localize::localize;
use crate::config::{PROFILE, VERSION};
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
// Initialize logger
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
info!("Iced Workspaces Applet ({})", APP_ID);
info!("Version: {} ({})", VERSION, PROFILE);
// Prepare i18n
localize();
tracing::info!("Starting battery applet with version {VERSION}");
app::run()
cosmic_applet_battery::run()
}

View file

@ -12,8 +12,9 @@ libcosmic.workspace = true
cosmic-time.workspace = true
futures = "0.3"
log = "0.4"
tracing-subscriber.workspace = true
tracing.workspace = true
tracing-log.workspace = true
tracing-subscriber.workspace = true
itertools = "0.10.3"
slotmap = "1.0.6"
tokio = { version = "1.15.0", features = ["full"] }

View file

@ -1,3 +1 @@
pub const APP_ID: &str = "com.system76.CosmicAppletBluetooth";
pub const PROFILE: &str = "";
pub const VERSION: &str = "0.1.0";

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
mod app;
mod bluetooth;
mod config;
mod localize;
use crate::localize::localize;
pub fn run() -> cosmic::iced::Result {
localize();
app::run()
}

View file

@ -1,24 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
mod app;
mod bluetooth;
mod config;
mod localize;
use log::info;
use crate::config::{APP_ID, PROFILE, VERSION};
use crate::localize::localize;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
// Initialize logger
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
info!("Iced Workspaces Applet ({})", APP_ID);
info!("Version: {} ({})", VERSION, PROFILE);
// Prepare i18n
localize();
tracing::info!("Starting bluetooth applet with version {VERSION}");
app::run()
cosmic_applet_bluetooth::run()
}

View file

@ -13,7 +13,7 @@ memmap2 = "0.9.0"
rustix = { version = "0.38.0", features = ["fs"] }
png = "0.17.5"
tokio = { version = "1.17.0", features = ["sync", "macros"] }
tracing = "0.1.40"
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true
tempfile = "3.5.0"

View file

@ -0,0 +1,179 @@
mod localize;
pub(crate) mod wayland_handler;
pub(crate) mod wayland_subscription;
pub(crate) mod window_image;
use crate::localize::localize;
use cosmic::app::Command;
use cosmic::applet::cosmic_panel_config::PanelAnchor;
use cosmic::cctk::cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1;
use cosmic::cctk::sctk::reexports::calloop;
use cosmic::cctk::toplevel_info::ToplevelInfo;
use cosmic::desktop::DesktopEntryData;
use cosmic::iced::{widget::text, Length, Subscription};
use cosmic::iced_style::application;
use cosmic::iced_widget::{Column, Row};
use cosmic::widget::tooltip;
use cosmic::{Element, Theme};
use wayland_subscription::{
ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
};
pub fn run() -> cosmic::iced::Result {
localize();
cosmic::applet::run::<Minimize>(true, ())
}
#[derive(Default)]
struct Minimize {
core: cosmic::app::Core,
apps: Vec<(
ZcosmicToplevelHandleV1,
ToplevelInfo,
DesktopEntryData,
Option<WaylandImage>,
)>,
tx: Option<calloop::channel::Sender<WaylandRequest>>,
}
#[derive(Debug, Clone)]
enum Message {
Wayland(WaylandUpdate),
Activate(ZcosmicToplevelHandleV1),
}
impl cosmic::Application for Minimize {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletMinimize";
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command<Message>) {
(
Self {
core,
..Default::default()
},
Command::none(),
)
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Wayland(update) => match update {
WaylandUpdate::Init(tx) => {
self.tx = Some(tx);
}
WaylandUpdate::Finished => {
panic!("Wayland Subscription ended...")
}
WaylandUpdate::Toplevel(t) => match t {
ToplevelUpdate::Add(handle, info) | ToplevelUpdate::Update(handle, info) => {
let data = |id| {
cosmic::desktop::load_applications_for_app_ids(
None,
std::iter::once(id),
true,
false,
)
.remove(0)
};
if let Some(pos) = self.apps.iter_mut().position(|a| a.0 == handle) {
if self.apps[pos].1.app_id != info.app_id {
self.apps[pos].2 = data(&info.app_id)
}
self.apps[pos].1 = info;
} else {
let data = data(&info.app_id);
self.apps.push((handle, info, data, None));
}
}
ToplevelUpdate::Remove(handle) => self.apps.retain(|a| a.0 != handle),
},
WaylandUpdate::Image(handle, img) => {
if let Some(pos) = self.apps.iter().position(|a| a.0 == handle) {
self.apps[pos].3 = Some(img);
}
}
},
Message::Activate(handle) => {
if let Some(tx) = self.tx.as_ref() {
let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Activate(handle)));
}
}
};
Command::none()
}
fn subscription(&self) -> Subscription<Message> {
wayland_subscription::wayland_subscription().map(Message::Wayland)
}
fn view(&self) -> Element<Message> {
let (width, _) = self.core.applet.suggested_size();
let theme = self.core.system_theme().cosmic();
let space_xxs = theme.space_xxs();
let icon_buttons = self.apps.iter().map(|(handle, _, data, img)| {
tooltip(
Element::from(crate::window_image::WindowImage::new(
img.clone(),
&data.icon,
width as f32,
Message::Activate(handle.clone()),
space_xxs,
)),
data.name.clone(),
// tooltip::Position::FollowCursor,
// FIXME tooltip fails to appear when created as indicated in design
// maybe it should be a subsurface
match self.core.applet.anchor {
PanelAnchor::Left => tooltip::Position::Right,
PanelAnchor::Right => tooltip::Position::Left,
PanelAnchor::Top => tooltip::Position::Bottom,
PanelAnchor::Bottom => tooltip::Position::Top,
},
)
.snap_within_viewport(false)
.text_shaping(text::Shaping::Advanced)
.into()
});
// TODO optional dividers on ends if detects app list neighbor
// not sure the best way to tell if there is an adjacent app-list
if matches!(
self.core.applet.anchor,
PanelAnchor::Top | PanelAnchor::Bottom
) {
Row::with_children(icon_buttons)
.align_items(cosmic::iced_core::Alignment::Center)
.height(Length::Shrink)
.width(Length::Shrink)
.spacing(space_xxs)
.padding([0, space_xxs])
.into()
} else {
Column::with_children(icon_buttons)
.align_items(cosmic::iced_core::Alignment::Center)
.height(Length::Shrink)
.width(Length::Shrink)
.spacing(space_xxs)
.padding([space_xxs, 0])
.into()
}
}
}

View file

@ -1,188 +1,10 @@
mod localize;
pub(crate) mod wayland_handler;
pub(crate) mod wayland_subscription;
pub(crate) mod window_image;
use crate::localize::localize;
use cosmic::app::Command;
use cosmic::applet::cosmic_panel_config::PanelAnchor;
use cosmic::cctk::cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1;
use cosmic::cctk::sctk::reexports::calloop;
use cosmic::cctk::toplevel_info::ToplevelInfo;
use cosmic::desktop::DesktopEntryData;
use cosmic::iced::{widget::text, Length, Subscription};
use cosmic::iced_style::application;
use cosmic::iced_widget::{Column, Row};
use cosmic::widget::tooltip;
use cosmic::{Element, Theme};
use wayland_subscription::{
ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn main() -> cosmic::iced::Result {
fn main() -> cosmic::iced::Result {
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
// Prepare i18n
localize();
tracing::info!("Starting minimize applet with version {VERSION}");
cosmic::applet::run::<Minimize>(true, ())
}
#[derive(Default)]
struct Minimize {
core: cosmic::app::Core,
apps: Vec<(
ZcosmicToplevelHandleV1,
ToplevelInfo,
DesktopEntryData,
Option<WaylandImage>,
)>,
tx: Option<calloop::channel::Sender<WaylandRequest>>,
}
#[derive(Debug, Clone)]
enum Message {
Wayland(WaylandUpdate),
Activate(ZcosmicToplevelHandleV1),
}
impl cosmic::Application for Minimize {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletMinimize";
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command<Message>) {
(
Self {
core,
..Default::default()
},
Command::none(),
)
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Wayland(update) => match update {
WaylandUpdate::Init(tx) => {
self.tx = Some(tx);
}
WaylandUpdate::Finished => {
panic!("Wayland Subscription ended...")
}
WaylandUpdate::Toplevel(t) => match t {
ToplevelUpdate::Add(handle, info) | ToplevelUpdate::Update(handle, info) => {
let data = |id| {
cosmic::desktop::load_applications_for_app_ids(
None,
std::iter::once(id),
true,
false,
)
.remove(0)
};
if let Some(pos) = self.apps.iter_mut().position(|a| a.0 == handle) {
if self.apps[pos].1.app_id != info.app_id {
self.apps[pos].2 = data(&info.app_id)
}
self.apps[pos].1 = info;
} else {
let data = data(&info.app_id);
self.apps.push((handle, info, data, None));
}
}
ToplevelUpdate::Remove(handle) => self.apps.retain(|a| a.0 != handle),
},
WaylandUpdate::Image(handle, img) => {
if let Some(pos) = self.apps.iter().position(|a| a.0 == handle) {
self.apps[pos].3 = Some(img);
}
}
},
Message::Activate(handle) => {
if let Some(tx) = self.tx.as_ref() {
let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Activate(handle)));
}
}
};
Command::none()
}
fn subscription(&self) -> Subscription<Message> {
wayland_subscription::wayland_subscription().map(Message::Wayland)
}
fn view(&self) -> Element<Message> {
let (width, _) = self.core.applet.suggested_size();
let theme = self.core.system_theme().cosmic();
let space_xxs = theme.space_xxs();
let icon_buttons = self.apps.iter().map(|(handle, _, data, img)| {
tooltip(
Element::from(crate::window_image::WindowImage::new(
img.clone(),
&data.icon,
width as f32,
Message::Activate(handle.clone()),
space_xxs,
)),
data.name.clone(),
// tooltip::Position::FollowCursor,
// FIXME tooltip fails to appear when created as indicated in design
// maybe it should be a subsurface
match self.core.applet.anchor {
PanelAnchor::Left => tooltip::Position::Right,
PanelAnchor::Right => tooltip::Position::Left,
PanelAnchor::Top => tooltip::Position::Bottom,
PanelAnchor::Bottom => tooltip::Position::Top,
},
)
.snap_within_viewport(false)
.text_shaping(text::Shaping::Advanced)
.into()
});
// TODO optional dividers on ends if detects app list neighbor
// not sure the best way to tell if there is an adjacent app-list
if matches!(
self.core.applet.anchor,
PanelAnchor::Top | PanelAnchor::Bottom
) {
Row::with_children(icon_buttons)
.align_items(cosmic::iced_core::Alignment::Center)
.height(Length::Shrink)
.width(Length::Shrink)
.spacing(space_xxs)
.padding([0, space_xxs])
.into()
} else {
Column::with_children(icon_buttons)
.align_items(cosmic::iced_core::Alignment::Center)
.height(Length::Shrink)
.width(Length::Shrink)
.spacing(space_xxs)
.padding([space_xxs, 0])
.into()
}
}
cosmic_applet_minimize::run()
}

View file

@ -13,8 +13,9 @@ cosmic-time.workspace = true
futures = "0.3"
zbus.workspace = true
log = "0.4"
tracing-subscriber.workspace = true
tracing.workspace = true
tracing-log.workspace = true
tracing-subscriber.workspace = true
itertools = "0.10.3"
slotmap = "1.0.6"
tokio = { version = "1.15.0", features = ["full"] }

View file

@ -1,3 +1 @@
pub const APP_ID: &str = "com.system76.CosmicAppletNetwork";
pub const PROFILE: &str = "";
pub const VERSION: &str = "0.1.0";

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
mod app;
mod config;
mod localize;
mod network_manager;
use crate::localize::localize;
pub fn run() -> cosmic::iced::Result {
localize();
app::run()
}

View file

@ -1,24 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
mod app;
mod config;
mod localize;
mod network_manager;
use log::info;
use crate::config::{APP_ID, PROFILE, VERSION};
use crate::localize::localize;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
// Initialize logger
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
info!("Iced Workspaces Applet ({})", APP_ID);
info!("Version: {} ({})", VERSION, PROFILE);
// Prepare i18n
localize();
tracing::info!("Starting network applet with version {VERSION}");
app::run()
cosmic_applet_network::run()
}

View file

@ -22,12 +22,12 @@ cosmic-notifications-util = { git = "https://github.com/pop-os/cosmic-notificati
cosmic-notifications-config = { git = "https://github.com/pop-os/cosmic-notifications" }
# cosmic-notifications-util = { path = "../../cosmic-notifications-daemon/cosmic-notifications-util" }
# cosmic-notifications-config = { path = "../../cosmic-notifications-daemon/cosmic-notifications-config" }
tracing = "0.1"
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true
ron = "0.8"
sendfd = { version = "0.4", features = ["tokio"] }
bytemuck = "1"
tracing-subscriber.workspace = true
tracing-log.workspace = true
zbus.workspace = true
# Application i18n
i18n-embed = { version = "0.13.4", features = [

View file

@ -0,0 +1,572 @@
mod localize;
mod subscriptions;
use cosmic::applet::token::subscription::{
activation_token_subscription, TokenRequest, TokenUpdate,
};
use cosmic::applet::{menu_button, menu_control_padding, padded_control};
use cosmic::cctk::sctk::reexports::calloop;
use cosmic::cosmic_config::{Config, CosmicConfigEntry};
use cosmic::iced::wayland::popup::{destroy_popup, get_popup};
use cosmic::iced::Limits;
use cosmic::iced::{
widget::{column, row, text},
window, Alignment, Length, Subscription,
};
use cosmic::iced_core::alignment::Horizontal;
use cosmic::Command;
use cosmic::iced_futures::futures::executor::block_on;
use cosmic::iced_style::application;
use cosmic::iced_widget::{scrollable, Column};
use cosmic::widget::{button, container, divider, icon};
use cosmic::{Element, Theme};
use cosmic_notifications_config::NotificationsConfig;
use cosmic_notifications_util::{Image, Notification};
use cosmic_time::{anim, chain, id, once_cell::sync::Lazy, Instant, Timeline};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use subscriptions::notifications::NotificationsAppletProxy;
use tokio::sync::mpsc::Sender;
use tracing::info;
pub fn run() -> cosmic::iced::Result {
localize::localize();
cosmic::applet::run::<Notifications>(false, ())
}
static DO_NOT_DISTURB: Lazy<id::Toggler> = Lazy::new(id::Toggler::unique);
struct Notifications {
core: cosmic::app::Core,
config: NotificationsConfig,
config_helper: Option<Config>,
icon_name: String,
popup: Option<window::Id>,
// notifications: Vec<Notification>,
timeline: Timeline,
dbus_sender: Option<Sender<subscriptions::dbus::Input>>,
cards: Vec<(id::Cards, Vec<Notification>, bool, String, String, String)>,
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
proxy: NotificationsAppletProxy<'static>,
}
impl Notifications {
fn update_cards(&mut self, id: id::Cards) {
if let Some((id, _, card_value, ..)) = self.cards.iter_mut().find(|c| c.0 == id) {
let chain = if *card_value {
chain::Cards::on(id.clone(), 1.)
} else {
chain::Cards::off(id.clone(), 1.)
};
self.timeline.set_chain(chain);
self.timeline.start();
}
}
fn update_icon(&mut self) {
self.icon_name = if self.config.do_not_disturb {
"cosmic-applet-notification-disabled-symbolic"
} else if self.cards.is_empty() {
"cosmic-applet-notification-symbolic"
} else {
"cosmic-applet-notification-new-symbolic"
}
.to_string();
}
}
#[derive(Debug, Clone)]
enum Message {
TogglePopup,
CloseRequested(window::Id),
DoNotDisturb(chain::Toggler, bool),
Frame(Instant),
NotificationEvent(Notification),
Config(NotificationsConfig),
DbusEvent(subscriptions::dbus::Output),
Dismissed(u32),
ClearAll(Option<String>),
CardsToggled(String, bool),
Token(TokenUpdate),
OpenSettings,
}
impl cosmic::Application for Notifications {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletNotifications";
fn init(
core: cosmic::app::Core,
_flags: Self::Flags,
) -> (
Self,
cosmic::iced::Command<cosmic::app::Message<Self::Message>>,
) {
let helper = Config::new(
cosmic_notifications_config::ID,
NotificationsConfig::VERSION,
)
.ok();
let config: NotificationsConfig = helper
.as_ref()
.map(|helper| {
NotificationsConfig::get_entry(helper).unwrap_or_else(|(errors, config)| {
for err in errors {
tracing::error!("{:?}", err);
}
config
})
})
.unwrap_or_default();
let mut _self = Self {
core,
config_helper: helper,
config,
icon_name: Default::default(),
popup: None,
timeline: Default::default(),
dbus_sender: Default::default(),
cards: Vec::new(),
token_tx: Default::default(),
proxy: block_on(crate::subscriptions::notifications::get_proxy())
.expect("Failed to get proxy"),
};
_self.update_icon();
(_self, Command::none())
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn subscription(&self) -> Subscription<Message> {
Subscription::batch(vec![
self.core
.watch_config(cosmic_notifications_config::ID)
.map(|res| {
for err in res.errors {
tracing::error!("{:?}", err);
}
Message::Config(res.config)
}),
self.timeline
.as_subscription()
.map(|(_, now)| Message::Frame(now)),
subscriptions::dbus::proxy().map(Message::DbusEvent),
subscriptions::notifications::notifications(self.proxy.clone())
.map(Message::NotificationEvent),
activation_token_subscription(0).map(Message::Token),
])
}
fn update(
&mut self,
message: Self::Message,
) -> cosmic::iced::Command<cosmic::app::Message<Self::Message>> {
match message {
Message::Frame(now) => {
self.timeline.now(now);
}
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
return destroy_popup(p);
} else {
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
window::Id::MAIN,
new_id,
None,
None,
None,
);
popup_settings.positioner.size_limits = Limits::NONE
.min_width(1.0)
.max_width(444.0)
.min_height(100.0)
.max_height(900.0);
return get_popup(popup_settings);
}
}
Message::DoNotDisturb(chain, b) => {
self.timeline.set_chain(chain).start();
self.config.do_not_disturb = b;
if let Some(helper) = &self.config_helper {
if let Err(err) = self.config.write_entry(helper) {
tracing::error!("{:?}", err);
}
}
}
Message::NotificationEvent(n) => {
if let Some(c) = self
.cards
.iter_mut()
.find(|c| c.1.iter().any(|notif| n.app_name == notif.app_name))
{
if let Some(notif) = c.1.iter_mut().find(|notif| n.id == notif.id) {
*notif = n;
} else {
c.1.push(n);
c.3 = fl!(
"show-more",
HashMap::from_iter(vec![("more", c.1.len().saturating_sub(1))])
);
}
} else {
self.cards.push((
id::Cards::new(n.app_name.clone()),
vec![n],
false,
fl!("show-more", HashMap::from_iter(vec![("more", "1")])),
fl!("show-less"),
fl!("clear-all"),
));
}
}
Message::Config(config) => {
self.config = config;
}
Message::Dismissed(id) => {
info!("Dismissed {}", id);
for c in &mut self.cards {
c.1.retain(|n| n.id != id);
}
self.cards.retain(|c| !c.1.is_empty());
if let Some(tx) = &self.dbus_sender {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(err) = tx.send(subscriptions::dbus::Input::Dismiss(id)).await {
tracing::error!("{:?}", err);
}
});
}
}
Message::DbusEvent(e) => match e {
subscriptions::dbus::Output::Ready(tx) => {
self.dbus_sender.replace(tx);
}
subscriptions::dbus::Output::CloseEvent(id) => {
for c in &mut self.cards {
c.1.retain(|n| n.id != id);
c.3 = fl!(
"show-more",
HashMap::from_iter(vec![("more", c.1.len().saturating_sub(1))])
);
}
self.cards.retain(|c| !c.1.is_empty());
}
},
Message::ClearAll(Some(app_name)) => {
if let Some(pos) = self
.cards
.iter_mut()
.position(|c| c.1.iter().any(|notif| app_name == notif.app_name))
{
for n in self.cards.remove(pos).1 {
if let Some(tx) = &self.dbus_sender {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(err) =
tx.send(subscriptions::dbus::Input::Dismiss(n.id)).await
{
tracing::error!("{:?}", err);
}
});
}
}
}
}
Message::ClearAll(None) => {
for n in self.cards.drain(..).map(|n| n.1).flatten() {
if let Some(tx) = &self.dbus_sender {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(err) =
tx.send(subscriptions::dbus::Input::Dismiss(n.id)).await
{
tracing::error!("{:?}", err);
}
});
}
}
}
Message::CardsToggled(name, expanded) => {
let id = if let Some((id, _, n_expanded, ..)) = self
.cards
.iter_mut()
.find(|c| c.1.iter().any(|notif| name == notif.app_name))
{
*n_expanded = expanded;
id.clone()
} else {
return Command::none();
};
self.update_cards(id);
}
Message::CloseRequested(id) => {
if Some(id) == self.popup {
self.popup = None;
}
}
Message::OpenSettings => {
let exec = "cosmic-settings notifications".to_string();
if let Some(tx) = self.token_tx.as_ref() {
let _ = tx.send(TokenRequest {
app_id: Self::APP_ID.to_string(),
exec,
});
}
}
Message::Token(u) => match u {
TokenUpdate::Init(tx) => {
self.token_tx = Some(tx);
}
TokenUpdate::Finished => {
self.token_tx = None;
}
TokenUpdate::ActivationToken { token, .. } => {
let mut cmd = std::process::Command::new("cosmic-settings");
cmd.arg("notifications");
if let Some(token) = token {
cmd.env("XDG_ACTIVATION_TOKEN", &token);
cmd.env("DESKTOP_STARTUP_ID", &token);
}
cosmic::process::spawn(cmd);
}
},
};
self.update_icon();
Command::none()
}
fn view(&self) -> Element<Message> {
self.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup)
.into()
}
fn view_window(&self, _id: window::Id) -> Element<Message> {
let do_not_disturb = padded_control(row![anim!(
DO_NOT_DISTURB,
&self.timeline,
fl!("do-not-disturb"),
self.config.do_not_disturb,
Message::DoNotDisturb
)
.text_size(14)
.width(Length::Fill)]);
let settings = menu_button(text(fl!("notification-settings")).size(14))
.on_press(Message::OpenSettings);
let notifications = if self.cards.is_empty() {
row![container(
column![
text_icon("cosmic-applet-notification-symbolic", 40),
text(&fl!("no-notifications")).size(14)
]
.align_items(Alignment::Center)
)
.width(Length::Fill)
.align_x(Horizontal::Center)]
.spacing(12)
} else {
let mut notifs: Vec<Element<_>> = Vec::with_capacity(self.cards.len());
notifs.push(
container(
cosmic::widget::button::text(fl!("clear-all"))
.on_press(Message::ClearAll(None)),
)
.width(Length::Fill)
.align_x(Horizontal::Right)
.into(),
);
for c in self.cards.iter().rev() {
if c.1.is_empty() {
continue;
}
let name = c.1[0].app_name.clone();
let notif_elems: Vec<_> = c
.1
.iter()
.rev()
.map(|n| {
let app_name = text(if n.app_name.len() > 24 {
Cow::from(format!(
"{:.26}...",
n.app_name.lines().next().unwrap_or_default()
))
} else {
Cow::from(&n.app_name)
})
.size(12)
.width(Length::Fill);
let duration_since = text(duration_ago_msg(n)).size(10);
let close_notif = button(
icon::from_name("window-close-symbolic")
.size(16)
.symbolic(true),
)
.on_press(Message::Dismissed(n.id))
.style(cosmic::theme::Button::Text);
Element::from(
column!(
match n.image() {
Some(cosmic_notifications_util::Image::File(path)) => {
row![
icon::from_path(PathBuf::from(path)).icon().size(16),
app_name,
duration_since,
close_notif
]
.spacing(8)
.align_items(Alignment::Center)
}
Some(cosmic_notifications_util::Image::Name(name)) => {
row![
icon::from_name(name.as_str()).size(16),
app_name,
duration_since,
close_notif
]
.spacing(8)
.align_items(Alignment::Center)
}
Some(cosmic_notifications_util::Image::Data {
width,
height,
data,
}) => {
row![
icon::from_raster_pixels(*width, *height, data.clone())
.icon()
.size(16),
app_name,
duration_since,
close_notif
]
.spacing(8)
.align_items(Alignment::Center)
}
None => row![app_name, duration_since, close_notif]
.spacing(8)
.align_items(Alignment::Center),
},
column![
text(n.summary.lines().next().unwrap_or_default())
.width(Length::Fill)
.size(14),
text(n.body.lines().next().unwrap_or_default())
.width(Length::Fill)
.size(12)
]
)
.width(Length::Fill),
)
})
.collect();
let show_more_icon = c.1.last().and_then(|n| {
info!("app_icon: {:?}", &n.app_icon);
if n.app_icon.is_empty() {
match n.image().cloned() {
Some(Image::File(p)) => Some(cosmic::widget::icon::from_path(p)),
Some(Image::Name(name)) => {
Some(cosmic::widget::icon::from_name(name).handle())
}
Some(Image::Data {
width,
height,
data,
}) => Some(cosmic::widget::icon::from_raster_pixels(
width, height, data,
)),
None => None,
}
} else if let Some(path) = url::Url::parse(&n.app_icon)
.ok()
.and_then(|u| u.to_file_path().ok())
{
Some(cosmic::widget::icon::from_path(path))
} else {
Some(cosmic::widget::icon::from_name(n.app_icon.clone()).handle())
}
});
let card_list = anim!(
//cards
c.0.clone(),
&self.timeline,
notif_elems,
Message::ClearAll(Some(name.clone())),
move |_, e| Message::CardsToggled(name.clone(), e),
&c.3,
&c.4,
&c.5,
show_more_icon,
c.2,
);
notifs.push(card_list.into());
}
row!(scrollable(
Column::with_children(notifs)
.spacing(8)
.height(Length::Shrink),
)
.height(Length::Shrink))
.padding(menu_control_padding())
};
let main_content = column![
padded_control(divider::horizontal::default()),
notifications,
padded_control(divider::horizontal::default())
];
let content = column![do_not_disturb, main_content, settings]
.align_items(Alignment::Start)
.padding([8, 0]);
self.core.applet.popup_container(content).into()
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::CloseRequested(id))
}
}
fn text_icon(name: &str, size: u16) -> cosmic::widget::Icon {
icon::from_name(name).size(size).symbolic(true).icon()
}
fn duration_ago_msg(notification: &Notification) -> String {
if let Some(d) = notification.duration_since() {
let min = d.as_secs() / 60;
let hrs = min / 60;
if hrs > 0 {
fl!("hours-ago", HashMap::from_iter(vec![("duration", hrs)]))
} else {
fl!("minutes-ago", HashMap::from_iter(vec![("duration", min)]))
}
} else {
String::new()
}
}

View file

@ -1,578 +1,10 @@
mod localize;
mod subscriptions;
use cosmic::applet::token::subscription::{
activation_token_subscription, TokenRequest, TokenUpdate,
};
use cosmic::applet::{menu_button, menu_control_padding, padded_control};
use cosmic::cctk::sctk::reexports::calloop;
use cosmic::cosmic_config::{Config, CosmicConfigEntry};
use cosmic::iced::wayland::popup::{destroy_popup, get_popup};
use cosmic::iced::Limits;
use cosmic::iced::{
widget::{column, row, text},
window, Alignment, Length, Subscription,
};
use cosmic::iced_core::alignment::Horizontal;
use cosmic::Command;
const VERSION: &str = env!("CARGO_PKG_VERSION");
use cosmic::iced_futures::futures::executor::block_on;
use cosmic::iced_style::application;
use cosmic::iced_widget::{scrollable, Column};
use cosmic::widget::{button, container, divider, icon};
use cosmic::{Element, Theme};
use cosmic_notifications_config::NotificationsConfig;
use cosmic_notifications_util::{Image, Notification};
use cosmic_time::{anim, chain, id, once_cell::sync::Lazy, Instant, Timeline};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use subscriptions::notifications::NotificationsAppletProxy;
use tokio::sync::mpsc::Sender;
use tracing::info;
pub fn main() -> cosmic::iced::Result {
fn main() -> cosmic::iced::Result {
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
// Prepare i18n
localize::localize();
info!("Notifications applet");
tracing::info!("Starting notifications applet with version {VERSION}");
cosmic::applet::run::<Notifications>(false, ())
}
static DO_NOT_DISTURB: Lazy<id::Toggler> = Lazy::new(id::Toggler::unique);
struct Notifications {
core: cosmic::app::Core,
config: NotificationsConfig,
config_helper: Option<Config>,
icon_name: String,
popup: Option<window::Id>,
// notifications: Vec<Notification>,
timeline: Timeline,
dbus_sender: Option<Sender<subscriptions::dbus::Input>>,
cards: Vec<(id::Cards, Vec<Notification>, bool, String, String, String)>,
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
proxy: NotificationsAppletProxy<'static>,
}
impl Notifications {
fn update_cards(&mut self, id: id::Cards) {
if let Some((id, _, card_value, ..)) = self.cards.iter_mut().find(|c| c.0 == id) {
let chain = if *card_value {
chain::Cards::on(id.clone(), 1.)
} else {
chain::Cards::off(id.clone(), 1.)
};
self.timeline.set_chain(chain);
self.timeline.start();
}
}
fn update_icon(&mut self) {
self.icon_name = if self.config.do_not_disturb {
"cosmic-applet-notification-disabled-symbolic"
} else if self.cards.is_empty() {
"cosmic-applet-notification-symbolic"
} else {
"cosmic-applet-notification-new-symbolic"
}
.to_string();
}
}
#[derive(Debug, Clone)]
enum Message {
TogglePopup,
CloseRequested(window::Id),
DoNotDisturb(chain::Toggler, bool),
Frame(Instant),
NotificationEvent(Notification),
Config(NotificationsConfig),
DbusEvent(subscriptions::dbus::Output),
Dismissed(u32),
ClearAll(Option<String>),
CardsToggled(String, bool),
Token(TokenUpdate),
OpenSettings,
}
impl cosmic::Application for Notifications {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletNotifications";
fn init(
core: cosmic::app::Core,
_flags: Self::Flags,
) -> (
Self,
cosmic::iced::Command<cosmic::app::Message<Self::Message>>,
) {
let helper = Config::new(
cosmic_notifications_config::ID,
NotificationsConfig::VERSION,
)
.ok();
let config: NotificationsConfig = helper
.as_ref()
.map(|helper| {
NotificationsConfig::get_entry(helper).unwrap_or_else(|(errors, config)| {
for err in errors {
tracing::error!("{:?}", err);
}
config
})
})
.unwrap_or_default();
let mut _self = Self {
core,
config_helper: helper,
config,
icon_name: Default::default(),
popup: None,
timeline: Default::default(),
dbus_sender: Default::default(),
cards: Vec::new(),
token_tx: Default::default(),
proxy: block_on(crate::subscriptions::notifications::get_proxy())
.expect("Failed to get proxy"),
};
_self.update_icon();
(_self, Command::none())
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn subscription(&self) -> Subscription<Message> {
Subscription::batch(vec![
self.core
.watch_config(cosmic_notifications_config::ID)
.map(|res| {
for err in res.errors {
tracing::error!("{:?}", err);
}
Message::Config(res.config)
}),
self.timeline
.as_subscription()
.map(|(_, now)| Message::Frame(now)),
subscriptions::dbus::proxy().map(Message::DbusEvent),
subscriptions::notifications::notifications(self.proxy.clone())
.map(Message::NotificationEvent),
activation_token_subscription(0).map(Message::Token),
])
}
fn update(
&mut self,
message: Self::Message,
) -> cosmic::iced::Command<cosmic::app::Message<Self::Message>> {
match message {
Message::Frame(now) => {
self.timeline.now(now);
}
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
return destroy_popup(p);
} else {
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
window::Id::MAIN,
new_id,
None,
None,
None,
);
popup_settings.positioner.size_limits = Limits::NONE
.min_width(1.0)
.max_width(444.0)
.min_height(100.0)
.max_height(900.0);
return get_popup(popup_settings);
}
}
Message::DoNotDisturb(chain, b) => {
self.timeline.set_chain(chain).start();
self.config.do_not_disturb = b;
if let Some(helper) = &self.config_helper {
if let Err(err) = self.config.write_entry(helper) {
tracing::error!("{:?}", err);
}
}
}
Message::NotificationEvent(n) => {
if let Some(c) = self
.cards
.iter_mut()
.find(|c| c.1.iter().any(|notif| n.app_name == notif.app_name))
{
if let Some(notif) = c.1.iter_mut().find(|notif| n.id == notif.id) {
*notif = n;
} else {
c.1.push(n);
c.3 = fl!(
"show-more",
HashMap::from_iter(vec![("more", c.1.len().saturating_sub(1))])
);
}
} else {
self.cards.push((
id::Cards::new(n.app_name.clone()),
vec![n],
false,
fl!("show-more", HashMap::from_iter(vec![("more", "1")])),
fl!("show-less"),
fl!("clear-all"),
));
}
}
Message::Config(config) => {
self.config = config;
}
Message::Dismissed(id) => {
info!("Dismissed {}", id);
for c in &mut self.cards {
c.1.retain(|n| n.id != id);
}
self.cards.retain(|c| !c.1.is_empty());
if let Some(tx) = &self.dbus_sender {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(err) = tx.send(subscriptions::dbus::Input::Dismiss(id)).await {
tracing::error!("{:?}", err);
}
});
}
}
Message::DbusEvent(e) => match e {
subscriptions::dbus::Output::Ready(tx) => {
self.dbus_sender.replace(tx);
}
subscriptions::dbus::Output::CloseEvent(id) => {
for c in &mut self.cards {
c.1.retain(|n| n.id != id);
c.3 = fl!(
"show-more",
HashMap::from_iter(vec![("more", c.1.len().saturating_sub(1))])
);
}
self.cards.retain(|c| !c.1.is_empty());
}
},
Message::ClearAll(Some(app_name)) => {
if let Some(pos) = self
.cards
.iter_mut()
.position(|c| c.1.iter().any(|notif| app_name == notif.app_name))
{
for n in self.cards.remove(pos).1 {
if let Some(tx) = &self.dbus_sender {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(err) =
tx.send(subscriptions::dbus::Input::Dismiss(n.id)).await
{
tracing::error!("{:?}", err);
}
});
}
}
}
}
Message::ClearAll(None) => {
for n in self.cards.drain(..).map(|n| n.1).flatten() {
if let Some(tx) = &self.dbus_sender {
let tx = tx.clone();
tokio::spawn(async move {
if let Err(err) =
tx.send(subscriptions::dbus::Input::Dismiss(n.id)).await
{
tracing::error!("{:?}", err);
}
});
}
}
}
Message::CardsToggled(name, expanded) => {
let id = if let Some((id, _, n_expanded, ..)) = self
.cards
.iter_mut()
.find(|c| c.1.iter().any(|notif| name == notif.app_name))
{
*n_expanded = expanded;
id.clone()
} else {
return Command::none();
};
self.update_cards(id);
}
Message::CloseRequested(id) => {
if Some(id) == self.popup {
self.popup = None;
}
}
Message::OpenSettings => {
let exec = "cosmic-settings notifications".to_string();
if let Some(tx) = self.token_tx.as_ref() {
let _ = tx.send(TokenRequest {
app_id: Self::APP_ID.to_string(),
exec,
});
}
}
Message::Token(u) => match u {
TokenUpdate::Init(tx) => {
self.token_tx = Some(tx);
}
TokenUpdate::Finished => {
self.token_tx = None;
}
TokenUpdate::ActivationToken { token, .. } => {
let mut cmd = std::process::Command::new("cosmic-settings");
cmd.arg("notifications");
if let Some(token) = token {
cmd.env("XDG_ACTIVATION_TOKEN", &token);
cmd.env("DESKTOP_STARTUP_ID", &token);
}
cosmic::process::spawn(cmd);
}
},
};
self.update_icon();
Command::none()
}
fn view(&self) -> Element<Message> {
self.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup)
.into()
}
fn view_window(&self, _id: window::Id) -> Element<Message> {
let do_not_disturb = padded_control(row![anim!(
DO_NOT_DISTURB,
&self.timeline,
fl!("do-not-disturb"),
self.config.do_not_disturb,
Message::DoNotDisturb
)
.text_size(14)
.width(Length::Fill)]);
let settings = menu_button(text(fl!("notification-settings")).size(14))
.on_press(Message::OpenSettings);
let notifications = if self.cards.is_empty() {
row![container(
column![
text_icon("cosmic-applet-notification-symbolic", 40),
text(&fl!("no-notifications")).size(14)
]
.align_items(Alignment::Center)
)
.width(Length::Fill)
.align_x(Horizontal::Center)]
.spacing(12)
} else {
let mut notifs: Vec<Element<_>> = Vec::with_capacity(self.cards.len());
notifs.push(
container(
cosmic::widget::button::text(fl!("clear-all"))
.on_press(Message::ClearAll(None)),
)
.width(Length::Fill)
.align_x(Horizontal::Right)
.into(),
);
for c in self.cards.iter().rev() {
if c.1.is_empty() {
continue;
}
let name = c.1[0].app_name.clone();
let notif_elems: Vec<_> = c
.1
.iter()
.rev()
.map(|n| {
let app_name = text(if n.app_name.len() > 24 {
Cow::from(format!(
"{:.26}...",
n.app_name.lines().next().unwrap_or_default()
))
} else {
Cow::from(&n.app_name)
})
.size(12)
.width(Length::Fill);
let duration_since = text(duration_ago_msg(n)).size(10);
let close_notif = button(
icon::from_name("window-close-symbolic")
.size(16)
.symbolic(true),
)
.on_press(Message::Dismissed(n.id))
.style(cosmic::theme::Button::Text);
Element::from(
column!(
match n.image() {
Some(cosmic_notifications_util::Image::File(path)) => {
row![
icon::from_path(PathBuf::from(path)).icon().size(16),
app_name,
duration_since,
close_notif
]
.spacing(8)
.align_items(Alignment::Center)
}
Some(cosmic_notifications_util::Image::Name(name)) => {
row![
icon::from_name(name.as_str()).size(16),
app_name,
duration_since,
close_notif
]
.spacing(8)
.align_items(Alignment::Center)
}
Some(cosmic_notifications_util::Image::Data {
width,
height,
data,
}) => {
row![
icon::from_raster_pixels(*width, *height, data.clone())
.icon()
.size(16),
app_name,
duration_since,
close_notif
]
.spacing(8)
.align_items(Alignment::Center)
}
None => row![app_name, duration_since, close_notif]
.spacing(8)
.align_items(Alignment::Center),
},
column![
text(n.summary.lines().next().unwrap_or_default())
.width(Length::Fill)
.size(14),
text(n.body.lines().next().unwrap_or_default())
.width(Length::Fill)
.size(12)
]
)
.width(Length::Fill),
)
})
.collect();
let show_more_icon = c.1.last().and_then(|n| {
info!("app_icon: {:?}", &n.app_icon);
if n.app_icon.is_empty() {
match n.image().cloned() {
Some(Image::File(p)) => Some(cosmic::widget::icon::from_path(p)),
Some(Image::Name(name)) => {
Some(cosmic::widget::icon::from_name(name).handle())
}
Some(Image::Data {
width,
height,
data,
}) => Some(cosmic::widget::icon::from_raster_pixels(
width, height, data,
)),
None => None,
}
} else if let Some(path) = url::Url::parse(&n.app_icon)
.ok()
.and_then(|u| u.to_file_path().ok())
{
Some(cosmic::widget::icon::from_path(path))
} else {
Some(cosmic::widget::icon::from_name(n.app_icon.clone()).handle())
}
});
let card_list = anim!(
//cards
c.0.clone(),
&self.timeline,
notif_elems,
Message::ClearAll(Some(name.clone())),
move |_, e| Message::CardsToggled(name.clone(), e),
&c.3,
&c.4,
&c.5,
show_more_icon,
c.2,
);
notifs.push(card_list.into());
}
row!(scrollable(
Column::with_children(notifs)
.spacing(8)
.height(Length::Shrink),
)
.height(Length::Shrink))
.padding(menu_control_padding())
};
let main_content = column![
padded_control(divider::horizontal::default()),
notifications,
padded_control(divider::horizontal::default())
];
let content = column![do_not_disturb, main_content, settings]
.align_items(Alignment::Start)
.padding([8, 0]);
self.core.applet.popup_container(content).into()
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::CloseRequested(id))
}
}
fn text_icon(name: &str, size: u16) -> cosmic::widget::Icon {
icon::from_name(name).size(size).symbolic(true).icon()
}
fn duration_ago_msg(notification: &Notification) -> String {
if let Some(d) = notification.duration_since() {
let min = d.as_secs() / 60;
let hrs = min / 60;
if hrs > 0 {
fl!("hours-ago", HashMap::from_iter(vec![("duration", hrs)]))
} else {
fl!("minutes-ago", HashMap::from_iter(vec![("duration", min)]))
}
} else {
String::new()
}
cosmic_applet_notifications::run()
}

View file

@ -20,4 +20,6 @@ i18n-embed-fl = "0.6"
rust-embed = "6.6"
rust-embed-utils = "7.5.0"
once_cell = "1.17.1"
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true

View file

@ -0,0 +1,436 @@
use std::collections::HashMap;
use std::process;
use std::time::Duration;
use cosmic::applet::{menu_button, padded_control};
use cosmic::iced;
use cosmic::iced::alignment::{Horizontal, Vertical};
use cosmic::iced::event::wayland::{self, LayerEvent};
use cosmic::iced::event::{listen_with, PlatformSpecific};
use cosmic::iced::time;
use cosmic::iced::wayland::actions::layer_surface::SctkLayerSurfaceSettings;
use cosmic::iced::wayland::popup::{destroy_popup, get_popup};
use cosmic::iced_core::{Border, Shadow};
use cosmic::iced_runtime::core::layout::Limits;
use cosmic::iced_sctk::commands::layer_surface::{
destroy_layer_surface, get_layer_surface, Anchor, KeyboardInteractivity,
};
use cosmic::iced_widget::mouse_area;
use cosmic::widget::{button, divider, icon};
use cosmic::Renderer;
use cosmic::iced::Color;
use cosmic::iced::{
widget::{self, column, container, row, space::Space, text},
window, Alignment, Length, Subscription,
};
use cosmic::iced_style::application;
use cosmic::theme;
use cosmic::{app::Command, Element, Theme};
use logind_zbus::manager::ManagerProxy;
use logind_zbus::session::{SessionProxy, SessionType};
use logind_zbus::user::UserProxy;
use nix::unistd::getuid;
use zbus::Connection;
pub mod cosmic_session;
mod localize;
pub mod session_manager;
use crate::cosmic_session::CosmicSessionProxy;
use crate::session_manager::SessionManagerProxy;
pub fn run() -> cosmic::iced::Result {
localize::localize();
cosmic::applet::run::<Power>(false, ())
}
const COUNTDOWN_LENGTH: u8 = 60;
#[derive(Default)]
struct Power {
core: cosmic::app::Core,
icon_name: String,
popup: Option<window::Id>,
action_to_confirm: Option<(window::Id, PowerAction, u8)>,
}
#[derive(Debug, Clone, Copy)]
enum PowerAction {
Lock,
LogOut,
Suspend,
Restart,
Shutdown,
}
#[derive(Debug, Clone)]
enum Message {
Countdown,
Action(PowerAction),
TogglePopup,
Settings,
Confirm,
Cancel,
Zbus(Result<(), zbus::Error>),
Closed(window::Id),
}
impl cosmic::Application for Power {
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
type Message = Message;
const APP_ID: &'static str = "com.system76.CosmicAppletPower";
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command<Message>) {
(
Self {
core,
icon_name: "system-shutdown-symbolic".to_string(),
..Default::default()
},
Command::none(),
)
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::Closed(id))
}
fn subscription(&self) -> Subscription<Message> {
let mut subscriptions = Vec::with_capacity(2);
subscriptions.push(listen_with(|e, _status| match e {
cosmic::iced::Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Layer(LayerEvent::Unfocused, ..),
)) => Some(Message::Cancel),
_ => None,
}));
if self.action_to_confirm.is_some() {
subscriptions
.push(time::every(Duration::from_millis(1000)).map(|_| Message::Countdown));
}
Subscription::batch(subscriptions)
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
destroy_popup(p)
} else {
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
window::Id::MAIN,
new_id,
None,
None,
None,
);
popup_settings.positioner.size_limits = Limits::NONE
.min_width(100.0)
.min_height(100.0)
.max_height(400.0)
.max_width(500.0);
get_popup(popup_settings)
}
}
Message::Settings => {
let _ = process::Command::new("cosmic-settings").spawn();
Command::none()
}
Message::Action(action) => {
let id = window::Id::unique();
self.action_to_confirm = Some((id, action, COUNTDOWN_LENGTH));
get_layer_surface(SctkLayerSurfaceSettings {
id,
keyboard_interactivity: KeyboardInteractivity::None,
anchor: Anchor::all(),
namespace: "dialog".into(),
size: Some((None, None)),
size_limits: Limits::NONE.min_width(1.0).min_height(1.0),
..Default::default()
})
}
Message::Zbus(result) => {
if let Err(e) = result {
eprintln!("cosmic-applet-power ERROR: '{}'", e);
}
Command::none()
}
Message::Confirm => {
if let Some((id, a, _)) = self.action_to_confirm.take() {
let msg = |m| cosmic::app::message::app(Message::Zbus(m));
Command::batch(vec![
destroy_layer_surface(id),
match a {
PowerAction::Lock => iced::Command::perform(lock(), msg),
PowerAction::LogOut => iced::Command::perform(log_out(), msg),
PowerAction::Suspend => iced::Command::perform(suspend(), msg),
PowerAction::Restart => iced::Command::perform(restart(), msg),
PowerAction::Shutdown => iced::Command::perform(shutdown(), msg),
},
])
} else {
Command::none()
}
}
Message::Cancel => {
if let Some((id, _, _)) = self.action_to_confirm.take() {
return destroy_layer_surface(id);
}
Command::none()
}
Message::Countdown => {
if let Some((surface_id, a, countdown)) = self.action_to_confirm.as_mut() {
*countdown -= 1;
if *countdown == 0 {
let id = *surface_id;
let a = *a;
self.action_to_confirm = None;
let msg = |m: zbus::Result<()>| cosmic::app::message::app(Message::Zbus(m));
return Command::batch(vec![
destroy_layer_surface(id),
match a {
PowerAction::Lock => iced::Command::perform(lock(), msg),
PowerAction::LogOut => iced::Command::perform(log_out(), msg),
PowerAction::Suspend => iced::Command::perform(suspend(), msg),
PowerAction::Restart => iced::Command::perform(restart(), msg),
PowerAction::Shutdown => iced::Command::perform(shutdown(), msg),
},
]);
}
}
Command::none()
}
Message::Closed(id) => {
if self.popup == Some(id) {
self.popup = None;
}
Command::none()
}
}
}
fn view(&self) -> Element<Message> {
self.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup)
.into()
}
fn view_window(&self, id: window::Id) -> Element<Message> {
if matches!(self.popup, Some(p) if p == id) {
let settings = menu_button(text(fl!("settings")).size(14)).on_press(Message::Settings);
let session = column![
menu_button(
row![
text_icon("system-lock-screen-symbolic", 24),
text(fl!("lock-screen")).size(14),
Space::with_width(Length::Fill),
text(fl!("lock-screen-shortcut")).size(14),
]
.align_items(Alignment::Center)
.spacing(8)
)
.on_press(Message::Action(PowerAction::Lock)),
menu_button(
row![
text_icon("system-log-out-symbolic", 24),
text(fl!("log-out")).size(14),
Space::with_width(Length::Fill),
text(fl!("log-out-shortcut")).size(14),
]
.align_items(Alignment::Center)
.spacing(8)
)
.on_press(Message::Action(PowerAction::LogOut)),
];
let power = row![
power_buttons("system-suspend-symbolic", fl!("suspend"))
.on_press(Message::Action(PowerAction::Suspend)),
power_buttons("system-restart-symbolic", fl!("restart"))
.on_press(Message::Action(PowerAction::Restart)),
power_buttons("system-shutdown-symbolic", fl!("shutdown"))
.on_press(Message::Action(PowerAction::Shutdown)),
]
.spacing(24)
.padding([0, 24]);
let content = column![
settings,
padded_control(divider::horizontal::default()),
session,
padded_control(divider::horizontal::default()),
power
]
.align_items(Alignment::Start)
.padding([8, 0]);
self.core.applet.popup_container(content).into()
} else if matches!(self.action_to_confirm, Some((c_id, _, _)) if c_id == id) {
let (_, power_action, countdown) = self.action_to_confirm.as_ref().unwrap();
let action = match power_action {
PowerAction::Lock => "lock-screen",
PowerAction::LogOut => "log-out",
PowerAction::Suspend => "suspend",
PowerAction::Restart => "restart",
PowerAction::Shutdown => "shutdown",
};
let countdown = &countdown.to_string();
let content = column![
text(fl!(
"confirm-question",
HashMap::from_iter(vec![("action", action), ("countdown", countdown)])
))
.size(16),
row![
button(text(fl!("confirm")).size(14))
.padding(8)
.style(theme::Button::Suggested)
.on_press(Message::Confirm),
button(text(fl!("cancel")).size(14))
.padding(8)
.style(theme::Button::Standard)
.on_press(Message::Cancel),
]
.spacing(24)
]
.align_items(Alignment::Center)
.spacing(12)
.padding(24);
mouse_area(
container(
container(content)
.style(cosmic::theme::Container::custom(|theme| {
container::Appearance {
icon_color: Some(theme.cosmic().background.on.into()),
text_color: Some(theme.cosmic().background.on.into()),
background: Some(
Color::from(theme.cosmic().background.base).into(),
),
border: Border {
radius: 12.0.into(),
width: 2.0,
color: theme.cosmic().bg_divider().into(),
},
shadow: Shadow::default(),
}
}))
.width(Length::Shrink)
.height(Length::Shrink),
)
.align_x(Horizontal::Center)
.align_y(Vertical::Center)
.width(Length::Fill)
.height(Length::Fill),
)
.on_press(Message::Cancel)
.on_right_press(Message::Cancel)
.on_middle_press(Message::Cancel)
.into()
} else {
//panic!("no view for window {}", id.0)
widget::text("").into()
}
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
}
fn power_buttons(
name: &str,
msg: String,
) -> cosmic::widget::Button<Message, cosmic::Theme, Renderer> {
cosmic::widget::button(
column![text_icon(name, 40), text(msg).size(14)]
.spacing(4)
.align_items(Alignment::Center)
.width(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fixed(76.0))
.style(theme::Button::Text)
}
fn text_icon(name: &str, size: u16) -> cosmic::widget::Icon {
icon::from_name(name).size(size).symbolic(true).icon()
}
// ### System helpers
async fn restart() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
manager_proxy.reboot(true).await
}
async fn shutdown() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
manager_proxy.power_off(true).await
}
async fn suspend() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
manager_proxy.suspend(true).await
}
async fn lock() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
// Get the session this current process is running in
let our_uid = getuid().as_raw() as u32;
let user_path = manager_proxy.get_user(our_uid).await?;
let user = UserProxy::builder(&connection)
.path(user_path)?
.build()
.await?;
// Lock all non-TTY sessions of this user
let sessions = user.sessions().await?;
for (_, session_path) in sessions {
let session = SessionProxy::builder(&connection)
.path(session_path)?
.build()
.await?;
if session.type_().await? != SessionType::TTY {
session.lock().await?;
}
}
Ok(())
}
async fn log_out() -> zbus::Result<()> {
let session_type = std::env::var("XDG_CURRENT_DESKTOP").ok();
let connection = Connection::session().await?;
match session_type.as_ref().map(|s| s.trim()) {
Some("pop:GNOME") => {
let manager_proxy = SessionManagerProxy::new(&connection).await?;
manager_proxy.logout(0).await?;
}
// By default assume COSMIC
_ => {
let cosmic_session = CosmicSessionProxy::new(&connection).await?;
cosmic_session.exit().await?;
}
}
Ok(())
}

View file

@ -1,436 +1,10 @@
use std::collections::HashMap;
use std::process;
use std::time::Duration;
const VERSION: &str = env!("CARGO_PKG_VERSION");
use cosmic::applet::{menu_button, padded_control};
use cosmic::iced;
use cosmic::iced::alignment::{Horizontal, Vertical};
use cosmic::iced::event::wayland::{self, LayerEvent};
use cosmic::iced::event::{listen_with, PlatformSpecific};
use cosmic::iced::time;
use cosmic::iced::wayland::actions::layer_surface::SctkLayerSurfaceSettings;
use cosmic::iced::wayland::popup::{destroy_popup, get_popup};
use cosmic::iced_core::{Border, Shadow};
use cosmic::iced_runtime::core::layout::Limits;
use cosmic::iced_sctk::commands::layer_surface::{
destroy_layer_surface, get_layer_surface, Anchor, KeyboardInteractivity,
};
use cosmic::iced_widget::mouse_area;
use cosmic::widget::{button, divider, icon};
use cosmic::Renderer;
fn main() -> cosmic::iced::Result {
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
use cosmic::iced::Color;
use cosmic::iced::{
widget::{self, column, container, row, space::Space, text},
window, Alignment, Length, Subscription,
};
use cosmic::iced_style::application;
use cosmic::theme;
use cosmic::{app::Command, Element, Theme};
tracing::info!("Starting power applet with version {VERSION}");
use logind_zbus::manager::ManagerProxy;
use logind_zbus::session::{SessionProxy, SessionType};
use logind_zbus::user::UserProxy;
use nix::unistd::getuid;
use zbus::Connection;
pub mod cosmic_session;
mod localize;
pub mod session_manager;
use crate::cosmic_session::CosmicSessionProxy;
use crate::session_manager::SessionManagerProxy;
pub fn main() -> cosmic::iced::Result {
localize::localize();
cosmic::applet::run::<Power>(false, ())
}
const COUNTDOWN_LENGTH: u8 = 60;
#[derive(Default)]
struct Power {
core: cosmic::app::Core,
icon_name: String,
popup: Option<window::Id>,
action_to_confirm: Option<(window::Id, PowerAction, u8)>,
}
#[derive(Debug, Clone, Copy)]
enum PowerAction {
Lock,
LogOut,
Suspend,
Restart,
Shutdown,
}
#[derive(Debug, Clone)]
enum Message {
Countdown,
Action(PowerAction),
TogglePopup,
Settings,
Confirm,
Cancel,
Zbus(Result<(), zbus::Error>),
Closed(window::Id),
}
impl cosmic::Application for Power {
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
type Message = Message;
const APP_ID: &'static str = "com.system76.CosmicAppletPower";
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command<Message>) {
(
Self {
core,
icon_name: "system-shutdown-symbolic".to_string(),
..Default::default()
},
Command::none(),
)
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::Closed(id))
}
fn subscription(&self) -> Subscription<Message> {
let mut subscriptions = Vec::with_capacity(2);
subscriptions.push(listen_with(|e, _status| match e {
cosmic::iced::Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Layer(LayerEvent::Unfocused, ..),
)) => Some(Message::Cancel),
_ => None,
}));
if self.action_to_confirm.is_some() {
subscriptions
.push(time::every(Duration::from_millis(1000)).map(|_| Message::Countdown));
}
Subscription::batch(subscriptions)
}
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
destroy_popup(p)
} else {
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
window::Id::MAIN,
new_id,
None,
None,
None,
);
popup_settings.positioner.size_limits = Limits::NONE
.min_width(100.0)
.min_height(100.0)
.max_height(400.0)
.max_width(500.0);
get_popup(popup_settings)
}
}
Message::Settings => {
let _ = process::Command::new("cosmic-settings").spawn();
Command::none()
}
Message::Action(action) => {
let id = window::Id::unique();
self.action_to_confirm = Some((id, action, COUNTDOWN_LENGTH));
get_layer_surface(SctkLayerSurfaceSettings {
id,
keyboard_interactivity: KeyboardInteractivity::None,
anchor: Anchor::all(),
namespace: "dialog".into(),
size: Some((None, None)),
size_limits: Limits::NONE.min_width(1.0).min_height(1.0),
..Default::default()
})
}
Message::Zbus(result) => {
if let Err(e) = result {
eprintln!("cosmic-applet-power ERROR: '{}'", e);
}
Command::none()
}
Message::Confirm => {
if let Some((id, a, _)) = self.action_to_confirm.take() {
let msg = |m| cosmic::app::message::app(Message::Zbus(m));
Command::batch(vec![
destroy_layer_surface(id),
match a {
PowerAction::Lock => iced::Command::perform(lock(), msg),
PowerAction::LogOut => iced::Command::perform(log_out(), msg),
PowerAction::Suspend => iced::Command::perform(suspend(), msg),
PowerAction::Restart => iced::Command::perform(restart(), msg),
PowerAction::Shutdown => iced::Command::perform(shutdown(), msg),
},
])
} else {
Command::none()
}
}
Message::Cancel => {
if let Some((id, _, _)) = self.action_to_confirm.take() {
return destroy_layer_surface(id);
}
Command::none()
}
Message::Countdown => {
if let Some((surface_id, a, countdown)) = self.action_to_confirm.as_mut() {
*countdown -= 1;
if *countdown == 0 {
let id = *surface_id;
let a = *a;
self.action_to_confirm = None;
let msg = |m: zbus::Result<()>| cosmic::app::message::app(Message::Zbus(m));
return Command::batch(vec![
destroy_layer_surface(id),
match a {
PowerAction::Lock => iced::Command::perform(lock(), msg),
PowerAction::LogOut => iced::Command::perform(log_out(), msg),
PowerAction::Suspend => iced::Command::perform(suspend(), msg),
PowerAction::Restart => iced::Command::perform(restart(), msg),
PowerAction::Shutdown => iced::Command::perform(shutdown(), msg),
},
]);
}
}
Command::none()
}
Message::Closed(id) => {
if self.popup == Some(id) {
self.popup = None;
}
Command::none()
}
}
}
fn view(&self) -> Element<Message> {
self.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup)
.into()
}
fn view_window(&self, id: window::Id) -> Element<Message> {
if matches!(self.popup, Some(p) if p == id) {
let settings = menu_button(text(fl!("settings")).size(14)).on_press(Message::Settings);
let session = column![
menu_button(
row![
text_icon("system-lock-screen-symbolic", 24),
text(fl!("lock-screen")).size(14),
Space::with_width(Length::Fill),
text(fl!("lock-screen-shortcut")).size(14),
]
.align_items(Alignment::Center)
.spacing(8)
)
.on_press(Message::Action(PowerAction::Lock)),
menu_button(
row![
text_icon("system-log-out-symbolic", 24),
text(fl!("log-out")).size(14),
Space::with_width(Length::Fill),
text(fl!("log-out-shortcut")).size(14),
]
.align_items(Alignment::Center)
.spacing(8)
)
.on_press(Message::Action(PowerAction::LogOut)),
];
let power = row![
power_buttons("system-suspend-symbolic", fl!("suspend"))
.on_press(Message::Action(PowerAction::Suspend)),
power_buttons("system-restart-symbolic", fl!("restart"))
.on_press(Message::Action(PowerAction::Restart)),
power_buttons("system-shutdown-symbolic", fl!("shutdown"))
.on_press(Message::Action(PowerAction::Shutdown)),
]
.spacing(24)
.padding([0, 24]);
let content = column![
settings,
padded_control(divider::horizontal::default()),
session,
padded_control(divider::horizontal::default()),
power
]
.align_items(Alignment::Start)
.padding([8, 0]);
self.core.applet.popup_container(content).into()
} else if matches!(self.action_to_confirm, Some((c_id, _, _)) if c_id == id) {
let (_, power_action, countdown) = self.action_to_confirm.as_ref().unwrap();
let action = match power_action {
PowerAction::Lock => "lock-screen",
PowerAction::LogOut => "log-out",
PowerAction::Suspend => "suspend",
PowerAction::Restart => "restart",
PowerAction::Shutdown => "shutdown",
};
let countdown = &countdown.to_string();
let content = column![
text(fl!(
"confirm-question",
HashMap::from_iter(vec![("action", action), ("countdown", countdown)])
))
.size(16),
row![
button(text(fl!("confirm")).size(14))
.padding(8)
.style(theme::Button::Suggested)
.on_press(Message::Confirm),
button(text(fl!("cancel")).size(14))
.padding(8)
.style(theme::Button::Standard)
.on_press(Message::Cancel),
]
.spacing(24)
]
.align_items(Alignment::Center)
.spacing(12)
.padding(24);
mouse_area(
container(
container(content)
.style(cosmic::theme::Container::custom(|theme| {
container::Appearance {
icon_color: Some(theme.cosmic().background.on.into()),
text_color: Some(theme.cosmic().background.on.into()),
background: Some(
Color::from(theme.cosmic().background.base).into(),
),
border: Border {
radius: 12.0.into(),
width: 2.0,
color: theme.cosmic().bg_divider().into(),
},
shadow: Shadow::default(),
}
}))
.width(Length::Shrink)
.height(Length::Shrink),
)
.align_x(Horizontal::Center)
.align_y(Vertical::Center)
.width(Length::Fill)
.height(Length::Fill),
)
.on_press(Message::Cancel)
.on_right_press(Message::Cancel)
.on_middle_press(Message::Cancel)
.into()
} else {
//panic!("no view for window {}", id.0)
widget::text("").into()
}
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
}
fn power_buttons(
name: &str,
msg: String,
) -> cosmic::widget::Button<Message, cosmic::Theme, Renderer> {
cosmic::widget::button(
column![text_icon(name, 40), text(msg).size(14)]
.spacing(4)
.align_items(Alignment::Center)
.width(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fixed(76.0))
.style(theme::Button::Text)
}
fn text_icon(name: &str, size: u16) -> cosmic::widget::Icon {
icon::from_name(name).size(size).symbolic(true).icon()
}
// ### System helpers
async fn restart() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
manager_proxy.reboot(true).await
}
async fn shutdown() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
manager_proxy.power_off(true).await
}
async fn suspend() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
manager_proxy.suspend(true).await
}
async fn lock() -> zbus::Result<()> {
let connection = Connection::system().await?;
let manager_proxy = ManagerProxy::new(&connection).await?;
// Get the session this current process is running in
let our_uid = getuid().as_raw() as u32;
let user_path = manager_proxy.get_user(our_uid).await?;
let user = UserProxy::builder(&connection)
.path(user_path)?
.build()
.await?;
// Lock all non-TTY sessions of this user
let sessions = user.sessions().await?;
for (_, session_path) in sessions {
let session = SessionProxy::builder(&connection)
.path(session_path)?
.build()
.await?;
if session.type_().await? != SessionType::TTY {
session.lock().await?;
}
}
Ok(())
}
async fn log_out() -> zbus::Result<()> {
let session_type = std::env::var("XDG_CURRENT_DESKTOP").ok();
let connection = Connection::session().await?;
match session_type.as_ref().map(|s| s.trim()) {
Some("pop:GNOME") => {
let manager_proxy = SessionManagerProxy::new(&connection).await?;
manager_proxy.logout(0).await?;
}
// By default assume COSMIC
_ => {
let cosmic_session = CosmicSessionProxy::new(&connection).await?;
cosmic_session.exit().await?;
}
}
Ok(())
cosmic_applet_power::run()
}

View file

@ -9,4 +9,7 @@ futures = "0.3"
libcosmic.workspace = true
serde = "1"
tokio = { version = "1.23.0" }
tracing.workspace = true
tracing-log.workspace = true
tracing-subscriber.workspace = true
zbus.workspace = true

View file

@ -0,0 +1,6 @@
mod components;
mod subscriptions;
pub fn run() -> cosmic::iced::Result {
components::app::main()
}

View file

@ -1,6 +1,10 @@
mod components;
mod subscriptions;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
components::app::main()
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
tracing::info!("Starting status-area applet with version {VERSION}");
cosmic_applet_status_area::run()
}

View file

@ -24,6 +24,8 @@ once_cell = "1"
i18n-embed = { version = "0.14.0", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.7.0"
rust-embed = "8.0.0"
tracing = "0.1"
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true
cosmic-comp-config = { git = "https://github.com/pop-os/cosmic-comp.git", rev = "5eb5af4" }
tokio = { version = "1.17.0", features = ["sync", "rt"] }

View file

@ -0,0 +1,12 @@
use crate::window::Window;
mod localize;
mod wayland;
mod wayland_subscription;
mod window;
pub fn run() -> cosmic::iced::Result {
localize::localize();
cosmic::applet::run::<Window>(false, ())
}

View file

@ -1,12 +1,10 @@
use crate::window::Window;
mod localize;
mod wayland;
mod wayland_subscription;
mod window;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
localize::localize();
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
cosmic::applet::run::<Window>(false, ())
tracing::info!("Starting tiling applet with version {VERSION}");
cosmic_applet_tiling::run()
}

View file

@ -12,12 +12,9 @@ use cctk::{
use cosmic::iced::futures;
use cosmic_protocols::workspace::v1::client::zcosmic_workspace_handle_v1::{self, TilingState};
use futures::{channel::mpsc, executor::block_on, SinkExt};
use std::{
os::{
fd::{FromRawFd, RawFd},
unix::net::UnixStream,
},
time::Duration,
use std::os::{
fd::{FromRawFd, RawFd},
unix::net::UnixStream,
};
use tracing::error;
use wayland_client::{

View file

@ -12,6 +12,8 @@ chrono = { version = "0.4.34", features = ["clock"] }
once_cell = "1"
tokio = { version = "1.36.0", features = ["time"] }
tracing.workspace = true
tracing-log.workspace = true
tracing-subscriber.workspace = true
# Application i18n
i18n-embed = { version = "0.13.4", features = ["fluent-system", "desktop-requester"] }

View file

@ -0,0 +1,11 @@
mod localize;
mod time;
mod window;
use window::Window;
pub fn run() -> cosmic::iced::Result {
localize::localize();
cosmic::applet::run::<Window>(true, ())
}

View file

@ -1,11 +1,10 @@
mod localize;
mod time;
mod window;
use window::Window;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
localize::localize();
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
cosmic::applet::run::<Window>(true, ())
tracing::info!("Starting time applet with version {VERSION}");
cosmic_applet_time::run()
}

View file

@ -16,7 +16,6 @@ use cosmic::{
};
use chrono::{DateTime, Datelike, DurationRound, Local, Months, NaiveDate, Timelike, Weekday};
use std::time::Duration;
use crate::fl;
use crate::time::get_calender_first;

View file

@ -9,7 +9,7 @@ libcosmic.workspace = true
cctk.workspace = true
cosmic-protocols.workspace = true
nix = "0.27.1"
tracing = "0.1"
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true
once_cell = "1.9"

View file

@ -0,0 +1,16 @@
mod components;
#[rustfmt::skip]
mod config;
mod localize;
mod wayland;
mod wayland_subscription;
use localize::localize;
use crate::components::app;
pub fn run() -> cosmic::iced::Result {
localize();
app::run()
}

View file

@ -1,28 +1,10 @@
mod components;
#[rustfmt::skip]
mod config;
mod localize;
mod wayland;
mod wayland_subscription;
use tracing::info;
use localize::localize;
use crate::components::app;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
// Initialize logger
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
info!("Starting audio applet with version {VERSION}");
info!("Iced Workspaces Applet ({VERSION})");
tracing::info!("Starting workspaces applet with version {VERSION}");
// Prepare i18n
localize();
app::run()
cosmic_applet_workspaces::run()
}

View file

@ -14,12 +14,9 @@ use cctk::{
};
use cosmic_protocols::workspace::v1::client::zcosmic_workspace_handle_v1;
use futures::{channel::mpsc, executor::block_on, SinkExt};
use std::{
os::{
fd::{FromRawFd, RawFd},
unix::net::UnixStream,
},
time::Duration,
use std::os::{
fd::{FromRawFd, RawFd},
unix::net::UnixStream,
};
use wayland_client::backend::ObjectId;
use wayland_client::{

22
cosmic-applets/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "cosmic-applets"
version = "0.1.1"
edition = "2021"
[dependencies]
cosmic-app-list = { path = "../cosmic-app-list" }
cosmic-applet-audio = { path = "../cosmic-applet-audio" }
cosmic-applet-battery = { path = "../cosmic-applet-battery" }
cosmic-applet-bluetooth = { path = "../cosmic-applet-bluetooth" }
cosmic-applet-minimize = { path = "../cosmic-applet-minimize" }
cosmic-applet-network = { path = "../cosmic-applet-network" }
cosmic-applet-notifications = { path = "../cosmic-applet-notifications" }
cosmic-applet-power = { path = "../cosmic-applet-power" }
cosmic-applet-status-area = { path = "../cosmic-applet-status-area" }
cosmic-applet-tiling = { path = "../cosmic-applet-tiling" }
cosmic-applet-time = { path = "../cosmic-applet-time" }
cosmic-applet-workspaces = { path = "../cosmic-applet-workspaces" }
libcosmic.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-log.workspace = true

View file

@ -0,0 +1,31 @@
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn main() -> cosmic::iced::Result {
tracing_subscriber::fmt::init();
let _ = tracing_log::LogTracer::init();
let Some(applet) = std::env::args().next() else {
return Ok(());
};
let start = applet.rfind('/').map(|v| v + 1).unwrap_or(0);
let cmd = &applet.as_str()[start..];
tracing::info!("Starting `{cmd}` with version {VERSION}");
match cmd {
"cosmic-app-list" => cosmic_app_list::run(),
"cosmic-applet-audio" => cosmic_applet_audio::run(),
"cosmic-applet-battery" => cosmic_applet_battery::run(),
"cosmic-applet-bluetooth" => cosmic_applet_bluetooth::run(),
"cosmic-applet-minimize" => cosmic_applet_minimize::run(),
"cosmic-applet-network" => cosmic_applet_network::run(),
"cosmic-applet-notifications" => cosmic_applet_notifications::run(),
"cosmic-applet-power" => cosmic_applet_power::run(),
"cosmic-applet-status-area" => cosmic_applet_status_area::run(),
"cosmic-applet-tiling" => cosmic_applet_tiling::run(),
"cosmic-applet-time" => cosmic_applet_time::run(),
"cosmic-applet-workspaces" => cosmic_applet_workspaces::run(),
_ => return Ok(()),
}
}

12
debian/links vendored Normal file
View file

@ -0,0 +1,12 @@
/usr/bin/cosmic-applets /usr/bin/cosmic-app-list
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-audio
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-battery
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-bluetooth
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-minimize
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-network
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-notifications
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-power
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-status-area
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-tiling
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-time
/usr/bin/cosmic-applets /usr/bin/cosmic-applet-workspaces

12
debian/rules vendored
View file

@ -16,15 +16,15 @@ override_dh_auto_clean:
fi
if ! ischroot && test "${VENDOR}" = "1"; then \
mkdir -p .cargo; \
cargo vendor --sync Cargo.toml | head -n -1 > .cargo/config; \
echo 'directory = "vendor"' >> .cargo/config; \
tar pcf vendor.tar vendor; \
rm -rf vendor; \
just vendor; \
fi
override_dh_auto_build:
just rootdir=$(DESTDIR) debug=$(DEBUG) vendor=$(VENDOR) build
if test "${VENDOR}" = "1"; then \
just rootdir=$(DESTDIR) debug=$(DEBUG) build-vendored; \
else \
just rootdir=$(DESTDIR) debug=$(DEBUG) build-release; \
fi
override_dh_auto_install:
just rootdir=$(DESTDIR) install

View file

@ -14,9 +14,9 @@ iconsdir := sharedir + '/icons/hicolor'
bindir := rootdir + prefix + '/bin'
default-schema-target := sharedir / 'cosmic'
build: _extract_vendor
#!/usr/bin/env bash
cargo build {{cargo_args}}
cosmic-applets-bin := bindir / 'cosmic-applets'
default: build-release
# Compiles with debug profile
build-debug *args:
@ -25,6 +25,12 @@ build-debug *args:
# Compiles with release profile
build-release *args: (build-debug '--release' args)
# Compile with a vendored tarball
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
_link_applet name:
ln -sf {{cosmic-applets-bin}} {{name}}
_install_icons name:
find {{name}}/'data'/'icons' -type f -exec echo {} \; | rev | cut -d'/' -f-3 | rev | xargs -d '\n' -I {} install -Dm0644 {{name}}/'data'/'icons'/{} {{iconsdir}}/{}
@ -37,33 +43,25 @@ _install_desktop path:
_install_bin name:
install -Dm0755 {{targetdir}}/{{target}}/{{name}} {{bindir}}/{{name}}
_install id name: (_install_icons name) (_install_desktop name + '/data/' + id + '.desktop') (_install_bin name)
_install_applet id name: (_install_icons name) \
(_install_desktop name + '/data/' + id + '.desktop') \
(_link_applet name)
_install_app_list: (_install 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list')
_install_audio: (_install 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio')
_install_battery: (_install 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery')
_install_bluetooth: (_install 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth')
_install_minimize: (_install 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize')
_install_network: (_install 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network')
_install_notifications: (_install 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications')
_install_power: (_install 'com.system76.CosmicAppletPower' 'cosmic-applet-power')
_install_workspace: (_install 'com.system76.CosmicAppletWorkspaces' 'cosmic-applet-workspaces')
_install_time: (_install 'com.system76.CosmicAppletTime' 'cosmic-applet-time')
_install_tiling: (_install 'com.system76.CosmicAppletTiling' 'cosmic-applet-tiling')
_install_status_area: (_install 'com.system76.CosmicAppletStatusArea' 'cosmic-applet-status-area')
# TODO: Turn this into one configurable applet?
_install_panel_button: (_install_bin 'cosmic-panel-button')
_install_button id name: (_install_icons name) (_install_desktop name + '/data/' + id + '.desktop')
_install_app_button: (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button')
_install_workspaces_button: (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button')
# Installs files into the system
install: _install_app_list _install_audio _install_battery _install_bluetooth _install_minimize _install_network _install_notifications _install_power _install_workspace _install_time _install_tiling _install_panel_button _install_app_button _install_workspaces_button _install_status_area
install: (_install_bin 'cosmic-applets') (_install_applet 'com.system76.CosmicAppList' 'cosmic-app-list') (_install_default_schema 'cosmic-app-list') (_install_applet 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') (_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_bin 'cosmic-panel-button') (_install_button 'com.system76.CosmicPanelAppButton' 'cosmic-panel-app-button') (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button')
# Extracts vendored dependencies if vendor=1
_extract_vendor:
#!/usr/bin/env sh
if test {{vendor}} = 1; then
rm -rf vendor; tar pxf vendor.tar
fi
# Vendor Cargo dependencies locally
vendor:
mkdir -p .cargo
cargo vendor | head -n -1 > .cargo/config
echo 'directory = "vendor"' >> .cargo/config
tar pcf vendor.tar vendor
rm -rf vendor
# Extracts vendored dependencies
[private]
vendor-extract:
rm -rf vendor
tar pxf vendor.tar