Skip to content

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,
}
textagram/src/app/mod.rs:312-316
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppKeyCode {
    Char(char),
    Enter,
    Esc,
    Left,
    Right,
    Up,
    Down,
    Backspace,
    Tab,
    PageUp,
    PageDown,
    Null,
}
textagram/src/app/mod.rs:268-282
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppKeyModifiers(u8);
textagram/src/app/mod.rs:284-285

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 {
tg/src/terminal_input.rs:5-6

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> {
textagram-wasm/src/web_host.rs:15-21

Key differences from the terminal path:

Concern Terminal Web
Modifier key KeyModifiers::CONTROL ctrl ∥ metaCONTROL (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 {
textagram-wasm/src/web_host.rs:51-51

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,
}
textagram/src/session.rs:15-23
    pub fn clipboard_intent(event: AppKeyEvent) -> Option<ClipboardIntent> {
textagram/src/session.rs:158-158

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> {
textagram-wasm/src/web_session.rs:172-172
    pub fn perform_cut(&mut self, intent: &str) -> Option<String> {
textagram-wasm/src/web_session.rs:177-177
    pub fn perform_paste(&mut self, intent: &str, text: String) {
textagram-wasm/src/web_session.rs:182-182

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.