diff --git a/src/backend/TextContext.h b/src/backend/TextContext.h index b59a146..6bdfc5c 100644 --- a/src/backend/TextContext.h +++ b/src/backend/TextContext.h @@ -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); + } }; diff --git a/src/input/KeyboardController.cpp b/src/input/KeyboardController.cpp index de9dbb3..a3404d5 100644 --- a/src/input/KeyboardController.cpp +++ b/src/input/KeyboardController.cpp @@ -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::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(); } diff --git a/src/input/ShiftState.cpp b/src/input/ShiftState.cpp index 3050a01..d76f616 100644 --- a/src/input/ShiftState.cpp +++ b/src/input/ShiftState.cpp @@ -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; } diff --git a/src/input/ShiftState.h b/src/input/ShiftState.h index 561d48c..ce2142c 100644 --- a/src/input/ShiftState.h +++ b/src/input/ShiftState.h @@ -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 diff --git a/src/input/TextRules.cpp b/src/input/TextRules.cpp index ae6513e..4cfff6a 100644 --- a/src/input/TextRules.cpp +++ b/src/input/TextRules.cpp @@ -1,5 +1,7 @@ #include "TextRules.h" +#include + 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(0, finder.toPreviousBoundary())); } QString withoutLastWord(const QString &text) diff --git a/src/input/TextRules.h b/src/input/TextRules.h index 19f57e7..229db19 100644 --- a/src/input/TextRules.h +++ b/src/input/TextRules.h @@ -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, diff --git a/tests/tst_controller.cpp b/tests/tst_controller.cpp index fc7d23a..64126b3 100644 --- a/tests/tst_controller.cpp +++ b/tests/tst_controller.cpp @@ -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}); diff --git a/tests/tst_shiftstate.cpp b/tests/tst_shiftstate.cpp index 7a88eb6..c5fa13a 100644 --- a/tests/tst_shiftstate.cpp +++ b/tests/tst_shiftstate.cpp @@ -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) diff --git a/tests/tst_textrules.cpp b/tests/tst_textrules.cpp index 53caaea..d0da128 100644 --- a/tests/tst_textrules.cpp +++ b/tests/tst_textrules.cpp @@ -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()); }