use crate::{memory, model::watch};
use anyhow::{Context, Result};
use bezel::theme::{TextStyle, appearance::AppearanceMode};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
const FILE: &str = "settings.toml";
#[derive(Debug, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "cover_memory")]
pub cover_memory: u64,
#[serde(default = "watch_bounce")]
pub watch_bounce: u64,
#[serde(default = "auto_update")]
pub auto_update: bool,
#[serde(default)]
pub appearance: Appearance,
#[serde(default)]
pub shortcuts: Shortcuts,
#[serde(default)]
pub features: Features,
#[serde(default)]
pub mcp: Mcp,
#[serde(default)]
pub agents: Vec<Agent>,
}
pub const TEXT_SIZE: (f32, f32) = (11., 17.);
pub const CONTENT_TEXT_SIZE: (f32, f32) = (8., 40.);
pub fn clamp_content_text_size(points: f32) -> f32 {
if points.is_finite() {
points.clamp(CONTENT_TEXT_SIZE.0, CONTENT_TEXT_SIZE.1)
} else {
terminal::view::TERM_FONT_SIZE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Appearance {
pub mode: AppearanceMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub opaque: Option<bool>,
pub cursor_blink: bool,
pub text_size: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub article_font_size: Option<f32>,
pub terminal_font_size: f32,
pub hue: f32,
pub chroma: f32,
pub wide_pages: bool,
pub indent_project_rows: bool,
pub scrollbars: Scrollbars,
pub sidebar_scrollbars: Scrollbars,
pub wrap_code: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scrollbars {
#[default]
Scrolling,
Always,
Never,
}
impl From<Scrollbars> for bezel::ui::scroll::Visibility {
fn from(value: Scrollbars) -> Self {
match value {
Scrollbars::Scrolling => Self::Scrolling,
Scrollbars::Always => Self::Always,
Scrollbars::Never => Self::Never,
}
}
}
impl Scrollbars {
pub const ALL: [Self; 3] = [Self::Scrolling, Self::Always, Self::Never];
pub fn label(self) -> &'static str {
match self {
Self::Scrolling => "While scrolling",
Self::Always => "Always",
Self::Never => "Never",
}
}
pub fn key(self) -> &'static str {
match self {
Self::Scrolling => "scrolling",
Self::Always => "always",
Self::Never => "never",
}
}
}
impl Default for Appearance {
fn default() -> Self {
Self {
mode: AppearanceMode::default(),
opaque: None,
cursor_blink: true,
text_size: TextStyle::Body.size(),
article_font_size: None,
terminal_font_size: terminal::view::TERM_FONT_SIZE,
hue: 0.,
chroma: 0.,
wide_pages: false,
indent_project_rows: true,
scrollbars: Scrollbars::default(),
sidebar_scrollbars: Scrollbars::Never,
wrap_code: false,
}
}
}
impl Appearance {
pub fn normalize(&mut self) {
self.text_size = if self.text_size.is_finite() {
self.text_size.clamp(TEXT_SIZE.0, TEXT_SIZE.1)
} else {
Self::default().text_size
};
self.article_font_size = self.article_font_size.map(clamp_content_text_size);
self.terminal_font_size = clamp_content_text_size(self.terminal_font_size);
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Shortcuts {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub emacs: bool,
#[serde(flatten)]
bindings: BTreeMap<String, String>,
}
impl Shortcuts {
pub const ACTIVATE: &'static str = "activate";
pub fn get(&self, key: &str) -> Option<&str> {
self.bindings.get(key).map(String::as_str)
}
pub fn activate(&self) -> Option<&str> {
self.get(Self::ACTIVATE)
}
pub fn set(&mut self, key: &str, chord: Option<&str>) {
match chord {
Some(chord) => {
self.bindings.insert(key.to_owned(), chord.to_owned());
}
None => {
self.bindings.remove(key);
}
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Mcp {
pub serve: bool,
pub write: bool,
}
impl Default for Mcp {
fn default() -> Self {
Self {
serve: true,
write: false,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Features {
pub sessions: bool,
pub boards: bool,
pub tables: bool,
}
impl Default for Features {
fn default() -> Self {
Self {
sessions: false,
boards: true,
tables: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Feature {
Sessions,
Boards,
Tables,
}
impl Feature {
pub const ALL: [Self; 3] = [Self::Sessions, Self::Boards, Self::Tables];
fn key(self) -> &'static str {
match self {
Self::Sessions => "sessions",
Self::Boards => "boards",
Self::Tables => "tables",
}
}
pub fn on(self, features: &Features) -> bool {
match self {
Self::Sessions => features.sessions,
Self::Boards => features.boards,
Self::Tables => features.tables,
}
}
pub fn set(self, features: &mut Features, on: bool) {
match self {
Self::Sessions => features.sessions = on,
Self::Boards => features.boards = on,
Self::Tables => features.tables = on,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
fn cover_memory() -> u64 {
memory::DEFAULT_LIMIT / 1_000_000
}
fn watch_bounce() -> u64 {
watch::BOUNCE
}
fn auto_update() -> bool {
true
}
const RUNNERS: [&str; 3] = ["npx", "bunx", "pnpx"];
impl Agent {
pub fn pinned(&self) -> bool {
if !RUNNERS.contains(&self.command.as_str()) {
return true;
}
self.args
.iter()
.filter(|arg| !arg.starts_with('-'))
.all(|spec| {
let name = cacp_agents::package_name(spec);
spec.len() > name.len()
&& spec[name.len() + 1..].starts_with(|c: char| c.is_ascii_digit())
})
}
}
impl Default for Settings {
fn default() -> Self {
Self {
cover_memory: cover_memory(),
watch_bounce: watch_bounce(),
auto_update: auto_update(),
appearance: Appearance::default(),
shortcuts: Shortcuts::default(),
features: Features::default(),
mcp: Mcp::default(),
agents: Vec::new(),
}
}
}
pub fn data_dir() -> Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME")
&& !xdg.is_empty()
{
return Ok(PathBuf::from(xdg).join("cydonia"));
}
Ok(dirs::home_dir()
.context("no home directory on this system")?
.join(".local")
.join("share")
.join("cydonia"))
}
pub fn dir() -> Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Ok(PathBuf::from(xdg).join("cydonia"));
}
Ok(dirs::home_dir()
.context("no home directory on this system")?
.join(".config")
.join("cydonia"))
}
pub(crate) fn path() -> Result<PathBuf> {
Ok(dir()?.join(FILE))
}
pub fn load() -> Result<Settings> {
let dir = dir()?;
let path = dir.join(FILE);
if !path.exists() {
let settings = Settings::default();
std::fs::create_dir_all(&dir)?;
let body = format!(
"# generated by cydonia — edits are kept, deleting regenerates defaults\n\n{}",
toml::to_string_pretty(&settings)?
);
std::fs::write(&path, body)?;
return Ok(settings);
}
let content = std::fs::read_to_string(&path)?;
let mut settings: Settings = toml::from_str(&content)
.with_context(|| format!("invalid settings: {}", path.display()))?;
settings.agents.retain(Agent::pinned);
settings.appearance.normalize();
Ok(settings)
}
fn edit(change: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>) -> Result<()> {
let path = path()?;
let body = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut =
body.parse().context("settings.toml is not valid toml")?;
if !change(&mut doc)? {
return Ok(());
}
std::fs::write(&path, doc.to_string())?;
Ok(())
}
fn table<'a>(doc: &'a mut toml_edit::DocumentMut, name: &str) -> Result<&'a mut toml_edit::Table> {
let item = doc[name].or_insert(toml_edit::table());
let Some(held) = item.as_table_mut() else {
anyhow::bail!("`{name}` in settings.toml is not a table");
};
held.set_implicit(false);
Ok(held)
}
pub fn set_appearance(appearance: &Appearance) -> Result<()> {
edit(|doc| {
write_appearance(doc, appearance)?;
Ok(true)
})
}
fn write_appearance(doc: &mut toml_edit::DocumentMut, appearance: &Appearance) -> Result<()> {
let held = table(doc, "appearance")?;
held["mode"] = toml_edit::value(match appearance.mode {
AppearanceMode::System => "system",
AppearanceMode::Light => "light",
AppearanceMode::Dark => "dark",
});
match appearance.opaque {
Some(opaque) => held["opaque"] = toml_edit::value(opaque),
None => {
held.remove("opaque");
}
}
held["cursor_blink"] = toml_edit::value(appearance.cursor_blink);
held["text_size"] = toml_edit::value(f64::from(appearance.text_size));
match appearance.article_font_size {
Some(size) => held["article_font_size"] = toml_edit::value(f64::from(size)),
None => {
held.remove("article_font_size");
}
}
held["terminal_font_size"] = toml_edit::value(f64::from(appearance.terminal_font_size));
held["hue"] = toml_edit::value(f64::from(appearance.hue));
held["chroma"] = toml_edit::value(f64::from(appearance.chroma));
held["wide_pages"] = toml_edit::value(appearance.wide_pages);
held["indent_project_rows"] = toml_edit::value(appearance.indent_project_rows);
held["scrollbars"] = toml_edit::value(appearance.scrollbars.key());
held["sidebar_scrollbars"] = toml_edit::value(appearance.sidebar_scrollbars.key());
held["wrap_code"] = toml_edit::value(appearance.wrap_code);
Ok(())
}
pub fn set_feature(feature: Feature, on: bool) -> Result<()> {
edit(|doc| {
table(doc, "features")?[feature.key()] = toml_edit::value(on);
Ok(true)
})
}
pub fn set_shortcut(key: &str, chord: Option<&str>) -> Result<()> {
edit(|doc| {
let held = table(doc, "shortcuts")?;
match chord {
Some(chord) => held[key] = toml_edit::value(chord),
None => {
held.remove(key);
}
}
Ok(true)
})
}
pub fn set_emacs_shortcuts(on: bool) -> Result<()> {
edit(|doc| {
let held = table(doc, "shortcuts")?;
if on {
held["emacs"] = toml_edit::value(true);
} else {
held.remove("emacs");
}
Ok(true)
})
}
pub fn set_mcp(key: &str, on: bool) -> Result<()> {
edit(|doc| {
table(doc, "mcp")?[key] = toml_edit::value(on);
Ok(true)
})
}
pub fn set_cover_memory(mb: u64) -> Result<()> {
edit(|doc| {
doc["cover_memory"] = toml_edit::value(mb as i64);
Ok(true)
})
}
pub fn set_watch_bounce(ms: u64) -> Result<()> {
edit(|doc| {
doc["watch_bounce"] = toml_edit::value(ms as i64);
Ok(true)
})
}
pub fn set_auto_update(on: bool) -> Result<()> {
edit(|doc| {
doc["auto_update"] = toml_edit::value(on);
Ok(true)
})
}
pub fn put_agent(agent: &Agent, supersedes: Option<&str>) -> Result<()> {
edit(|doc| {
let agents = doc["agents"].or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let Some(agents) = agents.as_array_of_tables_mut() else {
anyhow::bail!("`agents` in settings.toml is not a list of tables");
};
let existing = agents
.iter()
.position(|table| claims(table, agent, supersedes));
let name = existing
.and_then(|ix| agents.get(ix))
.and_then(|table| table.get("name"))
.and_then(|n| n.as_str())
.unwrap_or(&agent.name)
.to_owned();
let decor = existing
.and_then(|ix| agents.get(ix))
.map(|table| table.decor().clone());
let mut entry = toml_edit::Table::new();
entry["name"] = toml_edit::value(name);
if let Some(id) = &agent.id {
entry["id"] = toml_edit::value(id.clone());
}
entry["command"] = toml_edit::value(agent.command.clone());
let mut args = toml_edit::Array::new();
for arg in &agent.args {
args.push(arg.as_str());
}
entry["args"] = toml_edit::value(args);
if !agent.env.is_empty() {
let mut env = toml_edit::InlineTable::new();
for (key, value) in &agent.env {
env.insert(key, value.as_str().into());
}
entry["env"] = toml_edit::value(env);
}
match existing {
Some(ix) => {
if let Some(decor) = decor {
*entry.decor_mut() = decor;
}
*agents.get_mut(ix).expect("position is in range") = entry;
}
None => agents.push(entry),
}
Ok(true)
})
}
pub fn remove_agent(id: &str) -> Result<()> {
edit(|doc| {
let Some(agents) = doc
.get_mut("agents")
.and_then(|a| a.as_array_of_tables_mut())
else {
return Ok(false);
};
let Some(ix) = agents
.iter()
.position(|table| table.get("id").and_then(|i| i.as_str()) == Some(id))
else {
return Ok(false);
};
let prefix = agents
.get(ix)
.and_then(|table| table.decor().prefix().cloned());
agents.remove(ix);
if ix == 0
&& let Some(prefix) = prefix
{
match agents.get_mut(0) {
Some(first) => first.decor_mut().set_prefix(prefix),
None => doc.as_table_mut().decor_mut().set_prefix(prefix),
}
}
Ok(true)
})
}
fn claims(table: &toml_edit::Table, agent: &Agent, supersedes: Option<&str>) -> bool {
let field = |key| table.get(key).and_then(|v| v.as_str());
if agent.id.is_some() && field("id") == agent.id.as_deref() {
return true;
}
let Some(package) = supersedes else {
return false;
};
table
.get("args")
.and_then(|args| args.as_array())
.is_some_and(|args| {
args.iter()
.filter_map(|v| v.as_str())
.any(|arg| cacp_agents::package_name(arg) == package)
})
}
#[cfg(test)]
mod typography_tests {
use super::*;
#[test]
fn old_settings_keep_article_inheritance_and_terminal_default() {
let look: Appearance = toml::from_str("text_size = 16.0").unwrap();
assert_eq!(look.article_font_size, None);
assert_eq!(look.terminal_font_size, 13.);
assert_eq!(look.text_size, 16.);
}
#[test]
fn sizes_round_trip_without_losing_comments_or_unrelated_settings() {
let mut doc: toml_edit::DocumentMut = "# My settings\n[appearance]\n# Preferred terminal size\nterminal_font_size = 13.0\ncustom_setting = true\n".parse().unwrap();
let look = Appearance {
article_font_size: Some(18.),
terminal_font_size: 15.,
..Appearance::default()
};
write_appearance(&mut doc, &look).unwrap();
let body = doc.to_string();
assert!(body.contains("# My settings"));
assert!(body.contains("# Preferred terminal size"));
assert!(body.contains("custom_setting = true"));
let read: Settings = toml::from_str(&body).unwrap();
assert_eq!(read.appearance, look);
}
#[test]
fn invalid_font_sizes_are_normalized_before_layout() {
let mut look: Appearance =
toml::from_str("text_size = nan\narticle_font_size = -2.0\nterminal_font_size = inf")
.unwrap();
look.normalize();
assert!(look.text_size.is_finite());
assert_eq!(look.article_font_size, Some(CONTENT_TEXT_SIZE.0));
assert_eq!(look.terminal_font_size, 13.);
look.terminal_font_size = 1000.;
look.normalize();
assert_eq!(look.terminal_font_size, CONTENT_TEXT_SIZE.1);
}
}