From 2c2dc485aa524ac6b433b0c3c7cd3b5584627f23 Mon Sep 17 00:00:00 2001 From: Alvin Nguyen <106125101+alvinwin@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:02:31 -0700 Subject: [PATCH] feat(bluetooth): add back navigation for multiple Bluetooth adapters (#2154) --- cosmic-settings/src/pages/bluetooth/mod.rs | 110 ++++++++++++++++----- 1 file changed, 87 insertions(+), 23 deletions(-) diff --git a/cosmic-settings/src/pages/bluetooth/mod.rs b/cosmic-settings/src/pages/bluetooth/mod.rs index 8676665..66319e4 100644 --- a/cosmic-settings/src/pages/bluetooth/mod.rs +++ b/cosmic-settings/src/pages/bluetooth/mod.rs @@ -12,7 +12,7 @@ use futures::channel::oneshot; use futures::{SinkExt, StreamExt}; use slotmap::SlotMap; use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::Duration; use zbus::zvariant::OwnedObjectPath; @@ -20,6 +20,8 @@ use zbus::zvariant::OwnedObjectPath; use crate::service_manager::MockServiceManager; use crate::service_manager::ServiceManagerHandle; +static BLUETOOTH_PAGE_LABEL: LazyLock = LazyLock::new(|| fl!("bluetooth")); + enum Dialog { RequestConfirmation { device: String, @@ -173,6 +175,19 @@ impl page::Page for Page { .description(fl!("xdg-entry-bluetooth-comment")) } + fn header(&self) -> Option> { + if self.model.adapters.len() > 1 { + let (_, adapter) = self.model.get_selected_adapter()?; + return Some(crate::widget::sub_page_header( + &adapter.alias, + BLUETOOTH_PAGE_LABEL.as_str(), + Message::SelectAdapter(None).into(), + )); + } + + None + } + fn content( &self, sections: &mut SlotMap>, @@ -392,6 +407,30 @@ impl From for Message { } impl Page { + fn update_heading(&mut self) { + self.heading = if let Some((_, adapter)) = self.model.get_selected_adapter() { + fl!( + "bluetooth", + "status", + aliases = format!("“{}”", adapter.alias) + ) + } else { + fl!( + "bluetooth", + "status", + aliases = self + .model + .adapters + .values() + .map(|adapter| format!("“{}”", adapter.alias)) + .collect::>() + .into_iter() + .collect::>() + .join(", ") + ) + }; + } + pub fn update(&mut self, message: Message) -> cosmic::Task { let span = tracing::span!(tracing::Level::INFO, "bluetooth::update"); let _span = span.enter(); @@ -437,28 +476,7 @@ impl Page { Event::SetAdapters(adapters) => { let select_adapter = self.model.set_adapters(adapters); - - if let Some((_, adapter)) = self.model.get_selected_adapter() { - self.heading = fl!( - "bluetooth", - "status", - aliases = format!("“{}”", adapter.alias) - ); - } else { - self.heading = fl!( - "bluetooth", - "status", - aliases = self - .model - .adapters - .values() - .map(|adapter| format!("“{}”", adapter.alias)) - .collect::>() - .into_iter() - .collect::>() - .join(", ") - ); - } + self.update_heading(); if let Some(adapter) = select_adapter { return cosmic::task::message(Message::SelectAdapter(Some(adapter))); @@ -741,6 +759,7 @@ impl Page { Message::SelectAdapter(adapter_maybe) => { tracing::debug!("Adapter selected: {adapter_maybe:?}"); self.model.selected_adapter = adapter_maybe; + self.update_heading(); self.model.update_status(); let Some(connection) = self.connection.as_ref() else { tracing::error!("No DBus connection ready"); @@ -1158,6 +1177,51 @@ impl Page { #[cfg(test)] mod tests { use super::*; + use cosmic_settings_page::Page as _; + + fn adapter(path: &str, alias: &str) -> (OwnedObjectPath, Adapter) { + ( + OwnedObjectPath::try_from(path).unwrap(), + Adapter { + alias: alias.to_owned(), + ..Adapter::default() + }, + ) + } + + fn page_with_two_adapters() -> Page { + let mut page = Page::default(); + let adapters = [ + adapter("/org/bluez/hci0", "Adapter 0"), + adapter("/org/bluez/hci1", "Adapter 1"), + ] + .into_iter() + .collect(); + let _task = page.update(Message::BluetoothEvent(Event::SetAdapters(adapters))); + page + } + + #[test] + fn bluetooth_header_is_absent_at_adapter_chooser() { + let page = page_with_two_adapters(); + + assert!(page.header().is_none()); + } + + #[test] + fn bluetooth_header_tracks_adapter_selection_and_clearing() { + let mut page = page_with_two_adapters(); + let selected = OwnedObjectPath::try_from("/org/bluez/hci1").unwrap(); + + let _task = page.update(Message::SelectAdapter(Some(selected))); + assert!(page.header().is_some()); + assert!(page.heading.contains("Adapter 1")); + + let _task = page.update(Message::SelectAdapter(None)); + assert!(page.header().is_none()); + assert!(page.heading.contains("Adapter 0")); + assert!(page.heading.contains("Adapter 1")); + } #[test] fn test_dbus_service_unknown_with_installed_service_queries_manager() {