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
Related Topics
- Language Packs - Installing additional language support
- Layout System - Keyboard layout configuration
- Custom UI Components - Building locale-aware UI
- Architecture Overview - System architecture
Next Steps
- Explore Language Packs for additional language support
- Learn about Layout System for custom layouts
- Contribute translations to FlorisBoard on Crowdin
Note: This documentation is continuously being improved. Contributions are welcome!