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 <ogulcancelik@gmail.com>
This commit is contained in:
parent
03f68b7275
commit
f5e66b251f
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SettingsAction> {
|
||||
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"]);
|
||||
|
|
|
|||
185
src/app/mod.rs
185
src/app/mod.rs
|
|
@ -121,6 +121,7 @@ pub struct App {
|
|||
pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
|
||||
pub(crate) local_terminal_notifications: bool,
|
||||
pub(crate) config_reloaded_from_disk: bool,
|
||||
prefix_input_source: Box<dyn crate::platform::PrefixInputSource>,
|
||||
}
|
||||
|
||||
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<dyn crate::platform::PrefixInputSource>,
|
||||
) {
|
||||
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<Cell<usize>>,
|
||||
restore_calls: Rc<Cell<usize>>,
|
||||
switched: Rc<Cell<bool>>,
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<crate::detect::Agent>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TisInputSource>,
|
||||
}
|
||||
|
||||
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<InputSourceRestore> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<InputSourceRestore> {
|
||||
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<InputSourceRestore>,
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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::<String>();
|
||||
|
||||
assert!(rendered.contains("switch to ascii input source in prefix (macOS) [✓]"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue