aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-09 04:06:44 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-09 04:07:14 +0300
commitb5b61c14841fda60f289cfd9c782580ea099a2a2 (patch)
treef38fcf143d5dd99e3bc1540ac1f8f9ca83468031
parenta3ecb4e3b4151e840ad237e073d8282e1181c34a (diff)
parent74a6161bff998c311d0d43bf6c44de5e193c23e6 (diff)
merge: reload keybinds
-rw-r--r--src/app/input.rs46
-rw-r--r--src/app/mod.rs116
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config.rs71
4 files changed, 230 insertions, 5 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index c7874bbc..ada8e6d9 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -478,11 +478,16 @@ enum GlobalMenuAction {
ApplyUpdate,
WhatsNew,
Keybinds,
+ ReloadKeybinds,
Settings,
}
fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
- let mut actions = vec![GlobalMenuAction::Settings, GlobalMenuAction::Keybinds];
+ let mut actions = vec![
+ GlobalMenuAction::Settings,
+ GlobalMenuAction::Keybinds,
+ GlobalMenuAction::ReloadKeybinds,
+ ];
if state.latest_release_notes_available || state.update_available.is_some() {
actions.push(GlobalMenuAction::WhatsNew);
}
@@ -652,6 +657,10 @@ fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
GlobalMenuAction::ApplyUpdate => state.should_quit = true,
GlobalMenuAction::WhatsNew => open_update_release_notes(state),
GlobalMenuAction::Keybinds => open_keybind_help(state),
+ GlobalMenuAction::ReloadKeybinds => {
+ state.request_reload_keybinds = true;
+ leave_modal(state);
+ }
GlobalMenuAction::Settings => open_settings(state),
}
}
@@ -1594,7 +1603,7 @@ impl AppState {
}
pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
- let mut labels = vec!["settings", "keybinds"];
+ let mut labels = vec!["settings", "keybinds", "reload keybinds"];
if self.latest_release_notes_available || self.update_available.is_some() {
labels.push("what's new");
}
@@ -3509,6 +3518,27 @@ mod tests {
}
#[test]
+ fn clicking_reload_keybinds_menu_item_requests_reload() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 3,
+ ));
+
+ assert!(app.state.request_reload_keybinds);
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
fn update_pending_menu_surfaces_apply_update_action() {
let mut app = app_for_mouse_test();
app.state.update_available = Some("0.3.2".into());
@@ -3523,14 +3553,20 @@ mod tests {
assert_eq!(
app.state.global_menu_labels(),
- vec!["settings", "keybinds", "what's new", "quit to apply update"]
+ vec![
+ "settings",
+ "keybinds",
+ "reload keybinds",
+ "what's new",
+ "quit to apply update"
+ ]
);
let menu = app.state.global_menu_rect();
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
menu.x + 2,
- menu.y + 4,
+ menu.y + 5,
));
assert!(app.state.should_quit);
@@ -3543,7 +3579,7 @@ mod tests {
assert_eq!(
app.state.global_menu_labels(),
- vec!["settings", "keybinds", "what's new"]
+ vec!["settings", "keybinds", "reload keybinds", "what's new"]
);
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 87af63e5..a39ed436 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -238,6 +238,7 @@ impl App {
should_quit: false,
request_new_workspace: false,
request_new_tab: false,
+ request_reload_keybinds: false,
creating_new_tab: false,
requested_new_tab_name: None,
request_complete_onboarding: false,
@@ -435,6 +436,12 @@ impl App {
needs_render = true;
}
+ if self.state.request_reload_keybinds {
+ self.state.request_reload_keybinds = false;
+ self.reload_keybinds();
+ needs_render = true;
+ }
+
let now = Instant::now();
self.sync_animation_timer(now);
@@ -2034,6 +2041,34 @@ impl App {
}
}
+ fn reload_keybinds(&mut self) {
+ let previous_toast = self.state.toast.clone();
+ match crate::config::load_live_keybinds() {
+ Ok(live) => {
+ self.state.prefix_code = live.prefix.0;
+ self.state.prefix_mods = live.prefix.1;
+ self.state.keybinds = live.keybinds;
+ self.state.config_diagnostic = None;
+ self.config_diagnostic_deadline = None;
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::UpdateInstalled,
+ title: "reloaded keybinds".to_string(),
+ context: "using config.toml".to_string(),
+ });
+ }
+ Err(diagnostics) => {
+ let mut message = diagnostics.join("; ");
+ if !message.contains("keeping current keybinds") {
+ message.push_str("; keeping current keybinds");
+ }
+ self.state.toast = None;
+ self.state.config_diagnostic = Some(message);
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
+ }
+ }
+ self.sync_toast_deadline(previous_toast);
+ }
+
fn save_theme(&mut self, name: &str) {
self.update_config_file("theme", |content| {
crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\""))
@@ -2410,6 +2445,7 @@ mod tests {
use crate::detect::{Agent, AgentState};
use crate::workspace::Workspace;
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
+ use std::sync::{Mutex, OnceLock};
fn raw_key(
code: KeyCode,
@@ -2442,6 +2478,86 @@ mod tests {
)
}
+ fn config_env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
+ fn temp_config_path(name: &str) -> std::path::PathBuf {
+ let unique = format!(
+ "herdr-{name}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ std::env::temp_dir().join(unique).join("config.toml")
+ }
+
+ #[test]
+ fn reload_keybinds_updates_live_state() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-keybinds-success");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(
+ &path,
+ "[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n",
+ )
+ .unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ app.reload_keybinds();
+
+ assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
+ assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
+ assert_eq!(
+ app.state.keybinds.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert!(app.state.config_diagnostic.is_none());
+ let toast = app.state.toast.as_ref().unwrap();
+ assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
+ assert_eq!(toast.title, "reloaded keybinds");
+ assert_eq!(toast.context, "using config.toml");
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn reload_keybinds_keeps_current_state_on_invalid_binding() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-keybinds-invalid");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, "[keys]\nnew_workspace = \"wat\"\n").unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
+ let original_keybinds = app.state.keybinds.new_workspace;
+ app.reload_keybinds();
+
+ assert_eq!(
+ (app.state.prefix_code, app.state.prefix_mods),
+ original_prefix
+ );
+ assert_eq!(app.state.keybinds.new_workspace, original_keybinds);
+ assert!(app
+ .state
+ .config_diagnostic
+ .as_deref()
+ .is_some_and(|message| {
+ message.contains("keys.new_workspace")
+ && message.contains("keeping current keybinds")
+ }));
+ assert!(app.state.toast.is_none());
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
#[tokio::test]
async fn raw_input_waits_when_reader_is_gone() {
let result =
diff --git a/src/app/state.rs b/src/app/state.rs
index ea388daa..008d45c8 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -574,6 +574,7 @@ pub struct AppState {
pub should_quit: bool,
pub request_new_workspace: bool,
pub request_new_tab: bool,
+ pub request_reload_keybinds: bool,
pub creating_new_tab: bool,
pub requested_new_tab_name: Option<String>,
pub request_complete_onboarding: bool,
@@ -687,6 +688,7 @@ impl AppState {
should_quit: false,
request_new_workspace: false,
request_new_tab: false,
+ request_reload_keybinds: false,
creating_new_tab: false,
requested_new_tab_name: None,
request_complete_onboarding: false,
diff --git a/src/config.rs b/src/config.rs
index bd6b93b9..b57d67f4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -90,6 +90,12 @@ pub struct LoadedConfig {
pub diagnostics: Vec<String>,
}
+#[derive(Debug)]
+pub struct LiveKeybindConfig {
+ pub prefix: (KeyCode, KeyModifiers),
+ pub keybinds: Keybinds,
+}
+
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct KeysConfig {
@@ -425,6 +431,16 @@ impl Config {
.chain(self.ui.sound.diagnostics())
.collect()
}
+
+ pub fn live_keybinds(&self) -> Result<LiveKeybindConfig, Vec<String>> {
+ let (prefix_diag, prefix, keybind_diags, keybinds) = self.validated_keybinds();
+ let diagnostics: Vec<String> = prefix_diag.into_iter().chain(keybind_diags).collect();
+ if diagnostics.is_empty() {
+ Ok(LiveKeybindConfig { prefix, keybinds })
+ } else {
+ Err(diagnostics)
+ }
+ }
}
fn resolve_config_relative_path(path: &Path) -> PathBuf {
@@ -819,6 +835,25 @@ pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
+pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> {
+ let path = config_path();
+ if !path.exists() {
+ return Config::default().live_keybinds();
+ }
+
+ let content = std::fs::read_to_string(&path).map_err(|err| {
+ vec![format!(
+ "config read error: {err}; keeping current keybinds"
+ )]
+ })?;
+ let config = toml::from_str::<Config>(&content).map_err(|err| {
+ vec![format!(
+ "config parse error: {err}; keeping current keybinds"
+ )]
+ })?;
+ config.live_keybinds()
+}
+
fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String {
let replacement = format!("{key} = {value}");
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
@@ -1286,6 +1321,42 @@ rename_workspace = "g"
}
#[test]
+ fn live_keybinds_reject_invalid_keybinding() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+rename_workspace = "wat"
+"#,
+ )
+ .unwrap();
+
+ let diagnostics = config.live_keybinds().unwrap_err();
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_workspace")));
+ }
+
+ #[test]
+ fn live_keybinds_ignore_non_key_diagnostics() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+new_workspace = "g"
+
+[ui.sound]
+done_path = "sounds/missing.mp3"
+"#,
+ )
+ .unwrap();
+
+ let live = config.live_keybinds().unwrap();
+ assert_eq!(
+ live.keybinds.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ }
+
+ #[test]
fn sound_table_config_parses() {
let toml = r#"
[ui.sound]