From 4546a3878ace2fca7027b27a14817fc0039b35d6 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 11:39:03 +0200 Subject: [PATCH 1/4] Refactor Locale class and add LocaleProvider test --- app/src/processing/app/ui/theme/Locale.kt | 129 ++++++++++++++++++---- app/test/processing/app/LocaleKtTest.kt | 52 +++++++++ 2 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 app/test/processing/app/LocaleKtTest.kt diff --git a/app/src/processing/app/ui/theme/Locale.kt b/app/src/processing/app/ui/theme/Locale.kt index 254c0946c1..0879418a88 100644 --- a/app/src/processing/app/ui/theme/Locale.kt +++ b/app/src/processing/app/ui/theme/Locale.kt @@ -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) : 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(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 { 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 = preferencesFolderOverride ?: remember{ + Platform.init() + Platform.getSettingsFolder() + } + val languageFile = settingsFolder.resolve("language.txt") + remember(languageFile){ + if(languageFile.exists()) return@remember - val settingsFolder = Platform.getSettingsFolder() - val languageFile = File(settingsFolder, "language.txt") - watchFile(languageFile) + 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 = 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 + } - val locale = Locale(languageFile.readText().substring(0, 2)) - CompositionLocalProvider(LocalLocale provides locale) { - content() + CompositionLocalProvider(LocalLayoutDirection provides dir) { + CompositionLocalProvider(LocalLocale provides locale) { + content() + } } } \ No newline at end of file diff --git a/app/test/processing/app/LocaleKtTest.kt b/app/test/processing/app/LocaleKtTest.kt new file mode 100644 index 0000000000..a4e7d637cf --- /dev/null +++ b/app/test/processing/app/LocaleKtTest.kt @@ -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.setLocale(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") + } +} \ No newline at end of file From 643ec03090d725ab30de2e57970903cb97969193 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 11:42:36 +0200 Subject: [PATCH 2/4] 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. --- app/src/processing/app/ui/theme/Locale.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/processing/app/ui/theme/Locale.kt b/app/src/processing/app/ui/theme/Locale.kt index 0879418a88..d760998185 100644 --- a/app/src/processing/app/ui/theme/Locale.kt +++ b/app/src/processing/app/ui/theme/Locale.kt @@ -21,7 +21,7 @@ import java.util.* * val localizedString = locale["someKey"] * ``` */ -class Locale(language: String = "", val setLocale: (java.util.Locale) -> Unit) : Properties() { +class Locale(language: String = "", val setLocale: ((java.util.Locale) -> Unit)? = null) : Properties() { var locale: java.util.Locale = java.util.Locale.getDefault() init { @@ -46,7 +46,7 @@ class Locale(language: String = "", val setLocale: (java.util.Locale) -> Unit) : } operator fun get(key: String): String = getProperty(key, key) fun set(locale: java.util.Locale) { - setLocale(locale) + setLocale?.invoke(locale) } } /** @@ -104,7 +104,7 @@ fun LocaleProvider(content: @Composable () -> Unit) { val update = watchFile(languageFile) var code by remember(languageFile, update){ mutableStateOf(languageFile.readText().substring(0, 2)) } remember(code) { - val locale = Locale(code) + val locale = java.util.Locale(code) java.util.Locale.setDefault(locale) } From 06e309484065b5cb7f31812c9e569db92f073b7c Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 14 Oct 2025 12:49:25 +0200 Subject: [PATCH 3/4] Add compose ui test to the deps --- app/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0d3fcbd12d..1aea9ac6b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) From d42fb2fe365653bea7e052ba2e2bc660719e00c7 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 11:48:00 +0200 Subject: [PATCH 4/4] 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. --- app/test/processing/app/LocaleKtTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/test/processing/app/LocaleKtTest.kt b/app/test/processing/app/LocaleKtTest.kt index a4e7d637cf..f8ed32164a 100644 --- a/app/test/processing/app/LocaleKtTest.kt +++ b/app/test/processing/app/LocaleKtTest.kt @@ -28,7 +28,7 @@ class LocaleKtTest { Text(locale["menu.file.new"], modifier = Modifier.testTag("localisedText")) Button(onClick = { - locale.setLocale(java.util.Locale("es")) + locale.set(java.util.Locale("es")) }, modifier = Modifier.testTag("button")) { Text("Change") }