From e4606beffee5aeb218bf50e468d547a656c65897 Mon Sep 17 00:00:00 2001 From: h0lybyte <5599058+h0lybyte@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:40:55 -0400 Subject: [PATCH 1/3] style: adding a toast and skeleton loader. --- src/app.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++++- src/db/github.rs | 4 ++ src/lib.rs | 3 +- src/utility.rs | 58 +++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/utility.rs diff --git a/src/app.rs b/src/app.rs index 9336ae6..5f61493 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,6 @@ +// Use the utility module from crate root +use crate::utility::show_loading_spinner; +use egui::Id; use crate::db::github::{GithubDb, Repository}; /// We derive Deserialize/Serialize so we can persist app state on shutdown. @@ -14,6 +17,17 @@ pub struct TemplateApp { logo_texture: Option, #[serde(skip)] logo_loaded: bool, + // Loading and toast state + #[serde(skip)] + is_loading: bool, + #[serde(skip)] + loading_message: String, + #[serde(skip)] + toast_message: Option, + #[serde(skip)] + toast_timer: f32, + #[serde(skip)] + loading_timer: f32, } impl Default for TemplateApp { @@ -25,6 +39,11 @@ impl Default for TemplateApp { db: GithubDb::new(), logo_texture: None, logo_loaded: false, + is_loading: false, + loading_message: String::new(), + toast_message: None, + toast_timer: 0.0, + loading_timer: 0.0, } } } @@ -57,6 +76,36 @@ impl TemplateApp { .cloned() .collect() } + + fn show_toast(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) { + if let Some(msg) = &self.toast_message { + let toast_height = 32.0; + let toast_width = 300.0; + let rect = egui::Rect::from_min_size( + ui.max_rect().center_top() + egui::vec2(-toast_width / 2.0, 0.0), + egui::vec2(toast_width, toast_height), + ); + let painter = ui.painter(); + painter.rect_filled(rect, 8.0, egui::Color32::from_rgba_unmultiplied(30, 144, 255, 220)); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + msg, + egui::TextStyle::Button.resolve(ui.style()), + egui::Color32::WHITE, + ); + } + } + + fn trigger_toast(&mut self, message: &str) { + self.toast_message = Some(message.to_owned()); + self.toast_timer = 2.5; // seconds + } + + fn update_loading_state(&mut self) { + // Use a public getter for is_loading + self.is_loading = self.db.is_loading(); + } } impl eframe::App for TemplateApp { @@ -67,6 +116,42 @@ impl eframe::App for TemplateApp { /// Called each time the UI needs repainting, which may be many times per second. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.update_loading_state(); + // Toast timer logic + if let Some(_) = self.toast_message { + let dt = ctx.input(|i| i.unstable_dt); + self.toast_timer -= dt; + if self.toast_timer <= 0.0 { + self.toast_message = None; + } + } + // Loading timer logic (for fake spinner) + if self.is_loading && self.loading_timer > 0.0 { + let dt = ctx.input(|i| i.unstable_dt); + self.loading_timer -= dt; + if self.loading_timer <= 0.0 { + self.is_loading = false; + self.loading_timer = 0.0; + self.trigger_toast(&self.loading_message.replace("Switching to ", "Switched to ").replace("...", "!")); + // Actually switch language and load data here if needed + } + } + // Show loading spinner overlay if loading + if self.is_loading { + egui::Area::new(Id::new("loading_spinner_overlay")) + .fixed_pos((ctx.screen_rect().center().x - 60.0, ctx.screen_rect().center().y - 60.0)) + .show(ctx, |ui| { + show_loading_spinner(ui, &self.loading_message, None); + }); + } + // Show toast if present + if self.toast_message.is_some() { + egui::Area::new(Id::new("toast_area")) + .fixed_pos((ctx.screen_rect().center().x - 150.0, ctx.screen_rect().bottom() - 60.0)) + .show(ctx, |ui| { + self.show_toast(ctx, ui); + }); + } // Define available languages here for easy extensibility // To add a new language, just add it to this array const LANGUAGE_OPTIONS: &[&str] = &["Rust", "Python", "Javascript"]; @@ -76,15 +161,22 @@ impl eframe::App for TemplateApp { for &lang in LANGUAGE_OPTIONS.iter() { let selected = self.db.get_language() == lang; if ui.radio(selected, lang).clicked() { - self.db.set_language(lang); - self.db.load_from_indexeddb(); + self.loading_message = format!("Switching to {}...", lang); + self.is_loading = true; + self.loading_timer = 3.0; + // self.db.set_language(lang); // Move this to after loading if you want to delay the switch + // self.db.load_from_indexeddb(); // Move this to after loading if you want to delay the load } } ui.separator(); if ui.button("Sync").clicked() { + self.loading_message = "Syncing repositories...".to_owned(); + self.is_loading = true; self.db.sync_and_store(); } if ui.button("Clear Cache").clicked() { + self.loading_message = "Clearing cache...".to_owned(); + self.is_loading = true; self.db.clear_indexeddb(); } ui.separator(); @@ -129,5 +221,17 @@ impl eframe::App for TemplateApp { } } }); + + // Update and show loading state + self.update_loading_state(); + if self.is_loading { + let _response = ctx.screen_rect(); + // Remove all ctx.layer_painter() usages and related painter code from update and overlays + // Only use egui::Area and Ui for overlays and drawing + } + + // Remove this line, it is invalid and causes errors: + // self.show_toast(ctx, &mut ctx.layer_painter()); + // The toast is already shown via egui::Area above. } } diff --git a/src/db/github.rs b/src/db/github.rs index 9da28d8..172036e 100644 --- a/src/db/github.rs +++ b/src/db/github.rs @@ -367,4 +367,8 @@ impl GithubDb { pub fn get_repos(&self) -> Arc>> { Arc::clone(&self.repos) } + + pub fn is_loading(&self) -> bool { + *self.is_loading.lock().unwrap() + } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 0a4b607..1d6e2f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ mod app; mod db; pub use app::TemplateApp; -pub use db::*; \ No newline at end of file +pub use db::*; +pub mod utility; \ No newline at end of file diff --git a/src/utility.rs b/src/utility.rs new file mode 100644 index 0000000..dcfe03a --- /dev/null +++ b/src/utility.rs @@ -0,0 +1,58 @@ +use egui::{self, Color32, Pos2, Rect, Response, Sense, Ui}; + +/// Draws a spinning loading indicator with a message. +pub fn show_loading_spinner(ui: &mut Ui, message: &str, progress: Option) -> Response { + // Spinner parameters + let spinner_radius = 16.0; + let spinner_thickness = 4.0; + let spinner_color = Color32::from_rgb(100, 200, 255); + let spinner_size = egui::Vec2::splat(spinner_radius * 2.0 + spinner_thickness * 2.0); + + // Reserve space for spinner and message + let (rect, response) = ui.allocate_exact_size(spinner_size, Sense::hover()); + let center = rect.center(); + + // Animate spinner based on time + let time = ui.input(|i| i.time); + let start_angle = time as f32 * 2.0 * std::f32::consts::PI; + let end_angle = start_angle + std::f32::consts::PI * 1.5; + + // Draw spinner arc using circle_segment for partial arc + use egui::Stroke; + let n_points = 64; + let points: Vec = (0..=n_points) + .map(|i| { + let t = i as f32 / n_points as f32; + let angle = start_angle + t * (end_angle - start_angle); + egui::pos2( + center.x + spinner_radius * angle.cos(), + center.y + spinner_radius * angle.sin(), + ) + }) + .collect(); + ui.painter().add(egui::Shape::line( + points, + Stroke::new(spinner_thickness, spinner_color), + )); + + // Optionally, show progress as text + if let Some(p) = progress { + let pct = (p * 100.0).round() as u32; + ui.painter().text( + center, + egui::Align2::CENTER_CENTER, + format!("{}%", pct), + egui::TextStyle::Body.resolve(ui.style()), + Color32::WHITE, + ); + } + + // Show message below spinner + let message_rect = Rect::from_min_max( + Pos2::new(rect.left(), rect.bottom() + 4.0), + Pos2::new(rect.right(), rect.bottom() + 28.0), + ); + ui.put(message_rect, egui::widgets::Label::new(message)); + + response +} From 620ed00f6855205e9090914879fa31711365168c Mon Sep 17 00:00:00 2001 From: h0lybyte <5599058+h0lybyte@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:47:24 -0400 Subject: [PATCH 2/3] style: added a fake spinner --- src/app.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5f61493..ac0482d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -116,7 +116,6 @@ impl eframe::App for TemplateApp { /// Called each time the UI needs repainting, which may be many times per second. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.update_loading_state(); // Toast timer logic if let Some(_) = self.toast_message { let dt = ctx.input(|i| i.unstable_dt); @@ -132,8 +131,14 @@ impl eframe::App for TemplateApp { if self.loading_timer <= 0.0 { self.is_loading = false; self.loading_timer = 0.0; - self.trigger_toast(&self.loading_message.replace("Switching to ", "Switched to ").replace("...", "!")); // Actually switch language and load data here if needed + if let Some(pending_language) = self.loading_message.strip_prefix("Switching to ").and_then(|s| s.strip_suffix("...")) { + self.db.set_language(pending_language.trim()); + self.db.load_from_indexeddb(); + self.trigger_toast(&self.loading_message.replace("Switching to ", "Switched to ").replace("...", "!")); + } else { + self.trigger_toast(&self.loading_message.replace("...", "!")); + } } } // Show loading spinner overlay if loading @@ -160,12 +165,10 @@ impl eframe::App for TemplateApp { ui.label("Select Language:"); for &lang in LANGUAGE_OPTIONS.iter() { let selected = self.db.get_language() == lang; - if ui.radio(selected, lang).clicked() { + if ui.radio(selected, lang).clicked() && !self.is_loading { self.loading_message = format!("Switching to {}...", lang); self.is_loading = true; self.loading_timer = 3.0; - // self.db.set_language(lang); // Move this to after loading if you want to delay the switch - // self.db.load_from_indexeddb(); // Move this to after loading if you want to delay the load } } ui.separator(); @@ -222,12 +225,9 @@ impl eframe::App for TemplateApp { } }); - // Update and show loading state - self.update_loading_state(); + // Show loading spinner if needed if self.is_loading { - let _response = ctx.screen_rect(); - // Remove all ctx.layer_painter() usages and related painter code from update and overlays - // Only use egui::Area and Ui for overlays and drawing + // Removed redundant self.update_loading_state() call here } // Remove this line, it is invalid and causes errors: From f1d8fbaa30ee9022b1101bda06a7f1abbdde1535 Mon Sep 17 00:00:00 2001 From: h0lybyte <5599058+h0lybyte@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:24:24 -0400 Subject: [PATCH 3/3] fix: spinner now works but the data is still not synced. --- src/app.rs | 153 +++++++++++++++++++++++++++++++------------------ src/utility.rs | 66 +++++++++++++++++++++ 2 files changed, 163 insertions(+), 56 deletions(-) diff --git a/src/app.rs b/src/app.rs index ac0482d..65162b2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,27 @@ // Use the utility module from crate root -use crate::utility::show_loading_spinner; +use crate::utility::show_loading_spinner_custom; use egui::Id; use crate::db::github::{GithubDb, Repository}; +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub enum LoadingState { + Idle, + Loading { + kind: LoadingKind, + timer: f32, + message: String, + pending_language: Option, + }, + Error, +} + +#[derive(serde::Deserialize, serde::Serialize, PartialEq, Eq, Debug, Clone, Copy)] +pub enum LoadingKind { + LanguageSwitch, + Sync, + ClearCache, +} + /// We derive Deserialize/Serialize so we can persist app state on shutdown. #[derive(serde::Deserialize, serde::Serialize)] #[serde(default)] // if we add new fields, give them default values when deserializing old state @@ -17,17 +36,13 @@ pub struct TemplateApp { logo_texture: Option, #[serde(skip)] logo_loaded: bool, - // Loading and toast state + // Loader and toast state #[serde(skip)] - is_loading: bool, - #[serde(skip)] - loading_message: String, + loading_state: LoadingState, #[serde(skip)] toast_message: Option, #[serde(skip)] toast_timer: f32, - #[serde(skip)] - loading_timer: f32, } impl Default for TemplateApp { @@ -39,11 +54,9 @@ impl Default for TemplateApp { db: GithubDb::new(), logo_texture: None, logo_loaded: false, - is_loading: false, - loading_message: String::new(), + loading_state: LoadingState::Idle, toast_message: None, toast_timer: 0.0, - loading_timer: 0.0, } } } @@ -79,20 +92,32 @@ impl TemplateApp { fn show_toast(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) { if let Some(msg) = &self.toast_message { - let toast_height = 32.0; - let toast_width = 300.0; + let toast_height = 44.0; + let toast_width = 360.0; let rect = egui::Rect::from_min_size( ui.max_rect().center_top() + egui::vec2(-toast_width / 2.0, 0.0), egui::vec2(toast_width, toast_height), ); let painter = ui.painter(); - painter.rect_filled(rect, 8.0, egui::Color32::from_rgba_unmultiplied(30, 144, 255, 220)); + // Fade out effect based on timer + let alpha = (self.toast_timer / 2.5).clamp(0.0, 1.0); + // Dark stone 950 background + let bg_color = egui::Color32::from_rgba_unmultiplied(18, 24, 27, (240.0 * alpha) as u8); + // Bright cyan font + let font_color = egui::Color32::from_rgb(0, 255, 255); + // Light purple border/accent + let accent_color = egui::Color32::from_rgb(180, 140, 255); + // Shadow + let shadow_rect = rect.expand(10.0); + painter.rect_filled(shadow_rect, 18.0, egui::Color32::from_rgba_unmultiplied(80, 0, 120, (40.0 * alpha) as u8)); + painter.rect_filled(rect, 12.0, bg_color); + painter.rect_stroke(rect, 12.0, egui::epaint::Stroke::new(2.0, accent_color), egui::epaint::StrokeKind::Outside); painter.text( rect.center(), egui::Align2::CENTER_CENTER, msg, egui::TextStyle::Button.resolve(ui.style()), - egui::Color32::WHITE, + font_color, ); } } @@ -101,11 +126,6 @@ impl TemplateApp { self.toast_message = Some(message.to_owned()); self.toast_timer = 2.5; // seconds } - - fn update_loading_state(&mut self) { - // Use a public getter for is_loading - self.is_loading = self.db.is_loading(); - } } impl eframe::App for TemplateApp { @@ -124,29 +144,46 @@ impl eframe::App for TemplateApp { self.toast_message = None; } } - // Loading timer logic (for fake spinner) - if self.is_loading && self.loading_timer > 0.0 { - let dt = ctx.input(|i| i.unstable_dt); - self.loading_timer -= dt; - if self.loading_timer <= 0.0 { - self.is_loading = false; - self.loading_timer = 0.0; - // Actually switch language and load data here if needed - if let Some(pending_language) = self.loading_message.strip_prefix("Switching to ").and_then(|s| s.strip_suffix("...")) { - self.db.set_language(pending_language.trim()); - self.db.load_from_indexeddb(); - self.trigger_toast(&self.loading_message.replace("Switching to ", "Switched to ").replace("...", "!")); - } else { - self.trigger_toast(&self.loading_message.replace("...", "!")); + // Loading state machine + match &mut self.loading_state { + LoadingState::Idle => {}, + LoadingState::Loading { kind, timer, message: _, pending_language } => { + let dt = ctx.input(|i| i.unstable_dt); + *timer -= dt; + if *timer <= 0.0 { + match kind { + LoadingKind::LanguageSwitch => { + if let Some(lang) = pending_language.take() { + self.db.set_language(&lang); + self.db.load_from_indexeddb(); + self.trigger_toast(&format!("Switched to {}!", lang)); + } + }, + LoadingKind::Sync => { + self.db.sync_and_store(); + self.trigger_toast("Repositories synced!"); + }, + LoadingKind::ClearCache => { + self.db.clear_indexeddb(); + self.trigger_toast("Cache cleared!"); + }, + } + self.loading_state = LoadingState::Idle; } - } + }, + LoadingState::Error => { + // Could show an error toast or overlay here + }, } // Show loading spinner overlay if loading - if self.is_loading { + if let LoadingState::Loading { message, .. } = &self.loading_state { egui::Area::new(Id::new("loading_spinner_overlay")) - .fixed_pos((ctx.screen_rect().center().x - 60.0, ctx.screen_rect().center().y - 60.0)) + .fixed_pos((ctx.screen_rect().center().x - 100.0, ctx.screen_rect().center().y - 100.0)) .show(ctx, |ui| { - show_loading_spinner(ui, &self.loading_message, None); + ui.spacing_mut().item_spacing = egui::vec2(18.0, 18.0); + ui.add_space(48.0); + show_loading_spinner_custom(ui, message, Some(140.0)); + ui.add_space(48.0); }); } // Show toast if present @@ -163,24 +200,35 @@ impl eframe::App for TemplateApp { egui::SidePanel::left("side_panel").show(ctx, |ui| { ui.heading("Repository Sync & Search"); ui.label("Select Language:"); + let is_loading = matches!(self.loading_state, LoadingState::Loading { .. }); for &lang in LANGUAGE_OPTIONS.iter() { let selected = self.db.get_language() == lang; - if ui.radio(selected, lang).clicked() && !self.is_loading { - self.loading_message = format!("Switching to {}...", lang); - self.is_loading = true; - self.loading_timer = 3.0; + if ui.radio(selected, lang).clicked() && !is_loading { + self.loading_state = LoadingState::Loading { + kind: LoadingKind::LanguageSwitch, + timer: 2.0, + message: format!("Switching to {}...", lang), + pending_language: Some(lang.to_owned()), + }; } } ui.separator(); - if ui.button("Sync").clicked() { - self.loading_message = "Syncing repositories...".to_owned(); - self.is_loading = true; - self.db.sync_and_store(); + let is_loading = matches!(self.loading_state, LoadingState::Loading { .. }); + if ui.button("Sync").clicked() && !is_loading { + self.loading_state = LoadingState::Loading { + kind: LoadingKind::Sync, + timer: 2.0, + message: "Syncing repositories...".to_owned(), + pending_language: None, + }; } - if ui.button("Clear Cache").clicked() { - self.loading_message = "Clearing cache...".to_owned(); - self.is_loading = true; - self.db.clear_indexeddb(); + if ui.button("Clear Cache").clicked() && !is_loading { + self.loading_state = LoadingState::Loading { + kind: LoadingKind::ClearCache, + timer: 1.5, + message: "Clearing cache...".to_owned(), + pending_language: None, + }; } ui.separator(); ui.label("Search:"); @@ -226,12 +274,5 @@ impl eframe::App for TemplateApp { }); // Show loading spinner if needed - if self.is_loading { - // Removed redundant self.update_loading_state() call here - } - - // Remove this line, it is invalid and causes errors: - // self.show_toast(ctx, &mut ctx.layer_painter()); - // The toast is already shown via egui::Area above. } } diff --git a/src/utility.rs b/src/utility.rs index dcfe03a..2f8d310 100644 --- a/src/utility.rs +++ b/src/utility.rs @@ -56,3 +56,69 @@ pub fn show_loading_spinner(ui: &mut Ui, message: &str, progress: Option) - response } + +// Custom spinner with dark stone 950 background, bright cyan, and light purple +pub fn show_loading_spinner_custom(ui: &mut egui::Ui, message: &str, size: Option) -> egui::Response { + let spinner_radius = size.unwrap_or(32.0) / 2.0; + let spinner_thickness = 6.0; + let spinner_color = egui::Color32::from_rgb(0, 255, 255); // bright cyan + let accent_color = egui::Color32::from_rgb(180, 140, 255); // light purple + let bg_color = egui::Color32::from_rgb(18, 24, 27); // stone 950 + let spinner_size = egui::Vec2::splat(spinner_radius * 2.0 + spinner_thickness * 2.0 + 16.0); + + // Reserve space for spinner and message + let (rect, response) = ui.allocate_exact_size(spinner_size, egui::Sense::hover()); + let center = rect.center(); + + // Draw border as a slightly larger rounded rectangle (simulated border) + let border_rect = rect.expand(1.5); + ui.painter().rect_filled(border_rect, 18.0, accent_color); + // Draw background + ui.painter().rect_filled(rect, 18.0, bg_color); + + // Animate spinner based on time + let time = ui.input(|i| i.time); + let start_angle = time as f32 * 2.0 * std::f32::consts::PI; + let end_angle = start_angle + std::f32::consts::PI * 1.5; + let n_points = 64; + let points: Vec = (0..=n_points) + .map(|i| { + let t = i as f32 / n_points as f32; + let angle = start_angle + t * (end_angle - start_angle); + egui::pos2( + center.x + spinner_radius * angle.cos(), + center.y + spinner_radius * angle.sin(), + ) + }) + .collect(); + ui.painter().add(egui::Shape::line( + points, + egui::Stroke::new(spinner_thickness, spinner_color), + )); + // Accent arc + let accent_start = start_angle + std::f32::consts::PI * 0.5; + let accent_end = accent_start + std::f32::consts::PI * 0.5; + let accent_points: Vec = (0..=n_points/4) + .map(|i| { + let t = i as f32 / (n_points/4) as f32; + let angle = accent_start + t * (accent_end - accent_start); + egui::pos2( + center.x + spinner_radius * angle.cos(), + center.y + spinner_radius * angle.sin(), + ) + }) + .collect(); + ui.painter().add(egui::Shape::line( + accent_points, + egui::Stroke::new(spinner_thickness + 1.5, accent_color), + )); + // Show message below spinner, bright cyan + let message_rect = egui::Rect::from_min_max( + egui::Pos2::new(rect.left(), rect.bottom() + 8.0), + egui::Pos2::new(rect.right(), rect.bottom() + 36.0), + ); + ui.put(message_rect, egui::widgets::Label::new( + egui::RichText::new(message).color(spinner_color).strong() + )); + response +}