system settings
git clone https://git.lucas.co/cce-system-interface.git
feat: split the Radios page into Network and Bluetooth pages
pages/bluetooth.rs owns the bluetoothctl state/fetch/actions and its
own label-less well (extracted verbatim from network.rs, messages
de-prefixed); network.rs keeps WiFi only, as a label-less well.
Page::Radios becomes Page::Network + Page::Bluetooth (each with its
own gated watcher), AppAction::Radios splits likewise. Two old layout
bugs fixed on the way: the WiFi status lines double-advanced content_y
(the SSID list overlapped the Signal/IP line), and the hand-placed
Scan button overlapped the grid-placed toggle — both toggles are now
hand-placed at their real width.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/app.rs | 12 +-
src/main.rs | 13 +-
src/pages/audio.rs | 4 -
src/pages/bluetooth.rs | 323 +++++++++++++++++++++++++++++++++++++++++++++++++
src/pages/mod.rs | 12 +-
src/pages/network.rs | 305 +++-------------------------------------------
src/watchers.rs | 7 +-
7 files changed, 376 insertions(+), 300 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index 2f7c7c2..b2437e6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,6 +1,7 @@
use cce_ui::layout::RenderTarget;
use crate::pages::audio;
+use crate::pages::bluetooth;
use crate::pages::default_apps;
use crate::pages::network;
use crate::pages::processes;
@@ -16,6 +17,7 @@ use crate::pages::Page;
pub struct AppState {
pub current_page: Page,
pub audio: audio::AudioState,
+ pub bluetooth: bluetooth::BluetoothState,
pub default_apps: default_apps::DefaultAppsState,
pub network: network::NetworkState,
pub processes: processes::ProcessesState,
@@ -33,6 +35,7 @@ impl Default for AppState {
Self {
current_page: Page::ALL[0],
audio: audio::AudioState::default(),
+ bluetooth: bluetooth::BluetoothState::default(),
default_apps: default_apps::DefaultAppsState::default(),
network: network::NetworkState::default(),
processes: processes::ProcessesState::default(),
@@ -52,11 +55,12 @@ impl AppState {
match page {
Page::Accounts => &self.accounts,
Page::Audio => &self.audio,
+ Page::Bluetooth => &self.bluetooth,
Page::DefaultApps => &self.default_apps,
Page::Packages => &self.packages,
Page::Processes => &self.processes,
Page::Services => &self.services,
- Page::Radios => &self.network,
+ Page::Network => &self.network,
Page::Storage => &self.storage,
Page::System => &self.system_info,
Page::Fonts => &self.fonts,
@@ -68,11 +72,12 @@ impl AppState {
match page {
Page::Accounts => &mut self.accounts,
Page::Audio => &mut self.audio,
+ Page::Bluetooth => &mut self.bluetooth,
Page::DefaultApps => &mut self.default_apps,
Page::Packages => &mut self.packages,
Page::Processes => &mut self.processes,
Page::Services => &mut self.services,
- Page::Radios => &mut self.network,
+ Page::Network => &mut self.network,
Page::Storage => &mut self.storage,
Page::System => &mut self.system_info,
Page::Fonts => &mut self.fonts,
@@ -94,7 +99,8 @@ pub enum AppAction {
Exit,
Audio(audio::AudioMessage),
DefaultApps(default_apps::DefaultAppsMessage),
- Radios(network::NetworkMessage),
+ Network(network::NetworkMessage),
+ Bluetooth(bluetooth::BluetoothMessage),
Processes(processes::ProcessesMessage),
Services(services::ServicesMessage),
SystemInfo(system_info::SystemMessage),
diff --git a/src/main.rs b/src/main.rs
index d6fad2c..1d78923 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -55,6 +55,7 @@ struct SystemInterface {
rx_audio: std::sync::mpsc::Receiver<pages::audio::AudioState>,
rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
+ rx_bluetooth: std::sync::mpsc::Receiver<pages::bluetooth::BluetoothState>,
pub rx_processes: std::sync::mpsc::Receiver<pages::processes::ProcessesState>,
rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
@@ -154,6 +155,7 @@ impl cce_ui::engine::Application for SystemInterface {
cursor_y: 0.0,
rx_audio: watchers.rx_audio,
rx_network: watchers.rx_network,
+ rx_bluetooth: watchers.rx_bluetooth,
rx_processes: watchers.rx_processes,
rx_default_apps: watchers.rx_default_apps,
rx_system: watchers.rx_system,
@@ -488,7 +490,13 @@ impl SystemInterface {
}
while let Ok(s) = self.rx_network.try_recv() {
network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s));
- if self.app.current_page == Page::Radios {
+ if self.app.current_page == Page::Network {
+ self.needs_rebuild = true;
+ }
+ }
+ while let Ok(s) = self.rx_bluetooth.try_recv() {
+ pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s));
+ if self.app.current_page == Page::Bluetooth {
self.needs_rebuild = true;
}
}
@@ -569,7 +577,8 @@ impl SystemInterface {
match action {
AppAction::Exit => {}
AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone()),
- AppAction::Radios(m) => network::update(&mut self.app.network, m.clone()),
+ AppAction::Network(m) => network::update(&mut self.app.network, m.clone()),
+ AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone()),
AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index 91b492d..4985cd5 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -210,14 +210,10 @@ async fn fetch_sources(connected_ports: &[String]) -> Vec<AudioSource> {
#[allow(dead_code)]
const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
-const MUTED_BG: [f32; 4] = [0.33, 0.20, 0.20, 1.0];
-const BTN_INACTIVE: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
-const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
#[allow(dead_code)]
const BLANK_BAR: [f32; 4] = [0.15, 0.15, 0.24, 1.0];
#[allow(dead_code)]
const FILL_BAR: [f32; 4] = [0.30, 0.50, 0.32, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
#[allow(dead_code)]
const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
new file mode 100644
index 0000000..2c47c86
--- /dev/null
+++ b/src/pages/bluetooth.rs
@@ -0,0 +1,323 @@
+use crate::app::{AppAction, PageContent, SectionContextExt};
+use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy};
+use cce_ui::widget::{Adapted, Toggle};
+
+#[derive(Debug, Clone)]
+pub struct BluetoothDevice {
+ pub mac: String,
+ pub name: String,
+ pub icon: String,
+ pub connected: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct BluetoothState {
+ pub loaded: bool,
+ pub installed: bool,
+ pub service_active: bool,
+ pub enabled: bool,
+ pub devices: Vec<BluetoothDevice>,
+ pub scanning: bool,
+ pub toggle: Adapted<Toggle>,
+}
+
+impl Default for BluetoothState {
+ fn default() -> Self {
+ Self {
+ loaded: false,
+ installed: false,
+ service_active: false,
+ enabled: false,
+ devices: Vec::new(),
+ scanning: false,
+ toggle: Toggle::new(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum BluetoothMessage {
+ Refreshed(BluetoothState),
+ Toggle,
+ Connect(String),
+ Disconnect(String),
+ Scan,
+ InstallTools,
+ StartService,
+}
+
+const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+const ACCENT: [f32; 4] = [0.35, 0.65, 0.90, 1.0];
+const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+const TOGGLE_ON: [f32; 4] = [0.13, 0.18, 0.14, 1.0];
+const TOGGLE_OFF: [f32; 4] = [0.15, 0.15, 0.20, 1.0];
+const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
+
+pub async fn fetch_bluetooth_page_state() -> BluetoothState {
+ let installed = tokio::process::Command::new("bluetoothctl")
+ .arg("--version")
+ .output()
+ .await
+ .is_ok();
+ if !installed {
+ return BluetoothState { loaded: true, ..Default::default() };
+ }
+
+ let service_active = tokio::process::Command::new("systemctl")
+ .args(["is-active", "bluetooth"])
+ .output()
+ .await
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
+ .unwrap_or(false);
+ if !service_active {
+ return BluetoothState { loaded: true, installed, ..Default::default() };
+ }
+
+ let enabled = tokio::process::Command::new("bluetoothctl")
+ .args(["show"]).output().await.ok()
+ .map(|o| String::from_utf8_lossy(&o.stdout).lines().any(|l| l.contains("Powered: yes")))
+ .unwrap_or(false);
+
+ let devices = if enabled { fetch_devices().await } else { Vec::new() };
+ BluetoothState { loaded: true, installed, service_active, enabled, devices, scanning: false, toggle: Toggle::new() }
+}
+
+async fn fetch_devices() -> Vec<BluetoothDevice> {
+ let out = match tokio::process::Command::new("bluetoothctl")
+ .args(["devices"]).output().await
+ {
+ Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
+ Err(_) => return Vec::new(),
+ };
+
+ let mut devices = Vec::new();
+ for line in out.lines() {
+ let rest = line.strip_prefix("Device ").unwrap_or("");
+ let parts: Vec<&str> = rest.splitn(2, ' ').collect();
+ if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() { continue; }
+ let mac = parts[0].to_string();
+ let default_name = parts[1].to_string();
+
+ let info = tokio::process::Command::new("bluetoothctl")
+ .args(["info", &mac]).output().await.ok()
+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
+ .unwrap_or_default();
+
+ let info_name = info.lines()
+ .find(|l| l.contains("Name:"))
+ .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
+
+ let info_alias = info.lines()
+ .find(|l| l.contains("Alias:"))
+ .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
+
+ let name = info_name.or(info_alias).unwrap_or(default_name);
+
+ let connected = info.lines().any(|l| l.contains("Connected: yes"));
+ let icon = info.lines()
+ .find(|l| l.contains("Icon:"))
+ .and_then(|l| l.split(':').nth(1).map(|s| s.trim().to_string()))
+ .unwrap_or_else(|| "audio-card".into());
+
+ devices.push(BluetoothDevice { mac, name, icon, connected });
+ }
+ devices
+}
+
+fn bt_toggle(enable: bool) {
+ let _ = tokio::process::Command::new("bluetoothctl")
+ .args(["power", if enable { "on" } else { "off" }])
+ .spawn();
+}
+
+fn bt_connect(mac: &str) {
+ let _ = tokio::process::Command::new("bluetoothctl")
+ .args(["connect", mac])
+ .spawn();
+}
+
+fn bt_disconnect(mac: &str) {
+ let _ = tokio::process::Command::new("bluetoothctl")
+ .args(["disconnect", mac])
+ .spawn();
+}
+
+fn bt_scan() {
+ let _ = tokio::process::Command::new("bluetoothctl")
+ .args(["scan", "on"])
+ .spawn();
+ let _ = tokio::process::Command::new("sh")
+ .args(["-c", "sleep 5 && bluetoothctl scan off"])
+ .spawn();
+}
+
+pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
+ let mut final_pc = PageContent::new();
+ let sec_w = 320.0f32;
+ let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
+
+ builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+ let bt_sec_w = sec.cw;
+ let padding = sec.padding();
+ let row_gap = cce_ui::layout::label_margin();
+ let margin = padding.max(12.0);
+ let font_size = 12.0;
+ let btn_h = 28.0;
+
+ if !state.loaded {
+ sec.text("Loading Bluetooth status...", margin, 0.0, font_size, TEXT_DIM);
+ } else if !state.installed {
+ sec.text("Bluetooth tools (bluez) not installed", margin, 0.0, font_size, TEXT_DIM);
+ let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
+ let yt = sec.ay();
+ sec.button("Install Tools", sec.ax(margin), yt, btn_w, btn_h,
+ TOGGLE_ON, BTN_HOVER, WHITE,
+ AppAction::Bluetooth(BluetoothMessage::InstallTools));
+ sec.content_y = yt + btn_h + row_gap;
+ } else if !state.service_active {
+ sec.text("Bluetooth service is stopped", margin, 0.0, font_size, TEXT_DIM);
+ let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
+ let yt = sec.ay();
+ sec.button("Start Service", sec.ax(margin), yt, btn_w, btn_h,
+ TOGGLE_ON, BTN_HOVER, WHITE,
+ AppAction::Bluetooth(BluetoothMessage::StartService));
+ sec.content_y = yt + btn_h + row_gap;
+ } else {
+ let yt = sec.ay();
+ let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
+ let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
+ let scan_btn_x = margin + bt_btn_w + row_gap;
+
+ state.toggle.set_toggled(state.enabled);
+ state.toggle.set_label(if state.enabled { "ON" } else { "OFF" });
+ // Hand-placed: sec.widget grid-places at full column width, which
+ // would sit the toggle under the Scan button.
+ let tx = sec.ax(margin);
+ render_widget(sec.pc, &mut state.toggle, tx, yt, bt_btn_w, btn_h, ctx);
+ sec.button("Scan", sec.ax(scan_btn_x), yt, scan_btn_w, btn_h,
+ TOGGLE_OFF, BTN_HOVER, WHITE,
+ AppAction::Bluetooth(BluetoothMessage::Scan));
+ sec.content_y = yt + btn_h + row_gap;
+
+ if state.devices.is_empty() {
+ if state.enabled {
+ let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
+ sec.text(no_devices_msg, margin, 0.0, font_size, TEXT_DIM);
+ }
+ } else {
+ let item_h = 22.0;
+ for dev in &state.devices {
+ let status = if dev.connected { ">" } else { " " };
+ let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
+ let action_label = if dev.connected {
+ if bt_sec_w < 250.0 { "Disc" } else { "Disconnect" }
+ } else {
+ if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
+ };
+
+ let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap).max(20.0);
+ let label_max_chars = ((label_max_w / 6.0) as usize).max(5);
+
+ let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
+ let label = if is_unknown {
+ if bt_sec_w < 350.0 {
+ format!("{} {}", status, dev.mac)
+ } else {
+ format!("{} Unknown Device ({})", status, dev.mac)
+ }
+ } else {
+ if bt_sec_w < 350.0 {
+ let name_truncated = if dev.name.len() > label_max_chars {
+ format!("{}...", &dev.name[..label_max_chars.saturating_sub(3)])
+ } else {
+ dev.name.clone()
+ };
+ format!("{} {}", status, name_truncated)
+ } else {
+ let full_label = format!("{} {} ({})", status, dev.name, dev.mac);
+ if full_label.len() > label_max_chars {
+ format!("{}...", &full_label[..label_max_chars.saturating_sub(3)])
+ } else {
+ full_label
+ }
+ }
+ };
+
+ let yt = sec.ay();
+ let btn_x = margin;
+ let text_x = margin + btn_w + row_gap;
+ let text_y_offset = (item_h - font_size) / 2.0;
+ sec.button(action_label, sec.ax(btn_x), yt, btn_w, item_h,
+ if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
+ if dev.connected {
+ AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone()))
+ } else {
+ AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone()))
+ });
+ sec.text(&label, text_x, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
+ sec.content_y = yt + item_h + row_gap;
+ }
+ }
+ }
+ });
+
+ final_pc
+}
+
+pub fn update(state: &mut BluetoothState, msg: BluetoothMessage) {
+ match msg {
+ BluetoothMessage::Refreshed(new) => {
+ state.loaded = new.loaded;
+ state.installed = new.installed;
+ state.service_active = new.service_active;
+ state.enabled = new.enabled;
+ state.devices = new.devices;
+ state.scanning = new.scanning;
+ }
+ BluetoothMessage::Toggle => {
+ state.enabled = !state.enabled;
+ bt_toggle(state.enabled);
+ }
+ BluetoothMessage::Connect(mac) => { bt_connect(&mac); }
+ BluetoothMessage::Disconnect(mac) => { bt_disconnect(&mac); }
+ BluetoothMessage::Scan => { bt_scan(); }
+ BluetoothMessage::InstallTools => {
+ let _ = tokio::process::Command::new("pkexec")
+ .args(["sh", "-c", "pacman -S --noconfirm bluez bluez-utils && systemctl enable --now bluetooth"])
+ .spawn();
+ }
+ BluetoothMessage::StartService => {
+ let _ = tokio::process::Command::new("pkexec")
+ .args(["systemctl", "enable", "--now", "bluetooth"])
+ .spawn();
+ }
+ }
+}
+
+impl crate::pages::AppPage for BluetoothState {
+ // Sections: [Bluetooth]
+ fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+ vec![vec![self.toggle.id()]]
+ }
+
+ fn view(
+ &mut self,
+ cx: f32,
+ cy: f32,
+ cw: f32,
+ ch: f32,
+ _root_focused: bool,
+ sec_focused: &[bool],
+ layout: &mut dyn LayoutStrategy,
+ ctx: &mut cce_ui::context::UiContext,
+ ) -> crate::app::PageContent {
+ view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
+ }
+
+ fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
+ if self.toggle.take_change() {
+ actions.push(crate::app::AppAction::Bluetooth(BluetoothMessage::Toggle));
+ }
+ }
+}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index e3edcc8..a61095f 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,4 +1,5 @@
pub mod audio;
+pub mod bluetooth;
pub mod default_apps;
pub mod network;
pub mod storage;
@@ -14,9 +15,10 @@ pub mod notifications;
pub enum Page {
Accounts,
Audio,
+ Bluetooth,
DefaultApps,
+ Network,
Notifications,
- Radios,
Storage,
System,
Processes,
@@ -26,15 +28,16 @@ pub enum Page {
}
impl Page {
- pub const ALL: [Page; 11] = [
+ pub const ALL: [Page; 12] = [
Page::Accounts,
Page::Audio,
+ Page::Bluetooth,
Page::DefaultApps,
Page::Fonts,
+ Page::Network,
Page::Notifications,
Page::Packages,
Page::Processes,
- Page::Radios,
Page::Services,
Page::Storage,
Page::System,
@@ -44,9 +47,10 @@ impl Page {
match self {
Page::Accounts => "Accounts",
Page::Audio => "Audio",
+ Page::Bluetooth => "Bluetooth",
Page::DefaultApps => "Default Apps",
+ Page::Network => "Network",
Page::Notifications => "Notifications",
- Page::Radios => "Radios",
Page::Storage => "Storage",
Page::System => "System",
Page::Processes => "Processes",
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 89ff7b1..94db000 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -1,4 +1,4 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
use crate::scroll_region::ScrollRegion;
use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
use cce_ui::widget::{Adapted, Toggle};
@@ -11,14 +11,6 @@ pub struct WifiNetwork {
pub in_use: bool,
}
-#[derive(Debug, Clone)]
-pub struct BluetoothDevice {
- pub mac: String,
- pub name: String,
- pub icon: String,
- pub connected: bool,
-}
-
#[derive(Debug, Clone)]
pub struct NetworkState {
pub loaded: bool,
@@ -28,14 +20,8 @@ pub struct NetworkState {
pub ip_address: String,
pub device: String,
pub available: Vec<WifiNetwork>,
- pub bt_installed: bool,
- pub bt_service_active: bool,
- pub bt_enabled: bool,
- pub bt_devices: Vec<BluetoothDevice>,
- pub bt_scanning: bool,
pub wifi_list: ScrollRegion,
pub wifi_toggle: Adapted<Toggle>,
- pub bt_toggle: Adapted<Toggle>,
}
impl Default for NetworkState {
@@ -48,14 +34,8 @@ impl Default for NetworkState {
ip_address: String::new(),
device: String::new(),
available: Vec::new(),
- bt_installed: false,
- bt_service_active: false,
- bt_enabled: false,
- bt_devices: Vec::new(),
- bt_scanning: false,
wifi_list: ScrollRegion::new(26.0, 4.0),
wifi_toggle: Toggle::new(),
- bt_toggle: Toggle::new(),
}
}
}
@@ -65,12 +45,6 @@ pub enum NetworkMessage {
Refreshed(NetworkState),
ToggleWifi,
ConnectWifi(String),
- ToggleBluetooth,
- BtConnect(String),
- BtDisconnect(String),
- BtScan,
- InstallBtTools,
- StartBtService,
}
pub async fn fetch_network_state() -> NetworkState {
@@ -119,17 +93,13 @@ pub async fn fetch_network_state() -> NetworkState {
}).unwrap_or_default();
let available = if wifi_enabled { fetch_wifi_list().await } else { Vec::new() };
- let (bt_installed, bt_service_active, bt_enabled, bt_devices) = fetch_bluetooth_state().await;
NetworkState {
loaded: true,
wifi_enabled, connected_ssid, signal_strength: signal,
ip_address, device, available,
- bt_installed, bt_service_active,
- bt_enabled, bt_devices, bt_scanning: false,
wifi_list: ScrollRegion::new(26.0, 4.0),
wifi_toggle: Toggle::new(),
- bt_toggle: Toggle::new(),
}
}
@@ -161,79 +131,6 @@ async fn fetch_wifi_list() -> Vec<WifiNetwork> {
networks
}
-async fn fetch_bluetooth_state() -> (bool, bool, bool, Vec<BluetoothDevice>) {
- let bt_installed = tokio::process::Command::new("bluetoothctl")
- .arg("--version")
- .output()
- .await
- .is_ok();
-
- if !bt_installed {
- return (false, false, false, Vec::new());
- }
-
- let bt_service_active = tokio::process::Command::new("systemctl")
- .args(["is-active", "bluetooth"])
- .output()
- .await
- .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
- .unwrap_or(false);
-
- if !bt_service_active {
- return (true, false, false, Vec::new());
- }
-
- let bt_enabled = tokio::process::Command::new("bluetoothctl")
- .args(["show"]).output().await.ok()
- .map(|o| String::from_utf8_lossy(&o.stdout).lines().any(|l| l.contains("Powered: yes")))
- .unwrap_or(false);
-
- let devices = if bt_enabled { fetch_bt_devices().await } else { Vec::new() };
- (true, true, bt_enabled, devices)
-}
-
-async fn fetch_bt_devices() -> Vec<BluetoothDevice> {
- let out = match tokio::process::Command::new("bluetoothctl")
- .args(["devices"]).output().await
- {
- Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
- Err(_) => return Vec::new(),
- };
-
- let mut devices = Vec::new();
- for line in out.lines() {
- let rest = line.strip_prefix("Device ").unwrap_or("");
- let parts: Vec<&str> = rest.splitn(2, ' ').collect();
- if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() { continue; }
- let mac = parts[0].to_string();
- let default_name = parts[1].to_string();
-
- let info = tokio::process::Command::new("bluetoothctl")
- .args(["info", &mac]).output().await.ok()
- .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
- .unwrap_or_default();
-
- let info_name = info.lines()
- .find(|l| l.contains("Name:"))
- .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
-
- let info_alias = info.lines()
- .find(|l| l.contains("Alias:"))
- .and_then(|l| l.splitn(2, ':').nth(1).map(|s| s.trim().to_string()));
-
- let name = info_name.or(info_alias).unwrap_or(default_name);
-
- let connected = info.lines().any(|l| l.contains("Connected: yes"));
- let icon = info.lines()
- .find(|l| l.contains("Icon:"))
- .and_then(|l| l.split(':').nth(1).map(|s| s.trim().to_string()))
- .unwrap_or_else(|| "audio-card".into());
-
- devices.push(BluetoothDevice { mac, name, icon, connected });
- }
- devices
-}
-
fn wifi_connect(ssid: &str) {
let _ = tokio::process::Command::new("nmcli")
.args(["dev", "wifi", "connect", ssid]).spawn();
@@ -244,45 +141,20 @@ fn wifi_toggle(enable: bool) {
.args(["radio", "wifi", if enable { "on" } else { "off" }]).spawn();
}
-fn bt_toggle(enable: bool) {
- let _ = tokio::process::Command::new("bluetoothctl")
- .args([if enable { "power" } else { "power" }, if enable { "on" } else { "off" }]).spawn();
-}
-
-fn bt_connect(mac: &str) {
- let _ = tokio::process::Command::new("bluetoothctl")
- .args(["connect", mac]).spawn();
-}
-
-fn bt_disconnect(mac: &str) {
- let _ = tokio::process::Command::new("bluetoothctl")
- .args(["disconnect", mac]).spawn();
-}
-
-fn bt_scan() {
- let _ = tokio::process::Command::new("bluetoothctl")
- .args(["scan", "on"]).spawn();
- let _ = tokio::process::Command::new("sh")
- .args(["-c", "sleep 5 && bluetoothctl scan off"]).spawn();
-}
-
const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
-const TOGGLE_ON: [f32; 4] = [0.16, 0.41, 0.18, 1.0];
-const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
const NET_BTN: [f32; 4] = [0.13, 0.20, 0.27, 1.0];
const ACT_BTN: [f32; 4] = [0.16, 0.29, 0.18, 1.0];
-const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
let mut final_pc = PageContent::new();
let sec_w = 320.0f32;
- let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(2);
+ let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
- // ── WiFi ──
- builder.add_section(&mut final_pc, "WiFi", root_focused, |sec| {
+ // ── WiFi (label-less well) ──
+ builder.add_section_spanned(&mut final_pc, "", 1, root_focused, |sec| {
let sec_w = sec.cw;
let rx = sec.left;
let padding = sec.padding();
@@ -293,42 +165,37 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
sec.text("Loading WiFi interfaces...", margin, 0.0, 12.0, TEXT_DIM);
} else {
let wifi_btn_w = if sec_w < 200.0 { 40.0 } else { 60.0 };
- let wifi_btn_x = margin;
state.wifi_toggle.set_toggled(state.wifi_enabled);
state.wifi_toggle.set_label(if state.wifi_enabled { "ON" } else { "OFF" });
- sec.widget(&mut state.wifi_toggle, wifi_btn_x, wifi_btn_w, 28.0, ctx);
+ // Hand-placed at its real width — sec.widget grid-places at full
+ // column width (the old wide "ON" plate).
+ let yt = sec.ay();
+ let tx = sec.ax(margin);
+ cce_ui::layout::render_widget(sec.pc, &mut state.wifi_toggle, tx, yt, wifi_btn_w, 28.0, ctx);
+ sec.content_y = yt + 28.0 + row_gap;
if state.wifi_enabled {
- let status_y = sec.ay();
- let font_size_1 = 13.0;
- let font_size_2 = 12.0;
- let status_area_h;
-
+ // Flowing text rows — sec.text advances content_y itself.
if !state.connected_ssid.is_empty() {
- let y1 = 0.0;
- let y2 = font_size_1 + row_gap;
- status_area_h = y2 + font_size_2 + row_gap;
-
let ssid_max_chars = ((sec_w - 2.0 * margin) / 7.0) as usize;
let ssid_truncated = if state.connected_ssid.len() > ssid_max_chars {
format!("{}...", &state.connected_ssid[..ssid_max_chars.saturating_sub(3)])
} else {
state.connected_ssid.clone()
};
- sec.text(&format!("Connected: {}", ssid_truncated), margin, y1, font_size_1, ACCENT);
+ sec.text(&format!("Connected: {}", ssid_truncated), margin, 0.0, 13.0, ACCENT);
if sec_w < 220.0 {
- sec.text(&format!("Signal: {}%", state.signal_strength), margin, y2, font_size_2, TEXT_DIM);
+ sec.text(&format!("Signal: {}%", state.signal_strength), margin, 0.0, 12.0, TEXT_DIM);
} else {
sec.text(&format!("Signal: {}% IP: {}", state.signal_strength, state.ip_address),
- margin, y2, font_size_2, TEXT_DIM);
+ margin, 0.0, 12.0, TEXT_DIM);
}
} else {
- status_area_h = font_size_2 + row_gap;
- sec.text("Not connected", margin, 0.0, font_size_2, TEXT_DIM);
+ sec.text("Not connected", margin, 0.0, 12.0, TEXT_DIM);
}
- sec.content_y = status_y + status_area_h;
+ sec.content_y += row_gap;
}
if state.wifi_enabled && !state.available.is_empty() {
@@ -359,7 +226,7 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
sec.pc.button(&label, list_box_x + margin, draw_y, btn_w, 26.0,
if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
if active { ACCENT } else { TEXT_FG },
- AppAction::Radios(NetworkMessage::ConnectWifi(net.ssid.clone())));
+ AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
}
}
sec.pc.pop_clip_rect();
@@ -368,110 +235,6 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
}
});
- // ── Bluetooth ──
- builder.add_section(&mut final_pc, "Bluetooth", false, |sec| {
- let bt_sec_w = sec.cw;
- let padding = sec.padding();
- let row_gap = cce_ui::layout::label_margin();
- let margin = padding.max(12.0);
- let font_size = 12.0;
- let btn_h = 28.0;
-
- if !state.loaded {
- sec.text("Loading Bluetooth status...", margin, 0.0, font_size, TEXT_DIM);
- } else if !state.bt_installed {
- sec.text("Bluetooth tools (bluez) not installed", margin, 0.0, font_size, TEXT_DIM);
- let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
- let yt = sec.ay();
- sec.button("Install Tools", sec.ax(margin), yt, btn_w, btn_h,
- TOGGLE_ON, BTN_HOVER, WHITE,
- AppAction::Radios(NetworkMessage::InstallBtTools));
- sec.content_y = yt + btn_h + row_gap;
- } else if !state.bt_service_active {
- sec.text("Bluetooth service is stopped", margin, 0.0, font_size, TEXT_DIM);
- let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
- let yt = sec.ay();
- sec.button("Start Service", sec.ax(margin), yt, btn_w, btn_h,
- TOGGLE_ON, BTN_HOVER, WHITE,
- AppAction::Radios(NetworkMessage::StartBtService));
- sec.content_y = yt + btn_h + row_gap;
- } else {
- let yt = sec.ay();
- let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
- let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
- let bt_btn_x = margin;
- let scan_btn_x = margin + bt_btn_w + row_gap;
-
- state.bt_toggle.set_toggled(state.bt_enabled);
- state.bt_toggle.set_label(if state.bt_enabled { "ON" } else { "OFF" });
- sec.widget(&mut state.bt_toggle, bt_btn_x, bt_btn_w, btn_h, ctx);
- sec.button("Scan", sec.ax(scan_btn_x), yt, scan_btn_w, btn_h,
- TOGGLE_OFF, BTN_HOVER, WHITE,
- AppAction::Radios(NetworkMessage::BtScan));
- sec.content_y = yt + btn_h + row_gap;
-
- if state.bt_devices.is_empty() {
- if state.bt_enabled {
- let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
- sec.text(no_devices_msg, margin, 0.0, font_size, TEXT_DIM);
- }
- } else {
- let item_h = 22.0;
- for dev in &state.bt_devices {
- let status = if dev.connected { ">" } else { " " };
- let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
- let action_label = if dev.connected {
- if bt_sec_w < 250.0 { "Disc" } else { "Disconnect" }
- } else {
- if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
- };
-
- let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap).max(20.0);
- let label_max_chars = ((label_max_w / 6.0) as usize).max(5);
-
- let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
- let label = if is_unknown {
- if bt_sec_w < 350.0 {
- format!("{} {}", status, dev.mac)
- } else {
- format!("{} Unknown Device ({})", status, dev.mac)
- }
- } else {
- if bt_sec_w < 350.0 {
- let name_truncated = if dev.name.len() > label_max_chars {
- format!("{}...", &dev.name[..label_max_chars.saturating_sub(3)])
- } else {
- dev.name.clone()
- };
- format!("{} {}", status, name_truncated)
- } else {
- let full_label = format!("{} {} ({})", status, dev.name, dev.mac);
- if full_label.len() > label_max_chars {
- format!("{}...", &full_label[..label_max_chars.saturating_sub(3)])
- } else {
- full_label
- }
- }
- };
-
- let yt = sec.ay();
- let btn_x = margin;
- let text_x = margin + btn_w + row_gap;
- let text_y_offset = (item_h - font_size) / 2.0;
- sec.button(action_label, sec.ax(btn_x), yt, btn_w, item_h,
- if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
- if dev.connected {
- AppAction::Radios(NetworkMessage::BtDisconnect(dev.mac.clone()))
- } else {
- AppAction::Radios(NetworkMessage::BtConnect(dev.mac.clone()))
- });
- sec.text(&label, text_x, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
- sec.content_y = yt + item_h + row_gap;
- }
- }
- }
- });
-
final_pc
}
@@ -485,34 +248,12 @@ pub fn update(state: &mut NetworkState, msg: NetworkMessage) {
state.ip_address = new.ip_address;
state.device = new.device;
state.available = new.available;
- state.bt_installed = new.bt_installed;
- state.bt_service_active = new.bt_service_active;
- state.bt_enabled = new.bt_enabled;
- state.bt_devices = new.bt_devices;
- state.bt_scanning = new.bt_scanning;
}
NetworkMessage::ToggleWifi => {
state.wifi_enabled = !state.wifi_enabled;
wifi_toggle(state.wifi_enabled);
}
NetworkMessage::ConnectWifi(ssid) => { wifi_connect(&ssid); }
- NetworkMessage::ToggleBluetooth => {
- state.bt_enabled = !state.bt_enabled;
- bt_toggle(state.bt_enabled);
- }
- NetworkMessage::BtConnect(mac) => { bt_connect(&mac); }
- NetworkMessage::BtDisconnect(mac) => { bt_disconnect(&mac); }
- NetworkMessage::BtScan => { bt_scan(); }
- NetworkMessage::InstallBtTools => {
- let _ = tokio::process::Command::new("pkexec")
- .args(["sh", "-c", "pacman -S --noconfirm bluez bluez-utils && systemctl enable --now bluetooth"])
- .spawn();
- }
- NetworkMessage::StartBtService => {
- let _ = tokio::process::Command::new("pkexec")
- .args(["systemctl", "enable", "--now", "bluetooth"])
- .spawn();
- }
}
}
@@ -525,12 +266,9 @@ impl NetworkState {
}
impl crate::pages::AppPage for NetworkState {
- // Sections: [WiFi, Bluetooth]
+ // Sections: [WiFi]
fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
- vec![
- vec![self.wifi_toggle.id()],
- vec![self.bt_toggle.id()],
- ]
+ vec![vec![self.wifi_toggle.id()]]
}
fn view(
@@ -552,10 +290,7 @@ impl crate::pages::AppPage for NetworkState {
fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
if self.wifi_toggle.take_change() {
- actions.push(crate::app::AppAction::Radios(NetworkMessage::ToggleWifi));
- }
- if self.bt_toggle.take_change() {
- actions.push(crate::app::AppAction::Radios(NetworkMessage::ToggleBluetooth));
+ actions.push(crate::app::AppAction::Network(NetworkMessage::ToggleWifi));
}
}
diff --git a/src/watchers.rs b/src/watchers.rs
index 32b0638..20112ad 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,11 +1,12 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc::{channel, Receiver, Sender};
-use crate::pages::{Page, audio, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
+use crate::pages::{Page, audio, bluetooth, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
pub struct Watchers {
pub rx_audio: Receiver<audio::AudioState>,
pub rx_network: Receiver<network::NetworkState>,
+ pub rx_bluetooth: Receiver<bluetooth::BluetoothState>,
pub rx_processes: Receiver<processes::ProcessesState>,
pub rx_system: Receiver<system_info::SystemInfo>,
pub rx_storage: Receiver<storage::StorageState>,
@@ -59,7 +60,8 @@ pub fn spawn_all(
Receiver<packages::PackagesMessage>,
) {
let rx_audio = spawn_bg_active(current_page_shared.clone(), Page::Audio.index() as u8, 3, || audio::fetch_audio_state());
- let rx_network = spawn_bg_active(current_page_shared.clone(), Page::Radios.index() as u8, 5, || network::fetch_network_state());
+ let rx_network = spawn_bg_active(current_page_shared.clone(), Page::Network.index() as u8, 5, || network::fetch_network_state());
+ let rx_bluetooth = spawn_bg_active(current_page_shared.clone(), Page::Bluetooth.index() as u8, 5, || bluetooth::fetch_bluetooth_page_state());
let rx_system = spawn_bg_active(current_page_shared.clone(), Page::System.index() as u8, 5, || system_info::fetch_system_state());
let rx_processes = spawn_bg_active(current_page_shared.clone(), Page::Processes.index() as u8, 3, || processes::fetch_processes_state());
@@ -104,6 +106,7 @@ pub fn spawn_all(
Watchers {
rx_audio,
rx_network,
+ rx_bluetooth,
rx_processes,
rx_system,
rx_storage,