Skip to main content

Internationalization & Multilingual Input

Overview

FlorisBoard provides comprehensive internationalization (i18n) support, enabling users worldwide to type in their native languages with proper locale handling, RTL support, and multilingual input capabilities.

Introduction

Internationalization is a core feature of FlorisBoard, supporting over 100 languages and locales. The keyboard handles complex input methods including:

  • Multiple Scripts: Latin, Cyrillic, Arabic, Hebrew, CJK (Chinese, Japanese, Korean), Indic scripts, and more
  • RTL Languages: Right-to-left text input for Arabic, Hebrew, Persian, Urdu
  • Complex Input Methods: Composing characters for Asian languages, diacritics, ligatures
  • Language Switching: Quick switching between multiple language layouts
  • Locale-Aware Features: Date formats, number formats, currency symbols

Key Concepts

FlorisLocale

FlorisBoard uses a custom FlorisLocale class that wraps java.util.Locale to provide consistent language handling:

@Serializable(with = FlorisLocale.Serializer::class)
class FlorisLocale private constructor(val base: Locale) {
companion object {
private const val DELIMITER_LANGUAGE_TAG = '-'
private const val DELIMITER_LOCALE_TAG = '_'

fun fromTag(tag: String): FlorisLocale
fun from(language: String, country: String = ""): FlorisLocale
fun default(): FlorisLocale
fun installedSystemLocales(): List<FlorisLocale>
}
}

Subtypes

A Subtype represents a complete language configuration including:

  • Primary Locale: Main language (e.g., en_US, fr_FR, ja_JP)
  • Secondary Locales: Additional languages for multilingual typing
  • Layout Map: Keyboard layouts for different modes (characters, symbols, numeric)
  • Composer: Input method for character composition (e.g., Hangul, Kana, Telex)
  • Popup Mapping: Long-press character variants
  • Currency Set: Currency symbols for the locale
  • NLP Providers: Spell checking and prediction engines
@Serializable
data class Subtype(
val id: Long,
val primaryLocale: FlorisLocale,
val secondaryLocales: List<FlorisLocale>,
val nlpProviders: SubtypeNlpProviderMap = SubtypeNlpProviderMap(),
val composer: ExtensionComponentName,
val currencySet: ExtensionComponentName,
val punctuationRule: ExtensionComponentName,
val popupMapping: ExtensionComponentName,
val layoutMap: SubtypeLayoutMap,
)

SubtypeManager

Manages active subtypes and language switching:

class SubtypeManager(context: Context) {
private val _activeSubtypeFlow = MutableStateFlow(Subtype.DEFAULT)
val activeSubtypeFlow = _activeSubtypeFlow.asStateFlow()
var activeSubtype: Subtype

fun switchToNextSubtype()
fun addSubtype(subtype: Subtype): Boolean
fun removeSubtype(subtype: Subtype): Boolean
fun modifySubtype(old: Subtype, new: Subtype): Boolean
}

Composers

Composers handle complex character input for languages that require composition:

  • Appender: Simple character appending (default for most languages)
  • Hangul Unicode: Korean Hangul composition
  • Kana Unicode: Japanese Hiragana/Katakana composition
  • With Rules: Rule-based composition (e.g., Vietnamese Telex)

Implementation Details

Locale Management

System Locale Integration

FlorisBoard syncs with system locales and can display keyboard labels in the subtype's language:

override fun onCreate() {
super.onCreate()
subtypeManager.activeSubtypeFlow.collectIn(lifecycleScope) { subtype ->
val config = Configuration(resources.configuration)
if (prefs.localization.displayKeyboardLabelsInSubtypeLanguage.get()) {
config.setLocale(subtype.primaryLocale.base)
}
resourcesContext = createConfigurationContext(config)
}
}

Extended Locale Support

FlorisBoard extends system locales with language pack extensions:

