From be14bbcc621d575475624c4f3544b254d9a8d480 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 14 Oct 2026 14:50:21 +0200 Subject: [PATCH] Layouts, iPad-style rendering, taps, shift and layers - JSON layouts (en-US, sv-SE inheriting number/symbol layers via base) - LayoutModel + KeyGeometry: proportional rows, nearest-key hit testing - KeyboardSurface: multi-pointer touch/mouse routing to the controller - KeyboardController + ShiftState: iOS shift/caps-lock, layers, globe, dismiss - QML view with light/dark iPad palette and drag handle - DevHooks: SLOPOSK_AUTOTYPE, SLOPOSK_SCREENSHOT for headless checks - Unit tests for shift, loader, geometry and controller Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 +- layouts/en-US.json | 117 ++++++++++++++++++++++ layouts/sv-SE.json | 62 ++++++++++++ qml/DragHandle.qml | 30 ++++++ qml/Key.qml | 74 ++++++++++++++ qml/Keyboard.qml | 13 +++ qml/Main.qml | 53 ++++------ qml/Theme.qml | 19 ++++ src/CMakeLists.txt | 76 ++++++++++---- src/DevHooks.cpp | 38 +++++++ src/DevHooks.h | 9 ++ src/engine/DirectEngine.cpp | 18 ++++ src/engine/DirectEngine.h | 17 ++++ src/engine/InputEngine.h | 15 +++ src/input/KeyboardController.cpp | 108 ++++++++++++++++++++ src/input/KeyboardController.h | 55 +++++++++++ src/input/KeyboardSurface.cpp | 139 ++++++++++++++++++++++++++ src/input/KeyboardSurface.h | 59 +++++++++++ src/input/ShiftState.cpp | 37 +++++++ src/input/ShiftState.h | 33 +++++++ src/layout/KeyGeometry.cpp | 46 +++++++++ src/layout/KeyGeometry.h | 16 +++ src/layout/Layout.h | 59 +++++++++++ src/layout/LayoutLoader.cpp | 160 ++++++++++++++++++++++++++++++ src/layout/LayoutLoader.h | 21 ++++ src/layout/LayoutModel.cpp | 165 +++++++++++++++++++++++++++++++ src/layout/LayoutModel.h | 66 +++++++++++++ src/layout/LayoutRepository.cpp | 75 ++++++++++++++ src/layout/LayoutRepository.h | 24 +++++ src/main.cpp | 55 ++++++++--- tests/CMakeLists.txt | 11 +++ tests/tst_controller.cpp | 128 ++++++++++++++++++++++++ tests/tst_keygeometry.cpp | 55 +++++++++++ tests/tst_layoutloader.cpp | 91 +++++++++++++++++ tests/tst_shiftstate.cpp | 86 ++++++++++++++++ 35 files changed, 1970 insertions(+), 65 deletions(-) create mode 100644 layouts/en-US.json create mode 100644 layouts/sv-SE.json create mode 100644 qml/DragHandle.qml create mode 100644 qml/Key.qml create mode 100644 qml/Keyboard.qml create mode 100644 qml/Theme.qml create mode 100644 src/DevHooks.cpp create mode 100644 src/DevHooks.h create mode 100644 src/engine/DirectEngine.cpp create mode 100644 src/engine/DirectEngine.h create mode 100644 src/engine/InputEngine.h create mode 100644 src/input/KeyboardController.cpp create mode 100644 src/input/KeyboardController.h create mode 100644 src/input/KeyboardSurface.cpp create mode 100644 src/input/KeyboardSurface.h create mode 100644 src/input/ShiftState.cpp create mode 100644 src/input/ShiftState.h create mode 100644 src/layout/KeyGeometry.cpp create mode 100644 src/layout/KeyGeometry.h create mode 100644 src/layout/Layout.h create mode 100644 src/layout/LayoutLoader.cpp create mode 100644 src/layout/LayoutLoader.h create mode 100644 src/layout/LayoutModel.cpp create mode 100644 src/layout/LayoutModel.h create mode 100644 src/layout/LayoutRepository.cpp create mode 100644 src/layout/LayoutRepository.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/tst_controller.cpp create mode 100644 tests/tst_keygeometry.cpp create mode 100644 tests/tst_layoutloader.cpp create mode 100644 tests/tst_shiftstate.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cb7dee..c64f759 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -find_package(Qt6 6.8 REQUIRED COMPONENTS Gui Quick Widgets WaylandClient) +find_package(Qt6 6.8 REQUIRED COMPONENTS Gui Quick Widgets Test WaylandClient) find_package(LayerShellQt REQUIRED) find_package(PkgConfig REQUIRED) pkg_check_modules(XKB REQUIRED IMPORTED_TARGET xkbcommon) @@ -14,5 +14,8 @@ pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir) qt_standard_project_setup(REQUIRES 6.8) +enable_testing() + add_subdirectory(src) add_subdirectory(tools) +add_subdirectory(tests) diff --git a/layouts/en-US.json b/layouts/en-US.json new file mode 100644 index 0000000..c103b8c --- /dev/null +++ b/layouts/en-US.json @@ -0,0 +1,117 @@ +{ + "id": "en-US", + "name": "English (US)", + "locale": "en_US", + "layers": [ + { + "name": "letters", + "rows": [ + [ + {"label": "q", "flick": "1"}, + {"label": "w", "flick": "2"}, + {"label": "e", "flick": "3", "alternates": ["é", "è", "ê", "ë", "ē", "ė", "ę"]}, + {"label": "r", "flick": "4"}, + {"label": "t", "flick": "5", "alternates": ["þ"]}, + {"label": "y", "flick": "6", "alternates": ["ý", "ÿ"]}, + {"label": "u", "flick": "7", "alternates": ["ú", "ù", "û", "ü", "ū"]}, + {"label": "i", "flick": "8", "alternates": ["í", "ì", "î", "ï", "ī", "į"]}, + {"label": "o", "flick": "9", "alternates": ["ó", "ò", "ô", "ö", "õ", "ø", "œ", "ō"]}, + {"label": "p", "flick": "0"}, + {"type": "backspace", "width": 1.5} + ], + [ + {"type": "spacer", "width": 0.5}, + {"label": "a", "flick": "@", "alternates": ["á", "à", "â", "ä", "æ", "ã", "å", "ā"]}, + {"label": "s", "flick": "#", "alternates": ["ß", "ś", "š"]}, + {"label": "d", "flick": "$", "alternates": ["ð"]}, + {"label": "f", "flick": "&"}, + {"label": "g", "flick": "*"}, + {"label": "h", "flick": "("}, + {"label": "j", "flick": ")"}, + {"label": "k", "flick": "'"}, + {"label": "l", "flick": "\"", "alternates": ["ł"]}, + {"type": "enter", "width": 2} + ], + [ + {"type": "shift"}, + {"label": "z", "flick": "%", "alternates": ["ž", "ź", "ż"]}, + {"label": "x", "flick": "-"}, + {"label": "c", "flick": "+", "alternates": ["ç", "ć", "č"]}, + {"label": "v", "flick": "="}, + {"label": "b", "flick": "/"}, + {"label": "n", "flick": ";", "alternates": ["ñ", "ń"]}, + {"label": "m", "flick": ":"}, + {"label": ",", "flick": "!", "shifted": ","}, + {"label": ".", "flick": "?", "shifted": ".", "alternates": ["…"]}, + {"type": "shift", "width": 1.5} + ], + [ + {"type": "layer", "label": ".?123", "target": "numbers", "width": 1.5}, + {"type": "globe"}, + {"type": "space", "width": 6.5}, + {"type": "layer", "label": ".?123", "target": "numbers", "width": 1.5}, + {"type": "dismiss"} + ] + ] + }, + { + "name": "numbers", + "rows": [ + ["1", "2", "3", "4", "5", "6", "7", "8", "9", + {"label": "0", "alternates": ["°"]}, + {"type": "backspace", "width": 1.5}], + [ + {"type": "spacer", "width": 0.5}, + "@", "#", + {"label": "$", "alternates": ["€", "£", "¥", "₩", "₽", "¢"]}, + {"label": "&", "alternates": ["§"]}, + "*", "(", ")", + {"label": "'", "alternates": ["‘", "’", "`"]}, + {"label": "\"", "alternates": ["“", "”", "„", "«", "»"]}, + {"type": "enter", "width": 2} + ], + [ + {"type": "layer", "label": "#+=", "target": "symbols"}, + "%", + {"label": "-", "alternates": ["–", "—", "•"]}, + "+", "=", + {"label": "/", "alternates": ["\\"]}, + ";", ":", ",", + {"label": ".", "alternates": ["…"]}, + {"type": "layer", "label": "#+=", "target": "symbols", "width": 1.5} + ], + [ + {"type": "layer", "label": "ABC", "target": "letters", "width": 1.5}, + {"type": "globe"}, + {"type": "space", "width": 6.5}, + {"type": "layer", "label": "ABC", "target": "letters", "width": 1.5}, + {"type": "dismiss"} + ] + ] + }, + { + "name": "symbols", + "rows": [ + ["[", "]", "{", "}", "#", "%", "^", "*", "+", "=", + {"type": "backspace", "width": 1.5}], + [ + {"type": "spacer", "width": 0.5}, + "_", "\\", "|", "~", "<", ">", "€", "£", "¥", + {"type": "enter", "width": 2} + ], + [ + {"type": "layer", "label": "123", "target": "numbers"}, + "•", "…", ".", ",", "?", "!", "'", "\"", "`", + {"type": "layer", "label": "123", "target": "numbers", "width": 1.5} + ], + [ + {"type": "layer", "label": "ABC", "target": "letters", "width": 1.5}, + {"type": "globe"}, + {"type": "space", "width": 6.5}, + {"type": "layer", "label": "ABC", "target": "letters", "width": 1.5}, + {"type": "dismiss"} + ] + ] + } + ] +} diff --git a/layouts/sv-SE.json b/layouts/sv-SE.json new file mode 100644 index 0000000..37dc28c --- /dev/null +++ b/layouts/sv-SE.json @@ -0,0 +1,62 @@ +{ + "id": "sv-SE", + "name": "Svenska", + "locale": "sv_SE", + "base": "en-US", + "layers": [ + { + "name": "letters", + "rows": [ + [ + {"label": "q", "flick": "1"}, + {"label": "w", "flick": "2"}, + {"label": "e", "flick": "3", "alternates": ["é", "è", "ê", "ë"]}, + {"label": "r", "flick": "4"}, + {"label": "t", "flick": "5"}, + {"label": "y", "flick": "6", "alternates": ["ý", "ü"]}, + {"label": "u", "flick": "7", "alternates": ["ü", "ú", "ù", "û"]}, + {"label": "i", "flick": "8", "alternates": ["í", "ì", "î", "ï"]}, + {"label": "o", "flick": "9", "alternates": ["ó", "ò", "ô", "õ", "ø", "œ"]}, + {"label": "p", "flick": "0"}, + {"label": "å", "flick": "^"}, + {"type": "backspace", "width": 1.5} + ], + [ + {"label": "a", "flick": "@", "alternates": ["á", "à", "â", "ã", "æ"]}, + {"label": "s", "flick": "#", "alternates": ["ß", "š"]}, + {"label": "d", "flick": "$", "alternates": ["ð"]}, + {"label": "f", "flick": "&"}, + {"label": "g", "flick": "*"}, + {"label": "h", "flick": "("}, + {"label": "j", "flick": ")"}, + {"label": "k", "flick": "'"}, + {"label": "l", "flick": "\""}, + {"label": "ö", "flick": ";", "alternates": ["ø", "œ"]}, + {"label": "ä", "flick": ":", "alternates": ["æ"]}, + {"type": "enter", "width": 1.5} + ], + [ + {"type": "shift", "width": 1.5}, + {"label": "z", "flick": "%", "alternates": ["ž"]}, + {"label": "x", "flick": "-"}, + {"label": "c", "flick": "+", "alternates": ["ç", "č"]}, + {"label": "v", "flick": "="}, + {"label": "b", "flick": "/"}, + {"label": "n", "flick": "_", "alternates": ["ñ"]}, + {"label": "m", "flick": "~"}, + {"label": ",", "flick": "!", "shifted": ","}, + {"label": ".", "flick": "?", "shifted": ".", "alternates": ["…"]}, + {"label": "-", "flick": "–", "shifted": "-", "alternates": ["—", "•"]}, + {"type": "shift"} + ], + [ + {"type": "layer", "label": ".?123", "target": "numbers", "width": 1.5}, + {"type": "globe"}, + {"type": "space", "width": 7.5}, + {"type": "layer", "label": ".?123", "target": "numbers", "width": 1.5}, + {"type": "dismiss"} + ] + ] + } + ] +} diff --git a/qml/DragHandle.qml b/qml/DragHandle.qml new file mode 100644 index 0000000..01fdd69 --- /dev/null +++ b/qml/DragHandle.qml @@ -0,0 +1,30 @@ +import QtQuick + +// The grab bar under the floating keyboard. Emits how far it was dragged; +// moving the window is up to the owner. +Item { + id: handle + + signal moved(real dx, real dy) + + implicitHeight: 22 + + Rectangle { + anchors.centerIn: parent + width: 120 + height: 5 + radius: height / 2 + color: Theme.handle + } + + DragHandler { + target: null + // The window follows the pointer, so the pointer stays near its press + // position in window coordinates: measure each step from there. + onCentroidChanged: { + if (active) + handle.moved(centroid.scenePosition.x - centroid.scenePressPosition.x, + centroid.scenePosition.y - centroid.scenePressPosition.y) + } + } +} diff --git a/qml/Key.qml b/qml/Key.qml new file mode 100644 index 0000000..0306f2d --- /dev/null +++ b/qml/Key.qml @@ -0,0 +1,74 @@ +import QtQuick +import Sloposk + +// One key, drawn from the LayoutModel roles. Purely visual. +Item { + id: key + + required property string label + required property string flickLabel + required property string keyType + required property real keyX + required property real keyY + required property real keyWidth + required property real keyHeight + required property bool pressed + property int shiftMode: KeyboardController.Off + + readonly property bool isCharacter: keyType === "char" || keyType === "space" + readonly property bool shiftActive: keyType === "shift" && shiftMode !== KeyboardController.Off + // Character keys darken when pressed; function keys lighten (as on iPad). + readonly property bool lit: (isCharacter !== pressed) || shiftActive + + readonly property var defaultGlyphs: ({ + backspace: "⌫", + globe: "🌐", + dismiss: "⌨", + enter: "return", + }) + readonly property string displayLabel: { + if (keyType === "shift") + return ["⇧", "⬆", "⇪"][shiftMode] + return label !== "" ? label : (defaultGlyphs[keyType] ?? "") + } + // Count code points, not UTF-16 units, so emoji glyphs aren't treated as words. + readonly property bool isWord: [...displayLabel].length > 1 && !isCharacter + + x: keyX + y: keyY + width: keyWidth + height: keyHeight + + Rectangle { + anchors { fill: face; topMargin: 1; bottomMargin: -1 } + radius: Theme.keyRadius + color: Theme.keyShadow + } + + Rectangle { + id: face + anchors.fill: parent + radius: Theme.keyRadius + color: key.lit ? Theme.characterKey : Theme.functionKey + } + + Text { + id: flick + visible: text !== "" + text: key.flickLabel + anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: key.height * 0.1 } + font.pixelSize: key.height * 0.24 + color: Theme.secondaryText + } + + Text { + text: key.displayLabel + anchors { + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + verticalCenterOffset: flick.visible ? key.height * 0.14 : 0 + } + font.pixelSize: key.height * (key.isWord ? 0.24 : flick.visible ? 0.34 : 0.4) + color: Theme.text + } +} diff --git a/qml/Keyboard.qml b/qml/Keyboard.qml new file mode 100644 index 0000000..7593bde --- /dev/null +++ b/qml/Keyboard.qml @@ -0,0 +1,13 @@ +import QtQuick +import Sloposk + +KeyboardSurface { + id: surface + + Repeater { + model: surface.model + delegate: Key { + shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off + } + } +} diff --git a/qml/Main.qml b/qml/Main.qml index 801c45b..96d7f07 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -1,45 +1,34 @@ import QtQuick -import QtQuick.Controls +import Sloposk -// Spike UI: proves text commits, keysyms and dragging. Replaced by the real -// keyboard in milestone 2. Window { id: root - required property var backend - property var placement: null + required property LayoutModel layoutModel + required property KeyboardController controller + property var placement: null // FloatingWindow, set from C++ - width: 720 - height: 120 + width: 1000 + height: 340 visible: false - color: "#d1d3d9" + color: "transparent" Rectangle { - id: handle - anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 6 } - width: 120; height: 6; radius: 3 - color: "#8e8e93" + anchors.fill: parent + radius: Theme.windowRadius + color: Theme.background - DragHandler { - target: null - margin: 12 - property point last - onActiveChanged: last = centroid.scenePressPosition - onCentroidChanged: { - if (!active || !root.placement) - return - const p = centroid.scenePosition - root.placement.moveBy(p.x - last.x, p.y - last.y) - } + Keyboard { + anchors { left: parent.left; right: parent.right; top: parent.top; bottom: handle.top } + anchors.margins: 2 + model: root.layoutModel + controller: root.controller + } + + DragHandle { + id: handle + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + onMoved: (dx, dy) => root.placement?.moveBy(dx, dy) } } - - Row { - anchors.centerIn: parent - spacing: 8 - Button { text: "héllo👍"; focusPolicy: Qt.NoFocus; onClicked: root.backend.commitText(text) } - Button { text: "⌫"; focusPolicy: Qt.NoFocus; onClicked: root.backend.sendKeysym(0xff08) } - Button { text: "⏎"; focusPolicy: Qt.NoFocus; onClicked: root.backend.sendKeysym(0xff0d) } - Label { anchors.verticalCenter: parent.verticalCenter; text: root.backend.active ? "active" : "inactive" } - } } diff --git a/qml/Theme.qml b/qml/Theme.qml new file mode 100644 index 0000000..b905bd6 --- /dev/null +++ b/qml/Theme.qml @@ -0,0 +1,19 @@ +pragma Singleton + +import QtQuick + +// iPad keyboard palette; follows the system light/dark preference. +QtObject { + readonly property bool dark: Qt.styleHints.colorScheme === Qt.ColorScheme.Dark + + readonly property color background: dark ? "#2c2c2e" : "#d1d3d9" + readonly property color characterKey: dark ? "#6b6b6e" : "#ffffff" + readonly property color functionKey: dark ? "#464649" : "#abb0bc" + readonly property color keyShadow: dark ? "#141416" : "#88898d" + readonly property color text: dark ? "#ffffff" : "#000000" + readonly property color secondaryText: dark ? "#a5a5aa" : "#8a8a8e" + readonly property color handle: dark ? "#8e8e93" : "#7c7c80" + + readonly property real windowRadius: 14 + readonly property real keyRadius: 6 +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 95e8a9b..3e81d55 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,59 @@ +# --- sloposk_core: everything that runs without a compositor; also the "Sloposk" QML module. + +qt_add_library(sloposk_core STATIC + backend/TextBackend.h + engine/InputEngine.h + engine/DirectEngine.h engine/DirectEngine.cpp + input/ShiftState.h input/ShiftState.cpp + input/KeyboardController.h input/KeyboardController.cpp + input/KeyboardSurface.h input/KeyboardSurface.cpp + layout/Layout.h + layout/LayoutLoader.h layout/LayoutLoader.cpp + layout/LayoutRepository.h layout/LayoutRepository.cpp + layout/KeyGeometry.h layout/KeyGeometry.cpp + layout/LayoutModel.h layout/LayoutModel.cpp +) + +# QML lives in the top-level qml/ dir; alias each file so the module sees it at its root. +set(SLOPOSK_QML_FILES + Main.qml + Keyboard.qml + Key.qml + DragHandle.qml + Theme.qml +) +list(TRANSFORM SLOPOSK_QML_FILES PREPEND ${PROJECT_SOURCE_DIR}/qml/ OUTPUT_VARIABLE sloposk_qml_paths) +foreach(file IN LISTS SLOPOSK_QML_FILES) + set_source_files_properties(${PROJECT_SOURCE_DIR}/qml/${file} PROPERTIES QT_RESOURCE_ALIAS ${file}) +endforeach() +set_source_files_properties(${PROJECT_SOURCE_DIR}/qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + +qt_add_qml_module(sloposk_core + URI Sloposk + OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml/Sloposk + QML_FILES ${sloposk_qml_paths} +) + +file(GLOB layout_files CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/layouts/*.json) +qt_add_resources(sloposk_core "layouts" + PREFIX /layouts + BASE ${PROJECT_SOURCE_DIR}/layouts + FILES ${layout_files} +) + +target_include_directories(sloposk_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +# The generated QML type registration includes QML_ELEMENT headers by bare name. +target_include_directories(sloposk_core PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/input + ${CMAKE_CURRENT_SOURCE_DIR}/layout +) +target_link_libraries(sloposk_core PUBLIC Qt6::Quick PkgConfig::XKB) + +# --- sloposk: the app, with the compositor-specific parts. + qt_add_executable(sloposk main.cpp - backend/TextBackend.h + DevHooks.h DevHooks.cpp backend/InputMethodProtocol.h backend/KWinInputMethod.h backend/KWinInputMethod.cpp backend/KWinInputContext.h backend/KWinInputContext.cpp @@ -14,28 +67,11 @@ qt6_generate_wayland_protocol_client_sources(sloposk PRIVATE_CODE FILES set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/qwayland-input-method-unstable-v1.cpp PROPERTIES COMPILE_OPTIONS "-include;wayland-client-protocol.h") -# QML lives in the top-level qml/ dir; alias each file so the module sees it at its root. -set(SLOPOSK_QML_FILES - Main.qml -) -list(TRANSFORM SLOPOSK_QML_FILES PREPEND ${PROJECT_SOURCE_DIR}/qml/ OUTPUT_VARIABLE sloposk_qml_paths) -foreach(file IN LISTS SLOPOSK_QML_FILES) - set_source_files_properties(${PROJECT_SOURCE_DIR}/qml/${file} PROPERTIES QT_RESOURCE_ALIAS ${file}) -endforeach() - -qt_add_qml_module(sloposk - URI Sloposk - QML_FILES ${sloposk_qml_paths} -) - -target_include_directories(sloposk PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) - target_link_libraries(sloposk PRIVATE - Qt6::Gui - Qt6::Quick + sloposk_core + sloposk_coreplugin Qt6::WaylandClient LayerShellQt::Interface - PkgConfig::XKB PkgConfig::WAYLAND ) diff --git a/src/DevHooks.cpp b/src/DevHooks.cpp new file mode 100644 index 0000000..99e587b --- /dev/null +++ b/src/DevHooks.cpp @@ -0,0 +1,38 @@ +#include "DevHooks.h" + +#include "backend/TextBackend.h" + +#include +#include +#include + +namespace { + +void installAutotype(TextBackend &backend, const QString &text) +{ + QObject::connect(&backend, &TextBackend::activeChanged, &backend, [&backend, text] { + if (backend.isActive()) + backend.commitText(text); + }); +} + +void installScreenshot(QQuickWindow &window, const QString &path) +{ + constexpr int SettleMs = 500; + QTimer::singleShot(SettleMs, &window, [&window, path] { + const bool saved = window.grabWindow().save(path); + if (!saved) + qWarning("Failed to save screenshot to %s", qPrintable(path)); + QCoreApplication::exit(saved ? 0 : 1); + }); +} + +} // namespace + +void installDevHooks(TextBackend &backend, QQuickWindow &window) +{ + if (const QString text = qEnvironmentVariable("SLOPOSK_AUTOTYPE"); !text.isEmpty()) + installAutotype(backend, text); + if (const QString path = qEnvironmentVariable("SLOPOSK_SCREENSHOT"); !path.isEmpty()) + installScreenshot(window, path); +} diff --git a/src/DevHooks.h b/src/DevHooks.h new file mode 100644 index 0000000..e7d05ec --- /dev/null +++ b/src/DevHooks.h @@ -0,0 +1,9 @@ +#pragma once + +class QQuickWindow; +class TextBackend; + +// Environment-driven helpers for headless testing; no effect when unset. +// SLOPOSK_AUTOTYPE=text commit `text` whenever a text field gets focus +// SLOPOSK_SCREENSHOT=path save the keyboard window to `path`, then quit +void installDevHooks(TextBackend &backend, QQuickWindow &window); diff --git a/src/engine/DirectEngine.cpp b/src/engine/DirectEngine.cpp new file mode 100644 index 0000000..1bd3f38 --- /dev/null +++ b/src/engine/DirectEngine.cpp @@ -0,0 +1,18 @@ +#include "DirectEngine.h" + +#include "backend/TextBackend.h" + +DirectEngine::DirectEngine(TextBackend &backend) + : m_backend(backend) +{ +} + +void DirectEngine::commitText(const QString &text) +{ + m_backend.commitText(text); +} + +void DirectEngine::sendKeysym(quint32 keysym) +{ + m_backend.sendKeysym(keysym); +} diff --git a/src/engine/DirectEngine.h b/src/engine/DirectEngine.h new file mode 100644 index 0000000..b6729f4 --- /dev/null +++ b/src/engine/DirectEngine.h @@ -0,0 +1,17 @@ +#pragma once + +#include "InputEngine.h" + +class TextBackend; + +class DirectEngine : public InputEngine +{ +public: + explicit DirectEngine(TextBackend &backend); + + void commitText(const QString &text) override; + void sendKeysym(quint32 keysym) override; + +private: + TextBackend &m_backend; +}; diff --git a/src/engine/InputEngine.h b/src/engine/InputEngine.h new file mode 100644 index 0000000..23c5d38 --- /dev/null +++ b/src/engine/InputEngine.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +// Turns what the keyboard produces into text operations. +// DirectEngine passes them straight through; prediction and swipe engines +// will sit here too (preedit, candidates, auto-correction). +class InputEngine +{ +public: + virtual ~InputEngine() = default; + + virtual void commitText(const QString &text) = 0; + virtual void sendKeysym(quint32 keysym) = 0; +}; diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp new file mode 100644 index 0000000..e1858dc --- /dev/null +++ b/src/input/KeyboardController.cpp @@ -0,0 +1,108 @@ +#include "KeyboardController.h" + +#include "engine/InputEngine.h" +#include "layout/LayoutModel.h" +#include "layout/LayoutRepository.h" + +#include + +// ShiftMode mirrors ShiftState::Mode for QML. +static_assert(int(KeyboardController::ShiftMode::Off) == int(ShiftState::Mode::Off)); +static_assert(int(KeyboardController::ShiftMode::Once) == int(ShiftState::Mode::Once)); +static_assert(int(KeyboardController::ShiftMode::Locked) == int(ShiftState::Mode::Locked)); + +KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model, + const LayoutRepository &layouts, QObject *parent) + : QObject(parent) + , m_engine(engine) + , m_model(model) + , m_layouts(layouts) +{ + if (!m_layouts.layouts().isEmpty()) + m_model.setLayout(m_layouts.layouts().first()); +} + +KeyboardController::ShiftMode KeyboardController::shiftMode() const +{ + return ShiftMode(m_shift.mode()); +} + +void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs) +{ + switch (key.type) { + case KeyType::Shift: + changeShift([&] { m_shift.press(timeMs); }); + break; + case KeyType::Backspace: + m_engine.sendKeysym(XKB_KEY_BackSpace); + break; + case KeyType::LayerSwitch: + m_model.setLayer(key.target); + break; + default: + break; // everything else acts on release + } +} + +void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64) +{ + switch (key.type) { + case KeyType::Shift: + changeShift([&] { m_shift.release(); }); + break; + case KeyType::Character: + if (output == KeyOutput::Alternate && !alternate.isEmpty()) + typeText(alternate); + else if (output == KeyOutput::Flick && !key.flick.isEmpty()) + typeText(key.flick); + else + typeText(key.text(m_shift.isUppercase())); + break; + case KeyType::Space: + typeText(QStringLiteral(" ")); + break; + case KeyType::Enter: + m_engine.sendKeysym(XKB_KEY_Return); + break; + case KeyType::Globe: + nextLayout(); + break; + case KeyType::Dismiss: + Q_EMIT dismissRequested(); + break; + default: + break; + } +} + +void KeyboardController::keyCancelled(const KeyDef &key) +{ + if (key.type == KeyType::Shift) + changeShift([&] { m_shift.release(); }); +} + +void KeyboardController::nextLayout() +{ + const auto &layouts = m_layouts.layouts(); + if (layouts.size() < 2) + return; + m_layoutIndex = int((m_layoutIndex + 1) % layouts.size()); + m_model.setLayout(layouts[m_layoutIndex]); +} + +void KeyboardController::typeText(const QString &text) +{ + m_engine.commitText(text); + changeShift([&] { m_shift.characterTyped(); }); +} + +template +void KeyboardController::changeShift(Change change) +{ + const ShiftState::Mode before = m_shift.mode(); + change(); + if (m_shift.mode() == before) + return; + m_model.setUppercase(m_shift.isUppercase()); + Q_EMIT shiftChanged(); +} diff --git a/src/input/KeyboardController.h b/src/input/KeyboardController.h new file mode 100644 index 0000000..86a9bb7 --- /dev/null +++ b/src/input/KeyboardController.h @@ -0,0 +1,55 @@ +#pragma once + +#include "ShiftState.h" +#include "layout/Layout.h" + +#include +#include + +class InputEngine; +class LayoutModel; +class LayoutRepository; + +// Which of a key's outputs the gesture selected. +enum class KeyOutput { Primary, Flick, Alternate }; + +// Keyboard rules: what each key does, shift, layers and layout switching. +// Knows nothing about touch or Wayland: it receives key presses and talks to +// an InputEngine. +class KeyboardController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Created in C++") + Q_PROPERTY(ShiftMode shiftMode READ shiftMode NOTIFY shiftChanged) + +public: + enum class ShiftMode { Off, Once, Locked }; + Q_ENUM(ShiftMode) + + KeyboardController(InputEngine &engine, LayoutModel &model, const LayoutRepository &layouts, + QObject *parent = nullptr); + + ShiftMode shiftMode() const; + + void keyDown(const KeyDef &key, qint64 timeMs); + void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs); + void keyCancelled(const KeyDef &key); + + Q_INVOKABLE void nextLayout(); + +Q_SIGNALS: + void shiftChanged(); + void dismissRequested(); + +private: + void typeText(const QString &text); + template + void changeShift(Change change); + + InputEngine &m_engine; + LayoutModel &m_model; + const LayoutRepository &m_layouts; + ShiftState m_shift; + int m_layoutIndex = 0; +}; diff --git a/src/input/KeyboardSurface.cpp b/src/input/KeyboardSurface.cpp new file mode 100644 index 0000000..db176d2 --- /dev/null +++ b/src/input/KeyboardSurface.cpp @@ -0,0 +1,139 @@ +#include "KeyboardSurface.h" + +#include "KeyboardController.h" +#include "layout/LayoutModel.h" + +namespace { +constexpr int MousePointerId = -1; +} + +KeyboardSurface::KeyboardSurface(QQuickItem *parent) + : QQuickItem(parent) +{ + setAcceptTouchEvents(true); + setAcceptedMouseButtons(Qt::LeftButton); +} + +void KeyboardSurface::setModel(LayoutModel *model) +{ + if (model == m_model) + return; + cancelAll(); + if (m_model) + disconnect(m_model, nullptr, this, nullptr); + m_model = model; + if (m_model) { + connect(m_model, &QAbstractItemModel::modelAboutToBeReset, this, &KeyboardSurface::forgetIndices); + m_model->setSize(size()); + } + Q_EMIT modelChanged(); +} + +void KeyboardSurface::setController(KeyboardController *controller) +{ + if (controller == m_controller) + return; + cancelAll(); + m_controller = controller; + Q_EMIT controllerChanged(); +} + +void KeyboardSurface::touchEvent(QTouchEvent *event) +{ + if (event->type() == QEvent::TouchCancel) { + cancelAll(); + return; + } + const qint64 time = qint64(event->timestamp()); + for (const QEventPoint &point : event->points()) { + switch (point.state()) { + case QEventPoint::Pressed: pointerDown(point.id(), point.position(), time); break; + case QEventPoint::Updated: pointerMove(point.id(), point.position()); break; + case QEventPoint::Released: pointerUp(point.id(), time); break; + default: break; + } + } + event->accept(); +} + +void KeyboardSurface::touchUngrabEvent() +{ + cancelAll(); +} + +void KeyboardSurface::mousePressEvent(QMouseEvent *event) +{ + pointerDown(MousePointerId, event->position(), qint64(event->timestamp())); +} + +void KeyboardSurface::mouseMoveEvent(QMouseEvent *event) +{ + pointerMove(MousePointerId, event->position()); +} + +void KeyboardSurface::mouseReleaseEvent(QMouseEvent *event) +{ + pointerUp(MousePointerId, qint64(event->timestamp())); +} + +void KeyboardSurface::mouseUngrabEvent() +{ + cancelAll(); +} + +void KeyboardSurface::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) +{ + QQuickItem::geometryChange(newGeometry, oldGeometry); + if (m_model) + m_model->setSize(newGeometry.size()); +} + +void KeyboardSurface::pointerDown(int id, QPointF pos, qint64 timeMs) +{ + if (!m_model || !m_controller) + return; + const int index = m_model->keyAt(pos); + if (index < 0) + return; + + const Pointer pointer{m_model->key(index), index, pos, pos}; + m_pointers.insert(id, pointer); + m_model->setPressed(index, true); + m_controller->keyDown(pointer.key, timeMs); +} + +void KeyboardSurface::pointerMove(int id, QPointF pos) +{ + auto it = m_pointers.find(id); + if (it != m_pointers.end()) + it->position = pos; +} + +void KeyboardSurface::pointerUp(int id, qint64 timeMs) +{ + const auto it = m_pointers.constFind(id); + if (it == m_pointers.cend()) + return; + const Pointer pointer = *it; + m_pointers.erase(it); + + m_model->setPressed(pointer.index, false); + m_controller->keyUp(pointer.key, KeyOutput::Primary, {}, timeMs); +} + +void KeyboardSurface::cancelAll() +{ + const auto pointers = std::exchange(m_pointers, {}); + for (const Pointer &pointer : pointers) { + if (m_model) + m_model->setPressed(pointer.index, false); + if (m_controller) + m_controller->keyCancelled(pointer.key); + } +} + +void KeyboardSurface::forgetIndices() +{ + for (Pointer &pointer : m_pointers) + pointer.index = -1; +} diff --git a/src/input/KeyboardSurface.h b/src/input/KeyboardSurface.h new file mode 100644 index 0000000..c9d0028 --- /dev/null +++ b/src/input/KeyboardSurface.h @@ -0,0 +1,59 @@ +#pragma once + +#include "KeyboardController.h" +#include "layout/Layout.h" +#include "layout/LayoutModel.h" + +#include +#include +#include + +// Receives all touch points (and the mouse) over the keyboard, maps them to +// keys and forwards key presses to the controller. Tracks each pointer +// separately so several fingers can type at once. +class KeyboardSurface : public QQuickItem +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(LayoutModel *model READ model WRITE setModel NOTIFY modelChanged) + Q_PROPERTY(KeyboardController *controller READ controller WRITE setController NOTIFY controllerChanged) + +public: + explicit KeyboardSurface(QQuickItem *parent = nullptr); + + LayoutModel *model() const { return m_model; } + void setModel(LayoutModel *model); + KeyboardController *controller() const { return m_controller; } + void setController(KeyboardController *controller); + +Q_SIGNALS: + void modelChanged(); + void controllerChanged(); + +protected: + void touchEvent(QTouchEvent *event) override; + void touchUngrabEvent() override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseUngrabEvent() override; + void geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) override; + +private: + struct Pointer { + KeyDef key; // copied: the model may switch layers mid-press + int index; // into the model, -1 once the layer changed + QPointF start; + QPointF position; + }; + + void pointerDown(int id, QPointF pos, qint64 timeMs); + void pointerMove(int id, QPointF pos); + void pointerUp(int id, qint64 timeMs); + void cancelAll(); + void forgetIndices(); + + QPointer m_model; + QPointer m_controller; + QHash m_pointers; +}; diff --git a/src/input/ShiftState.cpp b/src/input/ShiftState.cpp new file mode 100644 index 0000000..8978112 --- /dev/null +++ b/src/input/ShiftState.cpp @@ -0,0 +1,37 @@ +#include "ShiftState.h" + +void ShiftState::press(qint64 timeMs) +{ + const bool doubleTap = timeMs - m_lastPress <= DoubleTapMs; + m_lastPress = timeMs; + m_held = true; + m_typedWhileHeld = false; + + switch (m_mode) { + case Mode::Off: m_mode = Mode::Once; break; + case Mode::Once: m_mode = doubleTap ? Mode::Locked : Mode::Off; break; + case Mode::Locked: m_mode = Mode::Off; break; + } +} + +void ShiftState::release() +{ + m_held = false; + if (m_typedWhileHeld && m_mode == Mode::Once) + m_mode = Mode::Off; +} + +void ShiftState::characterTyped() +{ + if (m_held) + m_typedWhileHeld = true; + else if (m_mode == Mode::Once) + m_mode = Mode::Off; +} + +void ShiftState::setAutomatic(bool capitalize) +{ + if (m_held || m_mode == Mode::Locked) + return; + m_mode = capitalize ? Mode::Once : Mode::Off; +} diff --git a/src/input/ShiftState.h b/src/input/ShiftState.h new file mode 100644 index 0000000..8e5a09b --- /dev/null +++ b/src/input/ShiftState.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +// iOS shift semantics: +// - tap: shift the next character only +// - double-tap: caps lock; tap again to unlock +// - hold while typing: every character shifted, off on release +// Times are in milliseconds and passed in, so this stays deterministic. +class ShiftState +{ +public: + enum class Mode { Off, Once, Locked }; + + static constexpr qint64 DoubleTapMs = 300; + + Mode mode() const { return m_mode; } + bool isUppercase() const { return m_mode != Mode::Off; } + + void press(qint64 timeMs); + void release(); + void characterTyped(); + + // Auto-capitalization hint; ignored while shift is held or locked. + void setAutomatic(bool capitalize); + +private: + Mode m_mode = Mode::Off; + bool m_held = false; + bool m_typedWhileHeld = false; + qint64 m_lastPress = std::numeric_limits::min() / 2; +}; diff --git a/src/layout/KeyGeometry.cpp b/src/layout/KeyGeometry.cpp new file mode 100644 index 0000000..e056729 --- /dev/null +++ b/src/layout/KeyGeometry.cpp @@ -0,0 +1,46 @@ +#include "KeyGeometry.h" + +#include + +namespace { + +qreal rowUnits(const Row &row) +{ + qreal units = 0; + for (const KeyDef &key : row) + units += key.width; + return units; +} + +} // namespace + +QList computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics) +{ + QList rects; + if (layer.rows.isEmpty()) + return rects; + + qreal maxUnits = 0; + for (const Row &row : layer.rows) + maxUnits = std::max(maxUnits, rowUnits(row)); + + const qreal innerWidth = size.width() - 2 * metrics.padding; + const qreal innerHeight = size.height() - 2 * metrics.padding; + const int rowCount = int(layer.rows.size()); + + // Each cell includes one trailing gap; the last gap of a row is dropped. + const qreal unitWidth = (innerWidth + metrics.gap) / maxUnits; + const qreal rowHeight = (innerHeight - (rowCount - 1) * metrics.gap) / rowCount; + + qreal y = metrics.padding; + for (const Row &row : layer.rows) { + qreal x = metrics.padding + (maxUnits - rowUnits(row)) * unitWidth / 2; + for (const KeyDef &key : row) { + const qreal cellWidth = key.width * unitWidth; + rects.append(QRectF(x, y, std::max(0, cellWidth - metrics.gap), rowHeight)); + x += cellWidth; + } + y += rowHeight + metrics.gap; + } + return rects; +} diff --git a/src/layout/KeyGeometry.h b/src/layout/KeyGeometry.h new file mode 100644 index 0000000..1644eff --- /dev/null +++ b/src/layout/KeyGeometry.h @@ -0,0 +1,16 @@ +#pragma once + +#include "Layout.h" + +#include +#include + +struct KeyboardMetrics { + qreal padding = 6; // around the whole keyboard + qreal gap = 8; // between keys, horizontally and vertically +}; + +// Lays out a layer's keys to fill `size`. Returns one rect per key, in row +// order (spacers included). One key unit has the same width in every row, so +// rows with equal unit totals line up; shorter rows are centered. +QList computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics = {}); diff --git a/src/layout/Layout.h b/src/layout/Layout.h new file mode 100644 index 0000000..c9b1433 --- /dev/null +++ b/src/layout/Layout.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +enum class KeyType { + Character, + Space, + Backspace, + Enter, + Shift, + LayerSwitch, + Globe, + Dismiss, + Spacer, // empty room, e.g. the half-key indent of the home row +}; + +struct KeyDef { + KeyType type = KeyType::Character; + QString label; // shown and typed (for character keys) + QString shifted; // explicit shifted form; empty means label.toUpper() + QString flick; // secondary symbol, typed by flicking down + QStringList alternates; // long-press choices + QString target; // layer to switch to (LayerSwitch only) + qreal width = 1.0; // in key units + + // Text this key shows/types; only character keys change case. + QString text(bool uppercase) const + { + if (!uppercase || type != KeyType::Character) + return label; + return shifted.isEmpty() ? label.toUpper() : shifted; + } +}; + +using Row = QList; + +struct Layer { + QString name; + QList rows; +}; + +struct Layout { + QString id; // e.g. "en-US" + QString name; // human readable + QString locale; // e.g. "en_US" + QString base; // id of a layout to take missing layers from + QList layers; // the first one is shown by default + + const Layer *layer(const QString &layerName) const + { + for (const Layer &l : layers) { + if (l.name == layerName) + return &l; + } + return nullptr; + } +}; diff --git a/src/layout/LayoutLoader.cpp b/src/layout/LayoutLoader.cpp new file mode 100644 index 0000000..6de5c5f --- /dev/null +++ b/src/layout/LayoutLoader.cpp @@ -0,0 +1,160 @@ +#include "LayoutLoader.h" + +#include +#include +#include +#include + +namespace LayoutLoader { + +namespace { + +const QHash &keyTypesByName() +{ + static const QHash types = { + {QStringLiteral("char"), KeyType::Character}, + {QStringLiteral("space"), KeyType::Space}, + {QStringLiteral("backspace"), KeyType::Backspace}, + {QStringLiteral("enter"), KeyType::Enter}, + {QStringLiteral("shift"), KeyType::Shift}, + {QStringLiteral("layer"), KeyType::LayerSwitch}, + {QStringLiteral("globe"), KeyType::Globe}, + {QStringLiteral("dismiss"), KeyType::Dismiss}, + {QStringLiteral("spacer"), KeyType::Spacer}, + }; + return types; +} + +std::optional parseKey(const QJsonValue &value, QString *error) +{ + KeyDef key; + if (value.isString()) { + key.label = value.toString(); + return key; + } + if (!value.isObject()) { + *error = QStringLiteral("key must be a string or an object"); + return std::nullopt; + } + + const QJsonObject obj = value.toObject(); + const QString typeName = obj.value(u"type").toString(QStringLiteral("char")); + const auto type = keyTypesByName().constFind(typeName); + if (type == keyTypesByName().cend()) { + *error = QStringLiteral("unknown key type \"%1\"").arg(typeName); + return std::nullopt; + } + + key.type = *type; + key.label = obj.value(u"label").toString(); + key.shifted = obj.value(u"shifted").toString(); + key.flick = obj.value(u"flick").toString(); + key.target = obj.value(u"target").toString(); + key.width = obj.value(u"width").toDouble(1.0); + for (const QJsonValue &alt : obj.value(u"alternates").toArray()) + key.alternates.append(alt.toString()); + + if (key.type == KeyType::Character && key.label.isEmpty()) { + *error = QStringLiteral("character key without a label"); + return std::nullopt; + } + if (key.width <= 0) { + *error = QStringLiteral("key \"%1\" has a non-positive width").arg(key.label); + return std::nullopt; + } + return key; +} + +std::optional parseLayer(const QJsonObject &obj, QString *error) +{ + Layer layer; + layer.name = obj.value(u"name").toString(); + if (layer.name.isEmpty()) { + *error = QStringLiteral("layer without a name"); + return std::nullopt; + } + + for (const QJsonValue &rowValue : obj.value(u"rows").toArray()) { + Row row; + for (const QJsonValue &keyValue : rowValue.toArray()) { + auto key = parseKey(keyValue, error); + if (!key) { + *error = QStringLiteral("layer \"%1\": %2").arg(layer.name, *error); + return std::nullopt; + } + row.append(*key); + } + layer.rows.append(row); + } + + if (layer.rows.isEmpty()) { + *error = QStringLiteral("layer \"%1\" has no rows").arg(layer.name); + return std::nullopt; + } + return layer; +} + +} // namespace + +std::optional parse(const QByteArray &json, QString *error) +{ + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(json, &parseError); + if (!doc.isObject()) { + *error = parseError.error != QJsonParseError::NoError ? parseError.errorString() + : QStringLiteral("top level must be an object"); + return std::nullopt; + } + + const QJsonObject root = doc.object(); + Layout layout; + layout.id = root.value(u"id").toString(); + layout.name = root.value(u"name").toString(layout.id); + layout.locale = root.value(u"locale").toString(); + layout.base = root.value(u"base").toString(); + if (layout.id.isEmpty()) { + *error = QStringLiteral("layout without an id"); + return std::nullopt; + } + + for (const QJsonValue &layerValue : root.value(u"layers").toArray()) { + auto layer = parseLayer(layerValue.toObject(), error); + if (!layer) + return std::nullopt; + layout.layers.append(*layer); + } + + if (layout.layers.isEmpty() && layout.base.isEmpty()) { + *error = QStringLiteral("layout has no layers and no base"); + return std::nullopt; + } + return layout; +} + +std::optional load(const QString &path, QString *error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + *error = file.errorString(); + return std::nullopt; + } + return parse(file.readAll(), error); +} + +bool validate(const Layout &layout, QString *error) +{ + for (const Layer &layer : layout.layers) { + for (const Row &row : layer.rows) { + for (const KeyDef &key : row) { + if (key.type == KeyType::LayerSwitch && !layout.layer(key.target)) { + *error = QStringLiteral("layer \"%1\" switches to unknown layer \"%2\"") + .arg(layer.name, key.target); + return false; + } + } + } + } + return true; +} + +} // namespace LayoutLoader diff --git a/src/layout/LayoutLoader.h b/src/layout/LayoutLoader.h new file mode 100644 index 0000000..c3e2070 --- /dev/null +++ b/src/layout/LayoutLoader.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Layout.h" + +#include + +class QJsonValue; + +// Parses the JSON layout format (see layouts/en-US.json). +// A key is either a plain string (a character key) or an object: +// {"type": "backspace", "width": 1.5} +// {"label": "e", "flick": "3", "alternates": ["é", "è"]} +namespace LayoutLoader { + +std::optional parse(const QByteArray &json, QString *error); +std::optional load(const QString &path, QString *error); + +// Checks references that can only be resolved once bases are merged in. +bool validate(const Layout &layout, QString *error); + +} // namespace LayoutLoader diff --git a/src/layout/LayoutModel.cpp b/src/layout/LayoutModel.cpp new file mode 100644 index 0000000..2c333d0 --- /dev/null +++ b/src/layout/LayoutModel.cpp @@ -0,0 +1,165 @@ +#include "LayoutModel.h" + +#include + +namespace { + +QString keyTypeName(KeyType type) +{ + switch (type) { + case KeyType::Character: return QStringLiteral("char"); + case KeyType::Space: return QStringLiteral("space"); + case KeyType::Backspace: return QStringLiteral("backspace"); + case KeyType::Enter: return QStringLiteral("enter"); + case KeyType::Shift: return QStringLiteral("shift"); + case KeyType::LayerSwitch: return QStringLiteral("layer"); + case KeyType::Globe: return QStringLiteral("globe"); + case KeyType::Dismiss: return QStringLiteral("dismiss"); + case KeyType::Spacer: return QStringLiteral("spacer"); + } + return {}; +} + +qreal distanceSquared(const QRectF &rect, QPointF p) +{ + const qreal dx = std::max({rect.left() - p.x(), 0.0, p.x() - rect.right()}); + const qreal dy = std::max({rect.top() - p.y(), 0.0, p.y() - rect.bottom()}); + return dx * dx + dy * dy; +} + +} // namespace + +int LayoutModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : int(m_keys.size()); +} + +QVariant LayoutModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index, CheckIndexOption::IndexIsValid)) + return {}; + + const KeyDef &key = m_keys.at(index.row()); + const QRectF rect = m_rects.value(index.row()); + switch (role) { + case LabelRole: return key.text(m_uppercase); + case FlickLabelRole: return key.flick; + case KeyTypeRole: return keyTypeName(key.type); + case KeyXRole: return rect.x(); + case KeyYRole: return rect.y(); + case KeyWidthRole: return rect.width(); + case KeyHeightRole: return rect.height(); + case PressedRole: return m_pressCount.at(index.row()) > 0; + } + return {}; +} + +QHash LayoutModel::roleNames() const +{ + return { + {LabelRole, "label"}, + {FlickLabelRole, "flickLabel"}, + {KeyTypeRole, "keyType"}, + {KeyXRole, "keyX"}, + {KeyYRole, "keyY"}, + {KeyWidthRole, "keyWidth"}, + {KeyHeightRole, "keyHeight"}, + {PressedRole, "pressed"}, + }; +} + +void LayoutModel::setLayout(const Layout &layout) +{ + m_layout = layout; + m_layerName = layout.layers.isEmpty() ? QString() : layout.layers.first().name; + rebuild(); + Q_EMIT layoutChanged(); + Q_EMIT layerChanged(); +} + +void LayoutModel::setLayer(const QString &layerName) +{ + if (layerName == m_layerName || !m_layout.layer(layerName)) + return; + m_layerName = layerName; + rebuild(); + Q_EMIT layerChanged(); +} + +void LayoutModel::setSize(QSizeF size) +{ + if (size == m_size) + return; + m_size = size; + relayout(); + if (!m_keys.isEmpty()) + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {KeyXRole, KeyYRole, KeyWidthRole, KeyHeightRole}); +} + +void LayoutModel::setUppercase(bool uppercase) +{ + if (uppercase == m_uppercase) + return; + m_uppercase = uppercase; + if (!m_keys.isEmpty()) + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {LabelRole}); +} + +void LayoutModel::setPressed(int row, bool pressed) +{ + if (row < 0 || row >= m_pressCount.size()) + return; + const bool wasPressed = m_pressCount[row] > 0; + m_pressCount[row] = std::max(0, m_pressCount[row] + (pressed ? 1 : -1)); + if (wasPressed != (m_pressCount[row] > 0)) + Q_EMIT dataChanged(index(row), index(row), {PressedRole}); +} + +int LayoutModel::keyAt(QPointF pos) const +{ + int nearest = -1; + qreal best = std::numeric_limits::max(); + for (qsizetype i = 0; i < m_rects.size(); ++i) { + const qreal d = distanceSquared(m_rects[i], pos); + if (d < best) { + best = d; + nearest = int(i); + } + } + return nearest; +} + +void LayoutModel::rebuild() +{ + beginResetModel(); + m_keys.clear(); + if (const Layer *layer = m_layout.layer(m_layerName)) { + for (const Row &row : layer->rows) { + for (const KeyDef &key : row) { + if (key.type != KeyType::Spacer) + m_keys.append(key); + } + } + } + m_pressCount = QList(m_keys.size(), 0); + relayout(); + endResetModel(); +} + +void LayoutModel::relayout() +{ + m_rects.clear(); + const Layer *layer = m_layout.layer(m_layerName); + if (!layer || m_size.isEmpty()) + return; + + const QList all = computeKeyRects(*layer, m_size); + qsizetype i = 0; + for (const Row &row : layer->rows) { + for (const KeyDef &key : row) { + if (key.type != KeyType::Spacer) + m_rects.append(all[i]); + ++i; + } + } +} diff --git a/src/layout/LayoutModel.h b/src/layout/LayoutModel.h new file mode 100644 index 0000000..91bcfb7 --- /dev/null +++ b/src/layout/LayoutModel.h @@ -0,0 +1,66 @@ +#pragma once + +#include "KeyGeometry.h" +#include "Layout.h" + +#include +#include + +// The keys of the current layer, positioned for the current size. +// Spacers take room but are not exposed as items. +class LayoutModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Created in C++") + Q_PROPERTY(QString layoutName READ layoutName NOTIFY layoutChanged) + Q_PROPERTY(QString layerName READ layerName NOTIFY layerChanged) + +public: + enum Role { + LabelRole = Qt::UserRole + 1, + FlickLabelRole, + KeyTypeRole, + KeyXRole, + KeyYRole, + KeyWidthRole, + KeyHeightRole, + PressedRole, + }; + + using QAbstractListModel::QAbstractListModel; + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + QString layoutName() const { return m_layout.name; } + QString layerName() const { return m_layerName; } + + void setLayout(const Layout &layout); + void setLayer(const QString &layerName); + void setSize(QSizeF size); + void setUppercase(bool uppercase); + void setPressed(int index, bool pressed); + + // Key under `pos`, or the nearest one; -1 if there are no keys. + int keyAt(QPointF pos) const; + const KeyDef &key(int index) const { return m_keys.at(index); } + +Q_SIGNALS: + void layoutChanged(); + void layerChanged(); + +private: + void rebuild(); + void relayout(); + + Layout m_layout; + QString m_layerName; + QSizeF m_size; + bool m_uppercase = false; + + QList m_keys; + QList m_rects; + QList m_pressCount; // several fingers may hold one key +}; diff --git a/src/layout/LayoutRepository.cpp b/src/layout/LayoutRepository.cpp new file mode 100644 index 0000000..ead8e82 --- /dev/null +++ b/src/layout/LayoutRepository.cpp @@ -0,0 +1,75 @@ +#include "LayoutRepository.h" +#include "LayoutLoader.h" + +#include +#include + +#include + +Q_LOGGING_CATEGORY(lcLayouts, "sloposk.layouts") + +void LayoutRepository::loadDirectory(const QString &dir) +{ + const QDir directory(dir); + for (const QString &file : directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name)) { + QString error; + const auto layout = LayoutLoader::load(directory.filePath(file), &error); + if (layout) + add(*layout); + else + qCWarning(lcLayouts) << "Skipping" << directory.filePath(file) << ":" << error; + } +} + +void LayoutRepository::finalize(const QString &preferredLocale) +{ + resolveBases(); + + m_layouts.removeIf([](const Layout &layout) { + QString error; + if (LayoutLoader::validate(layout, &error)) + return false; + qCWarning(lcLayouts) << "Dropping layout" << layout.id << ":" << error; + return true; + }); + + std::stable_partition(m_layouts.begin(), m_layouts.end(), [&](const Layout &layout) { + return layout.locale == preferredLocale; + }); +} + +int LayoutRepository::indexOf(const QString &id) const +{ + for (qsizetype i = 0; i < m_layouts.size(); ++i) { + if (m_layouts[i].id == id) + return int(i); + } + return -1; +} + +void LayoutRepository::add(const Layout &layout) +{ + const int existing = indexOf(layout.id); + if (existing >= 0) + m_layouts[existing] = layout; + else + m_layouts.append(layout); +} + +void LayoutRepository::resolveBases() +{ + // One level of inheritance keeps this simple and cycle-free. + for (Layout &layout : m_layouts) { + if (layout.base.isEmpty()) + continue; + const int baseIndex = indexOf(layout.base); + if (baseIndex < 0) { + qCWarning(lcLayouts) << "Layout" << layout.id << "has unknown base" << layout.base; + continue; + } + for (const Layer &layer : m_layouts[baseIndex].layers) { + if (!layout.layer(layer.name)) + layout.layers.append(layer); + } + } +} diff --git a/src/layout/LayoutRepository.h b/src/layout/LayoutRepository.h new file mode 100644 index 0000000..7ecf310 --- /dev/null +++ b/src/layout/LayoutRepository.h @@ -0,0 +1,24 @@ +#pragma once + +#include "Layout.h" + +// All usable layouts: built-ins from resources plus user overrides on disk. +// A later layout with the same id replaces an earlier one. +class LayoutRepository +{ +public: + // Loads every *.json in dir. Invalid files are skipped with a warning. + void loadDirectory(const QString &dir); + + // Merges base layers in, drops invalid layouts, puts `locale` first. + void finalize(const QString &preferredLocale); + + const QList &layouts() const { return m_layouts; } + int indexOf(const QString &id) const; + +private: + void add(const Layout &layout); + void resolveBases(); + + QList m_layouts; +}; diff --git a/src/main.cpp b/src/main.cpp index 183880f..fbb51a1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,32 @@ +#include "DevHooks.h" #include "backend/KWinInputMethod.h" +#include "engine/DirectEngine.h" +#include "input/KeyboardController.h" +#include "layout/LayoutModel.h" +#include "layout/LayoutRepository.h" #include "window/FloatingWindow.h" #include +#include #include +#include #include +#include + +Q_IMPORT_QML_PLUGIN(SloposkPlugin) + +namespace { + +void loadLayouts(LayoutRepository &layouts) +{ + layouts.loadDirectory(QStringLiteral(":/layouts")); + // User layouts (~/.local/share/sloposk/layouts) override built-ins by id. + layouts.loadDirectory(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/layouts")); + layouts.finalize(QLocale::system().name()); +} + +} // namespace int main(int argc, char *argv[]) { @@ -11,12 +34,25 @@ int main(int argc, char *argv[]) QGuiApplication::setApplicationName(QStringLiteral("sloposk")); QGuiApplication::setDesktopFileName(QStringLiteral("org.sloposk.keyboard")); - KWinInputMethod backend; + LayoutRepository layouts; + loadLayouts(layouts); + if (layouts.layouts().isEmpty()) { + qCritical("No usable keyboard layouts found"); + return 1; + } - QQmlApplicationEngine engine; - engine.setInitialProperties({{QStringLiteral("backend"), QVariant::fromValue(&backend)}}); - engine.loadFromModule("Sloposk", "Main"); - auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast(engine.rootObjects().first()); + KWinInputMethod backend; + DirectEngine engine(backend); + LayoutModel model; + KeyboardController controller(engine, model, layouts); + + QQmlApplicationEngine qml; + qml.setInitialProperties({ + {QStringLiteral("layoutModel"), QVariant::fromValue(&model)}, + {QStringLiteral("controller"), QVariant::fromValue(&controller)}, + }); + qml.loadFromModule("Sloposk", "Main"); + auto *window = qml.rootObjects().isEmpty() ? nullptr : qobject_cast(qml.rootObjects().first()); if (!window) { qCritical("Failed to load the keyboard UI"); return 1; @@ -35,15 +71,10 @@ int main(int argc, char *argv[]) }; QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility); QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility); + QObject::connect(&controller, &KeyboardController::dismissRequested, &placement, &FloatingWindow::hide); updateVisibility(); - // Dev aid for headless testing: type a string as soon as a field is focused. - if (const QString autotype = qEnvironmentVariable("SLOPOSK_AUTOTYPE"); !autotype.isEmpty()) { - QObject::connect(&backend, &TextBackend::activeChanged, &app, [&backend, autotype] { - if (backend.isActive()) - backend.commitText(autotype); - }); - } + installDevHooks(backend, *window); return app.exec(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..4e5e07b --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,11 @@ +function(sloposk_add_test name) + qt_add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE sloposk_core Qt6::Test) + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endfunction() + +sloposk_add_test(tst_shiftstate) +sloposk_add_test(tst_layoutloader) +sloposk_add_test(tst_keygeometry) +sloposk_add_test(tst_controller) diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp new file mode 100644 index 0000000..922b6a6 --- /dev/null +++ b/tests/tst_controller.cpp @@ -0,0 +1,128 @@ +#include "engine/InputEngine.h" +#include "input/KeyboardController.h" +#include "layout/LayoutLoader.h" +#include "layout/LayoutModel.h" +#include "layout/LayoutRepository.h" + +#include +#include + +using namespace Qt::StringLiterals; + +namespace { + +class FakeEngine : public InputEngine +{ +public: + void commitText(const QString &text) override { output += text; } + void sendKeysym(quint32 keysym) override { keysyms.append(keysym); } + + QString output; + QList keysyms; +}; + +KeyDef makeKey(KeyType type, const QString &label = {}) +{ + KeyDef k; + k.type = type; + k.label = label; + return k; +} + +} // namespace + +class TestController : public QObject +{ + Q_OBJECT + +private: + FakeEngine engine; + LayoutRepository repo; + LayoutModel model; + std::unique_ptr controller; + qint64 now = 0; + + void tap(const KeyDef &key, KeyOutput output = KeyOutput::Primary, const QString &alternate = {}) + { + now += 1000; // far apart: never a double tap + controller->keyDown(key, now); + controller->keyUp(key, output, alternate, now + 50); + } + +private Q_SLOTS: + void init() + { + engine = {}; + repo = {}; + Q_INIT_RESOURCE(layouts); + repo.loadDirectory(u":/layouts"_s); + repo.finalize(u"en_US"_s); + controller = std::make_unique(engine, model, repo); + } + + void typesLowercase() + { + tap(makeKey(KeyType::Character, u"a"_s)); + QCOMPARE(engine.output, u"a"); + } + + void shiftCapitalizesOneCharacter() + { + const KeyDef a = makeKey(KeyType::Character, u"a"_s); + tap(makeKey(KeyType::Shift)); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); + tap(a); + tap(a); + QCOMPARE(engine.output, u"Aa"); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); + } + + void flickAndAlternateOutputs() + { + KeyDef e = makeKey(KeyType::Character, u"e"_s); + e.flick = u"3"_s; + tap(e, KeyOutput::Flick); + tap(e, KeyOutput::Alternate, u"é"_s); + QCOMPARE(engine.output, u"3é"); + } + + void backspaceActsOnPress() + { + controller->keyDown(makeKey(KeyType::Backspace), 0); + QCOMPARE(engine.keysyms, QList{XKB_KEY_BackSpace}); + } + + void enterSendsReturn() + { + tap(makeKey(KeyType::Enter)); + QCOMPARE(engine.keysyms, QList{XKB_KEY_Return}); + } + + void layerSwitchChangesModelLayer() + { + KeyDef layer = makeKey(KeyType::LayerSwitch); + layer.target = u"numbers"_s; + controller->keyDown(layer, 0); + QCOMPARE(model.layerName(), u"numbers"); + } + + void globeCyclesLayouts() + { + QCOMPARE(model.layoutName(), u"English (US)"); + tap(makeKey(KeyType::Globe)); + QCOMPARE(model.layoutName(), u"Svenska"); + tap(makeKey(KeyType::Globe)); + QCOMPARE(model.layoutName(), u"English (US)"); + } + + void dismissEmitsSignal() + { + int dismissed = 0; + connect(controller.get(), &KeyboardController::dismissRequested, this, [&] { ++dismissed; }); + tap(makeKey(KeyType::Dismiss)); + QCOMPARE(dismissed, 1); + } +}; + +QTEST_GUILESS_MAIN(TestController) +#include "tst_controller.moc" diff --git a/tests/tst_keygeometry.cpp b/tests/tst_keygeometry.cpp new file mode 100644 index 0000000..b505e96 --- /dev/null +++ b/tests/tst_keygeometry.cpp @@ -0,0 +1,55 @@ +#include "layout/KeyGeometry.h" + +#include + +using namespace Qt::StringLiterals; + +namespace { + +KeyDef key(qreal width = 1.0, KeyType type = KeyType::Character) +{ + KeyDef k; + k.type = type; + k.label = u"x"_s; + k.width = width; + return k; +} + +} // namespace + +class TestKeyGeometry : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void fillsWidthAndHeight() + { + const Layer layer{u"l"_s, {{key(), key()}, {key(2)}}}; + const KeyboardMetrics metrics{.padding = 10, .gap = 10}; + const auto rects = computeKeyRects(layer, {210, 110}, metrics); + + QCOMPARE(rects.size(), 3); + QCOMPARE(rects[0], QRectF(10, 10, 90, 40)); + QCOMPARE(rects[1], QRectF(110, 10, 90, 40)); + QCOMPARE(rects[2], QRectF(10, 60, 190, 40)); // a 2-unit key spans the gap + } + + void spacersShiftFollowingKeys() + { + const Layer layer{u"l"_s, {{key(), key(), key()}, {key(0.5, KeyType::Spacer), key(), key(1.5)}}}; + const auto rects = computeKeyRects(layer, {300, 100}, {.padding = 0, .gap = 0}); + QCOMPARE(rects[4].left(), 50.0); + QCOMPARE(rects[5].right(), 300.0); + } + + void shorterRowsAreCentered() + { + const Layer layer{u"l"_s, {{key(), key(), key(), key()}, {key(), key()}}}; + const auto rects = computeKeyRects(layer, {400, 100}, {.padding = 0, .gap = 0}); + QCOMPARE(rects[4].left(), 100.0); + QCOMPARE(rects[5].right(), 300.0); + } +}; + +QTEST_GUILESS_MAIN(TestKeyGeometry) +#include "tst_keygeometry.moc" diff --git a/tests/tst_layoutloader.cpp b/tests/tst_layoutloader.cpp new file mode 100644 index 0000000..6194aa0 --- /dev/null +++ b/tests/tst_layoutloader.cpp @@ -0,0 +1,91 @@ +#include "layout/LayoutLoader.h" +#include "layout/LayoutRepository.h" + +#include + +using namespace Qt::StringLiterals; + +namespace { + +qreal rowUnits(const Row &row) +{ + qreal units = 0; + for (const KeyDef &key : row) + units += key.width; + return units; +} + +} // namespace + +class TestLayoutLoader : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void parsesStringAndObjectKeys() + { + QString error; + const auto layout = LayoutLoader::parse(R"({ + "id": "t", "layers": [{"name": "letters", "rows": [[ + "q", + {"label": "e", "flick": "3", "alternates": ["é", "è"]}, + {"type": "backspace", "width": 1.5} + ]]}] + })", &error); + QVERIFY2(layout, qPrintable(error)); + + const Row &row = layout->layers.first().rows.first(); + QCOMPARE(row.size(), 3); + QCOMPARE(row[0].label, u"q"); + QCOMPARE(row[1].flick, u"3"); + QCOMPARE(row[1].alternates, QStringList({u"é"_s, u"è"_s})); + QCOMPARE(row[2].type, KeyType::Backspace); + QCOMPARE(row[2].width, 1.5); + } + + void rejectsUnknownKeyType() + { + QString error; + QVERIFY(!LayoutLoader::parse(R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "warp"}]]}]})", &error)); + QVERIFY(error.contains(u"warp")); + } + + void rejectsMissingId() + { + QString error; + QVERIFY(!LayoutLoader::parse(R"({"layers": []})", &error)); + } + + void validateCatchesUnknownLayerTarget() + { + QString error; + const auto layout = LayoutLoader::parse( + R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "layer", "target": "nope"}]]}]})", &error); + QVERIFY(layout); + QVERIFY(!LayoutLoader::validate(*layout, &error)); + } + + void builtinLayoutsAreConsistent() + { + Q_INIT_RESOURCE(layouts); + LayoutRepository repo; + repo.loadDirectory(u":/layouts"_s); + repo.finalize({}); + QVERIFY(repo.indexOf(u"en-US"_s) >= 0); + QVERIFY(repo.indexOf(u"sv-SE"_s) >= 0); + + for (const Layout &layout : repo.layouts()) { + QVERIFY2(layout.layer(u"numbers"_s), qPrintable(layout.id)); // sv-SE gets it from its base + for (const Layer &layer : layout.layers) { + const qreal expected = rowUnits(layer.rows.first()); + for (const Row &row : layer.rows) { + QVERIFY2(qFuzzyCompare(rowUnits(row), expected), + qPrintable(u"%1/%2: rows differ in width"_s.arg(layout.id, layer.name))); + } + } + } + } +}; + +QTEST_GUILESS_MAIN(TestLayoutLoader) +#include "tst_layoutloader.moc" diff --git a/tests/tst_shiftstate.cpp b/tests/tst_shiftstate.cpp new file mode 100644 index 0000000..848b205 --- /dev/null +++ b/tests/tst_shiftstate.cpp @@ -0,0 +1,86 @@ +#include "input/ShiftState.h" + +#include + +using Mode = ShiftState::Mode; + +class TestShiftState : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void tapShiftsOneCharacter() + { + ShiftState shift; + shift.press(0); + shift.release(); + QCOMPARE(shift.mode(), Mode::Once); + shift.characterTyped(); + QCOMPARE(shift.mode(), Mode::Off); + } + + void doubleTapLocks() + { + ShiftState shift; + shift.press(0); + shift.release(); + shift.press(200); + shift.release(); + QCOMPARE(shift.mode(), Mode::Locked); + shift.characterTyped(); + shift.characterTyped(); + QCOMPARE(shift.mode(), Mode::Locked); + } + + void tapUnlocks() + { + ShiftState shift; + shift.press(0); + shift.press(100); + shift.press(1000); + QCOMPARE(shift.mode(), Mode::Off); + } + + void slowSecondTapTurnsOff() + { + ShiftState shift; + shift.press(0); + shift.release(); + shift.press(ShiftState::DoubleTapMs + 1); + shift.release(); + QCOMPARE(shift.mode(), Mode::Off); + } + + void holdShiftsEveryCharacterUntilRelease() + { + ShiftState shift; + shift.press(0); + shift.characterTyped(); + shift.characterTyped(); + QCOMPARE(shift.mode(), Mode::Once); + shift.release(); + QCOMPARE(shift.mode(), Mode::Off); + } + + void automaticIgnoredWhileLocked() + { + ShiftState shift; + shift.press(0); + shift.press(100); + shift.release(); + shift.setAutomatic(false); + QCOMPARE(shift.mode(), Mode::Locked); + } + + void automaticCapitalizes() + { + ShiftState shift; + shift.setAutomatic(true); + QCOMPARE(shift.mode(), Mode::Once); + shift.characterTyped(); + QCOMPARE(shift.mode(), Mode::Off); + } +}; + +QTEST_GUILESS_MAIN(TestShiftState) +#include "tst_shiftstate.moc"