final polish

This commit is contained in:
ms2k 2026-03-31 22:46:58 +09:00
commit b23c89cc1f
6 changed files with 66 additions and 26 deletions

View file

@ -8,7 +8,7 @@ use std::collections::HashMap;
use crate::config::Config; use crate::config::Config;
use crate::countries::{get_countries, Country}; use crate::countries::{get_countries, Country};
use crate::flags; use crate::flags;
use crate::nordvpn::{self, CmdResult, Poller}; use crate::nordvpn::{self, CmdResult, Poller, StatusInfo};
use crate::ui::config_dialog::{ConfigDialog, DialogResult}; use crate::ui::config_dialog::{ConfigDialog, DialogResult};
use crate::ui::country_selector::CountrySelector; use crate::ui::country_selector::CountrySelector;
use crate::world_map::WorldMap; use crate::world_map::WorldMap;
@ -28,10 +28,12 @@ pub struct App {
config_dialog: Option<ConfigDialog>, config_dialog: Option<ConfigDialog>,
/// Last connect/disconnect result shown inline below the status bar. /// Last connect/disconnect result shown inline below the status bar.
notification: Option<String>, notification: Option<String>,
/// A connect key that is awaiting user confirmation before being dispatched.
pending_connect: Option<String>,
} }
impl App { impl App {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self { pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let config = Config::load(); let config = Config::load();
let countries = get_countries(&config.external_app); let countries = get_countries(&config.external_app);
let poller = Poller::new(config.external_app.clone(), config.refresh_rate); let poller = Poller::new(config.external_app.clone(), config.refresh_rate);
@ -53,6 +55,7 @@ impl App {
world_map: WorldMap::new(), world_map: WorldMap::new(),
config_dialog: None, config_dialog: None,
notification: None, notification: None,
pending_connect: None,
} }
} }
@ -64,6 +67,16 @@ impl App {
}); });
} }
/// If already connected, queue a confirmation prompt; otherwise connect immediately.
fn request_connect(&mut self, key: String, status: &StatusInfo) {
if status.is_connected() {
self.pending_connect = Some(key);
} else {
self.notification = None;
self.spawn_connect(key);
}
}
fn spawn_disconnect(&self) { fn spawn_disconnect(&self) {
let app = self.config.external_app.clone(); let app = self.config.external_app.clone();
let tx = self.cmd_tx.clone(); let tx = self.cmd_tx.clone();
@ -162,7 +175,7 @@ impl eframe::App for App {
} }
// ── Left panel — country selector ─────────────────────────────────── // ── Left panel — country selector ───────────────────────────────────
let panel_width = ctx.screen_rect().width() * 0.3; let panel_width = ctx.content_rect().width() * 0.3;
let connect_key = let connect_key =
egui::SidePanel::left("country_selector") egui::SidePanel::left("country_selector")
.exact_width(panel_width) .exact_width(panel_width)
@ -179,16 +192,52 @@ impl eframe::App for App {
.inner; .inner;
if let Some(key) = connect_key { if let Some(key) = connect_key {
self.notification = None; self.request_connect(key, &status);
self.spawn_connect(key);
} }
// ── Central panel — world map ──────────────────────────────────────── // ── Central panel — world map ────────────────────────────────────────
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
if let Some(key) = self.world_map.show(ui, &self.countries, status.country.as_deref()) { if let Some(key) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
self.notification = None; self.request_connect(key, &status);
self.spawn_connect(key);
} }
}); });
// ── Confirmation modal ───────────────────────────────────────────────
if let Some(key) = self.pending_connect.clone() {
let label = self
.countries
.iter()
.find(|c| c.nordvpn_key == key)
.map(|c| c.name.as_str())
.unwrap_or(key.as_str());
let mut confirmed = false;
let mut cancelled = false;
egui::Window::new("Confirm")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(format!("Switch server to {}?", label));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Connect").clicked() {
confirmed = true;
}
if ui.button("Cancel").clicked() {
cancelled = true;
}
});
});
if confirmed {
self.notification = None;
self.spawn_connect(key);
self.pending_connect = None;
} else if cancelled {
self.pending_connect = None;
}
}
} }
} }

View file

@ -1,6 +1,3 @@
// TODO: remove after phase 7 — dead_code is expected while modules are being built up
#![allow(dead_code)]
mod app; mod app;
mod config; mod config;
mod countries; mod countries;

View file

