use anyhow::{Context, Result};
use crossterm::event::KeyCode;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::theme::ThemeConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppearanceConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_max_name_length")]
pub max_name_length: usize,
#[serde(default = "default_icons")]
pub icons: String,
#[serde(default)]
pub colors: ThemeConfig,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
theme: default_theme(),
max_name_length: default_max_name_length(),
icons: default_icons(),
colors: ThemeConfig::default(),
}
}
}
fn default_theme() -> String {
"default".to_string()
}
fn default_max_name_length() -> usize {
80
}
fn default_icons() -> String {
"unicode".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorConfig {
#[serde(default = "default_show_hidden")]
pub show_hidden: bool,
#[serde(default = "default_follow_symlinks")]
pub follow_symlinks: bool,
#[serde(default = "default_double_click_timeout")]
pub double_click_timeout_ms: u64,
#[serde(default = "default_mouse_scroll_lines")]
pub mouse_scroll_lines: usize,
}
impl Default for BehaviorConfig {
fn default() -> Self {
Self {
show_hidden: default_show_hidden(),
follow_symlinks: default_follow_symlinks(),
double_click_timeout_ms: default_double_click_timeout(),
mouse_scroll_lines: default_mouse_scroll_lines(),
}
}
}
fn default_show_hidden() -> bool {
true
}
fn default_follow_symlinks() -> bool {
true
}
fn default_double_click_timeout() -> u64 {
800
}
fn default_mouse_scroll_lines() -> usize {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeybindingsConfig {
#[serde(default = "default_search_keys")]
pub search: Vec<String>,
#[serde(default = "default_create_bookmark_keys")]
pub create_bookmark: Vec<String>,
#[serde(default = "default_select_bookmark_keys")]
pub select_bookmark: Vec<String>,
#[serde(default = "default_select_disk_keys")]
pub select_disk: Vec<String>,
#[serde(default = "default_go_to_parent_keys")]
pub go_to_parent: Vec<String>,
#[serde(default = "default_go_back_keys")]
pub go_back: Vec<String>,
#[serde(default = "default_quit_keys")]
pub quit: Vec<String>,
#[serde(default = "default_exit_keys")]
pub exit: Vec<String>,
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
search: default_search_keys(),
create_bookmark: default_create_bookmark_keys(),
select_bookmark: default_select_bookmark_keys(),
select_disk: default_select_disk_keys(),
go_to_parent: default_go_to_parent_keys(),
go_back: default_go_back_keys(),
quit: default_quit_keys(),
exit: default_exit_keys(),
}
}
}
fn default_search_keys() -> Vec<String> {
vec!["/".to_string()]
}
fn default_create_bookmark_keys() -> Vec<String> {
vec!["m".to_string()]
}
fn default_select_bookmark_keys() -> Vec<String> {
vec!["'".to_string()]
}
fn default_select_disk_keys() -> Vec<String> {
vec!["d".to_string()]
}
fn default_go_to_parent_keys() -> Vec<String> {
vec!["u".to_string()]
}
fn default_go_back_keys() -> Vec<String> {
vec!["Backspace".to_string()]
}
fn default_quit_keys() -> Vec<String> {
vec!["q".to_string()]
}
fn default_exit_keys() -> Vec<String> {
vec!["Esc".to_string()]
}
impl KeybindingsConfig {
fn matches_key(&self, key: KeyCode, configured_keys: &[String]) -> bool {
let key_str = match key {
KeyCode::Char(c) => c.to_string(),
KeyCode::Esc => "Esc".to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::Delete => "Delete".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PageUp".to_string(),
KeyCode::PageDown => "PageDown".to_string(),
_ => return false,
};
configured_keys
.iter()
.any(|k| k.eq_ignore_ascii_case(&key_str))
}
pub fn is_search(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.search)
}
pub fn is_create_bookmark(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.create_bookmark)
}
pub fn is_select_bookmark(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.select_bookmark)
}
pub fn is_select_disk(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.select_disk)
}
pub fn is_go_to_parent(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.go_to_parent)
}
pub fn is_go_back(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.go_back)
}
pub fn is_quit(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.quit)
}
pub fn is_exit(&self, key: KeyCode) -> bool {
self.matches_key(key, &self.exit)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub appearance: AppearanceConfig,
#[serde(default)]
pub behavior: BehaviorConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
}
impl Config {
pub fn parse_color(color_str: &str) -> Color {
ThemeConfig::parse_color(color_str)
}
pub fn get_color(opt: &Option<String>) -> &str {
opt.as_ref()
.expect("Color should be resolved after config load")
}
pub fn from_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn global_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("bmrk").join("config.toml"))
}
pub fn load() -> anyhow::Result<Self> {
let mut config = Config::default();
if let Some(global_path) = Self::global_config_path() {
if !global_path.exists() {
let _ = Self::create_default_file(&global_path);
}
if global_path.exists() {
match Self::from_file(&global_path) {
Ok(global_config) => {
config = global_config;
}
Err(e) => {
anyhow::bail!(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
Configuration file error!\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
\n\
Config file: {}\n\
\n\
Error details:\n\
{:#}\n\
\n\
To fix:\n\
1. Edit the config file and fix the syntax error\n\
2. Or delete the file - it will be recreated with defaults\n\
\n\
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
global_path.display(),
e
);
}
}
}
}
let preset = ThemeConfig::get_preset_theme(&config.appearance.theme);
let fallback = ThemeConfig::fallback_colors();
macro_rules! resolve_color {
($field:ident) => {
config.appearance.colors.$field = config
.appearance
.colors
.$field
.or_else(|| preset.as_ref().and_then(|p| p.$field.clone()))
.or_else(|| fallback.$field.clone());
};
}
resolve_color!(selected_color);
resolve_color!(directory_color);
resolve_color!(file_color);
resolve_color!(error_color);
resolve_color!(highlight_color);
resolve_color!(cursor_color);
resolve_color!(tree_cursor_color);
resolve_color!(tree_cursor_bg_color);
resolve_color!(header_path_color);
resolve_color!(header_hints_color);
Ok(config)
}
pub fn create_default_file(path: &Path) -> Result<()> {
let default_config = r#"# bmrk configuration file
# This file uses TOML format: https://toml.io
[appearance]
# Theme name - preset color schemes
# Available themes:
# "default" - Classic terminal colors (blue dirs, cyan selection)
# "gruvbox" - Warm, high contrast theme inspired by Gruvbox
# "nord" - Cold, muted colors inspired by Nord theme
# "tokyonight" - Modern dark theme with vibrant colors
# "dracula" - Popular dark theme with high contrast
# "obsidian" - Dark theme inspired by Obsidian app with subtle cursor
theme = "default"
# Maximum filename length in the tree before middle-truncation
# Example: "very_long_project_name.rs" -> "very_long_pro...ame.rs"
# Set to 0 to disable truncation
max_name_length = 80
# Icon set used for the directory tree
# "unicode" - filled triangles: ▼ (expanded) ▶ (collapsed)
# "ascii" - plain characters: v (expanded) > (collapsed)
icons = "unicode"
# Custom theme colors (override preset theme)
[appearance.colors]
# Color formats: name (red, blue...), #RRGGBB hex, 0-255 indexed
#
# selected_color = "cyan"
# directory_color = "gray"
# file_color = "white"
# error_color = "red"
# highlight_color = "yellow"
# cursor_color = "yellow"
# tree_cursor_color = "dim"
# tree_cursor_bg_color = "dim"
# header_path_color = "cyan"
# header_hints_color = "darkgray"
[behavior]
# Show hidden files (dotfiles)
show_hidden = true
# Follow symbolic links
follow_symlinks = true
# Double-click timeout in milliseconds
double_click_timeout_ms = 800
# Number of lines to scroll with mouse wheel
mouse_scroll_lines = 1
[keybindings]
# Key bindings — each entry is a list; multiple keys can trigger the same action.
# Supported key names: letters (a-z), symbols, Esc, Enter, Backspace, Tab,
# Up, Down, Left, Right, Home, End, PageUp, PageDown, Delete
search = ["/"]
create_bookmark = ["m"]
select_bookmark = ["'"]
select_disk = ["d"]
go_to_parent = ["u"]
go_back = ["Backspace"]
quit = ["q"]
exit = ["Esc"]
"#;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
fs::write(path, default_config)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.appearance.max_name_length, 80);
assert!(config.behavior.show_hidden);
}
#[test]
fn test_color_parsing() {
assert!(matches!(ThemeConfig::parse_color("red"), Color::Red));
assert!(matches!(ThemeConfig::parse_color("blue"), Color::Blue));
assert!(matches!(
ThemeConfig::parse_color("#FF0000"),
Color::Rgb(255, 0, 0)
));
}
}