use std::collections::HashMap;
use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
NextSlide,
PreviousSlide,
NextOverlay,
PreviousOverlay,
FirstSlide,
LastSlide,
GoToSlide,
ToggleFreeze,
ToggleBlackout,
ToggleWhiteboard,
ToggleLaser,
CycleLaserStyle,
ToggleInk,
ClearInk,
CycleInkColor,
CycleInkWidth,
ToggleSpotlight,
ToggleZoom,
ToggleOverview,
ToggleNotes,
ToggleNotesEdit,
StartPauseTimer,
ResetTimer,
IncrementNotesFont,
DecrementNotesFont,
ToggleScreenShare,
TogglePresentationMode,
ToggleTextBoxMode,
Quit,
SaveSidecar,
}
impl Action {
pub fn config_name(self) -> &'static str {
match self {
Self::NextSlide => "next_slide",
Self::PreviousSlide => "previous_slide",
Self::NextOverlay => "next_overlay",
Self::PreviousOverlay => "previous_overlay",
Self::FirstSlide => "first_slide",
Self::LastSlide => "last_slide",
Self::GoToSlide => "go_to_slide",
Self::ToggleFreeze => "toggle_freeze",
Self::ToggleBlackout => "toggle_blackout",
Self::ToggleWhiteboard => "toggle_whiteboard",
Self::ToggleLaser => "toggle_laser",
Self::CycleLaserStyle => "cycle_laser_style",
Self::ToggleInk => "toggle_ink",
Self::ClearInk => "clear_ink",
Self::CycleInkColor => "cycle_ink_color",
Self::CycleInkWidth => "cycle_ink_width",
Self::ToggleSpotlight => "toggle_spotlight",
Self::ToggleZoom => "toggle_zoom",
Self::ToggleOverview => "toggle_overview",
Self::ToggleNotes => "toggle_notes",
Self::ToggleNotesEdit => "toggle_notes_edit",
Self::StartPauseTimer => "start_pause_timer",
Self::ResetTimer => "reset_timer",
Self::IncrementNotesFont => "increment_notes_font",
Self::DecrementNotesFont => "decrement_notes_font",
Self::ToggleScreenShare => "toggle_screen_share",
Self::TogglePresentationMode => "toggle_presentation_mode",
Self::ToggleTextBoxMode => "toggle_text_box_mode",
Self::Quit => "quit",
Self::SaveSidecar => "save_sidecar",
}
}
pub fn description(self) -> &'static str {
match self {
Self::NextSlide => "Next slide",
Self::PreviousSlide => "Previous slide",
Self::NextOverlay => "Next overlay step",
Self::PreviousOverlay => "Previous overlay step",
Self::FirstSlide => "First slide",
Self::LastSlide => "Last slide",
Self::GoToSlide => "Go to slide…",
Self::ToggleFreeze => "Freeze audience",
Self::ToggleBlackout => "Black out audience",
Self::ToggleWhiteboard => "Whiteboard",
Self::ToggleLaser => "Laser pointer",
Self::CycleLaserStyle => "Cycle laser style",
Self::ToggleInk => "Drawing mode",
Self::ClearInk => "Clear ink",
Self::CycleInkColor => "Cycle pen color",
Self::CycleInkWidth => "Cycle pen width",
Self::ToggleSpotlight => "Spotlight",
Self::ToggleZoom => "Zoom mode",
Self::ToggleOverview => "Slide overview",
Self::ToggleNotes => "Toggle notes",
Self::ToggleNotesEdit => "Edit notes",
Self::StartPauseTimer => "Start / pause timer",
Self::ResetTimer => "Reset timer",
Self::IncrementNotesFont => "Increase notes font",
Self::DecrementNotesFont => "Decrease notes font",
Self::ToggleScreenShare => "Screen-share mode",
Self::TogglePresentationMode => "Presentation mode",
Self::ToggleTextBoxMode => "Text box mode",
Self::Quit => "Quit",
Self::SaveSidecar => "Save sidecar",
}
}
pub fn group(self) -> &'static str {
match self {
Self::NextSlide
| Self::PreviousSlide
| Self::NextOverlay
| Self::PreviousOverlay
| Self::FirstSlide
| Self::LastSlide
| Self::GoToSlide => "Navigation",
Self::ToggleFreeze
| Self::ToggleBlackout
| Self::ToggleWhiteboard
| Self::ToggleScreenShare
| Self::TogglePresentationMode => "Display",
Self::ToggleLaser
| Self::CycleLaserStyle
| Self::ToggleInk
| Self::ClearInk
| Self::CycleInkColor
| Self::CycleInkWidth
| Self::ToggleSpotlight
| Self::ToggleZoom
| Self::ToggleTextBoxMode => "Presenter Tools",
Self::StartPauseTimer | Self::ResetTimer => "Timer",
Self::ToggleOverview
| Self::ToggleNotes
| Self::ToggleNotesEdit
| Self::IncrementNotesFont
| Self::DecrementNotesFont => "Notes & Panels",
Self::Quit | Self::SaveSidecar => "System",
}
}
pub fn all() -> &'static [Action] {
&[
Self::NextSlide,
Self::PreviousSlide,
Self::NextOverlay,
Self::PreviousOverlay,
Self::FirstSlide,
Self::LastSlide,
Self::GoToSlide,
Self::ToggleFreeze,
Self::ToggleBlackout,
Self::ToggleWhiteboard,
Self::ToggleLaser,
Self::CycleLaserStyle,
Self::ToggleInk,
Self::ClearInk,
Self::CycleInkColor,
Self::CycleInkWidth,
Self::ToggleSpotlight,
Self::ToggleZoom,
Self::ToggleOverview,
Self::ToggleNotes,
Self::ToggleNotesEdit,
Self::StartPauseTimer,
Self::ResetTimer,
Self::IncrementNotesFont,
Self::DecrementNotesFont,
Self::ToggleScreenShare,
Self::TogglePresentationMode,
Self::ToggleTextBoxMode,
Self::Quit,
Self::SaveSidecar,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KeyCombo {
pub key: String,
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
}
impl KeyCombo {
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split('+').collect();
let mut shift = false;
let mut ctrl = false;
let mut alt = false;
let mut key = None;
for part in &parts {
let normalized = part.trim();
match normalized.to_lowercase().as_str() {
"shift" => shift = true,
"ctrl" | "control" | "cmd" | "command" => ctrl = true,
"alt" | "option" => alt = true,
_ => key = Some(normalized.to_string()),
}
}
key.map(|key| Self { key, shift, ctrl, alt })
}
pub fn display_name(&self) -> String {
let mut parts = Vec::new();
if self.ctrl {
parts.push("Ctrl");
}
if self.alt {
parts.push("Alt");
}
if self.shift {
parts.push("Shift");
}
let key_display: String =
if self.key.len() == 1 { self.key.to_uppercase() } else { self.key.clone() };
parts.push(&key_display);
parts.join("+")
}
}
pub struct KeybindingMap {
bindings: HashMap<KeyCombo, Action>,
}
impl KeybindingMap {
pub fn from_config(user_bindings: &HashMap<String, Vec<String>>) -> Self {
let mut bindings = HashMap::new();
for (action, keys) in default_keybindings() {
for key_str in keys {
if let Some(combo) = KeyCombo::parse(&key_str) {
bindings.insert(combo, action);
}
}
}
let action_lookup: HashMap<&str, Action> =
Action::all().iter().map(|a| (a.config_name(), *a)).collect();
for (action_name, keys) in user_bindings {
if let Some(&action) = action_lookup.get(action_name.as_str()) {
bindings.retain(|_, v| *v != action);
for key_str in keys {
if let Some(combo) = KeyCombo::parse(key_str) {
bindings.insert(combo, action);
}
}
} else {
tracing::warn!("Unknown action in keybinding config: {action_name}");
}
}
Self { bindings }
}
pub fn from_full_config(config: &Config) -> Self {
let mut map = Self::from_config(&config.keybindings);
map.apply_clicker_profile(&config.active_clicker_profile());
map
}
pub fn lookup(&self, combo: &KeyCombo) -> Option<Action> {
self.bindings.get(combo).copied()
}
pub fn action_bindings(&self) -> Vec<(Action, Vec<String>)> {
Action::all()
.iter()
.map(|&action| {
let mut keys: Vec<String> = self
.bindings
.iter()
.filter(|&(_, &a)| a == action)
.map(|(combo, _)| combo.display_name())
.collect();
keys.sort();
(action, keys)
})
.collect()
}
fn apply_clicker_profile(&mut self, clicker_bindings: &HashMap<String, String>) {
let action_lookup: HashMap<&str, Action> =
Action::all().iter().map(|a| (a.config_name(), *a)).collect();
for (key_name, action_name) in clicker_bindings {
let Some(&action) = action_lookup.get(action_name.as_str()) else {
tracing::warn!("Unknown action in clicker profile: {action_name}");
continue;
};
if let Some(combo) = KeyCombo::parse(key_name) {
self.bindings.insert(combo, action);
} else {
tracing::warn!("Invalid key in clicker profile: {key_name}");
}
}
}
}
fn default_keybindings() -> Vec<(Action, Vec<String>)> {
vec![
(Action::NextSlide, vec!["Right".into(), "Space".into(), "Down".into(), "PageDown".into()]),
(Action::PreviousSlide, vec!["Left".into(), "Up".into(), "PageUp".into()]),
(Action::NextOverlay, vec!["Shift+Right".into(), "Shift+Down".into()]),
(Action::PreviousOverlay, vec!["Shift+Left".into(), "Shift+Up".into()]),
(Action::FirstSlide, vec!["Home".into()]),
(Action::LastSlide, vec!["End".into()]),
(Action::GoToSlide, vec!["g".into()]),
(Action::ToggleFreeze, vec!["f".into()]),
(Action::ToggleBlackout, vec!["b".into(), ".".into()]),
(Action::ToggleWhiteboard, vec!["w".into()]),
(Action::ToggleLaser, vec!["l".into()]),
(Action::CycleLaserStyle, vec!["Ctrl+l".into()]),
(Action::ToggleInk, vec!["d".into()]),
(Action::ClearInk, vec!["c".into()]),
(Action::CycleInkColor, vec!["Ctrl+d".into()]),
(Action::CycleInkWidth, vec!["Shift+d".into()]),
(Action::ToggleSpotlight, vec!["s".into()]),
(Action::ToggleZoom, vec!["z".into()]),
(Action::ToggleOverview, vec!["o".into()]),
(Action::ToggleNotes, vec!["n".into()]),
(Action::ToggleNotesEdit, vec!["Ctrl+n".into()]),
(Action::StartPauseTimer, vec!["t".into()]),
(Action::ResetTimer, vec!["Shift+t".into()]),
(Action::IncrementNotesFont, vec!["+".into(), "Shift+=".into()]),
(Action::DecrementNotesFont, vec!["-".into()]),
(Action::ToggleScreenShare, vec!["Shift+s".into()]),
(Action::TogglePresentationMode, vec!["F5".into()]),
(Action::ToggleTextBoxMode, vec!["x".into()]),
(Action::Quit, vec!["q".into(), "Escape".into()]),
(Action::SaveSidecar, vec!["Ctrl+s".into()]),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_key() {
let combo = KeyCombo::parse("Right").unwrap();
assert_eq!(combo.key, "Right");
assert!(!combo.shift);
assert!(!combo.ctrl);
}
#[test]
fn parse_shift_combo() {
let combo = KeyCombo::parse("Shift+Right").unwrap();
assert_eq!(combo.key, "Right");
assert!(combo.shift);
assert!(!combo.ctrl);
}
#[test]
fn parse_ctrl_combo() {
let combo = KeyCombo::parse("Ctrl+s").unwrap();
assert_eq!(combo.key, "s");
assert!(!combo.shift);
assert!(combo.ctrl);
}
#[test]
fn default_bindings_load() {
let map = KeybindingMap::from_config(&HashMap::new());
let right = KeyCombo::parse("Right").unwrap();
assert_eq!(map.lookup(&right), Some(Action::NextSlide));
}
#[test]
fn user_override_replaces_defaults() {
let mut user = HashMap::new();
user.insert("next_slide".to_string(), vec!["x".to_string()]);
let map = KeybindingMap::from_config(&user);
let x = KeyCombo::parse("x").unwrap();
assert_eq!(map.lookup(&x), Some(Action::NextSlide));
let right = KeyCombo::parse("Right").unwrap();
assert_ne!(map.lookup(&right), Some(Action::NextSlide));
}
#[test]
fn clicker_profile_overlays_bindings() {
let mut config = Config::default();
config.clicker.profile = "custom".to_string();
config.clicker.profiles.insert(
"custom".to_string(),
HashMap::from([("Escape".to_string(), "toggle_blackout".to_string())]),
);
let map = KeybindingMap::from_full_config(&config);
let escape = KeyCombo::parse("Escape").unwrap();
assert_eq!(map.lookup(&escape), Some(Action::ToggleBlackout));
}
#[test]
fn display_name_simple_key() {
let combo = KeyCombo::parse("Right").unwrap();
assert_eq!(combo.display_name(), "Right");
}
#[test]
fn display_name_modifier_combo() {
let combo = KeyCombo::parse("Ctrl+Shift+s").unwrap();
assert_eq!(combo.display_name(), "Ctrl+Shift+S");
}
#[test]
fn display_name_single_char_uppercase() {
let combo = KeyCombo::parse("g").unwrap();
assert_eq!(combo.display_name(), "G");
}
#[test]
fn action_bindings_returns_all_actions() {
let map = KeybindingMap::from_config(&HashMap::new());
let bindings = map.action_bindings();
assert_eq!(bindings.len(), Action::all().len());
}
#[test]
fn action_bindings_reflects_overrides() {
let mut user = HashMap::new();
user.insert("quit".to_string(), vec!["x".to_string()]);
let map = KeybindingMap::from_config(&user);
let bindings = map.action_bindings();
let quit_keys: Vec<String> =
bindings.iter().find(|(a, _)| *a == Action::Quit).unwrap().1.clone();
assert_eq!(quit_keys, vec!["X"]);
}
#[test]
fn every_action_has_description_and_group() {
for action in Action::all() {
assert!(!action.description().is_empty(), "{action:?} missing description");
assert!(!action.group().is_empty(), "{action:?} missing group");
}
}
}