Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
directories = "5.0"
colored = "2.1"
jsonschema = "0.42"
toml = "0.8"
clap = { version = "4.5", features = ["derive"] }
ratatui = "0.30"
crossterm = "0.29"
terminal-colorsaurus = "1"
3 changes: 2 additions & 1 deletion tca-loader/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ exclude = [".gitignore", "codebook.toml"]

[dependencies]
anyhow = { workspace = true }
directories = { workspace = true }
terminal-colorsaurus = { workspace = true }
etcetera = "0.11.0"
serde = { workspace = true }
tca-types = { workspace = true }
toml = { workspace = true }
107 changes: 75 additions & 32 deletions tca-loader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,91 @@
#![warn(missing_docs)]

use anyhow::{Context, Result};
use directories::ProjectDirs;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

/// Resolve the TCA data directory path without creating it.
///
/// Returns `$XDG_DATA_HOME/tca` on Linux/BSD, or the platform-equivalent on other OS.
fn resolve_data_dir() -> Result<PathBuf> {
let project_dirs =
ProjectDirs::from("", "", "tca").context("Failed to determine project directories")?;
Ok(project_dirs.data_dir().to_path_buf())
/// Configuration for TCA user preferences.
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct TcaConfig {
/// The general default theme. Used if mode can't be detected or other options
/// aren't defined.
pub default_theme: Option<String>,
/// Default dark mode theme.
pub default_dark_theme: Option<String>,
/// Default light mode theme.
pub default_light_theme: Option<String>,
}

/// Get the TCA data directory path, creating it if it does not exist.
///
/// Returns `$XDG_DATA_HOME/tca` on Linux/BSD, or the platform-equivalent on other OS.
pub fn get_data_dir() -> Result<PathBuf> {
let data_dir = resolve_data_dir()?;
if !data_dir.exists() {
fs::create_dir_all(&data_dir)
.with_context(|| format!("Failed to create data directory: {:?}", data_dir))?;
/// Returns the path to the TCA config file (`tca.toml` in the app config dir).
fn config_file_path() -> Result<PathBuf> {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "org".to_string(),
author: "TCA".to_string(),
app_name: "tca".to_string(),
})?;
Ok(strategy.config_dir().join("tca.toml"))
}

impl TcaConfig {
/// Load the user's configuration preferences.
///
/// Returns [`Default`] if the config file doesn't exist or cannot be parsed.
pub fn load() -> Self {
let Ok(path) = config_file_path() else {
return Self::default();
};
let Ok(content) = fs::read_to_string(path) else {
return Self::default();
};
toml::from_str(&content).unwrap_or_default()
}

/// Save the user's configuration preferences.
pub fn store(&self) {
let path = config_file_path().expect("Could not determine TCA config path.");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("Could not create TCA config directory.");
}
let content = toml::to_string(self).expect("Could not serialize TCA config.");
fs::write(&path, content).expect("Could not save TCA config.");
}

/// Get the best default theme, based on user preference and current terminal
/// color mode.
pub fn mode_aware_theme(&self) -> Option<String> {
// Fallback order:
// Mode preference - if None or mode can't be determined then default
use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
match theme_mode(QueryOptions::default()).ok() {
Some(ThemeMode::Dark) => self
.default_dark_theme
.clone()
.or(self.default_theme.clone()),
Some(ThemeMode::Light) => self
.default_light_theme
.clone()
.or(self.default_theme.clone()),
None => self.default_theme.clone(),
}
}
Ok(data_dir)
}

/// Get the themes directory path, creating it if it does not exist.
///
/// Returns `$XDG_DATA_HOME/tca/themes` (or platform equivalent).
/// Returns `$XDG_DATA_HOME/tca-themes` (or platform equivalent).
pub fn get_themes_dir() -> Result<PathBuf> {
let themes_dir = resolve_data_dir()?.join("themes");
if !themes_dir.exists() {
fs::create_dir_all(&themes_dir)
.with_context(|| format!("Failed to create themes directory: {:?}", themes_dir))?;
}
Ok(themes_dir)
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "org".to_string(),
author: "TCA".to_string(),
app_name: "tca-themes".to_string(),
})
.unwrap();
let data_dir = strategy.data_dir();
fs::create_dir_all(&data_dir)?;

Ok(data_dir)
}

