implement phase 1~3

This commit is contained in:
ms2k 2026-03-20 02:18:13 +09:00
commit c60f3b2ab0

148
src/app.rs Normal file
View file

@ -0,0 +1,148 @@
use std::sync::mpsc::{self, Sender};
use std::time::Duration;
use eframe::egui;
use crate::config::Config;
use crate::countries::{get_countries, Country};
use crate::nordvpn::{self, CmdResult, Poller};
use crate::ui::country_selector::CountrySelector;
pub struct App {
config: Config,
countries: Vec<Country>,
poller: Poller,
cmd_tx: Sender<CmdResult>,
cmd_rx: mpsc::Receiver<CmdResult>,
selector: CountrySelector,
/// Last connect/disconnect result shown inline below the status bar.
notification: Option<String>,
}
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();
Self {
config,
countries,
poller,
cmd_tx,
cmd_rx,
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) {
// 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(),
)
})
.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");
});
});
}
}