use std::sync::mpsc::{self, Sender}; use std::time::Duration; use eframe::egui; use std::collections::HashMap; use crate::config::Config; use crate::countries::{get_countries, Country}; use crate::flags; use crate::nordvpn::{self, CmdResult, Poller}; use crate::ui::country_selector::CountrySelector; pub struct App { config: Config, countries: Vec, poller: Poller, cmd_tx: Sender, cmd_rx: mpsc::Receiver, /// Receives rasterized flag images from the background loader thread. flag_rx: mpsc::Receiver<(String, egui::ColorImage)>, /// Fully loaded flag textures, keyed by nordvpn_key. flag_textures: HashMap, selector: CountrySelector, /// Last connect/disconnect result shown inline below the status bar. notification: Option, } impl App { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let config = Config::load(); let countries = get_countries(&config.external_app); let poller = Poller::new(config.external_app.clone(), config.refresh_rate); let (cmd_tx, cmd_rx) = mpsc::channel(); // Kick off background flag rasterization. let (flag_tx, flag_rx) = mpsc::channel(); flags::spawn_loader(flags::flag_paths(&countries), flag_tx); Self { config, countries, poller, cmd_tx, cmd_rx, flag_rx, flag_textures: HashMap::new(), selector: CountrySelector::default(), notification: None, } } fn spawn_connect(&self, nordvpn_key: String) { let app = self.config.external_app.clone(); let tx = self.cmd_tx.clone(); std::thread::spawn(move || { let _ = tx.send(nordvpn::connect(&app, &nordvpn_key)); }); } fn spawn_disconnect(&self) { let app = self.config.external_app.clone(); let tx = self.cmd_tx.clone(); std::thread::spawn(move || { let _ = tx.send(nordvpn::disconnect(&app)); }); } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Upload any newly rasterized flag textures. while let Ok((key, image)) = self.flag_rx.try_recv() { let handle = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR); self.flag_textures.insert(key, handle); } // Drain completed command results. while let Ok(result) = self.cmd_rx.try_recv() { self.notification = Some(match result { CmdResult::Success { output, .. } => output.trim().to_string(), CmdResult::Failure { cmd, message } => { format!("Error running '{cmd}': {message}") } }); } // Repaint often enough to stay live without busy-looping. ctx.request_repaint_after(Duration::from_secs(1)); let status = self.poller.status.lock().unwrap().clone(); // ── Status panel ──────────────────────────────────────────────────── egui::TopBottomPanel::top("status_panel") .min_height(40.0) .show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { ui.strong(&status.status); if let Some(country) = &status.country { ui.separator(); ui.label(country); } if let Some(ip) = &status.ip { ui.separator(); ui.label(ip); } if let Some(uptime) = &status.uptime { ui.separator(); ui.weak(uptime); } }); if let Some(note) = &self.notification { ui.add_space(2.0); ui.weak(note); } ui.add_space(4.0); }); // ── Bottom bar ────────────────────────────────────────────────────── egui::TopBottomPanel::bottom("bottom_bar") .min_height(36.0) .show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { if ui.button("Disconnect").clicked() { self.notification = None; self.spawn_disconnect(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Configure").clicked() { // Phase 7 } }); }); }); // ── Left panel — country selector ─────────────────────────────────── let panel_width = ctx.screen_rect().width() * 0.3; let connect_key = egui::SidePanel::left("country_selector") .exact_width(panel_width) .resizable(false) .show(ctx, |ui| { self.selector.show( ui, &self.countries, &self.config.favorites, status.country.as_deref(), &self.flag_textures, ) }) .inner; if let Some(key) = connect_key { self.notification = None; self.spawn_connect(key); } // ── Central panel — world map placeholder ─────────────────────────── egui::CentralPanel::default().show(ctx, |ui| { ui.centered_and_justified(|ui| { ui.weak("World map — Phase 5"); }); }); } }