Fix double-space period, shift double-tap; settings; letters from system keymap

- Double-space replaced the space via a Backspace keysym, which clients
  apply after the commit: use delete_surrounding_text with the commit
- Tests use a shared FakeEngine that applies ops to a simulated field
- Double-tap shift locks at a sentence start; typing between taps doesn't
- Organization name set: config lives in ~/.config/sloposk/
- Settings (auto-capitalization, double-space period) with a ⚙ panel
- Letter rows come from the compositor's XKB keymap; other letters of the
  layout become long-press alternates; globe switches the system layout
  over org.kde.keyboard
- One JSON template for non-letter layers; stretch keys even out rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 20:28:39 +02:00
co-authored by Claude Opus 5.5
parent c6c977eb4c
commit 5277ab16ff
49 changed files with 1162 additions and 359 deletions
+19 -17
View File
@@ -4,7 +4,8 @@ iPad-style floating on-screen keyboard for Plasma Wayland. Qt 6, C++ core, QML U
- Flick a key down for its secondary symbol; long-press for accented alternates
- iOS shift: tap = one letter, double-tap = caps lock, hold = shift while held
- Auto-capitalization, double-space → ". ", backspace repeat → word delete
- Letters follow the system keyboard layout; the globe key switches it
- Auto-capitalization, double-space → ". " (both optional, ⚙ on the bottom bar), backspace repeat → word delete
- Long-press space = trackpad; slide from `.?123` onto a symbol and release
- Floating: drag the bottom bar, resize from the corner; geometry is remembered per screen
@@ -33,23 +34,23 @@ Dev hooks: `SLOPOSK_AUTOTYPE=text` commits on focus; `SLOPOSK_SCREENSHOT=file.pn
## Layouts
JSON in `layouts/` (built in) or `~/.local/share/sloposk/layouts/` (overrides by `id`). The globe key cycles them; the system locale's comes first.
Letters come from the system keymap (what KWin sends to every client): its Q/A/Z rows become the three letter rows. Letters elsewhere, e.g. Slovak ľščťž on the number row, become long-press alternates. The globe key switches the system layout over `org.kde.keyboard`, so the physical keyboard follows.
Everything else is `layouts/template.json`; `~/.local/share/sloposk/template.json` replaces it.
```json
{
"id": "sv-SE", "name": "Svenska", "locale": "sv_SE",
"base": "en-US",
"layers": [{"name": "letters", "rows": [[
"q",
{"label": "e", "flick": "3", "alternates": ["é", "è"]},
{"type": "backspace", "width": 1.5}
]]}]
}
{"type": "letters", "row": 0, "flicks": ["1", "2", "3"]},
{"type": "backspace", "width": 1.5, "stretch": true}
```
- Key: a string, or an object with `type` (`char` `space` `backspace` `enter` `shift` `layer` `globe` `dismiss` `spacer`), `label`, `shifted`, `flick`, `alternates`, `width` (key units), `target` (for `layer`)
- `base`: take layers this layout doesn't define (e.g. numbers/symbols) from another layout
- Rows in a layer should add up to the same width; `tst_layoutloader` checks the built-ins
- Key: a string, or an object with `type` (`char` `space` `backspace` `enter` `shift` `layer` `globe` `dismiss` `spacer` `letters`), `label`, `shifted`, `flick`, `alternates`, `width` (key units), `target` (for `layer`), `stretch`
- `letters`: the keymap row `row` (0–2), with `flicks` handed out left to right
- `stretch`: the key grows so its row is as wide as the layer's widest (letter counts vary by layout)
- Top-level `alternates`: long-press choices per letter, after the keymap's own
## Settings
`~/.config/sloposk/sloposk.conf`, also editable from the ⚙ panel: `typing/autoCapitalize`, `typing/doubleSpacePeriod`.
## Architecture
@@ -57,10 +58,11 @@ JSON in `layouts/` (built in) or `~/.local/share/sloposk/layouts/` (overrides by
qml/ view only
input/ touch → PointerTracker gestures → KeyboardController rules
engine/ key output → text ops (DirectEngine; prediction/swipe later)
backend/ text ops → compositor (KWin input-method-v1)
layout/ JSON → Layout → LayoutModel (geometry, hit testing)
backend/ text ops → compositor (KWin input-method-v1); the compositor's keymap
layout/ template JSON + keymap letters → Layout → LayoutModel (geometry, hit testing)
window/ floating layer-shell placement, visibility, saved geometry
app/ D-Bus control, dev hooks
app/ D-Bus control and system layouts, dev hooks
settings/ persisted user preferences
```
`sloposk_core` (everything compositor-independent, plus the `Sloposk` QML module) is a static library shared by the app and the tests.
-62
View File
@@ -1,62 +0,0 @@
{
"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"}
]
]
}
]
}
+25 -34
View File
@@ -1,54 +1,45 @@
{
"id": "en-US",
"name": "English (US)",
"locale": "en_US",
"id": "template",
"name": "Template",
"alternates": {
"a": ["á", "à", "â", "ä", "æ", "ã", "å", "ā"],
"c": ["ç", "ć", "č"],
"d": ["ð"],
"e": ["é", "è", "ê", "ë", "ē", "ė", "ę"],
"i": ["í", "ì", "î", "ï", "ī", "į"],
"l": ["ł"],
"n": ["ñ", "ń"],
"o": ["ó", "ò", "ô", "ö", "õ", "ø", "œ", "ō"],
"s": ["ß", "ś", "š"],
"t": ["þ"],
"u": ["ú", "ù", "û", "ü", "ū"],
"y": ["ý", "ÿ"],
"z": ["ž", "ź", "ż"]
},
"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": "letters", "row": 0, "flicks": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]},
{"type": "backspace", "width": 1.5, "stretch": true}
],
[
{"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": "letters", "row": 1, "flicks": ["@", "#", "$", "&", "*", "(", ")", "'", "\""]},
{"type": "enter", "width": 2, "stretch": true}
],
[
{"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": ":"},
{"type": "shift", "stretch": true},
{"type": "letters", "row": 2, "flicks": ["%", "-", "+", "=", "/", ";", ":"]},
{"label": ",", "flick": "!", "shifted": ","},
{"label": ".", "flick": "?", "shifted": ".", "alternates": ["…"]},
{"type": "shift", "width": 1.5}
{"type": "shift", "width": 1.5, "stretch": true}
],
[
{"type": "layer", "label": ".?123", "target": "numbers", "width": 1.5},
{"type": "globe"},
{"type": "space", "width": 6.5},
{"type": "space", "width": 6.5, "stretch": true},
{"type": "layer", "label": ".?123", "target": "numbers", "width": 1.5},
{"type": "dismiss"}
]
+15
View File
@@ -6,7 +6,9 @@ Window {
required property LayoutModel layoutModel
required property KeyboardController controller
required property Settings settings
property var placement: null // FloatingWindow, set from C++
property bool settingsOpen: false
// Transparent room above the keyboard for long-press popups on the top row.
readonly property real popupMargin: 90
@@ -35,16 +37,29 @@ Window {
id: keyboard
anchors { left: parent.left; right: parent.right; top: parent.top; bottom: handle.top }
anchors.margins: 2
visible: !root.settingsOpen
model: root.layoutModel
controller: root.controller
}
SettingsPanel {
anchors.fill: keyboard
visible: root.settingsOpen
settings: root.settings
}
DragHandle {
id: handle
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
onMoved: (dx, dy) => root.placement?.moveBy(dx, dy)
}
SettingsButton {
anchors { left: parent.left; leftMargin: 8; bottom: parent.bottom }
checked: root.settingsOpen
onClicked: root.settingsOpen = !root.settingsOpen
}
ResizeHandle {
anchors { right: parent.right; bottom: parent.bottom }
onResized: (dw, dh) => root.placement?.resizeBy(dw, dh)
+24
View File
@@ -0,0 +1,24 @@
import QtQuick
// Gear in the grab bar; opens and closes the settings panel.
Item {
id: button
property bool checked: false
signal clicked()
implicitWidth: 36
implicitHeight: 22
Text {
anchors.centerIn: parent
text: button.checked ? "⌨" : "⚙"
font.pixelSize: 15
color: tap.pressed ? Theme.text : Theme.handle
}
TapHandler {
id: tap
onTapped: button.clicked()
}
}
+33
View File
@@ -0,0 +1,33 @@
import QtQuick
import Sloposk
// Replaces the keys while open. Binds straight to Settings, which persists.
Rectangle {
id: panel
required property Settings settings
color: Theme.background
radius: Theme.windowRadius
Column {
anchors { fill: parent; margins: 16 }
spacing: 4
ToggleRow {
width: parent.width
text: qsTr("Auto-Capitalization")
detail: qsTr("Shift at the start of a sentence")
checked: panel.settings.autoCapitalize
onToggled: checked => panel.settings.autoCapitalize = checked
}
ToggleRow {
width: parent.width
text: qsTr("“.” Shortcut")
detail: qsTr("Double-tap space for a period and a space")
checked: panel.settings.doubleSpacePeriod
onToggled: checked => panel.settings.doubleSpacePeriod = checked
}
}
}
+1
View File
@@ -15,6 +15,7 @@ QtObject {
readonly property color handle: dark ? "#8e8e93" : "#7c7c80"
readonly property color accent: dark ? "#0a84ff" : "#007aff"
readonly property color accentText: "#ffffff"
readonly property color toggleOn: dark ? "#30d158" : "#34c759"
readonly property real windowRadius: 14
readonly property real keyRadius: 6
+57
View File
@@ -0,0 +1,57 @@
import QtQuick
// A labelled on/off switch in the iOS style.
Item {
id: row
required property string text
property string detail: ""
property bool checked: false
signal toggled(bool checked)
implicitHeight: 52
Column {
anchors { left: parent.left; right: track.left; rightMargin: 12; verticalCenter: parent.verticalCenter }
spacing: 2
Text {
width: parent.width
text: row.text
color: Theme.text
font.pixelSize: 16
elide: Text.ElideRight
}
Text {
width: parent.width
visible: text !== ""
text: row.detail
color: Theme.secondaryText
font.pixelSize: 12
elide: Text.ElideRight
}
}
Rectangle {
id: track
anchors { right: parent.right; verticalCenter: parent.verticalCenter }
width: 50
height: 30
radius: height / 2
color: row.checked ? Theme.toggleOn : Theme.functionKey
Rectangle {
x: row.checked ? parent.width - width - 2 : 2
anchors.verticalCenter: parent.verticalCenter
width: parent.height - 4
height: width
radius: width / 2
color: "#ffffff"
Behavior on x { NumberAnimation { duration: 120 } }
}
}
TapHandler {
onTapped: row.toggled(!row.checked)
}
}
+10 -1
View File
@@ -10,15 +10,18 @@ qt_add_library(sloposk_core STATIC
input/AlternatesPicker.h input/AlternatesPicker.cpp
input/KeyRepeater.h input/KeyRepeater.cpp
input/TextRules.h input/TextRules.cpp
input/TypingOptions.h
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/KeymapLetters.h layout/KeymapLetters.cpp
layout/LayoutBuilder.h layout/LayoutBuilder.cpp
layout/KeyGeometry.h layout/KeyGeometry.cpp
layout/LayoutModel.h layout/LayoutModel.cpp
window/VisibilityController.h window/VisibilityController.cpp
window/WindowSettings.h window/WindowSettings.cpp
settings/Settings.h settings/Settings.cpp
)
# QML lives in the top-level qml/ dir; alias each file so the module sees it at its root.
@@ -29,6 +32,9 @@ set(SLOPOSK_QML_FILES
AlternatesPopup.qml
DragHandle.qml
ResizeHandle.qml
SettingsButton.qml
SettingsPanel.qml
ToggleRow.qml
Theme.qml
)
list(TRANSFORM SLOPOSK_QML_FILES PREPEND ${PROJECT_SOURCE_DIR}/qml/ OUTPUT_VARIABLE sloposk_qml_paths)
@@ -55,6 +61,7 @@ target_include_directories(sloposk_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_include_directories(sloposk_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/input
${CMAKE_CURRENT_SOURCE_DIR}/layout
${CMAKE_CURRENT_SOURCE_DIR}/settings
)
target_link_libraries(sloposk_core PUBLIC Qt6::Quick PkgConfig::XKB)
@@ -64,9 +71,11 @@ qt_add_executable(sloposk
main.cpp
app/DevHooks.h app/DevHooks.cpp
app/DBusControl.h app/DBusControl.cpp
app/SystemLayouts.h app/SystemLayouts.cpp
backend/InputMethodProtocol.h
backend/KWinInputMethod.h backend/KWinInputMethod.cpp
backend/KWinInputContext.h backend/KWinInputContext.cpp
backend/WaylandKeymap.h backend/WaylandKeymap.cpp
window/FloatingWindow.h window/FloatingWindow.cpp
)
+49
View File
@@ -0,0 +1,49 @@
#include "SystemLayouts.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusReply>
namespace {
const QString Service = QStringLiteral("org.kde.keyboard");
const QString Path = QStringLiteral("/Layouts");
const QString Interface = QStringLiteral("org.kde.KeyboardLayouts");
QDBusMessage call(const QString &method, const QVariantList &args = {})
{
QDBusMessage message = QDBusMessage::createMethodCall(Service, Path, Interface, method);
message.setArguments(args);
return message;
}
} // namespace
SystemLayouts::SystemLayouts(QObject *parent)
: QObject(parent)
{
QDBusConnection bus = QDBusConnection::sessionBus();
const QDBusReply<uint> reply = bus.call(call(QStringLiteral("getLayout")));
m_system = reply.isValid();
if (!m_system)
return;
m_current = reply.value();
bus.connect(Service, Path, Interface, QStringLiteral("layoutChanged"), this, SLOT(setCurrent(uint)));
}
void SystemLayouts::next(quint32 count)
{
if (count < 2)
return;
const quint32 index = (m_current + 1) % count;
if (m_system)
QDBusConnection::sessionBus().asyncCall(call(QStringLiteral("setLayout"), {index})); // echoes layoutChanged
else
setCurrent(index);
}
void SystemLayouts::setCurrent(uint index)
{
if (index == m_current)
return;
m_current = index;
Q_EMIT currentChanged();
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QObject>
// Which of the system's keyboard layouts is active, followed and switched
// over KWin's org.kde.keyboard D-Bus interface, so the on-screen and the
// physical keyboard always agree. Without that service it switches locally.
class SystemLayouts : public QObject
{
Q_OBJECT
public:
explicit SystemLayouts(QObject *parent = nullptr);
quint32 current() const { return m_current; }
// Switches to the next of `count` layouts.
void next(quint32 count);
Q_SIGNALS:
void currentChanged();
private Q_SLOTS:
void setCurrent(uint index);
private:
bool m_system = false;
quint32 m_current = 0;
};
+8
View File
@@ -53,6 +53,14 @@ void KWinInputContext::commit(const QString &text)
commit_string(m_serial, text);
}
void KWinInputContext::replaceBeforeCursor(const QString &before, const QString &text)
{
// Offsets are UTF-8 bytes relative to the cursor; applied with the commit.
const auto bytes = uint32_t(before.toUtf8().size());
delete_surrounding_text(-int32_t(bytes), bytes);
commit_string(m_serial, text);
}
void KWinInputContext::preedit(const QString &text)
{
preedit_string(m_serial, text, text);
+1
View File
@@ -19,6 +19,7 @@ public:
TextContext context() const { return m_context; }
void commit(const QString &text);
void replaceBeforeCursor(const QString &before, const QString &text);
void preedit(const QString &text);
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers);
+6
View File
@@ -48,6 +48,12 @@ void KWinInputMethod::commitText(const QString &text)
m_context->commit(text);
}
void KWinInputMethod::replaceBeforeCursor(const QString &before, const QString &text)
{
if (m_context)
m_context->replaceBeforeCursor(before, text);
}
void KWinInputMethod::setPreedit(const QString &text)
{
if (m_context)
+1
View File
@@ -43,6 +43,7 @@ public:
TextContext context() const override;
void commitText(const QString &text) override;
void replaceBeforeCursor(const QString &before, const QString &text) override;
void setPreedit(const QString &text) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
+4
View File
@@ -23,6 +23,10 @@ public:
virtual TextContext context() const { return {}; }
Q_INVOKABLE virtual void commitText(const QString &text) = 0;
// Replaces `before`, the text right before the cursor, with `text` in one
// step. Don't emulate this with a Backspace keysym: clients may apply key
// events after commits, so the deletion would hit the new text.
virtual void replaceBeforeCursor(const QString &before, const QString &text) = 0;
Q_INVOKABLE virtual void setPreedit(const QString &text) = 0;
Q_INVOKABLE virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0;
+125
View File
@@ -0,0 +1,125 @@
#include "WaylandKeymap.h"
#include <QGuiApplication>
#include <wayland-client.h>
#include <xkbcommon/xkbcommon.h>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>
// Plain libwayland on Qt's display: Qt's own seat wrappers are private API.
// Our proxies live on the default queue, which Qt dispatches.
class WaylandKeymap::Seat
{
public:
Seat(wl_display *display, WaylandKeymap &owner)
: m_owner(owner)
, m_registry(wl_display_get_registry(display))
{
wl_registry_add_listener(m_registry, &RegistryListener, this);
}
~Seat()
{
if (m_keyboard)
wl_keyboard_release(m_keyboard);
if (m_seat)
wl_seat_release(m_seat);
wl_registry_destroy(m_registry);
}
private:
static constexpr uint32_t SeatVersion = 5; // wl_keyboard.release, wl_seat.release
static void global(void *data, wl_registry *registry, uint32_t name, const char *interface, uint32_t version)
{
auto *self = static_cast<Seat *>(data);
if (self->m_seat || std::strcmp(interface, wl_seat_interface.name) != 0 || version < SeatVersion)
return;
self->m_seat = static_cast<wl_seat *>(wl_registry_bind(registry, name, &wl_seat_interface, SeatVersion));
wl_seat_add_listener(self->m_seat, &SeatListener, self);
}
static void capabilities(void *data, wl_seat *seat, uint32_t caps)
{
auto *self = static_cast<Seat *>(data);
const bool hasKeyboard = caps & WL_SEAT_CAPABILITY_KEYBOARD;
if (hasKeyboard && !self->m_keyboard) {
self->m_keyboard = wl_seat_get_keyboard(seat);
wl_keyboard_add_listener(self->m_keyboard, &KeyboardListener, self);
} else if (!hasKeyboard && self->m_keyboard) {
wl_keyboard_release(std::exchange(self->m_keyboard, nullptr));
}
}
static void keymap(void *data, wl_keyboard *, uint32_t format, int32_t fd, uint32_t size)
{
if (format == WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1)
static_cast<Seat *>(data)->m_owner.loadKeymap(fd, size);
close(fd);
}
static constexpr wl_registry_listener RegistryListener = {
.global = global,
.global_remove = [](void *, wl_registry *, uint32_t) {},
};
static constexpr wl_seat_listener SeatListener = {
.capabilities = capabilities,
.name = [](void *, wl_seat *, const char *) {},
};
// The window never has keyboard focus; only the keymap matters.
static constexpr wl_keyboard_listener KeyboardListener = {
.keymap = keymap,
.enter = [](void *, wl_keyboard *, uint32_t, wl_surface *, wl_array *) {},
.leave = [](void *, wl_keyboard *, uint32_t, wl_surface *) {},
.key = [](void *, wl_keyboard *, uint32_t, uint32_t, uint32_t, uint32_t) {},
.modifiers = [](void *, wl_keyboard *, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {},
.repeat_info = [](void *, wl_keyboard *, int32_t, int32_t) {},
};
WaylandKeymap &m_owner;
wl_registry *m_registry;
wl_seat *m_seat = nullptr;
wl_keyboard *m_keyboard = nullptr;
};
WaylandKeymap::WaylandKeymap(QObject *parent)
: QObject(parent)
, m_context(xkb_context_new(XKB_CONTEXT_NO_FLAGS), xkb_context_unref)
, m_keymap(nullptr, xkb_keymap_unref)
{
setKeymap(xkb_keymap_new_from_names(m_context.get(), nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS));
if (auto *wayland = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>())
m_seat = std::make_unique<Seat>(wayland->display(), *this);
}
WaylandKeymap::~WaylandKeymap() = default;
quint32 WaylandKeymap::layoutCount() const
{
return m_keymap ? xkb_keymap_num_layouts(m_keymap.get()) : 0;
}
void WaylandKeymap::loadKeymap(int fd, uint32_t size)
{
void *data = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (data == MAP_FAILED)
return;
const auto *text = static_cast<const char *>(data);
// `size` counts the terminating NUL; the buffer API wants the text length.
setKeymap(xkb_keymap_new_from_buffer(m_context.get(), text, strnlen(text, size), XKB_KEYMAP_FORMAT_TEXT_V1,
XKB_KEYMAP_COMPILE_NO_FLAGS));
munmap(data, size);
}
void WaylandKeymap::setKeymap(xkb_keymap *keymap)
{
if (!keymap) {
qWarning("Failed to compile the keyboard keymap");
return;
}
m_keymap.reset(keymap);
Q_EMIT changed();
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <QObject>
#include <memory>
struct xkb_context;
struct xkb_keymap;
// The compositor's keymap, received on a wl_keyboard of our own (the keyboard
// window never has focus, but the keymap is sent regardless). Until it
// arrives, or without a keyboard on the seat, XKB's default is used
// (XKB_DEFAULT_LAYOUT etc., else "us").
class WaylandKeymap : public QObject
{
Q_OBJECT
public:
explicit WaylandKeymap(QObject *parent = nullptr);
~WaylandKeymap() override;
// Valid until the next changed().
xkb_keymap *keymap() const { return m_keymap.get(); }
quint32 layoutCount() const;
Q_SIGNALS:
void changed();
private:
class Seat;
void loadKeymap(int fd, uint32_t size);
void setKeymap(xkb_keymap *keymap);
std::unique_ptr<xkb_context, void (*)(xkb_context *)> m_context;
std::unique_ptr<xkb_keymap, void (*)(xkb_keymap *)> m_keymap;
std::unique_ptr<Seat> m_seat;
};
+5
View File
@@ -12,6 +12,11 @@ void DirectEngine::commitText(const QString &text)
m_backend.commitText(text);
}
void DirectEngine::replaceBeforeCursor(const QString &before, const QString &text)
{
m_backend.replaceBeforeCursor(before, text);
}
void DirectEngine::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers)
{
m_backend.sendKeysym(keysym, modifiers);
+1
View File
@@ -10,6 +10,7 @@ public:
explicit DirectEngine(TextBackend &backend);
void commitText(const QString &text) override;
void replaceBeforeCursor(const QString &before, const QString &text) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
private:
+2
View File
@@ -12,5 +12,7 @@ public:
virtual ~InputEngine() = default;
virtual void commitText(const QString &text) = 0;
// See TextBackend::replaceBeforeCursor.
virtual void replaceBeforeCursor(const QString &before, const QString &text) = 0;
virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0;
};
+16 -21
View File
@@ -3,7 +3,6 @@
#include "engine/InputEngine.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include <xkbcommon/xkbcommon-keysyms.h>
@@ -17,15 +16,11 @@ namespace {
constexpr qsizetype MaxTextBefore = 256;
}
KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
const LayoutRepository &layouts, QObject *parent)
KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model, 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());
connect(&m_backspaceRepeater, &KeyRepeater::repeat, this, &KeyboardController::deleteBackward);
}
@@ -79,7 +74,7 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin
autoCapitalize();
break;
case KeyType::Globe:
nextLayout();
Q_EMIT nextLayoutRequested();
break;
case KeyType::Dismiss:
Q_EMIT dismissRequested();
@@ -130,26 +125,25 @@ void KeyboardController::setContext(const TextContext &context)
autoCapitalize();
}
void KeyboardController::nextLayout()
void KeyboardController::setOptions(const TypingOptions &options)
{
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]);
m_options = options;
autoCapitalize();
}
void KeyboardController::typeText(const QString &text)
{
if (text == u" " && m_context.isProse() && TextRules::wantsDoubleSpacePeriod(m_textBefore)) {
m_engine.sendKeysym(XKB_KEY_BackSpace);
if (text == u" " && m_options.doubleSpacePeriod && m_context.isProse()
&& TextRules::wantsDoubleSpacePeriod(m_textBefore)) {
// "word " + space → "word. ", replacing the first space in place.
m_engine.replaceBeforeCursor(QStringLiteral(" "), QStringLiteral(". "));
m_textBefore.chop(1);
typeText(QStringLiteral(". "));
return;
m_textBefore += QStringLiteral(". ");
} else {
m_engine.commitText(text);
m_textBefore += text;
}
m_engine.commitText(text);
m_textBefore = (m_textBefore + text).right(MaxTextBefore);
m_textBefore = m_textBefore.right(MaxTextBefore);
changeShift([&] { m_shift.characterTyped(); });
autoCapitalize();
}
@@ -164,7 +158,8 @@ void KeyboardController::deleteBackward(bool wholeWord)
void KeyboardController::autoCapitalize()
{
const bool capitalize = m_context.allowsAutoCapitalization() && TextRules::startsSentence(m_textBefore);
const bool capitalize = m_options.autoCapitalize && m_context.allowsAutoCapitalization()
&& TextRules::startsSentence(m_textBefore);
changeShift([&] { m_shift.setAutomatic(capitalize); });
}
+6 -8
View File
@@ -2,6 +2,7 @@
#include "KeyRepeater.h"
#include "ShiftState.h"
#include "TypingOptions.h"
#include "backend/TextContext.h"
#include "layout/Layout.h"
@@ -11,12 +12,11 @@
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, layout switching and the
// Keyboard rules: what each key does, shift, layers and the
// iOS conveniences (auto-capitalization, double-space period, backspace
// repeat). Knows nothing about touch or Wayland: it receives key presses and
// text-field context, and talks to an InputEngine.
@@ -31,8 +31,7 @@ public:
enum class ShiftMode { Off, Once, Locked };
Q_ENUM(ShiftMode)
KeyboardController(InputEngine &engine, LayoutModel &model, const LayoutRepository &layouts,
QObject *parent = nullptr);
KeyboardController(InputEngine &engine, LayoutModel &model, QObject *parent = nullptr);
ShiftMode shiftMode() const;
@@ -48,12 +47,12 @@ public:
void resetContext();
// The focused field reported new text/content type.
void setContext(const TextContext &context);
Q_INVOKABLE void nextLayout();
void setOptions(const TypingOptions &options);
Q_SIGNALS:
void shiftChanged();
void dismissRequested();
void nextLayoutRequested(); // the globe key: switching is the system's job
private:
void typeText(const QString &text);
@@ -64,10 +63,9 @@ private:
InputEngine &m_engine;
LayoutModel &m_model;
const LayoutRepository &m_layouts;
TypingOptions m_options;
ShiftState m_shift;
KeyRepeater m_backspaceRepeater;
int m_layoutIndex = 0;
QString m_layerBeforeSwitch;
TextContext m_context;
+6 -2
View File
@@ -2,13 +2,16 @@
void ShiftState::press(qint64 timeMs)
{
const bool doubleTap = timeMs - m_lastPress <= DoubleTapMs;
// Locks whatever the first tap did: at a sentence start that tap turns
// auto-shift off, and the second must still lock.
const bool doubleTap = timeMs - m_lastPress <= DoubleTapMs && !m_lastPressUnlocked;
m_lastPress = timeMs;
m_lastPressUnlocked = m_mode == Mode::Locked;
m_held = true;
m_typedWhileHeld = false;
switch (m_mode) {
case Mode::Off: m_mode = Mode::Once; break;
case Mode::Off: m_mode = doubleTap ? Mode::Locked : Mode::Once; break;
case Mode::Once: m_mode = doubleTap ? Mode::Locked : Mode::Off; break;
case Mode::Locked: m_mode = Mode::Off; break;
}
@@ -23,6 +26,7 @@ void ShiftState::release()
void ShiftState::characterTyped()
{
m_lastPress = NoPress; // shift, type, shift is not a double tap
if (m_held)
m_typedWhileHeld = true;
else if (m_mode == Mode::Once)
+3 -1
View File
@@ -29,5 +29,7 @@ private:
Mode m_mode = Mode::Off;
bool m_held = false;
bool m_typedWhileHeld = false;
qint64 m_lastPress = std::numeric_limits<qint64>::min() / 2;
bool m_lastPressUnlocked = false; // a tap leaving caps lock doesn't start a double tap
static constexpr qint64 NoPress = std::numeric_limits<qint64>::min() / 2;
qint64 m_lastPress = NoPress;
};
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// User choices for the typing conveniences. Plain data: the controller reads
// it, Settings persists it.
struct TypingOptions {
bool autoCapitalize = true;
bool doubleSpacePeriod = true;
bool operator==(const TypingOptions &) const = default;
};
-4
View File
@@ -2,8 +2,6 @@
#include <algorithm>
namespace {
qreal rowUnits(const Row &row)
{
qreal units = 0;
@@ -12,8 +10,6 @@ qreal rowUnits(const Row &row)
return units;
}
} // namespace
QList<QRectF> computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics)
{
QList<QRectF> rects;
+3
View File
@@ -14,3 +14,6 @@ struct KeyboardMetrics {
// 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 = {});
// Total width of a row in key units.
qreal rowUnits(const Row &row);
+66
View File
@@ -0,0 +1,66 @@
#include "KeymapLetters.h"
#include <xkbcommon/xkbcommon.h>
#include <optional>
#include <ranges>
namespace {
// XKB keycodes are evdev codes + 8.
constexpr xkb_keycode_t fromEvdev(int code) { return xkb_keycode_t(code + 8); }
struct KeyRange {
int first;
int last;
};
// AD01–AD12, AC01–AC11, AB01–AB10 in evdev codes.
constexpr KeyRange LetterRows[] = {{16, 27}, {30, 40}, {44, 53}};
// AE01–AE12 (number row), BKSL, LSGT: letters here become extras.
constexpr KeyRange ExtraKeys[] = {{2, 13}, {43, 43}, {86, 86}};
QString symbolText(xkb_keymap *keymap, xkb_keycode_t key, xkb_layout_index_t layout, xkb_level_index_t level)
{
const xkb_keysym_t *syms = nullptr;
if (xkb_keymap_key_get_syms_by_level(keymap, key, layout, level, &syms) != 1)
return {};
const char32_t ucs = xkb_keysym_to_utf32(syms[0]); // 0 for dead keys and functions
return ucs ? QString::fromUcs4(&ucs, 1) : QString();
}
bool isLetter(const QString &text)
{
return !text.isEmpty() && QChar::isLetter(text.toUcs4().first());
}
std::optional<Letter> letterAt(xkb_keymap *keymap, xkb_keycode_t key, xkb_layout_index_t layout)
{
const QString lower = symbolText(keymap, key, layout, 0);
if (!isLetter(lower))
return std::nullopt;
const QString shifted = symbolText(keymap, key, layout, 1);
return Letter{lower, isLetter(shifted) ? shifted : lower.toUpper()};
}
} // namespace
KeymapLetters readKeymapLetters(xkb_keymap *keymap, quint32 layoutIndex)
{
KeymapLetters result;
if (!keymap || layoutIndex >= xkb_keymap_num_layouts(keymap))
return result;
result.name = QString::fromUtf8(xkb_keymap_layout_get_name(keymap, layoutIndex));
const auto collect = [&](KeyRange range, QList<Letter> &into) {
for (int code : std::views::iota(range.first, range.last + 1)) {
if (const auto letter = letterAt(keymap, fromEvdev(code), layoutIndex))
into.append(*letter);
}
};
for (KeyRange range : LetterRows)
collect(range, result.rows.emplace_back());
for (KeyRange range : ExtraKeys)
collect(range, result.extras);
return result;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <QList>
#include <QString>
struct xkb_keymap;
struct Letter {
QString lower;
QString upper;
};
// The letters one layout of an XKB keymap puts where a physical keyboard has
// its three letter rows (Q…, A…, Z… positions). Letters on other keys, e.g.
// Slovak ľščťž on the number row, are collected as extras.
struct KeymapLetters {
QString name; // e.g. "Slovak"
QList<QList<Letter>> rows;
QList<Letter> extras;
};
KeymapLetters readKeymapLetters(xkb_keymap *keymap, quint32 layoutIndex);
+11 -4
View File
@@ -1,5 +1,6 @@
#pragma once
#include <QHash>
#include <QList>
#include <QString>
#include <QStringList>
@@ -14,6 +15,7 @@ enum class KeyType {
Globe,
Dismiss,
Spacer, // empty room, e.g. the half-key indent of the home row
Letters, // template only: a row of letters from the system keymap
};
struct KeyDef {
@@ -24,6 +26,11 @@ struct KeyDef {
QStringList alternates; // long-press choices
QString target; // layer to switch to (LayerSwitch only)
qreal width = 1.0; // in key units
bool stretch = false; // grows so the row is as wide as the layer's widest
// Letters placeholders only.
int lettersRow = -1; // which keymap row (0 = top)
QStringList flicks; // flick symbol per letter, left to right
// Text this key shows/types; only character keys change case.
QString text(bool uppercase) const
@@ -42,11 +49,11 @@ struct Layer {
};
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
QString id;
QString name; // human readable, e.g. "Slovak"
QList<Layer> layers; // the first one is shown by default
// Template only: long-press choices for keymap letters, by lowercase letter.
QHash<QString, QStringList> alternates;
const Layer *layer(const QString &layerName) const
{
+110
View File
@@ -0,0 +1,110 @@
#include "LayoutBuilder.h"
#include "KeyGeometry.h"
#include <QSet>
#include <algorithm>
namespace {
// Letters without a Unicode decomposition, and the key they belong under.
const QHash<QString, QString> &baseOverrides()
{
static const QHash<QString, QString> bases = {
{QStringLiteral("ß"), QStringLiteral("s")}, {QStringLiteral("ł"), QStringLiteral("l")},
{QStringLiteral("ø"), QStringLiteral("o")}, {QStringLiteral("æ"), QStringLiteral("a")},
{QStringLiteral("œ"), QStringLiteral("o")}, {QStringLiteral("đ"), QStringLiteral("d")},
{QStringLiteral("ð"), QStringLiteral("d")}, {QStringLiteral("þ"), QStringLiteral("t")},
{QStringLiteral("ħ"), QStringLiteral("h")}, {QStringLiteral("ı"), QStringLiteral("i")},
};
return bases;
}
// "š" → "s": the letter an accented letter is a variant of.
QString baseLetter(const QString &letter)
{
if (const auto it = baseOverrides().constFind(letter); it != baseOverrides().cend())
return *it;
return letter.normalized(QString::NormalizationForm_D).left(1);
}
// Long-press choices per key letter: the keymap's extras first (they're what
// this language types), then the template's, minus letters that have a key.
QHash<QString, QStringList> alternatesByLetter(const Layout &tmpl, const KeymapLetters &letters)
{
QSet<QString> onKeys;
for (const auto &row : letters.rows) {
for (const Letter &letter : row)
onKeys.insert(letter.lower);
}
QHash<QString, QStringList> result;
const auto add = [&](const QString &base, const QString &choice) {
QStringList &list = result[base];
if (!onKeys.contains(choice) && !list.contains(choice))
list.append(choice);
};
for (const Letter &extra : letters.extras)
add(baseLetter(extra.lower), extra.lower);
for (auto it = tmpl.alternates.cbegin(); it != tmpl.alternates.cend(); ++it) {
for (const QString &choice : *it)
add(it.key(), choice);
}
return result;
}
Row expandLetters(const Row &row, const KeymapLetters &letters, const QHash<QString, QStringList> &alternates)
{
Row result;
for (const KeyDef &key : row) {
if (key.type != KeyType::Letters) {
result.append(key);
continue;
}
const QList<Letter> source = letters.rows.value(key.lettersRow);
for (qsizetype i = 0; i < source.size(); ++i) {
KeyDef letter;
letter.label = source[i].lower;
letter.shifted = source[i].upper;
letter.flick = key.flicks.value(i);
letter.alternates = alternates.value(source[i].lower);
result.append(letter);
}
}
return result;
}
void stretchRows(Layer &layer)
{
qreal widest = 0;
for (const Row &row : layer.rows)
widest = std::max(widest, rowUnits(row));
for (Row &row : layer.rows) {
const auto stretchy = std::ranges::count_if(row, &KeyDef::stretch);
if (stretchy == 0)
continue;
const qreal extra = (widest - rowUnits(row)) / qreal(stretchy);
for (KeyDef &key : row) {
if (key.stretch)
key.width += extra;
}
}
}
} // namespace
Layout buildLayout(const Layout &tmpl, const KeymapLetters &letters)
{
Layout layout = tmpl;
layout.name = letters.name.isEmpty() ? tmpl.name : letters.name;
layout.alternates.clear();
const QHash<QString, QStringList> alternates = alternatesByLetter(tmpl, letters);
for (Layer &layer : layout.layers) {
for (Row &row : layer.rows)
row = expandLetters(row, letters, alternates);
stretchRows(layer);
}
return layout;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include "KeymapLetters.h"
#include "Layout.h"
// Turns the template into the layout shown: fills its letters placeholders
// from the keymap, gives each letter its flick and long-press choices, and
// stretches the marked keys so every row of a layer is equally wide.
Layout buildLayout(const Layout &tmpl, const KeymapLetters &letters);
+24 -6
View File
@@ -9,6 +9,16 @@ namespace LayoutLoader {
namespace {
constexpr int LetterRows = 3;
QStringList toStringList(const QJsonValue &value)
{
QStringList list;
for (const QJsonValue &item : value.toArray())
list.append(item.toString());
return list;
}
const QHash<QString, KeyType> &keyTypesByName()
{
static const QHash<QString, KeyType> types = {
@@ -21,6 +31,7 @@ const QHash<QString, KeyType> &keyTypesByName()
{QStringLiteral("globe"), KeyType::Globe},
{QStringLiteral("dismiss"), KeyType::Dismiss},
{QStringLiteral("spacer"), KeyType::Spacer},
{QStringLiteral("letters"), KeyType::Letters},
};
return types;
}
@@ -51,13 +62,19 @@ std::optional<KeyDef> parseKey(const QJsonValue &value, QString *error)
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());
key.stretch = obj.value(u"stretch").toBool();
key.alternates = toStringList(obj.value(u"alternates"));
key.lettersRow = obj.value(u"row").toInt(-1);
key.flicks = toStringList(obj.value(u"flicks"));
if (key.type == KeyType::Character && key.label.isEmpty()) {
*error = QStringLiteral("character key without a label");
return std::nullopt;
}
if (key.type == KeyType::Letters && (key.lettersRow < 0 || key.lettersRow >= LetterRows)) {
*error = QStringLiteral("letters need a row from 0 to %1").arg(LetterRows - 1);
return std::nullopt;
}
if (key.width <= 0) {
*error = QStringLiteral("key \"%1\" has a non-positive width").arg(key.label);
return std::nullopt;
@@ -110,8 +127,9 @@ std::optional<Layout> parse(const QByteArray &json, QString *error)
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();
const QJsonObject alternates = root.value(u"alternates").toObject();
for (auto it = alternates.begin(); it != alternates.end(); ++it)
layout.alternates.insert(it.key(), toStringList(it.value()));
if (layout.id.isEmpty()) {
*error = QStringLiteral("layout without an id");
return std::nullopt;
@@ -124,8 +142,8 @@ std::optional<Layout> parse(const QByteArray &json, QString *error)
layout.layers.append(*layer);
}
if (layout.layers.isEmpty() && layout.base.isEmpty()) {
*error = QStringLiteral("layout has no layers and no base");
if (layout.layers.isEmpty()) {
*error = QStringLiteral("layout has no layers");
return std::nullopt;
}
return layout;
+5 -4
View File
@@ -6,16 +6,17 @@
class QJsonValue;
// Parses the JSON layout format (see layouts/en-US.json).
// Parses the JSON template format (see layouts/template.json).
// A key is either a plain string (a character key) or an object:
// {"type": "backspace", "width": 1.5}
// {"label": "e", "flick": "3", "alternates": ["é", "è"]}
// {"type": "backspace", "width": 1.5, "stretch": true}
// {"label": ",", "flick": "!", "alternates": ["…"]}
// {"type": "letters", "row": 0, "flicks": ["1", "2"]} (filled from the keymap)
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.
// Checks references between layers.
bool validate(const Layout &layout, QString *error);
} // namespace LayoutLoader
+1
View File
@@ -16,6 +16,7 @@ QString keyTypeName(KeyType type)
case KeyType::Globe: return QStringLiteral("globe");
case KeyType::Dismiss: return QStringLiteral("dismiss");
case KeyType::Spacer: return QStringLiteral("spacer");
case KeyType::Letters: break; // expanded before it reaches the model
}
return {};
}
-75
View File
@@ -1,75 +0,0 @@
#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
@@ -1,24 +0,0 @@
#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;
};
+48 -15
View File
@@ -1,16 +1,19 @@
#include "app/DBusControl.h"
#include "app/DevHooks.h"
#include "app/SystemLayouts.h"
#include "backend/KWinInputMethod.h"
#include "backend/WaylandKeymap.h"
#include "engine/DirectEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutBuilder.h"
#include "layout/LayoutLoader.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include "settings/Settings.h"
#include "window/FloatingWindow.h"
#include "window/VisibilityController.h"
#include "window/WindowSettings.h"
#include <QGuiApplication>
#include <QLocale>
#include <QQmlApplicationEngine>
#include <QQmlExtensionPlugin>
#include <QQuickWindow>
@@ -20,13 +23,33 @@ Q_IMPORT_QML_PLUGIN(SloposkPlugin)
namespace {
void loadLayouts(LayoutRepository &layouts)
std::optional<Layout> loadTemplate()
{
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());
// ~/.local/share/sloposk/template.json replaces the built-in one.
const QString user = QStandardPaths::locate(QStandardPaths::AppDataLocation, QStringLiteral("template.json"));
for (const QString &path : {user, QStringLiteral(":/layouts/template.json")}) {
if (path.isEmpty())
continue;
QString error;
if (auto layout = LayoutLoader::load(path, &error); layout && LayoutLoader::validate(*layout, &error))
return layout;
qWarning("Skipping layout template %s: %s", qPrintable(path), qPrintable(error));
}
return std::nullopt;
}
// The shown layout is the template filled with the active system layout's letters.
void connectLayouts(const Layout &tmpl, WaylandKeymap &keymap, SystemLayouts &systemLayouts,
KeyboardController &controller, LayoutModel &model)
{
const auto rebuild = [&] {
model.setLayout(buildLayout(tmpl, readKeymapLetters(keymap.keymap(), systemLayouts.current())));
};
QObject::connect(&keymap, &WaylandKeymap::changed, &model, rebuild);
QObject::connect(&systemLayouts, &SystemLayouts::currentChanged, &model, rebuild);
QObject::connect(&controller, &KeyboardController::nextLayoutRequested, &systemLayouts,
[&] { systemLayouts.next(keymap.layoutCount()); });
rebuild();
}
void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings)
@@ -65,25 +88,35 @@ int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QGuiApplication::setApplicationName(QStringLiteral("sloposk"));
QGuiApplication::setOrganizationName(QStringLiteral("sloposk")); // ~/.config/sloposk/sloposk.conf
QGuiApplication::setDesktopFileName(QStringLiteral("org.sloposk.keyboard"));
LayoutRepository layouts;
loadLayouts(layouts);
if (layouts.layouts().isEmpty()) {
qCritical("No usable keyboard layouts found");
const std::optional<Layout> layoutTemplate = loadTemplate();
if (!layoutTemplate) {
qCritical("No usable layout template found");
return 1;
}
KWinInputMethod backend;
DirectEngine engine(backend);
LayoutModel model;
KeyboardController controller(engine, model, layouts);
KeyboardController controller(engine, model);
connectTextContext(backend, controller);
WaylandKeymap keymap;
SystemLayouts systemLayouts;
connectLayouts(*layoutTemplate, keymap, systemLayouts, controller, model);
Settings settings;
controller.setOptions(settings.typingOptions());
QObject::connect(&settings, &Settings::typingOptionsChanged, &controller,
[&] { controller.setOptions(settings.typingOptions()); });
QQmlApplicationEngine qml;
qml.setInitialProperties({
{QStringLiteral("layoutModel"), QVariant::fromValue(&model)},
{QStringLiteral("controller"), QVariant::fromValue(&controller)},
{QStringLiteral("settings"), QVariant::fromValue(&settings)},
});
qml.loadFromModule("Sloposk", "Main");
auto *window = qml.rootObjects().isEmpty() ? nullptr : qobject_cast<QQuickWindow *>(qml.rootObjects().first());
@@ -94,8 +127,8 @@ int main(int argc, char *argv[])
FloatingWindow placement(window);
window->setProperty("placement", QVariant::fromValue(&placement));
WindowSettings settings;
restoreAndPersistGeometry(placement, settings);
WindowSettings windowSettings;
restoreAndPersistGeometry(placement, windowSettings);
VisibilityController visibility;
connectVisibility(visibility, backend, controller, placement);
+42
View File
@@ -0,0 +1,42 @@
#include "Settings.h"
#include <QSettings>
namespace {
constexpr auto AutoCapitalizeKey = "typing/autoCapitalize";
constexpr auto DoubleSpacePeriodKey = "typing/doubleSpacePeriod";
}
Settings::Settings(QObject *parent)
: QObject(parent)
{
const QSettings store;
const TypingOptions defaults;
m_typing.autoCapitalize = store.value(AutoCapitalizeKey, defaults.autoCapitalize).toBool();
m_typing.doubleSpacePeriod = store.value(DoubleSpacePeriodKey, defaults.doubleSpacePeriod).toBool();
}
void Settings::setAutoCapitalize(bool enabled)
{
TypingOptions options = m_typing;
options.autoCapitalize = enabled;
setTypingOptions(options);
}
void Settings::setDoubleSpacePeriod(bool enabled)
{
TypingOptions options = m_typing;
options.doubleSpacePeriod = enabled;
setTypingOptions(options);
}
void Settings::setTypingOptions(const TypingOptions &options)
{
if (options == m_typing)
return;
m_typing = options;
QSettings store;
store.setValue(AutoCapitalizeKey, m_typing.autoCapitalize);
store.setValue(DoubleSpacePeriodKey, m_typing.doubleSpacePeriod);
Q_EMIT typingOptionsChanged();
}
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "input/TypingOptions.h"
#include <QObject>
#include <QtQml/qqmlregistration.h>
// User preferences, persisted with QSettings (~/.config/sloposk/sloposk.conf)
// and editable from the keyboard's settings panel.
class Settings : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Created in C++")
Q_PROPERTY(bool autoCapitalize READ autoCapitalize WRITE setAutoCapitalize NOTIFY typingOptionsChanged)
Q_PROPERTY(bool doubleSpacePeriod READ doubleSpacePeriod WRITE setDoubleSpacePeriod NOTIFY typingOptionsChanged)
public:
explicit Settings(QObject *parent = nullptr);
TypingOptions typingOptions() const { return m_typing; }
bool autoCapitalize() const { return m_typing.autoCapitalize; }
void setAutoCapitalize(bool enabled);
bool doubleSpacePeriod() const { return m_typing.doubleSpacePeriod; }
void setDoubleSpacePeriod(bool enabled);
Q_SIGNALS:
void typingOptionsChanged();
private:
void setTypingOptions(const TypingOptions &options);
TypingOptions m_typing;
};
+1
View File
@@ -7,6 +7,7 @@ endfunction()
sloposk_add_test(tst_shiftstate)
sloposk_add_test(tst_layoutloader)
sloposk_add_test(tst_layoutbuilder)
sloposk_add_test(tst_keygeometry)
sloposk_add_test(tst_controller)
sloposk_add_test(tst_pointertracker)
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "engine/InputEngine.h"
#include <QList>
#include <QString>
// Records what the keyboard sends and applies it to a simulated field, so
// tests check the text a user would see rather than the calls made.
class FakeEngine : public InputEngine
{
public:
void commitText(const QString &text) override { output += text; }
void replaceBeforeCursor(const QString &before, const QString &text) override
{
if (output.endsWith(before))
output.chop(before.size());
else
mismatchedReplacements++;
output += text;
}
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override
{
keysyms.append(keysym);
lastModifiers = modifiers;
}
QString output; // the field's text
int mismatchedReplacements = 0; // replaced text that wasn't before the cursor
QList<quint32> keysyms;
Qt::KeyboardModifiers lastModifiers;
};
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "layout/KeymapLetters.h"
#include "layout/LayoutBuilder.h"
#include "layout/LayoutLoader.h"
#include <QTest>
#include <xkbcommon/xkbcommon.h>
#include <memory>
// Builds layouts the way the app does: the built-in template filled from a
// keymap, here compiled from XKB names instead of received from a compositor.
// Q_INIT_RESOURCE can't be used inside a namespace.
inline void initLayoutResources() { Q_INIT_RESOURCE(layouts); }
namespace TestLayouts {
inline Layout builtinTemplate()
{
initLayoutResources();
QString error;
const auto layout = LayoutLoader::load(QStringLiteral(":/layouts/template.json"), &error);
if (!layout)
qFatal("template.json: %s", qPrintable(error));
return *layout;
}
inline KeymapLetters letters(const char *layout, const char *variant = nullptr)
{
const std::unique_ptr<xkb_context, void (*)(xkb_context *)> context(
xkb_context_new(XKB_CONTEXT_NO_ENVIRONMENT_NAMES), xkb_context_unref);
const xkb_rule_names names{.rules = "evdev", .model = "pc105", .layout = layout, .variant = variant,
.options = nullptr};
const std::unique_ptr<xkb_keymap, void (*)(xkb_keymap *)> keymap(
xkb_keymap_new_from_names(context.get(), &names, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref);
if (!keymap)
qFatal("no XKB keymap for %s", layout);
return readKeymapLetters(keymap.get(), 0);
}
inline Layout build(const char *layout, const char *variant = nullptr)
{
return buildLayout(builtinTemplate(), letters(layout, variant));
}
} // namespace TestLayouts
+43 -30
View File
@@ -1,7 +1,7 @@
#include "engine/InputEngine.h"
#include "FakeEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include "TestLayouts.h"
#include <QTest>
#include <xkbcommon/xkbcommon-keysyms.h>
@@ -10,21 +10,6 @@ using namespace Qt::StringLiterals;
namespace {
class FakeEngine : public InputEngine
{
public:
void commitText(const QString &text) override { output += text; }
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override
{
keysyms.append(keysym);
lastModifiers = modifiers;
}
QString output;
QList<quint32> keysyms;
Qt::KeyboardModifiers lastModifiers;
};
KeyDef makeKey(KeyType type, const QString &label = {})
{
KeyDef k;
@@ -41,7 +26,6 @@ class TestController : public QObject
private:
FakeEngine engine;
LayoutRepository repo;
LayoutModel model;
std::unique_ptr<KeyboardController> controller;
qint64 now = 0;
@@ -57,11 +41,8 @@ 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);
model.setLayout(TestLayouts::build("us"));
controller = std::make_unique<KeyboardController>(engine, model);
}
void typesLowercase()
@@ -156,11 +137,44 @@ private Q_SLOTS:
tap(makeKey(KeyType::Character, u"a"_s));
tap(space);
tap(space);
QCOMPARE(engine.output, u"a . ");
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_BackSpace}); // removed the first space
QCOMPARE(engine.output, u"a. ");
QCOMPARE(engine.mismatchedReplacements, 0);
// A Backspace keysym may reach the client after the commit and eat the new space.
QVERIFY(engine.keysyms.isEmpty());
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
}
void doubleSpacePeriodCanBeTurnedOff()
{
controller->setOptions({.doubleSpacePeriod = false});
const KeyDef space = makeKey(KeyType::Space);
tap(makeKey(KeyType::Character, u"a"_s));
tap(space);
tap(space);
QCOMPARE(engine.output, u"A ");
}
void autoCapitalizationCanBeTurnedOff()
{
controller->resetContext();
controller->setOptions({.autoCapitalize = false});
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
tap(makeKey(KeyType::Character, u"a"_s));
QCOMPARE(engine.output, u"a");
}
void doubleTapShiftLocksAtSentenceStart()
{
controller->resetContext(); // a field was focused
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); // auto-shift
const KeyDef shift = makeKey(KeyType::Shift);
controller->keyDown(shift, 5000);
controller->keyUp(shift, KeyOutput::Primary, {}, 5050);
controller->keyDown(shift, 5200);
controller->keyUp(shift, KeyOutput::Primary, {}, 5250);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked);
}
void slideTypesSymbolAndSwitchesBack()
{
KeyDef layer = makeKey(KeyType::LayerSwitch);
@@ -177,13 +191,12 @@ private Q_SLOTS:
QCOMPARE(engine.keysyms, (QList<quint32>{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Up}));
}
void globeCyclesLayouts()
void globeRequestsNextLayout()
{
QCOMPARE(model.layoutName(), u"English (US)");
int requested = 0;
connect(controller.get(), &KeyboardController::nextLayoutRequested, this, [&] { ++requested; });
tap(makeKey(KeyType::Globe));
QCOMPARE(model.layoutName(), u"Svenska");
tap(makeKey(KeyType::Globe));
QCOMPARE(model.layoutName(), u"English (US)");
QCOMPARE(requested, 1);
}
void dismissEmitsSignal()
+4 -22
View File
@@ -1,11 +1,11 @@
// End-to-end gesture tests: real Keyboard.qml, synthetic touch events.
// Set SLOPOSK_SNAPSHOT_DIR to also save what the keyboard looks like mid-gesture.
#include "engine/InputEngine.h"
#include "FakeEngine.h"
#include "input/KeyboardController.h"
#include "input/KeyboardSurface.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include "TestLayouts.h"
#include <QQmlComponent>
#include <QQmlEngine>
@@ -21,21 +21,6 @@ using namespace Qt::StringLiterals;
namespace {
class FakeEngine : public InputEngine
{
public:
void commitText(const QString &text) override { output += text; }
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override
{
keysyms.append(keysym);
lastModifiers = modifiers;
}
QString output;
QList<quint32> keysyms;
Qt::KeyboardModifiers lastModifiers;
};
constexpr QPoint KeyboardOffset(0, 100); // room above for popups
constexpr int LongPressWaitMs = 600;
@@ -47,7 +32,6 @@ class TestKeyboardSurface : public QObject
private:
FakeEngine engine;
LayoutRepository repo;
LayoutModel model;
std::unique_ptr<KeyboardController> controller;
QQmlEngine qml;
@@ -81,10 +65,8 @@ private:
private Q_SLOTS:
void initTestCase()
{
Q_INIT_RESOURCE(layouts);
repo.loadDirectory(u":/layouts"_s);
repo.finalize(u"en_US"_s);
controller = std::make_unique<KeyboardController>(engine, model, repo);
model.setLayout(TestLayouts::build("us"));
controller = std::make_unique<KeyboardController>(engine, model);
QQmlComponent component(&qml, "Sloposk", "Keyboard");
keyboard = qobject_cast<QQuickItem *>(component.createWithInitialProperties({
+105
View File
@@ -0,0 +1,105 @@
#include "TestLayouts.h"
#include <QTest>
using namespace Qt::StringLiterals;
namespace {
QString rowLetters(const Layout &layout, int row)
{
QString letters;
for (const KeyDef &key : layout.layers.first().rows[row]) {
if (key.type == KeyType::Character && key.label.front().isLetter())
letters += key.label;
}
return letters;
}
const KeyDef &letterKey(const Layout &layout, const QString &label)
{
for (const Row &row : layout.layers.first().rows) {
for (const KeyDef &key : row) {
if (key.label == label)
return key;
}
}
qFatal("no key %s", qPrintable(label));
}
} // namespace
class TestLayoutBuilder : public QObject
{
Q_OBJECT
private Q_SLOTS:
void readsLetterRowsFromKeymap()
{
const Layout us = TestLayouts::build("us");
QCOMPARE(rowLetters(us, 0), u"qwertyuiop");
QCOMPARE(rowLetters(us, 1), u"asdfghjkl");
QCOMPARE(rowLetters(us, 2), u"zxcvbnm");
const Layout sk = TestLayouts::build("sk");
QCOMPARE(sk.name, u"Slovak");
QCOMPARE(rowLetters(sk, 0), u"qwertzuiopúä");
QCOMPARE(rowLetters(sk, 1), u"asdfghjklô");
QCOMPARE(rowLetters(sk, 2), u"yxcvbnm");
const Layout se = TestLayouts::build("se");
QCOMPARE(rowLetters(se, 0), u"qwertyuiopå");
QCOMPARE(rowLetters(se, 1), u"asdfghjklöä");
}
void shiftedFormComesFromKeymap()
{
QCOMPARE(letterKey(TestLayouts::build("sk"), u"ô"_s).shifted, u"Ô");
}
void flicksFollowColumns()
{
const Layout sk = TestLayouts::build("sk");
QCOMPARE(letterKey(sk, u"q"_s).flick, u"1");
QCOMPARE(letterKey(sk, u"p"_s).flick, u"0");
QVERIFY(letterKey(sk, u"ú"_s).flick.isEmpty()); // more letters than flicks
}
void numberRowLettersBecomeAlternates()
{
const Layout sk = TestLayouts::build("sk");
// The keymap's own letters come first: they're what this language types.
QCOMPARE(letterKey(sk, u"s"_s).alternates.first(), u"š");
QCOMPARE(letterKey(sk, u"l"_s).alternates.first(), u"ľ");
QCOMPARE(letterKey(sk, u"n"_s).alternates.first(), u"ň"); // BKSL on sk
QCOMPARE(letterKey(sk, u"e"_s).alternates.first(), u"é");
QCOMPARE(letterKey(sk, u"y"_s).alternates.first(), u"ý");
}
void lettersWithoutDecompositionFindTheirBase()
{
QCOMPARE(letterKey(TestLayouts::build("de"), u"s"_s).alternates.first(), u"ß");
}
void alternatesSkipLettersThatHaveKeys()
{
const Layout se = TestLayouts::build("se");
const QStringList a = letterKey(se, u"a"_s).alternates;
QVERIFY(!a.contains(u"ä"_s));
QVERIFY(!a.contains(u"å"_s));
QVERIFY(a.contains(u"á"_s));
}
void noPlaceholdersRemain()
{
for (const Layer &layer : TestLayouts::build("us").layers) {
for (const Row &row : layer.rows) {
for (const KeyDef &key : row)
QVERIFY(key.type != KeyType::Letters);
}
}
}
};
QTEST_GUILESS_MAIN(TestLayoutBuilder)
#include "tst_layoutbuilder.moc"
+29 -29
View File
@@ -1,22 +1,11 @@
#include "TestLayouts.h"
#include "layout/KeyGeometry.h"
#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
@@ -65,24 +54,35 @@ private Q_SLOTS:
QVERIFY(!LayoutLoader::validate(*layout, &error));
}
void builtinLayoutsAreConsistent()
void templateIsValid()
{
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);
QString error;
QVERIFY2(LayoutLoader::validate(TestLayouts::builtinTemplate(), &error), qPrintable(error));
}
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)));
}
}
void rejectsLettersWithoutRow()
{
QString error;
QVERIFY(!LayoutLoader::parse(R"({"id": "t", "layers": [{"name": "l", "rows": [[{"type": "letters"}]]}]})",
&error));
}
// Stretching must even out rows whatever the keymap's letter counts.
void builtLayersHaveEqualRows_data()
{
QTest::addColumn<QString>("xkbLayout");
for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"})
QTest::newRow(layout) << QString::fromLatin1(layout);
}
void builtLayersHaveEqualRows()
{
QFETCH(QString, xkbLayout);
const Layout layout = TestLayouts::build(qPrintable(xkbLayout));
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(layer.name));
}
}
};
+31
View File
@@ -32,6 +32,37 @@ private Q_SLOTS:
QCOMPARE(shift.mode(), Mode::Locked);
}
void doubleTapLocksAfterAutomaticShift()
{
ShiftState shift;
shift.setAutomatic(true);
shift.press(0); // turns auto-shift off...
shift.release();
shift.press(200); // ...and the second tap locks
shift.release();
QCOMPARE(shift.mode(), Mode::Locked);
}
void tapAfterUnlockIsNotDoubleTap()
{
ShiftState shift;
shift.press(0);
shift.press(100); // locked
shift.press(1000); // unlocks
shift.press(1100);
QCOMPARE(shift.mode(), Mode::Once);
}
void typingBetweenTapsIsNotDoubleTap()
{
ShiftState shift;
shift.press(0);
shift.release();
shift.characterTyped();
shift.press(200);
QCOMPARE(shift.mode(), Mode::Once);
}
void tapUnlocks()
{
ShiftState shift;