feat(terminal): Chinese / IME input (P3)

Implement IME composition support and migrate regular character input to
a hidden textarea, matching the xterm.js approach.

Key changes:
- Add a hidden, absolutely-positioned `<textarea>` inside `.terminal-screen`
  that follows the terminal cursor cell. It receives focus on click, connect,
  and resize so the OS/browser IME candidate window appears at the cursor.
- `on:input` now sends regular printable characters; `on:keydown` only handles
  special keys (arrows, Enter, Esc, Backspace, F-keys, Ctrl combos).
- `on:compositionstart/end` track IME sessions and send the final composed
  string at the end, avoiding partial pinyin/jamo/kana leaks.
- Move `on:paste` and `on:keydown` from `.terminal-screen` to the textarea so
  focus stays on the IME target.
- Replace `.terminal-screen:focus` with `:focus-within` since the screen div
  no longer holds focus.
- Add `is_regular_text_input` helper and unit test.

Web-sys features extended with `InputEvent` and `CompositionEvent`.

Tests: 24/24 pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zhangheng
2026-06-18 16:09:45 +08:00
parent 843e4e55ab
commit f76d341501
4 changed files with 252 additions and 45 deletions

View File

@@ -7,9 +7,9 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys::{Array, Function};
use web_sys::{
BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent,
MessageEvent, MouseEvent, ResizeObserver, ResizeObserverEntry, WebSocket,
WheelEvent,
BinaryType, ClipboardEvent, CompositionEvent, ErrorEvent, Event, HtmlElement,
InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver,
ResizeObserverEntry, WebSocket, WheelEvent,
};
use super::core::{
@@ -87,6 +87,7 @@ impl Default for TerminalViewport {
#[component]
pub fn TerminalPanel() -> impl IntoView {
let terminal_ref = NodeRef::<html::Div>::new();
let ime_ref = NodeRef::<html::Textarea>::new();
let measure_ref = NodeRef::<html::Span>::new();
let connection_status = RwSignal::new("Connecting".to_owned());
let ws_signal = RwSignal::new(None::<WebSocket>);
@@ -121,6 +122,10 @@ pub fn TerminalPanel() -> impl IntoView {
let selection = RwSignal::new(None::<Selection>);
let selecting = RwSignal::new(false);
// IME composition state: when true, regular `input` events are ignored and
// the final composed string is sent on `compositionend`.
let composing = RwSignal::new(false);
// Mouse-reporting bookkeeping (only used while an app has enabled mouse
// reporting). `mouse_held_button` remembers which button is down so we can
// emit motion events and SGR-style releases; `mouse_last_cell` throttles
@@ -130,6 +135,33 @@ pub fn TerminalPanel() -> impl IntoView {
let mouse_last_cell: Rc<std::cell::Cell<Option<(usize, usize)>>> =
Rc::new(std::cell::Cell::new(None));
// -- IME cursor follower -------------------------------------------------
//
// The hidden IME textarea's position is bound reactively to the terminal
// cursor cell so the OS/browser IME candidate window appears in the right
// place. See the `style` prop on the textarea in the view below.
let ime_style = move || {
let _ = render_tick.get();
let Some((row, col)) = core_signal
.try_with_untracked(|core| {
if core.hide_cursor() {
None
} else {
Some(core.cursor_position())
}
})
.flatten()
else {
return String::new();
};
let (cell_w, cell_h) = cell_size.get();
let left = TERMINAL_PADDING + f64::from(col) * cell_w;
let top = TERMINAL_PADDING + f64::from(row) * cell_h;
format!(
"left:{left}px;top:{top}px;width:{cell_w}px;height:{cell_h}px;"
)
};
// -- WebSocket lifecycle ------------------------------------------------
Effect::new(move |_| {
@@ -148,6 +180,7 @@ pub fn TerminalPanel() -> impl IntoView {
let open_ws = ws_signal;
let open_viewport = current_viewport;
let open_ref = terminal_ref;
let open_ime = ime_ref;
let open_measure = measure_ref;
let open_mode = view_mode;
let open_prog_scroll = programmatic_scroll_top;
@@ -168,7 +201,7 @@ pub fn TerminalPanel() -> impl IntoView {
send_resize_message(&open_socket, viewport);
sync_scroll_to_bottom(open_ref, open_prog_scroll);
focus_terminal(open_ref);
focus_ime_textarea(&open_ime);
});
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
on_open.forget();
@@ -273,6 +306,7 @@ pub fn TerminalPanel() -> impl IntoView {
let resize_core = core_signal;
let resize_st = scroll_top;
let resize_tick = render_tick;
let resize_ime = ime_ref;
let callback = Closure::<dyn FnMut(Array, ResizeObserver)>::new(
move |entries: Array, _observer: ResizeObserver| {
let Some(entry) = entries
@@ -314,7 +348,7 @@ pub fn TerminalPanel() -> impl IntoView {
send_resize_message(&resize_socket, viewport);
}
focus_terminal(resize_ref);
focus_ime_textarea(&resize_ime);
},
);
@@ -393,6 +427,19 @@ pub fn TerminalPanel() -> impl IntoView {
return;
}
// Printable characters are handled by the IME textarea's `input` /
// `composition` events so that IME composition and dead keys work.
// Only special keys (arrows, Enter, Esc, Backspace, F-keys,
// Ctrl-combos, …) are processed here.
if is_regular_text_input(
&event.key(),
event.ctrl_key(),
event.alt_key(),
event.meta_key(),
) {
return;
}
// The terminal owns bare Ctrl+letter (no Shift/Alt/Meta) so the
// browser doesn't intercept common shortcuts (Ctrl-W close tab,
// Ctrl-T new tab, Ctrl-N new window, Ctrl-R reload, Ctrl-F find,
@@ -643,9 +690,6 @@ pub fn TerminalPanel() -> impl IntoView {
let paste_core = core_signal;
move |event: ClipboardEvent| {
let Some(socket) = ws_signal.get_untracked() else {
return;
};
let Some(raw) = event
.clipboard_data()
.and_then(|dt| dt.get_data("text").ok())
@@ -660,18 +704,78 @@ pub fn TerminalPanel() -> impl IntoView {
.try_with_untracked(|core| core.bracketed_paste())
== Some(true);
let data = prepare_paste(&raw, bracketed);
// Pasting is an interaction at the prompt → return to live view.
if !paste_mode.get_untracked().is_live() {
paste_mode.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, paste_prog_scroll);
}
event.prevent_default();
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
send_input_data(
data,
ws_signal,
paste_mode,
terminal_ref,
paste_prog_scroll,
);
event.prevent_default();
}
};
// -- IME input ------------------------------------------------------------
//
// Regular characters and IME composition are handled through a hidden
// textarea that sits on top of the terminal cursor. Special keys
// (arrows, Enter, Esc, F-keys, Ctrl-combos) still go through keydown.
let handle_input = {
let input_mode = view_mode;
let input_prog_scroll = programmatic_scroll_top;
let input_composing = composing;
let input_ime = ime_ref;
move |event: Event| {
let Ok(_event) = event.dyn_into::<InputEvent>() else {
return;
};
if input_composing.get_untracked() {
return;
}
let Some(text) = ime_ref_text(&input_ime) else {
return;
};
if text.is_empty() {
return;
}
clear_ime_textarea(&input_ime);
send_input_data(
text,
ws_signal,
input_mode,
terminal_ref,
input_prog_scroll,
);
}
};
let handle_composition_start = move |_: CompositionEvent| {
composing.set(true);
};
let handle_composition_end = {
let end_mode = view_mode;
let end_prog_scroll = programmatic_scroll_top;
let end_composing = composing;
let end_ime = ime_ref;
move |event: CompositionEvent| {
if !end_composing.get_untracked() {
clear_ime_textarea(&end_ime);
return;
}
end_composing.set(false);
if let Some(text) = event.data()
&& !text.is_empty()
{
send_input_data(
text,
ws_signal,
end_mode,
terminal_ref,
end_prog_scroll,
);
}
clear_ime_textarea(&end_ime);
}
};
@@ -909,7 +1013,7 @@ pub fn TerminalPanel() -> impl IntoView {
};
let handle_click = move |_| {
focus_terminal(terminal_ref);
focus_ime_textarea(&ime_ref);
};
// -- View ---------------------------------------------------------------
@@ -954,16 +1058,27 @@ pub fn TerminalPanel() -> impl IntoView {
<div
node_ref=terminal_ref
class="terminal-screen"
tabindex="0"
on:click=handle_click
on:keydown=handle_keydown
on:scroll=handle_scroll
on:wheel=handle_wheel
on:paste=handle_paste
on:mousedown=handle_mousedown
on:mousemove=handle_mousemove
on:mouseup=handle_mouseup
>
<textarea
node_ref=ime_ref
class="terminal-ime"
tabindex="0"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
style=ime_style
on:keydown=handle_keydown
on:input=handle_input
on:compositionstart=handle_composition_start
on:compositionend=handle_composition_end
on:paste=handle_paste
></textarea>
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
{MEASURE_SAMPLE_TEXT}
</span>
@@ -1402,8 +1517,9 @@ fn point_to_cell(
)
}
fn focus_terminal(terminal_ref: NodeRef<html::Div>) {
if let Some(element) = terminal_ref.get_untracked() {
/// Focus the hidden IME textarea so keyboard input and composition go there.
fn focus_ime_textarea(ime_ref: &NodeRef<html::Textarea>) {
if let Some(element) = ime_ref.get_untracked() {
let _ = element.focus();
}
}
@@ -1430,6 +1546,42 @@ fn copy_current_selection(selection: RwSignal<Option<Selection>>, core: RwSignal
}
}
/// Send a string as terminal input, scrolling to the bottom if needed.
fn send_input_data(
data: String,
socket_signal: RwSignal<Option<WebSocket>>,
mode_signal: RwSignal<ViewMode>,
terminal_ref: NodeRef<html::Div>,
prog_scroll: RwSignal<i32>,
) {
if data.is_empty() {
return;
}
let Some(socket) = socket_signal.get_untracked() else {
return;
};
if !mode_signal.get_untracked().is_live() {
mode_signal.set(ViewMode::Live);
sync_scroll_to_bottom(terminal_ref, prog_scroll);
}
send_terminal_message(
&socket,
&ClientTerminalMessage::Input { data },
);
}
/// Read the current value of the hidden IME textarea.
fn ime_ref_text(ime_ref: &NodeRef<html::Textarea>) -> Option<String> {
ime_ref.get_untracked().map(|t| t.value())
}
/// Clear the hidden IME textarea after sending composed/typed text.
fn clear_ime_textarea(ime_ref: &NodeRef<html::Textarea>) {
if let Some(t) = ime_ref.get_untracked() {
t.set_value("");
}
}
// ---------------------------------------------------------------------------
// Key mapping
// ---------------------------------------------------------------------------
@@ -1668,6 +1820,14 @@ fn map_key_to_terminal_input(
)
}
/// Whether a keydown event represents a regular printable character that
/// should be handled by the IME textarea's `input` event, rather than sent
/// directly from `keydown`. This lets the browser manage IME composition,
/// dead keys, and upper/lower case before we see the final text.
fn is_regular_text_input(key: &str, ctrl: bool, alt: bool, meta: bool) -> bool {
key.chars().count() == 1 && !ctrl && !alt && !meta
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1854,6 +2014,19 @@ mod tests {
assert!(selection_rects(None, 0, 30, cols, cw).is_empty());
}
#[test]
fn is_regular_text_input_detects_printable_keys() {
assert!(is_regular_text_input("a", false, false, false));
assert!(is_regular_text_input("A", false, false, false)); // Shift handled by browser
assert!(is_regular_text_input(" ", false, false, false));
assert!(!is_regular_text_input("Enter", false, false, false));
assert!(!is_regular_text_input("ArrowUp", false, false, false));
assert!(!is_regular_text_input("a", true, false, false)); // Ctrl+a
assert!(!is_regular_text_input("a", false, true, false)); // Alt+a
assert!(!is_regular_text_input("a", false, false, true)); // Meta+a
assert!(!is_regular_text_input("Dead", false, false, false));
}
#[test]
fn key_to_bytes_table() {
// --- basic printable / control keys ---