From f5e66b251fa8ec24989bf25861bcf4e38a561470 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Wed, 3 Jun 2026 09:13:01 -0700 Subject: [PATCH] feat: switch input source during prefix mode (#434) On macOS, opt-in under [experimental], temporarily switch the host input source to the current ASCII-capable layout while prefix mode is active so prefix commands register even when a CJK IME is composing, then restore the previous input source when prefix mode exits or is cancelled. No-op when the current source is already ASCII-capable, on non-macOS platforms, or when any input source call fails. Pane text input is unaffected. Also pin next_resize_poll in next_loop_deadline_includes_selection_autoscroll_deadline so the selection autoscroll deadline stays the earliest; the default resize poll could otherwise beat it and flake the assertion. refs #400 Co-authored-by: Can Celik --- .../src/content/docs/configuration.mdx | 15 ++ src/app/config_io.rs | 13 ++ src/app/input/mod.rs | 3 + src/app/input/settings.rs | 82 +++++++- src/app/mod.rs | 185 +++++++++++++++++- src/app/runtime.rs | 6 +- src/app/state.rs | 38 ++++ src/config/model.rs | 25 +++ src/main.rs | 5 + src/platform/macos.rs | 182 +++++++++++++++++ src/platform/mod.rs | 43 ++++ src/ui/settings.rs | 64 ++++-- 12 files changed, 631 insertions(+), 30 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index d4a4c90e..b174cb42 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -431,6 +431,21 @@ Hot-reloads through the existing `[experimental]` block. The trade-off when enabled: an extra hardware cursor is visible in the outer terminal for apps that hide the cursor without painting a replacement (vim normal mode, etc.). Pair the reveal with `cjk_ime_agents` to scope it to specific TUIs. +## Prefix input source switching + +On macOS, prefix-mode commands can be hard to use while a non-ASCII input source is active because prefix commands are still interpreted through the host input source. + +Set `switch_ascii_input_source_in_prefix = true` to switch the host input source to the system ASCII-capable input source while prefix mode is active: + +```toml +[experimental] +switch_ascii_input_source_in_prefix = false +``` + +When enabled, Herdr switches input sources only after prefix mode is entered, then restores the previous input source when prefix mode exits. The setting is macOS-only and is a no-op on other platforms or when the system input-source switch fails. + +You can also toggle it from Settings > Experiments > switch to ascii input source in prefix (macOS). + ## Environment variables | Variable | Purpose | diff --git a/src/app/config_io.rs b/src/app/config_io.rs index fa2765e9..804a26f6 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -94,6 +94,19 @@ impl App { } } + pub(super) fn save_switch_ascii_input_source_in_prefix(&mut self, enabled: bool) { + if self.update_config_file("prefix ascii input source", |content| { + crate::config::upsert_section_bool( + content, + "experimental", + "switch_ascii_input_source_in_prefix", + enabled, + ) + }) { + self.apply_config_from_disk(false); + } + } + pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) { let value = match scope { crate::app::state::AgentPanelScope::CurrentWorkspace => { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index cfcc3039..1da647e8 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -219,6 +219,9 @@ impl App { SettingsAction::SavePaneHistory(enabled) => { self.save_pane_history_persistence(enabled) } + SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => { + self.save_switch_ascii_input_source_in_prefix(enabled) + } SettingsAction::InstallRecommendedIntegrations => { self.install_recommended_integrations() } diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 986f59e2..66ec3226 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -3,7 +3,7 @@ use ratatui::layout::Rect; use crate::{ app::{ - state::{AppState, SettingsSection, THEME_NAMES}, + state::{AppState, ExperimentSetting, SettingsSection, THEME_NAMES}, App, Mode, }, config::ToastDelivery, @@ -18,9 +18,24 @@ pub(super) enum SettingsAction { SaveToastDelivery(ToastDelivery), SaveAgentBorderLabels(bool), SavePaneHistory(bool), + SaveSwitchAsciiInputSourceInPrefix(bool), InstallRecommendedIntegrations, } +/// Map an Experiments row index to the toggle action that flips it. +fn experiment_toggle_action(state: &AppState, idx: usize) -> Option { + match ExperimentSetting::ALL.get(idx).copied()? { + ExperimentSetting::PaneHistory => Some(SettingsAction::SavePaneHistory( + !ExperimentSetting::PaneHistory.enabled(state), + )), + ExperimentSetting::SwitchAsciiInputSourceInPrefix => { + Some(SettingsAction::SaveSwitchAsciiInputSourceInPrefix( + !ExperimentSetting::SwitchAsciiInputSourceInPrefix.enabled(state), + )) + } + } +} + impl App { pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { let previous_section = self.state.settings.section; @@ -35,6 +50,9 @@ impl App { SettingsAction::SavePaneHistory(enabled) => { self.save_pane_history_persistence(enabled) } + SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => { + self.save_switch_ascii_input_source_in_prefix(enabled) + } SettingsAction::InstallRecommendedIntegrations => { self.install_recommended_integrations() } @@ -228,10 +246,12 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } }, SettingsSection::Experiments => match key.code { + KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(), + KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.move_next(ExperimentSetting::ALL.len()) + } KeyCode::Enter | KeyCode::Char(' ') => { - return Some(SettingsAction::SavePaneHistory( - !state.pane_history_persistence_enabled(), - )); + return experiment_toggle_action(state, state.settings.list.selected); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { state.settings.section = SettingsSection::Integrations; @@ -377,7 +397,11 @@ impl AppState { } SettingsSection::Experiments => { let list_y = area.y + 3; - (row == list_y).then_some(0) + if row >= list_y && row < list_y + ExperimentSetting::ALL.len() as u16 { + Some((row - list_y) as usize) + } else { + None + } } SettingsSection::Integrations => None, } @@ -419,9 +443,7 @@ impl AppState { let enabled = idx == 0; Some(SettingsAction::SaveAgentBorderLabels(enabled)) } - SettingsSection::Experiments => Some(SettingsAction::SavePaneHistory( - !self.pane_history_persistence_enabled(), - )), + SettingsSection::Experiments => experiment_toggle_action(self, idx), SettingsSection::Integrations => None, }; } @@ -523,6 +545,30 @@ mod tests { assert_eq!(state.mode, Mode::Settings); } + #[test] + fn settings_experiments_down_then_toggle_switches_ascii_input_source() { + let mut state = state_with_workspaces(&["test"]); + state.switch_ascii_input_source_in_prefix = false; + open_settings_at(&mut state, SettingsSection::Experiments); + + update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + assert_eq!(state.settings.list.selected, 1); + + let action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!( + action, + Some(SettingsAction::SaveSwitchAsciiInputSourceInPrefix(true)) + ); + assert_eq!(state.mode, Mode::Settings); + } + #[test] fn settings_tab_cycle_places_experiments_last() { let mut state = state_with_workspaces(&["test"]); @@ -606,6 +652,26 @@ mod tests { assert_eq!(app.state.settings.list.selected, 0); } + #[test] + fn settings_mouse_click_toggles_switch_ascii_input_source_row() { + let mut app = app_for_mouse_test(); + app.state.switch_ascii_input_source_in_prefix = false; + open_settings_at(&mut app.state, SettingsSection::Experiments); + + let area = app.state.settings_content_rect(); + let action = app.state.handle_settings_mouse(mouse( + MouseEventKind::Down(crossterm::event::MouseButton::Left), + area.x + 2, + area.y + 4, + )); + + assert_eq!( + action, + Some(SettingsAction::SaveSwitchAsciiInputSourceInPrefix(true)) + ); + assert_eq!(app.state.settings.list.selected, 1); + } + #[test] fn integration_update_badge_only_tracks_outdated_recommendations() { let mut state = state_with_workspaces(&["test"]); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5a39b8e2..18d52bfc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -121,6 +121,7 @@ pub struct App { pub(crate) overlay_panes: HashMap, pub(crate) local_terminal_notifications: bool, pub(crate) config_reloaded_from_disk: bool, + prefix_input_source: Box, } pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256; @@ -505,6 +506,9 @@ impl App { cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), + switch_ascii_input_source_in_prefix: config + .experimental + .switch_ascii_input_source_in_prefix, kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), shell_mode: config.terminal.shell_mode, @@ -598,6 +602,7 @@ impl App { overlay_panes: HashMap::new(), local_terminal_notifications: true, config_reloaded_from_disk: false, + prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()), } } @@ -675,6 +680,36 @@ impl App { self.full_redraw_pending = true; } + pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) { + match ( + previous_mode == Mode::Prefix, + self.state.mode == Mode::Prefix, + ) { + (false, true) if self.state.switch_ascii_input_source_in_prefix => { + self.prefix_input_source.switch_to_ascii(); + } + (true, false) => self.prefix_input_source.restore(), + _ => {} + } + } + + pub(crate) fn handle_internal_event_with_prefix_sync( + &mut self, + event: crate::events::AppEvent, + ) { + let previous_mode = self.state.mode; + self.handle_internal_event(event); + self.sync_prefix_input_source(previous_mode); + } + + #[cfg(test)] + pub(crate) fn set_prefix_input_source( + &mut self, + source: Box, + ) { + self.prefix_input_source = source; + } + pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { if self.input_rx.is_none() { self.input_rx = Some(crate::raw_input::spawn_input_reader()); @@ -851,7 +886,7 @@ impl App { match event { LoopEvent::Timer => {} LoopEvent::Internal(ev) => { - self.handle_internal_event(ev); + self.handle_internal_event_with_prefix_sync(ev); needs_render = true; } LoopEvent::Api(msg) => { @@ -1150,6 +1185,8 @@ impl App { self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents); self.state.cjk_ime_cursor_shape = config.experimental.cjk_ime_cursor_shape.to_decscusr(); + self.state.switch_ascii_input_source_in_prefix = + config.experimental.switch_ascii_input_source_in_prefix; self.persist_pane_history = config.experimental.pane_history; self.state.pane_history_persistence = config.experimental.pane_history; if !self.persist_pane_history { @@ -1242,6 +1279,7 @@ impl App { apply_host_terminal_theme: bool, ) { for event in events { + let previous_mode = self.state.mode; match event { crate::raw_input::RawInputEvent::Key(key) => { let key_id = repeat_key_identity(&key); @@ -1313,6 +1351,7 @@ impl App { } crate::raw_input::RawInputEvent::Unsupported => {} } + self.sync_prefix_input_source(previous_mode); } } @@ -1402,6 +1441,8 @@ mod tests { use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use std::cell::Cell; + use std::rc::Rc; use std::sync::Mutex; fn raw_key( @@ -1434,6 +1475,144 @@ mod tests { ) } + #[derive(Clone, Default)] + struct FakePrefixInputSource { + switch_calls: Rc>, + restore_calls: Rc>, + switched: Rc>, + will_switch: bool, + } + + impl FakePrefixInputSource { + fn switching() -> Self { + Self { + will_switch: true, + ..Self::default() + } + } + + fn no_op() -> Self { + Self { + will_switch: false, + ..Self::default() + } + } + } + + impl crate::platform::PrefixInputSource for FakePrefixInputSource { + fn switch_to_ascii(&mut self) { + self.switch_calls.set(self.switch_calls.get() + 1); + if self.will_switch { + self.switched.set(true); + } + } + + fn restore(&mut self) { + if self.switched.replace(false) { + self.restore_calls.set(self.restore_calls.get() + 1); + } + } + } + + #[test] + fn sync_prefix_input_source_switches_then_restores_when_enabled() { + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = true; + let fake = FakePrefixInputSource::switching(); + let switch_calls = fake.switch_calls.clone(); + let restore_calls = fake.restore_calls.clone(); + app.set_prefix_input_source(Box::new(fake)); + + // Terminal -> Prefix should switch to ASCII. + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + assert_eq!(switch_calls.get(), 1); + assert_eq!(restore_calls.get(), 0); + + // Prefix -> Terminal should restore the saved source. + app.state.mode = Mode::Terminal; + app.sync_prefix_input_source(Mode::Prefix); + assert_eq!(switch_calls.get(), 1); + assert_eq!(restore_calls.get(), 1); + } + + #[test] + fn sync_prefix_input_source_is_noop_when_flag_disabled() { + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = false; + let fake = FakePrefixInputSource::switching(); + let switch_calls = fake.switch_calls.clone(); + let restore_calls = fake.restore_calls.clone(); + app.set_prefix_input_source(Box::new(fake)); + + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + app.state.mode = Mode::Terminal; + app.sync_prefix_input_source(Mode::Prefix); + + assert_eq!(switch_calls.get(), 0); + assert_eq!(restore_calls.get(), 0); + } + + #[test] + fn sync_prefix_input_source_restore_is_safe_when_switch_was_noop() { + // Simulates the already-ASCII / failed-switch case: switch reports no + // change, and the later restore on leave must stay harmless. + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = true; + let fake = FakePrefixInputSource::no_op(); + let switch_calls = fake.switch_calls.clone(); + let restore_calls = fake.restore_calls.clone(); + app.set_prefix_input_source(Box::new(fake)); + + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + app.state.mode = Mode::Terminal; + app.sync_prefix_input_source(Mode::Prefix); + + assert_eq!(switch_calls.get(), 1); + assert_eq!(restore_calls.get(), 0); + } + + #[tokio::test] + async fn raw_input_dispatch_restores_input_source_when_leaving_prefix() { + // Leaving prefix mode happens inside the raw-input dispatch, not in + // `handle_key` itself — the sync must sit at the dispatch layer so any + // event that exits prefix (here Esc) still restores the host source. + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = true; + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + let fake = FakePrefixInputSource::switching(); + let switch_calls = fake.switch_calls.clone(); + let restore_calls = fake.restore_calls.clone(); + app.set_prefix_input_source(Box::new(fake)); + + // ctrl+b (the default prefix key) enters prefix mode → switch edge. + app.handle_raw_input_event(raw_key( + KeyCode::Char('b'), + KeyModifiers::CONTROL, + KeyEventKind::Press, + )) + .await; + assert_eq!(app.state.mode, Mode::Prefix); + assert_eq!(switch_calls.get(), 1); + assert_eq!(restore_calls.get(), 0); + + // Esc leaves prefix mode → restore edge, even though the exit is decided + // below `handle_key`. + app.handle_raw_input_event(raw_key( + KeyCode::Esc, + KeyModifiers::empty(), + KeyEventKind::Press, + )) + .await; + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(restore_calls.get(), 1); + } + fn config_env_lock() -> &'static Mutex<()> { crate::config::test_config_env_lock() } @@ -1730,7 +1909,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1769,6 +1948,7 @@ mod tests { app.state.new_terminal_cwd, crate::config::NewTerminalCwdConfig::Home ); + assert!(app.state.switch_ascii_input_source_in_prefix); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -3018,6 +3198,7 @@ mod tests { fn next_loop_deadline_includes_selection_autoscroll_deadline() { let mut app = test_app(); let now = Instant::now(); + app.next_resize_poll = now + Duration::from_millis(300); app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5)); app.next_animation_tick = Some(now + Duration::from_millis(100)); app.session_save_deadline = Some(now + Duration::from_millis(200)); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 4ddcde6c..5bfa9141 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -61,9 +61,11 @@ impl App { &mut self, msg: crate::api::ApiRequestMessage, ) -> bool { + let previous_mode = self.state.mode; let changed = crate::api::request_changes_ui(&msg.request); let response = self.handle_api_request(msg.request); let _ = msg.respond_to.send(response); + self.sync_prefix_input_source(previous_mode); changed } @@ -91,6 +93,7 @@ impl App { &mut self, event: crate::raw_input::RawInputEvent, ) -> bool { + let previous_mode = self.state.mode; let changed = match event { crate::raw_input::RawInputEvent::Key(key) => { let key_id = repeat_key_identity(&key); @@ -150,6 +153,7 @@ impl App { } crate::raw_input::RawInputEvent::Unsupported => false, }; + self.sync_prefix_input_source(previous_mode); self.shutdown_detached_terminal_runtimes(); changed } @@ -552,7 +556,7 @@ impl App { break; }; had_event = true; - self.handle_internal_event(ev); + self.handle_internal_event_with_prefix_sync(ev); } had_event } diff --git a/src/app/state.rs b/src/app/state.rs index dd1b167e..a25c7c5e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -867,6 +867,34 @@ impl SettingsSection { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExperimentSetting { + PaneHistory, + SwitchAsciiInputSourceInPrefix, +} + +impl ExperimentSetting { + pub(crate) const ALL: [Self; 2] = [Self::PaneHistory, Self::SwitchAsciiInputSourceInPrefix]; + + pub(crate) fn label(self) -> &'static str { + match self { + Self::PaneHistory => "pane screen history", + Self::SwitchAsciiInputSourceInPrefix => { + "switch to ascii input source in prefix (macOS)" + } + } + } + + pub(crate) fn enabled(self, state: &AppState) -> bool { + match self { + Self::PaneHistory => state.pane_history_persistence_enabled(), + Self::SwitchAsciiInputSourceInPrefix => { + state.switch_ascii_input_source_in_prefix_enabled() + } + } + } +} + /// All built-in theme names in display order. pub const THEME_NAMES: &[&str] = &[ "catppuccin", @@ -1268,6 +1296,11 @@ pub struct AppState { pub cjk_ime_agents: Vec, /// DECSCUSR shape parameter (1–6) for the IME anchor cursor. pub cjk_ime_cursor_shape: u8, + /// While prefix mode is active, switch the macOS host input source to an + /// ASCII-capable layout so prefix commands register as ASCII even when a + /// CJK IME is active. macOS only; a no-op elsewhere. See + /// `[experimental] switch_ascii_input_source_in_prefix`. + pub switch_ascii_input_source_in_prefix: bool, pub kitty_graphics_enabled: bool, pub default_shell: String, pub shell_mode: crate::config::ShellModeConfig, @@ -1327,6 +1360,10 @@ impl AppState { self.pane_history_persistence } + pub fn switch_ascii_input_source_in_prefix_enabled(&self) -> bool { + self.switch_ascii_input_source_in_prefix + } + pub(crate) fn integration_updates_available(&self) -> bool { self.integration_recommendations .iter() @@ -1575,6 +1612,7 @@ impl AppState { cjk_ime_agent_filter_configured: false, cjk_ime_agents: Vec::new(), cjk_ime_cursor_shape: 2, // steady_block + switch_ascii_input_source_in_prefix: false, kitty_graphics_enabled: false, default_shell: String::new(), shell_mode: crate::config::ShellModeConfig::Auto, diff --git a/src/config/model.rs b/src/config/model.rs index 7895341f..9fda88f4 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -477,6 +477,12 @@ pub struct ExperimentalConfig { /// Cursor shape rendered for the IME anchor when /// `reveal_hidden_cursor_for_cjk_ime` is enabled. Default: "steady_block". pub cjk_ime_cursor_shape: ImeCursorShape, + /// While prefix mode is active, temporarily switch the macOS host input + /// source to an ASCII-capable keyboard layout so prefix commands are read + /// as ASCII even when a CJK IME is active, then restore the previous input + /// source when prefix mode exits. macOS only; a no-op elsewhere and a + /// best-effort no-op if the switch fails. Default: false. + pub switch_ascii_input_source_in_prefix: bool, } impl Default for KeysConfig { @@ -755,6 +761,23 @@ reveal_hidden_cursor_for_cjk_ime = true assert!(config.experimental.reveal_hidden_cursor_for_cjk_ime); } + #[test] + fn switch_ascii_input_source_in_prefix_default_off_and_parse() { + let default_config = Config::default(); + assert!( + !default_config + .experimental + .switch_ascii_input_source_in_prefix + ); + + let toml = r#" +[experimental] +switch_ascii_input_source_in_prefix = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.experimental.switch_ascii_input_source_in_prefix); + } + #[test] fn cjk_ime_cursor_shape_default_steady_block_and_parse() { let default_config = Config::default(); @@ -1049,11 +1072,13 @@ kitty_graphics = true allow_nested = true kitty_graphics = true pane_history = true +switch_ascii_input_source_in_prefix = true "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.experimental.allow_nested); assert!(config.experimental.kitty_graphics); assert!(config.experimental.pane_history); + assert!(config.experimental.switch_ascii_input_source_in_prefix); } #[test] diff --git a/src/main.rs b/src/main.rs index 13ba5e6f..f88880d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -274,6 +274,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # kitty_graphics = false # Save recent pane screen history across full server restarts. pane_history = false +# While prefix mode is active, temporarily switch the macOS host input +# source to an ASCII-capable keyboard layout so prefix commands register +# even when a CJK IME is active, then restore the previous input source +# when prefix mode exits. macOS only; best-effort. Default: false. +# switch_ascii_input_source_in_prefix = false # Expose the focused pane's cursor to the outer terminal so macOS input # methods keep tracking the candidate window when TUIs paint their own # cursor (Claude Code, pi, codex). Trade-off: extra cursor visible for diff --git a/src/platform/macos.rs b/src/platform/macos.rs index e38b29eb..652b7a41 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -4,6 +4,7 @@ use std::os::fd::RawFd; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::ptr::NonNull; use std::sync::OnceLock; use super::{ @@ -13,6 +14,187 @@ use super::{ const PROC_PGRP_ONLY: u32 = 2; const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192; +const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + +#[repr(C)] +struct TisInputSource { + _private: [u8; 0], +} + +type TisInputSourceRef = *const TisInputSource; +type CfTypeRef = *const libc::c_void; +type CfStringRef = *const libc::c_void; +type OsStatus = libc::c_int; +type Boolean = libc::c_uchar; +type CfIndex = isize; + +#[link(name = "Carbon", kind = "framework")] +extern "C" { + #[link_name = "kTISPropertyInputSourceID"] + static TIS_PROPERTY_INPUT_SOURCE_ID: CfStringRef; + + #[link_name = "TISCopyCurrentKeyboardInputSource"] + fn tis_copy_current_keyboard_input_source() -> TisInputSourceRef; + + #[link_name = "TISCopyCurrentASCIICapableKeyboardLayoutInputSource"] + fn tis_copy_current_ascii_capable_keyboard_layout_input_source() -> TisInputSourceRef; + + #[link_name = "TISGetInputSourceProperty"] + fn tis_get_input_source_property( + input_source: TisInputSourceRef, + property_key: CfStringRef, + ) -> CfTypeRef; + + #[link_name = "TISSelectInputSource"] + fn tis_select_input_source(input_source: TisInputSourceRef) -> OsStatus; +} + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + #[link_name = "CFRelease"] + fn cf_release(value: CfTypeRef); + + #[link_name = "CFEqual"] + fn cf_equal(left: CfTypeRef, right: CfTypeRef) -> Boolean; + + #[link_name = "CFStringGetCStringPtr"] + fn cf_string_get_cstring_ptr(value: CfStringRef, encoding: u32) -> *const libc::c_char; + + #[link_name = "CFStringGetLength"] + fn cf_string_get_length(value: CfStringRef) -> CfIndex; + + #[link_name = "CFStringGetMaximumSizeForEncoding"] + fn cf_string_get_maximum_size_for_encoding(length: CfIndex, encoding: u32) -> CfIndex; + + #[link_name = "CFStringGetCString"] + fn cf_string_get_cstring( + value: CfStringRef, + buffer: *mut libc::c_char, + buffer_size: CfIndex, + encoding: u32, + ) -> Boolean; +} + +#[derive(Debug)] +pub(crate) struct InputSourceRestore { + previous: NonNull, +} + +impl Drop for InputSourceRestore { + fn drop(&mut self) { + // SAFETY: `previous` is a retained TIS input source created by + // `TISCopyCurrentKeyboardInputSource`; selecting it and releasing that + // retain follows the Carbon Input Source Services ownership contract. + unsafe { + let previous = self.previous.as_ptr(); + let status = tis_select_input_source(previous); + cf_release(previous.cast()); + if status != 0 { + tracing::debug!( + status, + "failed to restore host input source after prefix mode" + ); + } + } + } +} + +pub(crate) fn switch_to_ascii_input_source() -> Option { + // SAFETY: TISCopy* functions return retained references or null. Each + // retained reference is either transferred into `InputSourceRestore` or + // released before returning; TISSelectInputSource accepts live TIS refs. + unsafe { + let current = + NonNull::new(tis_copy_current_keyboard_input_source() as *mut TisInputSource)?; + let Some(ascii) = NonNull::new( + tis_copy_current_ascii_capable_keyboard_layout_input_source() as *mut TisInputSource, + ) else { + cf_release(current.as_ptr().cast()); + return None; + }; + + if input_source_ids_equal(current.as_ptr(), ascii.as_ptr()) { + cf_release(current.as_ptr().cast()); + cf_release(ascii.as_ptr().cast()); + return None; + } + + let debug_ids = tracing::enabled!(tracing::Level::DEBUG).then(|| { + ( + input_source_id(current.as_ptr()), + input_source_id(ascii.as_ptr()), + ) + }); + + let status = tis_select_input_source(ascii.as_ptr()); + cf_release(ascii.as_ptr().cast()); + if status != 0 { + cf_release(current.as_ptr().cast()); + tracing::debug!(status, "failed to switch host input source for prefix mode"); + return None; + } + + if let Some((Some(from), Some(to))) = debug_ids { + tracing::debug!(from, to, "switched host input source for prefix mode"); + } + + Some(InputSourceRestore { previous: current }) + } +} + +unsafe fn input_source_ids_equal(left: TisInputSourceRef, right: TisInputSourceRef) -> bool { + let left_property = tis_get_input_source_property(left, TIS_PROPERTY_INPUT_SOURCE_ID); + let right_property = tis_get_input_source_property(right, TIS_PROPERTY_INPUT_SOURCE_ID); + !left_property.is_null() + && !right_property.is_null() + && cf_equal(left_property, right_property) != 0 +} + +unsafe fn input_source_id(input_source: TisInputSourceRef) -> Option { + let property = + tis_get_input_source_property(input_source, TIS_PROPERTY_INPUT_SOURCE_ID) as CfStringRef; + cf_string_to_string(property) +} + +unsafe fn cf_string_to_string(value: CfStringRef) -> Option { + if value.is_null() { + return None; + } + + let direct = cf_string_get_cstring_ptr(value, CF_STRING_ENCODING_UTF8); + if !direct.is_null() { + return std::ffi::CStr::from_ptr(direct) + .to_str() + .ok() + .map(str::to_owned); + } + + let length = cf_string_get_length(value); + if length < 0 { + return None; + } + let max_bytes = cf_string_get_maximum_size_for_encoding(length, CF_STRING_ENCODING_UTF8); + if max_bytes < 0 { + return None; + } + let buffer_len = usize::try_from(max_bytes).ok()?.checked_add(1)?; + let mut buffer = vec![0 as libc::c_char; buffer_len]; + let buffer_size = CfIndex::try_from(buffer.len()).ok()?; + if cf_string_get_cstring( + value, + buffer.as_mut_ptr(), + buffer_size, + CF_STRING_ENCODING_UTF8, + ) == 0 + { + return None; + } + + std::ffi::CStr::from_ptr(buffer.as_ptr()) + .to_str() + .ok() + .map(str::to_owned) +} pub fn raise_server_nofile_limit() { match raise_nofile_limit(SERVER_NOFILE_LIMIT_TARGET) { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index a836ed81..12c275f0 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -96,6 +96,49 @@ mod fallback; #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub use fallback::*; +#[cfg(not(target_os = "macos"))] +#[derive(Debug)] +pub(crate) struct InputSourceRestore; + +#[cfg(not(target_os = "macos"))] +pub(crate) fn switch_to_ascii_input_source() -> Option { + None +} + +/// Switches the host keyboard input source while prefix mode is active. +/// +/// `App` drives this through a trait so the prefix-mode transitions can be +/// tested with a fake, without touching the real macOS APIs or leaking a +/// platform-specific restore type into `App`. +pub(crate) trait PrefixInputSource { + /// Switch to an ASCII-capable input source for prefix commands. No-op if + /// the current source is already ASCII-capable, the platform is + /// unsupported, or the switch fails. Calling it again before `restore` + /// keeps the source saved by the first call. + fn switch_to_ascii(&mut self); + + /// Restore whatever `switch_to_ascii` saved. No-op if nothing was switched. + fn restore(&mut self); +} + +/// Production [`PrefixInputSource`] backed by the per-platform API. +#[derive(Default)] +pub(crate) struct RealPrefixInputSource { + restore: Option, +} + +impl PrefixInputSource for RealPrefixInputSource { + fn switch_to_ascii(&mut self) { + if self.restore.is_none() { + self.restore = switch_to_ascii_input_source(); + } + } + + fn restore(&mut self) { + let _ = self.restore.take(); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 2e5ae806..b7104ea6 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -11,7 +11,10 @@ use super::widgets::{ render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec, }; use crate::{ - app::{state::Palette, AppState}, + app::{ + state::{ExperimentSetting, Palette}, + AppState, + }, config::ToastDelivery, }; @@ -395,24 +398,22 @@ fn render_settings_experiments(app: &AppState, frame: &mut Frame, area: Rect) { Style::default().fg(p.overlay1), ); - let marker = if app.pane_history_persistence_enabled() { - "[✓]" - } else { - "[ ]" - }; - let style = if app.settings.list.selected == 0 { - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(p.subtext0) - }; - let row = Rect::new(list_area.x, list_area.y, list_area.width, 1); - frame.render_widget( - Paragraph::new(format!(" pane screen history {marker}")).style(style), - row, - ); + for (idx, setting) in ExperimentSetting::ALL.iter().copied().enumerate() { + let marker = if setting.enabled(app) { "[✓]" } else { "[ ]" }; + let style = if app.settings.list.selected == idx { + Style::default() + .bg(p.surface0) + .fg(p.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.subtext0) + }; + let row = Rect::new(list_area.x, list_area.y + idx as u16, list_area.width, 1); + frame.render_widget( + Paragraph::new(format!(" {} {marker}", setting.label())).style(style), + row, + ); + } } #[cfg(test)] @@ -471,4 +472,29 @@ mod tests { assert!(rendered.contains("pane screen history [ ]")); } + + #[test] + fn experiments_renders_switch_ascii_input_source_row() { + let mut app = AppState::test_new(); + app.switch_ascii_input_source_in_prefix = true; + app.settings.section = SettingsSection::Experiments; + app.settings.list.selected = 1; + app.mode = Mode::Settings; + + let mut terminal = + Terminal::new(TestBackend::new(80, 24)).expect("test terminal should initialize"); + terminal + .draw(|frame| render_settings_overlay(&app, frame, Rect::new(0, 0, 80, 24))) + .expect("settings overlay should render"); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(rendered.contains("switch to ascii input source in prefix (macOS) [✓]")); + } }