use std::fs;
use std::path::PathBuf;
use std::sync::{LazyLock, OnceLock};
use anyhow::{Context as _, Result};
use gpui_kit::component::scroll::ScrollbarMode;
use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use gpui_kit::{App, AppContext as _, Global, SharedString, Window, WindowAppearance};
use serde::{Deserialize, Serialize};
use crate::db::store;
const FILE_NAME: &str = "settings.json";
const THEMES_DIR: &str = "themes";
const BUILTIN_THEMES: &[&str] = include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs"));
const ZIPPA_DARK: &str = "Zippa Dark";
const ZIPPA_LIGHT: &str = "Zippa Light";
pub const DEFAULT_PAGE_SIZE: usize = 500;
pub const MAX_PAGE_SIZE: usize = 100_000;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Appearance {
#[default]
Auto,
Light,
Dark,
}
impl Appearance {
pub const ALL: [Self; 3] = [Self::Auto, Self::Light, Self::Dark];
pub fn key(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Light => "light",
Self::Dark => "dark",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Match the system",
Self::Light => "Light",
Self::Dark => "Dark",
}
}
pub fn from_key(key: &str) -> Self {
Self::ALL
.into_iter()
.find(|appearance| appearance.key() == key)
.unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub page_size: usize,
pub appearance: Appearance,
pub light_theme: Option<SharedString>,
pub dark_theme: Option<SharedString>,
pub editor_font: Option<SharedString>,
pub grid_font: Option<SharedString>,
pub coerce_null_literal: bool,
pub stripe_rows: bool,
pub always_show_scrollbars: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
page_size: DEFAULT_PAGE_SIZE,
appearance: Appearance::default(),
light_theme: None,
dark_theme: None,
editor_font: None,
grid_font: None,
coerce_null_literal: true,
stripe_rows: true,
always_show_scrollbars: true,
}
}
}
impl Global for Settings {}
impl Settings {
pub fn global(cx: &App) -> &Self {
static FALLBACK: LazyLock<Settings> = LazyLock::new(Settings::default);
cx.try_global::<Self>().unwrap_or(&FALLBACK)
}
fn theme_for(&self, mode: ThemeMode) -> &SharedString {
static ZIPPA_LIGHT_NAME: LazyLock<SharedString> = LazyLock::new(|| ZIPPA_LIGHT.into());
static ZIPPA_DARK_NAME: LazyLock<SharedString> = LazyLock::new(|| ZIPPA_DARK.into());
let chosen = match mode {
ThemeMode::Light => self.light_theme.as_ref(),
ThemeMode::Dark => self.dark_theme.as_ref(),
};
chosen.unwrap_or(match mode {
ThemeMode::Light => &ZIPPA_LIGHT_NAME,
ThemeMode::Dark => &ZIPPA_DARK_NAME,
})
}
}
pub fn init(cx: &mut App) {
let settings = load().unwrap_or_else(|error| {
eprintln!("could not read the settings: {error:#}");
Settings::default()
});
cx.set_global(settings);
apply(None, cx);
}
pub fn update(cx: &mut App, change: impl FnOnce(&mut Settings)) {
let mut settings = Settings::global(cx).clone();
change(&mut settings);
if settings == *Settings::global(cx) {
return;
}
let to_save = settings.clone();
cx.background_spawn(async move {
if let Err(error) = save(&to_save) {
eprintln!("could not save the settings: {error:#}");
}
})
.detach();
cx.set_global(settings);
apply(None, cx);
cx.refresh_windows();
}
fn apply(window: Option<&mut Window>, cx: &mut App) {
let settings = Settings::global(cx).clone();
for mode in [ThemeMode::Light, ThemeMode::Dark] {
let Some(config) = ThemeRegistry::global(cx)
.themes()
.get(settings.theme_for(mode))
.cloned()
else {
continue;
};
match mode {
ThemeMode::Light => Theme::global_mut(cx).light_theme = config,
ThemeMode::Dark => Theme::global_mut(cx).dark_theme = config,
}
}
let system = window
.as_ref()
.map(|window| window.appearance())
.unwrap_or_else(|| cx.window_appearance());
Theme::change(mode_for(settings.appearance, system), window, cx);
apply_scrollbars(settings.always_show_scrollbars, cx);
}
fn apply_scrollbars(always: bool, cx: &mut App) {
if always {
Theme::set_scrollbar_mode(ScrollbarMode::Always, cx);
} else {
Theme::sync_scrollbar_appearance(cx);
}
}
pub fn mode_for(appearance: Appearance, system: WindowAppearance) -> ThemeMode {
match appearance {
Appearance::Auto => ThemeMode::from(system),
Appearance::Light => ThemeMode::Light,
Appearance::Dark => ThemeMode::Dark,
}
}
pub fn follow_system_appearance(window: &mut Window, cx: &mut App) {
apply(Some(window), cx);
}
pub fn editor_font(cx: &App) -> SharedString {
installed_or_default(Settings::global(cx).editor_font.as_ref(), cx)
}
pub fn grid_font(cx: &App) -> SharedString {
installed_or_default(Settings::global(cx).grid_font.as_ref(), cx)
}
fn installed_or_default(chosen: Option<&SharedString>, cx: &App) -> SharedString {
match chosen {
Some(family) if installed_families(cx).iter().any(|name| name == family) => family.clone(),
_ => cx.theme().mono_font_family.clone(),
}
}
pub fn installed_families(cx: &App) -> &'static [String] {
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
FAMILIES.get_or_init(|| {
let mut families = cx.text_system().all_font_names();
families.sort();
families.dedup();
families
})
}
pub fn load_builtin_themes(cx: &mut App) {
for theme in BUILTIN_THEMES {
if let Err(error) = ThemeRegistry::global_mut(cx).load_themes_from_str(theme) {
eprintln!("could not load a built-in theme: {error:#}");
}
}
}
pub fn load_user_themes(cx: &mut App) {
let Ok(dir) = store::config_dir().map(|dir| dir.join(THEMES_DIR)) else {
return;
};
let Ok(entries) = fs::read_dir(&dir) else {
return;
};
for path in entries.flatten().map(|entry| entry.path()) {
if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
continue;
}
let loaded = fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|contents| ThemeRegistry::global_mut(cx).load_themes_from_str(&contents));
if let Err(error) = loaded {
eprintln!("could not load the theme {}: {error:#}", path.display());
}
}
}
pub fn themes_for(mode: ThemeMode, cx: &App) -> Vec<SharedString> {
ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.filter(|theme| theme.mode == mode)
.map(|theme| theme.name.clone())
.collect()
}
fn settings_file() -> Result<PathBuf> {
Ok(store::config_dir()?.join(FILE_NAME))
}
fn load() -> Result<Settings> {
let path = settings_file()?;
if !path.exists() {
return Ok(Settings::default());
}
let contents =
fs::read_to_string(&path).with_context(|| format!("could not read {}", path.display()))?;
serde_json::from_str(&contents).with_context(|| format!("could not parse {}", path.display()))
}
#[cfg(not(test))]
fn save(settings: &Settings) -> Result<()> {
let path = settings_file()?;
let dir = path.parent().context("no config directory")?;
fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
let contents = serde_json::to_string_pretty(settings)?;
store::write_restricted(&path, &contents)
.with_context(|| format!("could not write {}", path.display()))
}
#[cfg(test)]
fn save(_settings: &Settings) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_follows_the_system_and_a_choice_does_not() {
assert_eq!(
mode_for(Appearance::Auto, WindowAppearance::Dark),
ThemeMode::Dark
);
assert_eq!(
mode_for(Appearance::Auto, WindowAppearance::VibrantLight),
ThemeMode::Light
);
assert_eq!(
mode_for(Appearance::Light, WindowAppearance::Dark),
ThemeMode::Light
);
assert_eq!(
mode_for(Appearance::Dark, WindowAppearance::Light),
ThemeMode::Dark
);
}
#[test]
fn a_file_from_an_older_build_keeps_its_settings() {
let settings: Settings =
serde_json::from_str(r#"{"page_size": 50}"#).expect("the file should still parse");
assert_eq!(settings.page_size, 50);
assert_eq!(settings.appearance, Appearance::Auto);
assert_eq!(settings.editor_font, None);
assert!(
settings.always_show_scrollbars,
"a file written before the setting existed should still show scrollbars"
);
}
#[test]
fn the_scrollbar_setting_survives_a_round_trip_through_the_file() {
let settings = Settings {
always_show_scrollbars: false,
..Settings::default()
};
let written = serde_json::to_string(&settings).expect("settings should serialize");
let read: Settings = serde_json::from_str(&written).expect("settings should parse");
assert!(!read.always_show_scrollbars);
}
#[test]
fn the_appearance_survives_a_round_trip_through_the_file() {
for appearance in Appearance::ALL {
let written = serde_json::to_string(&appearance).expect("appearance should serialize");
assert_eq!(written, format!("\"{}\"", appearance.key()));
assert_eq!(Appearance::from_key(appearance.key()), appearance);
}
}
}