tenseleyflow/rcal / 630db76

Browse files

Add user config and keybindings

Authored by espadonne
SHA
630db761903b54b7a705e756bc65089d7b3d4cb0
Parents
4367c8f
Tree
f174768

10 changed files

StatusFile+-
M Cargo.lock 41 1
M Cargo.toml 1 0
M README.md 15 3
M src/app.rs 667 95
M src/cli.rs 566 29
A src/config.rs 496 0
M src/lib.rs 1 0
M src/services.rs 2 2
M src/tui.rs 240 47
M tests/cli_smoke.rs 1 1
Cargo.lockmodified
@@ -1871,6 +1871,7 @@ dependencies = [
18711871
  "serde",
18721872
  "serde_json",
18731873
  "time",
1874
+ "toml",
18741875
 ]
18751876
 
18761877
 [[package]]
@@ -2117,6 +2118,15 @@ dependencies = [
21172118
  "syn 2.0.117",
21182119
 ]
21192120
 
2121
+[[package]]
2122
+name = "serde_spanned"
2123
+version = "1.1.1"
2124
+source = "registry+https://github.com/rust-lang/crates.io-index"
2125
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
2126
+dependencies = [
2127
+ "serde_core",
2128
+]
2129
+
21202130
 [[package]]
21212131
 name = "serde_urlencoded"
21222132
 version = "0.7.1"
@@ -2501,6 +2511,30 @@ dependencies = [
25012511
  "tokio",
25022512
 ]
25032513
 
2514
+[[package]]
2515
+name = "toml"
2516
+version = "0.9.12+spec-1.1.0"
2517
+source = "registry+https://github.com/rust-lang/crates.io-index"
2518
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
2519
+dependencies = [
2520
+ "indexmap",
2521
+ "serde_core",
2522
+ "serde_spanned",
2523
+ "toml_datetime 0.7.5+spec-1.1.0",
2524
+ "toml_parser",
2525
+ "toml_writer",
2526
+ "winnow 0.7.15",
2527
+]
2528
+
2529
+[[package]]
2530
+name = "toml_datetime"
2531
+version = "0.7.5+spec-1.1.0"
2532
+source = "registry+https://github.com/rust-lang/crates.io-index"
2533
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
2534
+dependencies = [
2535
+ "serde_core",
2536
+]
2537
+
25042538
 [[package]]
25052539
 name = "toml_datetime"
25062540
 version = "1.1.1+spec-1.1.0"
@@ -2517,7 +2551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
25172551
 checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
25182552
 dependencies = [
25192553
  "indexmap",
2520
- "toml_datetime",
2554
+ "toml_datetime 1.1.1+spec-1.1.0",
25212555
  "toml_parser",
25222556
  "winnow 1.0.2",
25232557
 ]
@@ -2531,6 +2565,12 @@ dependencies = [
25312565
  "winnow 1.0.2",
25322566
 ]
25332567
 
2568
+[[package]]
2569
+name = "toml_writer"
2570
+version = "1.1.1+spec-1.1.0"
2571
+source = "registry+https://github.com/rust-lang/crates.io-index"
2572
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
2573
+
25342574
 [[package]]
25352575
 name = "tower"
25362576
 version = "0.5.3"
