diff --git a/src/download.rs b/src/download.rs index c913010..84ff913 100644 --- a/src/download.rs +++ b/src/download.rs @@ -31,7 +31,7 @@ async fn download_to_file( url: &str, token: Option<&str>, dest_path: &std::path::Path, - progress_tx: Option>, + progress_tx: Option)>>, ) -> Result<()> { let mut request = client.get(url); if let Some(t) = token { @@ -65,13 +65,31 @@ async fn download_to_file( file.write_all(&chunk)?; downloaded += chunk.len() as u64; - if let (Some(ref tx), Some(total)) = (&progress_tx, total) { - if total > 0 { - let pct = ((downloaded as f64 / total as f64) * 100.0) as u32; - if pct != last_pct { - last_pct = pct; - let _ = tx.unbounded_send(downloaded as f32 / total as f32); + if let Some(ref tx) = progress_tx { + // Throttle: send on whole-percent changes when the total is + // known, else every 256 KiB - not per network chunk. + let should_send = match total { + Some(total) if total > 0 => { + let pct = ((downloaded as f64 / total as f64) * 100.0) as u32; + if pct != last_pct { + last_pct = pct; + true + } else { + false + } } + _ => { + let bucket = (downloaded / (256 * 1024)) as u32; + if bucket != last_pct { + last_pct = bucket; + true + } else { + false + } + } + }; + if should_send { + let _ = tx.unbounded_send((downloaded, total)); } } } @@ -309,7 +327,7 @@ pub struct AssetInstall { pub async fn download_release_asset( token: Option, install: AssetInstall, - progress_tx: Option>, + progress_tx: Option)>>, ) -> Result { let AssetInstall { repo_name, @@ -444,7 +462,7 @@ pub async fn download_launcher_asset( token: Option, tag: String, filename: String, - progress_tx: Option>, + progress_tx: Option)>>, ) -> Result { let temp_dir = colony_data_dir()?.join("update-staging"); std::fs::create_dir_all(&temp_dir)?; diff --git a/src/main.rs b/src/main.rs index 3a2ce90..b557d33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -171,6 +171,9 @@ impl App { next_notification_id: 0, app_icons: std::collections::HashMap::new(), download_progress: None, + download_bytes: None, + download_speed: 0.0, + last_progress_sample: None, download_abort: None, downloading_repo: None, favorites, @@ -237,6 +240,7 @@ impl App { update_queue: Vec::new(), release_notes: std::collections::HashMap::new(), fetching_notes: std::collections::HashSet::new(), + keyboard_cursor: None, window_size: ( prefs.window_width.unwrap_or(1000.0).clamp(640.0, 7680.0), prefs.window_height.unwrap_or(700.0).clamp(480.0, 4320.0), @@ -315,7 +319,38 @@ impl App { // Download progress bar with graphical bar and cancel button if let Some((ref filename, progress)) = self.download_progress { let pct = (progress * 100.0) as u32; - let bar_label = format!("\u{f019} {} — {}%", filename, pct); + // Size and speed when we have byte counters, plain % otherwise. + let bar_label = match self.download_bytes { + Some((done, Some(total))) if total > 0 => { + let speed = if self.download_speed > 1.0 { + format!(" · {}/s", state::human_bytes(self.download_speed as u64)) + } else { + String::new() + }; + format!( + "\u{f019} {} — {} / {} ({}%){}", + filename, + state::human_bytes(done), + state::human_bytes(total), + pct, + speed + ) + } + Some((done, _)) => { + let speed = if self.download_speed > 1.0 { + format!(" · {}/s", state::human_bytes(self.download_speed as u64)) + } else { + String::new() + }; + format!( + "\u{f019} {} — {}{}", + filename, + state::human_bytes(done), + speed + ) + } + None => format!("\u{f019} {} — {}%", filename, pct), + }; let cancel_btn = button( text("\u{f00d}") .size(self.sz(12)) diff --git a/src/message.rs b/src/message.rs index 2fd7fde..777914e 100644 --- a/src/message.rs +++ b/src/message.rs @@ -33,7 +33,10 @@ pub enum Message { GitHubRefreshRepos, DownloadRelease(String, String), // (repo_name, platform_key) #[allow(dead_code)] - DownloadProgress(String, f32), // (filename, progress 0.0..1.0) + /// (filename, downloaded bytes, total bytes when the server sent + /// Content-Length) - raw bytes so the UI can show size AND speed, not + /// just a bare percentage. + DownloadProgress(String, u64, Option), DownloadCompleted(Result<(PathBuf, String, String), String>), // (path, repo_name, tag) CancelDownload, LaunchColonyApp(PathBuf), diff --git a/src/state.rs b/src/state.rs index 09ec404..444c8c8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -172,6 +172,12 @@ pub struct App { pub app_icons: std::collections::HashMap, // Download progress pub download_progress: Option<(String, f32)>, // (filename, 0.0..1.0) + /// Raw transfer counters: (bytes downloaded, total when known). + pub download_bytes: Option<(u64, Option)>, + /// Smoothed transfer speed in bytes/second (EMA over progress samples). + pub download_speed: f32, + /// Last (instant, bytes) sample for the speed computation. + pub last_progress_sample: Option<(std::time::Instant, u64)>, /// Abort handle for the in-flight download (app asset or launcher self- /// update). Cancelling actually aborts the task instead of only clearing UI. pub download_abort: Option, @@ -244,6 +250,10 @@ pub struct App { std::collections::HashMap)>, /// Repos whose release notes are currently being fetched. pub fetching_notes: std::collections::HashSet, + /// Keyboard-highlighted grid row, as a stable key ("repo:" or + /// "app:") so catalog refreshes and re-filters cannot silently move + /// the highlight to a different item. Cleared on search/section changes. + pub keyboard_cursor: Option, /// Live window size, persisted (debounced) so the next boot reopens at /// the same dimensions. The pref fields existed but were never written. pub window_size: (f32, f32), @@ -271,6 +281,23 @@ impl App { &self.colony_repo_list } + /// Ordered stable keys of every row the grid currently shows (store repos + /// first, then local apps - the combined view's display order), for + /// keyboard traversal. + pub fn grid_keys(&self) -> Vec { + let mut keys: Vec = self + .filtered_colony_repos() + .iter() + .map(|r| format!("repo:{}", r.name)) + .collect(); + keys.extend( + self.filtered_applications() + .iter() + .map(|a| format!("app:{}", a.name)), + ); + keys + } + /// Resolve the repo whose detail page is open, surviving catalog refreshes /// (`None` if the repo disappeared from the catalog). pub fn active_repo(&self) -> Option<&ColonyRepo> { @@ -427,6 +454,23 @@ impl App { } } +/// Human-readable byte size (KiB/MiB/GiB with one decimal). +pub fn human_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + let b = bytes as f64; + if b >= GIB { + format!("{:.1} GiB", b / GIB) + } else if b >= MIB { + format!("{:.1} MiB", b / MIB) + } else if b >= KIB { + format!("{:.0} KiB", b / KIB) + } else { + format!("{bytes} B") + } +} + pub fn capitalize_platform(p: &str) -> String { match p.to_lowercase().as_str() { "windows" => "Windows".to_string(), @@ -459,6 +503,9 @@ impl App { next_notification_id: 0, app_icons: std::collections::HashMap::new(), download_progress: None, + download_bytes: None, + download_speed: 0.0, + last_progress_sample: None, download_abort: None, downloading_repo: None, favorites: Vec::new(), @@ -497,6 +544,7 @@ impl App { update_queue: Vec::new(), release_notes: std::collections::HashMap::new(), fetching_notes: std::collections::HashSet::new(), + keyboard_cursor: None, window_size: (1000.0, 700.0), window_save_gen: 0, launcher_update_available: None, diff --git a/src/ui/app_grid.rs b/src/ui/app_grid.rs index 685f6af..854b440 100644 --- a/src/ui/app_grid.rs +++ b/src/ui/app_grid.rs @@ -546,7 +546,8 @@ impl App { .align_y(iced::Alignment::Center) .width(Fill); - let selected = self.active_colony_repo.as_deref() == Some(repo.name.as_str()); + let selected = self.active_colony_repo.as_deref() == Some(repo.name.as_str()) + || self.keyboard_cursor.as_deref() == Some(&format!("repo:{}", repo.name)); self.cell_shell( content.into(), Message::ColonyRepoSelected(repo.name.clone()), @@ -562,6 +563,7 @@ impl App { &self, content: Element<'a, Message>, accent: Color, + selected: bool, ) -> Element<'a, Message> { let bar = container(text("")) .width(4) @@ -586,10 +588,21 @@ impl App { container(inner) .width(Fill) .height(Length::Fixed(self.sz(96))) - .style(|_theme| container::Style { - background: Some(Palette::BG_CARD().into()), + .style(move |_theme| container::Style { + background: Some( + if selected { + Palette::BG_SELECTED() + } else { + Palette::BG_CARD() + } + .into(), + ), border: iced::Border { - color: Palette::BORDER_SUBTLE(), + color: if selected { + Palette::ACCENT() + } else { + Palette::BORDER_SUBTLE() + }, width: 1.0, radius: 12.0.into(), }, @@ -679,6 +692,7 @@ impl App { .align_y(iced::Alignment::Center) .width(Fill); - self.cell_shell_static(content.into(), tint) + let selected = self.keyboard_cursor.as_deref() == Some(&format!("app:{}", app.name)); + self.cell_shell_static(content.into(), tint, selected) } } diff --git a/src/update.rs b/src/update.rs index addb7fe..aa32e97 100644 --- a/src/update.rs +++ b/src/update.rs @@ -149,6 +149,9 @@ impl App { match message { Message::SearchChanged(query) => { self.search_query = query; + // The visible rows changed: a stale highlight would point at + // a hidden item. + self.keyboard_cursor = None; Task::none() } Message::SectionSelected(index) => { @@ -159,6 +162,7 @@ impl App { self.sidebar_indicator_start = Some(std::time::Instant::now()); self.selected_section = index; self.active_colony_repo = None; + self.keyboard_cursor = None; // Dismiss any open overlay panel so the section change is // actually visible — otherwise users stay stuck on the // GitHub / Settings panel even though the underlying @@ -456,7 +460,8 @@ impl App { self.is_downloading = true; self.downloading_repo = Some(repo_name.clone()); let dl_repo = repo_name.clone(); - let (progress_tx, progress_rx) = futures::channel::mpsc::unbounded::(); + let (progress_tx, progress_rx) = + futures::channel::mpsc::unbounded::<(u64, Option)>(); let progress_name = display_name; let download_task = Task::perform( @@ -519,8 +524,8 @@ impl App { }, ); - let progress_task = Task::run(progress_rx, move |pct| { - Message::DownloadProgress(progress_name.clone(), pct) + let progress_task = Task::run(progress_rx, move |(downloaded, total)| { + Message::DownloadProgress(progress_name.clone(), downloaded, total) }); // Keep an abort handle so CancelDownload actually stops @@ -537,16 +542,40 @@ impl App { } Task::none() } - Message::DownloadProgress(filename, progress) => { + Message::DownloadProgress(filename, downloaded, total) => { // Ignore late progress events from a cancelled/finished download // so the toast cannot resurrect after CancelDownload. if self.is_downloading { - self.download_progress = Some((filename, progress)); + let fraction = total + .filter(|t| *t > 0) + .map(|t| downloaded as f32 / t as f32) + .unwrap_or(0.0); + self.download_progress = Some((filename, fraction)); + self.download_bytes = Some((downloaded, total)); + // Transfer speed: exponential moving average over samples. + let now = std::time::Instant::now(); + if let Some((t0, b0)) = self.last_progress_sample { + let dt = now.duration_since(t0).as_secs_f32(); + if dt > 0.05 && downloaded >= b0 { + let inst = (downloaded - b0) as f32 / dt; + self.download_speed = if self.download_speed > 0.0 { + 0.7 * self.download_speed + 0.3 * inst + } else { + inst + }; + self.last_progress_sample = Some((now, downloaded)); + } + } else { + self.last_progress_sample = Some((now, downloaded)); + } } Task::none() } Message::DownloadCompleted(result) => { self.download_progress = None; + self.download_bytes = None; + self.download_speed = 0.0; + self.last_progress_sample = None; self.is_downloading = false; self.download_abort = None; self.downloading_repo = None; @@ -607,6 +636,9 @@ impl App { } } self.download_progress = None; + self.download_bytes = None; + self.download_speed = 0.0; + self.last_progress_sample = None; self.is_downloading = false; self.status_message = i18n::t("download_cancelled"); self.push_notification(i18n::t("download_cancelled"), NotificationLevel::Warning) @@ -830,6 +862,48 @@ impl App { { self.settings_category = self.settings_category.saturating_sub(1); } + // Grid traversal: Down/Up move a highlight over the + // visible rows (store repos then local apps); Enter + // activates it. Keys are stable names, not indexes, + // so a catalog refresh cannot shift the highlight. + iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowDown) + if !self.show_settings + && !self.show_github_menu + && !self.show_first_launch + && self.active_colony_repo.is_none() => + { + let keys = self.grid_keys(); + if !keys.is_empty() { + let next = match &self.keyboard_cursor { + Some(cur) => keys + .iter() + .position(|k| k == cur) + .map(|i| (i + 1).min(keys.len() - 1)) + .unwrap_or(0), + None => 0, + }; + self.keyboard_cursor = Some(keys[next].clone()); + } + } + iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowUp) + if !self.show_settings + && !self.show_github_menu + && !self.show_first_launch + && self.active_colony_repo.is_none() => + { + let keys = self.grid_keys(); + if !keys.is_empty() { + let next = match &self.keyboard_cursor { + Some(cur) => keys + .iter() + .position(|k| k == cur) + .map(|i| i.saturating_sub(1)) + .unwrap_or(0), + None => 0, + }; + self.keyboard_cursor = Some(keys[next].clone()); + } + } iced::keyboard::Key::Named(iced::keyboard::key::Named::PageDown) if self.show_settings => { @@ -846,14 +920,31 @@ impl App { && !self.show_first_launch && self.active_colony_repo.is_none() => { - let first = - self.filtered_colony_repos().first().map(|r| r.name.clone()); - if let Some(name) = first { - self.active_colony_repo = Some(name); - // Refresh the (repo, tab) markdown cache — the - // detail view reads only cached blocks/placeholder - // now, so opening a repo must recompute them. - self.refresh_detail_markdown(); + // Activate the keyboard highlight when there is + // one, else fall back to the first store row. + let target = self.keyboard_cursor.clone().or_else(|| { + self.filtered_colony_repos() + .first() + .map(|r| format!("repo:{}", r.name)) + }); + match target { + Some(key) if key.starts_with("repo:") => { + self.active_colony_repo = + Some(key["repo:".len()..].to_string()); + // Refresh the (repo, tab) markdown cache — + // the detail view reads cached blocks only. + self.refresh_detail_markdown(); + } + Some(key) if key.starts_with("app:") => { + let name = &key["app:".len()..]; + if let Some(app) = + self.applications.iter().find(|a| a.name == name) + { + let exec = app.exec.clone(); + return self.update(Message::LaunchApp(exec)); + } + } + _ => {} } } _ => {} @@ -1303,7 +1394,8 @@ impl App { self.download_progress = Some((asset.clone(), 0.0)); self.status_message = i18n::t_fmt("downloading", &[("file", &asset)]); - let (progress_tx, progress_rx) = futures::channel::mpsc::unbounded::(); + let (progress_tx, progress_rx) = + futures::channel::mpsc::unbounded::<(u64, Option)>(); let download_task = Task::perform( async move { @@ -1314,7 +1406,14 @@ impl App { Message::LauncherDownloadCompleted, ); - let progress_task = Task::run(progress_rx, Message::LauncherDownloadProgress); + let progress_task = Task::run(progress_rx, |(downloaded, total)| { + Message::LauncherDownloadProgress( + total + .filter(|t| *t > 0) + .map(|t| downloaded as f32 / t as f32) + .unwrap_or(0.0), + ) + }); let (task, handle) = Task::batch([download_task, progress_task]).abortable(); self.download_abort = Some(handle);