Input
The engine speaks AppKeyEvent. Each host translates its native event type
into this struct before calling handle_key.
The platform-neutral key type
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppKeyEvent {
pub code: AppKeyCode,
pub modifiers: AppKeyModifiers,
}#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppKeyCode {
Char(char),
Enter,
Esc,
Left,
Right,
Up,
Down,
Backspace,
Tab,
PageUp,
PageDown,
Null,
}#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppKeyModifiers(u8);AppKeyModifiers uses BitOr so modifiers compose naturally:
AppKeyModifiers::SHIFT | AppKeyModifiers::CONTROL.
Terminal path
crossterm::event::KeyEvent
└─► app_key_event_from_crossterm(...) (tg/src/terminal_input.rs)The terminal adapter owns this translation explicitly now:
Translates crossterm key events into Textagram’s host-neutral key type.
pub fn app_key_event_from_crossterm(key_event: KeyEvent) -> AppKeyEvent {The terminal host also does movement coalescing — if multiple arrow/hjkl keys have queued up between frames, only the last one is dispatched. This prevents lag-induced cursor jumps in Nav/Shape/Connector modes.
Web path
KeyboardEvent (DOM)
└─► browser_key_to_app_event(key, shift, ctrl, alt, meta)
→ Option<AppKeyEvent> (web_host.rs)pub(crate) fn browser_key_to_app_event(
key: &str,
shift: bool,
ctrl: bool,
alt: bool,
meta: bool,
) -> Option<AppKeyEvent> {Key differences from the terminal path:
| Concern | Terminal | Web |
|---|---|---|
| Modifier key | KeyModifiers::CONTROL |
ctrl ∥ meta → CONTROL (Mac ⌘ normalization) |
| Single-char detection | KeyCode::Char(c) |
key.chars().count() == 1 |
| Unmapped keys | Never reach the engine | Returns None → host skips dispatch |
| Composing (IME) | Handled by crossterm | event.is_composing() → early return |
Browser-reserved shortcuts
Before translating, the web host checks browser_reserved_shortcut. Currently
only Cmd/Ctrl+R (reload) is reserved — left to the browser’s native handler.
Most handled editor keys are captured and preventDefault’d. Browser-native
clipboard shortcuts are the exception: when allowed, keydown records the
clipboard intent and lets the native copy / cut / paste event fire.
pub(crate) fn browser_reserved_shortcut(key: &str, _shift: bool, ctrl: bool, meta: bool) -> bool {Clipboard paths
Clipboard policy is shared, but clipboard transport is host-owned. The shared
surface is ClipboardIntent plus the Session clipboard methods:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardIntent {
CopySelectionOrDocument,
CutSelection,
CopyDocument,
CutDocument,
PasteSelection,
PasteDocument,
} pub fn clipboard_intent(event: AppKeyEvent) -> Option<ClipboardIntent> {Hosts use those intents differently depending on what their platform can provide.
Terminal clipboard keys
The terminal host maps shifted Y / P / X in Nav mode to whole-document
copy, paste/import, and cut. Those operations call Session::clipboard_copy,
Session::clipboard_paste, or Session::clipboard_cut; tg owns OS clipboard
reads and writes through arboard.
Plain y, x, c, and p remain normal editor keys forwarded through
Session::handle_key. Around those normal keys, tg may sync the session
clipboard with the OS clipboard so selection copy, cut, and paste stay aligned
with the terminal host clipboard.
Browser and Monaco clipboard keys
Browser hosts support the same Y / P / X document aliases in Nav mode.
They are handled directly from keydown, routed to the same Session document
clipboard intents, and use navigator.clipboard.readText() /
writeText() for transport.
Browser-native selection aliases are separate: Cmd/Ctrl+C, Cmd/Ctrl+X, and
Cmd/Ctrl+V are intercepted outside normal key handling. There are no Shift
variants on this native-selection path. Copy and cut are selection-only aliases
allowed only in Shape mode; paste follows the shared session suppression policy.
When a native browser selection clipboard intent is detected, the host stashes
it and lets the native copy / cut / paste DOM event fire. The actual
engine interaction happens in those DOM event handlers, which call the wasm
session bridge methods that forward into Session::clipboard_copy,
Session::clipboard_cut, or Session::clipboard_paste.
pub fn perform_copy(&mut self, intent: &str) -> Option<String> { pub fn perform_cut(&mut self, intent: &str) -> Option<String> { pub fn perform_paste(&mut self, intent: &str, text: String) {This two-phase design is necessary because ClipboardEvent.clipboardData is
only available inside the native event handler — you cannot read or write the
clipboard from a keydown handler alone.
Plain p in Nav mode is also browser-clipboard aware: before forwarding p
into handle_key(...), the browser host tries to refresh the session clipboard
with navigator.clipboard.readText(). Plain y, x, and c take the opposite
direction: after normal key handling, if the session clipboard changed, the host
tries to write the new text back with navigator.clipboard.writeText().
Cross-host test
The cross_host_sequence_reaches_same_canvas test in
textagram-wasm/src/web_host.rs runs the same logical sequence through both
the terminal translation path and browser_key_to_app_event, then asserts the
canvas snapshots match. This is the primary guard against translation drift
between the two hosts.