diff --git a/Cargo.toml b/Cargo.toml index 62d47b9..ece9b6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ web-sys = { version = "0.3", default-features = false, features = [ "Clipboard", "ClipboardEvent", "CloseEvent", + "CompositionEvent", "console", "DataTransfer", "Document", @@ -52,6 +53,7 @@ web-sys = { version = "0.3", default-features = false, features = [ "Event", "EventTarget", "HtmlElement", + "InputEvent", "KeyboardEvent", "Location", "MessageEvent", diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index ffea88e..f4696bd 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -36,6 +36,7 @@ | 粘贴(Ctrl-V / Ctrl-Shift-V / 右键菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` | | 文本选区 + 复制(自建模型,Ctrl-Shift-C / 选区存在时 Ctrl+C,跨滚动) | `component.rs` 鼠标处理 / `core.rs::selection_text` | | 鼠标上报(vim/htop/tmux 鼠标,SGR/Default/UTF-8 编码,滚轮) | `component.rs` 鼠标处理 / `encode_mouse_report` | +| 中文 / IME 输入(拼音/日文/韩文,隐藏 textarea + composition 事件) | `component.rs::handle_input` / `handle_composition_*` | --- @@ -111,33 +112,45 @@ --- -### P3 — 中文 / IME 输入 +### P3 — 中文 / IME 输入 ✅ 已完成 -**现状**:`map_key_to_terminal_input` 只处理 `key.chars().count() == 1` 的单字符,IME 组合输入(拼音候选)完全没接。 +**现状**:已实现。拼音、日文、韩文等 IME 组合输入可以正常工作;普通英文/数字输入也迁到了 `textarea` 的 `input` 事件,确保和 IME 走同一条路径。 -**计划实现**: -1. 改用 **`composition` 事件 + 隐藏 ` @@ -1402,8 +1517,9 @@ fn point_to_cell( ) } -fn focus_terminal(terminal_ref: NodeRef) { - 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) { + if let Some(element) = ime_ref.get_untracked() { let _ = element.focus(); } } @@ -1430,6 +1546,42 @@ fn copy_current_selection(selection: RwSignal>, core: RwSignal } } +/// Send a string as terminal input, scrolling to the bottom if needed. +fn send_input_data( + data: String, + socket_signal: RwSignal>, + mode_signal: RwSignal, + terminal_ref: NodeRef, + prog_scroll: RwSignal, +) { + 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) -> Option { + 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) { + 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 --- diff --git a/style/tailwind.css b/style/tailwind.css index 29b2dfc..6232064 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -610,13 +610,32 @@ scrollbar-color: rgb(148 163 184 / 60%) transparent; } - .terminal-screen:focus { + .terminal-screen:focus-within { border-color: #38bdf8; box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), 0 0 0 3px rgb(56 189 248 / 15%); } + .terminal-ime { + position: absolute; + z-index: 100; + opacity: 0; + background: transparent; + color: transparent; + caret-color: transparent; + border: none; + outline: none; + resize: none; + padding: 0; + margin: 0; + overflow: hidden; + pointer-events: auto; + font-family: inherit; + font-size: 1px; + line-height: 1px; + } + .terminal-text { margin: 0; min-height: 100%;