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
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
+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)
{
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()
+7 -6
View File
@@ -1,11 +1,13 @@
#pragma once
#include "InputMethodProtocol.h"
#include "TextContext.h"
#include <QObject>
// 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;
};
+4 -9
View File
@@ -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<KWinInputContext>(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();
}
+1 -2
View File
@@ -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;
+5 -6
View File
@@ -1,5 +1,7 @@
#pragma once
#include "TextContext.h"
#include <QObject>
#include <QString>
@@ -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();
};
+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);
}
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);
void commitText(const QString &text) override;
void sendKeysym(quint32 keysym) override;
void sendKeysym(quint32 keysym, Qt::KeyboardModifiers modifiers) override;
private:
TextBackend &m_backend;
+2 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include <QString>
#include <qnamespace.h>
// 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;
};
+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 "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<typename Change>
+19 -3
View File
@@ -1,6 +1,8 @@
#pragma once
#include "KeyRepeater.h"
#include "ShiftState.h"
#include "backend/TextContext.h"
#include "layout/Layout.h"
#include <QObject>
@@ -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<typename Change>
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;
};
+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, &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);