From ba2f17d665134ac636c88f2b95c40588c6cd5a69 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 14 Oct 2026 21:29:24 +0200 Subject: [PATCH] Full keyboard mode, Ctrl/Alt/Super, dead keys, shift levels - Full (iPad Pro) mode: every typing key from the keymap, number row, tab, caps lock, ctrl/super/alt; shifted symbol shown above and flickable - Dead keys compose with the locale's XKB compose table - Ctrl/Alt/Super stick like shift and turn keys into shortcuts; a tapped or held shift joins shortcuts and non-text keys, auto-shift and caps lock don't - Auto-capitalization and caps lock only capitalize letters; the user's shift picks the keymap's shifted level - Mode switch in settings keeps key size by resizing the window Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++- layouts/template.json | 30 ++++++++ qml/Key.qml | 21 +++++- qml/Keyboard.qml | 1 + qml/Main.qml | 2 + qml/SettingsPanel.qml | 8 ++ src/CMakeLists.txt | 4 +- src/input/DeadKeys.cpp | 68 +++++++++++++++++ src/input/DeadKeys.h | 32 ++++++++ src/input/KeyboardController.cpp | 106 +++++++++++++++++++++----- src/input/KeyboardController.h | 28 ++++++- src/input/Modifiers.cpp | 52 +++++++++++++ src/input/Modifiers.h | 40 ++++++++++ src/input/ShiftState.cpp | 8 ++ src/input/ShiftState.h | 10 ++- src/layout/KeyGeometry.cpp | 12 ++- src/layout/KeyGeometry.h | 2 + src/layout/KeymapKeys.cpp | 116 ++++++++++++++++++++++++++++ src/layout/KeymapKeys.h | 34 +++++++++ src/layout/KeymapLetters.cpp | 66 ---------------- src/layout/KeymapLetters.h | 22 ------ src/layout/Layout.h | 27 +++++-- src/layout/LayoutBuilder.cpp | 126 ++++++++++++++++++++++--------- src/layout/LayoutBuilder.h | 14 +++- src/layout/LayoutLoader.cpp | 20 ++++- src/layout/LayoutModel.cpp | 25 ++++-- src/layout/LayoutModel.h | 8 +- src/main.cpp | 48 +++++++----- src/settings/Settings.cpp | 11 +++ src/settings/Settings.h | 7 ++ tests/CMakeLists.txt | 1 + tests/TestLayouts.h | 10 +-- tests/tst_controller.cpp | 95 +++++++++++++++++++++++ tests/tst_deadkeys.cpp | 43 +++++++++++ tests/tst_layoutbuilder.cpp | 32 ++++++++ tests/tst_layoutloader.cpp | 10 ++- 36 files changed, 948 insertions(+), 202 deletions(-) create mode 100644 src/input/DeadKeys.cpp create mode 100644 src/input/DeadKeys.h create mode 100644 src/input/Modifiers.cpp create mode 100644 src/input/Modifiers.h create mode 100644 src/layout/KeymapKeys.cpp create mode 100644 src/layout/KeymapKeys.h delete mode 100644 src/layout/KeymapLetters.cpp delete mode 100644 src/layout/KeymapLetters.h create mode 100644 tests/tst_deadkeys.cpp diff --git a/README.md b/README.md index 78a659c..7fab61a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ iPad-style floating on-screen keyboard for Plasma Wayland. Qt 6, C++ core, QML U - 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 +- Compact (iPad) or full (iPad Pro) keyboard: number row, punctuation, tab, caps lock, ctrl/alt/super, dead keys +- Ctrl/Alt/Super stick like shift (tap, double-tap, hold) and turn keys into shortcuts; a tapped or held shift joins them (Shift+F5, Shift+Tab, Shift+arrows), auto-shift and caps lock don't - Optional PC key blocks: function row, navigation (ins/home/pg up, del/end/pg dn, arrows), numeric keypad; any mix, typing keys included (⚙) - Floating: drag the bottom bar, resize from the corner; geometry is remembered per screen @@ -37,6 +39,8 @@ Dev hooks: `SLOPOSK_AUTOTYPE=text` commits on focus; `SLOPOSK_SCREENSHOT=file.pn 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. +In full mode every typing key comes from the keymap (number row, `[ ] \ ; ' , . /` or the layout's equivalents), with the shifted symbol shown above. Dead keys compose with the locale's XKB compose table. + Everything else is `layouts/template.json`; `~/.local/share/sloposk/template.json` replaces it. ```json @@ -46,14 +50,17 @@ Everything else is `layouts/template.json`; `~/.local/share/sloposk/template.jso - Key: a string, or an object with `type` (`char` `space` `backspace` `enter` `shift` `layer` `globe` `dismiss` `spacer` `letters` `keysym`), `label`, `shifted`, `flick`, `alternates`, `width` (key units), `target` (for `layer`), `stretch` - `keysym`: sends the XKB keysym named by `keysym` (`Home`, `F5`, `Left`...) on press; `repeat` auto-repeats while held -- `letters`: the keymap row `row` (0–2), with `flicks` handed out left to right +- `letters`: the keymap's letters in row `row` (0–2), with `flicks` handed out left to right +- `keys`: every typing key in keymap row `row` (0 = number row … 3 = bottom) +- `modifier`: `"modifier": "ctrl" | "alt" | "super"`; `capslock` +- Layer `full`: rows used instead of `rows` in full-keyboard mode - `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 - Top-level `sections`: optional blocks, `{"id", "name", "placement": "above" | "right", "rows"}`; the user picks which show ## Settings -`~/.config/sloposk/sloposk.conf`, also editable from the ⚙ panel: `typing/autoCapitalize`, `typing/doubleSpacePeriod`, `layout/visibleSections` (`main` plus section ids). +`~/.config/sloposk/sloposk.conf`, also editable from the ⚙ panel: `typing/autoCapitalize`, `typing/doubleSpacePeriod`, `layout/visibleSections` (`main` plus section ids), `layout/fullKeyboard`. ## Architecture diff --git a/layouts/template.json b/layouts/template.json index 8b932d8..b77c59e 100644 --- a/layouts/template.json +++ b/layouts/template.json @@ -77,6 +77,36 @@ "layers": [ { "name": "letters", + "full": [ + [ + {"type": "keys", "row": 0}, + {"type": "backspace", "width": 1.5, "stretch": true} + ], + [ + {"type": "keysym", "keysym": "Tab", "label": "tab", "width": 1.5, "stretch": true}, + {"type": "keys", "row": 1} + ], + [ + {"type": "capslock", "label": "caps lock", "width": 1.75, "stretch": true}, + {"type": "keys", "row": 2}, + {"type": "enter", "width": 1.75, "stretch": true} + ], + [ + {"type": "shift", "width": 2.25, "stretch": true}, + {"type": "keys", "row": 3}, + {"type": "shift", "width": 2.25, "stretch": true} + ], + [ + {"type": "modifier", "modifier": "ctrl", "label": "ctrl", "width": 1.25}, + {"type": "modifier", "modifier": "super", "label": "super", "width": 1.25}, + {"type": "modifier", "modifier": "alt", "label": "alt", "width": 1.25}, + {"type": "globe"}, + {"type": "space", "width": 6, "stretch": true}, + {"type": "modifier", "modifier": "alt", "label": "alt", "width": 1.25}, + {"type": "layer", "label": ".?123", "target": "numbers", "width": 1.25}, + {"type": "dismiss"} + ] + ], "rows": [ [ {"type": "letters", "row": 0, "flicks": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]}, diff --git a/qml/Key.qml b/qml/Key.qml index 1629cfb..22436c1 100644 --- a/qml/Key.qml +++ b/qml/Key.qml @@ -14,13 +14,26 @@ Item { required property real keyHeight required property bool pressed required property real flickProgress + required property string modifier property int shiftMode: KeyboardController.Off + property var modifierModes: ({}) property bool blank: false // labels hidden, e.g. in trackpad mode readonly property bool isCharacter: keyType === "char" || keyType === "space" - readonly property bool shiftActive: keyType === "shift" && shiftMode !== KeyboardController.Off + // Off / Once / Locked for shift, caps lock and ctrl/alt/super. + readonly property int latch: { + if (keyType === "shift") + return shiftMode + if (keyType === "capslock") + return shiftMode === KeyboardController.Locked ? KeyboardController.Locked : KeyboardController.Off + if (keyType === "modifier") + return modifierModes[modifier] ?? KeyboardController.Off + return KeyboardController.Off + } + // Shift shows locking with its glyph; the others turn accent-coloured. + readonly property bool locked: latch === KeyboardController.Locked && keyType !== "shift" // Character keys darken when pressed; function keys lighten (as on iPad). - readonly property bool lit: (isCharacter !== pressed) || shiftActive + readonly property bool lit: (isCharacter !== pressed) || latch !== KeyboardController.Off readonly property var defaultGlyphs: ({ backspace: "⌫", @@ -51,7 +64,7 @@ Item { id: face anchors.fill: parent radius: Theme.keyRadius - color: key.lit ? Theme.characterKey : Theme.functionKey + color: key.locked ? Theme.accent : key.lit ? Theme.characterKey : Theme.functionKey } // Flicking slides the secondary symbol down into the main position. @@ -81,7 +94,7 @@ Item { verticalCenterOffset: flick.visible ? key.height * 0.14 : 0 } font.pixelSize: key.height * (key.isWord ? 0.24 : flick.visible ? 0.34 : 0.4) - color: Theme.text + color: key.locked ? Theme.accentText : Theme.text visible: !key.blank opacity: 1 - key.flickProgress } diff --git a/qml/Keyboard.qml b/qml/Keyboard.qml index 79b8802..6dd46ed 100644 --- a/qml/Keyboard.qml +++ b/qml/Keyboard.qml @@ -8,6 +8,7 @@ KeyboardSurface { model: surface.model delegate: Key { shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off + modifierModes: surface.controller ? surface.controller.modifierModes : ({}) blank: surface.trackpadActive } } diff --git a/qml/Main.qml b/qml/Main.qml index d969d39..b0a6dbf 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -9,6 +9,8 @@ Window { required property Settings settings property var placement: null // FloatingWindow, set from C++ property bool settingsOpen: false + // Height of the key area, for C++ keeping key sizes when rows change. + readonly property real keysHeight: keyboard.height // Transparent room above the keyboard for long-press popups on the top row. readonly property real popupMargin: 90 diff --git a/qml/SettingsPanel.qml b/qml/SettingsPanel.qml index 5e0e9ea..6c54c2b 100644 --- a/qml/SettingsPanel.qml +++ b/qml/SettingsPanel.qml @@ -47,6 +47,14 @@ Rectangle { SectionHeader { text: qsTr("Keys") } + ToggleRow { + width: parent.width + text: qsTr("Full Keyboard") + detail: qsTr("Number row, tab, ctrl, alt — like iPad Pro") + checked: panel.settings.fullKeyboard + onToggled: checked => panel.settings.fullKeyboard = checked + } + Repeater { // "main" is the typing keys; the rest come from the template. model: [{ id: "main", name: qsTr("Typing keys") }].concat(panel.layoutModel.sections) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 423ee76..6b3f034 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,6 +6,8 @@ qt_add_library(sloposk_core STATIC engine/InputEngine.h engine/DirectEngine.h engine/DirectEngine.cpp input/ShiftState.h input/ShiftState.cpp + input/Modifiers.h input/Modifiers.cpp + input/DeadKeys.h input/DeadKeys.cpp input/PointerTracker.h input/PointerTracker.cpp input/AlternatesPicker.h input/AlternatesPicker.cpp input/KeyRepeater.h input/KeyRepeater.cpp @@ -15,7 +17,7 @@ qt_add_library(sloposk_core STATIC input/KeyboardSurface.h input/KeyboardSurface.cpp layout/Layout.h layout/LayoutLoader.h layout/LayoutLoader.cpp - layout/KeymapLetters.h layout/KeymapLetters.cpp + layout/KeymapKeys.h layout/KeymapKeys.cpp layout/LayoutBuilder.h layout/LayoutBuilder.cpp layout/KeyGeometry.h layout/KeyGeometry.cpp layout/LayoutModel.h layout/LayoutModel.cpp diff --git a/src/input/DeadKeys.cpp b/src/input/DeadKeys.cpp new file mode 100644 index 0000000..fb9ab5c --- /dev/null +++ b/src/input/DeadKeys.cpp @@ -0,0 +1,68 @@ +#include "DeadKeys.h" + +#include "layout/KeymapKeys.h" + +#include +#include + +namespace { + +QByteArray systemLocale() +{ + for (const char *name : {"LC_ALL", "LC_CTYPE", "LANG"}) { + if (const QByteArray value = qgetenv(name); !value.isEmpty()) + return value; + } + return "C"; +} + +} // namespace + +DeadKeys::DeadKeys(const QString &locale) + : m_context(xkb_context_new(XKB_CONTEXT_NO_FLAGS), xkb_context_unref) + , m_table(nullptr, xkb_compose_table_unref) + , m_state(nullptr, xkb_compose_state_unref) +{ + // The C locale has no compose table; en_US.UTF-8's is the common base of all. + const QByteArray name = locale.isEmpty() ? systemLocale() : locale.toUtf8(); + for (const QByteArray &candidate : {name, QByteArrayLiteral("en_US.UTF-8")}) { + m_table.reset(xkb_compose_table_new_from_locale(m_context.get(), candidate.constData(), + XKB_COMPOSE_COMPILE_NO_FLAGS)); + if (m_table) + break; + } + if (m_table) + m_state.reset(xkb_compose_state_new(m_table.get(), XKB_COMPOSE_STATE_NO_FLAGS)); +} + +DeadKeys::~DeadKeys() = default; + +void DeadKeys::press(quint32 deadKeysym) +{ + m_pending += deadKeyText(deadKeysym); + if (m_state) + xkb_compose_state_feed(m_state.get(), deadKeysym); +} + +QString DeadKeys::compose(const QString &text) +{ + QString result = m_pending + text; + if (m_state && !text.isEmpty()) { + const char32_t ucs = text.toUcs4().first(); + xkb_compose_state_feed(m_state.get(), xkb_utf32_to_keysym(ucs)); + if (xkb_compose_state_get_status(m_state.get()) == XKB_COMPOSE_COMPOSED) { + char utf8[64]; + xkb_compose_state_get_utf8(m_state.get(), utf8, sizeof utf8); + result = QString::fromUtf8(utf8) + text.mid(QString::fromUcs4(&ucs, 1).size()); + } + } + cancel(); + return result; +} + +void DeadKeys::cancel() +{ + m_pending.clear(); + if (m_state) + xkb_compose_state_reset(m_state.get()); +} diff --git a/src/input/DeadKeys.h b/src/input/DeadKeys.h new file mode 100644 index 0000000..05215ea --- /dev/null +++ b/src/input/DeadKeys.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +struct xkb_context; +struct xkb_compose_table; +struct xkb_compose_state; + +// Dead-key composition (´ then e → é) with the locale's XKB compose table, +// the same one a physical keyboard uses. +class DeadKeys +{ +public: + // `locale` e.g. "sk_SK.UTF-8"; empty picks LC_ALL / LC_CTYPE / LANG. + explicit DeadKeys(const QString &locale = {}); + ~DeadKeys(); + + bool isPending() const { return !m_pending.isEmpty(); } + // A dead key was typed: starts or extends the sequence. + void press(quint32 deadKeysym); + // `text` typed while pending: the composed character, or the accents + // followed by `text` when nothing composes. Ends the sequence. + QString compose(const QString &text); + void cancel(); + +private: + std::unique_ptr m_context; + std::unique_ptr m_table; + std::unique_ptr m_state; + QString m_pending; // spacing accents, typed if nothing composes +}; diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp index 67dd4e4..1cb298a 100644 --- a/src/input/KeyboardController.cpp +++ b/src/input/KeyboardController.cpp @@ -4,7 +4,7 @@ #include "engine/InputEngine.h" #include "layout/LayoutModel.h" -#include +#include // ShiftMode mirrors ShiftState::Mode for QML. static_assert(int(KeyboardController::ShiftMode::Off) == int(ShiftState::Mode::Off)); @@ -39,16 +39,27 @@ void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs) case KeyType::Shift: changeShift([&] { m_shift.press(timeMs); }); break; + case KeyType::Modifier: + m_modifiers.press(key.modifier, timeMs); + Q_EMIT modifiersChanged(); + break; case KeyType::Backspace: - deleteBackward(false); - m_backspaceRepeater.start(); + if (m_deadKeys.isPending()) { + m_deadKeys.cancel(); // backspace takes back the accent + } else if (m_modifiers.active()) { + sendKey(XKB_KEY_BackSpace); + } else { + deleteBackward(false); + m_backspaceRepeater.start(); + } break; case KeyType::LayerSwitch: m_layerBeforeSwitch = m_model.layerName(); m_model.setLayer(key.target); break; case KeyType::Keysym: - m_engine.sendKeysym(key.keysym); + m_repeatingModifiers = shortcutModifiers(); + sendKey(key.keysym); if (key.repeat) { m_repeatingKeysym = key.keysym; m_keysymRepeater.start(); @@ -65,6 +76,13 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin case KeyType::Shift: changeShift([&] { m_shift.release(); }); break; + case KeyType::CapsLock: + changeShift([&] { m_shift.toggleLock(); }); + break; + case KeyType::Modifier: + m_modifiers.release(key.modifier); + Q_EMIT modifiersChanged(); + break; case KeyType::Backspace: m_backspaceRepeater.stop(); break; @@ -72,18 +90,12 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin stopRepeating(key); break; case KeyType::Character: - if (output == KeyOutput::Alternate && !alternate.isEmpty()) - typeText(alternate); - else if (output == KeyOutput::Flick && !key.flick.isEmpty()) - typeText(key.flick); - else - typeText(key.text(m_shift.isUppercase())); - break; case KeyType::Space: - typeText(QStringLiteral(" ")); + typeCharacter(key, output, alternate); break; case KeyType::Enter: - m_engine.sendKeysym(XKB_KEY_Return); + m_deadKeys.cancel(); + sendKey(XKB_KEY_Return); m_textBefore += u'\n'; autoCapitalize(); break; @@ -102,7 +114,10 @@ void KeyboardController::keyCancelled(const KeyDef &key) { if (key.type == KeyType::Shift) changeShift([&] { m_shift.release(); }); - else if (key.type == KeyType::Backspace) + else if (key.type == KeyType::Modifier) { + m_modifiers.release(key.modifier); + Q_EMIT modifiersChanged(); + } else if (key.type == KeyType::Backspace) m_backspaceRepeater.stop(); else if (key.type == KeyType::Keysym) stopRepeating(key); @@ -128,6 +143,7 @@ void KeyboardController::moveCursor(QPoint steps) void KeyboardController::resetContext() { + m_deadKeys.cancel(); m_context = {}; m_textBefore.clear(); autoCapitalize(); @@ -147,6 +163,52 @@ void KeyboardController::setOptions(const TypingOptions &options) autoCapitalize(); } +void KeyboardController::typeCharacter(const KeyDef &key, KeyOutput output, const QString &alternate) +{ + const QString base = key.type == KeyType::Space ? QStringLiteral(" ") : key.label; + if (m_modifiers.active()) { + // A shortcut such as Ctrl+C: the key's unshifted symbol, with modifiers. + m_deadKeys.cancel(); + sendKey(xkb_utf32_to_keysym(base.toLower().toUcs4().value(0))); + return; + } + + const ShiftLevel level = shiftLevel(); + if (const quint32 dead = key.deadAt(level); dead && output == KeyOutput::Primary) { + m_deadKeys.press(dead); + changeShift([&] { m_shift.characterTyped(); }); + return; + } + + QString text = key.type == KeyType::Space ? base : key.text(level); + if (output == KeyOutput::Alternate && !alternate.isEmpty()) + text = alternate; + else if (output == KeyOutput::Flick && !key.flick.isEmpty()) + text = key.flick; + if (m_deadKeys.isPending()) + text = m_deadKeys.compose(text); + typeText(text); +} + +void KeyboardController::sendKey(quint32 keysym) +{ + m_engine.sendKeysym(keysym, shortcutModifiers()); + useModifiers(); +} + +Qt::KeyboardModifiers KeyboardController::shortcutModifiers() const +{ + return m_modifiers.active() | (m_shift.appliesToShortcuts() ? Qt::ShiftModifier : Qt::NoModifier); +} + +void KeyboardController::useModifiers() +{ + m_modifiers.keyTyped(); + Q_EMIT modifiersChanged(); + if (m_shift.appliesToShortcuts()) + changeShift([&] { m_shift.characterTyped(); }); +} + void KeyboardController::typeText(const QString &text) { if (text == u" " && m_options.doubleSpacePeriod && m_context.isProse() @@ -181,7 +243,7 @@ void KeyboardController::stopRepeating(const KeyDef &key) void KeyboardController::sendRepeatedKeysym() { - m_engine.sendKeysym(m_repeatingKeysym); + m_engine.sendKeysym(m_repeatingKeysym, m_repeatingModifiers); } void KeyboardController::autoCapitalize() @@ -191,13 +253,21 @@ void KeyboardController::autoCapitalize() changeShift([&] { m_shift.setAutomatic(capitalize); }); } +ShiftLevel KeyboardController::shiftLevel() const +{ + if (m_shift.appliesToShortcuts()) + return ShiftLevel::Shifted; + return m_shift.isActive() ? ShiftLevel::Capitals : ShiftLevel::None; +} + template void KeyboardController::changeShift(Change change) { - const ShiftState::Mode before = m_shift.mode(); + const ShiftState::Mode mode = m_shift.mode(); + const ShiftLevel level = shiftLevel(); change(); - if (m_shift.mode() == before) + if (m_shift.mode() == mode && shiftLevel() == level) return; - m_model.setUppercase(m_shift.isUppercase()); + m_model.setShiftLevel(shiftLevel()); Q_EMIT shiftChanged(); } diff --git a/src/input/KeyboardController.h b/src/input/KeyboardController.h index 463c195..ae76a5d 100644 --- a/src/input/KeyboardController.h +++ b/src/input/KeyboardController.h @@ -1,6 +1,8 @@ #pragma once +#include "DeadKeys.h" #include "KeyRepeater.h" +#include "Modifiers.h" #include "ShiftState.h" #include "TypingOptions.h" #include "backend/TextContext.h" @@ -16,16 +18,23 @@ class LayoutModel; // Which of a key's outputs the gesture selected. enum class KeyOutput { Primary, Flick, Alternate }; -// 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. +// Keyboard rules: what each key does, shift and the other modifiers, dead +// keys, 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. +// +// With Ctrl, Alt or Super active, keys become shortcuts (keysyms with those +// modifiers) instead of text. Shift joins shortcuts and non-text keys +// (Shift+F5, Shift+Tab, Shift+arrows) only when the user turned it on, not +// when auto-capitalization did. class KeyboardController : public QObject { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Created in C++") Q_PROPERTY(ShiftMode shiftMode READ shiftMode NOTIFY shiftChanged) + // "ctrl"/"alt"/"super" → ShiftMode + Q_PROPERTY(QVariantMap modifierModes READ modifierModes NOTIFY modifiersChanged) public: enum class ShiftMode { Off, Once, Locked }; @@ -34,6 +43,7 @@ public: KeyboardController(InputEngine &engine, LayoutModel &model, QObject *parent = nullptr); ShiftMode shiftMode() const; + QVariantMap modifierModes() const { return m_modifiers.modes(); } void keyDown(const KeyDef &key, qint64 timeMs); void keyUp(const KeyDef &key, KeyOutput output, const QString &alternate, qint64 timeMs); @@ -51,11 +61,18 @@ public: Q_SIGNALS: void shiftChanged(); + void modifiersChanged(); void dismissRequested(); void nextLayoutRequested(); // the globe key: switching is the system's job private: + void typeCharacter(const KeyDef &key, KeyOutput output, const QString &alternate); void typeText(const QString &text); + // Sends a non-text key or shortcut with the active modifiers, then uses them up. + void sendKey(quint32 keysym); + Qt::KeyboardModifiers shortcutModifiers() const; + ShiftLevel shiftLevel() const; + void useModifiers(); void deleteBackward(bool wholeWord); void sendRepeatedKeysym(); void stopRepeating(const KeyDef &key); @@ -67,9 +84,12 @@ private: LayoutModel &m_model; TypingOptions m_options; ShiftState m_shift; + Modifiers m_modifiers; + DeadKeys m_deadKeys; KeyRepeater m_backspaceRepeater; KeyRepeater m_keysymRepeater; // arrows, Delete, ... quint32 m_repeatingKeysym = 0; + Qt::KeyboardModifiers m_repeatingModifiers; QString m_layerBeforeSwitch; TextContext m_context; diff --git a/src/input/Modifiers.cpp b/src/input/Modifiers.cpp new file mode 100644 index 0000000..ae1645e --- /dev/null +++ b/src/input/Modifiers.cpp @@ -0,0 +1,52 @@ +#include "Modifiers.h" + +ShiftState *Modifiers::find(const QString &name) +{ + for (Entry &entry : m_entries) { + if (name == QLatin1StringView(entry.name)) + return &entry.state; + } + return nullptr; +} + +void Modifiers::press(const QString &name, qint64 timeMs) +{ + if (ShiftState *state = find(name)) + state->press(timeMs); +} + +void Modifiers::release(const QString &name) +{ + if (ShiftState *state = find(name)) + state->release(); +} + +void Modifiers::keyTyped() +{ + for (Entry &entry : m_entries) + entry.state.characterTyped(); +} + +void Modifiers::clear() +{ + for (Entry &entry : m_entries) + entry.state = {}; +} + +Qt::KeyboardModifiers Modifiers::active() const +{ + Qt::KeyboardModifiers flags; + for (const Entry &entry : m_entries) { + if (entry.state.isActive()) + flags |= entry.flag; + } + return flags; +} + +QVariantMap Modifiers::modes() const +{ + QVariantMap modes; + for (const Entry &entry : m_entries) + modes.insert(QString::fromLatin1(entry.name), int(entry.state.mode())); + return modes; +} diff --git a/src/input/Modifiers.h b/src/input/Modifiers.h new file mode 100644 index 0000000..8f1cc3e --- /dev/null +++ b/src/input/Modifiers.h @@ -0,0 +1,40 @@ +#pragma once + +#include "ShiftState.h" + +#include +#include +#include + +#include + +// Ctrl, Alt and Super, each sticky like shift (see ShiftState): tap for the +// next key, double-tap to lock, hold while typing with another finger. +class Modifiers +{ +public: + // Names as used in layouts: "ctrl", "alt", "super". + void press(const QString &name, qint64 timeMs); + void release(const QString &name); + // A key was typed: one-shot modifiers are used up. + void keyTyped(); + void clear(); + + Qt::KeyboardModifiers active() const; + // name → ShiftState::Mode as int, for the view. + QVariantMap modes() const; + +private: + struct Entry { + const char *name; + Qt::KeyboardModifier flag; + ShiftState state; + }; + ShiftState *find(const QString &name); + + std::array m_entries{{ + {"ctrl", Qt::ControlModifier, {}}, + {"alt", Qt::AltModifier, {}}, + {"super", Qt::MetaModifier, {}}, + }}; +}; diff --git a/src/input/ShiftState.cpp b/src/input/ShiftState.cpp index c54343c..3050a01 100644 --- a/src/input/ShiftState.cpp +++ b/src/input/ShiftState.cpp @@ -9,6 +9,7 @@ void ShiftState::press(qint64 timeMs) m_lastPressUnlocked = m_mode == Mode::Locked; m_held = true; m_typedWhileHeld = false; + m_automatic = false; switch (m_mode) { case Mode::Off: m_mode = doubleTap ? Mode::Locked : Mode::Once; break; @@ -38,4 +39,11 @@ void ShiftState::setAutomatic(bool capitalize) if (m_held || m_mode == Mode::Locked) return; m_mode = capitalize ? Mode::Once : Mode::Off; + m_automatic = capitalize; +} + +void ShiftState::toggleLock() +{ + m_mode = m_mode == Mode::Locked ? Mode::Off : Mode::Locked; + m_automatic = false; } diff --git a/src/input/ShiftState.h b/src/input/ShiftState.h index e3a51e0..561d48c 100644 --- a/src/input/ShiftState.h +++ b/src/input/ShiftState.h @@ -7,6 +7,7 @@ // - tap: shift the next character only // - double-tap: caps lock; tap again to unlock // - hold while typing: every character shifted, off on release +// Also used for Ctrl/Alt/Super (see Modifiers), which are never automatic. // Times are in milliseconds and passed in, so this stays deterministic. class ShiftState { @@ -16,7 +17,11 @@ public: static constexpr qint64 DoubleTapMs = 300; Mode mode() const { return m_mode; } - bool isUppercase() const { return m_mode != Mode::Off; } + bool isActive() const { return m_mode != Mode::Off; } + // Whether shift joins shortcuts and non-text keys (Shift+F5): only when + // tapped or held by the user. Neither auto-capitalization nor caps lock + // does, as on a PC. + bool appliesToShortcuts() const { return m_held || (m_mode == Mode::Once && !m_automatic); } void press(qint64 timeMs); void release(); @@ -24,9 +29,12 @@ public: // Auto-capitalization hint; ignored while shift is held or locked. void setAutomatic(bool capitalize); + // Caps lock key: locks, or unlocks if locked. + void toggleLock(); private: Mode m_mode = Mode::Off; + bool m_automatic = false; bool m_held = false; bool m_typedWhileHeld = false; bool m_lastPressUnlocked = false; // a tap leaving caps lock doesn't start a double tap diff --git a/src/layout/KeyGeometry.cpp b/src/layout/KeyGeometry.cpp index 346e385..981ddf5 100644 --- a/src/layout/KeyGeometry.cpp +++ b/src/layout/KeyGeometry.cpp @@ -42,13 +42,19 @@ qreal totalUnits(const QList &blocks) return units; } +qsizetype totalRows(const QList &blocks) +{ + qsizetype rows = 0; + for (const Block &block : blocks) + rows = std::max(rows, block.size()); + return rows; +} + QList computeKeyRects(const QList &blocks, QSizeF size, KeyboardMetrics metrics) { QList rects; const qreal units = totalUnits(blocks); - qsizetype maxRows = 0; - for (const Block &block : blocks) - maxRows = std::max(maxRows, block.size()); + const qsizetype maxRows = totalRows(blocks); if (units <= 0 || maxRows == 0) return rects; diff --git a/src/layout/KeyGeometry.h b/src/layout/KeyGeometry.h index ecfb389..58ae586 100644 --- a/src/layout/KeyGeometry.h +++ b/src/layout/KeyGeometry.h @@ -29,3 +29,5 @@ inline QList computeKeyRects(const Layer &layer, QSizeF size, KeyboardMe qreal rowUnits(const Row &row); // Width of all blocks in key units, ignoring gaps. qreal totalUnits(const QList &blocks); +// Rows of the tallest block. +qsizetype totalRows(const QList &blocks); diff --git a/src/layout/KeymapKeys.cpp b/src/layout/KeymapKeys.cpp new file mode 100644 index 0000000..6a4cd7e --- /dev/null +++ b/src/layout/KeymapKeys.cpp @@ -0,0 +1,116 @@ +#include "KeymapKeys.h" + +#include +#include + +#include +#include + +namespace { + +// XKB keycodes are evdev codes + 8. +constexpr xkb_keycode_t fromEvdev(int code) { return xkb_keycode_t(code + 8); } + +constexpr int Tilde = 41, Backslash = 43, LessGreater = 86; + +struct PhysicalRow { + int before; // a key left of the range, or 0 + int first; + int last; + int after; // a key right of the range, or 0 +}; + +constexpr PhysicalRow Rows[] = { + {Tilde, 2, 13, 0}, // TLDE, AE01–AE12 + {0, 16, 27, Backslash}, // AD01–AD12, BKSL + {0, 30, 40, 0}, // AC01–AC11 + {LessGreater, 44, 53, 0}, // LSGT, AB01–AB10 +}; + +xkb_keysym_t symbolAt(xkb_keymap *keymap, int code, xkb_layout_index_t layout, xkb_level_index_t level) +{ + const xkb_keysym_t *syms = nullptr; + const int count = xkb_keymap_key_get_syms_by_level(keymap, fromEvdev(code), layout, level, &syms); + return count == 1 ? syms[0] : XKB_KEY_NoSymbol; +} + +bool isDead(xkb_keysym_t sym) +{ + return sym >= XKB_KEY_dead_grave && sym <= XKB_KEY_dead_longsolidusoverlay; +} + +// Fills one level; false if it types nothing. +bool readLevel(xkb_keysym_t sym, QString &text, quint32 &dead) +{ + if (isDead(sym)) { + dead = sym; + text = deadKeyText(sym); + return true; + } + const char32_t ucs = xkb_keysym_to_utf32(sym); // 0 for functions and NoSymbol + if (!ucs || QChar::isNonCharacter(ucs) || ucs < 0x20) + return false; + text = QString::fromUcs4(&ucs, 1); + return true; +} + +std::optional keyAt(xkb_keymap *keymap, int code, xkb_layout_index_t layout) +{ + KeymapKey key; + if (code == Backslash) + key.position = KeymapKey::Position::Backslash; + else if (code == LessGreater) + key.position = KeymapKey::Position::LessGreater; + + if (!readLevel(symbolAt(keymap, code, layout, 0), key.lower, key.deadLower)) + return std::nullopt; + if (!readLevel(symbolAt(keymap, code, layout, 1), key.upper, key.deadUpper)) + key.upper = key.lower.toUpper(); + return key; +} + +} // namespace + +bool KeymapKey::isLetter() const +{ + return !deadLower && !lower.isEmpty() && QChar::isLetter(lower.toUcs4().first()); +} + +KeymapKeys readKeymapKeys(xkb_keymap *keymap, quint32 layoutIndex) +{ + KeymapKeys result; + if (!keymap || layoutIndex >= xkb_keymap_num_layouts(keymap)) + return result; + result.name = QString::fromUtf8(xkb_keymap_layout_get_name(keymap, layoutIndex)); + + for (const PhysicalRow &physical : Rows) { + QList &row = result.rows.emplace_back(); + const auto add = [&](int code) { + if (const auto key = keyAt(keymap, code, layoutIndex)) + row.append(*key); + }; + if (physical.before) + add(physical.before); + for (int code : std::views::iota(physical.first, physical.last + 1)) + add(code); + if (physical.after) + add(physical.after); + } + return result; +} + +QString deadKeyText(quint32 keysym) +{ + static const QHash accents = { + {XKB_KEY_dead_grave, QStringLiteral("`")}, {XKB_KEY_dead_acute, QStringLiteral("´")}, + {XKB_KEY_dead_circumflex, QStringLiteral("^")}, {XKB_KEY_dead_tilde, QStringLiteral("~")}, + {XKB_KEY_dead_macron, QStringLiteral("¯")}, {XKB_KEY_dead_breve, QStringLiteral("˘")}, + {XKB_KEY_dead_abovedot, QStringLiteral("˙")}, {XKB_KEY_dead_diaeresis, QStringLiteral("¨")}, + {XKB_KEY_dead_abovering, QStringLiteral("˚")}, {XKB_KEY_dead_doubleacute, QStringLiteral("˝")}, + {XKB_KEY_dead_caron, QStringLiteral("ˇ")}, {XKB_KEY_dead_cedilla, QStringLiteral("¸")}, + {XKB_KEY_dead_ogonek, QStringLiteral("˛")}, {XKB_KEY_dead_belowdot, QStringLiteral(".")}, + {XKB_KEY_dead_hook, QStringLiteral("ˀ")}, {XKB_KEY_dead_horn, QStringLiteral("ʼ")}, + {XKB_KEY_dead_greek, QStringLiteral("µ")}, {XKB_KEY_dead_stroke, QStringLiteral("/")}, + }; + return accents.value(keysym, QStringLiteral("◌")); +} diff --git a/src/layout/KeymapKeys.h b/src/layout/KeymapKeys.h new file mode 100644 index 0000000..49cce1b --- /dev/null +++ b/src/layout/KeymapKeys.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +struct xkb_keymap; + +// One typing key of a keymap layout, at its two shift levels. A level is +// either text or a dead key (then `text` is its spacing accent, for labels). +struct KeymapKey { + enum class Position { Regular, Backslash, LessGreater }; // BKSL, LSGT: not on every keyboard + + Position position = Position::Regular; + QString lower; + QString upper; + quint32 deadLower = 0; // dead keysym, 0 if the level types text + quint32 deadUpper = 0; + + bool isLetter() const; +}; + +// The typing keys one layout of an XKB keymap has on a physical keyboard, +// in its four rows: 0 number row (TLDE, AE01–AE12), 1 top (AD01–AD12, BKSL), +// 2 home (AC01–AC11), 3 bottom (LSGT, AB01–AB10). Keys that type nothing on +// either level are left out. +struct KeymapKeys { + QString name; // e.g. "Slovak" + QList> rows; +}; + +KeymapKeys readKeymapKeys(xkb_keymap *keymap, quint32 layoutIndex); + +// What a dead key shows and types when nothing composes: ´ for dead_acute. +QString deadKeyText(quint32 keysym); diff --git a/src/layout/KeymapLetters.cpp b/src/layout/KeymapLetters.cpp deleted file mode 100644 index fb71f00..0000000 --- a/src/layout/KeymapLetters.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#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 deleted file mode 100644 index cfd4619..0000000 --- a/src/layout/KeymapLetters.h +++ /dev/null @@ -1,22 +0,0 @@ -#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 0065b09..e5ee3e0 100644 --- a/src/layout/Layout.h +++ b/src/layout/Layout.h @@ -11,14 +11,22 @@ enum class KeyType { Backspace, Enter, Shift, + CapsLock, + Modifier, // ctrl, alt or super: sticky like shift, applied to the next key LayerSwitch, 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 + Keys, // template only: a whole row of typing keys from the system keymap Keysym, // a non-text key (Home, F5, arrows...): sends `keysym` }; +// What shift does to a key. Auto-capitalization and caps lock only capitalize +// letters; a shift the user taps or holds picks the keymap's shifted level +// (Shift+1 = !), as on a PC. +enum class ShiftLevel { None, Capitals, Shifted }; + struct KeyDef { KeyType type = KeyType::Character; QString label; // shown and typed (for character keys) @@ -26,22 +34,30 @@ struct KeyDef { QString flick; // secondary symbol, typed by flicking down QStringList alternates; // long-press choices QString target; // layer to switch to (LayerSwitch only) + QString modifier; // Modifier only: "ctrl", "alt" or "super" + quint32 dead = 0; // character keys: dead keysym typed instead of `label`, 0 if none + quint32 deadShifted = 0; // same for the shifted level qreal width = 1.0; // in key units bool stretch = false; // grows so the row is as wide as its block's widest quint32 keysym = 0; // Keysym keys only bool repeat = false; // Keysym keys only: auto-repeat while held - // Letters placeholders only. - int lettersRow = -1; // which keymap row (0 = top) + // Letters/Keys placeholders only. + int lettersRow = -1; // which keymap row (Letters: 0 = top; Keys: 0 = number row) QStringList flicks; // flick symbol per letter, left to right - // Text this key shows/types; only character keys change case. - QString text(bool uppercase) const + // Text this key shows/types; only character keys change. + QString text(ShiftLevel level) const { - if (!uppercase || type != KeyType::Character) + if (level == ShiftLevel::None || type != KeyType::Character) return label; + if (level == ShiftLevel::Capitals) + return isLetter() ? label.toUpper() : label; return shifted.isEmpty() ? label.toUpper() : shifted; } + // The dead keysym typed at `level`, 0 if it types text. + quint32 deadAt(ShiftLevel level) const { return level == ShiftLevel::Shifted ? deadShifted : dead; } + bool isLetter() const { return !dead && label.size() == 1 && label.front().isLetter(); } }; using Row = QList; @@ -49,6 +65,7 @@ using Row = QList; struct Layer { QString name; QList rows; + QList fullRows; // replaces `rows` in full-keyboard mode, if present }; // An optional block of keys next to the main one, shown if the user wants it. diff --git a/src/layout/LayoutBuilder.cpp b/src/layout/LayoutBuilder.cpp index d8e62c2..42347a8 100644 --- a/src/layout/LayoutBuilder.cpp +++ b/src/layout/LayoutBuilder.cpp @@ -2,7 +2,6 @@ #include - namespace { // Letters without a Unicode decomposition, and the key they belong under. @@ -26,64 +25,121 @@ QString baseLetter(const QString &letter) 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) +KeyDef characterKey(const KeymapKey &source) { - QSet onKeys; - for (const auto &row : letters.rows) { - for (const Letter &letter : row) - onKeys.insert(letter.lower); + KeyDef key; + key.label = source.lower; + key.shifted = source.upper; + key.dead = source.deadLower; + key.deadShifted = source.deadUpper; + return key; +} + +// The compact letter rows: letters only, and not the keys some keyboards lack. +QList letterKeys(const KeymapKeys &keymap, int row, const QStringList &flicks) +{ + QList keys; + for (const KeymapKey &source : keymap.rows.value(row + 1)) { + if (!source.isLetter() || source.position != KeymapKey::Position::Regular) + continue; + KeyDef key = characterKey(source); + // iPad letters always uppercase; the keymap may shift some to a symbol (sk: ô → "). + key.shifted = source.lower.toUpper(); + key.deadShifted = 0; + key.flick = flicks.value(keys.size()); + keys.append(key); + } + return keys; +} + +// A full row: every typing key, the shifted symbol shown (and flickable) above. +QList rowKeys(const KeymapKeys &keymap, int row) +{ + QSet elsewhere; // LSGT often repeats a pair that exists elsewhere (US: < >) + for (const auto &keys : keymap.rows) { + for (const KeymapKey &key : keys) { + if (key.position != KeymapKey::Position::LessGreater) + elsewhere << key.lower << key.upper; + } } - QHash result; + QList keys; + for (const KeymapKey &source : keymap.rows.value(row)) { + if (source.position == KeymapKey::Position::LessGreater && elsewhere.contains(source.lower)) + continue; + KeyDef key = characterKey(source); + // Show (and flick for) a shifted level that isn't just the capital letter: ! on 1, " on sk ô. + if (!source.deadUpper && source.upper != source.lower && source.upper != source.lower.toUpper()) + key.flick = source.upper; + keys.append(key); + } + return keys; +} + +Row expandPlaceholders(const Row &row, const KeymapKeys &keymap) +{ + Row result; + for (const KeyDef &key : row) { + if (key.type == KeyType::Letters) + result += letterKeys(keymap, key.lettersRow, key.flicks); + else if (key.type == KeyType::Keys) + result += rowKeys(keymap, key.lettersRow); + else + result.append(key); + } + return result; +} + +// Long-press choices per letter: the keymap's letters that have no key here +// first (they're what this language types), then the template's. +void addAlternates(Layer &layer, const Layout &tmpl, const KeymapKeys &keymap) +{ + QSet onKeys; + for (const Row &row : layer.rows) { + for (const KeyDef &key : row) + onKeys << key.label << key.shifted.toLower(); + } + + QHash choices; const auto add = [&](const QString &base, const QString &choice) { - QStringList &list = result[base]; + QStringList &list = choices[base]; if (!onKeys.contains(choice) && !list.contains(choice)) list.append(choice); }; - for (const Letter &extra : letters.extras) - add(baseLetter(extra.lower), extra.lower); + for (const auto &row : keymap.rows) { + for (const KeymapKey &key : row) { + if (key.isLetter()) + add(baseLetter(key.lower), key.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); + for (Row &row : layer.rows) { + for (KeyDef &key : row) { + if (key.type == KeyType::Character && key.isLetter() && key.alternates.isEmpty()) + key.alternates = choices.value(key.label); } } - return result; } } // namespace -Layout buildLayout(const Layout &tmpl, const KeymapLetters &letters) +Layout buildLayout(const Layout &tmpl, const KeymapKeys &keymap, KeyboardMode mode) { Layout layout = tmpl; - layout.name = letters.name.isEmpty() ? tmpl.name : letters.name; + layout.name = keymap.name.isEmpty() ? tmpl.name : keymap.name; layout.alternates.clear(); - const QHash alternates = alternatesByLetter(tmpl, letters); for (Layer &layer : layout.layers) { + if (mode == KeyboardMode::Full && !layer.fullRows.isEmpty()) + layer.rows = layer.fullRows; + layer.fullRows.clear(); for (Row &row : layer.rows) - row = expandLetters(row, letters, alternates); + row = expandPlaceholders(row, keymap); + addAlternates(layer, tmpl, keymap); } return layout; } diff --git a/src/layout/LayoutBuilder.h b/src/layout/LayoutBuilder.h index 8d2263c..1ef7372 100644 --- a/src/layout/LayoutBuilder.h +++ b/src/layout/LayoutBuilder.h @@ -1,8 +1,14 @@ #pragma once -#include "KeymapLetters.h" +#include "KeymapKeys.h" #include "Layout.h" -// Turns the template into the layout shown: fills its letters placeholders -// from the keymap and gives each letter its flick and long-press choices. -Layout buildLayout(const Layout &tmpl, const KeymapLetters &letters); +enum class KeyboardMode { + Compact, // iPad: letters only, symbols by flick and layers + Full, // iPad Pro / PC: number row, punctuation, tab, modifiers +}; + +// Turns the template into the layout shown: picks each layer's rows for the +// mode, fills the keymap placeholders, and gives letters their long-press +// choices (letters of the keymap without a key of their own come first). +Layout buildLayout(const Layout &tmpl, const KeymapKeys &keymap, KeyboardMode mode = KeyboardMode::Compact); diff --git a/src/layout/LayoutLoader.cpp b/src/layout/LayoutLoader.cpp index ff598f7..796a55d 100644 --- a/src/layout/LayoutLoader.cpp +++ b/src/layout/LayoutLoader.cpp @@ -11,6 +11,8 @@ namespace LayoutLoader { namespace { constexpr int LetterRows = 3; +constexpr int KeyRows = 4; +const QStringList ModifierNames = {QStringLiteral("ctrl"), QStringLiteral("alt"), QStringLiteral("super")}; QStringList toStringList(const QJsonValue &value) { @@ -28,11 +30,14 @@ const QHash &keyTypesByName() {QStringLiteral("backspace"), KeyType::Backspace}, {QStringLiteral("enter"), KeyType::Enter}, {QStringLiteral("shift"), KeyType::Shift}, + {QStringLiteral("capslock"), KeyType::CapsLock}, + {QStringLiteral("modifier"), KeyType::Modifier}, {QStringLiteral("layer"), KeyType::LayerSwitch}, {QStringLiteral("globe"), KeyType::Globe}, {QStringLiteral("dismiss"), KeyType::Dismiss}, {QStringLiteral("spacer"), KeyType::Spacer}, {QStringLiteral("letters"), KeyType::Letters}, + {QStringLiteral("keys"), KeyType::Keys}, {QStringLiteral("keysym"), KeyType::Keysym}, }; return types; @@ -85,10 +90,20 @@ std::optional parseKey(const QJsonValue &value, QString *error) *error = QStringLiteral("character key without a label"); return std::nullopt; } + key.modifier = obj.value(u"modifier").toString(); + 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.type == KeyType::Keys && (key.lettersRow < 0 || key.lettersRow >= KeyRows)) { + *error = QStringLiteral("keys need a row from 0 to %1").arg(KeyRows - 1); + return std::nullopt; + } + if (key.type == KeyType::Modifier && !ModifierNames.contains(key.modifier)) { + *error = QStringLiteral("modifier must be one of %1").arg(ModifierNames.join(u", ")); + return std::nullopt; + } if (key.width <= 0) { *error = QStringLiteral("key \"%1\" has a non-positive width").arg(key.label); return std::nullopt; @@ -123,7 +138,8 @@ std::optional parseLayer(const QJsonObject &obj, QString *error) *error = QStringLiteral("layer without a name"); return std::nullopt; } - if (!parseRows(obj.value(u"rows"), &layer.rows, error)) { + if (!parseRows(obj.value(u"rows"), &layer.rows, error) + || (obj.contains(u"full") && !parseRows(obj.value(u"full"), &layer.fullRows, error))) { *error = QStringLiteral("layer \"%1\": %2").arg(layer.name, *error); return std::nullopt; } @@ -210,7 +226,7 @@ std::optional load(const QString &path, QString *error) bool validate(const Layout &layout, QString *error) { for (const Layer &layer : layout.layers) { - for (const Row &row : layer.rows) { + for (const Row &row : layer.rows + layer.fullRows) { for (const KeyDef &key : row) { if (key.type == KeyType::LayerSwitch && !layout.layer(key.target)) { *error = QStringLiteral("layer \"%1\" switches to unknown layer \"%2\"") diff --git a/src/layout/LayoutModel.cpp b/src/layout/LayoutModel.cpp index 3e0e6dd..87a064d 100644 --- a/src/layout/LayoutModel.cpp +++ b/src/layout/LayoutModel.cpp @@ -12,12 +12,15 @@ QString keyTypeName(KeyType type) case KeyType::Backspace: return QStringLiteral("backspace"); case KeyType::Enter: return QStringLiteral("enter"); case KeyType::Shift: return QStringLiteral("shift"); + case KeyType::CapsLock: return QStringLiteral("capslock"); + case KeyType::Modifier: return QStringLiteral("modifier"); case KeyType::LayerSwitch: return QStringLiteral("layer"); case KeyType::Globe: return QStringLiteral("globe"); case KeyType::Dismiss: return QStringLiteral("dismiss"); case KeyType::Spacer: return QStringLiteral("spacer"); case KeyType::Keysym: return QStringLiteral("keysym"); - case KeyType::Letters: break; // expanded before it reaches the model + case KeyType::Letters: + case KeyType::Keys: break; // expanded before it reaches the model } return {}; } @@ -44,8 +47,10 @@ QVariant LayoutModel::data(const QModelIndex &index, int role) const const KeyDef &key = m_keys.at(index.row()); const QRectF rect = m_rects.value(index.row()); switch (role) { - case LabelRole: return key.text(m_uppercase); - case FlickLabelRole: return key.flick; + case LabelRole: return key.text(m_shiftLevel); + // A shifted key already shows its shifted symbol (! on 1): don't repeat it. + case FlickLabelRole: return m_shiftLevel == ShiftLevel::Shifted && key.flick == key.shifted ? QString() : key.flick; + case ModifierRole: return key.modifier; case KeyTypeRole: return keyTypeName(key.type); case KeyXRole: return rect.x(); case KeyYRole: return rect.y(); @@ -69,6 +74,7 @@ QHash LayoutModel::roleNames() const {KeyHeightRole, "keyHeight"}, {PressedRole, "pressed"}, {FlickProgressRole, "flickProgress"}, + {ModifierRole, "modifier"}, }; } @@ -111,6 +117,11 @@ qreal LayoutModel::totalUnits() const return ::totalUnits(m_blocks); } +qsizetype LayoutModel::totalRows() const +{ + return ::totalRows(m_blocks); +} + void LayoutModel::setSize(QSizeF size) { if (size == m_size) @@ -121,13 +132,13 @@ void LayoutModel::setSize(QSizeF size) Q_EMIT dataChanged(index(0), index(rowCount() - 1), {KeyXRole, KeyYRole, KeyWidthRole, KeyHeightRole}); } -void LayoutModel::setUppercase(bool uppercase) +void LayoutModel::setShiftLevel(ShiftLevel level) { - if (uppercase == m_uppercase) + if (level == m_shiftLevel) return; - m_uppercase = uppercase; + m_shiftLevel = level; if (!m_keys.isEmpty()) - Q_EMIT dataChanged(index(0), index(rowCount() - 1), {LabelRole}); + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {LabelRole, FlickLabelRole}); } void LayoutModel::setPressed(int row, bool pressed) diff --git a/src/layout/LayoutModel.h b/src/layout/LayoutModel.h index b4b237b..e8cda7c 100644 --- a/src/layout/LayoutModel.h +++ b/src/layout/LayoutModel.h @@ -30,6 +30,7 @@ public: KeyHeightRole, PressedRole, FlickProgressRole, + ModifierRole, }; using QAbstractListModel::QAbstractListModel; @@ -43,13 +44,14 @@ public: QVariantList sections() const; // Width in key units of everything shown. qreal totalUnits() const; + qsizetype totalRows() const; void setLayout(const Layout &layout); void setLayer(const QString &layerName); // Section ids to show; MainSectionId stands for the layers. void setVisibleSections(const QStringList &ids); void setSize(QSizeF size); - void setUppercase(bool uppercase); + void setShiftLevel(ShiftLevel level); void setPressed(int index, bool pressed); void setFlickProgress(int index, qreal progress); @@ -57,7 +59,7 @@ public: int keyAt(QPointF pos) const; const KeyDef &key(int index) const { return m_keys.at(index); } QRectF keyRect(int index) const { return m_rects.value(index); } - bool isUppercase() const { return m_uppercase; } + bool isUppercase() const { return m_shiftLevel != ShiftLevel::None; } Q_SIGNALS: void layoutChanged(); @@ -73,7 +75,7 @@ private: QStringList m_visibleSections{MainSectionId}; QList m_blocks; QSizeF m_size; - bool m_uppercase = false; + ShiftLevel m_shiftLevel = ShiftLevel::None; QList m_keys; QList m_rects; diff --git a/src/main.cpp b/src/main.cpp index 54af9f2..9e8fe37 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,8 @@ #include #include +#include + Q_IMPORT_QML_PLUGIN(SloposkPlugin) namespace { @@ -38,29 +40,41 @@ std::optional loadTemplate() 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) +// The shown layout is the template filled with the active system layout's +// keys. Returns the rebuild, for settings that change the result. +std::function connectLayouts(const Layout &tmpl, WaylandKeymap &keymap, SystemLayouts &systemLayouts, + const Settings &settings, KeyboardController &controller, LayoutModel &model) { const auto rebuild = [&] { - model.setLayout(buildLayout(tmpl, readKeymapLetters(keymap.keymap(), systemLayouts.current()))); + const KeyboardMode mode = settings.fullKeyboard() ? KeyboardMode::Full : KeyboardMode::Compact; + model.setLayout(buildLayout(tmpl, readKeymapKeys(keymap.keymap(), systemLayouts.current()), mode)); }; 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(); + return rebuild; } -// Showing or hiding a block resizes the window so keys keep their width. -void connectSections(Settings &settings, LayoutModel &model, FloatingWindow &placement) +// Settings that add or remove keys resize the window so keys keep their size. +void connectKeySettings(Settings &settings, LayoutModel &model, FloatingWindow &placement, const QQuickWindow &window, + const std::function &rebuild) { - QObject::connect(&settings, &Settings::visibleSectionsChanged, &model, [&] { - const qreal before = model.totalUnits(); - model.setVisibleSections(settings.visibleSections()); - if (before > 0) - placement.resizeBy(placement.geometry().width() * (model.totalUnits() / before - 1), 0); - }); + const auto keepingKeySize = [&](const std::function &change) { + const qreal units = model.totalUnits(); + const auto rows = qreal(model.totalRows()); + change(); + if (units <= 0 || rows <= 0) + return; + const qreal keysHeight = window.property("keysHeight").toReal(); + placement.resizeBy(placement.geometry().width() * (model.totalUnits() / units - 1), + keysHeight * (qreal(model.totalRows()) / rows - 1)); + }; + QObject::connect(&settings, &Settings::visibleSectionsChanged, &model, + [&, keepingKeySize] { keepingKeySize([&] { model.setVisibleSections(settings.visibleSections()); }); }); + QObject::connect(&settings, &Settings::fullKeyboardChanged, &model, + [keepingKeySize, rebuild] { keepingKeySize(rebuild); }); } void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings) @@ -114,13 +128,13 @@ int main(int argc, char *argv[]) KeyboardController controller(engine, model); connectTextContext(backend, controller); - WaylandKeymap keymap; - SystemLayouts systemLayouts; - connectLayouts(*layoutTemplate, keymap, systemLayouts, controller, model); - Settings settings; controller.setOptions(settings.typingOptions()); model.setVisibleSections(settings.visibleSections()); + + WaylandKeymap keymap; + SystemLayouts systemLayouts; + const auto rebuildLayout = connectLayouts(*layoutTemplate, keymap, systemLayouts, settings, controller, model); QObject::connect(&settings, &Settings::typingOptionsChanged, &controller, [&] { controller.setOptions(settings.typingOptions()); }); @@ -141,7 +155,7 @@ int main(int argc, char *argv[]) window->setProperty("placement", QVariant::fromValue(&placement)); WindowSettings windowSettings; restoreAndPersistGeometry(placement, windowSettings); - connectSections(settings, model, placement); + connectKeySettings(settings, model, placement, *window, rebuildLayout); VisibilityController visibility; connectVisibility(visibility, backend, controller, placement); diff --git a/src/settings/Settings.cpp b/src/settings/Settings.cpp index 506cc6a..1370cb5 100644 --- a/src/settings/Settings.cpp +++ b/src/settings/Settings.cpp @@ -8,6 +8,7 @@ namespace { constexpr auto AutoCapitalizeKey = "typing/autoCapitalize"; constexpr auto DoubleSpacePeriodKey = "typing/doubleSpacePeriod"; constexpr auto VisibleSectionsKey = "layout/visibleSections"; +constexpr auto FullKeyboardKey = "layout/fullKeyboard"; } Settings::Settings(QObject *parent) @@ -20,6 +21,16 @@ Settings::Settings(QObject *parent) m_visibleSections = store.value(VisibleSectionsKey, QStringList{MainSectionId}).toStringList(); if (m_visibleSections.isEmpty()) m_visibleSections = {MainSectionId}; + m_fullKeyboard = store.value(FullKeyboardKey, false).toBool(); +} + +void Settings::setFullKeyboard(bool full) +{ + if (full == m_fullKeyboard) + return; + m_fullKeyboard = full; + QSettings().setValue(FullKeyboardKey, full); + Q_EMIT fullKeyboardChanged(); } void Settings::setSectionVisible(const QString &id, bool visible) diff --git a/src/settings/Settings.h b/src/settings/Settings.h index 96b4bc0..e7af02a 100644 --- a/src/settings/Settings.h +++ b/src/settings/Settings.h @@ -17,6 +17,8 @@ class Settings : public QObject Q_PROPERTY(bool doubleSpacePeriod READ doubleSpacePeriod WRITE setDoubleSpacePeriod NOTIFY typingOptionsChanged) // Which key blocks are shown: "main" (the typing keys) and template section ids. Q_PROPERTY(QStringList visibleSections READ visibleSections NOTIFY visibleSectionsChanged) + // iPad Pro-style full keyboard (number row, tab, ctrl/alt/super) instead of the compact one. + Q_PROPERTY(bool fullKeyboard READ fullKeyboard WRITE setFullKeyboard NOTIFY fullKeyboardChanged) public: explicit Settings(QObject *parent = nullptr); @@ -32,13 +34,18 @@ public: // Hiding the last visible section is refused: something must stay on screen. Q_INVOKABLE void setSectionVisible(const QString &id, bool visible); + bool fullKeyboard() const { return m_fullKeyboard; } + void setFullKeyboard(bool full); + Q_SIGNALS: void typingOptionsChanged(); void visibleSectionsChanged(); + void fullKeyboardChanged(); private: void setTypingOptions(const TypingOptions &options); TypingOptions m_typing; QStringList m_visibleSections; + bool m_fullKeyboard = false; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 08274ef..2335bdc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ sloposk_add_test(tst_controller) sloposk_add_test(tst_pointertracker) sloposk_add_test(tst_alternatespicker) sloposk_add_test(tst_textrules) +sloposk_add_test(tst_deadkeys) sloposk_add_test(tst_keyrepeater) sloposk_add_test(tst_visibility) diff --git a/tests/TestLayouts.h b/tests/TestLayouts.h index 90a3830..be9f2aa 100644 --- a/tests/TestLayouts.h +++ b/tests/TestLayouts.h @@ -1,6 +1,6 @@ #pragma once -#include "layout/KeymapLetters.h" +#include "layout/KeymapKeys.h" #include "layout/LayoutBuilder.h" #include "layout/LayoutLoader.h" @@ -26,7 +26,7 @@ inline Layout builtinTemplate() return *layout; } -inline KeymapLetters letters(const char *layout, const char *variant = nullptr) +inline KeymapKeys keymapKeys(const char *layout, const char *variant = nullptr) { const std::unique_ptr context( xkb_context_new(XKB_CONTEXT_NO_ENVIRONMENT_NAMES), xkb_context_unref); @@ -36,12 +36,12 @@ inline KeymapLetters letters(const char *layout, const char *variant = nullptr) 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); + return readKeymapKeys(keymap.get(), 0); } -inline Layout build(const char *layout, const char *variant = nullptr) +inline Layout build(const char *layout, KeyboardMode mode = KeyboardMode::Compact) { - return buildLayout(builtinTemplate(), letters(layout, variant)); + return buildLayout(builtinTemplate(), keymapKeys(layout), mode); } } // namespace TestLayouts diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp index abfc216..f927734 100644 --- a/tests/tst_controller.cpp +++ b/tests/tst_controller.cpp @@ -230,6 +230,101 @@ private Q_SLOTS: QCOMPARE(model.rowCount(), mainKeys); // never nothing } + KeyDef keysymKey(quint32 keysym) + { + KeyDef key = makeKey(KeyType::Keysym); + key.keysym = keysym; + return key; + } + + KeyDef modifierKey(const QString &name) + { + KeyDef key = makeKey(KeyType::Modifier); + key.modifier = name; + return key; + } + + void ctrlTurnsKeysIntoShortcuts() + { + tap(modifierKey(u"ctrl"_s)); + QCOMPARE(controller->modifierModes().value(u"ctrl"_s).toInt(), int(KeyboardController::ShiftMode::Once)); + tap(makeKey(KeyType::Character, u"c"_s)); + QCOMPARE(engine.keysyms, QList{XKB_KEY_c}); + QCOMPARE(engine.lastModifiers, Qt::ControlModifier); + QVERIFY(engine.output.isEmpty()); + // One-shot: the next key types again. + tap(makeKey(KeyType::Character, u"c"_s)); + QCOMPARE(engine.output, u"c"); + } + + void heldModifierAppliesToEveryKey() + { + const KeyDef alt = modifierKey(u"alt"_s); + controller->keyDown(alt, 0); + tap(keysymKey(XKB_KEY_Tab)); + tap(keysymKey(XKB_KEY_Tab)); + controller->keyUp(alt, KeyOutput::Primary, {}, 9000); + QCOMPARE(engine.keysyms.size(), 2); + QCOMPARE(engine.lastModifiers, Qt::AltModifier); + QCOMPARE(controller->modifierModes().value(u"alt"_s).toInt(), int(KeyboardController::ShiftMode::Off)); + } + + void tappedShiftAppliesToFunctionKeys() + { + tap(makeKey(KeyType::Character, u"a"_s)); // mid-sentence: no auto-shift + tap(makeKey(KeyType::Shift)); + tap(keysymKey(XKB_KEY_F5)); + QCOMPARE(engine.keysyms.last(), quint32(XKB_KEY_F5)); + QCOMPARE(engine.lastModifiers, Qt::ShiftModifier); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); // used up + } + + void autoShiftAndCapsLockDontApplyToFunctionKeys() + { + controller->resetContext(); // sentence start: auto-shift on + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); + tap(keysymKey(XKB_KEY_Left)); + QCOMPARE(engine.lastModifiers, Qt::NoModifier); + + tap(makeKey(KeyType::CapsLock)); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked); + tap(keysymKey(XKB_KEY_Left)); + QCOMPARE(engine.lastModifiers, Qt::NoModifier); + tap(makeKey(KeyType::Character, u"a"_s)); + tap(makeKey(KeyType::Character, u"b"_s)); + QCOMPARE(engine.output, u"AB"); + tap(makeKey(KeyType::CapsLock)); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); + } + + void autoShiftCapitalizesLettersOnly() + { + KeyDef lj = makeKey(KeyType::Character, u"ľ"_s); // sk number row: Shift+ľ = 2 + lj.shifted = u"2"_s; + KeyDef one = makeKey(KeyType::Character, u"+"_s); + one.shifted = u"1"_s; + controller->resetContext(); // sentence start + tap(one); + QCOMPARE(engine.output, u"+"); // not the shifted level + controller->resetContext(); + tap(lj); + QCOMPARE(engine.output, u"+Ľ"); + tap(makeKey(KeyType::Shift)); // the user's shift does pick it + tap(lj); + QCOMPARE(engine.output, u"+Ľ2"); + } + + void deadKeyComposesWithNextCharacter() + { + KeyDef acute = makeKey(KeyType::Character, u"´"_s); + acute.dead = XKB_KEY_dead_acute; + tap(makeKey(KeyType::Character, u"x"_s)); + tap(acute); + QVERIFY(engine.output == u"x"); // nothing typed yet + tap(makeKey(KeyType::Character, u"e"_s)); + QVERIFY(engine.output.endsWith(u"é"_s)); + } + void dismissEmitsSignal() { int dismissed = 0; diff --git a/tests/tst_deadkeys.cpp b/tests/tst_deadkeys.cpp new file mode 100644 index 0000000..e535c67 --- /dev/null +++ b/tests/tst_deadkeys.cpp @@ -0,0 +1,43 @@ +#include "input/DeadKeys.h" + +#include +#include + +using namespace Qt::StringLiterals; + +class TestDeadKeys : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void composesWithTheNextLetter() + { + DeadKeys dead(u"en_US.UTF-8"_s); + QVERIFY(!dead.isPending()); + dead.press(XKB_KEY_dead_acute); + QVERIFY(dead.isPending()); + QCOMPARE(dead.compose(u"e"_s), u"é"); + QVERIFY(!dead.isPending()); + + dead.press(XKB_KEY_dead_caron); + QCOMPARE(dead.compose(u"C"_s), u"Č"); + } + + void typesAccentAndLetterWhenNothingComposes() + { + DeadKeys dead(u"en_US.UTF-8"_s); + dead.press(XKB_KEY_dead_caron); + QCOMPARE(dead.compose(u"q"_s), u"ˇq"); + } + + void cancelForgetsTheAccent() + { + DeadKeys dead(u"en_US.UTF-8"_s); + dead.press(XKB_KEY_dead_acute); + dead.cancel(); + QVERIFY(!dead.isPending()); + } +}; + +QTEST_GUILESS_MAIN(TestDeadKeys) +#include "tst_deadkeys.moc" diff --git a/tests/tst_layoutbuilder.cpp b/tests/tst_layoutbuilder.cpp index 4548537..ddea0e0 100644 --- a/tests/tst_layoutbuilder.cpp +++ b/tests/tst_layoutbuilder.cpp @@ -1,6 +1,7 @@ #include "TestLayouts.h" #include +#include using namespace Qt::StringLiterals; @@ -90,6 +91,37 @@ private Q_SLOTS: QVERIFY(a.contains(u"á"_s)); } + void fullModeHasEveryTypingKey() + { + const Layout us = TestLayouts::build("us", KeyboardMode::Full); + const auto labels = [&](int row) { + QString text; + for (const KeyDef &key : us.layers.first().rows[row]) { + if (key.type == KeyType::Character) + text += key.label; + } + return text; + }; + QCOMPARE(labels(0), u"`1234567890-="); + QCOMPARE(labels(1), u"qwertyuiop[]\\"); + QCOMPARE(labels(2), u"asdfghjkl;'"); + QCOMPARE(labels(3), u"zxcvbnm,./"); // LSGT (< >) repeats , . shifted: left out + QCOMPARE(letterKey(us, u"1"_s).flick, u"!"); + QCOMPARE(letterKey(us, u"1"_s).shifted, u"!"); + } + + void fullModeKeepsDeadKeysAndKeymapShift() + { + const Layout sk = TestLayouts::build("sk", KeyboardMode::Full); + const KeyDef ´ = letterKey(sk, u"´"_s); + QCOMPARE(acute.dead, quint32(XKB_KEY_dead_acute)); + QCOMPARE(acute.deadShifted, quint32(XKB_KEY_dead_caron)); + QCOMPARE(letterKey(sk, u"ô"_s).shifted, u"\""); // as the physical key + QCOMPARE(letterKey(sk, u"ľ"_s).shifted, u"2"); + // ľ has its own key now, so it's not a long-press choice on l. + QVERIFY(!letterKey(sk, u"l"_s).alternates.contains(u"ľ"_s)); + } + void noPlaceholdersRemain() { for (const Layer &layer : TestLayouts::build("us").layers) { diff --git a/tests/tst_layoutloader.cpp b/tests/tst_layoutloader.cpp index ec0da1a..96670f7 100644 --- a/tests/tst_layoutloader.cpp +++ b/tests/tst_layoutloader.cpp @@ -72,14 +72,18 @@ private Q_SLOTS: void builtLayersHaveEvenRows_data() { QTest::addColumn("xkbLayout"); - for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"}) - QTest::newRow(layout) << QString::fromLatin1(layout); + QTest::addColumn("mode"); + for (const char *layout : {"us", "sk", "se", "de", "fr", "ru", "gr"}) { + QTest::addRow("%s compact", layout) << QString::fromLatin1(layout) << KeyboardMode::Compact; + QTest::addRow("%s full", layout) << QString::fromLatin1(layout) << KeyboardMode::Full; + } } void builtLayersHaveEvenRows() { QFETCH(QString, xkbLayout); - const Layout layout = TestLayouts::build(qPrintable(xkbLayout)); + QFETCH(KeyboardMode, mode); + const Layout layout = TestLayouts::build(qPrintable(xkbLayout), mode); for (const Layer &layer : layout.layers) { const QList rects = computeKeyRects(layer, {1000, 300}); qsizetype i = 0;