use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::state::TimerMode;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub display: DisplayConfig,
pub timer: TimerConfig,
pub laser: LaserConfig,
pub spotlight: SpotlightConfig,
pub ink: InkConfig,
pub text_boxes: TextBoxConfig,
pub notes: NotesConfig,
pub keybindings: HashMap<String, Vec<String>>,
pub clicker: ClickerConfig,
pub remote: RemoteConfig,
pub export: ExportConfig,
pub sidecar_format: String,
pub save_slide_timings: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialConfig {
display: Option<PartialDisplayConfig>,
timer: Option<PartialTimerConfig>,
laser: Option<PartialLaserConfig>,
spotlight: Option<PartialSpotlightConfig>,
ink: Option<PartialInkConfig>,
text_boxes: Option<PartialTextBoxConfig>,
notes: Option<PartialNotesConfig>,
keybindings: Option<HashMap<String, Vec<String>>>,
clicker: Option<PartialClickerConfig>,
remote: Option<PartialRemoteConfig>,
export: Option<PartialExportConfig>,
sidecar_format: Option<String>,
save_slide_timings: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DisplayConfig {
pub mode: String,
pub single_monitor_view: String,
pub audience_monitor: String,
pub presenter_monitor: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialDisplayConfig {
mode: Option<String>,
single_monitor_view: Option<String>,
audience_monitor: Option<String>,
presenter_monitor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TimerConfig {
pub mode: TimerMode,
pub duration_minutes: Option<u32>,
pub warning_minutes: Option<u32>,
pub overrun_color: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialTimerConfig {
mode: Option<TimerMode>,
duration_minutes: Option<OptionalU32Value>,
warning_minutes: Option<OptionalU32Value>,
overrun_color: Option<bool>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(untagged)]
enum OptionalU32Value {
Value(u32),
Null(()),
}
impl OptionalU32Value {
fn into_option(self) -> Option<u32> {
match self {
Self::Value(value) => Some(value),
Self::Null(()) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LaserConfig {
pub color: String,
pub size: f32,
pub style: String,
pub dot: PointerStyleConfig,
pub minimal: PointerStyleConfig,
pub crosshair: PointerStyleConfig,
pub arrow: PointerStyleConfig,
pub ring: PointerStyleConfig,
pub bullseye: PointerStyleConfig,
pub highlight: PointerStyleConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PointerStyleConfig {
pub color: String,
pub size: f32,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialLaserConfig {
color: Option<String>,
size: Option<f32>,
style: Option<String>,
dot: Option<PartialPointerStyleConfig>,
minimal: Option<PartialPointerStyleConfig>,
crosshair: Option<PartialPointerStyleConfig>,
arrow: Option<PartialPointerStyleConfig>,
ring: Option<PartialPointerStyleConfig>,
bullseye: Option<PartialPointerStyleConfig>,
highlight: Option<PartialPointerStyleConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialPointerStyleConfig {
color: Option<String>,
size: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SpotlightConfig {
pub radius: f32,
pub dim_opacity: f32,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialSpotlightConfig {
radius: Option<f32>,
dim_opacity: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct InkConfig {
pub colors: Vec<String>,
pub width: f32,
pub highlighter_colors: Vec<String>,
pub highlighter_width: f32,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialInkConfig {
colors: Option<Vec<String>>,
color: Option<String>,
width: Option<f32>,
highlighter_colors: Option<Vec<String>>,
highlighter_width: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TextBoxConfig {
pub color: String,
pub background: String,
pub typst_prelude: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialTextBoxConfig {
color: Option<String>,
background: Option<String>,
typst_prelude: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ClickerConfig {
pub profile: String,
pub profiles: HashMap<String, HashMap<String, String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialClickerConfig {
profile: Option<String>,
profiles: Option<HashMap<String, HashMap<String, String>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RemoteConfig {
pub enabled: bool,
pub host: String,
pub port: u16,
pub token: String,
pub allow_unauthenticated_loopback: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialRemoteConfig {
enabled: Option<bool>,
host: Option<String>,
port: Option<u16>,
token: Option<String>,
allow_unauthenticated_loopback: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NotesConfig {
pub font_size: f32,
pub font_size_step: f32,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialNotesConfig {
font_size: Option<f32>,
font_size_step: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ExportConfig {
pub format: String,
pub layers: String,
pub handout: bool,
pub whiteboard: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct PartialExportConfig {
format: Option<String>,
layers: Option<String>,
handout: Option<bool>,
whiteboard: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
display: DisplayConfig::default(),
timer: TimerConfig::default(),
laser: LaserConfig::default(),
spotlight: SpotlightConfig::default(),
ink: InkConfig::default(),
text_boxes: TextBoxConfig::default(),
notes: NotesConfig::default(),
keybindings: HashMap::new(),
clicker: ClickerConfig::default(),
remote: RemoteConfig::default(),
export: ExportConfig::default(),
sidecar_format: "dais".to_string(),
save_slide_timings: true,
}
}
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
mode: "dual".to_string(),
single_monitor_view: "hud".to_string(),
audience_monitor: "auto".to_string(),
presenter_monitor: "auto".to_string(),
}
}
}
impl Default for TimerConfig {
fn default() -> Self {
Self {
mode: TimerMode::Elapsed,
duration_minutes: None,
warning_minutes: None,
overrun_color: true,
}
}
}
impl Default for LaserConfig {
fn default() -> Self {
let pointer = PointerStyleConfig::default();
Self {
color: pointer.color.clone(),
size: pointer.size,
style: "dot".to_string(),
dot: pointer.clone(),
minimal: pointer.clone(),
crosshair: pointer.clone(),
arrow: pointer.clone(),
ring: pointer.clone(),
bullseye: pointer.clone(),
highlight: pointer,
}
}
}
impl Default for PointerStyleConfig {
fn default() -> Self {
Self { color: "#FF0000".to_string(), size: 12.0 }
}
}
impl Default for SpotlightConfig {
fn default() -> Self {
Self { radius: 80.0, dim_opacity: 0.6 }
}
}
impl Default for InkConfig {
fn default() -> Self {
Self {
colors: vec!["#FF0000".to_string()],
width: 3.0,
highlighter_colors: Vec::new(),
highlighter_width: 10.0,
}
}
}
impl Default for TextBoxConfig {
fn default() -> Self {
Self {
color: "#000000".to_string(),
background: "transparent".to_string(),
typst_prelude: String::new(),
}
}
}
impl Default for ClickerConfig {
fn default() -> Self {
Self { profile: "default".to_string(), profiles: HashMap::new() }
}
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
enabled: false,
host: "127.0.0.1".to_string(),
port: 4317,
token: String::new(),
allow_unauthenticated_loopback: true,
}
}
}
pub fn default_clicker_profile() -> HashMap<String, String> {
HashMap::from([
("PageDown".to_string(), "next_slide".to_string()),
("PageUp".to_string(), "previous_slide".to_string()),
("F5".to_string(), "toggle_presentation_mode".to_string()),
("b".to_string(), "toggle_blackout".to_string()),
(".".to_string(), "toggle_blackout".to_string()),
])
}
impl Config {
pub fn active_clicker_profile(&self) -> HashMap<String, String> {
if self.clicker.profile == "default" {
return default_clicker_profile();
}
self.clicker.profiles.get(&self.clicker.profile).cloned().unwrap_or_else(|| {
tracing::warn!(
"Configured clicker profile '{}' not found; using default profile",
self.clicker.profile
);
default_clicker_profile()
})
}
pub fn normalized_sidecar_format(&self) -> &str {
if self.sidecar_format.eq_ignore_ascii_case("dais") { "dais" } else { "pdfpc" }
}
}
impl Default for NotesConfig {
fn default() -> Self {
Self { font_size: 16.0, font_size_step: 2.0 }
}
}
pub fn config_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "dais").map(|dirs| dirs.config_dir().join("config.toml"))
}
pub fn project_config_path(pdf_path: &Path) -> Option<PathBuf> {
pdf_path.parent().map(|dir| dir.join("dais.toml"))
}
impl Default for ExportConfig {
fn default() -> Self {
Self {
format: "pdf".to_string(),
layers: "all".to_string(),
handout: false,
whiteboard: "none".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ConfigLoadOptions {
pub portable: bool,
}
pub fn load_config_for(pdf_path: &Path, explicit_config: Option<&Path>) -> Config {
load_config_for_with_options(pdf_path, explicit_config, ConfigLoadOptions::default())
}
pub fn load_config_for_with_options(
pdf_path: &Path,
explicit_config: Option<&Path>,
options: ConfigLoadOptions,
) -> Config {
load_config_from_paths(pdf_path, config_path().as_deref(), explicit_config, options)
}
fn load_config_from_paths(
pdf_path: &Path,
machine_config: Option<&Path>,
explicit_config: Option<&Path>,
options: ConfigLoadOptions,
) -> Config {
let mut config = Config::default();
if options.portable {
tracing::debug!("Portable mode enabled; skipping machine-wide config");
} else if let Some(path) = machine_config {
merge_config_file(&mut config, path);
} else {
tracing::warn!("Could not determine config directory, using defaults");
}
if let Some(path) = project_config_path(pdf_path) {
merge_config_file(&mut config, &path);
}
if let Some(path) = explicit_config {
merge_config_file(&mut config, path);
}
config
}
fn merge_config_file(config: &mut Config, path: &Path) {
let Ok(contents) = std::fs::read_to_string(path) else {
tracing::debug!("No config file at {}", path.display());
return;
};
match toml::from_str::<PartialConfig>(&contents) {
Ok(partial) => {
tracing::info!("Loaded config layer from {}", path.display());
apply_partial_config(config, partial);
}
Err(e) => {
tracing::warn!("Failed to parse config at {}: {e}", path.display());
}
}
}
fn apply_partial_config(config: &mut Config, partial: PartialConfig) {
if let Some(display) = partial.display {
if let Some(mode) = display.mode {
config.display.mode = mode;
}
if let Some(v) = display.single_monitor_view {
config.display.single_monitor_view = v;
}
if let Some(audience_monitor) = display.audience_monitor {
config.display.audience_monitor = audience_monitor;
}
if let Some(presenter_monitor) = display.presenter_monitor {
config.display.presenter_monitor = presenter_monitor;
}
}
if let Some(timer) = partial.timer {
if let Some(mode) = timer.mode {
config.timer.mode = mode;
}
if let Some(duration_minutes) = timer.duration_minutes {
config.timer.duration_minutes = duration_minutes.into_option();
}
if let Some(warning_minutes) = timer.warning_minutes {
config.timer.warning_minutes = warning_minutes.into_option();
}
if let Some(overrun_color) = timer.overrun_color {
config.timer.overrun_color = overrun_color;
}
}
if let Some(laser) = partial.laser {
apply_laser_config(&mut config.laser, laser);
}
if let Some(spotlight) = partial.spotlight {
if let Some(radius) = spotlight.radius {
config.spotlight.radius = radius;
}
if let Some(dim_opacity) = spotlight.dim_opacity {
config.spotlight.dim_opacity = dim_opacity;
}
}
if let Some(ink) = partial.ink {
if let Some(colors) = ink.colors {
config.ink.colors = colors;
} else if let Some(color) = ink.color {
config.ink.colors = vec![color];
}
if let Some(width) = ink.width {
config.ink.width = width;
}
if let Some(highlighter_colors) = ink.highlighter_colors {
config.ink.highlighter_colors = highlighter_colors;
}
if let Some(highlighter_width) = ink.highlighter_width {
config.ink.highlighter_width = highlighter_width;
}
}
if let Some(text_boxes) = partial.text_boxes {
if let Some(color) = text_boxes.color {
config.text_boxes.color = color;
}
if let Some(background) = text_boxes.background {
config.text_boxes.background = background;
}
if let Some(typst_prelude) = text_boxes.typst_prelude {
config.text_boxes.typst_prelude = typst_prelude;
}
}
if let Some(notes) = partial.notes {
if let Some(font_size) = notes.font_size {
config.notes.font_size = font_size;
}
if let Some(font_size_step) = notes.font_size_step {
config.notes.font_size_step = font_size_step;
}
}
if let Some(keybindings) = partial.keybindings {
config.keybindings.extend(keybindings);
}
if let Some(clicker) = partial.clicker {
if let Some(profile) = clicker.profile {
config.clicker.profile = profile;
}
if let Some(profiles) = clicker.profiles {
config.clicker.profiles.extend(profiles);
}
}
if let Some(remote) = partial.remote {
apply_remote_config(&mut config.remote, remote);
}
if let Some(export) = partial.export {
apply_export_config(&mut config.export, export);
}
if let Some(sidecar_format) = partial.sidecar_format {
config.sidecar_format = sidecar_format;
}
if let Some(save_slide_timings) = partial.save_slide_timings {
config.save_slide_timings = save_slide_timings;
}
}
fn apply_export_config(config: &mut ExportConfig, partial: PartialExportConfig) {
if let Some(format) = partial.format {
config.format = format;
}
if let Some(layers) = partial.layers {
config.layers = layers;
}
if let Some(handout) = partial.handout {
config.handout = handout;
}
if let Some(whiteboard) = partial.whiteboard {
config.whiteboard = whiteboard;
}
}
fn apply_remote_config(config: &mut RemoteConfig, partial: PartialRemoteConfig) {
if let Some(enabled) = partial.enabled {
config.enabled = enabled;
}
if let Some(host) = partial.host {
config.host = host;
}
if let Some(port) = partial.port {
config.port = port;
}
if let Some(token) = partial.token {
config.token = token;
}
if let Some(allow) = partial.allow_unauthenticated_loopback {
config.allow_unauthenticated_loopback = allow;
}
}
fn apply_laser_config(config: &mut LaserConfig, partial: PartialLaserConfig) {
if let Some(color) = partial.color {
config.color = color.clone();
config.dot.color = color.clone();
config.minimal.color = color.clone();
config.crosshair.color = color.clone();
config.arrow.color = color.clone();
config.ring.color = color.clone();
config.bullseye.color = color.clone();
config.highlight.color = color;
}
if let Some(size) = partial.size {
config.size = size;
config.dot.size = size;
config.minimal.size = size;
config.crosshair.size = size;
config.arrow.size = size;
config.ring.size = size;
config.bullseye.size = size;
config.highlight.size = size;
}
if let Some(style) = partial.style {
config.style = style;
}
if let Some(dot) = partial.dot {
apply_pointer_style_config(&mut config.dot, dot);
}
if let Some(minimal) = partial.minimal {
apply_pointer_style_config(&mut config.minimal, minimal);
}
if let Some(crosshair) = partial.crosshair {
apply_pointer_style_config(&mut config.crosshair, crosshair);
}
if let Some(arrow) = partial.arrow {
apply_pointer_style_config(&mut config.arrow, arrow);
}
if let Some(ring) = partial.ring {
apply_pointer_style_config(&mut config.ring, ring);
}
if let Some(bullseye) = partial.bullseye {
apply_pointer_style_config(&mut config.bullseye, bullseye);
}
if let Some(highlight) = partial.highlight {
apply_pointer_style_config(&mut config.highlight, highlight);
}
}
fn apply_pointer_style_config(config: &mut PointerStyleConfig, partial: PartialPointerStyleConfig) {
if let Some(color) = partial.color {
config.color = color;
}
if let Some(size) = partial.size {
config.size = size;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_config_dir(name: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("dais_config_test_{name}_{suffix}"));
std::fs::create_dir_all(&dir).expect("should create temp config test dir");
dir
}
#[test]
fn partial_config_overrides_selected_fields() {
let mut config = Config::default();
let partial = PartialConfig {
display: Some(PartialDisplayConfig {
mode: Some("screen-share".to_string()),
single_monitor_view: Some("split".to_string()),
audience_monitor: Some("Projector".to_string()),
presenter_monitor: None,
}),
timer: Some(PartialTimerConfig {
mode: Some(TimerMode::Countdown),
duration_minutes: Some(OptionalU32Value::Value(45)),
warning_minutes: Some(OptionalU32Value::Value(10)),
overrun_color: Some(false),
}),
save_slide_timings: Some(false),
..Default::default()
};
apply_partial_config(&mut config, partial);
assert_eq!(config.display.mode, "screen-share");
assert_eq!(config.display.single_monitor_view, "split");
assert_eq!(config.display.audience_monitor, "Projector");
assert_eq!(config.timer.mode, TimerMode::Countdown);
assert_eq!(config.timer.duration_minutes, Some(45));
assert_eq!(config.timer.warning_minutes, Some(10));
assert!(!config.timer.overrun_color);
assert!(!config.save_slide_timings);
}
#[test]
fn partial_config_overrides_text_box_defaults() {
let mut config = Config::default();
let partial = PartialConfig {
text_boxes: Some(PartialTextBoxConfig {
color: Some("#112233".to_string()),
background: Some("#445566AA".to_string()),
typst_prelude: Some("#set align(horizon)".to_string()),
}),
..Default::default()
};
apply_partial_config(&mut config, partial);
assert_eq!(config.text_boxes.color, "#112233");
assert_eq!(config.text_boxes.background, "#445566AA");
assert_eq!(config.text_boxes.typst_prelude, "#set align(horizon)");
}
#[test]
fn partial_config_overrides_export_defaults() {
let mut config = Config::default();
let partial = PartialConfig {
export: Some(PartialExportConfig {
format: Some("svg".to_string()),
layers: Some("ink".to_string()),
handout: Some(true),
whiteboard: Some("append".to_string()),
}),
..Default::default()
};
apply_partial_config(&mut config, partial);
assert_eq!(config.export.format, "svg");
assert_eq!(config.export.layers, "ink");
assert!(config.export.handout);
assert_eq!(config.export.whiteboard, "append");
}
#[test]
fn partial_laser_defaults_apply_to_all_pointer_styles() {
let mut config = Config::default();
let partial = PartialConfig {
laser: Some(PartialLaserConfig {
color: Some("#FFFFFF".to_string()),
size: Some(20.0),
..Default::default()
}),
..Default::default()
};
apply_partial_config(&mut config, partial);
assert_eq!(config.laser.dot.color, "#FFFFFF");
assert_eq!(config.laser.minimal.color, "#FFFFFF");
assert_eq!(config.laser.crosshair.color, "#FFFFFF");
assert_eq!(config.laser.arrow.color, "#FFFFFF");
assert_eq!(config.laser.ring.color, "#FFFFFF");
assert_eq!(config.laser.bullseye.color, "#FFFFFF");
assert_eq!(config.laser.highlight.color, "#FFFFFF");
assert!((config.laser.dot.size - 20.0).abs() < f32::EPSILON);
assert!((config.laser.minimal.size - 20.0).abs() < f32::EPSILON);
assert!((config.laser.crosshair.size - 20.0).abs() < f32::EPSILON);
assert!((config.laser.arrow.size - 20.0).abs() < f32::EPSILON);
assert!((config.laser.ring.size - 20.0).abs() < f32::EPSILON);
assert!((config.laser.bullseye.size - 20.0).abs() < f32::EPSILON);
assert!((config.laser.highlight.size - 20.0).abs() < f32::EPSILON);
}
#[test]
fn partial_laser_pointer_style_overrides_defaults() {
let partial: PartialConfig = toml::from_str(
r##"
[laser]
color = "#FFFFFF"
size = 14.0
style = "crosshair"
[laser.crosshair]
color = "#00FF00"
size = 30.0
[laser.minimal]
size = 8.0
[laser.highlight]
color = "#FFFF0080"
"##,
)
.unwrap();
let mut config = Config::default();
apply_partial_config(&mut config, partial);
assert_eq!(config.laser.style, "crosshair");
assert_eq!(config.laser.dot.color, "#FFFFFF");
assert!((config.laser.dot.size - 14.0).abs() < f32::EPSILON);
assert_eq!(config.laser.minimal.color, "#FFFFFF");
assert!((config.laser.minimal.size - 8.0).abs() < f32::EPSILON);
assert_eq!(config.laser.crosshair.color, "#00FF00");
assert!((config.laser.crosshair.size - 30.0).abs() < f32::EPSILON);
assert_eq!(config.laser.arrow.color, "#FFFFFF");
assert!((config.laser.arrow.size - 14.0).abs() < f32::EPSILON);
assert_eq!(config.laser.ring.color, "#FFFFFF");
assert!((config.laser.ring.size - 14.0).abs() < f32::EPSILON);
assert_eq!(config.laser.bullseye.color, "#FFFFFF");
assert!((config.laser.bullseye.size - 14.0).abs() < f32::EPSILON);
assert_eq!(config.laser.highlight.color, "#FFFF0080");
assert!((config.laser.highlight.size - 14.0).abs() < f32::EPSILON);
}
#[test]
fn partial_config_can_clear_optional_timer_values() {
let mut config = Config::default();
config.timer.duration_minutes = Some(20);
config.timer.warning_minutes = Some(5);
let partial = PartialConfig {
timer: Some(PartialTimerConfig {
duration_minutes: Some(OptionalU32Value::Null(())),
warning_minutes: Some(OptionalU32Value::Null(())),
..Default::default()
}),
..Default::default()
};
apply_partial_config(&mut config, partial);
assert_eq!(config.timer.duration_minutes, None);
assert_eq!(config.timer.warning_minutes, None);
}
#[test]
fn portable_config_skips_machine_config() {
let dir = temp_config_dir("portable_skips_machine");
let machine_config = dir.join("machine.toml");
let pdf_path = dir.join("slides.pdf");
std::fs::write(&machine_config, "[display]\nmode = \"screen-share\"\n")
.expect("should write machine config");
let regular = load_config_from_paths(
&pdf_path,
Some(&machine_config),
None,
ConfigLoadOptions { portable: false },
);
let portable = load_config_from_paths(
&pdf_path,
Some(&machine_config),
None,
ConfigLoadOptions { portable: true },
);
assert_eq!(regular.display.mode, "screen-share");
assert_eq!(portable.display.mode, "dual");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn portable_config_still_loads_project_and_explicit_config() {
let dir = temp_config_dir("portable_keeps_project_explicit");
let explicit_config = dir.join("explicit.toml");
let pdf_dir = dir.join("talk");
std::fs::create_dir_all(&pdf_dir).expect("should create pdf dir");
let pdf_path = pdf_dir.join("slides.pdf");
std::fs::write(pdf_dir.join("dais.toml"), "[display]\nmode = \"single\"\n")
.expect("should write project config");
std::fs::write(&explicit_config, "[display]\nmode = \"screen-share\"\n")
.expect("should write explicit config");
let config = load_config_from_paths(
&pdf_path,
None,
Some(&explicit_config),
ConfigLoadOptions { portable: true },
);
assert_eq!(config.display.mode, "screen-share");
let _ = std::fs::remove_dir_all(dir);
}
}