From f2fbbe32ce5cab152e6922c433b352f777febc4a Mon Sep 17 00:00:00 2001 From: Khaled Date: Mon, 27 Jul 2026 23:47:48 +0400 Subject: [PATCH] Add Arabic keyboard layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standard Arabic layout alongside the English ones, plus the plumbing an RTL, caseless language needs: - Layout gains a layoutDirection, applied by Lp3Keyboard. The Arabic letter rows and backspace render right-to-left; digits, paired symbols and the utility row stay left-to-right. - Lp3BaseViewModel holds the shared viewmodel logic, with the number, symbol and emoji layers and long-press alternates as parameters. EnBaseViewModel keeps its signature and fills in the EnShared ones. - Rows 1 and 2 are the standard Arabic rows truncated to 10 keys, so those 20 keys keep their usual positions. The letters that fall off the ends join row 3, which has 8 keys since Arabic needs no shift. Hamza forms, ة and ى are on long-press. - Number layer keeps Western digits with Arabic punctuation (، ؛ ؟). - back_lp3 and return_lp3 are autoMirrored, and MultiLabelKey pins its label to LTR so "#+=" doesn't render reversed. The sample app's text field now follows the active layout's direction. --- README.md | 4 +- .../thelightphone/lp3keyboard/MainActivity.kt | 35 ++- .../lp3Keyboard/ui/Lp3Keyboard.kt | 35 ++- .../lp3Keyboard/ui/layout/ArShared.kt | 112 +++++++ .../lp3Keyboard/ui/layout/ArStandard.kt | 85 ++++++ .../lp3Keyboard/ui/layout/EnShared.kt | 13 +- .../ui/layout/Lp3KeyboardLayouts.kt | 19 +- .../ui/viewmodel/ArStandardViewModel.kt | 50 +++ .../ui/viewmodel/EnBaseViewModel.kt | 270 ++-------------- .../ui/viewmodel/Lp3BaseViewModel.kt | 287 ++++++++++++++++++ ui/src/main/res/drawable/back_lp3.xml | 1 + ui/src/main/res/drawable/return_lp3.xml | 1 + .../lp3Keyboard/ui/ArStandardLayoutTest.kt | 84 +++++ 13 files changed, 737 insertions(+), 259 deletions(-) create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArShared.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArStandard.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/ArStandardViewModel.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3BaseViewModel.kt create mode 100644 ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ArStandardLayoutTest.kt diff --git a/README.md b/README.md index d6f5827..c1fd982 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ If you'd like to contribute/file issues, please read [CONTRIBUTING.md](CONTRIBUT ### Layouts -Currently, only English/QWERTY is supported. We want to add more languages/layouts as soon as possible. Please reach out if there are any you are particularly excited about! +Currently supported: English (QWERTY and Colemak) and Arabic. We want to add more languages/layouts as soon as possible. Please reach out if there are any you are particularly excited about! + +A layout declares its own rows, long-press alternates, and text direction — see [ArStandard](ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArStandard.kt) for a right-to-left, caseless example — and is picked up by the app once it's added to `LayoutRegistryItem`. ## Usage diff --git a/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt b/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt index 704a5ed..b1fef2c 100644 --- a/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt +++ b/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt @@ -3,6 +3,8 @@ package com.thelightphone.lp3keyboard import android.content.Intent import android.os.Bundle import android.provider.Settings +import android.text.TextUtils +import android.view.View import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background @@ -21,6 +23,7 @@ import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,6 +32,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue @@ -56,6 +61,7 @@ fun Options() { .fillMaxWidth(), ) { val ctx = LocalContext.current + var selected by remember { mutableStateOf(LayoutPreferences.getActiveLayout(ctx)) } Text(text = "LP3 Keyboard") val (text, setValue) = remember { mutableStateOf(TextFieldValue("Try here")) } Spacer(modifier = Modifier.height(16.dp)) @@ -73,21 +79,30 @@ fun Options() { } Spacer(modifier = Modifier.height(16.dp)) Text(text = "3. Choose layout") - LayoutPicker() + LayoutPicker(selected) { selected = it } Spacer(modifier = Modifier.height(16.dp)) - TextField( - value = text, - onValueChange = setValue, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), - ) + // Follow the layout's own direction so an RTL layout types into a field that behaves like + // a real Arabic one: right-aligned, with the caret travelling leftward. + val direction = + if (TextUtils.getLayoutDirectionFromLocale(selected.locale) == View.LAYOUT_DIRECTION_RTL) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + } + CompositionLocalProvider(LocalLayoutDirection provides direction) { + TextField( + value = text, + onValueChange = setValue, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + } } } @Composable -fun LayoutPicker() { +fun LayoutPicker(selected: LayoutRegistryItem, onSelected: (LayoutRegistryItem) -> Unit) { val ctx = LocalContext.current - var selected by remember { mutableStateOf(LayoutPreferences.getActiveLayout(ctx)) } Column(modifier = Modifier.fillMaxWidth()) { LayoutRegistryItem.entries.forEach { item -> Row( @@ -96,7 +111,7 @@ fun LayoutPicker() { .selectable( selected = item == selected, onClick = { - selected = item + onSelected(item) LayoutPreferences.setActiveLayout(ctx, item) }, ), diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt index b3b5ee4..02eca9d 100644 --- a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -50,13 +51,16 @@ import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.thelightphone.lp3Keyboard.ui.layout.ArStandard import com.thelightphone.lp3Keyboard.ui.layout.EnQwerty import com.thelightphone.lp3Keyboard.ui.layout.EnShared import com.thelightphone.lp3Keyboard.ui.layout.Layout @@ -249,7 +253,10 @@ fun Lp3Keyboard( ) ) { Column(Modifier.fillMaxSize().padding(top = 4.dp).align(Alignment.Center)) { - CompositionLocalProvider(LocalAkkuratFamily provides akkurat) { + CompositionLocalProvider( + LocalAkkuratFamily provides akkurat, + LocalLayoutDirection provides layout.layoutDirection + ) { with(layout) { Render(options, callback) } } } @@ -525,6 +532,8 @@ fun RowScope.MultiLabelKey( letterSpacing = 2.sp, fontSize = 16.sp, textAlign = TextAlign.Center, + // "#+=" is all bidi-neutral, so an RTL layout would render it "=+#". + style = LocalTextStyle.current.copy(textDirection = TextDirection.Ltr), modifier = Modifier.then( if (enableKeyAnimation) { Modifier.graphicsLayer { @@ -716,6 +725,30 @@ fun Lp3KeyboardDarkPreview() { } } +@Preview(name = "Arabic", widthDp = (1080 / 3), heightDp = (1240 / 3)) +@Composable +fun Lp3KeyboardArabicPreview() { + Lp3KeyboardTheme(DarkKeyboardColors) { + Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.fillMaxSize()) { + val keyboardOptions = KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = true + ) + val layoutOptions = LayoutOptions(displayCloseButton = true) + Lp3KeyboardWrapper( + ArStandard.LettersLayout, + keyboardOptions, + layoutOptions, + previewCallback, + null + ) + } + } +} + @Preview(name = "Light", widthDp = (1080 / 3), heightDp = (1240 / 3)) @Composable fun Lp3KeyboardLightPreview() { diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArShared.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArShared.kt new file mode 100644 index 0000000..c4a3b13 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArShared.kt @@ -0,0 +1,112 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +/** + * Layouts and data generally shared across Arabic keyboards. + * + * These mirror [EnShared], with Arabic punctuation (، ؛ ؟) in place of the Latin equivalents. + * Digits stay Western (1234567890) — they're what phone numbers, codes, and most modern Arabic + * text use, and Arabic-Indic digits break numeric fields in a lot of apps. + * + * Rows that must read left-to-right — digits, paired symbols, and the utility row, which stays + * put so it doesn't collide with the system's corner buttons — override the RTL direction. + */ +object ArShared { + /** Label for the key that returns to the letters layout, the Arabic analogue of "ABC". */ + const val LETTERS_LABEL = "أبج" + + object NumberLayout : Layout { + override val layoutDirection: LayoutDirection + get() = LayoutDirection.Rtl + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + // Digits and paired symbols read left-to-right even inside Arabic text, so only the + // surrounding chrome mirrors. + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + FirstRow("1234567890", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("-/:؛()\$&@\"", callback, swipeConfig, options.enableKeyAnimation) + } + ThirdRow(".،؟!'", callback, swipeConfig, options) { + MultiLabelKey("#+=", SpecialKey.Symbols, callback, options.enableKeyAnimation) + } + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + FinalRow(options, callback) { + MultiLabelKey( + LETTERS_LABEL, + SpecialKey.Letters, + callback, + options.enableKeyAnimation + ) + } + } + } + } + + object SymbolsLayout : Layout { + override val layoutDirection: LayoutDirection + get() = LayoutDirection.Rtl + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + FirstRow("[]{}#%^*+=", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("_\\|~<>€£¥", callback, swipeConfig, options.enableKeyAnimation) + } + ThirdRow(".،؟!'", callback, swipeConfig, options) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + FinalRow(options, callback) { + MultiLabelKey( + LETTERS_LABEL, + SpecialKey.Letters, + callback, + options.enableKeyAnimation + ) + } + } + } + } + + /** + * Long-press alternates. Everything Arabic needs that isn't one of the 28 letters lives here: + * the hamza forms on ا, ta marbuta on ه and ت, alef maqsura on ي. + */ + val extendedCharMapping = mapOf( + 'ا'.code to listOf( + listOf('أ', 'إ', 'آ', 'ء', 'ٱ'), + ), + 'ه'.code to listOf( + listOf('ة'), + ), + 'ت'.code to listOf( + listOf('ة'), + ), + 'ي'.code to listOf( + listOf('ى', 'ئ'), + ), + 'و'.code to listOf( + listOf('ؤ'), + ), + ) +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArStandard.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArStandard.kt new file mode 100644 index 0000000..6684b48 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArStandard.kt @@ -0,0 +1,85 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardLayoutCapture +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +private val ArStandardSwipeConfig: SwipeConfig by lazy { + object : Lp3KeyboardLayoutCapture(ArStandard.ALPHABET) { + private val codes = letters.map { it.code }.toSet() + + override fun report(code: Int, bounds: Rect) { + if (code !in codes) return + // onGloballyPositioned fires on every layout pass; skip identical + // writes so we don't churn the snapshot or re-fire boundsFlow. + if (letterBounds[code] == bounds) return + letterBounds[code] = bounds + } + } +} + +/** + * The layout for standard Arabic. + * + * Rows are written in logical order — the first character of each string is the *rightmost* key, + * matching how the row reads on screen, since the layout renders right-to-left. + * + * The standard (Windows/iOS) Arabic layout needs 12/11/10 keys per row. We only have room for 10 + * at the LP3's key width, so the first two rows are the standard rows truncated to 10, which keeps + * every one of those 20 keys in its usual spot, and the letters that fall off the ends (ج د ط, plus + * ذ from the backtick key) join ر و ز ظ on the third row. Arabic is caseless, so the shift slot is + * free and the third row holds 8 letters instead of 7. + * + * Hamza forms and the other non-alphabet characters — أ إ آ ء ٱ ة ى ئ ؤ — are on long-press, + * see [ArShared.extendedCharMapping]. + */ +object ArStandard { + const val FIRST_ROW = "ضصثقفغعهخح" + const val SECOND_ROW = "شسيبلاتنمك" + const val THIRD_ROW = "روزظذطدج" + + /** The 28 letters of the Arabic alphabet, in alphabetical order. */ + const val ALPHABET = "ابتثجحخدذرزسشصضطظعغفقكلمنهوي" + + object LettersLayout : Layout { + override val isRootLayout: Boolean + get() = true + + override val swipeConfig: SwipeConfig + get() = ArStandardSwipeConfig + + override val layoutDirection: LayoutDirection + get() = LayoutDirection.Rtl + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow(FIRST_ROW, callback, swipeConfig, options.enableKeyAnimation) + SecondRow(SECOND_ROW, callback, swipeConfig, options.enableKeyAnimation) + // No shift key — the slot opposite backspace stays empty. + ThirdRow(THIRD_ROW, callback, swipeConfig, options) {} + // The utility row stays left-to-right: Android draws its own hide-keyboard chevron + // in the bottom-left corner and IME switcher in the bottom-right, and mirroring + // this row parks our mic and 123 keys right on top of them. + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt index a5a343d..8599bb3 100644 --- a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt @@ -2,6 +2,7 @@ package com.thelightphone.lp3Keyboard.ui.layout import androidx.compose.foundation.layout.ColumnScope import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.thelightphone.lp3Keyboard.ui.DefaultRow import com.thelightphone.lp3Keyboard.ui.FinalRow @@ -75,8 +76,16 @@ object EnShared { } } - class ExtendedCharKeyboard(rootCode: Int) : Layout { - private val rows = extendedCharMapping[rootCode] + /** + * The long-press sheet of alternates for [rootCode]. [mapping] and [layoutDirection] default + * to English, other languages pass their own (see [ArShared.extendedCharMapping]). + */ + class ExtendedCharKeyboard( + rootCode: Int, + mapping: Map>> = extendedCharMapping, + override val layoutDirection: LayoutDirection = LayoutDirection.Ltr + ) : Layout { + private val rows = mapping[rootCode] @Composable override fun ColumnScope.Render( diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt index f26ed8a..74c0e0e 100644 --- a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.thelightphone.lp3Keyboard.ui.DefaultRow import com.thelightphone.lp3Keyboard.ui.FinalRow @@ -22,6 +23,7 @@ import com.thelightphone.lp3Keyboard.ui.R import com.thelightphone.lp3Keyboard.ui.SecondRow import com.thelightphone.lp3Keyboard.ui.SpecialKey import com.thelightphone.lp3Keyboard.ui.ThirdRow +import com.thelightphone.lp3Keyboard.ui.viewmodel.ArStandardLp3KeyboardViewModel import com.thelightphone.lp3Keyboard.ui.viewmodel.EnColemakLp3KeyboardViewModel import com.thelightphone.lp3Keyboard.ui.viewmodel.EnQwertyLp3KeyboardViewModel import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3KeyboardViewModel @@ -38,7 +40,8 @@ enum class LayoutRegistryItem( val label: String ) { EnQwerty(Locale.ENGLISH, "qwerty", "QWERTY (English)"), - EnColemak(Locale.ENGLISH, "colemak", "Colemak (English)") + EnColemak(Locale.ENGLISH, "colemak", "Colemak (English)"), + ArStandard(Locale.forLanguageTag("ar"), "standard", "Standard (Arabic)") ; val uniqueId: String = "${locale}_$variant" @@ -68,6 +71,13 @@ fun LayoutRegistryItem.buildRootViewModel( haptic, optionsForLayout ) + + LayoutRegistryItem.ArStandard -> ArStandardLp3KeyboardViewModel( + passedCallback, + swipeCallback, + haptic, + optionsForLayout + ) } } @@ -86,4 +96,11 @@ sealed interface Layout { val swipeConfig: SwipeConfig? get() = null + + /** + * Direction the rows are laid out in. [LayoutDirection.Rtl] mirrors the whole keyboard, so + * the first character of a row string lands on the right and backspace moves to the left. + */ + val layoutDirection: LayoutDirection + get() = LayoutDirection.Ltr } \ No newline at end of file diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/ArStandardViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/ArStandardViewModel.kt new file mode 100644 index 0000000..efd26c5 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/ArStandardViewModel.kt @@ -0,0 +1,50 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.layout.ArShared +import com.thelightphone.lp3Keyboard.ui.layout.ArStandard +import com.thelightphone.lp3Keyboard.ui.layout.EnShared +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Arabic is caseless, so all three caps modes render the same layout — the shift key is never + * drawn and [Lp3BaseViewModel.capsMode] stays inert. The emoji layer is language-neutral, so it + * comes from [EnShared]. + */ +class ArStandardLp3KeyboardViewModel( + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback? = null, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ) +) : Lp3BaseViewModel( + passedCallback = passedCallback, + swipeCallback = swipeCallback, + haptic = haptic, + optionsForLayout = optionsForLayout, + keyboardOptionsFlow = keyboardOptionsFlow, + initialLayout = ArStandard.LettersLayout, + lowerCaseLayout = ArStandard.LettersLayout, + upperCaseLayout = ArStandard.LettersLayout, + capsLockedLayout = ArStandard.LettersLayout, + numberLayout = ArShared.NumberLayout, + symbolsLayout = ArShared.SymbolsLayout, + emojiLayout = EnShared.EmojiLayout, + extendedCharMapping = ArShared.extendedCharMapping, +) diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt index 1ab93fd..393813a 100644 --- a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt @@ -1,37 +1,30 @@ package com.thelightphone.lp3Keyboard.ui.viewmodel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.thelightphone.lp3Keyboard.ui.KeyboardOptions import com.thelightphone.lp3Keyboard.ui.LayoutOptions import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback -import com.thelightphone.lp3Keyboard.ui.SpecialKey -import com.thelightphone.lp3Keyboard.ui.SpecialKey.Close import com.thelightphone.lp3Keyboard.ui.layout.EnShared import com.thelightphone.lp3Keyboard.ui.layout.Layout -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch /** * An abstract view model for the base, shared logic for English keyboards. * * Typically, setting initial, lower, upper, and capslock layouts is enough to define a standard - * English keyboard. + * English keyboard. Everything else — the number/symbol/emoji layers and the long-press + * alternates — comes from [EnShared]. See [Lp3BaseViewModel] for the shared behavior. */ abstract class EnBaseViewModel( - private val passedCallback: Lp3RepeatableKeyboardCallback, - private val swipeCallback: Lp3KeyboardSwipeCallback?, - private val haptic: () -> Unit = {}, - private val optionsForLayout: (Layout) -> LayoutOptions = { + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback?, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { LayoutOptions( displayCloseButton = true ) }, - override val keyboardOptionsFlow: StateFlow = MutableStateFlow( + keyboardOptionsFlow: StateFlow = MutableStateFlow( KeyboardOptions( defaultEmojis, displayReturn = true, @@ -40,233 +33,22 @@ abstract class EnBaseViewModel( swipeEnabled = false ) ), - val initialLayout: Layout, - val lowerCaseLayout: Layout, - val upperCaseLayout: Layout, - val capsLockedLayout: Layout, -) : ViewModel(), Lp3KeyboardViewModel { - - var previousLayout: Layout? = null - private set - - private var swipeActive = false - - private val delegateCallback: Lp3RepeatableKeyboardCallback? - get() = passedCallback.takeUnless { swipeActive } - - override val layoutFlow: MutableStateFlow = MutableStateFlow(initialLayout) - - private fun setLayout(layout: Layout) { - previousLayout = layoutFlow.value - layoutOptionsFlow.value = optionsForLayout(layout) - layoutFlow.value = layout - } - - override val layoutOptionsFlow = MutableStateFlow(optionsForLayout(initialLayout)) - - companion object { - private const val REPEAT_INTERVAL_MS = 350L - } - - private val heldSpecialKeys = mutableMapOf() - private val heldKeys = mutableMapOf() - - override fun cancelHeldKeys() { - heldSpecialKeys.values.forEach { it.cancel() } - heldSpecialKeys.clear() - heldKeys.values.forEach { it.cancel() } - heldKeys.clear() - } - - var capsMode: CapsMode = CapsMode.Off - private set - - private fun showAlphabetLayout() { - setLayout( - when (capsMode) { - CapsMode.Off -> lowerCaseLayout - CapsMode.Single -> upperCaseLayout - CapsMode.Locked -> capsLockedLayout - } - ) - } - - override fun onKeyPressed(code: Int) { - haptic() - delegateCallback?.onKeyPressed(code) - } - - override fun onSpecialKeyPressed(key: SpecialKey) { - haptic() - delegateCallback?.onSpecialKeyPressed(key) - } - - override fun onKeyReleased(code: Int) { - heldKeys.remove(code)?.apply { - cancel() - return // swallow on key released if held - } - // eagerly drop single-caps so fast typists see lowercase before the IME round-trip - if (capsMode == CapsMode.Single) { - capsMode = CapsMode.Off - showAlphabetLayout() - } - // auto-dismiss when a special key is typed - if (layoutFlow.value is EnShared.ExtendedCharKeyboard) { - setLayout(previousLayout ?: lowerCaseLayout) - } - delegateCallback?.onKeyReleased(code) - } - - override fun onKeyCancelled(code: Int) { - // Finger left the key bounds — treat as the start of a swipe (or a - // deliberate tap-cancel). Clean up press state but don't fire the IME - // release, which is where text actually gets committed. - heldKeys.remove(code)?.cancel() - if (layoutFlow.value is EnShared.ExtendedCharKeyboard) { - setLayout(previousLayout ?: lowerCaseLayout) - } - } - - override fun onSpecialKeyReleased(key: SpecialKey) { - val repeatJob = heldSpecialKeys.remove(key) - // if we were long-pressing, swallow the release - repeatJob?.apply { - cancel() - return - } - var consumed = true - when (key) { - SpecialKey.UpCase, SpecialKey.DownCase -> { - capsMode = when (capsMode) { - CapsMode.Off -> CapsMode.Single - CapsMode.Single, CapsMode.Locked -> CapsMode.Off - } - showAlphabetLayout() - } - - SpecialKey.Numbers -> { - setLayout(EnShared.NumberLayout) - } - - SpecialKey.Letters -> { - showAlphabetLayout() - } - - SpecialKey.Symbols -> { - setLayout(EnShared.SymbolsLayout) - } - - SpecialKey.Emojis -> { - setLayout(EnShared.EmojiLayout) - } - - Close -> { - if (!layoutFlow.value.isRootLayout) { - showAlphabetLayout() - } else { - consumed = false - } - } - - else -> { - consumed = false - } - } - if (!consumed) { - delegateCallback?.onSpecialKeyReleased(key) - } - } - - /** Called by IME after each character to handle system-requested caps. */ - override fun setCapsMode(enabled: Boolean) { - if (capsMode == CapsMode.Locked) return - capsMode = if (enabled) CapsMode.Single else CapsMode.Off - when (layoutFlow.value) { - // only update the layout if we were already showing letters - lowerCaseLayout, upperCaseLayout, capsLockedLayout -> showAlphabetLayout() - else -> {} - } - } - - override fun onKeyLongPressed(code: Int) { - heldKeys[code]?.cancel() - if (EnShared.extendedCharMapping.containsKey(code)) { - haptic() - setLayout(EnShared.ExtendedCharKeyboard(code)) - heldKeys[code] = viewModelScope.launch { } - return - } - delegateCallback?.onKeyLongPressed(code) - heldKeys[code] = viewModelScope.launch { - while (isActive) { - delay(REPEAT_INTERVAL_MS) - delegateCallback?.onKeyRepeated(code) - } - } - } - - override fun onSpecialKeyLongPressed(key: SpecialKey) { - heldSpecialKeys[key]?.cancel() - val allowRepeats = when (key) { - SpecialKey.UpCase, SpecialKey.DownCase -> { - capsMode = if (capsMode == CapsMode.Locked) CapsMode.Off else CapsMode.Locked - heldSpecialKeys[key] = viewModelScope.launch { } - showAlphabetLayout() - // don't allow repeats since we switched layouts and the original button is gone - false - } - - else -> true - } - haptic() - delegateCallback?.onSpecialKeyLongPressed(key) - if (allowRepeats) { - heldSpecialKeys[key] = viewModelScope.launch { - while (isActive) { - delay(REPEAT_INTERVAL_MS) - delegateCallback?.onSpecialKeyRepeated(key) - } - } - } - } - - override fun onSubmitWord(word: CharSequence) { - delegateCallback?.onSubmitWord("$word ") - } - - override fun onSwipeStarted() { - if (keyboardOptionsFlow.value.swipeEnabled) { - swipeActive = true - } - } - - override fun onSwipeLayoutReady( - letters: String, - cx: FloatArray, - cy: FloatArray - ) { - swipeCallback?.onSwipeLayoutReady(letters, cx, cy) - } - - override fun onSwipeCompleted( - x: FloatArray, - y: FloatArray, - t: FloatArray - ): List { - val results = swipeCallback?.onSwipeCompleted(x,y,t) ?: emptyList() - swipeActive = false - if (results.isNotEmpty()) { - swipeCallback?.getWordForResult(results[0]) - ?.let(this::onSubmitWord) - } - return results - } - - override fun getWordForResult(swipeResult: SwipeResult) = swipeCallback?.getWordForResult(swipeResult) - - override fun onCleared() { - super.onCleared() - cancelHeldKeys() - } -} + initialLayout: Layout, + lowerCaseLayout: Layout, + upperCaseLayout: Layout, + capsLockedLayout: Layout, +) : Lp3BaseViewModel( + passedCallback = passedCallback, + swipeCallback = swipeCallback, + haptic = haptic, + optionsForLayout = optionsForLayout, + keyboardOptionsFlow = keyboardOptionsFlow, + initialLayout = initialLayout, + lowerCaseLayout = lowerCaseLayout, + upperCaseLayout = upperCaseLayout, + capsLockedLayout = capsLockedLayout, + numberLayout = EnShared.NumberLayout, + symbolsLayout = EnShared.SymbolsLayout, + emojiLayout = EnShared.EmojiLayout, + extendedCharMapping = EnShared.extendedCharMapping, +) diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3BaseViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3BaseViewModel.kt new file mode 100644 index 0000000..a8b7573 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3BaseViewModel.kt @@ -0,0 +1,287 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.SpecialKey.Close +import com.thelightphone.lp3Keyboard.ui.layout.EnShared +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * An abstract view model for the base, shared logic of any keyboard. + * + * Subclasses supply the alphabet layouts for each caps mode plus the number/symbol/emoji layers + * and long-press alternates for their language. Caseless languages (Arabic, for example) pass the + * same layout for all three caps modes. + */ +abstract class Lp3BaseViewModel( + private val passedCallback: Lp3RepeatableKeyboardCallback, + private val swipeCallback: Lp3KeyboardSwipeCallback?, + private val haptic: () -> Unit = {}, + private val optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + override val keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ), + val initialLayout: Layout, + val lowerCaseLayout: Layout, + val upperCaseLayout: Layout, + val capsLockedLayout: Layout, + val numberLayout: Layout, + val symbolsLayout: Layout, + val emojiLayout: Layout, + val extendedCharMapping: Map>>, +) : ViewModel(), Lp3KeyboardViewModel { + + var previousLayout: Layout? = null + private set + + private var swipeActive = false + + private val delegateCallback: Lp3RepeatableKeyboardCallback? + get() = passedCallback.takeUnless { swipeActive } + + override val layoutFlow: MutableStateFlow = MutableStateFlow(initialLayout) + + private fun setLayout(layout: Layout) { + previousLayout = layoutFlow.value + layoutOptionsFlow.value = optionsForLayout(layout) + layoutFlow.value = layout + } + + override val layoutOptionsFlow = MutableStateFlow(optionsForLayout(initialLayout)) + + companion object { + private const val REPEAT_INTERVAL_MS = 350L + } + + private val heldSpecialKeys = mutableMapOf() + private val heldKeys = mutableMapOf() + + override fun cancelHeldKeys() { + heldSpecialKeys.values.forEach { it.cancel() } + heldSpecialKeys.clear() + heldKeys.values.forEach { it.cancel() } + heldKeys.clear() + } + + var capsMode: CapsMode = CapsMode.Off + private set + + private fun showAlphabetLayout() { + setLayout( + when (capsMode) { + CapsMode.Off -> lowerCaseLayout + CapsMode.Single -> upperCaseLayout + CapsMode.Locked -> capsLockedLayout + } + ) + } + + override fun onKeyPressed(code: Int) { + haptic() + delegateCallback?.onKeyPressed(code) + } + + override fun onSpecialKeyPressed(key: SpecialKey) { + haptic() + delegateCallback?.onSpecialKeyPressed(key) + } + + override fun onKeyReleased(code: Int) { + heldKeys.remove(code)?.apply { + cancel() + return // swallow on key released if held + } + // eagerly drop single-caps so fast typists see lowercase before the IME round-trip + if (capsMode == CapsMode.Single) { + capsMode = CapsMode.Off + showAlphabetLayout() + } + // auto-dismiss when a special key is typed + if (layoutFlow.value is EnShared.ExtendedCharKeyboard) { + setLayout(previousLayout ?: lowerCaseLayout) + } + delegateCallback?.onKeyReleased(code) + } + + override fun onKeyCancelled(code: Int) { + // Finger left the key bounds — treat as the start of a swipe (or a + // deliberate tap-cancel). Clean up press state but don't fire the IME + // release, which is where text actually gets committed. + // A held key cancels for a different reason: opening the alternates sheet moves the key + // out from under the finger, so the gesture that opened the sheet is cancelled a few + // milliseconds later. Dismissing here would close the sheet before it could be used. + val wasHeld = heldKeys.remove(code)?.also { it.cancel() } != null + if (wasHeld) return + if (layoutFlow.value is EnShared.ExtendedCharKeyboard) { + setLayout(previousLayout ?: lowerCaseLayout) + } + } + + override fun onSpecialKeyReleased(key: SpecialKey) { + val repeatJob = heldSpecialKeys.remove(key) + // if we were long-pressing, swallow the release + repeatJob?.apply { + cancel() + return + } + var consumed = true + when (key) { + SpecialKey.UpCase, SpecialKey.DownCase -> { + capsMode = when (capsMode) { + CapsMode.Off -> CapsMode.Single + CapsMode.Single, CapsMode.Locked -> CapsMode.Off + } + showAlphabetLayout() + } + + SpecialKey.Numbers -> { + setLayout(numberLayout) + } + + SpecialKey.Letters -> { + showAlphabetLayout() + } + + SpecialKey.Symbols -> { + setLayout(symbolsLayout) + } + + SpecialKey.Emojis -> { + setLayout(emojiLayout) + } + + Close -> { + if (!layoutFlow.value.isRootLayout) { + showAlphabetLayout() + } else { + consumed = false + } + } + + else -> { + consumed = false + } + } + if (!consumed) { + delegateCallback?.onSpecialKeyReleased(key) + } + } + + /** Called by IME after each character to handle system-requested caps. */ + override fun setCapsMode(enabled: Boolean) { + if (capsMode == CapsMode.Locked) return + capsMode = if (enabled) CapsMode.Single else CapsMode.Off + when (layoutFlow.value) { + // only update the layout if we were already showing letters + lowerCaseLayout, upperCaseLayout, capsLockedLayout -> showAlphabetLayout() + else -> {} + } + } + + override fun onKeyLongPressed(code: Int) { + heldKeys[code]?.cancel() + if (extendedCharMapping.containsKey(code)) { + haptic() + setLayout( + EnShared.ExtendedCharKeyboard( + code, + extendedCharMapping, + lowerCaseLayout.layoutDirection + ) + ) + heldKeys[code] = viewModelScope.launch { } + return + } + delegateCallback?.onKeyLongPressed(code) + heldKeys[code] = viewModelScope.launch { + while (isActive) { + delay(REPEAT_INTERVAL_MS) + delegateCallback?.onKeyRepeated(code) + } + } + } + + override fun onSpecialKeyLongPressed(key: SpecialKey) { + heldSpecialKeys[key]?.cancel() + val allowRepeats = when (key) { + SpecialKey.UpCase, SpecialKey.DownCase -> { + capsMode = if (capsMode == CapsMode.Locked) CapsMode.Off else CapsMode.Locked + heldSpecialKeys[key] = viewModelScope.launch { } + showAlphabetLayout() + // don't allow repeats since we switched layouts and the original button is gone + false + } + + else -> true + } + haptic() + delegateCallback?.onSpecialKeyLongPressed(key) + if (allowRepeats) { + heldSpecialKeys[key] = viewModelScope.launch { + while (isActive) { + delay(REPEAT_INTERVAL_MS) + delegateCallback?.onSpecialKeyRepeated(key) + } + } + } + } + + override fun onSubmitWord(word: CharSequence) { + delegateCallback?.onSubmitWord("$word ") + } + + override fun onSwipeStarted() { + if (keyboardOptionsFlow.value.swipeEnabled) { + swipeActive = true + } + } + + override fun onSwipeLayoutReady( + letters: String, + cx: FloatArray, + cy: FloatArray + ) { + swipeCallback?.onSwipeLayoutReady(letters, cx, cy) + } + + override fun onSwipeCompleted( + x: FloatArray, + y: FloatArray, + t: FloatArray + ): List { + val results = swipeCallback?.onSwipeCompleted(x,y,t) ?: emptyList() + swipeActive = false + if (results.isNotEmpty()) { + swipeCallback?.getWordForResult(results[0]) + ?.let(this::onSubmitWord) + } + return results + } + + override fun getWordForResult(swipeResult: SwipeResult) = swipeCallback?.getWordForResult(swipeResult) + + override fun onCleared() { + super.onCleared() + cancelHeldKeys() + } +} diff --git a/ui/src/main/res/drawable/back_lp3.xml b/ui/src/main/res/drawable/back_lp3.xml index 6068082..0a78b35 100644 --- a/ui/src/main/res/drawable/back_lp3.xml +++ b/ui/src/main/res/drawable/back_lp3.xml @@ -1,4 +1,5 @@ (relaxed = true) + private val swipeCallback = mockk>(relaxed = true) + + private val vm = ArStandardLp3KeyboardViewModel( + passedCallback = callback, + swipeCallback = swipeCallback, + ) + + private val rows = listOf(ArStandard.FIRST_ROW, ArStandard.SECOND_ROW, ArStandard.THIRD_ROW) + + @Test + fun `the three rows hold every letter of the alphabet exactly once`() { + val keys = rows.flatMap { it.toList() } + assertEquals( + "no letter may appear on two keys", + keys.size, + keys.toSet().size + ) + assertEquals(ArStandard.ALPHABET.toSortedSet(), keys.toSortedSet()) + } + + @Test + fun `rows fit the key grid`() { + // 10 standard keys is the widest a row can be; the third row also carries backspace. + assertEquals(10, ArStandard.FIRST_ROW.length) + assertEquals(10, ArStandard.SECOND_ROW.length) + assertEquals(8, ArStandard.THIRD_ROW.length) + } + + @Test + fun `swipe decoding is offered the whole alphabet`() { + val capture = ArStandard.LettersLayout.swipeConfig as Lp3KeyboardLayoutCapture + assertEquals(ArStandard.ALPHABET, capture.letters) + } + + @Test + fun `long-press alternates hang off keys that are actually on the keyboard`() { + val onScreen = rows.flatMap { it.toList() }.map { it.code }.toSet() + val missing = ArShared.extendedCharMapping.keys.filterNot { it in onScreen } + assertTrue("no key on the layout for ${missing.map { it.toChar() }}", missing.isEmpty()) + } + + @Test + fun `layer switching uses the Arabic number and symbol layouts`() { + vm.onSpecialKeyReleased(SpecialKey.Numbers) + assertSame(ArShared.NumberLayout, vm.layoutFlow.value) + + vm.onSpecialKeyReleased(SpecialKey.Symbols) + assertSame(ArShared.SymbolsLayout, vm.layoutFlow.value) + + vm.onSpecialKeyReleased(SpecialKey.Letters) + assertSame(ArStandard.LettersLayout, vm.layoutFlow.value) + } + + @Test + fun `caps is inert on a caseless layout`() { + vm.onSpecialKeyReleased(SpecialKey.UpCase) + assertEquals(CapsMode.Single, vm.capsMode) + assertSame(ArStandard.LettersLayout, vm.layoutFlow.value) + + vm.onKeyPressed('ا'.code) + vm.onKeyReleased('ا'.code) + + verify(exactly = 1) { callback.onKeyReleased('ا'.code) } + assertEquals(CapsMode.Off, vm.capsMode) + assertSame(ArStandard.LettersLayout, vm.layoutFlow.value) + } +}