Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
use crate::settings::import::config::ThemeError;
use async_recursion::async_recursion;
use async_trait::async_trait;
use serde::Deserialize;
use std::{env, io::ErrorKind, path::PathBuf};
use warp_core::ui::{
color::hex_color::coloru_from_hex_string,
theme::{AnsiColor, AnsiColors, TerminalColors, WarpTheme},
};
use warpui::fonts::FontInfo;
use super::config::{
calculate_accent_color, Config, ConfigError, ImportableSetting, ParseableConfig, SettingType,
ThemeType,
};
use pathfinder_color::ColorU;
type AlacrittyColor = String;
const CONFIG_DEPTH_LIMIT: u8 = 5;
// Constants for Alacritty's defaults: see the "Colors" section
// of https://alacritty.org/config-alacritty.html
pub const DEFAULT_ALACRITTY_FOREGROUND: ColorU = ColorU {
r: 0xd8,
g: 0xd8,
b: 0xd8,
a: 255,
};
pub const DEFAULT_ALACRITTY_BACKGROUND: ColorU = ColorU {
r: 0x18,
g: 0x18,
b: 0x18,
a: 255,
};
pub const DEFAULT_ALACRITTY_BRIGHT_COLORS: AnsiColors = AnsiColors {
black: AnsiColor {
r: 0x6b,
g: 0x6b,
b: 0x6b,
},
red: AnsiColor {
r: 0xc5,
g: 0x55,
b: 0x55,
},
green: AnsiColor {
r: 0xaa,
g: 0xc4,
b: 0x74,
},
yellow: AnsiColor {
r: 0xfe,
g: 0xca,
b: 0x88,
},
blue: AnsiColor {
r: 0x82,
g: 0xb8,
b: 0xc8,
},
magenta: AnsiColor {
r: 0xc2,
g: 0x8c,
b: 0xb8,
},
cyan: AnsiColor {
r: 0x93,
g: 0xd3,
b: 0xc3,
},
white: AnsiColor {
r: 0xf8,
g: 0xf8,
b: 0xf8,
},
};
pub const DEFAULT_ALACRITTY_NORMAL_COLORS: AnsiColors = AnsiColors {
black: AnsiColor {
r: 0x18,
g: 0x18,
b: 0x18,
},
red: AnsiColor {
r: 0xac,
g: 0x42,
b: 0x42,
},
green: AnsiColor {
r: 0x90,
g: 0xa9,
b: 0x59,
},
yellow: AnsiColor {
r: 0xf4,
g: 0xbf,
b: 0x75,
},
blue: AnsiColor {
r: 0x6a,
g: 0x9f,
b: 0xb5,
},
magenta: AnsiColor {
r: 0xaa,
g: 0x75,
b: 0x9f,
},
cyan: AnsiColor {
r: 0x75,
g: 0xb5,
b: 0xaa,
},
white: AnsiColor {
r: 0xd8,
g: 0xd8,
b: 0xd8,
},
};
/// `RecursivelyParseable` is a trait that bundles recursive functions over the different
/// data structures in our representation of Alacritty's config.
trait RecursivelyParseable {
/// Fills in any None fields from `self` with values from `other`.
fn merge_left(self, other: Self) -> Self;
}
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct AlacrittyTheme {
primary: Option<PrimaryAlacrittyColors>,
normal: Option<AlacrittyColors>,
bright: Option<AlacrittyColors>,
cursor: Option<AlacrittyCursorColor>,
}
/// Since Alacritty's config stores the cursor color in colors.cursor.cursor, we use this struct
/// to match Alacritty's keys exactly.
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct AlacrittyCursorColor {
cursor: Option<AlacrittyColor>,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct PrimaryAlacrittyColors {
foreground: Option<AlacrittyColor>,
background: Option<AlacrittyColor>,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct AlacrittyColors {
black: Option<AlacrittyColor>,
red: Option<AlacrittyColor>,
green: Option<AlacrittyColor>,
yellow: Option<AlacrittyColor>,
blue: Option<AlacrittyColor>,
magenta: Option<AlacrittyColor>,
cyan: Option<AlacrittyColor>,
white: Option<AlacrittyColor>,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct AlacrittyConfig {
colors: Option<AlacrittyTheme>,
import: Option<Vec<String>>,
}
impl RecursivelyParseable for AlacrittyConfig {
fn merge_left(mut self, other: Self) -> Self {
self.colors = self
.colors
.map(|inner| inner.merge_left(other.colors.clone().unwrap_or_default()))
.or(other.colors);
// Concatenate import lists if we can. Otherwise, take the other import list.
if let Some(import) = self.import {
self.import = Some(
import
.into_iter()
.chain(other.import.unwrap_or_default())
.collect(),
);
} else {
self.import = other.import;
}
self
}
}
impl RecursivelyParseable for AlacrittyColors {
fn merge_left(mut self, other: Self) -> Self {
self.black = self.black.or(other.black);
self.red = self.red.or(other.red);
self.green = self.green.or(other.green);
self.yellow = self.yellow.or(other.yellow);
self.blue = self.blue.or(other.blue);
self.magenta = self.magenta.or(other.magenta);
self.cyan = self.cyan.or(other.cyan);
self.white = self.white.or(other.white);
self
}
}
impl RecursivelyParseable for PrimaryAlacrittyColors {
fn merge_left(mut self, other: Self) -> Self {
self.foreground = self.foreground.or(other.foreground);
self.background = self.background.or(other.background);
self
}
}
impl RecursivelyParseable for AlacrittyTheme {
fn merge_left(mut self, other: Self) -> Self {
self.primary = self
.primary
.map(|inner| inner.merge_left(other.primary.clone().unwrap_or_default()))
.or(other.primary);
self.normal = self
.normal
.map(|inner| inner.merge_left(other.normal.clone().unwrap_or_default()))
.or(other.normal);
self.bright = self
.bright
.map(|inner| inner.merge_left(other.bright.clone().unwrap_or_default()))
.or(other.bright);
self.cursor = self
.cursor
.map(|inner| inner.merge_left(other.cursor.clone().unwrap_or_default()))
.or(other.cursor);
self
}
}
impl RecursivelyParseable for AlacrittyCursorColor {
fn merge_left(mut self, other: Self) -> Self {
self.cursor = self.cursor.or(other.cursor);
self
}
}
/// Parses a hex string into an AnsiColor, returning an error only if
/// the hex string is present but malformatted.
fn parse_alacritty_color(
color_string: Option<AlacrittyColor>,
) -> Result<Option<AnsiColor>, ThemeError> {
let Some(color) = color_string else {
return Ok(None);
};
if color.is_empty() {
return Ok(None);
}
match coloru_from_hex_string(color.as_str()) {
Ok(color) => Ok(Some(color.into())),
Err(e) => Err(ThemeError::HexColorError(e)),
}
}
impl AlacrittyTheme {
fn parse(self) -> Result<ThemeType, ThemeError> {
let primary = self.primary.unwrap_or_default();
let foreground = parse_alacritty_color(primary.foreground)?
.unwrap_or(DEFAULT_ALACRITTY_FOREGROUND.into());
let background = parse_alacritty_color(primary.background)?
.unwrap_or(DEFAULT_ALACRITTY_BACKGROUND.into());
let cursor_color =
parse_alacritty_color(self.cursor.unwrap_or_default().cursor)?.unwrap_or(foreground);
// Create TerminalColors from the config, filling in any missing values from Alacritty's default
// normal and bright colors.
let terminal_colors = TerminalColors {
normal: self
.normal
.unwrap_or_default()
.into_ansi_with_default(DEFAULT_ALACRITTY_NORMAL_COLORS)?,
bright: self
.bright
.unwrap_or_default()
.into_ansi_with_default(DEFAULT_ALACRITTY_BRIGHT_COLORS)?,
};
if foreground == DEFAULT_ALACRITTY_FOREGROUND.into()
|| background == DEFAULT_ALACRITTY_BACKGROUND.into()
{
Err(ThemeError::MissingValueError)
} else {
let bright = terminal_colors.bright;
let accent = calculate_accent_color(background, foreground, cursor_color, bright);
Ok(ThemeType::Single(WarpTheme::new(
background.into(),
foreground.into(),
accent.into(),
Some(cursor_color.into()),
None,
terminal_colors,
None,
Some(String::from("Imported Alacritty Theme")),
)))
}
}
}
impl AlacrittyColors {
/// Returns terminal colors with Warp's default colors substituted in for any
/// missing terminal colors.
fn into_ansi_with_default(self, default: AnsiColors) -> Result<AnsiColors, ThemeError> {
Ok(AnsiColors {
black: parse_alacritty_color(self.black)?.unwrap_or(default.black),
red: parse_alacritty_color(self.red)?.unwrap_or(default.red),
green: parse_alacritty_color(self.green)?.unwrap_or(default.green),
yellow: parse_alacritty_color(self.yellow)?.unwrap_or(default.yellow),
blue: parse_alacritty_color(self.blue)?.unwrap_or(default.blue),
magenta: parse_alacritty_color(self.magenta)?.unwrap_or(default.magenta),
cyan: parse_alacritty_color(self.cyan)?.unwrap_or(default.cyan),
white: parse_alacritty_color(self.white)?.unwrap_or(default.white),
})
}
}
#[async_trait]
impl ParseableConfig for AlacrittyConfig {
fn parse(self, _font_info: &[FontInfo]) -> Config {
Config {
theme: ImportableSetting::new(self.parse_theme(), SettingType::Theme),
terminal_name: "Alacritty".to_string(),
..Default::default()
}
}
async fn from_file(path: PathBuf) -> Result<Vec<Self>, ConfigError> {
Self::from_file_bounded_depth(path, 0).await
}
fn default_paths() -> Vec<std::path::PathBuf> {
// We follow Alacritty's strategy described here: https://github.com/alacritty/alacritty?tab=readme-ov-file#configuration.
// Since alacritty uses the `xdg` crate to read config files, we include paths in XDG_CONFIG_DIRS.
// If we are on Windows, search the only path: %APPDATA%\alacritty\alacritty.toml.
if cfg!(windows) {
return dirs::config_dir()
.map(|path| vec![path.join("alacritty").join("alacritty.toml")])
.unwrap_or_default();
}
let mut file_paths = vec![];
let mut second_file_paths = vec![];
let xdg_config_dirs = env::var("XDG_CONFIG_DIRS")
.ok()
.filter(|val| !val.is_empty())
.or_else(|| Some("/usr/local/share/:/usr/share/".to_string()));
// Add to file_paths:
// - $XDG_CONFIG_HOME/alacritty/alacritty.toml
// Add to second_file_paths:
// - $XDG_CONFIG_HOME/alacritty.toml
if let Some(xdg_config_home) = dirs::config_dir() {
file_paths.push(xdg_config_home.join("alacritty").join("alacritty.toml"));
second_file_paths.push(xdg_config_home.join("alacritty.toml"));
}
// Add to file_paths:
// - $XDG_CONFIG_DIRS/alacritty/alacritty.toml
// Add to second_file_paths:
// - $XDG_CONFIG_DIRS/alacritty.toml
if let Some(xdg_config_dirs) = xdg_config_dirs.clone() {
for dir in xdg_config_dirs.split(':') {
if !dir.is_empty() {
file_paths.push(PathBuf::from(dir).join("alacritty").join("alacritty.toml"));
second_file_paths.push(PathBuf::from(dir).join("alacritty.toml"));
}
}
}
// Add second_file_paths to the end of file_paths to maintain the correct order.
file_paths.extend(second_file_paths);
// As a backup, check
// - $HOME/.config/alacritty/alacritty.toml
// - $HOME/.alacritty.toml
if let Some(home) = dirs::home_dir() {
file_paths.push(
home.join(".config")
.join("alacritty")
.join("alacritty.toml"),
);
file_paths.push(home.join(".alacritty.toml"));
}
file_paths
}
fn remove_default_values(self) -> Self {
// The default Alacritty config is an empty file,
// so we don't need to remove anything.
self
}
}
impl AlacrittyConfig {
/// Reads Alacritty configs asynchronously and folds in any imported configs up to a given depth.
#[async_recursion]
async fn from_file_bounded_depth(path: PathBuf, depth: u8) -> Result<Vec<Self>, ConfigError> {
// Since Alacritty only reads configs up to depth 5,
// return an empty config if we are at depth 5.
if depth >= CONFIG_DEPTH_LIMIT {
log::warn!(
"Maximum configuration depth reached while parsing Alacritty configuration at {path:?}"
);
return Ok(vec![AlacrittyConfig::default()]);
}
let contents = match async_fs::read_to_string(path.clone()).await {
Ok(string) => string,
Err(e) => {
if e.kind() == ErrorKind::NotFound {
return Err(ConfigError::FileNotFoundError);
} else {
return Err(ConfigError::FileIOError(e));
}
}
};
let Ok(mut out) = toml::from_str::<AlacrittyConfig>(contents.as_str()) else {
return Err(ConfigError::MalformattedFileError(path));
};
// Alacritty prioritizes settings set in the current config.
// If a setting is not set in the highest-level config, it then looks to the imported configs.
// It reads each imported config in order, effectively prioritizing the last config listed.
// It does support tilde as the home directory, but not environment variables or relative paths.
// Start with a config with None in all fields.
let mut imported_config: AlacrittyConfig = Default::default();
if let Some(ref imports) = out.import {
// Reverse the iterator, so that the last configs listed get priority.
for file_path in imports.iter().rev() {
// Merge left, which replaces None values in the first argument
// with values from the second argument.
imported_config = imported_config.merge_left(
Self::from_file_bounded_depth(
PathBuf::from(shellexpand::tilde(file_path).into_owned().to_string()),
depth + 1,
)
.await?
.pop()
.unwrap_or_default(),
);
}
}
out = out.merge_left(imported_config);
Ok(vec![out])
}
fn parse_theme(self) -> Result<ThemeType, ThemeError> {
match self.colors {
Some(colors) => Ok(colors.parse()?),
None => Err(ThemeError::MissingValueError),
}
}
}
#[cfg(test)]
#[path = "alacritty_parser_tests.rs"]
mod tests;
@@ -0,0 +1,475 @@
use async_io::block_on;
use virtual_fs::{Stub, VirtualFS};
use warp_core::ui::{color::hex_color::coloru_from_hex_string, theme::AnsiColor};
use crate::settings::import::config::{ParseableConfig, ThemeType};
use super::{
AlacrittyColors, AlacrittyConfig, AlacrittyTheme, PrimaryAlacrittyColors, RecursivelyParseable,
};
#[test]
fn test_parse_cobalt2() {
let cobalt2_config = "# From the famous Cobalt2 sublime theme
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
# Default colors
[colors.primary]
background = '#122637'
foreground = '#ffffff'
[colors.cursor]
text = '#122637'
cursor = '#f0cb09'
# Normal colors
[colors.normal]
black = '#000000'
red = '#ff0000'
green = '#37dd21'
yellow = '#fee409'
blue = '#1460d2'
magenta = '#ff005d'
cyan = '#00bbbb'
white = '#bbbbbb'
# Bright colors
[colors.bright]
black = '#545454'
red = '#f40d17'
green = '#3bcf1d'
yellow = '#ecc809'
blue = '#5555ff'
magenta = '#ff55ff'
cyan = '#6ae3f9'
white = '#ffffff'";
let config: AlacrittyConfig =
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
let ThemeType::Single(theme) = config
.colors
.expect("Should have read colors!")
.parse()
.expect("Theme should have read!")
else {
panic!("Should not have a dark and light theme for Alacritty!")
};
// Check that the three primary colors are the same.
assert_eq!(
theme.accent().into_solid(),
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
);
assert_eq!(
theme.background().into_solid(),
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
);
assert_eq!(
theme.foreground().into_solid(),
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
);
// Check some terminal colors.
assert_eq!(
theme.terminal_colors().bright.yellow,
AnsiColor {
r: 0xec,
g: 0xc8,
b: 0x09
}
);
assert_eq!(
theme.terminal_colors().bright.cyan,
AnsiColor {
r: 0x6a,
g: 0xe3,
b: 0xf9
}
);
assert_eq!(
theme.terminal_colors().normal.yellow,
AnsiColor {
r: 0xfe,
g: 0xe4,
b: 0x09
}
);
assert_eq!(
theme.terminal_colors().normal.cyan,
AnsiColor {
r: 0x00,
g: 0xbb,
b: 0xbb
}
);
}
#[test]
fn test_parse_cobalt2_missing_color() {
let cobalt2_config = "# From the famous Cobalt2 sublime theme
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
# Default colors
[colors.primary]
background = '#122637'
foreground = '#ffffff'
[colors.cursor]
text = '#122637'
cursor = '#f0cb09'
# Normal colors
[colors.normal]
red = '#ff0000'
green = '#37dd21'
yellow = '#fee409'
blue = '#1460d2'
magenta = '#ff005d'
cyan = '#00bbbb'
white = '#bbbbbb'
# Bright colors
[colors.bright]
black = '#545454'
red = '#f40d17'
green = '#3bcf1d'
yellow = '#ecc809'
blue = '#5555ff'
magenta = '#ff55ff'
cyan = '#6ae3f9'
white = '#ffffff'";
let config: AlacrittyConfig =
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
let ThemeType::Single(theme) = config
.colors
.expect("Should have read colors!")
.parse()
.expect("Theme should have read!")
else {
panic!("Should not have a dark and light theme for Alacritty!")
};
// Check that the three primary colors are the same.
assert_eq!(
theme.accent().into_solid(),
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
);
assert_eq!(
theme.background().into_solid(),
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
);
assert_eq!(
theme.foreground().into_solid(),
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
);
// Check some terminal colors.
assert_eq!(
theme.terminal_colors().bright.yellow,
AnsiColor {
r: 0xec,
g: 0xc8,
b: 0x09
}
);
assert_eq!(
theme.terminal_colors().bright.cyan,
AnsiColor {
r: 0x6a,
g: 0xe3,
b: 0xf9
}
);
assert_eq!(
theme.terminal_colors().normal.yellow,
AnsiColor {
r: 0xfe,
g: 0xe4,
b: 0x09
}
);
assert_eq!(
theme.terminal_colors().normal.cyan,
AnsiColor {
r: 0x00,
g: 0xbb,
b: 0xbb
}
);
assert_eq!(
theme.terminal_colors().normal.black,
AnsiColor {
r: 0x18,
g: 0x18,
b: 0x18,
}
);
}
#[test]
fn test_parse_cobalt2_missing_section() {
let cobalt2_config = "# From the famous Cobalt2 sublime theme
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
# Default colors
[colors.primary]
background = '#122637'
foreground = '#ffffff'
[colors.cursor]
text = '#122637'
cursor = '#f0cb09'";
let config: AlacrittyConfig =
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
let ThemeType::Single(theme) = config
.colors
.expect("Should have read colors!")
.parse()
.expect("Theme should have read!")
else {
panic!("Should not have a dark and light theme for Alacritty!")
};
// Check that the three primary colors are the same.
assert_eq!(
theme.accent().into_solid(),
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
);
assert_eq!(
theme.background().into_solid(),
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
);
assert_eq!(
theme.foreground().into_solid(),
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
);
}
#[test]
fn test_parse_cobalt2_bad_terminal_color() {
let cobalt2_config = "# From the famous Cobalt2 sublime theme
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
# Default colors
[colors.primary]
background = '#122637'
foreground = '#ffffff'
[colors.cursor]
text = '#122637'
cursor = '#f0cb09'
# Normal colors
[colors.normal]
red = '#ff000'
green = '#37dd21'
yellow = '#fee409'
blue = '#1460d2'
magenta = '#ff005d'
cyan = '#00bbbb'
white = '#bbbbbb'
# Bright colors
[colors.bright]
black = '#545454'
red = '#f40d17'
green = '#3bcf1d'
yellow = '#ecc809'
blue = '#5555ff'
magenta = '#ff55ff'
cyan = '#6ae3f9'
white = '#ffffff'";
let config: AlacrittyConfig =
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
let _ = config
.colors
.expect("Should have read colors!")
.parse()
.expect_err("Theme should not have read!");
}
#[test]
fn test_parse_cobalt2_no_colors() {
let cobalt2_config = "# From the famous Cobalt2 sublime theme
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
# Default colors
[colors.primary]
background = ''
foreground = ''
[colors.cursor]
text = ''
cursor = ''";
let config: AlacrittyConfig =
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
let _ = config
.colors
.expect("Should have read colors!")
.parse()
.expect_err("Theme should not have read!");
}
#[test]
fn test_merge_left() {
let mut colors = AlacrittyTheme {
primary: Some(PrimaryAlacrittyColors {
foreground: Some("#ffffff".to_string()),
background: Some("#000000".to_string()),
}),
normal: Some(AlacrittyColors {
black: Some("#000000".to_string()),
..Default::default()
}),
..Default::default()
};
let second_colors = AlacrittyTheme {
primary: Some(PrimaryAlacrittyColors {
foreground: None,
background: Some("#ff0000".to_string()),
}),
normal: Some(AlacrittyColors {
red: Some("#ff1111".to_string()),
..Default::default()
}),
..Default::default()
};
colors = colors.merge_left(second_colors);
let primary = colors
.primary
.clone()
.expect("Primary colors should be present");
let normal = colors
.normal
.clone()
.expect("Normal colors should be present");
// Check all four combinations of present and absent in the inner struct.
assert_eq!(primary.foreground, Some("#ffffff".to_string()));
assert_eq!(primary.background, Some("#000000".to_string()));
assert_eq!(normal.black, Some("#000000".to_string()));
assert_eq!(normal.red, Some("#ff1111".to_string()));
assert_eq!(normal.white, None);
// Check that None values are preserved.
assert!(colors.bright.is_none());
}
/// This is a unit test that tests reading from the default config location with one import.
#[test]
fn test_parse_cobalt2_from_import() {
VirtualFS::test("test_parse_cobalt2_from_import", |dirs, mut sandbox| {
sandbox.mkdir("config");
sandbox.with_files(vec![
Stub::FileWithContent(
"config/config.toml",
format!(
"
import = [{:?}]
",
dirs.tests().join("config").join("Cobalt2.toml")
)
.as_str(),
),
Stub::FileWithContent(
"config/Cobalt2.toml",
"# From the famous Cobalt2 sublime theme
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
# Default colors
[colors.primary]
background = '#122637'
foreground = '#ffffff'
[colors.cursor]
text = '#122637'
cursor = '#f0cb09'
# Normal colors
[colors.normal]
black = '#000000'
red = '#ff0000'
green = '#37dd21'
yellow = '#fee409'
blue = '#1460d2'
magenta = '#ff005d'
cyan = '#00bbbb'
white = '#bbbbbb'
# Bright colors
[colors.bright]
black = '#545454'
red = '#f40d17'
green = '#3bcf1d'
yellow = '#ecc809'
blue = '#5555ff'
magenta = '#ff55ff'
cyan = '#6ae3f9'
white = '#ffffff'",
),
]);
let config: AlacrittyConfig = block_on(AlacrittyConfig::from_file(
dirs.tests().join("config").join("config.toml"),
))
.expect("Should be able to read file!")
.pop()
.expect("Should have returned at least one config!");
let ThemeType::Single(theme) = config
.colors
.expect("Should have read colors!")
.parse()
.expect("Theme should have read!")
else {
panic!("Should not have a dark and light theme for Alacritty!")
};
// Check that the three primary colors are the same.
assert_eq!(
theme.accent().into_solid(),
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
);
assert_eq!(
theme.background().into_solid(),
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
);
assert_eq!(
theme.foreground().into_solid(),
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
);
// Check some terminal colors.
assert_eq!(
theme.terminal_colors().bright.yellow,
AnsiColor {
r: 0xec,
g: 0xc8,
b: 0x09
}
);
assert_eq!(
theme.terminal_colors().bright.cyan,
AnsiColor {
r: 0x6a,
g: 0xe3,
b: 0xf9
}
);
assert_eq!(
theme.terminal_colors().normal.yellow,
AnsiColor {
r: 0xfe,
g: 0xe4,
b: 0x09
}
);
assert_eq!(
theme.terminal_colors().normal.cyan,
AnsiColor {
r: 0x00,
g: 0xbb,
b: 0xbb
}
);
});
}
+449
View File
@@ -0,0 +1,449 @@
use std::{path::PathBuf, sync::Arc};
use pathfinder_color::ColorU;
use serde::Serialize;
use strum_macros::EnumIter;
use warp_core::ui::{
color::hex_color::HexColorError as UiHexColorError,
theme::{AnsiColors, WarpTheme},
};
use async_trait::async_trait;
use thiserror::Error;
use warpui::{fonts::FontInfo, keymap::Keystroke, DisplayIdx};
use crate::{
interval_timer::IntervalTimer,
root_view::QuakeModePinPosition,
settings::ExtraMetaKeys,
terminal::session_settings::{StartupShell, WorkingDirectoryConfig},
themes::theme_creator::pick_accent_color_from_options,
};
#[cfg(feature = "local_fs")]
use crate::{themes::theme_creator_body::ThemeCreatorBody, user_config};
use super::{alacritty_parser::AlacrittyConfig, model::TerminalType};
#[cfg(target_os = "macos")]
use super::iterm_parser::ITermProfile;
#[derive(Debug)]
pub enum ThemeType {
LightAndDark { light: WarpTheme, dark: WarpTheme },
Single(WarpTheme),
}
#[derive(Clone, Debug)]
pub enum ThemeError {
/// A hex color is malformatted (not missing).
HexColorError(UiHexColorError),
/// A value in the theme is missing.
MissingValueError,
}
#[derive(Clone, Error, Debug)]
pub enum HotkeyError {
#[error("A hotkey window opens in a way Warp does not support")]
UnsupportedWindowType,
#[error("There are multiple hotkeys configured")]
MultipleHotkeys,
#[error("No hotkey is set")]
MissingHotkey,
}
#[derive(Debug)]
pub enum ConfigError {
/// A general IO error when reading the file, excluding
/// a NotFound error.
FileIOError(std::io::Error),
/// A file is missing.
FileNotFoundError,
/// A file is readable but is formatted incorrectly.
MalformattedFileError(PathBuf),
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, EnumIter, Serialize)]
pub enum SettingType {
Theme,
OptionAsMeta,
MouseAndScrollReporting,
Font,
DefaultShell,
WorkingDirectory,
HotkeyMode,
WindowSize,
CopyOnSelect,
Opacity,
CursorBlinking,
}
impl SettingType {
pub fn get_name(&self) -> &'static str {
match self {
SettingType::Theme => "Theme",
SettingType::OptionAsMeta => "Option as Meta",
SettingType::MouseAndScrollReporting => "Mouse/Scroll Reporting",
SettingType::Font => "Font",
SettingType::DefaultShell => "Default Shell",
SettingType::WorkingDirectory => "Working Directory",
SettingType::HotkeyMode => "Global hotkey",
SettingType::WindowSize => "Window Dimensions",
SettingType::CopyOnSelect => "Copy On Select",
SettingType::Opacity => "Window Opacity",
SettingType::CursorBlinking => "Cursor Blinking",
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct MouseAndScrollReporting {
pub mouse_reporting: bool,
pub scroll_reporting: bool,
}
impl Default for MouseAndScrollReporting {
fn default() -> Self {
Self {
mouse_reporting: true,
scroll_reporting: true,
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedFont {
pub family: Option<String>,
pub size: Option<f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct QuakeModeWindow {
pub keystroke: Keystroke,
pub autohide: bool,
pub screen: Option<DisplayIdx>,
pub pin_position: QuakeModePinPosition,
}
#[derive(Clone, Debug, PartialEq)]
pub enum GlobalHotkey {
Activation(Keystroke),
QuakeMode(QuakeModeWindow),
}
#[derive(Clone, Debug, PartialEq)]
pub struct OpacitySettings {
pub opacity: Option<u8>,
pub blur_radius: Option<u8>,
}
#[derive(Debug)]
pub struct Config {
pub theme: ImportableSetting<Result<ThemeType, ThemeError>>,
pub option_as_meta: ImportableSetting<ExtraMetaKeys>,
pub mouse_and_scroll_reporting: ImportableSetting<Option<MouseAndScrollReporting>>,
pub font: ImportableSetting<ImportedFont>,
pub default_shell: ImportableSetting<Option<StartupShell>>,
pub working_directory: ImportableSetting<Option<WorkingDirectoryConfig>>,
pub terminal_name: String,
pub description: Option<String>,
pub hotkey_mode: ImportableSetting<Result<GlobalHotkey, HotkeyError>>,
pub opacity: ImportableSetting<OpacitySettings>,
pub window_size: ImportableSetting<(Option<u16>, Option<u16>)>,
pub copy_on_select: ImportableSetting<Option<bool>>,
pub cursor_blinking: ImportableSetting<Option<bool>>,
}
impl Config {
/// Creates all valid Configs for a given ParseableConfig type.
/// Each profile (in terminals that support profiles) is mapped to a separate Config.
pub async fn create_from_external_configs<Input: ParseableConfig>(
fonts: Arc<Vec<FontInfo>>,
) -> (Result<Vec<Self>, ConfigError>, IntervalTimer) {
let mut timer = IntervalTimer::new();
let config = match Input::from_config_paths(&mut timer).await {
Ok(config) => config,
Err(err) => {
return (Err(err), timer);
}
};
let configs = config
.into_iter()
.map(|config| config.remove_default_values())
.map(|config| config.parse(&fonts))
.filter(|config| config.is_valid())
.collect();
timer.mark_interval_end("TERMINAL_SETTINGS_PARSED");
(Ok(configs), timer)
}
pub(super) fn write_theme(&self) -> Option<ThemeType> {
#[cfg(feature = "local_fs")]
{
if !self.theme.should_import {
return None;
}
let Ok(theme) = self.theme.value() else {
return None;
};
let dir = user_config::themes_dir();
match theme {
ThemeType::LightAndDark { light, dark } => {
let light_theme_yaml_file_name =
format!("{}_light_theme.yaml", self.terminal_name);
let light_written = ThemeCreatorBody::write_theme(
light,
dir.clone(),
light_theme_yaml_file_name,
None,
|_| light.clone(),
);
let dark_theme_yaml_file_name =
format!("{}_dark_theme.yaml", self.terminal_name);
let dark_written = ThemeCreatorBody::write_theme(
dark,
dir,
dark_theme_yaml_file_name,
None,
|_| dark.clone(),
);
if let (Some(light), Some(dark)) = (light_written, dark_written) {
Some(ThemeType::LightAndDark { light, dark })
} else {
None
}
}
ThemeType::Single(normal) => {
let theme_yaml_file_name = format!("{}_theme.yaml", self.terminal_name);
ThemeCreatorBody::write_theme(normal, dir, theme_yaml_file_name, None, |_| {
ThemeType::Single(normal.clone())
})
}
}
}
#[cfg(not(feature = "local_fs"))]
{
log::warn!("Tried to save theme without a local filesystem.");
None
}
}
/// Generates a Config from the given TerminalType.
pub async fn create_from_terminal_type(
terminal: TerminalType,
fonts: Arc<Vec<FontInfo>>,
) -> (Result<Vec<Self>, ConfigError>, IntervalTimer) {
match terminal {
TerminalType::Alacritty => {
Config::create_from_external_configs::<AlacrittyConfig>(fonts).await
}
#[cfg(target_os = "macos")]
TerminalType::ITerm => {
Config::create_from_external_configs::<ITermProfile>(fonts).await
}
}
}
/// Returns the list of [`SettingType`]s that have an importable setting associated with it.
pub(super) fn valid_setting_types(&self) -> Vec<SettingType> {
let mut out = vec![];
let default_config = Config::default();
if self.theme.value().is_ok() {
out.push(self.theme.setting_type().clone());
}
if *self.option_as_meta.value() != *default_config.option_as_meta.value() {
out.push(self.option_as_meta.setting_type().clone());
}
if *self.mouse_and_scroll_reporting.value()
!= *default_config.mouse_and_scroll_reporting.value()
{
out.push(self.mouse_and_scroll_reporting.setting_type().clone());
}
if *self.font.value() != *default_config.font.value() {
out.push(self.font.setting_type().clone());
}
if *self.default_shell.value() != *default_config.default_shell.value() {
out.push(self.default_shell.setting_type().clone());
}
if *self.working_directory.value() != *default_config.working_directory.value() {
out.push(self.working_directory.setting_type().clone());
}
if *self.copy_on_select.value() != *default_config.copy_on_select.value() {
out.push(self.copy_on_select.setting_type().clone());
}
if *self.cursor_blinking.value() != *default_config.cursor_blinking.value() {
out.push(self.cursor_blinking.setting_type().clone());
}
if *self.window_size.value() != *default_config.window_size.value() {
out.push(self.window_size.setting_type().clone());
}
if *self.opacity.value() != *default_config.opacity.value() {
out.push(self.opacity.setting_type().clone());
}
if self.hotkey_mode.value().is_ok() {
out.push(self.hotkey_mode.setting_type().clone());
}
out
}
/// Returns whether or not this Config contains valid importable settings.
pub fn is_valid(&self) -> bool {
!self.valid_setting_types().is_empty()
}
}
/// Used for telemetry.
#[derive(Clone, Serialize)]
pub struct ParsedTerminalSetting {
pub setting_type: SettingType,
pub was_imported_by_user: bool,
}
/// A wrapper for a setting along with its display name
/// and whether or not the user has selected to import it.
#[derive(Debug)]
pub struct ImportableSetting<T> {
pub(super) setting: T,
setting_type: SettingType,
pub should_import: bool,
}
impl<T> ImportableSetting<T> {
pub fn new(setting: T, setting_type: SettingType) -> Self {
Self {
setting,
setting_type,
should_import: true,
}
}
/// Returns the a reference to the inner setting.
pub fn value(&self) -> &T {
&self.setting
}
/// Returns the setting type.
pub fn setting_type(&self) -> &SettingType {
&self.setting_type
}
}
impl<T: Clone> ImportableSetting<T> {
/// Returns Some(value) if we should import this setting and None otherwise.
pub fn importable_value(&self) -> Option<T> {
if self.should_import {
Some(self.setting.clone())
} else {
None
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
theme: ImportableSetting::new(Err(ThemeError::MissingValueError), SettingType::Theme),
option_as_meta: ImportableSetting::new(Default::default(), SettingType::OptionAsMeta),
mouse_and_scroll_reporting: ImportableSetting::new(
None,
SettingType::MouseAndScrollReporting,
),
terminal_name: "".to_string(),
font: ImportableSetting::new(
ImportedFont {
family: None,
size: None,
},
SettingType::Font,
),
description: None,
default_shell: ImportableSetting::new(None, SettingType::DefaultShell),
working_directory: ImportableSetting::new(None, SettingType::WorkingDirectory),
hotkey_mode: ImportableSetting::new(
Err(HotkeyError::MissingHotkey),
SettingType::HotkeyMode,
),
window_size: ImportableSetting::new((None, None), SettingType::WindowSize),
opacity: ImportableSetting::new(
OpacitySettings {
opacity: None,
blur_radius: None,
},
SettingType::Opacity,
),
copy_on_select: ImportableSetting::new(None, SettingType::CopyOnSelect),
cursor_blinking: ImportableSetting::new(None, SettingType::CursorBlinking),
}
}
}
#[async_trait]
pub trait ParseableConfig: PartialEq + Sized + Send {
/// Reads the file at the given path into the struct implementing ParseableConfig.
async fn from_file(path: PathBuf) -> Result<Vec<Self>, ConfigError>;
/// Creates a Warp-readable `Config`. Sets corresponding errors if values have
/// not been configured from the default.
fn parse(self, fonts: &[FontInfo]) -> Config;
/// Tries to read configuration from the list of default paths.
///
/// NOTE: Returns an error if
/// none of the files are found or any of the files throw some other error.
async fn from_config_paths(timer: &mut IntervalTimer) -> Result<Vec<Self>, ConfigError> {
for path in Self::default_paths() {
match Self::from_file(path).await {
Err(ConfigError::FileNotFoundError) => continue,
result => {
timer.mark_interval_end("TERMINAL_SETTINGS_READ_FROM_FILE");
return result;
}
}
}
timer.mark_interval_end("TERMINAL_SETTINGS_READ_FROM_FILE");
Err(ConfigError::FileNotFoundError)
}
/// Returns the list of paths in which to search for configuration files.
fn default_paths() -> Vec<PathBuf>;
/// Strips all fields that have not been configured from the default.
fn remove_default_values(self) -> Self;
}
pub fn calculate_accent_color(
background: impl Into<ColorU>,
foreground: impl Into<ColorU>,
cursor_color: impl Into<ColorU>,
bright: AnsiColors,
) -> ColorU {
let cursor_color = cursor_color.into();
let foreground = foreground.into();
if cursor_color == foreground {
pick_accent_color_from_options(
&[background.into(), foreground],
// Exclude white and black so that we don't choose either.
&[
bright.red.into(),
bright.green.into(),
bright.yellow.into(),
bright.magenta.into(),
bright.cyan.into(),
bright.blue.into(),
],
)
} else {
cursor_color
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
pub mod alacritty_parser;
pub mod config;
#[cfg(target_os = "macos")]
pub mod iterm_parser;
pub mod model;
pub mod view;
+275
View File
@@ -0,0 +1,275 @@
use std::collections::HashMap;
use crate::interval_timer::IntervalTimer;
use crate::settings::import::config::{Config, ConfigError};
use crate::{send_telemetry_from_ctx, TelemetryEvent};
use serde::Serialize;
use strum::IntoEnumIterator;
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::features::FeatureFlag;
use warpui::Entity;
use warpui::ModelContext;
use warpui::SingletonEntity;
#[cfg(target_os = "macos")]
use super::config::HotkeyError;
use super::config::SettingType;
use super::config::ThemeType;
#[derive(Clone, Copy, Debug, EnumDiscriminants, Eq, Hash, PartialEq)]
#[strum_discriminants(derive(EnumIter, Hash, Serialize))]
#[strum_discriminants(name(TerminalType))]
pub enum TerminalTypeAndProfile {
Alacritty,
#[cfg(target_os = "macos")]
ITerm(usize),
}
pub struct CompletedParseEvent {
pub terminal: TerminalType,
}
pub struct ImportedConfigModel {
started: bool,
parsed_terminals: HashMap<TerminalType, Result<Vec<Config>, ConfigError>>,
}
impl ImportedConfigModel {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
ImportedConfigModel {
parsed_terminals: Default::default(),
started: false,
}
}
#[cfg(feature = "local_fs")]
pub fn search_for_settings_to_import(&mut self, ctx: &mut ModelContext<Self>) {
use itertools::Itertools;
use std::sync::Arc;
use strum::IntoEnumIterator;
self.started = true;
let loaded_system_fonts = warpui::fonts::Cache::handle(ctx)
.update(ctx, |font_cache, ctx| font_cache.all_system_fonts(ctx));
ctx.spawn(loaded_system_fonts, |_, fonts, ctx| {
let fonts = fonts
.into_iter()
.map(|(_family_id, font_info)| font_info)
.collect_vec();
let fonts_ref = Arc::new(fonts);
for terminal_type in TerminalType::iter() {
if terminal_type == TerminalType::Alacritty
&& !FeatureFlag::AlacrittySettingsImport.is_enabled()
{
continue;
}
let fonts_ref_clone = Arc::clone(&fonts_ref);
ctx.spawn(
async move {
Config::create_from_terminal_type(terminal_type, fonts_ref_clone).await
},
move |model, output, ctx| {
model.write_parse_results(terminal_type, output, ctx);
},
);
}
});
}
pub fn is_started(&self) -> bool {
self.started
}
#[cfg(target_os = "macos")]
fn maybe_send_multiple_hotkeys_telemetry_event(
&self,
terminal_type: &TerminalType,
configs: &Result<Vec<Config>, ConfigError>,
ctx: &mut ModelContext<Self>,
) {
if let TerminalType::ITerm = terminal_type {
if let Ok(configs) = configs {
if configs.iter().any(|config| {
matches!(
config.hotkey_mode.setting,
Err(HotkeyError::MultipleHotkeys)
)
}) {
send_telemetry_from_ctx!(TelemetryEvent::ITermMultipleHotkeys, ctx);
}
}
}
}
pub fn write_parse_results(
&mut self,
terminal_type: TerminalType,
(configs, timer): (Result<Vec<Config>, ConfigError>, IntervalTimer),
ctx: &mut ModelContext<Self>,
) {
send_telemetry_from_ctx!(
TelemetryEvent::SettingsImportConfigParsed {
timing_data: timer.compute_stats(),
terminal_type,
settings_shown_to_user: configs
.as_ref()
.ok()
.and_then(|configs| configs.first())
.map(|config| config.valid_setting_types())
},
ctx
);
#[cfg(target_os = "macos")]
self.maybe_send_multiple_hotkeys_telemetry_event(&terminal_type, &configs, ctx);
self.parsed_terminals.insert(terminal_type, configs);
ctx.emit(CompletedParseEvent {
terminal: terminal_type,
});
}
pub fn configs(&self) -> impl Iterator<Item = (TerminalTypeAndProfile, &Config)> {
self.parsed_terminals
.iter()
.filter_map(|(terminal, parse_result)| {
parse_result.as_ref().ok().map(|value| (terminal, value))
})
.flat_map(|(discriminant, vec)| match discriminant {
TerminalType::Alacritty => vec
.iter()
.take(1)
.map(|item| (TerminalTypeAndProfile::Alacritty, item))
.collect::<Vec<(TerminalTypeAndProfile, &Config)>>()
.into_iter(),
#[cfg(target_os = "macos")]
TerminalType::ITerm => vec
.iter()
.enumerate()
.map(|(idx, item)| (TerminalTypeAndProfile::ITerm(idx), item))
.collect::<Vec<(TerminalTypeAndProfile, &Config)>>()
.into_iter(),
})
}
pub(super) fn config(&self, profile: &TerminalTypeAndProfile) -> Option<&Config> {
self.parsed_terminals
.get(&TerminalType::from(profile))
.map(|vec| match profile {
TerminalTypeAndProfile::Alacritty => vec.as_ref().ok().and_then(|vec| vec.first()),
#[cfg(target_os = "macos")]
TerminalTypeAndProfile::ITerm(idx) => {
vec.as_ref().ok().and_then(|vec| vec.get(*idx))
}
})
.unwrap_or_else(|| {
log::warn!("Attempted to access an invalid profile.");
None
})
}
fn config_mut(&mut self, profile: &TerminalTypeAndProfile) -> Option<&mut Config> {
self.parsed_terminals
.get_mut(&TerminalType::from(profile))
.map(|vec| match profile {
TerminalTypeAndProfile::Alacritty => {
vec.as_mut().ok().and_then(|vec| vec.first_mut())
}
#[cfg(target_os = "macos")]
TerminalTypeAndProfile::ITerm(idx) => {
vec.as_mut().ok().and_then(|vec| vec.get_mut(*idx))
}
})
.unwrap_or_else(|| {
log::warn!("Attempted to access an invalid profile.");
None
})
}
pub fn toggle_should_import(
&mut self,
profile: &TerminalTypeAndProfile,
setting: &SettingType,
) {
let Some(config) = self.config_mut(profile) else {
log::warn!("Attempted to toggle import on an invalid profile!");
return;
};
match setting {
SettingType::Theme => config.theme.should_import = !config.theme.should_import,
SettingType::OptionAsMeta => {
config.option_as_meta.should_import = !config.option_as_meta.should_import
}
SettingType::MouseAndScrollReporting => {
config.mouse_and_scroll_reporting.should_import =
!config.mouse_and_scroll_reporting.should_import
}
SettingType::Font => config.font.should_import = !config.font.should_import,
SettingType::DefaultShell => {
config.default_shell.should_import = !config.default_shell.should_import
}
SettingType::WorkingDirectory => {
config.working_directory.should_import = !config.working_directory.should_import
}
SettingType::HotkeyMode => {
config.hotkey_mode.should_import = !config.hotkey_mode.should_import
}
SettingType::Opacity => config.opacity.should_import = !config.opacity.should_import,
SettingType::WindowSize => {
config.window_size.should_import = !config.window_size.should_import
}
SettingType::CopyOnSelect => {
config.copy_on_select.should_import = !config.copy_on_select.should_import
}
SettingType::CursorBlinking => {
config.cursor_blinking.should_import = !config.cursor_blinking.should_import
}
}
}
pub fn should_import(&self, profile: &TerminalTypeAndProfile, setting: &SettingType) -> bool {
let Some(config) = self.config(profile) else {
log::warn!("Attempted to read should_import on an invalid profile!");
return false;
};
match setting {
SettingType::Theme => config.theme.should_import,
SettingType::OptionAsMeta => config.option_as_meta.should_import,
SettingType::MouseAndScrollReporting => config.mouse_and_scroll_reporting.should_import,
SettingType::Font => config.font.should_import,
SettingType::DefaultShell => config.default_shell.should_import,
SettingType::WorkingDirectory => config.working_directory.should_import,
SettingType::HotkeyMode => config.hotkey_mode.should_import,
SettingType::Opacity => config.opacity.should_import,
SettingType::WindowSize => config.window_size.should_import,
SettingType::CopyOnSelect => config.copy_on_select.should_import,
SettingType::CursorBlinking => config.cursor_blinking.should_import,
}
}
pub fn write_theme(&self, profile: &TerminalTypeAndProfile) -> Option<ThemeType> {
self.config(profile)
.map(|config| config.write_theme())
.unwrap_or_else(|| {
log::warn!("Attempted to write the theme from an invalid profile.");
None
})
}
pub fn finished_searching_for_settings(&self) -> bool {
TerminalType::iter()
.filter(|terminal_type| {
if !FeatureFlag::AlacrittySettingsImport.is_enabled() {
*terminal_type != TerminalType::Alacritty
} else {
true
}
})
.all(|terminal| self.parsed_terminals.contains_key(&terminal))
}
}
impl Entity for ImportedConfigModel {
type Event = CompletedParseEvent;
}
impl SingletonEntity for ImportedConfigModel {}
File diff suppressed because it is too large Load Diff