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

568 lines
18 KiB
Rust

use super::default_themes::*;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::iter::FromIterator;
use std::path::PathBuf;
use warp_core::ui::color::pick_foreground_color;
use warpui::assets::asset_cache::AssetSource;
use warpui::{
color::ColorU,
elements::{
Align, Border, ConstrainedBox, Container, Element, Empty, Flex, ParentElement, Rect,
Shrinkable, Stack, Text,
},
fonts::FamilyId,
};
use super::theme_creator::{pick_accent_color_from_options, top_colors_for_image};
pub use warp_core::ui::color::blend::Blend;
pub use warp_core::ui::theme::*;
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 = "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::SentReferralReward => "Warp Referral",
ThemeKind::ReceivedReferralReward => "Referred to Warp",
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())
}
}
#[derive(
Debug,
Clone,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
PartialOrd,
Ord,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(description = "A user-provided custom theme.")]
pub struct CustomTheme {
#[schemars(description = "The display name of the custom theme.")]
name: String,
#[schemars(description = "The file path to the custom theme definition.")]
path: PathBuf,
}
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) -> WarpTheme {
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())
};
WarpTheme::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(),
},
opacity: 30,
}),
Some(self.name()),
)
}
}
#[derive(Debug, Clone)]
pub struct WarpThemeConfig {
theme_map: HashMap<ThemeKind, WarpTheme>,
}
impl WarpThemeConfig {
pub fn new() -> Self {
// preload with built-in themes
let theme_map: HashMap<ThemeKind, WarpTheme> = 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()),
]);
WarpThemeConfig { theme_map }
}
pub fn add_new_theme(&mut self, theme_name: ThemeKind, theme: WarpTheme) {
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, &WarpTheme)> {
self.theme_map.iter()
}
pub fn theme(&self, name: &ThemeKind) -> WarpTheme {
self.theme_map.get(name).cloned().unwrap_or_else(dark_theme)
}
}
impl Default for WarpThemeConfig {
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<WarpTheme> for PromptColors {
fn from(theme: WarpTheme) -> 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: &WarpTheme,
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.,
warpui::elements::Image::new(
background_image.source(),
warpui::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_test.rs"]
mod tests;