use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const DEFAULT_PROJECT_CONFIG: &str = include_str!("../assets/default_project.hjson");
pub const DEFAULT_PROMPTS: &str = include_str!("../assets/default_prompts.hjson");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub embeddings: EmbeddingsConfig,
#[serde(default)]
pub llm: LlmConfig,
#[serde(default)]
pub editor: EditorConfig,
#[serde(default)]
pub keys: KeyBindings,
#[serde(default)]
pub hierarchy: HierarchyConfig,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub backup: BackupConfig,
#[serde(default)]
pub sound: SoundConfig,
#[serde(default)]
pub typst_templates: TypstTemplatesConfig,
#[serde(default)]
pub typst_compile: TypstCompileConfig,
#[serde(default)]
pub typst_page: TypstPageConfig,
#[serde(default)]
pub typst_fonts: TypstFontsConfig,
#[serde(default)]
pub typst_layout: TypstLayoutConfig,
#[serde(default)]
pub images: ImagesConfig,
#[serde(default)]
pub output: OutputConfig,
#[serde(default)]
pub goals: GoalsConfig,
#[serde(default)]
pub ai: AiConfig,
#[serde(default)]
pub timeline: TimelineConfig,
#[serde(default)]
pub scrivener: ScrivenerConfig,
#[serde(default)]
pub shell: ShellConfig,
#[serde(default)]
pub scripting: crate::scripting::policy::Policy,
#[serde(default = "default_language")]
pub language: String,
#[serde(default = "default_prompts_path")]
pub prompts_file: PathBuf,
#[serde(default = "default_artefacts_directory")]
pub artefacts_directory: String,
#[serde(default = "default_sync_interval")]
pub sync_interval_seconds: u64,
}
fn default_view_prefix() -> String {
"Ctrl+v".into()
}
fn default_sync_interval() -> u64 {
600
}
fn default_prompts_path() -> PathBuf {
PathBuf::from("prompts.hjson")
}
fn default_language() -> String {
"english".into()
}
fn default_artefacts_directory() -> String {
String::new()
}
impl Default for Config {
fn default() -> Self {
Self {
embeddings: EmbeddingsConfig::default(),
llm: LlmConfig::default(),
editor: EditorConfig::default(),
keys: KeyBindings::default(),
hierarchy: HierarchyConfig::default(),
theme: ThemeConfig::default(),
backup: BackupConfig::default(),
sound: SoundConfig::default(),
typst_templates: TypstTemplatesConfig::default(),
typst_compile: TypstCompileConfig::default(),
typst_page: TypstPageConfig::default(),
typst_fonts: TypstFontsConfig::default(),
typst_layout: TypstLayoutConfig::default(),
images: ImagesConfig::default(),
output: OutputConfig::default(),
goals: GoalsConfig::default(),
ai: AiConfig::default(),
timeline: TimelineConfig::default(),
scrivener: ScrivenerConfig::default(),
shell: ShellConfig::default(),
scripting: crate::scripting::policy::Policy::default(),
language: default_language(),
prompts_file: default_prompts_path(),
artefacts_directory: default_artefacts_directory(),
sync_interval_seconds: default_sync_interval(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BackupConfig {
pub out_dir: String,
#[serde(with = "humantime_serde")]
pub max_age: std::time::Duration,
#[serde(default = "default_backup_wait_for_key")]
pub wait_for_key_after_backup: bool,
}
fn default_backup_wait_for_key() -> bool {
true
}
impl Default for BackupConfig {
fn default() -> Self {
Self {
out_dir: String::new(),
max_age: std::time::Duration::from_secs(7 * 24 * 3600),
wait_for_key_after_backup: default_backup_wait_for_key(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SoundConfig {
pub enabled: bool,
pub volume: f32,
}
impl Default for SoundConfig {
fn default() -> Self {
Self {
enabled: false,
volume: 0.6,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScrivenerConfig {
pub date_fields: Vec<String>,
}
impl Default for ScrivenerConfig {
fn default() -> Self {
Self {
date_fields: vec![
"Date".into(),
"Story Date".into(),
"Event Date".into(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShellConfig {
pub enabled: bool,
pub max_buffered_turns: usize,
pub max_output_lines: usize,
pub insert_template: String,
pub blocked_externals: Vec<String>,
pub external_timeout_secs: u64,
}
impl Default for ShellConfig {
fn default() -> Self {
Self {
enabled: true,
max_buffered_turns: 50,
max_output_lines: 1000,
insert_template:
"#raw(block: true, lang: \"shell\", `{output}`)".into(),
blocked_externals: default_blocked_externals(),
external_timeout_secs: 30,
}
}
}
pub fn default_blocked_externals() -> Vec<String> {
[
"vim", "nvim", "vi", "view", "ex",
"emacs", "emacsclient",
"nano", "pico", "joe", "jed",
"mc", "mcedit", "ranger", "nnn", "lf", "yazi",
"less", "more", "most", "pg",
"top", "htop", "btop", "atop", "iotop", "iftop", "nethogs", "glances",
"tmux", "screen", "byobu", "dtach", "abduco",
"ssh", "telnet", "mosh", "rlogin",
"gdb", "lldb",
"fzf", "peco", "sk", "skim",
"ipython", "irb", "pry",
"psql", "mysql", "sqlite3", "redis-cli",
"sudo", "su", "passwd",
]
.into_iter()
.map(String::from)
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstTemplatesConfig {
pub wrap_book: String,
pub wrap_chapter: String,
pub wrap_subchapter: String,
pub wrap_paragraph: String,
pub wrap_image_book: String,
pub wrap_image_chapter: String,
pub wrap_image_subchapter: String,
pub wrap_image_inline: String,
}
impl Default for TypstTemplatesConfig {
fn default() -> Self {
Self {
wrap_book: default_wrap_book().into(),
wrap_chapter: default_wrap_chapter().into(),
wrap_subchapter: default_wrap_subchapter().into(),
wrap_paragraph: default_wrap_paragraph().into(),
wrap_image_book: default_wrap_image_book().into(),
wrap_image_chapter: default_wrap_image_chapter().into(),
wrap_image_subchapter: default_wrap_image_subchapter().into(),
wrap_image_inline: default_wrap_image_inline().into(),
}
}
}
pub fn default_wrap_book() -> &'static str {
"#let wrap_book(body) = {\n body\n}\n"
}
pub fn default_wrap_chapter() -> &'static str {
"#let wrap_chapter(title, body) = {\n heading(level: 1, title)\n body\n}\n"
}
pub fn default_wrap_subchapter() -> &'static str {
"#let wrap_subchapter(title, body) = {\n heading(level: 2, title)\n body\n}\n"
}
pub fn default_wrap_paragraph() -> &'static str {
"#let wrap_paragraph(body) = {\n body\n parbreak()\n}\n"
}
pub fn default_wrap_image_book() -> &'static str {
"// Frontispiece — Image directly under a Book.\n\
#let wrap_image_book(path, title, caption, alt: none) = {\n\
\u{20}\u{20}pagebreak(weak: true)\n\
\u{20}\u{20}align(center + horizon, image(path, alt: alt, width: 90%))\n\
\u{20}\u{20}if caption != none [#align(center)[#emph(caption)]]\n\
\u{20}\u{20}pagebreak(weak: true)\n\
}\n"
}
pub fn default_wrap_image_chapter() -> &'static str {
"// Chapter-art — Image directly under a Chapter.\n\
#let wrap_image_chapter(path, title, caption, alt: none) = {\n\
\u{20}\u{20}pagebreak(weak: true)\n\
\u{20}\u{20}align(center, image(path, alt: alt, width: 80%))\n\
\u{20}\u{20}if caption != none [#align(center)[#emph(caption)]]\n\
}\n"
}
pub fn default_wrap_image_subchapter() -> &'static str {
"// Section image — Image directly under a Subchapter.\n\
#let wrap_image_subchapter(path, title, caption, alt: none) = {\n\
\u{20}\u{20}align(center, image(path, alt: alt, width: 60%))\n\
\u{20}\u{20}if caption != none [#align(center)[#emph(caption)]]\n\
}\n"
}
pub fn default_wrap_image_inline() -> &'static str {
"// Inline figure — call from paragraph text with #wrap_image_inline(...).\n\
#let wrap_image_inline(path, title, caption, alt: none) = figure(\n\
\u{20}\u{20}image(path, alt: alt, width: 80%),\n\
\u{20}\u{20}caption: caption,\n\
)\n"
}
impl TypstTemplatesConfig {
pub fn resolved_wrap_book(&self) -> String {
if self.wrap_book.trim().is_empty() {
default_wrap_book().into()
} else {
self.wrap_book.clone()
}
}
pub fn resolved_wrap_chapter(&self) -> String {
if self.wrap_chapter.trim().is_empty() {
default_wrap_chapter().into()
} else {
self.wrap_chapter.clone()
}
}
pub fn resolved_wrap_subchapter(&self) -> String {
if self.wrap_subchapter.trim().is_empty() {
default_wrap_subchapter().into()
} else {
self.wrap_subchapter.clone()
}
}
pub fn resolved_wrap_paragraph(&self) -> String {
if self.wrap_paragraph.trim().is_empty() {
default_wrap_paragraph().into()
} else {
self.wrap_paragraph.clone()
}
}
pub fn resolved_wrap_image_book(&self) -> String {
if self.wrap_image_book.trim().is_empty() {
default_wrap_image_book().into()
} else {
self.wrap_image_book.clone()
}
}
pub fn resolved_wrap_image_chapter(&self) -> String {
if self.wrap_image_chapter.trim().is_empty() {
default_wrap_image_chapter().into()
} else {
self.wrap_image_chapter.clone()
}
}
pub fn resolved_wrap_image_subchapter(&self) -> String {
if self.wrap_image_subchapter.trim().is_empty() {
default_wrap_image_subchapter().into()
} else {
self.wrap_image_subchapter.clone()
}
}
pub fn resolved_wrap_image_inline(&self) -> String {
if self.wrap_image_inline.trim().is_empty() {
default_wrap_image_inline().into()
} else {
self.wrap_image_inline.clone()
}
}
pub fn globals_typ_body(&self) -> String {
let mut out = String::new();
out.push_str("= globals.typ\n\n");
out.push_str(
"// Wrap functions used by inkhaven's `Book assembly` (Ctrl+B A).\n\
// Each node in the manuscript tree is fed through the matching\n\
// wrap_* call when the assembler synthesises index.typ files.\n\
// Customise to taste — page breaks, headings, fonts, layout.\n\n",
);
out.push_str("// ---- Prose wrappers ----\n");
out.push_str(&self.resolved_wrap_book());
out.push('\n');
out.push_str(&self.resolved_wrap_chapter());
out.push('\n');
out.push_str(&self.resolved_wrap_subchapter());
out.push('\n');
out.push_str(&self.resolved_wrap_paragraph());
out.push_str("\n// ---- Image wrappers ----\n");
out.push_str(&self.resolved_wrap_image_book());
out.push('\n');
out.push_str(&self.resolved_wrap_image_chapter());
out.push('\n');
out.push_str(&self.resolved_wrap_image_subchapter());
out.push('\n');
out.push_str(&self.resolved_wrap_image_inline());
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstCompileConfig {
pub error_system_prompt: String,
pub engine: String,
pub diagnostics: bool,
pub diagnostics_idle_seconds: u64,
pub semantic_diagnostics: bool,
pub bundle_fonts: bool,
pub use_system_fonts: bool,
pub packages_enabled: bool,
#[serde(default = "default_wait_for_key_after_compile")]
pub wait_for_key_after_compile: bool,
}
fn default_wait_for_key_after_compile() -> bool {
true
}
impl Default for TypstCompileConfig {
fn default() -> Self {
Self {
error_system_prompt: String::new(),
engine: "external".to_owned(),
diagnostics: true,
diagnostics_idle_seconds: 2,
semantic_diagnostics: false,
bundle_fonts: true,
use_system_fonts: true,
packages_enabled: true,
wait_for_key_after_compile: default_wait_for_key_after_compile(),
}
}
}
impl TypstCompileConfig {
pub fn resolved_error_system_prompt(&self) -> String {
if self.error_system_prompt.trim().is_empty() {
default_typst_error_system_prompt().into()
} else {
self.error_system_prompt.clone()
}
}
pub fn use_inprocess_engine(&self) -> bool {
self.engine.eq_ignore_ascii_case("inprocess")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ImagesConfig {
pub preview_enabled: bool,
pub allowed_extensions: Vec<String>,
pub max_size_bytes: u64,
}
impl Default for ImagesConfig {
fn default() -> Self {
Self {
preview_enabled: true,
allowed_extensions: vec![
"png".into(),
"jpg".into(),
"jpeg".into(),
"gif".into(),
"webp".into(),
"svg".into(),
],
max_size_bytes: 32 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstPageConfig {
pub paper: String,
pub margin_top: String,
pub margin_bottom: String,
pub margin_inside: String,
pub margin_outside: String,
pub page_numbering: String,
pub columns: u32,
}
impl Default for TypstPageConfig {
fn default() -> Self {
Self {
paper: "us-letter".into(),
margin_top: "2.5cm".into(),
margin_bottom: "2.5cm".into(),
margin_inside: "3cm".into(),
margin_outside: "2cm".into(),
page_numbering: "1".into(),
columns: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstFontsConfig {
pub body: String,
pub body_size: String,
pub monospace: String,
pub language: String,
}
impl Default for TypstFontsConfig {
fn default() -> Self {
Self {
body: "Linux Libertine".into(),
body_size: "11pt".into(),
monospace: "DejaVu Sans Mono".into(),
language: "en".into(),
}
}
}
const BUNDLED_BODY_FONT: &str = "Linux Libertine";
const BUNDLED_MONO_FONT: &str = "DejaVu Sans Mono";
fn font_literal(primary: &str, fallback: &str) -> String {
let primary = primary.trim();
if primary.eq_ignore_ascii_case(fallback) {
format!("\"{}\"", typst_escape(primary))
} else {
format!(
"(\"{}\", \"{}\")",
typst_escape(primary),
typst_escape(fallback)
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TypstLayoutConfig {
pub justify: bool,
pub leading: String,
pub paragraph_indent: String,
pub heading_numbering: String,
}
impl Default for TypstLayoutConfig {
fn default() -> Self {
Self {
justify: true,
leading: "0.7em".into(),
paragraph_indent: String::new(),
heading_numbering: String::new(),
}
}
}
impl Config {
pub fn synthesised_settings_typ_header(&self) -> String {
let mut out = String::new();
out.push_str(
"// ── inkhaven auto-generated · do not edit ────────────────\n\
// Source: typst_page / typst_fonts / typst_layout in\n\
// inkhaven.hjson. Change values there and re-run Ctrl+B A.\n\
// Anything below the `User overrides` line below is your\n\
// free-form paragraph content; preserved across rebuilds.\n\n",
);
let p = &self.typst_page;
if !p.paper.trim().is_empty() {
let mut args: Vec<String> = Vec::new();
args.push(format!("paper: \"{}\"", typst_escape(&p.paper)));
let any_margin = !(p.margin_top.is_empty()
&& p.margin_bottom.is_empty()
&& p.margin_inside.is_empty()
&& p.margin_outside.is_empty());
if any_margin {
args.push(format!(
"margin: (top: {}, bottom: {}, inside: {}, outside: {})",
pad_or(&p.margin_top, "2.5cm"),
pad_or(&p.margin_bottom, "2.5cm"),
pad_or(&p.margin_inside, "3cm"),
pad_or(&p.margin_outside, "2cm"),
));
}
if !p.page_numbering.trim().is_empty() {
args.push(format!(
"numbering: \"{}\"",
typst_escape(&p.page_numbering)
));
}
if p.columns > 1 {
args.push(format!("columns: {}", p.columns));
}
out.push_str(&format!("#set page({})\n\n", args.join(", ")));
}
let f = &self.typst_fonts;
let mut text_args: Vec<String> = Vec::new();
if !f.body.trim().is_empty() {
text_args.push(format!(
"font: {}",
font_literal(&f.body, BUNDLED_BODY_FONT)
));
}
if !f.body_size.trim().is_empty() {
text_args.push(format!("size: {}", f.body_size));
}
if !f.language.trim().is_empty() {
text_args.push(format!("lang: \"{}\"", typst_escape(&f.language)));
}
if !text_args.is_empty() {
out.push_str(&format!("#set text({})\n\n", text_args.join(", ")));
}
if !f.monospace.trim().is_empty() {
out.push_str(&format!(
"#show raw: set text(font: {})\n\n",
font_literal(&f.monospace, BUNDLED_MONO_FONT)
));
}
let l = &self.typst_layout;
let mut par_args: Vec<String> = Vec::new();
par_args.push(format!("justify: {}", l.justify));
if !l.leading.trim().is_empty() {
par_args.push(format!("leading: {}", l.leading));
}
if !l.paragraph_indent.trim().is_empty() {
par_args.push(format!("first-line-indent: {}", l.paragraph_indent));
}
out.push_str(&format!("#set par({})\n\n", par_args.join(", ")));
if !l.heading_numbering.trim().is_empty() {
out.push_str(&format!(
"#set heading(numbering: \"{}\")\n\n",
typst_escape(&l.heading_numbering)
));
}
out.push_str(
"// ── User overrides (your settings.typ paragraph below) ─────\n",
);
out
}
}
fn typst_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' | '\r' => out.push(' '),
other => out.push(other),
}
}
out
}
fn pad_or<'a>(v: &'a str, fallback: &'a str) -> &'a str {
if v.trim().is_empty() { fallback } else { v }
}
pub fn default_typst_error_system_prompt() -> &'static str {
"You are an expert Typst typesetter helping debug `typst compile` failures \
for books assembled by inkhaven. Inkhaven generates a tree of `.typ` files:\n\
- `<slug>.typ` — root, imports globals.typ + settings.typ, calls wrap_book(include \"book/index.typ\").\n\
- `globals.typ` — defines wrap_book / wrap_chapter / wrap_subchapter / wrap_paragraph functions.\n\
- `settings.typ` — document-wide #set / #show rules.\n\
- `book/index.typ` — sequence of `#include` for chapters at markup scope.\n\
- `book/<NN-chapter>/index.typ` — calls `#wrap_chapter(\"title\", { include … })` in code mode.\n\
- `book/<NN-chapter>/<NN-paragraph>.typ` — the user's prose (leading `= title` stripped).\n\n\
When you receive an error, walk through:\n\
1. What the error means in plain language.\n\
2. Which of the file categories above most likely caused it.\n\
3. The smallest concrete fix the user can apply — either in their inkhaven \
paragraph (via the editor) or in HJSON config (`typst_templates.wrap_*`).\n\n\
Be concise. The user wants to ship a PDF, not a tutorial."
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
pub pane_bg: String,
pub pane_fg: String,
pub line_number_fg: String,
pub current_line_bg: String,
pub border_focused: String,
pub border_unfocused: String,
pub border_dirty: String,
pub border_saved: String,
pub border_readonly: String,
pub modal_bg: String,
pub modal_border: String,
pub modal_fg: String,
pub places_fg: String,
pub characters_fg: String,
pub artefacts_fg: String,
pub notes_underline_fg: String,
#[serde(default)]
pub style_warning_filter_word_fg: String,
#[serde(default)]
pub style_warning_repeated_phrase_fg: String,
#[serde(default)]
pub style_warning_show_dont_tell_fg: String,
#[serde(default)]
pub style_warning_filter_word_modifier: String,
#[serde(default)]
pub style_warning_repeated_phrase_modifier: String,
#[serde(default)]
pub style_warning_show_dont_tell_modifier: String,
#[serde(default)]
pub pov_chip_bg: String,
#[serde(default)]
pub pov_chip_fg: String,
pub search_match_bg: String,
pub search_current_bg: String,
pub tree_open_marker: String,
pub tree_book_fg: String,
pub tree_chapter_fg: String,
pub tree_subchapter_fg: String,
pub tree_paragraph_fg: String,
pub tree_image_fg: String,
pub tree_script_fg: String,
pub editor_position_fg: String,
pub ai_scope_fg: String,
pub ai_infer_fg: String,
pub grammar_change_fg: String,
pub syntax_heading: String,
pub syntax_bold: String,
pub syntax_italic: String,
pub syntax_string: String,
pub syntax_number: String,
pub syntax_comment: String,
pub syntax_keyword: String,
pub syntax_function: String,
pub syntax_operator: String,
pub syntax_list_marker: String,
pub syntax_raw: String,
pub syntax_tag: String,
pub syntax_quote: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
pane_bg: "#1e1e2e".into(),
pane_fg: "#cdd6f4".into(),
line_number_fg: "#6c7086".into(),
current_line_bg: "#313244".into(),
border_focused: "#cba6f7".into(),
border_unfocused: "#45475a".into(),
border_dirty: "#f9e2af".into(),
border_saved: "#a6e3a1".into(),
border_readonly: "#94e2d5".into(),
modal_bg: "#181825".into(),
modal_border: "#cba6f7".into(),
modal_fg: "#cdd6f4".into(),
places_fg: "#89dceb".into(),
characters_fg: "#f9e2af".into(),
artefacts_fg: "#fab387".into(),
notes_underline_fg: "#cdd6f4".into(),
style_warning_filter_word_fg: "#f9c44e".into(),
style_warning_repeated_phrase_fg: "#eb6f92".into(),
style_warning_show_dont_tell_fg: "#94e2d5".into(),
style_warning_filter_word_modifier: String::new(),
style_warning_repeated_phrase_modifier: String::new(),
style_warning_show_dont_tell_modifier: String::new(),
pov_chip_bg: "#8b1d88".into(),
pov_chip_fg: "#ffffff".into(),
search_match_bg: "#f38ba8".into(),
search_current_bg: "#f5c2e7".into(),
tree_open_marker: "#a6e3a1".into(),
tree_book_fg: "#f5c2e7".into(), tree_chapter_fg: "#89b4fa".into(), tree_subchapter_fg: "#94e2d5".into(), tree_paragraph_fg: "#cdd6f4".into(), tree_image_fg: "#fab387".into(), tree_script_fg: "#cba6f7".into(),
editor_position_fg: "#89dceb".into(), ai_scope_fg: "#fab387".into(), ai_infer_fg: "#94e2d5".into(),
grammar_change_fg: "#f38ba8".into(),
syntax_heading: "#cba6f7".into(),
syntax_bold: "#f9e2af".into(),
syntax_italic: "#94e2d5".into(),
syntax_string: "#a6e3a1".into(),
syntax_number: "#fab387".into(),
syntax_comment: "#6c7086".into(),
syntax_keyword: "#cba6f7".into(),
syntax_function: "#89dceb".into(),
syntax_operator: "#94e2d5".into(),
syntax_list_marker: "#cba6f7".into(),
syntax_raw: "#fab387".into(),
syntax_tag: "#89b4fa".into(),
syntax_quote: "#9399b2".into(),
}
}
}
pub fn parse_color(s: &str) -> Option<ratatui::style::Color> {
use ratatui::style::Color;
let t = s.trim();
if t.is_empty() {
return None;
}
let hex = t.strip_prefix('#').unwrap_or(t);
let parse_byte = |h: &str| u8::from_str_radix(h, 16).ok();
match hex.len() {
3 => {
let r = parse_byte(&hex[0..1])? * 17;
let g = parse_byte(&hex[1..2])? * 17;
let b = parse_byte(&hex[2..3])? * 17;
Some(Color::Rgb(r, g, b))
}
6 => {
let r = parse_byte(&hex[0..2])?;
let g = parse_byte(&hex[2..4])?;
let b = parse_byte(&hex[4..6])?;
Some(Color::Rgb(r, g, b))
}
_ => None,
}
}
pub fn color_or(s: &str, default: ratatui::style::Color) -> ratatui::style::Color {
parse_color(s).unwrap_or(default)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddingsConfig {
pub model: String,
pub chunk_size: usize,
pub chunk_overlap: f32,
}
impl Default for EmbeddingsConfig {
fn default() -> Self {
Self {
model: "MultilingualE5Small".into(),
chunk_size: 800,
chunk_overlap: 0.15,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LlmConfig {
pub default: String,
pub providers: std::collections::BTreeMap<String, LlmProvider>,
}
impl Default for LlmConfig {
fn default() -> Self {
let mut providers = std::collections::BTreeMap::new();
providers.insert(
"gemini".into(),
LlmProvider {
model: "gemini-2.5-pro".into(),
api_key_env: Some("GEMINI_API_KEY".into()),
},
);
providers.insert(
"claude".into(),
LlmProvider {
model: "claude-sonnet-4-5".into(),
api_key_env: Some("ANTHROPIC_API_KEY".into()),
},
);
providers.insert(
"openai".into(),
LlmProvider {
model: "gpt-4o".into(),
api_key_env: Some("OPENAI_API_KEY".into()),
},
);
providers.insert(
"deepseek".into(),
LlmProvider {
model: "deepseek-chat".into(),
api_key_env: Some("DEEPSEEK_API_KEY".into()),
},
);
providers.insert(
"grok".into(),
LlmProvider {
model: "grok-2-latest".into(),
api_key_env: Some("XAI_API_KEY".into()),
},
);
Self {
default: "gemini".into(),
providers,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmProvider {
pub model: String,
#[serde(default)]
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EditorConfig {
pub theme: String,
pub tab_width: usize,
pub wrap: bool,
pub autosave_seconds: u64,
pub auto_close_pairs: bool,
pub stemming: StemmingConfig,
#[serde(default = "default_startup_splash")]
pub startup_splash: bool,
#[serde(default = "default_mouse_captured")]
pub mouse_captured: bool,
#[serde(default = "default_confirm_quit")]
pub confirm_quit: bool,
#[serde(default)]
pub tts: TtsConfig,
#[serde(default)]
pub style_warnings: StyleWarningsConfig,
#[serde(default = "default_pov_chip_enabled")]
pub pov_chip_enabled: bool,
#[serde(default = "default_prompt_language_mode")]
pub prompt_language_mode: String,
#[serde(default = "default_prompt_language_detection_min_chars")]
pub prompt_language_detection_min_chars: usize,
}
fn default_pov_chip_enabled() -> bool {
true
}
fn default_prompt_language_mode() -> String {
"book_defined".into()
}
fn default_prompt_language_detection_min_chars() -> usize {
50
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StyleWarningsConfig {
pub enabled: bool,
pub filter_words: FilterWordsConfig,
pub repeated_phrases: RepeatedPhrasesConfig,
pub show_dont_tell: ShowDontTellConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShowDontTellConfig {
pub enabled: bool,
pub use_stemming: bool,
pub english_linking_verbs: Vec<String>,
pub english_emotion_adjectives: Vec<String>,
pub english_manner_adverbs: Vec<String>,
pub english_cognition_verbs: Vec<String>,
pub russian_linking_verbs: Vec<String>,
pub russian_emotion_adjectives: Vec<String>,
pub russian_manner_adverbs: Vec<String>,
pub russian_cognition_verbs: Vec<String>,
pub french_linking_verbs: Vec<String>,
pub french_emotion_adjectives: Vec<String>,
pub french_manner_adverbs: Vec<String>,
pub french_cognition_verbs: Vec<String>,
pub german_linking_verbs: Vec<String>,
pub german_emotion_adjectives: Vec<String>,
pub german_manner_adverbs: Vec<String>,
pub german_cognition_verbs: Vec<String>,
pub spanish_linking_verbs: Vec<String>,
pub spanish_emotion_adjectives: Vec<String>,
pub spanish_manner_adverbs: Vec<String>,
pub spanish_cognition_verbs: Vec<String>,
}
impl Default for ShowDontTellConfig {
fn default() -> Self {
Self {
enabled: true,
use_stemming: true,
english_linking_verbs: Vec::new(),
english_emotion_adjectives: Vec::new(),
english_manner_adverbs: Vec::new(),
english_cognition_verbs: Vec::new(),
russian_linking_verbs: Vec::new(),
russian_emotion_adjectives: Vec::new(),
russian_manner_adverbs: Vec::new(),
russian_cognition_verbs: Vec::new(),
french_linking_verbs: Vec::new(),
french_emotion_adjectives: Vec::new(),
french_manner_adverbs: Vec::new(),
french_cognition_verbs: Vec::new(),
german_linking_verbs: Vec::new(),
german_emotion_adjectives: Vec::new(),
german_manner_adverbs: Vec::new(),
german_cognition_verbs: Vec::new(),
spanish_linking_verbs: Vec::new(),
spanish_emotion_adjectives: Vec::new(),
spanish_manner_adverbs: Vec::new(),
spanish_cognition_verbs: Vec::new(),
}
}
}
pub fn built_in_linking_verbs(language: &str) -> &'static [&'static str] {
match language.to_lowercase().as_str() {
"english" | "" => &[
"be", "is", "am", "are", "was", "were", "been", "being",
"seem", "seems", "seemed", "seeming",
"feel", "feels", "felt", "feeling",
"appear", "appears", "appeared", "appearing",
"look", "looks", "looked", "looking",
"become", "becomes", "became", "becoming",
"remain", "remains", "remained", "remaining",
"grow", "grows", "grew", "growing",
"sound", "sounds", "sounded",
],
"russian" => &[
"быть", "был", "была", "было", "были",
"буду", "будешь", "будет", "будем", "будете", "будут",
"есть",
"казаться", "кажется", "казался", "казалась", "казалось", "казались",
"выглядеть", "выглядит", "выглядел", "выглядела", "выглядело",
"становиться", "становится", "становился", "становилась",
"стать", "стал", "стала", "стало", "стали",
"оставаться", "остаётся", "оставался", "оставалась",
"чувствовать", "чувствует", "чувствовал", "чувствовала",
"оказаться", "оказался", "оказалась", "оказалось",
],
"french" => &[
"être", "est", "sont", "étais", "était", "étions", "étiez", "étaient",
"fus", "fut", "fûmes", "furent",
"sera", "seront", "serait", "seraient",
"paraître", "paraît", "paraissait", "paraissent",
"sembler", "semble", "semblait", "semblent",
"devenir", "devient", "devenait", "deviennent",
"rester", "reste", "restait", "restent",
"demeurer", "demeure", "demeurait",
"sentir", "sent", "sentait",
"avoir", "a", "avait", "ont",
],
"german" => &[
"sein", "ist", "sind", "war", "waren", "bin", "bist", "seid",
"gewesen",
"scheinen", "scheint", "schien", "schienen",
"wirken", "wirkt", "wirkte", "wirkten",
"werden", "wird", "wurde", "wurden", "geworden",
"bleiben", "bleibt", "blieb", "blieben",
"aussehen", "sieht", "sah",
"fühlen", "fühlt", "fühlte", "fühlten",
],
"spanish" => &[
"ser", "es", "son", "era", "eran", "fue", "fueron",
"será", "serán", "sería", "serían",
"estar", "está", "están", "estaba", "estaban", "estuvo", "estuvieron",
"parecer", "parece", "parecía", "parecen",
"sentir", "sentirse", "siente", "sentía",
"quedar", "quedarse", "queda", "quedaba",
"volverse", "vuelve", "volvía",
"ponerse", "pone", "puso", "ponía",
"encontrarse", "encuentra", "encontraba",
],
_ => &[],
}
}
pub fn built_in_emotion_adjectives(language: &str) -> &'static [&'static str] {
match language.to_lowercase().as_str() {
"english" | "" => &[
"angry", "mad", "furious", "livid", "irate", "enraged",
"annoyed", "irritated", "agitated",
"sad", "depressed", "melancholy", "gloomy", "miserable",
"unhappy", "dejected", "downcast", "forlorn",
"afraid", "scared", "frightened", "terrified", "anxious",
"nervous", "worried", "uneasy", "panicked", "apprehensive",
"happy", "joyful", "glad", "content", "pleased", "delighted",
"thrilled", "elated", "ecstatic", "cheerful",
"tired", "exhausted", "weary", "drained", "spent",
"confused", "puzzled", "perplexed", "baffled",
"surprised", "shocked", "stunned", "astonished", "amazed",
"embarrassed", "ashamed", "humiliated", "mortified",
"proud", "smug",
"jealous", "envious",
"lonely", "isolated",
"bored", "listless", "restless",
"excited", "eager", "enthusiastic",
"determined", "resolute",
"hopeless", "helpless", "defeated",
],
"russian" => &[
"сердитый", "злой", "разгневанный", "раздражённый",
"грустный", "печальный", "несчастный", "унылый", "тоскливый",
"испуганный", "напуганный", "тревожный", "встревоженный", "испугавшийся",
"счастливый", "радостный", "довольный", "весёлый", "восторженный",
"усталый", "измождённый", "утомлённый", "обессиленный",
"удивлённый", "поражённый", "ошеломлённый", "изумлённый",
"растерянный", "смущённый", "озадаченный",
"пристыженный", "сконфуженный",
"гордый", "ревнивый", "завистливый", "одинокий", "скучающий",
"взволнованный", "возбуждённый",
"решительный",
"безнадёжный", "беспомощный",
],
"french" => &[
"furieux", "furieuse", "en colère", "fâché", "fâchée",
"irrité", "irritée", "agacé", "agacée",
"triste", "malheureux", "malheureuse", "mélancolique", "abattu", "abattue",
"effrayé", "effrayée", "apeuré", "apeurée",
"anxieux", "anxieuse", "inquiet", "inquiète", "nerveux", "nerveuse",
"heureux", "heureuse", "joyeux", "joyeuse",
"ravi", "ravie", "content", "contente",
"fatigué", "fatiguée", "épuisé", "épuisée", "las", "lasse",
"surpris", "surprise", "étonné", "étonnée", "stupéfait", "stupéfaite",
"confus", "confuse", "perplexe",
"honteux", "honteuse", "gêné", "gênée",
"fier", "fière", "jaloux", "jalouse", "envieux", "envieuse",
"seul", "seule", "ennuyé", "ennuyée",
"excité", "excitée", "enthousiaste",
"désespéré", "désespérée", "impuissant", "impuissante",
],
"german" => &[
"wütend", "zornig", "verärgert", "gereizt",
"traurig", "betrübt", "niedergeschlagen", "trübselig", "unglücklich",
"ängstlich", "verängstigt", "besorgt", "nervös", "panisch",
"glücklich", "fröhlich", "erfreut", "zufrieden", "begeistert",
"müde", "erschöpft", "ermattet",
"überrascht", "erstaunt", "verblüfft", "schockiert",
"verwirrt", "verlegen", "beschämt",
"stolz", "eifersüchtig", "neidisch",
"einsam", "gelangweilt",
"aufgeregt", "entschlossen",
"hoffnungslos", "hilflos",
],
"spanish" => &[
"enfadado", "enfadada", "enojado", "enojada", "furioso", "furiosa",
"irritado", "irritada",
"triste", "afligido", "afligida", "deprimido", "deprimida",
"melancólico", "melancólica", "desdichado", "desdichada",
"asustado", "asustada", "aterrado", "aterrada",
"ansioso", "ansiosa", "nervioso", "nerviosa", "preocupado", "preocupada",
"feliz", "alegre", "contento", "contenta", "encantado", "encantada",
"cansado", "cansada", "agotado", "agotada", "exhausto", "exhausta",
"sorprendido", "sorprendida", "asombrado", "asombrada", "atónito", "atónita",
"confundido", "confundida", "perplejo", "perpleja",
"avergonzado", "avergonzada",
"orgulloso", "orgullosa", "celoso", "celosa", "envidioso", "envidiosa",
"solo", "sola", "aburrido", "aburrida",
"emocionado", "emocionada", "decidido", "decidida",
"desesperado", "desesperada", "impotente",
],
_ => &[],
}
}
pub fn built_in_manner_adverbs(language: &str) -> &'static [&'static str] {
match language.to_lowercase().as_str() {
"english" | "" => &[
"angrily", "sadly", "happily", "fearfully", "nervously",
"anxiously", "calmly", "frantically", "wearily", "tiredly",
"excitedly", "gleefully", "miserably", "joyfully",
"furiously", "irritably", "annoyedly", "bitterly",
"proudly", "smugly", "jealously", "enviously",
"lovingly", "tenderly", "coldly", "warmly",
"desperately", "hopelessly", "helplessly",
"embarrassedly", "shamefully", "guiltily",
"bored", "boredly", "listlessly",
"confusedly",
],
"russian" => &[
"сердито", "злобно", "раздражённо",
"грустно", "печально", "уныло", "тоскливо",
"испуганно", "тревожно", "нервно",
"счастливо", "радостно", "весело",
"устало", "измождённо",
"удивлённо", "поражённо",
"растерянно", "смущённо",
"гордо", "ревниво", "одиноко",
"взволнованно", "решительно",
"безнадёжно", "беспомощно",
"холодно", "тепло",
"горько", "нежно",
],
"french" => &[
"furieusement", "rageusement", "tristement", "mélancoliquement",
"peureusement", "nerveusement", "anxieusement",
"joyeusement", "heureusement", "gaiement",
"fatiguement",
"tendrement", "amoureusement", "froidement", "chaleureusement",
"fièrement", "jalousement", "envieusement",
"désespérément", "honteusement", "calmement",
"amèrement", "douloureusement",
],
"german" => &[
"wütend", "zornig", "ärgerlich",
"traurig", "betrübt", "unglücklich",
"ängstlich", "nervös", "besorgt",
"fröhlich", "glücklich", "freudig",
"müde", "erschöpft",
"überrascht", "verwirrt",
"stolz", "eifersüchtig",
"einsam", "gelangweilt",
"aufgeregt", "verzweifelt", "hilflos",
"kalt", "warm", "liebevoll", "bitter",
],
"spanish" => &[
"furiosamente", "rabiosamente", "enojadamente",
"tristemente", "melancólicamente",
"miedosamente", "nerviosamente", "ansiosamente",
"felizmente", "alegremente", "gozosamente",
"cansadamente",
"sorprendidamente",
"orgullosamente", "celosamente", "envidiosamente",
"solamente", "aburridamente",
"desesperadamente", "vergonzosamente",
"fríamente", "cálidamente", "amorosamente", "amargamente",
],
_ => &[],
}
}
pub fn built_in_cognition_verbs(language: &str) -> &'static [&'static str] {
match language.to_lowercase().as_str() {
"english" | "" => &[
"realised", "realized",
"understood", "knew", "thought",
"wondered", "wished", "hoped",
"believed", "supposed", "decided",
"concluded", "discovered", "recognised", "recognized",
"remembered", "considered",
"assumed", "expected",
],
"russian" => &[
"понял", "поняла", "понять", "понимал", "понимала",
"знал", "знала", "знать",
"подумал", "подумала", "думать",
"осознал", "осознала", "осознать",
"решил", "решила", "решить",
"вспомнил", "вспомнила", "вспомнить",
"заметил", "заметила",
"почувствовал", "почувствовала",
"поверил", "поверила", "верить",
"догадался", "догадалась",
],
"french" => &[
"réalisa", "réalisé", "réaliser",
"comprit", "compris", "comprendre",
"sut", "su", "savoir",
"pensa", "pensé", "penser",
"songea", "songer",
"décida", "décidé", "décider",
"se souvint", "se rappela",
"crut", "cru", "croire",
"supposa", "supposer",
"remarqua", "aperçut",
],
"german" => &[
"erkannte", "erkannt", "erkennen",
"verstand", "verstanden", "verstehen",
"wusste", "gewusst", "wissen",
"dachte", "gedacht", "denken",
"überlegte", "überlegt",
"beschloss", "entschied", "entschieden",
"erinnerte", "erinnert",
"bemerkte", "bemerkt",
"glaubte", "geglaubt",
"vermutete",
],
"spanish" => &[
"se dio cuenta", "comprendió", "comprender",
"entendió", "entender",
"supo", "sabía", "saber",
"pensó", "pensar",
"creyó", "creer", "creía",
"decidió", "decidir",
"recordó", "recordar",
"notó", "advirtió",
"supuso", "esperaba",
"concluyó",
],
_ => &[],
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RepeatedPhrasesConfig {
pub enabled: bool,
pub n: u8,
pub threshold: u8,
pub use_stemming: bool,
pub english_stop_words: Vec<String>,
pub russian_stop_words: Vec<String>,
pub french_stop_words: Vec<String>,
pub german_stop_words: Vec<String>,
pub spanish_stop_words: Vec<String>,
}
impl Default for RepeatedPhrasesConfig {
fn default() -> Self {
Self {
enabled: true,
n: 4,
threshold: 3,
use_stemming: true,
english_stop_words: Vec::new(),
russian_stop_words: Vec::new(),
french_stop_words: Vec::new(),
german_stop_words: Vec::new(),
spanish_stop_words: Vec::new(),
}
}
}
pub fn built_in_stop_words(language: &str) -> &'static [&'static str] {
match language.to_lowercase().as_str() {
"russian" => &[
"и", "в", "на", "не", "с", "что", "это", "как",
"а", "по", "из", "у", "от", "к", "за", "о",
"но", "же", "так", "то", "бы", "ли", "вот",
"только", "ещё", "также", "был", "была",
"было", "были", "есть",
],
"french" => &[
"le", "la", "les", "un", "une", "des", "de",
"du", "et", "à", "au", "aux", "en", "dans",
"pour", "par", "sur", "avec", "sans", "que",
"qui", "ce", "se", "il", "elle", "ils",
"elles", "ne", "pas",
],
"german" => &[
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "und", "in", "zu", "von", "mit",
"auf", "ist", "war", "sind", "waren", "es",
"er", "sie", "wir", "du", "ich", "nicht",
],
"spanish" => &[
"el", "la", "los", "las", "un", "una", "y",
"de", "del", "en", "a", "con", "por", "para",
"que", "no", "es", "son", "se", "su", "lo",
],
_ => &[
"the", "a", "an", "and", "or", "but", "of",
"to", "in", "on", "at", "by", "for", "with",
"as", "is", "was", "were", "are", "be",
"been", "being", "have", "has", "had", "do",
"does", "did", "it", "he", "she", "they",
"we", "you", "his", "her", "their", "its",
"this", "that", "these", "those", "not", "no",
],
}
}
impl Default for StyleWarningsConfig {
fn default() -> Self {
Self {
enabled: false,
filter_words: FilterWordsConfig::default(),
repeated_phrases: RepeatedPhrasesConfig::default(),
show_dont_tell: ShowDontTellConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FilterWordsConfig {
pub enabled: bool,
pub use_stemming: bool,
pub extra_words: Vec<String>,
pub english: Vec<String>,
pub russian: Vec<String>,
pub french: Vec<String>,
pub german: Vec<String>,
pub spanish: Vec<String>,
}
impl Default for FilterWordsConfig {
fn default() -> Self {
Self {
enabled: true,
use_stemming: true,
extra_words: Vec::new(),
english: Vec::new(),
russian: Vec::new(),
french: Vec::new(),
german: Vec::new(),
spanish: Vec::new(),
}
}
}
#[allow(dead_code)]
pub fn effective_filter_words<'a>(
cfg: &'a FilterWordsConfig,
language: &str,
) -> &'a [String] {
let configured: &Vec<String> = match language.to_lowercase().as_str() {
"russian" => &cfg.russian,
"french" => &cfg.french,
"german" => &cfg.german,
"spanish" => &cfg.spanish,
_ => &cfg.english,
};
if !configured.is_empty() {
return configured.as_slice();
}
&[]
}
pub fn built_in_filter_words(language: &str) -> &'static [&'static str] {
match language.to_lowercase().as_str() {
"russian" => BUILT_IN_RUSSIAN,
"french" => BUILT_IN_FRENCH,
"german" => BUILT_IN_GERMAN,
"spanish" => BUILT_IN_SPANISH,
_ => BUILT_IN_ENGLISH,
}
}
const BUILT_IN_ENGLISH: &[&str] = &[
"just", "really", "very", "pretty", "quite",
"rather", "fairly", "somewhat", "slightly",
"that", "actually", "basically", "literally",
"essentially", "simply", "definitely", "certainly",
"absolutely", "totally", "completely",
"seem", "feel", "look", "appear", "sound", "notice",
"begin", "start",
"suddenly", "perhaps", "maybe",
];
const BUILT_IN_RUSSIAN: &[&str] = &[
"очень", "просто", "именно", "довольно", "слишком",
"весьма", "крайне", "вполне", "достаточно",
"собственно", "буквально", "практически",
"фактически", "действительно", "реально",
"конечно", "разумеется", "безусловно",
"казаться", "почувствовать", "выглядеть",
"заметить",
"вдруг", "внезапно", "наверное", "возможно",
];
const BUILT_IN_FRENCH: &[&str] = &[
"vraiment", "très", "assez", "plutôt",
"juste", "simplement", "actuellement", "littéralement",
"essentiellement", "absolument", "totalement", "complètement",
"sembler", "paraître", "sentir",
"soudainement", "peut-être",
];
const BUILT_IN_GERMAN: &[&str] = &[
"sehr", "wirklich", "ziemlich", "eher", "etwas",
"einfach", "tatsächlich", "buchstäblich",
"absolut", "völlig", "komplett",
"scheinen", "fühlen", "sehen",
"plötzlich", "vielleicht",
];
const BUILT_IN_SPANISH: &[&str] = &[
"muy", "realmente", "bastante", "algo",
"solo", "simplemente", "actualmente", "literalmente",
"esencialmente", "absolutamente", "totalmente", "completamente",
"parecer", "sentir", "ver",
"repentinamente", "quizás",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TtsConfig {
pub enabled: bool,
pub voice: String,
pub speed: f32,
pub greeting: String,
pub goodbye: String,
}
impl Default for TtsConfig {
fn default() -> Self {
Self {
enabled: false,
voice: "Milena".into(),
speed: 1.0,
greeting: String::new(),
goodbye: String::new(),
}
}
}
fn default_startup_splash() -> bool {
true
}
fn default_mouse_captured() -> bool {
true
}
fn default_confirm_quit() -> bool {
false
}
impl Default for EditorConfig {
fn default() -> Self {
Self {
theme: "default".into(),
tab_width: 2,
wrap: true,
autosave_seconds: 5,
auto_close_pairs: true,
stemming: StemmingConfig::default(),
startup_splash: default_startup_splash(),
mouse_captured: default_mouse_captured(),
confirm_quit: default_confirm_quit(),
tts: TtsConfig::default(),
style_warnings: StyleWarningsConfig::default(),
pov_chip_enabled: default_pov_chip_enabled(),
prompt_language_mode: default_prompt_language_mode(),
prompt_language_detection_min_chars:
default_prompt_language_detection_min_chars(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StemmingConfig {
pub languages: Vec<String>,
}
impl Default for StemmingConfig {
fn default() -> Self {
Self {
languages: vec!["english".into(), "russian".into()],
}
}
}
pub fn parse_stemmer_language(name: &str) -> Option<rust_stemmers::Algorithm> {
use rust_stemmers::Algorithm;
let lower = name.trim().to_ascii_lowercase();
Some(match lower.as_str() {
"arabic" => Algorithm::Arabic,
"danish" => Algorithm::Danish,
"dutch" => Algorithm::Dutch,
"english" | "en" => Algorithm::English,
"finnish" => Algorithm::Finnish,
"french" => Algorithm::French,
"german" => Algorithm::German,
"greek" => Algorithm::Greek,
"hungarian" => Algorithm::Hungarian,
"italian" => Algorithm::Italian,
"norwegian" => Algorithm::Norwegian,
"portuguese" => Algorithm::Portuguese,
"romanian" => Algorithm::Romanian,
"russian" | "ru" => Algorithm::Russian,
"spanish" => Algorithm::Spanish,
"swedish" => Algorithm::Swedish,
"tamil" => Algorithm::Tamil,
"turkish" => Algorithm::Turkish,
_ => return None,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KeyBindings {
pub save: String,
pub search: String,
pub ai_prompt: String,
pub next_pane: String,
pub prev_pane: String,
pub page_up: String,
pub page_down: String,
pub meta_prefix: String,
pub bund_prefix: String,
#[serde(default = "default_view_prefix")]
pub view_prefix: String,
#[serde(default)]
pub bindings: Vec<BindingOverride>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BindingOverride {
pub chord: String,
pub action: String,
#[serde(default)]
pub scope: Option<String>,
}
impl Default for KeyBindings {
fn default() -> Self {
Self {
save: "Ctrl+s".into(),
search: "Ctrl+/".into(),
ai_prompt: "Ctrl+i".into(),
next_pane: "Tab".into(),
prev_pane: "Shift+Tab".into(),
page_up: "PageUp".into(),
page_down: "PageDown".into(),
meta_prefix: "Ctrl+b".into(),
bund_prefix: "Ctrl+z".into(),
view_prefix: default_view_prefix(),
bindings: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HierarchyConfig {
pub unbounded_subchapters: bool,
}
impl Default for HierarchyConfig {
fn default() -> Self {
Self {
unbounded_subchapters: false,
}
}
}
impl Config {
pub fn load(path: &Path) -> crate::error::Result<Self> {
let raw = std::fs::read_to_string(path).map_err(crate::error::Error::Io)?;
serde_hjson::from_str(&raw).map_err(|e| crate::error::Error::Config(e.to_string()))
}
#[allow(dead_code)]
pub fn save(&self, path: &Path) -> crate::error::Result<()> {
let s = serde_hjson::to_string(self)
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
std::fs::write(path, s).map_err(crate::error::Error::Io)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GoalsConfig {
pub daily_words: i64,
pub active_minutes_daily: i64,
pub streak_grace_per_week: i64,
pub books: std::collections::HashMap<String, BookGoal>,
pub status_ladder: std::collections::HashMap<String, i64>,
#[serde(default = "default_auto_promote_on_target")]
pub auto_promote_on_target: bool,
}
fn default_auto_promote_on_target() -> bool {
true
}
impl Default for GoalsConfig {
fn default() -> Self {
Self {
daily_words: 0,
active_minutes_daily: 0,
streak_grace_per_week: 0,
books: std::collections::HashMap::new(),
status_ladder: std::collections::HashMap::new(),
auto_promote_on_target: default_auto_promote_on_target(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct BookGoal {
pub target_words: i64,
pub deadline: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub extra_formats: Vec<String>,
pub extras_step_pause_ms: u64,
pub extras_wait_for_key: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
extra_formats: Vec::new(),
extras_step_pause_ms: 400,
extras_wait_for_key: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TimelineConfig {
pub enabled: bool,
pub default_track: String,
pub calendar: crate::timeline::calendar::CalendarConfig,
pub display: TimelineDisplayConfig,
}
impl Default for TimelineConfig {
fn default() -> Self {
Self {
enabled: false,
default_track: "main".into(),
calendar: crate::timeline::calendar::CalendarConfig::default(),
display: TimelineDisplayConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TimelineDisplayConfig {
pub show_orphans: bool,
pub swim_lane_max_rows: u32,
pub default_zoom: f32,
pub grid_every_days: u32,
}
impl Default for TimelineDisplayConfig {
fn default() -> Self {
Self {
show_orphans: true,
swim_lane_max_rows: 12,
default_zoom: 1.0,
grid_every_days: 7,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AiConfig {
pub per_paragraph_memory: bool,
pub per_paragraph_memory_max_turns: usize,
pub reseed_prompt_examples: bool,
pub diff_review_on_apply: bool,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
per_paragraph_memory: false,
per_paragraph_memory_max_turns: 10,
reseed_prompt_examples: true,
diff_review_on_apply: true,
}
}
}
#[cfg(test)]
mod settings_synth_tests {
use super::*;
#[test]
fn synthesised_header_with_defaults_compiles_typst_shape() {
let cfg = Config::default();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("auto-generated"));
assert!(s.contains("User overrides"));
assert!(s.contains("#set page("));
assert!(s.contains("paper: \"us-letter\""));
assert!(s.contains("margin: (top: 2.5cm"));
assert!(s.contains("#set text("));
assert!(s.contains("lang: \"en\""));
assert!(s.contains("#set par(justify: true"));
assert!(!s.contains("#set heading(numbering"));
}
#[test]
fn synthesised_header_emits_numbering_when_set() {
let mut cfg = Config::default();
cfg.typst_layout.heading_numbering = "1.1".into();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("#set heading(numbering: \"1.1\")"));
}
#[test]
fn synthesised_header_omits_text_set_when_all_empty() {
let mut cfg = Config::default();
cfg.typst_fonts.body = String::new();
cfg.typst_fonts.body_size = String::new();
cfg.typst_fonts.language = String::new();
let s = cfg.synthesised_settings_typ_header();
assert!(!s.contains("#set text("));
assert!(s.contains("#show raw: set text(font:"));
}
#[test]
fn synthesised_header_escapes_double_quotes_in_values() {
let mut cfg = Config::default();
cfg.typst_fonts.body = "Bad\"Font".into();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("\"Bad\\\"Font\""), "got:\n{s}");
}
#[test]
fn synthesised_header_uses_font_fallback_array_for_custom_body() {
let mut cfg = Config::default();
cfg.typst_fonts.body = "EB Garamond".into();
let s = cfg.synthesised_settings_typ_header();
assert!(
s.contains("font: (\"EB Garamond\", \"Linux Libertine\")"),
"got:\n{s}"
);
}
#[test]
fn synthesised_header_uses_font_fallback_array_for_custom_mono() {
let mut cfg = Config::default();
cfg.typst_fonts.monospace = "JetBrains Mono".into();
let s = cfg.synthesised_settings_typ_header();
assert!(
s.contains(
"#show raw: set text(font: (\"JetBrains Mono\", \"DejaVu Sans Mono\"))"
),
"got:\n{s}"
);
}
#[test]
fn synthesised_header_never_emits_invalid_set_raw_font() {
let cfg = Config::default();
let s = cfg.synthesised_settings_typ_header();
assert!(!s.contains("#set raw(font:"), "got:\n{s}");
}
#[test]
fn synthesised_header_dedupes_when_body_matches_bundled() {
let cfg = Config::default();
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("font: \"Linux Libertine\""), "got:\n{s}");
assert!(
!s.contains("(\"Linux Libertine\", \"Linux Libertine\")"),
"got:\n{s}"
);
}
#[test]
fn synthesised_header_multi_column_emits_columns_arg() {
let mut cfg = Config::default();
cfg.typst_page.columns = 2;
let s = cfg.synthesised_settings_typ_header();
assert!(s.contains("columns: 2"));
}
}