/// List all available theme files in the shared themes directory.
Expand Down Expand Up @@ -186,18 +236,11 @@ pub fn load_all_from_theme_dir() -> Result<Vec<tca_types::Theme>> {
mod tests {
use super::*;

#[test]
fn test_get_data_dir() {
let dir = get_data_dir().unwrap();
assert!(dir.exists());
assert!(dir.to_string_lossy().contains("tca"));
}

#[test]
fn test_get_themes_dir() {
let dir = get_themes_dir().unwrap();
assert!(dir.exists());
assert!(dir.ends_with("themes"));
assert!(dir.ends_with("tca-themes"));
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions tca-ratatui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ serde = { workspace = true }
toml = { workspace = true }
ratatui = { workspace = true }
anyhow = { workspace = true }
strum = { version = "0.28.0", features = ["derive"] }
terminal-colorsaurus = { workspace = true }

[dev-dependencies]
crossterm = { workspace = true }
Expand Down
18 changes: 7 additions & 11 deletions tca-ratatui/examples/basic.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
use std::env;
use std::path::PathBuf;

use ratatui::style::Style;
use tca_ratatui::TcaTheme;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let theme_path: Option<PathBuf> = env::args_os().nth(1).map(PathBuf::from);
let arg = env::args().nth(1);
let theme_path: Option<&str> = arg.as_deref();

let theme = match theme_path {
Some(theme_path) => {
println!("Loading TCA theme from: {:?}", theme_path);
TcaTheme::try_from(&theme_path)?
}
None => {
return Err("Usage: basic path/to/theme.toml".into());
}
};
if theme_path.is_none() {
return Err("Usage: basic path/to/theme.toml".into());
}
println!("Loading TCA theme from: {:?}", theme_path);
let theme = TcaTheme::new(theme_path);

println!("\nTheme: {}", theme.meta.name);
if let Some(author) = theme.meta.author {
Expand Down
60 changes: 52 additions & 8 deletions tca-ratatui/examples/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
DefaultTerminal, Frame,
};
use tca_ratatui::{load_all_from_dir, load_all_from_theme_dir};
use tca_ratatui::{ColorPicker, TcaTheme};
use tca_ratatui::{load_all_builtin, ColorPicker, TcaTheme};

use std::{env, io};

Expand Down Expand Up @@ -63,12 +62,57 @@ impl App {
}
}

fn load_from_dir(dir: &str) -> anyhow::Result<Vec<TcaTheme>> {
let paths: Vec<_> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml"))
.map(|e| e.path())
.collect();
Ok(paths
.iter()
.filter_map(|p| p.to_str())
.map(|s| TcaTheme::new(Some(s)))
.collect())
}

fn load_from_theme_dir() -> anyhow::Result<Vec<TcaTheme>> {
let paths = tca_loader::list_themes()?;
Ok(paths
.iter()
.filter_map(|p| p.to_str())
.map(|s| TcaTheme::new(Some(s)))
.collect())
}

fn main() -> anyhow::Result<()> {
let themes_dir: Option<String> = env::args().nth(1);
let args: Vec<String> = env::args().collect();

let mut themes = match &themes_dir {
Some(dir) => load_all_from_dir(dir)?,
None => load_all_from_theme_dir()?,
if args.iter().any(|a| a == "-h" || a == "--help") {
println!("Usage: picker [OPTIONS] [THEME_DIR]");
println!();
println!("Arguments:");
println!(" [THEME_DIR] Load themes from a specific directory");
println!();
println!("Options:");
println!(" --builtin Load built-in themes instead of user themes");
println!(" -h, --help Print this help message");
println!();
println!("Keys:");
println!(" ◀ / ▶ Previous / next theme");
println!(" Q Quit");
return Ok(());
}

let builtin_flag = args.iter().any(|a| a == "--builtin");
let themes_dir = args.iter().skip(1).find(|a| !a.starts_with('-')).cloned();

let mut themes = if builtin_flag {
load_all_builtin()
} else {
match &themes_dir {
Some(dir) => load_from_dir(dir)?,
None => load_from_theme_dir()?,
}
};
themes.sort_by_key(|t| t.meta.name.to_string());

Expand All @@ -78,8 +122,8 @@ fn main() -> anyhow::Result<()> {
themes_dir.unwrap_or("user theme directory.".to_string())
);
eprintln!(
"Usage: {} [theme-directory]",
env::args().next().unwrap_or_default()
"Usage: {} [--builtin] [theme-directory]",
args.first().map(String::as_str).unwrap_or("picker")
);
return Ok(());
}
Expand Down
98 changes: 91 additions & 7 deletions tca-ratatui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,95 @@
//! use ratatui::style::Style;
//! use std::path::Path;
//!
//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
//! // Load a TCA theme from a file
//! let theme = TcaTheme::try_from(Path::new("theme.toml"))?;
//! fn example() {
//! // Load a TCA theme from a file or name, with reasonable fallback.
//! let theme = TcaTheme::new(Some("theme.toml"));
//! let theme = TcaTheme::new(Some("Tokyo Night"));
//! let theme = TcaTheme::new(Some("tokyo-night"));
//! let theme = TcaTheme::new(None);
//!
//! // Or from a TOML string.
//! let toml_str = r###"
//! [theme]
//! name = "Tokyo Night"
//! slug = "tokyo-night"
//! author = "enkia / TCA Project"
//! version = "1.0.0"
//! description = "A clean, dark theme inspired by the city lights of Tokyo at night. Based on Tokyo Night by enkia."
//! dark = true
//!
//! [ansi]
//! black = "#15161e"
//! red = "#f7768e"
//! green = "#9ece6a"
//! yellow = "#e0af68"
//! blue = "#7aa2f7"
//! magenta = "#bb9af7"
//! cyan = "#7dcfff"
//! white = "#a9b1d6"
//! bright_black = "#414868"
//! bright_red = "#f7768e"
//! bright_green = "#9ece6a"
//! bright_yellow = "#e0af68"
//! bright_blue = "#7aa2f7"
//! bright_magenta = "#bb9af7"
//! bright_cyan = "#7dcfff"
//! bright_white = "#c0caf5"
//!
//! # Palette: darkest→lightest
//! # neutral: bg_dark→fg
//! # 0=#15161e 1=#1a1b26 2=#24283b 3=#292e42 4=#414868 5=#565f89 6=#a9b1d6 7=#c0caf5
//! [palette]
//! neutral = [
//! "#15161e",
//! "#1a1b26",
//! "#24283b",
//! "#292e42",
//! "#414868",
//! "#565f89",
//! "#a9b1d6",
//! "#c0caf5",
//! ]
//! red = ["#6b1a2a", "#db4b4b", "#f7768e"]
//! green = ["#2d4a1e", "#41a146", "#9ece6a"]
//! yellow = ["#5c4400", "#c09230", "#e0af68"]
//! blue = ["#1a2a6b", "#3d59a1", "#7aa2f7"]
//! purple = ["#3d2370", "#7953c7", "#bb9af7"]
//! cyan = ["#144a60", "#0db9d7", "#7dcfff"]
//! orange = ["#5c2e00", "#c47214", "#ff9e64"]
//! teal = ["#17403d", "#1abc9c", "#73daca"]
//! magenta = ["#5c1a5c", "#9d4bb6", "#c586c0"]
//!
//! [semantic]
//! error = "palette.red.2"
//! warning = "palette.orange.2"
//! info = "palette.cyan.2"
//! success = "palette.green.2"
//! highlight = "palette.yellow.2"
//! link = "palette.blue.2"
//!
//! [ui.bg]
//! primary = "palette.neutral.1"
//! secondary = "palette.neutral.2"
//!
//! [ui.fg]
//! primary = "palette.neutral.7"
//! secondary = "palette.neutral.6"
//! muted = "palette.neutral.5"
//!
//! [ui.border]
//! primary = "palette.blue.1"
//! muted = "palette.neutral.3"
//!
//! [ui.cursor]
//! primary = "palette.blue.2"
//! muted = "palette.neutral.5"
//!
//! [ui.selection]
//! bg = "palette.neutral.3"
//! fg = "palette.neutral.7"
//! "###;
//! let theme = TcaTheme::try_from(toml_str).expect("Couldn't parse TOML");
//!
//! // Use ANSI colors
//! let error_style = Style::default().fg(theme.ansi.red);
Expand All @@ -24,8 +110,7 @@
//! // Use UI colors
//! let bg_style = Style::default().bg(theme.ui.bg_primary);
//! let fg_style = Style::default().fg(theme.ui.fg_primary);
//! # Ok(())
//! # }
//! }
//! ```

#![warn(missing_docs)]
Expand All @@ -40,8 +125,7 @@ mod tests;

pub use theme::{Ansi, Base16, ColorRamp, Meta, Palette, Semantic, TcaTheme, TcaThemeBuilder, Ui};

#[cfg(feature = "loader")]
pub use theme::{load_all_from_dir, load_all_from_theme_dir};
pub use theme::load_all_builtin;

#[cfg(feature = "widgets")]
pub use widgets::ColorPicker;
Loading
Loading