@cplieger/web-terminal-ui 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/kernel/kernel.ts +240 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "MPL-2.0",
@@ -91,6 +91,34 @@ const CORE_TEMPLATE = `
91
91
  <div class="composition-view" aria-hidden="true"></div>
92
92
  </div>`;
93
93
 
94
+ /**
95
+ * The single character this key types, or null when it types none. Used to tell
96
+ * a character the engine's encoder deferred to the `input` event apart from a
97
+ * key whose `key` is a NAME rather than text.
98
+ *
99
+ * `codePointAt` consumes a surrogate pair whole, so a key whose first scalar is
100
+ * the whole string is exactly one character; "Shift", "Dead", "F5" and "Process"
101
+ * are not. Code points rather than grapheme clusters on purpose: a character key
102
+ * carries one code point, and a segmenter would also accept a multi-scalar
103
+ * cluster. UI Events does allow a key "associated with multiple characters"
104
+ * (macros, some dead-key sequences); those return null here and are simply not
105
+ * recovered, which is a stated limitation rather than a claim about key names.
106
+ */
107
+ function typedChar(ev: KeyboardEvent): string | null {
108
+ const first = ev.key.codePointAt(0);
109
+ return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
110
+ }
111
+
112
+ /**
113
+ * Normalizes text on its way to the PTY: iOS sends U+00A0 where a space was
114
+ * typed. Shared by every send path so one physical key cannot produce two byte
115
+ * sequences depending on which listener caught it (the `input` event on the
116
+ * focused textarea, or the document-level type-to-focus fallback).
117
+ */
118
+ function normalizeTypedText(text: string): string {
119
+ return text.replace(/\u00A0/g, " ");
120
+ }
121
+
94
122
  function pick(root: ParentNode, selector: string): HTMLElement {
95
123
  const el = root.querySelector<HTMLElement>(selector);
96
124
  if (!el) {
@@ -712,14 +740,14 @@ function buildTerminal(
712
740
  paste(ev.data);
713
741
  } else {
714
742
  // Normalize iOS's NBSP-for-space quirk, then send through the funnel.
715
- sendText(ev.data.replace(/\u00A0/g, " "));
743
+ sendText(normalizeTypedText(ev.data));
716
744
  }
717
745
  } else {
718
746
  const v = input.value;
719
747
  if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
720
- sendText(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " "));
748
+ sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
721
749
  } else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
722
- sendText(v.replace(/\u00A0/g, " "));
750
+ sendText(normalizeTypedText(v));
723
751
  }
724
752
  }
725
753
  resetToPlaceholder(input);
@@ -743,6 +771,42 @@ function buildTerminal(
743
771
  { signal },
744
772
  );
745
773
 
774
+ /**
775
+ * Applies the part of a `mapKeyboardEvent` result that BOTH keydown paths (the
776
+ * focused textarea below, and the document-level type-to-focus fallback in the
777
+ * focus-strategy section) handle identically. Returns false only for "ignore",
778
+ * the one arm the two callers resolve differently: the textarea lets its own
779
+ * `input` event deliver the character, while the fallback has no `input` event
780
+ * coming and must send the character itself.
781
+ *
782
+ * Switching over the whole `KeyboardResult` union in one place is what keeps
783
+ * the two paths from drifting — handling only "send" in the fallback silently
784
+ * dropped Shift+PageUp, which is exactly what a reader who just selected some
785
+ * scrollback presses next.
786
+ */
787
+ function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): boolean {
788
+ switch (result.kind) {
789
+ case "send":
790
+ ev.preventDefault();
791
+ sendText(result.bytes);
792
+ return true;
793
+ case "scroll-up": {
794
+ ev.preventDefault();
795
+ const h = termWrap.clientHeight;
796
+ termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
797
+ return true;
798
+ }
799
+ case "scroll-down": {
800
+ ev.preventDefault();
801
+ const h = termWrap.clientHeight;
802
+ termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
803
+ return true;
804
+ }
805
+ case "ignore":
806
+ return false;
807
+ }
808
+ }
809
+
746
810
  input.addEventListener(
747
811
  "keydown",
748
812
  (ev: KeyboardEvent) => {
@@ -755,27 +819,10 @@ function buildTerminal(
755
819
  return;
756
820
  }
757
821
  }
758
- const result = mapKeyboardEvent(ev, modes);
759
- switch (result.kind) {
760
- case "send":
761
- ev.preventDefault();
762
- sendText(result.bytes);
763
- return;
764
- case "scroll-up": {
765
- ev.preventDefault();
766
- const h = termWrap.clientHeight;
767
- termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
768
- return;
769
- }
770
- case "scroll-down": {
771
- ev.preventDefault();
772
- const h = termWrap.clientHeight;
773
- termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
774
- return;
775
- }
776
- case "ignore":
777
- return;
778
- }
822
+ // A false return ("ignore") needs nothing here: the `input` listener above
823
+ // delivers the character, which is also how IME and dead-key composition
824
+ // output reaches the terminal.
825
+ applyMappedKey(ev, mapKeyboardEvent(ev, modes));
779
826
  },