fun extendedAvailableLocales(context: Context): List<FlorisLocale> {
val systemLocales = installedSystemLocales()
val extensionManager by context.extensionManager()
val extraLocales = buildList {
for (languagePackExtension in extensionManager.languagePacks.value ?: listOf()) {
for (languagePackItem in languagePackExtension.items) {
val locale = languagePackItem.locale
if (from(locale.language, locale.country).localeTag() in systemLocalesSet) {
add(locale.localeTag())
}
}
}
}.toSet()
return systemLocales + extraLocales.map { fromTag(it) }
}

Language Switching

Quick Language Switch

Users can switch between configured subtypes:

fun switchToNextSubtype() = scope.launch {
val subtypeList = subtypes
val cachedActiveSubtype = activeSubtype
var triggerNextSubtype = false
var newActiveSubtype: Subtype = Subtype.DEFAULT
for (subtype in subtypeList) {
if (triggerNextSubtype) {
triggerNextSubtype = false
newActiveSubtype = subtype
} else if (subtype == cachedActiveSubtype) {
triggerNextSubtype = true
}
}
if (triggerNextSubtype) {
newActiveSubtype = subtypeList.first()
}
prefs.localization.activeSubtypeId.set(newActiveSubtype.id)
activeSubtype = newActiveSubtype
}

RTL Support

FlorisBoard properly handles right-to-left languages:

@Composable
fun ProvideLocalizedResources(
resourcesContext: Context,
@StringRes appName: Int,
forceLayoutDirection: LayoutDirection? = null,
content: @Composable () -> Unit,
) {
val layoutDirection = forceLayoutDirection ?: when (resourcesContext.resources.configuration.layoutDirection) {
View.LAYOUT_DIRECTION_LTR -> LayoutDirection.Ltr
View.LAYOUT_DIRECTION_RTL -> LayoutDirection.Rtl
else -> error("Given configuration specifies invalid layout direction!")
}
// Apply layout direction to content
}

Unicode Directional Controls

object UnicodeCtrlChar {
/** Sets base direction to LTR and isolates the embedded content */
const val LeftToRightIsolate = "\u2066"

/** Sets base direction to RTL and isolates the embedded content */
const val RightToLeftIsolate = "\u2067"

/** Isolates content and sets direction by first strong directional character */
const val FirstStrongIsolate = "\u2068"

/** Closes a previously opened isolated text block */
const val PopDirectionalIsolate = "\u2069"
}

Character Composition

Vietnamese Telex Example

{
"$": "with-rules",
"id": "telex",
"label": "Telex",
"rules": {
"aw": "ă", "aa": "â", "dd": "đ", "ee": "ê", "oo": "ô",
"af": "à", "ar": "ả", "ax": "ã", "as": "á", "aj": "ạ",
"ăf": "ằ", "ăr": "ẳ", "ăx": "ẵ", "ăs": "ắ", "ăj": "ặ"
}
}

Code Examples

Creating a Custom Subtype

val customSubtype = Subtype(
id = System.currentTimeMillis(),
primaryLocale = FlorisLocale.from("es", "ES"),
secondaryLocales = listOf(FlorisLocale.from("en", "US")),
composer = extCoreComposer("appender"),
currencySet = extCoreCurrencySet("euro"),
punctuationRule = extCorePunctuationRule("default"),
popupMapping = extCorePopupMapping("spanish"),
layoutMap = SubtypeLayoutMap(
characters = extCoreLayout("spanish/qwerty"),
symbols = extCoreLayout("symbols"),
numeric = extCoreLayout("numeric")
)
)

subtypeManager.addSubtype(customSubtype)

Displaying Locale Names

@Composable
fun LocaleSelector() {
val context = LocalContext.current
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
val systemLocales = FlorisLocale.extendedAvailableLocales(context)
.sortedBy { locale ->
when (displayLanguageNamesIn) {
DisplayLanguageNamesIn.SYSTEM_LOCALE -> locale.displayName()
DisplayLanguageNamesIn.NATIVE_LOCALE -> locale.displayName(locale)
}.lowercase()
}

LazyColumn {
items(systemLocales) { locale ->
Text(
text = when (displayLanguageNamesIn) {
DisplayLanguageNamesIn.SYSTEM_LOCALE -> locale.displayName()
DisplayLanguageNamesIn.NATIVE_LOCALE -> locale.displayName(locale)
}
)
}
}
}

