diff --git a/src/app.rs b/src/app.rs index 9336ae6..65162b2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,27 @@ +// Use the utility module from crate root +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 @@ -14,6 +36,13 @@ pub struct TemplateApp { logo_texture: Option, #[serde(skip)] logo_loaded: bool, + // Loader and toast state + #[serde(skip)] + loading_state: LoadingState, + #[serde(skip)] + toast_message: Option, + #[serde(skip)] + toast_timer: f32, } impl Default for TemplateApp { @@ -25,6 +54,9 @@ impl Default for TemplateApp { db: GithubDb::new(), logo_texture: None, logo_loaded: false, + loading_state: LoadingState::Idle, + toast_message: None, + toast_timer: 0.0, } } } @@ -57,6 +89,43 @@ 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 = 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(); + // 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()), + font_color, + ); + } + } + + fn trigger_toast(&mut self, message: &str) { + self.toast_message = Some(message.to_owned()); + self.toast_timer = 2.5; // seconds + } } impl eframe::App for TemplateApp { @@ -67,25 +136,99 @@ 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) { + // 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 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 let LoadingState::Loading { message, .. } = &self.loading_state { + egui::Area::new(Id::new("loading_spinner_overlay")) + .fixed_pos((ctx.screen_rect().center().x - 100.0, ctx.screen_rect().center().y - 100.0)) + .show(ctx, |ui| { + 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 + 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"]; 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.db.set_language(lang); - self.db.load_from_indexeddb(); + 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.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.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:"); @@ -129,5 +272,7 @@ impl eframe::App for TemplateApp { } } }); + + // Show loading spinner if needed } } 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..2f8d310 --- /dev/null +++ b/src/utility.rs @@ -0,0 +1,124 @@ +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 +} + +// 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 +}