@ -11,12 +11,12 @@ use std::time::Duration;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct StatusInfo { pub struct StatusInfo {
pub status: String, pub status: String,
pub server: Option<String>, #[allow(dead_code)] pub server: Option<String>,
pub country: Option<String>, pub country: Option<String>,
pub ip: Option<String>, pub ip: Option<String>,
pub city: Option<String>, #[allow(dead_code)] pub city: Option<String>,
pub protocol: Option<String>, #[allow(dead_code)] pub protocol: Option<String>,
pub transfer: Option<String>, #[allow(dead_code)] pub transfer: Option<String>,
pub uptime: Option<String>, pub uptime: Option<String>,
} }
@ -29,7 +29,7 @@ impl StatusInfo {
/// Result sent back to the UI thread after a connect/disconnect command. /// Result sent back to the UI thread after a connect/disconnect command.
#[derive(Debug)] #[derive(Debug)]
pub enum CmdResult { pub enum CmdResult {
Success { cmd: String, output: String }, Success { #[allow(dead_code)] cmd: String, output: String },
Failure { cmd: String, message: String }, Failure { cmd: String, message: String },
} }

View file

@ -3,7 +3,6 @@ use eframe::egui;
pub trait MapProjection: Send + Sync { pub trait MapProjection: Send + Sync {
fn project(&self, lon: f64, lat: f64, rect: egui::Rect) -> egui::Pos2; fn project(&self, lon: f64, lat: f64, rect: egui::Rect) -> egui::Pos2;
fn unproject(&self, pos: egui::Pos2, rect: egui::Rect) -> (f64, f64); fn unproject(&self, pos: egui::Pos2, rect: egui::Rect) -> (f64, f64);
fn name(&self) -> &'static str;
} }
pub struct Equirectangular; pub struct Equirectangular;
@ -22,9 +21,6 @@ impl MapProjection for Equirectangular {
(lon, lat) (lon, lat)
} }
fn name(&self) -> &'static str {
"Equirectangular"
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -36,8 +36,6 @@ impl CountrySelector {
connected_country: Option<&str>, connected_country: Option<&str>,
flags: &HashMap<String, egui::TextureHandle>, flags: &HashMap<String, egui::TextureHandle>,
) -> Option<String> { ) -> Option<String> {
let mut clicked = None;
// ── Toggle + search bar ───────────────────────────────────────────── // ── Toggle + search bar ─────────────────────────────────────────────
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.selectable_value(&mut self.view_mode, ViewMode::List, "List"); ui.selectable_value(&mut self.view_mode, ViewMode::List, "List");
@ -71,11 +69,9 @@ impl CountrySelector {
// ── Virtual-scrolled content ──────────────────────────────────────── // ── Virtual-scrolled content ────────────────────────────────────────
match self.view_mode { match self.view_mode {
ViewMode::List => clicked = show_list(ui, &favs, &rest, connected_country, flags), ViewMode::List => show_list(ui, &favs, &rest, connected_country, flags),
ViewMode::Grid => clicked = show_grid(ui, &favs, &rest, connected_country, flags), ViewMode::Grid => show_grid(ui, &favs, &rest, connected_country, flags),
} }
clicked
} }
} }

View file

@ -147,7 +147,8 @@ impl WorldMap {
let fill = if is_hovered { brightened(base, 50) } else { base }; let fill = if is_hovered { brightened(base, 50) } else { base };
render_fill(&painter, shape, &*self.projection, virtual_rect, fill); render_fill(&painter, shape, &*self.projection, virtual_rect, fill);
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER); let stroke_w = (0.8 * self.zoom).min(2.0);
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER, stroke_w);
} }
// ── Tooltip ────────────────────────────────────────────────────────── // ── Tooltip ──────────────────────────────────────────────────────────
@ -193,6 +194,7 @@ fn render_border(
projection: &dyn MapProjection, projection: &dyn MapProjection,
rect: egui::Rect, rect: egui::Rect,
color: egui::Color32, color: egui::Color32,
stroke_width: f32,
) { ) {
if shape.outer_ring.len() < 2 { if shape.outer_ring.len() < 2 {
return; return;
@ -202,7 +204,7 @@ fn render_border(
.iter() .iter()
.map(|&[lon, lat]| projection.project(lon, lat, rect)) .map(|&[lon, lat]| projection.project(lon, lat, rect))
.collect(); .collect();
painter.add(egui::Shape::closed_line(pts, egui::Stroke::new(0.8, color))); painter.add(egui::Shape::closed_line(pts, egui::Stroke::new(stroke_width, color)));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------