Refactor Locale class and add LocaleProvider test (#1283)

* Refactor Locale class and add LocaleProvider test

* Make setLocale parameter nullable in Locale class

Changed the setLocale parameter in the Locale class to be nullable and updated its usage to safely invoke it. This allows for more flexible instantiation when a setLocale function is not required.

* Add compose ui test to the deps

* Update locale change method in test

Replaces the call to locale.setLocale with locale.set in LocaleKtTest to match the updated API for changing the locale.
This commit is contained in:
Stef Tervelde
2025-10-17 03:56:35 +02:00
committed by GitHub
parent d3681f38c6
commit bf4d163c2e
3 changed files with 162 additions and 22 deletions

View File

@@ -1,6 +1,7 @@
import org.gradle.internal.jvm.Jvm
import org.gradle.internal.os.OperatingSystem
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.compose.ExperimentalComposeLibrary
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download
@@ -119,6 +120,8 @@ dependencies {
implementation(libs.markdown)
implementation(libs.markdownJVM)
@OptIn(ExperimentalComposeLibrary::class)
testImplementation(compose.uiTest)
testImplementation(kotlin("test"))
testImplementation(libs.mockitoKotlin)
testImplementation(libs.junitJupiter)

View File

@@ -1,24 +1,41 @@
package processing.app.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import processing.app.LocalPreferences
import processing.app.Messages
import processing.app.Platform
import processing.app.PlatformStart
import processing.app.watchFile
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import processing.app.*
import java.io.File
import java.io.InputStream
import java.util.*
class Locale(language: String = "") : Properties() {
/**
* The Locale class extends the standard Java Properties class
* to provide localization capabilities.
* It loads localization resources from property files based on the specified language code.
* The class also provides a method to change the current locale and update the application accordingly.
* Usage:
* ```
* val locale = Locale("es") { newLocale ->
* // Handle locale change, e.g., update UI or restart application
* }
* val localizedString = locale["someKey"]
* ```
*/
class Locale(language: String = "", val setLocale: ((java.util.Locale) -> Unit)? = null) : Properties() {
var locale: java.util.Locale = java.util.Locale.getDefault()
init {
val locale = java.util.Locale.getDefault()
load(ClassLoader.getSystemResourceAsStream("PDE.properties"))
load(ClassLoader.getSystemResourceAsStream("PDE_${locale.language}.properties") ?: InputStream.nullInputStream())
load(ClassLoader.getSystemResourceAsStream("PDE_${locale.toLanguageTag()}.properties") ?: InputStream.nullInputStream())
load(ClassLoader.getSystemResourceAsStream("PDE_${language}.properties") ?: InputStream.nullInputStream())
loadResourceUTF8("PDE.properties")
loadResourceUTF8("PDE_${locale.language}.properties")
loadResourceUTF8("PDE_${locale.toLanguageTag()}.properties")
loadResourceUTF8("PDE_${language}.properties")
}
fun loadResourceUTF8(path: String) {
val stream = ClassLoader.getSystemResourceAsStream(path)
stream?.reader(charset = Charsets.UTF_8)?.use { reader ->
load(reader)
}
}
@Deprecated("Use get instead", ReplaceWith("get(key)"))
@@ -28,18 +45,86 @@ class Locale(language: String = "") : Properties() {
return value
}
operator fun get(key: String): String = getProperty(key, key)
fun set(locale: java.util.Locale) {
setLocale?.invoke(locale)
}
}
val LocalLocale = compositionLocalOf { Locale() }
/**
* A CompositionLocal to provide access to the Locale instance
* throughout the composable hierarchy. see [LocaleProvider]
* Usage:
* ```
* val locale = LocalLocale.current
* val localizedString = locale["someKey"]
* ```
*/
val LocalLocale = compositionLocalOf<Locale> { error("No Locale Set") }
/**
* This composable function sets up a locale provider that manages application localization.
* It initializes the locale from a language file, watches for changes to that file, and updates
* the locale accordingly. It uses a [Locale] class to handle loading of localized resources.
*
* Usage:
* ```
* LocaleProvider {
* // Your app content here
* }
* ```
*
* To access the locale:
* ```
* val locale = LocalLocale.current
* val localizedString = locale["someKey"]
* ```
*
* To change the locale:
* ```
* locale.set(java.util.Locale("es"))
* ```
* This will update the `language.txt` file and reload the locale.
*/
@Composable
fun LocaleProvider(content: @Composable () -> Unit) {
PlatformStart()
val preferencesFolderOverride: File? = System.getProperty("processing.app.preferences.folder")?.let { File(it) }
val settingsFolder = Platform.getSettingsFolder()
val languageFile = File(settingsFolder, "language.txt")
watchFile(languageFile)
val settingsFolder = preferencesFolderOverride ?: remember{
Platform.init()
Platform.getSettingsFolder()
}
val languageFile = settingsFolder.resolve("language.txt")
remember(languageFile){
if(languageFile.exists()) return@remember
val locale = Locale(languageFile.readText().substring(0, 2))
CompositionLocalProvider(LocalLocale provides locale) {
content()
Messages.log("Creating language file at ${languageFile.absolutePath}")
settingsFolder.mkdirs()
languageFile.writeText(java.util.Locale.getDefault().language)
}
val update = watchFile(languageFile)
var code by remember(languageFile, update){ mutableStateOf(languageFile.readText().substring(0, 2)) }
remember(code) {
val locale = java.util.Locale(code)
java.util.Locale.setDefault(locale)
}
fun setLocale(locale: java.util.Locale) {
Messages.log("Setting locale to ${locale.language}")
languageFile.writeText(locale.language)
code = locale.language
}
val locale = Locale(code, ::setLocale)
remember(code) { Messages.log("Loaded Locale: $code") }
val dir = when(locale["locale.direction"]) {
"rtl" -> LayoutDirection.Rtl
else -> LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides dir) {
CompositionLocalProvider(LocalLocale provides locale) {
content()
}
}
}

View File

@@ -0,0 +1,52 @@
package processing.app
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import processing.app.ui.theme.LocalLocale
import processing.app.ui.theme.LocaleProvider
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
class LocaleKtTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun testLocale() = runComposeUiTest {
val tempPreferencesDir = createTempDirectory("preferences")
System.setProperty("processing.app.preferences.folder", tempPreferencesDir.toFile().absolutePath)
setContent {
LocaleProvider {
val locale = LocalLocale.current
Text(locale["menu.file.new"], modifier = Modifier.testTag("localisedText"))
Button(onClick = {
locale.set(java.util.Locale("es"))
}, modifier = Modifier.testTag("button")) {
Text("Change")
}
}
}
// Check if usage generates the language file if it doesn't exist
val languageFile = tempPreferencesDir.resolve("language.txt").toFile()
assert(languageFile.exists())
// Check if the text is localised
onNodeWithTag("localisedText").assertTextEquals("New")
// Change the locale to Spanish
onNodeWithTag("button").performClick()
onNodeWithTag("localisedText").assertTextEquals("Nuevo")
// Check if the preference was saved to file
assert(languageFile.readText().substring(0, 2) == "es")
}
}