From 5277ab16ffe31bfeeb6f5b4acce8d9adcc0a6d45 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 14 Oct 2026 20:28:39 +0200 Subject: [PATCH] Fix double-space period, shift double-tap; settings; letters from system keymap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 36 ++++---- layouts/sv-SE.json | 62 ------------- layouts/{en-US.json => template.json} | 59 ++++++------ qml/Main.qml | 15 ++++ qml/SettingsButton.qml | 24 +++++ qml/SettingsPanel.qml | 33 +++++++ qml/Theme.qml | 1 + qml/ToggleRow.qml | 57 ++++++++++++ src/CMakeLists.txt | 11 ++- src/app/SystemLayouts.cpp | 49 ++++++++++ src/app/SystemLayouts.h | 28 ++++++ src/backend/KWinInputContext.cpp | 8 ++ src/backend/KWinInputContext.h | 1 + src/backend/KWinInputMethod.cpp | 6 ++ src/backend/KWinInputMethod.h | 1 + src/backend/TextBackend.h | 4 + src/backend/WaylandKeymap.cpp | 125 ++++++++++++++++++++++++++ src/backend/WaylandKeymap.h | 37 ++++++++ src/engine/DirectEngine.cpp | 5 ++ src/engine/DirectEngine.h | 1 + src/engine/InputEngine.h | 2 + src/input/KeyboardController.cpp | 37 ++++---- src/input/KeyboardController.h | 14 ++- src/input/ShiftState.cpp | 8 +- src/input/ShiftState.h | 4 +- src/input/TypingOptions.h | 10 +++ src/layout/KeyGeometry.cpp | 4 - src/layout/KeyGeometry.h | 3 + src/layout/KeymapLetters.cpp | 66 ++++++++++++++ src/layout/KeymapLetters.h | 22 +++++ src/layout/Layout.h | 15 +++- src/layout/LayoutBuilder.cpp | 110 +++++++++++++++++++++++ src/layout/LayoutBuilder.h | 9 ++ src/layout/LayoutLoader.cpp | 30 +++++-- src/layout/LayoutLoader.h | 9 +- src/layout/LayoutModel.cpp | 1 + src/layout/LayoutRepository.cpp | 75 ---------------- src/layout/LayoutRepository.h | 24 ----- src/main.cpp | 63 +++++++++---- src/settings/Settings.cpp | 42 +++++++++ src/settings/Settings.h | 35 ++++++++ tests/CMakeLists.txt | 1 + tests/FakeEngine.h | 34 +++++++ tests/TestLayouts.h | 47 ++++++++++ tests/tst_controller.cpp | 73 ++++++++------- tests/tst_keyboardsurface.cpp | 26 +----- tests/tst_layoutbuilder.cpp | 105 ++++++++++++++++++++++ tests/tst_layoutloader.cpp | 58 ++++++------ tests/tst_shiftstate.cpp | 31 +++++++ 49 files changed, 1162 insertions(+), 359 deletions(-) delete mode 100644 layouts/sv-SE.json rename layouts/{en-US.json => template.json} (61%) create mode 100644 qml/SettingsButton.qml create mode 100644 qml/SettingsPanel.qml create mode 100644 qml/ToggleRow.qml create mode 100644 src/app/SystemLayouts.cpp create mode 100644 src/app/SystemLayouts.h create mode 100644 src/backend/WaylandKeymap.cpp create mode 100644 src/backend/WaylandKeymap.h create mode 100644 src/input/TypingOptions.h create mode 100644 src/layout/KeymapLetters.cpp create mode 100644 src/layout/KeymapLetters.h create mode 100644 src/layout/LayoutBuilder.cpp create mode 100644 src/layout/LayoutBuilder.h delete mode 100644 src/layout/LayoutRepository.cpp delete mode 100644 src/layout/LayoutRepository.h create mode 100644 src/settings/Settings.cpp create mode 100644 src/settings/Settings.h create mode 100644 tests/FakeEngine.h create mode 100644 tests/TestLayouts.h create mode 100644 tests/tst_layoutbuilder.cpp diff --git a/README.md b/README.md index 2847969..2493c20 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/layouts/sv-SE.json b/layouts/sv-SE.json deleted file mode 100644 index 37dc28c..0000000 --- a/layouts/sv-SE.json +++ /dev/null @@ -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"} - ] - ] - } - ] -} diff --git a/layouts/en-US.json b/layouts/template.json similarity index 61% rename from layouts/en-US.json rename to layouts/template.json index c103b8c..737069d 100644 --- a/layouts/en-US.json +++ b/layouts/template.json @@ -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"} ] diff --git a/qml/Main.qml b/qml/Main.qml index a818289..03f3726 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -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) diff --git a/qml/SettingsButton.qml b/qml/SettingsButton.qml new file mode 100644 index 0000000..f0443ed --- /dev/null +++ b/qml/SettingsButton.qml @@ -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() + } +} diff --git a/qml/SettingsPanel.qml b/qml/SettingsPanel.qml new file mode 100644 index 0000000..056ce91 --- /dev/null +++ b/qml/SettingsPanel.qml @@ -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 + } + } +} diff --git a/qml/Theme.qml b/qml/Theme.qml index d7c5af6..39af7e9 100644 --- a/qml/Theme.qml +++ b/qml/Theme.qml @@ -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 diff --git a/qml/ToggleRow.qml b/qml/ToggleRow.qml new file mode 100644 index 0000000..aecbb2c --- /dev/null +++ b/qml/ToggleRow.qml @@ -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) + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6d9952f..c40fe8b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 ) diff --git a/src/app/SystemLayouts.cpp b/src/app/SystemLayouts.cpp new file mode 100644 index 0000000..2e5dbcd --- /dev/null +++ b/src/app/SystemLayouts.cpp @@ -0,0 +1,49 @@ +#include "SystemLayouts.h" + +#include +#include +#include + +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 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(); +} diff --git a/src/app/SystemLayouts.h b/src/app/SystemLayouts.h new file mode 100644 index 0000000..0a76919 --- /dev/null +++ b/src/app/SystemLayouts.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// 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; +}; diff --git a/src/backend/KWinInputContext.cpp b/src/backend/KWinInputContext.cpp index 2198dc8..274b466 100644 --- a/src/backend/KWinInputContext.cpp +++ b/src/backend/KWinInputContext.cpp @@ -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); diff --git a/src/backend/KWinInputContext.h b/src/backend/KWinInputContext.h index 77f2234..7be2912 100644 --- a/src/backend/KWinInputContext.h +++ b/src/backend/KWinInputContext.h @@ -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); diff --git a/src/backend/KWinInputMethod.cpp b/src/backend/KWinInputMethod.cpp index 92cd9a6..88ac945 100644 --- a/src/backend/KWinInputMethod.cpp +++ b/src/backend/KWinInputMethod.cpp @@ -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) diff --git a/src/backend/KWinInputMethod.h b/src/backend/KWinInputMethod.h index 8d56414..d4acc2a 100644 --- a/src/backend/KWinInputMethod.h +++ b/src/backend/KWinInputMethod.h @@ -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; diff --git a/src/backend/TextBackend.h b/src/backend/TextBackend.h index 3e10d87..783c68c 100644 --- a/src/backend/TextBackend.h +++ b/src/backend/TextBackend.h @@ -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; diff --git a/src/backend/WaylandKeymap.cpp b/src/backend/WaylandKeymap.cpp new file mode 100644 index 0000000..f4c3ec7 --- /dev/null +++ b/src/backend/WaylandKeymap.cpp @@ -0,0 +1,125 @@ +#include "WaylandKeymap.h" + +#include +#include +#include + +#include +#include +#include + +// 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(data); + if (self->m_seat || std::strcmp(interface, wl_seat_interface.name) != 0 || version < SeatVersion) + return; + self->m_seat = static_cast(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(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(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()) + m_seat = std::make_unique(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(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(); +} diff --git a/src/backend/WaylandKeymap.h b/src/backend/WaylandKeymap.h new file mode 100644 index 0000000..908aba0 --- /dev/null +++ b/src/backend/WaylandKeymap.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +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 m_context; + std::unique_ptr m_keymap; + std::unique_ptr m_seat; +}; diff --git a/src/engine/DirectEngine.cpp b/src/engine/DirectEngine.cpp index adb68c5..45ed709 100644 --- a/src/engine/DirectEngine.cpp +++ b/src/engine/DirectEngine.cpp @@ -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); diff --git a/src/engine/DirectEngine.h b/src/engine/DirectEngine.h index e08b5db..b6e857e 100644 --- a/src/engine/DirectEngine.h +++ b/src/engine/DirectEngine.h @@ -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: diff --git a/src/engine/InputEngine.h b/src/engine/InputEngine.h index 57f62cb..65fda88 100644 --- a/src/engine/InputEngine.h +++ b/src/engine/InputEngine.h @@ -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; }; diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp index ef6996e..e65f600 100644 --- a/src/input/KeyboardController.cpp +++ b/src/input/KeyboardController.cpp @@ -3,7 +3,6 @@ #include "engine/InputEngine.h" #include "layout/LayoutModel.h" -#include "layout/LayoutRepository.h" #include @@ -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); }); } diff --git a/src/input/KeyboardController.h b/src/input/KeyboardController.h index dfaebe4..c82a6b6 100644 --- a/src/input/KeyboardController.h +++ b/src/input/KeyboardController.h @@ -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; diff --git a/src/input/ShiftState.cpp b/src/input/ShiftState.cpp index 8978112..c54343c 100644 --- a/src/input/ShiftState.cpp +++ b/src/input/ShiftState.cpp @@ -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) diff --git a/src/input/ShiftState.h b/src/input/ShiftState.h index 8e5a09b..e3a51e0 100644 --- a/src/input/ShiftState.h +++ b/src/input/ShiftState.h @@ -29,5 +29,7 @@ private: Mode m_mode = Mode::Off; bool m_held = false; bool m_typedWhileHeld = false; - qint64 m_lastPress = std::numeric_limits::min() / 2; + bool m_lastPressUnlocked = false; // a tap leaving caps lock doesn't start a double tap + static constexpr qint64 NoPress = std::numeric_limits::min() / 2; + qint64 m_lastPress = NoPress; }; diff --git a/src/input/TypingOptions.h b/src/input/TypingOptions.h new file mode 100644 index 0000000..710f185 --- /dev/null +++ b/src/input/TypingOptions.h @@ -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; +}; diff --git a/src/layout/KeyGeometry.cpp b/src/layout/KeyGeometry.cpp index e056729..d9d7e79 100644 --- a/src/layout/KeyGeometry.cpp +++ b/src/layout/KeyGeometry.cpp @@ -2,8 +2,6 @@ #include -namespace { - qreal rowUnits(const Row &row) { qreal units = 0; @@ -12,8 +10,6 @@ qreal rowUnits(const Row &row) return units; } -} // namespace - QList computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics) { QList rects; diff --git a/src/layout/KeyGeometry.h b/src/layout/KeyGeometry.h index 1644eff..f00e25e 100644 --- a/src/layout/KeyGeometry.h +++ b/src/layout/KeyGeometry.h @@ -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 computeKeyRects(const Layer &layer, QSizeF size, KeyboardMetrics metrics = {}); + +// Total width of a row in key units. +qreal rowUnits(const Row &row); diff --git a/src/layout/KeymapLetters.cpp b/src/layout/KeymapLetters.cpp new file mode 100644 index 0000000..fb71f00 --- /dev/null +++ b/src/layout/KeymapLetters.cpp @@ -0,0 +1,66 @@ +#include "KeymapLetters.h" + +#include + +#include +#include + +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 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 &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; +} diff --git a/src/layout/KeymapLetters.h b/src/layout/KeymapLetters.h new file mode 100644 index 0000000..cfd4619 --- /dev/null +++ b/src/layout/KeymapLetters.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +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> rows; + QList extras; +}; + +KeymapLetters readKeymapLetters(xkb_keymap *keymap, quint32 layoutIndex); diff --git a/src/layout/Layout.h b/src/layout/Layout.h index c9b1433..bac5d62 100644 --- a/src/layout/Layout.h +++ b/src/layout/Layout.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -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 layers; // the first one is shown by default + // Template only: long-press choices for keymap letters, by lowercase letter. + QHash alternates; const Layer *layer(const QString &layerName) const { diff --git a/src/layout/LayoutBuilder.cpp b/src/layout/LayoutBuilder.cpp new file mode 100644 index 0000000..db4b91d --- /dev/null +++ b/src/layout/LayoutBuilder.cpp @@ -0,0 +1,110 @@ +#include "LayoutBuilder.h" +#include "KeyGeometry.h" + +#include + +#include + +namespace { + +// Letters without a Unicode decomposition, and the key they belong under. +const QHash &baseOverrides() +{ + static const QHash 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 alternatesByLetter(const Layout &tmpl, const KeymapLetters &letters) +{ + QSet onKeys; + for (const auto &row : letters.rows) { + for (const Letter &letter : row) + onKeys.insert(letter.lower); + } + + QHash 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 &alternates) +{ + Row result; + for (const KeyDef &key : row) { + if (key.type != KeyType::Letters) { + result.append(key); + continue; + } + const QList 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 alternates = alternatesByLetter(tmpl, letters); + for (Layer &layer : layout.layers) { + for (Row &row : layer.rows) + row = expandLetters(row, letters, alternates); + stretchRows(layer); + } + return layout; +} diff --git a/src/layout/LayoutBuilder.h b/src/layout/LayoutBuilder.h new file mode 100644 index 0000000..2e083bc --- /dev/null +++ b/src/layout/LayoutBuilder.h @@ -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); diff --git a/src/layout/LayoutLoader.cpp b/src/layout/LayoutLoader.cpp index 6de5c5f..e43a909 100644 --- a/src/layout/LayoutLoader.cpp +++ b/src/layout/LayoutLoader.cpp @@ -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 &keyTypesByName() { static const QHash types = { @@ -21,6 +31,7 @@ const QHash &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 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 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 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; diff --git a/src/layout/LayoutLoader.h b/src/layout/LayoutLoader.h index c3e2070..8dab765 100644 --- a/src/layout/LayoutLoader.h +++ b/src/layout/LayoutLoader.h @@ -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 parse(const QByteArray &json, QString *error); std::optional load(const QString &path, QString *error); -// Checks references that can only be resolved once bases are merged in. +// Checks references between layers. bool validate(const Layout &layout, QString *error); } // namespace LayoutLoader diff --git a/src/layout/LayoutModel.cpp b/src/layout/LayoutModel.cpp index 6cba195..b322676 100644 --- a/src/layout/LayoutModel.cpp +++ b/src/layout/LayoutModel.cpp @@ -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 {}; } diff --git a/src/layout/LayoutRepository.cpp b/src/layout/LayoutRepository.cpp deleted file mode 100644 index ead8e82..0000000 --- a/src/layout/LayoutRepository.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "LayoutRepository.h" -#include "LayoutLoader.h" - -#include -#include - -#include - -Q_LOGGING_CATEGORY(lcLayouts, "sloposk.layouts") - -void LayoutRepository::loadDirectory(const QString &dir) -{ - const QDir directory(dir); - for (const QString &file : directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name)) { - QString error; - const auto layout = LayoutLoader::load(directory.filePath(file), &error); - if (layout) - add(*layout); - else - qCWarning(lcLayouts) << "Skipping" << directory.filePath(file) << ":" << error; - } -} - -void LayoutRepository::finalize(const QString &preferredLocale) -{ - resolveBases(); - - m_layouts.removeIf([](const Layout &layout) { - QString error; - if (LayoutLoader::validate(layout, &error)) - return false; - qCWarning(lcLayouts) << "Dropping layout" << layout.id << ":" << error; - return true; - }); - - std::stable_partition(m_layouts.begin(), m_layouts.end(), [&](const Layout &layout) { - return layout.locale == preferredLocale; - }); -} - -int LayoutRepository::indexOf(const QString &id) const -{ - for (qsizetype i = 0; i < m_layouts.size(); ++i) { - if (m_layouts[i].id == id) - return int(i); - } - return -1; -} - -void LayoutRepository::add(const Layout &layout) -{ - const int existing = indexOf(layout.id); - if (existing >= 0) - m_layouts[existing] = layout; - else - m_layouts.append(layout); -} - -void LayoutRepository::resolveBases() -{ - // One level of inheritance keeps this simple and cycle-free. - for (Layout &layout : m_layouts) { - if (layout.base.isEmpty()) - continue; - const int baseIndex = indexOf(layout.base); - if (baseIndex < 0) { - qCWarning(lcLayouts) << "Layout" << layout.id << "has unknown base" << layout.base; - continue; - } - for (const Layer &layer : m_layouts[baseIndex].layers) { - if (!layout.layer(layer.name)) - layout.layers.append(layer); - } - } -} diff --git a/src/layout/LayoutRepository.h b/src/layout/LayoutRepository.h deleted file mode 100644 index 7ecf310..0000000 --- a/src/layout/LayoutRepository.h +++ /dev/null @@ -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 &layouts() const { return m_layouts; } - int indexOf(const QString &id) const; - -private: - void add(const Layout &layout); - void resolveBases(); - - QList m_layouts; -}; diff --git a/src/main.cpp b/src/main.cpp index c89d786..b18c66d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 -#include #include #include #include @@ -20,13 +23,33 @@ Q_IMPORT_QML_PLUGIN(SloposkPlugin) namespace { -void loadLayouts(LayoutRepository &layouts) +std::optional 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 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(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); diff --git a/src/settings/Settings.cpp b/src/settings/Settings.cpp new file mode 100644 index 0000000..9592986 --- /dev/null +++ b/src/settings/Settings.cpp @@ -0,0 +1,42 @@ +#include "Settings.h" + +#include + +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(); +} diff --git a/src/settings/Settings.h b/src/settings/Settings.h new file mode 100644 index 0000000..f0cb6bf --- /dev/null +++ b/src/settings/Settings.h @@ -0,0 +1,35 @@ +#pragma once + +#include "input/TypingOptions.h" + +#include +#include + +// 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; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9bc7de7..08274ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/FakeEngine.h b/tests/FakeEngine.h new file mode 100644 index 0000000..fab536f --- /dev/null +++ b/tests/FakeEngine.h @@ -0,0 +1,34 @@ +#pragma once + +#include "engine/InputEngine.h" + +#include +#include + +// 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 keysyms; + Qt::KeyboardModifiers lastModifiers; +}; diff --git a/tests/TestLayouts.h b/tests/TestLayouts.h new file mode 100644 index 0000000..90a3830 --- /dev/null +++ b/tests/TestLayouts.h @@ -0,0 +1,47 @@ +#pragma once + +#include "layout/KeymapLetters.h" +#include "layout/LayoutBuilder.h" +#include "layout/LayoutLoader.h" + +#include +#include + +#include + +// 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 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 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 diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp index ca40fa5..e8e673b 100644 --- a/tests/tst_controller.cpp +++ b/tests/tst_controller.cpp @@ -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 #include @@ -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 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 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(engine, model, repo); + model.setLayout(TestLayouts::build("us")); + controller = std::make_unique(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{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{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() diff --git a/tests/tst_keyboardsurface.cpp b/tests/tst_keyboardsurface.cpp index 011e5fe..0ae0c8c 100644 --- a/tests/tst_keyboardsurface.cpp +++ b/tests/tst_keyboardsurface.cpp @@ -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 #include @@ -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 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 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(engine, model, repo); + model.setLayout(TestLayouts::build("us")); + controller = std::make_unique(engine, model); QQmlComponent component(&qml, "Sloposk", "Keyboard"); keyboard = qobject_cast(component.createWithInitialProperties({ diff --git a/tests/tst_layoutbuilder.cpp b/tests/tst_layoutbuilder.cpp new file mode 100644 index 0000000..4548537 --- /dev/null +++ b/tests/tst_layoutbuilder.cpp @@ -0,0 +1,105 @@ +#include "TestLayouts.h" + +#include + +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" diff --git a/tests/tst_layoutloader.cpp b/tests/tst_layoutloader.cpp index 6194aa0..206120e 100644 --- a/tests/tst_layoutloader.cpp +++ b/tests/tst_layoutloader.cpp @@ -1,22 +1,11 @@ +#include "TestLayouts.h" +#include "layout/KeyGeometry.h" #include "layout/LayoutLoader.h" -#include "layout/LayoutRepository.h" #include using namespace Qt::StringLiterals; -namespace { - -qreal rowUnits(const Row &row) -{ - qreal units = 0; - for (const KeyDef &key : row) - units += key.width; - return units; -} - -} // namespace - class TestLayoutLoader : public QObject { Q_OBJECT @@ -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("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)); } } }; diff --git a/tests/tst_shiftstate.cpp b/tests/tst_shiftstate.cpp index 848b205..7a88eb6 100644 --- a/tests/tst_shiftstate.cpp +++ b/tests/tst_shiftstate.cpp @@ -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;