Merge branch 'refs/heads/main' into emoji-inline-search

# Conflicts:
#	app/src/main/java/helium314/keyboard/latin/RichInputConnection.java
#	app/src/main/java/helium314/keyboard/latin/utils/TextRange.java
This commit is contained in:
eranl 2025-06-10 22:46:41 +03:00
commit f914cb6524
12 changed files with 227 additions and 116 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId = "helium314.keyboard"
minSdk = 21
targetSdk = 35
versionCode = 3101
versionName = "3.1"
versionCode = 3200
versionName = "3.2-beta1"
ndk {
abiFilters.clear()
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64"))

View file

@ -229,13 +229,15 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co
return
// replace comma / period if 2 keys in normal bottom row
if (baseKeys.last().size == 2) {
val newComma = baseKeys.last()[0]
functionalKeysBottom.replaceFirst(
{ it.label == KeyLabel.COMMA || it.groupId == KeyData.GROUP_COMMA},
{ baseKeys.last()[0].copy(newGroupId = 1, newType = baseKeys.last()[0].type ?: it.type) }
{ newComma.copy(newGroupId = 1, newType = newComma.type, newLabelFlags = it.labelFlags or newComma.labelFlags) }
)
val newPeriod = baseKeys.last()[1]
functionalKeysBottom.replaceFirst(
{ it.label == KeyLabel.PERIOD || it.groupId == KeyData.GROUP_PERIOD},
{ baseKeys.last()[1].copy(newGroupId = 2, newType = baseKeys.last()[1].type ?: it.type) }
{ newPeriod.copy(newGroupId = 2, newType = newPeriod.type, newLabelFlags = it.labelFlags or newPeriod.labelFlags) }
)
baseKeys.removeAt(baseKeys.lastIndex)
}

View file

@ -83,7 +83,7 @@ object KeyCode {
const val LANGUAGE_SWITCH = -227
//const val IME_SHOW_UI = -231
//const val IME_HIDE_UI = -232
const val IME_HIDE_UI = -232
const val VOICE_INPUT = -233
//const val TOGGLE_SMARTBAR_VISIBILITY = -241
@ -183,7 +183,7 @@ object KeyCode {
REDO, ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT, CLIPBOARD_COPY, CLIPBOARD_PASTE, CLIPBOARD_SELECT_ALL,
CLIPBOARD_SELECT_WORD, TOGGLE_INCOGNITO_MODE, TOGGLE_AUTOCORRECT, MOVE_START_OF_LINE, MOVE_END_OF_LINE,
MOVE_START_OF_PAGE, MOVE_END_OF_PAGE, SHIFT, CAPS_LOCK, MULTIPLE_CODE_POINTS, UNSPECIFIED, CTRL, ALT,
FN, CLIPBOARD_CLEAR_HISTORY, NUMPAD,
FN, CLIPBOARD_CLEAR_HISTORY, NUMPAD, IME_HIDE_UI,
// heliboard only
SYMBOL_ALPHA, TOGGLE_ONE_HANDED_MODE, SWITCH_ONE_HANDED_MODE, SPLIT_LAYOUT, SHIFT_ENTER,

View file

@ -40,8 +40,6 @@ import helium314.keyboard.latin.settings.SpacingAndPunctuations;
import helium314.keyboard.latin.utils.CapsModeUtils;
import helium314.keyboard.latin.utils.DebugLogUtils;
import helium314.keyboard.latin.utils.NgramContextUtils;
import helium314.keyboard.latin.utils.ScriptUtils;
import helium314.keyboard.latin.utils.SpannableStringUtils;
import helium314.keyboard.latin.utils.StatsUtils;
import helium314.keyboard.latin.utils.TextRange;
@ -825,15 +823,6 @@ public final class RichInputConnection implements PrivateCommandPerformer {
return NgramContextUtils.getNgramContextFromNthPreviousWord(prev, spacingAndPunctuations, n);
}
private static boolean isPartOfCompositionForScript(final int codePoint,
final SpacingAndPunctuations spacingAndPunctuations, final String script) {
// We always consider word connectors part of compositions.
return spacingAndPunctuations.isWordConnector(codePoint)
// Otherwise, it's part of composition if it's part of script and not a separator.
|| (!spacingAndPunctuations.isWordSeparator(codePoint)
&& ScriptUtils.isLetterPartOfScript(codePoint, script));
}
/**
* Returns the text surrounding the cursor.
*
@ -860,90 +849,7 @@ public final class RichInputConnection implements PrivateCommandPerformer {
if (before == null || after == null) {
return null;
}
// Going backward, find the first breaking point (separator)
int startIndexInBefore = before.length();
int endIndexInAfter = -1;
while (startIndexInBefore > 0) {
final int codePoint = Character.codePointBefore(before, startIndexInBefore);
if (!isPartOfCompositionForScript(codePoint, spacingAndPunctuations, script)) {
if (Character.isWhitespace(codePoint) || !spacingAndPunctuations.mCurrentLanguageHasSpaces)
break;
// continue to the next whitespace and see whether this contains a sometimesWordConnector
for (int i = startIndexInBefore - 1; i >= 0; i--) {
final char c = before.charAt(i);
if (spacingAndPunctuations.isSometimesWordConnector(c)) {
// if yes -> whitespace is the index
startIndexInBefore = Math.max(StringUtils.charIndexOfLastWhitespace(before), 0);
final int firstSpaceAfter = StringUtils.charIndexOfFirstWhitespace(after);
endIndexInAfter = firstSpaceAfter == -1 ? after.length() : firstSpaceAfter -1;
break;
} else if (Character.isWhitespace(c)) {
// if no, just break normally
break;
}
}
break;
}
--startIndexInBefore;
if (Character.isSupplementaryCodePoint(codePoint)) {
--startIndexInBefore;
}
}
// Find last word separator after the cursor
if (endIndexInAfter == -1) {
while (++endIndexInAfter < after.length()) {
final int codePoint = Character.codePointAt(after, endIndexInAfter);
if (!isPartOfCompositionForScript(codePoint, spacingAndPunctuations, script)) {
if (Character.isWhitespace(codePoint) || !spacingAndPunctuations.mCurrentLanguageHasSpaces)
break;
// continue to the next whitespace and see whether this contains a sometimesWordConnector
for (int i = endIndexInAfter; i < after.length(); i++) {
final char c = after.charAt(i);
if (spacingAndPunctuations.isSometimesWordConnector(c)) {
// if yes -> whitespace is next to the index
startIndexInBefore = Math.max(StringUtils.charIndexOfLastWhitespace(before), 0);
final int firstSpaceAfter = StringUtils.charIndexOfFirstWhitespace(after);
endIndexInAfter = firstSpaceAfter == -1 ? after.length() : firstSpaceAfter - 1;
break;
} else if (Character.isWhitespace(c)) {
// if no, just break normally
break;
}
}
break;
}
if (Character.isSupplementaryCodePoint(codePoint)) {
++endIndexInAfter;
}
}
}
// strip stuff before "//" (i.e. ignore http and other protocols)
final String beforeConsideringStart = before.subSequence(startIndexInBefore, before.length()).toString();
final int protocolEnd = beforeConsideringStart.lastIndexOf("//");
if (protocolEnd != -1)
startIndexInBefore += protocolEnd + 1;
// we don't want the end characters to be word separators
while (endIndexInAfter > 0 && spacingAndPunctuations.isWordSeparator(after.charAt(endIndexInAfter - 1))) {
--endIndexInAfter;
}
while (startIndexInBefore < before.length() && spacingAndPunctuations.isWordSeparator(before.charAt(startIndexInBefore))) {
++startIndexInBefore;
}
final boolean hasUrlSpans =
SpannableStringUtils.hasUrlSpans(before, startIndexInBefore, before.length())
|| SpannableStringUtils.hasUrlSpans(after, 0, endIndexInAfter);
// We don't use TextUtils#concat because it copies all spans without respect to their
// nature. If the text includes a PARAGRAPH span and it has been split, then
// TextUtils#concat will crash when it tries to concat both sides of it.
return new TextRange(
SpannableStringUtils.concatWithNonParagraphSuggestionSpansOnly(before, after),
startIndexInBefore, before.length() + endIndexInAfter, before.length(),
hasUrlSpans);
return StringUtilsKt.getTouchedWordRange(before, after, script, spacingAndPunctuations);
}
public boolean isCursorTouchingWord(final SpacingAndPunctuations spacingAndPunctuations,
@ -956,17 +862,7 @@ public final class RichInputConnection implements PrivateCommandPerformer {
// a composing region should always count as a word
return true;
}
final String textBeforeCursor = mCommittedTextBeforeComposingText.toString();
int indexOfCodePointInJavaChars = textBeforeCursor.length();
int consideredCodePoint = 0 == indexOfCodePointInJavaChars ? Constants.NOT_A_CODE
: textBeforeCursor.codePointBefore(indexOfCodePointInJavaChars);
// Search for the first non word-connector char
if (spacingAndPunctuations.isWordConnector(consideredCodePoint)) {
indexOfCodePointInJavaChars -= Character.charCount(consideredCodePoint);
consideredCodePoint = 0 == indexOfCodePointInJavaChars ? Constants.NOT_A_CODE
: textBeforeCursor.codePointBefore(indexOfCodePointInJavaChars);
}
return Constants.NOT_A_CODE != consideredCodePoint && spacingAndPunctuations.isWordCodePoint(consideredCodePoint);
return StringUtilsKt.endsWithWordCodepoint(mCommittedTextBeforeComposingText.toString(), spacingAndPunctuations);
}
public boolean isCursorFollowedByWordCharacter(

View file

@ -6,13 +6,18 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
import helium314.keyboard.latin.common.StringUtils.mightBeEmoji
import helium314.keyboard.latin.common.StringUtils.newSingleCodePointString
import helium314.keyboard.latin.settings.SpacingAndPunctuations
import helium314.keyboard.latin.utils.ScriptUtils
import helium314.keyboard.latin.utils.SpacedTokens
import helium314.keyboard.latin.utils.SpannableStringUtils
import helium314.keyboard.latin.utils.TextRange
import java.math.BigInteger
import java.util.Locale
import kotlin.math.max
fun CharSequence.codePointAt(offset: Int) = Character.codePointAt(this, offset)
fun CharSequence.codePointBefore(offset: Int) = Character.codePointBefore(this, offset)
/** Loops over the codepoints in [text]. Exits when [loop] returns true */
inline fun loopOverCodePoints(text: CharSequence, loop: (cp: Int, charCount: Int) -> Boolean) {
var offset = 0
while (offset < text.length) {
@ -23,6 +28,7 @@ inline fun loopOverCodePoints(text: CharSequence, loop: (cp: Int, charCount: Int
}
}
/** Loops backwards over the codepoints in [text]. Exits when [loop] returns true */
inline fun loopOverCodePointsBackwards(text: CharSequence, loop: (cp: Int, charCount: Int) -> Boolean) {
var offset = text.length
while (offset > 0) {
@ -88,6 +94,111 @@ fun getFullEmojiAtEnd(text: CharSequence): String {
return s.substring(offset)
}
/**
* Returns whether the [text] does not end with word separator, ignoring all word connectors.
* If the [text] is empty (after ignoring word connectors), the method returns false.
*/
// todo: this returns true on numbers, why isn't Character.isLetter(code) used?
fun endsWithWordCodepoint(text: String, spacingAndPunctuations: SpacingAndPunctuations): Boolean {
if (text.isEmpty()) return false
var codePoint = 0 // initial value irrelevant since length is always > 0
loopOverCodePointsBackwards(text) { cp, _ ->
codePoint = cp
!spacingAndPunctuations.isWordConnector(cp)
}
// codePoint might still be a wordConnector (if text consists of wordConnectors)
return !spacingAndPunctuations.isWordConnector(codePoint) && !spacingAndPunctuations.isWordSeparator(codePoint)
}
// todo: simplify... maybe compare with original code?
fun getTouchedWordRange(before: CharSequence, after: CharSequence, script: String, spacingAndPunctuations: SpacingAndPunctuations): TextRange {
// Going backward, find the first breaking point (separator)
var startIndexInBefore = before.length
var endIndexInAfter = -1 // todo: clarify why might we want to set it when checking before
loopOverCodePointsBackwards(before) { codePoint, cpLength ->
if (!isPartOfCompositionForScript(codePoint, spacingAndPunctuations, script)) {
if (Character.isWhitespace(codePoint) || !spacingAndPunctuations.mCurrentLanguageHasSpaces)
return@loopOverCodePointsBackwards true
// continue to the next whitespace and see whether this contains a sometimesWordConnector
for (i in startIndexInBefore - 1 downTo 0) {
val c = before[i]
if (spacingAndPunctuations.isSometimesWordConnector(c.code)) {
// if yes -> whitespace is the index
startIndexInBefore = max(StringUtils.charIndexOfLastWhitespace(before).toDouble(), 0.0).toInt()
val firstSpaceAfter = StringUtils.charIndexOfFirstWhitespace(after)
endIndexInAfter = if (firstSpaceAfter == -1) after.length else firstSpaceAfter - 1
return@loopOverCodePointsBackwards true
} else if (Character.isWhitespace(c)) {
// if no, just break normally
return@loopOverCodePointsBackwards true
}
}
return@loopOverCodePointsBackwards true
}
startIndexInBefore -= cpLength
false
}
// Find last word separator after the cursor
if (endIndexInAfter == -1) {
endIndexInAfter = 0
loopOverCodePoints(after) { codePoint, cpLength ->
if (!isPartOfCompositionForScript(codePoint, spacingAndPunctuations, script)) {
if (Character.isWhitespace(codePoint) || !spacingAndPunctuations.mCurrentLanguageHasSpaces)
return@loopOverCodePoints true
// continue to the next whitespace and see whether this contains a sometimesWordConnector
for (i in endIndexInAfter..<after.length) {
val c = after[i]
if (spacingAndPunctuations.isSometimesWordConnector(c.code)) {
// if yes -> whitespace is next to the index
startIndexInBefore = max(StringUtils.charIndexOfLastWhitespace(before), 0)
val firstSpaceAfter = StringUtils.charIndexOfFirstWhitespace(after)
endIndexInAfter = if (firstSpaceAfter == -1) after.length else firstSpaceAfter - 1
return@loopOverCodePoints true
} else if (Character.isWhitespace(c)) {
// if no, just break normally
return@loopOverCodePoints true
}
}
return@loopOverCodePoints true
}
endIndexInAfter += cpLength
false
}
}
// strip text before "//" (i.e. ignore http and other protocols)
val beforeConsideringStart = before.substring(startIndexInBefore, before.length)
val protocolEnd = beforeConsideringStart.lastIndexOf("//")
if (protocolEnd != -1) startIndexInBefore += protocolEnd + 1
// we don't want the end characters to be word separators
while (endIndexInAfter > 0 && spacingAndPunctuations.isWordSeparator(after[endIndexInAfter - 1].code)) {
--endIndexInAfter
}
while (startIndexInBefore < before.length && spacingAndPunctuations.isWordSeparator(before[startIndexInBefore].code)) {
++startIndexInBefore
}
val hasUrlSpans = SpannableStringUtils.hasUrlSpans(before, startIndexInBefore, before.length)
|| SpannableStringUtils.hasUrlSpans(after, 0, endIndexInAfter)
// We don't use TextUtils#concat because it copies all spans without respect to their
// nature. If the text includes a PARAGRAPH span and it has been split, then
// TextUtils#concat will crash when it tries to concat both sides of it.
return TextRange(
SpannableStringUtils.concatWithNonParagraphSuggestionSpansOnly(before, after),
startIndexInBefore, before.length + endIndexInAfter, before.length,
hasUrlSpans
)
}
// actually this should not be in STRING Utils, but only used for getTouchedWordRange
private fun isPartOfCompositionForScript(codePoint: Int, spacingAndPunctuations: SpacingAndPunctuations, script: String) =
spacingAndPunctuations.isWordConnector(codePoint) // We always consider word connectors part of compositions.
// Otherwise, it's part of composition if it's part of script and not a separator.
|| (!spacingAndPunctuations.isWordSeparator(codePoint) && ScriptUtils.isLetterPartOfScript(codePoint, script))
/** split the string on the first of consecutive space only, further consecutive spaces are added to the next split */
fun String.splitOnFirstSpacesOnly(): List<String> {
val out = mutableListOf<String>()

View file

@ -776,6 +776,9 @@ public final class InputLogic {
case KeyCode.TIMESTAMP:
mLatinIME.onTextInput(TimestampKt.getTimestamp(mLatinIME));
break;
case KeyCode.IME_HIDE_UI:
mLatinIME.hideWindow();
break;
case KeyCode.VOICE_INPUT:
// switching to shortcut IME, shift state, keyboard,... is handled by LatinIME,
// {@link KeyboardSwitcher#onEvent(Event)}, or {@link #onPressKey(int,int,boolean)} and {@link #onReleaseKey(int,boolean)}.

View file

@ -7,9 +7,13 @@
package helium314.keyboard.latin.utils;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.SuggestionSpan;
import androidx.annotation.NonNull;
import java.util.Arrays;
import java.util.Objects;
/**
* Represents a range of text, relative to the current cursor position.
@ -95,6 +99,28 @@ public final class TextRange {
return writeIndex == readIndex ? spans : Arrays.copyOfRange(spans, 0, writeIndex);
}
@Override
public boolean equals(Object other) {
if (!(other instanceof TextRange textRange)) return false;
return mWordAtCursorStartIndex == textRange.mWordAtCursorStartIndex
&& mWordAtCursorEndIndex == textRange.mWordAtCursorEndIndex
&& mCursorIndex == textRange.mCursorIndex
&& mHasUrlSpans == textRange.mHasUrlSpans
&& TextUtils.equals(mTextAtCursor, textRange.mTextAtCursor)
&& TextUtils.equals(mWord, textRange.mWord);
}
@Override
public int hashCode() {
return Objects.hash(mTextAtCursor, mWordAtCursorStartIndex, mWordAtCursorEndIndex, mCursorIndex, mWord, mHasUrlSpans);
}
@NonNull
@Override
public String toString() {
return mTextAtCursor + ", " + mWord + ", " + mCursorIndex;
}
public TextRange(final CharSequence textAtCursor, final int wordAtCursorStartIndex,
final int wordAtCursorEndIndex, final int cursorIndex, final boolean hasUrlSpans) {
if (wordAtCursorStartIndex < 0 || cursorIndex < wordAtCursorStartIndex

View file

@ -79,7 +79,8 @@ fun AppearanceScreen(
SettingsWithoutKey.CUSTOM_FONT,
Settings.PREF_FONT_SCALE,
Settings.PREF_EMOJI_FONT_SCALE,
Settings.PREF_EMOJI_KEY_FIT,
if (prefs.getFloat(Settings.PREF_EMOJI_FONT_SCALE, Defaults.PREF_EMOJI_FONT_SCALE) != 1f)
Settings.PREF_EMOJI_KEY_FIT else null,
if (prefs.getInt(Settings.PREF_EMOJI_MAX_SDK, Defaults.PREF_EMOJI_MAX_SDK) >= 24)
Settings.PREF_EMOJI_SKIN_TONE else null,
)
@ -109,6 +110,7 @@ fun createAppearanceSettings(context: Context) = listOf(
prefs.edit().remove(Settings.PREF_THEME_COLORS_NIGHT).apply()
}
KeyboardIconsSet.needsReload = true // only relevant for Settings.PREF_CUSTOM_ICON_NAMES
KeyboardSwitcher.getInstance().setThemeNeedsReload()
}
},
Setting(context, Settings.PREF_ICON_STYLE, R.string.icon_style) { setting ->

View file

@ -57,8 +57,11 @@ fun DictionaryScreen(
val enabledLanguages = SubtypeSettings.getEnabledSubtypes(true).map { it.locale().language }
val cachedDictFolders = DictionaryInfoUtils.getCacheDirectories(ctx).map { it.name }
val comparer = compareBy<Locale>({ it.language !in enabledLanguages }, { it.toLanguageTag() !in cachedDictFolders}, { it.displayName })
val dictionaryLocales = remember { getDictionaryLocales(ctx).sortedWith(comparer).toMutableList() }
dictionaryLocales.add(0, Locale(SubtypeLocaleUtils.NO_LANGUAGE))
val dictionaryLocales = remember {
getDictionaryLocales(ctx).sortedWith(comparer).toMutableList().apply {
add(0, Locale(SubtypeLocaleUtils.NO_LANGUAGE))
}
}
var selectedLocale: Locale? by remember { mutableStateOf(null) }
var showAddDictDialog by remember { mutableStateOf(false) }
val dictPicker = dictionaryFilePicker(selectedLocale)

View file

@ -4,9 +4,13 @@ package helium314.keyboard.latin
import androidx.test.core.app.ApplicationProvider
import helium314.keyboard.ShadowInputMethodManager2
import helium314.keyboard.latin.common.StringUtils
import helium314.keyboard.latin.common.endsWithWordCodepoint
import helium314.keyboard.latin.common.getFullEmojiAtEnd
import helium314.keyboard.latin.common.getTouchedWordRange
import helium314.keyboard.latin.common.nonWordCodePointAndNoSpaceBeforeCursor
import helium314.keyboard.latin.settings.SpacingAndPunctuations
import helium314.keyboard.latin.utils.ScriptUtils
import helium314.keyboard.latin.utils.TextRange
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@ -60,6 +64,54 @@ class StringUtilsTest {
assert(nonWordCodePointAndNoSpaceBeforeCursor("th.is", sp))
}
@Test fun `is word-like at end`() {
val sp = SpacingAndPunctuations(ApplicationProvider.getApplicationContext<App>().resources, false)
assert(!endsWithWordCodepoint("", sp))
assert(endsWithWordCodepoint("don'", sp))
assert(!endsWithWordCodepoint("hello!", sp))
assert(!endsWithWordCodepoint("when ", sp))
assert(endsWithWordCodepoint("3-", sp)) // todo: this seems wrong
assert(endsWithWordCodepoint("5'", sp)) // todo: this seems wrong
assert(endsWithWordCodepoint("1", sp)) // todo: this seems wrong
assert(endsWithWordCodepoint("a-", sp))
assert(!endsWithWordCodepoint("--", sp))
}
@Test fun `get touched text range`() {
val sp = SpacingAndPunctuations(ApplicationProvider.getApplicationContext<App>().resources, false)
val spUrl = SpacingAndPunctuations(ApplicationProvider.getApplicationContext<App>().resources, true)
val script = ScriptUtils.SCRIPT_LATIN
checkTextRange("blabla this is v", "ery good", sp, script, 15, 19)
checkTextRange(".hel", "lo...", sp, script, 1, 6)
checkTextRange("(hi", ")", sp, script, 1, 3)
checkTextRange("", "word", sp, script, 0, 4)
checkTextRange("mail: blorb@", "florb.com or", sp, script, 12, 17)
checkTextRange("mail: blorb@", "florb.com or", spUrl, script, 6, 21)
checkTextRange("mail: blor", "b@florb.com or", sp, script, 6, 11)
checkTextRange("mail: blor", "b@florb.com or", spUrl, script, 6, 21)
checkTextRange("mail: blorb@f", "lorb.com or", sp, script, 12, 17)
checkTextRange("mail: blorb@f", "lorb.com or", spUrl, script, 6, 21)
checkTextRange("http://exam", "ple.com", sp, script, 7, 14)
checkTextRange("http://exam", "ple.com", spUrl, script, 7, 18)
checkTextRange("http://example.", "com", sp, script, 15, 18)
checkTextRange("http://example.", "com", spUrl, script, 7, 18)
checkTextRange("htt", "p://example.com", sp, script, 0, 4)
checkTextRange("htt", "p://example.com", spUrl, script, 0, 18)
checkTextRange("http:/", "/example.com", sp, script, 6, 6)
checkTextRange("http:/", "/example.com", spUrl, script, 0, 18)
checkTextRange("..", ".", spUrl, script, 2, 2)
checkTextRange("...", "", spUrl, script, 3, 3)
// todo: these are bad cases of url detection
// also: sometimesWordConnectors are for URL and should be named accordingly
checkTextRange("@@@", "@@@", spUrl, script, 0, 6)
checkTextRange("a...", "", spUrl, script, 0, 4)
checkTextRange("@@@", "", spUrl, script, 0, 3)
}
@Test fun detectEmojisAtEnd() {
assertEquals("", getFullEmojiAtEnd("\uD83C\uDF83 "))
assertEquals("", getFullEmojiAtEnd("a"))
@ -87,4 +139,10 @@ class StringUtilsTest {
// could help towards fully fixing https://github.com/Helium314/HeliBoard/issues/22
// though this might be tricky, as some emojis will show as one on new Android versions, and
// as two on older versions
private fun checkTextRange(before: String, after: String, sp: SpacingAndPunctuations, script: String, wordStart: Int, WordEnd: Int) {
val got = getTouchedWordRange(before, after, script, sp)
val wanted = TextRange(before + after, wordStart, WordEnd, before.length, false)
assertEquals(wanted, got)
}
}

View file

@ -7,7 +7,7 @@ buildscript {
google()
}
dependencies {
classpath("com.android.tools.build:gradle:8.9.2")
classpath("com.android.tools.build:gradle:8.9.3")
classpath(kotlin("gradle-plugin", version = kotlinVersion))
// NOTE: Do not place your application dependencies here; they belong

View file

@ -0,0 +1,10 @@
* add toolbar modes (allows hiding toolbar)
* add some missing emoji variants
* improve subtype screen and dictionary dialog
* fix colors when forcing dark mode
* move most of the portrait / landscape scale settings into a dialog
* remove translations of strings marked as non-translatable
* fix next-screen arrow direction for RTL languages
* fix proper loading of Hebrew locale on Android 15
* have at least a basic keyboard when library doesn't work at all
* minor bug fixes