A fast, native on-screen keyboard for the Steam Controller on Windows.
DualTouch is a native SDL3 on-screen keyboard built around the idea of Steam's touch keyboard, but with a lot more behind it. The controller is read directly from the HID device — no web layer, no Steam UI stack — so it opens fast, types immediately, and sits on top of the desktop, windowed games, and fullscreen apps alike.
It is a Windows-only fork of SteamlessKeyboard
(originally from archshift/triton).
- 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; 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.
- Sounds & haptics — Steam's click sound and rumble feedback, both toggleable.
- Tray app — battery status, open/close keyboard, autostart, chord selection, skin menu.
- Elevated by design — runs elevated so it can type into Big Picture and UIPI-protected games.
- Windows 10 or 11 (64-bit)
- Steam, with Steam Input available — DualTouch waits for it unless “Start Steam on startup” is enabled in the tray
- Python 3.10+ if running from source (not needed for the prebuilt exe)
Release build
Run DualTouch-windows.exe from the extracted DualTouch-windows
folder (it will request elevation on start) — the whole folder ships
together, so keep all of its files side by side. The
DualTouch-cursor-helper.exe next to it is the same binary under its
cursor-helper name (so the two processes are distinguishable in Task
Manager); keep it too.
- Settings:
%APPDATA%\DualTouch\settings.json(auto-created with defaults; a legacy file next to the exe is migrated automatically) - Logs:
%APPDATA%\DualTouch\dualtouch.log(created only when logging is enabled)
From source
pip install -r requirements.txt
cd windows
python -m trayMost settings are live-editable from the tray (Startup and Steam Controller
menus), or by hand-editing settings.json for finer control.
| Key | Default | Meaning |
|---|---|---|
sc_osk_open_chord |
"X" |
Button that opens the keyboard when held with Steam: "X"/"Y"/"A"/"B" |
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); 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" |
skin |
"Gruvbox" |
Keyboard skin: Steam OSK themes load from the Steam install; Gruvbox is the bundled original |
osk_size |
"medium" |
"small"/"medium"/"full" |
osk_transparency |
"off" |
"off"/"low"/"medium"/"high" |
osk_split_layout |
false |
Split the keyboard into left/right halves with a middle gap; each touchpad covers its own half |
key_sound_enabled_sc |
true |
Steam keyboard click sound |
rumble_enabled_sc |
true |
Haptics on key clicks and trigger pulls |
steam_kbd_layer |
true |
Dispatch the forceinputappid keyboard layer while open |
block_sc_hid |
false |
Open the Steam Controller HID exclusively (Steam can't read it) |
start_with_windows |
false |
Launch at logon (elevated scheduled task, no UAC prompt) |
start_steam_on_startup |
false |
Start Steam when DualTouch starts if it is not already running; tray: Startup → Start Steam on startup |
logging_enabled |
false |
Write diagnostics to dualtouch.log; tray: Startup → Enable Logging |
cd windows
python build.py # folder distribution
python build.py --installer # + Windows installer (needs Inno Setup 6)Output: windows/dist/DualTouch-windows/ (a standalone folder
distribution — DualTouch-windows.exe, the DualTouch-cursor-helper.exe
copy, and everything they need; ship the whole folder together).
With --installer, an Inno Setup 6 setup is compiled to
windows/dist/DualTouch-windows-setup-<version>.exe
(Inno Setup must be installed or
ISCC.exe on PATH).
Releases are compiled with Nuitka: all Python is turned into machine code, so the bundle contains no Python sources or bytecode. The first build on a machine without a C compiler auto-downloads MinGW and takes several minutes; later builds are faster.
Keyboard doesn't open
Enable logging first (tray → Startup → Enable Logging), then check
%APPDATA%\DualTouch\dualtouch.log.
Steam Input grabs the controller
Confirm the keyboard-layer shortcut is registered (done automatically into
shortcuts.vdf) and that the keyboard opened with Steam running; cursor
containment and the injected-input gate keep Steam's emulated mouse out of
the keyboard.
Controller dead after quitting
Quitting from the tray never dispatches the /0 restore; if the appid
changed another way, relaunch and open/close the keyboard once (or alt-tab)
to make Steam re-evaluate.
The OSK has a headless UI-test harness (an input-pipeline simulator (OskSim)) that drives the real input pipeline — controller frame parsing, the pad/click state machines, hold-to-repeat cadence and the variant-row defer model — with a virtual clock, recording every key/mouse injection at the OS boundary. No display, Steam, or controller required:
cd windows
python -m pytest tests -q # whole suite (fast, headless)
python -m pytest tests/test_diacritic_hold.py -q # one layer-2 moduleLayers: pure state-machine tests (test_nav_cursor), synthetic-input
injection through ControllerManager + the shared drain loop
(tests/osk_sim.py, used by test_typing_flow, test_diacritic_hold,
test_pad_pointer). CI runs the same suite on every push via
.github/workflows/test.yml (JUnit XML artifact). Known OS-level
injection quirks on real hardware (pynput/SendInput desync) are out of
scope for this harness — those need a physical device.
This project was built through AI-assisted development: a human set the direction, reviewed changes, and handled testing, while AI coding agents wrote the implementation. It's shared partly as a working keyboard overlay, and partly as a real-world example of what that workflow can produce.
GNU LGPL v3 — see LICENSE.
Assets. Valve's OSK themes and controller glyphs load from the Steam install at runtime; bundled graphics are DualTouch's own.