Cargo.tomlmodified
@@ -13,3 +13,4 @@ reqwest = { version = "0.12.24", default-features = false, features = ["blocking
1313
 serde = { version = "1.0.228", features = ["derive"] }
1414
 serde_json = "1.0.145"
1515
 time = { version = "0.3.47", features = ["local-offset", "parsing"] }
16
+toml = "0.9.8"
README.mdmodified
@@ -25,9 +25,10 @@ cargo run -- --date 2026-04-23
2525
 ## Usage
2626
 
2727
 ```sh
28
-rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]
28
+rcal [--config PATH|--no-config] [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]
29
+rcal config init [--path PATH] [--force]
2930
 rcal reminders run [--events-file PATH] [--state-file PATH] [--once]
30
-rcal reminders install [--events-file PATH]
31
+rcal reminders install [--events-file PATH] [--state-file PATH]
3132
 rcal reminders uninstall
3233
 rcal reminders status
3334
 rcal reminders test [--verbose]
@@ -35,6 +36,8 @@ rcal reminders test [--verbose]
3536
 
3637
 Options:
3738
 
39
+- `--config PATH`: load a specific TOML config file.
40
+- `--no-config`: ignore any discovered config file.
3841
 - `--date YYYY-MM-DD`: open with a deterministic selected date.
3942
 - `--events-file PATH`: read and write local user-created events at `PATH`.
4043
   By default, rcal uses `$XDG_DATA_HOME/rcal/events.json`,
@@ -48,6 +51,13 @@ Options:
4851
 - `--help`: show CLI help.
4952
 - `--version`: show the installed version.
5053
 
54
+Config is discovered at `$XDG_CONFIG_HOME/rcal/config.toml`, else
55
+`~/.config/rcal/config.toml`. It is never created automatically; run
56
+`rcal config init` to write a commented starter file. Omitted settings keep
57
+built-in defaults, and CLI flags override config values. Config can set the
58
+events file, holiday source and country, reminder state file, and normal-mode
59
+keybindings. Modal/form keys stay fixed for now.
60
+
5161
 Nager.Date is cache-first and opt-in. Default startup does not need network
5262
 access.
5363
 
@@ -78,7 +88,9 @@ Reminder notifications are delivered by a user-level background service. Use
7888
 `rcal reminders install` to install it, `rcal reminders status` to inspect it,
7989
 and `rcal reminders test` to send a test notification. On macOS, notification
8090
 delivery uses `osascript` because it is more reliable for CLI-launched
81
-notifications than the generic notification backend.
91
+notifications than the generic notification backend. Reminder install snapshots
92
+the resolved events and state file paths, so reinstall the service after config
93
+changes that affect reminders.
8294
 
8395
 ## Layout
8496
 
src/app.rsmodified
@@ -1,4 +1,8 @@
1
-use std::time::{Duration, Instant};
1
+use std::{
2
+    collections::HashMap,
3
+    fmt,
4
+    time::{Duration, Instant},
5
+};
26
 
37
 use crossterm::event::{
48
     KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
@@ -1912,56 +1916,35 @@ fn selectable_day_events(date: CalendarDate, source: &dyn AgendaSource) -> Vec<E
19121916
         .collect()
19131917
 }
19141918
 
1915
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1919
+#[derive(Debug, Clone, PartialEq, Eq)]
19161920
 pub struct KeyboardInput {
19171921
     pending: PendingKey,
1922
+    bindings: KeyBindings,
19181923
 }
19191924
 
19201925
 impl KeyboardInput {
1926
+    pub fn new(bindings: KeyBindings) -> Self {
1927
+        Self {
1928
+            pending: PendingKey::None,
1929
+            bindings,
1930
+        }
1931
+    }
1932
+
19211933
     pub fn translate(&mut self, key: KeyEvent) -> AppAction {
19221934
         if key.kind == KeyEventKind::Release {
19231935
             return AppAction::Noop;
19241936
         }
19251937
 
1926
-        match key.code {
1927
-            KeyCode::Left => {
1928
-                self.clear();
1929
-                AppAction::MoveDays(-1)
1930
-            }
1931
-            KeyCode::Right => {
1932
-                self.clear();
1933
-                AppAction::MoveDays(1)
1934
-            }
1935
-            KeyCode::Up => {
1936
-                self.clear();
1937
-                AppAction::MoveDays(-7)
1938
-            }
1939
-            KeyCode::Down => {
1940
-                self.clear();
1941
-                AppAction::MoveDays(7)
1942
-            }
1943
-            KeyCode::Enter => {
1944
-                self.clear();
1945
-                AppAction::OpenDay
1946
-            }
1947
-            KeyCode::Esc => {
1948
-                self.clear();
1949
-                AppAction::CloseDay
1950
-            }
1951
-            KeyCode::Char(value) if ctrl_c(value, key.modifiers) => {
1952
-                self.clear();
1953
-                AppAction::Quit
1954
-            }
1955
-            KeyCode::Char(_) if key.modifiers.intersects(KeyModifiers::ALT) => {
1956
-                self.clear();
1957
-                AppAction::Noop
1958
-            }
1959
-            KeyCode::Char(value) => self.translate_char(value),
1960
-            _ => {
1961
-                self.clear();
1962
-                AppAction::Noop
1963
-            }
1938
+        if let Some(value) = digit_from_key(key) {
1939
+            return self.translate_digit(value);
19641940
         }
1941
+
1942
+        let Some(gesture) = KeyGesture::from_key_event(key) else {
1943
+            self.clear();
1944
+            return AppAction::Noop;
1945
+        };
1946
+
1947
+        self.translate_gesture(gesture)
19651948
     }
19661949
 
19671950
     pub fn clear(&mut self) {
@@ -1978,55 +1961,36 @@ impl KeyboardInput {
19781961
         }
19791962
     }
19801963
 
1981
-    fn translate_char(&mut self, value: char) -> AppAction {
1982
-        if value == '+' {
1983
-            self.clear();
1984
-            return AppAction::OpenCreate;
1985
-        }
1986
-
1987
-        if value == '?' {
1988
-            self.clear();
1989
-            return AppAction::OpenHelp;
1990
-        }
1991
-
1992
-        if value.eq_ignore_ascii_case(&'d') {
1993
-            self.clear();
1994
-            return AppAction::OpenDelete;
1995
-        }
1996
-
1997
-        if value.eq_ignore_ascii_case(&'c') {
1998
-            self.clear();
1999
-            return AppAction::OpenCopy;
2000
-        }
2001
-
2002
-        if value.is_ascii_digit() {
2003
-            return self.translate_digit(value);
2004
-        }
2005
-
2006
-        let value = value.to_ascii_lowercase();
2007
-
1964
+    fn translate_gesture(&mut self, gesture: KeyGesture) -> AppAction {
20081965
         match self.pending {
20091966
             PendingKey::Digit(_) => {
20101967
                 self.clear();
2011
-                self.translate_weekday_start(value)
1968
+                self.translate_gesture(gesture)
20121969
             }
2013
-            PendingKey::T => {
1970
+            PendingKey::Sequence(first) => {
20141971
                 self.clear();
2015
-                match value {
2016
-                    'u' => AppAction::JumpToWeekday(Weekday::Tuesday),
2017
-                    'h' => AppAction::JumpToWeekday(Weekday::Thursday),
2018
-                    _ => self.translate_weekday_start(value),
1972
+                if let Some(command) = self
1973
+                    .bindings
1974
+                    .command_for_sequence(&KeySequence::two(first, gesture))
1975
+                {
1976
+                    return command.app_action();
20191977
                 }
1978
+
1979
+                self.translate_gesture(gesture)
20201980
             }
2021
-            PendingKey::S => {
2022
-                self.clear();
2023
-                match value {
2024
-                    'a' => AppAction::JumpToWeekday(Weekday::Saturday),
2025
-                    'u' => AppAction::JumpToWeekday(Weekday::Sunday),
2026
-                    _ => self.translate_weekday_start(value),
1981
+            PendingKey::None => {
1982
+                let sequence = KeySequence::one(gesture);
1983
+                if self.bindings.is_prefix(&sequence) {
1984
+                    self.pending = PendingKey::Sequence(gesture);
1985
+                    AppAction::Noop
1986
+                } else if let Some(command) = self.bindings.command_for_sequence(&sequence) {
1987
+                    self.clear();
1988
+                    command.app_action()
1989
+                } else {
1990
+                    self.clear();
1991
+                    AppAction::Noop
20271992
                 }
20281993
             }
2029
-            PendingKey::None => self.translate_weekday_start(value),
20301994
         }
20311995
     }
20321996
 
@@ -2054,23 +2018,572 @@ impl KeyboardInput {
20542018
             }
20552019
         }
20562020
     }
2021
+}
20572022
 
2058
-    fn translate_weekday_start(&mut self, value: char) -> AppAction {
2059
-        match value {
2060
-            'm' => AppAction::JumpToWeekday(Weekday::Monday),
2061
-            'w' => AppAction::JumpToWeekday(Weekday::Wednesday),
2062
-            'f' => AppAction::JumpToWeekday(Weekday::Friday),
2063
-            't' => {
2064
-                self.pending = PendingKey::T;
2065
-                AppAction::Noop
2023
+impl Default for KeyboardInput {
2024
+    fn default() -> Self {
2025
+        Self::new(KeyBindings::default())
2026
+    }
2027
+}
2028
+
2029
+#[derive(Debug, Clone, PartialEq, Eq)]
2030
+pub struct KeyBindings {
2031
+    entries: Vec<KeyBinding>,
2032
+}
2033
+
2034
+impl KeyBindings {
2035
+    pub fn from_lists(lists: KeyBindingLists) -> Result<Self, KeyBindingError> {
2036
+        let entries = lists.into_entries()?;
2037
+        validate_key_bindings(&entries)?;
2038
+        Ok(Self { entries })
2039
+    }
2040
+
2041
+    pub fn default_lists() -> KeyBindingLists {
2042
+        KeyBindingLists::default()
2043
+    }
2044
+
2045
+    pub fn with_overrides(overrides: KeyBindingOverrides) -> Result<Self, KeyBindingError> {
2046
+        let mut lists = KeyBindingLists::default();
2047
+        overrides.apply_to(&mut lists);
2048
+        Self::from_lists(lists)
2049
+    }
2050
+
2051
+    fn command_for_sequence(&self, sequence: &KeySequence) -> Option<KeyCommand> {
2052
+        self.entries
2053
+            .iter()
2054
+            .find_map(|entry| (entry.sequence == *sequence).then_some(entry.command))
2055
+    }
2056
+
2057
+    fn is_prefix(&self, sequence: &KeySequence) -> bool {
2058
+        self.entries
2059
+            .iter()
2060
+            .any(|entry| entry.sequence.starts_with(sequence) && entry.sequence != *sequence)
2061
+    }
2062
+
2063
+    pub fn display_for(&self, command: KeyCommand) -> String {
2064
+        let labels = self
2065
+            .entries
2066
+            .iter()
2067
+            .filter(|entry| entry.command == command)
2068
+            .map(|entry| entry.sequence.label())
2069
+            .collect::<Vec<_>>();
2070
+
2071
+        if labels.is_empty() {
2072
+            command.default_label().to_string()
2073
+        } else {
2074
+            labels.join(" / ")
2075
+        }
2076
+    }
2077
+}
2078
+
2079
+impl Default for KeyBindings {
2080
+    fn default() -> Self {
2081
+        Self::from_lists(KeyBindingLists::default()).expect("default keybindings are valid")
2082
+    }
2083
+}
2084
+
2085
+#[derive(Debug, Clone, PartialEq, Eq)]
2086
+pub struct KeyBindingLists {
2087
+    pub move_left: Vec<String>,
2088
+    pub move_right: Vec<String>,
2089
+    pub move_up: Vec<String>,
2090
+    pub move_down: Vec<String>,
2091
+    pub open_day_or_edit: Vec<String>,
2092
+    pub close_day: Vec<String>,
2093
+    pub create_event: Vec<String>,
2094
+    pub delete_event: Vec<String>,
2095
+    pub copy_event: Vec<String>,
2096
+    pub help: Vec<String>,
2097
+    pub quit: Vec<String>,
2098
+    pub jump_monday: Vec<String>,
2099
+    pub jump_tuesday: Vec<String>,
2100
+    pub jump_wednesday: Vec<String>,
2101
+    pub jump_thursday: Vec<String>,
2102
+    pub jump_friday: Vec<String>,
2103
+    pub jump_saturday: Vec<String>,
2104
+    pub jump_sunday: Vec<String>,
2105
+}
2106
+
2107
+impl Default for KeyBindingLists {
2108
+    fn default() -> Self {
2109
+        Self {
2110
+            move_left: vec!["left".to_string()],
2111
+            move_right: vec!["right".to_string()],
2112
+            move_up: vec!["up".to_string()],
2113
+            move_down: vec!["down".to_string()],
2114
+            open_day_or_edit: vec!["enter".to_string()],
2115
+            close_day: vec!["esc".to_string()],
2116
+            create_event: vec!["+".to_string()],
2117
+            delete_event: vec!["d".to_string()],
2118
+            copy_event: vec!["c".to_string()],
2119
+            help: vec!["?".to_string()],
2120
+            quit: vec!["q".to_string(), "ctrl-c".to_string()],
2121
+            jump_monday: vec!["m".to_string()],
2122
+            jump_tuesday: vec!["tu".to_string()],
2123
+            jump_wednesday: vec!["w".to_string()],
2124
+            jump_thursday: vec!["th".to_string()],
2125
+            jump_friday: vec!["f".to_string()],
2126
+            jump_saturday: vec!["sa".to_string()],
2127
+            jump_sunday: vec!["su".to_string()],
2128
+        }
2129
+    }
2130
+}
2131
+
2132
+impl KeyBindingLists {
2133
+    fn into_entries(self) -> Result<Vec<KeyBinding>, KeyBindingError> {
2134
+        let mut entries = Vec::new();
2135
+        push_entries(&mut entries, KeyCommand::MoveLeft, self.move_left)?;
2136
+        push_entries(&mut entries, KeyCommand::MoveRight, self.move_right)?;
2137
+        push_entries(&mut entries, KeyCommand::MoveUp, self.move_up)?;
2138
+        push_entries(&mut entries, KeyCommand::MoveDown, self.move_down)?;
2139
+        push_entries(
2140
+            &mut entries,
2141
+            KeyCommand::OpenDayOrEdit,
2142
+            self.open_day_or_edit,
2143
+        )?;
2144
+        push_entries(&mut entries, KeyCommand::CloseDay, self.close_day)?;
2145
+        push_entries(&mut entries, KeyCommand::CreateEvent, self.create_event)?;
2146
+        push_entries(&mut entries, KeyCommand::DeleteEvent, self.delete_event)?;
2147
+        push_entries(&mut entries, KeyCommand::CopyEvent, self.copy_event)?;
2148
+        push_entries(&mut entries, KeyCommand::Help, self.help)?;
2149
+        push_entries(&mut entries, KeyCommand::Quit, self.quit)?;
2150
+        push_entries(&mut entries, KeyCommand::JumpMonday, self.jump_monday)?;
2151
+        push_entries(&mut entries, KeyCommand::JumpTuesday, self.jump_tuesday)?;
2152
+        push_entries(&mut entries, KeyCommand::JumpWednesday, self.jump_wednesday)?;
2153
+        push_entries(&mut entries, KeyCommand::JumpThursday, self.jump_thursday)?;
2154
+        push_entries(&mut entries, KeyCommand::JumpFriday, self.jump_friday)?;
2155
+        push_entries(&mut entries, KeyCommand::JumpSaturday, self.jump_saturday)?;
2156
+        push_entries(&mut entries, KeyCommand::JumpSunday, self.jump_sunday)?;
2157
+        Ok(entries)
2158
+    }
2159
+}
2160
+
2161
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
2162
+pub struct KeyBindingOverrides {
2163
+    pub move_left: Option<Vec<String>>,
2164
+    pub move_right: Option<Vec<String>>,
2165
+    pub move_up: Option<Vec<String>>,
2166
+    pub move_down: Option<Vec<String>>,
2167
+    pub open_day_or_edit: Option<Vec<String>>,
2168
+    pub close_day: Option<Vec<String>>,
2169
+    pub create_event: Option<Vec<String>>,
2170
+    pub delete_event: Option<Vec<String>>,
2171
+    pub copy_event: Option<Vec<String>>,
2172
+    pub help: Option<Vec<String>>,
2173
+    pub quit: Option<Vec<String>>,
2174
+    pub jump_monday: Option<Vec<String>>,
2175
+    pub jump_tuesday: Option<Vec<String>>,
2176
+    pub jump_wednesday: Option<Vec<String>>,
2177
+    pub jump_thursday: Option<Vec<String>>,
2178
+    pub jump_friday: Option<Vec<String>>,
2179
+    pub jump_saturday: Option<Vec<String>>,
2180
+    pub jump_sunday: Option<Vec<String>>,
2181
+}
2182
+
2183
+impl KeyBindingOverrides {
2184
+    fn apply_to(self, lists: &mut KeyBindingLists) {
2185
+        if let Some(value) = self.move_left {
2186
+            lists.move_left = value;
2187
+        }
2188
+        if let Some(value) = self.move_right {
2189
+            lists.move_right = value;
2190
+        }
2191
+        if let Some(value) = self.move_up {
2192
+            lists.move_up = value;
2193
+        }
2194
+        if let Some(value) = self.move_down {
2195
+            lists.move_down = value;
2196
+        }
2197
+        if let Some(value) = self.open_day_or_edit {
2198
+            lists.open_day_or_edit = value;
2199
+        }
2200
+        if let Some(value) = self.close_day {
2201
+            lists.close_day = value;
2202
+        }
2203
+        if let Some(value) = self.create_event {
2204
+            lists.create_event = value;
2205
+        }
2206
+        if let Some(value) = self.delete_event {
2207
+            lists.delete_event = value;
2208
+        }
2209
+        if let Some(value) = self.copy_event {
2210
+            lists.copy_event = value;
2211
+        }
2212
+        if let Some(value) = self.help {
2213
+            lists.help = value;
2214
+        }
2215
+        if let Some(value) = self.quit {
2216
+            lists.quit = value;
2217
+        }
2218
+        if let Some(value) = self.jump_monday {
2219
+            lists.jump_monday = value;
2220
+        }
2221
+        if let Some(value) = self.jump_tuesday {
2222
+            lists.jump_tuesday = value;
2223
+        }
2224
+        if let Some(value) = self.jump_wednesday {
2225
+            lists.jump_wednesday = value;
2226
+        }
2227
+        if let Some(value) = self.jump_thursday {
2228
+            lists.jump_thursday = value;
2229
+        }
2230
+        if let Some(value) = self.jump_friday {
2231
+            lists.jump_friday = value;
2232
+        }
2233
+        if let Some(value) = self.jump_saturday {
2234
+            lists.jump_saturday = value;
2235
+        }
2236
+        if let Some(value) = self.jump_sunday {
2237
+            lists.jump_sunday = value;
2238
+        }
2239
+    }
2240
+}
2241
+
2242
+fn push_entries(
2243
+    entries: &mut Vec<KeyBinding>,
2244
+    command: KeyCommand,
2245
+    keys: Vec<String>,
2246
+) -> Result<(), KeyBindingError> {
2247
+    if keys.is_empty() {
2248
+        return Err(KeyBindingError::new(format!(
2249
+            "{} must define at least one key",
2250
+            command.config_name()
2251
+        )));
2252
+    }
2253
+
2254
+    for key in keys {
2255
+        entries.push(KeyBinding {
2256
+            command,
2257
+            sequence: KeySequence::parse(&key)?,
2258
+        });
2259
+    }
2260
+
2261
+    Ok(())
2262
+}
2263
+
2264
+fn validate_key_bindings(entries: &[KeyBinding]) -> Result<(), KeyBindingError> {
2265
+    let mut seen = HashMap::new();
2266
+    for entry in entries {
2267
+        if let Some(existing) = seen.insert(entry.sequence.clone(), entry.command) {
2268
+            return Err(KeyBindingError::new(format!(
2269
+                "key '{}' is bound to both {} and {}",
2270
+                entry.sequence.label(),
2271
+                existing.config_name(),
2272
+                entry.command.config_name()
2273
+            )));
2274
+        }
2275
+    }
2276
+
2277
+    for left in entries {
2278
+        for right in entries {
2279
+            if left.sequence != right.sequence && right.sequence.starts_with(&left.sequence) {
2280
+                return Err(KeyBindingError::new(format!(
2281
+                    "key '{}' conflicts with longer key '{}'",
2282
+                    left.sequence.label(),
2283
+                    right.sequence.label()
2284
+                )));
20662285
             }
2067
-            's' => {
2068
-                self.pending = PendingKey::S;
2069
-                AppAction::Noop
2286
+        }
2287
+    }
2288
+
2289
+    Ok(())
2290
+}
2291
+
2292
+#[derive(Debug, Clone, PartialEq, Eq)]
2293
+struct KeyBinding {
2294
+    command: KeyCommand,
2295
+    sequence: KeySequence,
2296
+}
2297
+
2298
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2299
+struct KeySequence(Vec<KeyGesture>);
2300
+
2301
+impl KeySequence {
2302
+    fn one(gesture: KeyGesture) -> Self {
2303
+        Self(vec![gesture])
2304
+    }
2305
+
2306
+    fn two(first: KeyGesture, second: KeyGesture) -> Self {
2307
+        Self(vec![first, second])
2308
+    }
2309
+
2310
+    fn parse(value: &str) -> Result<Self, KeyBindingError> {
2311
+        if value.is_empty() {
2312
+            return Err(KeyBindingError::new("key binding may not be empty"));
2313
+        }
2314
+
2315
+        let normalized = value.to_ascii_lowercase();
2316
+        if let Some(gesture) = KeyGesture::parse_named(&normalized)? {
2317
+            return Ok(Self::one(gesture));
2318
+        }
2319
+
2320
+        let chars = normalized.chars().collect::<Vec<_>>();
2321
+        match chars.as_slice() {
2322
+            [single] => Ok(Self::one(KeyGesture::parse_printable(*single)?)),
2323
+            [first, second] => Ok(Self::two(
2324
+                KeyGesture::parse_printable(*first)?,
2325
+                KeyGesture::parse_printable(*second)?,
2326
+            )),
2327
+            _ => Err(KeyBindingError::new(format!(
2328
+                "unsupported key binding '{value}'"
2329
+            ))),
2330
+        }
2331
+    }
2332
+
2333
+    fn starts_with(&self, prefix: &Self) -> bool {
2334
+        self.0.starts_with(&prefix.0)
2335
+    }
2336
+
2337
+    fn label(&self) -> String {
2338
+        if self.0.len() == 2
2339
+            && self
2340
+                .0
2341
+                .iter()
2342
+                .all(|gesture| matches!(gesture, KeyGesture::Char(_)))
2343
+        {
2344
+            self.0
2345
+                .iter()
2346
+                .map(|gesture| gesture.label())
2347
+                .collect::<String>()
2348
+        } else {
2349
+            self.0
2350
+                .iter()
2351
+                .map(|gesture| gesture.label())
2352
+                .collect::<Vec<_>>()
2353
+                .join(" ")
2354
+        }
2355
+    }
2356
+}
2357
+
2358
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2359
+enum KeyGesture {
2360
+    Named(NamedKey),
2361
+    Char(char),
2362
+    Ctrl(char),
2363
+}
2364
+
2365
+impl KeyGesture {
2366
+    fn parse_named(value: &str) -> Result<Option<Self>, KeyBindingError> {
2367
+        let gesture = match value {
2368
+            "left" => Self::Named(NamedKey::Left),
2369
+            "right" => Self::Named(NamedKey::Right),
2370
+            "up" => Self::Named(NamedKey::Up),
2371
+            "down" => Self::Named(NamedKey::Down),
2372
+            "enter" => Self::Named(NamedKey::Enter),
2373
+            "esc" | "escape" => Self::Named(NamedKey::Esc),
2374
+            value if value.starts_with("ctrl-") => {
2375
+                let key = value.trim_start_matches("ctrl-");
2376
+                let mut chars = key.chars();
2377
+                let Some(value) = chars.next() else {
2378
+                    return Err(KeyBindingError::new("ctrl binding requires a key"));
2379
+                };
2380
+                if chars.next().is_some() || !value.is_ascii_alphabetic() {
2381
+                    return Err(KeyBindingError::new(format!(
2382
+                        "unsupported control binding 'ctrl-{key}'"
2383
+                    )));
2384
+                }
2385
+                Self::Ctrl(value.to_ascii_lowercase())
20702386
             }
2071
-            'q' => AppAction::Quit,
2072
-            _ => AppAction::Noop,
2387
+            _ => return Ok(None),
2388
+        };
2389
+
2390
+        Ok(Some(gesture))
2391
+    }
2392
+
2393
+    fn parse_printable(value: char) -> Result<Self, KeyBindingError> {
2394
+        if value.is_ascii_digit() {
2395
+            return Err(KeyBindingError::new(format!(
2396
+                "digit key '{value}' is reserved for day jumps"
2397
+            )));
2398
+        }
2399
+        if value.is_control() {
2400
+            return Err(KeyBindingError::new("control characters are unsupported"));
2401
+        }
2402
+
2403
+        Ok(Self::Char(value.to_ascii_lowercase()))
2404
+    }
2405
+
2406
+    fn from_key_event(key: KeyEvent) -> Option<Self> {
2407
+        if key.modifiers.intersects(KeyModifiers::ALT) {
2408
+            return None;
2409
+        }
2410
+
2411
+        match key.code {
2412
+            KeyCode::Left if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Left)),
2413
+            KeyCode::Right if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Right)),
2414
+            KeyCode::Up if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Up)),
2415
+            KeyCode::Down if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Down)),
2416
+            KeyCode::Enter if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Enter)),
2417
+            KeyCode::Esc if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Esc)),
2418
+            KeyCode::Char(value) if key.modifiers.contains(KeyModifiers::CONTROL) => {
2419
+                Some(Self::Ctrl(value.to_ascii_lowercase()))
2420
+            }
2421
+            KeyCode::Char(value)
2422
+                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
2423
+            {
2424
+                Some(Self::Char(value.to_ascii_lowercase()))
2425
+            }
2426
+            _ => None,
2427
+        }
2428
+    }
2429
+
2430
+    fn label(self) -> String {
2431
+        match self {
2432
+            Self::Named(named) => named.label().to_string(),
2433
+            Self::Char(value) => value.to_string(),
2434
+            Self::Ctrl(value) => format!("Ctrl-{}", value.to_ascii_uppercase()),
2435
+        }
2436
+    }
2437
+}
2438
+
2439
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2440
+enum NamedKey {
2441
+    Left,
2442
+    Right,
2443
+    Up,
2444
+    Down,
2445
+    Enter,
2446
+    Esc,
2447
+}
2448
+
2449
+impl NamedKey {
2450
+    const fn label(self) -> &'static str {
2451
+        match self {
2452
+            Self::Left => "Left",
2453
+            Self::Right => "Right",
2454
+            Self::Up => "Up",
2455
+            Self::Down => "Down",
2456
+            Self::Enter => "Enter",
2457
+            Self::Esc => "Esc",
2458
+        }
2459
+    }
2460
+}
2461
+
2462
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2463
+pub enum KeyCommand {
2464
+    MoveLeft,
2465
+    MoveRight,
2466
+    MoveUp,
2467
+    MoveDown,
2468
+    OpenDayOrEdit,
2469
+    CloseDay,
2470
+    CreateEvent,
2471
+    DeleteEvent,
2472
+    CopyEvent,
2473
+    Help,
2474
+    Quit,
2475
+    JumpMonday,
2476
+    JumpTuesday,
2477
+    JumpWednesday,
2478
+    JumpThursday,
2479
+    JumpFriday,
2480
+    JumpSaturday,
2481
+    JumpSunday,
2482
+}
2483
+
2484
+impl KeyCommand {
2485
+    const fn app_action(self) -> AppAction {
2486
+        match self {
2487
+            Self::MoveLeft => AppAction::MoveDays(-1),
2488
+            Self::MoveRight => AppAction::MoveDays(1),
2489
+            Self::MoveUp => AppAction::MoveDays(-7),
2490
+            Self::MoveDown => AppAction::MoveDays(7),
2491
+            Self::OpenDayOrEdit => AppAction::OpenDay,
2492
+            Self::CloseDay => AppAction::CloseDay,
2493
+            Self::CreateEvent => AppAction::OpenCreate,
2494
+            Self::DeleteEvent => AppAction::OpenDelete,
2495
+            Self::CopyEvent => AppAction::OpenCopy,
2496
+            Self::Help => AppAction::OpenHelp,
2497
+            Self::Quit => AppAction::Quit,
2498
+            Self::JumpMonday => AppAction::JumpToWeekday(Weekday::Monday),
2499
+            Self::JumpTuesday => AppAction::JumpToWeekday(Weekday::Tuesday),
2500
+            Self::JumpWednesday => AppAction::JumpToWeekday(Weekday::Wednesday),
2501
+            Self::JumpThursday => AppAction::JumpToWeekday(Weekday::Thursday),
2502
+            Self::JumpFriday => AppAction::JumpToWeekday(Weekday::Friday),
2503
+            Self::JumpSaturday => AppAction::JumpToWeekday(Weekday::Saturday),
2504
+            Self::JumpSunday => AppAction::JumpToWeekday(Weekday::Sunday),
2505
+        }
2506
+    }
2507
+
2508
+    const fn config_name(self) -> &'static str {
2509
+        match self {
2510
+            Self::MoveLeft => "move_left",
2511
+            Self::MoveRight => "move_right",
2512
+            Self::MoveUp => "move_up",
2513
+            Self::MoveDown => "move_down",
2514
+            Self::OpenDayOrEdit => "open_day_or_edit",
2515
+            Self::CloseDay => "close_day",
2516
+            Self::CreateEvent => "create_event",
2517
+            Self::DeleteEvent => "delete_event",
2518
+            Self::CopyEvent => "copy_event",
2519
+            Self::Help => "help",
2520
+            Self::Quit => "quit",
2521
+            Self::JumpMonday => "jump_monday",
2522
+            Self::JumpTuesday => "jump_tuesday",
2523
+            Self::JumpWednesday => "jump_wednesday",
2524
+            Self::JumpThursday => "jump_thursday",
2525
+            Self::JumpFriday => "jump_friday",
2526
+            Self::JumpSaturday => "jump_saturday",
2527
+            Self::JumpSunday => "jump_sunday",
2528
+        }
2529
+    }
2530
+
2531
+    const fn default_label(self) -> &'static str {
2532
+        match self {
2533
+            Self::MoveLeft => "Left",
2534
+            Self::MoveRight => "Right",
2535
+            Self::MoveUp => "Up",
2536
+            Self::MoveDown => "Down",
2537
+            Self::OpenDayOrEdit => "Enter",
2538
+            Self::CloseDay => "Esc",
2539
+            Self::CreateEvent => "+",
2540
+            Self::DeleteEvent => "d",
2541
+            Self::CopyEvent => "c",
2542
+            Self::Help => "?",
2543
+            Self::Quit => "q / Ctrl-C",
2544
+            Self::JumpMonday => "m",
2545
+            Self::JumpTuesday => "tu",
2546
+            Self::JumpWednesday => "w",
2547
+            Self::JumpThursday => "th",
2548
+            Self::JumpFriday => "f",
2549
+            Self::JumpSaturday => "sa",
2550
+            Self::JumpSunday => "su",
2551
+        }
2552
+    }
2553
+}
2554
+
2555
+#[derive(Debug, Clone, PartialEq, Eq)]
2556
+pub struct KeyBindingError {
2557
+    reason: String,
2558
+}
2559
+
2560
+impl KeyBindingError {
2561
+    fn new(reason: impl Into<String>) -> Self {
2562
+        Self {
2563
+            reason: reason.into(),
2564
+        }
2565
+    }
2566
+}
2567
+
2568
+impl fmt::Display for KeyBindingError {
2569
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2570
+        write!(f, "{}", self.reason)
2571
+    }
2572
+}
2573
+
2574
+impl std::error::Error for KeyBindingError {}
2575
+
2576
+fn digit_from_key(key: KeyEvent) -> Option<char> {
2577
+    match key.code {
2578
+        KeyCode::Char(value)
2579
+            if value.is_ascii_digit()
2580
+                && !key
2581
+                    .modifiers
2582
+                    .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
2583
+        {
2584
+            Some(value)
20732585
         }
2586
+        _ => None,
20742587
     }
20752588
 }
20762589
 
@@ -2147,8 +2660,7 @@ enum PendingKey {
21472660
     #[default]
21482661
     None,
21492662
     Digit(u8),
2150
-    T,
2151
-    S,
2663
+    Sequence(KeyGesture),
21522664
 }
21532665
 
21542666
 fn ctrl_c(value: char, modifiers: KeyModifiers) -> bool {
@@ -2246,6 +2758,66 @@ mod tests {
22462758
         }
22472759
     }
22482760
 
2761
+    #[test]
2762
+    fn configured_keybindings_replace_default_normal_mode_commands() {
2763
+        let bindings = KeyBindings::with_overrides(KeyBindingOverrides {
2764
+            create_event: Some(vec!["n".to_string()]),
2765
+            help: Some(vec!["h".to_string()]),
2766
+            quit: Some(vec!["x".to_string()]),
2767
+            ..KeyBindingOverrides::default()
2768
+        })
2769
+        .expect("custom bindings are valid");
2770
+        let mut input = KeyboardInput::new(bindings);
2771
+
2772
+        assert_eq!(input.translate(char_key('+')), AppAction::Noop);
2773
+        assert_eq!(input.translate(char_key('n')), AppAction::OpenCreate);
2774
+        assert_eq!(input.translate(char_key('?')), AppAction::Noop);
2775
+        assert_eq!(input.translate(char_key('h')), AppAction::OpenHelp);
2776
+        assert_eq!(input.translate(char_key('q')), AppAction::Noop);
2777
+        assert_eq!(input.translate(char_key('x')), AppAction::Quit);
2778
+    }
2779
+
2780
+    #[test]
2781
+    fn configured_two_key_weekday_sequence_waits_for_second_key() {
2782
+        let bindings = KeyBindings::with_overrides(KeyBindingOverrides {
2783
+            jump_tuesday: Some(vec!["xy".to_string()]),
2784
+            ..KeyBindingOverrides::default()
2785
+        })
2786
+        .expect("custom bindings are valid");
2787
+        let mut input = KeyboardInput::new(bindings);
2788
+
2789
+        assert_eq!(input.translate(char_key('x')), AppAction::Noop);
2790
+        assert_eq!(
2791
+            input.translate(char_key('y')),
2792
+            AppAction::JumpToWeekday(Weekday::Tuesday)
2793
+        );
2794
+    }
2795
+
2796
+    #[test]
2797
+    fn keybinding_validation_rejects_digits_duplicates_and_prefixes() {
2798
+        let digit_err = KeyBindings::with_overrides(KeyBindingOverrides {
2799
+            create_event: Some(vec!["1".to_string()]),
2800
+            ..KeyBindingOverrides::default()
2801
+        })
2802
+        .expect_err("digits are reserved");
2803
+        assert!(digit_err.to_string().contains("reserved for day jumps"));
2804
+
2805
+        let duplicate_err = KeyBindings::with_overrides(KeyBindingOverrides {
2806
+            create_event: Some(vec!["x".to_string()]),
2807
+            quit: Some(vec!["x".to_string()]),
2808
+            ..KeyBindingOverrides::default()
2809
+        })
2810
+        .expect_err("duplicate binding fails");
2811
+        assert!(duplicate_err.to_string().contains("bound to both"));
2812
+
2813
+        let prefix_err = KeyBindings::with_overrides(KeyBindingOverrides {
2814
+            create_event: Some(vec!["t".to_string()]),
2815
+            ..KeyBindingOverrides::default()
2816
+        })
2817
+        .expect_err("prefix binding fails");
2818
+        assert!(prefix_err.to_string().contains("conflicts with longer key"));
2819
+    }
2820
+
22492821
     #[test]
22502822
     fn arrow_keys_move_within_month() {
22512823
         let mut app = AppState::new(date(2026, Month::April, 23));
src/cli.rsmodified
@@ -18,10 +18,14 @@ use crate::{
1818
     agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
1919
     app::{
2020
         AppState, CreateEventInputResult, EventCopyInputResult, EventCopySubmission,
21
-        EventDeleteInputResult, EventDeleteSubmission, EventFormMode, HelpInputResult,
21
+        EventDeleteInputResult, EventDeleteSubmission, EventFormMode, HelpInputResult, KeyBindings,
2222
         KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
2323
     },
2424
     calendar::CalendarDate,
25
+    config::{
26
+        ConfigError, ConfigHolidaySource, UserConfig, init_config_file, load_discovered_config,
27
+        load_explicit_config,
28
+    },
2529
     reminders::{
2630
         ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file,
2731
         notification_backend_name, run_daemon, run_once, test_notification,
@@ -32,7 +36,7 @@ use crate::{
3236
     },
3337
     tui::{
3438
         AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date,
35
-        render_app_to_string_with_agenda_source,
39
+        render_app_to_string_with_agenda_source_and_keybindings,
3640
     },
3741
 };
3842
 
@@ -41,13 +45,16 @@ const HELP: &str = concat!(
4145
     env!("CARGO_PKG_VERSION"),
4246
     "\n\n",
4347
     "Usage:\n",
44
-    "  rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n\n",
48
+    "  rcal [--config PATH|--no-config] [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n",
49
+    "  rcal config init [--path PATH] [--force]\n\n",
4550
     "  rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n",
46
-    "  rcal reminders install [--events-file PATH]\n",
51
+    "  rcal reminders install [--events-file PATH] [--state-file PATH]\n",
4752
     "  rcal reminders uninstall\n",
4853
     "  rcal reminders status\n",
4954
     "  rcal reminders test [--verbose]\n\n",
5055
     "Options:\n",
56
+    "  --config PATH                       Load a specific config file.\n",
57
+    "  --no-config                         Ignore any discovered config file.\n",
5158
     "  --date YYYY-MM-DD                   Open with the given date selected.\n",
5259
     "  --events-file PATH                  Read and write local user events at PATH.\n",
5360
     "  --holiday-source off|us-federal|nager\n",
@@ -55,6 +62,10 @@ const HELP: &str = concat!(
5562
     "  --holiday-country CC                Country code for --holiday-source nager. Default: US.\n",
5663
     "  -h, --help                          Show this help.\n",
5764
     "  -V, --version                       Show version.\n\n",
65
+    "Config:\n",
66
+    "  rcal config init                     Write a commented starter TOML config.\n",
67
+    "  Config is discovered at $XDG_CONFIG_HOME/rcal/config.toml, else ~/.config/rcal/config.toml.\n",
68
+    "  CLI flags override config values. Reminder services snapshot resolved paths at install time.\n\n",
5869
     "Keys:\n",
5970
     "  Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
6071
     "  ? opens contextual help.\n",
@@ -79,6 +90,7 @@ pub struct AppConfig {
7990
     pub events_file: PathBuf,
8091
     pub holiday_source: HolidaySourceConfig,
8192
     pub holiday_country: String,
93
+    pub keybindings: KeyBindings,
8294
 }
8395
 
8496
 impl AppConfig {
@@ -88,6 +100,7 @@ impl AppConfig {
88100
             events_file: default_events_file(),
89101
             holiday_source: HolidaySourceConfig::UsFederal,
90102
             holiday_country: "US".to_string(),
103
+            keybindings: KeyBindings::default(),
91104
         }
92105
     }
93106
 }
@@ -103,17 +116,28 @@ pub enum HolidaySourceConfig {
103116
 pub enum CliAction {
104117
     Run(AppConfig),
105118
     Reminders(ReminderCliAction),
119
+    Config(ConfigCliAction),
106120
     Help,
107121
     Version,
108122
 }
109123
 
124
+#[derive(Debug, Clone, PartialEq, Eq)]
125
+pub enum ConfigCliAction {
126
+    Init { path: Option<PathBuf>, force: bool },
127
+}
128
+
110129
 #[derive(Debug, Clone, PartialEq, Eq)]
111130
 pub enum ReminderCliAction {
112131
     Run(ReminderRunConfig),
113
-    Install { events_file: PathBuf },
132
+    Install {
133
+        events_file: PathBuf,
134
+        state_file: PathBuf,
135
+    },
114136
     Uninstall,
115137
     Status,
116
-    Test { verbose: bool },
138
+    Test {
139
+        verbose: bool,
140
+    },
117141
 }
118142
 
119143
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -136,6 +160,14 @@ pub enum CliError {
136160
     MissingHolidayCountryValue,
137161
     InvalidHolidayCountry(String),
138162
     HolidayCountryRequiresNager,
163
+    DuplicateConfig,
164
+    MissingConfigValue,
165
+    ConfigAndNoConfig,
166
+    MissingConfigCommand,
167
+    UnknownConfigCommand(String),
168
+    DuplicateConfigInitPath,
169
+    MissingConfigInitPathValue,
170
+    Config(ConfigError),
139171
     MissingReminderCommand,
140172
     UnknownReminderCommand(String),
141173
     DuplicateStateFile,
@@ -178,6 +210,16 @@ impl fmt::Display for CliError {
178210
                     "--holiday-country may only be used with --holiday-source nager"
179211
                 )
180212
             }
213
+            Self::DuplicateConfig => write!(f, "--config may only be provided once"),
214
+            Self::MissingConfigValue => write!(f, "--config requires a path"),
215
+            Self::ConfigAndNoConfig => write!(f, "--config and --no-config cannot be combined"),
216
+            Self::MissingConfigCommand => write!(f, "config requires a command: init"),
217
+            Self::UnknownConfigCommand(command) => write!(f, "unknown config command: {command}"),
218
+            Self::DuplicateConfigInitPath => {
219
+                write!(f, "config init --path may only be provided once")
220
+            }
221
+            Self::MissingConfigInitPathValue => write!(f, "config init --path requires a path"),
222
+            Self::Config(err) => write!(f, "{err}"),
181223
             Self::MissingReminderCommand => write!(
182224
                 f,
183225
                 "reminders requires one of: run, install, uninstall, status, test"
@@ -197,6 +239,12 @@ impl fmt::Display for CliError {
197239
 
198240
 impl std::error::Error for CliError {}
199241
 
242
+impl From<ConfigError> for CliError {
243
+    fn from(err: ConfigError) -> Self {
244
+        Self::Config(err)
245
+    }
246
+}
247
+
200248
 pub fn run_terminal<I>(args: I) -> std::process::ExitCode
201249
 where
202250
     I: IntoIterator<Item = OsString>,
@@ -217,7 +265,7 @@ where
217265
     W: Write,
218266
     E: Write,
219267
 {
220
-    match parse_args(args, default_start_date()) {
268
+    match parse_runtime_args(args, default_start_date()) {
221269
         Ok(CliAction::Run(config)) => {
222270
             let app = AppState::new(config.start_date);
223271
             let agenda_source = match agenda_source(&config) {
@@ -225,14 +273,20 @@ where
225273
                 Err(err) => return local_event_error_exit(&mut stderr, err),
226274
             };
227275
             let (width, height) = terminal_size();
228
-            let rendered =
229
-                render_app_to_string_with_agenda_source(&app, width, height, &agenda_source);
276
+            let rendered = render_app_to_string_with_agenda_source_and_keybindings(
277
+                &app,
278
+                width,
279
+                height,
280
+                &agenda_source,
281
+                &config.keybindings,
282
+            );
230283
             match write!(stdout, "{rendered}") {
231284
                 Ok(()) => std::process::ExitCode::SUCCESS,
232285
                 Err(err) => io_error_exit(&mut stderr, err),
233286
             }
234287
         }
235288
         Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
289
+        Ok(CliAction::Config(action)) => run_config_action(action, &mut stdout, &mut stderr),
236290
         Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
237291
             Ok(()) => std::process::ExitCode::SUCCESS,
238292
             Err(err) => io_error_exit(&mut stderr, err),
@@ -254,19 +308,20 @@ where
254308
     W: Write,
255309
     E: Write,
256310
 {
257
-    match parse_args(args, default_start_date()) {
311
+    match parse_runtime_args(args, default_start_date()) {
258312
         Ok(CliAction::Run(config)) => {
259313
             let app = AppState::new(config.start_date);
260314
             let agenda_source = match agenda_source(&config) {
261315
                 Ok(source) => source,
262316
                 Err(err) => return local_event_error_exit(&mut stderr, err),
263317
             };
264
-            match run_interactive_terminal(stdout, app, agenda_source) {
318
+            match run_interactive_terminal(stdout, app, agenda_source, config.keybindings) {
265319
                 Ok(()) => std::process::ExitCode::SUCCESS,
266320
                 Err(err) => io_error_exit(&mut stderr, err),
267321
             }
268322
         }
269323
         Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
324
+        Ok(CliAction::Config(action)) => run_config_action(action, &mut stdout, &mut stderr),
270325
         Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
271326
             Ok(()) => std::process::ExitCode::SUCCESS,
272327
             Err(err) => io_error_exit(&mut stderr, err),
@@ -283,20 +338,151 @@ where
283338
 }
284339
 
285340
 pub fn parse_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
341
+where
342
+    I: IntoIterator<Item = OsString>,
343
+{
344
+    parse_args_with_config(args, today, UserConfig::empty(), None)
345
+}
346
+
347
+fn parse_runtime_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
348
+where
349
+    I: IntoIterator<Item = OsString>,
350
+{
351
+    let args = args.into_iter().collect::<Vec<_>>();
352
+    let (args, config_selection) = strip_config_flags(args)?;
353
+
354
+    if let Some(action) = early_static_action(&args) {
355
+        return Ok(action);
356
+    }
357
+
358
+    if let Some(first) = args.first()
359
+        && first == "config"
360
+    {
361
+        return parse_config_args(args.into_iter().skip(1), config_selection.path);
362
+    }
363
+
364
+    let config = match &config_selection {
365
+        ConfigSelection {
366
+            no_config: true, ..
367
+        } => UserConfig::empty(),
368
+        ConfigSelection {
369
+            path: Some(path), ..
370
+        } => load_explicit_config(path.clone())?,
371
+        ConfigSelection { .. } => load_discovered_config()?,
372
+    };
373
+
374
+    parse_args_with_config(args, today, config, config_selection.path)
375
+}
376
+
377
+fn parse_args_with_config<I>(
378
+    args: I,
379
+    today: Date,
380
+    user_config: UserConfig,
381
+    explicit_config_path: Option<PathBuf>,
382
+) -> Result<CliAction, CliError>
286383
 where
287384
     I: IntoIterator<Item = OsString>,
288385
 {
289386
     let args = args.into_iter().collect::<Vec<_>>();
387
+    if let Some(first) = args.first()
388
+        && first == "config"
389
+    {
390
+        return parse_config_args(args.into_iter().skip(1), explicit_config_path);
391
+    }
392
+
290393
     if let Some(first) = args.first()
291394
         && first == "reminders"
292395
     {
293
-        return parse_reminder_args(args.into_iter().skip(1));
396
+        return parse_reminder_args(args.into_iter().skip(1), &user_config);
397
+    }
398
+
399
+    parse_calendar_args(args, today, user_config)
400
+}
401
+
402
+#[derive(Debug, Clone, PartialEq, Eq)]
403
+struct ConfigSelection {
404
+    path: Option<PathBuf>,
405
+    no_config: bool,
406
+}
407
+
408
+fn strip_config_flags(args: Vec<OsString>) -> Result<(Vec<OsString>, ConfigSelection), CliError> {
409
+    let mut stripped = Vec::new();
410
+    let mut config_path = None;
411
+    let mut no_config = false;
412
+    let mut args = args.into_iter();
413
+
414
+    while let Some(arg) = args.next() {
415
+        if arg == "--config" {
416
+            if config_path.is_some() {
417
+                return Err(CliError::DuplicateConfig);
418
+            }
419
+            config_path = Some(PathBuf::from(
420
+                args.next().ok_or(CliError::MissingConfigValue)?,
421
+            ));
422
+            continue;
423
+        }
424
+
425
+        if let Some(value) = arg
426
+            .to_str()
427
+            .and_then(|value| value.strip_prefix("--config="))
428
+        {
429
+            if config_path.is_some() {
430
+                return Err(CliError::DuplicateConfig);
431
+            }
432
+            config_path = Some(PathBuf::from(value));
433
+            continue;
434
+        }
435
+
436
+        if arg == "--no-config" {
437
+            no_config = true;
438
+            continue;
439
+        }
440
+
441
+        stripped.push(arg);
442
+    }
443
+
444
+    if config_path.is_some() && no_config {
445
+        return Err(CliError::ConfigAndNoConfig);
446
+    }
447
+
448
+    Ok((
449
+        stripped,
450
+        ConfigSelection {
451
+            path: config_path,
452
+            no_config,
453
+        },
454
+    ))
455
+}
456
+
457
+fn early_static_action(args: &[OsString]) -> Option<CliAction> {
458
+    let first = args.first()?;
459
+    if first == "--help" || first == "-h" {
460
+        return Some(CliAction::Help);
461
+    }
462
+    if first == "--version" || first == "-V" {
463
+        return Some(CliAction::Version);
464
+    }
465
+    if first == "reminders"
466
+        && let Some(second) = args.get(1)
467
+        && (second == "--help" || second == "-h")
468
+    {
469
+        return Some(CliAction::Help);
470
+    }
471
+    if first == "config"
472
+        && let Some(second) = args.get(1)
473
+        && (second == "--help" || second == "-h")
474
+    {
475
+        return Some(CliAction::Help);
294476
     }
295477
 
296
-    parse_calendar_args(args, today)
478
+    None
297479
 }
298480
 
299
-fn parse_calendar_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
481
+fn parse_calendar_args<I>(
482
+    args: I,
483
+    today: Date,
484
+    user_config: UserConfig,
485
+) -> Result<CliAction, CliError>
300486
 where
301487
     I: IntoIterator<Item = OsString>,
302488
 {
@@ -403,8 +589,19 @@ where
403589
         return Err(CliError::UnknownArgument(display_arg(&arg)));
404590
     }
405591
 
406
-    let holiday_country_was_provided = holiday_country.is_some();
592
+    let cli_holiday_country_was_provided = holiday_country.is_some();
407593
     let mut config = AppConfig::new(CalendarDate::from(start_date.unwrap_or(today)));
594
+    if let Some(events_file) = user_config.events_file {
595
+        config.events_file = events_file;
596
+    }
597
+    if let Some(holiday_source) = user_config.holiday_source {
598
+        config.holiday_source = holiday_source.into();
599
+    }
600
+    if let Some(holiday_country) = user_config.holiday_country {
601
+        config.holiday_country = holiday_country;
602
+    }
603
+    config.keybindings = user_config.keybindings;
604
+
408605
     if let Some(events_file) = events_file {
409606
         config.events_file = events_file;
410607
     }
@@ -414,14 +611,85 @@ where
414611
     if let Some(holiday_country) = holiday_country {
415612
         config.holiday_country = holiday_country;
416613
     }
417
-    if holiday_country_was_provided && config.holiday_source != HolidaySourceConfig::Nager {
614
+    if cli_holiday_country_was_provided && config.holiday_source != HolidaySourceConfig::Nager {
418615
         return Err(CliError::HolidayCountryRequiresNager);
419616
     }
420617
 
421618
     Ok(CliAction::Run(config))
422619
 }
423620
 
424
-fn parse_reminder_args<I>(args: I) -> Result<CliAction, CliError>
621
+impl From<ConfigHolidaySource> for HolidaySourceConfig {
622
+    fn from(value: ConfigHolidaySource) -> Self {
623
+        match value {
624
+            ConfigHolidaySource::Off => Self::Off,
625
+            ConfigHolidaySource::UsFederal => Self::UsFederal,
626
+            ConfigHolidaySource::Nager => Self::Nager,
627
+        }
628
+    }
629
+}
630
+
631
+fn parse_config_args<I>(
632
+    args: I,
633
+    explicit_config_path: Option<PathBuf>,
634
+) -> Result<CliAction, CliError>
635
+where
636
+    I: IntoIterator<Item = OsString>,
637
+{
638
+    let mut args = args.into_iter();
639
+    let command = args.next().ok_or(CliError::MissingConfigCommand)?;
640
+    let Some(command) = command.to_str() else {
641
+        return Err(CliError::UnknownConfigCommand(display_arg(&command)));
642
+    };
643
+
644
+    match command {
645
+        "init" => parse_config_init_args(args, explicit_config_path),
646
+        "--help" | "-h" => Ok(CliAction::Help),
647
+        _ => Err(CliError::UnknownConfigCommand(command.to_string())),
648
+    }
649
+}
650
+
651
+fn parse_config_init_args<I>(
652
+    args: I,
653
+    explicit_config_path: Option<PathBuf>,
654
+) -> Result<CliAction, CliError>
655
+where
656
+    I: IntoIterator<Item = OsString>,
657
+{
658
+    let mut path = explicit_config_path;
659
+    let mut force = false;
660
+    let mut args = args.into_iter();
661
+
662
+    while let Some(arg) = args.next() {
663
+        if arg == "--path" {
664
+            if path.is_some() {
665
+                return Err(CliError::DuplicateConfigInitPath);
666
+            }
667
+            path = Some(PathBuf::from(
668
+                args.next().ok_or(CliError::MissingConfigInitPathValue)?,
669
+            ));
670
+            continue;
671
+        }
672
+
673
+        if let Some(value) = arg.to_str().and_then(|value| value.strip_prefix("--path=")) {
674
+            if path.is_some() {
675
+                return Err(CliError::DuplicateConfigInitPath);
676
+            }
677
+            path = Some(PathBuf::from(value));
678
+            continue;
679
+        }
680
+
681
+        if arg == "--force" {
682
+            force = true;
683
+            continue;
684
+        }
685
+
686
+        return Err(CliError::UnknownArgument(display_arg(&arg)));
687
+    }
688
+
689
+    Ok(CliAction::Config(ConfigCliAction::Init { path, force }))
690
+}
691
+
692
+fn parse_reminder_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
425693
 where
426694
     I: IntoIterator<Item = OsString>,
427695
 {
@@ -432,8 +700,8 @@ where
432700
     };
433701
 
434702
     match command {
435
-        "run" => parse_reminder_run_args(args),
436
-        "install" => parse_reminder_install_args(args),
703
+        "run" => parse_reminder_run_args(args, user_config),
704
+        "install" => parse_reminder_install_args(args, user_config),
437705
         "uninstall" => no_extra_reminder_args(args, ReminderCliAction::Uninstall),
438706
         "status" => no_extra_reminder_args(args, ReminderCliAction::Status),
439707
         "test" => parse_reminder_test_args(args),
@@ -442,7 +710,7 @@ where
442710
     }
443711
 }
444712
 
445
-fn parse_reminder_run_args<I>(args: I) -> Result<CliAction, CliError>
713
+fn parse_reminder_run_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
446714
 where
447715
     I: IntoIterator<Item = OsString>,
448716
 {
@@ -500,18 +768,29 @@ where
500768
 
501769
     Ok(CliAction::Reminders(ReminderCliAction::Run(
502770
         ReminderRunConfig {
503
-            events_file: events_file.unwrap_or_else(default_events_file),
504
-            state_file: state_file.unwrap_or_else(default_state_file),
771
+            events_file: events_file.unwrap_or_else(|| {
772
+                user_config
773
+                    .events_file
774
+                    .clone()
775
+                    .unwrap_or_else(default_events_file)
776
+            }),
777
+            state_file: state_file.unwrap_or_else(|| {
778
+                user_config
779
+                    .reminder_state_file
780
+                    .clone()
781
+                    .unwrap_or_else(default_state_file)
782
+            }),
505783
             once,
506784
         },
507785
     )))
508786
 }
509787
 
510
-fn parse_reminder_install_args<I>(args: I) -> Result<CliAction, CliError>
788
+fn parse_reminder_install_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
511789
 where
512790
     I: IntoIterator<Item = OsString>,
513791
 {
514792
     let mut events_file = None;
793
+    let mut state_file = None;
515794
     let mut args = args.into_iter();
516795
 
517796
     while let Some(arg) = args.next() {
@@ -534,12 +813,42 @@ where
534813
             events_file = Some(PathBuf::from(value));
535814
             continue;
536815
         }
816
+        if arg == "--state-file" {
817
+            if state_file.is_some() {
818
+                return Err(CliError::DuplicateStateFile);
819
+            }
820
+            state_file = Some(PathBuf::from(
821
+                args.next().ok_or(CliError::MissingStateFileValue)?,
822
+            ));
823
+            continue;
824
+        }
825
+        if let Some(value) = arg
826
+            .to_str()
827
+            .and_then(|value| value.strip_prefix("--state-file="))
828
+        {
829
+            if state_file.is_some() {
830
+                return Err(CliError::DuplicateStateFile);
831
+            }
832
+            state_file = Some(PathBuf::from(value));
833
+            continue;
834
+        }
537835
 
538836
         return Err(CliError::UnknownArgument(display_arg(&arg)));
539837
     }
540838
 
541839
     Ok(CliAction::Reminders(ReminderCliAction::Install {
542
-        events_file: events_file.unwrap_or_else(default_events_file),
840
+        events_file: events_file.unwrap_or_else(|| {
841
+            user_config
842
+                .events_file
843
+                .clone()
844
+                .unwrap_or_else(default_events_file)
845
+        }),
846
+        state_file: state_file.unwrap_or_else(|| {
847
+            user_config
848
+                .reminder_state_file
849
+                .clone()
850
+                .unwrap_or_else(default_state_file)
851
+        }),
543852
     }))
544853
 }
545854
 
@@ -596,6 +905,22 @@ fn agenda_source(config: &AppConfig) -> Result<ConfiguredAgendaSource, LocalEven
596905
     ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays)
597906
 }
598907
 
908
+fn run_config_action(
909
+    action: ConfigCliAction,
910
+    stdout: &mut impl Write,
911
+    stderr: &mut impl Write,
912
+) -> std::process::ExitCode {
913
+    match action {
914
+        ConfigCliAction::Init { path, force } => match init_config_file(path, force) {
915
+            Ok(path) => {
916
+                let _ = writeln!(stdout, "wrote config {}", path.display());
917
+                std::process::ExitCode::SUCCESS
918
+            }
919
+            Err(err) => config_error_exit(stderr, err),
920
+        },
921
+    }
922
+}
923
+
599924
 fn run_reminder_action(
600925
     action: ReminderCliAction,
601926
     stdout: &mut impl Write,
@@ -628,8 +953,11 @@ fn run_reminder_action(
628953
                 }
629954
             }
630955
         }
631
-        ReminderCliAction::Install { events_file } => {
632
-            let config = match ServiceConfig::new(events_file) {
956
+        ReminderCliAction::Install {
957
+            events_file,
958
+            state_file,
959
+        } => {
960
+            let config = match ServiceConfig::new(events_file, state_file) {
633961
                 Ok(config) => config,
634962
                 Err(err) => return service_error_exit(stderr, err),
635963
             };
@@ -686,6 +1014,7 @@ fn run_interactive_terminal<W>(
6861014
     stdout: W,
6871015
     app: AppState,
6881016
     agenda_source: ConfiguredAgendaSource,
1017
+    keybindings: KeyBindings,
6891018
 ) -> io::Result<()>
6901019
 where
6911020
     W: Write,
@@ -714,7 +1043,7 @@ where
7141043
         return Err(err);
7151044
     }
7161045
 
717
-    let result = run_event_loop(&mut terminal, app, agenda_source);
1046
+    let result = run_event_loop(&mut terminal, app, agenda_source, keybindings);
7181047
     let cleanup_result = restore_terminal(&mut terminal);
7191048
 
7201049
     result.and(cleanup_result)
@@ -724,17 +1053,18 @@ fn run_event_loop<W>(
7241053
     terminal: &mut Terminal<CrosstermBackend<W>>,
7251054
     mut app: AppState,
7261055
     mut agenda_source: ConfiguredAgendaSource,
1056
+    keybindings: KeyBindings,
7271057
 ) -> io::Result<()>
7281058
 where
7291059
     W: Write,
7301060
 {
731
-    let mut keyboard = KeyboardInput::default();
1061
+    let mut keyboard = KeyboardInput::new(keybindings.clone());
7321062
     let mut mouse = MouseInput::default();
7331063
 
7341064
     loop {
7351065
         terminal.draw(|frame| {
7361066
             frame.render_widget(
737
-                AppView::with_agenda_source(&app, &agenda_source),
1067
+                AppView::with_agenda_source_and_keybindings(&app, &agenda_source, &keybindings),
7381068
                 frame.area(),
7391069
             );
7401070
         })?;
@@ -989,6 +1319,11 @@ fn reminder_error_exit(stderr: &mut impl Write, err: ReminderError) -> std::proc
9891319
     std::process::ExitCode::FAILURE
9901320
 }
9911321
 
1322
+fn config_error_exit(stderr: &mut impl Write, err: ConfigError) -> std::process::ExitCode {
1323
+    let _ = writeln!(stderr, "error: {err}");
1324
+    std::process::ExitCode::from(2)
1325
+}
1326
+
9921327
 fn service_error_exit(stderr: &mut impl Write, err: ServiceError) -> std::process::ExitCode {
9931328
     let _ = writeln!(stderr, "error: {err}");
9941329
     std::process::ExitCode::FAILURE
@@ -997,6 +1332,9 @@ fn service_error_exit(stderr: &mut impl Write, err: ServiceError) -> std::proces
9971332
 #[cfg(test)]
9981333
 mod tests {
9991334
     use super::*;
1335
+    use std::{env, fs};
1336
+
1337
+    use crate::app::{KeyBindingOverrides, KeyCommand};
10001338
     use crate::calendar::CalendarMonth;
10011339
     use time::Month;
10021340
 
@@ -1008,6 +1346,28 @@ mod tests {
10081346
         OsString::from(value)
10091347
     }
10101348
 
1349
+    fn temp_path(name: &str) -> PathBuf {
1350
+        env::temp_dir()
1351
+            .join(format!("rcal-cli-test-{}", std::process::id()))
1352
+            .join(name)
1353
+    }
1354
+
1355
+    fn config_with_paths_and_keys() -> UserConfig {
1356
+        UserConfig {
1357
+            path: Some(PathBuf::from("/tmp/rcal/config.toml")),
1358
+            events_file: Some(PathBuf::from("/tmp/config-events.json")),
1359
+            holiday_source: Some(ConfigHolidaySource::Nager),
1360
+            holiday_country: Some("GB".to_string()),
1361
+            reminder_state_file: Some(PathBuf::from("/tmp/config-state.json")),
1362
+            keybindings: KeyBindings::with_overrides(KeyBindingOverrides {
1363
+                create_event: Some(vec!["n".to_string()]),
1364
+                help: Some(vec!["h".to_string()]),
1365
+                ..KeyBindingOverrides::default()
1366
+            })
1367
+            .expect("test bindings are valid"),
1368
+        }
1369
+    }
1370
+
10111371
     #[test]
10121372
     fn no_args_uses_provided_today() {
10131373
         let today = date(2026, Month::April, 23);
@@ -1153,6 +1513,142 @@ mod tests {
11531513
         );
11541514
     }
11551515
 
1516
+    #[test]
1517
+    fn config_values_merge_under_cli_overrides() {
1518
+        let today = date(2026, Month::April, 23);
1519
+
1520
+        let action = parse_args_with_config(
1521
+            [
1522
+                arg("--events-file"),
1523
+                arg("/tmp/cli-events.json"),
1524
+                arg("--holiday-country"),
1525
+                arg("ca"),
1526
+            ],
1527
+            today.into(),
1528
+            config_with_paths_and_keys(),
1529
+            None,
1530
+        )
1531
+        .expect("parse succeeds");
1532
+
1533
+        let CliAction::Run(config) = action else {
1534
+            panic!("calendar args should run the app");
1535
+        };
1536
+
1537
+        assert_eq!(config.events_file, PathBuf::from("/tmp/cli-events.json"));
1538
+        assert_eq!(config.holiday_source, HolidaySourceConfig::Nager);
1539
+        assert_eq!(config.holiday_country, "CA");
1540
+        assert_eq!(config.keybindings.display_for(KeyCommand::CreateEvent), "n");
1541
+    }
1542
+
1543
+    #[test]
1544
+    fn explicit_runtime_config_file_is_loaded() {
1545
+        let today = date(2026, Month::April, 23);
1546
+        let path = temp_path("explicit/config.toml");
1547
+        let root = path
1548
+            .parent()
1549
+            .expect("config dir")
1550
+            .parent()
1551
+            .expect("test root")
1552
+            .to_path_buf();
1553
+        let _ = fs::remove_dir_all(&root);
1554
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
1555
+        fs::write(
1556
+            &path,
1557
+            r#"
1558
+[paths]
1559
+events_file = "events.json"
1560
+
1561
+[keybindings]
1562
+create_event = ["n"]
1563
+"#,
1564
+        )
1565
+        .expect("config writes");
1566
+
1567
+        let action = parse_runtime_args(
1568
+            [
1569
+                arg("--config"),
1570
+                path.as_os_str().to_os_string(),
1571
+                arg("--date"),
1572
+                arg("2026-04-23"),
1573
+            ],
1574
+            today.into(),
1575
+        )
1576
+        .expect("runtime parse succeeds");
1577
+
1578
+        let _ = fs::remove_dir_all(&root);
1579
+        let CliAction::Run(config) = action else {
1580
+            panic!("calendar args should run the app");
1581
+        };
1582
+        assert_eq!(
1583
+            config.events_file,
1584
+            path.parent().expect("config dir").join("events.json")
1585
+        );
1586
+        assert_eq!(config.keybindings.display_for(KeyCommand::CreateEvent), "n");
1587
+    }
1588
+
1589
+    #[test]
1590
+    fn global_config_flags_reject_conflicts() {
1591
+        let today = date(2026, Month::April, 23);
1592
+
1593
+        assert_eq!(
1594
+            parse_runtime_args(
1595
+                [arg("--config"), arg("/tmp/config.toml"), arg("--no-config")],
1596
+                today.into(),
1597
+            )
1598
+            .expect_err("conflicting config flags fail"),
1599
+            CliError::ConfigAndNoConfig
1600
+        );
1601
+    }
1602
+
1603
+    #[test]
1604
+    fn config_init_args_parse_path_and_force() {
1605
+        let today = date(2026, Month::April, 23);
1606
+
1607
+        let action = parse_args(
1608
+            [
1609
+                arg("config"),
1610
+                arg("init"),
1611
+                arg("--path=/tmp/rcal/config.toml"),
1612
+                arg("--force"),
1613
+            ],
1614
+            today.into(),
1615
+        )
1616
+        .expect("parse succeeds");
1617
+
1618
+        assert_eq!(
1619
+            action,
1620
+            CliAction::Config(ConfigCliAction::Init {
1621
+                path: Some(PathBuf::from("/tmp/rcal/config.toml")),
1622
+                force: true,
1623
+            })
1624
+        );
1625
+    }
1626
+
1627
+    #[test]
1628
+    fn config_init_can_use_global_config_path_without_loading_it() {
1629
+        let today = date(2026, Month::April, 23);
1630
+
1631
+        let action = parse_runtime_args(
1632
+            [
1633
+                arg("--config"),
1634
+                arg("/tmp/nonexistent-rcal-config.toml"),
1635
+                arg("config"),
1636
+                arg("init"),
1637
+                arg("--force"),
1638
+            ],
1639
+            today.into(),
1640
+        )
1641
+        .expect("config init parses without loading missing config");
1642
+
1643
+        assert_eq!(
1644
+            action,
1645
+            CliAction::Config(ConfigCliAction::Init {
1646
+                path: Some(PathBuf::from("/tmp/nonexistent-rcal-config.toml")),
1647
+                force: true,
1648
+            })
1649
+        );
1650
+    }
1651
+
11561652
     #[test]
11571653
     fn reminder_run_args_set_events_and_state_paths() {
11581654
         let today = date(2026, Month::April, 23);
@@ -1180,6 +1676,46 @@ mod tests {
11801676
         );
11811677
     }
11821678
 
1679
+    #[test]
1680
+    fn reminder_commands_use_config_default_paths() {
1681
+        let today = date(2026, Month::April, 23);
1682
+
1683
+        let run_action = parse_args_with_config(
1684
+            [arg("reminders"), arg("run"), arg("--once")],
1685
+            today.into(),
1686
+            config_with_paths_and_keys(),
1687
+            None,
1688
+        )
1689
+        .expect("run parses");
1690
+        assert_eq!(
1691
+            run_action,
1692
+            CliAction::Reminders(ReminderCliAction::Run(ReminderRunConfig {
1693
+                events_file: PathBuf::from("/tmp/config-events.json"),
1694
+                state_file: PathBuf::from("/tmp/config-state.json"),
1695
+                once: true,
1696
+            }))
1697
+        );
1698
+
1699
+        let install_action = parse_args_with_config(
1700
+            [
1701
+                arg("reminders"),
1702
+                arg("install"),
1703
+                arg("--state-file=/tmp/override-state.json"),
1704
+            ],
1705
+            today.into(),
1706
+            config_with_paths_and_keys(),
1707
+            None,
1708
+        )
1709
+        .expect("install parses");
1710
+        assert_eq!(
1711
+            install_action,
1712
+            CliAction::Reminders(ReminderCliAction::Install {
1713
+                events_file: PathBuf::from("/tmp/config-events.json"),
1714
+                state_file: PathBuf::from("/tmp/override-state.json"),
1715
+            })
1716
+        );
1717
+    }
1718
+
11831719
     #[test]
11841720
     fn reminder_install_args_set_events_path() {
11851721
         let today = date(2026, Month::April, 23);
@@ -1198,6 +1734,7 @@ mod tests {
11981734
             action,
11991735
             CliAction::Reminders(ReminderCliAction::Install {
12001736
                 events_file: PathBuf::from("/tmp/events.json"),
1737
+                state_file: default_state_file(),
12011738
             })
12021739
         );
12031740
     }
src/config.rsadded
@@ -0,0 +1,496 @@
1
+use std::{
2
+    env,
3
+    error::Error,
4
+    fmt, fs,
5
+    path::{Path, PathBuf},
6
+};
7
+
8
+use serde::Deserialize;
9
+
10
+use crate::app::{KeyBindingError, KeyBindingOverrides, KeyBindings};
11
+
12
+pub const DEFAULT_CONFIG_TOML: &str = r#"# rcal configuration
13
+# Generate this file with `rcal config init`.
14
+
15
+[paths]
16
+# Local user-created events.
17
+events_file = "~/.local/share/rcal/events.json"
18
+
19
+[holidays]
20
+# One of: "off", "us-federal", "nager".
21
+source = "us-federal"
22
+# Two-letter country code. Only used by the Nager.Date holiday source.
23
+country = "US"
24
+
25
+[reminders]
26
+# Delivered/skipped reminder state. Services snapshot this path at install time.
27
+state_file = "~/.local/state/rcal/reminders-state.json"
28
+
29
+[keybindings]
30
+# Normal month/day app commands. Modal/form editing keys are fixed for now.
31
+move_left = ["left"]
32
+move_right = ["right"]
33
+move_up = ["up"]
34
+move_down = ["down"]
35
+open_day_or_edit = ["enter"]
36
+close_day = ["esc"]
37
+create_event = ["+"]
38
+delete_event = ["d"]
39
+copy_event = ["c"]
40
+help = ["?"]
41
+quit = ["q", "ctrl-c"]
42
+
43
+jump_monday = ["m"]
44
+jump_tuesday = ["tu"]
45
+jump_wednesday = ["w"]
46
+jump_thursday = ["th"]
47
+jump_friday = ["f"]
48
+jump_saturday = ["sa"]
49
+jump_sunday = ["su"]
50
+"#;
51
+
52
+#[derive(Debug, Clone, PartialEq, Eq)]
53
+pub struct UserConfig {
54
+    pub path: Option<PathBuf>,
55
+    pub events_file: Option<PathBuf>,
56
+    pub holiday_source: Option<ConfigHolidaySource>,
57
+    pub holiday_country: Option<String>,
58
+    pub reminder_state_file: Option<PathBuf>,
59
+    pub keybindings: KeyBindings,
60
+}
61
+
62
+impl UserConfig {
63
+    pub fn empty() -> Self {
64
+        Self {
65
+            path: None,
66
+            events_file: None,
67
+            holiday_source: None,
68
+            holiday_country: None,
69
+            reminder_state_file: None,
70
+            keybindings: KeyBindings::default(),
71
+        }
72
+    }
73
+}
74
+
75
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76
+pub enum ConfigHolidaySource {
77
+    Off,
78
+    UsFederal,
79
+    Nager,
80
+}
81
+
82
+pub fn default_config_file() -> PathBuf {
83
+    default_config_file_for(env::var_os("XDG_CONFIG_HOME"), env::var_os("HOME"))
84
+}
85
+
86
+fn default_config_file_for(
87
+    xdg_config_home: Option<impl Into<PathBuf>>,
88
+    home: Option<impl Into<PathBuf>>,
89
+) -> PathBuf {
90
+    if let Some(xdg_config_home) = xdg_config_home {
91
+        return xdg_config_home.into().join("rcal").join("config.toml");
92
+    }
93
+
94
+    if let Some(home) = home {
95
+        return home.into().join(".config").join("rcal").join("config.toml");
96
+    }
97
+
98
+    env::temp_dir().join("rcal").join("config.toml")
99
+}
100
+
101
+pub fn load_discovered_config() -> Result<UserConfig, ConfigError> {
102
+    let path = default_config_file();
103
+    if !path.exists() {
104
+        return Ok(UserConfig::empty());
105
+    }
106
+
107
+    load_config_file(&path)
108
+}
109
+
110
+pub fn load_explicit_config(path: impl Into<PathBuf>) -> Result<UserConfig, ConfigError> {
111
+    let path = expand_user_path(path.into())?;
112
+    if !path.exists() {
113
+        return Err(ConfigError::Missing { path });
114
+    }
115
+
116
+    load_config_file(&path)
117
+}
118
+
119
+pub fn load_config_file(path: &Path) -> Result<UserConfig, ConfigError> {
120
+    let body = fs::read_to_string(path).map_err(|err| ConfigError::Read {
121
+        path: path.to_path_buf(),
122
+        reason: err.to_string(),
123
+    })?;
124
+    let parsed = toml::from_str::<RawConfig>(&body).map_err(|err| ConfigError::Parse {
125
+        path: path.to_path_buf(),
126
+        reason: err.to_string(),
127
+    })?;
128
+    raw_config_to_user_config(parsed, path)
129
+}
130
+
131
+pub fn init_config_file(path: Option<PathBuf>, force: bool) -> Result<PathBuf, ConfigError> {
132
+    let path = match path {
133
+        Some(path) => expand_user_path(path)?,
134
+        None => default_config_file(),
135
+    };
136
+
137
+    if path.exists() && !force {
138
+        return Err(ConfigError::AlreadyExists { path });
139
+    }
140
+
141
+    if let Some(parent) = path.parent() {
142
+        fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
143
+            path: parent.to_path_buf(),
144
+            reason: err.to_string(),
145
+        })?;
146
+    }
147
+
148
+    fs::write(&path, DEFAULT_CONFIG_TOML).map_err(|err| ConfigError::Write {
149
+        path: path.clone(),
150
+        reason: err.to_string(),
151
+    })?;
152
+
153
+    Ok(path)
154
+}
155
+
156
+fn raw_config_to_user_config(raw: RawConfig, path: &Path) -> Result<UserConfig, ConfigError> {
157
+    let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
158
+    let events_file = raw
159
+        .paths
160
+        .and_then(|paths| paths.events_file)
161
+        .map(|path| resolve_config_path(&path, base_dir))
162
+        .transpose()?;
163
+    let reminder_state_file = raw
164
+        .reminders
165
+        .and_then(|reminders| reminders.state_file)
166
+        .map(|path| resolve_config_path(&path, base_dir))
167
+        .transpose()?;
168
+
169
+    let (holiday_source, holiday_country) = if let Some(holidays) = raw.holidays {
170
+        (
171
+            holidays
172
+                .source
173
+                .map(|value| parse_holiday_source(&value, path))
174
+                .transpose()?,
175
+            holidays
176
+                .country
177
+                .map(|value| parse_holiday_country(&value, path))
178
+                .transpose()?,
179
+        )
180
+    } else {
181
+        (None, None)
182
+    };
183
+
184
+    let keybindings = if let Some(keybindings) = raw.keybindings {
185
+        KeyBindings::with_overrides(keybindings.into_overrides()).map_err(|err| {
186
+            ConfigError::Invalid {
187
+                path: path.to_path_buf(),
188
+                reason: format!("invalid keybindings: {err}"),
189
+            }
190
+        })?
191
+    } else {
192
+        KeyBindings::default()
193
+    };
194
+
195
+    Ok(UserConfig {
196
+        path: Some(path.to_path_buf()),
197
+        events_file,
198
+        holiday_source,
199
+        holiday_country,
200
+        reminder_state_file,
201
+        keybindings,
202
+    })
203
+}
204
+
205
+fn parse_holiday_source(value: &str, path: &Path) -> Result<ConfigHolidaySource, ConfigError> {
206
+    match value {
207
+        "off" => Ok(ConfigHolidaySource::Off),
208
+        "us-federal" => Ok(ConfigHolidaySource::UsFederal),
209
+        "nager" => Ok(ConfigHolidaySource::Nager),
210
+        _ => Err(ConfigError::Invalid {
211
+            path: path.to_path_buf(),
212
+            reason: format!(
213
+                "invalid holidays.source '{value}'; expected off, us-federal, or nager"
214
+            ),
215
+        }),
216
+    }
217
+}
218
+
219
+fn parse_holiday_country(value: &str, path: &Path) -> Result<String, ConfigError> {
220
+    if value.len() == 2 && value.bytes().all(|value| value.is_ascii_alphabetic()) {
221
+        Ok(value.to_ascii_uppercase())
222
+    } else {
223
+        Err(ConfigError::Invalid {
224
+            path: path.to_path_buf(),
225
+            reason: format!("invalid holidays.country '{value}'; expected two ASCII letters"),
226
+        })
227
+    }
228
+}
229
+
230
+fn resolve_config_path(value: &str, base_dir: &Path) -> Result<PathBuf, ConfigError> {
231
+    let expanded = expand_user_path(PathBuf::from(value))?;
232
+    if expanded.is_absolute() {
233
+        Ok(expanded)
234
+    } else {
235
+        Ok(base_dir.join(expanded))
236
+    }
237
+}
238
+
239
+pub fn expand_user_path(path: PathBuf) -> Result<PathBuf, ConfigError> {
240
+    let Some(value) = path.to_str() else {
241
+        return Ok(path);
242
+    };
243
+
244
+    if value == "~" {
245
+        return Ok(home_dir()?.to_path_buf());
246
+    }
247
+
248
+    if let Some(rest) = value.strip_prefix("~/") {
249
+        return Ok(home_dir()?.join(rest));
250
+    }
251
+
252
+    Ok(path)
253
+}
254
+
255
+fn home_dir() -> Result<PathBuf, ConfigError> {
256
+    env::var_os("HOME")
257
+        .map(PathBuf::from)
258
+        .ok_or(ConfigError::MissingHome)
259
+}
260
+
261
+#[derive(Debug, Deserialize)]
262
+#[serde(deny_unknown_fields)]
263
+struct RawConfig {
264
+    paths: Option<RawPathsConfig>,
265
+    holidays: Option<RawHolidaysConfig>,
266
+    reminders: Option<RawRemindersConfig>,
267
+    keybindings: Option<RawKeyBindingsConfig>,
268
+}
269
+
270
+#[derive(Debug, Deserialize)]
271
+#[serde(deny_unknown_fields)]
272
+struct RawPathsConfig {
273
+    events_file: Option<String>,
274
+}
275
+
276
+#[derive(Debug, Deserialize)]
277
+#[serde(deny_unknown_fields)]
278
+struct RawHolidaysConfig {
279
+    source: Option<String>,
280
+    country: Option<String>,
281
+}
282
+
283
+#[derive(Debug, Deserialize)]
284
+#[serde(deny_unknown_fields)]
285
+struct RawRemindersConfig {
286
+    state_file: Option<String>,
287
+}
288
+
289
+#[derive(Debug, Default, Deserialize)]
290
+#[serde(deny_unknown_fields)]
291
+struct RawKeyBindingsConfig {
292
+    move_left: Option<Vec<String>>,
293
+    move_right: Option<Vec<String>>,
294
+    move_up: Option<Vec<String>>,
295
+    move_down: Option<Vec<String>>,
296
+    open_day_or_edit: Option<Vec<String>>,
297
+    close_day: Option<Vec<String>>,
298
+    create_event: Option<Vec<String>>,
299
+    delete_event: Option<Vec<String>>,
300
+    copy_event: Option<Vec<String>>,
301
+    help: Option<Vec<String>>,
302
+    quit: Option<Vec<String>>,
303
+    jump_monday: Option<Vec<String>>,
304
+    jump_tuesday: Option<Vec<String>>,
305
+    jump_wednesday: Option<Vec<String>>,
306
+    jump_thursday: Option<Vec<String>>,
307
+    jump_friday: Option<Vec<String>>,
308
+    jump_saturday: Option<Vec<String>>,
309
+    jump_sunday: Option<Vec<String>>,
310
+}
311
+
312
+impl RawKeyBindingsConfig {
313
+    fn into_overrides(self) -> KeyBindingOverrides {
314
+        KeyBindingOverrides {
315
+            move_left: self.move_left,
316
+            move_right: self.move_right,
317
+            move_up: self.move_up,
318
+            move_down: self.move_down,
319
+            open_day_or_edit: self.open_day_or_edit,
320
+            close_day: self.close_day,
321
+            create_event: self.create_event,
322
+            delete_event: self.delete_event,
323
+            copy_event: self.copy_event,
324
+            help: self.help,
325
+            quit: self.quit,
326
+            jump_monday: self.jump_monday,
327
+            jump_tuesday: self.jump_tuesday,
328
+            jump_wednesday: self.jump_wednesday,
329
+            jump_thursday: self.jump_thursday,
330
+            jump_friday: self.jump_friday,
331
+            jump_saturday: self.jump_saturday,
332
+            jump_sunday: self.jump_sunday,
333
+        }
334
+    }
335
+}
336
+
337
+#[derive(Debug, Clone, PartialEq, Eq)]
338
+pub enum ConfigError {
339
+    Missing { path: PathBuf },
340
+    MissingHome,
341
+    AlreadyExists { path: PathBuf },
342
+    Read { path: PathBuf, reason: String },
343
+    Write { path: PathBuf, reason: String },
344
+    Parse { path: PathBuf, reason: String },
345
+    Invalid { path: PathBuf, reason: String },
346
+}
347
+
348
+impl fmt::Display for ConfigError {
349
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350
+        match self {
351
+            Self::Missing { path } => write!(f, "config file does not exist: {}", path.display()),
352
+            Self::MissingHome => write!(f, "failed to locate a user home directory"),
353
+            Self::AlreadyExists { path } => {
354
+                write!(
355
+                    f,
356
+                    "config file already exists at {}; pass --force to overwrite",
357
+                    path.display()
358
+                )
359
+            }
360
+            Self::Read { path, reason } => {
361
+                write!(f, "failed to read config {}: {reason}", path.display())
362
+            }
363
+            Self::Write { path, reason } => {
364
+                write!(f, "failed to write config {}: {reason}", path.display())
365
+            }
366
+            Self::Parse { path, reason } => {
367
+                write!(f, "failed to parse config {}: {reason}", path.display())
368
+            }
369
+            Self::Invalid { path, reason } => {
370
+                write!(f, "invalid config {}: {reason}", path.display())
371
+            }
372
+        }
373
+    }
374
+}
375
+
376
+impl Error for ConfigError {}
377
+
378
+impl From<KeyBindingError> for ConfigError {
379
+    fn from(err: KeyBindingError) -> Self {
380
+        Self::Invalid {
381
+            path: PathBuf::from("<config>"),
382
+            reason: err.to_string(),
383
+        }
384
+    }
385
+}
386
+
387
+#[cfg(test)]
388
+mod tests {
389
+    use super::*;
390
+    use std::sync::atomic::{AtomicUsize, Ordering};
391
+
392
+    static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
393
+
394
+    fn temp_config_path(name: &str) -> PathBuf {
395
+        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
396
+        env::temp_dir()
397
+            .join(format!("rcal-config-test-{}-{counter}", std::process::id()))
398
+            .join(name)
399
+    }
400
+
401
+    #[test]
402
+    fn default_config_path_prefers_xdg_then_home() {
403
+        assert_eq!(
404
+            default_config_file_for(Some("/tmp/xdg"), Some("/tmp/home")),
405
+            PathBuf::from("/tmp/xdg/rcal/config.toml")
406
+        );
407
+        assert_eq!(
408
+            default_config_file_for(None::<&str>, Some("/tmp/home")),
409
+            PathBuf::from("/tmp/home/.config/rcal/config.toml")
410
+        );
411
+    }
412
+
413
+    #[test]
414
+    fn missing_discovered_config_is_empty() {
415
+        let config = UserConfig::empty();
416
+        assert!(config.events_file.is_none());
417
+        assert!(config.reminder_state_file.is_none());
418
+    }
419
+
420
+    #[test]
421
+    fn config_loads_paths_relative_to_file_and_expands_home() {
422
+        let path = temp_config_path("relative/config.toml");
423
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
424
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
425
+        fs::write(
426
+            &path,
427
+            r#"
428
+[paths]
429
+events_file = "events.json"
430
+
431
+[reminders]
432
+state_file = "~/state.json"
433
+"#,
434
+        )
435
+        .expect("config writes");
436
+
437
+        let config = load_config_file(&path).expect("config loads");
438
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
439
+
440
+        let expected_events_file = path.parent().expect("config dir").join("events.json");
441
+        assert_eq!(
442
+            config.events_file.as_deref(),
443
+            Some(expected_events_file.as_path())
444
+        );
445
+        assert!(
446
+            config
447
+                .reminder_state_file
448
+                .expect("state path")
449
+                .ends_with("state.json")
450
+        );
451
+    }
452
+
453
+    #[test]
454
+    fn generated_config_parses_cleanly() {
455
+        let parsed = toml::from_str::<RawConfig>(DEFAULT_CONFIG_TOML).expect("template parses");
456
+        assert!(parsed.paths.expect("paths").events_file.is_some());
457
+        assert!(parsed.keybindings.expect("keybindings").quit.is_some());
458
+    }
459
+
460
+    #[test]
461
+    fn malformed_and_unknown_config_fail_clearly() {
462
+        assert!(toml::from_str::<RawConfig>("not = [").is_err());
463
+        assert!(toml::from_str::<RawConfig>("[unknown]\nvalue = true\n").is_err());
464
+    }
465
+
466
+    #[test]
467
+    fn config_init_refuses_to_overwrite_without_force() {
468
+        let path = temp_config_path("init/config.toml");
469
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
470
+        let created = init_config_file(Some(path.clone()), false).expect("config initializes");
471
+        assert_eq!(created, path);
472
+        let err = init_config_file(Some(path.clone()), false).expect_err("overwrite fails");
473
+        init_config_file(Some(path.clone()), true).expect("force overwrites");
474
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
475
+        assert!(matches!(err, ConfigError::AlreadyExists { .. }));
476
+    }
477
+
478
+    #[test]
479
+    fn invalid_keybindings_fail() {
480
+        let path = temp_config_path("keys/config.toml");
481
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
482
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
483
+        fs::write(
484
+            &path,
485
+            r#"
486
+[keybindings]
487
+create_event = ["1"]
488
+"#,
489
+        )
490
+        .expect("config writes");
491
+
492
+        let err = load_config_file(&path).expect_err("digit key fails");
493
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
494
+        assert!(err.to_string().contains("reserved for day jumps"));
495
+    }
496
+}
src/lib.rsmodified
@@ -2,6 +2,7 @@ pub mod agenda;
22
 pub mod app;
33
 pub mod calendar;
44
 pub mod cli;
5
+pub mod config;
56
 pub mod layout;
67
 pub mod reminders;
78
 pub mod services;
src/services.rsmodified
@@ -26,14 +26,14 @@ pub struct ServiceConfig {
2626
 }
2727
 
2828
 impl ServiceConfig {
29
-    pub fn new(events_file: PathBuf) -> Result<Self, ServiceError> {
29
+    pub fn new(events_file: PathBuf, state_file: PathBuf) -> Result<Self, ServiceError> {
3030
         let executable = std::env::current_exe().map_err(|err| ServiceError::CurrentExe {
3131
             reason: err.to_string(),
3232
         })?;
3333
         Ok(Self {
3434
             executable,
3535
             events_file,
36
-            state_file: default_state_file(),
36
+            state_file,
3737
             log_file: default_log_file(),
3838
         })
3939
     }
src/tui.rsmodified
@@ -14,7 +14,7 @@ use crate::{
1414
     },
1515
     app::{
1616
         AppState, CreateEventForm, CreateEventFormRowKind, EventCopyChoice, EventDeleteChoice,
17
-        RecurrenceEditChoice, ViewMode,
17
+        KeyBindings, KeyCommand, RecurrenceEditChoice, ViewMode,
1818
     },
1919
     calendar::{
2020
         CalendarCell, CalendarDate, CalendarMonth, CalendarWeek, DAYS_PER_WEEK, MONTH_GRID_WEEKS,
@@ -28,7 +28,6 @@ pub const DEFAULT_RENDER_HEIGHT: u16 = 26;
2828
 const HEADER_HEIGHT: u16 = 2;
2929
 const VERTICAL_GRID_LINES: u16 = DAYS_PER_WEEK as u16 + 1;
3030
 const HORIZONTAL_GRID_LINES: u16 = MONTH_GRID_WEEKS as u16 + 1;
31
-const HELP_HINT: &str = "?: Help";
3231
 static EMPTY_AGENDA_SOURCE: EmptyAgendaSource = EmptyAgendaSource;
3332
 
3433
 #[derive(Clone, Copy)]
@@ -57,14 +56,22 @@ impl<'a> MonthGrid<'a> {
5756
 
5857
 impl Widget for MonthGrid<'_> {
5958
     fn render(self, area: Rect, buf: &mut Buffer) {
60
-        render_month_grid(self.month, self.agenda_source, area, buf, self.styles);
59
+        render_month_grid(
60
+            self.month,
61
+            self.agenda_source,
62
+            area,
63
+            buf,
64
+            self.styles,
65
+            &KeyBindings::default(),
66
+        );
6167
     }
6268
 }
6369
 
64
-#[derive(Clone, Copy)]
70
+#[derive(Clone)]
6571
 pub struct AppView<'a> {
6672
     app: &'a AppState,
6773
     agenda_source: &'a dyn AgendaSource,
74
+    keybindings: KeyBindings,
6875
 }
6976
 
7077
 impl<'a> AppView<'a> {
@@ -73,7 +80,19 @@ impl<'a> AppView<'a> {
7380
     }
7481
 
7582
     pub fn with_agenda_source(app: &'a AppState, agenda_source: &'a dyn AgendaSource) -> Self {
76
-        Self { app, agenda_source }
83
+        Self::with_agenda_source_and_keybindings(app, agenda_source, &KeyBindings::default())
84
+    }
85
+
86
+    pub fn with_agenda_source_and_keybindings(
87
+        app: &'a AppState,
88
+        agenda_source: &'a dyn AgendaSource,
89
+        keybindings: &KeyBindings,
90
+    ) -> Self {
91
+        Self {
92
+            app,
93
+            agenda_source,
94
+            keybindings: keybindings.clone(),
95
+        }
7796
     }
7897
 }
7998
 
@@ -82,16 +101,38 @@ impl Widget for AppView<'_> {
82101
         match (self.app.view_mode(), ResponsiveLayout::for_area(area)) {
83102
             (ViewMode::Month, layout) if layout.should_render_month_grid() => {
84103
                 let month = self.app.calendar_month();
85
-                MonthGrid::with_agenda_source(&month, self.agenda_source).render(area, buf);
104
+                render_month_grid(
105
+                    &month,
106
+                    self.agenda_source,
107
+                    area,
108
+                    buf,
109
+                    MonthGridStyles::new(),
110
+                    &self.keybindings,
111
+                );
86112
             }
87113
             (ViewMode::Month, layout) if layout.should_render_week_view() => {
88114
                 let month = self.app.calendar_month();
89
-                WeekGrid::with_agenda_source(&month, self.agenda_source).render(area, buf);
90
-            }
91
-            (ViewMode::Month, _) => {
92
-                DayView::responsive_fallback(self.app, self.agenda_source).render(area, buf)
115
+                render_week_grid(
116
+                    &month,
117
+                    self.agenda_source,
118
+                    area,
119
+                    buf,
120
+                    MonthGridStyles::new(),
121
+                    &self.keybindings,
122
+                );
93123
             }
94
-            (ViewMode::Day, _) => DayView::focused(self.app, self.agenda_source).render(area, buf),
124
+            (ViewMode::Month, _) => DayView::responsive_fallback_with_keybindings(
125
+                self.app,
126
+                self.agenda_source,
127
+                self.keybindings.clone(),
128
+            )
129
+            .render(area, buf),
130
+            (ViewMode::Day, _) => DayView::focused_with_keybindings(
131
+                self.app,
132
+                self.agenda_source,
133
+                self.keybindings.clone(),
134
+            )
135
+            .render(area, buf),
95136
         }
96137
 
97138
         if let Some(form) = self.app.create_form() {
@@ -107,17 +148,24 @@ impl Widget for AppView<'_> {
107148
             render_copy_choice_modal(choice, area, buf, CreateModalStyles::new());
108149
         }
109150
         if self.app.is_showing_help() {
110
-            render_help_modal(self.app.view_mode(), area, buf, CreateModalStyles::new());
151
+            render_help_modal(
152
+                self.app.view_mode(),
153
+                area,
154
+                buf,
155
+                CreateModalStyles::new(),
156
+                &self.keybindings,
157
+            );
111158
         }
112159
     }
113160
 }
114161
 
115
-#[derive(Clone, Copy)]
162
+#[derive(Clone)]
116163
 pub struct DayView<'a> {
117164
     app: &'a AppState,
118165
     agenda_source: &'a dyn AgendaSource,
119166
     context: DayViewContext,
120167
     styles: DayViewStyles,
168
+    keybindings: KeyBindings,
121169
 }
122170
 
123171
 impl<'a> DayView<'a> {
@@ -127,6 +175,7 @@ impl<'a> DayView<'a> {
127175
             agenda_source,
128176
             context: DayViewContext::Focused,
129177
             styles: DayViewStyles::new(),
178
+            keybindings: KeyBindings::default(),
130179
         }
131180
     }
132181
 
@@ -136,6 +185,35 @@ impl<'a> DayView<'a> {
136185
             agenda_source,
137186
             context: DayViewContext::ResponsiveFallback,
138187
             styles: DayViewStyles::new(),
188
+            keybindings: KeyBindings::default(),
189
+        }
190
+    }
191
+
192
+    fn focused_with_keybindings(
193
+        app: &'a AppState,
194
+        agenda_source: &'a dyn AgendaSource,
195
+        keybindings: KeyBindings,
196
+    ) -> Self {
197
+        Self {
198
+            app,
199
+            agenda_source,
200
+            context: DayViewContext::Focused,
201
+            styles: DayViewStyles::new(),
202
+            keybindings,
203
+        }
204
+    }
205
+
206
+    fn responsive_fallback_with_keybindings(
207
+        app: &'a AppState,
208
+        agenda_source: &'a dyn AgendaSource,
209
+        keybindings: KeyBindings,
210
+    ) -> Self {
211
+        Self {
212
+            app,
213
+            agenda_source,
214
+            context: DayViewContext::ResponsiveFallback,
215
+            styles: DayViewStyles::new(),
216
+            keybindings,
139217
         }
140218
     }
141219
 }
@@ -149,6 +227,7 @@ impl Widget for DayView<'_> {
149227
             area,
150228
             buf,
151229
             self.styles,
230
+            &self.keybindings,
152231
         );
153232
     }
154233
 }
@@ -185,7 +264,14 @@ impl<'a> WeekGrid<'a> {
185264
 
186265
 impl Widget for WeekGrid<'_> {
187266
     fn render(self, area: Rect, buf: &mut Buffer) {
188
-        render_week_grid(self.month, self.agenda_source, area, buf, self.styles);
267
+        render_week_grid(
268
+            self.month,
269
+            self.agenda_source,
270
+            area,
271
+            buf,
272
+            self.styles,
273
+            &KeyBindings::default(),
274
+        );
189275
     }
190276
 }
191277
 
@@ -548,12 +634,32 @@ pub fn render_app_to_string_with_agenda_source<S>(
548634
     height: u16,
549635
     agenda_source: &S,
550636
 ) -> String
637
+where
638
+    S: AgendaSource,
639
+{
640
+    render_app_to_string_with_agenda_source_and_keybindings(
641
+        app,
642
+        width,
643
+        height,
644
+        agenda_source,
645
+        &KeyBindings::default(),
646
+    )
647
+}
648
+
649
+pub fn render_app_to_string_with_agenda_source_and_keybindings<S>(
650
+    app: &AppState,
651
+    width: u16,
652
+    height: u16,
653
+    agenda_source: &S,
654
+    keybindings: &KeyBindings,
655
+) -> String
551656
 where
552657
     S: AgendaSource,
553658
 {
554659
     let area = Rect::new(0, 0, width, height);
555660
     let mut buffer = Buffer::empty(area);
556
-    AppView::with_agenda_source(app, agenda_source).render(area, &mut buffer);
661
+    AppView::with_agenda_source_and_keybindings(app, agenda_source, keybindings)
662
+        .render(area, &mut buffer);
557663
     buffer_to_string(&buffer)
558664
 }
559665
 
@@ -653,6 +759,7 @@ fn render_month_grid(
653759
     area: Rect,
654760
     buf: &mut Buffer,
655761
     styles: MonthGridStyles,
762
+    keybindings: &KeyBindings,
656763
 ) {
657764
     let Some(layout) = MonthGridLayout::new(area) else {
658765
         render_too_small_message(area, buf, styles);
@@ -660,7 +767,7 @@ fn render_month_grid(
660767
     };
661768
 
662769
     buf.set_style(area, Style::default());
663
-    render_title(month, &layout, buf, styles);
770
+    render_title(month, &layout, buf, styles, keybindings);
664771
     render_weekdays(&layout, buf, styles);
665772
 
666773
     for cell in month
@@ -688,6 +795,7 @@ fn render_week_grid(
688795
     area: Rect,
689796
     buf: &mut Buffer,
690797
     styles: MonthGridStyles,
798
+    keybindings: &KeyBindings,
691799
 ) {
692800
     let Some(layout) = WeekGridLayout::new(area) else {
693801
         render_too_small_message(area, buf, styles);
@@ -700,7 +808,7 @@ fn render_week_grid(
700808
     };
701809
 
702810
     buf.set_style(area, Style::default());
703
-    render_week_title(selected_week, &layout, buf, styles);
811
+    render_week_title(selected_week, &layout, buf, styles, keybindings);
704812
     render_weekdays_for_week(&layout, buf, styles);
705813
 
706814
     for cell in selected_week
@@ -755,6 +863,7 @@ fn render_day_view(
755863
     area: Rect,
756864
     buf: &mut Buffer,
757865
     styles: DayViewStyles,
866
+    keybindings: &KeyBindings,
758867
 ) {
759868
     if area.width == 0 || area.height == 0 {
760869
         return;
@@ -763,7 +872,7 @@ fn render_day_view(
763872
     let layout = DayViewLayout::new(area);
764873
     let agenda = app.day_agenda(agenda_source);
765874
     buf.set_style(area, Style::default());
766
-    render_day_header(&agenda, context, &layout, buf, styles);
875
+    render_day_header(&agenda, context, &layout, buf, styles, keybindings);
767876
 
768877
     match layout.mode {
769878
         DayViewLayoutMode::Split | DayViewLayoutMode::Stacked => {
@@ -800,6 +909,7 @@ fn render_day_header(
800909
     layout: &DayViewLayout,
801910
     buf: &mut Buffer,
802911
     styles: DayViewStyles,
912
+    keybindings: &KeyBindings,
803913
 ) {
804914
     let title = day_title(agenda.date);
805915
     write_centered(
@@ -817,6 +927,7 @@ fn render_day_header(
817927
         layout.area.width,
818928
         &title,
819929
         styles.help_hint,
930
+        keybindings,
820931
     );
821932
 
822933
     let Some(summary_y) = layout.summary_y else {
@@ -824,7 +935,11 @@ fn render_day_header(
824935
     };
825936
 
826937
     let summary = match context {
827
-        DayViewContext::Focused => format!("{} | Esc returns to month", agenda_summary(agenda)),
938
+        DayViewContext::Focused => format!(
939
+            "{} | {} returns to month",
940
+            agenda_summary(agenda),
941
+            keybindings.display_for(KeyCommand::CloseDay)
942
+        ),
828943
         DayViewContext::ResponsiveFallback => agenda_summary(agenda),
829944
     };
830945
     write_centered(
@@ -1137,12 +1252,18 @@ fn render_copy_choice_modal(
11371252
     );
11381253
 }
11391254
 
1140
-fn render_help_modal(view_mode: ViewMode, area: Rect, buf: &mut Buffer, styles: CreateModalStyles) {
1255
+fn render_help_modal(
1256
+    view_mode: ViewMode,
1257
+    area: Rect,
1258
+    buf: &mut Buffer,
1259
+    styles: CreateModalStyles,
1260
+    keybindings: &KeyBindings,
1261
+) {
11411262
     if area.width == 0 || area.height == 0 {
11421263
         return;
11431264
     }
11441265
 
1145
-    let rows = help_rows(view_mode);
1266
+    let rows = help_rows(view_mode, keybindings);
11461267
     let modal = help_modal_area(area, rows.len());
11471268
     fill_rect(buf, modal, styles.panel);
11481269
     draw_border(buf, modal, styles.border, BorderCharacters::normal());
@@ -1165,7 +1286,7 @@ fn render_help_modal(view_mode: ViewMode, area: Rect, buf: &mut Buffer, styles:
11651286
     let description_x = content.x.saturating_add(key_width).saturating_add(2);
11661287
     let description_width = content.right().saturating_sub(description_x);
11671288
     let mut y = content.y.saturating_add(2);
1168
-    for (key, description) in rows {
1289
+    for (key, description) in &rows {
11691290
         if y >= content.bottom().saturating_sub(2) {
11701291
             break;
11711292
         }
@@ -1186,7 +1307,7 @@ fn render_help_modal(view_mode: ViewMode, area: Rect, buf: &mut Buffer, styles:
11861307
         content.bottom().saturating_sub(1),
11871308
         content.x,
11881309
         content.width,
1189
-        "Esc / ? close",
1310
+        "Esc / Enter close",
11901311
         styles.footer,
11911312
     );
11921313
 }
@@ -1232,28 +1353,87 @@ fn help_heading(view_mode: ViewMode) -> &'static str {
12321353
     }
12331354
 }
12341355
 
1235
-fn help_rows(view_mode: ViewMode) -> &'static [(&'static str, &'static str)] {
1356
+fn help_rows(view_mode: ViewMode, keybindings: &KeyBindings) -> Vec<(String, &'static str)> {
12361357
     match view_mode {
1237
-        ViewMode::Month => &[
1238
-            ("Arrows", "Move the selected date"),
1239
-            ("Digits", "Jump to a day, with quick two-digit refinement"),
1240
-            ("Weekdays", "Jump within the selected week"),
1241
-            ("Enter", "Open the focused day view"),
1242
-            ("+", "Create an event on the selected date"),
1243
-            ("Mouse", "Select a date; double-click to open"),
1244
-            ("?", "Close this help"),
1245
-            ("q", "Quit"),
1358
+        ViewMode::Month => vec![
1359
+            (
1360
+                format!(
1361
+                    "{} / {} / {} / {}",
1362
+                    keybindings.display_for(KeyCommand::MoveLeft),
1363
+                    keybindings.display_for(KeyCommand::MoveRight),
1364
+                    keybindings.display_for(KeyCommand::MoveUp),
1365
+                    keybindings.display_for(KeyCommand::MoveDown)
1366
+                ),
1367
+                "Move the selected date",
1368
+            ),
1369
+            (
1370
+                "Digits".to_string(),
1371
+                "Jump to a day, with quick two-digit refinement",
1372
+            ),
1373
+            (
1374
+                format!(
1375
+                    "{} {} {} {} {} {} {}",
1376
+                    keybindings.display_for(KeyCommand::JumpMonday),
1377
+                    keybindings.display_for(KeyCommand::JumpTuesday),
1378
+                    keybindings.display_for(KeyCommand::JumpWednesday),
1379
+                    keybindings.display_for(KeyCommand::JumpThursday),
1380
+                    keybindings.display_for(KeyCommand::JumpFriday),
1381
+                    keybindings.display_for(KeyCommand::JumpSaturday),
1382
+                    keybindings.display_for(KeyCommand::JumpSunday)
1383
+                ),
1384
+                "Jump within the selected week",
1385
+            ),
1386
+            (
1387
+                keybindings.display_for(KeyCommand::OpenDayOrEdit),
1388
+                "Open the focused day view",
1389
+            ),
1390
+            (
1391
+                keybindings.display_for(KeyCommand::CreateEvent),
1392
+                "Create an event on the selected date",
1393
+            ),
1394
+            ("Mouse".to_string(), "Select a date; double-click to open"),
1395
+            ("Esc / Enter".to_string(), "Close this help"),
1396
+            (keybindings.display_for(KeyCommand::Quit), "Quit"),
12461397
         ],
1247
-        ViewMode::Day => &[
1248
-            ("Left/Right", "Move to the previous or next day"),
1249
-            ("Up/Down", "Select a local event"),
1250
-            ("Enter", "Edit the selected local event"),
1251
-            ("c", "Copy the selected local event"),
1252
-            ("d", "Delete the selected local event"),
1253
-            ("+", "Create an event on this day"),
1254
-            ("Esc", "Return to month view"),
1255
-            ("?", "Close this help"),
1256
-            ("q", "Quit"),
1398
+        ViewMode::Day => vec![
1399
+            (
1400
+                format!(
1401
+                    "{} / {}",
1402
+                    keybindings.display_for(KeyCommand::MoveLeft),
1403
+                    keybindings.display_for(KeyCommand::MoveRight)
1404
+                ),
1405
+                "Move to the previous or next day",
1406
+            ),
1407
+            (
1408
+                format!(
1409
+                    "{} / {}",
1410
+                    keybindings.display_for(KeyCommand::MoveUp),
1411
+                    keybindings.display_for(KeyCommand::MoveDown)
1412
+                ),
1413
+                "Select a local event",
1414
+            ),
1415
+            (
1416
+                keybindings.display_for(KeyCommand::OpenDayOrEdit),
1417
+                "Edit the selected local event",
1418
+            ),
1419
+            (
1420
+                keybindings.display_for(KeyCommand::CopyEvent),
1421
+                "Copy the selected local event",
1422
+            ),
1423
+            (
1424
+                keybindings.display_for(KeyCommand::DeleteEvent),
1425
+                "Delete the selected local event",
1426
+            ),
1427
+            (
1428
+                keybindings.display_for(KeyCommand::CreateEvent),
1429
+                "Create an event on this day",
1430
+            ),
1431
+            (
1432
+                keybindings.display_for(KeyCommand::CloseDay),
1433
+                "Return to month view",
1434
+            ),
1435
+            ("Esc / Enter".to_string(), "Close this help"),
1436
+            (keybindings.display_for(KeyCommand::Quit), "Quit"),
12571437
         ],
12581438
     }
12591439
 }
@@ -1707,6 +1887,7 @@ fn render_title(
17071887
     layout: &MonthGridLayout,
17081888
     buf: &mut Buffer,
17091889
     styles: MonthGridStyles,
1890
+    keybindings: &KeyBindings,
17101891
 ) {
17111892
     let title = format!("{} {}", month.current.month, month.current.year);
17121893
     write_centered(
@@ -1724,6 +1905,7 @@ fn render_title(
17241905
         layout.area.width,
17251906
         &title,
17261907
         styles.help_hint,
1908
+        keybindings,
17271909
     );
17281910
 }
17291911
 
@@ -1760,6 +1942,7 @@ fn render_week_title(
17601942
     layout: &WeekGridLayout,
17611943
     buf: &mut Buffer,
17621944
     styles: MonthGridStyles,
1945
+    keybindings: &KeyBindings,
17631946
 ) {
17641947
     let title = week_title(week);
17651948
     write_centered(
@@ -1777,6 +1960,7 @@ fn render_week_title(
17771960
         layout.area.width,
17781961
         &title,
17791962
         styles.help_hint,
1963
+        keybindings,
17801964
     );
17811965
 }
17821966
 
@@ -2062,8 +2246,17 @@ fn write_centered(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, styl
20622246
     buf.set_stringn(start, y, text, usize::from(width), style);
20632247
 }
20642248
 
2065
-fn render_title_hint(buf: &mut Buffer, y: u16, x: u16, width: u16, title: &str, style: Style) {
2066
-    let hint_width = u16::try_from(HELP_HINT.len()).unwrap_or(u16::MAX);
2249
+fn render_title_hint(
2250
+    buf: &mut Buffer,
2251
+    y: u16,
2252
+    x: u16,
2253
+    width: u16,
2254
+    title: &str,
2255
+    style: Style,
2256
+    keybindings: &KeyBindings,
2257
+) {
2258
+    let hint = format!("{}: Help", keybindings.display_for(KeyCommand::Help));
2259
+    let hint_width = u16::try_from(hint.len()).unwrap_or(u16::MAX);
20672260
     let title_width = u16::try_from(title.len()).unwrap_or(u16::MAX);
20682261
     if width <= hint_width.saturating_add(1) || title_width >= width {
20692262
         return;
@@ -2076,7 +2269,7 @@ fn render_title_hint(buf: &mut Buffer, y: u16, x: u16, width: u16, title: &str,
20762269
         return;
20772270
     }
20782271
 
2079
-    write_left(buf, y, hint_x, hint_width, HELP_HINT, style);
2272
+    write_left(buf, y, hint_x, hint_width, &hint, style);
20802273
 }
20812274
 
20822275
 fn write_left(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, style: Style) {
@@ -2628,7 +2821,7 @@ mod tests {
26282821
 
26292822
         assert!(rendered.contains("Month / Week keys"));
26302823
         assert!(rendered.contains("Open the focused day view"));
2631
-        assert!(rendered.contains("Esc / ? close"));
2824
+        assert!(rendered.contains("Esc / Enter close"));
26322825
         assert!(!rendered.contains("Delete the selected local event"));
26332826
     }
26342827
 
tests/cli_smoke.rsmodified
@@ -33,7 +33,7 @@ fn invalid_date_flag_fails() {
3333
     let stderr = String::from_utf8_lossy(&output.stderr);
3434
     assert!(stderr.contains("invalid --date value '2026-02-30'"));
3535
     assert!(stderr.contains("Usage:"));
36
-    assert!(stderr.contains("rcal [--date YYYY-MM-DD]"));
36
+    assert!(stderr.contains("rcal [--config PATH|--no-config]"));
3737
 }
3838
 
3939
 #[test]