Skip to main content

nmrs_gui/ui/
header.rs

1use glib::clone;
2use gtk::STYLE_PROVIDER_PRIORITY_USER;
3use gtk::prelude::*;
4use gtk::{Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch, glib};
5use std::cell::Cell;
6use std::collections::HashSet;
7use std::rc::Rc;
8
9use nmrs::models;
10
11use crate::ui::networks;
12use crate::ui::networks::NetworksContext;
13use crate::ui::wired_devices;
14
15pub struct ThemeDef {
16    pub key: &'static str,
17    pub name: &'static str,
18    pub css: &'static str,
19}
20
21pub static THEMES: &[ThemeDef] = &[
22    ThemeDef {
23        key: "gruvbox",
24        name: "Gruvbox",
25        css: include_str!("../themes/gruvbox.css"),
26    },
27    ThemeDef {
28        key: "nord",
29        name: "Nord",
30        css: include_str!("../themes/nord.css"),
31    },
32    ThemeDef {
33        key: "dracula",
34        name: "Dracula",
35        css: include_str!("../themes/dracula.css"),
36    },
37    ThemeDef {
38        key: "catppuccin",
39        name: "Catppuccin",
40        css: include_str!("../themes/catppuccin.css"),
41    },
42    ThemeDef {
43        key: "tokyo",
44        name: "Tokyo Night",
45        css: include_str!("../themes/tokyo.css"),
46    },
47];
48
49pub fn build_header(
50    ctx: Rc<NetworksContext>,
51    list_container: &GtkBox,
52    is_scanning: Rc<Cell<bool>>,
53    window: &gtk::ApplicationWindow,
54) -> HeaderBar {
55    let header = HeaderBar::new();
56    header.set_show_title_buttons(false);
57
58    let list_container = list_container.clone();
59
60    let wifi_box = GtkBox::new(Orientation::Horizontal, 6);
61    let wifi_label = Label::new(Some("Wi-Fi"));
62    wifi_label.set_halign(gtk::Align::Start);
63    wifi_label.add_css_class("wifi-label");
64
65    let names: Vec<&str> = THEMES.iter().map(|t| t.name).collect();
66    let dropdown = gtk::DropDown::from_strings(&names);
67
68    if let Some(saved) = crate::theme_config::load_theme()
69        && let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str())
70    {
71        dropdown.set_selected(idx as u32);
72    }
73
74    dropdown.set_valign(gtk::Align::Center);
75    dropdown.add_css_class("dropdown");
76
77    let window_weak = window.downgrade();
78
79    dropdown.connect_selected_notify(move |dd| {
80        let idx = dd.selected() as usize;
81        if idx >= THEMES.len() {
82            return;
83        }
84
85        let theme = &THEMES[idx];
86
87        if let Some(window) = window_weak.upgrade() {
88            let provider = gtk::CssProvider::new();
89            provider.load_from_data(theme.css);
90
91            let display = gtk::prelude::RootExt::display(&window);
92
93            gtk::style_context_add_provider_for_display(
94                &display,
95                &provider,
96                STYLE_PROVIDER_PRIORITY_USER,
97            );
98
99            crate::theme_config::save_theme(theme.key);
100
101            // Re-register user CSS after the new theme so it keeps priority.
102            crate::style::load_user_css();
103        }
104    });
105
106    wifi_box.append(&wifi_label);
107    wifi_box.append(&dropdown);
108    header.pack_start(&wifi_box);
109
110    let refresh_btn = gtk::Button::from_icon_name("view-refresh-symbolic");
111    refresh_btn.add_css_class("refresh-btn");
112    refresh_btn.set_tooltip_text(Some("Refresh networks and devices"));
113    header.pack_end(&refresh_btn);
114    refresh_btn.connect_clicked(clone!(
115        #[weak]
116        list_container,
117        #[strong]
118        ctx,
119        #[strong]
120        is_scanning,
121        move |_| {
122            let ctx = ctx.clone();
123            let list_container = list_container.clone();
124            let is_scanning = is_scanning.clone();
125
126            glib::MainContext::default().spawn_local(async move {
127                refresh_networks(ctx, &list_container, &is_scanning).await;
128            });
129        }
130    ));
131
132    let theme_btn = gtk::Button::new();
133    theme_btn.add_css_class("theme-toggle-btn");
134    theme_btn.set_valign(gtk::Align::Center);
135    theme_btn.set_has_frame(false);
136
137    let is_light = window.has_css_class("light-theme");
138    let initial_icon = if is_light {
139        "weather-clear-night-symbolic"
140    } else {
141        "weather-clear-symbolic"
142    };
143    theme_btn.set_icon_name(initial_icon);
144
145    let window_weak = window.downgrade();
146    theme_btn.connect_clicked(move |btn| {
147        if let Some(window) = window_weak.upgrade() {
148            let is_light = window.has_css_class("light-theme");
149
150            if is_light {
151                window.remove_css_class("light-theme");
152                window.add_css_class("dark-theme");
153                btn.set_icon_name("weather-clear-symbolic");
154                crate::theme_config::save_theme("light");
155            } else {
156                window.remove_css_class("dark-theme");
157                window.add_css_class("light-theme");
158                btn.set_icon_name("weather-clear-night-symbolic");
159                crate::theme_config::save_theme("dark");
160            }
161        }
162    });
163
164    header.pack_end(&theme_btn);
165
166    let wifi_switch = Switch::new();
167    wifi_switch.set_valign(gtk::Align::Center);
168    header.pack_end(&wifi_switch);
169    wifi_switch.set_size_request(24, 24);
170
171    header.pack_end(&ctx.status);
172
173    {
174        let list_container = list_container.clone();
175        let wifi_switch = wifi_switch.clone();
176        let ctx = ctx.clone();
177        let is_scanning = is_scanning.clone();
178
179        glib::MainContext::default().spawn_local(async move {
180            ctx.stack.set_visible_child_name("loading");
181            clear_children(&list_container);
182
183            match ctx.nm.wifi_enabled().await {
184                Ok(enabled) => {
185                    wifi_switch.set_active(enabled);
186                    if enabled {
187                        refresh_networks(ctx, &list_container, &is_scanning).await;
188                    }
189                }
190                Err(err) => {
191                    ctx.status
192                        .set_text(&format!("Error fetching networks: {err}"));
193                }
194            }
195        })
196    };
197
198    {
199        let ctx = ctx.clone();
200
201        wifi_switch.connect_active_notify(move |sw| {
202            let ctx = ctx.clone();
203            let list_container = list_container.clone();
204            let sw = sw.clone();
205            let is_scanning = is_scanning.clone();
206
207            glib::MainContext::default().spawn_local(async move {
208                clear_children(&list_container);
209
210                if let Err(err) = ctx.nm.set_wifi_enabled(sw.is_active()).await {
211                    ctx.status.set_text(&format!("Error setting Wi-Fi: {err}"));
212                    return;
213                }
214
215                if sw.is_active() {
216                    if ctx.nm.wait_for_wifi_ready().await.is_ok() {
217                        refresh_networks(ctx, &list_container, &is_scanning).await;
218                    } else {
219                        ctx.status.set_text("Wi-Fi failed to initialize");
220                    }
221                }
222            });
223        });
224    }
225
226    header
227}
228
229pub async fn refresh_networks(
230    ctx: Rc<NetworksContext>,
231    list_container: &GtkBox,
232    is_scanning: &Rc<Cell<bool>>,
233) {
234    if is_scanning.get() {
235        ctx.status.set_text("Scan already in progress");
236        return;
237    }
238    is_scanning.set(true);
239
240    clear_children(list_container);
241    ctx.status.set_text("Scanning...");
242
243    // Fetch wired devices first
244    match ctx.nm.list_wired_devices().await {
245        Ok(wired_devices) => {
246            // eprintln!("Found {} wired devices total", wired_devices.len());
247
248            let available_devices: Vec<_> = wired_devices
249                .into_iter()
250                .filter(|dev| {
251                    let show = matches!(
252                        dev.state,
253                        models::DeviceState::Activated
254                            | models::DeviceState::Disconnected
255                            | models::DeviceState::Prepare
256                            | models::DeviceState::Config
257                    );
258                    /* eprintln!(
259                        "  - {} ({}): {} -> {}",
260                        dev.interface,
261                        dev.device_type,
262                        dev.state,
263                        if show { "SHOW" } else { "HIDE" }
264                    ); */
265                    show
266                })
267                .collect();
268
269            /* eprintln!(
270                "Showing {} available wired devices",
271                available_devices.len()
272            ); */
273
274            if !available_devices.is_empty() {
275                let wired_header = Label::new(Some("Wired"));
276                wired_header.add_css_class("section-header");
277                wired_header.add_css_class("wired-section-header");
278                wired_header.set_halign(Align::Start);
279                wired_header.set_margin_top(8);
280                wired_header.set_margin_bottom(4);
281                wired_header.set_margin_start(12);
282                list_container.append(&wired_header);
283
284                let wired_list = wired_devices::wired_devices_view(
285                    ctx.clone(),
286                    &available_devices,
287                    ctx.wired_details_page.clone(),
288                );
289                wired_list.add_css_class("wired-devices-list");
290                list_container.append(&wired_list);
291
292                let separator = gtk::Separator::new(Orientation::Horizontal);
293                separator.add_css_class("device-separator");
294                separator.set_margin_top(12);
295                separator.set_margin_bottom(12);
296                list_container.append(&separator);
297            }
298        }
299        Err(e) => {
300            eprintln!("Failed to list wired devices: {}", e);
301        }
302    }
303
304    let wireless_header = Label::new(Some("Wireless"));
305    wireless_header.add_css_class("section-header");
306    wireless_header.add_css_class("wireless-section-header");
307    wireless_header.set_halign(Align::Start);
308    wireless_header.set_margin_top(8);
309    wireless_header.set_margin_bottom(4);
310    wireless_header.set_margin_start(12);
311    list_container.append(&wireless_header);
312
313    if let Err(err) = ctx.nm.scan_networks().await {
314        ctx.status.set_text(&format!("Scan failed: {err}"));
315        is_scanning.set(false);
316        return;
317    }
318
319    let mut last_len = 0;
320    for _ in 0..5 {
321        let nets = ctx.nm.list_networks().await.unwrap_or_default();
322        if nets.len() == last_len && last_len > 0 {
323            break;
324        }
325        last_len = nets.len();
326        glib::timeout_future_seconds(1).await;
327    }
328
329    match ctx.nm.list_networks().await {
330        Ok(mut nets) => {
331            let current_conn = ctx.nm.current_connection_info().await;
332            let (current_ssid, current_band) = if let Some((ssid, freq)) = current_conn {
333                let ssid_str = ssid.clone();
334                let band: Option<String> = freq
335                    .and_then(crate::ui::freq_to_band)
336                    .map(|s| s.to_string());
337                (Some(ssid_str), band)
338            } else {
339                (None, None)
340            };
341
342            nets.sort_by(|a, b| b.strength.unwrap_or(0).cmp(&a.strength.unwrap_or(0)));
343
344            let mut seen_combinations = HashSet::new();
345            nets.retain(|net| {
346                let band = net.frequency.and_then(crate::ui::freq_to_band);
347                let key = (net.ssid.clone(), band);
348                seen_combinations.insert(key)
349            });
350
351            ctx.status.set_text("");
352
353            let list: ListBox = networks::networks_view(
354                ctx.clone(),
355                &nets,
356                current_ssid.as_deref(),
357                current_band.as_deref(),
358            );
359            list_container.append(&list);
360            ctx.stack.set_visible_child_name("networks");
361        }
362        Err(err) => ctx
363            .status
364            .set_text(&format!("Error fetching networks: {err}")),
365    }
366
367    is_scanning.set(false);
368}
369
370pub fn clear_children(container: &gtk::Box) {
371    let mut child = container.first_child();
372    while let Some(widget) = child {
373        child = widget.next_sibling();
374        container.remove(&widget);
375    }
376}
377
378/// Refresh the network list WITHOUT triggering a new scan.
379/// This is useful for live updates when the network list changes
380/// (e.g., wired device state changes, AP added/removed).
381pub async fn refresh_networks_no_scan(
382    ctx: Rc<NetworksContext>,
383    list_container: &GtkBox,
384    is_scanning: &Rc<Cell<bool>>,
385) {
386    if is_scanning.get() {
387        // Don't interfere with an ongoing scan or refresh
388        return;
389    }
390
391    // Set flag to prevent concurrent refreshes
392    is_scanning.set(true);
393
394    clear_children(list_container);
395
396    // Fetch wired devices first
397    if let Ok(wired_devices) = ctx.nm.list_wired_devices().await {
398        // eprintln!("Found {} wired devices total", wired_devices.len());
399
400        // Filter out unavailable devices to reduce clutter
401        let available_devices: Vec<_> = wired_devices
402            .into_iter()
403            .filter(|dev| {
404                let show = matches!(
405                    dev.state,
406                    models::DeviceState::Activated
407                        | models::DeviceState::Disconnected
408                        | models::DeviceState::Prepare
409                        | models::DeviceState::Config
410                        | models::DeviceState::Unmanaged
411                );
412                /* eprintln!(
413                    "  - {} ({}): {} -> {}",
414                    dev.interface,
415                    dev.device_type,
416                    dev.state,
417                    if show { "SHOW" } else { "HIDE" }
418                ); */
419                show
420            })
421            .collect();
422
423        /* eprintln!(
424            "Showing {} available wired devices",
425            available_devices.len()
426        );*/
427
428        if !available_devices.is_empty() {
429            let wired_header = Label::new(Some("Wired"));
430            wired_header.add_css_class("section-header");
431            wired_header.add_css_class("wired-section-header");
432            wired_header.set_halign(Align::Start);
433            wired_header.set_margin_top(8);
434            wired_header.set_margin_bottom(4);
435            wired_header.set_margin_start(12);
436            list_container.append(&wired_header);
437
438            let wired_list = wired_devices::wired_devices_view(
439                ctx.clone(),
440                &available_devices,
441                ctx.wired_details_page.clone(),
442            );
443            wired_list.add_css_class("wired-devices-list");
444            list_container.append(&wired_list);
445
446            let separator = gtk::Separator::new(Orientation::Horizontal);
447            separator.add_css_class("device-separator");
448            separator.set_margin_top(12);
449            separator.set_margin_bottom(12);
450            list_container.append(&separator);
451        }
452    }
453
454    let wireless_header = Label::new(Some("Wireless"));
455    wireless_header.add_css_class("section-header");
456    wireless_header.add_css_class("wireless-section-header");
457    wireless_header.set_halign(Align::Start);
458    wireless_header.set_margin_top(8);
459    wireless_header.set_margin_bottom(4);
460    wireless_header.set_margin_start(12);
461    list_container.append(&wireless_header);
462
463    match ctx.nm.list_networks().await {
464        Ok(mut nets) => {
465            let current_conn = ctx.nm.current_connection_info().await;
466            let (current_ssid, current_band) = if let Some((ssid, freq)) = current_conn {
467                let ssid_str = ssid.clone();
468                let band: Option<String> = freq
469                    .and_then(crate::ui::freq_to_band)
470                    .map(|s| s.to_string());
471                (Some(ssid_str), band)
472            } else {
473                (None, None)
474            };
475
476            nets.sort_by(|a, b| b.strength.unwrap_or(0).cmp(&a.strength.unwrap_or(0)));
477
478            let mut seen_combinations = HashSet::new();
479            nets.retain(|net| {
480                let band = net.frequency.and_then(crate::ui::freq_to_band);
481                let key = (net.ssid.clone(), band);
482                seen_combinations.insert(key)
483            });
484
485            let list: ListBox = networks::networks_view(
486                ctx.clone(),
487                &nets,
488                current_ssid.as_deref(),
489                current_band.as_deref(),
490            );
491            list_container.append(&list);
492            ctx.stack.set_visible_child_name("networks");
493        }
494        Err(err) => {
495            ctx.status
496                .set_text(&format!("Error fetching networks: {err}"));
497        }
498    }
499
500    // Release the lock
501    is_scanning.set(false);
502}