Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 25 additions & 10 deletions app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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(
Expand All @@ -96,7 +111,7 @@ fun LayoutPicker() {
.selectable(
selected = item == selected,
onClick = {
selected = item
onSelected(item)
LayoutPreferences.setActiveLayout(ctx, item)
},
),
Expand Down
35 changes: 34 additions & 1 deletion ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
112 changes: 112 additions & 0 deletions ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/ArShared.kt
Original file line number Diff line number Diff line change
@@ -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('ؤ'),
),
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Int, List<List<Char>>> = extendedCharMapping,
override val layoutDirection: LayoutDirection = LayoutDirection.Ltr
) : Layout {
private val rows = mapping[rootCode]

@Composable
override fun ColumnScope.Render(
Expand Down
Loading
Loading