embed flags; wait double click period before popup
This commit is contained in:
parent
b23c89cc1f
commit
ec38a12d0c
6 changed files with 248 additions and 76 deletions
100
Cargo.lock
generated
100
Cargo.lock
generated
|
|
@ -448,6 +448,15 @@ version = "0.1.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
|
|
@ -713,6 +722,15 @@ dependencies = [
|
|||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -734,6 +752,16 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
|
|
@ -746,6 +774,16 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1272,6 +1310,16 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
|
|
@ -2031,6 +2079,7 @@ dependencies = [
|
|||
"egui_extras",
|
||||
"image",
|
||||
"resvg",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
|
|
@ -2780,6 +2829,40 @@ version = "0.20.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
|
|
@ -2954,6 +3037,17 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
|
|
@ -3390,6 +3484,12 @@ dependencies = [
|
|||
"rustc-hash 2.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ eframe = "0.33.3"
|
|||
egui_extras = { version = "0.33.3", features = ["file", "image", "svg"] }
|
||||
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
||||
resvg = "0.45"
|
||||
rust-embed = "8"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
toml = "1.0.7"
|
||||
|
|
|
|||
41
src/app.rs
41
src/app.rs
|
|
@ -1,5 +1,5 @@
|
|||
use std::sync::mpsc::{self, Sender};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
|
|
@ -30,6 +30,9 @@ pub struct App {
|
|||
notification: Option<String>,
|
||||
/// A connect key that is awaiting user confirmation before being dispatched.
|
||||
pending_connect: Option<String>,
|
||||
/// When the pending connect was queued — dialog is shown only after the
|
||||
/// double-click window (300 ms, matching egui's double_click_speed) has passed.
|
||||
pending_connect_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -41,7 +44,7 @@ impl App {
|
|||
|
||||
// Kick off background flag rasterization.
|
||||
let (flag_tx, flag_rx) = mpsc::channel();
|
||||
flags::spawn_loader(flags::flag_paths(&countries), flag_tx);
|
||||
flags::spawn_loader(flags::flag_data(&countries), flag_tx);
|
||||
|
||||
Self {
|
||||
config,
|
||||
|
|
@ -56,6 +59,7 @@ impl App {
|
|||
config_dialog: None,
|
||||
notification: None,
|
||||
pending_connect: None,
|
||||
pending_connect_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,12 +71,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() {
|
||||
/// If already connected and not forced, queue a confirmation prompt; otherwise connect immediately.
|
||||
/// `force` is true on double-click — skips the confirmation dialog entirely.
|
||||
fn request_connect(&mut self, key: String, status: &StatusInfo, force: bool) {
|
||||
if !force && status.is_connected() {
|
||||
self.pending_connect = Some(key);
|
||||
self.pending_connect_at = Some(Instant::now());
|
||||
} else {
|
||||
self.notification = None;
|
||||
self.pending_connect = None;
|
||||
self.pending_connect_at = None;
|
||||
self.spawn_connect(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -191,19 +199,31 @@ impl eframe::App for App {
|
|||
})
|
||||
.inner;
|
||||
|
||||
if let Some(key) = connect_key {
|
||||
self.request_connect(key, &status);
|
||||
if let Some((key, force)) = connect_key {
|
||||
self.request_connect(key, &status, force);
|
||||
}
|
||||
|
||||
// ── Central panel — world map ────────────────────────────────────────
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if let Some(key) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
|
||||
self.request_connect(key, &status);
|
||||
if let Some((key, force)) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
|
||||
self.request_connect(key, &status, force);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Confirmation modal ───────────────────────────────────────────────
|
||||
// Wait for the double-click window before showing the dialog, so a
|
||||
// double-click never causes a flash. 300 ms matches egui's own
|
||||
// double_click_speed default.
|
||||
const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(300);
|
||||
|
||||
if let Some(key) = self.pending_connect.clone() {
|
||||
let elapsed = self.pending_connect_at.map_or(DOUBLE_CLICK_WINDOW, |t| t.elapsed());
|
||||
|
||||
if elapsed < DOUBLE_CLICK_WINDOW {
|
||||
// Still within the double-click window — schedule a repaint for
|
||||
// when the window expires and don't show the dialog yet.
|
||||
ctx.request_repaint_after(DOUBLE_CLICK_WINDOW - elapsed);
|
||||
} else {
|
||||
let label = self
|
||||
.countries
|
||||
.iter()
|
||||
|
|
@ -235,8 +255,11 @@ impl eframe::App for App {
|
|||
self.notification = None;
|
||||
self.spawn_connect(key);
|
||||
self.pending_connect = None;
|
||||
self.pending_connect_at = None;
|
||||
} else if cancelled {
|
||||
self.pending_connect = None;
|
||||
self.pending_connect_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/flags.rs
20
src/flags.rs
|
|
@ -1,4 +1,3 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use eframe::egui;
|
||||
|
|
@ -6,6 +5,7 @@ use resvg::{
|
|||
tiny_skia::{Pixmap, Transform},
|
||||
usvg::{Options, Tree},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
use crate::countries::Country;
|
||||
|
||||
|
|
@ -14,13 +14,17 @@ use crate::countries::Country;
|
|||
pub const FLAG_TEX_W: u32 = 60;
|
||||
pub const FLAG_TEX_H: u32 = 40;
|
||||
|
||||
/// Collect (nordvpn_key, svg_path) for every country that has a flag file.
|
||||
pub fn flag_paths(countries: &[Country]) -> Vec<(String, PathBuf)> {
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "flags/"]
|
||||
struct FlagAssets;
|
||||
|
||||
/// Collect (nordvpn_key, svg_bytes) for every country that has an embedded flag.
|
||||
pub fn flag_data(countries: &[Country]) -> Vec<(String, Vec<u8>)> {
|
||||
countries
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let path = PathBuf::from("flags").join(format!("{}.svg", c.nordvpn_key));
|
||||
if path.exists() { Some((c.nordvpn_key.clone(), path)) } else { None }
|
||||
FlagAssets::get(&format!("{}.svg", c.nordvpn_key))
|
||||
.map(|f| (c.nordvpn_key.clone(), f.data.into_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -28,18 +32,16 @@ pub fn flag_paths(countries: &[Country]) -> Vec<(String, PathBuf)> {
|
|||
/// Spawn a background thread that rasterizes all flag SVGs and sends the
|
||||
/// resulting `ColorImage`s back via `tx`. The UI thread can drain `tx` in
|
||||
/// each frame and create `TextureHandle`s without ever blocking.
|
||||
pub fn spawn_loader(paths: Vec<(String, PathBuf)>, tx: Sender<(String, egui::ColorImage)>) {
|
||||
pub fn spawn_loader(data: Vec<(String, Vec<u8>)>, tx: Sender<(String, egui::ColorImage)>) {
|
||||
std::thread::Builder::new()
|
||||
.name("flag-loader".into())
|
||||
.spawn(move || {
|
||||
for (key, path) in paths {
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
for (key, bytes) in data {
|
||||
if let Some(image) = rasterize(&bytes) {
|
||||
// Ignore send error — UI may have closed.
|
||||
let _ = tx.send((key, image));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn flag-loader thread");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ impl CountrySelector {
|
|||
favorites: &[String],
|
||||
connected_country: Option<&str>,
|
||||
flags: &HashMap<String, egui::TextureHandle>,
|
||||
) -> Option<String> {
|
||||
) -> Option<(String, bool)> {
|
||||
// ── Toggle + search bar ─────────────────────────────────────────────
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.view_mode, ViewMode::List, "List");
|
||||
|
|
@ -115,7 +115,7 @@ fn show_list(
|
|||
rest: &[&Country],
|
||||
connected_country: Option<&str>,
|
||||
flags: &HashMap<String, egui::TextureHandle>,
|
||||
) -> Option<String> {
|
||||
) -> Option<(String, bool)> {
|
||||
let rows = build_list_rows(favs, rest);
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
|
|
@ -136,8 +136,11 @@ fn show_list(
|
|||
ui.separator();
|
||||
}
|
||||
ListRow::Country(c) => {
|
||||
if list_row(ui, c, connected_country, flags) {
|
||||
clicked = Some(c.nordvpn_key.clone());
|
||||
let (single, double) = list_row(ui, c, connected_country, flags);
|
||||
if double {
|
||||
clicked = Some((c.nordvpn_key.clone(), true));
|
||||
} else if single {
|
||||
clicked = Some((c.nordvpn_key.clone(), false));
|
||||
}
|
||||
}
|
||||
ListRow::Space => {
|
||||
|
|
@ -150,12 +153,13 @@ fn show_list(
|
|||
clicked
|
||||
}
|
||||
|
||||
/// Returns `(single_clicked, double_clicked)`.
|
||||
fn list_row(
|
||||
ui: &mut egui::Ui,
|
||||
country: &Country,
|
||||
connected_country: Option<&str>,
|
||||
flags: &HashMap<String, egui::TextureHandle>,
|
||||
) -> bool {
|
||||
) -> (bool, bool) {
|
||||
let is_connected = connected_country.is_some_and(|k| names_match(&country.name, k));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -172,8 +176,8 @@ fn list_row(
|
|||
egui::RichText::new(&country.name)
|
||||
};
|
||||
let remaining = ui.available_width();
|
||||
ui.add(egui::Button::new(text).frame(false).min_size(egui::vec2(remaining, 0.0)))
|
||||
.clicked()
|
||||
let resp = ui.add(egui::Button::new(text).frame(false).min_size(egui::vec2(remaining, 0.0)));
|
||||
(resp.clicked(), resp.double_clicked())
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
|
@ -188,7 +192,7 @@ fn show_grid(
|
|||
rest: &[&Country],
|
||||
connected_country: Option<&str>,
|
||||
flags: &HashMap<String, egui::TextureHandle>,
|
||||
) -> Option<String> {
|
||||
) -> Option<(String, bool)> {
|
||||
// Compute columns from available width once, before the scroll area consumes it.
|
||||
let available_w = ui.available_width();
|
||||
let spacing = ui.spacing().item_spacing.x;
|
||||
|
|
@ -224,7 +228,7 @@ fn show_grid(
|
|||
return None;
|
||||
}
|
||||
|
||||
let mut clicked = None;
|
||||
let mut clicked: Option<(String, bool)> = None;
|
||||
let row_h = GRID_DISPLAY_H + ui.spacing().button_padding.y * 2.0;
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
|
|
@ -262,8 +266,11 @@ fn show_grid(
|
|||
.min_size(egui::vec2(GRID_DISPLAY_W, GRID_DISPLAY_H)),
|
||||
)
|
||||
};
|
||||
if resp.on_hover_text(&c.name).clicked() {
|
||||
clicked = Some(c.nordvpn_key.clone());
|
||||
let resp = resp.on_hover_text(&c.name);
|
||||
if resp.double_clicked() {
|
||||
clicked = Some((c.nordvpn_key.clone(), true));
|
||||
} else if resp.clicked() {
|
||||
clicked = Some((c.nordvpn_key.clone(), false));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ impl WorldMap {
|
|||
ui: &mut egui::Ui,
|
||||
nordvpn_countries: &[Country],
|
||||
connected_country: Option<&str>,
|
||||
) -> Option<String> {
|
||||
) -> Option<(String, bool)> {
|
||||
let available = ui.available_rect_before_wrap();
|
||||
|
||||
// 2:1 aspect ratio map rect, centred in available space.
|
||||
|
|
@ -104,17 +104,22 @@ impl WorldMap {
|
|||
}
|
||||
|
||||
// ── Click to connect ─────────────────────────────────────────────────
|
||||
let mut clicked_key = None;
|
||||
if response.clicked() {
|
||||
let mut clicked_key: Option<(String, bool)> = None;
|
||||
if let Some(idx) = self.hovered {
|
||||
let sn = norm(&self.shapes[idx].name);
|
||||
clicked_key = nordvpn_countries
|
||||
let key = nordvpn_countries
|
||||
.iter()
|
||||
.find(|c| {
|
||||
let cn = norm(&c.name);
|
||||
sn == cn || sn.starts_with(&cn)
|
||||
})
|
||||
.map(|c| c.nordvpn_key.clone());
|
||||
if let Some(k) = key {
|
||||
if response.double_clicked() {
|
||||
clicked_key = Some((k, true));
|
||||
} else if response.clicked() {
|
||||
clicked_key = Some((k, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +131,28 @@ impl WorldMap {
|
|||
// ── 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);
|
||||
let stroke_w = (0.8 * self.zoom).min(2.0);
|
||||
|
||||
// Project the south pole to screen space. When zoomed/panned away it
|
||||
// projects far below the viewport and the letterbox fill is suppressed.
|
||||
let south_pole_y = self.projection.project(0.0, -90.0, virtual_rect).y;
|
||||
let antarctica_near_bottom =
|
||||
south_pole_y >= map_rect.max.y - 3.0 && south_pole_y <= map_rect.max.y + 3.0;
|
||||
|
||||
// Fill the letterbox below the 2:1 map rect with land color.
|
||||
// This is below map_rect so it never overlaps with shape rendering.
|
||||
if antarctica_near_bottom && map_rect.max.y < available.max.y {
|
||||
ui.painter_at(available).rect_filled(
|
||||
egui::Rect::from_min_max(
|
||||
egui::pos2(available.min.x, map_rect.max.y),
|
||||
available.max,
|
||||
),
|
||||
0.0,
|
||||
COLOR_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
for (idx, shape) in self.shapes.iter().enumerate() {
|
||||
let sn = norm(&shape.name);
|
||||
|
|
@ -147,10 +173,23 @@ impl WorldMap {
|
|||
let fill = if is_hovered { brightened(base, 50) } else { base };
|
||||
|
||||
render_fill(&painter, shape, &*self.projection, virtual_rect, fill);
|
||||
let stroke_w = (0.8 * self.zoom).min(2.0);
|
||||
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER, stroke_w);
|
||||
}
|
||||
|
||||
// Cover Antarctica's border seam at the bottom of the map. Uses the
|
||||
// same painter (map_rect clip) as the shapes so egui keeps it in the
|
||||
// same draw batch — guaranteed to render after the borders.
|
||||
if antarctica_near_bottom {
|
||||
painter.rect_filled(
|
||||
egui::Rect::from_min_max(
|
||||
egui::pos2(map_rect.min.x, map_rect.max.y - 3.0),
|
||||
map_rect.max,
|
||||
),
|
||||
0.0,
|
||||
COLOR_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip ──────────────────────────────────────────────────────────
|
||||
let hover_name: Option<String> =
|
||||
self.hovered.map(|i| self.shapes[i].name.clone());
|
||||
|
|
|
|||
Loading…
Reference in a new issue