From 82fba43a20a68f13e54b87afdc3a1f9b36dc75a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 18:00:43 +0200 Subject: [PATCH 1/6] feat: six key layouts, lift-off typing, hover-open accented variants - Language boards inject their symbols as characters (char keys via VkKeyScanW/paste), so AZERTY/QWERTZ type true on any Windows input language; Dvorak/Colemak/ABC pair labels with US scancodes and layout-consistency tests enforce it per cell - Lift-Off Typing inserts the key under the pointer when the finger lifts; resting on a variant key ~1.2 s opens its accent row (key fills in as progress), click-hold still works at 1 s --- README.md | 6 +- windows/appsettings.py | 15 + windows/data/cfg/keyboard-layout-abc.yaml | 93 +++++ windows/data/cfg/keyboard-layout-azerty.yaml | 95 +++++ windows/data/cfg/keyboard-layout-colemak.yaml | 91 +++++ windows/data/cfg/keyboard-layout-dvorak.yaml | 91 +++++ windows/data/cfg/keyboard-layout-qwertz.yaml | 95 +++++ windows/tests/test_hover_diacritics.py | 311 ++++++++++++++++ windows/tests/test_layout_consistency.py | 229 ++++++++++++ windows/tests/test_layouts_liftoff.py | 341 ++++++++++++++++++ windows/tests/vkb/test_diacritics.py | 11 +- windows/tray/app.py | 43 +++ windows/tray/menu.py | 21 ++ windows/triton/controller.py | 25 ++ windows/triton/layouts.py | 89 +++++ windows/triton/pad.py | 222 +++++++++++- windows/triton/screen.py | 30 ++ windows/triton/state.py | 63 ++++ windows/triton/triton.py | 5 +- windows/triton/vkb.py | 36 ++ 20 files changed, 1892 insertions(+), 20 deletions(-) create mode 100644 windows/data/cfg/keyboard-layout-abc.yaml create mode 100644 windows/data/cfg/keyboard-layout-azerty.yaml create mode 100644 windows/data/cfg/keyboard-layout-colemak.yaml create mode 100644 windows/data/cfg/keyboard-layout-dvorak.yaml create mode 100644 windows/data/cfg/keyboard-layout-qwertz.yaml create mode 100644 windows/tests/test_hover_diacritics.py create mode 100644 windows/tests/test_layout_consistency.py create mode 100644 windows/tests/test_layouts_liftoff.py create mode 100644 windows/triton/layouts.py diff --git a/README.md b/README.md index ad0fbd4..22a5b66 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DualTouch +# DualTouch

