Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 152 additions & 7 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
},
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
Expand All @@ -14,6 +36,13 @@ pub struct TemplateApp {
logo_texture: Option<egui::TextureHandle>,
#[serde(skip)]
logo_loaded: bool,
// Loader and toast state
#[serde(skip)]
loading_state: LoadingState,
#[serde(skip)]
toast_message: Option<String>,
#[serde(skip)]
toast_timer: f32,
}

impl Default for TemplateApp {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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:");
Expand Down Expand Up @@ -129,5 +272,7 @@ impl eframe::App for TemplateApp {
}
}
});

// Show loading spinner if needed
}
}
4 changes: 4 additions & 0 deletions src/db/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,8 @@ impl GithubDb {
pub fn get_repos(&self) -> Arc<Mutex<Vec<Repository>>> {
Arc::clone(&self.repos)
}

pub fn is_loading(&self) -> bool {
*self.is_loading.lock().unwrap()
}
}
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
mod app;
mod db;
pub use app::TemplateApp;
pub use db::*;
pub use db::*;
pub mod utility;
124 changes: 124 additions & 0 deletions src/utility.rs
Original file line number Diff line number Diff line change
@@ -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<f32>) -> 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<egui::Pos2> = (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<f32>) -> 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<egui::Pos2> = (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<egui::Pos2> = (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
}
Loading