From 60a581a175acbf632960af0a5226f90e94ca7818 Mon Sep 17 00:00:00 2001 From: JM-FRANK <156544419@qq.com> Date: Wed, 7 Oct 2026 01:36:14 +0900 Subject: [PATCH 1/4] Added(keyboard): Add basic kitty keyboard protocol disambiguation support Add negotiated flag 1 keyboard encoding, independent screen mode stacks, layout-aware text handling and existing scroll shortcut precedence. Include input regression tests and documented local and device validation. Virtual Fn combinations currently do not preserve Ctrl modifiers. Refs #5300 and #5198. --- .../terminal/TermuxTerminalViewClient.java | 12 +- .../app/terminal/KittyKeyboardInputTest.java | 172 ++++ docs/kitty-keyboard-device-validation.json | 333 ++++++++ docs/kitty-keyboard-ime-validation.json | 50 ++ ...tty-keyboard-review-device-validation.json | 732 ++++++++++++++++++ docs/kitty-keyboard.md | 146 ++++ .../java/com/termux/terminal/KeyHandler.java | 104 +++ .../termux/terminal/KittyKeyboardState.java | 47 ++ .../com/termux/terminal/TerminalEmulator.java | 43 +- .../termux/terminal/KittyKeyboardTest.java | 86 ++ .../java/com/termux/view/TerminalView.java | 35 +- 11 files changed, 1754 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java create mode 100644 docs/kitty-keyboard-device-validation.json create mode 100644 docs/kitty-keyboard-ime-validation.json create mode 100644 docs/kitty-keyboard-review-device-validation.json create mode 100644 docs/kitty-keyboard.md create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java index 700c5e50980..1ffa8c6f073 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -424,7 +424,7 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio // Other special keys. case 'e': - resultingCodePoint = /*Escape*/ 27; + resultingKeyCode = KeyEvent.KEYCODE_ESCAPE; break; case '.': resultingCodePoint = /*^.*/ 28; @@ -454,9 +454,15 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio if (resultingKeyCode != -1) { TerminalEmulator term = session.getEmulator(); - session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode())); + session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), + term.isKeypadApplicationMode(), term.isKittyKeyboardEnabled())); } else if (resultingCodePoint != -1) { - session.writeCodePoint(altDown, resultingCodePoint); + String code = null; + if (session.getEmulator().isKittyKeyboardEnabled()) { + code = KeyHandler.getKittyCodePoint(resultingCodePoint, altDown ? KeyHandler.KEYMOD_ALT : 0); + } + if (code != null) session.write(code); + else session.writeCodePoint(altDown, resultingCodePoint); } return true; } else if (ctrlDown) { diff --git a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java new file mode 100644 index 00000000000..97e3e42b5c6 --- /dev/null +++ b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java @@ -0,0 +1,172 @@ +package com.termux.app.terminal; + +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase; +import com.termux.shared.termux.terminal.TermuxTerminalViewClientBase; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.view.TerminalView; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowKeyCharacterMap; +import org.robolectric.util.ReflectionHelpers; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** Exercise Android key events through TerminalView into the real session input queue, without a PTY. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) +public class KittyKeyboardInputTest { + private TerminalView mView; + private TerminalSession mSession; + + @Before + public void setUp() { + mSession = new TerminalSession("/bin/sh", "/", new String[0], new String[0], 100, + new TermuxTerminalSessionClientBase()); + TerminalEmulator emulator = new TerminalEmulator(mSession, 20, 4, 8, 16, 100, null); + ReflectionHelpers.setField(mSession, "mEmulator", emulator); + ReflectionHelpers.setField(mSession, "mShellPid", 1); + mView = new TerminalView(RuntimeEnvironment.getApplication(), null); + mView.setTerminalViewClient(new TermuxTerminalViewClientBase()); + ReflectionHelpers.setField(mView, "mTermSession", mSession); + mView.mEmulator = emulator; + } + + private void enter(String sequence) { + byte[] bytes = sequence.getBytes(StandardCharsets.UTF_8); + mView.mEmulator.append(bytes, bytes.length); + } + + private String drainInput() throws Exception { + Object queue = ReflectionHelpers.getField(mSession, "mTerminalToProcessIOQueue"); + Method read = queue.getClass().getDeclaredMethod("read", byte[].class, boolean.class); + read.setAccessible(true); + byte[] bytes = new byte[4096]; + int count = (Integer) read.invoke(queue, bytes, false); + return new String(bytes, 0, count, StandardCharsets.UTF_8); + } + + private void press(int keyCode, int metaState, String expected) throws Exception { + KeyEvent event = new KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyCode, 0, metaState, + KeyCharacterMap.VIRTUAL_KEYBOARD, 0); + assertTrue(mView.onKeyDown(keyCode, event)); + assertEquals(expected, drainInput()); + } + + @Test + public void hardwareShortcutsRetainKeyIdentity() throws Exception { + enter("\033[>1u"); + press(KeyEvent.KEYCODE_0, KeyEvent.META_CTRL_ON, "\033[48;5u"); + press(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\033[105;5u"); + press(KeyEvent.KEYCODE_A, KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, "\033[97;6u"); + press(KeyEvent.KEYCODE_EQUALS, KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, "\033[61;6u"); + } + + @Test + public void modifiedEnterAndTabStayDistinct() throws Exception { + enter("\033[>1u"); + press(KeyEvent.KEYCODE_ENTER, KeyEvent.META_SHIFT_ON, "\033[13;2u"); + press(KeyEvent.KEYCODE_ENTER, KeyEvent.META_CTRL_ON, "\033[13;5u"); + press(KeyEvent.KEYCODE_TAB, KeyEvent.META_CTRL_ON, "\033[9;5u"); + press(KeyEvent.KEYCODE_TAB, 0, "\t"); + press(KeyEvent.KEYCODE_ENTER, 0, "\r"); + press(KeyEvent.KEYCODE_ESCAPE, 0, "\033[27u"); + } + + @Test + public void plainTextAndRestoredModeKeepLegacyBehavior() throws Exception { + enter("\033[>1u"); + press(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON, "A"); + enter("\033[1u"); + mView.inputCodePoint(TerminalView.KEY_EVENT_SOURCE_SOFT_KEYBOARD, '0', true, false); + assertEquals("\033[48;5u", drainInput()); + mView.inputCodePoint(TerminalView.KEY_EVENT_SOURCE_SOFT_KEYBOARD, 0x4E2D, false, false); + assertEquals("δΈ­", drainInput()); + mView.setTerminalViewClient(new TermuxTerminalViewClientBase() { + @Override + public boolean onKeyDown(int keyCode, KeyEvent event, TerminalSession session) { + return true; + } + }); + press(KeyEvent.KEYCODE_0, KeyEvent.META_CTRL_ON, ""); + } + + /** A layout fixture where right Alt composes NBSP on Space and Euro on E. */ + @Implements(KeyCharacterMap.class) + public static class AltGrCharacterMap extends ShadowKeyCharacterMap { + @Override + @Implementation + protected int get(int keyCode, int metaState) { + if ((metaState & KeyEvent.META_ALT_RIGHT_ON) != 0) { + if (keyCode == KeyEvent.KEYCODE_SPACE) return 0x00A0; + if (keyCode == KeyEvent.KEYCODE_E) return 0x20AC; + } + return super.get(keyCode, metaState); + } + } + + @Test + @Config(shadows = AltGrCharacterMap.class) + public void altGrTextUsesTheActiveLayout() throws Exception { + int altGr = KeyEvent.META_ALT_ON | KeyEvent.META_ALT_RIGHT_ON; + press(KeyEvent.KEYCODE_SPACE, altGr, "\u00a0"); + enter("\033[>1u"); + press(KeyEvent.KEYCODE_SPACE, altGr, "\u00a0"); + press(KeyEvent.KEYCODE_E, altGr, "\u20ac"); + press(KeyEvent.KEYCODE_SPACE, altGr | KeyEvent.META_CTRL_ON, "\033[160;5u"); + enter("\033[1u"); + press(KeyEvent.KEYCODE_SPACE, 0, " "); + press(KeyEvent.KEYCODE_SPACE, KeyEvent.META_CTRL_ON, "\033[32;5u"); + press(KeyEvent.KEYCODE_SPACE, KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON, "\033[32;3u"); + enter("\033[1u"); + press(KeyEvent.KEYCODE_PAGE_UP, KeyEvent.META_SHIFT_ON, ""); + assertEquals(-mView.mEmulator.mRows, (int) ReflectionHelpers.getField(mView, "mTopRow")); + press(KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.META_SHIFT_ON, ""); + assertEquals(0, (int) ReflectionHelpers.getField(mView, "mTopRow")); + if (kitty) enter("\033[1u"); + press(KeyEvent.KEYCODE_PAGE_UP, KeyEvent.META_CTRL_ON, "\033[5;5~"); + press(KeyEvent.KEYCODE_PAGE_DOWN, 0, "\033[6~"); + } +} diff --git a/docs/kitty-keyboard-device-validation.json b/docs/kitty-keyboard-device-validation.json new file mode 100644 index 00000000000..efdaf0c7658 --- /dev/null +++ b/docs/kitty-keyboard-device-validation.json @@ -0,0 +1,333 @@ +{ + "device": "PTP-AN10", + "android": "16", + "abi": "arm64-v8a", + "source_commit": "91ae4885", + "method": "Android system-injected key events through installed Termux TerminalView, native PTY and raw byte capture", + "tests": [ + { + "mode": "kitty", + "protocol_replies_hex": "1b5b3f30751b5b3f31751b5b3f30751b5b3f31751b5b3f30751b5b3f31751b5b3f30751b5b3f3175", + "results": [ + { + "key": "Ctrl+0", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "key": "Ctrl+1", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + }, + { + "key": "Ctrl+2", + "expected_hex": "1b5b35303b3575", + "actual_hex": "1b5b35303b3575", + "passed": true + }, + { + "key": "Ctrl+3", + "expected_hex": "1b5b35313b3575", + "actual_hex": "1b5b35313b3575", + "passed": true + }, + { + "key": "Ctrl+4", + "expected_hex": "1b5b35323b3575", + "actual_hex": "1b5b35323b3575", + "passed": true + }, + { + "key": "Ctrl+5", + "expected_hex": "1b5b35333b3575", + "actual_hex": "1b5b35333b3575", + "passed": true + }, + { + "key": "Ctrl+6", + "expected_hex": "1b5b35343b3575", + "actual_hex": "1b5b35343b3575", + "passed": true + }, + { + "key": "Ctrl+7", + "expected_hex": "1b5b35353b3575", + "actual_hex": "1b5b35353b3575", + "passed": true + }, + { + "key": "Ctrl+8", + "expected_hex": "1b5b35363b3575", + "actual_hex": "1b5b35363b3575", + "passed": true + }, + { + "key": "Ctrl+9", + "expected_hex": "1b5b35373b3575", + "actual_hex": "1b5b35373b3575", + "passed": true + }, + { + "key": "Ctrl+I", + "expected_hex": "1b5b3130353b3575", + "actual_hex": "1b5b3130353b3575", + "passed": true + }, + { + "key": "Ctrl+Shift+A", + "expected_hex": "1b5b39373b3675", + "actual_hex": "1b5b39373b3675", + "passed": true + }, + { + "key": "Ctrl+Shift+=", + "expected_hex": "1b5b36313b3675", + "actual_hex": "1b5b36313b3675", + "passed": true + }, + { + "key": "Shift+Enter", + "expected_hex": "1b5b31333b3275", + "actual_hex": "1b5b31333b3275", + "passed": true + }, + { + "key": "Ctrl+Enter", + "expected_hex": "1b5b31333b3575", + "actual_hex": "1b5b31333b3575", + "passed": true + }, + { + "key": "Alt+Enter", + "expected_hex": "1b5b31333b3375", + "actual_hex": "1b5b31333b3375", + "passed": true + }, + { + "key": "Ctrl+Tab", + "expected_hex": "1b5b393b3575", + "actual_hex": "1b5b393b3575", + "passed": true + }, + { + "key": "Shift+Tab", + "expected_hex": "1b5b393b3275", + "actual_hex": "1b5b393b3275", + "passed": true + }, + { + "key": "Ctrl+Shift+Tab", + "expected_hex": "1b5b393b3675", + "actual_hex": "1b5b393b3675", + "passed": true + }, + { + "key": "Plain Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "key": "Plain Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "key": "Backspace", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "key": "Escape", + "expected_hex": "1b5b323775", + "actual_hex": "1b5b323775", + "passed": true + }, + { + "key": "F3", + "expected_hex": "1b5b31337e", + "actual_hex": "1b5b31337e", + "passed": true + }, + { + "key": "Plain A", + "expected_hex": "61", + "actual_hex": "61", + "passed": true + }, + { + "key": "Shift+A", + "expected_hex": "41", + "actual_hex": "41", + "passed": true + } + ] + }, + { + "mode": "legacy", + "protocol_replies_hex": "1b5b3f3075", + "results": [ + { + "key": "Ctrl+0", + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "key": "Ctrl+1", + "expected_hex": "31", + "actual_hex": "31", + "passed": true + }, + { + "key": "Ctrl+2", + "expected_hex": "00", + "actual_hex": "00", + "passed": true + }, + { + "key": "Ctrl+3", + "expected_hex": "1b", + "actual_hex": "1b", + "passed": true + }, + { + "key": "Ctrl+4", + "expected_hex": "1c", + "actual_hex": "1c", + "passed": true + }, + { + "key": "Ctrl+5", + "expected_hex": "1d", + "actual_hex": "1d", + "passed": true + }, + { + "key": "Ctrl+6", + "expected_hex": "1e", + "actual_hex": "1e", + "passed": true + }, + { + "key": "Ctrl+7", + "expected_hex": "1f", + "actual_hex": "1f", + "passed": true + }, + { + "key": "Ctrl+8", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "key": "Ctrl+9", + "expected_hex": "39", + "actual_hex": "39", + "passed": true + }, + { + "key": "Ctrl+I", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "key": "Ctrl+Shift+A", + "expected_hex": "01", + "actual_hex": "01", + "passed": true + }, + { + "key": "Ctrl+Shift+=", + "expected_hex": "2b", + "actual_hex": "2b", + "passed": true + }, + { + "key": "Shift+Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "key": "Ctrl+Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "key": "Alt+Enter", + "expected_hex": "1b0d", + "actual_hex": "1b0d", + "passed": true + }, + { + "key": "Ctrl+Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "key": "Shift+Tab", + "expected_hex": "1b5b5a", + "actual_hex": "1b5b5a", + "passed": true + }, + { + "key": "Ctrl+Shift+Tab", + "expected_hex": "1b5b5a", + "actual_hex": "1b5b5a", + "passed": true + }, + { + "key": "Plain Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "key": "Plain Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "key": "Backspace", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "key": "Escape", + "expected_hex": "1b", + "actual_hex": "1b", + "passed": true + }, + { + "key": "F3", + "expected_hex": "1b4f52", + "actual_hex": "1b4f52", + "passed": true + }, + { + "key": "Plain A", + "expected_hex": "61", + "actual_hex": "61", + "passed": true + }, + { + "key": "Shift+A", + "expected_hex": "41", + "actual_hex": "41", + "passed": true + } + ] + } + ] +} diff --git a/docs/kitty-keyboard-ime-validation.json b/docs/kitty-keyboard-ime-validation.json new file mode 100644 index 00000000000..76b8dee2384 --- /dev/null +++ b/docs/kitty-keyboard-ime-validation.json @@ -0,0 +1,50 @@ +{ + "device": "PTP-AN10", + "android": "16", + "source_commit": "91ae4885", + "method": "Touching real Termux extra keys and Rime soft keyboard; app lifecycle and keyboard resize; shell capture setup through temporary instrumentation", + "results": [ + { + "name": "Termux CTRL extra key + real Rime IME 0", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "name": "IME plain 0 after one-shot CTRL clears", + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "name": "Termux CTRL extra key + real Rime IME 2", + "expected_hex": "1b5b35303b3575", + "actual_hex": "1b5b35303b3575", + "passed": true + }, + { + "name": "Termux CTRL extra key + real Rime IME 1", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + }, + { + "name": "Real Rime IME Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "name": "CTRL extra key + IME 0 after app background and foreground", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "name": "CTRL extra key + IME 1 after keyboard close/open and view resize", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + } + ] +} diff --git a/docs/kitty-keyboard-review-device-validation.json b/docs/kitty-keyboard-review-device-validation.json new file mode 100644 index 00000000000..2ec83e732d2 --- /dev/null +++ b/docs/kitty-keyboard-review-device-validation.json @@ -0,0 +1,732 @@ +{ + "source_commit": "65c625b2bc7d4b1552084f449400bda18cb00b84", + "apk_version": "0.118.0+kitty.65c625b2", + "date": "2026-10-07", + "device": { + "model": "PTP-AN10", + "android": "16", + "abi": "arm64-v8a" + }, + "counts": { + "framework_key_pty_and_scroll": 83, + "current_virtual_layout": 2, + "rime_touch_and_lifecycle": 7, + "rime_space_and_modifiers": 4 + }, + "total_checks": 96, + "all_recorded_checks_passed": true, + "uncovered": [ + "AltGr+Space producing NBSP or another layout character remains unverified. Automated device checks used a virtual mapping that returns no character; the user-reported combination retest does not specifically confirm this layout case." + ], + "reports": { + "framework_key_pty_and_scroll": { + "source_commit": "65c625b2", + "method": "Android framework KeyEvent delivered directly to installed production TerminalView; real session queue, native PTY and foreground shell capture", + "results": [ + { + "mode": "kitty", + "key": "Ctrl+0", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+1", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+2", + "expected_hex": "1b5b35303b3575", + "actual_hex": "1b5b35303b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+3", + "expected_hex": "1b5b35313b3575", + "actual_hex": "1b5b35313b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+4", + "expected_hex": "1b5b35323b3575", + "actual_hex": "1b5b35323b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+5", + "expected_hex": "1b5b35333b3575", + "actual_hex": "1b5b35333b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+6", + "expected_hex": "1b5b35343b3575", + "actual_hex": "1b5b35343b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+7", + "expected_hex": "1b5b35353b3575", + "actual_hex": "1b5b35353b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+8", + "expected_hex": "1b5b35363b3575", + "actual_hex": "1b5b35363b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+9", + "expected_hex": "1b5b35373b3575", + "actual_hex": "1b5b35373b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+I", + "expected_hex": "1b5b3130353b3575", + "actual_hex": "1b5b3130353b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+Shift+A", + "expected_hex": "1b5b39373b3675", + "actual_hex": "1b5b39373b3675", + "passed": true + }, + { + "mode": "kitty", + "key": "Shift+Enter", + "expected_hex": "1b5b31333b3275", + "actual_hex": "1b5b31333b3275", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+Enter", + "expected_hex": "1b5b31333b3575", + "actual_hex": "1b5b31333b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+Tab", + "expected_hex": "1b5b393b3575", + "actual_hex": "1b5b393b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Shift+Tab", + "expected_hex": "1b5b393b3275", + "actual_hex": "1b5b393b3275", + "passed": true + }, + { + "mode": "kitty", + "key": "Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "mode": "kitty", + "key": "Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "mode": "kitty", + "key": "Backspace", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "mode": "kitty", + "key": "Escape", + "expected_hex": "1b5b323775", + "actual_hex": "1b5b323775", + "passed": true + }, + { + "mode": "kitty", + "key": "F3", + "expected_hex": "1b5b31337e", + "actual_hex": "1b5b31337e", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+Space", + "expected_hex": "1b5b33323b3575", + "actual_hex": "1b5b33323b3575", + "passed": true + }, + { + "mode": "kitty", + "key": "Left Alt+Space", + "expected_hex": "1b5b33323b3375", + "actual_hex": "1b5b33323b3375", + "passed": true + }, + { + "mode": "kitty", + "key": "Ctrl+PageUp", + "expected_hex": "1b5b353b357e", + "actual_hex": "1b5b353b357e", + "passed": true + }, + { + "mode": "kitty", + "key": "PageDown", + "expected_hex": "1b5b367e", + "actual_hex": "1b5b367e", + "passed": true + }, + { + "mode": "kitty", + "key": "Shift+PageUp local scroll", + "expected_hex": "", + "actual_hex": "", + "passed": true + }, + { + "mode": "kitty", + "key": "Scrollback offset after Shift+PageUp", + "actual": -27, + "expected": -27, + "passed": true + }, + { + "mode": "kitty", + "key": "Shift+PageDown local scroll", + "expected_hex": "", + "actual_hex": "", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+0", + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+1", + "expected_hex": "31", + "actual_hex": "31", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+2", + "expected_hex": "00", + "actual_hex": "00", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+3", + "expected_hex": "1b", + "actual_hex": "1b", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+4", + "expected_hex": "1c", + "actual_hex": "1c", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+5", + "expected_hex": "1d", + "actual_hex": "1d", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+6", + "expected_hex": "1e", + "actual_hex": "1e", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+7", + "expected_hex": "1f", + "actual_hex": "1f", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+8", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+9", + "expected_hex": "39", + "actual_hex": "39", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+I", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+Shift+A", + "expected_hex": "01", + "actual_hex": "01", + "passed": true + }, + { + "mode": "legacy", + "key": "Shift+Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "mode": "legacy", + "key": "Shift+Tab", + "expected_hex": "1b5b5a", + "actual_hex": "1b5b5a", + "passed": true + }, + { + "mode": "legacy", + "key": "Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "mode": "legacy", + "key": "Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "mode": "legacy", + "key": "Backspace", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "mode": "legacy", + "key": "Escape", + "expected_hex": "1b", + "actual_hex": "1b", + "passed": true + }, + { + "mode": "legacy", + "key": "F3", + "expected_hex": "1b4f52", + "actual_hex": "1b4f52", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+Space", + "expected_hex": "00", + "actual_hex": "00", + "passed": true + }, + { + "mode": "legacy", + "key": "Left Alt+Space", + "expected_hex": "1b20", + "actual_hex": "1b20", + "passed": true + }, + { + "mode": "legacy", + "key": "Ctrl+PageUp", + "expected_hex": "1b5b353b357e", + "actual_hex": "1b5b353b357e", + "passed": true + }, + { + "mode": "legacy", + "key": "PageDown", + "expected_hex": "1b5b367e", + "actual_hex": "1b5b367e", + "passed": true + }, + { + "mode": "legacy", + "key": "Shift+PageUp local scroll", + "expected_hex": "", + "actual_hex": "", + "passed": true + }, + { + "mode": "legacy", + "key": "Scrollback offset after Shift+PageUp", + "actual": -27, + "expected": -27, + "passed": true + }, + { + "mode": "legacy", + "key": "Shift+PageDown local scroll", + "expected_hex": "", + "actual_hex": "", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+0", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+1", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+2", + "expected_hex": "1b5b35303b3575", + "actual_hex": "1b5b35303b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+3", + "expected_hex": "1b5b35313b3575", + "actual_hex": "1b5b35313b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+4", + "expected_hex": "1b5b35323b3575", + "actual_hex": "1b5b35323b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+5", + "expected_hex": "1b5b35333b3575", + "actual_hex": "1b5b35333b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+6", + "expected_hex": "1b5b35343b3575", + "actual_hex": "1b5b35343b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+7", + "expected_hex": "1b5b35353b3575", + "actual_hex": "1b5b35353b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+8", + "expected_hex": "1b5b35363b3575", + "actual_hex": "1b5b35363b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+9", + "expected_hex": "1b5b35373b3575", + "actual_hex": "1b5b35373b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+I", + "expected_hex": "1b5b3130353b3575", + "actual_hex": "1b5b3130353b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+Shift+A", + "expected_hex": "1b5b39373b3675", + "actual_hex": "1b5b39373b3675", + "passed": true + }, + { + "mode": "alt", + "key": "Shift+Enter", + "expected_hex": "1b5b31333b3275", + "actual_hex": "1b5b31333b3275", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+Enter", + "expected_hex": "1b5b31333b3575", + "actual_hex": "1b5b31333b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+Tab", + "expected_hex": "1b5b393b3575", + "actual_hex": "1b5b393b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Shift+Tab", + "expected_hex": "1b5b393b3275", + "actual_hex": "1b5b393b3275", + "passed": true + }, + { + "mode": "alt", + "key": "Tab", + "expected_hex": "09", + "actual_hex": "09", + "passed": true + }, + { + "mode": "alt", + "key": "Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "mode": "alt", + "key": "Backspace", + "expected_hex": "7f", + "actual_hex": "7f", + "passed": true + }, + { + "mode": "alt", + "key": "Escape", + "expected_hex": "1b5b323775", + "actual_hex": "1b5b323775", + "passed": true + }, + { + "mode": "alt", + "key": "F3", + "expected_hex": "1b5b31337e", + "actual_hex": "1b5b31337e", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+Space", + "expected_hex": "1b5b33323b3575", + "actual_hex": "1b5b33323b3575", + "passed": true + }, + { + "mode": "alt", + "key": "Left Alt+Space", + "expected_hex": "1b5b33323b3375", + "actual_hex": "1b5b33323b3375", + "passed": true + }, + { + "mode": "alt", + "key": "Ctrl+PageUp", + "expected_hex": "1b5b353b357e", + "actual_hex": "1b5b353b357e", + "passed": true + }, + { + "mode": "alt", + "key": "PageDown", + "expected_hex": "1b5b367e", + "actual_hex": "1b5b367e", + "passed": true + }, + { + "mode": "alt", + "key": "Shift+PageUp alternate scroll", + "expected_hex": "1b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b41", + "actual_hex": "1b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b411b5b41", + "passed": true + }, + { + "mode": "alt", + "key": "Shift+PageDown alternate scroll", + "expected_hex": "1b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b42", + "actual_hex": "1b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b421b5b42", + "passed": true + } + ] + }, + "current_virtual_layout": { + "source_commit": "65c625b2", + "method": "Android framework KeyEvent delivered directly to installed production TerminalView; real session queue, native PTY and foreground shell capture", + "layouts": [ + { + "device": "virtual keyboard", + "altgr_space_codepoint": 0 + }, + { + "device": "Virtual", + "virtual": true, + "altgr_space_codepoint": 0 + } + ], + "results": [ + { + "mode": "kitty", + "key": "Right Alt+Space actual virtual layout", + "expected_hex": "", + "actual_hex": "", + "passed": true + }, + { + "mode": "legacy", + "key": "Right Alt+Space actual virtual layout", + "expected_hex": "", + "actual_hex": "", + "passed": true + } + ] + }, + "rime_touch_and_lifecycle": { + "source_commit": "65c625b2", + "method": "Real Rime touch input and Termux CTRL extra key across native PTY, with application lifecycle and keyboard resize checks; capture setup through temporary instrumentation", + "results": [ + { + "name": "CTRL extra key + real Rime 0", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "name": "Plain Rime 0 after one-shot CTRL clears", + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "name": "CTRL extra key + real Rime 2", + "expected_hex": "1b5b35303b3575", + "actual_hex": "1b5b35303b3575", + "passed": true + }, + { + "name": "CTRL extra key + real Rime 1", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + }, + { + "name": "Real Rime Enter", + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "name": "CTRL + Rime 0 after background and foreground", + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "name": "CTRL + Rime 1 after keyboard close/open and resize", + "expected_hex": "1b5b34393b3575", + "actual_hex": "1b5b34393b3575", + "passed": true + } + ] + }, + "rime_space_and_modifiers": { + "source_commit": "65c625b2", + "method": "Real Rime Space touches plus Termux CTRL/ALT extra keys, captured after native PTY", + "results": [ + { + "name": "Plain Rime Space", + "expected_hex": "20", + "actual_hex": "20", + "passed": true + }, + { + "name": "CTRL extra key + real Rime Space", + "expected_hex": "1b5b33323b3575", + "actual_hex": "1b5b33323b3575", + "passed": true + }, + { + "name": "ALT extra key + real Rime Space", + "expected_hex": "1b5b33323b3375", + "actual_hex": "1b5b33323b3375", + "passed": true + }, + { + "name": "Plain Space after one-shot ALT clears", + "expected_hex": "20", + "actual_hex": "20", + "passed": true + } + ] + } + }, + "user_reported_manual_validation": { + "source_commit": "65c625b2bc7d4b1552084f449400bda18cb00b84", + "date": "2026-10-07", + "source": "user report", + "status": "completed", + "coverage": [ + "on-device and external physical keyboards", + "local Termux and SSH", + "Zellij and tmux" + ], + "versions_and_raw_captures": "not supplied", + "altgr_character_producing_layout": "not specifically confirmed" + } +} diff --git a/docs/kitty-keyboard.md b/docs/kitty-keyboard.md new file mode 100644 index 00000000000..ee52441d5a8 --- /dev/null +++ b/docs/kitty-keyboard.md @@ -0,0 +1,146 @@ +# Basic kitty keyboard protocol support + +The terminal supports the disambiguate escape codes enhancement (flag `1`). It +does not implement repeat/release reporting, alternate key codes, reporting all +keys, or associated text. Unsupported flags are ignored and are never reported +as enabled by a query. + +Applications negotiate support using `CSI ? u`, enable it using `CSI > 1 u`, and +restore the previous mode using `CSI < u`. Direct flag updates (`CSI = flags ; +mode u`) and popping multiple entries are supported. The main and alternate +screens have independent stacks, limited to 32 entries including the current mode. Terminal reset +clears both stacks. Without negotiation the existing keyboard encoding is used. + +Examples after enabling flag `1`: + +| Input | Bytes sent | +| --- | --- | +| Ctrl+0 | `ESC [ 48 ; 5 u` | +| Ctrl+I | `ESC [ 105 ; 5 u` | +| Ctrl+Shift+A | `ESC [ 97 ; 6 u` | +| Shift+Enter | `ESC [ 13 ; 2 u` | +| Ctrl+Enter | `ESC [ 13 ; 5 u` | +| Escape | `ESC [ 27 u` | +| Plain Tab / Enter / Backspace | Existing recovery control bytes | + +Hardware shortcuts use the active Android layout's unshifted Unicode character. +Shift, Alt, Ctrl and Android Meta (mapped to Super) are encoded. Num Lock and +Caps Lock are included for functional keys; keypad keys use dedicated codes. +Plain text, IME commits and paste retain their existing paths. Soft keyboard or +extra-key shortcuts encode the available character and modifiers; an IME text +commit cannot recover a physical key identity or its original modifier state. +Termux application shortcuts continue to take precedence over terminal input. +This includes the existing Shift+PageUp/PageDown scroll bindings. Main-screen +scrollback, alternate-screen arrow scrolling and mouse reporting retain their +existing scroll behavior. Space is resolved through the Android layout before +shortcut encoding, so right Alt/AltGr text is preserved; left Alt+Space and +Ctrl+Space still use negotiated keyboard encoding. + +The program receiving the input must support the protocol. Intermediaries such +as terminal multiplexers also need to pass or translate the sequences correctly. + +Specification: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + +## Validation status + +### Review fixes (2026-10-07) + +- Resolve printable Space through the keyboard layout before encoding; a + synthetic AltGr layout now checks NBSP and Euro text, including Ctrl+AltGr + modifier handling. Ctrl+Space and left Alt+Space keep their negotiated codes. +- Run the existing scroll actions before key encoding, so Shift+PageUp/PageDown + retain precedence in both legacy and kitty mode. Other Page keys still reach + the terminal application. +- Select functional-key encoding through one `KeyHandler.getCode` entry point + used by the view and virtual Fn mappings. Original commits were reworded to + follow the repository's commit-message requirements without changing their + source trees; the original history is retained in the local + `backup/kitty-pre-review-20261007` branch. +- All 150 terminal-emulator and 10 app tests passed locally (zero failures, + errors or skips). The app suite includes four new regression tests for the + above layout and shortcut cases. The AltGr mapping is a test fixture, not a + claim of coverage for every Android keyboard layout. +- The revised APK (`0.118.0+kitty.65c625b2`, source `65c625b2`) was installed as + an update on the Android 16 arm64 PTP-AN10 device, preserving application data. + All 96 recorded checks passed; see + [the review-fix device report](kitty-keyboard-review-device-validation.json). +- Of these, 83 checks delivered Android framework `KeyEvent` objects directly + to the installed production `TerminalView` through a temporary instrumentation + component, then checked the real session queue, native PTY and shell capture. + They include Ctrl/left-Alt+Space, page-key delivery, primary-screen scrollback + offsets, and alternate-screen arrow scrolling. These are not physical HID or + OS input-dispatch tests. +- Two more checks verified right Alt+Space against the current virtual keyboard + mapping in kitty and legacy modes. That mapping returns no character; both + modes correctly sent no bytes. This does not exercise an NBSP-producing layout. +- Eleven checks used real touches on the Rime keyboard and Termux CTRL/ALT extra + keys, including Space combinations, one-shot modifier clearing, Enter, + background/foreground and keyboard close/open. Capture setup used temporary + instrumentation to avoid Chinese composition of the startup command. +- The user reports completing manual combination retests on this revision across + on-device/external physical keyboards, local Termux/SSH, and Zellij/tmux. + This is user-reported verification; tool versions and raw captures were not + supplied. The earlier manual report below remains historical evidence. +- The external-keyboard AltGr/NBSP case remains uncovered; the combination + retest report does not specifically confirm a character-producing AltGr layout. + +Remaining manual verification is an AltGr+Space layout that produces NBSP or +another layout character. + +### Historical validation before review fixes + +Validated on 2026-10-06 with an isolated temporary toolchain: + +- Eclipse Temurin JDK 21.0.12.1, Gradle 9.2.1, Android API 36, Build Tools 36.0.0, + and NDK 29.0.14206865. +- All 150 terminal-emulator unit tests passed, including the five negotiation, + state restoration, screen isolation, parsing and encoding tests in + `KittyKeyboardTest`. +- All six app unit tests passed, including four Robolectric API 28 tests in + `KittyKeyboardInputTest`. Those tests exercise Android keyboard events through + `TerminalView` into the session input queue for Ctrl+0, Ctrl+I, Ctrl+Shift+A, + shifted punctuation, modified Enter/Tab, mode restoration, soft input, and + application shortcut handling. They do not start a native PTY or run a shell. +- Debug APK assembly succeeded for arm64-v8a, armeabi-v7a, x86, x86_64 and the + universal APK using the default apt-android-7 package variant. +- The initial test run caught F3 being treated as the Enter recovery key because + both use parameter 13. Recovery handling now also checks the sequence suffix. +- On a physical PTP-AN10 device running Android 16 (arm64-v8a), the installed + Termux APK passed 52 raw input byte comparisons: 26 combinations in kitty + mode and the same 26 in legacy mode. Android's `input` command injected the + key events; they passed through the production `TerminalView`, native PTY and + a foreground shell capture. Mode queries also verified push/pop restoration + and isolation between the main and alternate screens. The source code tested + was commit `91ae4885`, with APK version `0.118.0+kitty.91ae4885`. +- See [the real-device report](kitty-keyboard-device-validation.json) for each + expected and actual byte sequence. This is device execution evidence, not + Bluetooth/USB keyboard hardware coverage. +- Seven additional checks passed using real touches on the device's Rime numeric + keyboard and Termux CTRL extra key: Ctrl+0/1/2, plain 0 after one-shot CTRL + clears, IME Enter, and modifier encoding after background/foreground and soft + keyboard close/open. See [the IME report](kitty-keyboard-ime-validation.json). + A temporary instrumentation component started the ASCII capture command to + avoid Rime composing the setup command as Chinese; the checked input itself + came from the real keyboard UI and crossed the native PTY. The component is + removed after validation. +- The user additionally reports successful manual verification combining + on-device and external physical keyboards, local Termux and SSH, and + Zellij/tmux. Kitty-encoded input reached the receiver in the tested + combinations. This is user-reported verification; tool versions and raw + captures were not supplied. +- Broader keyboard layouts, IME composition and additional application or + multiplexer versions are outside the recorded coverage. Text-only IME commits cannot expose + physical Shift/Alt key identity that Android does not deliver to the terminal. +- `git diff --check` passed. + +With the toolchain paths exported as `JAVA_HOME` and `ANDROID_HOME`, reproduce +the checks from the repository root: + +```sh +./gradlew --no-daemon --max-workers=2 :terminal-emulator:testDebugUnitTest +./gradlew --no-daemon --max-workers=2 :app:testDebugUnitTest :app:assembleDebug +``` + +For temporary setups, keep `GRADLE_USER_HOME` and `ANDROID_USER_HOME` inside the +same temporary directory as the JDK, SDK and build checkout. After Gradle exits, +delete that directory to remove the toolchain, caches and build artifacts. diff --git a/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java b/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java index 8ecfb630e42..5bcab919511 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java @@ -1,5 +1,6 @@ package com.termux.terminal; +import android.view.KeyEvent; import java.util.HashMap; import java.util.Map; @@ -60,6 +61,109 @@ public final class KeyHandler { public static final int KEYMOD_CTRL = 0x40000000; public static final int KEYMOD_SHIFT = 0x20000000; public static final int KEYMOD_NUM_LOCK = 0x10000000; + public static final int KEYMOD_SUPER = 0x08000000; + public static final int KEYMOD_CAPS_LOCK = 0x04000000; + private static final int[] KITTY_KEYPAD_NAVIGATION = { + 57425, 57424, 57420, 57422, 57417, 57427, 57418, 57423, 57419, 57421 + }; + + /** Encode a non-text key in negotiated kitty disambiguation mode, or return null for text. */ + public static String getKittyCode(int keyCode, int keyMode) { + int codePoint; + char suffix = 'u'; + boolean numLock = (keyMode & KEYMOD_NUM_LOCK) != 0; + if (keyCode >= KEYCODE_NUMPAD_0 && keyCode <= KEYCODE_NUMPAD_9) { + if (numLock) { + codePoint = 57399 + keyCode - KEYCODE_NUMPAD_0; + } else { + // Insert, End, Down, Page Down, Left, Begin, Right, Home, Up, Page Up. + codePoint = KITTY_KEYPAD_NAVIGATION[keyCode - KEYCODE_NUMPAD_0]; + } + return codePoint == 57427 ? kittySequence(1, keyMode, 'E') : kittySequence(codePoint, keyMode, 'u'); + } + switch (keyCode) { + case KEYCODE_ESCAPE: + case KEYCODE_BACK: codePoint = 27; break; + case KEYCODE_ENTER: + case KEYCODE_DPAD_CENTER: codePoint = 13; break; + case KEYCODE_TAB: codePoint = 9; break; + case KEYCODE_DEL: codePoint = 127; break; + // Printable keys, including Space, must first be resolved through the Android layout. + // AltGr+Space may produce a different character instead of an Alt shortcut. + case KEYCODE_SPACE: return null; + case KEYCODE_DPAD_UP: suffix = 'A'; codePoint = 1; break; + case KEYCODE_DPAD_DOWN: suffix = 'B'; codePoint = 1; break; + case KEYCODE_DPAD_RIGHT: suffix = 'C'; codePoint = 1; break; + case KEYCODE_DPAD_LEFT: suffix = 'D'; codePoint = 1; break; + case KEYCODE_MOVE_HOME: suffix = 'H'; codePoint = 1; break; + case KEYCODE_MOVE_END: suffix = 'F'; codePoint = 1; break; + case KEYCODE_INSERT: suffix = '~'; codePoint = 2; break; + case KEYCODE_FORWARD_DEL: suffix = '~'; codePoint = 3; break; + case KEYCODE_PAGE_UP: suffix = '~'; codePoint = 5; break; + case KEYCODE_PAGE_DOWN: suffix = '~'; codePoint = 6; break; + case KEYCODE_F1: suffix = 'P'; codePoint = 1; break; + case KEYCODE_F2: suffix = 'Q'; codePoint = 1; break; + // CSI R overlaps with cursor position reports; kitty uses CSI 13 ~ for F3. + case KEYCODE_F3: suffix = '~'; codePoint = 13; break; + case KEYCODE_F4: suffix = 'S'; codePoint = 1; break; + case KEYCODE_F5: suffix = '~'; codePoint = 15; break; + case KEYCODE_F6: suffix = '~'; codePoint = 17; break; + case KEYCODE_F7: suffix = '~'; codePoint = 18; break; + case KEYCODE_F8: suffix = '~'; codePoint = 19; break; + case KEYCODE_F9: suffix = '~'; codePoint = 20; break; + case KEYCODE_F10: suffix = '~'; codePoint = 21; break; + case KEYCODE_F11: suffix = '~'; codePoint = 23; break; + case KEYCODE_F12: suffix = '~'; codePoint = 24; break; + case KEYCODE_NUMPAD_DOT: codePoint = numLock ? 57409 : 57426; break; + case KEYCODE_NUMPAD_DIVIDE: codePoint = 57410; break; + case KEYCODE_NUMPAD_MULTIPLY: codePoint = 57411; break; + case KEYCODE_NUMPAD_SUBTRACT: codePoint = 57412; break; + case KEYCODE_NUMPAD_ADD: codePoint = 57413; break; + case KEYCODE_NUMPAD_ENTER: codePoint = 57414; break; + case KEYCODE_NUMPAD_EQUALS: codePoint = 57415; break; + case KEYCODE_NUMPAD_COMMA: codePoint = 57416; break; + case KeyEvent.KEYCODE_CAPS_LOCK: codePoint = 57358; break; + case KeyEvent.KEYCODE_SCROLL_LOCK: codePoint = 57359; break; + case KEYCODE_NUM_LOCK: codePoint = 57360; break; + case KEYCODE_SYSRQ: codePoint = 57361; break; + case KEYCODE_BREAK: codePoint = 57362; break; + default: return null; + } + int ordinaryModifiers = keyMode & (KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL | KEYMOD_SUPER); + if (ordinaryModifiers == 0 && suffix == 'u') { + // Preserve the recovery keys even when Caps Lock or Num Lock is on. + if (codePoint == 13) return "\r"; + if (codePoint == 9) return "\t"; + if (codePoint == 127) return "\u007f"; + } + return kittySequence(codePoint, keyMode, suffix); + } + + /** Plain and Shift-only text stays UTF-8; shortcut keys use the unshifted code point. */ + public static String getKittyCodePoint(int unshiftedCodePoint, int keyMode) { + if (unshiftedCodePoint < 32 || !Character.isValidCodePoint(unshiftedCodePoint)) return null; + if ((keyMode & (KEYMOD_ALT | KEYMOD_CTRL | KEYMOD_SUPER)) == 0) return null; + return kittySequence(unshiftedCodePoint, keyMode & ~(KEYMOD_NUM_LOCK | KEYMOD_CAPS_LOCK), 'u'); + } + + private static String kittySequence(int number, int keyMode, char suffix) { + int modifiers = 1; + if ((keyMode & KEYMOD_SHIFT) != 0) modifiers += 1; + if ((keyMode & KEYMOD_ALT) != 0) modifiers += 2; + if ((keyMode & KEYMOD_CTRL) != 0) modifiers += 4; + if ((keyMode & KEYMOD_SUPER) != 0) modifiers += 8; + if ((keyMode & KEYMOD_CAPS_LOCK) != 0) modifiers += 64; + if ((keyMode & KEYMOD_NUM_LOCK) != 0) modifiers += 128; + String prefix = number == 1 && suffix != 'u' && suffix != '~' && modifiers == 1 ? "" : Integer.toString(number); + return "\033[" + prefix + (modifiers == 1 ? "" : ";" + modifiers) + suffix; + } + + /** Select negotiated or legacy functional-key encoding. Null leaves layout text to the caller. */ + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication, + boolean kittyKeyboard) { + return kittyKeyboard ? getKittyCode(keyCode, keyMode) : + getCode(keyCode, keyMode, cursorApp, keypadApplication); + } private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); diff --git a/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java b/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java new file mode 100644 index 00000000000..20175dcebf0 --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java @@ -0,0 +1,47 @@ +package com.termux.terminal; + +/** Per-screen progressive keyboard flags. Only disambiguation (bit 1) is implemented. */ +final class KittyKeyboardState { + // The current flags occupy one entry; reserve at most 31 saved entries. + private static final int MAX_DEPTH = 31; + private final int[] mSavedFlags = new int[MAX_DEPTH]; + private int mDepth; + private int mFlags; + + int getFlags() { + return mFlags; + } + + void set(int flags, int mode) { + flags &= 1; + switch (mode) { + case 1: mFlags = flags; break; + case 2: mFlags |= flags; break; + case 3: mFlags &= ~flags; break; + default: break; + } + } + + void push(int flags) { + if (mDepth == MAX_DEPTH) { + System.arraycopy(mSavedFlags, 1, mSavedFlags, 0, MAX_DEPTH - 1); + mDepth--; + } + mSavedFlags[mDepth++] = mFlags; + set(flags, 1); + } + + void pop(int count) { + if (count <= 0) return; + if (count > mDepth) { + reset(); + } else { + mDepth -= count; + mFlags = mSavedFlags[mDepth]; + } + } + + void reset() { + mDepth = mFlags = 0; + } +} diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index 5e943ab5fc1..42056aa94d4 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -33,6 +33,19 @@ public final class TerminalEmulator { /** Log unknown or unimplemented escape sequences received from the shell process. */ private static final boolean LOG_ESCAPE_SEQUENCES = false; + private static final int ESC_CSI_KITTY_KEYBOARD = 24; + private int mKittyKeyboardCommand; + private final KittyKeyboardState mKittyKeyboardMain = new KittyKeyboardState(); + private final KittyKeyboardState mKittyKeyboardAlt = new KittyKeyboardState(); + + private KittyKeyboardState kittyKeyboardState() { + return mScreen == mAltBuffer ? mKittyKeyboardAlt : mKittyKeyboardMain; + } + + public boolean isKittyKeyboardEnabled() { + return kittyKeyboardState().getFlags() != 0; + } + public static final int MOUSE_LEFT_BUTTON = 0; /** Mouse moving while having left mouse button pressed. */ @@ -901,6 +914,23 @@ public void processCodePoint(int b) { case ESC_CSI_BIGGERTHAN: doCsiBiggerThan(b); break; + case ESC_CSI_KITTY_KEYBOARD: + if (b == 'u') { + // Reject subparameters and excess parameters rather than changing modes. + if (mArgsSubParamsBitSet == 0 && mArgIndex <= (mKittyKeyboardCommand == '=' ? 1 : 0)) { + if (mKittyKeyboardCommand == '<') + kittyKeyboardState().pop(getArg0(1)); + else + kittyKeyboardState().set(getArg0(0), getArg1(1)); + } + } else if ((b >= '0' && b <= '9') || b == ';' || b == ':') { + parseArg(b); + } else if (b >= 0x20 && b <= 0x3F) { + continueSequence(b >= 0x30 ? ESC_CSI_UNSUPPORTED_PARAMETER_BYTE : ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE); + } else { + unknownSequence(b); + } + break; case ESC_CSI_DOLLAR: boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); int effectiveTopMargin = originMode ? mTopMargin : 0; @@ -1703,6 +1733,10 @@ private void doCsiUnsupportedParameterOrIntermediateByte(int b) { /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ private void doCsiQuestionMark(int b) { switch (b) { + case 'u': + if (mArgIndex == 0 && mArgs[0] == -1) + mSession.write("\033[?" + kittyKeyboardState().getFlags() + "u"); + break; case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED. case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL. mAboutToAutoWrap = false; @@ -1887,6 +1921,10 @@ public void doDecSetOrReset(boolean setting, int externalBit) { private void doCsiBiggerThan(int b) { switch (b) { + case 'u': + if (mArgIndex == 0 && mArgsSubParamsBitSet == 0) + kittyKeyboardState().push(getArg0(0)); + break; case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2). // Originally this was used for the terminal to respond with "identification code, firmware version level, // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420 @@ -2308,7 +2346,8 @@ private void doCsi(int b) { break; case '<': // "Esc [ <" -- start of a private parameter byte case '=': // "Esc [ =" -- start of a private parameter byte - continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + mKittyKeyboardCommand = b; + continueSequence(ESC_CSI_KITTY_KEYBOARD); break; case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). setCursorColRespectingOriginMode(getArg0(1) - 1); @@ -3484,6 +3523,8 @@ public void setTopRow(int topRow) { /** Reset terminal state so user can interact with it regardless of present state. */ public void reset() { + mKittyKeyboardMain.reset(); + mKittyKeyboardAlt.reset(); setCursorStyle(); mArgIndex = 0; mContinueSequence = false; diff --git a/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java b/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java new file mode 100644 index 00000000000..93838b630c6 --- /dev/null +++ b/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java @@ -0,0 +1,86 @@ +package com.termux.terminal; + +import android.view.KeyEvent; + +/** Negotiation goes through the real terminal parser; key output is checked against protocol bytes. */ +public class KittyKeyboardTest extends TerminalTestCase { + public void testNegotiationAndRestore() { + withTerminalSized(20, 4); + assertEnteringStringGivesResponse("\033[?u", "\033[?0u"); + enterString("\033[>1u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?1u"); + enterString("\033[>0u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?0u"); + enterString("\033[1;2u"); + assertFalse(mTerminal.isKittyKeyboardEnabled()); + } + + public void testScreenIsolationAndReset() { + withTerminalSized(20, 4); + enterString("\033[>1u\033[?1049h"); + assertFalse(mTerminal.isKittyKeyboardEnabled()); + enterString("\033[>1u\033[>0u\033[?1049l"); + assertTrue(mTerminal.isKittyKeyboardEnabled()); + enterString("\033[1u\033[?u".toCharArray()) enterString(String.valueOf(c)); + assertEquals("\033[?1u", mOutput.getOutputAndClear()); + for (int i = 0; i < 100; i++) enterString("\033[>1u"); + enterString("\033[<9999u"); + assertFalse(mTerminal.isKittyKeyboardEnabled()); + enterString("\033[=1 $uOK"); + assertLineIs(0, "OK "); + } + + public void testShortcutBytesAndTextFallback() { + assertEquals("\033[48;5u", KeyHandler.getKittyCodePoint('0', KeyHandler.KEYMOD_CTRL)); + assertEquals("\033[105;5u", KeyHandler.getKittyCodePoint('i', KeyHandler.KEYMOD_CTRL)); + assertEquals("\033[97;6u", KeyHandler.getKittyCodePoint('a', KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT)); + assertEquals("\033[61;6u", KeyHandler.getKittyCodePoint('=', KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT)); + assertEquals("\033[91;3u", KeyHandler.getKittyCodePoint('[', KeyHandler.KEYMOD_ALT)); + assertNull(KeyHandler.getKittyCodePoint('a', 0)); + assertNull(KeyHandler.getKittyCodePoint('a', KeyHandler.KEYMOD_SHIFT)); + assertNull(KeyHandler.getKittyCodePoint(0x4E2D, 0)); + assertEquals("\033[13;2u", KeyHandler.getKittyCode(KeyEvent.KEYCODE_ENTER, KeyHandler.KEYMOD_SHIFT)); + assertEquals("\033[13;5u", KeyHandler.getKittyCode(KeyEvent.KEYCODE_ENTER, KeyHandler.KEYMOD_CTRL)); + assertEquals("\033[9;6u", KeyHandler.getKittyCode(KeyEvent.KEYCODE_TAB, KeyHandler.KEYMOD_SHIFT | KeyHandler.KEYMOD_CTRL)); + assertEquals("\r", KeyHandler.getKittyCode(KeyEvent.KEYCODE_ENTER, KeyHandler.KEYMOD_NUM_LOCK)); + assertEquals("\t", KeyHandler.getKittyCode(KeyEvent.KEYCODE_TAB, 0)); + assertEquals("\u007f", KeyHandler.getKittyCode(KeyEvent.KEYCODE_DEL, 0)); + assertEquals("\033[27u", KeyHandler.getKittyCode(KeyEvent.KEYCODE_ESCAPE, 0)); + assertEquals("\033[13~", KeyHandler.getKittyCode(KeyEvent.KEYCODE_F3, 0)); + assertEquals("\033[1;2A", KeyHandler.getKittyCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_SHIFT)); + assertEquals("\033[57399;129u", KeyHandler.getKittyCode(KeyEvent.KEYCODE_NUMPAD_0, KeyHandler.KEYMOD_NUM_LOCK)); + assertEquals("\033[57425u", KeyHandler.getKittyCode(KeyEvent.KEYCODE_NUMPAD_0, 0)); + assertEquals("\033[E", KeyHandler.getKittyCode(KeyEvent.KEYCODE_NUMPAD_5, 0)); + assertEquals("\033[97;9u", KeyHandler.getKittyCodePoint('a', KeyHandler.KEYMOD_SUPER)); + assertEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, KeyHandler.KEYMOD_SHIFT, false, false)); + assertEquals("\033", KeyHandler.getCode(KeyEvent.KEYCODE_ESCAPE, 0, false, false)); + } +} diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index f4f82ad7c51..5dd439b563a 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -798,6 +798,10 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { if (event.isAltPressed() || leftAltDown) keyMod |= KeyHandler.KEYMOD_ALT; if (shiftDown) keyMod |= KeyHandler.KEYMOD_SHIFT; if (event.isNumLockOn()) keyMod |= KeyHandler.KEYMOD_NUM_LOCK; + if (mEmulator.isKittyKeyboardEnabled()) { + if (event.isMetaPressed()) keyMod |= KeyHandler.KEYMOD_SUPER; + if (event.isCapsLockOn()) keyMod |= KeyHandler.KEYMOD_CAPS_LOCK; + } // https://github.com/termux/termux-app/issues/731 if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event"); @@ -806,6 +810,7 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { // Clear Ctrl since we handle that ourselves: int bitsToClear = KeyEvent.META_CTRL_MASK; + if (mEmulator.isKittyKeyboardEnabled()) bitsToClear |= KeyEvent.META_META_MASK; if (rightAltDownFromEvent) { // Let right Alt/Alt Gr be used to compose characters. } else { @@ -836,7 +841,15 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { if (combinedChar > 0) result = combinedChar; mCombiningAccent = 0; } - inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown); + // Keep the layout's unshifted key before legacy Ctrl mapping destroys its identity. + int unshiftedMetaState = effectiveMetaState & ~(KeyEvent.META_SHIFT_MASK | KeyEvent.META_CAPS_LOCK_ON); + int unshiftedCodePoint = event.getUnicodeChar(unshiftedMetaState); + if (oldCombiningAccent != 0 || unshiftedCodePoint <= 0 || (unshiftedCodePoint & KeyCharacterMap.COMBINING_ACCENT) != 0) + unshiftedCodePoint = Character.toLowerCase(result); + int textKeyMod = keyMod; + // Right Alt is used by the keyboard layout (AltGr), rather than a terminal Alt shortcut. + if (rightAltDownFromEvent && !leftAltDown) textKeyMod &= ~KeyHandler.KEYMOD_ALT; + inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown, unshiftedCodePoint, textKeyMod); } if (mCombiningAccent != oldCombiningAccent) invalidate(); @@ -845,6 +858,12 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { } public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { + inputCodePoint(eventSource, codePoint, controlDownFromEvent, leftAltDownFromEvent, + Character.toLowerCase(codePoint), 0); + } + + private void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, + boolean leftAltDownFromEvent, int unshiftedCodePoint, int keyMod) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { mClient.logInfo(LOG_TAG, "inputCodePoint(eventSource=" + eventSource + ", codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + leftAltDownFromEvent + ")"); @@ -861,6 +880,17 @@ public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFr if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return; + if (mEmulator != null && mEmulator.isKittyKeyboardEnabled()) { + if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL; + if (altDown) keyMod |= KeyHandler.KEYMOD_ALT; + if (mClient.readShiftKey()) keyMod |= KeyHandler.KEYMOD_SHIFT; + String kittyCode = KeyHandler.getKittyCodePoint(unshiftedCodePoint, keyMod); + if (kittyCode != null) { + mTermSession.write(kittyCode); + return; + } + } + if (controlDown) { if (codePoint >= 'a' && codePoint <= 'z') { codePoint = codePoint - 'a' + 1; @@ -919,7 +949,8 @@ public boolean handleKeyCode(int keyCode, int keyMod) { return true; TerminalEmulator term = mTermSession.getEmulator(); - String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()); + String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), + term.isKeypadApplicationMode(), term.isKittyKeyboardEnabled()); if (code == null) return false; mTermSession.write(code); return true; From 59c2f1ea591fed2c282380b0542597d4b5c29ba1 Mon Sep 17 00:00:00 2001 From: JM-FRANK <156544419@qq.com> Date: Fri, 9 Oct 2026 00:10:05 +0900 Subject: [PATCH 2/4] Added(keyboard): Add kitty keyboard event reporting Support independent flag 2 negotiation and Android repeat/release events. Preserve the initial release identity across repeats and encode modified recovery repeats while retaining text and recovery behavior. Include protocol and Android input regressions, 59 native PTY checks, real Bluetooth recovery holds and 12 real IME checks. Document remaining hardware/layout coverage and retain the deferred virtual Fn Ctrl limitation. --- .../terminal/TermuxTerminalViewClient.java | 5 +- .../app/terminal/KittyKeyboardInputTest.java | 269 +++ ...tty-keyboard-events-device-validation.json | 1769 +++++++++++++++++ .../kitty-keyboard-real-input-validation.json | 365 ++++ docs/kitty-keyboard.md | 138 +- .../java/com/termux/terminal/KeyHandler.java | 80 +- .../termux/terminal/KittyKeyboardState.java | 16 +- .../com/termux/terminal/TerminalEmulator.java | 11 + .../termux/terminal/KittyKeyboardTest.java | 44 +- .../java/com/termux/view/TerminalView.java | 110 +- 10 files changed, 2782 insertions(+), 25 deletions(-) create mode 100644 docs/kitty-keyboard-events-device-validation.json create mode 100644 docs/kitty-keyboard-real-input-validation.json diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java index 1ffa8c6f073..16dd27094de 100644 --- a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -455,11 +455,12 @@ public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSessio if (resultingKeyCode != -1) { TerminalEmulator term = session.getEmulator(); session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), - term.isKeypadApplicationMode(), term.isKittyKeyboardEnabled())); + term.isKeypadApplicationMode(), term.getKittyKeyboardFlags(), KeyHandler.KEY_EVENT_PRESS)); } else if (resultingCodePoint != -1) { String code = null; if (session.getEmulator().isKittyKeyboardEnabled()) { - code = KeyHandler.getKittyCodePoint(resultingCodePoint, altDown ? KeyHandler.KEYMOD_ALT : 0); + code = KeyHandler.getKittyCodePoint(resultingCodePoint, altDown ? KeyHandler.KEYMOD_ALT : 0, + session.getEmulator().getKittyKeyboardFlags(), KeyHandler.KEY_EVENT_PRESS); } if (code != null) session.write(code); else session.writeCodePoint(altDown, resultingCodePoint); diff --git a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java index 97e3e42b5c6..913c347248b 100644 --- a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java +++ b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java @@ -169,4 +169,273 @@ public void otherPageKeysStillReachTheApplication() throws Exception { press(KeyEvent.KEYCODE_PAGE_UP, KeyEvent.META_CTRL_ON, "\033[5;5~"); press(KeyEvent.KEYCODE_PAGE_DOWN, 0, "\033[6~"); } + private void keyEvent(int action, int keyCode, int repeat, int metaState, int deviceId, String expected) throws Exception { + KeyEvent event = new KeyEvent(0, 0, action, keyCode, repeat, metaState, deviceId, 0); + assertTrue(action == KeyEvent.ACTION_UP ? mView.onKeyUp(keyCode, event) : mView.onKeyDown(keyCode, event)); + assertEquals(expected, drainInput()); + } + + private void repeat(int keyCode, int metaState, String expected) throws Exception { + keyEvent(KeyEvent.ACTION_DOWN, keyCode, 1, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, expected); + } + + private void release(int keyCode, int metaState, String expected) throws Exception { + keyEvent(KeyEvent.ACTION_UP, keyCode, 0, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, expected); + } + + @Test + public void negotiatedRepeatsAndReleasesReachSession() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + repeat(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[1;1:2A"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[1;1:3A"); + press(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\033[105;5u"); + repeat(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\033[105;5:2u"); + release(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\033[105;5:3u"); + press(KeyEvent.KEYCODE_F3, KeyEvent.META_SHIFT_ON, "\033[13;2~"); + release(KeyEvent.KEYCODE_F3, KeyEvent.META_SHIFT_ON, "\033[13;2:3~"); + press(KeyEvent.KEYCODE_ESCAPE, 0, "\033[27u"); + release(KeyEvent.KEYCODE_ESCAPE, 0, "\033[27;1:3u"); + } + + @Test + public void eventReportingCanBeEnabledWithoutDisambiguation() throws Exception { + enter("\033[>2u"); + press(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\t"); + repeat(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\033[105;5:2u"); + release(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\033[105;5:3u"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[1;1:3A"); + press(KeyEvent.KEYCODE_ENTER, KeyEvent.META_CTRL_ON, "\r"); + release(KeyEvent.KEYCODE_ENTER, KeyEvent.META_CTRL_ON, ""); + } + + @Test + public void textAndRecoveryKeysKeepPressAndRepeatPaths() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_A, 0, "a"); + repeat(KeyEvent.KEYCODE_A, 0, "a"); + release(KeyEvent.KEYCODE_A, 0, ""); + press(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON, "A"); + release(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON, ""); + int[] keys = {KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_DEL}; + String[] bytes = {"\r", "\t", "\u007f"}; + for (int i = 0; i < keys.length; i++) { + press(keys[i], 0, bytes[i]); + repeat(keys[i], 0, bytes[i]); + release(keys[i], 0, ""); + } + press(KeyEvent.KEYCODE_ENTER, KeyEvent.META_CTRL_ON, "\033[13;5u"); + repeat(KeyEvent.KEYCODE_ENTER, KeyEvent.META_CTRL_ON, "\033[13;5:2u"); + release(KeyEvent.KEYCODE_ENTER, KeyEvent.META_CTRL_ON, ""); + } + + @Test + public void releasesUseCurrentModifiersAndRememberLayoutIdentity() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_EQUALS, KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, "\033[61;6u"); + release(KeyEvent.KEYCODE_EQUALS, 0, "\033[61;1:3u"); + press(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.META_CTRL_ON, "\033[1;5A"); + release(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.META_SHIFT_ON, "\033[1;2:3A"); + } + + @Test + public void orphanedCanceledAndModeChangedReleasesAreSuppressed() throws Exception { + enter("\033[>3u"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + enter("\033[>0u\033[3u"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + KeyEvent canceled = new KeyEvent(0, 0, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP, 0, 0, + KeyCharacterMap.VIRTUAL_KEYBOARD, 0, KeyEvent.FLAG_CANCELED); + assertTrue(mView.onKeyUp(KeyEvent.KEYCODE_DPAD_UP, canceled)); + assertEquals("", drainInput()); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + } + + @Test + public void releasesDoNotCrossScreensOrDevices() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + enter("\033[?1049h\033[>3u"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + enter("\033[?1049l"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + keyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP, 0, 0, 42, ""); + release(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[1;1:3A"); + } + + @Test + public void shortcutsScrollAndSoftInputDoNotSynthesizeReleases() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_PAGE_UP, KeyEvent.META_SHIFT_ON, ""); + release(KeyEvent.KEYCODE_PAGE_UP, 0, ""); + mView.inputCodePoint(TerminalView.KEY_EVENT_SOURCE_SOFT_KEYBOARD, 'i', true, false); + assertEquals("\033[105;5u", drainInput()); + release(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, ""); + mView.setTerminalViewClient(new TermuxTerminalViewClientBase() { + @Override + public boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session) { + return true; + } + }); + press(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, ""); + release(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, ""); + mView.setTerminalViewClient(new TermuxTerminalViewClientBase() { + @Override + public boolean onKeyDown(int keyCode, KeyEvent event, TerminalSession session) { + return true; + } + }); + press(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + } + + @Test + public void disabledEventReportingKeepsRepeatAsPress() throws Exception { + for (int flags : new int[]{0, 1}) { + enter("\033[=" + flags + "u"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + repeat(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + } + } + + @Test + public void keypadReleaseKeepsIdentityWhenNumLockChanges() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[57399;129u"); + release(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[57399;1:3u"); + press(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[57425u"); + release(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[57425;129:3u"); + enter("\033[=2u"); + press(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[2~"); + release(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[2;129:3~"); + } + + @Test + public void escapeEventsDoNotRequireDisambiguation() throws Exception { + enter("\033[>2u"); + press(KeyEvent.KEYCODE_ESCAPE, 0, "\033"); + repeat(KeyEvent.KEYCODE_ESCAPE, 0, "\033[27;1:2u"); + release(KeyEvent.KEYCODE_ESCAPE, 0, "\033[27;1:3u"); + } + + @Test + public void focusLossAndScreenRoundTripDiscardPendingReleases() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + enter("\033[?1049h\033[?1049l"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + ReflectionHelpers.callInstanceMethod(mView, "onFocusChanged", + ReflectionHelpers.ClassParameter.from(boolean.class, false), + ReflectionHelpers.ClassParameter.from(int.class, 0), + ReflectionHelpers.ClassParameter.from(android.graphics.Rect.class, null)); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + } + + @Test + @Config(shadows = AltGrCharacterMap.class) + public void altGrTextDoesNotProduceReleaseEvents() throws Exception { + enter("\033[>3u"); + int altGr = KeyEvent.META_ALT_ON | KeyEvent.META_ALT_RIGHT_ON; + press(KeyEvent.KEYCODE_SPACE, altGr, "\u00a0"); + repeat(KeyEvent.KEYCODE_SPACE, altGr, "\u00a0"); + release(KeyEvent.KEYCODE_SPACE, altGr, ""); + press(KeyEvent.KEYCODE_E, altGr | KeyEvent.META_CTRL_ON, "\033[8364;5u"); + release(KeyEvent.KEYCODE_E, altGr, "\033[8364;1:3u"); + } + + @Test + public void applicationKeyUpCanConsumeRelease() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + mView.setTerminalViewClient(new TermuxTerminalViewClientBase() { + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return true; + } + }); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + } + + @Test + public void extraKeyBridgeSendsOnePressWithoutPendingRelease() throws Exception { + enter("\033[>3u"); + new com.termux.shared.termux.terminal.io.TerminalExtraKeys(mView) { + void tap() { + onTerminalExtraKeyButtonClick(null, "UP", true, false, false, false); + } + }.tap(); + assertEquals("\033[1;5A", drainInput()); + keyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP, 0, KeyEvent.META_CTRL_ON, 0, ""); + } + + @Test + public void virtualFnPressEncodingUsesActualNegotiatedFlags() throws Exception { + TermuxTerminalViewClient client = new TermuxTerminalViewClient(null, null); + client.mVirtualFnKeyDown = true; + for (int flags : new int[]{0, 1, 2, 3}) { + enter("\033[=" + flags + "u"); + assertTrue(client.onCodePoint('b', false, mSession)); + assertEquals((flags & 1) != 0 ? "\033[98;3u" : "\033b", drainInput()); + assertTrue(client.onCodePoint('e', false, mSession)); + assertEquals((flags & 1) != 0 ? "\033[27u" : "\033", drainInput()); + // The separately deferred virtual Fn Ctrl-loss behavior remains unchanged. + assertTrue(client.onCodePoint('w', true, mSession)); + assertEquals("\033[A", drainInput()); + } + } + + @Test + public void keypadRepeatsPreserveInitialReleaseIdentity() throws Exception { + enter("\033[>3u"); + press(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[57399;129u"); + repeat(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[57425;1:2u"); + release(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[57399;1:3u"); + press(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[57425u"); + repeat(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[57399;129:2u"); + release(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[57425;129:3u"); + enter("\033[=2u"); + press(KeyEvent.KEYCODE_NUMPAD_0, 0, "\033[2~"); + repeat(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "0"); + release(KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.META_NUM_LOCK_ON, "\033[2;129:3~"); + } + + @Test + public void repeatsDoNotCreateOrRevivePendingReleases() throws Exception { + enter("\033[>3u"); + repeat(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[1;1:2A"); + release(KeyEvent.KEYCODE_DPAD_UP, 0, ""); + press(KeyEvent.KEYCODE_DPAD_UP, 0, "\033[A"); + enter("\033[>0u\033[2u"); + int[] keys = {KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_DEL}; + int[] modifiers = {KeyEvent.META_CTRL_ON, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON}; + String[] pressBytes = {"\r", "\033[Z", "\033\u007f"}; + String[] repeatBytes = {"\033[13;5:2u", "\033[9;2:2u", "\033[127;3:2u"}; + for (int i = 0; i < keys.length; i++) { + press(keys[i], modifiers[i], pressBytes[i]); + repeat(keys[i], modifiers[i], repeatBytes[i]); + release(keys[i], modifiers[i], ""); + } + String[] plainBytes = {"\r", "\t", "\u007f"}; + for (int i = 0; i < keys.length; i++) { + press(keys[i], KeyEvent.META_NUM_LOCK_ON | KeyEvent.META_CAPS_LOCK_ON, plainBytes[i]); + repeat(keys[i], KeyEvent.META_NUM_LOCK_ON | KeyEvent.META_CAPS_LOCK_ON, plainBytes[i]); + release(keys[i], 0, ""); + } + } + } diff --git a/docs/kitty-keyboard-events-device-validation.json b/docs/kitty-keyboard-events-device-validation.json new file mode 100644 index 00000000000..d0691af0dd8 --- /dev/null +++ b/docs/kitty-keyboard-events-device-validation.json @@ -0,0 +1,1769 @@ +{ + "method": "Android framework KeyEvent delivered directly to production TerminalView; real TerminalSession/native PTY and bash raw byte capture; not physical HID or OS input dispatch", + "model": "PTP-AN10", + "android": "16", + "checks": 59, + "failures": 0, + "results": [ + { + "name": "Up press repeat release", + "flags": 0, + "expected_hex": "1b5b411b5b41", + "actual_hex": "1b5b411b5b41", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "flags": 0, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "flags": 0, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "flags": 0, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "flags": 0, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "flags": 0, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "flags": 0, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "flags": 0, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "flags": 0, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "flags": 0, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "flags": 0, + "expected_hex": "1b1b", + "actual_hex": "1b1b", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "flags": 1, + "expected_hex": "1b5b411b5b41", + "actual_hex": "1b5b411b5b41", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "flags": 1, + "expected_hex": "1b5b3130353b35751b5b3130353b3575", + "actual_hex": "1b5b3130353b35751b5b3130353b3575", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "flags": 1, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "flags": 1, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "flags": 1, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "flags": 1, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "flags": 1, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "flags": 1, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "flags": 1, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "flags": 1, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "flags": 1, + "expected_hex": "1b5b3237751b5b323775", + "actual_hex": "1b5b3237751b5b323775", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "flags": 2, + "expected_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "actual_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "flags": 2, + "expected_hex": "091b5b3130353b353a32751b5b3130353b353a3375", + "actual_hex": "091b5b3130353b353a32751b5b3130353b353a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "flags": 2, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "flags": 2, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "flags": 2, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "flags": 2, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "flags": 2, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "flags": 2, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "flags": 2, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "flags": 2, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "flags": 2, + "expected_hex": "1b1b5b32373b313a32751b5b32373b313a3375", + "actual_hex": "1b1b5b32373b313a32751b5b32373b313a3375", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "flags": 3, + "expected_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "actual_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "flags": 3, + "expected_hex": "1b5b3130353b35751b5b3130353b353a32751b5b3130353b353a3375", + "actual_hex": "1b5b3130353b35751b5b3130353b353a32751b5b3130353b353a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "flags": 3, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "flags": 3, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "flags": 3, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "flags": 3, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "flags": 3, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "flags": 3, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "flags": 3, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "flags": 3, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "flags": 3, + "expected_hex": "1b5b3237751b5b32373b313a32751b5b32373b313a3375", + "actual_hex": "1b5b3237751b5b32373b313a32751b5b32373b313a3375", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P2 Ctrl+Enter repeated", + "flags": 2, + "expected_hex": "0d1b5b31333b353a3275", + "actual_hex": "0d1b5b31333b353a3275", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 4096, + 0 + ], + [ + 0, + 66, + 1, + 4096, + 0 + ], + [ + 1, + 66, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "P2 Shift+Tab repeated", + "flags": 2, + "expected_hex": "1b5b5a1b5b393b323a3275", + "actual_hex": "1b5b5a1b5b393b323a3275", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 1, + 0 + ], + [ + 0, + 61, + 1, + 1, + 0 + ], + [ + 1, + 61, + 0, + 1, + 0 + ] + ] + }, + { + "name": "P2 Alt+Backspace repeated", + "flags": 2, + "expected_hex": "1b7f1b5b3132373b333a3275", + "actual_hex": "1b7f1b5b3132373b333a3275", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 18, + 0 + ], + [ + 0, + 67, + 1, + 18, + 0 + ], + [ + 1, + 67, + 0, + 18, + 0 + ] + ] + }, + { + "name": "F3 Shift press repeat release", + "flags": 2, + "expected_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "actual_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "passed": true, + "events": [ + [ + 0, + 133, + 0, + 1, + 0 + ], + [ + 0, + 133, + 1, + 1, + 0 + ], + [ + 1, + 133, + 0, + 1, + 0 + ] + ] + }, + { + "name": "modifier released before letter", + "flags": 2, + "expected_hex": "091b5b3130353b313a3375", + "actual_hex": "091b5b3130353b313a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 0, + 0 + ] + ] + }, + { + "name": "orphaned repeat has no release", + "flags": 2, + "expected_hex": "1b5b313b313a3241", + "actual_hex": "1b5b313b313a3241", + "passed": true, + "events": [ + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P2 Ctrl+Enter repeated", + "flags": 3, + "expected_hex": "1b5b31333b35751b5b31333b353a3275", + "actual_hex": "1b5b31333b35751b5b31333b353a3275", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 4096, + 0 + ], + [ + 0, + 66, + 1, + 4096, + 0 + ], + [ + 1, + 66, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "P2 Shift+Tab repeated", + "flags": 3, + "expected_hex": "1b5b393b32751b5b393b323a3275", + "actual_hex": "1b5b393b32751b5b393b323a3275", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 1, + 0 + ], + [ + 0, + 61, + 1, + 1, + 0 + ], + [ + 1, + 61, + 0, + 1, + 0 + ] + ] + }, + { + "name": "P2 Alt+Backspace repeated", + "flags": 3, + "expected_hex": "1b5b3132373b33751b5b3132373b333a3275", + "actual_hex": "1b5b3132373b33751b5b3132373b333a3275", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 18, + 0 + ], + [ + 0, + 67, + 1, + 18, + 0 + ], + [ + 1, + 67, + 0, + 18, + 0 + ] + ] + }, + { + "name": "F3 Shift press repeat release", + "flags": 3, + "expected_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "actual_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "passed": true, + "events": [ + [ + 0, + 133, + 0, + 1, + 0 + ], + [ + 0, + 133, + 1, + 1, + 0 + ], + [ + 1, + 133, + 0, + 1, + 0 + ] + ] + }, + { + "name": "modifier released before letter", + "flags": 3, + "expected_hex": "1b5b3130353b35751b5b3130353b313a3375", + "actual_hex": "1b5b3130353b35751b5b3130353b313a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 0, + 0 + ] + ] + }, + { + "name": "orphaned repeat has no release", + "flags": 3, + "expected_hex": "1b5b313b313a3241", + "actual_hex": "1b5b313b313a3241", + "passed": true, + "events": [ + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P1 NumLock on to off during repeats", + "flags": 3, + "expected_hex": "1b5b35373339393b313239751b5b35373432353b313a32751b5b35373339393b313a3375", + "actual_hex": "1b5b35373339393b313239751b5b35373432353b313a32751b5b35373339393b313a3375", + "passed": true, + "events": [ + [ + 0, + 144, + 0, + 2097152, + 0 + ], + [ + 0, + 144, + 1, + 0, + 0 + ], + [ + 1, + 144, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P1 NumLock off to on during repeats", + "flags": 3, + "expected_hex": "1b5b3537343235751b5b35373339393b3132393a32751b5b35373432353b3132393a3375", + "actual_hex": "1b5b3537343235751b5b35373339393b3132393a32751b5b35373432353b3132393a3375", + "passed": true, + "events": [ + [ + 0, + 144, + 0, + 0, + 0 + ], + [ + 0, + 144, + 1, + 2097152, + 0 + ], + [ + 1, + 144, + 0, + 2097152, + 0 + ] + ] + }, + { + "name": "P1 legacy keypad navigation to text repeat", + "flags": 2, + "expected_hex": "1b5b327e301b5b323b3132393a337e", + "actual_hex": "1b5b327e301b5b323b3132393a337e", + "passed": true, + "events": [ + [ + 0, + 144, + 0, + 0, + 0 + ], + [ + 0, + 144, + 1, + 2097152, + 0 + ], + [ + 1, + 144, + 0, + 2097152, + 0 + ] + ] + } + ], + "date": "2026-10-08", + "apk_version": "0.118.0+kitty.events.p2.60a581a1.2439221fb851", + "apk_arm64_sha256": "e792ba8bea09cccbafaa262154fc6f6ecda32f9ab5180f70020716960f22c392", + "source_patch_sha256": "2439221fb851e0e8ddef6501402d9aca9ea2149ad17c0a39e4a0aa43bc98f02c", + "base_commit": "60a581a175acbf632960af0a5226f90e94ca7818", + "all_recorded_checks_passed": true, + "uncovered": [ + "Physical HID keyboards and OS input-dispatch injection", + "Real IME/extra-key touches and device lifecycle/session-switch behavior", + "SSH and multiplexer behavior", + "AltGr+Space with a character-producing hardware layout" + ], + "harness_notes": [ + "Foreground target activity avoids OEM background process freezing.", + "Capture uses timeout --foreground and an explicit start-file handshake.", + "A previous attempt recorded 58 passes and one infrastructure timeout; report retained separately.", + "Initial background harness stalled; user authorized terminating its Termux process. No application data cleared." + ] +} diff --git a/docs/kitty-keyboard-real-input-validation.json b/docs/kitty-keyboard-real-input-validation.json new file mode 100644 index 00000000000..964fecdc5f2 --- /dev/null +++ b/docs/kitty-keyboard-real-input-validation.json @@ -0,0 +1,365 @@ +{ + "started_date": "2026-10-08", + "completed_date": "2026-10-09", + "device": { + "model": "PTP-AN10", + "android": "16", + "abi": "arm64-v8a" + }, + "apk_version": "0.118.0+kitty.events.p2.60a581a1.2439221fb851", + "apk_sha256": "e792ba8bea09cccbafaa262154fc6f6ecda32f9ab5180f70020716960f22c392", + "source_patch_sha256": "2439221fb851e0e8ddef6501402d9aca9ea2149ad17c0a39e4a0aa43bc98f02c", + "bluetooth": { + "keyboard": "RAPOO Keyboard-1 Keyboard", + "android_device_id": 9, + "sources": "0x01000311", + "keyboard_type": 2, + "actual_hardware": true, + "input_injection": false, + "flag3_group": { + "stage": "bt_flag3_retry_1791470731", + "flags": 3, + "method": "Actual Bluetooth keyboard hardware input through Android input dispatch, production Termux activity/client/view and native PTY. No injected key events.", + "keyboard": "RAPOO Keyboard-1 Keyboard", + "raw_hex": "1b5b411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a32411b5b313b313a33411b5b3130353b35751b5b3130353b353a33751b5b3130353b35751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a33751b5b39373b36751b5b39373b363a33751b5b31333b35751b5b393b32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b33323b33751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a32751b5b33323b333a33751b5b3237751b5b32373b313a33751b5b3130353b35751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a32751b5b3130353b353a337561410d097f", + "sequence_counts": { + "ESC[A": 1, + "ESC[1;1:2A": 13, + "ESC[1;1:3A": 1, + "ESC[105;5u": 3, + "ESC[105;5:3u": 3, + "ESC[105;5:2u": 27, + "ESC[97;6u": 1, + "ESC[97;6:3u": 1, + "ESC[13;5u": 1, + "ESC[9;2u": 1, + "ESC[9;2:2u": 28, + "ESC[32;3u": 1, + "ESC[32;3:2u": 25, + "ESC[32;3:3u": 1, + "ESC[27u": 1, + "ESC[27;1:3u": 1 + }, + "passed_observations": [ + "Up press/repeat/release", + "Ctrl+I press/repeat/release", + "Ctrl+Shift+A press/release", + "Esc press/release", + "plain a and Shift+A text", + "plain Enter/Tab/Backspace control bytes without releases" + ], + "pending": [ + "Ctrl+Enter repeat absent", + "requested Alt+Backspace yielded Alt+Space", + "modifier-first I release still carried Ctrl" + ], + "text_tail_hex": "61410d097f", + "all_requested_checks_passed": false + }, + "flag2_recovery_supplement": { + "flags": 2, + "keyboard": "RAPOO Keyboard-1 Keyboard", + "method": "Actual Bluetooth keyboard hardware input through Android input dispatch, production Termux activity/client/view and native PTY. No injected key events.", + "hardware_event_log": "bt_flag2_recovery_1791470895-hardware-events.log", + "raw_capture": "bt_flag2_recovery_1791470895.bin", + "raw_hex": "0d1b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b31333b353a32751b5b5a1b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b5b393b323a32751b7f1b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a32751b5b3132373b333a3275", + "checks": [ + { + "input": "Ctrl+Enter hold", + "repeat_count": 34, + "press_hex": "0d", + "repeat_hex": "1b5b31333b353a3275", + "release": "none", + "passed": true + }, + { + "input": "Shift+Tab hold", + "repeat_count": 37, + "press_hex": "1b5b5a", + "repeat_hex": "1b5b393b323a3275", + "release": "none", + "passed": true + }, + { + "input": "left Alt+Backspace hold", + "repeat_count": 39, + "press_hex": "1b7f", + "repeat_hex": "1b5b3132373b333a3275", + "release": "none", + "passed": true + } + ], + "all_checks_passed": true + }, + "limitations": [ + "No numeric keypad: P1 Num Lock/keypad hardware scenarios skipped.", + "Original flag3 group had no Ctrl+Enter repeat, Alt+Space instead of requested Alt+Backspace, and I released while Ctrl still active; these original cases were not passed.", + "The simpler flag2 supplement confirms real Ctrl+Enter, Shift+Tab, Alt+Backspace holds and correct repeated bytes; it does not retroactively pass the missing flag3 cases.", + "Modifier-first I release remains unverified on hardware.", + "AltGr character-producing layout not tested." + ] + }, + "ime": { + "id": "org.fcitx.fcitx5.android/.input.FcitxInputMethodService", + "engines": [ + "English", + "Rime 中州韡(θ–„)" + ], + "method": "ADB touchscreen taps and gestures on actual IME and Termux extra-key UI, through actual Termux activity/client/view/session and native PTY; no direct text commit or injected key events", + "checks": [ + { + "name": "ime_ctrl0", + "flags": 3, + "stage": "ime_ctrl0_1791471312968", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 280, + 1750 + ], + [ + 640, + 2570 + ] + ], + "expected_hex": "1b5b34383b3575", + "actual_hex": "1b5b34383b3575", + "passed": true + }, + { + "name": "ime_plain0_after_ctrl", + "flags": 3, + "stage": "ime_plain0_after_ctrl_1791471314212", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 640, + 2570 + ] + ], + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "name": "ime_alt1", + "flags": 3, + "stage": "ime_alt1_1791471315228", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 460, + 1750 + ], + [ + 340, + 2050 + ] + ], + "expected_hex": "1b5b34393b3375", + "actual_hex": "1b5b34393b3375", + "passed": true + }, + { + "name": "ime_plain1_after_alt", + "flags": 3, + "stage": "ime_plain1_after_alt_1791471316621", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 340, + 2050 + ] + ], + "expected_hex": "31", + "actual_hex": "31", + "passed": true + }, + { + "name": "ime_enter", + "flags": 3, + "stage": "ime_enter_1791471317758", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 1180, + 2570 + ] + ], + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "name": "ime_ctrl_enter", + "flags": 3, + "stage": "ime_ctrl_enter_1791471318893", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 280, + 1750 + ], + [ + 1180, + 2570 + ] + ], + "expected_hex": "1b5b31333b3575", + "actual_hex": "1b5b31333b3575", + "passed": true + }, + { + "name": "ime_chinese_candidate_commit", + "flags": 3, + "stage": "ime_chinese_composition_1791471362808", + "method": "ADB touchscreen taps on actual Fcitx Rime engine; zhongwen composition and visible δΈ­ζ–‡ candidate tap; native PTY", + "composition_before_commit_hex": "", + "expected_hex": "e4b8ade69687", + "actual_hex": "e4b8ade69687", + "passed": true, + "taps": [ + [ + 256, + 2400 + ], + [ + 768, + 2200 + ], + [ + 1088, + 2025 + ], + [ + 896, + 2400 + ], + [ + 640, + 2200 + ], + [ + 192, + 2025 + ], + [ + 320, + 2025 + ], + [ + 896, + 2400 + ], + [ + 110, + 1890 + ] + ] + }, + { + "name": "ime_flag2_ctrl0_legacy_press", + "flags": 2, + "stage": "ime_flag2_ctrl0_legacy_press_1791471462027", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 280, + 1750 + ], + [ + 640, + 2570 + ] + ], + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "name": "ime_flag2_plain0_after_ctrl", + "flags": 2, + "stage": "ime_flag2_plain0_after_ctrl_1791471544202", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 640, + 2570 + ] + ], + "expected_hex": "30", + "actual_hex": "30", + "passed": true + }, + { + "name": "ime_flag2_alt1_legacy_press", + "flags": 2, + "stage": "ime_flag2_alt1_legacy_press_1791471545353", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 460, + 1750 + ], + [ + 340, + 2050 + ] + ], + "expected_hex": "1b31", + "actual_hex": "1b31", + "passed": true + }, + { + "name": "ime_flag2_ctrl_enter_legacy_press", + "flags": 2, + "stage": "ime_flag2_ctrl_enter_legacy_press_1791471546825", + "method": "ADB touchscreen taps on real Fcitx IME/Termux UI; no injected text or key events; real native PTY capture", + "taps": [ + [ + 280, + 1750 + ], + [ + 1180, + 2570 + ] + ], + "expected_hex": "0d", + "actual_hex": "0d", + "passed": true + }, + { + "name": "ime_background_foreground", + "flags": 3, + "stage": "ime_background_foreground_1791471548286", + "method": "Actual IME/extra-key taps, Home touchscreen gesture and Termux resume; real native PTY", + "expected_hex": "1b5b34383b3575301b5b34383b3575", + "actual_hex": "1b5b34383b3575301b5b34383b3575", + "passed": true, + "operations": [ + "tap CTRL then IME 0", + "touchscreen Home gesture", + "am start TermuxActivity", + "tap IME 0 without modifier", + "tap CTRL then IME 0" + ] + } + ], + "total_checks": 12, + "failures": 0, + "all_checks_passed": true + }, + "test_harness": { + "setup": "Temporary instrumentation created a named dedicated Termux session and wrote only the capture script startup command. Test input came from user Bluetooth hardware or touchscreen taps.", + "control": "Foreground bash with raw /dev/tty dd capture; each test negotiates and pops its own keyboard mode.", + "infrastructure_note": "A temporary request-file race during extra flag2 IME checks was corrected with atomic rename; no invalid stage was counted.", + "cleanup": "Capture shell exited and restored modes; temporary helper uninstalled; fixture directory and raw event/UI dump files removed; original IME display setting restored; Termux reopened. Host artifacts and toolchain retained." + }, + "remaining": [ + "Physical Num Lock transitions and modifier-first key release", + "Untested hardware combinations/layouts", + "Other IMEs", + "SSH and multiplexers" + ] +} diff --git a/docs/kitty-keyboard.md b/docs/kitty-keyboard.md index ee52441d5a8..0a80977f49e 100644 --- a/docs/kitty-keyboard.md +++ b/docs/kitty-keyboard.md @@ -1,8 +1,8 @@ -# Basic kitty keyboard protocol support +# Kitty keyboard protocol support -The terminal supports the disambiguate escape codes enhancement (flag `1`). It -does not implement repeat/release reporting, alternate key codes, reporting all -keys, or associated text. Unsupported flags are ignored and are never reported +The terminal supports disambiguate escape codes (flag `1`) and report event +types (flag `2`). It does not implement alternate key codes, reporting all keys, +or associated text. Unsupported flags are ignored and are never reported as enabled by a query. Applications negotiate support using `CSI ? u`, enable it using `CSI > 1 u`, and @@ -41,8 +41,132 @@ as terminal multiplexers also need to pass or translate the sequences correctly. Specification: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ +## Repeat and release events + +Enable both enhancements with `CSI > 3 u`, or add event reporting to an existing +mode with `CSI = 2 ; 2 u`. A query returns the enabled supported flags, including +`2` or `3`. Flag `2` can also be enabled independently with `CSI > 2 u`. + +Android `ACTION_DOWN` with a nonzero repeat count is encoded as event type `2`; +`ACTION_UP` is encoded as type `3`. Press events omit the optional event type. +Examples with flags `3`: + +| Input | Press | Repeat | Release | +| --- | --- | --- | --- | +| Up | `ESC [ A` | `ESC [ 1 ; 1 : 2 A` | `ESC [ 1 ; 1 : 3 A` | +| Ctrl+I | `ESC [ 105 ; 5 u` | `ESC [ 105 ; 5 : 2 u` | `ESC [ 105 ; 5 : 3 u` | +| Escape | `ESC [ 27 u` | `ESC [ 27 ; 1 : 2 u` | `ESC [ 27 ; 1 : 3 u` | +| Plain Enter | `CR` | `CR` | None | +| Plain A | `a` | `a` | None | + +With flag `2` alone, shortcut press bytes retain legacy encoding, while their +repeat and release events use CSI event encoding. Escape likewise retains its +legacy press byte. Modified Enter, Tab and Backspace repeats also use CSI +with event type `2`; their presses keep legacy bytes and releases stay suppressed. +Plain recovery-key repeats remain control bytes even with lock modifiers enabled. +Functional keys use CSI event encoding. Legacy keypad text +and application-keypad encodings retain their existing input paths; keypad +navigation events use the equivalent normal navigation key identity. Flag `1` +is required for dedicated keypad identities. + +Enter, Tab and Backspace do not emit release events, including modified variants. +Plain and Shift-only text, AltGr text, IME commits and paste retain their text +paths. Standalone modifier key events require the unsupported report-all-keys +flag `8`. Soft input and virtual Fn mappings do not manufacture repeat or release +events from text commits or virtual shortcuts. + +Only keys delivered to the terminal have a matching release. Application +shortcuts and Shift+PageUp/PageDown remain prior to terminal encoding. Pending +releases are discarded on canceled input, focus loss, view detachment, session +changes, screen switches, terminal reset, or keyboard mode updates. Device IDs +and press timestamps prevent an unrelated key-up from matching a held key. +Release events preserve the layout/keypad identity resolved on key-down and use +the current physical modifiers, including when Ctrl, Shift or Num Lock changes +before the key is released. Virtual modifiers used for the original shortcut +are retained for its release. + ## Validation status +### Event reporting (2026-10-08) + +- All 152 terminal-emulator and 28 app tests passed with zero failures, errors + or skips. This adds two terminal tests and eighteen Android input tests. +- Negotiation covers both flag `2` alone and flags `3`, set/add/remove, + push/pop, independent screens, and reset. Byte comparisons cover repeated + and released navigation, shortcuts, Escape, F3 and keypad keys. +- Android events pass through the production `TerminalView` into the session + input queue under Robolectric API 28. Coverage includes current release + modifiers, Num Lock identity changes, AltGr layout text, recovery keys, + consumed shortcuts, canceled/orphaned releases, separate device IDs, + screen round trips, mode changes, reset and focus loss. The real extra-key + bridge and virtual Fn press paths are covered; the deferred virtual Fn Ctrl + limitation remains unchanged. Two P1 regression tests additionally cover + Num Lock changes during repeats and prevent orphaned repeats or mode changes + from creating or reviving a pending release. Only the initial press records + the release identity. A P2 regression additionally checks flag `2` modified + Enter/Tab/Backspace repeats and ordinary recovery repeats with lock modifiers. + These tests do not + start a native PTY or exercise physical keyboard hardware. +- Debug APK assembly passed for arm64-v8a, armeabi-v7a, x86, x86_64 and universal + using the default apt-android-7 package variant. Toolchain: Temurin + 21.0.12.1, Gradle 9.2.1, API 36, Build Tools 36.0.0, NDK 29.0.14206865. +- `git diff --check` passed. The initial local build had no attached device. + Subsequent P1/P2 device verification on the Android 16 arm64 PTP-AN10 passed + all 59 raw byte checks against the installed production `TerminalView`, real + session input queue and native PTY. See + [the event-reporting device report](kitty-keyboard-events-device-validation.json). +- Device coverage includes flags `0`/`1`/`2`/`3`, ordinary and modified repeats, + matching releases, recovery/text boundaries, modifier-first release, F3, + orphaned repeats, the three P1 Num Lock transitions, and P2 modified recovery + repeats under both flags `2` and `3`. Android framework `KeyEvent` objects + were delivered directly to the production view through temporary + instrumentation. This does not exercise physical HID or OS input dispatch. +- APK version: `0.118.0+kitty.events.p2.60a581a1.2439221fb851`. The source patch + and APK checksums are recorded with the report. A first attempt had one + infrastructure timeout due to background freezing; the final foreground + rerun passed all cases. Capture used foreground `dd` with a start handshake. +- New physical-keyboard, real IME UI, SSH, multiplexer, lifecycle and session + switching checks remain outside this recorded device coverage. + +For device acceptance, negotiate `3` in a raw PTY capture, hold and release Up, +Ctrl+I, Escape, F3 and a Num Lock keypad key, and compare press/repeat/release +bytes against this table. Repeat with flag `2` alone, restore with `CSI < u`, +and confirm that plain text and recovery keys emit no releases. Check consumed +application/scroll shortcuts, modifier-first release, screen/session changes, +background/foreground, IME commits and extra keys. Record injection separately +from physical HID events and record SSH/multiplexer versions with raw captures. +The historical device reports below validate flag `1`, not this new flag `2`. + +### Bluetooth keyboard and real IME validation (2026-10-08/09) + +- The user connected a RAPOO Keyboard-1 Keyboard over Bluetooth. Android + recognized an external alphabetic keyboard (device ID 9, keyboard type 2). + Test keys passed through actual Android input dispatch and the installed + Termux activity/client/view into the native PTY; no synthetic key events + were used. See [the real-input report](kitty-keyboard-real-input-validation.json). +- With flags `3`, recorded physical input confirms Up and Ctrl+I + press/repeat/release, Ctrl+Shift+A and Escape press/release, modified + Shift+Tab repeats, ordinary text, and ordinary recovery bytes without + releases. The first group also contained incomplete or different actions: + Ctrl+Enter had no repeat, the requested Alt+Backspace was Alt+Space, and + the modifier-first I release still carried Ctrl. These were not passed. +- A simpler physical flags `2` supplement passed all three recovery-key + cases. Raw Linux events confirm actual Ctrl+Enter, Shift+Tab and left + Alt+Backspace held for about two seconds. PTY captures contain their legacy + press bytes followed by 34, 37 and 39 correct repeat sequences, respectively, + with no recovery-key releases. This validates the P2 fix on real hardware. +- All 12 real IME checks passed. ADB touchscreen taps operated Fcitx's real + English keyboard, Rime engine and Termux CTRL/ALT extra keys. They cover + Ctrl+0, Alt+1, clearing one-shot modifiers before plain digits, Enter and + Ctrl+Enter, flags `2` legacy soft-input presses, and Home/foreground return. + During `zhongwen` composition the PTY received no bytes; tapping the visible + `δΈ­ζ–‡` candidate committed exactly its UTF-8 bytes. No direct text or key + injection was used for these IME checks. +- This keyboard has no numeric keypad, so the P1 Num Lock transitions still + rely on the earlier 59 framework/native-PTY checks and local regressions. + Modifier-first release, additional physical layouts/keys, character-producing + AltGr, other IMEs, SSH and multiplexers remain outside the new coverage. + ### Review fixes (2026-10-07) - Resolve printable Space through the keyboard layout before encoding; a @@ -142,5 +266,7 @@ the checks from the repository root: ``` For temporary setups, keep `GRADLE_USER_HOME` and `ANDROID_USER_HOME` inside the -same temporary directory as the JDK, SDK and build checkout. After Gradle exits, -delete that directory to remove the toolchain, caches and build artifacts. +same temporary directory as the JDK and SDK. Retain the toolchain, APKs, +checksums and validation reports while device verification is pending. The +current event-reporting validation keeps its temporary toolchain for reuse, +following the user's explicit retention requirement. diff --git a/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java b/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java index 5bcab919511..90e01573541 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java @@ -57,18 +57,33 @@ public final class KeyHandler { + public static final int KITTY_DISAMBIGUATE = 1; + public static final int KITTY_REPORT_EVENTS = 2; + public static final int KEY_EVENT_PRESS = 1; + public static final int KEY_EVENT_REPEAT = 2; + public static final int KEY_EVENT_RELEASE = 3; + public static final int KEYMOD_ALT = 0x80000000; public static final int KEYMOD_CTRL = 0x40000000; public static final int KEYMOD_SHIFT = 0x20000000; public static final int KEYMOD_NUM_LOCK = 0x10000000; public static final int KEYMOD_SUPER = 0x08000000; public static final int KEYMOD_CAPS_LOCK = 0x04000000; + private static final int[] KEYPAD_NAVIGATION_KEYS = { + KEYCODE_INSERT, KEYCODE_MOVE_END, KEYCODE_DPAD_DOWN, KEYCODE_PAGE_DOWN, KEYCODE_DPAD_LEFT, + KEYCODE_NUMPAD_5, KEYCODE_DPAD_RIGHT, KEYCODE_MOVE_HOME, KEYCODE_DPAD_UP, KEYCODE_PAGE_UP + }; private static final int[] KITTY_KEYPAD_NAVIGATION = { 57425, 57424, 57420, 57422, 57417, 57427, 57418, 57423, 57419, 57421 }; /** Encode a non-text key in negotiated kitty disambiguation mode, or return null for text. */ public static String getKittyCode(int keyCode, int keyMode) { + return getKittyCode(keyCode, keyMode, KEY_EVENT_PRESS); + } + + private static String getKittyCode(int keyCode, int keyMode, int eventType) { + if (eventType == KEY_EVENT_RELEASE && isKittyRecoveryKey(keyCode)) return null; int codePoint; char suffix = 'u'; boolean numLock = (keyMode & KEYMOD_NUM_LOCK) != 0; @@ -79,7 +94,7 @@ public static String getKittyCode(int keyCode, int keyMode) { // Insert, End, Down, Page Down, Left, Begin, Right, Home, Up, Page Up. codePoint = KITTY_KEYPAD_NAVIGATION[keyCode - KEYCODE_NUMPAD_0]; } - return codePoint == 57427 ? kittySequence(1, keyMode, 'E') : kittySequence(codePoint, keyMode, 'u'); + return codePoint == 57427 ? kittySequence(1, keyMode, 'E', eventType) : kittySequence(codePoint, keyMode, 'u', eventType); } switch (keyCode) { case KEYCODE_ESCAPE: @@ -136,7 +151,7 @@ public static String getKittyCode(int keyCode, int keyMode) { if (codePoint == 9) return "\t"; if (codePoint == 127) return "\u007f"; } - return kittySequence(codePoint, keyMode, suffix); + return kittySequence(codePoint, keyMode, suffix, eventType); } /** Plain and Shift-only text stays UTF-8; shortcut keys use the unshifted code point. */ @@ -146,7 +161,33 @@ public static String getKittyCodePoint(int unshiftedCodePoint, int keyMode) { return kittySequence(unshiftedCodePoint, keyMode & ~(KEYMOD_NUM_LOCK | KEYMOD_CAPS_LOCK), 'u'); } + /** Recovery control keys deliberately have no release events without report-all-keys support. */ + public static boolean isKittyRecoveryKey(int keyCode) { + return keyCode == KEYCODE_ENTER || keyCode == KEYCODE_DPAD_CENTER || + keyCode == KEYCODE_TAB || keyCode == KEYCODE_DEL; + } + + public static String getKittyCodePoint(int unshiftedCodePoint, int keyMode, int flags, int eventType) { + if ((flags & KITTY_REPORT_EVENTS) == 0) eventType = KEY_EVENT_PRESS; + if ((flags & KITTY_DISAMBIGUATE) == 0 && eventType == KEY_EVENT_PRESS) return null; + if (unshiftedCodePoint < 32 || !Character.isValidCodePoint(unshiftedCodePoint)) return null; + if (eventType != KEY_EVENT_RELEASE && getKittyCodePoint(unshiftedCodePoint, keyMode) == null) return null; + return kittySequence(unshiftedCodePoint, + keyMode & ~(KEYMOD_NUM_LOCK | KEYMOD_CAPS_LOCK), 'u', eventType); + } + + /** Reuse a previously encoded functional identity with the release event's current modifiers. */ + public static String getKittyReleaseCode(String releaseCode, int keyMode) { + int separator = releaseCode.indexOf(';'); + int number = Integer.parseInt(releaseCode.substring(2, separator)); + return kittySequence(number, keyMode, releaseCode.charAt(releaseCode.length() - 1), KEY_EVENT_RELEASE); + } + private static String kittySequence(int number, int keyMode, char suffix) { + return kittySequence(number, keyMode, suffix, KEY_EVENT_PRESS); + } + + private static String kittySequence(int number, int keyMode, char suffix, int eventType) { int modifiers = 1; if ((keyMode & KEYMOD_SHIFT) != 0) modifiers += 1; if ((keyMode & KEYMOD_ALT) != 0) modifiers += 2; @@ -154,8 +195,9 @@ private static String kittySequence(int number, int keyMode, char suffix) { if ((keyMode & KEYMOD_SUPER) != 0) modifiers += 8; if ((keyMode & KEYMOD_CAPS_LOCK) != 0) modifiers += 64; if ((keyMode & KEYMOD_NUM_LOCK) != 0) modifiers += 128; - String prefix = number == 1 && suffix != 'u' && suffix != '~' && modifiers == 1 ? "" : Integer.toString(number); - return "\033[" + prefix + (modifiers == 1 ? "" : ";" + modifiers) + suffix; + String prefix = number == 1 && suffix != 'u' && suffix != '~' && modifiers == 1 && eventType == KEY_EVENT_PRESS ? "" : Integer.toString(number); + return "\033[" + prefix + (modifiers == 1 && eventType == KEY_EVENT_PRESS ? "" : ";" + modifiers) + + (eventType == KEY_EVENT_PRESS ? "" : ":" + eventType) + suffix; } /** Select negotiated or legacy functional-key encoding. Null leaves layout text to the caller. */ @@ -165,6 +207,36 @@ public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolea getCode(keyCode, keyMode, cursorApp, keypadApplication); } + /** Event reporting is independent of disambiguation; text-producing legacy keypad keys stay text. */ + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication, + int flags, int eventType) { + if ((flags & KITTY_REPORT_EVENTS) == 0) { + if (eventType == KEY_EVENT_RELEASE) return null; + return getCode(keyCode, keyMode, cursorApp, keypadApplication, + (flags & KITTY_DISAMBIGUATE) != 0); + } + if ((flags & KITTY_DISAMBIGUATE) == 0) { + String legacy = getCode(keyCode, keyMode, cursorApp, keypadApplication); + if (isKittyRecoveryKey(keyCode)) { + if (eventType == KEY_EVENT_RELEASE) return null; + // Keep legacy presses and plain recovery repeats, but report modified repeats. + return eventType == KEY_EVENT_REPEAT ? getKittyCode(keyCode, keyMode, eventType) : legacy; + } + if ((keyCode == KEYCODE_ESCAPE || keyCode == KEYCODE_BACK) && eventType == KEY_EVENT_PRESS) + return legacy; + if (keyCode >= KEYCODE_NUMPAD_0 && keyCode <= KEYCODE_NUMPAD_9) { + if ((keyMode & KEYMOD_NUM_LOCK) != 0 || keyCode == KEYCODE_NUMPAD_5) + return eventType == KEY_EVENT_RELEASE ? null : legacy; + keyCode = KEYPAD_NAVIGATION_KEYS[keyCode - KEYCODE_NUMPAD_0]; + } else if (keyCode == KEYCODE_NUMPAD_DOT && (keyMode & KEYMOD_NUM_LOCK) == 0) { + keyCode = KEYCODE_FORWARD_DEL; + } else if (keyCode >= KEYCODE_NUMPAD_DIVIDE && keyCode <= KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN) { + return eventType == KEY_EVENT_RELEASE ? null : legacy; + } + } + return getKittyCode(keyCode, keyMode, eventType); + } + private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); static { diff --git a/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java b/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java index 20175dcebf0..64ce9b989e3 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java @@ -1,19 +1,29 @@ package com.termux.terminal; -/** Per-screen progressive keyboard flags. Only disambiguation (bit 1) is implemented. */ +/** Per-screen progressive keyboard flags. Supports disambiguation and event reporting. */ final class KittyKeyboardState { // The current flags occupy one entry; reserve at most 31 saved entries. private static final int MAX_DEPTH = 31; private final int[] mSavedFlags = new int[MAX_DEPTH]; private int mDepth; private int mFlags; + private long mGeneration; int getFlags() { return mFlags; } + long getGeneration() { + return mGeneration; + } + + void invalidateEvents() { + mGeneration++; + } + void set(int flags, int mode) { - flags &= 1; + flags &= KeyHandler.KITTY_DISAMBIGUATE | KeyHandler.KITTY_REPORT_EVENTS; + if (mode >= 1 && mode <= 3) mGeneration++; switch (mode) { case 1: mFlags = flags; break; case 2: mFlags |= flags; break; @@ -33,6 +43,7 @@ void push(int flags) { void pop(int count) { if (count <= 0) return; + mGeneration++; if (count > mDepth) { reset(); } else { @@ -43,5 +54,6 @@ void pop(int count) { void reset() { mDepth = mFlags = 0; + mGeneration++; } } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index 42056aa94d4..00dd7a9f9d1 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -46,6 +46,15 @@ public boolean isKittyKeyboardEnabled() { return kittyKeyboardState().getFlags() != 0; } + public int getKittyKeyboardFlags() { + return kittyKeyboardState().getFlags(); + } + + /** Changes whenever the active keyboard mode is updated, including reset and stack operations. */ + public long getKittyKeyboardGeneration() { + return kittyKeyboardState().getGeneration(); + } + public static final int MOUSE_LEFT_BUTTON = 0; /** Mouse moving while having left mouse button pressed. */ @@ -1891,7 +1900,9 @@ public void doDecSetOrReset(boolean setting, int externalBit) { if (newScreen != mScreen) { boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows); if (setting) saveCursor(); + kittyKeyboardState().invalidateEvents(); mScreen = newScreen; + kittyKeyboardState().invalidateEvents(); if (!setting) { int col = mSavedStateMain.mSavedCursorCol; int row = mSavedStateMain.mSavedCursorRow; diff --git a/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java b/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java index 93838b630c6..b2ea8dd05f0 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java @@ -22,9 +22,9 @@ public void testNegotiationAndRestore() { public void testSupportedFlagsAndSetModes() { withTerminalSized(20, 4); enterString("\033[=31u"); - assertEnteringStringGivesResponse("\033[?u", "\033[?1u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?3u"); enterString("\033[=1;3u"); - assertFalse(mTerminal.isKittyKeyboardEnabled()); + assertEnteringStringGivesResponse("\033[?u", "\033[?2u"); enterString("\033[=1;2u\033[=30;2u"); assertTrue(mTerminal.isKittyKeyboardEnabled()); enterString("\033[=0;1u"); @@ -83,4 +83,44 @@ public void testShortcutBytesAndTextFallback() { assertEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, KeyHandler.KEYMOD_SHIFT, false, false)); assertEquals("\033", KeyHandler.getCode(KeyEvent.KEYCODE_ESCAPE, 0, false, false)); } + public void testEventFlagsRestoreAndScreenIsolation() { + withTerminalSized(20, 4); + enterString("\033[>3u\033[>2u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?2u"); + enterString("\033[2u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?2u"); + enterString("\033[?1049l"); + assertEnteringStringGivesResponse("\033[?u", "\033[?3u"); + enterString("\033c\033[?1049h"); + assertEnteringStringGivesResponse("\033[?u", "\033[?0u"); + } + + public void testEventEncoding() { + for (int flags : new int[]{2, 3}) { + assertEquals("\033[1;1:2A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, true, false, flags, 2)); + assertEquals("\033[1;1:3A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, true, false, flags, 3)); + assertEquals("\033[13;5:2~", KeyHandler.getCode(KeyEvent.KEYCODE_F3, KeyHandler.KEYMOD_CTRL, false, false, flags, 2)); + for (int key : new int[]{KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_DEL}) { + assertNull(KeyHandler.getCode(key, 0, false, false, flags, 3)); + assertNull(KeyHandler.getCode(key, KeyHandler.KEYMOD_CTRL, false, false, flags, 3)); + } + } + assertEquals("\033[105;5:2u", KeyHandler.getKittyCodePoint('i', KeyHandler.KEYMOD_CTRL, 3, 2)); + assertEquals("\033[105;1:3u", KeyHandler.getKittyCodePoint('i', 0, 3, 3)); + assertEquals("\033[105;5u", KeyHandler.getKittyCodePoint('i', KeyHandler.KEYMOD_CTRL, 1, 2)); + assertNull(KeyHandler.getKittyCodePoint('i', KeyHandler.KEYMOD_CTRL, 2, 1)); + assertNull(KeyHandler.getKittyCodePoint('a', 0, 3, 2)); + assertEquals("\033[57399;129:3u", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, + KeyHandler.KEYMOD_NUM_LOCK, false, false, 3, 3)); + assertEquals("0", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, + KeyHandler.KEYMOD_NUM_LOCK, false, false, 2, 2)); + assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, + KeyHandler.KEYMOD_NUM_LOCK, false, false, 2, 3)); + assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, false, false, 1, 3)); + } + } diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index 5dd439b563a..1ccaad6ba18 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -34,6 +34,9 @@ import android.view.inputmethod.InputConnection; import android.widget.Scroller; +import java.util.HashMap; +import java.util.Map; + import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -86,6 +89,53 @@ public final class TerminalView extends View { /** If non-zero, this is the last unicode code point received if that was a combining character. */ int mCombiningAccent; + /** Only terminal-delivered physical keys may produce a matching release. */ + private final Map mKittyKeyPresses = new HashMap<>(); + + private static final class KittyKeyPress { + final int codePoint; + final int virtualModifiers; + final String releaseCode; + final TerminalEmulator emulator; + final Object screen; + final long generation; + final long downTime; + + KittyKeyPress(int codePoint, int virtualModifiers, String releaseCode, TerminalEmulator emulator, KeyEvent event) { + this.codePoint = codePoint; + this.virtualModifiers = virtualModifiers; + this.releaseCode = releaseCode; + this.emulator = emulator; + screen = emulator.getScreen(); + generation = emulator.getKittyKeyboardGeneration(); + downTime = event.getDownTime(); + } + } + + private static long kittyKeyId(KeyEvent event) { + return ((long) event.getDeviceId() << 32) | (event.getKeyCode() & 0xffffffffL); + } + + private static int kittyEventModifiers(KeyEvent event) { + int modifiers = 0; + if (event.isCtrlPressed()) modifiers |= KeyHandler.KEYMOD_CTRL; + if (event.isAltPressed()) modifiers |= KeyHandler.KEYMOD_ALT; + if (event.isShiftPressed()) modifiers |= KeyHandler.KEYMOD_SHIFT; + if (event.isMetaPressed()) modifiers |= KeyHandler.KEYMOD_SUPER; + if (event.isCapsLockOn()) modifiers |= KeyHandler.KEYMOD_CAPS_LOCK; + if (event.isNumLockOn()) modifiers |= KeyHandler.KEYMOD_NUM_LOCK; + return modifiers; + } + + private void rememberKittyKey(KeyEvent event, int codePoint, int modifiers, String releaseCode) { + // Repeats must not replace the identity or mode captured by the initial press. + if (event == null || event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() != 0 || + (mEmulator.getKittyKeyboardFlags() & KeyHandler.KITTY_REPORT_EVENTS) == 0 || + KeyHandler.isKittyRecoveryKey(event.getKeyCode())) return; + mKittyKeyPresses.put(kittyKeyId(event), new KittyKeyPress(codePoint, + modifiers & ~kittyEventModifiers(event), releaseCode, mEmulator, event)); + } + /** * The current AutoFill type returned for {@link View#getAutofillType()} by {@link #getAutofillType()}. * @@ -290,6 +340,7 @@ public void setIsTerminalViewKeyLoggingEnabled(boolean value) { public boolean attachSession(TerminalSession session) { if (session == mTermSession) return false; + mKittyKeyPresses.clear(); mTermSession = session; mEmulator = null; mCombiningAccent = 0; @@ -775,7 +826,10 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { stopTextSelectionMode(); } + // Forget stale or intercepted presses before dispatching this event to application shortcuts. + if (event.getRepeatCount() == 0) mKittyKeyPresses.remove(kittyKeyId(event)); if (mClient.onKeyDown(keyCode, event, mTermSession)) { + mKittyKeyPresses.remove(kittyKeyId(event)); invalidate(); return true; } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) { @@ -803,7 +857,7 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { if (event.isCapsLockOn()) keyMod |= KeyHandler.KEYMOD_CAPS_LOCK; } // https://github.com/termux/termux-app/issues/731 - if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) { + if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod, event)) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event"); return true; } @@ -849,7 +903,7 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { int textKeyMod = keyMod; // Right Alt is used by the keyboard layout (AltGr), rather than a terminal Alt shortcut. if (rightAltDownFromEvent && !leftAltDown) textKeyMod &= ~KeyHandler.KEYMOD_ALT; - inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown, unshiftedCodePoint, textKeyMod); + inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown, unshiftedCodePoint, textKeyMod, event); } if (mCombiningAccent != oldCombiningAccent) invalidate(); @@ -859,11 +913,11 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { inputCodePoint(eventSource, codePoint, controlDownFromEvent, leftAltDownFromEvent, - Character.toLowerCase(codePoint), 0); + Character.toLowerCase(codePoint), 0, null); } private void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, - boolean leftAltDownFromEvent, int unshiftedCodePoint, int keyMod) { + boolean leftAltDownFromEvent, int unshiftedCodePoint, int keyMod, KeyEvent event) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { mClient.logInfo(LOG_TAG, "inputCodePoint(eventSource=" + eventSource + ", codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + leftAltDownFromEvent + ")"); @@ -878,13 +932,21 @@ private void inputCodePoint(int eventSource, int codePoint, boolean controlDownF final boolean controlDown = controlDownFromEvent || mClient.readControlKey(); final boolean altDown = leftAltDownFromEvent || mClient.readAltKey(); - if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return; + if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) { + if (event != null) mKittyKeyPresses.remove(kittyKeyId(event)); + return; + } if (mEmulator != null && mEmulator.isKittyKeyboardEnabled()) { if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL; if (altDown) keyMod |= KeyHandler.KEYMOD_ALT; if (mClient.readShiftKey()) keyMod |= KeyHandler.KEYMOD_SHIFT; - String kittyCode = KeyHandler.getKittyCodePoint(unshiftedCodePoint, keyMod); + int eventType = event != null && event.getRepeatCount() > 0 ? + KeyHandler.KEY_EVENT_REPEAT : KeyHandler.KEY_EVENT_PRESS; + String kittyCode = KeyHandler.getKittyCodePoint(unshiftedCodePoint, keyMod, + mEmulator.getKittyKeyboardFlags(), eventType); + if (KeyHandler.getKittyCodePoint(unshiftedCodePoint, keyMod) != null) + rememberKittyKey(event, unshiftedCodePoint, keyMod, null); if (kittyCode != null) { mTermSession.write(kittyCode); return; @@ -941,18 +1003,28 @@ private void inputCodePoint(int eventSource, int codePoint, boolean controlDownF /** Input the specified keyCode if applicable and return if the input was consumed. */ public boolean handleKeyCode(int keyCode, int keyMod) { + return handleKeyCode(keyCode, keyMod, null); + } + + private boolean handleKeyCode(int keyCode, int keyMod, KeyEvent event) { // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys if (mEmulator != null) mEmulator.setCursorBlinkState(true); - if (handleKeyCodeAction(keyCode, keyMod)) + if (handleKeyCodeAction(keyCode, keyMod)) { + if (event != null) mKittyKeyPresses.remove(kittyKeyId(event)); return true; + } TerminalEmulator term = mTermSession.getEmulator(); String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), - term.isKeypadApplicationMode(), term.isKittyKeyboardEnabled()); + term.isKeypadApplicationMode(), term.getKittyKeyboardFlags(), + event != null && event.getRepeatCount() > 0 ? KeyHandler.KEY_EVENT_REPEAT : KeyHandler.KEY_EVENT_PRESS); if (code == null) return false; mTermSession.write(code); + String releaseCode = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), + term.isKeypadApplicationMode(), term.getKittyKeyboardFlags(), KeyHandler.KEY_EVENT_RELEASE); + if (releaseCode != null) rememberKittyKey(event, 0, keyMod, releaseCode); return true; } @@ -992,17 +1064,36 @@ public boolean onKeyUp(int keyCode, KeyEvent event) { // to exit the activity. if (mEmulator == null && keyCode != KeyEvent.KEYCODE_BACK) return true; + KittyKeyPress press = mKittyKeyPresses.remove(kittyKeyId(event)); if (mClient.onKeyUp(keyCode, event)) { invalidate(); return true; - } else if (event.isSystem()) { + } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) { // Let system key events through. return super.onKeyUp(keyCode, event); } + if (press != null && mEmulator == press.emulator && mEmulator.getScreen() == press.screen && + mEmulator.getKittyKeyboardGeneration() == press.generation && event.getDownTime() == press.downTime && + (mEmulator.getKittyKeyboardFlags() & KeyHandler.KITTY_REPORT_EVENTS) != 0 && !event.isCanceled()) { + int modifiers = kittyEventModifiers(event) | press.virtualModifiers; + if (press.codePoint != 0 && (event.getMetaState() & KeyEvent.META_ALT_RIGHT_ON) != 0 && + (event.getMetaState() & KeyEvent.META_ALT_LEFT_ON) == 0) + modifiers &= ~KeyHandler.KEYMOD_ALT; + String code = press.codePoint == 0 ? KeyHandler.getKittyReleaseCode(press.releaseCode, modifiers) : + KeyHandler.getKittyCodePoint(press.codePoint, modifiers, + mEmulator.getKittyKeyboardFlags(), KeyHandler.KEY_EVENT_RELEASE); + if (code != null) mTermSession.write(code); + } return true; } + @Override + protected void onFocusChanged(boolean gainFocus, int direction, android.graphics.Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + if (!gainFocus) mKittyKeyPresses.clear(); + } + /** * This is called during layout when the size of this view has changed. If you were just added to the view * hierarchy, you're called with the old values of 0. @@ -1493,6 +1584,7 @@ protected void onAttachedToWindow() { @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); + mKittyKeyPresses.clear(); if (mTextSelectionCursorController != null) { // Might solve the following exception From 817dfb4322a6e072bc006638da20e6e24532fc2b Mon Sep 17 00:00:00 2001 From: JM-FRANK <156544419@qq.com> Date: Fri, 9 Oct 2026 00:31:59 +0900 Subject: [PATCH 3/4] Added(keyboard): Add kitty alternate key reporting Support flag 4 with layout-resolved shifted characters and known standard base keys. Preserve initial text-key identity for releases, omit unavailable alternatives and keep alternate-only mode on legacy input. Add protocol and Android layout regressions and a retained validation runner using the upstream Gradle tasks. Record 380 Debug/Release test executions and 125 framework/native PTY byte checks, distinguishing synthetic layouts from physical hardware. --- .../app/terminal/KittyKeyboardInputTest.java | 131 + ...-keyboard-alternate-device-validation.json | 3827 +++++++++++++++++ docs/kitty-keyboard.md | 83 +- .../java/com/termux/terminal/KeyHandler.java | 73 +- .../termux/terminal/KittyKeyboardState.java | 5 +- .../com/termux/terminal/TerminalEmulator.java | 3 +- .../termux/terminal/KittyKeyboardTest.java | 59 +- .../java/com/termux/view/TerminalView.java | 41 +- tools/kitty-keyboard/README.md | 43 + tools/kitty-keyboard/validate.py | 102 + 10 files changed, 4338 insertions(+), 29 deletions(-) create mode 100644 docs/kitty-keyboard-alternate-device-validation.json create mode 100644 tools/kitty-keyboard/README.md create mode 100644 tools/kitty-keyboard/validate.py diff --git a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java index 913c347248b..bee69346ca4 100644 --- a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java +++ b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java @@ -438,4 +438,135 @@ public void flag2ModifiedRecoveryRepeatsReportEventType() throws Exception { } } + /** Robolectric 4.10 omits Shift+=; provide the US layout entry explicitly. */ + @Implements(KeyCharacterMap.class) + public static class PunctuationCharacterMap extends ShadowKeyCharacterMap { + @Override + @Implementation + protected int get(int keyCode, int metaState) { + if (keyCode == KeyEvent.KEYCODE_EQUALS) + return (metaState & KeyEvent.META_SHIFT_ON) != 0 ? '+' : '='; + return super.get(keyCode, metaState); + } + } + + @Test + @Config(shadows = PunctuationCharacterMap.class) + public void alternateShiftedShortcutsUseTheAndroidLayout() throws Exception { + enter("\033[>7u"); + int modifiers = KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON; + press(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[61:43;6u"); + repeat(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[61:43;6:2u"); + release(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[61:43;6:3u"); + press(KeyEvent.KEYCODE_A, modifiers, "\033[97:65;6u"); + release(KeyEvent.KEYCODE_A, KeyEvent.META_CTRL_ON, "\033[97;5:3u"); + press(KeyEvent.KEYCODE_A, 0, "a"); + release(KeyEvent.KEYCODE_A, 0, ""); + press(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON, "A"); + release(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON, ""); + } + + /** Synthetic non-Latin layout, including a Meta mapping to check alternate-only legacy isolation. */ + @Implements(KeyCharacterMap.class) + public static class AlternateCharacterMap extends ShadowKeyCharacterMap { + static boolean greek; + @Override + @Implementation + protected int get(int keyCode, int metaState) { + if (keyCode == KeyEvent.KEYCODE_D) + return (metaState & KeyEvent.META_SHIFT_ON) != 0 ? '!' : 'a'; + if (keyCode == KeyEvent.KEYCODE_C) { + if ((metaState & KeyEvent.META_META_ON) != 0) return 0x03BB; + boolean shift = (metaState & KeyEvent.META_SHIFT_ON) != 0; + return greek ? (shift ? 0x03A0 : 0x03C0) : (shift ? 0x0421 : 0x0441); + } + return super.get(keyCode, metaState); + } + } + + @Test + @Config(shadows = AlternateCharacterMap.class) + public void alternateBaseLayoutFieldsPreserveTheInitialReleaseIdentity() throws Exception { + AlternateCharacterMap.greek = false; + enter("\033[>7u"); + int modifiers = KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON; + try { + press(KeyEvent.KEYCODE_C, modifiers, "\033[1089:1057:99;6u"); + AlternateCharacterMap.greek = true; + repeat(KeyEvent.KEYCODE_C, modifiers, "\033[960:928:99;6:2u"); + release(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON, "\033[1089::99;5:3u"); + press(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON, "\033[960::99;5u"); + release(KeyEvent.KEYCODE_C, 0, "\033[960::99;1:3u"); + } finally { + AlternateCharacterMap.greek = false; + } + } + + @Test + @Config(shadows = AlternateCharacterMap.class) + public void alternateOnlyFlagDoesNotChangeLegacyTextOrShortcuts() throws Exception { + AlternateCharacterMap.greek = false; + for (int flags : new int[]{0, 4}) { + enter("\033[=" + flags + "u"); + press(KeyEvent.KEYCODE_C, KeyEvent.META_META_ON, "\u03bb"); + press(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\t"); + repeat(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, "\t"); + release(KeyEvent.KEYCODE_I, KeyEvent.META_CTRL_ON, ""); + press(KeyEvent.KEYCODE_ESCAPE, 0, "\033"); + } + } + + @Test + @Config(shadows = AltGrCharacterMap.class) + public void alternateReportingRetainsAltGrTextAndBaseKey() throws Exception { + enter("\033[>7u"); + int altGr = KeyEvent.META_ALT_ON | KeyEvent.META_ALT_RIGHT_ON; + press(KeyEvent.KEYCODE_SPACE, altGr, "\u00a0"); + release(KeyEvent.KEYCODE_SPACE, altGr, ""); + press(KeyEvent.KEYCODE_E, altGr | KeyEvent.META_CTRL_ON, "\033[8364::101;5u"); + release(KeyEvent.KEYCODE_E, altGr, "\033[8364::101;1:3u"); + } + + @Test + public void alternateFlagCombinationsKeepTextAndModeRestoration() throws Exception { + int modifiers = KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON; + for (int flags = 0; flags < 8; flags++) { + enter("\033[=" + flags + "u"); + press(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON, "A"); + boolean disambiguate = (flags & 1) != 0, events = (flags & 2) != 0, alternate = (flags & 4) != 0; + String key = alternate ? "97:65" : "97"; + press(KeyEvent.KEYCODE_A, modifiers, disambiguate ? "\033[" + key + ";6u" : "\u0001"); + repeat(KeyEvent.KEYCODE_A, modifiers, events ? "\033[" + key + ";6:2u" : + disambiguate ? "\033[" + key + ";6u" : "\u0001"); + release(KeyEvent.KEYCODE_A, modifiers, events ? "\033[" + key + ";6:3u" : ""); + } + enter("\033[>0u"); + press(KeyEvent.KEYCODE_A, modifiers, "\u0001"); + enter("\033[7u"); + mView.inputCodePoint(TerminalView.KEY_EVENT_SOURCE_SOFT_KEYBOARD, 'a', true, false); + assertEquals("\033[97;5u", drainInput()); + mView.inputCodePoint(TerminalView.KEY_EVENT_SOURCE_SOFT_KEYBOARD, 0x4E2D, false, false); + assertEquals("δΈ­", drainInput()); + KeyEvent soft = new KeyEvent(0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_C, 0, + KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, + KeyEvent.FLAG_SOFT_KEYBOARD); + assertTrue(mView.onKeyDown(KeyEvent.KEYCODE_C, soft)); + assertEquals("\033[1089:1057;6u", drainInput()); + } + + @Test + @Config(shadows = AlternateCharacterMap.class) + public void shiftedAlternateIsNotAssumedToBeUppercase() throws Exception { + enter("\033[>5u"); + press(KeyEvent.KEYCODE_D, KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, "\033[97:33:100;6u"); + } + } diff --git a/docs/kitty-keyboard-alternate-device-validation.json b/docs/kitty-keyboard-alternate-device-validation.json new file mode 100644 index 00000000000..cc67ca50af2 --- /dev/null +++ b/docs/kitty-keyboard-alternate-device-validation.json @@ -0,0 +1,3827 @@ +{ + "method": "Android framework KeyEvent delivered directly to production TerminalView; real TerminalSession/native PTY and bash raw byte capture; not physical HID or OS input dispatch", + "model": "PTP-AN10", + "android": "16", + "checks": 125, + "failures": 0, + "results": [ + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "1b5b411b5b41", + "actual_hex": "1b5b411b5b41", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 0, + "expected_hex": "1b1b", + "actual_hex": "1b1b", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "1b5b411b5b41", + "actual_hex": "1b5b411b5b41", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "1b5b3130353b35751b5b3130353b3575", + "actual_hex": "1b5b3130353b35751b5b3130353b3575", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 1, + "expected_hex": "1b5b3237751b5b323775", + "actual_hex": "1b5b3237751b5b323775", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "actual_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "091b5b3130353b353a32751b5b3130353b353a3375", + "actual_hex": "091b5b3130353b353a32751b5b3130353b353a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b1b5b32373b313a32751b5b32373b313a3375", + "actual_hex": "1b1b5b32373b313a32751b5b32373b313a3375", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "actual_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b3130353b35751b5b3130353b353a32751b5b3130353b353a3375", + "actual_hex": "1b5b3130353b35751b5b3130353b353a32751b5b3130353b353a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b3237751b5b32373b313a32751b5b32373b313a3375", + "actual_hex": "1b5b3237751b5b32373b313a32751b5b32373b313a3375", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "1b5b411b5b41", + "actual_hex": "1b5b411b5b41", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 4, + "expected_hex": "1b1b", + "actual_hex": "1b1b", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "1b5b411b5b41", + "actual_hex": "1b5b411b5b41", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "1b5b3130353b35751b5b3130353b3575", + "actual_hex": "1b5b3130353b35751b5b3130353b3575", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "1b5b3237751b5b323775", + "actual_hex": "1b5b3237751b5b323775", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "actual_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "091b5b3130353b353a32751b5b3130353b353a3375", + "actual_hex": "091b5b3130353b353a32751b5b3130353b353a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "1b1b5b32373b313a32751b5b32373b313a3375", + "actual_hex": "1b1b5b32373b313a32751b5b32373b313a3375", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Up press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "actual_hex": "1b5b411b5b313b313a32411b5b313b313a3341", + "passed": true, + "events": [ + [ + 0, + 19, + 0, + 0, + 0 + ], + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Ctrl+I press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b3130353b35751b5b3130353b353a32751b5b3130353b353a3375", + "actual_hex": "1b5b3130353b35751b5b3130353b353a32751b5b3130353b353a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 0, + 37, + 1, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "plain text A press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "6161", + "actual_hex": "6161", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 0, + 0 + ], + [ + 0, + 29, + 1, + 0, + 0 + ], + [ + 1, + 29, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Shift text A press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "4141", + "actual_hex": "4141", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 1, + 0 + ], + [ + 0, + 29, + 1, + 1, + 0 + ], + [ + 1, + 29, + 0, + 1, + 0 + ] + ] + }, + { + "name": "plain recovery 66", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 0, + 0 + ], + [ + 0, + 66, + 1, + 0, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 66", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "0d0d", + "actual_hex": "0d0d", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 3145728, + 0 + ], + [ + 0, + 66, + 1, + 3145728, + 0 + ], + [ + 1, + 66, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 61", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 0, + 0 + ], + [ + 0, + 61, + 1, + 0, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 61", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "0909", + "actual_hex": "0909", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 3145728, + 0 + ], + [ + 0, + 61, + 1, + 3145728, + 0 + ], + [ + 1, + 61, + 0, + 0, + 0 + ] + ] + }, + { + "name": "plain recovery 67", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 0, + 0 + ], + [ + 0, + 67, + 1, + 0, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "locked recovery 67", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "7f7f", + "actual_hex": "7f7f", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 3145728, + 0 + ], + [ + 0, + 67, + 1, + 3145728, + 0 + ], + [ + 1, + 67, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Escape press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b3237751b5b32373b313a32751b5b32373b313a3375", + "actual_hex": "1b5b3237751b5b32373b313a32751b5b32373b313a3375", + "passed": true, + "events": [ + [ + 0, + 111, + 0, + 0, + 0 + ], + [ + 0, + 111, + 1, + 0, + 0 + ], + [ + 1, + 111, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P2 Ctrl+Enter repeated", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "0d1b5b31333b353a3275", + "actual_hex": "0d1b5b31333b353a3275", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 4096, + 0 + ], + [ + 0, + 66, + 1, + 4096, + 0 + ], + [ + 1, + 66, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "P2 Shift+Tab repeated", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b5b5a1b5b393b323a3275", + "actual_hex": "1b5b5a1b5b393b323a3275", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 1, + 0 + ], + [ + 0, + 61, + 1, + 1, + 0 + ], + [ + 1, + 61, + 0, + 1, + 0 + ] + ] + }, + { + "name": "P2 Alt+Backspace repeated", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b7f1b5b3132373b333a3275", + "actual_hex": "1b7f1b5b3132373b333a3275", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 18, + 0 + ], + [ + 0, + 67, + 1, + 18, + 0 + ], + [ + 1, + 67, + 0, + 18, + 0 + ] + ] + }, + { + "name": "F3 Shift press repeat release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "actual_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "passed": true, + "events": [ + [ + 0, + 133, + 0, + 1, + 0 + ], + [ + 0, + 133, + 1, + 1, + 0 + ], + [ + 1, + 133, + 0, + 1, + 0 + ] + ] + }, + { + "name": "modifier released before letter", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "091b5b3130353b313a3375", + "actual_hex": "091b5b3130353b313a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 0, + 0 + ] + ] + }, + { + "name": "orphaned repeat has no release", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b5b313b313a3241", + "actual_hex": "1b5b313b313a3241", + "passed": true, + "events": [ + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P2 Ctrl+Enter repeated", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b31333b35751b5b31333b353a3275", + "actual_hex": "1b5b31333b35751b5b31333b353a3275", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 4096, + 0 + ], + [ + 0, + 66, + 1, + 4096, + 0 + ], + [ + 1, + 66, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "P2 Shift+Tab repeated", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b393b32751b5b393b323a3275", + "actual_hex": "1b5b393b32751b5b393b323a3275", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 1, + 0 + ], + [ + 0, + 61, + 1, + 1, + 0 + ], + [ + 1, + 61, + 0, + 1, + 0 + ] + ] + }, + { + "name": "P2 Alt+Backspace repeated", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b3132373b33751b5b3132373b333a3275", + "actual_hex": "1b5b3132373b33751b5b3132373b333a3275", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 18, + 0 + ], + [ + 0, + 67, + 1, + 18, + 0 + ], + [ + 1, + 67, + 0, + 18, + 0 + ] + ] + }, + { + "name": "F3 Shift press repeat release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "actual_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "passed": true, + "events": [ + [ + 0, + 133, + 0, + 1, + 0 + ], + [ + 0, + 133, + 1, + 1, + 0 + ], + [ + 1, + 133, + 0, + 1, + 0 + ] + ] + }, + { + "name": "modifier released before letter", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b3130353b35751b5b3130353b313a3375", + "actual_hex": "1b5b3130353b35751b5b3130353b313a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 0, + 0 + ] + ] + }, + { + "name": "orphaned repeat has no release", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b313b313a3241", + "actual_hex": "1b5b313b313a3241", + "passed": true, + "events": [ + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P2 Ctrl+Enter repeated", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "0d1b5b31333b353a3275", + "actual_hex": "0d1b5b31333b353a3275", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 4096, + 0 + ], + [ + 0, + 66, + 1, + 4096, + 0 + ], + [ + 1, + 66, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "P2 Shift+Tab repeated", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "1b5b5a1b5b393b323a3275", + "actual_hex": "1b5b5a1b5b393b323a3275", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 1, + 0 + ], + [ + 0, + 61, + 1, + 1, + 0 + ], + [ + 1, + 61, + 0, + 1, + 0 + ] + ] + }, + { + "name": "P2 Alt+Backspace repeated", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "1b7f1b5b3132373b333a3275", + "actual_hex": "1b7f1b5b3132373b333a3275", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 18, + 0 + ], + [ + 0, + 67, + 1, + 18, + 0 + ], + [ + 1, + 67, + 0, + 18, + 0 + ] + ] + }, + { + "name": "F3 Shift press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "actual_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "passed": true, + "events": [ + [ + 0, + 133, + 0, + 1, + 0 + ], + [ + 0, + 133, + 1, + 1, + 0 + ], + [ + 1, + 133, + 0, + 1, + 0 + ] + ] + }, + { + "name": "modifier released before letter", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "091b5b3130353b313a3375", + "actual_hex": "091b5b3130353b313a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 0, + 0 + ] + ] + }, + { + "name": "orphaned repeat has no release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "1b5b313b313a3241", + "actual_hex": "1b5b313b313a3241", + "passed": true, + "events": [ + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P2 Ctrl+Enter repeated", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b31333b35751b5b31333b353a3275", + "actual_hex": "1b5b31333b35751b5b31333b353a3275", + "passed": true, + "events": [ + [ + 0, + 66, + 0, + 4096, + 0 + ], + [ + 0, + 66, + 1, + 4096, + 0 + ], + [ + 1, + 66, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "P2 Shift+Tab repeated", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b393b32751b5b393b323a3275", + "actual_hex": "1b5b393b32751b5b393b323a3275", + "passed": true, + "events": [ + [ + 0, + 61, + 0, + 1, + 0 + ], + [ + 0, + 61, + 1, + 1, + 0 + ], + [ + 1, + 61, + 0, + 1, + 0 + ] + ] + }, + { + "name": "P2 Alt+Backspace repeated", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b3132373b33751b5b3132373b333a3275", + "actual_hex": "1b5b3132373b33751b5b3132373b333a3275", + "passed": true, + "events": [ + [ + 0, + 67, + 0, + 18, + 0 + ], + [ + 0, + 67, + 1, + 18, + 0 + ], + [ + 1, + 67, + 0, + 18, + 0 + ] + ] + }, + { + "name": "F3 Shift press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "actual_hex": "1b5b31333b327e1b5b31333b323a327e1b5b31333b323a337e", + "passed": true, + "events": [ + [ + 0, + 133, + 0, + 1, + 0 + ], + [ + 0, + 133, + 1, + 1, + 0 + ], + [ + 1, + 133, + 0, + 1, + 0 + ] + ] + }, + { + "name": "modifier released before letter", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b3130353b35751b5b3130353b313a3375", + "actual_hex": "1b5b3130353b35751b5b3130353b313a3375", + "passed": true, + "events": [ + [ + 0, + 37, + 0, + 4096, + 0 + ], + [ + 1, + 37, + 0, + 0, + 0 + ] + ] + }, + { + "name": "orphaned repeat has no release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b313b313a3241", + "actual_hex": "1b5b313b313a3241", + "passed": true, + "events": [ + [ + 0, + 19, + 1, + 0, + 0 + ], + [ + 1, + 19, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P1 NumLock on to off during repeats", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b35373339393b313239751b5b35373432353b313a32751b5b35373339393b313a3375", + "actual_hex": "1b5b35373339393b313239751b5b35373432353b313a32751b5b35373339393b313a3375", + "passed": true, + "events": [ + [ + 0, + 144, + 0, + 2097152, + 0 + ], + [ + 0, + 144, + 1, + 0, + 0 + ], + [ + 1, + 144, + 0, + 0, + 0 + ] + ] + }, + { + "name": "P1 NumLock off to on during repeats", + "synthetic_layout": false, + "flags": 3, + "expected_hex": "1b5b3537343235751b5b35373339393b3132393a32751b5b35373432353b3132393a3375", + "actual_hex": "1b5b3537343235751b5b35373339393b3132393a32751b5b35373432353b3132393a3375", + "passed": true, + "events": [ + [ + 0, + 144, + 0, + 0, + 0 + ], + [ + 0, + 144, + 1, + 2097152, + 0 + ], + [ + 1, + 144, + 0, + 2097152, + 0 + ] + ] + }, + { + "name": "P1 legacy keypad navigation to text repeat", + "synthetic_layout": false, + "flags": 2, + "expected_hex": "1b5b327e301b5b323b3132393a337e", + "actual_hex": "1b5b327e301b5b323b3132393a337e", + "passed": true, + "events": [ + [ + 0, + 144, + 0, + 0, + 0 + ], + [ + 0, + 144, + 1, + 2097152, + 0 + ], + [ + 1, + 144, + 0, + 2097152, + 0 + ] + ] + }, + { + "name": "Alternate Ctrl+Shift+= press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "1b5b36313a34333b36751b5b36313a34333b3675", + "actual_hex": "1b5b36313a34333b36751b5b36313a34333b3675", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4097, + 0 + ], + [ + 0, + 70, + 1, + 4097, + 0 + ], + [ + 1, + 70, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Alternate Ctrl+Shift+A press repeat release", + "synthetic_layout": false, + "flags": 5, + "expected_hex": "1b5b39373a36353b36751b5b39373a36353b3675", + "actual_hex": "1b5b39373a36353b36751b5b39373a36353b3675", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 4097, + 0 + ], + [ + 0, + 29, + 1, + 4097, + 0 + ], + [ + 1, + 29, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Alternate Ctrl+Shift+= press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "2b1b5b36313a34333b363a32751b5b36313a34333b363a3375", + "actual_hex": "2b1b5b36313a34333b363a32751b5b36313a34333b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4097, + 0 + ], + [ + 0, + 70, + 1, + 4097, + 0 + ], + [ + 1, + 70, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Alternate Ctrl+Shift+A press repeat release", + "synthetic_layout": false, + "flags": 6, + "expected_hex": "011b5b39373a36353b363a32751b5b39373a36353b363a3375", + "actual_hex": "011b5b39373a36353b363a32751b5b39373a36353b363a3375", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 4097, + 0 + ], + [ + 0, + 29, + 1, + 4097, + 0 + ], + [ + 1, + 29, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Alternate Ctrl+Shift+= press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b36313a34333b36751b5b36313a34333b363a32751b5b36313a34333b363a3375", + "actual_hex": "1b5b36313a34333b36751b5b36313a34333b363a32751b5b36313a34333b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4097, + 0 + ], + [ + 0, + 70, + 1, + 4097, + 0 + ], + [ + 1, + 70, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Alternate Ctrl+Shift+A press repeat release", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b39373a36353b36751b5b39373a36353b363a32751b5b39373a36353b363a3375", + "actual_hex": "1b5b39373a36353b36751b5b39373a36353b363a32751b5b39373a36353b363a3375", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 4097, + 0 + ], + [ + 0, + 29, + 1, + 4097, + 0 + ], + [ + 1, + 29, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Shift released before shortcut key", + "synthetic_layout": false, + "flags": 7, + "expected_hex": "1b5b39373a36353b36751b5b39373b353a3375", + "actual_hex": "1b5b39373a36353b36751b5b39373b353a3375", + "passed": true, + "events": [ + [ + 0, + 29, + 0, + 4097, + 0 + ], + [ + 1, + 29, + 0, + 4096, + 0 + ] + ] + }, + { + "name": "Fixture Cyrillic primary shifted and base", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b313038393a313035373a39393b36751b5b313038393a313035373a39393b363a32751b5b313038393a313035373a39393b363a3375", + "actual_hex": "1b5b313038393a313035373a39393b36751b5b313038393a313035373a39393b363a32751b5b313038393a313035373a39393b363a3375", + "passed": true, + "events": [ + [ + 0, + 31, + 0, + 4097, + 0 + ], + [ + 0, + 31, + 1, + 4097, + 0 + ], + [ + 1, + 31, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Fixture Cyrillic base-only and modifier-first release", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b313038393a3a39393b35751b5b313038393a3a39393b313a3375", + "actual_hex": "1b5b313038393a3a39393b35751b5b313038393a3a39393b313a3375", + "passed": true, + "events": [ + [ + 0, + 31, + 0, + 4096, + 0 + ], + [ + 1, + 31, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Fixture soft keyboard omits physical base", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b313038393a313035373b36751b5b313038393a313035373b363a3375", + "actual_hex": "1b5b313038393a313035373b36751b5b313038393a313035373b363a3375", + "passed": true, + "events": [ + [ + 0, + 31, + 0, + 4097, + 2 + ], + [ + 1, + 31, + 0, + 4097, + 2 + ] + ] + } + ], + "date": "2026-10-09", + "apk_version": "0.118.0+kitty.59c2f1ea.10429391625c", + "apk_arm64_sha256": "3a228d5a3bf52604670879663ad6017dcf0218acabd942e71a1e826182cd56d1", + "source_snapshot_sha256": "10429391625cb048ef9ea822c421b00758a0ccdf4671d78cd51a0d93ccb6ce2e", + "base_commit": "59c2f1ea591fed2c282380b0542597d4b5c29ba1", + "all_recorded_checks_passed": true, + "counts": { + "real_android_virtual_layout": 122, + "synthetic_layout_values": 3 + }, + "notes": [ + "Framework events are directly delivered to production TerminalView, not injected through OS input dispatch.", + "Three cases override Unicode resolution in a synthetic KeyEvent fixture to exercise Cyrillic/base-only/soft keyboard boundaries.", + "Flags 0 through 7 use raw negotiation emitted by a real bash process.", + "Capture uses foreground dd, a start handshake, and an actual foreground target activity to avoid background freezing." + ], + "uncovered": [ + "No new flag 4 physical Bluetooth keyboard, real IME touches, hardware non-Latin layout, AltGr hardware, SSH or multiplexer verification. Historical phase 2 evidence does not validate flag 4." + ], + "cleanup": "Fixture processes exited; temporary capture directory removed; temporary instrumentation package uninstalled; Termux reopened. Application data and host toolchain/APKs retained." +} diff --git a/docs/kitty-keyboard.md b/docs/kitty-keyboard.md index 0a80977f49e..1a6a1ac500c 100644 --- a/docs/kitty-keyboard.md +++ b/docs/kitty-keyboard.md @@ -1,8 +1,8 @@ # Kitty keyboard protocol support -The terminal supports disambiguate escape codes (flag `1`) and report event -types (flag `2`). It does not implement alternate key codes, reporting all keys, -or associated text. Unsupported flags are ignored and are never reported +The terminal supports disambiguate escape codes (flag `1`), report event +types (flag `2`) and report alternate keys (flag `4`). It does not implement +reporting all keys or associated text. Unsupported flags are ignored and are never reported as enabled by a query. Applications negotiate support using `CSI ? u`, enable it using `CSI > 1 u`, and @@ -85,8 +85,85 @@ the current physical modifiers, including when Ctrl, Shift or Num Lock changes before the key is released. Virtual modifiers used for the original shortcut are retained for its release. +## Alternate key codes + +Flag `4` enhances an existing encoded key event with optional shifted and +standard PC-101 layout code points. Enable it with disambiguation using +`CSI > 5 u`, or with disambiguation and events using `CSI > 7 u`. +It can be added using `CSI = 4 ; 2 u` and removed using `CSI = 4 ; 3 u`. +Queries report supported combinations `0` through `7`; flags `8` and `16` +remain unsupported. Flag `4` alone is queryable but keeps legacy input. + +The key field is `primary:shifted:base`. The primary stays the active layout's +unshifted character. The shifted value is resolved through Android's current +layout, not by assuming uppercase, and is included only when Shift is active. +The base value uses known Android letter, digit, Space and punctuation key codes +to identify their standard PC-101 equivalents. Unknown and extended positions +are omitted. Values identical to the primary are redundant and omitted. +A base-only alternate keeps an empty shifted subfield: `primary::base`. + +Examples on layouts with the indicated characters: + +| Input and flags | Bytes | +| --- | --- | +| Ctrl+Shift+A, flags `5` | `ESC [ 97 : 65 ; 6 u` | +| Ctrl+Shift+= producing `+`, flags `5` | `ESC [ 61 : 43 ; 6 u` | +| Same key repeated, flags `7` | `ESC [ 61 : 43 ; 6 : 2 u` | +| Cyrillic `с` on the C position, Ctrl, flags `5` | `ESC [ 1089 : : 99 ; 5 u` | +| Cyrillic `с` on C, Ctrl+Shift, flags `5` | `ESC [ 1089 : 1057 : 99 ; 6 u` | +| Plain Shift+A, flags `7` | UTF-8 `A` | + +Presses retain an immutable layout identity for matching releases. A release +uses the initial primary/base identity and the current modifier state; if Shift +has been released, its shifted subfield is omitted. Functional-key sequences +retain their dedicated identities and never acquire printable alternates. + +IME commits and virtual shortcuts supply no physical identity and do not invent +base or shifted fields. Soft-keyboard flagged KeyEvents can report a known +layout-shifted character, but omit the physical base. Dead-key composition +fallbacks also omit unavailable alternates. Right Alt/AltGr remains part of +layout text resolution, and plain text keeps its UTF-8 path without flag `8`. +The base mapping assumes standard Android key-code assignments; custom firmware +or key-layout remapping needs separate validation. + ## Validation status +### Alternate keys (2026-10-09) + +- The complete upstream `./gradlew test` task passed with 155 terminal-emulator + and 35 app tests per Debug/Release variant: 380 executions, zero failures, + errors or skips. Three terminal and seven Android input tests were added. +- Negotiation covers every supported flag combination, unsupported masks, + set/add/remove, stack restoration, separate screens and reset. Encoding + covers shifted punctuation, base-only/non-Latin fields, supplementary Unicode, + omitted invalid/duplicate alternatives, functional and recovery boundaries. +- Robolectric fixtures exercise Cyrillic and changed Greek layouts, a shifted + character that is not uppercase, release identity after layout/modifier + changes, AltGr, soft input, and alternate-only legacy isolation. These are + synthetic layout fixtures, not physical non-Latin keyboard coverage. The + Robolectric 4.10 map lacks Shift+=; that US entry is supplied explicitly. +- Debug APK assembly succeeded for the four ABIs and universal package. + Toolchain retained: Temurin 21.0.12.1, Gradle 9.2.1, Android API 36, + Build Tools 36.0.0, NDK 29.0.14206865. The upstream CI currently uses Temurin + 17; this local run used the retained compatible JDK 21. +- Reproduce and retain the source snapshot, all unit-test XML, log, versioned + APKs and checksums with the [validation runner](../tools/kitty-keyboard/README.md). + This entry point was run successfully, retaining five uniquely versioned APKs + and all 380 Debug/Release test results. +- On the Android 16 arm64 PTP-AN10, all 125 new native PTY checks passed against + installed version `0.118.0+kitty.59c2f1ea.10429391625c`. See + [the alternate-key device report](kitty-keyboard-alternate-device-validation.json). + It includes all flags `0` through `7`, shifted A and punctuation, dropping the + shifted subfield after Shift release, and earlier event/text/recovery cases. +- Of these, 122 checks use Android's real virtual keyboard mapping and three + override Unicode values in a synthetic KeyEvent layout fixture. All input was + delivered directly to production `TerminalView`, not through OS dispatch or + physical HID. The three fixture checks cover Cyrillic primary/shifted/base, + base-only fields and soft-keyboard base suppression across a real native PTY. + No new flag `4` physical layout, real IME, SSH or multiplexer verification is + claimed; the historical reports below still validate their original flags. + + ### Event reporting (2026-10-08) - All 152 terminal-emulator and 28 app tests passed with zero failures, errors diff --git a/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java b/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java index 90e01573541..9850e101b2a 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java @@ -59,6 +59,7 @@ public final class KeyHandler { public static final int KITTY_DISAMBIGUATE = 1; public static final int KITTY_REPORT_EVENTS = 2; + public static final int KITTY_REPORT_ALTERNATE_KEYS = 4; public static final int KEY_EVENT_PRESS = 1; public static final int KEY_EVENT_REPEAT = 2; public static final int KEY_EVENT_RELEASE = 3; @@ -154,6 +155,47 @@ private static String getKittyCode(int keyCode, int keyMode, int eventType) { return kittySequence(codePoint, keyMode, suffix, eventType); } + /** Immutable layout identity, also retained across repeats for a matching release. */ + public static final class KittyKey { + public final int codePoint; + public final int shiftedCodePoint; + public final int baseLayoutCodePoint; + + public KittyKey(int codePoint, int shiftedCodePoint, int baseLayoutCodePoint) { + this.codePoint = codePoint; + this.shiftedCodePoint = validAlternate(shiftedCodePoint, codePoint); + this.baseLayoutCodePoint = validAlternate(baseLayoutCodePoint, codePoint); + } + + private static int validAlternate(int alternate, int primary) { + return alternate >= 32 && Character.isValidCodePoint(alternate) && + !(alternate >= 0xD800 && alternate <= 0xDFFF) && alternate != primary ? alternate : 0; + } + } + + /** Known standard PC-101 positions from Android key codes; unknown or extended keys are omitted. */ + public static int getKittyBaseLayoutCodePoint(int keyCode) { + if (keyCode >= KeyEvent.KEYCODE_A && keyCode <= KeyEvent.KEYCODE_Z) + return 'a' + keyCode - KeyEvent.KEYCODE_A; + if (keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9) + return '0' + keyCode - KeyEvent.KEYCODE_0; + switch (keyCode) { + case KEYCODE_SPACE: return ' '; + case KeyEvent.KEYCODE_GRAVE: return '`'; + case KeyEvent.KEYCODE_MINUS: return '-'; + case KeyEvent.KEYCODE_EQUALS: return '='; + case KeyEvent.KEYCODE_LEFT_BRACKET: return '['; + case KeyEvent.KEYCODE_RIGHT_BRACKET: return ']'; + case KeyEvent.KEYCODE_BACKSLASH: return '\\'; + case KeyEvent.KEYCODE_SEMICOLON: return ';'; + case KeyEvent.KEYCODE_APOSTROPHE: return '\''; + case KeyEvent.KEYCODE_COMMA: return ','; + case KeyEvent.KEYCODE_PERIOD: return '.'; + case KeyEvent.KEYCODE_SLASH: return '/'; + default: return 0; + } + } + /** Plain and Shift-only text stays UTF-8; shortcut keys use the unshifted code point. */ public static String getKittyCodePoint(int unshiftedCodePoint, int keyMode) { if (unshiftedCodePoint < 32 || !Character.isValidCodePoint(unshiftedCodePoint)) return null; @@ -168,12 +210,26 @@ public static boolean isKittyRecoveryKey(int keyCode) { } public static String getKittyCodePoint(int unshiftedCodePoint, int keyMode, int flags, int eventType) { - if ((flags & KITTY_REPORT_EVENTS) == 0) eventType = KEY_EVENT_PRESS; + return getKittyCodePoint(new KittyKey(unshiftedCodePoint, 0, 0), keyMode, flags, eventType); + } + + /** Alternate reporting enhances an encoded event without turning layout text into an escape code. */ + public static String getKittyCodePoint(KittyKey key, int keyMode, int flags, int eventType) { + if ((flags & KITTY_REPORT_EVENTS) == 0) { + if (eventType == KEY_EVENT_RELEASE) return null; + eventType = KEY_EVENT_PRESS; + } if ((flags & KITTY_DISAMBIGUATE) == 0 && eventType == KEY_EVENT_PRESS) return null; - if (unshiftedCodePoint < 32 || !Character.isValidCodePoint(unshiftedCodePoint)) return null; - if (eventType != KEY_EVENT_RELEASE && getKittyCodePoint(unshiftedCodePoint, keyMode) == null) return null; - return kittySequence(unshiftedCodePoint, - keyMode & ~(KEYMOD_NUM_LOCK | KEYMOD_CAPS_LOCK), 'u', eventType); + if (key.codePoint < 32 || !Character.isValidCodePoint(key.codePoint)) return null; + if (eventType != KEY_EVENT_RELEASE && getKittyCodePoint(key.codePoint, keyMode) == null) return null; + String number = Integer.toString(key.codePoint); + if ((flags & KITTY_REPORT_ALTERNATE_KEYS) != 0) { + int shifted = (keyMode & KEYMOD_SHIFT) != 0 ? key.shiftedCodePoint : 0; + if (shifted != 0 || key.baseLayoutCodePoint != 0) + number += ":" + (shifted == 0 ? "" : shifted) + + (key.baseLayoutCodePoint == 0 ? "" : ":" + key.baseLayoutCodePoint); + } + return kittySequence(number, keyMode & ~(KEYMOD_NUM_LOCK | KEYMOD_CAPS_LOCK), 'u', eventType); } /** Reuse a previously encoded functional identity with the release event's current modifiers. */ @@ -188,6 +244,10 @@ private static String kittySequence(int number, int keyMode, char suffix) { } private static String kittySequence(int number, int keyMode, char suffix, int eventType) { + return kittySequence(Integer.toString(number), keyMode, suffix, eventType); + } + + private static String kittySequence(String number, int keyMode, char suffix, int eventType) { int modifiers = 1; if ((keyMode & KEYMOD_SHIFT) != 0) modifiers += 1; if ((keyMode & KEYMOD_ALT) != 0) modifiers += 2; @@ -195,7 +255,8 @@ private static String kittySequence(int number, int keyMode, char suffix, int ev if ((keyMode & KEYMOD_SUPER) != 0) modifiers += 8; if ((keyMode & KEYMOD_CAPS_LOCK) != 0) modifiers += 64; if ((keyMode & KEYMOD_NUM_LOCK) != 0) modifiers += 128; - String prefix = number == 1 && suffix != 'u' && suffix != '~' && modifiers == 1 && eventType == KEY_EVENT_PRESS ? "" : Integer.toString(number); + String prefix = number.equals("1") && suffix != 'u' && suffix != '~' && + modifiers == 1 && eventType == KEY_EVENT_PRESS ? "" : number; return "\033[" + prefix + (modifiers == 1 && eventType == KEY_EVENT_PRESS ? "" : ";" + modifiers) + (eventType == KEY_EVENT_PRESS ? "" : ":" + eventType) + suffix; } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java b/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java index 64ce9b989e3..2f6450071a3 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/KittyKeyboardState.java @@ -1,6 +1,6 @@ package com.termux.terminal; -/** Per-screen progressive keyboard flags. Supports disambiguation and event reporting. */ +/** Per-screen progressive keyboard flags. Supports disambiguation, event and alternate-key reporting. */ final class KittyKeyboardState { // The current flags occupy one entry; reserve at most 31 saved entries. private static final int MAX_DEPTH = 31; @@ -22,7 +22,8 @@ void invalidateEvents() { } void set(int flags, int mode) { - flags &= KeyHandler.KITTY_DISAMBIGUATE | KeyHandler.KITTY_REPORT_EVENTS; + flags &= KeyHandler.KITTY_DISAMBIGUATE | KeyHandler.KITTY_REPORT_EVENTS | + KeyHandler.KITTY_REPORT_ALTERNATE_KEYS; if (mode >= 1 && mode <= 3) mGeneration++; switch (mode) { case 1: mFlags = flags; break; diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index 00dd7a9f9d1..87ba5bd3ee0 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -42,8 +42,9 @@ private KittyKeyboardState kittyKeyboardState() { return mScreen == mAltBuffer ? mKittyKeyboardAlt : mKittyKeyboardMain; } + /** Whether an enhancement changes key encoding; alternate reporting alone keeps legacy input. */ public boolean isKittyKeyboardEnabled() { - return kittyKeyboardState().getFlags() != 0; + return (getKittyKeyboardFlags() & (KeyHandler.KITTY_DISAMBIGUATE | KeyHandler.KITTY_REPORT_EVENTS)) != 0; } public int getKittyKeyboardFlags() { diff --git a/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java b/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java index b2ea8dd05f0..ee0b027d211 100644 --- a/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java +++ b/terminal-emulator/src/test/java/com/termux/terminal/KittyKeyboardTest.java @@ -22,9 +22,9 @@ public void testNegotiationAndRestore() { public void testSupportedFlagsAndSetModes() { withTerminalSized(20, 4); enterString("\033[=31u"); - assertEnteringStringGivesResponse("\033[?u", "\033[?3u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?7u"); enterString("\033[=1;3u"); - assertEnteringStringGivesResponse("\033[?u", "\033[?2u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?6u"); enterString("\033[=1;2u\033[=30;2u"); assertTrue(mTerminal.isKittyKeyboardEnabled()); enterString("\033[=0;1u"); @@ -123,4 +123,59 @@ public void testEventEncoding() { assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, false, false, 1, 3)); } + public void testAlternateFlagCombinationsAndRestoration() { + withTerminalSized(20, 4); + for (int flags = 0; flags <= 31; flags++) { + enterString("\033[=" + flags + "u"); + assertEnteringStringGivesResponse("\033[?u", "\033[?" + (flags & 7) + "u"); + assertEquals((flags & 3) != 0, mTerminal.isKittyKeyboardEnabled()); + } + enterString("\033[=4u\033[>7u\033[5u\033[?1049h"); + assertEnteringStringGivesResponse("\033[?u", "\033[?0u"); + enterString("\033[>6u\033[?1049l"); + assertEnteringStringGivesResponse("\033[?u", "\033[?5u"); + enterString("\033c\033[?1049h"); + assertEnteringStringGivesResponse("\033[?u", "\033[?0u"); + } + + public void testAlternateCodePointFields() { + KeyHandler.KittyKey equals = new KeyHandler.KittyKey('=', '+', '='); + int modifiers = KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT; + assertEquals("\033[61:43;6u", KeyHandler.getKittyCodePoint(equals, modifiers, 5, 1)); + assertEquals("\033[61:43;6:2u", KeyHandler.getKittyCodePoint(equals, modifiers, 7, 2)); + assertEquals("\033[61;5:3u", KeyHandler.getKittyCodePoint(equals, KeyHandler.KEYMOD_CTRL, 7, 3)); + assertEquals("\033[61;6u", KeyHandler.getKittyCodePoint(equals, modifiers, 3, 1)); + assertNull(KeyHandler.getKittyCodePoint(equals, modifiers, 4, 1)); + assertNull(KeyHandler.getKittyCodePoint(equals, modifiers, 6, 1)); + assertEquals("\033[61:43;6:2u", KeyHandler.getKittyCodePoint(equals, modifiers, 6, 2)); + assertNull(KeyHandler.getKittyCodePoint(equals, modifiers, 5, 3)); + assertNull(KeyHandler.getKittyCodePoint(equals, KeyHandler.KEYMOD_SHIFT, 7, 1)); + KeyHandler.KittyKey cyrillic = new KeyHandler.KittyKey(1089, 1057, 'c'); + assertEquals("\033[1089:1057:99;6u", KeyHandler.getKittyCodePoint(cyrillic, modifiers, 5, 1)); + assertEquals("\033[1089::99;5u", KeyHandler.getKittyCodePoint(cyrillic, KeyHandler.KEYMOD_CTRL, 5, 1)); + assertEquals("\033[1089::99;1:3u", KeyHandler.getKittyCodePoint(cyrillic, 0, 7, 3)); + KeyHandler.KittyKey unknown = new KeyHandler.KittyKey('a', 0xD800, 0x80000000); + assertEquals("\033[97;6u", KeyHandler.getKittyCodePoint(unknown, modifiers, 5, 1)); + KeyHandler.KittyKey supplementary = new KeyHandler.KittyKey(0x1F600, 0x1F601, 'a'); + assertEquals("\033[128512:128513:97;6u", KeyHandler.getKittyCodePoint(supplementary, modifiers, 5, 1)); + } + + public void testBaseLayoutMappingAndFunctionalKeyBoundaries() { + for (int key = KeyEvent.KEYCODE_A; key <= KeyEvent.KEYCODE_Z; key++) + assertEquals('a' + key - KeyEvent.KEYCODE_A, KeyHandler.getKittyBaseLayoutCodePoint(key)); + for (int key = KeyEvent.KEYCODE_0; key <= KeyEvent.KEYCODE_9; key++) + assertEquals('0' + key - KeyEvent.KEYCODE_0, KeyHandler.getKittyBaseLayoutCodePoint(key)); + assertEquals('=', KeyHandler.getKittyBaseLayoutCodePoint(KeyEvent.KEYCODE_EQUALS)); + assertEquals('\\', KeyHandler.getKittyBaseLayoutCodePoint(KeyEvent.KEYCODE_BACKSLASH)); + assertEquals(0, KeyHandler.getKittyBaseLayoutCodePoint(KeyEvent.KEYCODE_UNKNOWN)); + assertEquals(0, KeyHandler.getKittyBaseLayoutCodePoint(KeyEvent.KEYCODE_PLUS)); + assertEquals(0, KeyHandler.getKittyBaseLayoutCodePoint(KeyEvent.KEYCODE_NUMPAD_0)); + assertEquals("\033[13;2:2~", KeyHandler.getCode(KeyEvent.KEYCODE_F3, KeyHandler.KEYMOD_SHIFT, false, false, 7, 2)); + assertEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, 0, false, false, 7, 2)); + } + } diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index 1ccaad6ba18..bb959842703 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -93,7 +93,7 @@ public final class TerminalView extends View { private final Map mKittyKeyPresses = new HashMap<>(); private static final class KittyKeyPress { - final int codePoint; + final KeyHandler.KittyKey textKey; final int virtualModifiers; final String releaseCode; final TerminalEmulator emulator; @@ -101,8 +101,8 @@ private static final class KittyKeyPress { final long generation; final long downTime; - KittyKeyPress(int codePoint, int virtualModifiers, String releaseCode, TerminalEmulator emulator, KeyEvent event) { - this.codePoint = codePoint; + KittyKeyPress(KeyHandler.KittyKey textKey, int virtualModifiers, String releaseCode, TerminalEmulator emulator, KeyEvent event) { + this.textKey = textKey; this.virtualModifiers = virtualModifiers; this.releaseCode = releaseCode; this.emulator = emulator; @@ -127,12 +127,12 @@ private static int kittyEventModifiers(KeyEvent event) { return modifiers; } - private void rememberKittyKey(KeyEvent event, int codePoint, int modifiers, String releaseCode) { + private void rememberKittyKey(KeyEvent event, KeyHandler.KittyKey textKey, int modifiers, String releaseCode) { // Repeats must not replace the identity or mode captured by the initial press. if (event == null || event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() != 0 || (mEmulator.getKittyKeyboardFlags() & KeyHandler.KITTY_REPORT_EVENTS) == 0 || KeyHandler.isKittyRecoveryKey(event.getKeyCode())) return; - mKittyKeyPresses.put(kittyKeyId(event), new KittyKeyPress(codePoint, + mKittyKeyPresses.put(kittyKeyId(event), new KittyKeyPress(textKey, modifiers & ~kittyEventModifiers(event), releaseCode, mEmulator, event)); } @@ -903,7 +903,18 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { int textKeyMod = keyMod; // Right Alt is used by the keyboard layout (AltGr), rather than a terminal Alt shortcut. if (rightAltDownFromEvent && !leftAltDown) textKeyMod &= ~KeyHandler.KEYMOD_ALT; - inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown, unshiftedCodePoint, textKeyMod, event); + int shiftedCodePoint = 0, baseLayoutCodePoint = 0; + if ((mEmulator.getKittyKeyboardFlags() & KeyHandler.KITTY_REPORT_ALTERNATE_KEYS) != 0 && + oldCombiningAccent == 0 && unshiftedCodePoint > 0 && + (event.getUnicodeChar(unshiftedMetaState) & KeyCharacterMap.COMBINING_ACCENT) == 0) { + if (shiftDown) shiftedCodePoint = event.getUnicodeChar(unshiftedMetaState | KeyEvent.META_SHIFT_ON); + if (event.getAction() == KeyEvent.ACTION_DOWN && + (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) == 0) + baseLayoutCodePoint = KeyHandler.getKittyBaseLayoutCodePoint(keyCode); + } + KeyHandler.KittyKey textKey = new KeyHandler.KittyKey(unshiftedCodePoint, + shiftedCodePoint, baseLayoutCodePoint); + inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown, textKey, textKeyMod, event); } if (mCombiningAccent != oldCombiningAccent) invalidate(); @@ -913,11 +924,11 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { inputCodePoint(eventSource, codePoint, controlDownFromEvent, leftAltDownFromEvent, - Character.toLowerCase(codePoint), 0, null); + new KeyHandler.KittyKey(Character.toLowerCase(codePoint), 0, 0), 0, null); } private void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, - boolean leftAltDownFromEvent, int unshiftedCodePoint, int keyMod, KeyEvent event) { + boolean leftAltDownFromEvent, KeyHandler.KittyKey textKey, int keyMod, KeyEvent event) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { mClient.logInfo(LOG_TAG, "inputCodePoint(eventSource=" + eventSource + ", codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + leftAltDownFromEvent + ")"); @@ -943,10 +954,10 @@ private void inputCodePoint(int eventSource, int codePoint, boolean controlDownF if (mClient.readShiftKey()) keyMod |= KeyHandler.KEYMOD_SHIFT; int eventType = event != null && event.getRepeatCount() > 0 ? KeyHandler.KEY_EVENT_REPEAT : KeyHandler.KEY_EVENT_PRESS; - String kittyCode = KeyHandler.getKittyCodePoint(unshiftedCodePoint, keyMod, + String kittyCode = KeyHandler.getKittyCodePoint(textKey, keyMod, mEmulator.getKittyKeyboardFlags(), eventType); - if (KeyHandler.getKittyCodePoint(unshiftedCodePoint, keyMod) != null) - rememberKittyKey(event, unshiftedCodePoint, keyMod, null); + if (KeyHandler.getKittyCodePoint(textKey.codePoint, keyMod) != null) + rememberKittyKey(event, textKey, keyMod, null); if (kittyCode != null) { mTermSession.write(kittyCode); return; @@ -1024,7 +1035,7 @@ private boolean handleKeyCode(int keyCode, int keyMod, KeyEvent event) { mTermSession.write(code); String releaseCode = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode(), term.getKittyKeyboardFlags(), KeyHandler.KEY_EVENT_RELEASE); - if (releaseCode != null) rememberKittyKey(event, 0, keyMod, releaseCode); + if (releaseCode != null) rememberKittyKey(event, null, keyMod, releaseCode); return true; } @@ -1077,11 +1088,11 @@ public boolean onKeyUp(int keyCode, KeyEvent event) { mEmulator.getKittyKeyboardGeneration() == press.generation && event.getDownTime() == press.downTime && (mEmulator.getKittyKeyboardFlags() & KeyHandler.KITTY_REPORT_EVENTS) != 0 && !event.isCanceled()) { int modifiers = kittyEventModifiers(event) | press.virtualModifiers; - if (press.codePoint != 0 && (event.getMetaState() & KeyEvent.META_ALT_RIGHT_ON) != 0 && + if (press.textKey != null && (event.getMetaState() & KeyEvent.META_ALT_RIGHT_ON) != 0 && (event.getMetaState() & KeyEvent.META_ALT_LEFT_ON) == 0) modifiers &= ~KeyHandler.KEYMOD_ALT; - String code = press.codePoint == 0 ? KeyHandler.getKittyReleaseCode(press.releaseCode, modifiers) : - KeyHandler.getKittyCodePoint(press.codePoint, modifiers, + String code = press.textKey == null ? KeyHandler.getKittyReleaseCode(press.releaseCode, modifiers) : + KeyHandler.getKittyCodePoint(press.textKey, modifiers, mEmulator.getKittyKeyboardFlags(), KeyHandler.KEY_EVENT_RELEASE); if (code != null) mTermSession.write(code); } diff --git a/tools/kitty-keyboard/README.md b/tools/kitty-keyboard/README.md new file mode 100644 index 00000000000..33e31e09d45 --- /dev/null +++ b/tools/kitty-keyboard/README.md @@ -0,0 +1,43 @@ +# Keyboard validation + +This runner uses the same `./gradlew test` task as the official +[unit-test workflow](https://github.com/termux/termux-app/blob/master/.github/workflows/run_tests.yml), +then builds the default `apt-android-7` Debug variant and checks the five APKs +and SHA-256 hashes, following the +[build workflow](https://github.com/termux/termux-app/blob/master/.github/workflows/debug_build.yml). +The official CI uses Temurin 17; a compatible local JDK and the SDK/NDK versions +specified in `gradle.properties` must already be installed. This runner does +not download or delete toolchains, install APKs, or publish changes. + +Export `JAVA_HOME` and `ANDROID_HOME` (or `ANDROID_SDK_ROOT`). For an isolated +setup, also export `GRADLE_USER_HOME` and `ANDROID_USER_HOME` to the retained +toolchain directory. Run from the repository root: + +```sh +python3 tools/kitty-keyboard/validate.py --output-dir ../termux-app-artifacts/kitty-alternates +``` + +Use `--offline` only with a provisioned dependency cache. Choose a new, empty +output directory for each run. It retains the source diff and newly added files, +version, Gradle log, Debug/Release test XML, five versioned APKs, checksums and +`validation.json`. This uses the default package variant; the official build CI +also tests `apt-android-5` independently. A runner failure preserves its results; +inspect `gradle.log` and the XML rather than treating old artifacts as a new pass. + +Device acceptance remains a separate step. Record the installed APK version and +hash, negotiation flags, expected and actual PTY bytes, event source and cleanup. +Direct framework delivery, OS injection, touchscreen operations and physical HID +must be labelled separately. A previous feature's device results do not validate +a newer enhancement. Physical actions remain manual; test helpers must never +claim hardware verification for synthetic events. Retain tooling and APKs for +retesting; uninstall temporary helpers and restore terminal modes after capture. + +Contribution guidance comes from the app +[README](https://github.com/termux/termux-app#for-maintainers-and-contributors), +`.editorconfig`, and the +[Termux Libraries development guide](https://github.com/termux/termux-app/wiki/Termux-Libraries#forking-and-local-development). +Shared app/plugin utilities belong in the appropriate library; keyboard protocol +encoding remains in `terminal-emulator` and Android input handling in +`terminal-view`. Follow the repository's capitalized commit types and semantic +version requirements. Local Maven publishing is needed when testing dependent +plugins against modified libraries, not for building this app's own modules. diff --git a/tools/kitty-keyboard/validate.py b/tools/kitty-keyboard/validate.py new file mode 100644 index 00000000000..20b81036ce2 --- /dev/null +++ b/tools/kitty-keyboard/validate.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Run the upstream Gradle checks and retain reproducible keyboard validation artifacts.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", required=True, type=Path, + help="New artifact directory outside the repository; retained after validation") + parser.add_argument("--offline", action="store_true", help="Use an already provisioned Gradle cache") + args = parser.parse_args() + repo = Path(__file__).resolve().parents[2] + output = args.output_dir.resolve() + if output == repo or repo in output.parents: + parser.error("Keep artifacts outside the repository") + if output.exists() and any(output.iterdir()): + parser.error("Use an empty output directory to preserve earlier results") + output.mkdir(parents=True, exist_ok=True) + + def git(*arguments): + return subprocess.check_output(["git", *arguments], cwd=repo) + + base = git("rev-parse", "HEAD").decode().strip() + patch = git("diff", "--binary", "HEAD") + (output / "source.patch").write_bytes(patch) + digest = hashlib.sha256(patch) + # Include newly added files so the recorded version identifies the entire source snapshot. + untracked = git("ls-files", "--others", "--exclude-standard", "-z").split(b"\0") + for entry in sorted(filter(None, untracked)): + source = repo / os.fsdecode(entry) + if not source.is_file(): + continue + data = source.read_bytes() + digest.update(entry + b"\0" + hashlib.sha256(data).digest()) + destination = output / "untracked-source" / os.fsdecode(entry) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + snapshot = digest.hexdigest() + match = re.search(r'^\s*versionName "([^"]+)"\s*$', (repo / "app/build.gradle").read_text(), re.M) + if match is None: + raise RuntimeError("Cannot find the app versionName") + version = match.group(1).split("+", 1)[0] + "+kitty." + base[:8] + "." + snapshot[:12] + (output / "version.txt").write_text(version + "\n") + env = dict(os.environ, TERMUX_APP_VERSION_NAME=version, TERMUX_PACKAGE_VARIANT="apt-android-7", + TERMUX_SPLIT_APKS_FOR_DEBUG_BUILDS="1", TERMUX_APK_VERSION_TAG="kitty-validation") + command = [str(repo / "gradlew"), "--no-daemon", "--max-workers=2"] + if args.offline: + command.append("--offline") + command += ["test", ":app:assembleDebug"] + print("Running upstream Gradle test and Debug APK assembly", flush=True) + with (output / "gradle.log").open("w") as log: + result = subprocess.run(command, cwd=repo, env=env, stdout=log, stderr=subprocess.STDOUT) + + suites = {} + for module in ("terminal-emulator", "terminal-view", "termux-shared", "app"): + for directory in sorted((repo / module / "build/test-results").glob("test*UnitTest")): + roots = [ET.parse(path).getroot() for path in directory.glob("TEST-*.xml")] + if not roots: + continue + suites[module + ":" + directory.name] = { + field: sum(int(root.get(field, 0)) for root in roots) + for field in ("tests", "failures", "errors", "skipped") + } + shutil.copytree(directory, output / "test-results" / module / directory.name) + checksums = {} + # Copy only this invocation's APK names, not stale APKs from earlier builds. + for abi in ("universal", "arm64-v8a", "armeabi-v7a", "x86_64", "x86"): + apk = repo / "app/build/outputs/apk/debug" / ("termux-app_kitty-validation_" + abi + ".apk") + if result.returncode == 0 and apk.is_file(): + shutil.copy2(apk, output / apk.name) + checksums[apk.name] = hashlib.sha256(apk.read_bytes()).hexdigest() + diff_check = subprocess.run(["git", "diff", "--check", "HEAD"], cwd=repo, capture_output=True) + report = { + "base_commit": base, "source_snapshot_sha256": snapshot, "apk_version": version, + "command": command, "gradle_exit_code": result.returncode, "unit_tests": suites, + "apks": checksums, "diff_check_exit_code": diff_check.returncode, + "device_validation": "Not run by this script; record actual device evidence separately", + "toolchain": {key: os.environ.get(key) for key in + ("JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT", "GRADLE_USER_HOME", "ANDROID_USER_HOME")}, + } + (output / "validation.json").write_text(json.dumps(report, indent=2) + "\n") + (output / "SHA256SUMS").write_text("".join(value + " " + name + "\n" for name, value in sorted(checksums.items()))) + print(json.dumps(report, indent=2)) + if result.returncode: + return result.returncode + if diff_check.returncode or len(checksums) != 5 or not suites: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a9f40a5b813146091b41d0a130043bd0740beff8 Mon Sep 17 00:00:00 2001 From: JM-FRANK <156544419@qq.com> Date: Fri, 9 Oct 2026 00:57:43 +0900 Subject: [PATCH 4/4] Fixed(keyboard): Preserve Shift sides and validation evidence Keep physical left/right Shift states for layout-resolved alternate codes while retaining the virtual Shift default. Archive raw test results before parsing, record malformed XML and continue summarizing valid reports without hiding the Gradle exit status. Add four Android input regressions and three isolated validation-tool checks. Record 388 successful Gradle test executions and 138 native PTY checks, including thirteen new synthetic side-specific layout cases. --- .../app/terminal/KittyKeyboardInputTest.java | 75 ++++ ...-keyboard-alternate-review-validation.json | 407 ++++++++++++++++++ docs/kitty-keyboard.md | 21 + .../java/com/termux/view/TerminalView.java | 8 +- tools/kitty-keyboard/README.md | 13 + tools/kitty-keyboard/test_validate.py | 92 ++++ tools/kitty-keyboard/validate.py | 49 ++- 7 files changed, 651 insertions(+), 14 deletions(-) create mode 100644 docs/kitty-keyboard-alternate-review-validation.json create mode 100644 tools/kitty-keyboard/test_validate.py diff --git a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java index bee69346ca4..e8662fa73e0 100644 --- a/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java +++ b/app/src/test/java/com/termux/app/terminal/KittyKeyboardInputTest.java @@ -569,4 +569,79 @@ public void shiftedAlternateIsNotAssumedToBeUppercase() throws Exception { press(KeyEvent.KEYCODE_D, KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON, "\033[97:33:100;6u"); } + /** Distinct generic, left, right, both and right-Shift+AltGr mappings. */ + @Implements(KeyCharacterMap.class) + public static class SideShiftCharacterMap extends ShadowKeyCharacterMap { + @Override + @Implementation + protected int get(int keyCode, int metaState) { + if (keyCode == KeyEvent.KEYCODE_EQUALS) { + boolean left = (metaState & KeyEvent.META_SHIFT_LEFT_ON) != 0; + boolean right = (metaState & KeyEvent.META_SHIFT_RIGHT_ON) != 0; + if ((metaState & KeyEvent.META_ALT_RIGHT_ON) != 0) return right ? 0x00D7 : 0x20AC; + if (left && right) return '^'; + if (right) return '*'; + if (left) return '#'; + return (metaState & KeyEvent.META_SHIFT_ON) != 0 ? '+' : '='; + } + return super.get(keyCode, metaState); + } + } + + @Test + @Config(shadows = SideShiftCharacterMap.class) + public void alternateShiftedCharactersRetainPhysicalShiftSides() throws Exception { + int[] sides = {KeyEvent.META_SHIFT_LEFT_ON, KeyEvent.META_SHIFT_RIGHT_ON, + KeyEvent.META_SHIFT_LEFT_ON | KeyEvent.META_SHIFT_RIGHT_ON, 0}; + int[] codePoints = {'#', '*', '^', '+'}; + for (int flags : new int[]{5, 7}) { + enter("\033[=" + flags + "u"); + for (int i = 0; i < sides.length; i++) { + int modifiers = KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON | sides[i]; + String key = "\033[61:" + codePoints[i] + ";6"; + press(KeyEvent.KEYCODE_EQUALS, modifiers, key + "u"); + repeat(KeyEvent.KEYCODE_EQUALS, modifiers, key + (flags == 7 ? ":2u" : "u")); + release(KeyEvent.KEYCODE_EQUALS, modifiers, flags == 7 ? key + ":3u" : ""); + } + } + } + + @Test + @Config(shadows = SideShiftCharacterMap.class) + public void rightShiftAndAltGrResolveTheActualLayoutCombination() throws Exception { + enter("\033[>7u"); + int modifiers = KeyEvent.META_CTRL_ON | KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_RIGHT_ON | + KeyEvent.META_ALT_ON | KeyEvent.META_ALT_RIGHT_ON; + press(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[8364:215:61;6u"); + repeat(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[8364:215:61;6:2u"); + release(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[8364:215:61;6:3u"); + press(KeyEvent.KEYCODE_EQUALS, modifiers, "\033[8364:215:61;6u"); + release(KeyEvent.KEYCODE_EQUALS, KeyEvent.META_ALT_ON | KeyEvent.META_ALT_RIGHT_ON, + "\033[8364::61;1:3u"); + } + + @Test + @Config(shadows = SideShiftCharacterMap.class) + public void physicalRightShiftDoesNotGainAnArtificialLeftShift() throws Exception { + for (int flags : new int[]{0, 4, 7}) { + enter("\033[=" + flags + "u"); + press(KeyEvent.KEYCODE_EQUALS, KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_RIGHT_ON, "*"); + release(KeyEvent.KEYCODE_EQUALS, 0, ""); + } + } + + @Test + @Config(shadows = SideShiftCharacterMap.class) + public void virtualShiftKeepsItsExistingLeftShiftLayoutBehavior() throws Exception { + enter("\033[>7u"); + mView.setTerminalViewClient(new TermuxTerminalViewClientBase() { + @Override + public boolean readShiftKey() { + return true; + } + }); + press(KeyEvent.KEYCODE_EQUALS, KeyEvent.META_CTRL_ON, "\033[61:35;6u"); + release(KeyEvent.KEYCODE_EQUALS, KeyEvent.META_CTRL_ON, "\033[61:35;6:3u"); + } + } diff --git a/docs/kitty-keyboard-alternate-review-validation.json b/docs/kitty-keyboard-alternate-review-validation.json new file mode 100644 index 00000000000..7becb96c6ab --- /dev/null +++ b/docs/kitty-keyboard-alternate-review-validation.json @@ -0,0 +1,407 @@ +{ + "method": "Android framework KeyEvent delivered directly to production TerminalView; real TerminalSession/native PTY and bash raw byte capture; not physical HID or OS input dispatch", + "model": "PTP-AN10", + "android": "16", + "checks": 138, + "failures": 0, + "date": "2026-10-09", + "apk_version": "0.118.0+kitty.817dfb43.edcdab58162c", + "apk_arm64_sha256": "37b2b7587bd31c69f9c33067f22a21276014f5c79ab9e6ccc7a29f625d7e7afc", + "source_snapshot_sha256": "edcdab58162c2689b21bdabcc30201f3c9abe74c5f70b76a3b11673157e38dd4", + "base_commit": "817dfb4322a6e072bc006638da20e6e24532fc2b", + "all_recorded_checks_passed": true, + "counts": { + "android_virtual_layout": 122, + "synthetic_layout_fixture": 16 + }, + "notes": [ + "Framework KeyEvents are delivered directly to production TerminalView and the native PTY; not physical HID or OS injection.", + "Original 125 regression cases plus 13 new synthetic Shift-side cases are verified on the new APK.", + "New fixtures cover generic/left/right/both Shift, right Shift+AltGr, ordinary right-Shift text, and virtual Shift default." + ], + "uncovered": [ + "No new physical side-dependent keyboard layout or real IME verification is claimed for these fixes." + ], + "regression_checks": 125, + "regression_reference": "kitty-keyboard-alternate-device-validation.json (same expected cases, rerun on this APK)", + "new_checks": [ + { + "name": "Fixture Shift side 64 shortcut hold", + "synthetic_layout": true, + "flags": 5, + "expected_hex": "1b5b36313a33353b36751b5b36313a33353b3675", + "actual_hex": "1b5b36313a33353b36751b5b36313a33353b3675", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4161, + 0 + ], + [ + 0, + 70, + 1, + 4161, + 0 + ], + [ + 1, + 70, + 0, + 4161, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 128 shortcut hold", + "synthetic_layout": true, + "flags": 5, + "expected_hex": "1b5b36313a34323b36751b5b36313a34323b3675", + "actual_hex": "1b5b36313a34323b36751b5b36313a34323b3675", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4225, + 0 + ], + [ + 0, + 70, + 1, + 4225, + 0 + ], + [ + 1, + 70, + 0, + 4225, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 192 shortcut hold", + "synthetic_layout": true, + "flags": 5, + "expected_hex": "1b5b36313a39343b36751b5b36313a39343b3675", + "actual_hex": "1b5b36313a39343b36751b5b36313a39343b3675", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4289, + 0 + ], + [ + 0, + 70, + 1, + 4289, + 0 + ], + [ + 1, + 70, + 0, + 4289, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 0 shortcut hold", + "synthetic_layout": true, + "flags": 5, + "expected_hex": "1b5b36313a34333b36751b5b36313a34333b3675", + "actual_hex": "1b5b36313a34333b36751b5b36313a34333b3675", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4097, + 0 + ], + [ + 0, + 70, + 1, + 4097, + 0 + ], + [ + 1, + 70, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 64 shortcut hold", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b36313a33353b36751b5b36313a33353b363a32751b5b36313a33353b363a3375", + "actual_hex": "1b5b36313a33353b36751b5b36313a33353b363a32751b5b36313a33353b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4161, + 0 + ], + [ + 0, + 70, + 1, + 4161, + 0 + ], + [ + 1, + 70, + 0, + 4161, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 128 shortcut hold", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b36313a34323b36751b5b36313a34323b363a32751b5b36313a34323b363a3375", + "actual_hex": "1b5b36313a34323b36751b5b36313a34323b363a32751b5b36313a34323b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4225, + 0 + ], + [ + 0, + 70, + 1, + 4225, + 0 + ], + [ + 1, + 70, + 0, + 4225, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 192 shortcut hold", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b36313a39343b36751b5b36313a39343b363a32751b5b36313a39343b363a3375", + "actual_hex": "1b5b36313a39343b36751b5b36313a39343b363a32751b5b36313a39343b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4289, + 0 + ], + [ + 0, + 70, + 1, + 4289, + 0 + ], + [ + 1, + 70, + 0, + 4289, + 0 + ] + ] + }, + { + "name": "Fixture Shift side 0 shortcut hold", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b36313a34333b36751b5b36313a34333b363a32751b5b36313a34333b363a3375", + "actual_hex": "1b5b36313a34333b36751b5b36313a34333b363a32751b5b36313a34333b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4097, + 0 + ], + [ + 0, + 70, + 1, + 4097, + 0 + ], + [ + 1, + 70, + 0, + 4097, + 0 + ] + ] + }, + { + "name": "Fixture right Shift+AltGr retains side", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b383336343a3231353a36313b36751b5b383336343a3231353a36313b363a32751b5b383336343a3a36313b313a3375", + "actual_hex": "1b5b383336343a3231353a36313b36751b5b383336343a3231353a36313b363a32751b5b383336343a3a36313b313a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4259, + 0 + ], + [ + 0, + 70, + 1, + 4259, + 0 + ], + [ + 1, + 70, + 0, + 34, + 0 + ] + ] + }, + { + "name": "Fixture plain right Shift stays text", + "synthetic_layout": true, + "flags": 0, + "expected_hex": "2a", + "actual_hex": "2a", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 129, + 0 + ], + [ + 1, + 70, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Fixture plain right Shift stays text", + "synthetic_layout": true, + "flags": 4, + "expected_hex": "2a", + "actual_hex": "2a", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 129, + 0 + ], + [ + 1, + 70, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Fixture plain right Shift stays text", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "2a", + "actual_hex": "2a", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 129, + 0 + ], + [ + 1, + 70, + 0, + 0, + 0 + ] + ] + }, + { + "name": "Fixture virtual Shift keeps left-side default", + "synthetic_layout": true, + "flags": 7, + "expected_hex": "1b5b36313a33353b36751b5b36313a33353b363a3375", + "actual_hex": "1b5b36313a33353b36751b5b36313a33353b363a3375", + "passed": true, + "events": [ + [ + 0, + 70, + 0, + 4096, + 0 + ], + [ + 1, + 70, + 0, + 4096, + 0 + ] + ] + } + ], + "full_report_artifact": "kitty-alternate-review-fixes/device-validation.json", + "full_report_sha256": "ff0d8f483ef14350b860c5c160a455e083e1ff88bad7c2b5c469a77717a90da1", + "cleanup": "Fixture processes exited; temporary capture directory and helper removed; Termux reopened; application data, host APKs/reports and toolchain retained." +} diff --git a/docs/kitty-keyboard.md b/docs/kitty-keyboard.md index 1a6a1ac500c..afa0cb59e8f 100644 --- a/docs/kitty-keyboard.md +++ b/docs/kitty-keyboard.md @@ -97,6 +97,8 @@ remain unsupported. Flag `4` alone is queryable but keeps legacy input. The key field is `primary:shifted:base`. The primary stays the active layout's unshifted character. The shifted value is resolved through Android's current layout, not by assuming uppercase, and is included only when Shift is active. +Physical left/right Shift states are retained for side-specific layout mappings, +including right Shift with AltGr. Virtual Shift retains its left-Shift default. The base value uses known Android letter, digit, Space and punctuation key codes to identify their standard PC-101 equivalents. Unknown and extended positions are omitted. Values identical to the primary are redundant and omitted. @@ -128,6 +130,25 @@ or key-layout remapping needs separate validation. ## Validation status +### Alternate-key review fixes (2026-10-09) + +- Preserve actual left/right Shift states when resolving the shifted alternate; + a physical right Shift no longer gains an artificial left-Shift bit. Four + new Android regressions cover generic/left/right/both Shift, repeats and + releases, right Shift with AltGr, ordinary text and virtual Shift's existing + default. These side-specific layouts are test fixtures, not hardware layouts. +- Archive raw Gradle test directories before parsing their XML. Damaged reports + are retained and recorded in `test_result_errors`, other valid suites are + still summarized, and the runner reports failure. Three isolated tool checks + include an end-to-end simulated Gradle failure with truncated XML. +- All 388 Debug/Release Gradle test executions passed (155 emulator and 39 app + tests per variant), along with the three validation-tool checks; five Debug + APKs were rebuilt and retained. Version: `0.118.0+kitty.817dfb43.edcdab58162c`. +- All 138 native PTY checks passed on Android 16 PTP-AN10: the previous 125 + cases plus 13 new Shift-side fixtures. The 13 new cases are synthetic layout + fixtures delivered as framework events, not physical keyboard layouts. See + [the review-fix report](kitty-keyboard-alternate-review-validation.json). + ### Alternate keys (2026-10-09) - The complete upstream `./gradlew test` task passed with 155 terminal-emulator diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index bb959842703..0d1cebb8913 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -873,7 +873,11 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { } int effectiveMetaState = event.getMetaState() & ~bitsToClear; - if (shiftDown) effectiveMetaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON; + if (shiftDown) { + effectiveMetaState |= KeyEvent.META_SHIFT_ON; + // Virtual Shift keeps the existing left-Shift default; physical sides stay intact. + if (!event.isShiftPressed()) effectiveMetaState |= KeyEvent.META_SHIFT_LEFT_ON; + } if (mClient.readFnKey()) effectiveMetaState |= KeyEvent.META_FUNCTION_ON; int result = event.getUnicodeChar(effectiveMetaState); @@ -907,7 +911,7 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { if ((mEmulator.getKittyKeyboardFlags() & KeyHandler.KITTY_REPORT_ALTERNATE_KEYS) != 0 && oldCombiningAccent == 0 && unshiftedCodePoint > 0 && (event.getUnicodeChar(unshiftedMetaState) & KeyCharacterMap.COMBINING_ACCENT) == 0) { - if (shiftDown) shiftedCodePoint = event.getUnicodeChar(unshiftedMetaState | KeyEvent.META_SHIFT_ON); + if (shiftDown) shiftedCodePoint = event.getUnicodeChar(effectiveMetaState & ~KeyEvent.META_CAPS_LOCK_ON); if (event.getAction() == KeyEvent.ACTION_DOWN && (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) == 0) baseLayoutCodePoint = KeyHandler.getKittyBaseLayoutCodePoint(keyCode); diff --git a/tools/kitty-keyboard/README.md b/tools/kitty-keyboard/README.md index 33e31e09d45..8e990841b21 100644 --- a/tools/kitty-keyboard/README.md +++ b/tools/kitty-keyboard/README.md @@ -23,6 +23,19 @@ version, Gradle log, Debug/Release test XML, five versioned APKs, checksums and `validation.json`. This uses the default package variant; the official build CI also tests `apt-android-5` independently. A runner failure preserves its results; inspect `gradle.log` and the XML rather than treating old artifacts as a new pass. +Raw test directories are archived before their XML is parsed. Damaged or invalid +reports are listed in `validation.json` under `test_result_errors`; the remaining +valid reports are summarized and the runner returns failure. A nonzero Gradle +exit code is preserved even if some XML reports are unreadable. + +Exercise evidence-retention regressions without a Java toolchain or device: + +```sh +python3 -B tools/kitty-keyboard/test_validate.py +``` + +These checks use temporary fixture repositories and a simulated failing Gradle +command; they do not replace the actual app tests. Device acceptance remains a separate step. Record the installed APK version and hash, negotiation flags, expected and actual PTY bytes, event source and cleanup. diff --git a/tools/kitty-keyboard/test_validate.py b/tools/kitty-keyboard/test_validate.py new file mode 100644 index 00000000000..3a98d2d09c0 --- /dev/null +++ b/tools/kitty-keyboard/test_validate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Exercise failure-evidence retention without a Java toolchain or Android device.""" + +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SOURCE = Path(__file__).with_name("validate.py") +spec = importlib.util.spec_from_file_location("kitty_validation", SOURCE) +validation = importlib.util.module_from_spec(spec) +spec.loader.exec_module(validation) + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +class ValidationEvidenceTest(unittest.TestCase): + def test_damaged_xml_is_archived_and_other_modules_are_summarized(self): + with tempfile.TemporaryDirectory(prefix="kitty-validation-evidence-") as temporary: + root = Path(temporary) + repo, output = root / "repo", root / "output" + damaged = repo / "terminal-emulator/build/test-results/testDebugUnitTest/TEST-damaged.xml" + valid = repo / "app/build/test-results/testDebugUnitTest/TEST-valid.xml" + write(damaged, '') + summaries, errors = validation.archive_test_results(repo, output) + self.assertEqual((output / "test-results/terminal-emulator/testDebugUnitTest/TEST-damaged.xml").read_bytes(), + damaged.read_bytes()) + self.assertEqual((output / "test-results/app/testDebugUnitTest/TEST-valid.xml").read_bytes(), + valid.read_bytes()) + self.assertEqual(summaries["app:testDebugUnitTest"]["tests"], 3) + self.assertEqual(len(errors), 1) + self.assertIn("TEST-damaged.xml", errors[0]["path"]) + + def test_invalid_counts_preserve_raw_evidence_and_valid_suites(self): + with tempfile.TemporaryDirectory(prefix="kitty-validation-counts-") as temporary: + root = Path(temporary) + directory = root / "repo/app/build/test-results/testReleaseUnitTest" + write(directory / "TEST-invalid.xml", '') + write(directory / "TEST-negative.xml", '') + write(directory / "TEST-valid.xml", '') + summaries, errors = validation.archive_test_results(root / "repo", root / "output") + self.assertEqual(summaries["app:testReleaseUnitTest"]["tests"], 2) + self.assertEqual(summaries["app:testReleaseUnitTest"]["failures"], 1) + self.assertEqual(len(errors), 2) + self.assertEqual(len(list((root / "output/test-results/app/testReleaseUnitTest").glob("*.xml"))), 3) + + def test_cli_failure_still_writes_summary_and_archives_damaged_xml(self): + with tempfile.TemporaryDirectory(prefix="kitty-validation-cli-") as temporary: + root = Path(temporary) + repo, output = root / "repo", root / "output" + script = repo / "tools/kitty-keyboard/validate.py" + script.parent.mkdir(parents=True) + shutil.copy2(SOURCE, script) + write(repo / "app/build.gradle", ' versionName "0.118.0"\n') + gradle = repo / "gradlew" + write(gradle, """#!/bin/sh +mkdir -p terminal-emulator/build/test-results/testDebugUnitTest app/build/test-results/testDebugUnitTest +printf ' terminal-emulator/build/test-results/testDebugUnitTest/TEST-damaged.xml +printf '' > app/build/test-results/testDebugUnitTest/TEST-valid.xml +printf 'Simulated Gradle failure for evidence-retention regression\\n' +exit 19 +""") + gradle.chmod(0o755) + subprocess.run(["git", "init", "--quiet", str(repo)], check=True) + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run(["git", "-c", "user.name=Kitty Validation Tests", "-c", "user.email=kitty-tests@example.invalid", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", + "commit", "--quiet", "-m", "Fixture"], cwd=repo, check=True) + result = subprocess.run([sys.executable, "-B", str(script), "--offline", "--output-dir", str(output)], + cwd=repo, capture_output=True, text=True) + self.assertEqual(result.returncode, 19, result.stderr) + report = json.loads((output / "validation.json").read_text()) + self.assertEqual(report["gradle_exit_code"], 19) + self.assertEqual(len(report["test_result_errors"]), 1) + self.assertEqual(report["unit_tests"]["app:testDebugUnitTest"]["tests"], 3) + self.assertEqual(report["apks"], {}) + self.assertEqual((output / "test-results/terminal-emulator/testDebugUnitTest/TEST-damaged.xml").read_text(), + '