implement phase 5~7

This commit is contained in:
ms2k 2026-03-31 22:28:07 +09:00
commit 6140548774
10 changed files with 591 additions and 6 deletions

27
Cargo.lock generated
View file

@ -803,6 +803,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "earcutr"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afc28d89ac6cb489f679fcf51aaa62b4667bb0fb73a30f1d4d23949069707c07"
dependencies = [
"itertools",
"num-traits",
]
[[package]] [[package]]
name = "ecolor" name = "ecolor"
version = "0.33.3" version = "0.33.3"
@ -941,6 +951,12 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "emath" name = "emath"
version = "0.33.3" version = "0.33.3"
@ -1645,6 +1661,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@ -2001,11 +2026,13 @@ checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
name = "nordvpn-ui" name = "nordvpn-ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"earcutr",
"eframe", "eframe",
"egui_extras", "egui_extras",
"image", "image",
"resvg", "resvg",
"serde", "serde",
"serde_json",
"toml", "toml",
] ]

View file

@ -4,9 +4,11 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
earcutr = "0.5.0"
eframe = "0.33.3" eframe = "0.33.3"
egui_extras = { version = "0.33.3", features = ["file", "image", "svg"] } egui_extras = { version = "0.33.3", features = ["file", "image", "svg"] }
image = { version = "0.25.10", default-features = false, features = ["png"] } image = { version = "0.25.10", default-features = false, features = ["png"] }
resvg = "0.45" resvg = "0.45"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
toml = "1.0.7" toml = "1.0.7"

16
SPEC.md
View file

@ -264,7 +264,7 @@ gantt
| 6 | Map pan/zoom, hover highlight, click-to-connect, color states by availability | | 6 | Map pan/zoom, hover highlight, click-to-connect, color states by availability |
| 7 | `config_dialog.rs` modal, full settings flow, apply restarts poller | | 7 | `config_dialog.rs` modal, full settings flow, apply restarts poller |
| 8 | Unit tests (see below) | | 8 | Unit tests (see below) |
| 9 | Polish pass | | 9 | Polish pass: confirmation prompts, status formatting, edge cases, deprecation cleanup |
--- ---
@ -280,6 +280,20 @@ gantt
--- ---
## Phase 9 — Polish Details
- **Confirmation prompts on connect/switch**: connecting takes long enough to feel costly, so clicking
a country (list, grid, or map) when already connected should show a small modal or inline prompt
("Switch to X? [Connect] [Cancel]") before issuing the CLI call. Disconnected → connect can proceed
immediately.
- **Deprecated API cleanup**: replace `ctx.screen_rect()` with `content_rect()`, replace
`show_tooltip_at_pointer` with `egui::Tooltip`, remove `#![allow(dead_code)]`.
- **Border stroke scaling**: consider thinning borders at low zoom and thickening at high zoom.
- **Notification display**: show `self.notification` somewhere visible (inline banner or status bar).
- **Cursor polish**: grab/grabbing cursor icons on map pan.
---
## Future Enhancements (out of scope) ## Future Enhancements (out of scope)
- **Flags setup script**: download SVGs from https://github.com/emcrisostomo/flags (public domain, - **Flags setup script**: download SVGs from https://github.com/emcrisostomo/flags (public domain,

