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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<quint32> 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<quint32>{XKB_KEY_BackSpace}); // removed the first space
|
||||
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
|
||||
}
|
||||
|
||||
void slideTypesSymbolAndSwitchesBack()
|
||||
{
|
||||
KeyDef layer = makeKey(KeyType::LayerSwitch);
|
||||
|
||||
@@ -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<quint32> keysyms;
|
||||
Qt::KeyboardModifiers lastModifiers;
|
||||
};
|
||||
|
||||
constexpr QPoint KeyboardOffset(0, 100); // room above for popups
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user