Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
129 changes: 107 additions & 22 deletions app/src/processing/app/ui/theme/Locale.kt
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines -17 to +31
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is so nice

}

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)"))
Expand All @@ -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 = 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 = 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
}

val locale = Locale(languageFile.readText().substring(0, 2))
CompositionLocalProvider(LocalLocale provides locale) {
content()
CompositionLocalProvider(LocalLayoutDirection provides dir) {
CompositionLocalProvider(LocalLocale provides locale) {
content()
}
Comment on lines +125 to +128
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah cool! So this is like how things are composable? People can pass in a block that will be wrapped by this Locale work

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, if you want access to localised strings you do

LocaleProvider {
  val locale = LocalLocale.current
  val localizedString = locale["someKey"]
}

And the beatiful things about these contexts is that if you want to display say something in Spanish within the English interface, you can override the LocalLocale with a CompositionLocalProvider. In that case its not super helpful but there's a lot of other cases where its very much is

}
}
52 changes: 52 additions & 0 deletions app/test/processing/app/LocaleKtTest.kt
Original file line number Diff line number Diff line change
@@ -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")
}
}