780
827
  { signal },
781
828
  );
@@ -935,6 +982,175 @@ function buildTerminal(
935
982
  { signal },
936
983
  );
937
984
 
985
+ // --- Type-to-focus: recovering a keystroke after a mouse selection ---
986
+ // A mouse gesture over `.term-output` leaves the browser no focusable target,
987
+ // so the key target becomes the document body (UI Events: "If no suitable
988
+ // element is in focus, the event target will be the HTML body element") and
989
+ // the hidden textarea stops receiving keys. The click handler above cannot fix
990
+ // that, because focusing the textarea COLLAPSES the selection the user just
991
+ // made, so it deliberately declines while one exists. Net effect without this
992
+ // listener: select a single cell with the mouse and typing silently does
993
+ // nothing, and the two shortcuts that ride the keydown chain go with it
994
+ // (clipboard's Ctrl+Shift+C, contextMenu's Escape).
995
+ //
996
+ // Native terminals have no such competition: a selection is app state and the
997
+ // keyboard belongs to the window, so VTE grabs widget focus on the very press
998
+ // that starts a selection. xterm.js sidesteps it by preventDefaulting mousedown
999
+ // and painting its own selection, which is not open to us — native touch
1000
+ // selection is this UI's mobile selection UX (css/02-terminal.css). So the
1001
+ // browser's model stands and typing takes the keyboard back instead, which is
1002
+ // also what Windows Terminal does ("most key input clears the selection and
1003
+ // passes the key event directly to the underlying shell").
1004
+ const doc = root.ownerDocument;
1005
+ function ownsSelection(): boolean {
1006
+ // EVERY range, and both ends of each: a range dragged from the terminal out
1007
+ // into the host page's own content never arms this in either drag direction,
1008
+ // and neither does a Firefox Ctrl+drag multi-range selection with one range
1009
+ // outside (the mousedown policy above deliberately preserves those, and
1010
+ // removeAllRanges below would take the host's range with the terminal's).
1011
+ // Range ends are in document order whichever way the user dragged, so
1012
+ // direction costs nothing here. Selected TEXT rather than isCollapsed, so a
1013
+ // bare caret does not arm, matching the click handler's own test. Scoped to
1014
+ // `.term-output`, so a selection in the chrome does not arm. Known
1015
+ // limitation: a terminal inside a host's shadow root never arms, because
1016
+ // getSelection() does not reach into shadow trees.
1017
+ const sel = doc.getSelection();
1018
+ if (sel === null || sel.toString().length === 0) {
1019
+ return false;
1020
+ }
1021
+ for (let i = 0; i < sel.rangeCount; i++) {
1022
+ const range = sel.getRangeAt(i);
1023
+ if (!outputEl.contains(range.startContainer) || !outputEl.contains(range.endContainer)) {
1024
+ return false;
1025
+ }
1026
+ }
1027
+ return true;
1028
+ }
1029
+ doc.addEventListener(
1030
+ "keydown",
1031
+ (ev: KeyboardEvent) => {
1032
+ // Two groups, in this order. The ARMING gates immediately below decide
1033
+ // whether this listener is responsible for the keystroke at all; the
1034
+ // TERMINAL-KEY gates after the feature chain decide whether the terminal
1035
+ // wants this particular key. Both are preconditions rather than a
1036
+ // classification: nothing runs unless every gate passes, so no keystroke
1037
+ // falls between them.
1038
+ if (destroyed) {
1039
+ // Reachable in one window only: cleanupRuntime() tears features down
1040
+ // BEFORE it aborts the signal that removes this listener, so a feature
1041
+ // teardown that synchronously dispatched a key would otherwise be served
1042
+ // by a terminal that is already gone. (The textarea listener needs no
1043
+ // such gate: it only fires while the textarea holds focus, and a teardown
1044
+ // does not focus it.)
1045
+ return;
1046
+ }
1047
+ // A host that already handled this key marked it, so leave it alone. This
1048
+ // is the platform's own convention for composing keyboard handlers, and it
1049
+ // is the reason this listener needs no opt-out option: an embedder with a
1050
+ // document-level keymap cancels the events it owns.
1051
+ if (ev.defaultPrevented) {
1052
+ return;
1053
+ }
1054
+ // Nobody owns the keyboard. This one test replaces a blocklist of
1055
+ // interactive tag names and is strictly more complete: focus inside an open
1056
+ // shadow root reports the HOST, an open <dialog> reports the dialog (or its
1057
+ // focused child), and a tabindex="0" ARIA widget reports itself. All
1058
+ // non-body, so all bail. It also subsumes the double-send guard: a focused
1059
+ // textarea is not the body, so a key the handler above already took can
1060
+ // never reach here. Residual: HTML's focus fixup rule can park focus back
1061
+ // on the body while a modal dialog is still open (its focused child was
1062
+ // removed), which the focus-landed check below is what actually covers.
1063
+ if (doc.activeElement !== null && doc.activeElement !== doc.body) {
1064
+ return;
1065
+ }
1066
+ // Composition first, matching the textarea path, and on the composition
1067
+ // module's clock rather than ev.isComposing: that clock stays true through
1068
+ // the deferred compositionend read (the documented Chromium workaround).
1069
+ if (composition.isComposing()) {
1070
+ return;
1071
+ }
1072
+ if (!ownsSelection()) {
1073
+ return;
1074
+ }
1075
+ // Features next, while the selection is STILL INTACT, and BEFORE the
1076
+ // modifier gate below. Both orderings are load-bearing: clipboard's
1077
+ // Ctrl+Shift+C is a MODIFIED key that reads the selection, so a modifier
1078
+ // gate placed first would deny the chain the one shortcut this listener
1079
+ // exists to restore (caught by its own test). A claimed key never focuses,
1080
+ // so the selection survives it and a second copy is still possible.
1081
+ for (const h of keydownHandlers) {
1082
+ if (h(ev)) {
1083
+ return;
1084
+ }
1085
+ }
1086
+ // What this key DOES is decided before taking focus, because a keystroke
1087
+ // that delivers nothing must leave the selection and the focus untouched.
1088
+ // A bare modifier press is the case that proves it: Shift on its way to
1089
+ // Shift+click, or to Ctrl+Shift+C, would otherwise clear the very selection
1090
+ // the chord is about to act on.
1091
+ //
1092
+ // The character is computed ONLY for an "ignore" result, so the encoder
1093
+ // always wins a key it maps. Under the kitty protocol a MODIFIED character
1094
+ // key encodes to a CSI-u sequence rather than to its text, and the focused
1095
+ // path sends that sequence; this path has to agree with it.
1096
+ const result = mapKeyboardEvent(ev, modes);
1097
+ const char = result.kind === "ignore" ? typedChar(ev) : null;
1098
+ if (result.kind === "ignore" && char === null) {
1099
+ // Nothing to deliver: a bare modifier or lock key, a dead key, an IME
1100
+ // start, a multi-character or unknown key name.
1101
+ return;
1102
+ }
1103
+ // Modified keys stay with the browser, with one exception: an AltGr
1104
+ // CHARACTER, because on the layouts that need AltGr that is how the
1105
+ // character is typed. The exception is deliberately limited to a character
1106
+ // rather than granted to any AltGraph event, because AltGraph is not a
1107
+ // reliable signal on its own: Firefox reports it for plain Option on macOS
1108
+ // and for ordinary Ctrl+Alt on Windows, so admitting every AltGraph event
1109
+ // would hand the terminal Option+ArrowLeft, which is browser back.
1110
+ // (xterm.js carries the mirror-image heuristic, ctrl+alt meaning AltGr, and
1111
+ // scopes it to Windows.)
1112
+ //
1113
+ // Nothing else modified is recovered, and the asymmetry decides that: not
1114
+ // recovering Ctrl+ArrowLeft costs one keypress in a state that ends on the
1115
+ // next character typed, while stealing Ctrl+C costs the user the selection
1116
+ // they just made, and Alt+ArrowLeft is browser back.
1117
+ const altGraphChar = char !== null && ev.getModifierState("AltGraph");
1118
+ if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
1119
+ return;
1120
+ }
1121
+ // Tab stays with the browser: this only ever runs on a body-focused page,
1122
+ // where Tab is how a keyboard user moves on (web-terminal-kiro's
1123
+ // focusable-inventory test pins that expectation).
1124
+ if (ev.key === "Tab") {
1125
+ return;
1126
+ }
1127
+ // Take the keyboard. Collapsing explicitly rather than leaning on focus()
1128
+ // to do it keeps the behaviour identical wherever focus() would not.
1129
+ doc.getSelection()?.removeAllRanges();
1130
+ focusTerminal();
1131
+ // Fail closed. Focus can fail to land (an inert subtree under an open modal
1132
+ // dialog, a released root), and bytes whose effect the user cannot see do
1133
+ // not belong in a shell.
1134
+ if (doc.activeElement !== input) {
1135
+ return;
1136
+ }
1137
+ if (char !== null) {
1138
+ // The encoder defers a plain character to the `input` event, which the
1139
+ // focused path delivers. No input event is coming for a key that targeted
1140
+ // the body, so send it here. preventDefault makes that exactly once BY
1141
+ // SPEC ("If this event is canceled, the associated event types MUST NOT be
1142
+ // dispatched"), rather than by depending on whether an engine re-resolves
1143
+ // an uncanceled keydown's insertion target after a listener moved focus
1144
+ // mid-dispatch. Chromium does; no specification requires it.
1145
+ ev.preventDefault();
1146
+ sendText(normalizeTypedText(char));
1147
+ return;
1148
+ }
1149
+ applyMappedKey(ev, result);
1150
+ },
1151
+ { signal },
1152
+ );
1153
+
938
1154
  // --- Viewport ---
939
1155
  viewport.init({
940
1156
  termWrap,