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:
2026-10-14 14:50:21 +02:00
co-authored by Claude Opus 5.5
parent cdef9b4ad0
commit be14bbcc62
35 changed files with 1970 additions and 65 deletions
+4 -1
View File
@@ -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)
+117
View File
@@ -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"}
]
]
}
]
}
+62
View File
@@ -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"}
]
]
}
]
}
+30
View File
@@ -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
View File
@@ -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
}
}
+13
View File
@@ -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
View File
@@ -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 {
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
anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 6 }
width: 120; height: 6; radius: 3
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)
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" }
}
}
+19
View File
@@ -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
View File
@@ -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
)
+38
View File
@@ -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);
}
+9
View File
@@ -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);
+18
View File
@@ -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);
}
+17
View File
@@ -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;
};
+15
View File
@@ -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;
};
+108
View File
@@ -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();
}
+55
View File
@@ -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;
};
+139
View File
@@ -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;
}
+59
View File
@@ -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;
};
+37
View File
@@ -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;
}
+33
View File
@@ -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;
};
+46
View File
@@ -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;
}
+16
View File
@@ -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 = {});
+59
View File
@@ -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;
}
};
+160
View File
@@ -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
+21
View File
@@ -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
+165
View File
@@ -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;
}
}
}
+66
View File
@@ -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
};
+75
View File
@@ -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);
}
}
}
+24
View File
@@ -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
View File
@@ -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 <QGuiApplication>
#include <QLocale>
#include <QQmlApplicationEngine>
#include <QQmlExtensionPlugin>
#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[])
{
@@ -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<TextBackend *>(&backend)}});
engine.loadFromModule("Sloposk", "Main");
auto *window = engine.rootObjects().isEmpty() ? nullptr : qobject_cast<QQuickWindow *>(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<QQuickWindow *>(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();
}
+11
View File
@@ -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)
+128
View File
@@ -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"
+55
View File
@@ -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"
+91
View File
@@ -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"
+86
View File
@@ -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"