From 4546a3878ace2fca7027b27a14817fc0039b35d6 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 11:39:03 +0200 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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") } From 58c746b291b05dc5f2d4ad1a701329a964569d88 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 13:22:18 +0200 Subject: [PATCH 5/6] Add PDE window utilities for Compose and Swing Introduces PDESwingWindow and PDEComposeWindow classes to simplify creating themed and localized windows in Compose and Swing applications. Includes macOS-specific handling for full window content and localization support for window titles. --- app/src/processing/app/ui/theme/Window.kt | 140 ++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 app/src/processing/app/ui/theme/Window.kt diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt new file mode 100644 index 0000000000..6f49843678 --- /dev/null +++ b/app/src/processing/app/ui/theme/Window.kt @@ -0,0 +1,140 @@ +package processing.app.ui.theme + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import com.formdev.flatlaf.util.SystemInfo + +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.JFrame + +val LocalWindow = compositionLocalOf { error("No Window Set") } + +/** + * A utility class to create a new Window with Compose content in a Swing application. + * It sets up the window with some default properties and allows for custom content. + * Use this when creating a Compose based window from Swing. + * + * Usage example: + * ``` + * SwingUtilities.invokeLater { + * PDESwingWindow("menu.help.welcome", fullWindowContent = true) { + * + * } + * } + * ``` + * + * @param titleKey The key for the window title, which will be localized. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param content The composable content to be displayed in the window. + */ +class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, content: @Composable BoxScope.() -> Unit): JFrame(){ + init{ + val window = this + defaultCloseOperation = DISPOSE_ON_CLOSE + ComposePanel().apply { + setContent { + PDEWindowContent(window, titleKey, fullWindowContent, content) + } + window.add(this) + } + background = java.awt.Color.white + setLocationRelativeTo(null) + addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ESCAPE) window.dispose() + } + }) + isResizable = false + isVisible = true + requestFocus() + } +} + +/** + * Internal Composable function to set up the window content with theming and localization. + * It also handles macOS specific properties for full window content. + * + * @param window The JFrame instance to be configured. + * @param titleKey The key for the window title, which will be localized. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param content The composable content to be displayed in the window. + */ +@Composable +private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent: Boolean = false, content: @Composable BoxScope.() -> Unit){ + val mac = SystemInfo.isMacOS && SystemInfo.isMacFullWindowContentSupported + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", mac && fullWindowContent) + } + + CompositionLocalProvider(LocalWindow provides window) { + ProcessingTheme { + val locale = LocalLocale.current + window.title = locale[titleKey] + LaunchedEffect(locale) { + window.pack() + window.setLocationRelativeTo(null) + } + + Box(modifier = Modifier.padding(top = if (mac && !fullWindowContent) 22.dp else 0.dp),content = content) + } + } +} + +/** + * A Composable function to create and display a new window with the specified content. + * This function sets up the window state and handles the close request. + * Use this when creating a Compose based window from another Compose context. + * + * Usage example: + * ``` + * PDEComposeWindow("window.title", fullWindowContent = true, onClose = { /* handle close */ }) { + * // Your window content here + * Text("Hello, World!") + * } + * ``` + * + * This will create a new window with the title localized from "window.title" key, + * with content extending into the title bar area on macOS, and a custom close handler. + * + * Fully standalone example: + * ``` + * application { + * PDEComposeWindow("window.title", fullWindowContent = true, onClose = ::exitApplication) { + * // Your window content here + * } + * } + * ``` + * + * @param titleKey The key for the window title, which will be localized. + * @param fullWindowContent If true, the content will extend into the title bar area on + * macOS. + * @param onClose A lambda function to be called when the window is requested to close. + * @param content The composable content to be displayed in the window. + * + * + * + */ +@Composable +fun PDEComposeWindow(titleKey: String, fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable BoxScope.() -> Unit){ + val windowState = rememberWindowState( + size = DpSize.Unspecified, + position = WindowPosition(Alignment.Center) + ) + Window(onCloseRequest = onClose, state = windowState, title = "") { + PDEWindowContent(window, titleKey, fullWindowContent, content) + } +} \ No newline at end of file From db69773c43ed84cd24e43baa22279e8ce1125bf2 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 13:36:17 +0200 Subject: [PATCH 6/6] Refactor beta welcome window handling Replaces custom JFrame setup in WelcomeToBeta with PDESwingWindow and PDEComposeWindow, centralizing window logic and close handling. Adds onClose callback to PDESwingWindow for improved lifecycle management. Also ensures beta welcome preference is reset on forced update check. --- app/src/processing/app/ui/Editor.java | 1 + app/src/processing/app/ui/WelcomeToBeta.kt | 57 +++++----------------- app/src/processing/app/ui/theme/Window.kt | 7 ++- 3 files changed, 18 insertions(+), 47 deletions(-) diff --git a/app/src/processing/app/ui/Editor.java b/app/src/processing/app/ui/Editor.java index df2440d391..e4b4f15879 100644 --- a/app/src/processing/app/ui/Editor.java +++ b/app/src/processing/app/ui/Editor.java @@ -1057,6 +1057,7 @@ public void buildDevelopMenu(){ var updateTrigger = new JMenuItem(Language.text("menu.develop.check_for_updates")); updateTrigger.addActionListener(e -> { Preferences.unset("update.last"); + Preferences.setInteger("update.beta_welcome", 0); new UpdateCheck(base); }); developMenu.add(updateTrigger); diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt index 7757e820f6..ce10fb67cd 100644 --- a/app/src/processing/app/ui/WelcomeToBeta.kt +++ b/app/src/processing/app/ui/WelcomeToBeta.kt @@ -41,6 +41,8 @@ import processing.app.Base.getVersionName import processing.app.ui.theme.LocalLocale import processing.app.ui.theme.LocalTheme import processing.app.ui.theme.Locale +import processing.app.ui.theme.PDEComposeWindow +import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.ProcessingTheme import java.awt.Cursor import java.awt.Dimension @@ -54,46 +56,20 @@ import javax.swing.SwingUtilities class WelcomeToBeta { companion object{ - val windowSize = Dimension(400, 200) - val windowTitle = Locale()["beta.window.title"] - @JvmStatic fun showWelcomeToBeta() { - val mac = SystemInfo.isMacFullWindowContentSupported SwingUtilities.invokeLater { - JFrame(windowTitle).apply { - val close = { - Preferences.set("update.beta_welcome", getRevision().toString()) - dispose() - } - rootPane.putClientProperty("apple.awt.transparentTitleBar", mac) - rootPane.putClientProperty("apple.awt.fullWindowContent", mac) - defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - contentPane.add(ComposePanel().apply { - size = windowSize - setContent { - ProcessingTheme { - Box(modifier = Modifier.padding(top = if (mac) 22.dp else 0.dp)) { - welcomeToBeta(close) - } - } - } - }) - pack() - background = java.awt.Color.white - setLocationRelativeTo(null) - addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - if (e.keyCode == KeyEvent.VK_ESCAPE) close() - } - }) - isResizable = false - isVisible = true - requestFocus() + val close = { + Preferences.set("update.beta_welcome", getRevision().toString()) + } + + PDESwingWindow("beta.window.title", onClose = close) { + welcomeToBeta(close) } } } + val windowSize = Dimension(400, 200) @Composable fun welcomeToBeta(close: () -> Unit = {}) { Row( @@ -194,18 +170,9 @@ class WelcomeToBeta { @JvmStatic fun main(args: Array) { application { - val windowState = rememberWindowState( - size = DpSize.Unspecified, - position = WindowPosition(Alignment.Center) - ) - - Window(onCloseRequest = ::exitApplication, state = windowState, title = windowTitle) { - ProcessingTheme { - Surface(color = colors.background) { - welcomeToBeta { - exitApplication() - } - } + PDEComposeWindow(titleKey = "beta.window.title", onClose = ::exitApplication){ + welcomeToBeta { + exitApplication() } } } diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index 6f49843678..91d245089e 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -40,7 +40,7 @@ val LocalWindow = compositionLocalOf { error("No Window Set") } * @param fullWindowContent If true, the content will extend into the title bar area on macOS. * @param content The composable content to be displayed in the window. */ -class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, content: @Composable BoxScope.() -> Unit): JFrame(){ +class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable BoxScope.() -> Unit): JFrame(){ init{ val window = this defaultCloseOperation = DISPOSE_ON_CLOSE @@ -54,7 +54,10 @@ class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, setLocationRelativeTo(null) addKeyListener(object : KeyAdapter() { override fun keyPressed(e: KeyEvent) { - if (e.keyCode == KeyEvent.VK_ESCAPE) window.dispose() + if (e.keyCode != KeyEvent.VK_ESCAPE) return + + window.dispose() + onClose() } }) isResizable = false