Keep the user's shift; trust the text guess less

- A tapped shift (on or off) survives the field re-reporting its context:
  it lasts until a character is typed, the hint changes or a field is focused
- An input-method reset forgets the guessed text and pending dead key, so
  double-space can't delete a character the keyboard never saw
- Fields that state typing preferences only get capitals if they ask
- Trackpad moves in fields without surrounding text make the guess unknown;
  Backspace removes a whole grapheme cluster from it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 10:13:27 +02:00
co-authored by Claude Opus 5.5
parent 076bc14e2f
commit d44aab7772
9 changed files with 130 additions and 10 deletions
+9 -1
View File
@@ -37,5 +37,13 @@ struct TextContext {
return field && prosePurpose && !(hints & (HiddenText | SensitiveData));
}
bool allowsAutoCapitalization() const { return isProse() && !(hints & Lowercase); }
// Fields that state any text preference must ask for capitals; most that
// state none (hints 0) still want them.
bool allowsAutoCapitalization() const
{
constexpr quint32 Preferences = AutoCompletion | AutoCorrection | AutoCapitalization | Lowercase
| Uppercase | Titlecase;
const bool asked = (hints & AutoCapitalization) || !(hints & Preferences);
return isProse() && asked && !(hints & Lowercase);
}
};
+15 -2
View File
@@ -14,6 +14,9 @@ static_assert(int(KeyboardController::ShiftMode::Locked) == int(ShiftState::Mode
namespace {
// Enough history for the text rules; fields may report megabytes.
constexpr qsizetype MaxTextBefore = 256;
// Stands for text we can't see: neither a sentence start nor a word, so no
// rule fires on it. For fields that don't report text, after cursor moves.
constexpr QChar UnknownText = u'\uFFFC';
// Like a hardware key: no word mode, a faster rate than Backspace.
constexpr KeyRepeater::Timing KeysymRepeat{.intervalMs = 50, .wordModeAfterMs = std::numeric_limits<int>::max()};
}
@@ -168,20 +171,30 @@ void KeyboardController::moveCursor(QPoint steps)
repeat(qMax(0, -steps.x()), XKB_KEY_Left);
repeat(qMax(0, steps.y()), XKB_KEY_Down);
repeat(qMax(0, -steps.y()), XKB_KEY_Up);
if (!steps.isNull() && !m_context.hasSurroundingText) {
m_textBefore = UnknownText;
autoCapitalize();
}
}
void KeyboardController::resetContext(const TextContext &context)
{
m_deadKeys.cancel();
m_textBefore.clear();
m_shift.forgetUserChoice();
setContext(context);
}
void KeyboardController::setContext(const TextContext &context)
{
m_context = context;
if (context.hasSurroundingText)
if (context.hasSurroundingText) {
m_textBefore = context.textBeforeCursor.right(MaxTextBefore);
} else if (m_context.hasSurroundingText) {
// The field stopped reporting (an input-method reset): what we knew is stale.
m_deadKeys.cancel();
m_textBefore.clear();
}
m_context = context;
autoCapitalize();
}
+9 -1
View File
@@ -10,6 +10,7 @@ void ShiftState::press(qint64 timeMs)
m_held = true;
m_typedWhileHeld = false;
m_automatic = false;
m_userChoice = true;
switch (m_mode) {
case Mode::Off: m_mode = doubleTap ? Mode::Locked : Mode::Once; break;
@@ -28,6 +29,7 @@ void ShiftState::release()
void ShiftState::characterTyped()
{
m_lastPress = NoPress; // shift, type, shift is not a double tap
m_userChoice = false;
if (m_held)
m_typedWhileHeld = true;
else if (m_mode == Mode::Once)
@@ -36,8 +38,13 @@ void ShiftState::characterTyped()
void ShiftState::setAutomatic(bool capitalize)
{
if (m_held || m_mode == Mode::Locked)
if (m_held)
return;
const bool changed = capitalize != m_hint;
m_hint = capitalize;
if (m_mode == Mode::Locked || (m_userChoice && !changed))
return;
m_userChoice = false;
m_mode = capitalize ? Mode::Once : Mode::Off;
m_automatic = capitalize;
}
@@ -46,4 +53,5 @@ void ShiftState::toggleLock()
{
m_mode = m_mode == Mode::Locked ? Mode::Off : Mode::Locked;
m_automatic = false;
m_userChoice = true;
}
+7 -1
View File
@@ -27,14 +27,20 @@ public:
void release();
void characterTyped();
// Auto-capitalization hint; ignored while shift is held or locked.
// Auto-capitalization hint; ignored while shift is held or locked. A shift
// the user tapped stays until a character is typed or the hint changes,
// so a field re-reporting the same context doesn't undo it.
void setAutomatic(bool capitalize);
// A new field: the user's shift choice no longer applies.
void forgetUserChoice() { m_userChoice = false; }
// Caps lock key: locks, or unlocks if locked.
void toggleLock();
private:
Mode m_mode = Mode::Off;
bool m_automatic = false;
bool m_hint = false; // last setAutomatic value
bool m_userChoice = false; // the user set the mode since the last character
bool m_held = false;
bool m_typedWhileHeld = false;
bool m_lastPressUnlocked = false; // a tap leaving caps lock doesn't start a double tap
+5 -4
View File
@@ -1,5 +1,7 @@
#include "TextRules.h"
#include <QTextBoundaryFinder>
namespace TextRules {
namespace {
@@ -38,10 +40,9 @@ bool wantsDoubleSpacePeriod(const QString &textBeforeCursor)
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);
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
finder.toEnd();
return text.left(qMax<qsizetype>(0, finder.toPreviousBoundary()));
}
QString withoutLastWord(const QString &text)
+2 -1
View File
@@ -12,7 +12,8 @@ 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).
// `text` without its last character, as Backspace deletes it: a whole
// grapheme cluster (é as e + accent, emoji sequences).
QString withoutLastCharacter(const QString &text);
// `text` without its trailing word and the whitespace after it,
+40
View File
@@ -150,6 +150,46 @@ private Q_SLOTS:
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
}
void fieldHintsCanOptOutOfCapitalization()
{
TextContext context = Field;
context.hints = TextContext::AutoCompletion; // states preferences, no capitals among them
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
context.hints |= TextContext::AutoCapitalization;
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
}
void cursorMovesForgetTheGuessedText()
{
controller->resetContext(Field); // reports no text: we only know what we typed
for (const char *c : {"h", "i", "."})
tap(makeKey(KeyType::Character, QString::fromLatin1(c)));
controller->moveCursor({-2, 0}); // now somewhere inside "Hi."
tap(makeKey(KeyType::Space));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void contextReportKeepsTappedShift()
{
tap(makeKey(KeyType::Shift));
controller->setContext(MidSentence); // unchanged text, re-reported
tap(makeKey(KeyType::Character, u"a"_s));
QCOMPARE(engine.output, u"A");
}
void resetForgetsTextBefore()
{
TextContext context = MidSentence;
context.textBeforeCursor = u"word "_s;
controller->setContext(context);
controller->setContext(Field); // input-method reset: no surrounding text
tap(makeKey(KeyType::Space));
QCOMPARE(engine.output, u" ");
QCOMPARE(engine.mismatchedReplacements, 0);
}
void doubleSpacePeriodCanBeTurnedOff()
{
controller->setOptions({.doubleSpacePeriod = false});
+40
View File
@@ -111,6 +111,46 @@ private Q_SLOTS:
shift.characterTyped();
QCOMPARE(shift.mode(), Mode::Off);
}
void repeatedHintKeepsUserShift()
{
ShiftState shift;
shift.setAutomatic(false);
shift.press(0);
shift.release();
shift.setAutomatic(false); // the field re-reported
QCOMPARE(shift.mode(), Mode::Once);
}
void repeatedHintKeepsUserUnshift()
{
ShiftState shift;
shift.setAutomatic(true);
shift.press(0); // off at a sentence start
shift.release();
shift.setAutomatic(true);
QCOMPARE(shift.mode(), Mode::Off);
}
void changedHintOverridesUserShift()
{
ShiftState shift;
shift.setAutomatic(true);
shift.press(0);
shift.release();
shift.setAutomatic(false);
shift.setAutomatic(true); // e.g. backspaced to a sentence start
QCOMPARE(shift.mode(), Mode::Once);
}
void hintAppliesAgainAfterTyping()
{
ShiftState shift;
shift.setAutomatic(true);
shift.characterTyped(); // e.g. a space at a sentence start
shift.setAutomatic(true);
QCOMPARE(shift.mode(), Mode::Once);
}
};
QTEST_GUILESS_MAIN(TestShiftState)
+3
View File
@@ -44,6 +44,9 @@ private Q_SLOTS:
{
QCOMPARE(TextRules::withoutLastCharacter(u"ab"_s), u"a");
QCOMPARE(TextRules::withoutLastCharacter(u"a👍"_s), u"a");
QCOMPARE(TextRules::withoutLastCharacter(u"ae\u0301"_s), u"a"); // e + combining acute
QCOMPARE(TextRules::withoutLastCharacter(u"a👍🏽"_s), u"a"); // with a skin tone
QCOMPARE(TextRules::withoutLastCharacter(QString()), QString());
QCOMPARE(TextRules::withoutLastWord(u"one two "_s), u"one ");
QCOMPARE(TextRules::withoutLastWord(u"one"_s), QString());
}