1
assets/world.geojson Normal file

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,9 @@ 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};
use crate::ui::config_dialog::{ConfigDialog, DialogResult};
use crate::ui::country_selector::CountrySelector; use crate::ui::country_selector::CountrySelector;
use crate::world_map::WorldMap;
pub struct App { pub struct App {
config: Config, config: Config,
@ -22,6 +24,8 @@ pub struct App {
/// Fully loaded flag textures, keyed by nordvpn_key. /// Fully loaded flag textures, keyed by nordvpn_key.
flag_textures: HashMap<String, egui::TextureHandle>, flag_textures: HashMap<String, egui::TextureHandle>,
selector: CountrySelector, selector: CountrySelector,
world_map: WorldMap,
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>,
} }
@ -46,6 +50,8 @@ impl App {
flag_rx, flag_rx,
flag_textures: HashMap::new(), flag_textures: HashMap::new(),
selector: CountrySelector::default(), selector: CountrySelector::default(),
world_map: WorldMap::new(),
config_dialog: None,
notification: None, notification: None,
} }
} }
@ -130,12 +136,31 @@ impl eframe::App for App {
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Configure").clicked() { if ui.button("Configure").clicked() {
// Phase 7 self.config_dialog = Some(ConfigDialog::new(&self.config));
} }
}); });
}); });
}); });
// ── Config dialog ────────────────────────────────────────────────────
if let Some(dialog) = &mut self.config_dialog {
match dialog.show(ctx, &self.countries) {
DialogResult::Apply(new_cfg) => {
self.poller.update_config(
new_cfg.external_app.clone(),
new_cfg.refresh_rate,
);
self.config = new_cfg;
if let Err(e) = self.config.save() {
self.notification = Some(format!("Failed to save config: {e}"));
}
self.config_dialog = None;
}
DialogResult::Cancel => self.config_dialog = None,
DialogResult::Open => {}
}
}
// ── Left panel — country selector ─────────────────────────────────── // ── Left panel — country selector ───────────────────────────────────
let panel_width = ctx.screen_rect().width() * 0.3; let panel_width = ctx.screen_rect().width() * 0.3;
let connect_key = let connect_key =
@ -158,11 +183,12 @@ impl eframe::App for App {
self.spawn_connect(key); self.spawn_connect(key);
} }
// ── Central panel — world map placeholder ─────────────────────────── // ── Central panel — world map ────────────────────────────────────────
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
ui.centered_and_justified(|ui| { if let Some(key) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
ui.weak("World map — Phase 5"); self.notification = None;
}); self.spawn_connect(key);
}
}); });
} }
} }

View file

@ -6,7 +6,9 @@ mod config;
mod countries; mod countries;
mod flags; mod flags;
mod nordvpn; mod nordvpn;
mod projection;
mod ui; mod ui;
mod world_map;
fn main() -> eframe::Result { fn main() -> eframe::Result {
let options = eframe::NativeOptions { let options = eframe::NativeOptions {

77
src/projection.rs Normal file
View file

@ -0,0 +1,77 @@
use eframe::egui;
pub trait MapProjection: Send + Sync {
fn project(&self, lon: f64, lat: f64, rect: egui::Rect) -> egui::Pos2;
fn unproject(&self, pos: egui::Pos2, rect: egui::Rect) -> (f64, f64);
fn name(&self) -> &'static str;
}
pub struct Equirectangular;
impl MapProjection for Equirectangular {
fn project(&self, lon: f64, lat: f64, rect: egui::Rect) -> egui::Pos2 {
egui::pos2(
rect.left() + ((lon + 180.0) / 360.0) as f32 * rect.width(),
rect.top() + ((90.0 - lat) / 180.0) as f32 * rect.height(),
)
}
fn unproject(&self, pos: egui::Pos2, rect: egui::Rect) -> (f64, f64) {
let lon = (pos.x - rect.left()) as f64 / rect.width() as f64 * 360.0 - 180.0;
let lat = 90.0 - (pos.y - rect.top()) as f64 / rect.height() as f64 * 180.0;
(lon, lat)
}
fn name(&self) -> &'static str {
"Equirectangular"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unit_rect() -> egui::Rect {
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 180.0))
}
#[test]
fn roundtrip_center() {
let proj = Equirectangular;
let r = unit_rect();
let pos = proj.project(0.0, 0.0, r);
let (lon, lat) = proj.unproject(pos, r);
assert!((lon).abs() < 1e-4);
assert!((lat).abs() < 1e-4);
}
#[test]
fn roundtrip_corners() {
let proj = Equirectangular;
let r = unit_rect();
for (lon, lat) in [(-180.0_f64, 90.0_f64), (180.0, -90.0), (-180.0, -90.0), (180.0, 90.0)] {
let pos = proj.project(lon, lat, r);
let (lon2, lat2) = proj.unproject(pos, r);
assert!((lon - lon2).abs() < 1e-4, "lon {lon} → {lon2}");
assert!((lat - lat2).abs() < 1e-4, "lat {lat} → {lat2}");
}
}
#[test]
fn project_prime_meridian_equator() {
let proj = Equirectangular;
let r = unit_rect();
let pos = proj.project(0.0, 0.0, r);
assert!((pos.x - 180.0).abs() < 1e-3, "x={}", pos.x);
assert!((pos.y - 90.0).abs() < 1e-3, "y={}", pos.y);
}
#[test]
fn rect_scaling() {
let proj = Equirectangular;
let r = egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(720.0, 360.0));
let pos = proj.project(0.0, 0.0, r);
assert!((pos.x - 370.0).abs() < 1e-3, "x={}", pos.x);
assert!((pos.y - 200.0).abs() < 1e-3, "y={}", pos.y);
}
}

102
src/ui/config_dialog.rs Normal file
View file

@ -0,0 +1,102 @@
use eframe::egui;
use crate::config::Config;
use crate::countries::Country;
pub enum DialogResult {
Open,
Apply(Config),
Cancel,
}
pub struct ConfigDialog {
draft: Config,
fav_filter: String,
}
impl ConfigDialog {
pub fn new(config: &Config) -> Self {
Self { draft: config.clone(), fav_filter: String::new() }
}
pub fn show(&mut self, ctx: &egui::Context, countries: &[Country]) -> DialogResult {
let mut result = DialogResult::Open;
egui::Window::new("Settings")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.min_width(300.0)
.show(ctx, |ui| {
egui::Grid::new("cfg_grid")
.num_columns(2)
.spacing([8.0, 6.0])
.show(ui, |ui| {
ui.label("NordVPN binary:");
ui.add(
egui::TextEdit::singleline(&mut self.draft.external_app)
.desired_width(180.0),
);
ui.end_row();
ui.label("Refresh rate:");
ui.horizontal(|ui| {
ui.add(
egui::DragValue::new(&mut self.draft.refresh_rate)
.range(1..=300),
);
ui.label("s");
});
ui.end_row();
});
ui.add_space(10.0);
ui.label("Favorites:");
ui.add(
egui::TextEdit::singleline(&mut self.fav_filter)
.hint_text("🔍 Filter…")
.desired_width(f32::INFINITY),
);
ui.add_space(2.0);
let filter = self.fav_filter.to_lowercase();
egui::ScrollArea::vertical()
.id_salt("fav_scroll")
.max_height(220.0)
.show(ui, |ui| {
for country in countries {
if !filter.is_empty()
&& !country.name.to_lowercase().contains(&filter)
{
continue;
}
let mut checked =
self.draft.favorites.contains(&country.nordvpn_key);
if ui.checkbox(&mut checked, &country.name).changed() {
if checked {
self.draft.favorites.push(country.nordvpn_key.clone());
} else {
self.draft
.favorites
.retain(|f| f != &country.nordvpn_key);
}
}
}
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Apply").clicked() {
result = DialogResult::Apply(self.draft.clone());
}
if ui.button("Cancel").clicked() {
result = DialogResult::Cancel;
}
});
});
});
result
}
}

