use std::collections::HashMap; use std::iter::FromIterator; use std::path::{Component, Path, PathBuf}; use anyhow::Result; pub use galaxy_core::ui::color::blend::Blend; use galaxy_core::ui::color::pick_foreground_color; pub use galaxy_core::ui::theme::*; use galaxyui::assets::asset_cache::AssetSource; use galaxyui::color::ColorU; use galaxyui::elements::{ Align, Border, ConstrainedBox, Container, Element, Empty, Flex, ParentElement, Rect, Shrinkable, Stack, Text, }; use galaxyui::fonts::FamilyId; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::default_themes::*; use super::theme_creator::{pick_accent_color_from_options, top_colors_for_image}; const THUMBNAIL_MARGIN: f32 = 10.; // We use the discriminant of enum variants to determine the order of theme types in the // theme chooser view. #[derive( Clone, Debug, Default, PartialEq, Serialize, Deserialize, Hash, Eq, Ord, PartialOrd, schemars::JsonSchema, settings_value::SettingsValue, )] #[schemars(description = "The color theme.", rename_all = "snake_case")] pub enum ThemeKind { // Need an alias for backwards-compatibility: Originally we only had a single reward theme // so it was named `ReferralReward`. #[serde(alias = "ReferralReward")] #[schemars(skip)] SentReferralReward, #[schemars(skip)] ReceivedReferralReward, #[schemars(description = "Adeberry")] Adeberry, #[schemars(description = "Samsung Dark")] SamsungDark, #[schemars(description = "Samsung Light")] SamsungLight, #[schemars(description = "Phenomenon")] Phenomenon, #[default] #[schemars(description = "Dark")] Dark, #[schemars(description = "Dracula")] Dracula, #[schemars(description = "Fancy Dracula")] FancyDracula, #[schemars(description = "Cyber Wave")] CyberWave, #[schemars(description = "Solar Flare")] SolarFlare, #[schemars(description = "Solarized Dark")] SolarizedDark, #[schemars(description = "Willow Dream")] WillowDream, #[schemars(description = "Light")] Light, #[schemars(description = "Dark City")] DarkCity, #[schemars(description = "Gruvbox Dark")] GruvboxDark, #[schemars(description = "Red Rock")] RedRock, #[schemars(description = "Jellyfish")] JellyFish, #[schemars(description = "Leafy")] Leafy, #[schemars(description = "Koi")] Koi, #[schemars(description = "Solarized Light")] SolarizedLight, #[schemars(description = "Snowy")] Snowy, #[schemars(description = "Gruvbox Light")] GruvboxLight, #[schemars(description = "Pink City")] PinkCity, #[schemars(description = "Marble")] Marble, #[schemars(description = "A user-provided custom theme loaded from a file.")] Custom(CustomTheme), /// Base16 themes are a special case of custom themes with their own semantics for ANSI colors that override "bright" color variants. #[schemars(description = "A custom theme using the Base16 color scheme format.")] CustomBase16(CustomTheme), #[schemars(skip)] InMemory(InMemoryThemeOptions), } impl From for ThemeKind { fn from(custom_theme: CustomTheme) -> ThemeKind { if custom_theme.name.as_str().starts_with("Base16") { ThemeKind::CustomBase16(custom_theme) } else { ThemeKind::Custom(custom_theme) } } } impl std::fmt::Display for ThemeKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let value = match &self { ThemeKind::Light => "Light", ThemeKind::Dark => "Dark", ThemeKind::Dracula => "Dracula", ThemeKind::SolarizedDark => "Solarized Dark", ThemeKind::SolarizedLight => "Solarized Light", ThemeKind::GruvboxDark => "Gruvbox Dark", ThemeKind::GruvboxLight => "Gruvbox Light", ThemeKind::JellyFish => "Jellyfish", ThemeKind::Koi => "Koi", ThemeKind::Leafy => "Leafy", ThemeKind::Marble => "Marble", ThemeKind::PinkCity => "Pink City", ThemeKind::Snowy => "Snowy", ThemeKind::DarkCity => "Dark City", ThemeKind::RedRock => "Red Rock", ThemeKind::CyberWave => "Cyber Wave", ThemeKind::WillowDream => "Willow Dream", ThemeKind::FancyDracula => "Fancy Dracula", ThemeKind::Phenomenon => "Phenomenon", ThemeKind::SolarFlare => "Solar Flare", ThemeKind::Adeberry => "Adeberry", ThemeKind::SamsungDark => "Samsung Dark", ThemeKind::SamsungLight => "Samsung Light", ThemeKind::SentReferralReward => "Galaxy Referral", ThemeKind::ReceivedReferralReward => "Referred to Galaxy", ThemeKind::Custom(custom_theme) => custom_theme.name.as_str(), ThemeKind::CustomBase16(custom_theme) => custom_theme.name.as_str(), ThemeKind::InMemory(in_memory_theme) => in_memory_theme.name.as_str(), }; write!(f, "{value}") } } impl ThemeKind { pub fn matches(&self, query: &str) -> bool { let theme_name = format!("{self}").to_lowercase(); theme_name.contains(&query.to_lowercase()) } pub(crate) fn is_custom_theme_reference_syncable(&self) -> bool { match self { ThemeKind::Custom(custom_theme) | ThemeKind::CustomBase16(custom_theme) => { custom_theme_path_is_portable(&custom_theme.path, &crate::user_config::themes_dir()) } _ => true, } } } #[derive( Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, schemars::JsonSchema, )] #[schemars(description = "A user-provided custom theme.")] pub struct CustomTheme { #[schemars(description = "The display name of the custom theme.")] name: String, #[serde( deserialize_with = "deserialize_custom_theme_path", serialize_with = "serialize_custom_theme_path" )] #[schemars(description = "The file path to the custom theme definition.")] path: PathBuf, } impl settings_value::SettingsValue for CustomTheme { fn to_file_value(&self) -> serde_json::Value { serde_json::json!({ "name": &self.name, "path": custom_theme_path_storage_value(&self.path, &crate::user_config::themes_dir()), }) } fn from_file_value(value: &serde_json::Value) -> Option { #[derive(Deserialize)] struct FileValue { name: String, path: String, } let value = serde_json::from_value::(value.clone()).ok()?; Some(Self { name: value.name, path: portable_custom_theme_path_from_stored_raw( &value.path, &crate::user_config::themes_dir(), ), }) } } fn serialize_custom_theme_path(path: &Path, serializer: S) -> Result where S: Serializer, { if let Some(path) = portable_custom_theme_storage_string(path, &crate::user_config::themes_dir()) { path.serialize(serializer) } else { path.serialize(serializer) } } fn deserialize_custom_theme_path<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let path = String::deserialize(deserializer)?; Ok(portable_custom_theme_path_from_stored_raw( &path, &crate::user_config::themes_dir(), )) } fn custom_theme_path_storage_value(path: &Path, theme_root: &Path) -> serde_json::Value { if let Some(path) = portable_custom_theme_storage_string(path, theme_root) { serde_json::Value::String(path) } else { serde_json::json!(path) } } pub(crate) fn custom_theme_path_is_portable(path: &Path, theme_root: &Path) -> bool { if path_is_absolute_or_foreign_absolute(path) { return portable_custom_theme_storage_string(path, theme_root).is_some(); } path.to_str() .is_some_and(|path| portable_stored_raw_components(path).is_some()) } pub(crate) fn portable_custom_theme_path_from_stored_raw(raw: &str, theme_root: &Path) -> PathBuf { portable_stored_raw_components(raw) .map(|components| { components .iter() .fold(theme_root.to_path_buf(), |path, component| { path.join(component) }) }) .unwrap_or_else(|| PathBuf::from(raw)) } pub(crate) fn portable_custom_theme_storage_string( path: &Path, theme_root: &Path, ) -> Option { if path_starts_with_windows_drive_prefix_using_forward_slash(path) { return None; } let relative = path.strip_prefix(theme_root).ok()?; let mut components = Vec::new(); for component in relative.components() { let Component::Normal(value) = component else { return None; }; let value = value.to_str()?; if value.contains('\\') { return None; } components.push(value); } if components.is_empty() { return None; } let path = components.join("/"); if portable_stored_raw_components(&path).is_some() { Some(path) } else { None } } fn portable_stored_raw_components(raw: &str) -> Option> { if raw.is_empty() || raw.contains('\\') || raw.starts_with('/') || raw_starts_with_windows_drive_prefix(raw) { return None; } let components = raw.split('/').collect::>(); if components .iter() .all(|component| !component.is_empty() && *component != "." && *component != "..") { Some(components) } else { None } } fn raw_starts_with_windows_drive_prefix(raw: &str) -> bool { let bytes = raw.as_bytes(); bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' } fn path_starts_with_windows_drive_prefix_using_forward_slash(path: &Path) -> bool { let Some(path) = path.as_os_str().to_str() else { return false; }; let bytes = path.as_bytes(); bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' } fn path_is_absolute_or_foreign_absolute(path: &Path) -> bool { path.has_root() || path_looks_like_foreign_windows_absolute(path) } fn path_looks_like_foreign_windows_absolute(path: &Path) -> bool { if path.has_root() { return false; } let Some(path) = path.as_os_str().to_str() else { return false; }; let bytes = path.as_bytes(); let starts_with_drive_root = bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/'); starts_with_drive_root || path.starts_with(r"\\") } impl CustomTheme { pub fn new(s: String, p: PathBuf) -> Self { CustomTheme { name: s, path: p } } pub fn name(&self) -> String { self.name.clone() } pub fn path(&self) -> PathBuf { self.path.clone() } } #[derive( Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, settings_value::SettingsValue, )] pub struct InMemoryThemeOptions { name: String, path: PathBuf, #[serde(skip)] possible_bg_colors: Vec, #[serde(skip)] chosen_bg_color_index: usize, } impl InMemoryThemeOptions { pub async fn new(name: String, path: PathBuf) -> Result { top_colors_for_image(path.clone()).map(|top_colors| InMemoryThemeOptions { name, path, possible_bg_colors: top_colors, chosen_bg_color_index: 0, }) } pub fn chosen_bg_color(&self) -> ColorU { self.possible_bg_colors[self.chosen_bg_color_index] } pub fn possible_bg_colors(&self) -> Vec { self.possible_bg_colors.clone() } pub fn chosen_bg_color_index(&self) -> usize { self.chosen_bg_color_index } pub fn set_chosen_bg_color_index(&mut self, index: usize) { self.chosen_bg_color_index = index; } pub fn name(&self) -> String { self.name.clone() } pub fn set_name(&mut self, name: String) { self.name = name; } pub fn path(&self) -> PathBuf { self.path.clone() } pub fn set_path(&mut self, path: PathBuf) { self.path = path; } pub fn theme(&self) -> GalaxyTheme { let bg_color = self.chosen_bg_color(); let fg_color = pick_foreground_color(bg_color); let possible_accent_colors: Vec = self .possible_bg_colors .iter() .enumerate() .filter(|(index, _)| *index != self.chosen_bg_color_index) .map(|(_, color)| *color) .collect(); let accent_color = pick_accent_color_from_options(&[bg_color, fg_color], &possible_accent_colors[..]); let (details, terminal_colors) = if fg_color.eq(&ColorU::white()) { (Details::Darker, dark_mode_colors()) } else { (Details::Lighter, light_mode_colors()) }; GalaxyTheme::new( bg_color.into(), fg_color, accent_color.into(), None, Some(details), terminal_colors, Some(Image { // Note that, as an invariant, in-memory themes come from local files. source: AssetSource::LocalFile { path: self.path().to_str().unwrap_or_default().to_owned(), content_version: None, }, opacity: 30, }), Some(self.name()), ) } } #[derive(Debug, Clone)] pub struct GalaxyThemeConfig { theme_map: HashMap, } pub type WarpThemeConfig = GalaxyThemeConfig; impl GalaxyThemeConfig { pub fn new() -> Self { // preload with built-in themes let theme_map: HashMap = HashMap::from_iter([ (ThemeKind::SentReferralReward, sent_referral_reward()), ( ThemeKind::ReceivedReferralReward, received_referral_reward(), ), (ThemeKind::Dark, dark_theme()), (ThemeKind::Light, light_theme()), (ThemeKind::SolarizedDark, solarized_dark()), (ThemeKind::SolarizedLight, solarized_light()), (ThemeKind::Dracula, dracula()), (ThemeKind::GruvboxDark, gruvbox_dark()), (ThemeKind::GruvboxLight, gruvbox_light()), (ThemeKind::JellyFish, jellyfish()), (ThemeKind::Koi, koi()), (ThemeKind::Leafy, leafy()), (ThemeKind::Marble, marble()), (ThemeKind::PinkCity, pink_city()), (ThemeKind::Snowy, snowy()), (ThemeKind::DarkCity, dark_city()), (ThemeKind::RedRock, red_rock()), (ThemeKind::CyberWave, cyber_wave()), (ThemeKind::WillowDream, willow_dream()), (ThemeKind::FancyDracula, fancy_dracula()), (ThemeKind::Phenomenon, phenomenon()), (ThemeKind::SolarFlare, solar_flare()), (ThemeKind::Adeberry, adeberry()), (ThemeKind::SamsungDark, samsung_dark()), (ThemeKind::SamsungLight, samsung_light()), ]); GalaxyThemeConfig { theme_map } } pub fn add_new_theme(&mut self, theme_name: ThemeKind, theme: GalaxyTheme) { self.theme_map.insert(theme_name, theme); } pub fn file_to_theme(name: String, path: PathBuf) -> ThemeKind { CustomTheme::new(name, path).into() } pub fn theme_items(&self) -> impl Iterator { self.theme_map.iter() } pub fn theme(&self, name: &ThemeKind) -> GalaxyTheme { self.theme_map.get(name).cloned().unwrap_or_else(dark_theme) } } impl Default for GalaxyThemeConfig { fn default() -> Self { Self::new() } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum RespectSystemTheme { #[default] Off, On(SelectedSystemThemes), } #[derive( Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, settings_value::SettingsValue, )] #[schemars(description = "Themes to use when following the system light/dark mode.")] pub struct SelectedSystemThemes { #[schemars(description = "The theme to use in light mode.")] pub light: ThemeKind, #[schemars(description = "The theme to use in dark mode.")] pub dark: ThemeKind, } impl RespectSystemTheme { pub fn selected_system_themes(&self) -> Option<&SelectedSystemThemes> { match self { RespectSystemTheme::Off => None, RespectSystemTheme::On(selected) => Some(selected), } } } impl Default for SelectedSystemThemes { fn default() -> Self { Self { light: ThemeKind::Light, dark: ThemeKind::Dark, } } } #[derive(Copy, Clone, Debug)] pub struct PromptColors { pub input_prompt_conversation_management: ColorU, pub input_prompt_pwd: ColorU, pub input_prompt_git: ColorU, pub input_prompt_branch: ColorU, pub input_prompt_agent_mode_hint: ColorU, pub input_prompt_agent_mode_tasks: ColorU, pub input_prompt_dirty_color: ColorU, pub input_prompt_virtual_env: ColorU, pub input_prompt_user_and_host: ColorU, pub input_prompt_date: ColorU, pub input_prompt_time: ColorU, pub input_prompt_kubernetes: ColorU, pub input_prompt_svn: ColorU, pub input_prompt_separator: ColorU, pub input_prompt_subshell: ColorU, pub input_prompt_ssh: ColorU, } impl From for PromptColors { fn from(theme: GalaxyTheme) -> Self { PromptColors { input_prompt_conversation_management: theme.terminal_colors().normal.white.into(), input_prompt_pwd: theme.terminal_colors().normal.magenta.into(), input_prompt_git: theme.terminal_colors().normal.green.into(), input_prompt_agent_mode_hint: theme.terminal_colors().normal.yellow.into(), input_prompt_agent_mode_tasks: theme.terminal_colors().normal.yellow.into(), input_prompt_branch: theme.terminal_colors().normal.yellow.into(), input_prompt_dirty_color: theme.terminal_colors().normal.green.into(), input_prompt_virtual_env: theme.terminal_colors().normal.yellow.into(), input_prompt_user_and_host: theme.terminal_colors().normal.green.into(), input_prompt_date: theme.terminal_colors().normal.cyan.into(), input_prompt_time: theme.terminal_colors().normal.red.into(), input_prompt_kubernetes: theme.terminal_colors().normal.cyan.into(), input_prompt_ssh: theme.terminal_colors().normal.blue.into(), input_prompt_subshell: theme.terminal_colors().normal.blue.into(), input_prompt_svn: theme.terminal_colors().normal.blue.into(), input_prompt_separator: theme.terminal_colors().normal.magenta.into(), } } } pub fn render_preview( theme: &GalaxyTheme, font_family: FamilyId, form_factor: Option, ) -> Box { let text_size = 8. * form_factor.unwrap_or(1.); let margin = THUMBNAIL_MARGIN * form_factor.unwrap_or(1.); let padding = 5. * form_factor.unwrap_or(1.); let text_line_1 = Container::new( Text::new_inline("ls", font_family, text_size) .with_color(theme.foreground().into_solid()) .finish(), ) .with_margin_left(margin) .with_margin_right(margin) .finish(); let text_line_2 = Container::new( Flex::row() .with_child( Text::new_inline("dir ", font_family, text_size) .with_color(theme.terminal_colors().normal.blue.into()) .finish(), ) .with_child( Text::new_inline("executable ", font_family, text_size) .with_color(theme.terminal_colors().normal.red.into()) .finish(), ) .with_child( Text::new_inline("file", font_family, text_size) .with_color(theme.foreground().into_solid()) .finish(), ) .finish(), ) .with_margin_left(margin) .with_margin_right(margin) .finish(); let input_box = Shrinkable::new( 1., Align::new( Flex::column() // The border above the input box. .with_child( Container::new(Empty::new().finish()) .with_padding_bottom(padding) .with_border( Border::top(1.2 * form_factor.unwrap_or(1.)) .with_border_color(theme.outline().into_solid()), ) .finish(), ) // The fake cursor within the input box. .with_child( Container::new( ConstrainedBox::new( Rect::new() .with_background_color(theme.accent().into_solid()) .finish(), ) .with_height(12. * form_factor.unwrap_or(1.)) .with_width(2. * form_factor.unwrap_or(1.)) .finish(), ) .with_margin_left(margin) .with_margin_right(margin) .finish(), ) .finish(), ) .bottom_left() .finish(), ) .finish(); let mut thumbnail = Stack::new(); let mut background_opacity = 100; if let Some(background_image) = theme.background_image() { thumbnail.add_child( Shrinkable::new( 1., galaxyui::elements::Image::new( background_image.source(), galaxyui::elements::CacheOption::BySize, ) .cover() .finish(), ) .finish(), ); background_opacity -= background_image.opacity; } thumbnail.add_child( Container::new( Container::new( Flex::column() .with_child(text_line_1) .with_child( Container::new(text_line_2) .with_padding_top(padding) .finish(), ) .with_child(input_box) .finish(), ) .with_margin_top(margin) .with_margin_bottom(margin) .finish(), ) .with_background(theme.background().with_opacity(background_opacity)) .finish(), ); Align::new( Container::new( ConstrainedBox::new(thumbnail.finish()) .with_height(100. * form_factor.unwrap_or(1.)) .with_width(190. * form_factor.unwrap_or(1.)) .finish(), ) .finish(), ) .finish() } #[cfg(test)] #[path = "theme_tests.rs"] mod tests;