Handling Language-Specific Input

fun handleEnterKey(subtype: Subtype, activeContent: EditorContent): Boolean {
// Special handling for Chinese: commit composing text on enter
return if (subtype.primaryLocale.language.startsWith("zh") &&
activeContent.composing.length > 0) {
finalizeComposingText(activeContent.composingText)
true
} else {
false
}
}

RTL-Aware Layout

@Composable
fun RTLAwareKeyboard() {
val layoutDirection = LocalLayoutDirection.current

CompositionLocalProvider(
LocalLayoutDirection provides when (layoutDirection) {
LayoutDirection.Rtl -> LayoutDirection.Rtl
else -> LayoutDirection.Ltr
}
) {
KeyboardLayout()
}
}

Best Practices

1. Use FlorisLocale for Consistency

// Good
val locale = FlorisLocale.from("ja", "JP")
val displayName = locale.displayName()

// Avoid
val locale = Locale("ja", "JP")

2. Respect User's Language Preferences

val shouldSyncLabels = prefs.localization.displayKeyboardLabelsInSubtypeLanguage.get()
if (shouldSyncLabels) {
config.setLocale(subtype.primaryLocale.base)
}

3. Handle Locale-Specific Formatting

val dateFormatter = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withLocale(locale.base)
.withZone(ZoneId.systemDefault())

4. Provide Fallbacks

val displayName = locale.displayLanguage().ifBlank {
locale.base.language
}

5. Test with Multiple Scripts

Always test your keyboard with:

  • Latin scripts (English, Spanish, French)
  • RTL scripts (Arabic, Hebrew)
  • CJK scripts (Chinese, Japanese, Korean)
  • Indic scripts (Hindi, Tamil, Bengali)

Common Patterns

Locale Selection UI

@Composable
fun SelectLocaleScreen() {
val context = LocalContext.current
val navController = LocalNavController.current
var searchTerm by remember { mutableStateOf("") }

val systemLocales = FlorisLocale.extendedAvailableLocales(context)
val filteredLocales = systemLocales.filter { locale ->
locale.displayName().contains(searchTerm, ignoreCase = true)
}

Column {
SearchBar(
value = searchTerm,
onValueChange = { searchTerm = it }
)

LazyColumn {
items(filteredLocales) { locale ->
LocaleItem(
locale = locale,
onClick = {
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_locale", locale.languageTag())
navController.popBackStack()
}
)
}
}
}
}

Subtype Persistence

class SubtypeManager {
private fun persistNewSubtypeList(list: List<Subtype>) = scope.launch {
val listRaw = SubtypeJsonConfig.encodeToString(list)
prefs.localization.subtypes.set(listRaw)
}

init {
prefs.localization.subtypes.asFlow().collectLatestIn(scope) { listRaw ->
val list = if (listRaw.isNotBlank()) {
SubtypeJsonConfig.decodeFromString<List<Subtype>>(listRaw)
} else {
emptyList()
}
subtypes = list
evaluateActiveSubtype(list)
}
}
}

Troubleshooting

Locale Not Displaying Correctly

Problem: Language names show incorrectly or in wrong script.

Solutions:

  • Check displayLanguageNamesIn preference
  • Verify locale is properly constructed
  • Ensure system supports the locale
  • Use displayName(locale) for native names

RTL Layout Issues

Problem: RTL languages display incorrectly.

Solutions:

  • Ensure LocalLayoutDirection is properly set
  • Use CompositionLocalProvider to force direction
  • Test with LayoutDirection.Rtl
  • Check for hardcoded LTR assumptions

Character Composition Not Working

Problem: Composed characters don't appear correctly.

Solutions:

  • Verify correct composer is selected
  • Check composer rules are loaded
  • Ensure subtype has proper composer configuration
  • Test with simple appender first

Language Switching Fails

Problem: Can't switch between languages.

Solutions:

  • Verify multiple subtypes are configured
  • Check switchToNextSubtype() is called
  • Ensure subtypes are persisted
  • Validate subtype list is not empty

Next Steps


Note: This documentation is continuously being improved. Contributions are welcome!