parent
2a20e90a02
commit
e7fc85bfdb
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"protocol": 17,
|
||||
"protocol": 18,
|
||||
"schema_version": 1,
|
||||
"schemas": {
|
||||
"error_response": {
|
||||
|
|
|
|||
|
|
@ -2571,6 +2571,80 @@ mod tests {
|
|||
assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_release_follows_pane_moved_across_workspaces() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
let source = app.state.workspaces[0].tabs[0].root_pane;
|
||||
let source_terminal_id = app.state.workspaces[0].tabs[0]
|
||||
.terminal_id(source)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let (runtime, mut rx) =
|
||||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||||
80,
|
||||
24,
|
||||
0,
|
||||
b"\x1b[>15u",
|
||||
2,
|
||||
);
|
||||
app.terminal_runtimes.insert(source_terminal_id, runtime);
|
||||
app.state.workspaces.push(Workspace::test_new("other"));
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
let source_public = app.public_pane_id(0, source).unwrap();
|
||||
let target = app.state.workspaces[1].tabs[0].root_pane;
|
||||
let target_tab_id = app.public_tab_id(1, 0).unwrap();
|
||||
let target_pane_id = app.public_pane_id(1, target).unwrap();
|
||||
|
||||
app.route_client_events_from(
|
||||
42,
|
||||
vec![crate::raw_input::RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(
|
||||
crossterm::event::KeyCode::Char('j'),
|
||||
crossterm::event::KeyModifiers::empty(),
|
||||
),
|
||||
)],
|
||||
false,
|
||||
);
|
||||
let response = app.handle_pane_move(
|
||||
"req".into(),
|
||||
PaneMoveParams {
|
||||
pane_id: source_public,
|
||||
destination: PaneMoveDestination::Tab {
|
||||
tab_id: target_tab_id,
|
||||
target_pane_id: Some(target_pane_id),
|
||||
split: SplitDirection::Down,
|
||||
ratio: None,
|
||||
},
|
||||
focus: false,
|
||||
},
|
||||
);
|
||||
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
assert!(matches!(success.result, ResponseResult::PaneMove { .. }));
|
||||
app.route_client_events_from(
|
||||
42,
|
||||
vec![crate::raw_input::RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(
|
||||
crossterm::event::KeyCode::Char('j'),
|
||||
crossterm::event::KeyModifiers::empty(),
|
||||
)
|
||||
.with_kind(crossterm::event::KeyEventKind::Release),
|
||||
)],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("forwarded press"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("forwarded release after pane move"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() {
|
||||
let mut app = app_with_linked_worktree();
|
||||
|
|
|
|||
|
|
@ -71,21 +71,23 @@ use super::App;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl App {
|
||||
pub(super) async fn handle_key(&mut self, key: TerminalKey) {
|
||||
pub(super) async fn handle_key(
|
||||
&mut self,
|
||||
key: TerminalKey,
|
||||
) -> Option<super::TerminalInputTarget> {
|
||||
if self.state.popup_pane.is_some() {
|
||||
self.handle_terminal_key(key).await;
|
||||
return;
|
||||
return self.handle_terminal_key(key).await;
|
||||
}
|
||||
let key_event = key.as_key_event();
|
||||
if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) {
|
||||
if let Some(text) = crate::platform::read_clipboard_text() {
|
||||
self.paste_into_active_text_input(&text);
|
||||
}
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.state.mode {
|
||||
Mode::Terminal => self.handle_terminal_key(key).await,
|
||||
Mode::Terminal => return self.handle_terminal_key(key).await,
|
||||
Mode::Prefix => self.handle_prefix_key(key),
|
||||
Mode::Navigate => self.handle_navigate_key(key),
|
||||
Mode::Copy => self.handle_copy_mode_key(key),
|
||||
|
|
@ -114,6 +116,7 @@ impl App {
|
|||
Mode::Terminal => unreachable!(),
|
||||
},
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) async fn handle_paste(&mut self, text: String) {
|
||||
|
|
|
|||
|
|
@ -3,20 +3,24 @@ use crossterm::event::KeyCode;
|
|||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{
|
||||
app::{App, Mode},
|
||||
app::{App, Mode, TerminalInputTarget},
|
||||
input::TerminalKey,
|
||||
};
|
||||
|
||||
struct PreparedPaneInput {
|
||||
ws_idx: usize,
|
||||
pane_id: crate::layout::PaneId,
|
||||
target: TerminalInputTarget,
|
||||
bytes: Bytes,
|
||||
}
|
||||
|
||||
enum PreparedPopupInput {
|
||||
NotOpen,
|
||||
Consumed,
|
||||
Bytes(Bytes),
|
||||
Bytes {
|
||||
target: TerminalInputTarget,
|
||||
bytes: Bytes,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_modifier_only_key(code: &KeyCode) -> bool {
|
||||
|
|
@ -24,26 +28,27 @@ fn is_modifier_only_key(code: &KeyCode) -> bool {
|
|||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) {
|
||||
pub(crate) fn handle_terminal_key_headless(
|
||||
&mut self,
|
||||
key: TerminalKey,
|
||||
) -> Option<TerminalInputTarget> {
|
||||
match self.prepare_popup_key_forward(key) {
|
||||
PreparedPopupInput::NotOpen => {}
|
||||
PreparedPopupInput::Consumed => return,
|
||||
PreparedPopupInput::Bytes(bytes) => {
|
||||
PreparedPopupInput::Consumed => return None,
|
||||
PreparedPopupInput::Bytes { target, bytes } => {
|
||||
let Some(runtime) = self.popup_runtime() else {
|
||||
self.close_popup_pane();
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
let _ = runtime.try_send_bytes(bytes);
|
||||
return;
|
||||
return runtime.try_send_bytes(bytes).is_ok().then_some(target);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(input) = self.prepare_terminal_key_forward(key) else {
|
||||
return;
|
||||
};
|
||||
if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
|
||||
let _ = runtime.try_send_bytes(input.bytes);
|
||||
}
|
||||
let input = self.prepare_terminal_key_forward(key)?;
|
||||
let sent = self
|
||||
.lookup_runtime_sender(input.ws_idx, input.pane_id)
|
||||
.is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok());
|
||||
sent.then_some(input.target)
|
||||
}
|
||||
|
||||
fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> {
|
||||
|
|
@ -116,6 +121,7 @@ impl App {
|
|||
let ws_idx = self.state.active?;
|
||||
let ws = self.state.workspaces.get(ws_idx)?;
|
||||
let pane_id = ws.focused_pane_id()?;
|
||||
let terminal_id = ws.terminal_id(pane_id)?.clone();
|
||||
let rt =
|
||||
self.state
|
||||
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
|
||||
|
|
@ -207,6 +213,7 @@ impl App {
|
|||
Some(PreparedPaneInput {
|
||||
ws_idx,
|
||||
pane_id,
|
||||
target: TerminalInputTarget { terminal_id },
|
||||
bytes: Bytes::from(bytes),
|
||||
})
|
||||
}
|
||||
|
|
@ -215,7 +222,15 @@ impl App {
|
|||
if self.state.popup_pane.is_none() {
|
||||
return PreparedPopupInput::NotOpen;
|
||||
}
|
||||
let Some(rt) = self.popup_runtime() else {
|
||||
let Some(terminal_id) = self
|
||||
.state
|
||||
.popup_pane
|
||||
.as_ref()
|
||||
.map(|popup| popup.terminal_id.clone())
|
||||
else {
|
||||
return PreparedPopupInput::NotOpen;
|
||||
};
|
||||
let Some(rt) = self.terminal_runtimes.get(&terminal_id) else {
|
||||
self.close_popup_pane();
|
||||
return PreparedPopupInput::Consumed;
|
||||
};
|
||||
|
|
@ -225,30 +240,136 @@ impl App {
|
|||
if bytes.is_empty() {
|
||||
PreparedPopupInput::Consumed
|
||||
} else {
|
||||
PreparedPopupInput::Bytes(Bytes::from(bytes))
|
||||
PreparedPopupInput::Bytes {
|
||||
target: TerminalInputTarget { terminal_id },
|
||||
bytes: Bytes::from(bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) {
|
||||
pub(crate) fn host_keyboard_report_all_requested(&self) -> bool {
|
||||
let runtime = if self.state.popup_pane.is_some() {
|
||||
self.popup_runtime()
|
||||
} else if self.state.mode == Mode::Terminal {
|
||||
self.state.active.and_then(|ws_idx| {
|
||||
self.state
|
||||
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
runtime.is_some_and(|runtime| runtime.keyboard_protocol().reports_all_keys())
|
||||
}
|
||||
|
||||
fn terminal_input_runtime(
|
||||
&self,
|
||||
target: &TerminalInputTarget,
|
||||
) -> Option<&crate::terminal::TerminalRuntime> {
|
||||
if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) {
|
||||
return Some(runtime);
|
||||
}
|
||||
#[cfg(test)]
|
||||
for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
|
||||
for tab in &workspace.tabs {
|
||||
for (&pane_id, pane) in &tab.panes {
|
||||
if pane.attached_terminal_id == target.terminal_id {
|
||||
return self.state.runtime_for_pane_in_workspace(
|
||||
&self.terminal_runtimes,
|
||||
ws_idx,
|
||||
pane_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn forward_terminal_key_to_target_headless(
|
||||
&self,
|
||||
target: &TerminalInputTarget,
|
||||
key: TerminalKey,
|
||||
) -> bool {
|
||||
let Some(runtime) = self.terminal_input_runtime(target) else {
|
||||
return false;
|
||||
};
|
||||
let bytes = runtime.encode_terminal_key(key);
|
||||
bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok()
|
||||
}
|
||||
|
||||
pub(crate) async fn forward_terminal_key_to_target(
|
||||
&self,
|
||||
target: &TerminalInputTarget,
|
||||
key: TerminalKey,
|
||||
) -> bool {
|
||||
let Some(runtime) = self.terminal_input_runtime(target) else {
|
||||
return false;
|
||||
};
|
||||
let bytes = runtime.encode_terminal_key(key);
|
||||
bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok()
|
||||
}
|
||||
|
||||
fn take_pressed_keys_for_source(
|
||||
&mut self,
|
||||
source_id: crate::app::InputSourceId,
|
||||
) -> Vec<crate::app::PressedTerminalKey> {
|
||||
let pressed = self
|
||||
.pressed_terminal_keys
|
||||
.iter()
|
||||
.filter(|((id, _), _)| *id == source_id)
|
||||
.map(|(_, pressed)| pressed.clone())
|
||||
.collect();
|
||||
self.pressed_terminal_keys
|
||||
.retain(|(id, _), _| *id != source_id);
|
||||
self.suppressed_repeat_keys
|
||||
.retain(|(id, _)| *id != source_id);
|
||||
pressed
|
||||
}
|
||||
|
||||
pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
|
||||
for pressed in self.take_pressed_keys_for_source(source_id) {
|
||||
let release = pressed
|
||||
.key
|
||||
.with_kind(crossterm::event::KeyEventKind::Release);
|
||||
let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
|
||||
for pressed in self.take_pressed_keys_for_source(source_id) {
|
||||
let release = pressed
|
||||
.key
|
||||
.with_kind(crossterm::event::KeyEventKind::Release);
|
||||
let _ = self
|
||||
.forward_terminal_key_to_target(&pressed.target, release)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_terminal_key(
|
||||
&mut self,
|
||||
key: TerminalKey,
|
||||
) -> Option<TerminalInputTarget> {
|
||||
match self.prepare_popup_key_forward(key) {
|
||||
PreparedPopupInput::NotOpen => {}
|
||||
PreparedPopupInput::Consumed => return,
|
||||
PreparedPopupInput::Bytes(bytes) => {
|
||||
PreparedPopupInput::Consumed => return None,
|
||||
PreparedPopupInput::Bytes { target, bytes } => {
|
||||
let Some(runtime) = self.popup_runtime() else {
|
||||
self.close_popup_pane();
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
let _ = runtime.send_bytes(bytes).await;
|
||||
return;
|
||||
return runtime.send_bytes(bytes).await.is_ok().then_some(target);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(input) = self.prepare_terminal_key_forward(key) else {
|
||||
return;
|
||||
let input = self.prepare_terminal_key_forward(key)?;
|
||||
let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
|
||||
runtime.send_bytes(input.bytes).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
|
||||
let _ = runtime.send_bytes(input.bytes).await;
|
||||
}
|
||||
sent.then_some(input.target)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
503
src/app/mod.rs
503
src/app/mod.rs
|
|
@ -135,8 +135,9 @@ pub struct App {
|
|||
pub(crate) detached_custom_command_children: Vec<std::process::Child>,
|
||||
pub(crate) persist_pane_history: bool,
|
||||
pub(crate) last_render_at: Option<Instant>,
|
||||
pub(crate) suppressed_repeat_keys:
|
||||
HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
|
||||
pub(crate) pressed_terminal_keys:
|
||||
HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>,
|
||||
pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>,
|
||||
pub render_notify: Arc<Notify>,
|
||||
pub render_dirty: Arc<AtomicBool>,
|
||||
pub(crate) full_redraw_pending: bool,
|
||||
|
|
@ -197,10 +198,25 @@ async fn sleep_until_or_pending(deadline: Option<Instant>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn repeat_key_identity(
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TerminalInputTarget {
|
||||
terminal_id: crate::terminal::TerminalId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PressedTerminalKey {
|
||||
target: TerminalInputTarget,
|
||||
key: crate::input::TerminalKey,
|
||||
}
|
||||
|
||||
pub(crate) type InputSourceId = u64;
|
||||
const LOCAL_INPUT_SOURCE: InputSourceId = 0;
|
||||
|
||||
fn pressed_key_identity(
|
||||
source_id: InputSourceId,
|
||||
key: &crate::input::TerminalKey,
|
||||
) -> (crossterm::event::KeyCode, crossterm::event::KeyModifiers) {
|
||||
(key.code, key.modifiers)
|
||||
) -> (InputSourceId, crossterm::event::KeyCode) {
|
||||
(source_id, key.code)
|
||||
}
|
||||
|
||||
fn auto_updates_enabled(no_session: bool) -> bool {
|
||||
|
|
@ -746,6 +762,7 @@ impl App {
|
|||
selection_highlight_clear_deadline: None,
|
||||
persist_pane_history: config.experimental.pane_history,
|
||||
last_render_at: None,
|
||||
pressed_terminal_keys: HashMap::new(),
|
||||
suppressed_repeat_keys: HashSet::new(),
|
||||
api_rx,
|
||||
event_hub,
|
||||
|
|
@ -895,6 +912,7 @@ impl App {
|
|||
|
||||
let mut needs_render = true;
|
||||
let mut host_mouse_capture_active = self.state.mouse_capture;
|
||||
let mut host_keyboard_report_all_active = false;
|
||||
|
||||
while !self.state.should_quit {
|
||||
self.reap_finished_custom_commands();
|
||||
|
|
@ -1021,6 +1039,7 @@ impl App {
|
|||
let now = Instant::now();
|
||||
self.sync_animation_timer(now);
|
||||
self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
|
||||
self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
|
||||
|
||||
if needs_render && self.can_render_now(now) {
|
||||
self.render_dirty.swap(false, Ordering::AcqRel);
|
||||
|
|
@ -1137,6 +1156,16 @@ impl App {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> {
|
||||
let desired = self.host_keyboard_report_all_requested();
|
||||
if desired == *active {
|
||||
return Ok(());
|
||||
}
|
||||
crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?;
|
||||
*active = desired;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> {
|
||||
let desired = self
|
||||
.state
|
||||
|
|
@ -1570,35 +1599,66 @@ impl App {
|
|||
&mut self,
|
||||
events: Vec<crate::raw_input::RawInputEvent>,
|
||||
apply_host_terminal_theme: bool,
|
||||
) {
|
||||
self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme);
|
||||
}
|
||||
|
||||
pub(crate) fn route_client_events_from(
|
||||
&mut self,
|
||||
source_id: InputSourceId,
|
||||
events: Vec<crate::raw_input::RawInputEvent>,
|
||||
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);
|
||||
let pressed_key_id = pressed_key_identity(source_id, &key);
|
||||
match key.kind {
|
||||
crossterm::event::KeyEventKind::Press => {
|
||||
if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal
|
||||
{
|
||||
self.suppressed_repeat_keys.remove(&key_id);
|
||||
self.handle_terminal_key_headless(key);
|
||||
self.suppressed_repeat_keys.remove(&pressed_key_id);
|
||||
if let Some(target) = self.handle_terminal_key_headless(key) {
|
||||
if !key.is_text_commit {
|
||||
self.pressed_terminal_keys.insert(
|
||||
pressed_key_id,
|
||||
PressedTerminalKey { target, key },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.pressed_terminal_keys.remove(&pressed_key_id);
|
||||
}
|
||||
} else {
|
||||
self.suppressed_repeat_keys.insert(key_id);
|
||||
self.pressed_terminal_keys.remove(&pressed_key_id);
|
||||
self.suppressed_repeat_keys.insert(pressed_key_id);
|
||||
self.handle_non_terminal_key_headless(key);
|
||||
}
|
||||
}
|
||||
crossterm::event::KeyEventKind::Repeat => {
|
||||
if (self.state.popup_pane.is_some()
|
||||
|| self.state.mode == Mode::Terminal)
|
||||
&& !self.suppressed_repeat_keys.contains(&key_id)
|
||||
if let Some(pressed) =
|
||||
self.pressed_terminal_keys.get(&pressed_key_id).cloned()
|
||||
{
|
||||
self.handle_terminal_key_headless(key);
|
||||
if !self
|
||||
.forward_terminal_key_to_target_headless(&pressed.target, key)
|
||||
{
|
||||
self.pressed_terminal_keys.remove(&pressed_key_id);
|
||||
}
|
||||
} else if (self.state.popup_pane.is_some()
|
||||
|| self.state.mode == Mode::Terminal)
|
||||
&& !self.suppressed_repeat_keys.contains(&pressed_key_id)
|
||||
{
|
||||
let _ = self.handle_terminal_key_headless(key);
|
||||
}
|
||||
// Repeats in non-terminal modes are ignored
|
||||
// (same as monolithic behavior).
|
||||
}
|
||||
crossterm::event::KeyEventKind::Release => {
|
||||
self.suppressed_repeat_keys.remove(&key_id);
|
||||
self.suppressed_repeat_keys.remove(&pressed_key_id);
|
||||
if let Some(pressed) =
|
||||
self.pressed_terminal_keys.remove(&pressed_key_id)
|
||||
{
|
||||
let _ = self
|
||||
.forward_terminal_key_to_target_headless(&pressed.target, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1634,6 +1694,7 @@ impl App {
|
|||
self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
|
||||
}
|
||||
crate::raw_input::RawInputEvent::OuterFocusLost => {
|
||||
self.release_input_source_headless(source_id);
|
||||
self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
|
||||
}
|
||||
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
|
||||
|
|
@ -1652,6 +1713,10 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) {
|
||||
self.release_input_source_headless(source_id);
|
||||
}
|
||||
|
||||
/// Handles a key event in non-terminal mode for the headless server.
|
||||
///
|
||||
/// Uses the standalone handler functions that work on `&mut AppState`
|
||||
|
|
@ -3298,6 +3363,43 @@ mod tests {
|
|||
assert!(handled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn monolithic_input_forwards_report_all_printable_event_kinds() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
for kind in [
|
||||
KeyEventKind::Press,
|
||||
KeyEventKind::Repeat,
|
||||
KeyEventKind::Release,
|
||||
] {
|
||||
app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind))
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:2u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outer_focus_gained_marks_visible_done_panes_seen() {
|
||||
let mut app = test_app();
|
||||
|
|
@ -4908,6 +5010,375 @@ last_pane = "prefix+tab"
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_report_all_follows_the_focused_terminal_protocol() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, _rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
assert!(app.host_keyboard_report_all_requested());
|
||||
|
||||
app.state
|
||||
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
|
||||
.unwrap()
|
||||
.test_process_pty_bytes(b"\x1b[<u");
|
||||
assert!(!app.host_keyboard_report_all_requested());
|
||||
|
||||
app.state
|
||||
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
|
||||
.unwrap()
|
||||
.test_process_pty_bytes(b"\x1b[>15u");
|
||||
let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
|
||||
let (other_runtime, _rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1);
|
||||
app.state.workspaces[0].tabs[0]
|
||||
.runtimes
|
||||
.insert(other_pane, other_runtime);
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane));
|
||||
assert!(!app.host_keyboard_report_all_requested());
|
||||
|
||||
assert!(app.state.focus_pane_in_workspace(0, focused));
|
||||
app.state.mode = Mode::Prefix;
|
||||
assert!(!app.host_keyboard_report_all_requested());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn route_client_input_forwards_report_all_printable_event_kinds() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec());
|
||||
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:2u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_ime_text_bypasses_report_all_key_encoding() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input("你".as_bytes().to_vec());
|
||||
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static("你".as_bytes())
|
||||
);
|
||||
assert!(app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input(b"A".to_vec());
|
||||
|
||||
assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A"));
|
||||
assert!(app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_text_does_not_erase_owned_physical_key() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input(b"\x1b[106u".to_vec());
|
||||
app.route_client_input(b"j".to_vec());
|
||||
app.route_client_input(b"\x1b[106;1:3u".to_vec());
|
||||
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("physical press"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("committed text"),
|
||||
bytes::Bytes::from_static(b"j")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("physical release"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outer_focus_loss_releases_owned_report_all_keys() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_events(
|
||||
vec![
|
||||
raw_key(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
KeyEventKind::Press,
|
||||
),
|
||||
crate::raw_input::RawInputEvent::OuterFocusLost,
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("forwarded press"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("synthetic release on focus loss"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnected_input_source_releases_owned_report_all_keys() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_events_from(
|
||||
42,
|
||||
vec![raw_key(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
KeyEventKind::Press,
|
||||
)],
|
||||
false,
|
||||
);
|
||||
app.clear_input_source(42);
|
||||
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("forwarded press"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("synthetic release on disconnect"),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_all_repeat_and_release_return_to_the_pressed_pane() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let pressed_pane = workspace.focused_pane_id().unwrap();
|
||||
let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
||||
workspace.tabs[0].layout.focus_pane(pressed_pane);
|
||||
let (pressed_runtime, mut pressed_rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
|
||||
let (other_runtime, mut other_rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
|
||||
workspace.tabs[0]
|
||||
.runtimes
|
||||
.insert(pressed_pane, pressed_runtime);
|
||||
workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input(b"\x1b[106u".to_vec());
|
||||
assert!(app.state.focus_pane_in_workspace(0, other_pane));
|
||||
app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec());
|
||||
|
||||
assert_eq!(
|
||||
pressed_rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
pressed_rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:2u")
|
||||
);
|
||||
assert_eq!(
|
||||
pressed_rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(pressed_rx.try_recv().is_err());
|
||||
assert!(other_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_all_key_ownership_is_isolated_by_client() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let first_pane = workspace.focused_pane_id().unwrap();
|
||||
let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
||||
workspace.tabs[0].layout.focus_pane(first_pane);
|
||||
let (first_runtime, mut first_rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
|
||||
let (second_runtime, mut second_rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
|
||||
workspace.tabs[0].runtimes.insert(first_pane, first_runtime);
|
||||
workspace.tabs[0]
|
||||
.runtimes
|
||||
.insert(second_pane, second_runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_events_from(
|
||||
1,
|
||||
vec![raw_key(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
KeyEventKind::Press,
|
||||
)],
|
||||
false,
|
||||
);
|
||||
assert!(app.state.focus_pane_in_workspace(0, second_pane));
|
||||
app.route_client_events_from(
|
||||
2,
|
||||
vec![raw_key(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
KeyEventKind::Press,
|
||||
)],
|
||||
false,
|
||||
);
|
||||
app.route_client_events_from(
|
||||
1,
|
||||
vec![raw_key(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
KeyEventKind::Release,
|
||||
)],
|
||||
false,
|
||||
);
|
||||
app.route_client_events_from(
|
||||
2,
|
||||
vec![raw_key(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
KeyEventKind::Release,
|
||||
)],
|
||||
false,
|
||||
);
|
||||
|
||||
for rx in [&mut first_rx, &mut second_rx] {
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_all_release_survives_modifier_release_order() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec());
|
||||
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106:74;2:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv().await.unwrap(),
|
||||
bytes::Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn route_client_input_does_not_forward_release_for_consumed_prefix() {
|
||||
let mut app = test_app();
|
||||
let mut workspace = Workspace::test_new("test");
|
||||
let focused = workspace.focused_pane_id().unwrap();
|
||||
let (runtime, mut rx) =
|
||||
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
|
||||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||||
app.state.workspaces = vec![workspace];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec());
|
||||
|
||||
assert_eq!(app.state.mode, Mode::Prefix);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() {
|
||||
let mut app = test_app();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
|
|||
use crossterm::terminal;
|
||||
|
||||
use super::{
|
||||
background_update_check_enabled, repeat_key_identity, App, Mode, ANIMATION_INTERVAL,
|
||||
background_update_check_enabled, pressed_key_identity, App, ANIMATION_INTERVAL,
|
||||
AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL,
|
||||
RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
|
||||
};
|
||||
|
|
@ -139,20 +139,42 @@ impl App {
|
|||
let previous_mode = self.state.mode;
|
||||
let changed = match event {
|
||||
crate::raw_input::RawInputEvent::Key(key) => {
|
||||
let key_id = repeat_key_identity(&key);
|
||||
let pressed_key_id = pressed_key_identity(super::LOCAL_INPUT_SOURCE, &key);
|
||||
match key.kind {
|
||||
crossterm::event::KeyEventKind::Press => {
|
||||
if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal {
|
||||
self.suppressed_repeat_keys.remove(&key_id);
|
||||
if self.state.popup_pane.is_some()
|
||||
|| self.state.mode == crate::app::Mode::Terminal
|
||||
{
|
||||
self.suppressed_repeat_keys.remove(&pressed_key_id);
|
||||
} else {
|
||||
self.suppressed_repeat_keys.insert(key_id);
|
||||
self.suppressed_repeat_keys.insert(pressed_key_id);
|
||||
}
|
||||
if let Some(target) = self.handle_key(key).await {
|
||||
if !key.is_text_commit {
|
||||
self.pressed_terminal_keys.insert(
|
||||
pressed_key_id,
|
||||
super::PressedTerminalKey { target, key },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.pressed_terminal_keys.remove(&pressed_key_id);
|
||||
}
|
||||
self.handle_key(key).await;
|
||||
true
|
||||
}
|
||||
crossterm::event::KeyEventKind::Repeat => {
|
||||
if (self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal)
|
||||
&& !self.suppressed_repeat_keys.contains(&key_id)
|
||||
if let Some(pressed) =
|
||||
self.pressed_terminal_keys.get(&pressed_key_id).cloned()
|
||||
{
|
||||
if !self
|
||||
.forward_terminal_key_to_target(&pressed.target, key)
|
||||
.await
|
||||
{
|
||||
self.pressed_terminal_keys.remove(&pressed_key_id);
|
||||
}
|
||||
true
|
||||
} else if (self.state.popup_pane.is_some()
|
||||
|| self.state.mode == crate::app::Mode::Terminal)
|
||||
&& !self.suppressed_repeat_keys.contains(&pressed_key_id)
|
||||
{
|
||||
self.handle_key(key).await;
|
||||
true
|
||||
|
|
@ -161,7 +183,12 @@ impl App {
|
|||
}
|
||||
}
|
||||
crossterm::event::KeyEventKind::Release => {
|
||||
self.suppressed_repeat_keys.remove(&key_id);
|
||||
self.suppressed_repeat_keys.remove(&pressed_key_id);
|
||||
if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) {
|
||||
let _ = self
|
||||
.forward_terminal_key_to_target(&pressed.target, key)
|
||||
.await;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +216,7 @@ impl App {
|
|||
true
|
||||
}
|
||||
crate::raw_input::RawInputEvent::OuterFocusLost => {
|
||||
self.release_input_source(super::LOCAL_INPUT_SOURCE).await;
|
||||
self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
|
||||
self.state.outer_terminal_focus = Some(false);
|
||||
false
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ fn windows_crossterm_reader_loop(
|
|||
tracing::debug!(event = ?event, "windows control key forwarded as semantic input");
|
||||
}
|
||||
|
||||
let Some(event) = crate::protocol::ClientInputEvent::from_crossterm(event) else {
|
||||
let Some(event) = windows_crossterm_input_event(event) else {
|
||||
continue;
|
||||
};
|
||||
if event_tx
|
||||
|
|
@ -226,6 +226,21 @@ fn windows_crossterm_reader_loop(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_crossterm_input_event(
|
||||
event: crossterm::event::Event,
|
||||
) -> Option<crate::protocol::ClientInputEvent> {
|
||||
let event = crate::protocol::ClientInputEvent::from_crossterm(event)?;
|
||||
match event {
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char(codepoint),
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
} => Some(crate::protocol::ClientInputEvent::Text { codepoint }),
|
||||
event => Some(event),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool {
|
||||
use crossterm::event::{Event, KeyModifiers};
|
||||
|
|
@ -308,11 +323,22 @@ fn windows_client_input_event_from_raw(
|
|||
event: crate::raw_input::RawInputEvent,
|
||||
) -> Option<crate::protocol::ClientInputEvent> {
|
||||
match event {
|
||||
crate::raw_input::RawInputEvent::Key(key) => Some(crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::from_crossterm(key.code)?,
|
||||
modifiers: key.modifiers.bits(),
|
||||
kind: crate::protocol::ClientKeyKind::from_crossterm(key.kind),
|
||||
}),
|
||||
crate::raw_input::RawInputEvent::Key(key) if key.is_text_commit => {
|
||||
let crossterm::event::KeyCode::Char(codepoint) = key.code else {
|
||||
return None;
|
||||
};
|
||||
Some(crate::protocol::ClientInputEvent::Text { codepoint })
|
||||
}
|
||||
crate::raw_input::RawInputEvent::Key(key) => {
|
||||
let code = crate::protocol::ClientKeyCode::from_crossterm(key.code)?;
|
||||
let modifiers = key.modifiers.bits();
|
||||
let kind = crate::protocol::ClientKeyKind::from_crossterm(key.kind);
|
||||
Some(crate::protocol::ClientInputEvent::Key {
|
||||
code,
|
||||
modifiers,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
crate::raw_input::RawInputEvent::Mouse(mouse) => {
|
||||
Some(crate::protocol::ClientInputEvent::Mouse {
|
||||
kind: crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?,
|
||||
|
|
@ -473,6 +499,16 @@ mod windows_tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_crossterm_printable_press_is_text() {
|
||||
let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty()));
|
||||
|
||||
assert_eq!(
|
||||
windows_crossterm_input_event(event),
|
||||
Some(crate::protocol::ClientInputEvent::Text { codepoint: '你' })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_ctrl_bracket_starts_raw_escape_sequence() {
|
||||
let ctrl_bracket = Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::CONTROL));
|
||||
|
|
|
|||
|
|
@ -622,6 +622,12 @@ impl WindowsInputMapper {
|
|||
kind: crate::protocol::ClientKeyKind,
|
||||
) -> Option<crate::protocol::ClientInputEvent> {
|
||||
let modifiers = windows_key_modifiers(key.control_key_state);
|
||||
if key.virtual_key_code == 0 {
|
||||
let codepoint = self.utf16_unit_to_char(key.unicode)?;
|
||||
if !codepoint.is_control() {
|
||||
return Some(crate::protocol::ClientInputEvent::Text { codepoint });
|
||||
}
|
||||
}
|
||||
if modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
|
||||
&& key.unicode == 0x000a
|
||||
&& (key.virtual_key_code == 0x4a || key.virtual_scan_code == 0x24)
|
||||
|
|
@ -1219,6 +1225,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vti_win32_input_mode_marks_ime_commit_as_text() {
|
||||
for control_key_state in [0, 0x0010, 0x0008] {
|
||||
let records = win32_input_mode_encoded_record(WindowsKeyRecord {
|
||||
key_down: true,
|
||||
repeat_count: 1,
|
||||
virtual_key_code: 0,
|
||||
virtual_scan_code: 0,
|
||||
unicode: '你' as u16,
|
||||
control_key_state,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
translate(records),
|
||||
vec![crate::protocol::ClientInputEvent::Text { codepoint: '你' }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vti_win32_input_mode_decoded_paste_flag_clears_after_raw_completion() {
|
||||
let mut records = win32_input_mode_encoded_key_bytes(b"\x1b[200~");
|
||||
|
|
@ -1694,11 +1719,7 @@ mod tests {
|
|||
assert_eq!(
|
||||
translate(records),
|
||||
vec![
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('a'),
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
},
|
||||
crate::protocol::ClientInputEvent::Text { codepoint: 'a' },
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('a'),
|
||||
modifiers: 0,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ struct ClientState {
|
|||
blit_encoder: render_ansi::BlitEncoder,
|
||||
/// Whether host mouse capture is currently active.
|
||||
mouse_capture_active: bool,
|
||||
/// Whether the host terminal currently reports all keys as Kitty sequences.
|
||||
keyboard_report_all_active: bool,
|
||||
/// The terminal size we reported to the server in our last Hello/Resize.
|
||||
reported_size: (u16, u16),
|
||||
/// Client-local sound playback config, refreshed on server request.
|
||||
|
|
@ -1290,6 +1292,7 @@ async fn run_client_loop(
|
|||
let mut state = ClientState {
|
||||
blit_encoder: render_ansi::BlitEncoder::new(),
|
||||
mouse_capture_active: config.mouse_capture_active,
|
||||
keyboard_report_all_active: false,
|
||||
reported_size: (cols, rows),
|
||||
sound_config: config.sound_config,
|
||||
kitty_graphics_enabled: config.kitty_graphics_enabled,
|
||||
|
|
@ -1585,6 +1588,16 @@ async fn run_client_loop(
|
|||
host_mouse_capture_active.store(desired, Ordering::Release);
|
||||
}
|
||||
}
|
||||
ServerMessage::KittyKeyboardReportAll { enabled } => {
|
||||
if enabled != state.keyboard_report_all_active {
|
||||
crate::terminal_modes::set_host_kitty_keyboard_report_all(
|
||||
&mut io::stdout(),
|
||||
enabled,
|
||||
)
|
||||
.map_err(ClientError::ConnectionFailed)?;
|
||||
state.keyboard_report_all_active = enabled;
|
||||
}
|
||||
}
|
||||
ServerMessage::PrefixInputSource { active } => {
|
||||
if active {
|
||||
prefix_input_source.switch_to_ascii();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
|
||||
|
||||
use super::model::KITTY_FLAG_REPORT_ALL_KEYS;
|
||||
use super::{KeyboardProtocol, MouseProtocolEncoding, TerminalKey};
|
||||
|
||||
const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010;
|
||||
const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100;
|
||||
const KITTY_FLAG_REPORT_ALL_KEYS: u16 = 0b0000_1000;
|
||||
|
||||
/// Encode a key event for a PTY child using the pane's negotiated keyboard protocol.
|
||||
#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers
|
||||
|
|
@ -13,6 +13,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> {
|
|||
}
|
||||
|
||||
pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> {
|
||||
if key.is_text_commit {
|
||||
return encode_text_input(&key).unwrap_or_default();
|
||||
}
|
||||
|
||||
// A release event only produces bytes when the pane protocol reports event
|
||||
// types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single
|
||||
// legacy byte per keystroke, so re-emitting it on release would double keys
|
||||
|
|
@ -22,13 +26,26 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<
|
|||
return Vec::new();
|
||||
}
|
||||
|
||||
let kitty_first = protocol.reports_all_keys()
|
||||
|| (key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types());
|
||||
|
||||
if kitty_first {
|
||||
if let KeyboardProtocol::Kitty { flags } = protocol {
|
||||
if let Some(bytes) = try_encode_csi_u(&key, flags) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bytes) = encode_text_input(&key) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if let KeyboardProtocol::Kitty { flags } = protocol {
|
||||
if let Some(bytes) = try_encode_csi_u(&key, flags) {
|
||||
return bytes;
|
||||
if !kitty_first {
|
||||
if let KeyboardProtocol::Kitty { flags } = protocol {
|
||||
if let Some(bytes) = try_encode_csi_u(&key, flags) {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() {
|
||||
|
|
@ -837,6 +854,43 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_report_all_keys_encodes_printable_event_kinds() {
|
||||
for (kind, expected) in [
|
||||
(
|
||||
crossterm::event::KeyEventKind::Press,
|
||||
b"\x1b[106;1:1u".as_slice(),
|
||||
),
|
||||
(
|
||||
crossterm::event::KeyEventKind::Repeat,
|
||||
b"\x1b[106;1:2u".as_slice(),
|
||||
),
|
||||
(
|
||||
crossterm::event::KeyEventKind::Release,
|
||||
b"\x1b[106;1:3u".as_slice(),
|
||||
),
|
||||
] {
|
||||
let key = KeyEvent::new_with_kind(KeyCode::Char('j'), KeyModifiers::empty(), kind);
|
||||
assert_eq!(
|
||||
encode_key(key, KeyboardProtocol::Kitty { flags: 15 }),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_printable_release_is_encoded_without_report_all() {
|
||||
let release = KeyEvent::new_with_kind(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::empty(),
|
||||
crossterm::event::KeyEventKind::Release,
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(release, KeyboardProtocol::Kitty { flags: 3 }),
|
||||
b"\x1b[106;1:3u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_shift_tab() {
|
||||
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
|
||||
|
|
@ -869,13 +923,16 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_shift_letter_release_does_not_emit_text() {
|
||||
fn kitty_shift_letter_release_uses_csi_u() {
|
||||
let key = KeyEvent::new_with_kind(
|
||||
KeyCode::Char('L'),
|
||||
KeyModifiers::SHIFT,
|
||||
crossterm::event::KeyEventKind::Release,
|
||||
);
|
||||
assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"");
|
||||
assert_eq!(
|
||||
encode_key(key, KeyboardProtocol::Kitty { flags: 7 }),
|
||||
b"\x1b[108:76;2:3u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -893,7 +950,7 @@ mod tests {
|
|||
.with_kind(crossterm::event::KeyEventKind::Release);
|
||||
assert_eq!(
|
||||
encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }),
|
||||
b""
|
||||
b"\x1b[63;2:3u"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub struct TerminalKey {
|
|||
pub modifiers: KeyModifiers,
|
||||
pub kind: crossterm::event::KeyEventKind,
|
||||
pub shifted_codepoint: Option<u32>,
|
||||
pub is_text_commit: bool,
|
||||
}
|
||||
|
||||
impl TerminalKey {
|
||||
|
|
@ -18,6 +19,7 @@ impl TerminalKey {
|
|||
modifiers,
|
||||
kind: crossterm::event::KeyEventKind::Press,
|
||||
shifted_codepoint: None,
|
||||
is_text_commit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +34,19 @@ impl TerminalKey {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn as_text_commit(mut self) -> Self {
|
||||
let has_text_only_modifiers = match self.code {
|
||||
KeyCode::Char(ch) if ch.is_ascii_uppercase() => {
|
||||
self.modifiers == KeyModifiers::SHIFT || self.modifiers.is_empty()
|
||||
}
|
||||
KeyCode::Char(_) => self.modifiers.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
self.is_text_commit =
|
||||
has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn as_key_event(self) -> KeyEvent {
|
||||
KeyEvent::new_with_kind(self.code, self.modifiers, self.kind)
|
||||
}
|
||||
|
|
@ -43,6 +58,8 @@ impl From<KeyEvent> for TerminalKey {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) const KITTY_FLAG_REPORT_ALL_KEYS: u16 = 0b0000_1000;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags {
|
||||
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||
|
|
@ -117,6 +134,10 @@ impl KeyboardProtocol {
|
|||
pub(crate) fn reports_event_types(self) -> bool {
|
||||
matches!(self, Self::Kitty { flags } if flags & 0b0000_0010 != 0)
|
||||
}
|
||||
|
||||
pub(crate) fn reports_all_keys(self) -> bool {
|
||||
matches!(self, Self::Kitty { flags } if flags & KITTY_FLAG_REPORT_ALL_KEYS != 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
|
|||
modifiers: key_modifiers_from_u8(modifier),
|
||||
kind,
|
||||
shifted_codepoint,
|
||||
is_text_commit: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current protocol version. Bumped when wire format changes incompatibly.
|
||||
pub const PROTOCOL_VERSION: u32 = 17;
|
||||
pub const PROTOCOL_VERSION: u32 = 18;
|
||||
|
||||
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
|
||||
/// rejected to prevent denial-of-service via oversized length prefixes.
|
||||
|
|
@ -116,6 +116,9 @@ pub enum ClientInputEvent {
|
|||
modifiers: u8,
|
||||
kind: ClientKeyKind,
|
||||
},
|
||||
Text {
|
||||
codepoint: char,
|
||||
},
|
||||
Mouse {
|
||||
kind: ClientMouseKind,
|
||||
column: u16,
|
||||
|
|
@ -251,7 +254,7 @@ impl ClientMouseKind {
|
|||
}
|
||||
|
||||
impl ClientInputEvent {
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option<Self> {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key) => Some(Self::Key {
|
||||
|
|
@ -285,6 +288,13 @@ impl ClientInputEvent {
|
|||
)
|
||||
.with_kind(kind.to_crossterm()),
|
||||
),
|
||||
Self::Text { codepoint } => crate::raw_input::RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(
|
||||
crossterm::event::KeyCode::Char(*codepoint),
|
||||
crossterm::event::KeyModifiers::empty(),
|
||||
)
|
||||
.as_text_commit(),
|
||||
),
|
||||
Self::Mouse {
|
||||
kind,
|
||||
column,
|
||||
|
|
@ -657,6 +667,12 @@ pub enum ServerMessage {
|
|||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Whether the focused terminal requests Kitty report-all keyboard input.
|
||||
KittyKeyboardReportAll {
|
||||
/// True only while the focused pane requests `REPORT_ALL_KEYS_AS_ESCAPE_CODES`.
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Apply the prefix-mode ASCII input-source change on the foreground client.
|
||||
/// `active = true` → switch to an ASCII-capable source (saving the current one);
|
||||
/// `active = false` → restore the saved source.
|
||||
|
|
@ -1412,6 +1428,15 @@ mod tests {
|
|||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_kitty_keyboard_report_all_roundtrip() {
|
||||
let msg = ServerMessage::KittyKeyboardReportAll { enabled: true };
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ServerMessage, _) =
|
||||
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_prefix_input_source_roundtrip() {
|
||||
for active in [true, false] {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRa
|
|||
} else if let Ok(text) = std::str::from_utf8(&buffer) {
|
||||
if let Some(key) = parse_terminal_key_sequence(text) {
|
||||
events.push(RawInputEventWithRange {
|
||||
event: RawInputEvent::Key(key),
|
||||
event: RawInputEvent::Key(key.as_text_commit()),
|
||||
start: offset,
|
||||
len: buffer.len(),
|
||||
});
|
||||
|
|
@ -710,7 +710,7 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
|
|||
|
||||
let consumed = first_complete_utf8_char_len(buffer)?;
|
||||
let text = std::str::from_utf8(&buffer[..consumed]).ok()?;
|
||||
let key = parse_terminal_key_sequence(text)?;
|
||||
let key = parse_terminal_key_sequence(text)?.as_text_commit();
|
||||
Some((RawInputEvent::Key(key), consumed))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ pub(crate) struct ClientConnection {
|
|||
pane_graphics_render_pending: bool,
|
||||
/// Last host mouse capture mode sent to this client.
|
||||
pub(crate) host_mouse_capture_active: Option<bool>,
|
||||
/// Last Kitty report-all mode sent to this client's host terminal.
|
||||
pub(crate) host_keyboard_report_all_active: Option<bool>,
|
||||
/// Temporary files staged from this client's local clipboard image pastes.
|
||||
pub(crate) staged_clipboard_files: Vec<PathBuf>,
|
||||
/// Channels for sending framed ServerMessage data to the client writer thread.
|
||||
|
|
@ -127,6 +129,7 @@ impl ClientConnection {
|
|||
render_pending: false,
|
||||
pane_graphics_render_pending: false,
|
||||
host_mouse_capture_active: None,
|
||||
host_keyboard_report_all_active: None,
|
||||
staged_clipboard_files: Vec::new(),
|
||||
writer,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -613,6 +613,7 @@ impl HeadlessServer {
|
|||
|
||||
self.drain_client_config_reload_request();
|
||||
self.stream_host_mouse_capture_mode();
|
||||
self.stream_host_keyboard_enhancement_flags();
|
||||
|
||||
self.app.sync_headless_animation_timer(now);
|
||||
|
||||
|
|
@ -1397,6 +1398,7 @@ impl HeadlessServer {
|
|||
|
||||
fn remove_client(&mut self, client_id: u64) -> bool {
|
||||
let was_foreground = self.foreground_client_id == Some(client_id);
|
||||
self.app.clear_input_source(client_id);
|
||||
self.send_client_graphics_cleanup(client_id);
|
||||
let removed = self.clients.remove(&client_id);
|
||||
if let Some(removed) = removed {
|
||||
|
|
@ -2595,6 +2597,12 @@ impl HeadlessServer {
|
|||
}
|
||||
if source_is_full_app {
|
||||
self.update_client_outer_focus_from_events(client_id, &events);
|
||||
if events
|
||||
.iter()
|
||||
.any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost))
|
||||
{
|
||||
self.app.clear_input_source(client_id);
|
||||
}
|
||||
}
|
||||
let events = events_for_app_routing(events, source_was_foreground, source_is_full_app);
|
||||
let interaction = events_include_interaction(&events);
|
||||
|
|
@ -2607,8 +2615,11 @@ impl HeadlessServer {
|
|||
self.resize_shared_runtime_to_effective_size_before_input();
|
||||
}
|
||||
let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
|
||||
self.app
|
||||
.route_client_events(events, self.foreground_client_id == Some(client_id));
|
||||
self.app.route_client_events_from(
|
||||
client_id,
|
||||
events,
|
||||
self.foreground_client_id == Some(client_id),
|
||||
);
|
||||
if self.app.take_config_reloaded_from_disk() {
|
||||
self.reload_server_config(false);
|
||||
} else {
|
||||
|
|
@ -3352,6 +3363,44 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn stream_host_keyboard_enhancement_flags(&mut self) {
|
||||
let report_all_keys = self.app.host_keyboard_report_all_requested();
|
||||
let serialized = match Self::frame_server_message(&ServerMessage::KittyKeyboardReportAll {
|
||||
enabled: report_all_keys,
|
||||
}) {
|
||||
Ok(framed) => framed,
|
||||
Err(err) => {
|
||||
warn!(err = %err, "failed to serialize keyboard enhancement flags for clients");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut broken_clients = Vec::new();
|
||||
for (&client_id, client) in &mut self.clients {
|
||||
if !client.is_full_app_client()
|
||||
|| client.host_keyboard_report_all_active == Some(report_all_keys)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(writer) = &client.writer else {
|
||||
continue;
|
||||
};
|
||||
if writer.control.send(serialized.clone()).is_err() {
|
||||
debug!(
|
||||
client_id,
|
||||
"client writer channel closed during keyboard enhancement update"
|
||||
);
|
||||
broken_clients.push(client_id);
|
||||
continue;
|
||||
}
|
||||
client.host_keyboard_report_all_active = Some(report_all_keys);
|
||||
}
|
||||
|
||||
for client_id in broken_clients {
|
||||
self.remove_client_and_resize_if_needed(client_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_retained_pty_update_and_stream(&mut self) -> bool {
|
||||
crate::render_prof::event("retained.attempt");
|
||||
let retained_started = crate::render_prof::timer();
|
||||
|
|
@ -6819,6 +6868,43 @@ next_tab = ""
|
|||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_client_focus_loss_releases_its_owned_keys() {
|
||||
let mut server = test_headless_server();
|
||||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>15u");
|
||||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||||
server.clients.insert(2, test_app_client(Some(true), 2));
|
||||
server.foreground_client_id = Some(1);
|
||||
server.sync_foreground_client_state();
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||||
client_id: 1,
|
||||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Char('j'),
|
||||
modifiers: 0,
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
}],
|
||||
}));
|
||||
server.foreground_client_id = Some(2);
|
||||
server.sync_foreground_client_state();
|
||||
|
||||
assert!(!server.handle_server_event(ServerEvent::ClientInputEvents {
|
||||
client_id: 1,
|
||||
events: vec![crate::protocol::ClientInputEvent::FocusLost],
|
||||
}));
|
||||
assert_eq!(
|
||||
input_rx.try_recv().expect("forwarded press"),
|
||||
Bytes::from_static(b"\x1b[106;1:1u")
|
||||
);
|
||||
assert_eq!(
|
||||
input_rx
|
||||
.try_recv()
|
||||
.expect("synthetic release from background client"),
|
||||
Bytes::from_static(b"\x1b[106;1:3u")
|
||||
);
|
||||
assert!(server.app.pressed_terminal_keys.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_outer_focus_events_reach_reporting_pane() {
|
||||
let mut server = test_headless_server();
|
||||
|
|
@ -7932,6 +8018,49 @@ next_tab = ""
|
|||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn focused_report_all_pane_updates_headless_client_keyboard_flags() {
|
||||
let mut server = test_headless_server();
|
||||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||||
server.clients.insert(
|
||||
1,
|
||||
ClientConnection::new(
|
||||
(80, 24),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
None,
|
||||
1,
|
||||
RenderEncoding::SemanticFrame,
|
||||
Some(client_tx),
|
||||
),
|
||||
);
|
||||
let popup_runtime =
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[>15u");
|
||||
server.app.install_test_popup_runtime(popup_runtime);
|
||||
|
||||
server.stream_host_keyboard_enhancement_flags();
|
||||
|
||||
assert!(matches!(
|
||||
read_server_message(
|
||||
client_control_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("keyboard enhancement message")
|
||||
),
|
||||
ServerMessage::KittyKeyboardReportAll { enabled: true }
|
||||
));
|
||||
|
||||
assert!(server.app.close_popup_pane());
|
||||
server.stream_host_keyboard_enhancement_flags();
|
||||
assert!(matches!(
|
||||
read_server_message(
|
||||
client_control_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("IME-compatible keyboard enhancement message")
|
||||
),
|
||||
ServerMessage::KittyKeyboardReportAll { enabled: false }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn virtual_render_uses_popup_cursor() {
|
||||
let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H");
|
||||
|
|
|
|||
|
|
@ -15,10 +15,41 @@ pub(crate) fn clear_host_mouse_reporting<W: Write>(_writer: &mut W) -> io::Resul
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn set_host_kitty_keyboard_report_all<W: Write>(
|
||||
writer: &mut W,
|
||||
report_all_keys: bool,
|
||||
) -> io::Result<()> {
|
||||
let mut flags = crate::input::ime_compatible_keyboard_enhancement_flags();
|
||||
if report_all_keys {
|
||||
flags |= crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
||||
}
|
||||
write!(writer, "\x1b[={}u", flags.bits())?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn set_host_kitty_keyboard_report_all<W: Write>(
|
||||
_writer: &mut W,
|
||||
_report_all_keys: bool,
|
||||
) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_keyboard_report_all_only_changes_the_current_herdr_stack_entry() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
set_host_kitty_keyboard_report_all(&mut output, true).unwrap();
|
||||
set_host_kitty_keyboard_report_all(&mut output, false).unwrap();
|
||||
|
||||
assert_eq!(output, b"\x1b[=15u\x1b[=7u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_all_known_host_mouse_modes() {
|
||||
let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap();
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() {
|
|||
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
// Intentionally hardcoded so wire protocol bumps require updating this test.
|
||||
// Changing this value means old clients/servers are no longer compatible.
|
||||
assert_eq!(value["result"]["protocol"], 17);
|
||||
assert_eq!(value["result"]["protocol"], 18);
|
||||
|
||||
cleanup_spawned_herdr(child, base);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(
|
||||
full_stdout.contains(" protocol: 17"),
|
||||
full_stdout.contains(" protocol: 18"),
|
||||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
|
||||
|
|
@ -359,7 +359,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {server_stdout}"
|
||||
);
|
||||
assert!(
|
||||
server_stdout.contains("protocol: 17"),
|
||||
server_stdout.contains("protocol: 18"),
|
||||
"stdout: {server_stdout}"
|
||||
);
|
||||
|
||||
|
|
@ -371,7 +371,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
client_stdout.contains("protocol: 17"),
|
||||
client_stdout.contains("protocol: 18"),
|
||||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -381,7 +381,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
|
||||
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
|
||||
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(full_json["client"]["protocol"], 17);
|
||||
assert_eq!(full_json["client"]["protocol"], 18);
|
||||
assert_eq!(full_json["server"]["status"], "running");
|
||||
assert_eq!(full_json["server"]["running"], true);
|
||||
assert_eq!(full_json["server"]["compatible"], true);
|
||||
|
|
@ -395,12 +395,12 @@ fn status_commands_report_client_and_server_versions() {
|
|||
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
|
||||
assert_eq!(server_json["status"], "running");
|
||||
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(server_json["protocol"], 17);
|
||||
assert_eq!(server_json["protocol"], 18);
|
||||
assert_eq!(server_json["compatible"], true);
|
||||
|
||||
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
|
||||
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(client_json["protocol"], 17);
|
||||
assert_eq!(client_json["protocol"], 18);
|
||||
assert!(client_json["binary"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.is_empty()));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ static INIT: Once = Once::new();
|
|||
static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
|
||||
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
|
||||
pub const CURRENT_PROTOCOL: u32 = 17;
|
||||
pub const CURRENT_PROTOCOL: u32 = 18;
|
||||
|
||||
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
|
||||
let Some(pid) = pid else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue