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 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -5,7 +5,7 @@ set(CMAKE_CXX_STANDARD 20)
|
|||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS 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(LayerShellQt REQUIRED)
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(XKB REQUIRED IMPORTED_TARGET xkbcommon)
|
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)
|
qt_standard_project_setup(REQUIRES 6.8)
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
|
||||||
add_subdirectory(src)
|
add_subdirectory(src)
|
||||||
add_subdirectory(tools)
|
add_subdirectory(tools)
|
||||||
|
add_subdirectory(tests)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-32
@@ -1,45 +1,34 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import Sloposk
|
||||||
|
|
||||||
// Spike UI: proves text commits, keysyms and dragging. Replaced by the real
|
|
||||||
// keyboard in milestone 2.
|
|
||||||
Window {
|
Window {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
required property var backend
|
required property LayoutModel layoutModel
|
||||||
property var placement: null
|
required property KeyboardController controller
|
||||||
|
property var placement: null // FloatingWindow, set from C++
|
||||||
|
|
||||||
width: 720
|
width: 1000
|
||||||
height: 120
|
height: 340
|
||||||
visible: false
|
visible: false
|
||||||
color: "#d1d3d9"
|
color: "transparent"
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.windowRadius
|
||||||
|
color: Theme.background
|
||||||
|
|
||||||
|
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
|
id: handle
|
||||||
anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 6 }
|
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
|
||||||
width: 120; height: 6; radius: 3
|
onMoved: (dx, dy) => root.placement?.moveBy(dx, dy)
|
||||||
color: "#8e8e93"
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+56
-20
@@ -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
|
qt_add_executable(sloposk
|
||||||
main.cpp
|
main.cpp
|
||||||
backend/TextBackend.h
|
DevHooks.h DevHooks.cpp
|
||||||
backend/InputMethodProtocol.h
|
backend/InputMethodProtocol.h
|
||||||
backend/KWinInputMethod.h backend/KWinInputMethod.cpp
|
backend/KWinInputMethod.h backend/KWinInputMethod.cpp
|
||||||
backend/KWinInputContext.h backend/KWinInputContext.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
|
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/qwayland-input-method-unstable-v1.cpp
|
||||||
PROPERTIES COMPILE_OPTIONS "-include;wayland-client-protocol.h")
|
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
|
target_link_libraries(sloposk PRIVATE
|
||||||
Qt6::Gui
|
sloposk_core
|
||||||
Qt6::Quick
|
sloposk_coreplugin
|
||||||
Qt6::WaylandClient
|
Qt6::WaylandClient
|
||||||
LayerShellQt::Interface
|
LayerShellQt::Interface
|
||||||
PkgConfig::XKB
|
|
||||||
PkgConfig::WAYLAND
|
PkgConfig::WAYLAND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#include "DevHooks.h"
|
||||||
|
|
||||||
|
#include "backend/TextBackend.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QQuickWindow>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#include "KeyboardController.h"
|
||||||
|
|
||||||
|
#include "engine/InputEngine.h"
|
||||||
|
#include "layout/LayoutModel.h"
|
||||||
|
#include "layout/LayoutRepository.h"
|
||||||
|
|
||||||
|
#include <xkbcommon/xkbcommon-keysyms.h>
|
||||||
|
|
||||||
|
// 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<typename Change>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ShiftState.h"
|
||||||
|
#include "layout/Layout.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
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<typename Change>
|
||||||
|
void changeShift(Change change);
|
||||||
|
|
||||||
|
InputEngine &m_engine;
|
||||||
|
LayoutModel &m_model;
|
||||||
|
const LayoutRepository &m_layouts;
|
||||||
|
ShiftState m_shift;
|
||||||
|
int m_layoutIndex = 0;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "KeyboardController.h"
|
||||||
|
#include "layout/Layout.h"
|
||||||
|
#include "layout/LayoutModel.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QQuickItem>
|
||||||
|
|
||||||
|
// 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<LayoutModel> m_model;
|
||||||
|
QPointer<KeyboardController> m_controller;
|
||||||
|
QHash<int, Pointer> m_pointers;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
// 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<qint64>::min() / 2;
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#include "KeyGeometry.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
qreal rowUnits(const Row &row)
|
||||||
|
{
|
||||||
|
qreal units = 0;
|
||||||
|
for (const KeyDef &key : row)
|
||||||
|
units += key.width;
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QList<QRectF> computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics)
|
||||||
|
{
|
||||||
|
QList<QRectF> 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<qreal>(0, cellWidth - metrics.gap), rowHeight));
|
||||||
|
x += cellWidth;
|
||||||
|
}
|
||||||
|
y += rowHeight + metrics.gap;
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Layout.h"
|
||||||
|
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QSizeF>
|
||||||
|
|
||||||
|
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<QRectF> computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics = {});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
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<KeyDef>;
|
||||||
|
|
||||||
|
struct Layer {
|
||||||
|
QString name;
|
||||||
|
QList<Row> 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<Layer> 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#include "LayoutLoader.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
namespace LayoutLoader {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QHash<QString, KeyType> &keyTypesByName()
|
||||||
|
{
|
||||||
|
static const QHash<QString, KeyType> 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<KeyDef> 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<Layer> 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<Layout> 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<Layout> 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Layout.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
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<Layout> parse(const QByteArray &json, QString *error);
|
||||||
|
std::optional<Layout> 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
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#include "LayoutModel.h"
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
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<int, QByteArray> 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<qreal>::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<int>(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<QRectF> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "KeyGeometry.h"
|
||||||
|
#include "Layout.h"
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QtQml/qqmlregistration.h>
|
||||||
|
|
||||||
|
// 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<int, QByteArray> 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<KeyDef> m_keys;
|
||||||
|
QList<QRectF> m_rects;
|
||||||
|
QList<int> m_pressCount; // several fingers may hold one key
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#include "LayoutRepository.h"
|
||||||
|
#include "LayoutLoader.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QLoggingCategory>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Layout> &layouts() const { return m_layouts; }
|
||||||
|
int indexOf(const QString &id) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void add(const Layout &layout);
|
||||||
|
void resolveBases();
|
||||||
|
|
||||||
|
QList<Layout> m_layouts;
|
||||||
|
};
|
||||||
+43
-12
@@ -1,9 +1,32 @@
|
|||||||
|
#include "DevHooks.h"
|
||||||
#include "backend/KWinInputMethod.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 "window/FloatingWindow.h"
|
||||||
|
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
|
#include <QLocale>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QQmlExtensionPlugin>
|
||||||
#include <QQuickWindow>
|
#include <QQuickWindow>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
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[])
|
int main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
@@ -11,12 +34,25 @@ int main(int argc, char *argv[])
|
|||||||
QGuiApplication::setApplicationName(QStringLiteral("sloposk"));
|
QGuiApplication::setApplicationName(QStringLiteral("sloposk"));
|
||||||
QGuiApplication::setDesktopFileName(QStringLiteral("org.sloposk.keyboard"));
|
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;
|
KWinInputMethod backend;
|
||||||
engine.setInitialProperties({{QStringLiteral("backend"), QVariant::fromValue<TextBackend *>(&backend)}});
|
DirectEngine engine(backend);
|
||||||
engine.loadFromModule("Sloposk", "Main");
|
LayoutModel model;
|
||||||
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QQuickWindow *>(engine.rootObjects().first());
|
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<QQuickWindow *>(qml.rootObjects().first());
|
||||||
if (!window) {
|
if (!window) {
|
||||||
qCritical("Failed to load the keyboard UI");
|
qCritical("Failed to load the keyboard UI");
|
||||||
return 1;
|
return 1;
|
||||||
@@ -35,15 +71,10 @@ int main(int argc, char *argv[])
|
|||||||
};
|
};
|
||||||
QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility);
|
QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility);
|
||||||
QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility);
|
QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility);
|
||||||
|
QObject::connect(&controller, &KeyboardController::dismissRequested, &placement, &FloatingWindow::hide);
|
||||||
updateVisibility();
|
updateVisibility();
|
||||||
|
|
||||||
// Dev aid for headless testing: type a string as soon as a field is focused.
|
installDevHooks(backend, *window);
|
||||||
if (const QString autotype = qEnvironmentVariable("SLOPOSK_AUTOTYPE"); !autotype.isEmpty()) {
|
|
||||||
QObject::connect(&backend, &TextBackend::activeChanged, &app, [&backend, autotype] {
|
|
||||||
if (backend.isActive())
|
|
||||||
backend.commitText(autotype);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 <QTest>
|
||||||
|
#include <xkbcommon/xkbcommon-keysyms.h>
|
||||||
|
|
||||||
|
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<quint32> 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<KeyboardController> 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<KeyboardController>(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<quint32>{XKB_KEY_BackSpace});
|
||||||
|
}
|
||||||
|
|
||||||
|
void enterSendsReturn()
|
||||||
|
{
|
||||||
|
tap(makeKey(KeyType::Enter));
|
||||||
|
QCOMPARE(engine.keysyms, QList<quint32>{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"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#include "layout/KeyGeometry.h"
|
||||||
|
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#include "layout/LayoutLoader.h"
|
||||||
|
#include "layout/LayoutRepository.h"
|
||||||
|
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#include "input/ShiftState.h"
|
||||||
|
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user