Skip to content

Commit 7b30e76

Browse files
committed
fix(input): preserve Thai segmentation with text expansion
- defer segmentation only for matching immediate shortcuts - respect per-shortcut prefixes during prefix detection - commit completed Thai segments individually - preserve user history and n-gram context - add regression coverage for expansion and fallback paths
1 parent a779286 commit 7b30e76

5 files changed

Lines changed: 274 additions & 3 deletions

File tree

app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
import helium314.keyboard.latin.utils.TextRange;
6868
import helium314.keyboard.latin.utils.TimestampKt;
6969

70+
import java.text.BreakIterator;
7071
import java.util.ArrayList;
7172
import java.util.Locale;
7273

@@ -78,7 +79,17 @@
7879
public final class InputLogic {
7980
private static final String TAG = InputLogic.class.getSimpleName();
8081
private static final char INLINE_EMOJI_SEARCH_MARKER = ':';
81-
private static final int[] EMPTY_CODE_POINTS = new int[0];
82+
// Currently only Thai needs word segmentation. If additional scripts are
83+
// added to ScriptUtils.needsWordSegmentation(), the BreakIterator locale
84+
// selection must be revisited.
85+
private static final Locale THAI_LOCALE = Locale.forLanguageTag("th");
86+
private static final ThreadLocal<BreakIterator> THAI_WORD_BREAK_ITERATOR =
87+
new ThreadLocal<BreakIterator>() {
88+
@Override
89+
protected BreakIterator initialValue() {
90+
return BreakIterator.getWordInstance(THAI_LOCALE);
91+
}
92+
};
8293

8394
// TODO : Remove this member when we can.
8495
private final LatinIME mLatinIME;
@@ -1453,10 +1464,14 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set
14531464
if (mWordComposer.isSingleLetter()) {
14541465
mWordComposer.setCapitalizedModeAtStartComposingTime(inputTransaction.getShiftState());
14551466
}
1456-
setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1);
1467+
boolean didSetComposingText = false;
1468+
boolean didExpand = false;
1469+
boolean shouldDeferSegmentation = false;
14571470
if (helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME)
14581471
&& helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isImmediateEnabled(mLatinIME)) {
14591472
final String typedWord = mWordComposer.getTypedWord();
1473+
setComposingTextInternal(getTextWithUnderline(typedWord), 1);
1474+
didSetComposingText = true;
14601475
final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0);
14611476
if (textBefore != null) {
14621477
final String textStr = textBefore.toString();
@@ -1469,7 +1484,19 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set
14691484
}
14701485
commitExpandedText(result.getMatchedString(), result.getExpandedText());
14711486
resetComposingState(true);
1487+
didExpand = true;
14721488
}
1489+
shouldDeferSegmentation = !didExpand
1490+
&& ScriptUtils.needsWordSegmentation(settingsValues.mLocale)
1491+
&& helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE
1492+
.isPrefixOfNonRegexShortcut(typedWord, textStr, mLatinIME);
1493+
}
1494+
}
1495+
if (!didExpand && !shouldDeferSegmentation) {
1496+
final boolean didCommitCompletedWordSegments =
1497+
maybeCommitCompletedWordSegments(settingsValues);
1498+
if (!didSetComposingText || didCommitCompletedWordSegments) {
1499+
setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1);
14731500
}
14741501
}
14751502
} else {
@@ -1499,6 +1526,60 @@ private boolean isCursorAtStartOrAfterSeparator(SettingsValues settingsValues) {
14991526
|| settingsValues.mSpacingAndPunctuations.isWordSeparator(codePointBeforeCursor);
15001527
}
15011528

1529+
private boolean maybeCommitCompletedWordSegments(final SettingsValues settingsValues) {
1530+
if (!ScriptUtils.needsWordSegmentation(settingsValues.mLocale)
1531+
|| settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces) {
1532+
return false;
1533+
}
1534+
1535+
final String typedWord = mWordComposer.getTypedWord();
1536+
final int length = typedWord.length();
1537+
if (length <= 1) {
1538+
return false;
1539+
}
1540+
1541+
final BreakIterator iterator = THAI_WORD_BREAK_ITERATOR.get();
1542+
iterator.setText(typedWord);
1543+
int segmentStart = iterator.first();
1544+
int wordBoundary = iterator.next();
1545+
boolean didCommitSegment = false;
1546+
while (wordBoundary != BreakIterator.DONE && wordBoundary < length) {
1547+
final String completedWordSegment = typedWord.substring(segmentStart, wordBoundary);
1548+
if (!TextUtils.isEmpty(completedWordSegment)) {
1549+
commitCompletedWordSegment(settingsValues, completedWordSegment);
1550+
didCommitSegment = true;
1551+
}
1552+
segmentStart = wordBoundary;
1553+
wordBoundary = iterator.next();
1554+
}
1555+
if (!didCommitSegment) {
1556+
return false;
1557+
}
1558+
1559+
// Scripts that require explicit word segmentation (currently only Thai) can accumulate
1560+
// multiple word segments in one composing span. Commit completed segments and
1561+
// leave the latest segment composing so underline and candidate handling stay local.
1562+
final String remainingWord = typedWord.substring(segmentStart);
1563+
final int[] codePoints = StringUtils.toCodePointArray(remainingWord);
1564+
mWordComposer.setComposingWord(codePoints,
1565+
mLatinIME.getCoordinatesForCurrentKeyboard(codePoints));
1566+
return true;
1567+
}
1568+
1569+
private void commitCompletedWordSegment(final SettingsValues settingsValues,
1570+
final String completedWordSegment) {
1571+
final NgramContext ngramContext = getNgramContextFromNthPreviousWordForSuggestion(
1572+
settingsValues.mSpacingAndPunctuations, 2);
1573+
mConnection.commitText(completedWordSegment, 1);
1574+
performAdditionToUserHistoryDictionary(settingsValues, completedWordSegment, ngramContext);
1575+
mLastComposedWord = new LastComposedWord(new ArrayList<>(), null, completedWordSegment,
1576+
completedWordSegment, LastComposedWord.NOT_A_SEPARATOR, ngramContext,
1577+
WordComposer.CAPS_MODE_OFF);
1578+
StatsUtils.onWordCommitUserTyped(completedWordSegment, mWordComposer.isBatchMode());
1579+
}
1580+
1581+
1582+
15021583
/**
15031584
* Handle input of a separator code point.
15041585
*
@@ -1510,10 +1591,14 @@ private void handleSeparatorEvent(final Event event, final InputTransaction inpu
15101591
final int codePoint = event.getCodePoint();
15111592
final SettingsValues settingsValues = inputTransaction.getSettingsValues();
15121593
final boolean wasComposingWord = mWordComposer.isComposingWord();
1594+
// Scripts that require explicit word segmentation should still allow an
1595+
// explicit Space to be inserted while committing composing text.
1596+
final boolean needsSegmentation = ScriptUtils.needsWordSegmentation(settingsValues.mLocale);
15131597
// We avoid sending spaces in languages without spaces if we were composing.
15141598
final boolean shouldAvoidSendingCode = Constants.CODE_SPACE == codePoint
15151599
&& !settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces
1516-
&& wasComposingWord;
1600+
&& wasComposingWord
1601+
&& !needsSegmentation;
15171602

15181603
// wrap / unwrap selected text in codepoint pairs
15191604
if (!wasComposingWord && mConnection.hasSelection()) { // we should never be composing when something is

app/src/main/java/helium314/keyboard/latin/utils/ScriptUtils.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ object ScriptUtils {
185185
}
186186
}
187187

188+
/**
189+
* Returns true if the locale uses a script that requires explicit word segmentation.
190+
* Currently returns true for Thai only.
191+
*/
192+
@JvmStatic
193+
fun needsWordSegmentation(locale: Locale): Boolean =
194+
locale.language == "th"
195+
188196
@JvmStatic
189197
fun isScriptRtl(script: String): Boolean {
190198
return when (script) {

app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,22 @@ object TextExpanderUtils {
209209
return result
210210
}
211211

212+
fun isPrefixOfNonRegexShortcut(
213+
word: String,
214+
textBeforeCursor: String,
215+
context: Context,
216+
): Boolean =
217+
getShortcuts(context).any { (key, entry) ->
218+
if (key.startsWith(REGEX_PREFIX) || key.length < entry.prefix.length) {
219+
false
220+
} else {
221+
val shortcut = key.substring(entry.prefix.length)
222+
!shortcut.equals(word, ignoreCase = true) &&
223+
shortcut.startsWith(word, ignoreCase = true) &&
224+
textBeforeCursor.endsWith(entry.prefix + word, ignoreCase = true)
225+
}
226+
}
227+
212228
fun getExpandedWordForTyped(word: String?, textBeforeCursor: String?, context: Context): ExpandedResult? {
213229
if (word == null || textBeforeCursor == null || !isEnabled(context)) return null
214230
val shortcuts = getShortcuts(context)

app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import helium314.keyboard.keyboard.KeyboardSwitcher
1616
import helium314.keyboard.keyboard.MainKeyboardView
1717
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
1818
import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastAddedWord
19+
import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastNgramContext
1920
import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo
2021
import helium314.keyboard.latin.common.Constants
2122
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
@@ -84,6 +85,15 @@ class InputLogicTest {
8485
assertEquals("", composingText)
8586
}
8687

88+
@Test fun `english space-separated typing keeps composing word`() {
89+
reset()
90+
chainInput("hello")
91+
assertEquals("hello", composingText)
92+
input(' ')
93+
assertEquals("hello ", text)
94+
assertEquals("", composingText)
95+
}
96+
8797
@Test fun delete() {
8898
reset()
8999
setText("hello there ")
@@ -160,6 +170,138 @@ class InputLogicTest {
160170
assertEquals("ㅛ.", text)
161171
}
162172

173+
@Test fun `space after thai composing word inserts space`() {
174+
reset()
175+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
176+
currentScript = ScriptUtils.SCRIPT_THAI
177+
chainInput("ภาษาไทย")
178+
assertEquals("ไทย", composingText)
179+
input(' ')
180+
assertEquals("ภาษาไทย ", text)
181+
assertEquals("", composingText)
182+
}
183+
184+
@Test fun `thai composing word follows word boundaries`() {
185+
reset()
186+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
187+
currentScript = ScriptUtils.SCRIPT_THAI
188+
chainInput("ภาษาไทยดี")
189+
assertEquals("ภาษาไทยดี", text)
190+
assertEquals("ดี", composingText)
191+
assertEquals("ไทย", lastAddedWord)
192+
assertEquals("ภาษา", lastNgramContext)
193+
}
194+
195+
@Test fun `single thai composing segment remains composing`() {
196+
reset()
197+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
198+
currentScript = ScriptUtils.SCRIPT_THAI
199+
chainInput("ไทย")
200+
assertEquals("ไทย", text)
201+
assertEquals("ไทย", composingText)
202+
}
203+
204+
@Test fun `space after segmented thai composing word inserts one space`() {
205+
reset()
206+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
207+
currentScript = ScriptUtils.SCRIPT_THAI
208+
chainInput("ภาษาไทยดี")
209+
input(' ')
210+
assertEquals("ภาษาไทยดี ", text)
211+
assertEquals("", composingText)
212+
}
213+
214+
@Test fun `immediate text expansion uses full segmented thai word`() {
215+
reset()
216+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
217+
currentScript = ScriptUtils.SCRIPT_THAI
218+
latinIME.prefs().edit().apply {
219+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_ENABLED, true)
220+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_IMMEDIATE, true)
221+
}.commit()
222+
val shortcuts = mapOf("ภาษาไทย" to helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry("expanded", ""))
223+
helium314.keyboard.latin.utils.TextExpanderUtils.saveShortcuts(latinIME, shortcuts)
224+
225+
typeNoAssert("ภาษาไทย")
226+
227+
assertEquals("expanded", text)
228+
assertEquals("", composingText)
229+
assertEquals("", lastAddedWord)
230+
}
231+
232+
@Test fun `immediate text expansion uses prefixed segmented thai word`() {
233+
reset()
234+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
235+
currentScript = ScriptUtils.SCRIPT_THAI
236+
latinIME.prefs().edit().apply {
237+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_ENABLED, true)
238+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_IMMEDIATE, true)
239+
}.commit()
240+
val shortcuts = mapOf(".ภาษาไทย" to helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry("expanded", "."))
241+
helium314.keyboard.latin.utils.TextExpanderUtils.saveShortcuts(latinIME, shortcuts)
242+
243+
typeNoAssert(".ภาษาไทย")
244+
245+
assertEquals("expanded", text)
246+
assertEquals("", composingText)
247+
assertEquals("", lastAddedWord)
248+
}
249+
250+
@Test fun `prefixed immediate text expansion does not defer thai without prefix`() {
251+
reset()
252+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
253+
currentScript = ScriptUtils.SCRIPT_THAI
254+
latinIME.prefs().edit().apply {
255+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_ENABLED, true)
256+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_IMMEDIATE, true)
257+
}.commit()
258+
val shortcuts = mapOf(".ภาษาไทย" to helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry("expanded", "."))
259+
helium314.keyboard.latin.utils.TextExpanderUtils.saveShortcuts(latinIME, shortcuts)
260+
261+
typeNoAssert("ภาษาไทย")
262+
263+
assertEquals("ภาษาไทย", text)
264+
assertEquals("ไทย", composingText)
265+
assertEquals("ภาษา", lastAddedWord)
266+
}
267+
268+
@Test fun `immediate text expansion still segments thai non-shortcut`() {
269+
reset()
270+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
271+
currentScript = ScriptUtils.SCRIPT_THAI
272+
latinIME.prefs().edit().apply {
273+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_ENABLED, true)
274+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_IMMEDIATE, true)
275+
}.commit()
276+
val shortcuts = mapOf("อื่น" to helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry("expanded", ""))
277+
helium314.keyboard.latin.utils.TextExpanderUtils.saveShortcuts(latinIME, shortcuts)
278+
279+
chainInput("ภาษาไทยดี")
280+
281+
assertEquals("ภาษาไทยดี", text)
282+
assertEquals("ดี", composingText)
283+
assertEquals("ไทย", lastAddedWord)
284+
}
285+
286+
@Test fun `failed immediate expansion commits thai segments separately`() {
287+
reset()
288+
latinIME.switchToSubtype(SubtypeSettings.getResourceSubtypesForLocale("th".constructLocale()).first())
289+
currentScript = ScriptUtils.SCRIPT_THAI
290+
latinIME.prefs().edit().apply {
291+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_ENABLED, true)
292+
putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_IMMEDIATE, true)
293+
}.commit()
294+
val shortcuts = mapOf("ภาษาไทยดี" to helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry("expanded", ""))
295+
helium314.keyboard.latin.utils.TextExpanderUtils.saveShortcuts(latinIME, shortcuts)
296+
297+
typeNoAssert("ภาษาไทยแดง")
298+
299+
assertEquals("ภาษาไทยแดง", text)
300+
assertEquals("แดง", composingText)
301+
assertEquals("ไทย", lastAddedWord)
302+
assertEquals("ภาษา", lastNgramContext)
303+
}
304+
163305
// see issue 1551 (debug only)
164306
@Test fun deleteHangul() {
165307
reset()
@@ -779,6 +921,7 @@ class InputLogicTest {
779921
batchEdit = 0
780922
currentInputType = InputType.TYPE_CLASS_TEXT
781923
lastAddedWord = ""
924+
lastNgramContext = ""
782925

783926
// reset settings
784927
latinIME.prefs().edit { clear() }
@@ -1212,8 +1355,10 @@ class ShadowFacilitator2 {
12121355
ngramContext: NgramContext, timeStampInSeconds: Long,
12131356
blockPotentiallyOffensive: Boolean) {
12141357
lastAddedWord = suggestion
1358+
lastNgramContext = ngramContext.extractPrevWordsContext()
12151359
}
12161360
companion object {
12171361
var lastAddedWord = ""
1362+
var lastNgramContext = ""
12181363
}
12191364
}

app/src/test/java/helium314/keyboard/latin/ScriptUtilsTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import helium314.keyboard.latin.common.LocaleUtils.constructLocale
55
import helium314.keyboard.latin.utils.ScriptUtils.SCRIPT_CYRILLIC
66
import helium314.keyboard.latin.utils.ScriptUtils.SCRIPT_DEVANAGARI
77
import helium314.keyboard.latin.utils.ScriptUtils.SCRIPT_LATIN
8+
import helium314.keyboard.latin.utils.ScriptUtils.needsWordSegmentation
89
import helium314.keyboard.latin.utils.ScriptUtils.script
910
import kotlin.test.Test
1011
import kotlin.test.assertEquals
12+
import kotlin.test.assertFalse
13+
import kotlin.test.assertTrue
1114

1215
class ScriptUtilsTest {
1316
@Test fun defaultScript() {
@@ -18,4 +21,18 @@ class ScriptUtilsTest {
1821
assertEquals(SCRIPT_CYRILLIC, "mk".constructLocale().script())
1922
assertEquals(SCRIPT_CYRILLIC, "fr-Cyrl".constructLocale().script())
2023
}
24+
25+
@Test fun needsWordSegmentationThai() {
26+
assertTrue(needsWordSegmentation("th".constructLocale()))
27+
}
28+
29+
@Test fun needsWordSegmentationNonThai() {
30+
assertFalse(needsWordSegmentation("en".constructLocale()))
31+
assertFalse(needsWordSegmentation("ja".constructLocale()))
32+
assertFalse(needsWordSegmentation("zh".constructLocale()))
33+
assertFalse(needsWordSegmentation("lo".constructLocale()))
34+
assertFalse(needsWordSegmentation("km".constructLocale()))
35+
assertFalse(needsWordSegmentation("ko".constructLocale()))
36+
assertFalse(needsWordSegmentation("my".constructLocale()))
37+
}
2138
}

0 commit comments

Comments
 (0)