View file

@ -1 +1,2 @@
pub mod config_dialog;
pub mod country_selector; pub mod country_selector;

333
src/world_map.rs Normal file
View file

@ -0,0 +1,333 @@
use std::collections::HashSet;
use eframe::egui;
use serde_json::Value;
use crate::countries::Country;
use crate::projection::{Equirectangular, MapProjection};
const COLOR_AVAILABLE: egui::Color32 = egui::Color32::from_rgb(56, 148, 136);
const COLOR_CONNECTED: egui::Color32 = egui::Color32::from_rgb(60, 210, 80);
const COLOR_UNAVAILABLE: egui::Color32 = egui::Color32::from_rgb(50, 52, 58);
const COLOR_OCEAN: egui::Color32 = egui::Color32::from_rgb(22, 24, 32);
const COLOR_BORDER: egui::Color32 = egui::Color32::from_rgb(15, 17, 22);
struct CountryShape {
name: String,
/// Tessellated vertices in lon/lat space, for filled rendering.
vertices: Vec<[f64; 2]>,
indices: Vec<u32>,
/// Outer ring in lon/lat space, for hit-testing and border drawing.
outer_ring: Vec<[f64; 2]>,
}
pub struct WorldMap {
shapes: Vec<CountryShape>,
projection: Box<dyn MapProjection>,
zoom: f32,
pan: egui::Vec2,
hovered: Option<usize>,
}
impl Default for WorldMap {
fn default() -> Self {
Self::new()
}
}
impl WorldMap {
pub fn new() -> Self {
let bytes = include_bytes!("../assets/world.geojson");
Self {
shapes: parse_geojson(bytes),
projection: Box::new(Equirectangular),
zoom: 1.0,
pan: egui::Vec2::ZERO,
hovered: None,
}
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
nordvpn_countries: &[Country],
connected_country: Option<&str>,
) -> Option<String> {
let available = ui.available_rect_before_wrap();
// 2:1 aspect ratio map rect, centred in available space.
let (aw, ah) = (available.width(), available.height());
let (mw, mh) = if aw / ah > 2.0 { (ah * 2.0, ah) } else { (aw, aw / 2.0) };
let map_rect = egui::Rect::from_center_size(available.center(), egui::vec2(mw, mh));
// Virtual map rect: the world map as scaled + panned within map_rect.
let virtual_rect = egui::Rect::from_min_size(
map_rect.min + self.pan,
map_rect.size() * self.zoom,
);
let response = ui.allocate_rect(map_rect, egui::Sense::click_and_drag());
// ── Scroll to zoom ───────────────────────────────────────────────────
if response.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
let factor = (scroll * 0.005).exp();
let cursor = response.hover_pos().unwrap_or(map_rect.center());
let cursor_in_map = cursor - map_rect.min - self.pan;
let old_zoom = self.zoom;
self.zoom = (self.zoom * factor).max(1.0);
self.pan = cursor - map_rect.min - cursor_in_map * (self.zoom / old_zoom);
}
}
// ── Drag to pan ──────────────────────────────────────────────────────
if response.dragged() {
self.pan += response.drag_delta();
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grabbing);
} else if response.hovered() && self.zoom > 1.0 {
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grab);
}
// ── Clamp pan so the map always covers map_rect ──────────────────────
self.pan.x = self.pan.x.clamp(-(self.zoom - 1.0) * map_rect.width(), 0.0);
self.pan.y = self.pan.y.clamp(-(self.zoom - 1.0) * map_rect.height(), 0.0);
// ── Hover detection ──────────────────────────────────────────────────
self.hovered = None;
if let Some(cursor) = response.hover_pos() {
let (lon, lat) = self.projection.unproject(cursor, virtual_rect);
self.hovered = self
.shapes
.iter()
.position(|s| point_in_polygon([lon, lat], &s.outer_ring));
}
// ── Click to connect ─────────────────────────────────────────────────
let mut clicked_key = None;
if response.clicked() {
if let Some(idx) = self.hovered {
let sn = norm(&self.shapes[idx].name);
clicked_key = nordvpn_countries
.iter()
.find(|c| {
let cn = norm(&c.name);
sn == cn || sn.starts_with(&cn)
})
.map(|c| c.nordvpn_key.clone());
}
}
// ── Build availability sets ──────────────────────────────────────────
let available_set: HashSet<String> =
nordvpn_countries.iter().map(|c| norm(&c.name)).collect();
let connected_norm = connected_country.map(norm);
// ── Render ───────────────────────────────────────────────────────────
// Ocean background for full panel; country shapes clipped to map_rect.
ui.painter_at(available).rect_filled(available, 0.0, COLOR_OCEAN);
let painter = ui.painter_at(map_rect);
for (idx, shape) in self.shapes.iter().enumerate() {
let sn = norm(&shape.name);
let is_connected = connected_norm
.as_deref()
.is_some_and(|cn| sn == cn || sn.starts_with(cn));
let is_available = available_set.contains(&sn)
|| available_set.iter().any(|a| sn.starts_with(a.as_str()));
let is_hovered = self.hovered == Some(idx);
let base = if is_connected {
COLOR_CONNECTED
} else if is_available {
COLOR_AVAILABLE
} else {
COLOR_UNAVAILABLE
};
let fill = if is_hovered { brightened(base, 50) } else { base };
render_fill(&painter, shape, &*self.projection, virtual_rect, fill);
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER);
}
// ── Tooltip ──────────────────────────────────────────────────────────
let hover_name: Option<String> =
self.hovered.map(|i| self.shapes[i].name.clone());
if let Some(name) = hover_name {
response.on_hover_ui_at_pointer(|ui| {
ui.label(&name);
});
}
clicked_key
}
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
fn render_fill(
painter: &egui::Painter,
shape: &CountryShape,
projection: &dyn MapProjection,
rect: egui::Rect,
color: egui::Color32,
) {
if shape.indices.is_empty() {
return;
}
let mut mesh = egui::Mesh::default();
for &[lon, lat] in &shape.vertices {
mesh.colored_vertex(projection.project(lon, lat, rect), color);
}
for tri in shape.indices.chunks_exact(3) {
mesh.add_triangle(tri[0], tri[1], tri[2]);
}
painter.add(egui::Shape::mesh(mesh));
}
fn render_border(
painter: &egui::Painter,
shape: &CountryShape,
projection: &dyn MapProjection,
rect: egui::Rect,
color: egui::Color32,
) {
if shape.outer_ring.len() < 2 {
return;
}
let pts: Vec<egui::Pos2> = shape
.outer_ring
.iter()
.map(|&[lon, lat]| projection.project(lon, lat, rect))
.collect();
painter.add(egui::Shape::closed_line(pts, egui::Stroke::new(0.8, color)));
}
// ---------------------------------------------------------------------------
// GeoJSON parsing + tessellation
// ---------------------------------------------------------------------------
fn parse_geojson(bytes: &[u8]) -> Vec<CountryShape> {
let json: Value = serde_json::from_slice(bytes).expect("invalid world.geojson");
let Some(features) = json["features"].as_array() else {
return Vec::new();
};
let mut shapes = Vec::new();
for feature in features {
let name = feature["properties"]["NAME"]
.as_str()
.unwrap_or("")
.to_string();
let geom = &feature["geometry"];
match geom["type"].as_str().unwrap_or("") {
"Polygon" => {
if let Some(s) = tessellate(&geom["coordinates"], name) {
shapes.push(s);
}
}
"MultiPolygon" => {
if let Some(polys) = geom["coordinates"].as_array() {
for poly in polys {
if let Some(s) = tessellate(poly, name.clone()) {
shapes.push(s);
}
}
}
}
_ => {}
}
}
shapes
}
fn tessellate(coordinates: &Value, name: String) -> Option<CountryShape> {
let rings = coordinates.as_array()?;
if rings.is_empty() {
return None;
}
// Capture the outer ring for hit-testing and border drawing.
let outer_pts = rings[0].as_array()?;
let n_outer = outer_pts.len().saturating_sub(1); // GeoJSON rings are closed
let outer_ring: Vec<[f64; 2]> = outer_pts[..n_outer]
.iter()
.filter_map(|pt| {
let c = pt.as_array()?;
Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?])
})
.collect();
if outer_ring.len() < 3 {
return None;
}
// Build flat vertex + hole data for earcutr.
let mut data: Vec<f64> = Vec::new();
let mut hole_indices: Vec<usize> = Vec::new();
for (i, ring) in rings.iter().enumerate() {
if i > 0 {
hole_indices.push(data.len() / 2);
}
let pts = ring.as_array()?;
let n = pts.len().saturating_sub(1);
for pt in &pts[..n] {
let c = pt.as_array()?;
data.push(c.first()?.as_f64()?);
data.push(c.get(1)?.as_f64()?);
}
}
let tri_indices = earcutr::earcut(&data, &hole_indices, 2).ok()?;
if tri_indices.is_empty() {
return None;
}
let vertices: Vec<[f64; 2]> = data.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
let indices: Vec<u32> = tri_indices.into_iter().map(|i| i as u32).collect();
Some(CountryShape { name, vertices, indices, outer_ring })
}
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
/// Ray-casting point-in-polygon test (2D, Cartesian).
fn point_in_polygon(p: [f64; 2], ring: &[[f64; 2]]) -> bool {
let [px, py] = p;
let n = ring.len();
let mut inside = false;
let mut j = n.wrapping_sub(1);
for i in 0..n {
let [xi, yi] = ring[i];
let [xj, yj] = ring[j];
if (yi > py) != (yj > py) {
let x_int = xi + (py - yi) * (xj - xi) / (yj - yi);
if px < x_int {
inside = !inside;
}
}
j = i;
}
inside
}
fn brightened(c: egui::Color32, amount: u8) -> egui::Color32 {
egui::Color32::from_rgb(
c.r().saturating_add(amount),
c.g().saturating_add(amount),
c.b().saturating_add(amount),
)
}
/// Normalise a country name for loose matching: ASCII letters only, lowercased.
fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphabetic())
.map(|c| c.to_ascii_lowercase())
.collect()
}