Skip to content

Commit

Permalink
Support plural string resource (#4519)
Browse files Browse the repository at this point in the history
Ports a part of Unicode's ICU in pure Kotlin and implements
Android-style plural string resource support. Fixes
#425.

# Changes

- Added `org.jetbrains.compose.resources.intl.{PluralCategory,
PluralRule, PluralRuleList}`, which parses and evaluates scripts in
Unicode's Locale Data Markup Langauge.
- Copied `plurals.xml` from Unicode's
[CLDR](https://github.com/unicode-org/cldr/blob/release-44-1/common/supplemental/plurals.xml).
- Added `GeneratePluralRuleListsTask`, which parses `plurals.xml` and
generates required Kotlin source codes.
- Added `PluralStringResource`, `pluralStringResource`, or
`getPluralString`, corresponding to `StringResource`, `stringResource`,
or `getString`.
- Modified `ResourcesSpec.kt` so the generated `Res` class exposes
`Res.plurals`.

# Potential Further Improvements

- [ ] Allow configuring the default language in the `compose.resources
{}` block (#4482) to determine the default pluralization rule (or just
presume English as default)
- [ ] Move the parser logic to the Gradle plugin and generate
pluralization rules in `Res` only for languages used in
`composeResources`

---------

Co-authored-by: Konstantin Tskhovrebov <konstantin.tskhovrebov@jetbrains.com>
  • Loading branch information
paxbun and terrakok committed Mar 25, 2024
1 parent adfb71f commit 2b1bf65
Show file tree
Hide file tree
Showing 24 changed files with 2,827 additions and 18 deletions.
134 changes: 134 additions & 0 deletions components/buildSrc/src/main/kotlin/GeneratePluralRuleListsTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/

import groovy.util.Node
import groovy.xml.XmlParser
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.*

/**
* Reads a pluralization rules XML file from Unicode's CLDR and generates a Kotlin file that holds the XML content as
* arrays. This Task is required for quantity string resource support.
*/
@CacheableTask
abstract class GeneratePluralRuleListsTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val pluralsFile: RegularFileProperty

@get:OutputFile
abstract val outputFile: RegularFileProperty

@get:OutputFile
abstract val samplesOutputFile: RegularFileProperty

@TaskAction
fun generatePluralRuleLists() {
val pluralRuleLists = parsePluralRuleLists()

val mainContent = generateMainContent(pluralRuleLists)
outputFile.get().asFile.writeText(mainContent)

val testContent = generateTestContent(pluralRuleLists)
samplesOutputFile.get().asFile.writeText(testContent)
}

private fun parsePluralRuleLists(): List<PluralRuleList> {
val parser = XmlParser(false, false).apply {
setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
setFeature("http://apache.org/xml/features/disallow-doctype-decl", false)
}
val supplementalData = parser.parse(pluralsFile.get().asFile)
val pluralRuleLists = supplementalData.children().filterIsInstance<Node>().first { it.name() == "plurals" }

return pluralRuleLists.children().filterIsInstance<Node>().map { pluralRules ->
val locales = pluralRules.attribute("locales").toString().split(' ')
PluralRuleList(
locales,
pluralRules.children().filterIsInstance<Node>().map { pluralRule ->
val rule = pluralRule.text().split('@')
PluralRule(
pluralRule.attribute("count").toString(),
// trim samples as not needed
rule[0].trim(),
rule.firstOrNull { it.startsWith("integer") }?.substringAfter("integer")?.trim() ?: "",
rule.firstOrNull { it.startsWith("decimal") }?.substringAfter("decimal")?.trim() ?: "",
)
}
)
}
}

private fun generateMainContent(pluralRuleLists: List<PluralRuleList>): String {
val pluralRuleListIndexByLocale = pluralRuleLists.flatMapIndexed { idx, pluralRuleList ->
pluralRuleList.locales.map { locale ->
locale to idx
}
}

return """
package org.jetbrains.compose.resources.plural
/**
* THIS CODE IS AUTOGENERATED BY './gradlew :resources:library:generatePluralRuleLists'
* DO NOT EDIT!!!
*/
internal val cldrPluralRuleListIndexByLocale = mapOf(
${pluralRuleListIndexByLocale.joinToString(separator = ",\n ") { (locale, idx) ->
"\"$locale\" to $idx"
}}
)
internal val cldrPluralRuleLists = arrayOf(${pluralRuleLists.joinToString(",") { pluralRuleList ->
"""
arrayOf(
${pluralRuleList.rules.joinToString(",\n ") { rule ->
"PluralCategory.${rule.count.uppercase()} to \"${rule.rule}\""
}}
)"""
}}
)
""".trimIndent()
}

private fun generateTestContent(pluralRuleLists: List<PluralRuleList>): String {
val pluralRuleIntegerSamplesByLocale = pluralRuleLists.flatMap { pluralRuleList ->
pluralRuleList.locales.map { locale ->
locale to pluralRuleList.rules.map { it.count to it.integerSample }
}
}

return """
package org.jetbrains.compose.resources.plural
/**
* THIS CODE IS AUTOGENERATED BY './gradlew :resources:library:generatePluralRuleLists'
* DO NOT EDIT!!!
*/
internal val cldrPluralRuleIntegerSamples = arrayOf(
${pluralRuleIntegerSamplesByLocale.joinToString(",\n ") { (locale, samples) ->
""""$locale" to arrayOf(
${samples.joinToString(",\n ") { (count, sample) ->
"PluralCategory.${count.uppercase()} to \"$sample\""
}}
)"""
}}
)
""".trimIndent()
}
}

private data class PluralRuleList(
val locales: List<String>,
val rules: List<PluralRule>,
)

private data class PluralRule(
val count: String,
val rule: String,
val integerSample: String,
val decimalSample: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ Donec eget turpis ac sem ultricies consequat.</string>
<item>item \u2318</item>
<item>item \u00BD</item>
</string-array>
<plurals name="new_message">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import components.resources.demo.shared.generated.resources.Res
import components.resources.demo.shared.generated.resources.*
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.*

@Composable
fun StringRes(paddingValues: PaddingValues) {
Expand Down Expand Up @@ -99,5 +99,25 @@ fun StringRes(paddingValues: PaddingValues) {
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
)
)
var numMessages by remember { mutableStateOf(0) }
OutlinedTextField(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
value = pluralStringResource(Res.plurals.new_message, numMessages, numMessages),
onValueChange = {},
label = { Text("Text(pluralStringResource(Res.plurals.new_message, $numMessages, $numMessages))") },
leadingIcon = {
Row {
IconButton({ numMessages += 1 }) {
Icon(Icons.Default.Add, contentDescription = "Add Message")
}
}
},
enabled = false,
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
)
)
}
}

0 comments on commit 2b1bf65

Please sign in to comment.