use crate::{
code::{execute::SnippetExecutor, highlighting::HighlightThemeSet},
commands::listener::CommandListener,
config::{Config, ImageProtocol, ValidateOverflows},
demo::ThemesDemo,
export::exporter::Exporter,
markdown::parse::MarkdownParser,
presentation::builder::{CommentCommand, PresentationBuilderOptions, Themes},
presenter::{PresentMode, Presenter, PresenterOptions},
resource::Resources,
terminal::{
GraphicsMode,
image::printer::{ImagePrinter, ImageRegistry},
},
theme::{raw::PresentationTheme, registry::PresentationThemeRegistry},
third_party::{ThirdPartyConfigs, ThirdPartyRender},
};
use anyhow::anyhow;
use clap::{CommandFactory, Parser, error::ErrorKind};
use commands::speaker_notes::{SpeakerNotesEventListener, SpeakerNotesEventPublisher};
use comrak::Arena;
use config::ConfigLoadError;
use crossterm::{
execute,
style::{PrintStyledContent, Stylize},
};
use directories::ProjectDirs;
use export::exporter::OutputDirectory;
use render::{engine::MaxSize, properties::WindowSize};
use std::{
env::{self, current_dir},
io,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use terminal::emulator::TerminalEmulator;
use theme::ThemeOptions;
mod code;
mod commands;
mod config;
mod demo;
mod export;
mod markdown;
mod presentation;
mod presenter;
mod render;
mod resource;
mod terminal;
mod theme;
mod third_party;
mod tools;
mod transitions;
mod ui;
mod utils;
const DEFAULT_THEME: &str = "dark";
const DEFAULT_THEME_DYNAMIC_DETECTION_TIMEOUT: u64 = 100;
const DEFAULT_EXPORT_PIXELS_PER_COLUMN: u16 = 20;
const DEFAULT_EXPORT_PIXELS_PER_ROW: u16 = DEFAULT_EXPORT_PIXELS_PER_COLUMN * 2;
#[derive(Parser)]
#[command()]
#[command(author, version, about = create_splash(), arg_required_else_help = true)]
struct Cli {
#[clap(group = "target")]
path: Option<PathBuf>,
#[clap(short, long, group = "export")]
export_pdf: bool,
#[clap(short = 'E', long, group = "export")]
export_html: bool,
#[clap(long, requires = "export")]
export_temporary_path: Option<PathBuf>,
#[clap(short = 'o', long = "output", requires = "export")]
export_output: Option<PathBuf>,
#[clap(long)]
#[cfg(feature = "json-schema")]
generate_config_file_schema: bool,
#[clap(short, long, default_value_t = false)]
present: bool,
#[clap(short, long)]
theme: Option<String>,
#[clap(long, group = "target")]
list_themes: bool,
#[clap(long, group = "target")]
current_theme: bool,
#[clap(long, group = "target")]
acknowledgements: bool,
#[clap(long)]
image_protocol: Option<ImageProtocol>,
#[clap(long)]
validate_overflows: bool,
#[clap(short = 'x', long)]
enable_snippet_execution: bool,
#[clap(short = 'X', long)]
enable_snippet_execution_replace: bool,
#[clap(short, long, env = "PRESENTERM_CONFIG_FILE")]
config_file: Option<String>,
#[clap(short = 'P', long, group = "speaker-notes")]
publish_speaker_notes: bool,
#[clap(short, long, group = "speaker-notes")]
listen_speaker_notes: bool,
#[clap(long)]
validate_snippets: bool,
#[clap(long, group = "target")]
list_comment_commands: bool,
}
fn create_splash() -> String {
let crate_version = env!("CARGO_PKG_VERSION");
format!(
r#"
┌─┐┬─┐┌─┐┌─┐┌─┐┌┐┌┌┬┐┌─┐┬─┐┌┬┐
├─┘├┬┘├┤ └─┐├┤ │││ │ ├┤ ├┬┘│││
┴ ┴└─└─┘└─┘└─┘┘└┘ ┴ └─┘┴└─┴ ┴ v{crate_version}
A terminal slideshow tool
@mfontanini/presenterm
"#,
)
}
#[derive(Default)]
struct Customizations {
config: Config,
themes: Themes,
themes_path: Option<PathBuf>,
code_executor: SnippetExecutor,
}
impl Customizations {
fn load(config_file_path: Option<PathBuf>, cwd: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let configs_path: PathBuf = match env::var("XDG_CONFIG_HOME") {
Ok(path) => Path::new(&path).join("presenterm"),
Err(_) => {
let Some(project_dirs) = ProjectDirs::from("", "", "presenterm") else {
return Ok(Default::default());
};
project_dirs.config_dir().into()
}
};
let themes_path = configs_path.join("themes");
let themes = Self::load_themes(&themes_path)?;
let require_config_file = config_file_path.is_some();
let config_file_path = config_file_path.unwrap_or_else(|| configs_path.join("config.yaml"));
let config = match Config::load(&config_file_path) {
Ok(config) => config,
Err(ConfigLoadError::NotFound) if !require_config_file => Default::default(),
Err(e) => return Err(e.into()),
};
let code_executor = SnippetExecutor::new(config.snippet.exec.custom.clone(), cwd.to_path_buf())?;
Ok(Customizations { config, themes, themes_path: Some(themes_path), code_executor })
}
fn load_themes(themes_path: &Path) -> Result<Themes, Box<dyn std::error::Error>> {
let mut highlight_themes = HighlightThemeSet::default();
highlight_themes.register_from_directory(themes_path.join("highlighting"))?;
let mut presentation_themes = PresentationThemeRegistry::default();
presentation_themes.register_from_directory(themes_path)?;
let themes = Themes { presentation: presentation_themes, highlight: highlight_themes };
Ok(themes)
}
}
struct CoreComponents {
third_party: ThirdPartyRender,
code_executor: Arc<SnippetExecutor>,
resources: Resources,
printer: Arc<ImagePrinter>,
builder_options: PresentationBuilderOptions,
themes: Themes,
default_theme: PresentationTheme,
config: Config,
present_mode: PresentMode,
graphics_mode: GraphicsMode,
}
impl CoreComponents {
fn new(cli: &Cli, path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let mut resources_path = path.parent().unwrap_or(Path::new("./")).to_path_buf();
if resources_path == Path::new("") {
resources_path = "./".into();
}
let resources_path = resources_path.canonicalize().unwrap_or(resources_path);
let Customizations { config, themes, code_executor, themes_path } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &resources_path)?;
let default_theme = Self::load_default_theme(&config, &themes, cli);
let force_default_theme = cli.theme.is_some();
let present_mode = match (cli.present, cli.export_pdf) {
(true, _) | (_, true) => PresentMode::Presentation,
(false, false) => PresentMode::Development,
};
let mut builder_options = Self::make_builder_options(&config, force_default_theme, cli.listen_speaker_notes);
if cli.enable_snippet_execution {
builder_options.enable_snippet_execution = true;
}
if cli.enable_snippet_execution_replace {
builder_options.enable_snippet_execution_replace = true;
}
let graphics_mode = Self::select_graphics_mode(cli, &config);
let printer = Arc::new(ImagePrinter::new(graphics_mode.clone())?);
let registry = ImageRegistry::new(printer.clone());
let resources = Resources::new(
resources_path.clone(),
themes_path.unwrap_or_else(|| resources_path.clone()),
registry.clone(),
);
let third_party_config = ThirdPartyConfigs {
typst_ppi: config.typst.ppi.to_string(),
mermaid_scale: config.mermaid.scale.to_string(),
mermaid_puppeteer_file: config.mermaid.puppeteer_config_path.clone(),
mermaid_config_file: config.mermaid.config_path.clone(),
d2_scale: config.d2.scale.map(|s| s.to_string()).unwrap_or_else(|| "-1".to_string()),
threads: config.snippet.render.threads,
};
let third_party = ThirdPartyRender::new(third_party_config, registry, &resources_path);
let code_executor = Arc::new(code_executor);
Ok(Self {
third_party,
code_executor,
resources,
printer,
builder_options,
themes,
default_theme,
config,
present_mode,
graphics_mode,
})
}
fn make_builder_options(
config: &Config,
force_default_theme: bool,
render_speaker_notes_only: bool,
) -> PresentationBuilderOptions {
let options = &config.options;
PresentationBuilderOptions {
allow_mutations: true,
implicit_slide_ends: options.implicit_slide_ends.unwrap_or_default(),
command_prefix: options.command_prefix.clone().unwrap_or_default(),
image_attribute_prefix: options.image_attributes_prefix.clone().unwrap_or_else(|| "image:".to_string()),
incremental_lists: options.incremental_lists.unwrap_or_default(),
force_default_theme,
end_slide_shorthand: options.end_slide_shorthand.unwrap_or_default(),
print_modal_background: false,
strict_front_matter_parsing: options.strict_front_matter_parsing.unwrap_or(true),
enable_snippet_execution: config.snippet.exec.enable,
enable_snippet_execution_replace: config.snippet.exec_replace.enable,
render_speaker_notes_only,
auto_render_languages: options.auto_render_languages.clone(),
theme_options: ThemeOptions { font_size_supported: TerminalEmulator::capabilities().font_size },
pause_before_incremental_lists: config.defaults.incremental_lists.pause_before.unwrap_or(true),
pause_after_incremental_lists: config.defaults.incremental_lists.pause_after.unwrap_or(true),
pause_create_new_slide: false,
list_item_newlines: options.list_item_newlines.map(Into::into).unwrap_or(1),
validate_snippets: config.snippet.validate,
layout_grid: false,
h1_slide_titles: options.h1_slide_titles.unwrap_or_default(),
}
}
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
if cli.export_pdf | cli.export_html {
GraphicsMode::Raw
} else {
cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol).into()
}
}
fn theme_name(config: &Config, cli: &Cli) -> String {
if let Some(name) = cli.theme.as_ref() {
name.clone()
} else {
match &config.defaults.theme {
config::ThemeConfig::None => DEFAULT_THEME.into(),
config::ThemeConfig::Some(theme_name) => theme_name.clone(),
config::ThemeConfig::Dynamic { dark, light, timeout } => {
let default_timeout = timeout.unwrap_or(DEFAULT_THEME_DYNAMIC_DETECTION_TIMEOUT);
let timeout_duration = Duration::from_millis(default_timeout);
if let Ok(theme) = termbg::theme(timeout_duration) {
if theme == termbg::Theme::Dark { dark.clone() } else { light.clone() }
} else {
Cli::command()
.error(
ErrorKind::Io,
"terminal theme detection failed, unsupported terminal or timeout exceeded",
)
.exit();
}
}
}
}
}
fn load_default_theme(config: &Config, themes: &Themes, cli: &Cli) -> PresentationTheme {
let default_theme_name = Self::theme_name(config, cli);
let Some(default_theme) = themes.presentation.load_by_name(default_theme_name.as_str()) else {
let valid_themes = themes.presentation.theme_names().join(", ");
let error_message = format!("invalid theme name, valid themes are: {valid_themes}");
Cli::command().error(ErrorKind::InvalidValue, error_message).exit();
};
default_theme
}
}
struct SpeakerNotesComponents {
events_listener: Option<SpeakerNotesEventListener>,
events_publisher: Option<SpeakerNotesEventPublisher>,
}
impl SpeakerNotesComponents {
fn new(cli: &Cli, config: &Config, path: &Path) -> anyhow::Result<Self> {
let full_presentation_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let publish_speaker_notes =
cli.publish_speaker_notes || (config.speaker_notes.always_publish && !cli.listen_speaker_notes);
let events_publisher = publish_speaker_notes
.then(|| {
SpeakerNotesEventPublisher::new(config.speaker_notes.publish_address, full_presentation_path.clone())
})
.transpose()
.map_err(|e| anyhow!("failed to create speaker notes publisher: {e}"))?;
let events_listener = cli
.listen_speaker_notes
.then(|| SpeakerNotesEventListener::new(config.speaker_notes.listen_address, full_presentation_path))
.transpose()
.map_err(|e| anyhow!("failed to create speaker notes listener: {e}"))?;
Ok(Self { events_listener, events_publisher })
}
}
fn overflow_validation_enabled(mode: &PresentMode, config: &ValidateOverflows) -> bool {
match (config, mode) {
(ValidateOverflows::Always, _) => true,
(ValidateOverflows::Never, _) => false,
(ValidateOverflows::WhenPresenting, PresentMode::Presentation) => true,
(ValidateOverflows::WhenDeveloping, PresentMode::Development) => true,
_ => false,
}
}
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "json-schema")]
if cli.generate_config_file_schema {
let schema = schemars::schema_for!(Config);
serde_json::to_writer_pretty(io::stdout(), &schema).map_err(|e| format!("failed to write schema: {e}"))?;
return Ok(());
}
if cli.acknowledgements {
let acknowledgements = include_bytes!("../bat/acknowledgements.txt");
println!("{}", String::from_utf8_lossy(acknowledgements));
return Ok(());
} else if cli.list_themes {
TerminalEmulator::capabilities();
let Customizations { config, themes, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?;
let bindings = config.bindings.try_into()?;
let demo = ThemesDemo::new(themes, bindings)?;
demo.run()?;
return Ok(());
} else if cli.current_theme {
let Customizations { config, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), ¤t_dir()?)?;
let theme_name = CoreComponents::theme_name(&config, &cli);
println!("{theme_name}");
return Ok(());
} else if cli.list_comment_commands {
let samples = CommentCommand::generate_samples();
for sample in samples {
println!("{}", sample);
}
return Ok(());
}
if cli.export_pdf {
TerminalEmulator::disable_capability_detection();
}
let Some(path) = cli.path.clone() else {
Cli::command().error(ErrorKind::MissingRequiredArgument, "no path specified").exit();
};
let CoreComponents {
third_party,
code_executor,
resources,
printer,
mut builder_options,
themes,
default_theme,
config,
present_mode,
graphics_mode,
} = CoreComponents::new(&cli, &path)?;
let arena = Arena::new();
let parser = MarkdownParser::new(&arena);
let validate_overflows =
overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows;
if cli.validate_snippets {
builder_options.validate_snippets = cli.validate_snippets;
}
if cli.export_pdf || cli.export_html {
let dimensions = match config.export.dimensions {
Some(dimensions) => WindowSize {
rows: dimensions.rows,
columns: dimensions.columns,
height: dimensions.rows * DEFAULT_EXPORT_PIXELS_PER_ROW,
width: dimensions.columns * DEFAULT_EXPORT_PIXELS_PER_COLUMN,
},
None => WindowSize::current(config.defaults.terminal_font_size)?,
};
let exporter = Exporter::new(
parser,
&default_theme,
resources,
third_party,
code_executor,
printer,
themes,
builder_options,
dimensions,
config.export.pauses,
config.export.snippets,
);
let output_directory = match cli.export_temporary_path {
Some(path) => OutputDirectory::external(path),
None => OutputDirectory::temporary(),
}?;
if cli.export_pdf {
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref(), config.export.pdf)?;
} else {
exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;
}
} else {
let SpeakerNotesComponents { events_listener, events_publisher } =
SpeakerNotesComponents::new(&cli, &config, &path)?;
let command_listener = CommandListener::new(config.bindings.clone(), events_listener)?;
builder_options.print_modal_background = matches!(graphics_mode, GraphicsMode::Kitty { .. });
let options = PresenterOptions {
builder_options,
mode: present_mode,
font_size_fallback: config.defaults.terminal_font_size,
bindings: config.bindings,
validate_overflows,
max_size: MaxSize {
max_columns: config.defaults.max_columns,
max_columns_alignment: config.defaults.max_columns_alignment,
max_rows: config.defaults.max_rows,
max_rows_alignment: config.defaults.max_rows_alignment,
},
transition: config.transition,
};
let presenter = Presenter::new(
&default_theme,
command_listener,
parser,
resources,
third_party,
code_executor,
themes,
printer,
options,
events_publisher,
);
presenter.present(&path)?;
}
Ok(())
}
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
let _ =
execute!(io::stdout(), PrintStyledContent(format!("{e}\n").stylize().with(crossterm::style::Color::Red)));
std::process::exit(1);
}
}