use crate::{
code::snippet::SnippetLanguage,
commands::keyboard::KeyBinding,
terminal::{GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode},
};
use clap::ValueEnum;
use serde::Deserialize;
use std::{
collections::{BTreeMap, HashMap},
fs, io,
net::{IpAddr, Ipv4Addr, SocketAddr},
num::NonZeroU8,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub defaults: DefaultsConfig,
#[serde(default)]
pub typst: TypstConfig,
#[serde(default)]
pub mermaid: MermaidConfig,
#[serde(default)]
pub d2: D2Config,
#[serde(default)]
pub options: OptionsConfig,
#[serde(default)]
pub bindings: KeyBindingsConfig,
#[serde(default)]
pub snippet: SnippetConfig,
#[serde(default)]
pub speaker_notes: SpeakerNotesConfig,
#[serde(default)]
pub export: ExportConfig,
#[serde(default)]
pub transition: Option<SlideTransitionConfig>,
}
impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigLoadError> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigLoadError::NotFound),
Err(e) => return Err(e.into()),
};
let config = serde_yaml::from_str(&contents)?;
Ok(config)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigLoadError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("config file not found")]
NotFound,
#[error("invalid configuration: {0}")]
Invalid(#[from] serde_yaml::Error),
}
#[derive(Clone, Debug, Deserialize, Default)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
#[cfg_attr(feature = "json-schema", schemars(with = "ThemeConfigSchema"))]
pub enum ThemeConfig {
#[default]
None,
Some(String),
Dynamic {
dark: String,
light: String,
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
timeout: Option<u64>,
},
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct DefaultsConfig {
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default = "default_terminal_font_size")]
#[cfg_attr(feature = "json-schema", validate(range(min = 1)))]
pub terminal_font_size: u8,
#[serde(default)]
pub image_protocol: ImageProtocol,
#[serde(default)]
pub validate_overflows: ValidateOverflows,
#[serde(default = "default_u16_max")]
pub max_columns: u16,
#[serde(default)]
pub max_columns_alignment: MaxColumnsAlignment,
#[serde(default = "default_u16_max")]
pub max_rows: u16,
#[serde(default)]
pub max_rows_alignment: MaxRowsAlignment,
#[serde(default)]
pub incremental_lists: IncrementalListsConfig,
}
impl Default for DefaultsConfig {
fn default() -> Self {
Self {
theme: Default::default(),
terminal_font_size: default_terminal_font_size(),
image_protocol: Default::default(),
validate_overflows: Default::default(),
max_columns: default_u16_max(),
max_columns_alignment: Default::default(),
max_rows: default_u16_max(),
max_rows_alignment: Default::default(),
incremental_lists: Default::default(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct IncrementalListsConfig {
#[serde(default)]
pub pause_before: Option<bool>,
#[serde(default)]
pub pause_after: Option<bool>,
}
fn default_terminal_font_size() -> u8 {
16
}
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MaxColumnsAlignment {
Left,
#[default]
Center,
Right,
}
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MaxRowsAlignment {
Top,
#[default]
Center,
Bottom,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ValidateOverflows {
#[default]
Never,
Always,
WhenPresenting,
WhenDeveloping,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct OptionsConfig {
pub implicit_slide_ends: Option<bool>,
pub command_prefix: Option<String>,
pub image_attributes_prefix: Option<String>,
pub incremental_lists: Option<bool>,
pub list_item_newlines: Option<NonZeroU8>,
pub end_slide_shorthand: Option<bool>,
pub strict_front_matter_parsing: Option<bool>,
#[serde(default)]
pub auto_render_languages: Vec<SnippetLanguage>,
pub h1_slide_titles: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetConfig {
#[serde(default)]
pub exec: SnippetExecConfig,
#[serde(default)]
pub exec_replace: SnippetExecReplaceConfig,
#[serde(default)]
pub render: SnippetRenderConfig,
#[serde(default)]
pub validate: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetExecConfig {
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub custom: BTreeMap<SnippetLanguage, LanguageSnippetExecutionConfig>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetExecReplaceConfig {
pub enable: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SnippetRenderConfig {
#[serde(default = "default_snippet_render_threads")]
pub threads: usize,
}
impl Default for SnippetRenderConfig {
fn default() -> Self {
Self { threads: default_snippet_render_threads() }
}
}
pub(crate) fn default_snippet_render_threads() -> usize {
2
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TypstConfig {
#[serde(default = "default_typst_ppi")]
pub ppi: u32,
}
impl Default for TypstConfig {
fn default() -> Self {
Self { ppi: default_typst_ppi() }
}
}
pub(crate) fn default_typst_ppi() -> u32 {
300
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct MermaidConfig {
#[serde(default = "default_mermaid_scale")]
pub scale: u32,
pub puppeteer_config_path: Option<String>,
pub config_path: Option<String>,
}
impl Default for MermaidConfig {
fn default() -> Self {
Self { scale: default_mermaid_scale(), puppeteer_config_path: None, config_path: None }
}
}
pub(crate) fn default_mermaid_scale() -> u32 {
2
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct D2Config {
#[serde(default)]
pub scale: Option<f32>,
}
pub(crate) fn default_u16_max() -> u16 {
u16::MAX
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct LanguageSnippetExecutionConfig {
#[serde(flatten)]
pub executor: SnippetExecutorConfig,
pub hidden_line_prefix: Option<String>,
#[serde(default)]
pub alternative: HashMap<String, SnippetExecutorConfig>,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SnippetExecutorConfig {
pub filename: String,
#[serde(default)]
pub environment: HashMap<String, String>,
pub commands: Vec<Vec<String>>,
}
#[derive(Clone, Debug, Default, Deserialize, ValueEnum)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ImageProtocol {
#[default]
Auto,
Iterm2,
Iterm2Multipart,
KittyLocal,
KittyRemote,
Sixel,
AsciiBlocks,
}
impl From<&ImageProtocol> for GraphicsMode {
fn from(protocol: &ImageProtocol) -> Self {
match protocol {
ImageProtocol::Auto => {
let emulator = TerminalEmulator::detect();
emulator.preferred_protocol()
}
ImageProtocol::Iterm2 => GraphicsMode::Iterm2,
ImageProtocol::Iterm2Multipart => GraphicsMode::Iterm2Multipart,
ImageProtocol::KittyLocal => GraphicsMode::Kitty { mode: KittyMode::Local },
ImageProtocol::KittyRemote => GraphicsMode::Kitty { mode: KittyMode::Remote },
ImageProtocol::AsciiBlocks => GraphicsMode::AsciiBlocks,
ImageProtocol::Sixel => GraphicsMode::Sixel,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct KeyBindingsConfig {
#[serde(default = "default_next_bindings")]
pub(crate) next: Vec<KeyBinding>,
#[serde(default = "default_next_fast_bindings")]
pub(crate) next_fast: Vec<KeyBinding>,
#[serde(default = "default_previous_bindings")]
pub(crate) previous: Vec<KeyBinding>,
#[serde(default = "default_previous_fast_bindings")]
pub(crate) previous_fast: Vec<KeyBinding>,
#[serde(default = "default_first_slide_bindings")]
pub(crate) first_slide: Vec<KeyBinding>,
#[serde(default = "default_last_slide_bindings")]
pub(crate) last_slide: Vec<KeyBinding>,
#[serde(default = "default_go_to_slide_bindings")]
pub(crate) go_to_slide: Vec<KeyBinding>,
#[serde(default = "default_execute_code_bindings")]
pub(crate) execute_code: Vec<KeyBinding>,
#[serde(default = "default_reload_bindings")]
pub(crate) reload: Vec<KeyBinding>,
#[serde(default = "default_toggle_index_bindings")]
pub(crate) toggle_slide_index: Vec<KeyBinding>,
#[serde(default = "default_toggle_bindings_modal_bindings")]
pub(crate) toggle_bindings: Vec<KeyBinding>,
#[serde(default = "default_toggle_layout_grid")]
pub(crate) toggle_layout_grid: Vec<KeyBinding>,
#[serde(default = "default_close_modal_bindings")]
pub(crate) close_modal: Vec<KeyBinding>,
#[serde(default = "default_exit_bindings")]
pub(crate) exit: Vec<KeyBinding>,
#[serde(default = "default_suspend_bindings")]
pub(crate) suspend: Vec<KeyBinding>,
#[serde(default = "default_skip_pauses")]
pub(crate) skip_pauses: Vec<KeyBinding>,
}
impl Default for KeyBindingsConfig {
fn default() -> Self {
Self {
next: default_next_bindings(),
next_fast: default_next_fast_bindings(),
previous: default_previous_bindings(),
previous_fast: default_previous_fast_bindings(),
first_slide: default_first_slide_bindings(),
last_slide: default_last_slide_bindings(),
go_to_slide: default_go_to_slide_bindings(),
execute_code: default_execute_code_bindings(),
reload: default_reload_bindings(),
toggle_slide_index: default_toggle_index_bindings(),
toggle_bindings: default_toggle_bindings_modal_bindings(),
toggle_layout_grid: default_toggle_layout_grid(),
close_modal: default_close_modal_bindings(),
exit: default_exit_bindings(),
suspend: default_suspend_bindings(),
skip_pauses: default_skip_pauses(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SpeakerNotesConfig {
#[serde(default = "default_speaker_notes_listen_address")]
pub listen_address: SocketAddr,
#[serde(default = "default_speaker_notes_publish_address")]
pub publish_address: SocketAddr,
#[serde(default)]
pub always_publish: bool,
}
impl Default for SpeakerNotesConfig {
fn default() -> Self {
Self {
listen_address: default_speaker_notes_listen_address(),
publish_address: default_speaker_notes_publish_address(),
always_publish: false,
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ExportConfig {
pub dimensions: Option<ExportDimensionsConfig>,
#[serde(default)]
pub pauses: PauseExportPolicy,
#[serde(default)]
pub snippets: SnippetsExportPolicy,
#[serde(default)]
pub pdf: PdfExportConfig,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum PauseExportPolicy {
#[default]
Ignore,
NewSlide,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum SnippetsExportPolicy {
#[default]
Parallel,
Sequential,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ExportDimensionsConfig {
pub rows: u16,
pub columns: u16,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct PdfExportConfig {
pub fonts: Option<ExportFontsConfig>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ExportFontsConfig {
pub normal: PathBuf,
pub bold: Option<PathBuf>,
pub italic: Option<PathBuf>,
pub bold_italic: Option<PathBuf>,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "style", deny_unknown_fields)]
pub struct SlideTransitionConfig {
#[serde(default = "default_transition_duration_millis")]
pub duration_millis: u16,
#[serde(default = "default_transition_frames")]
pub frames: usize,
pub animation: SlideTransitionStyleConfig,
}
#[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "style", rename_all = "snake_case", deny_unknown_fields)]
pub enum SlideTransitionStyleConfig {
SlideHorizontal,
Fade,
CollapseHorizontal,
}
fn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {
let mut bindings = Vec::new();
for binding in raw_bindings {
bindings.push(binding.parse().expect("invalid binding"));
}
bindings
}
fn default_next_bindings() -> Vec<KeyBinding> {
make_keybindings(["l", "j", "<right>", "<page_down>", "<down>", " "])
}
fn default_next_fast_bindings() -> Vec<KeyBinding> {
make_keybindings(["n"])
}
fn default_previous_bindings() -> Vec<KeyBinding> {
make_keybindings(["h", "k", "<left>", "<page_up>", "<up>"])
}
fn default_previous_fast_bindings() -> Vec<KeyBinding> {
make_keybindings(["p"])
}
fn default_first_slide_bindings() -> Vec<KeyBinding> {
make_keybindings(["gg"])
}
fn default_last_slide_bindings() -> Vec<KeyBinding> {
make_keybindings(["G"])
}
fn default_go_to_slide_bindings() -> Vec<KeyBinding> {
make_keybindings(["<number>G"])
}
fn default_execute_code_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-e>"])
}
fn default_reload_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-r>"])
}
fn default_toggle_index_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-p>"])
}
fn default_toggle_bindings_modal_bindings() -> Vec<KeyBinding> {
make_keybindings(["?"])
}
fn default_toggle_layout_grid() -> Vec<KeyBinding> {
make_keybindings(["T"])
}
fn default_close_modal_bindings() -> Vec<KeyBinding> {
make_keybindings(["<esc>"])
}
fn default_exit_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-c>", "q"])
}
fn default_suspend_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-z>"])
}
fn default_skip_pauses() -> Vec<KeyBinding> {
make_keybindings(["s"])
}
fn default_transition_duration_millis() -> u16 {
1000
}
fn default_transition_frames() -> usize {
30
}
#[cfg(target_os = "linux")]
pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn default_speaker_notes_listen_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
}
#[cfg(target_os = "macos")]
pub(crate) fn default_speaker_notes_publish_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 59418)
}
#[cfg(test)]
mod test {
use super::*;
use crate::commands::keyboard::CommandKeyBindings;
#[test]
fn default_bindings() {
let config = KeyBindingsConfig::default();
CommandKeyBindings::try_from(config).expect("construction failed");
}
#[test]
fn default_options_serde() {
serde_yaml::from_str::<'_, OptionsConfig>("implicit_slide_ends: true").expect("failed to parse");
}
}