From 92c132c66f066f9da51ce8e95ecf419735630848 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 14 Oct 2026 14:59:21 +0200 Subject: [PATCH] Text rules: auto-capitalization, double-space period, backspace repeat - TextContext: field purpose/hints and text before the cursor, reported by the backend (KWin content_type + surrounding_text) - TextRules: sentence-start and double-space heuristics, deletion helpers - KeyRepeater: held backspace repeats, then deletes whole words (Ctrl+BS) - Controller keeps a best-guess of the text before the cursor so rules work instantly and in fields that don't report surrounding text - No auto-capitalization or smart punctuation in password/URL/email fields Co-Authored-By: Claude Opus 5.5 --- src/CMakeLists.txt | 3 ++ src/backend/KWinInputContext.cpp | 21 ++++++++---- src/backend/KWinInputContext.h | 13 +++---- src/backend/KWinInputMethod.cpp | 13 +++---- src/backend/KWinInputMethod.h | 3 +- src/backend/TextBackend.h | 11 +++--- src/backend/TextContext.h | 40 ++++++++++++++++++++++ src/engine/DirectEngine.cpp | 4 +-- src/engine/DirectEngine.h | 2 +- src/engine/InputEngine.h | 3 +- src/input/KeyRepeater.cpp | 31 +++++++++++++++++ src/input/KeyRepeater.h | 37 ++++++++++++++++++++ src/input/KeyboardController.cpp | 55 +++++++++++++++++++++++++++++- src/input/KeyboardController.h | 22 ++++++++++-- src/input/TextRules.cpp | 57 +++++++++++++++++++++++++++++++ src/input/TextRules.h | 22 ++++++++++++ src/main.cpp | 3 ++ tests/CMakeLists.txt | 2 ++ tests/tst_controller.cpp | 58 +++++++++++++++++++++++++++++++- tests/tst_keyboardsurface.cpp | 7 +++- tests/tst_keyrepeater.cpp | 31 +++++++++++++++++ tests/tst_textrules.cpp | 53 +++++++++++++++++++++++++++++ 22 files changed, 452 insertions(+), 39 deletions(-) create mode 100644 src/backend/TextContext.h create mode 100644 src/input/KeyRepeater.cpp create mode 100644 src/input/KeyRepeater.h create mode 100644 src/input/TextRules.cpp create mode 100644 src/input/TextRules.h create mode 100644 tests/tst_keyrepeater.cpp create mode 100644 tests/tst_textrules.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 819939f..36a0051 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,11 +2,14 @@ qt_add_library(sloposk_core STATIC backend/TextBackend.h + backend/TextContext.h engine/InputEngine.h engine/DirectEngine.h engine/DirectEngine.cpp input/ShiftState.h input/ShiftState.cpp input/PointerTracker.h input/PointerTracker.cpp input/AlternatesPicker.h input/AlternatesPicker.cpp + input/KeyRepeater.h input/KeyRepeater.cpp + input/TextRules.h input/TextRules.cpp input/KeyboardController.h input/KeyboardController.cpp input/KeyboardSurface.h input/KeyboardSurface.cpp layout/Layout.h diff --git a/src/backend/KWinInputContext.cpp b/src/backend/KWinInputContext.cpp index cad4683..2198dc8 100644 --- a/src/backend/KWinInputContext.cpp +++ b/src/backend/KWinInputContext.cpp @@ -67,9 +67,18 @@ void KWinInputContext::sendKeysym(quint32 sym, Qt::KeyboardModifiers modifiers) void KWinInputContext::zwp_input_method_context_v1_surrounding_text(const QString &text, uint32_t cursor, uint32_t) { - m_surroundingText = text; - m_cursorPosition = utf8OffsetToIndex(text, cursor); - Q_EMIT surroundingChanged(); + m_context.hasSurroundingText = true; + m_context.textBeforeCursor = text.left(utf8OffsetToIndex(text, cursor)); + Q_EMIT contextChanged(); +} + +void KWinInputContext::zwp_input_method_context_v1_content_type(uint32_t hint, uint32_t purpose) +{ + // input-method-v1 uses zwp_text_input_v1's values, which TextContext mirrors. + m_context.hints = hint; + m_context.purpose = purpose <= uint32_t(TextContext::Purpose::Terminal) ? TextContext::Purpose(purpose) + : TextContext::Purpose::Normal; + Q_EMIT contextChanged(); } void KWinInputContext::zwp_input_method_context_v1_commit_state(uint32_t serial) @@ -79,9 +88,9 @@ void KWinInputContext::zwp_input_method_context_v1_commit_state(uint32_t serial) void KWinInputContext::zwp_input_method_context_v1_reset() { - m_surroundingText.clear(); - m_cursorPosition = -1; - Q_EMIT surroundingChanged(); + m_context.hasSurroundingText = false; + m_context.textBeforeCursor.clear(); + Q_EMIT contextChanged(); } void KWinInputContext::announceModifiers() diff --git a/src/backend/KWinInputContext.h b/src/backend/KWinInputContext.h index 95f6ed6..77f2234 100644 --- a/src/backend/KWinInputContext.h +++ b/src/backend/KWinInputContext.h @@ -1,11 +1,13 @@ #pragma once #include "InputMethodProtocol.h" +#include "TextContext.h" #include // One input-method context: exists while a text field is focused. -// Owns the serial bookkeeping and the UTF-8 <-> QString offset conversion. +// Owns the serial bookkeeping and translates what the field reports +// (UTF-8 byte offsets, protocol enums) into a TextContext. class KWinInputContext : public QObject, public QtWayland::zwp_input_method_context_v1 { Q_OBJECT @@ -14,18 +16,18 @@ public: explicit KWinInputContext(::zwp_input_method_context_v1 *object, QObject *parent = nullptr); ~KWinInputContext() override; - QString surroundingText() const { return m_surroundingText; } - int cursorPosition() const { return m_cursorPosition; } + TextContext context() const { return m_context; } void commit(const QString &text); void preedit(const QString &text); void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers); Q_SIGNALS: - void surroundingChanged(); + void contextChanged(); protected: void zwp_input_method_context_v1_surrounding_text(const QString &text, uint32_t cursor, uint32_t anchor) override; + void zwp_input_method_context_v1_content_type(uint32_t hint, uint32_t purpose) override; void zwp_input_method_context_v1_commit_state(uint32_t serial) override; void zwp_input_method_context_v1_reset() override; @@ -33,6 +35,5 @@ private: void announceModifiers(); uint32_t m_serial = 0; - QString m_surroundingText; - int m_cursorPosition = -1; + TextContext m_context; }; diff --git a/src/backend/KWinInputMethod.cpp b/src/backend/KWinInputMethod.cpp index 0ca24e1..92cd9a6 100644 --- a/src/backend/KWinInputMethod.cpp +++ b/src/backend/KWinInputMethod.cpp @@ -37,14 +37,9 @@ bool KWinInputMethod::isActive() const return m_context != nullptr; } -QString KWinInputMethod::surroundingText() const +TextContext KWinInputMethod::context() const { - return m_context ? m_context->surroundingText() : QString(); -} - -int KWinInputMethod::cursorPosition() const -{ - return m_context ? m_context->cursorPosition() : -1; + return m_context ? m_context->context() : TextContext(); } void KWinInputMethod::commitText(const QString &text) @@ -68,7 +63,7 @@ void KWinInputMethod::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers void KWinInputMethod::activate(::zwp_input_method_context_v1 *context) { m_context = std::make_unique(context); - connect(m_context.get(), &KWinInputContext::surroundingChanged, this, &TextBackend::surroundingChanged); + connect(m_context.get(), &KWinInputContext::contextChanged, this, &TextBackend::contextChanged); Q_EMIT activeChanged(); } @@ -76,5 +71,5 @@ void KWinInputMethod::deactivate() { m_context.reset(); Q_EMIT activeChanged(); - Q_EMIT surroundingChanged(); + Q_EMIT contextChanged(); } diff --git a/src/backend/KWinInputMethod.h b/src/backend/KWinInputMethod.h index 9af8717..8d56414 100644 --- a/src/backend/KWinInputMethod.h +++ b/src/backend/KWinInputMethod.h @@ -40,8 +40,7 @@ public: bool isAvailable() const; bool isActive() const override; - QString surroundingText() const override; - int cursorPosition() const override; + TextContext context() const override; void commitText(const QString &text) override; void setPreedit(const QString &text) override; diff --git a/src/backend/TextBackend.h b/src/backend/TextBackend.h index aabf1d5..3e10d87 100644 --- a/src/backend/TextBackend.h +++ b/src/backend/TextBackend.h @@ -1,5 +1,7 @@ #pragma once +#include "TextContext.h" + #include #include @@ -10,8 +12,6 @@ class TextBackend : public QObject { Q_OBJECT Q_PROPERTY(bool active READ isActive NOTIFY activeChanged) - Q_PROPERTY(QString surroundingText READ surroundingText NOTIFY surroundingChanged) - Q_PROPERTY(int cursorPosition READ cursorPosition NOTIFY surroundingChanged) public: using QObject::QObject; @@ -19,9 +19,8 @@ public: // True while a text field is focused and accepts input. virtual bool isActive() const = 0; - // Text around the cursor, if the backend knows it (empty otherwise). - virtual QString surroundingText() const { return {}; } - virtual int cursorPosition() const { return -1; } + // What the focused field reported about itself (defaults if nothing). + virtual TextContext context() const { return {}; } Q_INVOKABLE virtual void commitText(const QString &text) = 0; Q_INVOKABLE virtual void setPreedit(const QString &text) = 0; @@ -29,5 +28,5 @@ public: Q_SIGNALS: void activeChanged(); - void surroundingChanged(); + void contextChanged(); }; diff --git a/src/backend/TextContext.h b/src/backend/TextContext.h new file mode 100644 index 0000000..a7d570b --- /dev/null +++ b/src/backend/TextContext.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +// What the focused text field tells us about itself. Values mirror +// zwp_text_input_v1's content purpose and hints; backends map onto these. +struct TextContext { + enum class Purpose { + Normal, Alpha, Digits, Number, Phone, Url, Email, Name, + Password, Date, Time, DateTime, Terminal, + }; + + enum Hint : quint32 { + AutoCompletion = 0x1, + AutoCorrection = 0x2, + AutoCapitalization = 0x4, + Lowercase = 0x8, + Uppercase = 0x10, + Titlecase = 0x20, + HiddenText = 0x40, + SensitiveData = 0x80, + Latin = 0x100, + Multiline = 0x200, + }; + + bool hasSurroundingText = false; + QString textBeforeCursor; + Purpose purpose = Purpose::Normal; + quint32 hints = 0; + + // Prose fields get iOS conveniences; codes, addresses and secrets don't. + bool isProse() const + { + const bool prosePurpose = purpose == Purpose::Normal || purpose == Purpose::Alpha + || purpose == Purpose::Name; + return prosePurpose && !(hints & (HiddenText | SensitiveData)); + } + + bool allowsAutoCapitalization() const { return isProse() && !(hints & Lowercase); } +}; diff --git a/src/engine/DirectEngine.cpp b/src/engine/DirectEngine.cpp index 1bd3f38..adb68c5 100644 --- a/src/engine/DirectEngine.cpp +++ b/src/engine/DirectEngine.cpp @@ -12,7 +12,7 @@ void DirectEngine::commitText(const QString &text) m_backend.commitText(text); } -void DirectEngine::sendKeysym(quint32 keysym) +void DirectEngine::sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) { - m_backend.sendKeysym(keysym); + m_backend.sendKeysym(keysym, modifiers); } diff --git a/src/engine/DirectEngine.h b/src/engine/DirectEngine.h index b6729f4..e08b5db 100644 --- a/src/engine/DirectEngine.h +++ b/src/engine/DirectEngine.h @@ -10,7 +10,7 @@ public: explicit DirectEngine(TextBackend &backend); void commitText(const QString &text) override; - void sendKeysym(quint32 keysym) override; + void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override; private: TextBackend &m_backend; diff --git a/src/engine/InputEngine.h b/src/engine/InputEngine.h index 23c5d38..57f62cb 100644 --- a/src/engine/InputEngine.h +++ b/src/engine/InputEngine.h @@ -1,6 +1,7 @@ #pragma once #include +#include // Turns what the keyboard produces into text operations. // DirectEngine passes them straight through; prediction and swipe engines @@ -11,5 +12,5 @@ public: virtual ~InputEngine() = default; virtual void commitText(const QString &text) = 0; - virtual void sendKeysym(quint32 keysym) = 0; + virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0; }; diff --git a/src/input/KeyRepeater.cpp b/src/input/KeyRepeater.cpp new file mode 100644 index 0000000..6f60cbb --- /dev/null +++ b/src/input/KeyRepeater.cpp @@ -0,0 +1,31 @@ +#include "KeyRepeater.h" + +KeyRepeater::KeyRepeater(QObject *parent) + : KeyRepeater(Timing{}, parent) +{ +} + +KeyRepeater::KeyRepeater(Timing timing, QObject *parent) + : QObject(parent) + , m_timing(timing) +{ + connect(&m_timer, &QTimer::timeout, this, &KeyRepeater::fire); +} + +void KeyRepeater::start() +{ + m_held.start(); + m_timer.start(m_timing.initialDelayMs); +} + +void KeyRepeater::stop() +{ + m_timer.stop(); +} + +void KeyRepeater::fire() +{ + const bool wordMode = m_held.elapsed() >= m_timing.wordModeAfterMs; + m_timer.setInterval(wordMode ? m_timing.wordIntervalMs : m_timing.intervalMs); + Q_EMIT repeat(wordMode); +} diff --git a/src/input/KeyRepeater.h b/src/input/KeyRepeater.h new file mode 100644 index 0000000..0fd4904 --- /dev/null +++ b/src/input/KeyRepeater.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +// Auto-repeat for a held key (backspace). After holding long enough it +// switches to word mode, like iOS deleting whole words. +class KeyRepeater : public QObject +{ + Q_OBJECT + +public: + struct Timing { + int initialDelayMs = 500; + int intervalMs = 90; + int wordModeAfterMs = 2000; // counted from start() + int wordIntervalMs = 250; + }; + + explicit KeyRepeater(QObject *parent = nullptr); + KeyRepeater(Timing timing, QObject *parent = nullptr); + + void start(); + void stop(); + bool isRunning() const { return m_timer.isActive(); } + +Q_SIGNALS: + void repeat(bool wordMode); + +private: + void fire(); + + Timing m_timing; + QTimer m_timer; + QElapsedTimer m_held; +}; diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp index 8d15ea8..ef6996e 100644 --- a/src/input/KeyboardController.cpp +++ b/src/input/KeyboardController.cpp @@ -1,4 +1,5 @@ #include "KeyboardController.h" +#include "TextRules.h" #include "engine/InputEngine.h" #include "layout/LayoutModel.h" @@ -11,6 +12,11 @@ static_assert(int(KeyboardController::ShiftMode::Off) == int(ShiftState::Mode::O static_assert(int(KeyboardController::ShiftMode::Once) == int(ShiftState::Mode::Once)); static_assert(int(KeyboardController::ShiftMode::Locked) == int(ShiftState::Mode::Locked)); +namespace { +// Enough history for the text rules; fields may report megabytes. +constexpr qsizetype MaxTextBefore = 256; +} + KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model, const LayoutRepository &layouts, QObject *parent) : QObject(parent) @@ -20,6 +26,7 @@ KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model, { if (!m_layouts.layouts().isEmpty()) m_model.setLayout(m_layouts.layouts().first()); + connect(&m_backspaceRepeater, &KeyRepeater::repeat, this, &KeyboardController::deleteBackward); } KeyboardController::ShiftMode KeyboardController::shiftMode() const @@ -34,7 +41,8 @@ void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs) changeShift([&] { m_shift.press(timeMs); }); break; case KeyType::Backspace: - m_engine.sendKeysym(XKB_KEY_BackSpace); + deleteBackward(false); + m_backspaceRepeater.start(); break; case KeyType::LayerSwitch: m_layerBeforeSwitch = m_model.layerName(); @@ -51,6 +59,9 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin case KeyType::Shift: changeShift([&] { m_shift.release(); }); break; + case KeyType::Backspace: + m_backspaceRepeater.stop(); + break; case KeyType::Character: if (output == KeyOutput::Alternate && !alternate.isEmpty()) typeText(alternate); @@ -64,6 +75,8 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin break; case KeyType::Enter: m_engine.sendKeysym(XKB_KEY_Return); + m_textBefore += u'\n'; + autoCapitalize(); break; case KeyType::Globe: nextLayout(); @@ -80,6 +93,8 @@ void KeyboardController::keyCancelled(const KeyDef &key) { if (key.type == KeyType::Shift) changeShift([&] { m_shift.release(); }); + else if (key.type == KeyType::Backspace) + m_backspaceRepeater.stop(); } void KeyboardController::slideReleased(const KeyDef &target, qint64 timeMs) @@ -100,6 +115,21 @@ void KeyboardController::moveCursor(QPoint steps) repeat(qMax(0, -steps.y()), XKB_KEY_Up); } +void KeyboardController::resetContext() +{ + m_context = {}; + m_textBefore.clear(); + autoCapitalize(); +} + +void KeyboardController::setContext(const TextContext &context) +{ + m_context = context; + if (context.hasSurroundingText) + m_textBefore = context.textBeforeCursor.right(MaxTextBefore); + autoCapitalize(); +} + void KeyboardController::nextLayout() { const auto &layouts = m_layouts.layouts(); @@ -111,8 +141,31 @@ void KeyboardController::nextLayout() void KeyboardController::typeText(const QString &text) { + if (text == u" " && m_context.isProse() && TextRules::wantsDoubleSpacePeriod(m_textBefore)) { + m_engine.sendKeysym(XKB_KEY_BackSpace); + m_textBefore.chop(1); + typeText(QStringLiteral(". ")); + return; + } + m_engine.commitText(text); + m_textBefore = (m_textBefore + text).right(MaxTextBefore); changeShift([&] { m_shift.characterTyped(); }); + autoCapitalize(); +} + +void KeyboardController::deleteBackward(bool wholeWord) +{ + m_engine.sendKeysym(XKB_KEY_BackSpace, wholeWord ? Qt::ControlModifier : Qt::NoModifier); + m_textBefore = wholeWord ? TextRules::withoutLastWord(m_textBefore) + : TextRules::withoutLastCharacter(m_textBefore); + autoCapitalize(); +} + +void KeyboardController::autoCapitalize() +{ + const bool capitalize = m_context.allowsAutoCapitalization() && TextRules::startsSentence(m_textBefore); + changeShift([&] { m_shift.setAutomatic(capitalize); }); } template diff --git a/src/input/KeyboardController.h b/src/input/KeyboardController.h index dd2dd6b..dfaebe4 100644 --- a/src/input/KeyboardController.h +++ b/src/input/KeyboardController.h @@ -1,6 +1,8 @@ #pragma once +#include "KeyRepeater.h" #include "ShiftState.h" +#include "backend/TextContext.h" #include "layout/Layout.h" #include @@ -14,9 +16,10 @@ class LayoutRepository; // Which of a key's outputs the gesture selected. enum class KeyOutput { Primary, Flick, Alternate }; -// Keyboard rules: what each key does, shift, layers and layout switching. -// Knows nothing about touch or Wayland: it receives key presses and talks to -// an InputEngine. +// Keyboard rules: what each key does, shift, layers, layout switching 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. class KeyboardController : public QObject { Q_OBJECT @@ -41,6 +44,11 @@ public: // Space-bar trackpad: move the text cursor by whole steps. void moveCursor(QPoint steps); + // A field was focused or unfocused: forget what we knew about the text. + void resetContext(); + // The focused field reported new text/content type. + void setContext(const TextContext &context); + Q_INVOKABLE void nextLayout(); Q_SIGNALS: @@ -49,6 +57,8 @@ Q_SIGNALS: private: void typeText(const QString &text); + void deleteBackward(bool wholeWord); + void autoCapitalize(); template void changeShift(Change change); @@ -56,6 +66,12 @@ private: LayoutModel &m_model; const LayoutRepository &m_layouts; ShiftState m_shift; + KeyRepeater m_backspaceRepeater; int m_layoutIndex = 0; QString m_layerBeforeSwitch; + + TextContext m_context; + // Best guess of the text before the cursor: the field's last report plus + // what we typed since. Fields that don't report text rely on the latter. + QString m_textBefore; }; diff --git a/src/input/TextRules.cpp b/src/input/TextRules.cpp new file mode 100644 index 0000000..ae6513e --- /dev/null +++ b/src/input/TextRules.cpp @@ -0,0 +1,57 @@ +#include "TextRules.h" + +namespace TextRules { + +namespace { + +bool endsSentence(QChar c) +{ + return c == u'.' || c == u'!' || c == u'?' || c == u'…'; +} + +qsizetype trailingSpaceStart(const QString &text) +{ + qsizetype i = text.size(); + while (i > 0 && text[i - 1].isSpace() && text[i - 1] != u'\n') + --i; + return i; +} + +} // namespace + +bool startsSentence(const QString &textBeforeCursor) +{ + if (textBeforeCursor.isEmpty() || textBeforeCursor.endsWith(u'\n')) + return true; + if (!textBeforeCursor.back().isSpace()) + return false; + + const qsizetype end = trailingSpaceStart(textBeforeCursor); + return end == 0 || textBeforeCursor[end - 1] == u'\n' || endsSentence(textBeforeCursor[end - 1]); +} + +bool wantsDoubleSpacePeriod(const QString &textBeforeCursor) +{ + const qsizetype n = textBeforeCursor.size(); + return n >= 2 && textBeforeCursor[n - 1] == u' ' && textBeforeCursor[n - 2].isLetterOrNumber(); +} + +QString withoutLastCharacter(const QString &text) +{ + if (text.isEmpty()) + return text; + const bool pair = text.size() >= 2 && text.back().isLowSurrogate() && text[text.size() - 2].isHighSurrogate(); + return text.chopped(pair ? 2 : 1); +} + +QString withoutLastWord(const QString &text) +{ + qsizetype i = text.size(); + while (i > 0 && text[i - 1].isSpace()) + --i; + while (i > 0 && !text[i - 1].isSpace()) + --i; + return text.left(i); +} + +} // namespace TextRules diff --git a/src/input/TextRules.h b/src/input/TextRules.h new file mode 100644 index 0000000..19f57e7 --- /dev/null +++ b/src/input/TextRules.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +// Pure text heuristics behind the iOS conveniences. +namespace TextRules { + +// True at the start of a sentence: empty text, after a newline, or after +// sentence-ending punctuation followed by whitespace. +bool startsSentence(const QString &textBeforeCursor); + +// True if typing another space should turn "word " into "word. ". +bool wantsDoubleSpacePeriod(const QString &textBeforeCursor); + +// `text` without its last character (surrogate pairs count as one). +QString withoutLastCharacter(const QString &text); + +// `text` without its trailing word and the whitespace after it, +// matching what Ctrl+Backspace does in most editors. +QString withoutLastWord(const QString &text); + +} // namespace TextRules diff --git a/src/main.cpp b/src/main.cpp index fbb51a1..ed77a91 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -72,6 +72,9 @@ int main(int argc, char *argv[]) QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility); QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility); QObject::connect(&controller, &KeyboardController::dismissRequested, &placement, &FloatingWindow::hide); + QObject::connect(&backend, &TextBackend::activeChanged, &controller, &KeyboardController::resetContext); + QObject::connect(&backend, &TextBackend::contextChanged, &controller, + [&] { controller.setContext(backend.context()); }); updateVisibility(); installDevHooks(backend, *window); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 04b413d..7170fe3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,8 @@ sloposk_add_test(tst_keygeometry) sloposk_add_test(tst_controller) sloposk_add_test(tst_pointertracker) sloposk_add_test(tst_alternatespicker) +sloposk_add_test(tst_textrules) +sloposk_add_test(tst_keyrepeater) # Needs the QML module itself, not just the C++ backing library. sloposk_add_test(tst_keyboardsurface) diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp index 8d1c216..ca40fa5 100644 --- a/tests/tst_controller.cpp +++ b/tests/tst_controller.cpp @@ -14,10 +14,15 @@ class FakeEngine : public InputEngine { public: void commitText(const QString &text) override { output += text; } - void sendKeysym(quint32 keysym) override { keysyms.append(keysym); } + 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 = {}) @@ -105,6 +110,57 @@ private Q_SLOTS: QCOMPARE(model.layerName(), u"numbers"); } + void backspaceStopsOnRelease() + { + const KeyDef backspace = makeKey(KeyType::Backspace); + controller->keyDown(backspace, 0); + controller->keyUp(backspace, KeyOutput::Primary, {}, 50); + QTest::qWait(700); // past the repeat delay + QCOMPARE(engine.keysyms.size(), 1); + } + + void capitalizesAtSentenceStart() + { + controller->resetContext(); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); + for (const char *c : {"h", "i", ".", " "}) + tap(makeKey(KeyType::Character, QString::fromLatin1(c))); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); + tap(makeKey(KeyType::Character, u"o"_s)); + QCOMPARE(engine.output, u"Hi. O"); + } + + void noCapitalizationInPasswordFields() + { + TextContext context; + context.purpose = TextContext::Purpose::Password; + controller->setContext(context); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); + } + + void fieldTextDrivesCapitalization() + { + TextContext context; + context.hasSurroundingText = true; + context.textBeforeCursor = u"Done! "_s; + controller->setContext(context); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); + context.textBeforeCursor = u"Done, "_s; + controller->setContext(context); + QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); + } + + void doubleSpaceTypesPeriod() + { + const KeyDef space = makeKey(KeyType::Space); + 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(controller->shiftMode(), KeyboardController::ShiftMode::Once); + } + void slideTypesSymbolAndSwitchesBack() { KeyDef layer = makeKey(KeyType::LayerSwitch); diff --git a/tests/tst_keyboardsurface.cpp b/tests/tst_keyboardsurface.cpp index 57656fb..011e5fe 100644 --- a/tests/tst_keyboardsurface.cpp +++ b/tests/tst_keyboardsurface.cpp @@ -25,10 +25,15 @@ class FakeEngine : public InputEngine { public: void commitText(const QString &text) override { output += text; } - void sendKeysym(quint32 keysym) override { keysyms.append(keysym); } + 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 diff --git a/tests/tst_keyrepeater.cpp b/tests/tst_keyrepeater.cpp new file mode 100644 index 0000000..1a85a1c --- /dev/null +++ b/tests/tst_keyrepeater.cpp @@ -0,0 +1,31 @@ +#include "input/KeyRepeater.h" + +#include +#include + +class TestKeyRepeater : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void repeatsThenSwitchesToWords() + { + KeyRepeater repeater({.initialDelayMs = 50, .intervalMs = 10, .wordModeAfterMs = 150, .wordIntervalMs = 20}); + QSignalSpy spy(&repeater, &KeyRepeater::repeat); + repeater.start(); + + QTest::qWait(40); + QCOMPARE(spy.count(), 0); // still in the initial delay + QTRY_VERIFY(spy.count() >= 3); + QCOMPARE(spy.first().first().toBool(), false); + QTRY_VERIFY(spy.last().first().toBool()); // word mode eventually + + repeater.stop(); + const auto count = spy.count(); + QTest::qWait(60); + QCOMPARE(spy.count(), count); + } +}; + +QTEST_GUILESS_MAIN(TestKeyRepeater) +#include "tst_keyrepeater.moc" diff --git a/tests/tst_textrules.cpp b/tests/tst_textrules.cpp new file mode 100644 index 0000000..53caaea --- /dev/null +++ b/tests/tst_textrules.cpp @@ -0,0 +1,53 @@ +#include "input/TextRules.h" + +#include + +using namespace Qt::StringLiterals; + +class TestTextRules : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void startsSentence_data() + { + QTest::addColumn("text"); + QTest::addColumn("expected"); + QTest::newRow("empty") << QString() << true; + QTest::newRow("after period and space") << u"Hi. "_s << true; + QTest::newRow("after question, two spaces") << u"Why? "_s << true; + QTest::newRow("after newline") << u"Hi\n"_s << true; + QTest::newRow("only spaces") << u" "_s << true; + QTest::newRow("period without space") << u"Hi."_s << false; + QTest::newRow("mid sentence") << u"Hi there "_s << false; + QTest::newRow("after comma") << u"Hi, "_s << false; + QTest::newRow("mid word") << u"Hel"_s << false; + } + + void startsSentence() + { + QFETCH(QString, text); + QFETCH(bool, expected); + QCOMPARE(TextRules::startsSentence(text), expected); + } + + void doubleSpacePeriod() + { + QVERIFY(TextRules::wantsDoubleSpacePeriod(u"word "_s)); + QVERIFY(TextRules::wantsDoubleSpacePeriod(u"42 "_s)); + QVERIFY(!TextRules::wantsDoubleSpacePeriod(u"word. "_s)); + QVERIFY(!TextRules::wantsDoubleSpacePeriod(u"word "_s)); + QVERIFY(!TextRules::wantsDoubleSpacePeriod(u" "_s)); + } + + void deletion() + { + QCOMPARE(TextRules::withoutLastCharacter(u"ab"_s), u"a"); + QCOMPARE(TextRules::withoutLastCharacter(u"a👍"_s), u"a"); + QCOMPARE(TextRules::withoutLastWord(u"one two "_s), u"one "); + QCOMPARE(TextRules::withoutLastWord(u"one"_s), QString()); + } +}; + +QTEST_GUILESS_MAIN(TestTextRules) +#include "tst_textrules.moc"