Merge remote-tracking branch 'upstream/master' into local/yoda-main-1.3.0

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	cosmic-settings/Cargo.toml
This commit is contained in:
Lionel DARNIS 2026-08-19 21:58:13 +02:00
commit 02eccba3f8
62 changed files with 2908 additions and 625 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "cosmic-settings"
version = "1.0.12"
version = "1.5.0"
edition = "2024"
license = "GPL-3.0-only"
publish = false
@ -16,7 +16,7 @@ ashpd = { version = "0.12", default-features = false, features = [
], optional = true }
chrono = "0.4"
jiff = "0.2"
clap = { version = "4.5.59", features = ["derive"] }
clap = { version = "4.6.4", features = ["derive"] }
color-eyre = "0.6.5"
cosmic-bg-config.workspace = true
cosmic-comp-config = { workspace = true, optional = true }
@ -41,28 +41,28 @@ derive_setters = "0.1.9"
dirs = "6.0.0"
eyre = "0.6.12"
freedesktop-desktop-entry = "0.8.1"
futures = "0.3.32"
futures = "0.3.33"
hostname-validator = "1.1.1"
hostname1-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
i18n-embed-fl = "0.10.0"
i18n-embed-fl = "0.10.1"
image.workspace = true
indexmap = "2.13.0"
indexmap = "2.14.0"
itertools = "0.14.0"
itoa = "1.0.17"
itoa = "1.0.18"
cosmic.workspace = true
iced_winit.workspace = true
locale1 = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
sysinfo = { version = "=0.38.0", optional = true }
mime-apps = { package = "cosmic-mime-apps", git = "https://github.com/pop-os/cosmic-mime-apps", features = ["tokio"], optional = true }
notify = "8.2.0"
nmrs = { version = "3.4.0", optional = true }
regex = "1.12.3"
nmrs = { version = "3.4.2", optional = true }
regex = "1.13.1"
ron = "0.12"
rust-embed = "8.11.0"
rust-embed = "8.12.0"
sctk = { workspace = true, optional = true }
secure-string = "0.3.0"
secret-service = { version = "5.1.0", features = ["rt-tokio-crypto-rust"], optional = true }
serde = { version = "1.0.228", features = ["derive"] }
serde = { version = "1.0.229", features = ["derive"] }
slab = "0.4.12"
slotmap = "1.1.1"
static_init = "1.0.4"
@ -70,26 +70,26 @@ sunrise = "3.0.0"
timedate-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
tokio = { workspace = true, features = ["fs", "io-util", "process", "sync"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
udev = { version = "0.9.3", optional = true }
upower_dbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
bluez-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true }
url = "2.5.8"
xkb-data = "0.2.2"
xkeysym = { version = "0.2.1", optional = true }
zbus = { version = "5.13.2", default-features = false, features = [
zbus = { version = "5.18.0", default-features = false, features = [
"tokio",
], optional = true }
zbus_polkit = { version = "5.0.0", optional = true }
fontdb = "0.23.0"
mime = "0.3.17"
rustix = { version = "1.1.3", features = ["process"] }
rustix = { version = "1.1.4", features = ["process"] }
gettext-rs = { version = "0.7.7", features = [
"gettext-system",
], optional = true }
num-traits = "0.2"
pwhash = "1"
which = "8.0.0"
which = "8.0.5"
zlink = "0.5.0"
intmap = "3.1.3"
@ -99,7 +99,7 @@ features = ["codec"]
optional = true
[dependencies.icu]
version = "2.1.1"
version = "2.2.0"
features = ["compiled_data"]
[dependencies.i18n-embed]
@ -196,6 +196,6 @@ cosmic-comp-config = ["dep:cosmic-comp-config"]
dbus-config = ["cosmic/dbus-config", "cosmic-config/dbus"]
single-instance = ["cosmic/single-instance"]
test = []
wayland = ["cosmic/wayland", "dep:cosmic-panel-config", "dep:cosmic-randr", "iced_winit/cctk"]
wayland = ["cosmic/wayland", "dep:cosmic-panel-config", "dep:cosmic-randr", "iced_winit/wayland"]
wgpu = ["cosmic/wgpu"]
xdg-portal = ["ashpd", "cosmic/xdg-portal"]

View file

@ -584,6 +584,13 @@ impl cosmic::Application for SettingsApp {
}
}
#[cfg(feature = "page-sound")]
crate::pages::Message::SoundApplications(message) => {
if let Some(page) = self.pages.page_mut::<sound::applications::Page>() {
return page.update(message).map(Into::into);
}
}
#[cfg(feature = "page-sound")]
crate::pages::Message::SoundDeviceProfiles(message) => {
if let Some(page) = self.pages.page_mut::<sound::device_profiles::Page>() {
@ -1158,6 +1165,9 @@ impl SettingsApp {
}
for page in unload {
if page == self.active_page {
continue;
}
self.loaded_pages.remove(&page);
self.pages.on_leave(page);
}

View file

@ -1,7 +1,7 @@
use cosmic::app::ContextDrawer;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::text_input::focus;
use cosmic::widget::{Id, button, icon, settings, text};
use cosmic::widget::{button, icon, settings, text};
use cosmic::{Apply, Element, Task, task, widget};
use cosmic_settings_page::section::Entity;
use cosmic_settings_page::{self as page, Content, Info, Section};

View file

@ -153,6 +153,16 @@ impl Content {
}
}
pub fn preserve_from(&mut self, previous: &mut Content) {
self.font_config
.take_families_from(&mut previous.font_config);
self.icons_fetched = previous.icons_fetched;
self.icon_theme_active = previous.icon_theme_active;
self.icon_themes = std::mem::take(&mut previous.icon_themes);
self.icon_handles = std::mem::take(&mut previous.icon_handles);
}
pub fn update_font(
&mut self,
message: FontMessage,

View file

@ -6,7 +6,6 @@ use std::sync::{Arc, LazyLock};
use cosmic::config::{CosmicTk, FontConfig};
use cosmic::{Apply, Element, Task, widget};
use cosmic_config::ConfigSet;
use mime::FONT;
use crate::app;
use crate::widget::selection_context_item;
@ -94,6 +93,11 @@ impl Model {
self.font_filter.clear();
}
pub fn take_families_from(&mut self, other: &mut Model) {
self.interface_font_families = std::mem::take(&mut other.interface_font_families);
self.monospace_font_families = std::mem::take(&mut other.monospace_font_families);
}
pub fn font_loaded(
&mut self,
mono: Vec<Arc<str>>,

View file

@ -200,7 +200,9 @@ impl Page {
Message::DrawerOpen(context_view) => {
self.context_view = Some(context_view);
self.drawer = drawer::Content::from(&self.theme_manager);
let mut previous =
std::mem::replace(&mut self.drawer, drawer::Content::from(&self.theme_manager));
self.drawer.preserve_from(&mut previous);
tasks.push(cosmic::task::message(
crate::app::Message::OpenContextDrawer(self.entity),
));

View file

@ -65,7 +65,7 @@ pub enum Message {
AddFolder(Arc<Result<Url, file_chooser::Error>>),
/// Adds a new image file the system wallpaper folder.
#[cfg(feature = "xdg-portal")]
AddFile(Arc<Result<Url, file_chooser::Error>>),
AddFiles(Arc<Result<Vec<Url>, file_chooser::Error>>),
/// Cache currently displayed image.
CacheDisplayImage,
/// Selects an option in the category dropdown menu.
@ -787,11 +787,11 @@ impl Page {
.title(fl!("wallpaper", "image-dialog"))
.accept_label(fl!("dialog-add"))
.modal(false)
.open_file()
.open_files()
.await
.map(|response| response.url().to_owned());
.map(|response| response.urls().to_vec());
let message = Message::AddFile(Arc::new(dialog_result));
let message = Message::AddFiles(Arc::new(dialog_result));
let page_message = crate::pages::Message::DesktopWallpaper(message);
crate::Message::PageMessage(page_message)
});
@ -862,6 +862,11 @@ impl Page {
#[cfg(feature = "xdg-portal")]
Message::AddFolder(result) => {
let path = match dialog_response(result) {
DialogResponse::Paths(_) => {
// TODO find if there is a use of multi folder addition.
tracing::warn!("unexpected multi-path response for folder dialog");
return Task::none();
}
DialogResponse::Path(path) => path,
DialogResponse::Error(why) => {
tracing::error!(why, "dialog response error");
@ -887,27 +892,30 @@ impl Page {
}
#[cfg(feature = "xdg-portal")]
Message::AddFile(result) => {
let path = match dialog_response(result) {
DialogResponse::Path(path) => path,
Message::AddFiles(result) => {
let paths = match dialog_response_multi(result) {
DialogResponse::Paths(paths) => paths,
DialogResponse::Path(path) => vec![path], // not really but for the sake of consistency..
DialogResponse::Error(why) => {
tracing::error!(why, "dialog response error");
return Task::none();
}
};
if path.is_file() {
let tasks = paths.into_iter().filter(|path| path.is_file()).map(|path| {
tracing::info!(?path, "opening custom image");
// Loads a single custom image and its thumbnail for display in the backgrounds view.
return cosmic::Task::future(async move {
cosmic::Task::future(async move {
let result = wallpaper::load_image_with_thumbnail(path);
let message = Message::ImageAdd(result.map(Arc::new));
let page_message = crate::pages::Message::DesktopWallpaper(message);
crate::Message::PageMessage(page_message)
});
}
})
});
return Task::batch(tasks);
}
Message::Event(event) => match event {
@ -1417,27 +1425,33 @@ pub fn settings() -> Section<crate::pages::Message> {
enum DialogResponse {
Error(String),
Path(PathBuf),
Paths(Vec<PathBuf>),
}
#[cfg(feature = "xdg-portal")]
fn unwrap_dialog_response<T>(result: Arc<Result<T, file_chooser::Error>>) -> Result<T, String> {
let Some(result) = Arc::into_inner(result) else {
return Err(String::from("Arc::into_inner returned None"));
};
result.map_err(|why| {
let mut source: &dyn std::error::Error = &why;
let mut string = format!("open dialog subscription errored\n cause: {source}");
while let Some(new_source) = source.source() {
string.push_str(&format!("\n cause: {new_source}"));
source = new_source;
}
string
})
}
#[cfg(feature = "xdg-portal")]
fn dialog_response(result: Arc<Result<Url, file_chooser::Error>>) -> DialogResponse {
let Some(result) = Arc::into_inner(result) else {
return DialogResponse::Error(String::from("Arc::into_inner returned None"));
};
let selection = match result {
Ok(response) => response,
Err(why) => {
let mut source: &dyn std::error::Error = &why;
let mut string = format!("open dialog subscription errored\n cause: {source}");
while let Some(new_source) = source.source() {
string.push_str(&format!("\n cause: {new_source}"));
source = new_source;
}
return DialogResponse::Error(string);
}
let selection = match unwrap_dialog_response(result) {
Ok(selection) => selection,
Err(why) => return DialogResponse::Error(why),
};
let Ok(path) = selection.to_file_path() else {
@ -1446,3 +1460,22 @@ fn dialog_response(result: Arc<Result<Url, file_chooser::Error>>) -> DialogRespo
DialogResponse::Path(path)
}
#[cfg(feature = "xdg-portal")]
fn dialog_response_multi(result: Arc<Result<Vec<Url>, file_chooser::Error>>) -> DialogResponse {
let selections = match unwrap_dialog_response(result) {
Ok(selections) => selections,
Err(why) => return DialogResponse::Error(why),
};
let mut paths = Vec::with_capacity(selections.len());
for selection in selections {
let Ok(path) = selection.to_file_path() else {
tracing::warn!("not a valid file path: {}", selection.path());
continue;
};
paths.push(path);
}
DialogResponse::Paths(paths)
}

View file

@ -244,11 +244,13 @@ impl<Message: Clone> Widget<Message, cosmic::Theme, Renderer> for Arrangement<'_
if let Some(ref on_placement) = self.on_placement {
shell.publish(on_placement(
output_key,
((region.x - state.max_dimensions.0 - bounds.x) * UNIT_PIXELS) as i32,
((region.x - state.max_dimensions.0 - bounds.x) * UNIT_PIXELS).round()
as i32,
((region.y
- (state.max_dimensions.1 / VERTICAL_DISPLAY_OVERHEAD)
- bounds.y)
* UNIT_PIXELS) as i32,
* UNIT_PIXELS)
.round() as i32,
));
}

View file

@ -91,6 +91,8 @@ pub enum Message {
#[cfg(feature = "page-sound")]
Sound(sound::Message),
#[cfg(feature = "page-sound")]
SoundApplications(sound::applications::Message),
#[cfg(feature = "page-sound")]
SoundDeviceProfiles(sound::device_profiles::Message),
StartupApps(applications::startup_apps::Message),
#[cfg(feature = "page-users")]

View file

@ -12,7 +12,7 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use futures::{FutureExt, SinkExt, StreamExt};
use nmrs::agent::{SecretAgent, SecretAgentFlags, SecretRequest, SecretResponder, SecretSetting};
use nmrs::raw::zbus;
use nmrs::raw::zvariant::{OwnedObjectPath, OwnedValue, Str, Value};
use nmrs::raw::zvariant::{Dict, OwnedObjectPath, OwnedValue, Str, Value};
use nmrs::{
ActiveConnection, ConnectType, ConnectionOptions, EapOptions, NetworkEvent, NetworkManager,
SavedConnection, SettingsSummary, WifiKeyMgmt, WifiSecurity,
@ -514,6 +514,10 @@ pub enum Request {
Option<String>,
),
SetAirplaneMode(bool),
SetVpnUsername {
uuid: UUID,
username: String,
},
SetWiFi(bool),
}
@ -720,12 +724,64 @@ async fn handle_request(nm: &NetworkManager, req: Request) -> Event {
result.is_ok()
}
Request::SetAirplaneMode(enabled) => nm.set_airplane_mode(*enabled).await.is_ok(),
Request::SetVpnUsername { uuid, username } => {
let result = set_vpn_username(nm, uuid, username).await;
if let Err(err) = &result {
tracing::error!(%err, %uuid, "failed to store VPN username");
}
result.is_ok()
}
Request::SetWiFi(enabled) => nm.set_wireless_enabled(*enabled).await.is_ok(),
};
request_response(nm, req, success).await
}
/// Stores a VPN username in `vpn.data`, which is where NetworkManager and the VPN
/// plugins read it from.
///
/// The username must not be sent through the secret agent: openvpn fails to
/// reconnect when it receives a `username` key in the VPN secrets dictionary.
async fn set_vpn_username(
nm: &NetworkManager,
uuid: &str,
username: &str,
) -> Result<(), nmrs::ConnectionError> {
let settings = nm.get_saved_connection_raw(uuid).await?;
// The whole dict is rewritten, so the keys already in it have to be carried over.
let mut data = HashMap::<String, String>::new();
if let Some(stored) = settings.get("vpn").and_then(|vpn| vpn.get("data"))
&& let Ok(dict) = Dict::try_from(stored.clone())
{
for (key, value) in dict.iter() {
if let (Ok(key), Ok(value)) = (Str::try_from(key.clone()), Str::try_from(value.clone()))
{
data.insert(key.to_string(), value.to_string());
}
}
}
if data
.get("username")
.is_some_and(|stored| stored == username)
{
return Ok(());
}
data.insert("username".to_string(), username.to_string());
let data = OwnedValue::try_from(Value::from(data))
.map_err(|err| nmrs::ConnectionError::VpnFailed(err.to_string()))?;
let mut patch = nmrs::SettingsPatch::default();
patch.raw_overlay = Some(HashMap::from([(
"vpn".to_string(),
HashMap::from([("data".to_string(), data)]),
)]));
nm.update_saved_connection(uuid, patch).await
}
async fn request_response(nm: &NetworkManager, req: Request, success: bool) -> Event {
Event::RequestResponse {
req,
@ -1649,15 +1705,28 @@ pub mod nm_secret_agent {
}
}
SecretSetting::Vpn { .. } => {
let secrets = stored
// Earlier versions stored the username next to the password. openvpn
// fails to reconnect when one is present in the secrets dictionary, so
// profiles saved by those versions have to be filtered on read too.
let secrets: HashMap<String, String> = stored
.into_iter()
.filter(|(key, _)| key != "username")
.map(|(key, value)| (key, value.unsecure().to_string()))
.collect();
request
.responder
.vpn_secrets(secrets)
.await
.map_err(|err| Error(err.to_string()))?;
if secrets.is_empty() {
request
.responder
.no_secrets()
.await
.map_err(|err| Error(err.to_string()))?;
} else {
request
.responder
.vpn_secrets(secrets)
.await
.map_err(|err| Error(err.to_string()))?;
}
}
_ => {
let setting_name = setting_name(&request.setting).to_string();

View file

@ -612,7 +612,7 @@ impl Page {
} = dialog
&& let Some(NmState { ref sender, .. }) = self.nm_state
{
let username_unwrapped = username.clone().unwrap_or_default();
let username = username.clone().unwrap_or_default();
let sec_tx = self.secret_tx.clone();
let nm_sender = sender.clone();
return Task::future(async move {
@ -626,10 +626,10 @@ impl Page {
.send(nm_secret_agent::Request::SetSecrets {
setting_name: "vpn".to_string(),
uuid: uuid.to_string(),
secrets: HashMap::from_iter([
("username".to_string(), username_unwrapped.into()),
("password".to_string(), password),
]),
secrets: HashMap::from_iter([(
"password".to_string(),
password,
)]),
applied_tx,
})
.await
@ -645,6 +645,16 @@ impl Page {
tracing::error!(%err, "failed to apply secret");
}
}
if !username.is_empty() {
_ = nm_sender.unbounded_send(
network_manager::Request::SetVpnUsername {
uuid: uuid.clone(),
username,
},
);
}
_ = nm_sender
.unbounded_send(network_manager::Request::ActivateVpn(uuid));
}
@ -668,7 +678,7 @@ impl Page {
} = dialog
&& let Some(NmState { ref sender, .. }) = self.nm_state
{
let username_unwrapped = username.unwrap_or_default();
let username = username.unwrap_or_default();
let sec_tx = self.secret_tx.clone();
let nm_sender = sender.clone();
return Task::future(async move {
@ -678,10 +688,10 @@ impl Page {
.send(nm_secret_agent::Request::SetSecrets {
setting_name: "vpn".to_string(),
uuid: uuid.to_string(),
secrets: HashMap::from_iter([
("username".to_string(), username_unwrapped.into()),
("password".to_string(), password),
]),
secrets: HashMap::from_iter([(
"password".to_string(),
password,
)]),
applied_tx,
})
.await;
@ -689,6 +699,16 @@ impl Page {
tokio::time::timeout(std::time::Duration::from_secs(1), applied_rx)
.await;
}
if !username.is_empty() {
_ = nm_sender.unbounded_send(
network_manager::Request::SetVpnUsername {
uuid: uuid.clone(),
username,
},
);
}
_ = nm_sender.unbounded_send(network_manager::Request::ActivateVpn(uuid));
Message::Refresh
})

View file

@ -0,0 +1,465 @@
// Copyright 2026 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use super::model;
use cosmic::desktop::{
DesktopEntryCache, DesktopLookupContext, DesktopResolveOptions, resolve_desktop_entry,
};
use cosmic::iced::futures;
use cosmic::iced::{self, Alignment, Length, window};
use cosmic::widget::space::horizontal as horizontal_space;
use cosmic::{Apply, Element, surface, widget};
use cosmic_config::{Config, ConfigGet};
use cosmic_settings_audio_client::{self as audio_client, CosmicAudioProxy};
use cosmic_settings_page::{self as page, Section, section};
use futures::executor::block_on;
use intmap::IntMap;
use slotmap::SlotMap;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
const AUDIO_CONFIG: &str = "com.system76.CosmicAudio";
const AMPLIFICATION_SINK: &str = "amplification_sink";
#[derive(Clone, Debug)]
pub enum Message {
Model(cosmic_settings_sound::Message),
SetBalance(u32, f32),
SetMute(u32, bool),
SetSink(u32, usize),
SetVolume(u32, u32),
Surface(surface::Action),
ToggleExpanded(u32),
}
impl From<Message> for crate::pages::Message {
fn from(message: Message) -> Self {
crate::pages::Message::SoundApplications(message)
}
}
impl From<Message> for crate::Message {
fn from(message: Message) -> Self {
crate::Message::PageMessage(message.into())
}
}
#[derive(Default)]
pub struct Page {
entity: page::Entity,
model: model::Model,
client: Option<Rc<RefCell<audio_client::Client>>>,
desktop_entries: DesktopEntryCache,
playback_icons: IntMap<u32, String>,
amplification_sink: bool,
expanded: Option<u32>,
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl page::Page<crate::pages::Message> for Page {
fn on_enter(&mut self) -> cosmic::Task<crate::pages::Message> {
self.amplification_sink = Config::new(AUDIO_CONFIG, 1)
.ok()
.and_then(|config| config.get::<bool>(AMPLIFICATION_SINK).ok())
.unwrap_or(true);
cosmic::Task::none()
}
fn info(&self) -> page::Info {
page::Info::new("sound-applications", "preferences-sound-symbolic")
.title(fl!("sound-applications"))
}
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(view())])
}
fn set_id(&mut self, entity: page::Entity) {
self.entity = entity;
}
fn on_leave(&mut self) -> cosmic::Task<crate::pages::Message> {
*self = Page {
entity: self.entity,
..Page::default()
};
cosmic::Task::none()
}
fn subscription(&self, _core: &cosmic::Core) -> iced::Subscription<crate::pages::Message> {
iced::Subscription::run(|| {
iced::stream::channel(
1,
move |emitter: futures::channel::mpsc::Sender<crate::pages::Message>| async move {
cosmic_settings_sound::subscribe(emitter, |m| Message::Model(m).into()).await
},
)
})
}
}
impl Page {
fn resolve_playback_icon(&mut self, node_id: u32, info: &audio_client::PlaybackInfo) {
let Some(candidate) = [
info.application_id.as_deref(),
info.icon_name.as_deref(),
info.application_name.as_deref(),
]
.into_iter()
.flatten()
.find(|value| !value.is_empty()) else {
return;
};
let mut context = DesktopLookupContext::new(candidate);
if let Some(icon_name) = info.icon_name.as_deref().filter(|value| !value.is_empty()) {
context = context.with_identifier(icon_name);
}
if let Some(application_name) = info
.application_name
.as_deref()
.filter(|value| !value.is_empty())
{
context = context.with_title(application_name);
}
let entry = resolve_desktop_entry(
&mut self.desktop_entries,
&context,
&DesktopResolveOptions::default(),
);
let entry_icon = entry.icon();
let icon_name = entry_icon
.filter(|value| !value.is_empty())
.or(info.icon_name.as_deref())
.or(info.application_id.as_deref())
.unwrap_or("application-default");
self.playback_icons.insert(node_id, icon_name.to_owned());
}
pub fn update(&mut self, message: Message) -> cosmic::Task<crate::app::Message> {
match message {
Message::Model(cosmic_settings_sound::Message::Subscription(message)) => {
match &message {
audio_client::Event::Playback(node_id, info) => {
self.resolve_playback_icon(*node_id, info);
}
audio_client::Event::RemoveNode(node_id) => {
self.playback_icons.remove(*node_id);
}
_ => {}
}
self.model.update(message);
}
Message::Model(cosmic_settings_sound::Message::Client(client)) => {
if let Some(client) = Arc::into_inner(client) {
self.client = Some(Rc::new(RefCell::new(client)));
self.model = model::Model::default();
self.playback_icons = IntMap::new();
}
}
Message::SetMute(node_id, mute) => {
self.model
.update(audio_client::Event::NodeMute(node_id, mute));
if let Some(client) = &self.client {
match block_on(client.borrow_mut().conn.set_node_mute(node_id, mute)) {
Ok(Ok(_)) => {}
Ok(Err(why)) => {
tracing::error!(?why, node_id, mute, "daemon rejected playback mute");
}
Err(why) => {
tracing::error!(?why, node_id, mute, "failed to set playback mute");
}
}
} else {
tracing::warn!(node_id, mute, "audio client is not connected");
}
}
Message::SetBalance(playback_id, balance) => {
if let Some(playback) = self
.model
.playback
.iter_mut()
.find(|playback| playback.id == playback_id)
{
playback.balance = Some(balance);
}
if let Some(client) = &self.client {
match block_on(
client
.borrow_mut()
.conn
.set_node_volume_balance(playback_id, Some(balance)),
) {
Ok(Ok(volume)) => {
self.model.update(audio_client::Event::NodeVolume(
playback_id,
volume.volume,
volume.balance,
));
}
Ok(Err(why)) => {
tracing::error!(
?why,
playback_id,
balance,
"daemon rejected playback balance"
);
}
Err(why) => {
tracing::error!(
?why,
playback_id,
balance,
"failed to set playback balance"
);
}
}
} else {
tracing::warn!(playback_id, balance, "audio client is not connected");
}
}
Message::SetSink(playback_id, sorted_pos) => {
let Some(&sink_pos) = self.model.sinks.sorted_index.get(sorted_pos) else {
return cosmic::Task::none();
};
let Some(&sink_id) = self.model.sinks.id.get(sink_pos as usize) else {
return cosmic::Task::none();
};
if let Some(client) = &self.client {
match block_on(
client
.borrow_mut()
.conn
.set_playback_sink(playback_id, sink_id),
) {
Ok(Ok(())) => {
if let Some(playback) = self
.model
.playback
.iter_mut()
.find(|playback| playback.id == playback_id)
{
playback.sink_id = Some(sink_id);
}
}
Ok(Err(why)) => {
tracing::error!(
?why,
playback_id,
sink_id,
"daemon rejected playback output"
);
}
Err(why) => {
tracing::error!(
?why,
playback_id,
sink_id,
"failed to set playback output"
);
}
}
} else {
tracing::warn!(playback_id, sink_id, "audio client is not connected");
}
}
Message::SetVolume(node_id, volume) => {
if let Some(playback) = self
.model
.playback
.iter_mut()
.find(|playback| playback.id == node_id)
{
playback.volume = volume;
}
if let Some(client) = &self.client {
match block_on(client.borrow_mut().conn.set_node_volume(node_id, volume)) {
Ok(Ok(_)) => {}
Ok(Err(why)) => {
tracing::error!(
?why,
node_id,
volume,
"daemon rejected playback volume"
);
}
Err(why) => {
tracing::error!(?why, node_id, volume, "failed to set playback volume");
}
}
} else {
tracing::warn!(node_id, volume, "audio client is not connected");
}
}
Message::Surface(action) => {
return cosmic::task::message(crate::app::Message::Surface(action));
}
Message::ToggleExpanded(node_id) => {
self.expanded = if self.expanded == Some(node_id) {
None
} else {
Some(node_id)
};
}
}
cosmic::Task::none()
}
}
pub fn view() -> Section<crate::pages::Message> {
Section::default().view::<Page>(move |_, page, _section| {
if page.model.playback.is_empty() {
return widget::settings::section()
.add(
widget::text::body(fl!("sound-applications", "none"))
.width(Length::Fill)
.align_x(Alignment::Center),
)
.into();
}
let mut applications = widget::settings::section();
for playback in page.model.playback.iter().cloned() {
let node_id = playback.id;
let mute = playback.mute;
let volume = playback.volume;
let expanded = page.expanded == Some(node_id);
let icon_name = page
.playback_icons
.get(node_id)
.map(String::as_str)
.unwrap_or("application-default");
let slider = if page.amplification_sink {
widget::slider(0..=150, volume, move |volume| {
Message::SetVolume(node_id, volume)
})
.breakpoints(&[100])
} else {
widget::slider(0..=100, volume, move |volume| {
Message::SetVolume(node_id, volume)
})
}
.width(Length::Fill)
.apply(widget::container)
.max_width(250.);
let volume_controls = widget::row::with_capacity(5)
.align_y(Alignment::Center)
.push(
widget::button::icon(widget::icon::from_name(if mute {
"audio-volume-muted-symbolic"
} else {
"audio-volume-high-symbolic"
}))
.on_press(Message::SetMute(node_id, !mute)),
)
.push(
widget::text::body(volume.to_string())
.width(Length::Fixed(22.))
.align_x(Alignment::Center),
)
.push(horizontal_space().width(8.))
.push(slider)
.push(
widget::button::icon(widget::icon::from_name(if expanded {
"go-up-symbolic"
} else {
"go-down-symbolic"
}))
.on_press(Message::ToggleExpanded(node_id)),
)
.apply(Element::from)
.map(crate::pages::Message::from);
applications = applications.add(
widget::settings::item::builder(playback.name().to_owned())
.icon(widget::icon::from_name(icon_name).size(16))
.flex_control(volume_controls)
.align_items(Alignment::Center),
);
if !expanded {
continue;
}
let selected_sink =
playback
.selected_sink(page.model.default_sink)
.and_then(|sink_id| {
let sink_pos = page.model.sinks.id.iter().position(|id| *id == sink_id)?;
page.model
.sinks
.sorted_index
.iter()
.position(|pos| usize::from(*pos) == sink_pos)
});
let output = widget::dropdown::popup_dropdown(
&page.model.sinks.sorted_display,
selected_sink,
move |pos| Message::SetSink(node_id, pos),
window::Id::RESERVED,
Message::Surface,
crate::Message::from,
)
.apply(Element::from)
.map(crate::pages::Message::from);
let balance = widget::row::with_capacity(5)
.align_y(Alignment::Center)
.push(
widget::column::with_capacity(2)
.align_x(Alignment::Center)
.push(widget::text::body(fl!("sound-output", "left")))
.push(horizontal_space().width(22.)),
)
.push(horizontal_space().width(8.))
.push(
widget::slider(0.0..=2.0, playback.balance.unwrap_or(1.0), move |balance| {
Message::SetBalance(node_id, balance)
})
.step(0.01)
.breakpoints(&[1.0]),
)
.push(horizontal_space().width(8.))
.push(
widget::column::with_capacity(2)
.align_x(Alignment::Center)
.push(widget::text::body(fl!("sound-output", "right")))
.push(horizontal_space().width(22.)),
)
.apply(Element::from)
.map(crate::pages::Message::from);
applications = applications
.add(widget::settings::item(
fl!("sound-output", "device"),
output,
))
.add(widget::settings::item(
fl!("sound-output", "balance"),
balance,
));
}
applications.into()
})
}

View file

@ -1,6 +1,7 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod applications;
pub mod device_profiles;
use cosmic::iced::{self, Alignment, Length, window};
@ -63,6 +64,7 @@ impl From<Message> for crate::Message {
pub struct Page {
entity: page::Entity,
applications: page::Entity,
device_profiles: page::Entity,
client: Option<audio_client::Client>,
model: model::Model,
@ -78,6 +80,7 @@ impl Default for Page {
fn default() -> Self {
Self {
entity: page::Entity::default(),
applications: page::Entity::default(),
device_profiles: page::Entity::default(),
client: None,
model: model::Model {
@ -122,6 +125,7 @@ impl page::Page<crate::pages::Message> for Page {
Some(vec![
sections.insert(output()),
sections.insert(input()),
sections.insert(applications()),
sections.insert(device_profiles()),
])
}
@ -150,6 +154,7 @@ impl page::Page<crate::pages::Message> for Page {
fn on_leave(&mut self) -> Task<crate::pages::Message> {
*self = Page {
entity: self.entity,
applications: self.applications,
device_profiles: self.device_profiles,
..Page::default()
};
@ -163,8 +168,10 @@ impl page::AutoBind<crate::pages::Message> for Page {
mut page: page::Insert<crate::pages::Message>,
) -> page::Insert<crate::pages::Message> {
let id = page.sub_page_with_id::<device_profiles::Page>();
let applications_id = page.sub_page_with_id::<applications::Page>();
let model = page.model.page_mut::<Page>().unwrap();
model.device_profiles = id;
model.applications = applications_id;
page
}
}
@ -265,7 +272,7 @@ impl Page {
tokio::time::sleep(Duration::from_millis(128)).await;
_ = client
.conn
.set_sink_volume(volume.load(atomic::Ordering::Relaxed))
.set_source_volume(volume.load(atomic::Ordering::Relaxed))
.await;
Message::ReattachClient(Arc::new(client)).into()
});
@ -534,6 +541,24 @@ fn device_profiles() -> Section<crate::pages::Message> {
})
}
/// A section for opening the application volume controls sub-page.
fn applications() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
button_txt = fl!("sound-applications");
});
Section::default()
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
settings::section()
.add(crate::widget::go_next_item(
&section.descriptions[button_txt],
crate::pages::Message::Page(page.applications),
))
.into()
})
}
// fn alerts() -> Section<crate::pages::Message> {
// let mut descriptions = Slab::new();
// let volume = descriptions.insert(fl!("sound-alerts", "volume"));
@ -549,21 +574,3 @@ fn device_profiles() -> Section<crate::pages::Message> {
// .into()
// })
// }
// fn applications() -> Section<crate::pages::Message> {
// let mut descriptions = Slab::new();
// let applications = descriptions.insert(fl!("sound-applications", "desc"));
// Section::default()
// .title(fl!("sound-applications"))
// .descriptions(descriptions)
// .view::<Page>(move |_binder, _page, section| {
// settings::section().title(&section.title)
// .add(settings::item(
// &*section.descriptions[applications],
// text::body("TODO"),
// ))
// .into()
// })
// }