diff --git a/src/app.rs b/src/app.rs index f24b1e9..b2d6881 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,7 +76,24 @@ enum SortMode { ChannelAsc, } -#[derive(Clone, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ViewMode { + List, + Card, + Grid, +} + +impl ViewMode { + fn from_config(s: &str) -> Self { + match s { + "card" => ViewMode::Card, + "grid" => ViewMode::Grid, + _ => ViewMode::List, + } + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] enum SidebarView { Channels, All, @@ -165,6 +182,12 @@ pub struct App { plex_status: String, db: Database, card_density: f32, + /// Theme-aware accent colors, recomputed whenever the theme changes. + theme_accents: crate::theme::ThemeAccents, + /// Global default video-list render mode (from config). + default_view_mode: ViewMode, + /// Per-SidebarView overrides; a view absent here falls back to default. + view_mode_overrides: std::collections::HashMap, /// When set, the global UI zoom changed and config should be saved at /// this instant (debounced so a slider drag / key-repeat doesn't write /// config.toml every frame). Synced from `ctx.zoom_factor()` in update(). @@ -356,6 +379,43 @@ fn idx_to_sponsorblock(i: usize) -> Option { } impl App { + /// The view mode to use for `view`: the per-view override if set, else + /// the global default. + fn view_mode_for(&self, view: &SidebarView) -> ViewMode { + self.view_mode_overrides + .get(view) + .copied() + .unwrap_or(self.default_view_mode) + } + + /// Paint a consistent missing-thumbnail placeholder inside `rect`: + /// a theme-tinted two-tone fill + a single glyph. Used for both channel + /// and video cards so the empty states stop diverging. + fn paint_thumb_placeholder(&self, ui: &egui::Ui, rect: egui::Rect, glyph: &str, density: f32) { + let v = ui.visuals(); + let top = v.faint_bg_color.to_array(); + let bot = v.panel_fill.to_array(); + // egui has no native gradient; fake one with two stacked rects. + let mid = rect.top() + rect.height() * 0.5; + ui.painter().rect_filled( + egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, mid)), + 4.0, + egui::Color32::from_rgba_unmultiplied(top[0], top[1], top[2], 255), + ); + ui.painter().rect_filled( + egui::Rect::from_min_max(egui::pos2(rect.min.x, mid), rect.max), + 4.0, + egui::Color32::from_rgba_unmultiplied(bot[0], bot[1], bot[2], 255), + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + glyph, + egui::FontId::proportional(24.0 * density), + v.weak_text_color(), + ); + } + pub fn new(cc: &eframe::CreationContext<'_>, tray: Option) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let config_path = cwd.join("config.toml"); @@ -369,6 +429,14 @@ impl App { }; theme::apply(&cc.egui_ctx, &config.ui.theme); + let theme_accents = theme::accents_for(&config.ui.theme); + let default_view_mode = ViewMode::from_config(&config.ui.default_view_mode); + // Typography & spacing baseline: slightly larger base text, more + // breathable item spacing, comfortable button padding. + let mut style = (*cc.egui_ctx.style()).clone(); + style.spacing.item_spacing = egui::vec2(8.0, 5.0); + style.spacing.button_padding = egui::vec2(6.0, 3.0); + cc.egui_ctx.set_style(style); // Restore the persisted global UI zoom. egui's built-in Ctrl +/-/0 // also drives zoom_factor; update() keeps config.ui.ui_scale synced // and persists changes so the size survives restarts. @@ -551,6 +619,9 @@ impl App { plex_status: String::new(), db, card_density: 1.0, + theme_accents, + default_view_mode, + view_mode_overrides: Default::default(), scale_save_at: None, sort_mode: SortMode::Title, watched, @@ -3176,6 +3247,36 @@ impl App { { self.config.ui.theme = id.to_string(); theme::apply(ctx, id); + self.theme_accents = theme::accents_for(id); + } + } + }); + ui.end_row(); + + ui.label("Default view:"); + egui::ComboBox::from_id_salt("default_view_mode_combo") + .selected_text(match self.default_view_mode { + ViewMode::List => "List", + ViewMode::Card => "Card", + ViewMode::Grid => "Grid", + }) + .show_ui(ui, |ui| { + for (mode, label) in [ + (ViewMode::List, "List"), + (ViewMode::Card, "Card"), + (ViewMode::Grid, "Grid"), + ] { + if ui + .selectable_label(self.default_view_mode == mode, label) + .clicked() + { + self.default_view_mode = mode; + self.config.ui.default_view_mode = match mode { + ViewMode::List => "list", + ViewMode::Card => "card", + ViewMode::Grid => "grid", + } + .to_string(); } } }); @@ -3969,14 +4070,7 @@ impl App { .paint_at(ui, thumb_rect); } None => { - ui.painter().rect_filled(thumb_rect, 4.0, egui::Color32::from_gray(30)); - ui.painter().text( - thumb_rect.center(), - egui::Align2::CENTER_CENTER, - "📺", - egui::FontId::proportional(28.0 * density), - egui::Color32::from_gray(100), - ); + self.paint_thumb_placeholder(ui, thumb_rect, "🎬", density); } } @@ -4180,209 +4274,369 @@ impl App { return; } + // View-mode toggle: ☰ List / ▢ Card / ◫ Grid. Writes a per-view + // override; a view with no override falls back to the global default. + ui.horizontal(|ui| { + ui.label(egui::RichText::new("View:").weak().small()); + let current = self.view_mode_for(&self.sidebar_view); + for (mode, label) in [ + (ViewMode::List, "☰ List"), + (ViewMode::Card, "▢ Card"), + (ViewMode::Grid, "◫ Grid"), + ] { + if ui.selectable_label(current == mode, label).clicked() { + self.view_mode_overrides + .insert(self.sidebar_view.clone(), mode); + } + } + }); + ui.separator(); + let density = self.card_density; + let view_mode = self.view_mode_for(&self.sidebar_view); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - let thumb_w = (176.0 * density).round(); - let thumb_h = (99.0 * density).round(); - let thumb_size = egui::vec2(thumb_w, thumb_h); - - for card in &cards { - let selected = self.selected_video.as_deref() == Some(card.id.as_str()); - let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str()); - let bulk_checked = self.bulk_selected.contains(&card.id); - let mut clicked_card = false; - let mut play_card = false; - let mut toggle_watched_card = false; - // Flag toggles deferred outside the row closure so we can - // mutate `self.flags` + DB without fighting the borrow checker. - let mut toggle_flag_card: Option<&'static str> = None; - - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click()); - let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p)); - match &texture { - Some(handle) => { - egui::Image::new(handle) - .maintain_aspect_ratio(true) - .paint_at(ui, rect); - } - None => { - ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38)); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - "▶", - egui::FontId::proportional(26.0 * density), - egui::Color32::from_gray(110), - ); - } - } - - if selected { - ui.painter().rect_stroke( - rect, - 4.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)), - ); - } - if is_playing { - ui.painter().rect_stroke( - rect, - 4.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)), - ); - } - if bulk_checked { - ui.painter().rect_stroke( - rect, - 4.0, - egui::Stroke::new(3.0, egui::Color32::from_rgb(180, 130, 240)), - ); - } - if card.watched { - ui.painter().rect_filled( - egui::Rect::from_min_size( - rect.min, - egui::vec2(rect.width(), rect.height() * 0.18), - ), - 0.0, - egui::Color32::from_rgba_premultiplied(30, 140, 60, 200), - ); - ui.painter().text( - rect.min + egui::vec2(4.0, 2.0), - egui::Align2::LEFT_TOP, - "✓ watched", - egui::FontId::proportional(10.0 * density), - egui::Color32::WHITE, - ); - } - - if resp.clicked() { - clicked_card = true; - } - if resp.double_clicked() { - play_card = true; - } - - ui.vertical(|ui| { - let title_color = if card.watched { - ui.visuals().weak_text_color() - } else { - ui.visuals().text_color() - }; - if ui - .selectable_label( - selected, - egui::RichText::new(&card.title) - .strong() - .color(title_color), - ) - .clicked() - { - clicked_card = true; - } - ui.horizontal(|ui| { - if show_channel { - ui.label(egui::RichText::new(&card.channel_name).small().weak()); - ui.label(egui::RichText::new("·").weak()); - } - ui.label(egui::RichText::new(&card.id).small().monospace().weak()); - if let Some(date) = card.upload_date.as_deref().map(format_upload_date) { - if !date.is_empty() { - ui.label(egui::RichText::new("·").weak()); - ui.label(egui::RichText::new(date).small().weak()); - } - } - if let Some(secs) = card.duration_secs { - ui.label(egui::RichText::new("·").weak()); - ui.label(egui::RichText::new(format_duration(secs)).small().weak()); - } - if let Some(bytes) = card.file_size { - ui.label(egui::RichText::new("·").weak()); - ui.label(egui::RichText::new(format_size(bytes)).small().weak()); - } - if card.has_live_chat { - ui.label(egui::RichText::new("· 💬").small().weak()); - } - if card.video_path.is_none() { - ui.label( - egui::RichText::new("· no video file") - .small() - .color(egui::Color32::from_rgb(200, 140, 90)), - ); - } - }); - ui.horizontal(|ui| { - if self.bulk_mode { - let chk_label = if bulk_checked { "☑" } else { "☐" }; - if ui.small_button(chk_label).clicked() { - clicked_card = true; // handled below - } - } else { - if card.video_path.is_some() && ui.small_button("▶ Play").clicked() { - play_card = true; - } - if let Some(pos) = card.resume_pos { - if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() { - play_card = true; - } - } - if ui.small_button("Details").clicked() { - clicked_card = true; - } - let w_label = if card.watched { "✓" } else { "○" }; - if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() { - toggle_watched_card = true; - } - let fav_label = if card.favourite { "★" } else { "☆" }; - if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() { - toggle_flag_card = Some("favourite"); - } - let bm_label = if card.bookmark { "🔖" } else { "🔖̲" }; - if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() { - toggle_flag_card = Some("bookmark"); - } - let wait_label = if card.waiting { "⏳" } else { "⏰" }; - if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() { - toggle_flag_card = Some("waiting"); - } - } - }); - }); - }); - - if self.bulk_mode { - if clicked_card { - let id = card.id.clone(); - if self.bulk_selected.contains(&id) { - self.bulk_selected.remove(&id); - } else { - self.bulk_selected.insert(id); - } - } - } else if play_card { - if let Some(p) = card.video_path.clone() { - let id = card.id.clone(); - self.play_with_tracking(&p, id); - } - self.selected_video = Some(card.id.clone()); - } else if clicked_card { - self.selected_video = Some(card.id.clone()); + match view_mode { + ViewMode::List | ViewMode::Card => { + self.render_video_rows(ui, ctx, &cards, density, show_channel, view_mode == ViewMode::Card); } - if toggle_watched_card { - let id = card.id.clone(); - self.toggle_watched(&id); + ViewMode::Grid => { + self.render_video_grid(ui, ctx, &cards, density, show_channel); } - if let Some(flag) = toggle_flag_card { - let id = card.id.clone(); - self.toggle_video_flag(&id, flag); - } - - ui.separator(); } }); self.cards_cache = cards; } + + /// Row-based render path shared by List and Card modes. When `as_card` + /// is true, each row is wrapped in a rounded faint-bg card with a hover + /// accent ring; otherwise the row is rendered flat (legacy List style). + fn render_video_rows( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + cards: &[Card], + density: f32, + show_channel: bool, + as_card: bool, + ) { + let thumb_w = (176.0 * density).round(); + let thumb_h = (99.0 * density).round(); + let thumb_size = egui::vec2(thumb_w, thumb_h); + + for card in cards { + let selected = self.selected_video.as_deref() == Some(card.id.as_str()); + let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str()); + let bulk_checked = self.bulk_selected.contains(&card.id); + let mut clicked_card = false; + let mut play_card = false; + let mut toggle_watched_card = false; + // Flag toggles deferred outside the row closure so we can + // mutate `self.flags` + DB without fighting the borrow checker. + let mut toggle_flag_card: Option<&'static str> = None; + + // Wrap each row in a Frame when rendering as a Card. + let mut card_rect: Option = None; + if as_card { + let frame = egui::Frame::default() + .fill(ui.visuals().faint_bg_color) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) + .rounding(egui::Rounding::same(8.0)) + .inner_margin(egui::Margin::same(8.0)) + .outer_margin(egui::Margin::symmetric(0.0, 3.0)); + let resp = frame.show(ui, |ui| { + ui.horizontal(|ui| { + self.render_row_body( + ui, ctx, card, density, thumb_size, show_channel, + selected, is_playing, bulk_checked, + &mut clicked_card, &mut play_card, + &mut toggle_watched_card, &mut toggle_flag_card, + ); + }); + }).response; + if resp.hovered() { + card_rect = Some(resp.rect); + } + } else { + ui.horizontal(|ui| { + self.render_row_body( + ui, ctx, card, density, thumb_size, show_channel, + selected, is_playing, bulk_checked, + &mut clicked_card, &mut play_card, + &mut toggle_watched_card, &mut toggle_flag_card, + ); + }); + } + + if let Some(rect) = card_rect { + ui.painter().rect_stroke( + rect, + 8.0, + egui::Stroke::new(1.5, self.theme_accents.accent), + ); + } + + self.apply_card_actions( + card, clicked_card, play_card, toggle_watched_card, toggle_flag_card, + ); + ui.separator(); + } + } + + /// Grid mode: YouTube/Plex-style vertical cards in a responsive grid. + fn render_video_grid( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + cards: &[Card], + density: f32, + show_channel: bool, + ) { + let thumb_w = (176.0 * density).round(); + let thumb_h = (99.0 * density).round(); + let card_w = thumb_w + 8.0; + let avail = ui.available_width(); + let cols = ((avail / card_w).floor() as usize).max(1); + + // Grid mode renders one cell at a time but still uses the deferred- + // flags pattern: actions are applied per-card right after its cell. + egui::Grid::new("video_grid") + .num_columns(cols) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + for card in cards { + let selected = self.selected_video.as_deref() == Some(card.id.as_str()); + let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str()); + let bulk_checked = self.bulk_selected.contains(&card.id); + let mut clicked_card = false; + let mut play_card = false; + let mut toggle_watched_card = false; + let mut toggle_flag_card: Option<&'static str> = None; + + ui.vertical(|ui| { + // `render_row_body` owns the thumbnail + rings + clicks, + // so the grid just lays out title/meta below it. + self.render_row_body( + ui, ctx, card, density, egui::vec2(thumb_w, thumb_h), show_channel, + selected, is_playing, bulk_checked, + &mut clicked_card, &mut play_card, + &mut toggle_watched_card, &mut toggle_flag_card, + ); + }); + + self.apply_card_actions( + card, clicked_card, play_card, toggle_watched_card, toggle_flag_card, + ); + ui.end_row(); + } + }); + } + + /// The thumbnail + metadata + flag-button body shared by List/Card/Grid. + /// Owns the thumbnail paint, selection/playing/bulk rings, watched + /// banner, and click/double-click sensing. Callers lay out the cell + /// (horizontal for List/Card, vertical for Grid) and pass `thumb_size`. + fn render_row_body( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + card: &Card, + density: f32, + thumb_size: egui::Vec2, + show_channel: bool, + selected: bool, + is_playing: bool, + bulk_checked: bool, + clicked_card: &mut bool, + play_card: &mut bool, + toggle_watched_card: &mut bool, + toggle_flag_card: &mut Option<&'static str>, + ) { + let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click()); + let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p)); + match &texture { + Some(handle) => { + egui::Image::new(handle) + .maintain_aspect_ratio(true) + .paint_at(ui, rect); + } + None => { + self.paint_thumb_placeholder(ui, rect, "🎬", density); + } + } + + if selected { + ui.painter().rect_stroke( + rect, + 4.0, + egui::Stroke::new(2.0, self.theme_accents.accent), + ); + } + if is_playing { + ui.painter().rect_stroke( + rect, + 4.0, + egui::Stroke::new(2.0, self.theme_accents.success), + ); + } + if bulk_checked { + ui.painter().rect_stroke( + rect, + 4.0, + egui::Stroke::new(3.0, self.theme_accents.warning), + ); + } + if card.watched { + ui.painter().rect_filled( + egui::Rect::from_min_size( + rect.min, + egui::vec2(rect.width(), rect.height() * 0.18), + ), + 0.0, + egui::Color32::from_rgba_premultiplied(30, 140, 60, 200), + ); + ui.painter().text( + rect.min + egui::vec2(4.0, 2.0), + egui::Align2::LEFT_TOP, + "✓ watched", + egui::FontId::proportional(10.0 * density), + egui::Color32::WHITE, + ); + } + + if resp.clicked() { + *clicked_card = true; + } + if resp.double_clicked() { + *play_card = true; + } + + ui.vertical(|ui| { + let title_color = if card.watched { + ui.visuals().weak_text_color() + } else { + ui.visuals().text_color() + }; + if ui + .selectable_label( + selected, + egui::RichText::new(&card.title) + .strong() + .size(14.0) + .color(title_color), + ) + .clicked() + { + *clicked_card = true; + } + ui.horizontal(|ui| { + if show_channel { + ui.label(egui::RichText::new(&card.channel_name).small().weak()); + ui.label(egui::RichText::new("·").weak()); + } + ui.label(egui::RichText::new(&card.id).small().monospace().weak()); + if let Some(date) = card.upload_date.as_deref().map(format_upload_date) { + if !date.is_empty() { + ui.label(egui::RichText::new("·").weak()); + ui.label(egui::RichText::new(date).small().weak()); + } + } + if let Some(secs) = card.duration_secs { + ui.label(egui::RichText::new("·").weak()); + ui.label(egui::RichText::new(format_duration(secs)).small().weak()); + } + if let Some(bytes) = card.file_size { + ui.label(egui::RichText::new("·").weak()); + ui.label(egui::RichText::new(format_size(bytes)).small().weak()); + } + if card.has_live_chat { + ui.label(egui::RichText::new("· 💬").small().weak()); + } + if card.video_path.is_none() { + ui.label( + egui::RichText::new("· no video file") + .small() + .color(egui::Color32::from_rgb(200, 140, 90)), + ); + } + }); + ui.horizontal(|ui| { + if self.bulk_mode { + let chk_label = if bulk_checked { "☑" } else { "☐" }; + if ui.small_button(chk_label).clicked() { + *clicked_card = true; // handled below + } + } else { + if card.video_path.is_some() && ui.small_button("▶ Play").clicked() { + *play_card = true; + } + if let Some(pos) = card.resume_pos { + if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() { + *play_card = true; + } + } + if ui.small_button("Details").clicked() { + *clicked_card = true; + } + let w_label = if card.watched { "✓" } else { "○" }; + if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() { + *toggle_watched_card = true; + } + let fav_label = if card.favourite { "★" } else { "☆" }; + if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() { + *toggle_flag_card = Some("favourite"); + } + let bm_label = if card.bookmark { "🔖" } else { "🔖̲" }; + if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() { + *toggle_flag_card = Some("bookmark"); + } + let wait_label = if card.waiting { "⏳" } else { "⏰" }; + if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() { + *toggle_flag_card = Some("waiting"); + } + } + }); + }); + } + + /// Apply the per-card deferred actions (click/play/watch/flag toggles). + fn apply_card_actions( + &mut self, + card: &Card, + clicked_card: bool, + play_card: bool, + toggle_watched_card: bool, + toggle_flag_card: Option<&'static str>, + ) { + if self.bulk_mode { + if clicked_card { + let id = card.id.clone(); + if self.bulk_selected.contains(&id) { + self.bulk_selected.remove(&id); + } else { + self.bulk_selected.insert(id); + } + } + } else if play_card { + if let Some(p) = card.video_path.clone() { + let id = card.id.clone(); + self.play_with_tracking(&p, id); + } + self.selected_video = Some(card.id.clone()); + } else if clicked_card { + self.selected_video = Some(card.id.clone()); + } + if toggle_watched_card { + let id = card.id.clone(); + self.toggle_watched(&id); + } + if let Some(flag) = toggle_flag_card { + let id = card.id.clone(); + self.toggle_video_flag(&id, flag); + } + } } impl eframe::App for App { diff --git a/src/config.rs b/src/config.rs index fa10d02..7ecd4bb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -207,9 +207,14 @@ pub struct UiSection { /// the built-in Ctrl + / Ctrl - / Ctrl 0 keyboard zoom. #[serde(default = "default_ui_scale")] pub ui_scale: f32, + /// Default video-list render mode: "list", "card", or "grid". + /// Per-view overrides live in App state (not persisted beyond session). + #[serde(default = "default_view_mode")] + pub default_view_mode: String, } fn default_ui_scale() -> f32 { 1.0 } +fn default_view_mode() -> String { "list".to_string() } impl Default for UiSection { fn default() -> Self { @@ -217,6 +222,7 @@ impl Default for UiSection { theme: default_theme(), minimize_to_tray: false, ui_scale: default_ui_scale(), + default_view_mode: default_view_mode(), } } } @@ -319,3 +325,22 @@ impl Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_section_default_view_mode_is_list() { + let ui = UiSection::default(); + assert_eq!(ui.default_view_mode, "list"); + } + + #[test] + fn ui_section_round_trips_through_toml() { + let ui = UiSection { default_view_mode: "grid".into(), ..Default::default() }; + let s = toml::to_string(&ui).unwrap(); + let back: UiSection = toml::from_str(&s).unwrap(); + assert_eq!(back.default_view_mode, "grid"); + } +} diff --git a/src/theme.rs b/src/theme.rs index 152e471..f601591 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -5,20 +5,89 @@ pub const THEMES: &[(&str, &str)] = &[ ("light", "Light"), ("dracula", "Dracula"), ("trans", "Trans"), + // Catacomb / goth ("emo-nocturnal", "Emo: Nocturnal"), ("emo-coffin", "Emo: Coffin"), ("emo-scene-queen", "Emo: Scene Queen"), + ("cemetery-moss", "Cemetery Moss"), + ("vampire", "Vampire"), + ("witching-hour", "Witching Hour"), + // Neon / retro + ("cyberpunk", "Cyberpunk"), + ("synthwave", "Synthwave '84"), + ("vaporwave", "Vaporwave"), + // Dev palettes + ("nord", "Nord"), + ("gruvbox", "Gruvbox"), + ("tokyo-night", "Tokyo Night"), + // Cozy / light + ("paper", "Paper"), + ("honey", "Honey"), + ("candlelight", "Candlelight"), ]; +/// Theme-aware semantic accent colors. egui `Visuals` has no slot for these, +/// so each theme exposes them here and the paint code in `app.rs` reads from +/// the active snapshot instead of hardcoding RGB values. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct ThemeAccents { + /// Selection / focus ring (replaces the hardcoded 120,170,230 blue). + pub accent: Color32, + /// Playing / watched indicator (replaces 110,200,110 green). + pub success: Color32, + /// Bulk-selection highlight (replaces 180,130,240 purple). + pub warning: Color32, +} + +/// Look up the semantic accents for a theme name. Falls back to the dark +/// theme's accents for unknown names. +pub fn accents_for(name: &str) -> ThemeAccents { + match name { + "dark" => ThemeAccents { accent: hex(0x7aa2f7), success: hex(0x9ece6a), warning: hex(0xbb9af7) }, + "light" => ThemeAccents { accent: hex(0x2a5db0), success: hex(0x2e7d32), warning: hex(0x8e44ad) }, + "dracula" => ThemeAccents { accent: hex(0xbd93f9), success: hex(0x50fa7b), warning: hex(0xff79c6) }, + "trans" => ThemeAccents { accent: hex(0x55cdfc), success: hex(0x2e7d32), warning: hex(0xf7a8b8) }, + "emo-nocturnal" => ThemeAccents { accent: hex(0xff0090), success: hex(0x39ff14), warning: hex(0x00f5ff) }, + "emo-coffin" => ThemeAccents { accent: hex(0x8b0000), success: hex(0x39ff14), warning: hex(0xcc2222) }, + "emo-scene-queen" => ThemeAccents { accent: hex(0x39ff14), success: hex(0xff00ff), warning: hex(0x00f5ff) }, + // New themes (added in a later task — values match their palettes). + "cyberpunk" => ThemeAccents { accent: hex(0x00fff5), success: hex(0x39ff14), warning: hex(0xff003c) }, + "synthwave" => ThemeAccents { accent: hex(0xff2a6d), success: hex(0x05d9e8), warning: hex(0xd136a6) }, + "vaporwave" => ThemeAccents { accent: hex(0x01cdfe), success: hex(0x05ffa1), warning: hex(0xff71ce) }, + "cemetery-moss" => ThemeAccents { accent: hex(0x7a8a6a), success: hex(0x4a5d3a), warning: hex(0x9a9a8a) }, + "vampire" => ThemeAccents { accent: hex(0xc9a227), success: hex(0x8b1a2b), warning: hex(0x5c0a1e) }, + "witching-hour" => ThemeAccents { accent: hex(0x6a4a8b), success: hex(0xb0b8d0), warning: hex(0x1a1a4a) }, + "nord" => ThemeAccents { accent: hex(0x88c0d0), success: hex(0xa3be8c), warning: hex(0xebcb8b) }, + "gruvbox" => ThemeAccents { accent: hex(0xfe8019), success: hex(0xb8bb26), warning: hex(0xd3869b) }, + "tokyo-night" => ThemeAccents { accent: hex(0x7aa2f7), success: hex(0x9ece6a), warning: hex(0xbb9af7) }, + "paper" => ThemeAccents { accent: hex(0x8b6f3a), success: hex(0x4a6b3a), warning: hex(0xc4a86a) }, + "honey" => ThemeAccents { accent: hex(0xe8a838), success: hex(0xb87420), warning: hex(0xc97b4a) }, + "candlelight" => ThemeAccents { accent: hex(0xa8703a), success: hex(0x6b3f1e), warning: hex(0xd9b382) }, + _ => ThemeAccents { accent: hex(0x7aa2f7), success: hex(0x9ece6a), warning: hex(0xbb9af7) }, + } +} + pub fn apply(ctx: &egui::Context, name: &str) { let visuals = match name { - "light" => egui::Visuals::light(), + "light" => light(), "dracula" => dracula(), "trans" => trans(), "emo-nocturnal" => emo_nocturnal(), "emo-coffin" => emo_coffin(), "emo-scene-queen" => emo_scene_queen(), - _ => egui::Visuals::dark(), + "cemetery-moss" => cemetery_moss(), + "vampire" => vampire(), + "witching-hour" => witching_hour(), + "cyberpunk" => cyberpunk(), + "synthwave" => synthwave(), + "vaporwave" => vaporwave(), + "nord" => nord(), + "gruvbox" => gruvbox(), + "tokyo-night" => tokyo_night(), + "paper" => paper(), + "honey" => honey(), + "candlelight" => candlelight(), + _ => dark(), }; ctx.set_visuals(visuals); } @@ -27,6 +96,68 @@ fn hex(v: u32) -> Color32 { Color32::from_rgb(((v >> 16) & 0xff) as u8, ((v >> 8) & 0xff) as u8, (v & 0xff) as u8) } +// Hand-tuned default dark — true near-black panel, cool steel-blue accent. +// Replaces the stock egui::Visuals::dark() so the out-of-box experience +// matches the care given to the themed variants. +fn dark() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x14141a); + v.window_fill = hex(0x1a1a22); + v.extreme_bg_color = hex(0x0a0a0e); + v.faint_bg_color = hex(0x1f1f29); + v.code_bg_color = hex(0x101016); + v.selection.bg_fill = hex(0x2a3a5a); + v.selection.stroke = Stroke::new(1.0, hex(0x7aa2f7)); + v.hyperlink_color = hex(0x7aa2f7); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x1f1f29); + v.widgets.noninteractive.weak_bg_fill = hex(0x181820); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8d8)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a36)); + v.widgets.inactive.bg_fill = hex(0x2a2a36); + v.widgets.inactive.weak_bg_fill = hex(0x222230); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d0e0)); + v.widgets.hovered.bg_fill = hex(0x3a3a4e); + v.widgets.hovered.weak_bg_fill = hex(0x303044); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xe8e8f8)); + v.widgets.active.bg_fill = hex(0x7aa2f7); + v.widgets.active.weak_bg_fill = hex(0x5a82d7); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x14141a)); + v.widgets.open.bg_fill = hex(0x3a3a4e); + v.window_stroke = Stroke::new(1.0, hex(0x2a2a36)); + v +} + +// Hand-tuned default light — warm off-white, slate accent. +fn light() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xf6f4ef); + v.window_fill = hex(0xfefcf7); + v.extreme_bg_color = hex(0xffffff); + v.faint_bg_color = hex(0xefece4); + v.code_bg_color = hex(0xeae6dc); + v.selection.bg_fill = hex(0xbcd0ee); + v.selection.stroke = Stroke::new(1.0, hex(0x2a5db0)); + v.hyperlink_color = hex(0x2a5db0); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xeae6dc); + v.widgets.noninteractive.weak_bg_fill = hex(0xf0ede5); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x3a3a3a)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xd0ccbf)); + v.widgets.inactive.bg_fill = hex(0xdfe0e8); + v.widgets.inactive.weak_bg_fill = hex(0xe8e9f0); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x2a2a2a)); + v.widgets.hovered.bg_fill = hex(0xcdd6e8); + v.widgets.hovered.weak_bg_fill = hex(0xd8e0ee); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x141414)); + v.widgets.active.bg_fill = hex(0x2a5db0); + v.widgets.active.weak_bg_fill = hex(0x4a7dd0); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff)); + v.widgets.open.bg_fill = hex(0xcdd6e8); + v.window_stroke = Stroke::new(1.0, hex(0xd0ccbf)); + v +} + fn dracula() -> egui::Visuals { let mut v = egui::Visuals::dark(); v.panel_fill = hex(0x282a36); @@ -192,3 +323,422 @@ fn emo_scene_queen() -> egui::Visuals { v.error_fg_color = hex(0xff4444); v } + +// === Neon / retro === + +// Magenta + electric cyan on black. Hacker HUD. +fn cyberpunk() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x0a0a12); + v.window_fill = hex(0x0e0e18); + v.extreme_bg_color = hex(0x050508); + v.faint_bg_color = hex(0x14141f); + v.code_bg_color = hex(0x08080f); + v.selection.bg_fill = hex(0xff003c); + v.selection.stroke = Stroke::new(1.0, hex(0x00fff5)); + v.hyperlink_color = hex(0x00fff5); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x14141f); + v.widgets.noninteractive.weak_bg_fill = hex(0x0f0f18); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8e0)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a3a)); + v.widgets.inactive.bg_fill = hex(0x1f0018); + v.widgets.inactive.weak_bg_fill = hex(0x180013); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d0e8)); + v.widgets.hovered.bg_fill = hex(0x6a0044); + v.widgets.hovered.weak_bg_fill = hex(0x500034); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x00fff5)); + v.widgets.active.bg_fill = hex(0xff003c); + v.widgets.active.weak_bg_fill = hex(0xcc0030); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff)); + v.widgets.open.bg_fill = hex(0x6a0044); + v.window_stroke = Stroke::new(1.0, hex(0xff003c)); + v.warn_fg_color = hex(0xfcee0a); + v.error_fg_color = hex(0xff003c); + v +} + +// Sunset gradient on deep indigo. +fn synthwave() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x2b0a3d); + v.window_fill = hex(0x330a48); + v.extreme_bg_color = hex(0x1a0529); + v.faint_bg_color = hex(0x3a1055); + v.code_bg_color = hex(0x220833); + v.selection.bg_fill = hex(0xff2a6d); + v.selection.stroke = Stroke::new(1.0, hex(0x05d9e8)); + v.hyperlink_color = hex(0x05d9e8); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x3a1055); + v.widgets.noninteractive.weak_bg_fill = hex(0x2e0c45); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe8c0e0)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x5a1a7a)); + v.widgets.inactive.bg_fill = hex(0x4a1466); + v.widgets.inactive.weak_bg_fill = hex(0x3c1055); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xf0d0e8)); + v.widgets.hovered.bg_fill = hex(0x7a1a4a); + v.widgets.hovered.weak_bg_fill = hex(0x60143a); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xff9a3c)); + v.widgets.active.bg_fill = hex(0xff2a6d); + v.widgets.active.weak_bg_fill = hex(0xcc2055); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff)); + v.widgets.open.bg_fill = hex(0x7a1a4a); + v.window_stroke = Stroke::new(1.0, hex(0xff2a6d)); + v.warn_fg_color = hex(0xff9a3c); + v.error_fg_color = hex(0xff2a6d); + v +} + +// Pastel pink + cyan + lavender on deep plum. +fn vaporwave() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x1a0033); + v.window_fill = hex(0x200040); + v.extreme_bg_color = hex(0x10001f); + v.faint_bg_color = hex(0x28004e); + v.code_bg_color = hex(0x150028); + v.selection.bg_fill = hex(0xb967ff); + v.selection.stroke = Stroke::new(1.0, hex(0x01cdfe)); + v.hyperlink_color = hex(0x01cdfe); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x28004e); + v.widgets.noninteractive.weak_bg_fill = hex(0x1f003c); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe0c0ff)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x4a0070)); + v.widgets.inactive.bg_fill = hex(0x350060); + v.widgets.inactive.weak_bg_fill = hex(0x2a004f); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xf0d8ff)); + v.widgets.hovered.bg_fill = hex(0x55008a); + v.widgets.hovered.weak_bg_fill = hex(0x440070); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x01cdfe)); + v.widgets.active.bg_fill = hex(0xff71ce); + v.widgets.active.weak_bg_fill = hex(0xcc5aa6); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x1a0033)); + v.widgets.open.bg_fill = hex(0x55008a); + v.window_stroke = Stroke::new(1.0, hex(0xff71ce)); + v.warn_fg_color = hex(0x05ffa1); + v.error_fg_color = hex(0xff71ce); + v +} + +// === Catacomb / goth === + +// Weathered stone + mossy green + bone. +fn cemetery_moss() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x1a1f1a); + v.window_fill = hex(0x1e241e); + v.extreme_bg_color = hex(0x0f140f); + v.faint_bg_color = hex(0x242a24); + v.code_bg_color = hex(0x141814); + v.selection.bg_fill = hex(0x4a5d3a); + v.selection.stroke = Stroke::new(1.0, hex(0x7a8a6a)); + v.hyperlink_color = hex(0x9aa86a); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x242a24); + v.widgets.noninteractive.weak_bg_fill = hex(0x1f241f); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xb8b8a8)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x363a30)); + v.widgets.inactive.bg_fill = hex(0x2a302a); + v.widgets.inactive.weak_bg_fill = hex(0x232823); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xc8c8b8)); + v.widgets.hovered.bg_fill = hex(0x3a4530); + v.widgets.hovered.weak_bg_fill = hex(0x2e3826); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xaac08a)); + v.widgets.active.bg_fill = hex(0x4a5d3a); + v.widgets.active.weak_bg_fill = hex(0x3a4a2e); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xd4d0c0)); + v.widgets.open.bg_fill = hex(0x3a4530); + v.window_stroke = Stroke::new(1.0, hex(0x4a5d3a)); + v.warn_fg_color = hex(0xc9a227); + v.error_fg_color = hex(0x8b1a2b); + v +} + +// Deep wine burgundy + antique gold + black. +fn vampire() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x0d0006); + v.window_fill = hex(0x120008); + v.extreme_bg_color = hex(0x070003); + v.faint_bg_color = hex(0x18000c); + v.code_bg_color = hex(0x0a0005); + v.selection.bg_fill = hex(0x5c0a1e); + v.selection.stroke = Stroke::new(1.0, hex(0xc9a227)); + v.hyperlink_color = hex(0xc9a227); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x18000c); + v.widgets.noninteractive.weak_bg_fill = hex(0x130009); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xd8c8a8)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x3a0014)); + v.widgets.inactive.bg_fill = hex(0x200010); + v.widgets.inactive.weak_bg_fill = hex(0x19000c); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xe8d8b8)); + v.widgets.hovered.bg_fill = hex(0x400018); + v.widgets.hovered.weak_bg_fill = hex(0x300012); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xc9a227)); + v.widgets.active.bg_fill = hex(0x8b1a2b); + v.widgets.active.weak_bg_fill = hex(0x700020); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xe8d8b8)); + v.widgets.open.bg_fill = hex(0x400018); + v.window_stroke = Stroke::new(1.0, hex(0x8b1a2b)); + v.warn_fg_color = hex(0xc9a227); + v.error_fg_color = hex(0x8b1a2b); + v +} + +// Midnight indigo + moonlight silver + arcane violet. +fn witching_hour() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x0a0a1f); + v.window_fill = hex(0x0e0e28); + v.extreme_bg_color = hex(0x05050f); + v.faint_bg_color = hex(0x14142e); + v.code_bg_color = hex(0x08081a); + v.selection.bg_fill = hex(0x1a1a4a); + v.selection.stroke = Stroke::new(1.0, hex(0xb0b8d0)); + v.hyperlink_color = hex(0xb0b8d0); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x14142e); + v.widgets.noninteractive.weak_bg_fill = hex(0x101024); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc0c8e0)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a55)); + v.widgets.inactive.bg_fill = hex(0x1c1c3c); + v.widgets.inactive.weak_bg_fill = hex(0x161630); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d8f0)); + v.widgets.hovered.bg_fill = hex(0x2a2a5a); + v.widgets.hovered.weak_bg_fill = hex(0x20204a); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x6a4a8b)); + v.widgets.active.bg_fill = hex(0x6a4a8b); + v.widgets.active.weak_bg_fill = hex(0x523a72); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xe8e8f8)); + v.widgets.open.bg_fill = hex(0x2a2a5a); + v.window_stroke = Stroke::new(1.0, hex(0x6a4a8b)); + v.warn_fg_color = hex(0xc9a227); + v.error_fg_color = hex(0x8b1a2b); + v +} + +// === Dev palettes === + +// Arctic blues & greys. +fn nord() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x2e3440); + v.window_fill = hex(0x3b4252); + v.extreme_bg_color = hex(0x1e222a); + v.faint_bg_color = hex(0x3b4252); + v.code_bg_color = hex(0x242933); + v.selection.bg_fill = hex(0x434c5e); + v.selection.stroke = Stroke::new(1.0, hex(0x88c0d0)); + v.hyperlink_color = hex(0x88c0d0); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x3b4252); + v.widgets.noninteractive.weak_bg_fill = hex(0x343b48); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xd8dee9)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x434c5e)); + v.widgets.inactive.bg_fill = hex(0x434c5e); + v.widgets.inactive.weak_bg_fill = hex(0x3c4454); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xe5e9f0)); + v.widgets.hovered.bg_fill = hex(0x4c566a); + v.widgets.hovered.weak_bg_fill = hex(0x424c60); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x88c0d0)); + v.widgets.active.bg_fill = hex(0x88c0d0); + v.widgets.active.weak_bg_fill = hex(0x6fa6ba); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x2e3440)); + v.widgets.open.bg_fill = hex(0x4c566a); + v.window_stroke = Stroke::new(1.0, hex(0x4c566a)); + v.warn_fg_color = hex(0xebcb8b); + v.error_fg_color = hex(0xbf616a); + v +} + +// Warm earthy retro groove. +fn gruvbox() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x282828); + v.window_fill = hex(0x32302f); + v.extreme_bg_color = hex(0x1d2021); + v.faint_bg_color = hex(0x32302f); + v.code_bg_color = hex(0x1d2021); + v.selection.bg_fill = hex(0x504945); + v.selection.stroke = Stroke::new(1.0, hex(0xfe8019)); + v.hyperlink_color = hex(0x83a598); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x32302f); + v.widgets.noninteractive.weak_bg_fill = hex(0x2c2a29); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xd5c4a1)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x504945)); + v.widgets.inactive.bg_fill = hex(0x504945); + v.widgets.inactive.weak_bg_fill = hex(0x44403c); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xebdbb2)); + v.widgets.hovered.bg_fill = hex(0x665c54); + v.widgets.hovered.weak_bg_fill = hex(0x585048); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xfe8019)); + v.widgets.active.bg_fill = hex(0xd65d0e); + v.widgets.active.weak_bg_fill = hex(0xaf4f0b); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xebdbb2)); + v.widgets.open.bg_fill = hex(0x665c54); + v.window_stroke = Stroke::new(1.0, hex(0x665c54)); + v.warn_fg_color = hex(0xfe9d44); + v.error_fg_color = hex(0xfb4934); + v +} + +// Tokyo city lights. Blue/purple, clean. +fn tokyo_night() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x1a1b26); + v.window_fill = hex(0x20202f); + v.extreme_bg_color = hex(0x12121c); + v.faint_bg_color = hex(0x24253a); + v.code_bg_color = hex(0x16161e); + v.selection.bg_fill = hex(0x33415c); + v.selection.stroke = Stroke::new(1.0, hex(0x7aa2f7)); + v.hyperlink_color = hex(0x7aa2f7); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x24253a); + v.widgets.noninteractive.weak_bg_fill = hex(0x1f2030); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xa9b1d6)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x3b4261)); + v.widgets.inactive.bg_fill = hex(0x2a2b3e); + v.widgets.inactive.weak_bg_fill = hex(0x232436); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xc0caf5)); + v.widgets.hovered.bg_fill = hex(0x363b54); + v.widgets.hovered.weak_bg_fill = hex(0x2d324a); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x7aa2f7)); + v.widgets.active.bg_fill = hex(0x7aa2f7); + v.widgets.active.weak_bg_fill = hex(0x5c82d4); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x1a1b26)); + v.widgets.open.bg_fill = hex(0x363b54); + v.window_stroke = Stroke::new(1.0, hex(0x3b4261)); + v.warn_fg_color = hex(0xe0af68); + v.error_fg_color = hex(0xf7768e); + v +} + +// === Cozy / light === + +// Aged paper + sepia ink. +fn paper() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xf4ecd8); + v.window_fill = hex(0xf8f0dc); + v.extreme_bg_color = hex(0xfef8e8); + v.faint_bg_color = hex(0xefe5cc); + v.code_bg_color = hex(0xe8dcc0); + v.selection.bg_fill = hex(0xd9c896); + v.selection.stroke = Stroke::new(1.0, hex(0x8b6f3a)); + v.hyperlink_color = hex(0x6b4f2a); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xefe5cc); + v.widgets.noninteractive.weak_bg_fill = hex(0xf2e9d2); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x3d2b1f)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xc4a86a)); + v.widgets.inactive.bg_fill = hex(0xe2d4b0); + v.widgets.inactive.weak_bg_fill = hex(0xeadab4); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x2d1f15)); + v.widgets.hovered.bg_fill = hex(0xd6c498); + v.widgets.hovered.weak_bg_fill = hex(0xddcaa8); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x3d2b1f)); + v.widgets.active.bg_fill = hex(0x8b6f3a); + v.widgets.active.weak_bg_fill = hex(0xa18450); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xf4ecd8)); + v.widgets.open.bg_fill = hex(0xd6c498); + v.window_stroke = Stroke::new(1.0, hex(0xc4a86a)); + v.warn_fg_color = hex(0xb87420); + v.error_fg_color = hex(0x9a3a1a); + v +} + +// Warm amber + gold + cream. +fn honey() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xfff4e0); + v.window_fill = hex(0xfff9ec); + v.extreme_bg_color = hex(0xffffff); + v.faint_bg_color = hex(0xffecc8); + v.code_bg_color = hex(0xffe6b8); + v.selection.bg_fill = hex(0xffd97a); + v.selection.stroke = Stroke::new(1.0, hex(0xb87420)); + v.hyperlink_color = hex(0x9a5a10); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xffecc8); + v.widgets.noninteractive.weak_bg_fill = hex(0xfff2d4); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x5c3818)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xe8a838)); + v.widgets.inactive.bg_fill = hex(0xffdf9c); + v.widgets.inactive.weak_bg_fill = hex(0xffe6b0); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x4a2c10)); + v.widgets.hovered.bg_fill = hex(0xffd166); + v.widgets.hovered.weak_bg_fill = hex(0xffdb84); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x4a2c10)); + v.widgets.active.bg_fill = hex(0xe8a838); + v.widgets.active.weak_bg_fill = hex(0xc8902a); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xfff4e0)); + v.widgets.open.bg_fill = hex(0xffd166); + v.window_stroke = Stroke::new(1.0, hex(0xe8a838)); + v.warn_fg_color = hex(0xb87420); + v.error_fg_color = hex(0xa83a1a); + v +} + +// Dim warm glow + toasted brown. +fn candlelight() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xf2e6d0); + v.window_fill = hex(0xf6ecd9); + v.extreme_bg_color = hex(0xfbf3e2); + v.faint_bg_color = hex(0xead8b8); + v.code_bg_color = hex(0xe2cea0); + v.selection.bg_fill = hex(0xd9b382); + v.selection.stroke = Stroke::new(1.0, hex(0x6b3f1e)); + v.hyperlink_color = hex(0x5a3418); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xead8b8); + v.widgets.noninteractive.weak_bg_fill = hex(0xeedec2); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x3a2818)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xa8703a)); + v.widgets.inactive.bg_fill = hex(0xddc498); + v.widgets.inactive.weak_bg_fill = hex(0xe4ceaa); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x2d1a0c)); + v.widgets.hovered.bg_fill = hex(0xd2ac6e); + v.widgets.hovered.weak_bg_fill = hex(0xdab880); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x2d1a0c)); + v.widgets.active.bg_fill = hex(0xa8703a); + v.widgets.active.weak_bg_fill = hex(0x8c5a2c); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xf2e6d0)); + v.widgets.open.bg_fill = hex(0xd2ac6e); + v.window_stroke = Stroke::new(1.0, hex(0xa8703a)); + v.warn_fg_color = hex(0xb87420); + v.error_fg_color = hex(0x9a3a1a); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accents_for_known_theme_returns_nonzero() { + let a = accents_for("dark"); + assert_ne!(a.accent, Color32::TRANSPARENT); + assert_ne!(a.success, Color32::TRANSPARENT); + assert_ne!(a.warning, Color32::TRANSPARENT); + } + + #[test] + fn accents_for_unknown_theme_falls_back() { + let a = accents_for("this-theme-does-not-exist"); + let dark = accents_for("dark"); + assert_eq!(a.accent, dark.accent); + } + + #[test] + fn accents_differ_across_themes() { + let dark = accents_for("dark"); + let light = accents_for("light"); + assert_ne!(dark.accent, light.accent); + } +}