From 89d0cdf873c2ec5defa20bb828485c0bb412f3dc Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sat, 25 Apr 2026 10:10:37 -0400 Subject: [PATCH] feat(gui): integrate nmrs 3.x APIs and restructure header layout - Add airplane mode toggle using set_airplane_mode/airplane_mode_state - Add connectivity status (captive portal, limited, no internet) via connectivity_report - Annotate Wi-Fi network rows with "Saved" badge using list_saved_connections_brief - Enrich network detail page with per-AP data (interface, device state, last seen) from list_access_points - Move theme and appearance controls out of header into a new settings page behind a gear icon - Simplify header to: status, refresh, airplane, Wi-Fi switch, settings --- nmrs-gui/Cargo.toml | 2 +- nmrs-gui/src/ui/header.rs | 270 ++++++++++++++++++------------- nmrs-gui/src/ui/mod.rs | 8 +- nmrs-gui/src/ui/network_page.rs | 36 +++++ nmrs-gui/src/ui/networks.rs | 17 ++ nmrs-gui/src/ui/settings_page.rs | 154 ++++++++++++++++++ 6 files changed, 375 insertions(+), 112 deletions(-) create mode 100644 nmrs-gui/src/ui/settings_page.rs diff --git a/nmrs-gui/Cargo.toml b/nmrs-gui/Cargo.toml index b8da6b71..4881c95e 100644 --- a/nmrs-gui/Cargo.toml +++ b/nmrs-gui/Cargo.toml @@ -3,7 +3,7 @@ name = "nmrs-gui" version = "1.5.1" authors = ["Akrm Al-Hakimi "] edition.workspace = true -rust-version = "1.94.0" +rust-version = "1.90.0" description = "GTK4 GUI for managing NetworkManager connections" license.workspace = true repository.workspace = true diff --git a/nmrs-gui/src/ui/header.rs b/nmrs-gui/src/ui/header.rs index e830779e..963b0acd 100644 --- a/nmrs-gui/src/ui/header.rs +++ b/nmrs-gui/src/ui/header.rs @@ -1,11 +1,11 @@ use glib::clone; -use gtk::STYLE_PROVIDER_PRIORITY_USER; use gtk::prelude::*; use gtk::{Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch, glib}; use std::cell::Cell; use std::collections::HashSet; use std::rc::Rc; +use nmrs::ConnectivityState; use nmrs::models; use crate::ui::networks; @@ -50,65 +50,49 @@ pub fn build_header( ctx: Rc, list_container: &GtkBox, is_scanning: Rc>, - window: >k::ApplicationWindow, ) -> HeaderBar { let header = HeaderBar::new(); header.set_show_title_buttons(false); let list_container = list_container.clone(); - let wifi_box = GtkBox::new(Orientation::Horizontal, 6); - let wifi_label = Label::new(Some("Wi-Fi")); - wifi_label.set_halign(gtk::Align::Start); - wifi_label.add_css_class("wifi-label"); - - let names: Vec<&str> = THEMES.iter().map(|t| t.name).collect(); - let dropdown = gtk::DropDown::from_strings(&names); - - if let Some(saved) = crate::theme_config::load_theme() - && let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str()) + // Left side: status label + ctx.status.set_hexpand(true); + ctx.status.set_halign(Align::Start); + header.pack_start(&ctx.status); + + // Right side: settings gear + let settings_btn = gtk::Button::from_icon_name("emblem-system-symbolic"); + settings_btn.set_has_frame(false); + settings_btn.set_valign(Align::Center); + settings_btn.set_tooltip_text(Some("Settings")); + settings_btn.add_css_class("settings-btn"); { - dropdown.set_selected(idx as u32); + let stack = ctx.stack.clone(); + settings_btn.connect_clicked(move |_| { + stack.set_visible_child_name("settings"); + }); } + header.pack_end(&settings_btn); - dropdown.set_valign(gtk::Align::Center); - dropdown.add_css_class("dropdown"); - - let window_weak = window.downgrade(); - - dropdown.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if idx >= THEMES.len() { - return; - } - - let theme = &THEMES[idx]; - - if let Some(window) = window_weak.upgrade() { - let provider = gtk::CssProvider::new(); - provider.load_from_data(theme.css); - - let display = gtk::prelude::RootExt::display(&window); + // Right side: radio controls (airplane + wifi switch) + let airplane_btn = gtk::Button::new(); + airplane_btn.set_valign(Align::Center); + airplane_btn.set_has_frame(false); + airplane_btn.set_icon_name("airplane-mode-symbolic"); + airplane_btn.set_tooltip_text(Some("Toggle Airplane Mode")); + airplane_btn.add_css_class("airplane-btn"); + header.pack_end(&airplane_btn); - gtk::style_context_add_provider_for_display( - &display, - &provider, - STYLE_PROVIDER_PRIORITY_USER, - ); - - crate::theme_config::save_theme(theme.key); - - // Re-register user CSS after the new theme so it keeps priority. - crate::style::load_user_css(); - } - }); - - wifi_box.append(&wifi_label); - wifi_box.append(&dropdown); - header.pack_start(&wifi_box); + let wifi_switch = Switch::new(); + wifi_switch.set_valign(Align::Center); + wifi_switch.set_size_request(24, 24); + header.pack_end(&wifi_switch); + // Right side: refresh let refresh_btn = gtk::Button::from_icon_name("view-refresh-symbolic"); refresh_btn.add_css_class("refresh-btn"); + refresh_btn.set_has_frame(false); refresh_btn.set_tooltip_text(Some("Refresh networks and devices")); header.pack_end(&refresh_btn); refresh_btn.connect_clicked(clone!( @@ -129,50 +113,10 @@ pub fn build_header( } )); - let theme_btn = gtk::Button::new(); - theme_btn.add_css_class("theme-toggle-btn"); - theme_btn.set_valign(gtk::Align::Center); - theme_btn.set_has_frame(false); - - let is_light = window.has_css_class("light-theme"); - let initial_icon = if is_light { - "weather-clear-night-symbolic" - } else { - "weather-clear-symbolic" - }; - theme_btn.set_icon_name(initial_icon); - - let window_weak = window.downgrade(); - theme_btn.connect_clicked(move |btn| { - if let Some(window) = window_weak.upgrade() { - let is_light = window.has_css_class("light-theme"); - - if is_light { - window.remove_css_class("light-theme"); - window.add_css_class("dark-theme"); - btn.set_icon_name("weather-clear-symbolic"); - crate::theme_config::save_theme("light"); - } else { - window.remove_css_class("dark-theme"); - window.add_css_class("light-theme"); - btn.set_icon_name("weather-clear-night-symbolic"); - crate::theme_config::save_theme("dark"); - } - } - }); - - header.pack_end(&theme_btn); - - let wifi_switch = Switch::new(); - wifi_switch.set_valign(gtk::Align::Center); - header.pack_end(&wifi_switch); - wifi_switch.set_size_request(24, 24); - - header.pack_end(&ctx.status); - { let list_container = list_container.clone(); let wifi_switch = wifi_switch.clone(); + let airplane_btn = airplane_btn.clone(); let ctx = ctx.clone(); let is_scanning = is_scanning.clone(); @@ -180,6 +124,9 @@ pub fn build_header( ctx.stack.set_visible_child_name("loading"); clear_children(&list_container); + apply_airplane_icon(&airplane_btn, &ctx).await; + apply_connectivity_status(&ctx).await; + match ctx.nm.wifi_state().await.map(|s| s.enabled) { Ok(enabled) => { wifi_switch.set_active(enabled); @@ -195,6 +142,60 @@ pub fn build_header( }) }; + { + let ctx = ctx.clone(); + airplane_btn.connect_clicked(clone!( + #[weak] + list_container, + #[strong] + wifi_switch, + #[strong] + is_scanning, + move |btn| { + let ctx = ctx.clone(); + let list_container = list_container.clone(); + let is_scanning = is_scanning.clone(); + let wifi_switch = wifi_switch.clone(); + let btn = btn.clone(); + + glib::MainContext::default().spawn_local(async move { + let currently_airplane = ctx + .nm + .airplane_mode_state() + .await + .map(|s| s.is_airplane_mode()) + .unwrap_or(false); + + let new_state = !currently_airplane; + + if let Err(err) = ctx.nm.set_airplane_mode(new_state).await { + ctx.status.set_text(&format!("Airplane mode error: {err}")); + return; + } + + apply_airplane_icon(&btn, &ctx).await; + + if new_state { + wifi_switch.set_active(false); + clear_children(&list_container); + ctx.status.set_text("Airplane mode on"); + } else { + let wifi_on = ctx + .nm + .wifi_state() + .await + .map(|s| s.enabled) + .unwrap_or(false); + wifi_switch.set_active(wifi_on); + if wifi_on && ctx.nm.wait_for_wifi_ready().await.is_ok() { + refresh_networks(ctx, &list_container, &is_scanning).await; + } + } + }); + } + )); + } + { let ctx = ctx.clone(); @@ -226,6 +227,54 @@ pub fn build_header( header } +async fn apply_airplane_icon(btn: >k::Button, ctx: &NetworksContext) { + match ctx.nm.airplane_mode_state().await { + Ok(state) => { + if state.is_airplane_mode() { + btn.set_icon_name("airplane-mode-symbolic"); + btn.set_tooltip_text(Some("Airplane Mode is ON — click to disable")); + btn.add_css_class("airplane-active"); + } else { + btn.set_icon_name("network-wireless-symbolic"); + btn.set_tooltip_text(Some("Airplane Mode is OFF — click to enable")); + btn.remove_css_class("airplane-active"); + } + + if state.any_hardware_killed() { + btn.set_tooltip_text(Some("A hardware radio kill switch is active")); + } + } + Err(_) => { + btn.set_icon_name("airplane-mode-symbolic"); + btn.set_sensitive(false); + btn.set_tooltip_text(Some("Airplane mode unavailable")); + } + } +} + +async fn apply_connectivity_status(ctx: &NetworksContext) { + if let Ok(report) = ctx.nm.connectivity_report().await { + let text = connectivity_label(&report.state, report.captive_portal_url.as_deref()); + if !text.is_empty() { + ctx.status.set_text(&text); + } + } +} + +fn connectivity_label(state: &ConnectivityState, portal_url: Option<&str>) -> String { + match state { + ConnectivityState::Full => String::new(), + ConnectivityState::Portal => match portal_url { + Some(url) => format!("Captive portal: {url}"), + None => "Captive portal detected".to_string(), + }, + ConnectivityState::Limited => "Limited connectivity".to_string(), + ConnectivityState::None => "No internet".to_string(), + ConnectivityState::Unknown => String::new(), + _ => String::new(), + } +} + pub async fn refresh_networks( ctx: Rc, list_container: &GtkBox, @@ -326,6 +375,8 @@ pub async fn refresh_networks( glib::timeout_future_seconds(1).await; } + let saved_ssids = saved_network_ids(&ctx).await; + match ctx.nm.list_networks(None).await { Ok(mut nets) => { let current_conn = ctx.nm.current_connection_info().await; @@ -355,6 +406,7 @@ pub async fn refresh_networks( &nets, current_ssid.as_deref(), current_band.as_deref(), + &saved_ssids, ); list_container.append(&list); ctx.stack.set_visible_child_name("networks"); @@ -364,6 +416,8 @@ pub async fn refresh_networks( .set_text(&format!("Error fetching networks: {err}")), } + apply_connectivity_status(&ctx).await; + is_scanning.set(false); } @@ -375,6 +429,17 @@ pub fn clear_children(container: >k::Box) { } } +async fn saved_network_ids(ctx: &NetworksContext) -> HashSet { + ctx.nm + .list_saved_connections_brief() + .await + .unwrap_or_default() + .into_iter() + .filter(|c| c.connection_type == "802-11-wireless") + .map(|c| c.id) + .collect() +} + /// Refresh the network list WITHOUT triggering a new scan. /// This is useful for live updates when the network list changes /// (e.g., wired device state changes, AP added/removed). @@ -384,47 +449,28 @@ pub async fn refresh_networks_no_scan( is_scanning: &Rc>, ) { if is_scanning.get() { - // Don't interfere with an ongoing scan or refresh return; } - // Set flag to prevent concurrent refreshes is_scanning.set(true); clear_children(list_container); - // Fetch wired devices first if let Ok(wired_devices) = ctx.nm.list_wired_devices().await { - // eprintln!("Found {} wired devices total", wired_devices.len()); - - // Filter out unavailable devices to reduce clutter let available_devices: Vec<_> = wired_devices .into_iter() .filter(|dev| { - let show = matches!( + matches!( dev.state, models::DeviceState::Activated | models::DeviceState::Disconnected | models::DeviceState::Prepare | models::DeviceState::Config | models::DeviceState::Unmanaged - ); - /* eprintln!( - " - {} ({}): {} -> {}", - dev.interface, - dev.device_type, - dev.state, - if show { "SHOW" } else { "HIDE" } - ); */ - show + ) }) .collect(); - /* eprintln!( - "Showing {} available wired devices", - available_devices.len() - );*/ - if !available_devices.is_empty() { let wired_header = Label::new(Some("Wired")); wired_header.add_css_class("section-header"); @@ -460,6 +506,8 @@ pub async fn refresh_networks_no_scan( wireless_header.set_margin_start(12); list_container.append(&wireless_header); + let saved_ssids = saved_network_ids(&ctx).await; + match ctx.nm.list_networks(None).await { Ok(mut nets) => { let current_conn = ctx.nm.current_connection_info().await; @@ -487,6 +535,7 @@ pub async fn refresh_networks_no_scan( &nets, current_ssid.as_deref(), current_band.as_deref(), + &saved_ssids, ); list_container.append(&list); ctx.stack.set_visible_child_name("networks"); @@ -497,6 +546,7 @@ pub async fn refresh_networks_no_scan( } } - // Release the lock + apply_connectivity_status(&ctx).await; + is_scanning.set(false); } diff --git a/nmrs-gui/src/ui/mod.rs b/nmrs-gui/src/ui/mod.rs index f4f7384d..48c9ce95 100644 --- a/nmrs-gui/src/ui/mod.rs +++ b/nmrs-gui/src/ui/mod.rs @@ -2,6 +2,7 @@ pub mod connect; pub mod header; pub mod network_page; pub mod networks; +pub mod settings_page; pub mod wired_devices; pub mod wired_page; @@ -99,6 +100,12 @@ pub fn build_ui(app: &Application) { wired_details_scroller.set_child(Some(wired_details_page.widget())); stack_clone.add_named(&wired_details_scroller, Some("wired-details")); + let settings = settings_page::SettingsPage::new(&stack_clone, &win_clone); + let settings_scroller = ScrolledWindow::new(); + settings_scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + settings_scroller.set_child(Some(settings.widget())); + stack_clone.add_named(&settings_scroller, Some("settings")); + let on_success: Rc = { let list_container = list_container_clone.clone(); let is_scanning = is_scanning_clone.clone(); @@ -160,7 +167,6 @@ pub fn build_ui(app: &Application) { ctx.clone(), &list_container_clone, is_scanning_clone.clone(), - &win_clone, ); vbox_clone.prepend(&header); diff --git a/nmrs-gui/src/ui/network_page.rs b/nmrs-gui/src/ui/network_page.rs index e750c375..0dccb3f7 100644 --- a/nmrs-gui/src/ui/network_page.rs +++ b/nmrs-gui/src/ui/network_page.rs @@ -1,6 +1,7 @@ use glib::clone; use gtk::prelude::*; use gtk::{Align, Box, Button, Image, Label, Orientation}; +use nmrs::AccessPoint; use nmrs::NetworkManager; use nmrs::models::NetworkInfo; use std::cell::RefCell; @@ -23,6 +24,10 @@ pub struct NetworkPage { rate: gtk::Label, security: gtk::Label, + interface: gtk::Label, + device_state: gtk::Label, + last_seen: gtk::Label, + current_ssid: Rc>, on_success: OnSuccessCallback, } @@ -123,6 +128,9 @@ impl NetworkPage { let mode = Label::new(None); let rate = Label::new(None); let security = Label::new(None); + let interface = Label::new(None); + let device_state = Label::new(None); + let last_seen = Label::new(None); Self::add_row(&advanced_box, "BSSID", &bssid); Self::add_row(&advanced_box, "Frequency", &freq); @@ -130,6 +138,9 @@ impl NetworkPage { Self::add_row(&advanced_box, "Mode", &mode); Self::add_row(&advanced_box, "Speed", &rate); Self::add_row(&advanced_box, "Security", &security); + Self::add_row(&advanced_box, "Interface", &interface); + Self::add_row(&advanced_box, "Device State", &device_state); + Self::add_row(&advanced_box, "Last Seen", &last_seen); root.append(&advanced_box); @@ -146,6 +157,9 @@ impl NetworkPage { mode, rate, security, + interface, + device_state, + last_seen, current_ssid, on_success: on_success_callback, } @@ -199,6 +213,28 @@ impl NetworkPage { .unwrap_or_else(|| "-".into()), ); self.security.set_text(&info.security); + + self.interface.set_text("-"); + self.device_state.set_text("-"); + self.last_seen.set_text("-"); + } + + pub fn enrich_with_ap(&self, ap: &AccessPoint) { + self.bssid.set_text(&ap.bssid); + self.interface.set_text(&ap.interface); + self.device_state + .set_text(&format!("{:?}", ap.device_state)); + self.last_seen.set_text( + &ap.last_seen_secs + .map(|s| format!("{s}s ago")) + .unwrap_or_else(|| "-".into()), + ); + self.freq + .set_text(&format!("{:.1} GHz", ap.frequency_mhz as f32 / 1000.0)); + if ap.max_bitrate_kbps > 0 { + self.rate + .set_text(&format!("{:.1} Mbps", ap.max_bitrate_kbps as f32 / 1000.0)); + } } pub fn widget(&self) -> >k::Box { diff --git a/nmrs-gui/src/ui/networks.rs b/nmrs-gui/src/ui/networks.rs index 072e3f2a..7e29d9f6 100644 --- a/nmrs-gui/src/ui/networks.rs +++ b/nmrs-gui/src/ui/networks.rs @@ -5,6 +5,7 @@ use gtk::prelude::*; use gtk::{Box, Image, Label, ListBox, ListBoxRow, Orientation}; use nmrs::models::WifiSecurity; use nmrs::{NetworkManager, models}; +use std::collections::HashSet; use std::rc::Rc; use crate::ui::connect; @@ -90,6 +91,17 @@ impl NetworkRowController { glib::MainContext::default().spawn_local(async move { if let Ok(info) = ctx_c.nm.show_details(&net_c).await { page_c.update(&info); + + if let Ok(aps) = ctx_c.nm.list_access_points(None).await { + let best = aps + .iter() + .filter(|ap| ap.ssid == net_c.ssid) + .max_by_key(|ap| ap.strength); + if let Some(ap) = best { + page_c.enrich_with_ap(ap); + } + } + stack_c.set_visible_child_name("details"); } }); @@ -176,6 +188,7 @@ pub fn networks_view( networks: &[models::Network], current_ssid: Option<&str>, current_band: Option<&str>, + saved_ssids: &HashSet, ) -> ListBox { let conn_threshold = 75; let list = ListBox::new(); @@ -218,6 +231,10 @@ pub fn networks_view( let connected_label = Label::new(Some("Connected")); connected_label.add_css_class("connected-label"); hbox.append(&connected_label); + } else if saved_ssids.contains(&net.ssid) { + let saved_label = Label::new(Some("Saved")); + saved_label.add_css_class("saved-label"); + hbox.append(&saved_label); } let spacer = Box::new(Orientation::Horizontal, 0); diff --git a/nmrs-gui/src/ui/settings_page.rs b/nmrs-gui/src/ui/settings_page.rs new file mode 100644 index 00000000..979e0eb2 --- /dev/null +++ b/nmrs-gui/src/ui/settings_page.rs @@ -0,0 +1,154 @@ +use gtk::prelude::*; +use gtk::{Align, Box, Button, Label, Orientation, STYLE_PROVIDER_PRIORITY_USER}; + +use crate::ui::header::{THEMES, ThemeDef}; + +pub struct SettingsPage { + root: gtk::Box, +} + +impl SettingsPage { + pub fn new(stack: >k::Stack, window: >k::ApplicationWindow) -> Self { + let root = Box::new(Orientation::Vertical, 12); + root.add_css_class("settings-page"); + root.set_margin_top(12); + root.set_margin_bottom(12); + root.set_margin_start(16); + root.set_margin_end(16); + + let back = Button::with_label("← Back"); + back.add_css_class("back-button"); + back.set_halign(Align::Start); + back.set_cursor_from_name(Some("pointer")); + { + let stack = stack.clone(); + back.connect_clicked(move |_| { + stack.set_visible_child_name("networks"); + }); + } + root.append(&back); + + let title = Label::new(Some("Settings")); + title.add_css_class("section-header"); + title.set_halign(Align::Start); + root.append(&title); + + Self::build_theme_section(&root, window); + Self::build_light_dark_section(&root, window); + + Self { root } + } + + fn build_theme_section(root: >k::Box, window: >k::ApplicationWindow) { + let section = Box::new(Orientation::Vertical, 6); + + let label = Label::new(Some("Theme")); + label.add_css_class("info-label"); + label.set_halign(Align::Start); + section.append(&label); + + let names: Vec<&str> = THEMES.iter().map(|t| t.name).collect(); + let dropdown = gtk::DropDown::from_strings(&names); + dropdown.set_halign(Align::Start); + dropdown.set_hexpand(false); + + if let Some(saved) = crate::theme_config::load_theme() + && let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str()) + { + dropdown.set_selected(idx as u32); + } + + let window_weak = window.downgrade(); + dropdown.connect_selected_notify(move |dd| { + let idx = dd.selected() as usize; + if idx >= THEMES.len() { + return; + } + + let theme = &THEMES[idx]; + + if let Some(window) = window_weak.upgrade() { + Self::apply_theme(theme, &window); + } + }); + + section.append(&dropdown); + root.append(§ion); + } + + fn build_light_dark_section(root: >k::Box, window: >k::ApplicationWindow) { + let section = Box::new(Orientation::Vertical, 6); + + let label = Label::new(Some("Appearance")); + label.add_css_class("info-label"); + label.set_halign(Align::Start); + section.append(&label); + + let toggle_box = Box::new(Orientation::Horizontal, 8); + + let light_btn = Button::with_label("Light"); + light_btn.add_css_class("appearance-btn"); + + let dark_btn = Button::with_label("Dark"); + dark_btn.add_css_class("appearance-btn"); + + { + let window_weak = window.downgrade(); + let dark_btn_clone = dark_btn.clone(); + light_btn.connect_clicked(move |btn| { + if let Some(window) = window_weak.upgrade() { + window.remove_css_class("dark-theme"); + window.add_css_class("light-theme"); + btn.add_css_class("appearance-active"); + dark_btn_clone.remove_css_class("appearance-active"); + crate::theme_config::save_theme("dark"); + } + }); + } + + { + let window_weak = window.downgrade(); + let light_btn_clone = light_btn.clone(); + dark_btn.connect_clicked(move |btn| { + if let Some(window) = window_weak.upgrade() { + window.remove_css_class("light-theme"); + window.add_css_class("dark-theme"); + btn.add_css_class("appearance-active"); + light_btn_clone.remove_css_class("appearance-active"); + crate::theme_config::save_theme("light"); + } + }); + } + + if window.has_css_class("light-theme") { + light_btn.add_css_class("appearance-active"); + } else { + dark_btn.add_css_class("appearance-active"); + } + + toggle_box.append(&light_btn); + toggle_box.append(&dark_btn); + section.append(&toggle_box); + root.append(§ion); + } + + fn apply_theme(theme: &ThemeDef, window: >k::ApplicationWindow) { + let provider = gtk::CssProvider::new(); + provider.load_from_data(theme.css); + + let display = gtk::prelude::RootExt::display(window); + + gtk::style_context_add_provider_for_display( + &display, + &provider, + STYLE_PROVIDER_PRIORITY_USER, + ); + + crate::theme_config::save_theme(theme.key); + crate::style::load_user_css(); + } + + pub fn widget(&self) -> >k::Box { + &self.root + } +}