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 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:59:21 +02:00
co-authored by Claude Opus 5.5
parent 2b5edeb365
commit 92c132c66f
22 changed files with 452 additions and 39 deletions
+3
View File
@@ -2,11 +2,14 @@
qt_add_library(sloposk_core STATIC qt_add_library(sloposk_core STATIC
backend/TextBackend.h backend/TextBackend.h
backend/TextContext.h
engine/InputEngine.h engine/InputEngine.h
engine/DirectEngine.h engine/DirectEngine.cpp engine/DirectEngine.h engine/DirectEngine.cpp
input/ShiftState.h input/ShiftState.cpp input/ShiftState.h input/ShiftState.cpp
input/PointerTracker.h input/PointerTracker.cpp input/PointerTracker.h input/PointerTracker.cpp
input/AlternatesPicker.h input/AlternatesPicker.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/KeyboardController.h input/KeyboardController.cpp
input/KeyboardSurface.h input/KeyboardSurface.cpp input/KeyboardSurface.h input/KeyboardSurface.cpp
layout/Layout.h layout/Layout.h
+15 -6
View File
@@ -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) void KWinInputContext::zwp_input_method_context_v1_surrounding_text(const QString &text, uint32_t cursor, uint32_t)
{ {
m_surroundingText = text; m_context.hasSurroundingText = true;
m_cursorPosition = utf8OffsetToIndex(text, cursor); m_context.textBeforeCursor = text.left(utf8OffsetToIndex(text, cursor));
Q_EMIT surroundingChanged(); 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) 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() void KWinInputContext::zwp_input_method_context_v1_reset()
{ {
m_surroundingText.clear(); m_context.hasSurroundingText = false;
m_cursorPosition = -1; m_context.textBeforeCursor.clear();
Q_EMIT surroundingChanged(); Q_EMIT contextChanged();
} }
void KWinInputContext::announceModifiers() void KWinInputContext::announceModifiers()
+7 -6
View File
@@ -1,11 +1,13 @@
#pragma once #pragma once
#include "InputMethodProtocol.h" #include "InputMethodProtocol.h"
#include "TextContext.h"
#include <QObject> #include <QObject>
// One input-method context: exists while a text field is focused. // 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 class KWinInputContext : public QObject, public QtWayland::zwp_input_method_context_v1
{ {
Q_OBJECT Q_OBJECT
@@ -14,18 +16,18 @@ public:
explicit KWinInputContext(::zwp_input_method_context_v1 *object, QObject *parent = nullptr); explicit KWinInputContext(::zwp_input_method_context_v1 *object, QObject *parent = nullptr);
~KWinInputContext() override; ~KWinInputContext() override;
QString surroundingText() const { return m_surroundingText; } TextContext context() const { return m_context; }
int cursorPosition() const { return m_cursorPosition; }
void commit(const QString &text); void commit(const QString &text);
void preedit(const QString &text); void preedit(const QString &text);
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers); void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers);
Q_SIGNALS: Q_SIGNALS:
void surroundingChanged(); void contextChanged();
protected: 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_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_commit_state(uint32_t serial) override;
void zwp_input_method_context_v1_reset() override; void zwp_input_method_context_v1_reset() override;
@@ -33,6 +35,5 @@ private:
void announceModifiers(); void announceModifiers();
uint32_t m_serial = 0; uint32_t m_serial = 0;
QString m_surroundingText; TextContext m_context;
int m_cursorPosition = -1;
}; };
+4 -9
View File
@@ -37,14 +37,9 @@ bool KWinInputMethod::isActive() const
return m_context != nullptr; return m_context != nullptr;
} }
QString KWinInputMethod::surroundingText() const TextContext KWinInputMethod::context() const
{ {
return m_context ? m_context->surroundingText() : QString(); return m_context ? m_context->context() : TextContext();
}
int KWinInputMethod::cursorPosition() const
{
return m_context ? m_context->cursorPosition() : -1;
} }
void KWinInputMethod::commitText(const QString &text) 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) void KWinInputMethod::activate(::zwp_input_method_context_v1 *context)
{ {
m_context = std::make_unique<KWinInputContext>(context); m_context = std::make_unique<KWinInputContext>(context);
connect(m_context.get(), &KWinInputContext::surroundingChanged, this, &TextBackend::surroundingChanged); connect(m_context.get(), &KWinInputContext::contextChanged, this, &TextBackend::contextChanged);
Q_EMIT activeChanged(); Q_EMIT activeChanged();
} }
@@ -76,5 +71,5 @@ void KWinInputMethod::deactivate()
{ {
m_context.reset(); m_context.reset();
Q_EMIT activeChanged(); Q_EMIT activeChanged();
Q_EMIT surroundingChanged(); Q_EMIT contextChanged();
} }
+1 -2
View File
@@ -40,8 +40,7 @@ public:
bool isAvailable() const; bool isAvailable() const;
bool isActive() const override; bool isActive() const override;
QString surroundingText() const override; TextContext context() const override;
int cursorPosition() const override;
void commitText(const QString &text) override; void commitText(const QString &text) override;
void setPreedit(const QString &text) override; void setPreedit(const QString &text) override;
+5 -6
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "TextContext.h"
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@@ -10,8 +12,6 @@ class TextBackend : public QObject
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(bool active READ isActive NOTIFY activeChanged) 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: public:
using QObject::QObject; using QObject::QObject;
@@ -19,9 +19,8 @@ public:
// True while a text field is focused and accepts input. // True while a text field is focused and accepts input.
virtual bool isActive() const = 0; virtual bool isActive() const = 0;
// Text around the cursor, if the backend knows it (empty otherwise). // What the focused field reported about itself (defaults if nothing).
virtual QString surroundingText() const { return {}; } virtual TextContext context() const { return {}; }
virtual int cursorPosition() const { return -1; }
Q_INVOKABLE virtual void commitText(const QString &text) = 0; Q_INVOKABLE virtual void commitText(const QString &text) = 0;
Q_INVOKABLE virtual void setPreedit(const QString &text) = 0; Q_INVOKABLE virtual void setPreedit(const QString &text) = 0;
@@ -29,5 +28,5 @@ public:
Q_SIGNALS: Q_SIGNALS:
void activeChanged(); void activeChanged();
void surroundingChanged(); void contextChanged();
}; };
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QString>
// 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); }
};
+2 -2
View File
@@ -12,7 +12,7 @@ void DirectEngine::commitText(const QString &text)
m_backend.commitText(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);
} }
+1 -1
View File
@@ -10,7 +10,7 @@ public:
explicit DirectEngine(TextBackend &backend); explicit DirectEngine(TextBackend &backend);
void commitText(const QString &text) override; void commitText(const QString &text) override;
void sendKeysym(quint32 keysym) override; void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
private: private:
TextBackend &m_backend; TextBackend &m_backend;
+2 -1
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <qnamespace.h>
// Turns what the keyboard produces into text operations. // Turns what the keyboard produces into text operations.
// DirectEngine passes them straight through; prediction and swipe engines // DirectEngine passes them straight through; prediction and swipe engines
@@ -11,5 +12,5 @@ public:
virtual ~InputEngine() = default; virtual ~InputEngine() = default;
virtual void commitText(const QString &text) = 0; virtual void commitText(const QString &text) = 0;
virtual void sendKeysym(quint32 keysym) = 0; virtual void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers = Qt::NoModifier) = 0;
}; };
+31
View File
@@ -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);
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <QElapsedTimer>
#include <QObject>
#include <QTimer>
// 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;
};
+54 -1
View File
@@ -1,4 +1,5 @@
#include "KeyboardController.h" #include "KeyboardController.h"
#include "TextRules.h"
#include "engine/InputEngine.h" #include "engine/InputEngine.h"
#include "layout/LayoutModel.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::Once) == int(ShiftState::Mode::Once));
static_assert(int(KeyboardController::ShiftMode::Locked) == int(ShiftState::Mode::Locked)); 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, KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
const LayoutRepository &layouts, QObject *parent) const LayoutRepository &layouts, QObject *parent)
: QObject(parent) : QObject(parent)
@@ -20,6 +26,7 @@ KeyboardController::KeyboardController(InputEngine &engine, LayoutModel &model,
{ {
if (!m_layouts.layouts().isEmpty()) if (!m_layouts.layouts().isEmpty())
m_model.setLayout(m_layouts.layouts().first()); m_model.setLayout(m_layouts.layouts().first());
connect(&m_backspaceRepeater, &KeyRepeater::repeat, this, &KeyboardController::deleteBackward);
} }
KeyboardController::ShiftMode KeyboardController::shiftMode() const KeyboardController::ShiftMode KeyboardController::shiftMode() const
@@ -34,7 +41,8 @@ void KeyboardController::keyDown(const KeyDef &key, qint64 timeMs)
changeShift([&] { m_shift.press(timeMs); }); changeShift([&] { m_shift.press(timeMs); });
break; break;
case KeyType::Backspace: case KeyType::Backspace:
m_engine.sendKeysym(XKB_KEY_BackSpace); deleteBackward(false);
m_backspaceRepeater.start();
break; break;
case KeyType::LayerSwitch: case KeyType::LayerSwitch:
m_layerBeforeSwitch = m_model.layerName(); m_layerBeforeSwitch = m_model.layerName();
@@ -51,6 +59,9 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin
case KeyType::Shift: case KeyType::Shift:
changeShift([&] { m_shift.release(); }); changeShift([&] { m_shift.release(); });
break; break;
case KeyType::Backspace:
m_backspaceRepeater.stop();
break;
case KeyType::Character: case KeyType::Character:
if (output == KeyOutput::Alternate && !alternate.isEmpty()) if (output == KeyOutput::Alternate && !alternate.isEmpty())
typeText(alternate); typeText(alternate);
@@ -64,6 +75,8 @@ void KeyboardController::keyUp(const KeyDef &key, KeyOutput output, const QStrin
break; break;
case KeyType::Enter: case KeyType::Enter:
m_engine.sendKeysym(XKB_KEY_Return); m_engine.sendKeysym(XKB_KEY_Return);
m_textBefore += u'\n';
autoCapitalize();
break; break;
case KeyType::Globe: case KeyType::Globe:
nextLayout(); nextLayout();
@@ -80,6 +93,8 @@ void KeyboardController::keyCancelled(const KeyDef &key)
{ {
if (key.type == KeyType::Shift) if (key.type == KeyType::Shift)
changeShift([&] { m_shift.release(); }); changeShift([&] { m_shift.release(); });
else if (key.type == KeyType::Backspace)
m_backspaceRepeater.stop();
} }
void KeyboardController::slideReleased(const KeyDef &target, qint64 timeMs) 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); 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() void KeyboardController::nextLayout()
{ {
const auto &layouts = m_layouts.layouts(); const auto &layouts = m_layouts.layouts();
@@ -111,8 +141,31 @@ void KeyboardController::nextLayout()
void KeyboardController::typeText(const QString &text) 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_engine.commitText(text);
m_textBefore = (m_textBefore + text).right(MaxTextBefore);
changeShift([&] { m_shift.characterTyped(); }); 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<typename Change> template<typename Change>
+19 -3
View File
@@ -1,6 +1,8 @@
#pragma once #pragma once
#include "KeyRepeater.h"
#include "ShiftState.h" #include "ShiftState.h"
#include "backend/TextContext.h"
#include "layout/Layout.h" #include "layout/Layout.h"
#include <QObject> #include <QObject>
@@ -14,9 +16,10 @@ class LayoutRepository;
// Which of a key's outputs the gesture selected. // Which of a key's outputs the gesture selected.
enum class KeyOutput { Primary, Flick, Alternate }; enum class KeyOutput { Primary, Flick, Alternate };
// Keyboard rules: what each key does, shift, layers and layout switching. // Keyboard rules: what each key does, shift, layers, layout switching and the
// Knows nothing about touch or Wayland: it receives key presses and talks to // iOS conveniences (auto-capitalization, double-space period, backspace
// an InputEngine. // repeat). Knows nothing about touch or Wayland: it receives key presses and
// text-field context, and talks to an InputEngine.
class KeyboardController : public QObject class KeyboardController : public QObject
{ {
Q_OBJECT Q_OBJECT
@@ -41,6 +44,11 @@ public:
// Space-bar trackpad: move the text cursor by whole steps. // Space-bar trackpad: move the text cursor by whole steps.
void moveCursor(QPoint 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_INVOKABLE void nextLayout();
Q_SIGNALS: Q_SIGNALS:
@@ -49,6 +57,8 @@ Q_SIGNALS:
private: private:
void typeText(const QString &text); void typeText(const QString &text);
void deleteBackward(bool wholeWord);
void autoCapitalize();
template<typename Change> template<typename Change>
void changeShift(Change change); void changeShift(Change change);
@@ -56,6 +66,12 @@ private:
LayoutModel &m_model; LayoutModel &m_model;
const LayoutRepository &m_layouts; const LayoutRepository &m_layouts;
ShiftState m_shift; ShiftState m_shift;
KeyRepeater m_backspaceRepeater;
int m_layoutIndex = 0; int m_layoutIndex = 0;
QString m_layerBeforeSwitch; 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;
}; };
+57
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <QString>
// 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
+3
View File
@@ -72,6 +72,9 @@ int main(int argc, char *argv[])
QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility); QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility);
QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility); QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility);
QObject::connect(&controller, &KeyboardController::dismissRequested, &placement, &FloatingWindow::hide); 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(); updateVisibility();
installDevHooks(backend, *window); installDevHooks(backend, *window);
+2
View File
@@ -11,6 +11,8 @@ sloposk_add_test(tst_keygeometry)
sloposk_add_test(tst_controller) sloposk_add_test(tst_controller)
sloposk_add_test(tst_pointertracker) sloposk_add_test(tst_pointertracker)
sloposk_add_test(tst_alternatespicker) 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. # Needs the QML module itself, not just the C++ backing library.
sloposk_add_test(tst_keyboardsurface) sloposk_add_test(tst_keyboardsurface)
+57 -1
View File
@@ -14,10 +14,15 @@ class FakeEngine : public InputEngine
{ {
public: public:
void commitText(const QString &text) override { output += text; } 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; QString output;
QList<quint32> keysyms; QList<quint32> keysyms;
Qt::KeyboardModifiers lastModifiers;
}; };
KeyDef makeKey(KeyType type, const QString &label = {}) KeyDef makeKey(KeyType type, const QString &label = {})
@@ -105,6 +110,57 @@ private Q_SLOTS:
QCOMPARE(model.layerName(), u"numbers"); 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<quint32>{XKB_KEY_BackSpace}); // removed the first space
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
}
void slideTypesSymbolAndSwitchesBack() void slideTypesSymbolAndSwitchesBack()
{ {
KeyDef layer = makeKey(KeyType::LayerSwitch); KeyDef layer = makeKey(KeyType::LayerSwitch);
+6 -1
View File
@@ -25,10 +25,15 @@ class FakeEngine : public InputEngine
{ {
public: public:
void commitText(const QString &text) override { output += text; } 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; QString output;
QList<quint32> keysyms; QList<quint32> keysyms;
Qt::KeyboardModifiers lastModifiers;
}; };
constexpr QPoint KeyboardOffset(0, 100); // room above for popups constexpr QPoint KeyboardOffset(0, 100); // room above for popups
+31
View File
@@ -0,0 +1,31 @@
#include "input/KeyRepeater.h"
#include <QSignalSpy>
#include <QTest>
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"
+53
View File
@@ -0,0 +1,53 @@
#include "input/TextRules.h"
#include <QTest>
using namespace Qt::StringLiterals;
class TestTextRules : public QObject
{
Q_OBJECT
private Q_SLOTS:
void startsSentence_data()
{
QTest::addColumn<QString>("text");
QTest::addColumn<bool>("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"