Files
galaxy/app/src/themes/theme.rs
T

749 lines
23 KiB
Rust

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<CustomTheme> 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<Self> {
#[derive(Deserialize)]
struct FileValue {
name: String,
path: String,
}
let value = serde_json::from_value::<FileValue>(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<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
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<PathBuf, D::Error>
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<String> {
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<Vec<&str>> {
if raw.is_empty()
|| raw.contains('\\')
|| raw.starts_with('/')
|| raw_starts_with_windows_drive_prefix(raw)
{
return None;
}
let components = raw.split('/').collect::<Vec<_>>();
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<ColorU>,
#[serde(skip)]
chosen_bg_color_index: usize,
}
impl InMemoryThemeOptions {
pub async fn new(name: String, path: PathBuf) -> Result<Self> {
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<ColorU> {
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<ColorU> = 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<ThemeKind, GalaxyTheme>,
}
pub type WarpThemeConfig = GalaxyThemeConfig;
impl GalaxyThemeConfig {
pub fn new() -> Self {
// preload with built-in themes
let theme_map: HashMap<ThemeKind, GalaxyTheme> = 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<Item = (&ThemeKind, &GalaxyTheme)> {
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<GalaxyTheme> 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<f32>,
) -> Box<dyn Element> {
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;