DualTouch @@ -34,7 +34,7 @@ It is a Windows-only fork of [SteamlessKeyboard](https://github.com/PietPetGit/S - **Global overlay** — draws above every app, not tied to Big Picture or a launcher. - **Native SDL3 rendering** — low CPU/memory use; skins, transparency, and size selectable from the tray. -- **Steam Controller input** — two-finger trackpad typing with hit-target expansion and debounce, DPAD/stick navigation, auto-repeat on held keys, configurable insert control (bumpers or triggers). +- **Steam Controller input** — two-finger trackpad typing with hit-target expansion and debounce, DPAD/stick navigation, auto-repeat on held keys, configurable insert control (bumpers or triggers), Lift-Off Typing (insert on finger release), hover-to-open accented variants while Lift-Off Typing is on (rest a finger on a key for ~1.2 s — the key fills in, then slide to an accent), six selectable key layouts (language boards inject their symbols as characters, so they type true on any Windows input language). - **Steam Input coexistence** — reads the shared HID alongside Steam Input, switches to a keyboard layer via `steam://forceinputappid`, and restores your config on close. - **Opens with a chord** — Steam + X/Y/A/B (configurable) opens the keyboard; Steam's own menu is suppressed via a Guide Button Chord dead-binding. - **Per-app memory** — remembers the OSK size, skin, and position per foreground app. @@ -85,6 +85,8 @@ menus), or by hand-editing `settings.json` for finer control. | `sc_click_button` | `"L1/R1"` | Click insert per side: `"L1/R1"` (bumpers) or `"L2/R2"` (triggers) | | `sc_pad_click_enter` | `false` | Trackpad press-click inserts the key under the pointer | | `sc_pad_click_engage` | `2500` | Pad force that fires the pad-click insert | +| `sc_liftoff_enter` | `false` | Lift-Off Typing: insert the key under the pointer when the finger lifts off the pad (no click needed); also enables hover-to-open accented variants | +| `osk_layout` | `"QWERTY"` | Key layout: `"QWERTY"`/`"AZERTY"`/`"QWERTZ"`/`"Dvorak"`/`"Colemak"`/`"ABC"` (tray "Keyboard Layout"; applied at the next open) | | `sc_left_stick_nav` | `true` | Sticks control the keyboard while it is open | | `sc_osk_trigger_actuation` | `"default"` | L2/R2 actuation point: `"default"` or `"low"` | | `skin` | `"Gruvbox"` | Keyboard skin: Steam OSK themes load from the Steam install; Gruvbox is the bundled original | diff --git a/windows/appsettings.py b/windows/appsettings.py index 9f3d649..20903c2 100755 --- a/windows/appsettings.py +++ b/windows/appsettings.py @@ -90,6 +90,11 @@ # "L1/R1" = bumpers (the original behavior, default) / "L2/R2" = triggers. "sc_pad_click_enter": False, "sc_click_button": "L1/R1", + # Lift-off typing (tray "Steam Controller -> Lift-Off Typing"): insert the + # key under the pointer when the finger LIFTS off the trackpad — no pad + # press or click button needed. The click paths keep working alongside it; + # a touch that already clicked never double-fires on lift. + "sc_liftoff_enter": False, # Click-button focus (the lock-on-key that follows the click button): # when the click button is L2/R2, the trigger's analog pull (0..32767) at # which the pointer freezes on the key center (default = half pull); the @@ -107,6 +112,11 @@ # reach). Applied on the next OSK open (or live while open, like the # other SC toggles). "osk_split_layout": False, + # On-screen-keyboard key layout (tray "Keyboard Layout" radio): which + # bundled layout the OSK builds at the next open. "QWERTY" keeps the + # original keyboard-layout.yaml; the full name->file map lives in + # triton/layouts.py (AZERTY/QWERTZ/Dvorak/Colemak/ABC bundled). + "osk_layout": "QWERTY", # Per-foreground-app remembered OSK "Move" position: {exe name (lowercase): # position index 0-5}. Remembered per app so the keyboard reopens where the # user left it in each app; apps without an entry keep the current spot @@ -336,6 +346,11 @@ def _load_settings(): merged[k] = _coerce_number(val, default) elif k == "skin": merged[k] = _valid_skin_name(val) or default + elif k == "osk_layout": + # Same plain-identifier rule as the skin name: a layout name is + # matched against triton/layouts.py's registry and must never + # carry a path. (The tray re-validates against the registry.) + merged[k] = _valid_skin_name(val) or default elif k == "window_position_per_app": merged[k] = _valid_position_per_app(val) elif k == "osk_size_per_app": diff --git a/windows/data/cfg/keyboard-layout-abc.yaml b/windows/data/cfg/keyboard-layout-abc.yaml new file mode 100644 index 0000000..f555eb9 --- /dev/null +++ b/windows/data/cfg/keyboard-layout-abc.yaml @@ -0,0 +1,93 @@ +--- +# ABC 75% layout — same board shape as keyboard-layout.yaml with the 26 +# letters in strict alphabetical order, reading left-to-right, top-to-bottom +# (a-l on the top row, m-v on home, then w z and punctuation below). Built +# for hunt-and-peck on a touchpad: every letter sits where a first-time user +# will guess. Positional like every layout: scancodes sit at their US-QWERTY +# positions, so on a stock US Windows layout the labels always match what +# lands in the text field. +keys: +- - {label: "Esc", keycode: KEY_ESC, width_weight: 1.1, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "F1", keycode: KEY_F1, modifier: true, outline_opacity: 0.33} + - {label: "F2", keycode: KEY_F2, modifier: true, outline_opacity: 0.33} + - {label: "F3", keycode: KEY_F3, modifier: true, outline_opacity: 0.33} + - {label: "F4", keycode: KEY_F4, modifier: true, outline_opacity: 0.33} + - {label: "F5", keycode: KEY_F5, modifier: true, outline_opacity: 0.33} + - {label: "F6", keycode: KEY_F6, modifier: true, outline_opacity: 0.33} + - {label: "F7", keycode: KEY_F7, modifier: true, outline_opacity: 0.33} + - {label: "F8", keycode: KEY_F8, modifier: true, outline_opacity: 0.33} + - {label: "F9", keycode: KEY_F9, modifier: true, outline_opacity: 0.33} + - {label: "F10", keycode: KEY_F10, modifier: true, outline_opacity: 0.33} + - {label: "F11", keycode: KEY_F11, modifier: true, outline_opacity: 0.33} + - {label: "F12", keycode: KEY_F12, modifier: true, outline_opacity: 0.33} + - {label: "Home", keycode: KEY_HOME, modifier: true, outline_opacity: 0.33} + - {label: "End", keycode: KEY_END, modifier: true, outline_opacity: 0.33} + - {label: "Ins", keycode: KEY_INSERT, modifier: true, outline_opacity: 0.33} + +- - {label: "`", shifted: "~", keycode: KEY_GRAVE} + - {label: "1", shifted: "!", keycode: KEY_1} + - {label: "2", shifted: "@", keycode: KEY_2} + - {label: "3", shifted: "#", keycode: KEY_3} + - {label: "4", shifted: "$", keycode: KEY_4} + - {label: "5", shifted: "%", keycode: KEY_5} + - {label: "6", shifted: "^", keycode: KEY_6} + - {label: "7", shifted: "&", keycode: KEY_7} + - {label: "8", shifted: "*", keycode: KEY_8} + - {label: "9", shifted: "(", keycode: KEY_9} + - {label: "0", shifted: ")", keycode: KEY_0} + - {label: "-", shifted: "_", keycode: KEY_MINUS} + - {label: "=", shifted: "+", keycode: KEY_EQUAL} + - {label: "Backspace", behavior: backspace, keycode: KEY_BACKSPACE, width_weight: 1.4, modifier: true, glyph: glyph_x.png} + +- - {label: "Tab", keycode: KEY_TAB, width_weight: 1.4, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "a", shifted: "A", keycode: KEY_A} + - {label: "b", shifted: "B", keycode: KEY_B} + - {label: "c", shifted: "C", keycode: KEY_C} + - {label: "d", shifted: "D", keycode: KEY_D} + - {label: "e", shifted: "E", keycode: KEY_E} + - {label: "f", shifted: "F", keycode: KEY_F} + - {label: "g", shifted: "G", keycode: KEY_G} + - {label: "h", shifted: "H", keycode: KEY_H} + - {label: "i", shifted: "I", keycode: KEY_I} + - {label: "j", shifted: "J", keycode: KEY_J} + - {label: "k", shifted: "K", keycode: KEY_K} + - {label: "l", shifted: "L", keycode: KEY_L} + - {label: "\\", shifted: "|", keycode: KEY_BACKSLASH, outline_opacity: 0.33} + +- - {label: "Caps", keycode: KEY_CAPSLOCK, width_weight: 1.6, modifier: true, glyph: glyph_l3.png, outline_opacity: 0.33} + - {label: "m", shifted: "M", keycode: KEY_M} + - {label: "n", shifted: "N", keycode: KEY_N} + - {label: "o", shifted: "O", keycode: KEY_O} + - {label: "p", shifted: "P", keycode: KEY_P} + - {label: "q", shifted: "Q", keycode: KEY_Q} + - {label: "r", shifted: "R", keycode: KEY_R} + - {label: "s", shifted: "S", keycode: KEY_S} + - {label: "t", shifted: "T", keycode: KEY_T} + - {label: "u", shifted: "U", keycode: KEY_U} + - {label: "v", shifted: "V", keycode: KEY_V} + - {label: "'", shifted: "\"", keycode: KEY_APOSTROPHE, legacy_label_pos: true} + - {label: "Enter", keycode: KEY_ENTER, width_weight: 1.7, modifier: true, align: right, valign: bottom, glyph: sc_r2_md.png, outline_opacity: 0.33} + +- - {label: "Shift", behavior: shift, keycode: KEY_LEFTSHIFT, width_weight: 2.0, modifier: true, align: left, valign: bottom, glyph: glyph_l2.png, outline_opacity: 0.33} + - {label: "w", shifted: "W", keycode: KEY_W} + - {label: "x", shifted: "X", keycode: KEY_X} + - {label: "y", shifted: "Y", keycode: KEY_Y} + - {label: "z", shifted: "Z", keycode: KEY_Z} + - {label: ",", shifted: "<", keycode: KEY_COMMA, dpad_down: 3, legacy_label_pos: true} + - {label: ".", shifted: ">", keycode: KEY_DOT, legacy_label_pos: true} + - {label: "/", shifted: "?", keycode: KEY_SLASH, legacy_label_pos: true} + - {label: ";", shifted: ":", keycode: KEY_SEMICOLON, legacy_label_pos: true} + - {label: "[", shifted: "{", keycode: KEY_LEFTBRACE, outline_opacity: 0.33} + - {label: "]", shifted: "}", keycode: KEY_RIGHTBRACE, outline_opacity: 0.33} + - {label: "Select", behavior: select, keycode: KEY_SELECT, width_weight: 2.0, modifier: true, align: right, valign: bottom, outline_opacity: 0.33} + +- - {label: "Ctrl", behavior: ctrl, keycode: KEY_LEFTCTRL, width_weight: 1.2, modifier: true, align: left, valign: bottom} + - {label: "Alt", behavior: alt, keycode: KEY_LEFTALT, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Win", keycode: KEY_LEFTWIN, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Space", keycode: KEY_SPACE, width_weight: 4.5, glyph: glyph_y.png} + - {label: "◀", keycode: KEY_LEFT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▲", keycode: KEY_UP, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▼", keycode: KEY_DOWN, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▶", keycode: KEY_RIGHT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "Paste", shifted: "Copy", behavior: paste, width_weight: 1.2, modifier: true, dual_top_dy: 4, dual_bottom_dy: 4, outline_opacity: 0.33} + - {label: "Move", behavior: move, width_weight: 1.4, modifier: true, valign: top, glyph: glyph_smiley.png} diff --git a/windows/data/cfg/keyboard-layout-azerty.yaml b/windows/data/cfg/keyboard-layout-azerty.yaml new file mode 100644 index 0000000..f1b1243 --- /dev/null +++ b/windows/data/cfg/keyboard-layout-azerty.yaml @@ -0,0 +1,95 @@ +--- +# AZERTY 75% layout (French) — same board shape as keyboard-layout.yaml +# (F-row, number row, three letter rows, bottom Ctrl/Alt/Win/Space/arrows/ +# Paste/Move row), with the French keycaps placed at their AZERTY positions. +# +# Letters ride their own scancodes (label == what the code types under any +# Latin Windows layout). Keys whose printed symbol is NOT universal carry +# `char: true`: dispatch injects the printed label as a character (active- +# layout chord, Unicode fallback) instead of tapping the raw scancode, so +# the board types what it prints on EVERY Windows input language. +keys: +- - {label: "Esc", keycode: KEY_ESC, width_weight: 1.1, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "F1", keycode: KEY_F1, modifier: true, outline_opacity: 0.33} + - {label: "F2", keycode: KEY_F2, modifier: true, outline_opacity: 0.33} + - {label: "F3", keycode: KEY_F3, modifier: true, outline_opacity: 0.33} + - {label: "F4", keycode: KEY_F4, modifier: true, outline_opacity: 0.33} + - {label: "F5", keycode: KEY_F5, modifier: true, outline_opacity: 0.33} + - {label: "F6", keycode: KEY_F6, modifier: true, outline_opacity: 0.33} + - {label: "F7", keycode: KEY_F7, modifier: true, outline_opacity: 0.33} + - {label: "F8", keycode: KEY_F8, modifier: true, outline_opacity: 0.33} + - {label: "F9", keycode: KEY_F9, modifier: true, outline_opacity: 0.33} + - {label: "F10", keycode: KEY_F10, modifier: true, outline_opacity: 0.33} + - {label: "F11", keycode: KEY_F11, modifier: true, outline_opacity: 0.33} + - {label: "F12", keycode: KEY_F12, modifier: true, outline_opacity: 0.33} + - {label: "Home", keycode: KEY_HOME, modifier: true, outline_opacity: 0.33} + - {label: "End", keycode: KEY_END, modifier: true, outline_opacity: 0.33} + - {label: "Ins", keycode: KEY_INSERT, modifier: true, outline_opacity: 0.33} + +- - {label: "²", char: true, keycode: KEY_GRAVE} + - {label: "&", shifted: "1", char: true, keycode: KEY_1} + - {label: "é", shifted: "2", char: true, keycode: KEY_2} + - {label: "\"", shifted: "3", char: true, keycode: KEY_3, legacy_label_pos: true} + - {label: "'", shifted: "4", char: true, keycode: KEY_4} + - {label: "(", shifted: "5", char: true, keycode: KEY_5} + - {label: "-", shifted: "6", char: true, keycode: KEY_6} + - {label: "è", shifted: "7", char: true, keycode: KEY_7} + - {label: "_", shifted: "8", char: true, keycode: KEY_8} + - {label: "ç", shifted: "9", char: true, keycode: KEY_9} + - {label: "à", shifted: "0", char: true, keycode: KEY_0} + - {label: ")", shifted: "°", char: true, keycode: KEY_MINUS} + - {label: "=", shifted: "+", keycode: KEY_EQUAL} + - {label: "Backspace", behavior: backspace, keycode: KEY_BACKSPACE, width_weight: 1.4, modifier: true, glyph: glyph_x.png} + +- - {label: "Tab", keycode: KEY_TAB, width_weight: 1.4, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "a", shifted: "A", keycode: KEY_A} + - {label: "z", shifted: "Z", keycode: KEY_Z} + - {label: "e", shifted: "E", keycode: KEY_E} + - {label: "r", shifted: "R", keycode: KEY_R} + - {label: "t", shifted: "T", keycode: KEY_T} + - {label: "y", shifted: "Y", keycode: KEY_Y} + - {label: "u", shifted: "U", keycode: KEY_U} + - {label: "i", shifted: "I", keycode: KEY_I} + - {label: "o", shifted: "O", keycode: KEY_O} + - {label: "p", shifted: "P", keycode: KEY_P} + - {label: "^", shifted: "¨", char: true, keycode: KEY_LEFTBRACE, legacy_label_pos: true} + - {label: "$", shifted: "£", char: true, keycode: KEY_RIGHTBRACE} + - {label: "*", shifted: "µ", char: true, keycode: KEY_BACKSLASH} + +- - {label: "Caps", keycode: KEY_CAPSLOCK, width_weight: 1.6, modifier: true, glyph: glyph_l3.png, outline_opacity: 0.33} + - {label: "q", shifted: "Q", keycode: KEY_Q} + - {label: "s", shifted: "S", keycode: KEY_S} + - {label: "d", shifted: "D", keycode: KEY_D} + - {label: "f", shifted: "F", keycode: KEY_F} + - {label: "g", shifted: "G", keycode: KEY_G} + - {label: "h", shifted: "H", keycode: KEY_H} + - {label: "j", shifted: "J", keycode: KEY_J} + - {label: "k", shifted: "K", keycode: KEY_K} + - {label: "l", shifted: "L", keycode: KEY_L} + - {label: "m", shifted: "M", keycode: KEY_M} + - {label: "ù", shifted: "%", char: true, keycode: KEY_APOSTROPHE} + - {label: "Enter", keycode: KEY_ENTER, width_weight: 1.7, modifier: true, align: right, valign: bottom, glyph: sc_r2_md.png, outline_opacity: 0.33} + +- - {label: "Shift", behavior: shift, keycode: KEY_LEFTSHIFT, width_weight: 2.0, modifier: true, align: left, valign: bottom, glyph: glyph_l2.png, outline_opacity: 0.33} + - {label: "w", shifted: "W", keycode: KEY_W} + - {label: "x", shifted: "X", keycode: KEY_X} + - {label: "c", shifted: "C", keycode: KEY_C} + - {label: "v", shifted: "V", keycode: KEY_V} + - {label: "b", shifted: "B", keycode: KEY_B} + - {label: "n", shifted: "N", keycode: KEY_N} + - {label: ",", shifted: "?", char: true, keycode: KEY_COMMA, dpad_down: 3, legacy_label_pos: true} + - {label: ";", shifted: ".", char: true, keycode: KEY_DOT, legacy_label_pos: true} + - {label: ":", shifted: "/", char: true, keycode: KEY_SLASH, legacy_label_pos: true} + - {label: "!", shifted: "§", char: true, keycode: KEY_SLASH} + - {label: "Select", behavior: select, keycode: KEY_SELECT, width_weight: 2.0, modifier: true, align: right, valign: bottom, outline_opacity: 0.33} + +- - {label: "Ctrl", behavior: ctrl, keycode: KEY_LEFTCTRL, width_weight: 1.2, modifier: true, align: left, valign: bottom} + - {label: "Alt", behavior: alt, keycode: KEY_LEFTALT, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Win", keycode: KEY_LEFTWIN, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Space", keycode: KEY_SPACE, width_weight: 4.5, glyph: glyph_y.png} + - {label: "◀", keycode: KEY_LEFT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▲", keycode: KEY_UP, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▼", keycode: KEY_DOWN, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▶", keycode: KEY_RIGHT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "Paste", shifted: "Copy", behavior: paste, width_weight: 1.2, modifier: true, dual_top_dy: 4, dual_bottom_dy: 4, outline_opacity: 0.33} + - {label: "Move", behavior: move, width_weight: 1.4, modifier: true, valign: top, glyph: glyph_smiley.png} diff --git a/windows/data/cfg/keyboard-layout-colemak.yaml b/windows/data/cfg/keyboard-layout-colemak.yaml new file mode 100644 index 0000000..5671b9f --- /dev/null +++ b/windows/data/cfg/keyboard-layout-colemak.yaml @@ -0,0 +1,91 @@ +--- +# Colemak 75% layout — same board shape as keyboard-layout.yaml with the +# Colemak arrangement. Every key pairs its printed label with the +# scancode that produces it on a stock US Windows layout, so the labels +# always match what lands in the text field; only the visual arrangement +# differs from QWERTY. +keys: +- - {label: "Esc", keycode: KEY_ESC, width_weight: 1.1, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "F1", keycode: KEY_F1, modifier: true, outline_opacity: 0.33} + - {label: "F2", keycode: KEY_F2, modifier: true, outline_opacity: 0.33} + - {label: "F3", keycode: KEY_F3, modifier: true, outline_opacity: 0.33} + - {label: "F4", keycode: KEY_F4, modifier: true, outline_opacity: 0.33} + - {label: "F5", keycode: KEY_F5, modifier: true, outline_opacity: 0.33} + - {label: "F6", keycode: KEY_F6, modifier: true, outline_opacity: 0.33} + - {label: "F7", keycode: KEY_F7, modifier: true, outline_opacity: 0.33} + - {label: "F8", keycode: KEY_F8, modifier: true, outline_opacity: 0.33} + - {label: "F9", keycode: KEY_F9, modifier: true, outline_opacity: 0.33} + - {label: "F10", keycode: KEY_F10, modifier: true, outline_opacity: 0.33} + - {label: "F11", keycode: KEY_F11, modifier: true, outline_opacity: 0.33} + - {label: "F12", keycode: KEY_F12, modifier: true, outline_opacity: 0.33} + - {label: "Home", keycode: KEY_HOME, modifier: true, outline_opacity: 0.33} + - {label: "End", keycode: KEY_END, modifier: true, outline_opacity: 0.33} + - {label: "Ins", keycode: KEY_INSERT, modifier: true, outline_opacity: 0.33} + +- - {label: "`", shifted: "~", keycode: KEY_GRAVE} + - {label: "1", shifted: "!", keycode: KEY_1} + - {label: "2", shifted: "@", keycode: KEY_2} + - {label: "3", shifted: "#", keycode: KEY_3} + - {label: "4", shifted: "$", keycode: KEY_4} + - {label: "5", shifted: "%", keycode: KEY_5} + - {label: "6", shifted: "^", keycode: KEY_6} + - {label: "7", shifted: "&", keycode: KEY_7} + - {label: "8", shifted: "*", keycode: KEY_8} + - {label: "9", shifted: "(", keycode: KEY_9} + - {label: "0", shifted: ")", keycode: KEY_0} + - {label: "-", shifted: "_", keycode: KEY_MINUS} + - {label: "=", shifted: "+", keycode: KEY_EQUAL} + - {label: "Backspace", behavior: backspace, keycode: KEY_BACKSPACE, width_weight: 1.4, modifier: true, glyph: glyph_x.png} + +- - {label: "Tab", keycode: KEY_TAB, width_weight: 1.4, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "q", shifted: "Q", keycode: KEY_Q} + - {label: "w", shifted: "W", keycode: KEY_W} + - {label: "f", shifted: "F", keycode: KEY_F} + - {label: "p", shifted: "P", keycode: KEY_P} + - {label: "g", shifted: "G", keycode: KEY_G} + - {label: "j", shifted: "J", keycode: KEY_J} + - {label: "l", shifted: "L", keycode: KEY_L} + - {label: "u", shifted: "U", keycode: KEY_U} + - {label: "y", shifted: "Y", keycode: KEY_Y} + - {label: ";", shifted: ":", keycode: KEY_SEMICOLON, legacy_label_pos: true} + - {label: "[", shifted: "{", keycode: KEY_LEFTBRACE, outline_opacity: 0.33} + - {label: "]", shifted: "}", keycode: KEY_RIGHTBRACE, outline_opacity: 0.33} + - {label: "\\", shifted: "|", keycode: KEY_BACKSLASH, outline_opacity: 0.33} + +- - {label: "Caps", keycode: KEY_CAPSLOCK, width_weight: 1.6, modifier: true, glyph: glyph_l3.png, outline_opacity: 0.33} + - {label: "a", shifted: "A", keycode: KEY_A} + - {label: "r", shifted: "R", keycode: KEY_R} + - {label: "s", shifted: "S", keycode: KEY_S} + - {label: "t", shifted: "T", keycode: KEY_T} + - {label: "d", shifted: "D", keycode: KEY_D} + - {label: "h", shifted: "H", keycode: KEY_H} + - {label: "n", shifted: "N", keycode: KEY_N} + - {label: "e", shifted: "E", keycode: KEY_E} + - {label: "i", shifted: "I", keycode: KEY_I} + - {label: "o", shifted: "O", keycode: KEY_O} + - {label: "'", shifted: "\"", keycode: KEY_APOSTROPHE, legacy_label_pos: true} + - {label: "Enter", keycode: KEY_ENTER, width_weight: 1.7, modifier: true, align: right, valign: bottom, glyph: sc_r2_md.png, outline_opacity: 0.33} + +- - {label: "Shift", behavior: shift, keycode: KEY_LEFTSHIFT, width_weight: 2.0, modifier: true, align: left, valign: bottom, glyph: glyph_l2.png, outline_opacity: 0.33} + - {label: "z", shifted: "Z", keycode: KEY_Z} + - {label: "x", shifted: "X", keycode: KEY_X} + - {label: "c", shifted: "C", keycode: KEY_C} + - {label: "v", shifted: "V", keycode: KEY_V} + - {label: "b", shifted: "B", keycode: KEY_B} + - {label: "k", shifted: "K", keycode: KEY_K} + - {label: "m", shifted: "M", keycode: KEY_M} + - {label: ",", shifted: "<", keycode: KEY_COMMA, dpad_down: 3, legacy_label_pos: true} + - {label: ".", shifted: ">", keycode: KEY_DOT, legacy_label_pos: true} + - {label: "/", shifted: "?", keycode: KEY_SLASH, legacy_label_pos: true, outline_opacity: 0.33} + - {label: "Select", behavior: select, keycode: KEY_SELECT, width_weight: 2.0, modifier: true, align: right, valign: bottom, outline_opacity: 0.33} + +- - {label: "Ctrl", behavior: ctrl, keycode: KEY_LEFTCTRL, width_weight: 1.2, modifier: true, align: left, valign: bottom} + - {label: "Alt", behavior: alt, keycode: KEY_LEFTALT, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Win", keycode: KEY_LEFTWIN, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Space", keycode: KEY_SPACE, width_weight: 4.5, glyph: glyph_y.png} + - {label: "◀", keycode: KEY_LEFT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▲", keycode: KEY_UP, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▼", keycode: KEY_DOWN, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▶", keycode: KEY_RIGHT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "Paste", shifted: "Copy", behavior: paste, width_weight: 1.2, modifier: true, dual_top_dy: 4, dual_bottom_dy: 4, outline_opacity: 0.33} + - {label: "Move", behavior: move, width_weight: 1.4, modifier: true, valign: top, glyph: glyph_smiley.png} diff --git a/windows/data/cfg/keyboard-layout-dvorak.yaml b/windows/data/cfg/keyboard-layout-dvorak.yaml new file mode 100644 index 0000000..5db8819 --- /dev/null +++ b/windows/data/cfg/keyboard-layout-dvorak.yaml @@ -0,0 +1,91 @@ +--- +# Dvorak 75% layout — same board shape as keyboard-layout.yaml with the +# Dvorak Simplified Keyboard arrangement. Every key pairs its printed +# label with the scancode that produces it on a stock US Windows layout, +# so the labels always match what lands in the text field; only the +# visual arrangement differs from QWERTY. +keys: +- - {label: "Esc", keycode: KEY_ESC, width_weight: 1.1, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "F1", keycode: KEY_F1, modifier: true, outline_opacity: 0.33} + - {label: "F2", keycode: KEY_F2, modifier: true, outline_opacity: 0.33} + - {label: "F3", keycode: KEY_F3, modifier: true, outline_opacity: 0.33} + - {label: "F4", keycode: KEY_F4, modifier: true, outline_opacity: 0.33} + - {label: "F5", keycode: KEY_F5, modifier: true, outline_opacity: 0.33} + - {label: "F6", keycode: KEY_F6, modifier: true, outline_opacity: 0.33} + - {label: "F7", keycode: KEY_F7, modifier: true, outline_opacity: 0.33} + - {label: "F8", keycode: KEY_F8, modifier: true, outline_opacity: 0.33} + - {label: "F9", keycode: KEY_F9, modifier: true, outline_opacity: 0.33} + - {label: "F10", keycode: KEY_F10, modifier: true, outline_opacity: 0.33} + - {label: "F11", keycode: KEY_F11, modifier: true, outline_opacity: 0.33} + - {label: "F12", keycode: KEY_F12, modifier: true, outline_opacity: 0.33} + - {label: "Home", keycode: KEY_HOME, modifier: true, outline_opacity: 0.33} + - {label: "End", keycode: KEY_END, modifier: true, outline_opacity: 0.33} + - {label: "Ins", keycode: KEY_INSERT, modifier: true, outline_opacity: 0.33} + +- - {label: "`", shifted: "~", keycode: KEY_GRAVE} + - {label: "1", shifted: "!", keycode: KEY_1} + - {label: "2", shifted: "@", keycode: KEY_2} + - {label: "3", shifted: "#", keycode: KEY_3} + - {label: "4", shifted: "$", keycode: KEY_4} + - {label: "5", shifted: "%", keycode: KEY_5} + - {label: "6", shifted: "^", keycode: KEY_6} + - {label: "7", shifted: "&", keycode: KEY_7} + - {label: "8", shifted: "*", keycode: KEY_8} + - {label: "9", shifted: "(", keycode: KEY_9} + - {label: "0", shifted: ")", keycode: KEY_0} + - {label: "[", shifted: "{", keycode: KEY_LEFTBRACE} + - {label: "]", shifted: "}", keycode: KEY_RIGHTBRACE} + - {label: "Backspace", behavior: backspace, keycode: KEY_BACKSPACE, width_weight: 1.4, modifier: true, glyph: glyph_x.png} + +- - {label: "Tab", keycode: KEY_TAB, width_weight: 1.4, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "'", shifted: "\"", keycode: KEY_APOSTROPHE, legacy_label_pos: true} + - {label: ",", shifted: "<", keycode: KEY_COMMA, dpad_down: 3, legacy_label_pos: true} + - {label: ".", shifted: ">", keycode: KEY_DOT, legacy_label_pos: true} + - {label: "p", shifted: "P", keycode: KEY_P} + - {label: "y", shifted: "Y", keycode: KEY_Y} + - {label: "f", shifted: "F", keycode: KEY_F} + - {label: "g", shifted: "G", keycode: KEY_G} + - {label: "c", shifted: "C", keycode: KEY_C} + - {label: "r", shifted: "R", keycode: KEY_R} + - {label: "l", shifted: "L", keycode: KEY_L} + - {label: "/", shifted: "?", keycode: KEY_SLASH, outline_opacity: 0.33} + - {label: "=", shifted: "+", keycode: KEY_EQUAL, outline_opacity: 0.33} + - {label: "\\", shifted: "|", keycode: KEY_BACKSLASH, outline_opacity: 0.33} + +- - {label: "Caps", keycode: KEY_CAPSLOCK, width_weight: 1.6, modifier: true, glyph: glyph_l3.png, outline_opacity: 0.33} + - {label: "a", shifted: "A", keycode: KEY_A} + - {label: "o", shifted: "O", keycode: KEY_O} + - {label: "e", shifted: "E", keycode: KEY_E} + - {label: "u", shifted: "U", keycode: KEY_U} + - {label: "i", shifted: "I", keycode: KEY_I} + - {label: "d", shifted: "D", keycode: KEY_D} + - {label: "h", shifted: "H", keycode: KEY_H} + - {label: "t", shifted: "T", keycode: KEY_T} + - {label: "n", shifted: "N", keycode: KEY_N} + - {label: "s", shifted: "S", keycode: KEY_S} + - {label: "-", shifted: "_", keycode: KEY_MINUS, legacy_label_pos: true} + - {label: "Enter", keycode: KEY_ENTER, width_weight: 1.7, modifier: true, align: right, valign: bottom, glyph: sc_r2_md.png, outline_opacity: 0.33} + +- - {label: "Shift", behavior: shift, keycode: KEY_LEFTSHIFT, width_weight: 2.0, modifier: true, align: left, valign: bottom, glyph: glyph_l2.png, outline_opacity: 0.33} + - {label: ";", shifted: ":", keycode: KEY_SEMICOLON} + - {label: "q", shifted: "Q", keycode: KEY_Q} + - {label: "j", shifted: "J", keycode: KEY_J} + - {label: "k", shifted: "K", keycode: KEY_K} + - {label: "x", shifted: "X", keycode: KEY_X} + - {label: "b", shifted: "B", keycode: KEY_B} + - {label: "m", shifted: "M", keycode: KEY_M} + - {label: "w", shifted: "W", keycode: KEY_W} + - {label: "v", shifted: "V", keycode: KEY_V} + - {label: "z", shifted: "Z", keycode: KEY_Z} + - {label: "Select", behavior: select, keycode: KEY_SELECT, width_weight: 2.0, modifier: true, align: right, valign: bottom, outline_opacity: 0.33} + +- - {label: "Ctrl", behavior: ctrl, keycode: KEY_LEFTCTRL, width_weight: 1.2, modifier: true, align: left, valign: bottom} + - {label: "Alt", behavior: alt, keycode: KEY_LEFTALT, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Win", keycode: KEY_LEFTWIN, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Space", keycode: KEY_SPACE, width_weight: 4.5, glyph: glyph_y.png} + - {label: "◀", keycode: KEY_LEFT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▲", keycode: KEY_UP, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▼", keycode: KEY_DOWN, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▶", keycode: KEY_RIGHT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "Paste", shifted: "Copy", behavior: paste, width_weight: 1.2, modifier: true, dual_top_dy: 4, dual_bottom_dy: 4, outline_opacity: 0.33} + - {label: "Move", behavior: move, width_weight: 1.4, modifier: true, valign: top, glyph: glyph_smiley.png} diff --git a/windows/data/cfg/keyboard-layout-qwertz.yaml b/windows/data/cfg/keyboard-layout-qwertz.yaml new file mode 100644 index 0000000..1b33168 --- /dev/null +++ b/windows/data/cfg/keyboard-layout-qwertz.yaml @@ -0,0 +1,95 @@ +--- +# QWERTZ 75% layout (German) — same board shape as keyboard-layout.yaml, +# with the German keycaps placed at their QWERTZ positions (Y/Z swapped, +# umlauts on the bracket/semicolon/apostrophe slots, ß and ´ right of 0). +# +# Letters ride their own scancodes (label == what the code types under any +# Latin Windows layout). Keys whose printed symbol is NOT universal carry +# `char: true`: dispatch injects the printed label as a character (active- +# layout chord, Unicode fallback) instead of tapping the raw scancode, so +# the board types what it prints on EVERY Windows input language. +keys: +- - {label: "Esc", keycode: KEY_ESC, width_weight: 1.1, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "F1", keycode: KEY_F1, modifier: true, outline_opacity: 0.33} + - {label: "F2", keycode: KEY_F2, modifier: true, outline_opacity: 0.33} + - {label: "F3", keycode: KEY_F3, modifier: true, outline_opacity: 0.33} + - {label: "F4", keycode: KEY_F4, modifier: true, outline_opacity: 0.33} + - {label: "F5", keycode: KEY_F5, modifier: true, outline_opacity: 0.33} + - {label: "F6", keycode: KEY_F6, modifier: true, outline_opacity: 0.33} + - {label: "F7", keycode: KEY_F7, modifier: true, outline_opacity: 0.33} + - {label: "F8", keycode: KEY_F8, modifier: true, outline_opacity: 0.33} + - {label: "F9", keycode: KEY_F9, modifier: true, outline_opacity: 0.33} + - {label: "F10", keycode: KEY_F10, modifier: true, outline_opacity: 0.33} + - {label: "F11", keycode: KEY_F11, modifier: true, outline_opacity: 0.33} + - {label: "F12", keycode: KEY_F12, modifier: true, outline_opacity: 0.33} + - {label: "Home", keycode: KEY_HOME, modifier: true, outline_opacity: 0.33} + - {label: "End", keycode: KEY_END, modifier: true, outline_opacity: 0.33} + - {label: "Ins", keycode: KEY_INSERT, modifier: true, outline_opacity: 0.33} + +- - {label: "^", shifted: "°", char: true, keycode: KEY_GRAVE, legacy_label_pos: true} + - {label: "1", shifted: "!", keycode: KEY_1} + - {label: "2", shifted: "\"", char: true, keycode: KEY_2} + - {label: "3", shifted: "§", char: true, keycode: KEY_3} + - {label: "4", shifted: "$", keycode: KEY_4} + - {label: "5", shifted: "%", keycode: KEY_5} + - {label: "6", shifted: "&", char: true, keycode: KEY_6} + - {label: "7", shifted: "/", char: true, keycode: KEY_7} + - {label: "8", shifted: "(", char: true, keycode: KEY_8} + - {label: "9", shifted: ")", char: true, keycode: KEY_9} + - {label: "0", shifted: "=", char: true, keycode: KEY_0} + - {label: "ß", shifted: "?", char: true, keycode: KEY_MINUS} + - {label: "´", shifted: "`", char: true, keycode: KEY_EQUAL, legacy_label_pos: true} + - {label: "Backspace", behavior: backspace, keycode: KEY_BACKSPACE, width_weight: 1.4, modifier: true, glyph: glyph_x.png} + +- - {label: "Tab", keycode: KEY_TAB, width_weight: 1.4, modifier: true, align: left, valign: bottom, outline_opacity: 0.33} + - {label: "q", shifted: "Q", keycode: KEY_Q} + - {label: "w", shifted: "W", keycode: KEY_W} + - {label: "e", shifted: "E", keycode: KEY_E} + - {label: "r", shifted: "R", keycode: KEY_R} + - {label: "t", shifted: "T", keycode: KEY_T} + - {label: "z", shifted: "Z", keycode: KEY_Z} + - {label: "u", shifted: "U", keycode: KEY_U} + - {label: "i", shifted: "I", keycode: KEY_I} + - {label: "o", shifted: "O", keycode: KEY_O} + - {label: "p", shifted: "P", keycode: KEY_P} + - {label: "ü", shifted: "Ü", char: true, keycode: KEY_LEFTBRACE, legacy_label_pos: true} + - {label: "+", shifted: "*", char: true, keycode: KEY_RIGHTBRACE} + - {label: "#", shifted: "'", char: true, keycode: KEY_BACKSLASH} + +- - {label: "Caps", keycode: KEY_CAPSLOCK, width_weight: 1.6, modifier: true, glyph: glyph_l3.png, outline_opacity: 0.33} + - {label: "a", shifted: "A", keycode: KEY_A} + - {label: "s", shifted: "S", keycode: KEY_S} + - {label: "d", shifted: "D", keycode: KEY_D} + - {label: "f", shifted: "F", keycode: KEY_F} + - {label: "g", shifted: "G", keycode: KEY_G} + - {label: "h", shifted: "H", keycode: KEY_H} + - {label: "j", shifted: "J", keycode: KEY_J} + - {label: "k", shifted: "K", keycode: KEY_K} + - {label: "l", shifted: "L", keycode: KEY_L} + - {label: "ö", shifted: "Ö", char: true, keycode: KEY_SEMICOLON, legacy_label_pos: true} + - {label: "ä", shifted: "Ä", char: true, keycode: KEY_APOSTROPHE, legacy_label_pos: true} + - {label: "Enter", keycode: KEY_ENTER, width_weight: 1.7, modifier: true, align: right, valign: bottom, glyph: sc_r2_md.png, outline_opacity: 0.33} + +- - {label: "Shift", behavior: shift, keycode: KEY_LEFTSHIFT, width_weight: 2.0, modifier: true, align: left, valign: bottom, glyph: glyph_l2.png, outline_opacity: 0.33} + - {label: "y", shifted: "Y", keycode: KEY_Y} + - {label: "x", shifted: "X", keycode: KEY_X} + - {label: "c", shifted: "C", keycode: KEY_C} + - {label: "v", shifted: "V", keycode: KEY_V} + - {label: "b", shifted: "B", keycode: KEY_B} + - {label: "n", shifted: "N", keycode: KEY_N} + - {label: "m", shifted: "M", keycode: KEY_M} + - {label: ",", shifted: ";", char: true, keycode: KEY_COMMA, dpad_down: 3, legacy_label_pos: true} + - {label: ".", shifted: ":", char: true, keycode: KEY_DOT, legacy_label_pos: true} + - {label: "-", shifted: "_", char: true, keycode: KEY_SLASH, legacy_label_pos: true, outline_opacity: 0.33} + - {label: "Select", behavior: select, keycode: KEY_SELECT, width_weight: 2.0, modifier: true, align: right, valign: bottom, outline_opacity: 0.33} + +- - {label: "Ctrl", behavior: ctrl, keycode: KEY_LEFTCTRL, width_weight: 1.2, modifier: true, align: left, valign: bottom} + - {label: "Alt", behavior: alt, keycode: KEY_LEFTALT, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Win", keycode: KEY_LEFTWIN, width_weight: 1.1, modifier: true, align: left, valign: bottom} + - {label: "Space", keycode: KEY_SPACE, width_weight: 4.5, glyph: glyph_y.png} + - {label: "◀", keycode: KEY_LEFT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▲", keycode: KEY_UP, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▼", keycode: KEY_DOWN, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "▶", keycode: KEY_RIGHT, width_weight: 0.9, modifier: true, font: symbol, legacy_label_pos: true} + - {label: "Paste", shifted: "Copy", behavior: paste, width_weight: 1.2, modifier: true, dual_top_dy: 4, dual_bottom_dy: 4, outline_opacity: 0.33} + - {label: "Move", behavior: move, width_weight: 1.4, modifier: true, valign: top, glyph: glyph_smiley.png} diff --git a/windows/tests/test_hover_diacritics.py b/windows/tests/test_hover_diacritics.py new file mode 100644 index 0000000..dc65293 --- /dev/null +++ b/windows/tests/test_hover_diacritics.py @@ -0,0 +1,311 @@ +"""Headless tests for hover-to-open diacritics. + +A real finger resting on a variant-capable key WITHOUT any click activity +fills that key over DIACRITIC_HOVER_OPEN seconds (progress published to +state for the renderer's fill bar), then its variant row opens. Hover is +gated on Lift-Off Typing — resting a finger is a lift-native gesture, so +with lift-off off the countdown never runs. Hover-opened rows commit on the +finger LIFT (no click will ever release), while moving to another key, +click activity, or lifting before the threshold cancels. +""" + +from collections import deque + +import pytest + +from triton import state +from triton.pad import _PadMixin +from triton.screen import CoordFraction + + +LPADTOUCH, LT, LPAD = 0x00000200, 0x00000100, 0x00000400 + + +@pytest.fixture(autouse=True) +def _clean_state(): + state.reset_session() + state.set_diacritics_enabled(True) + # Hover counting is gated on Lift-Off Typing — most tests exercise the + # hover flow itself, so the gate starts open. + state.set_sc_liftoff_enter(True) + yield + state.set_sc_liftoff_enter(False) + state.set_diacritics_enabled(True) + state.set_hover_fill(None) + + +def _build_kb(): + from triton import config, layouts, vkb + + cfg = config.YamlFile(layouts.layout_filename("QWERTY")) + cfg.read() + cfg.add_to_config("keys", vkb.VirtualKeyboardConfig()) + kb = vkb.VirtualKeyboardConfig().construct() + kb.update_dimensions() + return kb + + +class _KB: + def __init__(self): + self.downs = [] + + def pressEvent(self, keys): + self.downs.append(keys) + + def releaseEvent(self, keys): + pass + + +class _P: + def __init__(self, buttons): + self.buttons = buttons + + +class _CS: + def __init__(self): + self.click_queue = deque() + + +class _D(_PadMixin): + _kb: _KB + sc_input_previous: _P + controller_state: _CS + + BACKSPACE_HOLD_DELAY = 0.5 + BACKSPACE_REPEAT = 0.05 + PAD_CLICK_SETTLE = 0.05 + + def __init__(self): + self._kb = _KB() + self._select_pad = None + self._diacritic_pad = None + self._deferred_base = {} + self._click_repeat_at = {} + self._click_settle_at = {} + self._select_real_touch = False + self._select_dir = 0 + self._select_base_dir = 0 + self._select_reverse_buffer = [] + self._liftoff_touch = {} + self._liftoff_coord = {} + self._liftoff_clicked = {} + self._liftoff_t0 = {} + self._hover_start = {} + self._hover_rc = {} + self._diacritic_hover = {} + self.sc_input_previous = _P(0) + self.controller_state = _CS() + self._prev = 0 + + def frame(self, buttons, cf, raw_x, now, real_touch=True): + self.sc_input_previous = _P(self._prev) + r = self.handle_pad_input( + cf, + buttons, + LPADTOUCH, + LT, + click_button_mask=LPAD, + allow_click=True, + now=now, + trigger_pressed=False, + trigger_prev=False, + raw_x=raw_x, + real_touch=real_touch, + ) + self._prev = buttons + return r + + +def _center_cf(kb, row, col): + layout = kb.get_key_layout(row, col) + assert layout is not None + return CoordFraction.from_absolute( + layout.x + layout.w // 2, layout.y + layout.h // 2 + ) + + +def _rest(d, cf, t, seconds, step=0.1, real_touch=True): + """Feed resting-finger frames for `seconds`, returning the new clock.""" + n = int(round(seconds / step)) + for _ in range(n): + d.frame(LPADTOUCH, cf, 0, t, real_touch=real_touch) + t += step + return t + + +def test_hover_opens_variant_row_after_the_window(): + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) # 'a' + t = 1000.0 + t = _rest(d, cf, t, 1.1) + assert not state.is_diacritic_open() + t = _rest(d, cf, t, 0.2) + # The frame crossing DIACRITIC_HOVER_OPEN (1.2 s) opens the row and latches it + # as hover-opened. + assert state.is_diacritic_open() + assert d._diacritic_pad == LT + assert d._diacritic_hover.get(LT) is True + # Opening consumed the countdown — the fill is hidden again. + assert state.get_hover_fill() is None + + +def test_hover_publishes_fill_progress_for_the_renderer(): + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + # The start frame publishes zero progress (renderer draws nothing at 0). + assert state.get_hover_fill() == (3, 1, 0.0) + t += 1.0 + d.frame(LPADTOUCH, cf, 0, t) + fill = state.get_hover_fill() + assert fill is not None + row, col, frac = fill + assert (row, col) == (3, 1) + assert 0.0 < frac < 1.0 + + +def test_hover_silent_while_counting_down(): + """Resting must never type anything — the insert only comes from the + opened row's commit (or the ordinary click paths).""" + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 1.1) + assert list(d.controller_state.click_queue) == [] + assert not state.is_diacritic_open() + + +def test_hover_moving_to_another_key_restarts(): + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + a = _center_cf(kb, 3, 1) + s = _center_cf(kb, 3, 2) + t = 1000.0 + t = _rest(d, a, t, 1.1) + # Slide one key over: the countdown starts from zero there. + t = _rest(d, s, t, 1.1) + assert not state.is_diacritic_open() + # ...and back to 'a': another full window from scratch. + t = _rest(d, a, t, 1.1) + assert not state.is_diacritic_open() + t = _rest(d, a, t, 0.2) + assert state.is_diacritic_open() + + +def test_hover_cancelled_by_click_activity(): + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 1.1) + # A pad click interrupts the rest (press edge + release while touching). + d.frame(LPADTOUCH | LPAD, cf, 0, t) + t += 0.05 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.05 + # More than the original window remains under the threshold from NOW: + # a further 1.4 s of rest must NOT open (the countdown restarted). + t = _rest(d, cf, t, 1.1) + assert not state.is_diacritic_open() + t = _rest(d, cf, t, 0.2) + assert state.is_diacritic_open() + + +def test_hover_never_counts_synthetic_touch(): + """Only the REAL finger counts — the synthesized touch of a held click + button (or a stick-driven pointer) must not open rows by itself.""" + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 3.0, real_touch=False) + assert not state.is_diacritic_open() + assert state.get_hover_fill() is None + + +def test_hover_disabled_when_diacritics_off(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_diacritics_enabled(False) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 3.0) + assert not state.is_diacritic_open() + assert state.get_hover_fill() is None + + +def test_hover_disabled_when_liftoff_off(): + """Resting a finger is a lift-native gesture: with Lift-Off Typing off + the countdown never runs and nothing is published to the renderer.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(False) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 3.0) + assert not state.is_diacritic_open() + assert state.get_hover_fill() is None + + +def test_hover_row_commits_first_variant_on_finger_lift(): + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 2.25) + assert state.is_diacritic_open() + # Resting frames past the opening keep highlighting whatever sits under + # the finger inside the row, so read the CURRENT selection rather than + # assuming the first variant. + expected = state.get_diacritic_selected_char() + if expected is None: + expected = state.get_diacritic_variants_list()[0] + # Finger lifts (no click was ever held): commit the highlighted variant. + t += 0.01 + d.frame(0, cf, 0, t, real_touch=False) + queue = list(d.controller_state.click_queue) + assert queue == [("variant", expected)] + # The row itself is closed by commit_diacritic when the MAIN thread + # consumes the queued commit; the pad side must already be unlatched: + assert d._diacritic_pad is None + assert d._diacritic_hover.get(LT) is None + + +def test_hover_row_lift_does_not_double_fire_liftoff_insert(): + """With Lift-Off Typing ALSO enabled, an opened row's lift commits the + variant only — the lift-off insert must stay suppressed for the pad + that owns the row.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 2.25) + assert state.is_diacritic_open() + expected = state.get_diacritic_selected_char() + if expected is None: + expected = state.get_diacritic_variants_list()[0] + t += 0.01 + d.frame(0, cf, 0, t, real_touch=False) + queue = list(d.controller_state.click_queue) + assert queue == [("variant", expected)] + + +def test_reset_session_clears_hover_fill(): + state.set_hover_fill((3, 1, 0.5)) + state.reset_session() + assert state.get_hover_fill() is None diff --git a/windows/tests/test_layout_consistency.py b/windows/tests/test_layout_consistency.py new file mode 100644 index 0000000..0e57fcb --- /dev/null +++ b/windows/tests/test_layout_consistency.py @@ -0,0 +1,229 @@ +"""Every bundled layout must type what its keys print — on EVERY machine. + +A printable key can be correct two ways: + +1. Plain scancode: its (label, shifted) pair equals exactly what the + scancode produces under a stock US/UK Windows layout. +2. char-mode (`char: true` in the YAML): dispatch injects the printed label + as a CHARACTER — VkKeyScanW chord on whatever layout is active, + KEYEVENTF_UNICODE fallback — so the label lands regardless of the OS + input language. + +The invariant below pins BOTH rules for every cell of every board. This is +the test class that would have caught both historical bugs: "dvorak o types +s" (label/keycode drift) and "azerty $ types ]" (symbol rows trusted a +French OS keymap the user's machine didn't have). Nothing escapes it: +every single-character generic key of every board must satisfy rule 1 or +rule 2, or the suite fails naming the exact key. +""" + +import pytest +import steamcontroller.uinput as sui + +from triton import state, vkb +from triton.layouts import layout_filename + + +def _board(name): + from triton import config + + cfg = config.YamlFile(layout_filename(name)) + cfg.read() + cfg.add_to_config("keys", vkb.VirtualKeyboardConfig()) + kb = vkb.VirtualKeyboardConfig().construct() + kb.update_dimensions() + return kb + + +def _cells(kb): + for row in kb.keys: + yield from row + + +def _table(pairs): + return {getattr(sui.Keys, code): pair for code, pair in pairs.items()} + + +def _us_table(): + table = {} + for digit, shifted in zip("1234567890", "!@#$%^&*()"): + table[f"KEY_{digit}"] = (digit, shifted) + for ch in "abcdefghijklmnopqrstuvwxyz": + table[f"KEY_{ch.upper()}"] = (ch, ch.upper()) + table.update( + { + "KEY_GRAVE": ("`", "~"), + "KEY_MINUS": ("-", "_"), + "KEY_EQUAL": ("=", "+"), + "KEY_LEFTBRACE": ("[", "{"), + "KEY_RIGHTBRACE": ("]", "}"), + "KEY_BACKSLASH": ("\\", "|"), + "KEY_SEMICOLON": (";", ":"), + "KEY_APOSTROPHE": ("'", '"'), + "KEY_COMMA": (",", "<"), + "KEY_DOT": (".", ">"), + "KEY_SLASH": ("/", "?"), + } + ) + return _table(table) + + +US = _us_table() + +STRICT_BOARDS = ("QWERTY", "Dvorak", "Colemak", "ABC") +ALL_BOARDS = STRICT_BOARDS + ("AZERTY", "QWERTZ") + +# Native characters every board variant MUST offer (base or shifted labels) +# — the whole point of these boards. +AZERTY_REQUIRED = '²&é"' + "'(-è_çà)°=+^¨$£*µù%,?;.:/!§" +QWERTZ_REQUIRED = '^°!"§$%/()=ß?´`üÜ+*#\'öÖäÄyz;,.:-_' + + +def _labels(kb): + out = set() + for key in _cells(kb): + out.add(key.str) + if key.shifted: + out.add(key.shifted) + return out + + +def test_letters_always_match_their_scancode(): + """Letter keys ride their own scancodes on EVERY board (a on KEY_A …), + so they type true under any Latin Windows layout — never drift.""" + for name in ALL_BOARDS: + for key in _cells(_board(name)): + pair = US.get(key.keycode) + if pair is None or not pair[0].isalpha(): + continue + base, shifted = pair + assert key.str == base, ( + f"{name}: key prints {key.str!r} but its scancode types " + f"{base!r}" + ) + if key.shifted is not None: + assert key.shifted == shifted, ( + f"{name}: shift-label above {base!r} should be " + f"{shifted!r}, got {key.shifted!r}" + ) + + +@pytest.mark.parametrize("name", ALL_BOARDS) +def test_every_printable_cell_types_its_label(name): + """THE invariant: every single-character generic key of every board is + EITHER char-mode (injects the printed label) OR pairs with a scancode + whose stock-US output IS the printed label. No third option.""" + for key in _cells(_board(name)): + if key.modifier or key.str is None or len(key.str) != 1: + continue + if getattr(key.callback, "__name__", "") != "on_key_generic": + continue + if key.char_mode: + continue # rule 2: dispatch injects the label itself + pair = US.get(key.keycode) + assert pair is not None, ( + f"{name}: key {key.str!r} is neither char-mode nor a known US " + f"scancode ({key.keycode}) — it would type something else" + ) + base, shifted = pair + assert key.str == base, ( + f"{name}: key prints {key.str!r} but its scancode types " + f"{base!r} (mark it `char: true` or fix the pairing)" + ) + assert key.shifted == shifted, ( + f"{name}: shift-label above {key.str!r} should be {shifted!r}, " + f"got {key.shifted!r} (mark it `char: true` or fix the pairing)" + ) + + +@pytest.mark.parametrize("name", STRICT_BOARDS) +def test_strict_boards_need_neither_overrides_nor_char_mode(name): + """QWERTY/Dvorak/Colemak/ABC assume a stock US/UK input language: every + typewriter key matches the US table and NONE of them needs injection.""" + expected = US + checked = set() + for key in _cells(_board(name)): + pair = expected.get(key.keycode) + if pair is None: + continue # F-keys, Esc, Backspace, arrows… aren't in the table. + assert not key.char_mode, ( + f"{name}: {key.str!r} carries a redundant `char: true`" + ) + base, shifted = pair + assert key.str == base, ( + f"{name}: key prints {key.str!r} but its scancode types " + f"{base!r}" + ) + assert key.shifted == shifted, ( + f"{name}: shift-label above {base!r} should be {shifted!r}, " + f"got {key.shifted!r}" + ) + checked.add(key.keycode) + letter_codes = { + code + for code in expected + if code.startswith("KEY_") and len(code) == 5 and code[4].isalpha() + } + assert letter_codes <= checked, ( + f"{name}: letter scancodes missing from the board: " + f"{sorted(code for code in letter_codes - checked)}" + ) + + +@pytest.mark.parametrize( + ("name", "required"), + [("AZERTY", AZERTY_REQUIRED), ("QWERTZ", QWERTZ_REQUIRED)], +) +def test_native_characters_are_all_present(name, required): + """Each language board offers its FULL native character set (as base or + shifted labels) — no silently missing é, ß, £, § …""" + have = _labels(_board(name)) + missing = [ch for ch in required if ch not in have] + assert not missing, f"{name}: native characters missing: {missing}" + + +def test_char_mode_key_injects_its_label(monkeypatch): + """The user's bug, end to end: on a machine whose OS layout has no `$` + (VkKeyScanW misses), pressing the AZERTY `$` key still types `$` via the + KEYEVENTF_UNICODE fallback.""" + import applog + import ctypes + + applog.set_logging_enabled(False) + + kb = _board("AZERTY") + dollar = next(k for k in _cells(kb) if k.str == "$") + assert dollar.char_mode, "the $ key must be char-mode" + + calls = {} + user32 = sui._U32 + real_send = user32.SendInput + real_vkscan = user32.VkKeyScanW + real_openclip = user32.OpenClipboard + + def _stub_vkscan(_ch): + return -1 # active layout has no such key -> UNICODE fallback + + def _stub_openclipboard(_hwnd): + return 0 # paste path refuses -> UNICODE fallback + + def fake_send(n, inputs, cb): + arr = ctypes.cast(inputs, ctypes.POINTER(sui._INPUT)) + calls["kis"] = [ + (arr[i].u.ki.wVk, int(arr[i].u.ki.wScan), int(arr[i].u.ki.dwFlags)) + for i in range(int(n)) + ] + return int(n) + + monkeypatch.setattr(user32, "VkKeyScanW", _stub_vkscan, raising=False) + monkeypatch.setattr(user32, "OpenClipboard", _stub_openclipboard, raising=False) + monkeypatch.setattr(user32, "SendInput", fake_send, raising=False) + try: + state.reset_session() + assert state.is_shift_held() is False + vkb.dispatch_key(None, dollar) + finally: + # Restore even on failure so later tests keep the real functions. + monkeypatch.undo() + + assert calls["kis"] == [(0, ord("$"), 0x0004), (0, ord("$"), 0x0006)] diff --git a/windows/tests/test_layouts_liftoff.py b/windows/tests/test_layouts_liftoff.py new file mode 100644 index 0000000..d445c42 --- /dev/null +++ b/windows/tests/test_layouts_liftoff.py @@ -0,0 +1,341 @@ +"""Headless tests for the multi-layout registry + lift-off typing. + +Covers: +- triton/layouts.py: bundled layout discovery, filename mapping, name + normalization; +- triton.load_kb_config honoring state's selected layout (the OSK rebuilds + the VirtualKeyboard from the chosen YAML at every open); +- pad._PadMixin lift-off typing: insert on real-finger lift, suppressed when + disabled, when the touch already clicked, when too short, or when nothing + resolves under the final position. +""" + +from collections import deque + +import pytest + +from triton import state +from triton.layouts import ( + DEFAULT_LAYOUT, + available_layouts, + layout_filename, + normalize_layout_name, +) +from triton.pad import _PadMixin +from triton.screen import CoordFraction + + +# --- Layout registry --------------------------------------------------------- + + +def test_all_bundled_layouts_discovered(): + names = available_layouts() + for expected in ("QWERTY", "AZERTY", "QWERTZ", "Dvorak", "Colemak", "ABC"): + assert expected in names + assert names[0] == DEFAULT_LAYOUT + + +def test_layout_filename_maps_and_falls_back(): + assert layout_filename("Dvorak") == "keyboard-layout-dvorak.yaml" + assert layout_filename("AZERTY") == "keyboard-layout-azerty.yaml" + # Unknown / hostile values fall back to the default board's file — never + # a path smuggled through settings.json. + assert layout_filename("nope") == "keyboard-layout.yaml" + assert layout_filename(None) == "keyboard-layout.yaml" + assert layout_filename("../etc/passwd") == "keyboard-layout.yaml" + + +def test_normalize_layout_name_case_insensitive(): + assert normalize_layout_name("dvorak") == "Dvorak" + assert normalize_layout_name("qwerty") == "QWERTY" + assert normalize_layout_name(" No Such ") is None + assert normalize_layout_name(42) is None + + +def test_load_kb_config_builds_selected_layout(): + import steamcontroller.uinput as sui + + from triton.triton import load_kb_config + + def probe(name): + state.set_kb_layout(name) + try: + kb = load_kb_config().construct() + finally: + state.set_kb_layout(None) + labels = {} + for r, row in enumerate(kb.keys): + for c, key in enumerate(row): + if key.keycode in (sui.Keys.KEY_S, sui.Keys.KEY_GRAVE): + labels[key.keycode] = key.str + if r == 2 and c == 1: # first letter slot after Tab + labels["tab1"] = key.str + return ( + labels[sui.Keys.KEY_S], + labels[sui.Keys.KEY_GRAVE], + labels["tab1"], + ) + + # One (home-row S slot, grave slot, first-tab-slot) probe distinguishes + # every bundled board. Dvorak/Colemak pair labels with their own + # scancodes, so their S slot reads "s" like QWERTY — the arrangement + # lives elsewhere (see test_layout_consistency.py). + assert probe("QWERTY") == ("s", "`", "q") + assert probe("AZERTY") == ("s", "²", "a") + assert probe("QWERTZ") == ("s", "^", "q") + assert probe("Dvorak") == ("s", "`", "'") + assert probe("Colemak") == ("s", "`", "q") + # ABC keeps every letter on its own keycode, so its labels alone match + # QWERTY's probes — the tab row is where it visibly differs. + assert probe("ABC") == ("s", "`", "a") + # A stale/unknown selection falls back to QWERTY. + assert probe("Nope") == ("s", "`", "q") + + +# --- Lift-off typing harness -------------------------------------------------- + + +LPADTOUCH, LT, LPAD = 0x00000200, 0x00000100, 0x00000400 + + +@pytest.fixture(autouse=True) +def _clean_state(): + state.reset_session() + state.set_sc_liftoff_enter(False) + yield + state.set_sc_liftoff_enter(False) + + +def _build_kb(): + from triton import config, vkb + + cfg = config.YamlFile("keyboard-layout.yaml") + cfg.read() + cfg.add_to_config("keys", vkb.VirtualKeyboardConfig()) + kb = vkb.VirtualKeyboardConfig().construct() + kb.update_dimensions() + return kb + + +class _KB: + def __init__(self): + self.downs = [] + + def pressEvent(self, keys): + self.downs.append(keys) + + def releaseEvent(self, keys): + pass + + +class _P: + def __init__(self, buttons): + self.buttons = buttons + + +class _CS: + def __init__(self): + self.click_queue = deque() + + +class _D(_PadMixin): + _kb: _KB + sc_input_previous: _P + controller_state: _CS + + BACKSPACE_HOLD_DELAY = 0.5 + BACKSPACE_REPEAT = 0.05 + PAD_CLICK_SETTLE = 0.05 + + def __init__(self): + self._kb = _KB() + self._select_pad = None + self._diacritic_pad = None + self._deferred_base = {} + self._click_repeat_at = {} + self._click_settle_at = {} + self._select_real_touch = False + self._select_dir = 0 + self._select_base_dir = 0 + self._select_reverse_buffer = [] + self._liftoff_touch = {} + self._liftoff_coord = {} + self._liftoff_clicked = {} + self._liftoff_t0 = {} + self._hover_start = {} + self._hover_rc = {} + self._diacritic_hover = {} + self.sc_input_previous = _P(0) + self.controller_state = _CS() + self._prev = 0 + + def frame(self, buttons, cf, raw_x, now, real_touch=True): + self.sc_input_previous = _P(self._prev) + r = self.handle_pad_input( + cf, + buttons, + LPADTOUCH, + LT, + click_button_mask=LPAD, + allow_click=True, + now=now, + trigger_pressed=False, + trigger_prev=False, + raw_x=raw_x, + real_touch=real_touch, + ) + self._prev = buttons + return r + + +def _a_center_cf(kb): + layout = kb.get_key_layout(3, 1) # 'a' — same slot the diacritic tests use + assert layout is not None + return CoordFraction.from_absolute( + layout.x + layout.w // 2, layout.y + layout.h // 2 + ) + + +def test_liftoff_inserts_on_finger_lift(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _a_center_cf(kb) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + assert list(d.controller_state.click_queue) == [] + t += 0.08 + d.frame(LPADTOUCH, cf, 0, t) + assert list(d.controller_state.click_queue) == [] + # Lift: the key under the last finger position is inserted once. + t += 0.01 + d.frame(0, cf, 0, t, real_touch=False) + assert list(d.controller_state.click_queue) == [cf] + # The lift already consumed the touch — further release frames are quiet. + t += 0.02 + d.frame(0, cf, 0, t, real_touch=False) + assert list(d.controller_state.click_queue) == [cf] + + +def test_liftoff_disabled_inserts_nothing(): + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _a_center_cf(kb) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.08 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.01 + d.frame(0, cf, 0, t, real_touch=False) + assert list(d.controller_state.click_queue) == [] + + +def test_liftoff_suppressed_after_click_activity(): + """A touch that entered a key through the click path (pad press bit) must + never fire a second insert when the finger lifts.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + state.set_diacritics_enabled(False) + d = _D() + cf = _a_center_cf(kb) + t = 1000.0 + # Pad-click rising edge while touching: the immediate-type path fires. + d.frame(LPADTOUCH | LPAD, cf, 0, t) + inserted = list(d.controller_state.click_queue) + assert inserted == [cf] + t += 0.05 + d.frame(LPADTOUCH | LPAD, cf, 0, t) + t += 0.05 + d.frame(LPADTOUCH, cf, 0, t) # click released, finger still down + t += 0.08 + d.frame(0, cf, 0, t, real_touch=False) # finger lifts + assert list(d.controller_state.click_queue) == inserted + + +def test_liftoff_ignores_brush_shorter_than_min_touch(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _a_center_cf(kb) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 # well under LIFTOFF_MIN_TOUCH + d.frame(0, cf, 0, t, real_touch=False) + assert list(d.controller_state.click_queue) == [] + + +def test_liftoff_silent_when_no_key_resolves(): + """With no published keyboard (nothing resolves under the finger) a lift + must stay silent — covers the _liftoff_resolves guard deterministically.""" + state.set_sc_liftoff_enter(True) + # The kb persists across a session reset by design, so clear it + # explicitly: nothing may resolve under the finger. + state.set_virtual_kb(None) + d = _D() + cf = CoordFraction.from_absolute(100, 100) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.08 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.01 + d.frame(0, cf, 0, t, real_touch=False) + assert list(d.controller_state.click_queue) == [] + + +def test_liftoff_per_pad_independent(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _a_center_cf(kb) + t = 1000.0 + # Only the LEFT pad's real finger lifts here; the right pad never + # touched, so nothing may fire for it (per-pad trackers keyed by mask). + d.frame(LPADTOUCH, cf, 0, t) + t += 0.08 + d.frame(0, cf, 0, t, real_touch=False) + assert len(d.controller_state.click_queue) == 1 + + +def test_release_held_clears_liftoff_trackers(): + from triton.controller import ControllerManager + + class _FakeSC: + def addExit(self): + pass + + mgr = ControllerManager.__new__(ControllerManager) + # Only exercise the tracker-reset part via the dicts the mixin reads; + # full __init__ needs HID hardware, so build just these attributes. + mgr._deferred_base = {} + mgr._a_deferred_cell = None + mgr._mouse_l_active = False + mgr._mouse_r_active = False + mgr._shift_active = False + mgr._enter_active = False + mgr._select_pad = None + mgr._kb = _KB() + mgr._liftoff_touch = {LT: True} + mgr._liftoff_coord = {LT: _a_center_cf(_build_kb())} + mgr._liftoff_clicked = {LT: False} + mgr._liftoff_t0 = {LT: 1.0} + mgr.release_held() + assert mgr._liftoff_touch == {} + assert mgr._liftoff_coord == {} + assert mgr._liftoff_clicked == {} + assert mgr._liftoff_t0 == {} + + +def test_state_roundtrip(): + state.set_kb_layout("Dvorak") + assert state.get_kb_layout() == "Dvorak" + state.set_kb_layout(None) + assert state.get_kb_layout() is None + state.set_sc_liftoff_enter(True) + assert state.is_sc_liftoff_enabled() is True + state.set_sc_liftoff_enter(False) + assert state.is_sc_liftoff_enabled() is False diff --git a/windows/tests/vkb/test_diacritics.py b/windows/tests/vkb/test_diacritics.py index 91182de..9efecf5 100644 --- a/windows/tests/vkb/test_diacritics.py +++ b/windows/tests/vkb/test_diacritics.py @@ -1,4 +1,4 @@ -"""Headless tests for the Feature-B diacritic-variant logic +"""Headless tests for the Feature-B diacritic-variant logic (triton/diacritics.py + the settings roundtrip). Pure logic: the per-locale variant map and its merge/lookup rules, the @@ -443,8 +443,9 @@ def frame(self, buttons, cf, raw_x, now, real_touch=True): assert list(d.controller_state.click_queue) == [] assert d._deferred_base == {LT: cf} - # 2) Held past the hold delay -> the row opens instead of a key repeat. - t += vkb.KEY_REPEAT_DELAY + # 2) Held past the hold delay (DIACRITIC_HOLD_OPEN = 1.0 s) -> the row + # opens instead of a key repeat. + t += 1.0 d.frame(LPADTOUCH | LT | LPAD, cf, 0, t) assert state.is_diacritic_open() assert list(d.controller_state.click_queue) == [] @@ -582,8 +583,8 @@ def frame(self, buttons, cf, raw_x, now, real_touch=True): d.frame(LPADTOUCH | LT | LPAD, cf, 0, t) assert list(d.controller_state.click_queue) == [] - # 2) Held past the hold delay -> the row opens. - t += vkb.KEY_REPEAT_DELAY + # 2) Held past the hold delay (DIACRITIC_HOLD_OPEN = 1.0 s) -> opens. + t += 1.0 d.frame(LPADTOUCH | LT | LPAD, cf, 0, t) assert state.is_diacritic_open() sess = state.get_diacritic() diff --git a/windows/tray/app.py b/windows/tray/app.py index 70b3818..2a0a0d2 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -18,6 +18,7 @@ ) from steamcontroller import SCButtons from triton import diacritics +from triton import layouts as triton_layouts from triton import screen as triton_screen from triton import skins as triton_skins from triton import state as triton_state @@ -97,6 +98,19 @@ def __init__(self): triton_state.set_split_layout( bool(self.settings.get("osk_split_layout", False)) ) + # Keyboard layout (tray "Keyboard Layout" radio): normalize against + # the bundled layouts, then publish so the next OSK open builds it + # (main() re-runs load_kb_config every open). + if self.settings.get("osk_layout") not in ( + triton_layouts.available_layouts() + ): + self.settings["osk_layout"] = triton_layouts.DEFAULT_LAYOUT + triton_state.set_kb_layout(self.settings["osk_layout"]) + # Lift-off typing: insert the key under the pointer when the finger + # leaves the pad (Steam Controller submenu toggle). + triton_state.set_sc_liftoff_enter( + bool(self.settings.get("sc_liftoff_enter", False)) + ) # Trackpad press-force calibration (settings.json, hand-edited). triton_state.set_sc_pad_press( @@ -421,6 +435,35 @@ def is_click_button_checked(self, name): self.settings.get("sc_click_button", "L1/R1") == name ) + def toggle_liftoff_enter(self, icon, item): + self.settings["sc_liftoff_enter"] = not item.checked + _save_settings(self.settings) + triton_state.set_sc_liftoff_enter( + self.settings["sc_liftoff_enter"] + ) + self._refresh_menu() + + def is_liftoff_enter_checked(self, item): + return self.settings.get("sc_liftoff_enter", False) + + # Keyboard layout (tray "Keyboard Layout" radio): saved to settings.json + # and published immediately; the OSK picks it up at its next open (the + # VirtualKeyboard is rebuilt from the layout YAML every open). + def select_layout(self, name): + def _select(icon, item): + self.settings["osk_layout"] = name + _save_settings(self.settings) + triton_state.set_kb_layout(name) + self._refresh_menu() + + return _select + + def is_layout_checked(self, name): + return lambda item: ( + self.settings.get("osk_layout", triton_layouts.DEFAULT_LAYOUT) + == name + ) + # Split keyboard layout: left/right halves, each touchpad covers its own # half (no cross-body reach). Published to triton state immediately, so an # already-open keyboard resizes its window + re-lays-out live (the main diff --git a/windows/tray/menu.py b/windows/tray/menu.py index 385df69..89b913e 100755 --- a/windows/tray/menu.py +++ b/windows/tray/menu.py @@ -2,6 +2,7 @@ import pystray from appsettings import _SC_OSK_OPEN_CHORDS +from triton import layouts as triton_layouts from triton import skins as triton_skins @@ -127,9 +128,28 @@ def build_menu(app): app.toggle_pad_click_enter, checked=app.is_pad_click_enter_checked, ), + pystray.MenuItem( + "Lift-Off Typing", + app.toggle_liftoff_enter, + checked=app.is_liftoff_enter_checked, + ), pystray.MenuItem("Click Button", click_button_submenu), ) + # Keyboard layout (radio; applied on the next OSK open — the keyboard is + # rebuilt from the layout YAML every time it opens). + layout_submenu = pystray.Menu( + *[ + pystray.MenuItem( + name, + app.select_layout(name), + checked=app.is_layout_checked(name), + radio=True, + ) + for name in triton_layouts.available_layouts() + ] + ) + # Diacritic variants (Feature B: hold a letter to pick accented variants): # a master on/off plus the active locale (radio over "auto" = the Windows # keyboard layout, or one of the locales in the variant map). @@ -205,6 +225,7 @@ def build_menu(app): pystray.MenuItem("Startup", startup_submenu), pystray.MenuItem("Steam Controller", sc_submenu), pystray.MenuItem("Diacritics", diacritics_submenu), + pystray.MenuItem("Keyboard Layout", layout_submenu), pystray.MenuItem("Keyboard Skin", skin_submenu), pystray.Menu.SEPARATOR, pystray.MenuItem("Exit", app.exit_app), diff --git a/windows/triton/controller.py b/windows/triton/controller.py index c6b4490..bce3b79 100755 --- a/windows/triton/controller.py +++ b/windows/triton/controller.py @@ -224,6 +224,20 @@ def _map_events(self): # by the row). Cleared on release / teardown so a held pad press never # leaks a base insert across presses. self._deferred_base = {} + # Per-pad lift-off typing trackers (see pad._PadMixin): last real-touch + # edge, the coordinate to insert on lift, whether click activity + # consumed the current touch, and the touch start time. Keyed like + # _click_repeat_at by the select-button mask. + self._liftoff_touch = {} + self._liftoff_coord = {} + self._liftoff_clicked = {} + self._liftoff_t0 = {} + # Per-pad hover-to-open diacritics trackers (see pad._PadMixin): + # hover countdown start time, hovered key (row, col), and which pads' + # open variant rows came from a hover. + self._hover_start = {} + self._hover_rc = {} + self._diacritic_hover = {} # LT's role is decided on its rising edge from whether the left pad was # being touched: "shift" (pressed untouched) or "click" (pressed while # touching). Latched until LT is released so a later touch can't flip it. @@ -1042,6 +1056,17 @@ def release_held(self): # button can never leak a base insert across a teardown. self._deferred_base.clear() self._a_deferred_cell = None + # Reset the lift-off trackers too: a touch in flight at teardown must + # not insert into whatever app gains focus next. + self._liftoff_touch.clear() + self._liftoff_coord.clear() + self._liftoff_clicked.clear() + self._liftoff_t0.clear() + # And the hover-to-open trackers; hide any hover fill with them. + self._hover_start.clear() + self._hover_rc.clear() + self._diacritic_hover.clear() + state.set_hover_fill(None) def update(sc, sc_input, manager): diff --git a/windows/triton/layouts.py b/windows/triton/layouts.py new file mode 100644 index 0000000..92fa1b9 --- /dev/null +++ b/windows/triton/layouts.py @@ -0,0 +1,89 @@ +"""Keyboard layout registry. + +The OSK's key layout is a YAML file under data/cfg/ (the same loader as the +original single QWERTY board). This module maps a user-facing layout name to +its file so the tray can offer a radio list and triton.load_kb_config can +build the matching VirtualKeyboard at every OSK open. + +Layouts are POSITIONAL: each YAML places a keycode + label at a board slot, +and symbol production goes through the OS's active keymap (see +keyboard-layout.yaml's header). The bundled boards therefore read best when +the Windows layout matches (AZERTY/QWERTZ), and Dvorak/Colemak/ABC send +US-QWERTY-positioned scancodes so the printed labels always match what lands +in the text field on a stock US system. +""" + +import os + +from triton import resources + +DEFAULT_LAYOUT = "QWERTY" + +_PREFIX = "keyboard-layout" +_SUFFIX = ".yaml" + +# Display name -> cfg filename. QWERTY keeps the original legacy filename. +_LAYOUT_FILES = { + "QWERTY": "keyboard-layout.yaml", + "AZERTY": "keyboard-layout-azerty.yaml", + "QWERTZ": "keyboard-layout-qwertz.yaml", + "Dvorak": "keyboard-layout-dvorak.yaml", + "Colemak": "keyboard-layout-colemak.yaml", + "ABC": "keyboard-layout-abc.yaml", +} + +_BY_FILENAME = {fn: name for name, fn in _LAYOUT_FILES.items()} + + +def _cfg_dir(): + """Directory holding the bundled layout YAMLs (via the always-present + default file), or None when the bundle dir is missing.""" + p = resources.find_cfg_resource(_LAYOUT_FILES[DEFAULT_LAYOUT]) + return os.path.dirname(p) if p else None + + +def available_layouts(): + """Layout names with a file present in the cfg dir, display order first; + any user-added keyboard-layout-*.yaml is appended alphabetically. Falls + back to [DEFAULT_LAYOUT] so the tray always has an entry.""" + d = _cfg_dir() + found = set() + if d: + try: + for fn in os.listdir(d): + if fn.startswith(_PREFIX) and fn.lower().endswith(_SUFFIX): + found.add(fn) + except OSError: + pass + out = [n for n, fn in _LAYOUT_FILES.items() if fn in found] + known = set(_LAYOUT_FILES.values()) + for fn in sorted(found - known): + stem = fn[: -len(_SUFFIX)] + extra = stem[len(_PREFIX) :].lstrip("-_") + out.append(extra.capitalize() if extra else stem.capitalize()) + return out or [DEFAULT_LAYOUT] + + +def layout_filename(name): + """Cfg filename for a layout display name; unknown names (a stale + settings.json) fall back to the default board's file.""" + fn = _LAYOUT_FILES.get(name) + if fn is None: + # Case-insensitive retry, then user-added files by derived name. + folded = { + n.casefold(): f + for n, f in _LAYOUT_FILES.items() + } + fn = folded.get(str(name).casefold()) + return fn or _LAYOUT_FILES[DEFAULT_LAYOUT] + + +def normalize_layout_name(name): + """Canonical display name for a setting value (case-insensitive match + against the registry), or None when it names no bundled layout.""" + if not isinstance(name, str): + return None + for n in _LAYOUT_FILES: + if n.casefold() == name.casefold(): + return n + return None diff --git a/windows/triton/pad.py b/windows/triton/pad.py index da12ae5..ea2cd11 100755 --- a/windows/triton/pad.py +++ b/windows/triton/pad.py @@ -37,6 +37,13 @@ class _PadMixin: sc_input_previous: _PadFrame _click_repeat_at: dict _click_settle_at: dict + _liftoff_touch: dict + _liftoff_coord: dict + _liftoff_clicked: dict + _liftoff_t0: dict + _hover_start: dict + _hover_rc: dict + _diacritic_hover: dict _pad_click_engage: float _pad_click_release: float _pad_press_hold: float @@ -79,6 +86,48 @@ class _PadMixin: # fresh dict by ControllerManager/__init__; this class default is only a # safety net for the standalone test harnesses. _deferred_base = {} + # Lift-off typing (release-to-insert) per-pad trackers, keyed by the + # select-button mask like every other per-pad dict: whether the real + # finger was on the pad last frame, the coordinate to insert on lift, + # whether ANY click activity happened during the current touch (a touch + # that already entered a key must not fire a second insert on lift), and + # the monotonic time the touch began. Overridden with fresh dicts by + # ControllerManager/__init__; these class defaults are safety nets for + # standalone test harnesses. + _liftoff_touch = {} + _liftoff_coord = {} + _liftoff_clicked = {} + _liftoff_t0 = {} + # Hover-to-open diacritics per-pad trackers, keyed like every other + # per-pad dict: the monotonic time the finger STARTED resting on the + # currently hovered variant-capable key, and that key's (row, col) — + # moving to a different key restarts the countdown. Overridden with + # fresh dicts by ControllerManager/__init__; class defaults are safety + # nets for standalone test harnesses. + _hover_start = {} + _hover_rc = {} + # Which pads' open diacritic rows came from a HOVER (no click held): + # those rows commit when the real finger lifts off the pad instead of + # waiting for a click/trigger release that will never come. + _diacritic_hover = {} + # Minimum real-finger contact (s) before a lift counts as an intentional + # tap: brushes past the pad on the way to/from a button are shorter than + # any deliberate tap (~50-120 ms) and must stay silent. Mirrors the + # `now > 0` convention of the settle window — harness calls with now=0 + # bypass the duration gate. + LIFTOFF_MIN_TOUCH = 0.04 + # Seconds a real finger must rest on a variant-capable key — no click, + # no other session — before its diacritic row opens by itself. The + # renderer draws the countdown as the key "filling in" over this window. + # Only active while Lift-Off Typing is enabled (hover is a lift-native + # gesture; with clicks the press-hold path stays the entry). + DIACRITIC_HOVER_OPEN = 1.2 + # Seconds a CLICKED variant-capable key must be HELD before its variant + # row opens (the "hold to show more letters" gesture). Uniform whether + # Lift-Off Typing is on or off. Only variant-capable presses arm this + # clock; other keys keep the shorter BACKSPACE_HOLD_DELAY so backspace + # rub-out cadence is unchanged. + DIACRITIC_HOLD_OPEN = 1.0 # Minimum gap (s) between two "enter the key" edges on the same pad — # debounce/settle. A physical pad click can wobble: the force dips below # RELEASE and re-crosses ENGAGE within a few ms of the mechanical click's @@ -111,6 +160,48 @@ def handle_pad_input( if (not (buttons & click_button_mask)) and (prev & click_button_mask): state.pad_click_haptic() repeat_key = int(select_button_mask) + # Lift-off typing (release-to-insert): track the REAL finger per pad + # before any session routing below, so the touch's falling edge is + # always seen — sessions (select/diacritic/deferred) return early and + # would otherwise hide it. Uses `real_touch` (actual pad contact), not + # the synthesized touch bit, because the click button fakes touch + # while held. On the falling edge, when enabled and nothing else + # consumed this touch, queue an insert of where the finger last sat. + lt_touch_now = bool(real_touch) + lt_touch_was = self._liftoff_touch.get(repeat_key, False) + if lt_touch_now: + if not lt_touch_was: + self._liftoff_clicked[repeat_key] = False + self._liftoff_t0[repeat_key] = now + self._liftoff_coord[repeat_key] = coord_frac + elif lt_touch_was: + # The real finger left the pad: any hover-to-open countdown dies + # with it (the fill is hidden too). + self._cancel_hover(repeat_key) + clicked = self._liftoff_clicked.get(repeat_key, False) + long_enough = now <= 0 or ( + now - self._liftoff_t0.get(repeat_key, 0.0) + >= self.LIFTOFF_MIN_TOUCH + ) + if ( + state.is_sc_liftoff_enabled() + and not clicked + and long_enough + and self._select_pad != repeat_key + and self._diacritic_pad != repeat_key + and repeat_key not in self._deferred_base + and coord_frac is not None + and self._liftoff_coord.get(repeat_key) is not None + and self._liftoff_resolves( + self._liftoff_coord[repeat_key] + ) + ): + self.controller_state.click_queue.append( + self._liftoff_coord[repeat_key] + ) + state.pad_click_haptic() + self._diag("liftoff insert k={}", repeat_key) + self._liftoff_touch[repeat_key] = lt_touch_now if self._select_pad == repeat_key: still_active = (allow_click and trigger_pressed) or bool( buttons & click_button_mask @@ -151,10 +242,14 @@ def handle_pad_input( # the same frame the click releases, so with the finger already # lifted the gate below would return INACTIVE and the release — # and its commit — would never run (the row would stay latched, - # swallowing every later press on this pad). - if not ( - (allow_click and trigger_pressed) - or bool(buttons & click_button_mask) + # swallowing every later press on this pad). Hover-opened rows + # have no click to release: they end when the REAL finger lifts. + click_held = (allow_click and trigger_pressed) or bool( + buttons & click_button_mask + ) + if not click_held and not ( + self._diacritic_hover.get(repeat_key, False) + and bool(real_touch) ): self._end_diacritic_pad() return state.InputState.INACTIVE @@ -163,6 +258,7 @@ def handle_pad_input( # latch so it can't swallow every later press on this pad. if not state.is_diacritic_open(): self._diacritic_pad = None + self._diacritic_hover.pop(repeat_key, None) self._click_repeat_at.pop(repeat_key, None) elif repeat_key in self._deferred_base: # A deferred variant-capable press (the base was NOT typed at the @@ -211,6 +307,10 @@ def handle_pad_input( trigger_edge = trigger_held and (not touch_was or not trigger_prev) pad_edge = pad_clicked and not (prev & click_button_mask) click_active = trigger_held or pad_clicked + # Any click activity during this touch consumes the insert — the lift + # must never fire a second one behind the click path. + if click_active: + self._liftoff_clicked[repeat_key] = True # Hold-to-repeat, keyed per pad. First hit enters the key, rumbles, and # arms the repeat clock; held past BACKSPACE_HOLD_DELAY it re-enters the # key every BACKSPACE_REPEAT. Repeat hits are tagged so the main thread @@ -240,8 +340,10 @@ def handle_pad_input( # variant row (see _try_open_diacritic). While the press stays engaged # the finger's position within the row highlights the variant; a # release commits it (the base already fired on the press edge). + # Hover-opened rows behave the same but with no click held — the + # finger alone drives the highlight and its lift commits. if self._diacritic_pad == repeat_key: - if click_active: + if click_active or self._diacritic_hover.get(repeat_key, False): rect = state.get_diacritic_rect() if rect is not None: px, py = coord_frac.to_absolute() @@ -250,7 +352,9 @@ def handle_pad_input( rect, px, py, state.get_diacritic_variant_count() ) ) - return state.InputState.CLICK + if click_active: + return state.InputState.CLICK + return state.InputState.HOVER self._end_diacritic_pad() return state.InputState.INACTIVE if trigger_edge or pad_edge: @@ -278,9 +382,13 @@ def handle_pad_input( # settle window must not lose the base letter. if self._should_defer_press(coord_frac): self._deferred_base[repeat_key] = coord_frac - self._click_repeat_at[repeat_key] = ( - now + self.BACKSPACE_HOLD_DELAY - ) + self._click_repeat_at[repeat_key] = ( + now + self.DIACRITIC_HOLD_OPEN + ) + else: + self._click_repeat_at[repeat_key] = ( + now + self.BACKSPACE_HOLD_DELAY + ) else: # Defer model (Feature B): a press landing on a variant-capable # letter does NOT type anything at the press edge. The hold @@ -288,7 +396,8 @@ def handle_pad_input( # then types either the picked variant or the base letter. A # QUICK tap just types the base on release. Non-variant keys # keep firing at the press edge exactly as before. - if self._should_defer_press(coord_frac): + deferred = self._should_defer_press(coord_frac) + if deferred: self._deferred_base[repeat_key] = coord_frac self._diag( "defer press k={} cf={!r}", repeat_key, coord_frac @@ -305,8 +414,13 @@ def handle_pad_input( coord_frac, ) state.pad_click_haptic() - self._click_repeat_at[repeat_key] = ( - now + self.BACKSPACE_HOLD_DELAY + # Variant-capable presses arm the LONG hold clock (the + # hold-to-show-accents window, lift mode on or off); other + # keys keep the shorter backspace-repeat cadence. + self._click_repeat_at[repeat_key] = now + ( + self.DIACRITIC_HOLD_OPEN + if deferred + else self.BACKSPACE_HOLD_DELAY ) self._click_settle_at[repeat_key] = now + self.PAD_CLICK_SETTLE elif click_active and now >= self._click_repeat_at.get( @@ -324,6 +438,11 @@ def handle_pad_input( self._click_repeat_at[repeat_key] = now + self.BACKSPACE_REPEAT if not click_active: self._click_repeat_at.pop(repeat_key, None) + # Hover-to-open diacritics: a resting finger (no click, no session) + # fills the variant-capable key under it and opens its row at 100%. + self._update_hover( + coord_frac, repeat_key, now, click_active, bool(real_touch) + ) if click_active: return state.InputState.CLICK return state.InputState.HOVER @@ -358,6 +477,16 @@ def _diag(self, fmt, *args): except Exception: pass + def _liftoff_resolves(self, coord_frac): + """True if the stored lift-off coordinate sits on an actual key cell + (expanded hit-target, same resolution as a click). Lifting the finger + off empty space must stay silent.""" + kb = state.get_virtual_kb() + if kb is None: + return False + x, y = coord_frac.to_absolute() + return kb.find_key_expanded(x, y) is not None + def _should_defer_press(self, coord_frac): """Defer model (Feature B): True if the press under the pointer should NOT be typed at the press edge — it's a letter key that has accented @@ -393,6 +522,74 @@ def _try_open_diacritic(self, coord_frac, repeat_key): self._diacritic_pad = repeat_key return True + def _cancel_hover(self, repeat_key): + """Drop this pad's hover-to-open countdown; hide the fill only if + THIS pad was the one publishing it (the other pad may be hovering).""" + dropped = ( + self._hover_start.pop(repeat_key, None) is not None + or self._hover_rc.pop(repeat_key, None) is not None + ) + if dropped: + state.set_hover_fill(None) + + def _update_hover(self, coord_frac, repeat_key, now, click_active, touched): + """Hover-to-open (diacritics): a real finger RESTING on a variant- + capable key — no click activity, no other session on this pad — + fills that key over DIACRITIC_HOVER_OPEN seconds (the renderer draws + the growing bar from state.get_hover_fill) and then opens its variant + row. The row is marked as hover-opened so the finger's lift commits + the highlighted variant. Any click activity, moving to a different + key, or lifting cancels the countdown; the press-hold path stays + available as the secondary entry. + + Gated on Lift-Off Typing: resting a finger is a lift-native gesture — + with lift-off off the countdown never runs (and any stale one is + dropped), leaving press-hold as the only row entry.""" + if not state.is_sc_liftoff_enabled(): + self._cancel_hover(repeat_key) + return + if ( + click_active + or self._select_pad == repeat_key + or self._diacritic_pad == repeat_key + or repeat_key in self._deferred_base + or now <= 0 # mirrors the settle-window `now > 0` convention + ): + self._cancel_hover(repeat_key) + return + if not touched or coord_frac is None: + self._cancel_hover(repeat_key) + return + kb = state.get_virtual_kb() + if kb is None: + return + x, y = coord_frac.to_absolute() + rc = kb.find_key_expanded_rc(x, y) + if ( + rc is None + or vkb.diacritic_variants_for_key(kb.keys[rc[0]][rc[1]]) is None + ): + # Not resting on a variant-capable key: drop the countdown. + self._cancel_hover(repeat_key) + return + # Restart-proof accumulation: never assume the rc/start pair is in + # sync — any missing half (fresh pad, cancelled countdown, teardown) + # simply starts a new window. + start = self._hover_start.get(repeat_key) + if self._hover_rc.get(repeat_key) != rc or start is None: + self._hover_rc[repeat_key] = rc + start = self._hover_start[repeat_key] = now + elapsed = now - start + frac = min(1.0, max(0.0, elapsed / self.DIACRITIC_HOVER_OPEN)) + state.set_hover_fill((rc[0], rc[1], frac)) + if elapsed >= self.DIACRITIC_HOVER_OPEN: + self._cancel_hover(repeat_key) + if self._try_open_diacritic(coord_frac, repeat_key): + self._diacritic_hover[repeat_key] = True + self._diag( + "hover open k={} rc={}/{}", repeat_key, rc[0], rc[1] + ) + def _end_diacritic_pad(self): """End the held-to-extend session for this pad: the press released. In the DEFER model the base was never typed at the press edge, so a @@ -434,6 +631,7 @@ def _end_diacritic_pad(self): state.close_diacritic() finally: self._diacritic_pad = None + self._diacritic_hover.pop(repeat_key, None) self._click_repeat_at.pop(repeat_key, None) def _fire_arrow(self, dirn): diff --git a/windows/triton/screen.py b/windows/triton/screen.py index ca8ef09..06e2253 100755 --- a/windows/triton/screen.py +++ b/windows/triton/screen.py @@ -701,6 +701,7 @@ def render_key( dual_anim=None, outline_px=None, outline_opacity=None, + progress=None, ): if ( bg_color_override is not None @@ -751,6 +752,25 @@ def render_key( self.renderer, key.x, key.y, key.x + key.w - 1, key.y ) + # Hover-to-open diacritics: while a finger rests on this key toward + # opening its variant row, a bar in the pressed-key color grows from + # the bottom — the "key fills in" affordance. Drawn right after the + # base fill so the label/glyph stay on top of it. + if progress is not None and progress > 0.0: + pc = self.key_color[state.InputState.CLICK] + if self._transparent: + pa = min(255, int(round(230 * self._tscale))) + else: + pa = 255 + fh = max(1, int(round(key.h * min(1.0, progress)))) + S.SDL_SetRenderDrawColor(self.renderer, pc.r, pc.g, pc.b, pa) + S.SDL_RenderFillRect( + self.renderer, + ctypes.byref( + S.SDL_FRect(key.x, key.y + key.h - fh, key.w, fh) + ), + ) + # Inline label + icon: a key with BOTH a text label and a shortcut # glyph ("Backspace X", "Caps L3", "Space Y") draws them side by side, # centered as one unit, instead of the icon perched at the bottom edge @@ -1295,6 +1315,9 @@ def render_vkb(self, virtual_kb, pointers): # up. So when a key's rect intersects the strip, the pointers cannot # drive its state (button/cursor/mouse-press highlights still apply). strip_rect = state.get_diacritic_rect() + # Hover-to-open diacritics progress: (row, col, fraction) of the key + # a finger is currently filling toward opening its variant row. + hover_fill = state.get_hover_fill() def _key_under_strip(kx, ky, kw, kh): if strip_rect is None: @@ -1499,6 +1522,13 @@ def _key_under_strip(kx, ky, kw, kh): dual_anim=dual_anim, outline_px=kb_key.outline_px, outline_opacity=kb_key.outline_opacity, + progress=( + hover_fill[2] + if hover_fill is not None + and hover_fill[0] == key.row + and hover_fill[1] == key.col + else None + ), ) # Drop the per-key clip so the pointer circles (and the next frame's diff --git a/windows/triton/state.py b/windows/triton/state.py index 7075d7e..3883b83 100755 --- a/windows/triton/state.py +++ b/windows/triton/state.py @@ -397,6 +397,7 @@ def reset_session(): global _emoji_open, _window_position_request global _last_controller_activity, _osk_mouse_inject_t global _diacritic + global _hover_fill with should_exit_lock: should_exit = False with _emoji_lock: @@ -441,6 +442,8 @@ def reset_session(): _osk_mouse_inject_t = 0.0 with _diacritic_lock: _diacritic = None + with _hover_fill_lock: + _hover_fill = None def set_last_controller_activity(t): @@ -965,6 +968,66 @@ def is_split_layout_enabled(): return _split_layout +# Keyboard layout selection (settings.json "osk_layout", tray "Keyboard +# Layout" radio): which bundled key layout (see triton/layouts.py) the OSK +# builds at open. Session-independent config - deliberately NOT reset by +# reset_session(). Published by the tray; read by triton.load_kb_config(). +_kb_layout = None +_kb_layout_lock = Lock() + + +def set_kb_layout(name): + global _kb_layout + with _kb_layout_lock: + _kb_layout = name + + +def get_kb_layout(): + with _kb_layout_lock: + return _kb_layout + + +# Lift-off typing (settings.json "sc_liftoff_enter", tray "Steam Controller -> +# Lift-Off Typing"): insert the key under the pointer when the finger LIFTS +# off the trackpad, instead of requiring a pad press / click button. Shared +# sc_* lock with the other SC toggles; read per-frame by pad._PadMixin. +# Session-independent config. +_sc_liftoff_enter = False + + +def set_sc_liftoff_enter(enabled): + global _sc_liftoff_enter + with _sc_lock: + _sc_liftoff_enter = bool(enabled) + + +def is_sc_liftoff_enabled(): + with _sc_lock: + return _sc_liftoff_enter + + +# Hover-to-open diacritics: while a finger RESTS on a variant-capable key +# without any click activity, the pad thread publishes the hover progress so +# the renderer can draw the "key fills in" bar over that key. Published as +# (row, col, fraction 0..1) — row/col identify the target key robustly across +# size changes — or None when no hover is in flight. Cleared by the pad +# thread when the finger moves off, clicks, lifts, or when the variant row +# opens; also wiped per session. +_hover_fill = None +_hover_fill_lock = Lock() + + +def set_hover_fill(progress): + global _hover_fill + with _hover_fill_lock: + _hover_fill = progress + + +def get_hover_fill(): + with _hover_fill_lock: + return _hover_fill + + def key_sound_tick(): """Fire the registered key-press sound hook on every dispatched key. Gated by the key-sound enabled flag; NOT silenced while Steam runs (see diff --git a/windows/triton/triton.py b/windows/triton/triton.py index 82b0110..b85c3ee 100755 --- a/windows/triton/triton.py +++ b/windows/triton/triton.py @@ -16,6 +16,7 @@ config, controller, diacritics, + layouts, screen, state, utils, @@ -129,7 +130,9 @@ def _begin_open_anim(scr, virtual_kb, controller_state, rest): def load_kb_config(): kb_config = vkb.VirtualKeyboardConfig() - kb_layout_file = config.YamlFile("keyboard-layout.yaml") + kb_layout_file = config.YamlFile( + layouts.layout_filename(state.get_kb_layout()) + ) kb_layout_file.read() kb_layout_file.add_to_config("keys", kb_config) return kb_config diff --git a/windows/triton/vkb.py b/windows/triton/vkb.py index 059d2ae..96fabf0 100755 --- a/windows/triton/vkb.py +++ b/windows/triton/vkb.py @@ -436,6 +436,7 @@ def __init__( shift_valign=None, font_size=None, shift_keycode=None, + char_mode=False, ): self.str = str self.shifted = ( @@ -473,6 +474,13 @@ def __init__( # Optional alternate keycode used when Shift is held at dispatch time # (e.g. ◀ sends KEY_LEFT unshifted, KEY_UP while Shift is held). self.shift_keycode = shift_keycode + # Character-injection mode (layout YAML `char: true`): dispatch types + # the printed label as a CHARACTER — VkKeyScanW chord on the active + # layout, KEYEVENTF_UNICODE fallback — instead of tapping the raw + # scancode. Used by AZERTY/QWERTZ symbol keys whose labels don't exist + # under other OS keymaps (é $ £ ü ß …), so the board types what it + # prints on EVERY Windows input language, not just its native one. + self.char_mode = char_mode # True for the on-screen "Select" key (behavior: select) — the hold- # and-drag text-selection key that replaced the right Shift. self.is_select = False @@ -545,6 +553,28 @@ def tap_keycode(keycode): kb.releaseEvent([keycode]) +def _type_char_key(key): + """Type a char-mode key's printed label as a character (see KeyButton + .char_mode): the label the user SEES is what gets injected, on any OS + keymap. Shift/caps pick the shifted label exactly like the renderer + draws it; failures land in dualtouch.log (a windowed exe has no stdout, + so this is the only way to see an injection miss).""" + kb.reset_shift_state() + ch = key.display_label(state.is_shift_held(), state.is_caps_on()) + if not ch: + return + if kb.tap_char(ch) is False: + try: + from applog import log_line + + log_line( + "triton", + f"char key {ch!r} FAILED (all injection paths)", + ) + except Exception: + pass + + def _modifier_highlights(): """Keycode set of every LATELY-latched modifier, so toggling one on or off keeps the others' on-screen highlight. A toggle must never clear the whole @@ -804,6 +834,7 @@ def construct(self): if shift_keycode_str else None ) + char_mode = bool(yaml_key.get("char", False)) behavior = yaml_key.get("behavior", "generic") width_weight = yaml_key.get("width_weight", 1.0) @@ -826,6 +857,7 @@ def construct(self): shift_valign=shift_valign, font_size=font_size, shift_keycode=shift_keycode, + char_mode=char_mode, ) if behavior == "select": kb_btn.is_select = True @@ -920,6 +952,10 @@ def dispatch_key(virtual_kb, key): global _dispatch_is_repeat, _dispatch_silent if not _dispatch_is_repeat and not _dispatch_silent: state.key_sound_tick() + # Char-mode keys bypass scancode dispatch entirely — see KeyButton. + if key.char_mode: + _type_char_key(key) + return # Keys with a `shift_keycode` (e.g. ◀▶ → ▲▼) want to send the alternate # keycode WITHOUT the OS seeing a Shift modifier; otherwise Shift+Arrow # selects text instead of just moving the caret. Briefly drop and From a95e0faf38dcebf6377f22686b28032bad28edbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 18:31:21 +0200 Subject: [PATCH 2/6] chore: silence Nuitka build warnings - compile package in -m mode, allow local triton --- windows/build.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/windows/build.py b/windows/build.py index cace38b..95f0458 100644 --- a/windows/build.py +++ b/windows/build.py @@ -31,7 +31,9 @@ from ctypes import wintypes PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) -ENTRY = os.path.join("tray", "__main__.py") +# Compile the package itself (never tray/__main__.py) in -m package mode, +# so the frozen program runs exactly like the documented `python -m tray`. +ENTRY = "tray" OUTPUT_NAME = "DualTouch-windows" HELPER_EXE_NAME = "DualTouch-cursor-helper.exe" DIST_DIR = os.path.join(PROJECT_DIR, "dist") @@ -379,6 +381,11 @@ def _run_nuitka(): "--include-module=pynput.mouse._win32", "--include-module=PIL._tkinter_finder", "--include-package=sdl3w", + # Our local `triton` package collides with the ML 'triton' PyPI + # package in anti-bloat's eyes ("undesirable import"); allow it so + # every import of our own code compiles without warnings. + "--noinclude-custom-mode=triton:allow", + "--python-flag=-m", ENTRY, ] print("running:", " ".join(cmd)) From e89713eda176e93e299278c658db3f5c63416bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 19:56:50 +0200 Subject: [PATCH 3/6] fix: accent windows to half a second; remove hover fill animation; click sound on accent typing - Both accent-open windows are now 0.5 s: rest-to-open (Lift-Off Typing on) and click-hold-to-open (always available); backspace keeps its own cadence - Hover-to-open stays gated on Lift-Off Typing; the removed fill-in progress animation left no state/renderer traces - Typing an accented variant clicks once per inserted character; failed injection stays silent - Review pass: user-added keyboard-layout-*.yaml boards now actually load under their derived tray name; stored layout names are canonicalized case-insensitively; AZERTY bottom-row scancode metadata matches the physical board; README BOM dropped --- README.md | 4 +- windows/data/cfg/keyboard-layout-azerty.yaml | 6 +- windows/tests/state/test_state.py | 40 ++++--- windows/tests/test_hover_diacritics.py | 113 +++++++++++-------- windows/tests/test_layout_consistency.py | 21 +++- windows/tests/test_layouts_liftoff.py | 25 ++++ windows/tests/vkb/test_diacritic_hold.py | 12 +- windows/tests/vkb/test_diacritics.py | 8 +- windows/tray/app.py | 16 +-- windows/triton/controller.py | 11 +- windows/triton/layouts.py | 50 +++++--- windows/triton/pad.py | 56 ++++----- windows/triton/screen.py | 30 ----- windows/triton/state.py | 25 ---- windows/triton/vkb.py | 13 ++- 15 files changed, 236 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index 22a5b66..cd89dae 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DualTouch +# DualTouch

DualTouch @@ -34,7 +34,7 @@ It is a Windows-only fork of [SteamlessKeyboard](https://github.com/PietPetGit/S - **Global overlay** — draws above every app, not tied to Big Picture or a launcher. - **Native SDL3 rendering** — low CPU/memory use; skins, transparency, and size selectable from the tray. -- **Steam Controller input** — two-finger trackpad typing with hit-target expansion and debounce, DPAD/stick navigation, auto-repeat on held keys, configurable insert control (bumpers or triggers), Lift-Off Typing (insert on finger release), hover-to-open accented variants while Lift-Off Typing is on (rest a finger on a key for ~1.2 s — the key fills in, then slide to an accent), six selectable key layouts (language boards inject their symbols as characters, so they type true on any Windows input language). +- **Steam Controller input** — two-finger trackpad typing with hit-target expansion and debounce, DPAD/stick navigation, auto-repeat on held keys, configurable insert control (bumpers or triggers), Lift-Off Typing (insert on finger release), hover-to-open accented variants while Lift-Off Typing is on (rest a finger on a key for half a second, then slide to an accent; click-hold opens the same row), six selectable key layouts (language boards inject their symbols as characters, so they type true on any Windows input language). - **Steam Input coexistence** — reads the shared HID alongside Steam Input, switches to a keyboard layer via `steam://forceinputappid`, and restores your config on close. - **Opens with a chord** — Steam + X/Y/A/B (configurable) opens the keyboard; Steam's own menu is suppressed via a Guide Button Chord dead-binding. - **Per-app memory** — remembers the OSK size, skin, and position per foreground app. diff --git a/windows/data/cfg/keyboard-layout-azerty.yaml b/windows/data/cfg/keyboard-layout-azerty.yaml index f1b1243..c45d628 100644 --- a/windows/data/cfg/keyboard-layout-azerty.yaml +++ b/windows/data/cfg/keyboard-layout-azerty.yaml @@ -77,9 +77,9 @@ keys: - {label: "v", shifted: "V", keycode: KEY_V} - {label: "b", shifted: "B", keycode: KEY_B} - {label: "n", shifted: "N", keycode: KEY_N} - - {label: ",", shifted: "?", char: true, keycode: KEY_COMMA, dpad_down: 3, legacy_label_pos: true} - - {label: ";", shifted: ".", char: true, keycode: KEY_DOT, legacy_label_pos: true} - - {label: ":", shifted: "/", char: true, keycode: KEY_SLASH, legacy_label_pos: true} + - {label: ",", shifted: "?", char: true, keycode: KEY_M, dpad_down: 3, legacy_label_pos: true} + - {label: ";", shifted: ".", char: true, keycode: KEY_COMMA, legacy_label_pos: true} + - {label: ":", shifted: "/", char: true, keycode: KEY_DOT, legacy_label_pos: true} - {label: "!", shifted: "§", char: true, keycode: KEY_SLASH} - {label: "Select", behavior: select, keycode: KEY_SELECT, width_weight: 2.0, modifier: true, align: right, valign: bottom, outline_opacity: 0.33} diff --git a/windows/tests/state/test_state.py b/windows/tests/state/test_state.py index 8d66860..daca341 100644 --- a/windows/tests/state/test_state.py +++ b/windows/tests/state/test_state.py @@ -236,19 +236,29 @@ def test_diacritic_session_is_reset_by_reset_session(): def test_diacritic_config_survives_reset_and_snapshots(): state.reset_session() - state.set_diacritic_variants({"de": {"a": ["ä"]}}) - state.set_active_locale("de") - state.set_diacritics_enabled(False) - # Config is session-independent: reset_session must NOT wipe it (the tray - # re-publishes at startup, but it must also survive in-session resets). - state.reset_session() - assert state.get_diacritic_variants() == {"de": {"a": ["ä"]}} - assert state.get_active_locale() == "de" - assert state.is_diacritics_enabled() is False - # The setter snapshots the caller's dict - later mutation must not leak in. - m = {"fr": {"e": ["é", "è"]}} - state.set_diacritic_variants(m) - m["fr"]["e"].append("ê") - assert state.get_diacritic_variants() == {"fr": {"e": ["é", "è"]}} - state.set_diacritics_enabled(True) + # The variants map and locale are session-independent globals — snapshot + # them so this test can't pollute other modules' diacritic tests. + saved_variants = state.get_diacritic_variants() + saved_locale = state.get_active_locale() + try: + state.set_diacritic_variants({"de": {"a": ["ä"]}}) + state.set_active_locale("de") + state.set_diacritics_enabled(False) + # Config is session-independent: reset_session must NOT wipe it (the + # tray re-publishes at startup, but it must also survive in-session + # resets). + state.reset_session() + assert state.get_diacritic_variants() == {"de": {"a": ["ä"]}} + assert state.get_active_locale() == "de" + assert state.is_diacritics_enabled() is False + # The setter snapshots the caller's dict - later mutation must not + # leak in. + m = {"fr": {"e": ["é", "è"]}} + state.set_diacritic_variants(m) + m["fr"]["e"].append("ê") + assert state.get_diacritic_variants() == {"fr": {"e": ["é", "è"]}} + finally: + state.set_diacritic_variants(saved_variants) + state.set_active_locale(saved_locale) + state.set_diacritics_enabled(True) assert state.is_diacritics_enabled() is True diff --git a/windows/tests/test_hover_diacritics.py b/windows/tests/test_hover_diacritics.py index dc65293..bdcaa04 100644 --- a/windows/tests/test_hover_diacritics.py +++ b/windows/tests/test_hover_diacritics.py @@ -1,8 +1,7 @@ """Headless tests for hover-to-open diacritics. A real finger resting on a variant-capable key WITHOUT any click activity -fills that key over DIACRITIC_HOVER_OPEN seconds (progress published to -state for the renderer's fill bar), then its variant row opens. Hover is +opens that key's variant row after ACCENT_HOVER_OPEN seconds. Hover is gated on Lift-Off Typing — resting a finger is a lift-native gesture, so with lift-off off the countdown never runs. Hover-opened rows commit on the finger LIFT (no click will ever release), while moving to another key, @@ -25,13 +24,12 @@ def _clean_state(): state.reset_session() state.set_diacritics_enabled(True) - # Hover counting is gated on Lift-Off Typing — most tests exercise the - # hover flow itself, so the gate starts open. + # Most tests exercise the hover flow itself; the liftoff-dependent + # ones flip this explicitly. state.set_sc_liftoff_enter(True) yield state.set_sc_liftoff_enter(False) state.set_diacritics_enabled(True) - state.set_hover_fill(None) def _build_kb(): @@ -139,34 +137,14 @@ def test_hover_opens_variant_row_after_the_window(): d = _D() cf = _center_cf(kb, 3, 1) # 'a' t = 1000.0 - t = _rest(d, cf, t, 1.1) + t = _rest(d, cf, t, 0.4) assert not state.is_diacritic_open() t = _rest(d, cf, t, 0.2) - # The frame crossing DIACRITIC_HOVER_OPEN (1.2 s) opens the row and latches it - # as hover-opened. + # The frame crossing ACCENT_HOVER_OPEN (0.5 s) opens the row and latches + # it as hover-opened. assert state.is_diacritic_open() assert d._diacritic_pad == LT assert d._diacritic_hover.get(LT) is True - # Opening consumed the countdown — the fill is hidden again. - assert state.get_hover_fill() is None - - -def test_hover_publishes_fill_progress_for_the_renderer(): - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - d.frame(LPADTOUCH, cf, 0, t) - # The start frame publishes zero progress (renderer draws nothing at 0). - assert state.get_hover_fill() == (3, 1, 0.0) - t += 1.0 - d.frame(LPADTOUCH, cf, 0, t) - fill = state.get_hover_fill() - assert fill is not None - row, col, frac = fill - assert (row, col) == (3, 1) - assert 0.0 < frac < 1.0 def test_hover_silent_while_counting_down(): @@ -177,7 +155,7 @@ def test_hover_silent_while_counting_down(): d = _D() cf = _center_cf(kb, 3, 1) t = 1000.0 - t = _rest(d, cf, t, 1.1) + t = _rest(d, cf, t, 0.4) assert list(d.controller_state.click_queue) == [] assert not state.is_diacritic_open() @@ -189,12 +167,12 @@ def test_hover_moving_to_another_key_restarts(): a = _center_cf(kb, 3, 1) s = _center_cf(kb, 3, 2) t = 1000.0 - t = _rest(d, a, t, 1.1) + t = _rest(d, a, t, 0.4) # Slide one key over: the countdown starts from zero there. - t = _rest(d, s, t, 1.1) + t = _rest(d, s, t, 0.4) assert not state.is_diacritic_open() # ...and back to 'a': another full window from scratch. - t = _rest(d, a, t, 1.1) + t = _rest(d, a, t, 0.4) assert not state.is_diacritic_open() t = _rest(d, a, t, 0.2) assert state.is_diacritic_open() @@ -206,15 +184,39 @@ def test_hover_cancelled_by_click_activity(): d = _D() cf = _center_cf(kb, 3, 1) t = 1000.0 - t = _rest(d, cf, t, 1.1) + t = _rest(d, cf, t, 0.4) # A pad click interrupts the rest (press edge + release while touching). d.frame(LPADTOUCH | LPAD, cf, 0, t) t += 0.05 d.frame(LPADTOUCH, cf, 0, t) t += 0.05 # More than the original window remains under the threshold from NOW: - # a further 1.4 s of rest must NOT open (the countdown restarted). - t = _rest(d, cf, t, 1.1) + # a further 0.4 s of rest must NOT open (the countdown restarted). + t = _rest(d, cf, t, 0.4) + assert not state.is_diacritic_open() + t = _rest(d, cf, t, 0.2) + assert state.is_diacritic_open() + + +def test_hover_cancelled_by_finger_lift_mid_countdown(): + """Lifting the finger before the threshold kills the half-counted rest: + the countdown restarts from zero on the next touch. (Hover needs + Lift-Off Typing on, so the mid-rest lift also fires its ordinary + lift-off insert — asserted separately in test_layouts_liftoff.)""" + kb = _build_kb() + state.set_virtual_kb(kb) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + t = _rest(d, cf, t, 0.3) + assert not state.is_diacritic_open() + # The finger lifts mid-countdown... + t += 0.01 + d.frame(0, cf, 0, t, real_touch=False) + # ...and comes back down: a fresh window starts from zero. + t += 0.05 + d.frame(LPADTOUCH, cf, 0, t) + t = _rest(d, cf, t + 0.1, 0.3) assert not state.is_diacritic_open() t = _rest(d, cf, t, 0.2) assert state.is_diacritic_open() @@ -230,7 +232,6 @@ def test_hover_never_counts_synthetic_touch(): t = 1000.0 t = _rest(d, cf, t, 3.0, real_touch=False) assert not state.is_diacritic_open() - assert state.get_hover_fill() is None def test_hover_disabled_when_diacritics_off(): @@ -242,12 +243,11 @@ def test_hover_disabled_when_diacritics_off(): t = 1000.0 t = _rest(d, cf, t, 3.0) assert not state.is_diacritic_open() - assert state.get_hover_fill() is None def test_hover_disabled_when_liftoff_off(): """Resting a finger is a lift-native gesture: with Lift-Off Typing off - the countdown never runs and nothing is published to the renderer.""" + the countdown never runs.""" kb = _build_kb() state.set_virtual_kb(kb) state.set_sc_liftoff_enter(False) @@ -256,7 +256,6 @@ def test_hover_disabled_when_liftoff_off(): t = 1000.0 t = _rest(d, cf, t, 3.0) assert not state.is_diacritic_open() - assert state.get_hover_fill() is None def test_hover_row_commits_first_variant_on_finger_lift(): @@ -284,6 +283,36 @@ def test_hover_row_commits_first_variant_on_finger_lift(): assert d._diacritic_hover.get(LT) is None +def test_accent_commit_plays_the_click_sound(monkeypatch): + """Typing an accented variant clicks — one tick per inserted character. + A failed injection stays silent.""" + from triton import vkb + + kb = _build_kb() + state.set_virtual_kb(kb) + ticks = [] + # Earlier runner-fixture tests leave key sound globally disabled + # (state_cleanup); this test owns both knobs and restores them. + saved_enabled = state.is_key_sound_enabled() + state.set_key_sound_enabled(True) + state.set_key_sound(lambda: ticks.append(1)) + try: + monkeypatch.setattr(vkb.kb, "tap_char", lambda ch: True) + assert vkb.open_diacritic_rc(kb, 3, 1, "pad") + state.set_diacritic_index(0) + vkb.commit_diacritic() + assert ticks == [1] + + monkeypatch.setattr(vkb.kb, "tap_char", lambda ch: False) + assert vkb.open_diacritic_rc(kb, 3, 1, "pad") + state.set_diacritic_index(0) + vkb.commit_diacritic() + assert ticks == [1] + finally: + state.set_key_sound(None) + state.set_key_sound_enabled(saved_enabled) + + def test_hover_row_lift_does_not_double_fire_liftoff_insert(): """With Lift-Off Typing ALSO enabled, an opened row's lift commits the variant only — the lift-off insert must stay suppressed for the pad @@ -303,9 +332,3 @@ def test_hover_row_lift_does_not_double_fire_liftoff_insert(): d.frame(0, cf, 0, t, real_touch=False) queue = list(d.controller_state.click_queue) assert queue == [("variant", expected)] - - -def test_reset_session_clears_hover_fill(): - state.set_hover_fill((3, 1, 0.5)) - state.reset_session() - assert state.get_hover_fill() is None diff --git a/windows/tests/test_layout_consistency.py b/windows/tests/test_layout_consistency.py index 0e57fcb..ed0ee09 100644 --- a/windows/tests/test_layout_consistency.py +++ b/windows/tests/test_layout_consistency.py @@ -90,18 +90,27 @@ def _labels(kb): def test_letters_always_match_their_scancode(): """Letter keys ride their own scancodes on EVERY board (a on KEY_A …), - so they type true under any Latin Windows layout — never drift.""" + so they type true under any Latin Windows layout — never drift. A + SYMBOL label on a letter scancode is only legal in char-mode: language + boards mirror their physical keyboard (AZERTY prints ',' on the US-M + position), and injection makes it type true anywhere.""" for name in ALL_BOARDS: for key in _cells(_board(name)): pair = US.get(key.keycode) if pair is None or not pair[0].isalpha(): continue base, shifted = pair - assert key.str == base, ( - f"{name}: key prints {key.str!r} but its scancode types " - f"{base!r}" - ) - if key.shifted is not None: + if key.str.isalpha(): + assert key.str == base, ( + f"{name}: key prints {key.str!r} but its scancode types " + f"{base!r}" + ) + else: + assert key.char_mode, ( + f"{name}: symbol {key.str!r} on scancode {base!r} needs " + f"`char: true` (it would type {base!r})" + ) + if key.shifted is not None and key.shifted.isalpha(): assert key.shifted == shifted, ( f"{name}: shift-label above {base!r} should be " f"{shifted!r}, got {key.shifted!r}" diff --git a/windows/tests/test_layouts_liftoff.py b/windows/tests/test_layouts_liftoff.py index d445c42..af47fcb 100644 --- a/windows/tests/test_layouts_liftoff.py +++ b/windows/tests/test_layouts_liftoff.py @@ -52,6 +52,24 @@ def test_normalize_layout_name_case_insensitive(): assert normalize_layout_name(42) is None +def test_user_added_board_roundtrip(tmp_path, monkeypatch): + """A dropped-in keyboard-layout-*.yaml must be offered AND loadable + under its derived name — never silently fall back to QWERTY.""" + from triton import layouts + + (tmp_path / "keyboard-layout-brazil.yaml").write_text( + "keys: []\n", encoding="utf-8" + ) + monkeypatch.setattr(layouts, "_cfg_dir", lambda: str(tmp_path)) + + assert "Brazil" in layouts.available_layouts() + assert layouts.layout_filename("Brazil") == "keyboard-layout-brazil.yaml" + assert layouts.layout_filename("brazil") == "keyboard-layout-brazil.yaml" + assert layouts.normalize_layout_name("BRAZIL") == "Brazil" + # Unknown names still fall back safely. + assert layouts.layout_filename("nope") == "keyboard-layout.yaml" + + def test_load_kb_config_builds_selected_layout(): import steamcontroller.uinput as sui @@ -323,11 +341,18 @@ def addExit(self): mgr._liftoff_coord = {LT: _a_center_cf(_build_kb())} mgr._liftoff_clicked = {LT: False} mgr._liftoff_t0 = {LT: 1.0} + # Hover-to-open trackers are cleared alongside the lift-off ones. + mgr._hover_start = {LT: 2.0} + mgr._hover_rc = {LT: (3, 1)} + mgr._diacritic_hover = {LT: True} mgr.release_held() assert mgr._liftoff_touch == {} assert mgr._liftoff_coord == {} assert mgr._liftoff_clicked == {} assert mgr._liftoff_t0 == {} + assert mgr._hover_start == {} + assert mgr._hover_rc == {} + assert mgr._diacritic_hover == {} def test_state_roundtrip(): diff --git a/windows/tests/vkb/test_diacritic_hold.py b/windows/tests/vkb/test_diacritic_hold.py index dfe5b8c..72a7ad4 100644 --- a/windows/tests/vkb/test_diacritic_hold.py +++ b/windows/tests/vkb/test_diacritic_hold.py @@ -23,7 +23,7 @@ def _hold_open_row(runner, label="a"): pos = runner.raw_at_cell(label) runner.advance(0.02) runner.push(buttons=SCButtons.LB, **pos) # press edge: defer + sound - for _ in range(8): # 8 * 0.06 s clears the 0.4 s delay mid-band + for _ in range(10): # 10 * 0.06 s clears the 0.5 s accent window mid-band runner.advance(0.06) runner.push(buttons=SCButtons.LB, **pos) assert state.is_diacritic_open() @@ -53,7 +53,8 @@ def test_hold_commits_variant_under_finger_x(runner, en_a): runner.push(buttons=0, **pos) # release commits the highlighted one assert runner.typed() == en_a[picked] assert not state.is_diacritic_open() - assert runner.sounds == 1 # press edge only + # Press edge tick + the accent-commit tick (commit_diacritic clicks). + assert runner.sounds == 2 def test_hold_with_finger_off_strip_defaults_to_first(runner, en_a): @@ -91,7 +92,7 @@ def test_dpad_moves_selection_while_row_open(runner, en_a): state.set_cursor(*runner.cell_rc("a")) runner.advance(0.02) runner.push(buttons=SCButtons.A) - for _ in range(8): + for _ in range(10): runner.advance(0.06) runner.push(buttons=SCButtons.A) assert state.get_diacritic_source() == "a" @@ -114,7 +115,7 @@ def test_a_button_hold_opens_row_and_commits_first(runner, en_a): state.set_cursor(*runner.cell_rc("a")) runner.advance(0.02) runner.push(buttons=SCButtons.A) # press edge: defer + click sound - for _ in range(8): + for _ in range(10): runner.advance(0.06) runner.push(buttons=SCButtons.A) assert state.is_diacritic_open() @@ -122,7 +123,8 @@ def test_a_button_hold_opens_row_and_commits_first(runner, en_a): runner.auto_advance = True runner.push() assert runner.typed() == en_a[0] - assert runner.sounds == 1 + # Press edge tick + the accent-commit tick (commit_diacritic clicks). + assert runner.sounds == 2 def test_a_button_quick_tap_types_base_silently(runner): diff --git a/windows/tests/vkb/test_diacritics.py b/windows/tests/vkb/test_diacritics.py index 9efecf5..e817fd7 100644 --- a/windows/tests/vkb/test_diacritics.py +++ b/windows/tests/vkb/test_diacritics.py @@ -443,9 +443,9 @@ def frame(self, buttons, cf, raw_x, now, real_touch=True): assert list(d.controller_state.click_queue) == [] assert d._deferred_base == {LT: cf} - # 2) Held past the hold delay (DIACRITIC_HOLD_OPEN = 1.0 s) -> the row + # 2) Held past the hold delay (ACCENT_HOLD_OPEN = 0.5 s) -> the row # opens instead of a key repeat. - t += 1.0 + t += 0.5 d.frame(LPADTOUCH | LT | LPAD, cf, 0, t) assert state.is_diacritic_open() assert list(d.controller_state.click_queue) == [] @@ -583,8 +583,8 @@ def frame(self, buttons, cf, raw_x, now, real_touch=True): d.frame(LPADTOUCH | LT | LPAD, cf, 0, t) assert list(d.controller_state.click_queue) == [] - # 2) Held past the hold delay (DIACRITIC_HOLD_OPEN = 1.0 s) -> opens. - t += 1.0 + # 2) Held past the hold delay (ACCENT_HOLD_OPEN = 0.5 s) -> opens. + t += 0.5 d.frame(LPADTOUCH | LT | LPAD, cf, 0, t) assert state.is_diacritic_open() sess = state.get_diacritic() diff --git a/windows/tray/app.py b/windows/tray/app.py index 2a0a0d2..6845e53 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -98,13 +98,15 @@ def __init__(self): triton_state.set_split_layout( bool(self.settings.get("osk_split_layout", False)) ) - # Keyboard layout (tray "Keyboard Layout" radio): normalize against - # the bundled layouts, then publish so the next OSK open builds it - # (main() re-runs load_kb_config every open). - if self.settings.get("osk_layout") not in ( - triton_layouts.available_layouts() - ): - self.settings["osk_layout"] = triton_layouts.DEFAULT_LAYOUT + # Keyboard layout (tray "Keyboard Layout" radio): canonicalize the + # stored name against the registry (case-insensitive, user-added + # boards included), falling back to QWERTY when nothing matches; + # publish so the next OSK open builds it (main() re-runs + # load_kb_config every open). + self.settings["osk_layout"] = ( + triton_layouts.normalize_layout_name(self.settings.get("osk_layout")) + or triton_layouts.DEFAULT_LAYOUT + ) triton_state.set_kb_layout(self.settings["osk_layout"]) # Lift-off typing: insert the key under the pointer when the finger # leaves the pad (Steam Controller submenu toggle). diff --git a/windows/triton/controller.py b/windows/triton/controller.py index bce3b79..c0eea6c 100755 --- a/windows/triton/controller.py +++ b/windows/triton/controller.py @@ -630,7 +630,13 @@ def handle_input(self, sc, sc_input): state.key_sound_tick() else: state.queue_key_press(a_row, a_col) - self._a_repeat_at = now + self.BACKSPACE_HOLD_DELAY + # Variant-capable cells use the same 0.5 s accent window as the + # pad hold; plain keys keep the backspace rub-out clock. + self._a_repeat_at = now + ( + self.ACCENT_HOLD_OPEN + if self._a_deferred_cell is not None + else self.BACKSPACE_HOLD_DELAY + ) elif a_pressed and now >= self._a_repeat_at: state.queue_key_press(a_row, a_col, repeat=True) self._a_repeat_at = now + self.BACKSPACE_REPEAT @@ -1062,11 +1068,10 @@ def release_held(self): self._liftoff_coord.clear() self._liftoff_clicked.clear() self._liftoff_t0.clear() - # And the hover-to-open trackers; hide any hover fill with them. + # And the hover-to-open trackers with them. self._hover_start.clear() self._hover_rc.clear() self._diacritic_hover.clear() - state.set_hover_fill(None) def update(sc, sc_input, manager): diff --git a/windows/triton/layouts.py b/windows/triton/layouts.py index 92fa1b9..2c358cb 100644 --- a/windows/triton/layouts.py +++ b/windows/triton/layouts.py @@ -42,6 +42,29 @@ def _cfg_dir(): return os.path.dirname(p) if p else None +def _extra_files(): + """User-added keyboard-layout-*.yaml files in the cfg dir as + {derived display name: filename}, sorted by filename. The derivation + mirrors available_layouts() so both agree on the names they offer.""" + d = _cfg_dir() + out = {} + if d: + try: + found = { + fn + for fn in os.listdir(d) + if fn.startswith(_PREFIX) and fn.lower().endswith(_SUFFIX) + } + except OSError: + found = set() + known = set(_LAYOUT_FILES.values()) + for fn in sorted(found - known): + stem = fn[: -len(_SUFFIX)] + extra = stem[len(_PREFIX) :].lstrip("-_") + out[extra.capitalize() if extra else stem.capitalize()] = fn + return out + + def available_layouts(): """Layout names with a file present in the cfg dir, display order first; any user-added keyboard-layout-*.yaml is appended alphabetically. Falls @@ -56,34 +79,35 @@ def available_layouts(): except OSError: pass out = [n for n, fn in _LAYOUT_FILES.items() if fn in found] - known = set(_LAYOUT_FILES.values()) - for fn in sorted(found - known): - stem = fn[: -len(_SUFFIX)] - extra = stem[len(_PREFIX) :].lstrip("-_") - out.append(extra.capitalize() if extra else stem.capitalize()) + out.extend(_extra_files()) return out or [DEFAULT_LAYOUT] def layout_filename(name): - """Cfg filename for a layout display name; unknown names (a stale + """Cfg filename for a layout display name (bundled first, then + user-added boards by derived name); unknown names (a stale settings.json) fall back to the default board's file.""" fn = _LAYOUT_FILES.get(name) if fn is None: - # Case-insensitive retry, then user-added files by derived name. - folded = { - n.casefold(): f - for n, f in _LAYOUT_FILES.items() - } + folded = {n.casefold(): f for n, f in _LAYOUT_FILES.items()} + fn = folded.get(str(name).casefold()) + if fn is None: + folded = {n.casefold(): f for n, f in _extra_files().items()} fn = folded.get(str(name).casefold()) return fn or _LAYOUT_FILES[DEFAULT_LAYOUT] def normalize_layout_name(name): """Canonical display name for a setting value (case-insensitive match - against the registry), or None when it names no bundled layout.""" + against bundled layouts, then user-added boards), or None when it names + no file present in the cfg dir.""" if not isinstance(name, str): return None + folded = str(name).casefold() for n in _LAYOUT_FILES: - if n.casefold() == name.casefold(): + if n.casefold() == folded: + return n + for n in _extra_files(): + if n.casefold() == folded: return n return None diff --git a/windows/triton/pad.py b/windows/triton/pad.py index ea2cd11..651d1f9 100755 --- a/windows/triton/pad.py +++ b/windows/triton/pad.py @@ -116,18 +116,19 @@ class _PadMixin: # `now > 0` convention of the settle window — harness calls with now=0 # bypass the duration gate. LIFTOFF_MIN_TOUCH = 0.04 - # Seconds a real finger must rest on a variant-capable key — no click, - # no other session — before its diacritic row opens by itself. The - # renderer draws the countdown as the key "filling in" over this window. + # Seconds before a variant-capable key's accent row opens by HOVER: + # a real finger merely RESTING on the key (no click, no other session). # Only active while Lift-Off Typing is enabled (hover is a lift-native - # gesture; with clicks the press-hold path stays the entry). - DIACRITIC_HOVER_OPEN = 1.2 - # Seconds a CLICKED variant-capable key must be HELD before its variant - # row opens (the "hold to show more letters" gesture). Uniform whether - # Lift-Off Typing is on or off. Only variant-capable presses arm this - # clock; other keys keep the shorter BACKSPACE_HOLD_DELAY so backspace - # rub-out cadence is unchanged. - DIACRITIC_HOLD_OPEN = 1.0 + # gesture; with clicks it would fire accidentally while the pad is used + # as a mouse). No visual countdown — the row simply opens. + ACCENT_HOVER_OPEN = 0.5 + # Seconds before a variant-capable key's accent row opens by HOLD: + # the key is CLICKED and held down this long ("hold to show more + # letters"). Kept identical to ACCENT_HOVER_OPEN so both gestures feel + # the same; only variant-capable presses arm this clock, other keys + # keep the shorter BACKSPACE_HOLD_DELAY so backspace rub-out cadence is + # unchanged. + ACCENT_HOLD_OPEN = 0.5 # Minimum gap (s) between two "enter the key" edges on the same pad — # debounce/settle. A physical pad click can wobble: the force dips below # RELEASE and re-crosses ENGAGE within a few ms of the mechanical click's @@ -176,7 +177,7 @@ def handle_pad_input( self._liftoff_coord[repeat_key] = coord_frac elif lt_touch_was: # The real finger left the pad: any hover-to-open countdown dies - # with it (the fill is hidden too). + # with it. self._cancel_hover(repeat_key) clicked = self._liftoff_clicked.get(repeat_key, False) long_enough = now <= 0 or ( @@ -315,7 +316,6 @@ def handle_pad_input( # arms the repeat clock; held past BACKSPACE_HOLD_DELAY it re-enters the # key every BACKSPACE_REPEAT. Repeat hits are tagged so the main thread # only acts on them over Backspace (no rumble on repeat — matches X). - repeat_key = int(select_button_mask) # Select mode (the on-screen "Select" key, iOS hold-space style): a pad # press landing on the Select key holds OS Shift while the press stays # engaged, and horizontal pad travel fires Shift+Left/Right — so holding @@ -383,7 +383,7 @@ def handle_pad_input( if self._should_defer_press(coord_frac): self._deferred_base[repeat_key] = coord_frac self._click_repeat_at[repeat_key] = ( - now + self.DIACRITIC_HOLD_OPEN + now + self.ACCENT_HOLD_OPEN ) else: self._click_repeat_at[repeat_key] = ( @@ -418,7 +418,7 @@ def handle_pad_input( # hold-to-show-accents window, lift mode on or off); other # keys keep the shorter backspace-repeat cadence. self._click_repeat_at[repeat_key] = now + ( - self.DIACRITIC_HOLD_OPEN + self.ACCENT_HOLD_OPEN if deferred else self.BACKSPACE_HOLD_DELAY ) @@ -523,24 +523,18 @@ def _try_open_diacritic(self, coord_frac, repeat_key): return True def _cancel_hover(self, repeat_key): - """Drop this pad's hover-to-open countdown; hide the fill only if - THIS pad was the one publishing it (the other pad may be hovering).""" - dropped = ( - self._hover_start.pop(repeat_key, None) is not None - or self._hover_rc.pop(repeat_key, None) is not None - ) - if dropped: - state.set_hover_fill(None) + """Drop this pad's hover-to-open countdown.""" + self._hover_start.pop(repeat_key, None) + self._hover_rc.pop(repeat_key, None) def _update_hover(self, coord_frac, repeat_key, now, click_active, touched): """Hover-to-open (diacritics): a real finger RESTING on a variant- capable key — no click activity, no other session on this pad — - fills that key over DIACRITIC_HOVER_OPEN seconds (the renderer draws - the growing bar from state.get_hover_fill) and then opens its variant - row. The row is marked as hover-opened so the finger's lift commits - the highlighted variant. Any click activity, moving to a different - key, or lifting cancels the countdown; the press-hold path stays - available as the secondary entry. + opens its variant row after ACCENT_HOVER_OPEN seconds. The row is + marked as hover-opened so the finger's lift commits the highlighted + variant. Any click activity, moving to a different key, or lifting + cancels the countdown; the press-hold path stays available as the + secondary entry. Gated on Lift-Off Typing: resting a finger is a lift-native gesture — with lift-off off the countdown never runs (and any stale one is @@ -580,9 +574,7 @@ def _update_hover(self, coord_frac, repeat_key, now, click_active, touched): self._hover_rc[repeat_key] = rc start = self._hover_start[repeat_key] = now elapsed = now - start - frac = min(1.0, max(0.0, elapsed / self.DIACRITIC_HOVER_OPEN)) - state.set_hover_fill((rc[0], rc[1], frac)) - if elapsed >= self.DIACRITIC_HOVER_OPEN: + if elapsed >= self.ACCENT_HOVER_OPEN: self._cancel_hover(repeat_key) if self._try_open_diacritic(coord_frac, repeat_key): self._diacritic_hover[repeat_key] = True diff --git a/windows/triton/screen.py b/windows/triton/screen.py index 06e2253..ca8ef09 100755 --- a/windows/triton/screen.py +++ b/windows/triton/screen.py @@ -701,7 +701,6 @@ def render_key( dual_anim=None, outline_px=None, outline_opacity=None, - progress=None, ): if ( bg_color_override is not None @@ -752,25 +751,6 @@ def render_key( self.renderer, key.x, key.y, key.x + key.w - 1, key.y ) - # Hover-to-open diacritics: while a finger rests on this key toward - # opening its variant row, a bar in the pressed-key color grows from - # the bottom — the "key fills in" affordance. Drawn right after the - # base fill so the label/glyph stay on top of it. - if progress is not None and progress > 0.0: - pc = self.key_color[state.InputState.CLICK] - if self._transparent: - pa = min(255, int(round(230 * self._tscale))) - else: - pa = 255 - fh = max(1, int(round(key.h * min(1.0, progress)))) - S.SDL_SetRenderDrawColor(self.renderer, pc.r, pc.g, pc.b, pa) - S.SDL_RenderFillRect( - self.renderer, - ctypes.byref( - S.SDL_FRect(key.x, key.y + key.h - fh, key.w, fh) - ), - ) - # Inline label + icon: a key with BOTH a text label and a shortcut # glyph ("Backspace X", "Caps L3", "Space Y") draws them side by side, # centered as one unit, instead of the icon perched at the bottom edge @@ -1315,9 +1295,6 @@ def render_vkb(self, virtual_kb, pointers): # up. So when a key's rect intersects the strip, the pointers cannot # drive its state (button/cursor/mouse-press highlights still apply). strip_rect = state.get_diacritic_rect() - # Hover-to-open diacritics progress: (row, col, fraction) of the key - # a finger is currently filling toward opening its variant row. - hover_fill = state.get_hover_fill() def _key_under_strip(kx, ky, kw, kh): if strip_rect is None: @@ -1522,13 +1499,6 @@ def _key_under_strip(kx, ky, kw, kh): dual_anim=dual_anim, outline_px=kb_key.outline_px, outline_opacity=kb_key.outline_opacity, - progress=( - hover_fill[2] - if hover_fill is not None - and hover_fill[0] == key.row - and hover_fill[1] == key.col - else None - ), ) # Drop the per-key clip so the pointer circles (and the next frame's diff --git a/windows/triton/state.py b/windows/triton/state.py index 3883b83..91865eb 100755 --- a/windows/triton/state.py +++ b/windows/triton/state.py @@ -397,7 +397,6 @@ def reset_session(): global _emoji_open, _window_position_request global _last_controller_activity, _osk_mouse_inject_t global _diacritic - global _hover_fill with should_exit_lock: should_exit = False with _emoji_lock: @@ -442,8 +441,6 @@ def reset_session(): _osk_mouse_inject_t = 0.0 with _diacritic_lock: _diacritic = None - with _hover_fill_lock: - _hover_fill = None def set_last_controller_activity(t): @@ -1006,28 +1003,6 @@ def is_sc_liftoff_enabled(): return _sc_liftoff_enter -# Hover-to-open diacritics: while a finger RESTS on a variant-capable key -# without any click activity, the pad thread publishes the hover progress so -# the renderer can draw the "key fills in" bar over that key. Published as -# (row, col, fraction 0..1) — row/col identify the target key robustly across -# size changes — or None when no hover is in flight. Cleared by the pad -# thread when the finger moves off, clicks, lifts, or when the variant row -# opens; also wiped per session. -_hover_fill = None -_hover_fill_lock = Lock() - - -def set_hover_fill(progress): - global _hover_fill - with _hover_fill_lock: - _hover_fill = progress - - -def get_hover_fill(): - with _hover_fill_lock: - return _hover_fill - - def key_sound_tick(): """Fire the registered key-press sound hook on every dispatched key. Gated by the key-sound enabled flag; NOT silenced while Steam runs (see diff --git a/windows/triton/vkb.py b/windows/triton/vkb.py index 96fabf0..5858e8d 100755 --- a/windows/triton/vkb.py +++ b/windows/triton/vkb.py @@ -1042,10 +1042,13 @@ def commit_diacritic(char=None): the base still untyped, so a release over a variant just types that variant via uinput.tap_char. There is no Backspace to undo: the base never stood. `char` is read from the open row session when not given. Closes - the row FIRST and in a finally. The click sound fired at the PRESS edge - (see pad/controller defer handling), so a held variant pick doesn't sound - laggy on release. A base selection (index -1) is a no-op: the caller types - the base letter on release instead.""" + the row FIRST and in a finally. The click sound fires HERE, on the typed + variant — one tick per inserted character. On the hold path the + press-edge tick (see pad/controller defer handling) reads as the + down-stroke and this as the release; on the hover/mouse/A paths this is + the only tick, so hitting an accent key always clicks. A base selection + (index -1) is a no-op: the caller types the base letter on release + instead.""" if char is None: char = state.get_diacritic_selected_char() ok = None @@ -1053,6 +1056,8 @@ def commit_diacritic(char=None): if not char: return ok = kb.tap_char(char) + if ok: + state.key_sound_tick() finally: # ALWAYS close the row: if the injection throws, an open row would # make every later hold queue a ("repeat", coord) and silently break From 094812146de3ed51b41ce5f7d473e4d2e5e3ba5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 21:00:00 +0200 Subject: [PATCH 4/6] feat: lift mode shows accents on pad press-click; drop the hover gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - With Lift-Off Typing on, pressing the pad click on a variant-capable key opens its accent row instantly — slide to pick, release commits; without lift-off, quick press still types the base and hold-to-open stays at 0.5 s - The hover/rest gesture and its fill countdown are removed outright (trackers, tests, README wording) - PR template: 'Manual verification' replaced by 'Added test coverage' --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- README.md | 4 +- windows/tests/test_hover_diacritics.py | 334 ----------------------- windows/tests/test_layouts_liftoff.py | 10 - windows/tests/test_lift_click_accents.py | 277 +++++++++++++++++++ windows/triton/controller.py | 10 - windows/triton/pad.py | 146 +++------- windows/triton/vkb.py | 6 +- 8 files changed, 315 insertions(+), 476 deletions(-) delete mode 100644 windows/tests/test_hover_diacritics.py create mode 100644 windows/tests/test_lift_click_accents.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 00883bb..2d093b1 100755 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,6 @@ What does this PR do? ## Screenshots -## Manual verification +## Added test coverage -What did you test on hardware? (Steam Controller chord open/close, touchpad typing, split layout, touchscreen taps, tray toggles.) \ No newline at end of file +Which behaviors got new/updated headless tests? (pytest suite, `python -m pytest tests -q`.) diff --git a/README.md b/README.md index cd89dae..7ad636b 100755 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ It is a Windows-only fork of [SteamlessKeyboard](https://github.com/PietPetGit/S - **Global overlay** — draws above every app, not tied to Big Picture or a launcher. - **Native SDL3 rendering** — low CPU/memory use; skins, transparency, and size selectable from the tray. -- **Steam Controller input** — two-finger trackpad typing with hit-target expansion and debounce, DPAD/stick navigation, auto-repeat on held keys, configurable insert control (bumpers or triggers), Lift-Off Typing (insert on finger release), hover-to-open accented variants while Lift-Off Typing is on (rest a finger on a key for half a second, then slide to an accent; click-hold opens the same row), six selectable key layouts (language boards inject their symbols as characters, so they type true on any Windows input language). +- **Steam Controller input** — two-finger trackpad typing with hit-target expansion and debounce, DPAD/stick navigation, auto-repeat on held keys, configurable insert control (bumpers or triggers), Lift-Off Typing (insert on finger release; the pad press-click then shows a key's accented variants — slide to pick, release commits), click-hold opens accent rows without Lift-Off Typing, six selectable key layouts (language boards inject their symbols as characters, so they type true on any Windows input language). - **Steam Input coexistence** — reads the shared HID alongside Steam Input, switches to a keyboard layer via `steam://forceinputappid`, and restores your config on close. - **Opens with a chord** — Steam + X/Y/A/B (configurable) opens the keyboard; Steam's own menu is suppressed via a Guide Button Chord dead-binding. - **Per-app memory** — remembers the OSK size, skin, and position per foreground app. @@ -85,7 +85,7 @@ menus), or by hand-editing `settings.json` for finer control. | `sc_click_button` | `"L1/R1"` | Click insert per side: `"L1/R1"` (bumpers) or `"L2/R2"` (triggers) | | `sc_pad_click_enter` | `false` | Trackpad press-click inserts the key under the pointer | | `sc_pad_click_engage` | `2500` | Pad force that fires the pad-click insert | -| `sc_liftoff_enter` | `false` | Lift-Off Typing: insert the key under the pointer when the finger lifts off the pad (no click needed); also enables hover-to-open accented variants | +| `sc_liftoff_enter` | `false` | Lift-Off Typing: insert the key under the pointer when the finger lifts off the pad (no click needed); the pad press-click then opens a key's accented variants | | `osk_layout` | `"QWERTY"` | Key layout: `"QWERTY"`/`"AZERTY"`/`"QWERTZ"`/`"Dvorak"`/`"Colemak"`/`"ABC"` (tray "Keyboard Layout"; applied at the next open) | | `sc_left_stick_nav` | `true` | Sticks control the keyboard while it is open | | `sc_osk_trigger_actuation` | `"default"` | L2/R2 actuation point: `"default"` or `"low"` | diff --git a/windows/tests/test_hover_diacritics.py b/windows/tests/test_hover_diacritics.py deleted file mode 100644 index bdcaa04..0000000 --- a/windows/tests/test_hover_diacritics.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Headless tests for hover-to-open diacritics. - -A real finger resting on a variant-capable key WITHOUT any click activity -opens that key's variant row after ACCENT_HOVER_OPEN seconds. Hover is -gated on Lift-Off Typing — resting a finger is a lift-native gesture, so -with lift-off off the countdown never runs. Hover-opened rows commit on the -finger LIFT (no click will ever release), while moving to another key, -click activity, or lifting before the threshold cancels. -""" - -from collections import deque - -import pytest - -from triton import state -from triton.pad import _PadMixin -from triton.screen import CoordFraction - - -LPADTOUCH, LT, LPAD = 0x00000200, 0x00000100, 0x00000400 - - -@pytest.fixture(autouse=True) -def _clean_state(): - state.reset_session() - state.set_diacritics_enabled(True) - # Most tests exercise the hover flow itself; the liftoff-dependent - # ones flip this explicitly. - state.set_sc_liftoff_enter(True) - yield - state.set_sc_liftoff_enter(False) - state.set_diacritics_enabled(True) - - -def _build_kb(): - from triton import config, layouts, vkb - - cfg = config.YamlFile(layouts.layout_filename("QWERTY")) - cfg.read() - cfg.add_to_config("keys", vkb.VirtualKeyboardConfig()) - kb = vkb.VirtualKeyboardConfig().construct() - kb.update_dimensions() - return kb - - -class _KB: - def __init__(self): - self.downs = [] - - def pressEvent(self, keys): - self.downs.append(keys) - - def releaseEvent(self, keys): - pass - - -class _P: - def __init__(self, buttons): - self.buttons = buttons - - -class _CS: - def __init__(self): - self.click_queue = deque() - - -class _D(_PadMixin): - _kb: _KB - sc_input_previous: _P - controller_state: _CS - - BACKSPACE_HOLD_DELAY = 0.5 - BACKSPACE_REPEAT = 0.05 - PAD_CLICK_SETTLE = 0.05 - - def __init__(self): - self._kb = _KB() - self._select_pad = None - self._diacritic_pad = None - self._deferred_base = {} - self._click_repeat_at = {} - self._click_settle_at = {} - self._select_real_touch = False - self._select_dir = 0 - self._select_base_dir = 0 - self._select_reverse_buffer = [] - self._liftoff_touch = {} - self._liftoff_coord = {} - self._liftoff_clicked = {} - self._liftoff_t0 = {} - self._hover_start = {} - self._hover_rc = {} - self._diacritic_hover = {} - self.sc_input_previous = _P(0) - self.controller_state = _CS() - self._prev = 0 - - def frame(self, buttons, cf, raw_x, now, real_touch=True): - self.sc_input_previous = _P(self._prev) - r = self.handle_pad_input( - cf, - buttons, - LPADTOUCH, - LT, - click_button_mask=LPAD, - allow_click=True, - now=now, - trigger_pressed=False, - trigger_prev=False, - raw_x=raw_x, - real_touch=real_touch, - ) - self._prev = buttons - return r - - -def _center_cf(kb, row, col): - layout = kb.get_key_layout(row, col) - assert layout is not None - return CoordFraction.from_absolute( - layout.x + layout.w // 2, layout.y + layout.h // 2 - ) - - -def _rest(d, cf, t, seconds, step=0.1, real_touch=True): - """Feed resting-finger frames for `seconds`, returning the new clock.""" - n = int(round(seconds / step)) - for _ in range(n): - d.frame(LPADTOUCH, cf, 0, t, real_touch=real_touch) - t += step - return t - - -def test_hover_opens_variant_row_after_the_window(): - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) # 'a' - t = 1000.0 - t = _rest(d, cf, t, 0.4) - assert not state.is_diacritic_open() - t = _rest(d, cf, t, 0.2) - # The frame crossing ACCENT_HOVER_OPEN (0.5 s) opens the row and latches - # it as hover-opened. - assert state.is_diacritic_open() - assert d._diacritic_pad == LT - assert d._diacritic_hover.get(LT) is True - - -def test_hover_silent_while_counting_down(): - """Resting must never type anything — the insert only comes from the - opened row's commit (or the ordinary click paths).""" - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 0.4) - assert list(d.controller_state.click_queue) == [] - assert not state.is_diacritic_open() - - -def test_hover_moving_to_another_key_restarts(): - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - a = _center_cf(kb, 3, 1) - s = _center_cf(kb, 3, 2) - t = 1000.0 - t = _rest(d, a, t, 0.4) - # Slide one key over: the countdown starts from zero there. - t = _rest(d, s, t, 0.4) - assert not state.is_diacritic_open() - # ...and back to 'a': another full window from scratch. - t = _rest(d, a, t, 0.4) - assert not state.is_diacritic_open() - t = _rest(d, a, t, 0.2) - assert state.is_diacritic_open() - - -def test_hover_cancelled_by_click_activity(): - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 0.4) - # A pad click interrupts the rest (press edge + release while touching). - d.frame(LPADTOUCH | LPAD, cf, 0, t) - t += 0.05 - d.frame(LPADTOUCH, cf, 0, t) - t += 0.05 - # More than the original window remains under the threshold from NOW: - # a further 0.4 s of rest must NOT open (the countdown restarted). - t = _rest(d, cf, t, 0.4) - assert not state.is_diacritic_open() - t = _rest(d, cf, t, 0.2) - assert state.is_diacritic_open() - - -def test_hover_cancelled_by_finger_lift_mid_countdown(): - """Lifting the finger before the threshold kills the half-counted rest: - the countdown restarts from zero on the next touch. (Hover needs - Lift-Off Typing on, so the mid-rest lift also fires its ordinary - lift-off insert — asserted separately in test_layouts_liftoff.)""" - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 0.3) - assert not state.is_diacritic_open() - # The finger lifts mid-countdown... - t += 0.01 - d.frame(0, cf, 0, t, real_touch=False) - # ...and comes back down: a fresh window starts from zero. - t += 0.05 - d.frame(LPADTOUCH, cf, 0, t) - t = _rest(d, cf, t + 0.1, 0.3) - assert not state.is_diacritic_open() - t = _rest(d, cf, t, 0.2) - assert state.is_diacritic_open() - - -def test_hover_never_counts_synthetic_touch(): - """Only the REAL finger counts — the synthesized touch of a held click - button (or a stick-driven pointer) must not open rows by itself.""" - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 3.0, real_touch=False) - assert not state.is_diacritic_open() - - -def test_hover_disabled_when_diacritics_off(): - kb = _build_kb() - state.set_virtual_kb(kb) - state.set_diacritics_enabled(False) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 3.0) - assert not state.is_diacritic_open() - - -def test_hover_disabled_when_liftoff_off(): - """Resting a finger is a lift-native gesture: with Lift-Off Typing off - the countdown never runs.""" - kb = _build_kb() - state.set_virtual_kb(kb) - state.set_sc_liftoff_enter(False) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 3.0) - assert not state.is_diacritic_open() - - -def test_hover_row_commits_first_variant_on_finger_lift(): - kb = _build_kb() - state.set_virtual_kb(kb) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 2.25) - assert state.is_diacritic_open() - # Resting frames past the opening keep highlighting whatever sits under - # the finger inside the row, so read the CURRENT selection rather than - # assuming the first variant. - expected = state.get_diacritic_selected_char() - if expected is None: - expected = state.get_diacritic_variants_list()[0] - # Finger lifts (no click was ever held): commit the highlighted variant. - t += 0.01 - d.frame(0, cf, 0, t, real_touch=False) - queue = list(d.controller_state.click_queue) - assert queue == [("variant", expected)] - # The row itself is closed by commit_diacritic when the MAIN thread - # consumes the queued commit; the pad side must already be unlatched: - assert d._diacritic_pad is None - assert d._diacritic_hover.get(LT) is None - - -def test_accent_commit_plays_the_click_sound(monkeypatch): - """Typing an accented variant clicks — one tick per inserted character. - A failed injection stays silent.""" - from triton import vkb - - kb = _build_kb() - state.set_virtual_kb(kb) - ticks = [] - # Earlier runner-fixture tests leave key sound globally disabled - # (state_cleanup); this test owns both knobs and restores them. - saved_enabled = state.is_key_sound_enabled() - state.set_key_sound_enabled(True) - state.set_key_sound(lambda: ticks.append(1)) - try: - monkeypatch.setattr(vkb.kb, "tap_char", lambda ch: True) - assert vkb.open_diacritic_rc(kb, 3, 1, "pad") - state.set_diacritic_index(0) - vkb.commit_diacritic() - assert ticks == [1] - - monkeypatch.setattr(vkb.kb, "tap_char", lambda ch: False) - assert vkb.open_diacritic_rc(kb, 3, 1, "pad") - state.set_diacritic_index(0) - vkb.commit_diacritic() - assert ticks == [1] - finally: - state.set_key_sound(None) - state.set_key_sound_enabled(saved_enabled) - - -def test_hover_row_lift_does_not_double_fire_liftoff_insert(): - """With Lift-Off Typing ALSO enabled, an opened row's lift commits the - variant only — the lift-off insert must stay suppressed for the pad - that owns the row.""" - kb = _build_kb() - state.set_virtual_kb(kb) - state.set_sc_liftoff_enter(True) - d = _D() - cf = _center_cf(kb, 3, 1) - t = 1000.0 - t = _rest(d, cf, t, 2.25) - assert state.is_diacritic_open() - expected = state.get_diacritic_selected_char() - if expected is None: - expected = state.get_diacritic_variants_list()[0] - t += 0.01 - d.frame(0, cf, 0, t, real_touch=False) - queue = list(d.controller_state.click_queue) - assert queue == [("variant", expected)] diff --git a/windows/tests/test_layouts_liftoff.py b/windows/tests/test_layouts_liftoff.py index af47fcb..80a3629 100644 --- a/windows/tests/test_layouts_liftoff.py +++ b/windows/tests/test_layouts_liftoff.py @@ -180,9 +180,6 @@ def __init__(self): self._liftoff_coord = {} self._liftoff_clicked = {} self._liftoff_t0 = {} - self._hover_start = {} - self._hover_rc = {} - self._diacritic_hover = {} self.sc_input_previous = _P(0) self.controller_state = _CS() self._prev = 0 @@ -341,18 +338,11 @@ def addExit(self): mgr._liftoff_coord = {LT: _a_center_cf(_build_kb())} mgr._liftoff_clicked = {LT: False} mgr._liftoff_t0 = {LT: 1.0} - # Hover-to-open trackers are cleared alongside the lift-off ones. - mgr._hover_start = {LT: 2.0} - mgr._hover_rc = {LT: (3, 1)} - mgr._diacritic_hover = {LT: True} mgr.release_held() assert mgr._liftoff_touch == {} assert mgr._liftoff_coord == {} assert mgr._liftoff_clicked == {} assert mgr._liftoff_t0 == {} - assert mgr._hover_start == {} - assert mgr._hover_rc == {} - assert mgr._diacritic_hover == {} def test_state_roundtrip(): diff --git a/windows/tests/test_lift_click_accents.py b/windows/tests/test_lift_click_accents.py new file mode 100644 index 0000000..7634b4d --- /dev/null +++ b/windows/tests/test_lift_click_accents.py @@ -0,0 +1,277 @@ +"""Headless tests for lift-mode accent opening. + +With Lift-Off Typing ON, pressing the pad click on a variant-capable key +opens its accent row INSTANTLY — no hold, no rest gesture. The click's +release commits the highlighted variant (first variant when nothing was +picked), and the touch's later lift stays silent because the click consumed +the insert. With Lift-Off Typing OFF nothing changes: a quick press defers +the base letter, and only holding past ACCENT_HOLD_OPEN opens the row. +""" + +from collections import deque + +import pytest + +from triton import state +from triton.pad import _PadMixin +from triton.screen import CoordFraction + + +LPADTOUCH, LT, LPAD = 0x00000200, 0x00000100, 0x00000400 +RPADTOUCH, RT, RPAD = 0x00000800, 0x00001000, 0x00002000 + + +@pytest.fixture(autouse=True) +def _clean_state(): + state.reset_session() + state.set_diacritics_enabled(True) + yield + state.set_sc_liftoff_enter(False) + state.set_diacritics_enabled(True) + + +def _build_kb(): + from triton import config, layouts, vkb + + cfg = config.YamlFile(layouts.layout_filename("QWERTY")) + cfg.read() + cfg.add_to_config("keys", vkb.VirtualKeyboardConfig()) + kb = vkb.VirtualKeyboardConfig().construct() + kb.update_dimensions() + return kb + + +class _KB: + def __init__(self): + self.downs = [] + + def pressEvent(self, keys): + self.downs.append(keys) + + def releaseEvent(self, keys): + pass + + +class _P: + def __init__(self, buttons): + self.buttons = buttons + + +class _CS: + def __init__(self): + self.click_queue = deque() + + +class _D(_PadMixin): + _kb: _KB + sc_input_previous: _P + controller_state: _CS + + BACKSPACE_HOLD_DELAY = 0.5 + BACKSPACE_REPEAT = 0.05 + PAD_CLICK_SETTLE = 0.05 + + def __init__(self): + self._kb = _KB() + self._select_pad = None + self._diacritic_pad = None + self._deferred_base = {} + self._click_repeat_at = {} + self._click_settle_at = {} + self._select_real_touch = False + self._select_dir = 0 + self._select_base_dir = 0 + self._select_reverse_buffer = [] + self._liftoff_touch = {} + self._liftoff_coord = {} + self._liftoff_clicked = {} + self._liftoff_t0 = {} + self.sc_input_previous = _P(0) + self.controller_state = _CS() + self._prev = 0 + + def frame(self, buttons, cf, raw_x, now, real_touch=True, + touch_mask=LPADTOUCH, sel_mask=LT, click_mask=LPAD): + self.sc_input_previous = _P(self._prev) + r = self.handle_pad_input( + cf, + buttons, + touch_mask, + sel_mask, + click_button_mask=click_mask, + allow_click=True, + now=now, + trigger_pressed=False, + trigger_prev=False, + raw_x=raw_x, + real_touch=real_touch, + ) + self._prev = buttons + return r + + +def _center_cf(kb, row, col): + layout = kb.get_key_layout(row, col) + assert layout is not None + return CoordFraction.from_absolute( + layout.x + layout.w // 2, layout.y + layout.h // 2 + ) + + +def test_lift_mode_press_opens_accent_row_instantly(): + """Lift-Off Typing on: the pad press itself shows the accents — no hold.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _center_cf(kb, 3, 1) # 'a' + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + assert not state.is_diacritic_open() + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) # pad-click press edge + assert state.is_diacritic_open() + assert state.get_diacritic_source() == "pad" + assert d._diacritic_pad == LT + + +def test_without_lift_mode_press_stays_deferred(): + """Lift-Off Typing off: a quick press must NOT open anything — the base + letter comes on release exactly as before.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(False) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) + assert not state.is_diacritic_open() + t += 0.02 + d.frame(LPADTOUCH, cf, 0, t) # click released + assert list(d.controller_state.click_queue) == [("deferred", cf)] + + +def test_without_lift_mode_hold_still_opens_after_the_window(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(False) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) # press edge arms ACCENT_HOLD_OPEN + t += 0.4 + d.frame(LPADTOUCH | LPAD, cf, 0, t) + assert not state.is_diacritic_open() + t += 0.2 # crosses 0.5 s of hold + d.frame(LPADTOUCH | LPAD, cf, 0, t) + assert state.is_diacritic_open() + assert d._diacritic_pad == LT + + +def test_lift_mode_release_commits_highlighted_variant(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) + assert state.is_diacritic_open() + state.set_diacritic_index(1) + variants = state.get_diacritic_variants_list() + t += 0.02 + d.frame(LPADTOUCH, cf, 0, t) # click released → commit the pick + assert list(d.controller_state.click_queue) == [("variant", variants[1])] + # The pad side unlatched even though the MAIN thread closes the row when + # it consumes the commit. + assert d._diacritic_pad is None + + +def test_lift_mode_release_without_pick_commits_first_variant(): + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) + state.set_diacritic_index(-1) # simulate "no explicit pick" + variants = state.get_diacritic_variants_list() + t += 0.02 + d.frame(LPADTOUCH, cf, 0, t) + assert list(d.controller_state.click_queue) == [("variant", variants[0])] + + +def test_lift_mode_accent_click_never_double_inserts_on_lift(): + """The whole accent gesture consumes the touch: after the click released + and committed, lifting the finger must stay silent.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) # row opens + t += 0.02 + d.frame(LPADTOUCH, cf, 0, t) # click releases, commits v0 + committed = [ + item for item in d.controller_state.click_queue if isinstance(item, tuple) + ] + assert len(committed) == 1 + t += 0.08 + d.frame(0, cf, 0, t, real_touch=False) # finger lifts + assert [item for item in d.controller_state.click_queue if not isinstance(item, tuple)] == [] + + +def test_lift_mode_press_types_immediately_when_diacritics_off(): + """No variants configured: the press behaves like any normal key press.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + state.set_diacritics_enabled(False) + d = _D() + cf = _center_cf(kb, 3, 1) + t = 1000.0 + d.frame(LPADTOUCH, cf, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, cf, 0, t) + assert not state.is_diacritic_open() + assert list(d.controller_state.click_queue) == [cf] + + +def test_row_already_open_second_pad_press_defers(): + """With a row open on the left pad, a right-pad press on another variant + key can't open a second row — it falls back to the plain defer model.""" + kb = _build_kb() + state.set_virtual_kb(kb) + state.set_sc_liftoff_enter(True) + d = _D() + left = _center_cf(kb, 3, 1) + right = _center_cf(kb, 3, 2) + t = 1000.0 + d.frame(LPADTOUCH, left, 0, t) + t += 0.02 + d.frame(LPADTOUCH | LPAD, left, 0, t) + assert state.is_diacritic_open() + t += 0.02 + d.frame(RPADTOUCH | RPAD, right, 0x7FFF, t, + touch_mask=RPADTOUCH, sel_mask=RT, click_mask=RPAD) + assert d._deferred_base.get(RT) is not None + t += 0.02 + d.frame(RPADTOUCH, right, 0x7FFF, t, + touch_mask=RPADTOUCH, sel_mask=RT, click_mask=RPAD) + assert list( + item + for item in d.controller_state.click_queue + if isinstance(item, tuple) and item[0] == "deferred" + ) == [("deferred", right)] diff --git a/windows/triton/controller.py b/windows/triton/controller.py index c0eea6c..55a204e 100755 --- a/windows/triton/controller.py +++ b/windows/triton/controller.py @@ -232,12 +232,6 @@ def _map_events(self): self._liftoff_coord = {} self._liftoff_clicked = {} self._liftoff_t0 = {} - # Per-pad hover-to-open diacritics trackers (see pad._PadMixin): - # hover countdown start time, hovered key (row, col), and which pads' - # open variant rows came from a hover. - self._hover_start = {} - self._hover_rc = {} - self._diacritic_hover = {} # LT's role is decided on its rising edge from whether the left pad was # being touched: "shift" (pressed untouched) or "click" (pressed while # touching). Latched until LT is released so a later touch can't flip it. @@ -1068,10 +1062,6 @@ def release_held(self): self._liftoff_coord.clear() self._liftoff_clicked.clear() self._liftoff_t0.clear() - # And the hover-to-open trackers with them. - self._hover_start.clear() - self._hover_rc.clear() - self._diacritic_hover.clear() def update(sc, sc_input, manager): diff --git a/windows/triton/pad.py b/windows/triton/pad.py index 651d1f9..688e27e 100755 --- a/windows/triton/pad.py +++ b/windows/triton/pad.py @@ -41,9 +41,6 @@ class _PadMixin: _liftoff_coord: dict _liftoff_clicked: dict _liftoff_t0: dict - _hover_start: dict - _hover_rc: dict - _diacritic_hover: dict _pad_click_engage: float _pad_click_release: float _pad_press_hold: float @@ -98,36 +95,19 @@ class _PadMixin: _liftoff_coord = {} _liftoff_clicked = {} _liftoff_t0 = {} - # Hover-to-open diacritics per-pad trackers, keyed like every other - # per-pad dict: the monotonic time the finger STARTED resting on the - # currently hovered variant-capable key, and that key's (row, col) — - # moving to a different key restarts the countdown. Overridden with - # fresh dicts by ControllerManager/__init__; class defaults are safety - # nets for standalone test harnesses. - _hover_start = {} - _hover_rc = {} - # Which pads' open diacritic rows came from a HOVER (no click held): - # those rows commit when the real finger lifts off the pad instead of - # waiting for a click/trigger release that will never come. - _diacritic_hover = {} # Minimum real-finger contact (s) before a lift counts as an intentional # tap: brushes past the pad on the way to/from a button are shorter than # any deliberate tap (~50-120 ms) and must stay silent. Mirrors the # `now > 0` convention of the settle window — harness calls with now=0 # bypass the duration gate. LIFTOFF_MIN_TOUCH = 0.04 - # Seconds before a variant-capable key's accent row opens by HOVER: - # a real finger merely RESTING on the key (no click, no other session). - # Only active while Lift-Off Typing is enabled (hover is a lift-native - # gesture; with clicks it would fire accidentally while the pad is used - # as a mouse). No visual countdown — the row simply opens. - ACCENT_HOVER_OPEN = 0.5 # Seconds before a variant-capable key's accent row opens by HOLD: # the key is CLICKED and held down this long ("hold to show more - # letters"). Kept identical to ACCENT_HOVER_OPEN so both gestures feel - # the same; only variant-capable presses arm this clock, other keys + # letters"). Only variant-capable presses arm this clock, other keys # keep the shorter BACKSPACE_HOLD_DELAY so backspace rub-out cadence is - # unchanged. + # unchanged. With Lift-Off Typing enabled the row opens INSTANTLY on the + # pad press instead (the press IS the accent gesture then), so this + # clock only matters for lift-off-off holds. ACCENT_HOLD_OPEN = 0.5 # Minimum gap (s) between two "enter the key" edges on the same pad — # debounce/settle. A physical pad click can wobble: the force dips below @@ -176,9 +156,7 @@ def handle_pad_input( self._liftoff_t0[repeat_key] = now self._liftoff_coord[repeat_key] = coord_frac elif lt_touch_was: - # The real finger left the pad: any hover-to-open countdown dies - # with it. - self._cancel_hover(repeat_key) + # The real finger left the pad. clicked = self._liftoff_clicked.get(repeat_key, False) long_enough = now <= 0 or ( now - self._liftoff_t0.get(repeat_key, 0.0) @@ -243,15 +221,11 @@ def handle_pad_input( # the same frame the click releases, so with the finger already # lifted the gate below would return INACTIVE and the release — # and its commit — would never run (the row would stay latched, - # swallowing every later press on this pad). Hover-opened rows - # have no click to release: they end when the REAL finger lifts. + # swallowing every later press on this pad). click_held = (allow_click and trigger_pressed) or bool( buttons & click_button_mask ) - if not click_held and not ( - self._diacritic_hover.get(repeat_key, False) - and bool(real_touch) - ): + if not click_held: self._end_diacritic_pad() return state.InputState.INACTIVE # Self-heal: the row is latched to this pad but the diacritic @@ -259,7 +233,6 @@ def handle_pad_input( # latch so it can't swallow every later press on this pad. if not state.is_diacritic_open(): self._diacritic_pad = None - self._diacritic_hover.pop(repeat_key, None) self._click_repeat_at.pop(repeat_key, None) elif repeat_key in self._deferred_base: # A deferred variant-capable press (the base was NOT typed at the @@ -340,10 +313,8 @@ def handle_pad_input( # variant row (see _try_open_diacritic). While the press stays engaged # the finger's position within the row highlights the variant; a # release commits it (the base already fired on the press edge). - # Hover-opened rows behave the same but with no click held — the - # finger alone drives the highlight and its lift commits. if self._diacritic_pad == repeat_key: - if click_active or self._diacritic_hover.get(repeat_key, False): + if click_active: rect = state.get_diacritic_rect() if rect is not None: px, py = coord_frac.to_absolute() @@ -352,9 +323,7 @@ def handle_pad_input( rect, px, py, state.get_diacritic_variant_count() ) ) - if click_active: - return state.InputState.CLICK - return state.InputState.HOVER + return state.InputState.CLICK self._end_diacritic_pad() return state.InputState.INACTIVE if trigger_edge or pad_edge: @@ -391,17 +360,30 @@ def handle_pad_input( ) else: # Defer model (Feature B): a press landing on a variant-capable - # letter does NOT type anything at the press edge. The hold - # opens its variant row (see _try_open_diacritic); the release - # then types either the picked variant or the base letter. A - # QUICK tap just types the base on release. Non-variant keys - # keep firing at the press edge exactly as before. + # letter does NOT type anything at the press edge. With + # Lift-Off Typing enabled the press IS the accent gesture: the + # row opens INSTANTLY (the click's release then commits the + # picked variant). Without lift-off, holding past the accent + # window opens the row instead; a quick tap types the base on + # release. Non-variant keys keep firing at the press edge. deferred = self._should_defer_press(coord_frac) + opened_row = ( + deferred + and state.is_sc_liftoff_enabled() + and self._try_open_diacritic(coord_frac, repeat_key) + ) if deferred: - self._deferred_base[repeat_key] = coord_frac - self._diag( - "defer press k={} cf={!r}", repeat_key, coord_frac - ) + if not opened_row: + self._deferred_base[repeat_key] = coord_frac + self._diag( + "defer press k={} cf={!r}", repeat_key, coord_frac + ) + else: + self._diag( + "lift-click accents k={} cf={!r}", + repeat_key, + coord_frac, + ) # Click sound fires at the PRESS edge like any other key — # the release commit types the char but must not re-tick, # or a held variant pick would sound laggy. @@ -438,11 +420,6 @@ def handle_pad_input( self._click_repeat_at[repeat_key] = now + self.BACKSPACE_REPEAT if not click_active: self._click_repeat_at.pop(repeat_key, None) - # Hover-to-open diacritics: a resting finger (no click, no session) - # fills the variant-capable key under it and opens its row at 100%. - self._update_hover( - coord_frac, repeat_key, now, click_active, bool(real_touch) - ) if click_active: return state.InputState.CLICK return state.InputState.HOVER @@ -522,66 +499,6 @@ def _try_open_diacritic(self, coord_frac, repeat_key): self._diacritic_pad = repeat_key return True - def _cancel_hover(self, repeat_key): - """Drop this pad's hover-to-open countdown.""" - self._hover_start.pop(repeat_key, None) - self._hover_rc.pop(repeat_key, None) - - def _update_hover(self, coord_frac, repeat_key, now, click_active, touched): - """Hover-to-open (diacritics): a real finger RESTING on a variant- - capable key — no click activity, no other session on this pad — - opens its variant row after ACCENT_HOVER_OPEN seconds. The row is - marked as hover-opened so the finger's lift commits the highlighted - variant. Any click activity, moving to a different key, or lifting - cancels the countdown; the press-hold path stays available as the - secondary entry. - - Gated on Lift-Off Typing: resting a finger is a lift-native gesture — - with lift-off off the countdown never runs (and any stale one is - dropped), leaving press-hold as the only row entry.""" - if not state.is_sc_liftoff_enabled(): - self._cancel_hover(repeat_key) - return - if ( - click_active - or self._select_pad == repeat_key - or self._diacritic_pad == repeat_key - or repeat_key in self._deferred_base - or now <= 0 # mirrors the settle-window `now > 0` convention - ): - self._cancel_hover(repeat_key) - return - if not touched or coord_frac is None: - self._cancel_hover(repeat_key) - return - kb = state.get_virtual_kb() - if kb is None: - return - x, y = coord_frac.to_absolute() - rc = kb.find_key_expanded_rc(x, y) - if ( - rc is None - or vkb.diacritic_variants_for_key(kb.keys[rc[0]][rc[1]]) is None - ): - # Not resting on a variant-capable key: drop the countdown. - self._cancel_hover(repeat_key) - return - # Restart-proof accumulation: never assume the rc/start pair is in - # sync — any missing half (fresh pad, cancelled countdown, teardown) - # simply starts a new window. - start = self._hover_start.get(repeat_key) - if self._hover_rc.get(repeat_key) != rc or start is None: - self._hover_rc[repeat_key] = rc - start = self._hover_start[repeat_key] = now - elapsed = now - start - if elapsed >= self.ACCENT_HOVER_OPEN: - self._cancel_hover(repeat_key) - if self._try_open_diacritic(coord_frac, repeat_key): - self._diacritic_hover[repeat_key] = True - self._diag( - "hover open k={} rc={}/{}", repeat_key, rc[0], rc[1] - ) - def _end_diacritic_pad(self): """End the held-to-extend session for this pad: the press released. In the DEFER model the base was never typed at the press edge, so a @@ -623,7 +540,6 @@ def _end_diacritic_pad(self): state.close_diacritic() finally: self._diacritic_pad = None - self._diacritic_hover.pop(repeat_key, None) self._click_repeat_at.pop(repeat_key, None) def _fire_arrow(self, dirn): diff --git a/windows/triton/vkb.py b/windows/triton/vkb.py index 5858e8d..6760acd 100755 --- a/windows/triton/vkb.py +++ b/windows/triton/vkb.py @@ -1043,9 +1043,9 @@ def commit_diacritic(char=None): variant via uinput.tap_char. There is no Backspace to undo: the base never stood. `char` is read from the open row session when not given. Closes the row FIRST and in a finally. The click sound fires HERE, on the typed - variant — one tick per inserted character. On the hold path the - press-edge tick (see pad/controller defer handling) reads as the - down-stroke and this as the release; on the hover/mouse/A paths this is + variant — one tick per inserted character. On the hold/lift-click paths + the press-edge tick (see pad/controller defer handling) reads as the + down-stroke and this as the release; on the mouse/A paths this is the only tick, so hitting an accent key always clicks. A base selection (index -1) is a no-op: the caller types the base letter on release instead.""" From ab352481a6ad43a56acf5b3aad1d41d7be36bdf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 21:17:26 +0200 Subject: [PATCH 5/6] chore: pre-commit fixes across the branch - ruff: zip(strict=), drop unused locals, reformat 6 files, strip two UTF-8 BOMs from test docstrings - basedpyright: guard the diacritic highlight against a None coordinate (reportOptionalMemberAccess) --- windows/tests/test_layout_consistency.py | 18 ++++----- windows/tests/test_layouts_liftoff.py | 3 -- windows/tests/test_lift_click_accents.py | 49 ++++++++++++++++++------ windows/tests/vkb/test_diacritics.py | 2 +- windows/tray/app.py | 8 ++-- windows/triton/pad.py | 6 +-- windows/triton/screen.py | 8 ++-- 7 files changed, 57 insertions(+), 37 deletions(-) diff --git a/windows/tests/test_layout_consistency.py b/windows/tests/test_layout_consistency.py index ed0ee09..8208725 100644 --- a/windows/tests/test_layout_consistency.py +++ b/windows/tests/test_layout_consistency.py @@ -19,7 +19,6 @@ import pytest import steamcontroller.uinput as sui - from triton import state, vkb from triton.layouts import layout_filename @@ -46,7 +45,7 @@ def _table(pairs): def _us_table(): table = {} - for digit, shifted in zip("1234567890", "!@#$%^&*()"): + for digit, shifted in zip("1234567890", "!@#$%^&*()", strict=True): table[f"KEY_{digit}"] = (digit, shifted) for ch in "abcdefghijklmnopqrstuvwxyz": table[f"KEY_{ch.upper()}"] = (ch, ch.upper()) @@ -76,7 +75,7 @@ def _us_table(): # Native characters every board variant MUST offer (base or shifted labels) # — the whole point of these boards. AZERTY_REQUIRED = '²&é"' + "'(-è_çà)°=+^¨$£*µù%,?;.:/!§" -QWERTZ_REQUIRED = '^°!"§$%/()=ß?´`üÜ+*#\'öÖäÄyz;,.:-_' +QWERTZ_REQUIRED = "^°!\"§$%/()=ß?´`üÜ+*#'öÖäÄyz;,.:-_" def _labels(kb): @@ -160,8 +159,7 @@ def test_strict_boards_need_neither_overrides_nor_char_mode(name): ) base, shifted = pair assert key.str == base, ( - f"{name}: key prints {key.str!r} but its scancode types " - f"{base!r}" + f"{name}: key prints {key.str!r} but its scancode types {base!r}" ) assert key.shifted == shifted, ( f"{name}: shift-label above {base!r} should be {shifted!r}, " @@ -195,9 +193,10 @@ def test_char_mode_key_injects_its_label(monkeypatch): """The user's bug, end to end: on a machine whose OS layout has no `$` (VkKeyScanW misses), pressing the AZERTY `$` key still types `$` via the KEYEVENTF_UNICODE fallback.""" - import applog import ctypes + import applog + applog.set_logging_enabled(False) kb = _board("AZERTY") @@ -206,9 +205,6 @@ def test_char_mode_key_injects_its_label(monkeypatch): calls = {} user32 = sui._U32 - real_send = user32.SendInput - real_vkscan = user32.VkKeyScanW - real_openclip = user32.OpenClipboard def _stub_vkscan(_ch): return -1 # active layout has no such key -> UNICODE fallback @@ -225,7 +221,9 @@ def fake_send(n, inputs, cb): return int(n) monkeypatch.setattr(user32, "VkKeyScanW", _stub_vkscan, raising=False) - monkeypatch.setattr(user32, "OpenClipboard", _stub_openclipboard, raising=False) + monkeypatch.setattr( + user32, "OpenClipboard", _stub_openclipboard, raising=False + ) monkeypatch.setattr(user32, "SendInput", fake_send, raising=False) try: state.reset_session() diff --git a/windows/tests/test_layouts_liftoff.py b/windows/tests/test_layouts_liftoff.py index 80a3629..6e23136 100644 --- a/windows/tests/test_layouts_liftoff.py +++ b/windows/tests/test_layouts_liftoff.py @@ -13,7 +13,6 @@ from collections import deque import pytest - from triton import state from triton.layouts import ( DEFAULT_LAYOUT, @@ -24,7 +23,6 @@ from triton.pad import _PadMixin from triton.screen import CoordFraction - # --- Layout registry --------------------------------------------------------- @@ -72,7 +70,6 @@ def test_user_added_board_roundtrip(tmp_path, monkeypatch): def test_load_kb_config_builds_selected_layout(): import steamcontroller.uinput as sui - from triton.triton import load_kb_config def probe(name): diff --git a/windows/tests/test_lift_click_accents.py b/windows/tests/test_lift_click_accents.py index 7634b4d..0f72377 100644 --- a/windows/tests/test_lift_click_accents.py +++ b/windows/tests/test_lift_click_accents.py @@ -1,4 +1,4 @@ -"""Headless tests for lift-mode accent opening. +"""Headless tests for lift-mode accent opening. With Lift-Off Typing ON, pressing the pad click on a variant-capable key opens its accent row INSTANTLY — no hold, no rest gesture. The click's @@ -11,12 +11,10 @@ from collections import deque import pytest - from triton import state from triton.pad import _PadMixin from triton.screen import CoordFraction - LPADTOUCH, LT, LPAD = 0x00000200, 0x00000100, 0x00000400 RPADTOUCH, RT, RPAD = 0x00000800, 0x00001000, 0x00002000 @@ -90,8 +88,17 @@ def __init__(self): self.controller_state = _CS() self._prev = 0 - def frame(self, buttons, cf, raw_x, now, real_touch=True, - touch_mask=LPADTOUCH, sel_mask=LT, click_mask=LPAD): + def frame( + self, + buttons, + cf, + raw_x, + now, + real_touch=True, + touch_mask=LPADTOUCH, + sel_mask=LT, + click_mask=LPAD, + ): self.sc_input_previous = _P(self._prev) r = self.handle_pad_input( cf, @@ -225,12 +232,18 @@ def test_lift_mode_accent_click_never_double_inserts_on_lift(): t += 0.02 d.frame(LPADTOUCH, cf, 0, t) # click releases, commits v0 committed = [ - item for item in d.controller_state.click_queue if isinstance(item, tuple) + item + for item in d.controller_state.click_queue + if isinstance(item, tuple) ] assert len(committed) == 1 t += 0.08 d.frame(0, cf, 0, t, real_touch=False) # finger lifts - assert [item for item in d.controller_state.click_queue if not isinstance(item, tuple)] == [] + assert [ + item + for item in d.controller_state.click_queue + if not isinstance(item, tuple) + ] == [] def test_lift_mode_press_types_immediately_when_diacritics_off(): @@ -264,12 +277,26 @@ def test_row_already_open_second_pad_press_defers(): d.frame(LPADTOUCH | LPAD, left, 0, t) assert state.is_diacritic_open() t += 0.02 - d.frame(RPADTOUCH | RPAD, right, 0x7FFF, t, - touch_mask=RPADTOUCH, sel_mask=RT, click_mask=RPAD) + d.frame( + RPADTOUCH | RPAD, + right, + 0x7FFF, + t, + touch_mask=RPADTOUCH, + sel_mask=RT, + click_mask=RPAD, + ) assert d._deferred_base.get(RT) is not None t += 0.02 - d.frame(RPADTOUCH, right, 0x7FFF, t, - touch_mask=RPADTOUCH, sel_mask=RT, click_mask=RPAD) + d.frame( + RPADTOUCH, + right, + 0x7FFF, + t, + touch_mask=RPADTOUCH, + sel_mask=RT, + click_mask=RPAD, + ) assert list( item for item in d.controller_state.click_queue diff --git a/windows/tests/vkb/test_diacritics.py b/windows/tests/vkb/test_diacritics.py index e817fd7..0977eec 100644 --- a/windows/tests/vkb/test_diacritics.py +++ b/windows/tests/vkb/test_diacritics.py @@ -1,4 +1,4 @@ -"""Headless tests for the Feature-B diacritic-variant logic +"""Headless tests for the Feature-B diacritic-variant logic (triton/diacritics.py + the settings roundtrip). Pure logic: the per-locale variant map and its merge/lookup rules, the diff --git a/windows/tray/app.py b/windows/tray/app.py index 6845e53..90dc2f8 100755 --- a/windows/tray/app.py +++ b/windows/tray/app.py @@ -104,7 +104,9 @@ def __init__(self): # publish so the next OSK open builds it (main() re-runs # load_kb_config every open). self.settings["osk_layout"] = ( - triton_layouts.normalize_layout_name(self.settings.get("osk_layout")) + triton_layouts.normalize_layout_name( + self.settings.get("osk_layout") + ) or triton_layouts.DEFAULT_LAYOUT ) triton_state.set_kb_layout(self.settings["osk_layout"]) @@ -440,9 +442,7 @@ def is_click_button_checked(self, name): def toggle_liftoff_enter(self, icon, item): self.settings["sc_liftoff_enter"] = not item.checked _save_settings(self.settings) - triton_state.set_sc_liftoff_enter( - self.settings["sc_liftoff_enter"] - ) + triton_state.set_sc_liftoff_enter(self.settings["sc_liftoff_enter"]) self._refresh_menu() def is_liftoff_enter_checked(self, item): diff --git a/windows/triton/pad.py b/windows/triton/pad.py index 688e27e..5ed8161 100755 --- a/windows/triton/pad.py +++ b/windows/triton/pad.py @@ -171,9 +171,7 @@ def handle_pad_input( and repeat_key not in self._deferred_base and coord_frac is not None and self._liftoff_coord.get(repeat_key) is not None - and self._liftoff_resolves( - self._liftoff_coord[repeat_key] - ) + and self._liftoff_resolves(self._liftoff_coord[repeat_key]) ): self.controller_state.click_queue.append( self._liftoff_coord[repeat_key] @@ -316,7 +314,7 @@ def handle_pad_input( if self._diacritic_pad == repeat_key: if click_active: rect = state.get_diacritic_rect() - if rect is not None: + if rect is not None and coord_frac is not None: px, py = coord_frac.to_absolute() state.set_diacritic_index( diacritics.variant_index_at_point( diff --git a/windows/triton/screen.py b/windows/triton/screen.py index ca8ef09..b698e39 100755 --- a/windows/triton/screen.py +++ b/windows/triton/screen.py @@ -66,7 +66,9 @@ def resize_for_layout(): width, height = _compute_split_size(_active_osk_size) else: width, height = _compute_size(_active_osk_size) - set_dims(width, height) # keep geometry.py in sync (CoordFraction reads it) + set_dims( + width, height + ) # keep geometry.py in sync (CoordFraction reads it) return width, height @@ -531,9 +533,7 @@ def _render_split_background(self, virtual_kb): S.SDL_SetRenderDrawColor(self.renderer, c.r, c.g, c.b, 255) S.SDL_RenderFillRect( self.renderer, - ctypes.byref( - S.SDL_FRect(0.0, 0.0, float(left_x), float(height)) - ), + ctypes.byref(S.SDL_FRect(0.0, 0.0, float(left_x), float(height))), ) S.SDL_RenderFillRect( self.renderer, From 020912abb9da276c9b9dbcb98e23da30d494fc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 21:23:15 +0200 Subject: [PATCH 6/6] chore: version from the pushed tag - DUALTOUCH_VERSION env, git-tag fallback - CI forwards the tag ref name (github.ref_name) as DUALTOUCH_VERSION on tag pushes; workflow_dispatch and local builds fall back to the latest reachable git tag, 0.0.0 when untagged --- .github/workflows/build.yml | 4 ++++ windows/build.py | 23 ++++++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef6ede3..04deee4 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,10 @@ jobs: - name: Build Windows binaries working-directory: windows + env: + # Tag builds name their artifacts after the pushed tag; empty on + # workflow_dispatch (build.py then falls back to the latest tag). + DUALTOUCH_VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: python build.py --installer - name: Package Windows release diff --git a/windows/build.py b/windows/build.py index 95f0458..0e98599 100644 --- a/windows/build.py +++ b/windows/build.py @@ -47,20 +47,29 @@ ISS_PATH = os.path.join(INSTALLER_DIR, "DualTouch.iss") -def _app_version(): - """Latest git tag (v-prefixed tags welcome) as the exe version, or 0.0.0 - when building outside a tagged checkout (CI uses fetch-depth: 0).""" +def _latest_git_tag(): + """Latest tag reachable from HEAD ('' when unavailable), v-prefix kept.""" try: - tag = subprocess.check_output( + return subprocess.check_output( ["git", "describe", "--tags", "--abbrev=0"], cwd=PROJECT_DIR, text=True, stderr=subprocess.DEVNULL, ).strip() except Exception: - return "0.0.0" - m = re.match(r"v?(\d[\w.\-]*)", tag) - return m.group(1) if m else "0.0.0" + return "" + + +def _app_version(): + """The exe/installer version. Precedence: the DUALTOUCH_VERSION env var + (CI forwards the pushed tag's ref name there), then the latest reachable + git tag; "latest" when neither yields a version-looking string (untagged + local builds).""" + for candidate in (os.environ.get("DUALTOUCH_VERSION"), _latest_git_tag()): + m = re.match(r"v?(\d[\w.\-]*)", (candidate or "").strip()) + if m: + return m.group(1) + return "latest" def _preflight():