From 7fecf27abfd42423311bf9684fddd7d1773ac517 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 14 Oct 2025 10:34:49 +0200 Subject: [PATCH 01/39] Remove ContributionManager and ContributionPane UI files Deleted ContributionManager.kt and ContributionPane.kt from the contrib/ui directory. This removes the Compose-based contributions manager and its detail pane prototypes which got merged unnecessarily --- .../app/contrib/ui/ContributionManager.kt | 310 ------------------ .../app/contrib/ui/ContributionPane.kt | 79 ----- 2 files changed, 389 deletions(-) delete mode 100644 app/src/processing/app/contrib/ui/ContributionManager.kt delete mode 100644 app/src/processing/app/contrib/ui/ContributionPane.kt diff --git a/app/src/processing/app/contrib/ui/ContributionManager.kt b/app/src/processing/app/contrib/ui/ContributionManager.kt deleted file mode 100644 index 2ad472159b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionManager.kt +++ /dev/null @@ -1,310 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.animation.Animatable -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import com.charleskorn.kaml.Yaml -import com.charleskorn.kaml.YamlConfiguration -import kotlinx.serialization.Serializable -import processing.app.Platform -import processing.app.loadPreferences -import java.net.URL -import java.util.* -import javax.swing.JFrame -import javax.swing.SwingUtilities -import kotlin.io.path.* - - -fun main() = application { - Window(onCloseRequest = ::exitApplication) { - contributionsManager() - } -} - -enum class Status { - VALID, - BROKEN, - DEPRECATED -} -enum class Type { - library, - mode, - tool, - examples, -} - -@Serializable -data class Author( - val name: String, - val url: String? = null, -) - -@Serializable -data class Contribution( - val id: Int, - val status: Status, - val source: String, - val type: Type, - val name: String? = null, - val categories: List? = emptyList(), - val authors: String? = null, - val authorList: List? = emptyList(), - val url: String? = null, - val sentence: String? = null, - val paragraph: String? = null, - val version: String? = null, - val prettyVersion: String? = null, - val minRevision: Int? = null, - val maxRevision: Int? = null, - val download: String? = null, - val isUpdate: Boolean? = null, - val isInstalled: Boolean? = null, -) - -@Serializable -data class Contributions( - val contributions: List -) - -fun openContributionsManager(){ - // open the compose window - - SwingUtilities.invokeLater { - val frame = JFrame("Contributions Manager") - frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - frame.setSize(800, 600) - - val composePanel = ComposePanel() - composePanel.setContent { - contributionsManager() - } - - frame.contentPane.add(composePanel) - frame.isVisible = true - } -} - -@Composable -fun contributionsManager(){ - var contributions by remember { mutableStateOf(listOf()) } - var localContributions by remember { mutableStateOf(listOf()) } - var error by remember { mutableStateOf(null) } - - val preferences = loadPreferences() - - LaunchedEffect(preferences){ - try { - localContributions = loadContributionProperties(preferences) - .map { (type, props) -> - Contribution( - id = 0, - status = Status.VALID, - source = "local", - type = type, - name = props.getProperty("name"), - authors = props.getProperty("authors"), - url = props.getProperty("url"), - sentence = props.getProperty("sentence"), - paragraph = props.getProperty("paragraph"), - version = props.getProperty("version"), - prettyVersion = props.getProperty("prettyVersion"), - minRevision = props.getProperty("minRevision")?.toIntOrNull(), - maxRevision = props.getProperty("maxRevision")?.toIntOrNull(), - download = props.getProperty("download"), - ) - } - } catch (e: Exception){ - error = e - } - } - - - LaunchedEffect(Unit){ - try { - val url = URL("https://github.com/mingness/processing-contributions-new/raw/refs/heads/main/contributions.yaml") - val connection = url.openConnection() - val inputStream = connection.getInputStream() - val yaml = inputStream.readAllBytes().decodeToString() - // TODO cache yaml in processing folder - - val parser = Yaml( - configuration = YamlConfiguration( - strictMode = false - ) - ) - val result = parser.decodeFromString(Contributions.serializer(), yaml) - - contributions = result.contributions - .filter { it.status == Status.VALID } - .map { - // TODO Parse better - val authorList = it.authors?.split(",")?.map { author -> - val parts = author.split("](") - val name = parts[0].removePrefix("[") - val url = parts.getOrNull(1)?.removeSuffix(")") - Author(name, url) - } ?: emptyList() - it.copy(authorList = authorList) - } - } catch (e: Exception){ - error = e - } - } - if(error != null){ - Text("Error loading contributions: ${error?.message}") - return - } - if(contributions.isEmpty()){ - Text("Loading contributions...") - return - } - - val contributionsByType = (contributions + localContributions) - .groupBy { it.name } - .map { (_, contributions) -> - if(contributions.size == 1) return@map contributions.first() - else{ - // check if they all have the same version, otherwise return the newest version - val versions = contributions.mapNotNull { it.version } - if(versions.toSet().size == 1) return@map contributions.first().copy(isInstalled = true) - else{ - val newest = contributions.maxByOrNull { it.version?.toIntOrNull() ?: 0 } - if(newest != null) return@map newest.copy(isUpdate = true, isInstalled = true) - else return@map contributions.first().copy(isUpdate = true, isInstalled = true) - } - } - } - .groupBy { it.type } - - val types = Type.entries - var selectedType by remember { mutableStateOf(types.first()) } - val contributionsForType = (contributionsByType[selectedType] ?: emptyList()) - .sortedBy { it.name } - - var selectedContribution by remember { mutableStateOf(null) } - Box{ - Column { - Row{ - for(type in types){ - val background = remember { Animatable(Color.Transparent) } - val color = remember { Animatable(Color.Black) } - LaunchedEffect(selectedType){ - if(selectedType == type){ - background.animateTo(Color(0xff0251c8)) - color.animateTo(Color.White) - }else{ - background.animateTo(Color.Transparent) - color.animateTo(Color.Black) - } - } - - Row(modifier = Modifier - .background(background.value) - .pointerHoverIcon(PointerIcon.Hand) - .clickable { - selectedType = type - selectedContribution = null - } - .padding(16.dp, 8.dp) - ){ - Text(type.name, color = color.value) - val updates = contributionsByType[type]?.count { it.isUpdate == true } ?: 0 - if(updates > 0){ - Text("($updates)") - } - } - } - } - - Box(modifier = Modifier.weight(1f)){ - val state = rememberLazyListState() - LazyColumn(state = state) { - item{ - // Table Header - } - items(contributionsForType){ contribution -> - Row(modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .clickable { selectedContribution = contribution } - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(modifier = Modifier.weight(1f)){ - if(contribution.isUpdate == true){ - Text("Update") - }else if(contribution.isInstalled == true){ - Text("Installed") - } - - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(8f)){ - Text(contribution.name ?: "Unnamed", fontWeight = FontWeight.Bold) - Text(contribution.sentence ?: "No description", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Row(modifier = Modifier.weight(4f)){ - Text(contribution.authorList?.joinToString { it.name } ?: "Unknown") - } - } - } - } - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .background(Color.LightGray) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter( - scrollState = state - ) - ) - } - ContributionPane( - contribution = selectedContribution, - onClose = { selectedContribution = null } - ) - } - - } - -} - - -fun loadContributionProperties(preferences: Properties): List>{ - val result = mutableListOf>() - val sketchBook = Path(preferences.getProperty("sketchbook.path.four", Platform.getDefaultSketchbookFolder().path)) - sketchBook.forEachDirectoryEntry{ contributionsFolder -> - if(!contributionsFolder.isDirectory()) return@forEachDirectoryEntry - val typeName = contributionsFolder.fileName.toString() - val type: Type = when(typeName){ - "libraries" -> Type.library - "modes" -> Type.mode - "tools" -> Type.tool - "examples" -> Type.examples - else -> return@forEachDirectoryEntry - } - contributionsFolder.forEachDirectoryEntry { contribution -> - if(!contribution.isDirectory()) return@forEachDirectoryEntry - contribution.forEachDirectoryEntry("*.properties"){ entry -> - val props = Properties() - props.load(entry.inputStream()) - result += Pair(type, props) - } - } - } - return result -} \ No newline at end of file diff --git a/app/src/processing/app/contrib/ui/ContributionPane.kt b/app/src/processing/app/contrib/ui/ContributionPane.kt deleted file mode 100644 index 2f4a96931b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionPane.kt +++ /dev/null @@ -1,79 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Window - -//--processing-blue-light: #82afff; -//--processing-blue-mid: #0564ff; -//--processing-blue-deep: #1e32aa; -//--processing-blue-dark: #0f195a; -//--processing-blue: #0251c8; - -@Composable -fun ContributionPane(contribution: Contribution?, onClose: () -> Unit) { - if(contribution == null) { - return - } - val typeName = when(contribution.type) { - Type.library -> "Library" - Type.tool -> "Tool" - Type.examples -> "Example" - Type.mode -> "Mode" - } - Window( - title = "${typeName}: ${contribution.name}", - onCloseRequest = onClose, - onKeyEvent = { - if(it.key == Key.Escape) { - onClose() - true - } else { - false - } - } - ){ - Box { - Column(modifier = Modifier.padding(10.dp)) { - Text(typeName, style = TextStyle(fontSize = 16.sp)) - Text(contribution.name ?: "", style = TextStyle(fontSize = 20.sp)) - Row(modifier = Modifier.padding(0.dp, 10.dp)) { - val action = when(contribution.isUpdate) { - true -> "Update" - false, null -> when(contribution.isInstalled) { - true -> "Uninstall" - false, null -> "Install" - } - } - Text(action, - style = TextStyle(fontSize = 14.sp, color = Color.White), - modifier = Modifier - .clickable { - - } - .pointerHoverIcon(PointerIcon.Hand) - .background(Color(0xff0251c8)) - .padding(24.dp,12.dp) - ) - } - Text(contribution.paragraph ?: "", style = TextStyle(fontSize = 14.sp)) - } - } - } - -} \ No newline at end of file From 838ea14cd52a1a3d9a3779b6ce0010a22379f1ea Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 14 Oct 2025 11:53:38 +0200 Subject: [PATCH 02/39] Enhance Preferences reactivity and test coverage Refactored ReactiveProperties to use snapshotStateMap for Compose reactivity. Improved PreferencesProvider and watchFile composables with better file watching, override support via system properties, and added documentation. Updated PreferencesKtTest to use temporary files and verify file-to-UI reactivity. --- app/src/processing/app/Preferences.kt | 163 ++++++++++++++++--- app/test/processing/app/PreferencesKtTest.kt | 61 +++++++ 2 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 app/test/processing/app/PreferencesKtTest.kt diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c5645c9bbc..c54cbbd817 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -2,56 +2,183 @@ package processing.app import androidx.compose.runtime.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream import java.nio.file.* import java.util.Properties +/* + The ReactiveProperties class extends the standard Java Properties class + to provide reactive capabilities using Jetpack Compose's mutableStateMapOf. + This allows UI components to automatically update when preference values change. +*/ +class ReactiveProperties: Properties() { + val snapshotStateMap = mutableStateMapOf() + + override fun setProperty(key: String, value: String) { + super.setProperty(key, value) + snapshotStateMap[key] = value + } + + override fun getProperty(key: String): String? { + return snapshotStateMap[key] ?: super.getProperty(key) + } + + operator fun get(key: String): String? = getProperty(key) + + operator fun set(key: String, value: String) { + setProperty(key, value) + } +} + +/* + A CompositionLocal to provide access to the ReactiveProperties instance + throughout the composable hierarchy. + */ +val LocalPreferences = compositionLocalOf { error("No preferences provided") } const val PREFERENCES_FILE_NAME = "preferences.txt" const val DEFAULTS_FILE_NAME = "defaults.txt" -fun PlatformStart(){ - Platform.inst ?: Platform.init() -} +/* + This composable function sets up a preferences provider that manages application settings. + It initializes the preferences from a file, watches for changes to that file, and saves + any updates back to the file. It uses a ReactiveProperties class to allow for reactive + updates in the UI when preferences change. + usage: + PreferencesProvider { + // Your app content here + } + + to access preferences: + val preferences = LocalPreferences.current + val someSetting = preferences["someKey"] ?: "defaultValue" + preferences["someKey"] = "newValue" + + This will automatically save to the preferences file and update any UI components + that are observing that key. + + to override the preferences file (for testing, etc) + System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt") + to override the debounce time (in milliseconds) + System.setProperty("processing.app.preferences.debounce", "200") + + */ +@OptIn(FlowPreview::class) @Composable -fun loadPreferences(): Properties{ - PlatformStart() +fun PreferencesProvider(content: @Composable () -> Unit){ + val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) } + val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull() - val settingsFolder = Platform.getSettingsFolder() - val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME) + // Initialize the platform (if not already done) to ensure we have access to the settings folder + remember { + Platform.init() + } + // Grab the preferences file, creating it if it doesn't exist + // TODO: This functionality should be separated from the `Preferences` class itself + val settingsFolder = Platform.getSettingsFolder() + val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME) if(!preferencesFile.exists()){ + preferencesFile.mkdirs() preferencesFile.createNewFile() } - watchFile(preferencesFile) - return Properties().apply { - load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream()) - load(preferencesFile.inputStream()) + val update = watchFile(preferencesFile) + + + val properties = remember(preferencesFile, update) { + ReactiveProperties().apply { + val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) + ?: InputStream.nullInputStream() + load(defaultsStream + .reader(Charsets.UTF_8) + ) + load(preferencesFile + .inputStream() + .reader(Charsets.UTF_8) + ) + } + } + + val initialState = remember(properties) { properties.snapshotStateMap.toMap() } + + // Listen for changes to the preferences and save them to file + LaunchedEffect(properties) { + snapshotFlow { properties.snapshotStateMap.toMap() } + .dropWhile { it == initialState } + .debounce(preferencesDebounceOverride ?: 100) + .collect { + + // Save the preferences to file, sorted alphabetically + preferencesFile.outputStream().use { output -> + output.write( + properties.entries + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() }) + .joinToString("\n") { (key, value) -> "$key=$value" } + .toByteArray() + ) + } + } + } + + CompositionLocalProvider(LocalPreferences provides properties){ + content() } + } +/* + This composable function watches a specified file for modifications. When the file is modified, + it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates + or other actions in response to changes in the file. + + To watch the file at the fasted speed (for testing) set the following system property: + System.setProperty("processing.app.watchfile.forced", "true") + */ @Composable fun watchFile(file: File): Any? { + val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean() + val scope = rememberCoroutineScope() var event by remember(file) { mutableStateOf?> (null) } DisposableEffect(file){ val fileSystem = FileSystems.getDefault() val watcher = fileSystem.newWatchService() + var active = true + // In forced mode we just poll the last modified time of the file + // This is not efficient but works better for testing with temp files + val toWatch = { file.lastModified() } + var state = toWatch() + val path = file.toPath() val parent = path.parent val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY) scope.launch(Dispatchers.IO) { while (active) { - for (modified in key.pollEvents()) { - if (modified.context() != path.fileName) continue - event = modified + if(forcedWatch) { + if(toWatch() == state) continue + state = toWatch() + event = object : WatchEvent { + override fun count(): Int = 1 + override fun context(): Path = file.toPath().fileName + override fun kind(): WatchEvent.Kind = StandardWatchEventKinds.ENTRY_MODIFY + override fun toString(): String = "ForcedEvent(${context()})" + } + continue + }else{ + for (modified in key.pollEvents()) { + if (modified.context() != path.fileName) continue + event = modified + } } } } @@ -62,12 +189,4 @@ fun watchFile(file: File): Any? { } } return event -} -val LocalPreferences = compositionLocalOf { error("No preferences provided") } -@Composable -fun PreferencesProvider(content: @Composable () -> Unit){ - val preferences = loadPreferences() - CompositionLocalProvider(LocalPreferences provides preferences){ - content() - } } \ No newline at end of file diff --git a/app/test/processing/app/PreferencesKtTest.kt b/app/test/processing/app/PreferencesKtTest.kt new file mode 100644 index 0000000000..6b5dbc5ea9 --- /dev/null +++ b/app/test/processing/app/PreferencesKtTest.kt @@ -0,0 +1,61 @@ +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.* +import java.util.Properties +import kotlin.io.path.createFile +import kotlin.io.path.createTempDirectory +import kotlin.test.Test + +class PreferencesKtTest{ + @OptIn(ExperimentalTestApi::class) + @Test + fun testKeyReactivity() = runComposeUiTest { + val directory = createTempDirectory("preferences") + val tempPreferences = directory + .resolve("preferences.txt") + .createFile() + .toFile() + + // Set system properties for testing + System.setProperty("processing.app.preferences.file", tempPreferences.absolutePath) + System.setProperty("processing.app.preferences.debounce", "0") + System.setProperty("processing.app.watchfile.forced", "true") + + val newValue = (0..Int.MAX_VALUE).random().toString() + val testKey = "test.preferences.reactivity" + + setContent { + PreferencesProvider { + val preferences = LocalPreferences.current + Text(preferences[testKey] ?: "default", modifier = Modifier.testTag("text")) + + Button(onClick = { + preferences[testKey] = newValue + }, modifier = Modifier.testTag("button")) { + Text("Change") + } + } + } + + onNodeWithTag("text").assertTextEquals("default") + onNodeWithTag("button").performClick() + onNodeWithTag("text").assertTextEquals(newValue) + + val preferences = Properties() + preferences.load(tempPreferences.inputStream().reader(Charsets.UTF_8)) + + // Check if the preference was saved to file + assert(preferences[testKey] == newValue) + + + val nextValue = (0..Int.MAX_VALUE).random().toString() + // Overwrite the file to see if the UI updates + tempPreferences.writeText("$testKey=${nextValue}") + + onNodeWithTag("text").assertTextEquals(nextValue) + } +} \ No newline at end of file From e1e1e3a602d6791b99f4bd605f0fbda69a5610d4 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 14 Oct 2025 12:02:05 +0200 Subject: [PATCH 03/39] Small bugfix for removed function --- app/src/processing/app/ui/theme/Locale.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/processing/app/ui/theme/Locale.kt b/app/src/processing/app/ui/theme/Locale.kt index 254c0946c1..df37eb13a7 100644 --- a/app/src/processing/app/ui/theme/Locale.kt +++ b/app/src/processing/app/ui/theme/Locale.kt @@ -3,10 +3,9 @@ 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 androidx.compose.runtime.remember import processing.app.Messages import processing.app.Platform -import processing.app.PlatformStart import processing.app.watchFile import java.io.File import java.io.InputStream @@ -32,7 +31,9 @@ class Locale(language: String = "") : Properties() { val LocalLocale = compositionLocalOf { Locale() } @Composable fun LocaleProvider(content: @Composable () -> Unit) { - PlatformStart() + remember { + Platform.init() + } val settingsFolder = Platform.getSettingsFolder() val languageFile = File(settingsFolder, "language.txt") From 20fa9be3b8c3cbfddca5bd736a812efde7cdac72 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 14 Oct 2025 12:49:25 +0200 Subject: [PATCH 04/39] 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 4546a3878ace2fca7027b27a14817fc0039b35d6 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 15 Oct 2025 11:39:03 +0200 Subject: [PATCH 05/39] 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 06/39] 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 07/39] 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 08/39] 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 09/39] 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 10/39] 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 From d3681f38c63d2353334c41994473911521827afd Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 16 Oct 2025 18:24:51 +0200 Subject: [PATCH 11/39] Remove ContributionManager and ContributionPane UI files (#1276) Deleted ContributionManager.kt and ContributionPane.kt from the contrib/ui directory. This removes the Compose-based contributions manager and its detail pane prototypes which got merged unnecessarily --- .../app/contrib/ui/ContributionManager.kt | 310 ------------------ .../app/contrib/ui/ContributionPane.kt | 79 ----- 2 files changed, 389 deletions(-) delete mode 100644 app/src/processing/app/contrib/ui/ContributionManager.kt delete mode 100644 app/src/processing/app/contrib/ui/ContributionPane.kt diff --git a/app/src/processing/app/contrib/ui/ContributionManager.kt b/app/src/processing/app/contrib/ui/ContributionManager.kt deleted file mode 100644 index 2ad472159b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionManager.kt +++ /dev/null @@ -1,310 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.animation.Animatable -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import com.charleskorn.kaml.Yaml -import com.charleskorn.kaml.YamlConfiguration -import kotlinx.serialization.Serializable -import processing.app.Platform -import processing.app.loadPreferences -import java.net.URL -import java.util.* -import javax.swing.JFrame -import javax.swing.SwingUtilities -import kotlin.io.path.* - - -fun main() = application { - Window(onCloseRequest = ::exitApplication) { - contributionsManager() - } -} - -enum class Status { - VALID, - BROKEN, - DEPRECATED -} -enum class Type { - library, - mode, - tool, - examples, -} - -@Serializable -data class Author( - val name: String, - val url: String? = null, -) - -@Serializable -data class Contribution( - val id: Int, - val status: Status, - val source: String, - val type: Type, - val name: String? = null, - val categories: List? = emptyList(), - val authors: String? = null, - val authorList: List? = emptyList(), - val url: String? = null, - val sentence: String? = null, - val paragraph: String? = null, - val version: String? = null, - val prettyVersion: String? = null, - val minRevision: Int? = null, - val maxRevision: Int? = null, - val download: String? = null, - val isUpdate: Boolean? = null, - val isInstalled: Boolean? = null, -) - -@Serializable -data class Contributions( - val contributions: List -) - -fun openContributionsManager(){ - // open the compose window - - SwingUtilities.invokeLater { - val frame = JFrame("Contributions Manager") - frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - frame.setSize(800, 600) - - val composePanel = ComposePanel() - composePanel.setContent { - contributionsManager() - } - - frame.contentPane.add(composePanel) - frame.isVisible = true - } -} - -@Composable -fun contributionsManager(){ - var contributions by remember { mutableStateOf(listOf()) } - var localContributions by remember { mutableStateOf(listOf()) } - var error by remember { mutableStateOf(null) } - - val preferences = loadPreferences() - - LaunchedEffect(preferences){ - try { - localContributions = loadContributionProperties(preferences) - .map { (type, props) -> - Contribution( - id = 0, - status = Status.VALID, - source = "local", - type = type, - name = props.getProperty("name"), - authors = props.getProperty("authors"), - url = props.getProperty("url"), - sentence = props.getProperty("sentence"), - paragraph = props.getProperty("paragraph"), - version = props.getProperty("version"), - prettyVersion = props.getProperty("prettyVersion"), - minRevision = props.getProperty("minRevision")?.toIntOrNull(), - maxRevision = props.getProperty("maxRevision")?.toIntOrNull(), - download = props.getProperty("download"), - ) - } - } catch (e: Exception){ - error = e - } - } - - - LaunchedEffect(Unit){ - try { - val url = URL("https://github.com/mingness/processing-contributions-new/raw/refs/heads/main/contributions.yaml") - val connection = url.openConnection() - val inputStream = connection.getInputStream() - val yaml = inputStream.readAllBytes().decodeToString() - // TODO cache yaml in processing folder - - val parser = Yaml( - configuration = YamlConfiguration( - strictMode = false - ) - ) - val result = parser.decodeFromString(Contributions.serializer(), yaml) - - contributions = result.contributions - .filter { it.status == Status.VALID } - .map { - // TODO Parse better - val authorList = it.authors?.split(",")?.map { author -> - val parts = author.split("](") - val name = parts[0].removePrefix("[") - val url = parts.getOrNull(1)?.removeSuffix(")") - Author(name, url) - } ?: emptyList() - it.copy(authorList = authorList) - } - } catch (e: Exception){ - error = e - } - } - if(error != null){ - Text("Error loading contributions: ${error?.message}") - return - } - if(contributions.isEmpty()){ - Text("Loading contributions...") - return - } - - val contributionsByType = (contributions + localContributions) - .groupBy { it.name } - .map { (_, contributions) -> - if(contributions.size == 1) return@map contributions.first() - else{ - // check if they all have the same version, otherwise return the newest version - val versions = contributions.mapNotNull { it.version } - if(versions.toSet().size == 1) return@map contributions.first().copy(isInstalled = true) - else{ - val newest = contributions.maxByOrNull { it.version?.toIntOrNull() ?: 0 } - if(newest != null) return@map newest.copy(isUpdate = true, isInstalled = true) - else return@map contributions.first().copy(isUpdate = true, isInstalled = true) - } - } - } - .groupBy { it.type } - - val types = Type.entries - var selectedType by remember { mutableStateOf(types.first()) } - val contributionsForType = (contributionsByType[selectedType] ?: emptyList()) - .sortedBy { it.name } - - var selectedContribution by remember { mutableStateOf(null) } - Box{ - Column { - Row{ - for(type in types){ - val background = remember { Animatable(Color.Transparent) } - val color = remember { Animatable(Color.Black) } - LaunchedEffect(selectedType){ - if(selectedType == type){ - background.animateTo(Color(0xff0251c8)) - color.animateTo(Color.White) - }else{ - background.animateTo(Color.Transparent) - color.animateTo(Color.Black) - } - } - - Row(modifier = Modifier - .background(background.value) - .pointerHoverIcon(PointerIcon.Hand) - .clickable { - selectedType = type - selectedContribution = null - } - .padding(16.dp, 8.dp) - ){ - Text(type.name, color = color.value) - val updates = contributionsByType[type]?.count { it.isUpdate == true } ?: 0 - if(updates > 0){ - Text("($updates)") - } - } - } - } - - Box(modifier = Modifier.weight(1f)){ - val state = rememberLazyListState() - LazyColumn(state = state) { - item{ - // Table Header - } - items(contributionsForType){ contribution -> - Row(modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .clickable { selectedContribution = contribution } - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(modifier = Modifier.weight(1f)){ - if(contribution.isUpdate == true){ - Text("Update") - }else if(contribution.isInstalled == true){ - Text("Installed") - } - - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(8f)){ - Text(contribution.name ?: "Unnamed", fontWeight = FontWeight.Bold) - Text(contribution.sentence ?: "No description", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Row(modifier = Modifier.weight(4f)){ - Text(contribution.authorList?.joinToString { it.name } ?: "Unknown") - } - } - } - } - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .background(Color.LightGray) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter( - scrollState = state - ) - ) - } - ContributionPane( - contribution = selectedContribution, - onClose = { selectedContribution = null } - ) - } - - } - -} - - -fun loadContributionProperties(preferences: Properties): List>{ - val result = mutableListOf>() - val sketchBook = Path(preferences.getProperty("sketchbook.path.four", Platform.getDefaultSketchbookFolder().path)) - sketchBook.forEachDirectoryEntry{ contributionsFolder -> - if(!contributionsFolder.isDirectory()) return@forEachDirectoryEntry - val typeName = contributionsFolder.fileName.toString() - val type: Type = when(typeName){ - "libraries" -> Type.library - "modes" -> Type.mode - "tools" -> Type.tool - "examples" -> Type.examples - else -> return@forEachDirectoryEntry - } - contributionsFolder.forEachDirectoryEntry { contribution -> - if(!contribution.isDirectory()) return@forEachDirectoryEntry - contribution.forEachDirectoryEntry("*.properties"){ entry -> - val props = Properties() - props.load(entry.inputStream()) - result += Pair(type, props) - } - } - } - return result -} \ No newline at end of file diff --git a/app/src/processing/app/contrib/ui/ContributionPane.kt b/app/src/processing/app/contrib/ui/ContributionPane.kt deleted file mode 100644 index 2f4a96931b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionPane.kt +++ /dev/null @@ -1,79 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Window - -//--processing-blue-light: #82afff; -//--processing-blue-mid: #0564ff; -//--processing-blue-deep: #1e32aa; -//--processing-blue-dark: #0f195a; -//--processing-blue: #0251c8; - -@Composable -fun ContributionPane(contribution: Contribution?, onClose: () -> Unit) { - if(contribution == null) { - return - } - val typeName = when(contribution.type) { - Type.library -> "Library" - Type.tool -> "Tool" - Type.examples -> "Example" - Type.mode -> "Mode" - } - Window( - title = "${typeName}: ${contribution.name}", - onCloseRequest = onClose, - onKeyEvent = { - if(it.key == Key.Escape) { - onClose() - true - } else { - false - } - } - ){ - Box { - Column(modifier = Modifier.padding(10.dp)) { - Text(typeName, style = TextStyle(fontSize = 16.sp)) - Text(contribution.name ?: "", style = TextStyle(fontSize = 20.sp)) - Row(modifier = Modifier.padding(0.dp, 10.dp)) { - val action = when(contribution.isUpdate) { - true -> "Update" - false, null -> when(contribution.isInstalled) { - true -> "Uninstall" - false, null -> "Install" - } - } - Text(action, - style = TextStyle(fontSize = 14.sp, color = Color.White), - modifier = Modifier - .clickable { - - } - .pointerHoverIcon(PointerIcon.Hand) - .background(Color(0xff0251c8)) - .padding(24.dp,12.dp) - ) - } - Text(contribution.paragraph ?: "", style = TextStyle(fontSize = 14.sp)) - } - } - } - -} \ No newline at end of file From bf4d163c2eca0edd5fd3f3ee532275e2c8a73c3d Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Fri, 17 Oct 2025 03:56:35 +0200 Subject: [PATCH 12/39] 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. --- app/build.gradle.kts | 3 + app/src/processing/app/ui/theme/Locale.kt | 129 ++++++++++++++++++---- app/test/processing/app/LocaleKtTest.kt | 52 +++++++++ 3 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 app/test/processing/app/LocaleKtTest.kt 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) diff --git a/app/src/processing/app/ui/theme/Locale.kt b/app/src/processing/app/ui/theme/Locale.kt index 254c0946c1..d760998185 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)? = 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 { 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() + } } } \ 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..f8ed32164a --- /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.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") + } +} \ No newline at end of file From 77eba30bb2ca8dbfcba148761a3f6b3fc7904e36 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 22 Oct 2025 04:46:57 +0200 Subject: [PATCH 13/39] Theming (#1298) * Add Material3-based Processing theme and typography Introduces Colors.kt with custom color schemes for light and dark themes using Material3. Refactors Theme.kt to use Material3 theming, adds a PDETheme composable, and provides a desktop preview app for theme components. Updates Typography.kt to use Space Grotesk font family and defines new typography styles for Material3. * Refactor to use Material3 and update theme usage Replaces Material2 components with Material3 in WelcomeToBeta, removes custom PDEButton in favor of Material3 Button, and updates theme usage to PDETheme. Also simplifies background modifier in PDETheme and removes unused Kotlin Multiplatform plugin from build.gradle.kts. * Add Space Grotesk font files and license Includes SpaceGrotesk font variants (Bold, Light, Medium, Regular, SemiBold) and the associated SIL Open Font License. This enables usage of the Space Grotesk typeface in the project. * Update markdown renderer to m3 and adjust UI Switched markdown renderer imports from m2 to m3 and updated the dependency version to 0.37.0. Adjusted WelcomeToBeta window size, layout, and logo dimensions for improved appearance. Ensured Box in Theme.kt fills available space for better layout consistency. --- app/build.gradle.kts | 13 +- app/src/processing/app/ui/WelcomeToBeta.kt | 85 +--- app/src/processing/app/ui/theme/Colors.kt | 134 ++++++ app/src/processing/app/ui/theme/Theme.kt | 399 +++++++++++++++--- app/src/processing/app/ui/theme/Typography.kt | 101 ++++- build.gradle.kts | 1 - build/shared/lib/fonts/SpaceGrotesk-Bold.ttf | Bin 0 -> 86520 bytes .../shared/lib/fonts/SpaceGrotesk-LICENSE.txt | 93 ++++ build/shared/lib/fonts/SpaceGrotesk-Light.ttf | Bin 0 -> 86616 bytes .../shared/lib/fonts/SpaceGrotesk-Medium.ttf | Bin 0 -> 86616 bytes .../shared/lib/fonts/SpaceGrotesk-Regular.ttf | Bin 0 -> 86592 bytes .../lib/fonts/SpaceGrotesk-SemiBold.ttf | Bin 0 -> 86576 bytes gradle/libs.versions.toml | 11 +- 13 files changed, 700 insertions(+), 137 deletions(-) create mode 100644 app/src/processing/app/ui/theme/Colors.kt create mode 100644 build/shared/lib/fonts/SpaceGrotesk-Bold.ttf create mode 100644 build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt create mode 100644 build/shared/lib/fonts/SpaceGrotesk-Light.ttf create mode 100644 build/shared/lib/fonts/SpaceGrotesk-Medium.ttf create mode 100644 build/shared/lib/fonts/SpaceGrotesk-Regular.ttf create mode 100644 build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1aea9ac6b2..6c8ac55f00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,7 @@ plugins{ alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.serialization) alias(libs.plugins.download) } @@ -60,7 +61,7 @@ compose.desktop { ).map { "-D${it.first}=${it.second}" }.toTypedArray()) nativeDistributions{ - modules("jdk.jdi", "java.compiler", "jdk.accessibility", "java.management.rmi", "java.scripting", "jdk.httpserver") + modules("jdk.jdi", "java.compiler", "jdk.accessibility", "jdk.zipfs", "java.management.rmi", "java.scripting", "jdk.httpserver") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Processing" @@ -108,27 +109,29 @@ dependencies { implementation(compose.runtime) implementation(compose.foundation) - implementation(compose.material) implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + implementation(compose.materialIconsExtended) implementation(compose.desktop.currentOs) + implementation(libs.material3) implementation(libs.compottie) implementation(libs.kaml) implementation(libs.markdown) implementation(libs.markdownJVM) + implementation(libs.clikt) + implementation(libs.kotlinxSerializationJson) + @OptIn(ExperimentalComposeLibrary::class) testImplementation(compose.uiTest) testImplementation(kotlin("test")) testImplementation(libs.mockitoKotlin) testImplementation(libs.junitJupiter) testImplementation(libs.junitJupiterParams) - - implementation(libs.clikt) - implementation(libs.kotlinxSerializationJson) + } tasks.test { diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt index 7757e820f6..2725a78176 100644 --- a/app/src/processing/app/ui/WelcomeToBeta.kt +++ b/app/src/processing/app/ui/WelcomeToBeta.kt @@ -5,11 +5,11 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.MaterialTheme.colors -import androidx.compose.material.MaterialTheme.typography -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -31,17 +31,16 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo import com.mikepenz.markdown.compose.Markdown -import com.mikepenz.markdown.m2.markdownColor -import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.model.MarkdownColors import com.mikepenz.markdown.model.MarkdownTypography import processing.app.Preferences import processing.app.Base.getRevision 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.ProcessingTheme +import processing.app.ui.theme.PDETheme import java.awt.Cursor import java.awt.Dimension import java.awt.event.KeyAdapter @@ -54,7 +53,7 @@ import javax.swing.SwingUtilities class WelcomeToBeta { companion object{ - val windowSize = Dimension(400, 200) + val windowSize = Dimension(400, 250) val windowTitle = Locale()["beta.window.title"] @JvmStatic @@ -72,7 +71,7 @@ class WelcomeToBeta { contentPane.add(ComposePanel().apply { size = windowSize setContent { - ProcessingTheme { + PDETheme(darkTheme = false) { Box(modifier = Modifier.padding(top = if (mac) 22.dp else 0.dp)) { welcomeToBeta(close) } @@ -99,7 +98,7 @@ class WelcomeToBeta { Row( modifier = Modifier .padding(20.dp, 10.dp) - .size(windowSize.width.dp, windowSize.height.dp), + .fillMaxSize(), horizontalArrangement = Arrangement .spacedBy(20.dp) ){ @@ -109,7 +108,7 @@ class WelcomeToBeta { contentDescription = locale["beta.logo"], modifier = Modifier .align(Alignment.CenterVertically) - .size(100.dp, 100.dp) + .size(120.dp) .offset(0.dp, (-25).dp) ) Column( @@ -123,7 +122,7 @@ class WelcomeToBeta { ) { Text( text = locale["beta.title"], - style = typography.subtitle1, + style = typography.titleLarge, ) val text = locale["beta.message"] .replace('$' + "version", getVersionName()) @@ -131,80 +130,36 @@ class WelcomeToBeta { Markdown( text, colors = markdownColor(), - typography = markdownTypography(text = typography.body1, link = typography.body1.copy(color = colors.primary)), + typography = markdownTypography(), modifier = Modifier.background(Color.Transparent).padding(bottom = 10.dp) ) Row { Spacer(modifier = Modifier.weight(1f)) - PDEButton(onClick = { + Button(onClick = { close() }) { Text( text = locale["beta.button"], - color = colors.onPrimary + color = MaterialTheme.colorScheme.onPrimary ) } } } } } - @OptIn(ExperimentalComposeUiApi::class) - @Composable - fun PDEButton(onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) { - val theme = LocalTheme.current - - var hover by remember { mutableStateOf(false) } - var clicked by remember { mutableStateOf(false) } - val offset by animateFloatAsState(if (hover) -5f else 5f) - val color by animateColorAsState(if(clicked) colors.primaryVariant else colors.primary) - - Box(modifier = Modifier.padding(end = 5.dp, top = 5.dp)) { - Box( - modifier = Modifier - .offset((-offset).dp, (offset).dp) - .background(theme.getColor("toolbar.button.pressed.field")) - .matchParentSize() - ) - Box( - modifier = Modifier - .onPointerEvent(PointerEventType.Press) { - clicked = true - } - .onPointerEvent(PointerEventType.Release) { - clicked = false - onClick() - } - .onPointerEvent(PointerEventType.Enter) { - hover = true - } - .onPointerEvent(PointerEventType.Exit) { - hover = false - } - .pointerHoverIcon(PointerIcon(Cursor(Cursor.HAND_CURSOR))) - .background(color) - .padding(10.dp) - .sizeIn(minWidth = 100.dp), - contentAlignment = Alignment.Center, - content = content - ) - } - } - @JvmStatic fun main(args: Array) { application { val windowState = rememberWindowState( - size = DpSize.Unspecified, + size = windowSize.let { DpSize(it.width.dp, it.height.dp) }, position = WindowPosition(Alignment.Center) ) Window(onCloseRequest = ::exitApplication, state = windowState, title = windowTitle) { - ProcessingTheme { - Surface(color = colors.background) { - welcomeToBeta { - exitApplication() - } + PDETheme(darkTheme = false) { + welcomeToBeta { + exitApplication() } } } diff --git a/app/src/processing/app/ui/theme/Colors.kt b/app/src/processing/app/ui/theme/Colors.kt new file mode 100644 index 0000000000..61c6d6b55f --- /dev/null +++ b/app/src/processing/app/ui/theme/Colors.kt @@ -0,0 +1,134 @@ +package processing.app.ui.theme + +import androidx.compose.material.Colors +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +class ProcessingColors{ + companion object{ + val blue = Color(0xFF0251c8) + val lightBlue = Color(0xFF82AFFF) + + val deepBlue = Color(0xFF1e32aa) + val darkBlue = Color(0xFF0F195A) + + val white = Color(0xFFFFFFFF) + val lightGray = Color(0xFFF5F5F5) + val gray = Color(0xFFDBDBDB) + val darkGray = Color(0xFF898989) + val darkerGray = Color(0xFF727070) + val veryDarkGray = Color(0xFF1E1E1E) + val black = Color(0xFF0D0D0D) + + val error = Color(0xFFFF5757) + val errorContainer = Color(0xFFFFA6A6) + + val p5Light = Color(0xFFfd9db9) + val p5Mid = Color(0xFFff4077) + val p5Dark = Color(0xFFaf1f42) + + val foundationLight = Color(0xFFd4b2fe) + val foundationMid = Color(0xFF9c4bff) + val foundationDark = Color(0xFF5501a4) + + val downloadInactive = Color(0xFF8890B3) + val downloadBackgroundActive = Color(0x14508BFF) + } +} + +@Deprecated("Use PDE3LightColor instead") +val PDE2LightColors = Colors( + primary = ProcessingColors.blue, + primaryVariant = ProcessingColors.lightBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.deepBlue, + secondaryVariant = ProcessingColors.darkBlue, + onSecondary = ProcessingColors.white, + + background = ProcessingColors.white, + onBackground = ProcessingColors.darkBlue, + + surface = ProcessingColors.lightGray, + onSurface = ProcessingColors.darkerGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + isLight = true, +) + +@Deprecated("Use PDE3DarkColor instead") +val PDE2DarkColors = Colors( + primary = ProcessingColors.deepBlue, + primaryVariant = ProcessingColors.darkBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.lightBlue, + secondaryVariant = ProcessingColors.blue, + onSecondary = ProcessingColors.white, + + background = ProcessingColors.veryDarkGray, + onBackground = ProcessingColors.white, + + surface = ProcessingColors.darkerGray, + onSurface = ProcessingColors.lightGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + isLight = false, +) + +val PDELightColor = lightColorScheme( + primary = ProcessingColors.blue, + onPrimary = ProcessingColors.white, + + primaryContainer = ProcessingColors.downloadBackgroundActive, + onPrimaryContainer = ProcessingColors.darkBlue, + + secondary = ProcessingColors.deepBlue, + onSecondary = ProcessingColors.white, + + secondaryContainer = ProcessingColors.downloadInactive, + onSecondaryContainer = ProcessingColors.white, + + tertiary = ProcessingColors.p5Mid, + onTertiary = ProcessingColors.white, + + tertiaryContainer = ProcessingColors.p5Light, + onTertiaryContainer = ProcessingColors.p5Dark, + + background = ProcessingColors.white, + onBackground = ProcessingColors.darkBlue, + + surface = ProcessingColors.lightGray, + onSurface = ProcessingColors.darkerGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + errorContainer = ProcessingColors.errorContainer, + onErrorContainer = ProcessingColors.white +) + +val PDEDarkColor = darkColorScheme( + primary = ProcessingColors.deepBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.lightBlue, + onSecondary = ProcessingColors.white, + + tertiary = ProcessingColors.blue, + onTertiary = ProcessingColors.white, + + background = ProcessingColors.veryDarkGray, + onBackground = ProcessingColors.white, + + surface = ProcessingColors.darkerGray, + onSurface = ProcessingColors.lightGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, +) \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 735d8e5b2a..7cc70455f0 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -1,75 +1,364 @@ package processing.app.ui.theme +import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.graphics.Color -import processing.app.LocalPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.Density +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.application +import androidx.compose.ui.window.rememberWindowState import processing.app.PreferencesProvider -import java.io.InputStream -import java.util.Properties - - -class Theme(themeFile: String? = "") : Properties() { - init { - load(ClassLoader.getSystemResourceAsStream("theme.txt")) - load(ClassLoader.getSystemResourceAsStream(themeFile) ?: InputStream.nullInputStream()) - } - fun getColor(key: String): Color { - return Color(getProperty(key).toColorInt()) - } -} - -val LocalTheme = compositionLocalOf { error("No theme provided") } +/** + * Processing Theme for Jetpack Compose Desktop + * Based on Material3 + * + * Makes Material3 components follow Processing color scheme and typography + * We experimented with using the material3 theme builder, but it made it look too Android-y + * So we defined our own color scheme and typography based on Processing design guidelines + * + * This composable also provides Preferences and Locale context to all child composables + * + * Also, important: sets a default density of 1.25 for better scaling on desktop screens, [LocalDensity] + * + * Usage: + * ``` + * PDETheme { + * val pref = LocalPreferences.current + * val locale = LocalLocale.current + * ... + * // Your composables here + * } + * ``` + * + * @param darkTheme Whether to use dark theme or light theme. Defaults to system setting. + */ @Composable -fun ProcessingTheme( +fun PDETheme( darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable() () -> Unit -) { + content: @Composable () -> Unit +){ PreferencesProvider { - val preferences = LocalPreferences.current - val theme = Theme(preferences.getProperty("theme")) - val colors = Colors( - primary = theme.getColor("editor.gradient.top"), - primaryVariant = theme.getColor("toolbar.button.pressed.field"), - secondary = theme.getColor("editor.gradient.bottom"), - secondaryVariant = theme.getColor("editor.scrollbar.thumb.pressed.color"), - background = theme.getColor("editor.bgcolor"), - surface = theme.getColor("editor.bgcolor"), - error = theme.getColor("status.error.bgcolor"), - onPrimary = theme.getColor("toolbar.button.enabled.field"), - onSecondary = theme.getColor("toolbar.button.enabled.field"), - onBackground = theme.getColor("editor.fgcolor"), - onSurface = theme.getColor("editor.fgcolor"), - onError = theme.getColor("status.error.fgcolor"), - isLight = theme.getProperty("laf.mode").equals("light") + LocaleProvider { + MaterialTheme( + colorScheme = if(darkTheme) PDEDarkColor else PDELightColor, + typography = PDETypography + ){ + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background).fillMaxSize()) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onBackground, + LocalDensity provides Density(1.25f, 1.25f), + content = content + ) + } + } + } + } +} + +/** + * Simple app to preview the Processing Theme components + * Includes buttons, text fields, checkboxes, sliders, etc. + * Run by executing the main() function by clicking the green arrow next to it in intelliJ IDEA + */ +fun main() { + application { + val windowState = rememberWindowState( + size = DpSize(800.dp, 600.dp), + position = WindowPosition(Alignment.Center) ) + var darkTheme by remember { mutableStateOf(false) } + Window(onCloseRequest = ::exitApplication, state = windowState, title = "Processing Theme") { + PDETheme(darkTheme = darkTheme) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Processing Theme Components", style = MaterialTheme.typography.titleLarge) + Card { + Row { + Checkbox(darkTheme, onCheckedChange = { darkTheme = !darkTheme }) + Text( + "Dark Theme", + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } + val scrollable = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(scrollable), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ComponentPreview("Colors") { + Column { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), + onClick = {}) { + Text("Primary", color = MaterialTheme.colorScheme.onPrimary) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), + onClick = {}) { + Text("Secondary", color = MaterialTheme.colorScheme.onSecondary) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), + onClick = {}) { + Text("Tertiary", color = MaterialTheme.colorScheme.onTertiary) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + onClick = {}) { + Text("Primary Container", color = MaterialTheme.colorScheme.onPrimaryContainer) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + onClick = {}) { + Text("Secondary Container", color = MaterialTheme.colorScheme.onSecondaryContainer) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + onClick = {}) { + Text("Tertiary Container", color = MaterialTheme.colorScheme.onTertiaryContainer) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer), + onClick = {}) { + Text("Error Container", color = MaterialTheme.colorScheme.onErrorContainer) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.background), + onClick = {}) { + Text("Background", color = MaterialTheme.colorScheme.onBackground) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = {}) { + Text("Surface", color = MaterialTheme.colorScheme.onSurface) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + onClick = {}) { + Text("Surface Variant", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onClick = {}) { + Text("Error", color = MaterialTheme.colorScheme.onError) + } + } + } + } + ComponentPreview("Text & Fonts") { + Column { + Text("displayLarge", style = MaterialTheme.typography.displayLarge) + Text("displayMedium", style = MaterialTheme.typography.displayMedium) + Text("displaySmall", style = MaterialTheme.typography.displaySmall) + + Text("headlineLarge", style = MaterialTheme.typography.headlineLarge) + Text("headlineMedium", style = MaterialTheme.typography.headlineMedium) + Text("headlineSmall", style = MaterialTheme.typography.headlineSmall) - CompositionLocalProvider(LocalTheme provides theme) { - LocaleProvider { - MaterialTheme( - colors = colors, - typography = Typography, - content = content - ) + Text("titleLarge", style = MaterialTheme.typography.titleLarge) + Text("titleMedium", style = MaterialTheme.typography.titleMedium) + Text("titleSmall", style = MaterialTheme.typography.titleSmall) + + Text("bodyLarge", style = MaterialTheme.typography.bodyLarge) + Text("bodyMedium", style = MaterialTheme.typography.bodyMedium) + Text("bodySmall", style = MaterialTheme.typography.bodySmall) + + Text("labelLarge", style = MaterialTheme.typography.labelLarge) + Text("labelMedium", style = MaterialTheme.typography.labelMedium) + Text("labelSmall", style = MaterialTheme.typography.labelSmall) + } + } + ComponentPreview("Buttons") { + Button(onClick = {}) { + Text("Filled") + } + Button(onClick = {}, enabled = false) { + Text("Disabled") + } + OutlinedButton(onClick = {}) { + Text("Outlined") + } + TextButton(onClick = {}) { + Text("Text") + } + } + ComponentPreview("Icon Buttons") { + IconButton(onClick = {}) { + Icon(Icons.Default.Map, contentDescription = "Icon Button") + } + } + ComponentPreview("Chip") { + AssistChip(onClick = {}, label = { + Text("Assist Chip") + }) + FilterChip(selected = false, onClick = {}, label = { + Text("Filter not Selected") + }) + FilterChip(selected = true, onClick = {}, label = { + Text("Filter Selected") + }) + } + ComponentPreview("Progress Indicator") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)){ + CircularProgressIndicator() + LinearProgressIndicator() + } + } + ComponentPreview("Radio Button") { + var state by remember { mutableStateOf(true) } + RadioButton(!state, onClick = { state = false }) + RadioButton(state, onClick = { state = true }) + + } + ComponentPreview("Checkbox") { + var state by remember { mutableStateOf(true) } + Checkbox(state, onCheckedChange = { state = it }) + Checkbox(!state, onCheckedChange = { state = !it }) + Checkbox(state, onCheckedChange = {}, enabled = false) + TriStateCheckbox(ToggleableState.Indeterminate, onClick = {}) + } + ComponentPreview("Switch") { + var state by remember { mutableStateOf(true) } + Switch(state, onCheckedChange = { state = it }) + Switch(!state, enabled = false, onCheckedChange = { state = it }) + } + ComponentPreview("Slider") { + Column{ + var state by remember { mutableStateOf(0.5f) } + Slider(state, onValueChange = { state = it }) + var rangeState by remember { mutableStateOf(0.25f..0.75f) } + RangeSlider(rangeState, onValueChange = { rangeState = it }) + } + + } + ComponentPreview("Badge") { + IconButton(onClick = {}) { + BadgedBox(badge = { Badge() }) { + Icon(Icons.Default.Map, contentDescription = "Icon with Badge") + } + } + } + ComponentPreview("Number Field") { + var number by remember { mutableStateOf("123") } + TextField(number, onValueChange = { + if(it.all { char -> char.isDigit() }) { + number = it + } + }, label = { Text("Number Field") }) + + } + ComponentPreview("Text Field") { + Row { + var text by remember { mutableStateOf("Text Field") } + TextField(text, onValueChange = { text = it }) + } + var text by remember { mutableStateOf("Outlined Text Field") } + OutlinedTextField(text, onValueChange = { text = it}) + } + ComponentPreview("Dropdown Menu") { + var show by remember { mutableStateOf(false) } + AssistChip( + onClick = { show = true }, + label = { Text("Show Menu") } + ) + DropdownMenu( + expanded = show, + onDismissRequest = { + show = false + }, + ) { + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 1", modifier = Modifier.padding(8.dp)) + }) + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 2", modifier = Modifier.padding(8.dp)) + }) + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 3", modifier = Modifier.padding(8.dp)) + }) + } + + + } + + ComponentPreview("Scrollable View") { + + } + + ComponentPreview("Tabs") { + + } + } + } } } } } -fun String.toColorInt(): Int { - if (this[0] == '#') { - var color = substring(1).toLong(16) - if (length == 7) { - color = color or 0x00000000ff000000L - } else if (length != 9) { - throw IllegalArgumentException("Unknown color") +@Composable +private fun ComponentPreview(title: String, content: @Composable () -> Unit) { + Column { + Text(title, style = MaterialTheme.typography.titleLarge) + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp)) { + content() } - return color.toInt() + HorizontalDivider() } - throw IllegalArgumentException("Unknown color") } \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Typography.kt b/app/src/processing/app/ui/theme/Typography.kt index 5d87c490e6..6650ac7167 100644 --- a/app/src/processing/app/ui/theme/Typography.kt +++ b/app/src/processing/app/ui/theme/Typography.kt @@ -1,6 +1,5 @@ package processing.app.ui.theme -import androidx.compose.material.MaterialTheme.typography import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -21,18 +20,108 @@ val processingFont = FontFamily( style = FontStyle.Normal ) ) +val spaceGroteskFont = FontFamily( + Font( + resource = "SpaceGrotesk-Bold.ttf", + weight = FontWeight.Bold, + ), + Font( + resource = "SpaceGrotesk-Regular.ttf", + weight = FontWeight.Normal, + ), + Font( + resource = "SpaceGrotesk-Medium.ttf", + weight = FontWeight.Medium, + ), + Font( + resource = "SpaceGrotesk-SemiBold.ttf", + weight = FontWeight.SemiBold, + ), + Font( + resource = "SpaceGrotesk-Light.ttf", + weight = FontWeight.Light, + ) +) -val Typography = Typography( +@Deprecated("Use PDE3Typography instead") +val PDE2Typography = Typography( + defaultFontFamily = spaceGroteskFont, + h1 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 42.725.sp, + lineHeight = 48.sp + ), + h2 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 34.18.sp, + lineHeight = 40.sp + ), + h3 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 27.344.sp, + lineHeight = 32.sp + ), + h4 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 21.875.sp, + lineHeight = 28.sp + ), + h5 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 17.5.sp, + lineHeight = 22.sp + ), + h6 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 18.sp + ), body1 = TextStyle( - fontFamily = processingFont, fontWeight = FontWeight.Normal, - fontSize = 13.sp, + fontSize = 14.sp, + lineHeight = 18.sp + ), + body2 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.8.sp, lineHeight = 16.sp ), subtitle1 = TextStyle( - fontFamily = processingFont, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp + ), + subtitle2 = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 13.824.sp, + lineHeight = 16.sp, + ), + caption = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 11.2.sp, + lineHeight = 14.sp + ), + overline = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 8.96.sp, + lineHeight = 10.sp ) +) +val base = androidx.compose.material3.Typography() +val PDETypography = androidx.compose.material3.Typography( + displayLarge = base.displayLarge.copy(fontFamily = spaceGroteskFont), + displayMedium = base.displayMedium.copy(fontFamily = spaceGroteskFont), + displaySmall = base.displaySmall.copy(fontFamily = spaceGroteskFont), + headlineLarge = base.headlineLarge.copy(fontFamily = spaceGroteskFont), + headlineMedium = base.headlineMedium.copy(fontFamily = spaceGroteskFont), + headlineSmall = base.headlineSmall.copy(fontFamily = spaceGroteskFont), + titleLarge = base.titleLarge.copy(fontFamily = spaceGroteskFont), + titleMedium = base.titleMedium.copy(fontFamily = spaceGroteskFont), + titleSmall = base.titleSmall.copy(fontFamily = spaceGroteskFont), + bodyLarge = base.bodyLarge.copy(fontFamily = spaceGroteskFont), + bodyMedium = base.bodyMedium.copy(fontFamily = spaceGroteskFont), + bodySmall = base.bodySmall.copy(fontFamily = spaceGroteskFont), + labelLarge = base.labelLarge.copy(fontFamily = spaceGroteskFont), + labelMedium = base.labelMedium.copy(fontFamily = spaceGroteskFont), + labelSmall = base.labelSmall.copy(fontFamily = spaceGroteskFont), ) \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8e7ad44a7a..6c8c5262cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ plugins { kotlin("jvm") version libs.versions.kotlin apply false - alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.jetbrainsCompose) apply false diff --git a/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf b/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0408641c61d0f3ce53d24348e9db72dcc1261179 GIT binary patch literal 86520 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 literal 0 HcmV?d00001 diff --git a/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..981bcf5b2cb0a1ca43dcee7577015c0bfbdf91fb GIT binary patch literal 86592 zcmd442YggT_xL?!v#BHj5)y%gB|r*f)sl}MX`6WAlMZ} z!2*h60ULG@tQ332-ev#anR_?81Qhl8J)ig8yJzOyX?N;5XUaWSNFhWbhDV5@6_r)j z#a{H55Gq>;xpnA)W5*vIS$4Y+hkq@^QKt+Ye@NMgPLb1vNUakh{xR2_Fa%b?(&aSu@Sk@K3^ynp!t+O8ir4 z+^@p@`d6maR8MaI`uA<{zZn0LX*fh3?fHoN0o-?;Hlu0o>j$qNC`8r}()86eOseh` z9eEZ>9VkTPr8BDM&eU&;rwHGX@V@%$88r>NucKuAh3CMT4YQh_Iye0%l0H_5jEiSB z*36vdU9^DkaRi8bSOylX@La&Hkb!_mz!v&hF+oI$$s)qsh5fWp)_?2ncS!r|z=l5# z@~mwU#>6Hq;+wch*6b+MoT<~AY+_1i+?7iE)4X zx{PZa`+`Y<4ZA=0to01z-fP0zpGWl~l?WoArw+$uq+`pYgq--$uD zs*zypM62IC+9}H^va3(X%Z$JL$mYsNJ|$1ufYTI!7UE}Igct?Hfv3wUs)rh_#;C*8 z1a-8Us3xg;b-Zd+v(*V|zB*Aop`KJPt5?((wN?G3e%2%ONL{CA=ri=0CM^>~v=hUwp9x=}6BvvKtZDc6#VRO&&h_rT&U_O$DFx@%wO z+Gh}=C$UU>#83(;l8GxtdoC$DiVV!Iq8nzmFi-6z{Fp^tQk00^n8$NT@dB4LIil^R z$SFe0Q{*YQoFUJ^yh+}KxmvC!PpwsJ%pR(T&}x)A40D2-fO)h!8naqW$DE<+F{vrc zCUpYle037$Vzq=?c}P7(3e+7g)E?$$wHcFI#N45F;QNvK2=hnvGrgsQ?tn?%Vp6-9 zjhd8olWxMCO}!~w<67UMZ=o!vHQzJs>xl+`THWK~)QY;?#i>_ywu@`gPmOYMkLa#4 zTs(sCQ7-Nk*>by!N6|CLyG=hj438B4I#>`C0$ z6W-*;M{eq|YY62d{4^VOyckZpKGL6Q!-l79avn+{mr#e=*d}l0={`|F4h{Iia<=Dl zgSn{|Cy+l=rfM+})8umw_a+}c+{|-J#X%NtqE39a#{PK?wXD}EZatYgox*cXeb(~4 zdNCcdk-BNJ<<6s|4YXF(9$|c9UUQ z2KPEwJR`o7DYB<5m2>4qas5NAC(%>o zIni^U=femYF({%sqAp@i#6=N{BVLX8&fCR%g!dxv?cR?gb!28_-^jxwkB_`Aa#iGe zkzYskh^me{Eox1)h)#>18~s@HThTwpWX2pCGdt$;n58lI#yl1CTFl!qpTv9{^IL38 zY)WjW*dDQ^vHyv!jh!94Aoix%$KpJ3Bjc*$=ER*DcTwEpxSQkF#cgcWx7F-ckGFcE z)s|K}TYcH;=Xf0-AKyMcGk#?JVeymV>*MFeFNnW5esTP=_530o6(CH$5cnb;;V zEiostIB`hg$i%}ECnnY>&QDyBcyHn(i5n6(C+!Gd3 zwf;}*+SapMpWga{*4MPYrS*!|kFy0Z1Y%~=h|#) zv#rhNZGLKdOxx*g=d?Ye?FDVGY5PvwPuhN;B$DEiQj#)~dM5QrDo+}lbX3xmq{gIE zk`^Xio^)f5Zfhl6EEin%pKiEjcH-ck-y@!;>c`HzdzbUXXlA^7YBL zC$CI?Ecv7Ph;*-4`jIl*E+6l))(@Qw~k3PN_?o zlX6DNg(=sh+>&x%%G#7?Q(jNmmhxH3kEwa7|4FS)ot=7e>b0qFrUnjZdqCa+mmTm} z`;_)m+Mn0{jrM^KV>;Z>VMB*6I%ah&?s$G$Oxgiy*=fDgs?rWkt4V80J2UOlv|H0w zrfp37IXyPLQ@THWbo$iv)6*|cUy;6}(|}GRJ6+Z3DWCGC`}+CD`s#eA`p)rPK1ec=1T_r34;&Uu{=>^!UUX`L5#zO?h=&JTCqlHtv0laZE@ zo6$R?EaRYz2^rHf=4UL-xH{vujQcYl%XmKHjf|aL(z@hyDeh9)WpbC9T~6w9PM3?i zEbek!m;1Y{?ebig&0Rj|@?BTewRKlt*Ir%wb*<_;uItfVXLLQO>p5L7?RtIJJGy?` z^^b1R-IBX??>4sE>D?A}yQM#?pY;SWm#jgj?AjbnwfQK z)`eNuXWgCkNY;j|H?ls;`Zl{qcJJ)+?6KKLWlznXm3>O~dD+)w-=4iHdtLU++3#h4 znf*(SCnqtdV@_61Nls->Q_g8Qi*l~cxjko9&bpk9Ia_je=6sp+d-vGx?Yrl6FYaF2 zeRTIDy4Q3+zWd4D&+UGB_hsGh@BUc#=exhreP{Qty8qh4+oN@ljy2X<)r9JNNv9`x^JvQ~&-eXsfKyFNKa;`78SMH$P(YX_Hr{~VkU6^}y?rpgb=027C zO76S4pX7d*8|WF^Go@#jp1peZ?>V&RxSmJ%tnE3w=Vd(?_q?s=%ASws<>vLytIAuH z_fp>5z5Klv^jg_#N3TEf56CahKPZ1j{=EDJ`IqEhpMOXG1Nl$nznK4N{vQQV1zih< z6pSl4rr`L3(+gG=yjAc;!ES$yznwqBpXcxAukw%cAML-!zuNz%e@9_rVVA;^!m`4t zg%=dAEPSGHV^Km;x1#<<2NfMrG`HxiqRWbw7Ts60uIS~W_liC(`k`1Bk0>5jd{ps+ z#g7$lD1NzkYw`QV-xg2N0eMwa$Cs070rmVRHR%i5N8 zD(hc1v}}CYF=f-s=9HaTc465yWw)0-RQ7DyD`jt%eO9i@+n0AK?^Qm%d`|h<fPFyX8Muw5!Ok7*R34qPF7HiaRUbsrZ2FtBPMLJ(UTSd6lCpCsod=TvqvVW`t(Lz9Pg8`@{+kfBEmtsZ*Z&~u00GW4mT8;5Ql z7C)@ruuj7!533v2H0Z@8`U)Gv{8#jtsb>?)CZ$>jrw)8 zcXXT4>7%=kE*V`qdi3bSM^74EKYHHivqoP$diCg!Mt?Q>$I*c?QDgdzX&Q6Vn6t(# z8gu!Wr^mcF=8ZAi#(Xly&K{ZfFqH+Y)NE}}R1@}2aT+tGiJ}z}ZC^1&9Et2bUz{RO zgI{0DOlF0AM}90nR~=MORj7Kaa&?e8LOrS;R~yy4+N0xif^Nf{qpQx=dAdON(UCx#4=_%=H>7CQNrT0khmEJ#nc>4JC+VqC>6Vn%@UzC1X`c>&Sr2pcJ^(Fd} zeeI#k8NN(kzONAaT;Ut%JKi_Xx6pT??{eQwzMG-Zcleh3?)N?5TjN{nd%Sbo&h0w; zI%jmwgiasa`RLBqWGLu!0u(wqqkTqtMvshP8OLVSbbCJQ(;bDsd6>-$%IHIu>W55p z6f)t7Vu>x|5_zwDT7D!ylfSC&D!;jmtJPy_gW9Tv_EN^yl(CD>v}G)AE@M=BTzXP^ z$8=wM*Yxh`dFdtTW$B~Rk4&#ipPPPK`i1G2+A=QjMN-B#zEodFpU;-j@9S&JIMdhU zJK1-EE#orZt(5UD-+h#EweQguWgOr6sLmHt#t6#Tnlc{HT*k?i@sl0?-$Wn~_<>R~ z>j^vstOXb1=)mjN4ma)%4A}jW_$}NiFp(H*j7uOruwIBip7`UgKaTz0ESv1-_rZ>x zrf$W~=Ys*a`*+aOws+nB<<9hGx2-!~u%YZzcid(09p~)WzT?;JG20`zuh>z$?SpOa z39*gUgl!eXCO_NqwsqU4-uvXe?QbtI;rU5mJMcTG7l^m(NP;zm*VHC@qgO{U^JOi_ z`5{>ztPj-_nFrI?^(hAQX~xenEryFeTQAZV>WlTIwCt67iM~fas^8Mv^+)<^{jCl4 zwPz`-Jhyx9@!aou(6~4z_Whn!p2s|IShr2a_PpU?1V6WiK&8c9TcREZJFYkRx@y%$5`6Xjvtv$y#}s`b}nviLzQwl^N<; zStN^P39DUZMtdA{C^I)Z9_fBQW91}qia1v+6z7RW%o48=*NVmBI&rE%o_q15gYSDA7lWY{5#Afk^*b1$AUwj}w60eHy#1G;}@ss#j{33^l zKV*!Im2tFYvg{yJnDafuZ17p;7|%1)c#-+bOUzYYVJ7pkm@8gme)PIHRlEu1dP|%x z-VtYrx5dd~i#SufE6!pqV}aNvuVdZnZ2HPMVuv_id?+puAB&5{r{W^STJ|>@3-^=y# zc^xBXsdRa(dPi>5t>uq;k^D_xB7fEw%3t(F@>hMaT&z~h9lEDHSq+jiRH59dd&zk! zPoAWD%gfZ+@=A4yyhFV!SE={qYdS@~r@P3Hb(#EBkC8v=3zSD+E5Fi*$nW$V`MOS( zA8Nncqz_Q>`aV5Kova$v06kidl84J9^b&QmK2klPZQZ@u zI!)f79+ykhOY%nbqP$zZC!bKCiOa=T@+tL$d|Le~pJn0bIW6T2I##}~^W_J+KyKIF zoJSRK@5!RHA-B&Q}HUL{%)CRA+gD>L%x?EIC)@$l0oktXD~Lrb?B^tM;-{b(9UN zoxEI~Bd<{B%4^lt@|BVX1@ z@)eydU)Am8OS-LmQ>V+fwNJjIJIi-U#+yhMhShT ziqp$gtiDUN()X$ay;3dHgY|s1Q9q+@f{RzH*TooCk;btmaF7@;yU4C$lKNdAq1Vgf z)GBqiK2A?m7wc$Utoz96`YD~O&)27`mFg~CtE<&T(9$B^TRo`0QGe(GYM#1AeWC}d z_4*+7t@=eDs6N-%=?Zm<`jWYOJd(gneZ0CteIUolLFy=ZklLlIWV(1lEjBBqtlZvU z>357gPi+^fqH5H^aztHqQ@x0nF(SJ3$V#6aQeJwnPrOk+?qKkdrAK0p9y`h>mW~-! z;S=@a#*W5?O$K9*8|j0W!}UZ8RMf0RdszMUibxU5Dqt&Cf)m(jXwB+oTcn<3R+dv( z>pg&#-hIW1`4@4b|3#drzY(XMEst(4&tI3u@@Q(oi504azmCpHCf7I2ka<%Yt0&2>b+uEgWqMux>=`nZy_P!J#+q@~^jcHk*(iQ(z!Kj# zG)}G;U(7W4N3%#@Y@bzIKSjJXYgT@N*o5g9FJTsnXEBS!lbFTg(OI)6&JqvKnmu!t zSkYwS-Z{Ufu>tC8S~bI(MvjoQr7;t%nQhH1Yi3%rn>D*yGtrt|teJ+XX!j2Gu9G#* z{)O3YTR3ihpVe3P!ps}h;))7>QRlQ+xT<-_tW`JlW_ z>f7|~`c8c}Qq>B5zc$a05hd{Yb~1&z!d-f~z8CJYGAw42h-Ba65%n-K_!_lVt-~*p zotbyx?%ULAGyez+JwS9|pQT8(Q9f0Q1m9P+RRdK&ou`sjiaLO~OPcDWGE`TUsj`vr zdnvyvQGL{4(&)gRLP%bD2C3sY@jTK}l4_??ReM${(^Y5HMRikIDo5q10#(cmVNlr9 zQ$@6XQa_|u>qqoj{kVQYKdm?5A1(Um2lc~x4YK58dL8t2J$=XQJQ>T=yz9jS(}Q#D2%sK%*- z)uHTBO;R;#s;XtJwvj!&6WLKT*HU$>z3x%wdQv^3oI?Oi`kK8x=hupv zQqbnaJPfde~g#`w*nsa-`WxEBg*ZsvUupdn9%CA8|BN zFuaW!EBhcw8l%-%W~AfQAxIb#)nqkAO;g9Qmo^*OW0AT*U8rtSx2rqUooa=;PuL(q+%pnpE7O!)3ciltx(-pdknZ(ih7=5gsqNnO9baHqPT)O(Q9G~%D9O>a)3t^2~i z`ilW#AUmCdY5h|2dxyG@ymaTeQ+e7#{aA}9Jci#weJW+SgAm843$*tiJz){Gv4ZmE zP_`+Q=v@8io=*@%)FLrZ-7W^G6{1u}h`u^U4Aw`BL3)bl&m5|sK9`-N>9=!>IM2iH~k_feNvN@&Pm_wE$=&yd0uxQn3EJ6@_cohx|exu zw*HT<(I@M3^cviy42@>V(>^tB%mhO@8vvz!kUTBWC#vP@7IJo!TBuc62t$k4knTD9 zWOc8)jnx06&etC5MN<2vZhjp;Qz7Dz5vQ6tVoNzOEX8y}{fW#-s>)5(%w7$Zb6Qb> zE4fB}h>L=kWVv;h?be;CuV9VDgB$K_%!Rgn_V&%PzVBlOd&wyEX4u^z_Q>~Wea4++ zm&=Sl6UUreFcO7n4RZp(xEfm^LmHon*37XkPTiP3=FX3B^_cmdo$G~i;8pDVb7kTi z%+;0PO-SPTNTnX+i*|77820{^K2y~S1@)YY`J}4B>=72?II~x;rs4Xsnu7U+vL_D0 zLL6$-I2gZI)Huv1)q$AdX&hqH7?11A>LAP~)L7bJipjk_tuawe0(aX-9xRgg!vmh= zNn4@tBRRF<)NMz%^}5nik*;lOUg{K#8L(+llYRu|KS{eePn^C;;he)q{~%uySw9ng z`Pcm632I~u)PICN!t@7LyNi*2hb&8(DH1l23biuslS}k~8E| zF&lYoGjshdDiQg_^rUbKW5!qVKgfrzHF%wo4*IyyM)u+CNHufklQ{eFqdDJU^HtGFHCL>lSo||azB!okue=fuZ5&I)=HgbE|U%gFL9;Mz2sX*9^G4#b0kJb)|8#Y zv8){#8-kXePEBqk`%TN#k_6?K9*KwTkvsWaf9S78^5164=ha_%o? zF8)3EIOMj8*cswLU5$M;a6SGO?Y@#0mf@FTtY z)mdV^I#l$>?$5aIFV~1Pp4*?e<5^)FBz_8fr`lV5yq-W<+|_Y5{CE>b&f`8o6v~an ze}F0iZxDIlg>o!w3}cy3 ztYF-&z&??9^F*X*7roCE%X}=(tcHAGB zHVy)9O?$`D-mQQ#@qJ(;unBk@*pAyEU{F&+?FVe1GyN?Hrr!m@^i`9$7ip810Mj4$1NwTxnEu)lrsDRD z?U%M6o4)*)VET9vY62T6qnG}!fhzGs;3eQKUj)NZppD-3)amPZL>|@46V7ok? zK68URF3i|)V3-{vW}N&5=-tlvp^s++{meMp1DY5Qhl?~bwwlM68DskaJO0AMm@ycH zGn_oi&&431ioR*;vRK|s9v%=`svm6%$Zu(nQrdbQ{h_Vsqe}4Ow3BMWy^egEKG8(n zI+4g&9-z(_smOv|^cJ9(Xsdezr-}~xDD1V^qp%laUnx54;Ua-}lUs`1D`$pg<;1a+OEQTX|h+U5Tr6{33 z(k#?5N92$%^rfw=C#|{6^Is+0W3<`LBFm2V{m_K{(F`@8zHjLKd=Y2q#yR3Z&vT-e zXEpBNoTKb$X@|iL4Ke;p@Hcc}e{`g!ze&ru1!>DX+S%nNhNsADNPj49%$yBw0J^df zJKX^I2JBQI5BzOo1B9U;HHT*`IPpFXy190y>IT0uJj>Vy;9Z_3po%O`*R0@`VRyvoRB3y|HA zldL_HzE_7FRfimeyLAgo!^kq~G}1+`0i5*jVnxr$LcfVLy)^{XdzyL+s3WWc!qUfC zqO+0Fq_K|{<@l8W2by~e3z>-_$4O&PM24%g^th|Jw*cK$l_Eo>kxv7L*Zu__US|fk zwCOHUq#UTkt_;d-P#xj&+3_vonX+41&TWsNzmfBT0N*zJ{aFD1jtu31k#|#Z`-HM| zFmi8D_GL9I1HV!ePWwb|ILIaR&1vw)!zo`M(S^9j!B?$p5PY^R&nm&nCH!j!a^~}t zZsA&jh$$~$kt*7_4*8Lx0!wc zn0la3vXMu>o+nCy3}7(z>o-8%##6WP@+j({+MzkNPCw-g@~6n}^LWO*zz+IWhPp^p zHp@;2ir&q*D^p3vo0SE|AbT+9kj5_K-Ge#{MdseZ+1xWPk;a}0eXc{Mt`lR-y#;7g zuw8@wDcBaqU9JbjSN3%?Q>abFM&3*8~DTq8${`>9vJ$c)W!AN@ERS{x20uDSoaHu9#t zK;#%O^FKQW4BiL(UY#f)9|nxPRvaEC7?--FgIH4puHzK@K7PC*C3yddV^+evd1F>6 zvPF*QE_#SuPVwhKFY?*V@QXs$fr=?_Z`MUZE9WhD2}V&XX4l*7d5>cxAIxq7CuYRq z;s{n%jvz?BAxA?z(2E6m<;8}=AJVQo5wwTB1ALt>S9K$!C^tyw8dLz=pa zm7ryefTiLw@i?R7ezB4hFXu4F>%y*2E3uqCq-R)5n#ehj2r)@|Ie9XfQ!bI57l{&| z%4iuQUJy0nSsBY-Q=Dui<7I+O6hF(>vJK}#Ugpfqi!w>PB$LIjl3g;H%DIsCvV+(u zJIXYf&bgr1IS=zHXKtp-PSPhkv;MJym2dX=Iq#FnDVyIo^OMb~8_wRy95GFH=R{F1 zXNvMfSM4c>=y+u7Uq$QmG*)99Bl-ZASGX0@< z$8#d5Q4V0;YaqLmgXIue%K4o#SuQI$o6{stkdCx}xyZ*-dck2sxk zCCA8PIioa@-LEg$4O=5?#K){(e&wuC%i}nS#5p8R0X@lypL03w!x<$xof+X;_6!$r zp64v_D7$ML-~iu?AK6)*A?sy>oXPAjUhHCB;$h)~E_CL6$rYRcDrLX4THMC|^QD|9 zx>~H}oYO7l#FK1o$s z^W};1BzZDxY^Sm|b~^j4XR@(HA+MBI z$*Wn5yOuX5u9Mfx8|0002|K@LZ}w*Pdv9gO_jdMk?_>}7Zgzg}VPAIz`@;9L1H4K; zz`pQ9><_PIUF#8cbl0*o`?y>upO8<=r#R`jUT%=j$Yka!u6$2!liTGExl_I`Kad}CR`O%{iTsp(>d)mD@=N)Z z+$F!3-^g#}ck+ArgZxqcB!8B_$Y14e@^|@%+${ra2uQ`@73JaW53h<;Q7T%+s8|)J zTB&%Ipb}YuY@^z;@0!fcYYKa>2eA9vf&JGsc3?ZPv)h>++%D|lc4HSei+$W2c5-{D zT+XHDv7?*M&a9skszvP2mas$Hhh5-)s=pe*PVgW#SPfxkxQsQt3idv$SZic2RGB@| zQFaY{EPI{@vFkaWea}PK`8=55D0YA)wx%sY=KsgpS&bE-N`ovzMMXR5Q*0(G`JN1e+~ z(s}B9caQQSb+NicU8*iqmve&VN_CaGn$tGdhQIH~KIT%jOx?`>)2-|h-EQ|Z?^1WO z-*XQ;I?nFqO7?3W;Ka^D?3}M=J@pZG%Ga`o{Ea z8`X>IC04>;QLn1k#2uU(x|6-!&FT%-#<#LJ{1&^q@2Gdxdup57&N}=~^}hN*eaL$J z$LbUHsrpQPF1E1m`z1TSyV(2vhSNdcsqfVfypPCRp6VC%tNKm-uKrNFRRA(C**(+j zaXEXgQJg=D(Xs5kwqo}+K__z3wvBG9lXS9fr&DyQK0vqUyjVw_rqgvN&HALy&|P#_ z-HrXOEOxY<9ot;qBg)ggbUtU0{p@lVajL0A_vSouUv_i*>j8S89>n>mA-YtT>2mgW zE7{>4s)y;}=3PlWic@W4^jLkMK1h$#<2g5a2q#Am(}#0j>PYtbCa@#t>;_lsiR`aU zW(UpL7p~IfU!$+pi#cg^y}m);$l27J^isV{ z-`sq%;tp2K@8WdDa?V%W%L$A7IAgI=uhI{&Blr+I>Z{rJdxV|owd}+`&N+l9IIZv$ zXB5`!4f+}VtbR^EuV2s`^^5u?{jz>Vzv>xt$f!}Od|YJdjOs~^4fWpAiH$WU)I^px zOl_#InI2m{skU*_>={$)YUak2Pi|PGMIrrNs6HIc)e1S5w#(Y?cM!m%Sl!M&rZC(UlE z@s4tGFuT6Cpt7)!XB26B$2eYN9IvrXTCK)Tn_WM(x^ec5y6V|Y-mx}C?>HyuxUf`; z%e~{RYwY;2#~o}#cn&t1JjBW5A#SM;u}MW7(pXzRwbdcvPl-IlE&gGllwywv;oc+d zgJO^@*%@pHXd}5n1h~Hz|}x%%tYX-pMwT-WsPgHE!G*CvJ@s zH>RdJeC*V)WTu9kr-ht7(@cSDodVZ72}ahstx{_fjy*0EJZ}2b#+sV?y6XDLwUfMc zPUY5xRc>KlPaO~P);nJH)+@HY+Pt&h*f4WiO=N@fkX8-h73poTkMeR}%`|#rSc=6J z-dWZ)c2+1K5p@mqQ)hXbY&cJoDZ^~1471%znQaq{m~9(vcKEX*XStJLD2N+s z)Et<&LC`LWs%)AT_MozI@6hJHoL}H~)9vd%*XcV2m42HZc9D0uiCqkRu&Knkbi&@m z4!1RH1H?KM(+OOdKBS(VUs{x3m|u_>hMO=&iD9h6I@sqG6xxQNa(cU!(>vU=%mjk11OvIP zTj;h%k<+gWoT8G5&9pmcii(}cMed*}3MS$vT4WQ;FQ~ACw4k!W?OGM?Ln_>^P~pa} zaN_&(ZP(2AJ6$5*Ul{C?)*W(V!%?l35o4z{G}b%0sBp7f8BEpf;1zC1tF)<5@9urE z8?M-KXUxZromE#oYg!ojD00&%vT?|fle2U+|&Whc^P!LSheMX5}vJ$su`dBiR z?=Ns`pu~+|;>Ig+Ys|SPoLi<6Hy zV`+FEOZ&PZ`-bJQw9Iw1jH4jlR)=ANdFFI`==PXdwUcYwHv8IJ8^rbkf4(h0J>4mP ze08vu!XM?6DnBW0JF)9o+~6}Ym{q5Ie-UQraXP_wT; zzs&j?+xo^&M8c}6tC`{?o?veqjjUqhB%1r_HBHR{TDuORAhwnZ{m$qp%r7yG21PD$ zGV9(nlW05)-qJJQ(r?Ezzolp2Fwed%JqyD;3$3SAB4M^QtLXuzTcL+^kzTXY(56>O_!sSZ@ zXjy3E5G=ICTgD6)+Ia3!XyXtpw8d@AR+Ac#fM+0RHdQyybKArrF@+8t&i9vDI_S@L zXg$d|9R)kQj8^ueYz{Fkbmar==0Mb#OG)#qJd=^ToyX-nEVVGdw+$JGkT9{Pu3=7C zS1*mOZftCrGr3`oWm8ZUn?>X=SK=(QECFU_YZlubcjcDEbMMqVWiZc#RM{56E{n0U zbDnw4CCWkTrnqkARhDSStzp&_*S9I?+k~6#2l;(03HImrcK8m6^N@m|T^w68V=_F^ zmM^Mi#>}R9vuc`RYwDXFqibe0F==Y5ne67T!c#fBu_2*m?xecv8K&GeuAXwtlnCIM z+8ARVmuKF{u?}@n!P{0-j&bHu)-z^G?bO+gHReGHAvDw^`A%l@{pEe5&9uT+M5M*) zrZ_I98ltR=DX~SGT(7V?uoX+ui=(GB%x-j(F_=ll*iJGAv&k65BxCJRRodP|GEOh@ zmzQ}hf%C^sZRTWt?^I_p=vamv`4ht?sMf($BVw{cVX>`aLTKvR90)1ZCE|Wd#Qo8h zcAL%@-;CM%3f(%KquF;)&cQJEx0yP-7Gg1@Aw&S%Hamq#pbf?=QBw))5>gw?I)#Ec zy)56A8~m~Uvvyr+}RX38F+TIRgKvSV0YadQvJFDxi? z$Xh{KetwA~dKKnd9t$ZbvjJnL&0|LH^ie0b1^I@wk&;7|o#t>vSBRQB`utVVwRz^X zF<#y?AsDA_;mU0YLOP_!pI_n#?Ioo=(~fNfT8D}jI5sl@hXNH8x`J0>Z-;7_x{bH= zqq&wHkBJRU(;N?jHJ8}myVA}$jBOn~b+wJvPO+T%n!ljH5giMhd?NYVeC8JxI(aM1 zcjlnjp=9&R+>8}Dkzohk8MXB+Q#fgtI%GHBnGNR`I1_<_%H|0|@96s4dUu3cjG<+1 zJG3lj29iY>T8te5Zl@P7kI5ZDrBuj(efs6=ln-A{s*SwIhWWR8|I4wV`>6 zOY$q-?vU@^JIf~pmBj^7a5YPayauV8V((2|MVfmm#=18-?qQ_NNf73c$P0{9YG@-< z?N+vK*lTX%=BCIQ7=C|6{LJb`7Pf)|+G*Q9HW-y(kzl=B4sJ)-$G@`e%$mm9hRJ3L zmHG5!+kM)#a5F=OH)A{O+u26(&a8pO1beMpMGn_v*`-hG<7XR=(@@{cT1-9}_s*E2 zgPVIQu?)!)&TI3FWEn(5y}Ea$mBAS-jZov!T)W(AccZYfIKbV~YqXF)6LC)>jM%S&VI>Y2m* z9Tev8Af}&0J19LR%E4g%E)p76_Sv*t@2FX|bDi#H5W1VOY2AiRyICugfF1TE zP!iKZA}=g-1d2kJ(m~;@yolXgX_VEGJ_`Ceg)b~DX=PSOr_*mIH;Yh(g~d@#$23`y zz-eixPMG|<6t}M}2X*Gwaem(zyXNRrj)PJ;4q_|EK~3d27?tB9p|TZ5+ewGzrp;J$ z=6PCGRMVUWr`8NYtr^SKnn6sh8Jk+ORw!jld!RwCpx{WB+&fiP;H+bCZ>b!%D==2N zU8b^k#Ox4_sdOnzN>M|V)7>s=OU$HMHH4kwvCbF#aB;!;63_*4$q=i;o3a04Jv8(JH+jZ<)7;Rq4 zwhb5GjO7_D(%#lhiF0l1=!7=S>V!6!^@y^eXx3IiBrK68H<6|(VTl+*?IdEbFb|V} zQ>lGi;iFH!lbEgP-mYWsaL4>I!8;n9?8xSnh}A(L;vF3Xoax}ld-Tq`HA?8|HH~Q0 zoKf94U9_Juea3X&$l<7pJ)A^E`aMT`c<;tD4!`D~hqt!SA~_`J;OXZnGPoxX z$o6Dlnm7D~r=7>>39vs;jK{V=r}~Bd+35T+fcb^v@0j|d;2pYw-U{M`+7!e+?et4Q zoW0d2*>`==+_NuxXS4gw`i5rwYJF)C_f)Y1yGWmH?)7OwSMZzl{NNq?%Jt3oG(8dD z3Bh~EX4m>aJtE}hl-=C3=RF9skH!6=5R^C9xzmT@pBeOV>Y@40*Nhz)3{U9xXbnl? zyc|FDh0v>%IbwLz0tQrk8HC_<`2lXhfAV$GC8^#-y;^;$9mvU{jkJ z*ok|-^}WQJrpBbQPnW%ni#cnFxrGu+X?0-m=BaT%(YkN4v4$9&b42DT#n!yfx>yY< zobochBdz-!ix*lm&$^f~A~_3Y(j0GH-n1?qt=ZnXnD-y0Ot<+ju=s-(hZ>OE11+9q z-7_sd*y05iA87GHiyv+Ahb=zF;$tk1rg+j{Va*=K6!WaR>9Z33K&FPxSirRI!)&h1 znN2B&S(k$>KGxzZE#BRlc{Z+7w~PY953*rKT70zYdmyDSBST7a4jj|E`{4}{Q20pb ze2hII*b2%X)Gia$EYqG-gywW$Cr*=f=5%0JPBVs1G6qjE7TFVw$BP#@4d%XgY2Ix` z6L&TbWNdS0R!YV-XW#@ny&U^M>=C$EUQ$86j5?@3ryb!}wm!DlK60k5 z`$miZYR#Xm?=_BTU8WMx@HEqID;?9ims;~G>vFZlms$KCi_fyLW?7e6)@8HBU$OWr z*7q~(!YMRTp6Qs@{W@!YV10L5_ZKYwoW-BBzV+5+x^>Cst=S&#DKJNGy>rgXoa!%fUvf=MnPFTv3j z_{Iqocz=(;_NHa42X^lrX8$gG(+|_ie-&?EdHJjG|I)AB-;4)W{I|crAA6TH@Hyo- zV|!;X)qh4q0^hZ4mtZ;}JM>hm;}s7>QOqwm9)cXhy6rK>Y_NZ17!K3Sk-pwDYOsF= zUEvg#iV?FR>H;%lLm8J#a_h!@vWSH0tu}=0p$N z8Mr%er@;f)1s)4L5_sHk|GQ?u$Xd-m^TfS=rY|GKHUH{(LuLQJ{`(KoG6eMb3&5os z#y8lGf%m`{09OIGIeLTxW=tM7Ycl4_^|yUr}3;H6$`8DfRTOw?8huH@W!8c;`eS#zn~=7QpRb?2MtYeet~sC z?4Q`d(Hn}>OfU8$-vqgJDCR%Ig4E!j#rkKV_b%Q3;_r(N1=}EK(_a5bvJU+A=i!lv zzuuFoTQ2b%I=UTt>PiNI7ehG@yxg)Eg#7-mFeBmHoLHF?{2xjHZtnM)9cbsbMm7k1 zY@f2%EQ6V|{RK7!-gjh(z;pjbG6?qMP$rk`rCJ=zckaZW=ay6-68>pnzoP} zT7=o}?cSq{5%;?pRoK*@i~YeJmrc%T=Sb`GD=P!S%sHBU_xHqzeFw0qy&l+U+9L3J z;CW*LrVYN~9-QA0YdSug4IX&Xgxh!fIbrv2+UK}@tR-z|c^^v6=e9c_Di{6iP$$S+o+{?W{hE!N!EY*J=Tk00YHNXIt2J;3q!o93P~D4XIhX5Ayi z5x`$?5;IZ}?=ah6U`fc$V9w0ebqnkDP`U?p?@b=zRG4Plh?>S?$ zhVz^0e}DE1mdj8QLYg(CcSBUAWvpON3g$BC-?B!bTCS8BUf$-=LD?zju$LW@Y4-B6 zW4Wa(tzfxi@Trb}uxu?u?|mQG^;a_B-r@f{mwzsk{CCd_#r~^$4TbtQ*uHp4u(vqr z{W&cNf1X(hGIF6Kg@q)BA3u1`g%HaSzhEJwyHd zyM5Kv-;e$GLjK(z#(ZvPi&io;?RUnnvqI6qc^O|9LOkduFyb#|}QgZR!5O z`*1s4CUNr}1%>-0@Uy!z`XjmDlm3TC|F315P+~1=two;+g$u`mE8ijN893MYH`A#k zc8bisyV4KVEZ-UT|Ns2#3a*tln)CjzGZ%QLWz=w_$S*P@-> zm9&umzr{jr9G(K>@sq$u#_ykxvG8>EhX*zUL$D@g*J+yXe|9idFV`cJFrLT@ZhO{4 zF!X<8hp$2g(+Tw^JA1G)MEIBwhWlUIe|Np=@8_YVbhNLS%yTW>vUR;*`DkA${NK9; z^KEE#uw??jx}5o!aGroalB50gaIDZ+3Q4kVh<|AX`DWmUz%$`}!f7v_-_;}>*p2*Z zbbc9$|FXaTC}t5Z)eYLAxJcRGnDPYPG5cw@g-nh7z>^#)*OfMygTLy;3arHMyXHCx zCH0rsp0g(-*;9s3jAR;Y2{88Lk<)*2R|Qhs=e>?=aO6I-rxg5`kNp4ClR7u`Xx0hr z+LB}bFSF#-W3Z-p?x)nHefIzIsU~fA)ch8F4o~LU!Mpw2dr6^NO`Lz)!}kB2|80o9 z<-q^V^Fs6?@G`aZTTmtpx&LpT8%h;AWX7%O+s3wQ6d~XL1=fC0S6WD6L_!l9bjmW06|Kca|T3BSTA-H5m&_M;Qu$sK5M+AiNWnw$8N>xb$?`6arWK9*k@?M-r*(bXis zK}*xG@;fv%Y56_6nIib2V3dxMzZ&gKd}XjT->v%tElk~1B$}3bs@CXQ@~bvR&ys3~ zPNi~{YV;?m4rot0T&0<>{HU&IOgdV1Guo0=CfbswsBFG0SF3W2rX=uTCN)}y;s z8G4WIQRSSlU7;$_eRRL7MElWdRb_M@sZpH4eOhtW8(n+mi!feJw3+W%dASs}xnj&& zE%f%a5-I5ZYXi=w(J|X`#o?C1T`HFcEtm&zdb>SWD%vnR(9b$@C35aB4WD$b2-d7R zp#{*#2|!6d?LyjJxg`Cy8}6CtH;lFC0;4$v-h;DZxm=RI+!H*HOVX!%fupaR92IcI zuz%s_gkB+6yeQ)8NPjQ3XUR)&@6DBlR>VF$voBW;J-;6*_2=qIr~y245LYfb4F~hg zAzYd0xho|f7E9hI1)(3#iYUCT7if@0-rN+)gub#4_ zD1lLQF*DptxKhxdcRBdAXh&3N(7Th`yGz_n?JY--q6bZi4^eLqbF$EA6kSV=J&7K~ z2u>Ytqtx5EqS36kgL>M@mqKGXrT8&A7eC>OMPuk^;Gc6vi7&)ggxtkdEWY7eqEYDe z`yQ7c_$H?m0j?;?;YJaQUdag2N=7oCWBA5sTa=HPQQeX6L8O4E%J%5_?0{ayX!I&} zB$m;wsO@Nvu%kVO(SEYOdeE8Zag9WG-#M5I(UTjA{=V}u(b9+S4QP^Aoawv? z7c}(=kI~f!eyh9{?WAbz1HXeW93&XMec*S?yTO;sd%)4&hq+R Sp;;r@Vp7<@IS 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 literal 0 HcmV?d00001 diff --git a/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e7e02e51e48209e508954e9fdc08a1036d752702 GIT binary patch literal 86576 zcmd3v2Y6IP_wc7An+6GiKqz6^gd`*oAiF8_l1c{)DwbCb5Fio~OoD*DA!6^nfL%~g zY=9yb?1&8&D@DcLv4ObXZ|2_3Za_uf@;(2(&zU)QdOhdN%-Oj@3L)BHL1X-$3C@N3AF9 z1Eg~FffGw6&YL~sJl@lIPn$BUYW9Q+{S(N5*VwbCS2fNyx#3UxB6fQ1{As;5F1SmG z2j&TJ{mL2DRZ}~^zM}{JC*of`1Bcinqc-tAiTBJIvzq2@_1;k+M2{ncNb=RzPpQhh zc6S$&sud#Ul37*rX6v`bQ-tqNcwb%Btm?)$Z+lw^{UsGVd3Jqc(^F@Aem0mIbsHDL^3avji3A}q<#?_8}{*>VT zzke9@N>nxP9uwC7qEufsiCl#`2V00SjxCQA0omORW344Z1osoZ6GCp4W56C4t#?If zr)($7OrMaK8h^Q6J&NTMnsO=e4EQvlEnsSE@*WGc0{6;ks+St4#;Zfr5$Z@aSxr%O z>R8pF=BVS;@#+NignClFtX@%XsZHu<^@|>@$LLx;OP{XKFlm_>qJxMNlf-^vq!`Lm zXz@IJK}p!8}48fqA4l60=Ip#GIw-Fli~wCUqR<@#;j( zYt>>}(n|Imm*JH<>IufI>*Je7_7#-cvM7~Xwg{}y6zs) zQ^mV@EFS(NuCplSA(CA zw|Y@WSf4l$Y#P{Ha%!+SIU#BZX>zV3yeYSja@S#36Us;U88+;JVif86NPo5s8=11H zV>pF8LLF>lo3feQeZo%(4frBT)-qq8Tiz;h9OW~0suGhiO^eLs-IT+Jo5{CC>~HZV z+Qj#7wvg$O$abCPwv%boY2<6#vxfZY=mXPUjf6IB)Q6hZ(|46E!uZ6P7NNrZ*|Il# z(+e$oe$SQ)kVC$G!q^~k8zTl0H#AE2mFfZHGM#=jt=JF|Z_m+XMn%i=lu^6cV!o}t zGj4s*t9%Pi@0lJ6mt^l0_akn-IEHkm&}Ke*YYz4d+oEPf=IqsD)7}1^Lyw2rx9?x} zf|&=Qw&+bwBWFx5Gf|M6^DGk2i5;?|>?2F%e0h<)PCh2zmY=FnIA@;%8U2z@b8sk>Qy%G0ae0u!;@%8cN$6ptJ zXZ)J@_3>}SZ;Ssj{>Oxr2M2oNtH?aCmoSAJ*gq-q@=TxE={^2 zX=&1fNlzrblJrT^4@to`@on0-NpI7;P5(A!Z6>riyv_7BjcrbDb4Qy8+N^D}q0KvO zK5p}Eo8OXSlG`SGlY1r?B@a&?lYB_>l;ry46OtDuUz&Vl^3vo7lb=XlpS-E9Xq(yg zfVStiy{hdkZI`!Q-S+9Wue5!)?I&%2Xm@zK>FpZZo!o9wyUW|X-R|Rd-?iJF5|@&a z(k-QDN>R%2l(8w3Ql_NTr<{;-R?5XG*QMN^vLaChv;A@HPiudE`>WgE(tdgS)$N~d|7!d9+JDkvXopc9CU%(Ip{~RH4rg|_ zu)`-Ef~g6qeNqReR;KQsdPM5<)P~fPQWvFOmby6guGE#OPo-{1eJAzf)bBdxcAV64 zYRB0f=XJci;~O1!bxP{gv(ts0R(EdKd2;6konP&|v&*P1S9f`$%O`0WY58eqb&c+t z(zScnK-ZG4`*%IMYkk*~yPn^5N!NS2KHK#NugBZj+uJ+RJK1}Z_ag6I-uJro>sHzA zl5UT6+wJS*EAox@P4mt7o#tEQyV!TNZ;9_t-+jJSzGr+Jd~fsQzbyT}^jFe@83`F3GO{xAGX`ah&NwJzYDQzm=@}PiEY4V#@o>gd882tN z+r2~gjP8B95AFW1?ls-#bU(HG+1)SezPS6c?hkc;vipYa?|1*Q`>xFR%ubm(nFX1{ zGRI^dk~uANPUfkZ=Vo4!c~j;WnLlUgtfZ{WtWjAfWSy0Dan^NNw`Z-$T9fr$*6Uf@ zvcAjuEjuQ=ZFbk}oa}<^VcBD{56-U2uFYPQeR=kh?7Op9Wj~$$YWDltpJo5pL-c6X zqhpWE9{D{6_ZZn@VvnPH%T!FIhk88Gb8F6hIcsv(=e(QqdCpI{GPiYZr`*ikg51*FV{=c+U6gxy?yb4^<*vzn zK6hj8=G@P7f9)CDGqq=S&p^+iJxBLExaZ`abv=*oxuEC8J(u*nx96ihpX<4?=a!yd z_WY$+RIj97oqJ{Xn%t|d*YUj;^t!m$;$HXmdZgF0yzy|^ zZ%p1{c{O?S@)qV@k#}p}19?y6ZOD5!Z+qT%dAobZ^={ugy?5{41A15V-mmu&y{GqX z?0s?Xt9#$t`@Y_5`tTf}u2>YeE*?`nsrcIB+lsdpf7Q?1 zZ+gF#{l4j++y8+6NA{n|)7by!{>%D5(EqFcKla}}z%w9eK<)tlfPn)h4VW|Fqyc9Q zxNyLg1MVA`Hn7LQz`!8`M-H4g@aTcH1LqDrec*)yuN!#hz=sC@Fer78IcVLW&j+^|Ts-)Q!BYp<4nBSGRfCreer)iIgI^!~?%?f%zZ)WkBn;^=Bx6YL zA^nGp8!~A~?T~pxt{!sBkmW-*4Ebtk#?V8C&Ki2!(B(rn4NDx>d05sk|FEIM#tds1 zcJi=g!&VJ@pXak-KMt3}2M#YCK4$pE!>=B`WcbqI_YYq?{DtA4mUv3CODapwE4i}d zwvq=+)|I?ovaRH+lHW>Om3AyGEFE4twscbI)Y93dCzdWOy`*$W>Aj^-ls;ejTIq+S zyUN;?rIqEBO)Z;Uc1qa=W!IOjE_sbm>qkb9Y(28W$iqiY898%g)5w!Xo;&jDk++XrKJwv_Z;t$N zlp2*ZDs9yGQHPGI9(C-flX#YnS~2R8QQwXFb+jBEKYGyU+R+z`UOoEB(JzkveT-*J z;+WJi8Dn~n89Zjpn8U`@jF~g$v@z$8xq8g4W9}QXX3TSAULUh#;k>dB(LJ=N;E`T+z6aapT5K8aHKJ-MIPV7L2=R+`HpG z9QVbz9piS7?>m0R_}Sy0c2sN=_BL@U zE3C<)HS+5KFY{q90@Y8Isr}WV>QS{;tyk~s zDBVgY>9(vE0}FFK=J(Anz#ef!-Q#z4rv~ z0`G<1OTAZmZ}9%=OYpVvwfA-Qd3_naY+t^wz&FrW?mNJDEEIZ??*iXt(C1rxw?m`v z_O0+e=v(bu<6E2FF1xCYlZ7tXp#Y$gLM<3GEK%}C>kM`|<+N6c{ zP{(BI*j;DaIuw z`-$Dlb|1Zi{i1#SKH9p?w5{0oLMY^xz*a`umdq_*ZSyv}ZQA;x4P|rPy3F8P7jE6M zb?27&Eiqf}-CDExqs<=(v6?VJe&J$&f2U#`1FGSTtK2KkuFVdILvsdWF z`X2qLen)T7+x55ldmHN8sGHd3xg+YHs1;EU85hUIUJ>;`)MHU^TDOhHj(RhS-L+!)o1kD~I>a{2vn^}6O7$z2r*p_5eJE*Siy~CeRl}* zbcrlu6j!r~9V5!*2r*S2EXr9YR>)E6cX61^N9xX#eZ^>*B@dT9WV(7rj?r~8M;;-^ z$x1mx*2qKDE}1PR%PKitW~k?6p)8Wc>~@*;>oKgo%u4K7EfEQanb#Jjw3dI`K5SOV6{fvR-TyZ-_U=CTPuv z;v=zLyefVWKe9*hv-m~)Du;{RGF~RgR`h0j*+r(Z!h4q0-gB%nUSO^95-XSutV~~F zE%UONCthQP^tw1jyba}gN1P_!6Q_%J#Yy5VafWzboGCsK3&du59s68oF;*6et>Rqq zu{d9RA}$i2i3`Q2;sW+q9+zFk#o}|hMtvf_5SNHA<>TrzajE!9Tp_*{my2)2mEt@3 ztO|;&#P?!{Sj%qbjbf)*!q{8P+__o&E^ZM)xm}ltTN#$OOC|1*S}c{(;!YVQzt_i! zWinRWCF8`svb9(tlf?b9jd(yNiw9*}@sMmM9%l5ek{!fq@rdjw*2vD{QQ1kxs6*u@ z-BtdsFOz4;bJZ!Vnx0fUmcgklS=$IbZdWC#rt(QgxQRLR~EHRPW0N)Q9plohm=j-Q_2`RDPz% z%b)f6DoS4?zt#uIAM{-Ly6z}H)&aRucT$P^K0Q>Or0UgRJx-66ljNa#vARVcrXJKc z%74kD<&ko-JWL*?o?zc_vOb+1!Ex++%~3Dw(d=yz{W{6-%vztxAxFZ2QOOMRgHPEV3M z^nCfFK2v%04Juk+tK#&{Dn>6+vHB(zukTcC^n>zv<(DU@BH5(U<#8%Y&Q(3+Je4cw zsP3{(rO4T;qdZo1mJKRR)~gQkGPO`%uFjU%sH@}!>NI(gIzwKp&Xkv^1@c05x?G{Q z$>nN`d_#AZZ|OAol5Q(s)+zE8-Cn+`JID>XoqSt+<-6J^-_z;xeVrlS(cR>4`VzTY zUn+z8awYYZO6jYV)>kW`uTZV@-6}yZQ?2!Km89=iH|t^gc(qT>mwJU|Xr zhs*udH@Z@K#S`jUv$M)h?+uoI$IEln7NN>3$4-=^Ypa^-M52rraV3XU_~h`il8HX? zX4wG~!N-&whBf#Uj@6~AsIkvT8^GFW!7vt+?s_`>*{C8KGPbirpV0Nn(0;2TU$40mh8wm zOs#Bd%~sa*SX1EHAa>Sci5>L~Q|rW+vkktzk@Uru#+tfm;+@9Ee81R;84w#V3&eAn zh2lxfBJpVBoXL&ip~gA08^yg%ChpSXs~hT}uBKNrtZC#3NnaW>$(lLV>|xDpYi3z9 z)0*ZeN{a5*?24)AcTSLS>t;=J-eFFCjM>_nX0P1rHXAd|n(3I@9XZCe9q*h^05Lqq z&%uqKk9Q-RoZY+}PM}V^{Jd#_WvQ6mwb3Suyis0x=yu+jt)JT;!?q9Ok(w z`lslPJXb_FMIR8|G3ueHiTZnWp7J4y*76LMQQ|f@zDd`VRf%lQ@RrE?8{`uFPvhYXvay% z>6~p`Ef>hMEFN)#y9b_tNg=P9~y&Udx ze?-g_5yLsjBWfiw_-eIAJ&s=tCpquK-8ZXMX8jQnda&rixlEyIt9+^i34VZTr-rD3 zx{qqFQdK9`E?reOm7y|Kw#q@q@2di;SoK%KNTUm94PklZS)`8V#S2JFDXN3&s5-M# z=~d~fyUJ2MRIciy{Hll*!qAA^JBm2{q<&bh(vRphdaZs!uhY-qA1C_jhxAIl8d>r& z{W$dXX~vE@sWNZ`{h2~LFow(t4mP1m}ZC*k`IXPrW|TK4&0*n`fnYDEC1~ zwPi@N6;}2giBvlpDfcki>|f$Yq+oa(D^^ZDkTk}r39LvDR0knpOjc9XG&MsV!`a&$ zWRLUI`RW3-MBSn8R7=&p>OQrCbGK*IbDX=qj!g27`a*rBzEMBxXjTp}aIi$3t9$BR zdY~@Xm8>L=)JN%~^)x+Q&(L+O9Zq4baJoKQFVg2Yy&BQVeMAawx%i$;UUv}dNOhi$ zrUXmX3R3SyN;8Omjy9t?g}xpD{~9C)iy@qh4x{%=DDR!>KFZRQe5aG!BK_DNIXs5n zB7F*Vxswn_sq?kxALVc!t#L2)&82SBsL|Q_(Uv)g;p#jwMBTxu;Ju2h#a5^)N}~7E}{OzsQDmT zp+6zw>94)ZX1JR(V^Ul@O1EL8=UK{VctUubCyFSUE;9_^8pYF=nUsf2U^qY~IYYCN zy(R7@Rbfww6>oXQhtMQcWcE?z~Op|K0z}#47wXlxOBtTclH5KsZma~uB^wN-eTXtG z&?l(7)vc85aJ5LQh!BPruO{7v`Xsen-A?NNQs?R@+C|d(C2n~gK2t7QAtO#VYs5X} z#E2BVg!&VikyMqNs#(1nD(Cc~0#|ai`WP1lFX`d7U5?v!roBQfk_c|NvoROh{@L5N zhxPpsGc-!Zs<$KFhH|FfqW771l5;LI|4baC=U^lX(;G%(z_=P)AVV6T$=1xZE>7E+ zG3Kt1aE&tSJ-gNmm%yXY6X40lH&m)Cz?+c7^N~uUkS{vGrQ^{RQ2Gp2BNWtgI_8tA z8nahKh-1vzzM6sS%W4|t6Uu5RM1(llrZEw}SJVNRPpbVeBhxs@rg0#yFRT4ApHLI% zgK4JpR=Z=engZ_jkDMsl?}Gv+dYSNF! z{3mHQmkDi+RP-XY|ATT#Wc_UT9!_O>|&hFyh^m=!IawQ!kBUx zT^|pvZe+Cl(XbbVh-}y8?5!;Qf-h=%t(r)FlK(W{|Du;tp=|%(m{VW zZ)6{IO{!QspNOu=PevcamaEXs%TPB)vCYNk>P)tJI)(#A*3is2+Qg>mjOAOz1S|b5 zk*koO9@e+IQsCYarj-l#marTN&6V)_M97U15~(Xg?n4qYGNvQx?I9^nuu`X4%Xp#S z#jfPE5nle2?UX754UDOQ^Q4HRf6nyiWsHF2|u=9RR}-we;12S7o*sRDwZ+9FI69lkJ5(_ z7I$cj2|vn&lMAt1i#+)}@urh!2Kl6jUeFM)eUA~v>MT(x9~G_DMA2IvMVaOkb`p8l zP~JR|MqSdud:=V*~EUl3V9vRp*GGoUo526u?v$Z`Pq5A37$Lw~5BwL^I5-Pqk+ zKVt)0x^CdAOk@JRii+j)!iPo$;W$ z1-GawxP!sXxCnt6ANv4ZK^QZ3LSV*FB$)9O8dGLm?E~zXGvh4;X559qj8#*%w^)(9 z1DNr+572W7W5(;AFax)Zc3j$VY{v3mf*J22m>ztVu_|SBa2I2KF!r;+Yc6cH^B_97 zmAUY|I~NAZL(QDP?Ml&Bu5{;xnHvrav~$GFlfMAH)tNucp){b_%%c{tfIMnN7c;k- z=a-pd`v5!tBEy(D7y>hIO&MF^)(Q#za>_VDq|2K{ch>b8inA>Patm|vU!t8_&v=Lx z0px)UmBqY1p7yT=%$PWlG-}Z}JeV-2vD*Aqr0FMsfg)M=2Tl|nkouEZuXolXuov;Z z0K6DlfNq!zQ}@8ldk9RJ5w3p`wgKapOM9ie@A=rL0GYrbU@*`N=qxjt=LTMKVFmU| z;2Pj+!1yf{z1XSm#ra1s@ip;ogfR3huOYV}YoMdP($&T8TTjx{q=g+F@|lj#`ZWxxDX-j_m1g(lTx#+Hw~C z?D7-KQ|>393fvBX&VXAm^yYr_GY#nA{n!aWhNVBoHsK5nY7Q?_9XDuG$jz~HR5s(s z@GN5+fM-Qf|ELPeWN8+BEZe~iKa1K0Jjr{Ac46zA-S>Uaux1)(#(@dD{7+zC>4a!t zq#dG#=aOa!57{Bw7_#?48`T!t`;QwGZ$0Ui;TmC4zE}D(BF&^`5N-TYoe2^fgaYd z4;xX2zl9?=K86OcVvb4X*C(1x`Z`kHqObEy;S z5^3yX#ZdhF0mIC@1!z>L9HH?!>pIAT}ofoQomYJY2PDdyFbEjXFU87gu*&=HJ!0nf#yIj14d?S zhR+zs1;}%eVB(thziT6J%2R1Gz^wo58Zh+UKz$52ZEyOZj|H<%bi;&f+JgaNO%c3K z1RSiTsh^`=6{AwKzR$8d6geVS^rU8aqBqo{FS}-b;ub)+i-h^tPUOz{o~Hz3#W-P3 zz0H~T0pdV*i4PKJ%!o;7)f|S#j5$Nt^E|kVNMj#*IA;q-+q2`g;wZF4hOw&^EgljN ziwDGmR-YxAox-ldE0&2U^glAtdwEQ(WhZZixL-Vlrb(&j&Z$ppv|*k{gXJDHYocVd zm?AxBo=io{B?f(wSn-*Rlkw=qREy_i0%uLFWNS2ElF$wLMJCI(=!LwD&df_PMQo7m z#ZJj789MZxWM|n$Y?EoStMsB5^g8-5ucC7^U3QZ`na=*ly)wg`@uTmPjh4+WbbfNs zxpO4a-vuw?vn?}gXJMcp9C$P-^3zxNDh}r zpx<+{I92{joQ7V>QSxYXlqPff^(Ciat7WzLg#F8}ogHd<44O#jA)y8IBpN?wqwRx^ z5?sy$#fm`}=n8a#9_4iH8FY?zh@UuFoh9pJy__wN6^X1KzC~-v$GLDi`X!f(OW6y* zkn^S6Ie)$cjiRfV_t%MAjmDE~kd2&6oevehtYR*{~a=yBV6V`J%V?AG9fF9yS@?!KY zFO`?c%jFgFN_iD~ao5Oe`On1l@&%O~WM@+mYOpO(+aXXSJ9dHI5TQLdLS z$qn*l`HFm1z9wIn8|54FP5GAG#6LCOk?*4Y`M&%>ZkAi*R=G`nC_j=PqbvD|{8WC% zIrSIvOZk=jT7DzHmEXzl?0|BW}UQtmhT6t8A zidAtcUL~kjs-r zdySlhDsv_}R+)b|PT3bMa@?H-%mBFCj0NmxoRHzGUh*$C!#ZTvN}bbs!mgD zvz&rHZ%=>MtC!RUcEVp#ud3I?o$7V5l(XD7)SKoXiJT3;!>R6j>V5Tr+N`#)55G-) zs6J93vmgJ7`c!?UK389ew>bCxij&`OIQ#t$?Vumj4)r7dU*un*>Q}W>?NYy~-RgH0 zgv?7$&opOT&Y5d0`bY6PfwR}voW3UMHfY+m)$MeOZm&D&RNYZ`(w)&4OVeGoS9jAs zovt%o5%l%`sltoA06ZXr`&~TH5KcA=pzr{G7Em`T%_(dZP!SIeLhmguc{aob?^SiJWs9 zT%{*-zBZK;H0NBn20f~o=AW6I5Z4L+IOi<+T=Y-RM+5ajbWkrw3-wa;P%lRl^-6t}zFJ?SuSL`9dVPbw5#7`! z`X+s|zNJ~S;!bwVm!Vy8H~JOJ(XhA=9gF+*1NuQu1Rv%^eHG_^k8m=*hLhN}=pj6T zw!%~BC_Js7(a-AV^z-@!{i0s4U(y@&%lZ}lYSj3H#*S5G2gH=js+!VJU*{>A+)#a7 zbxcY9^!mE$nF(c6Y8s}@nKiApdR~0l)cU5XDO0NJnmpxGs!ZT=qEt0`D(qWKg_}f$ zO(MRcIe1Kko7#wo)J8OWCyWd`M~$3Z)!-S`R8u>(I%brUV9Y2dx@VM4IAL@+xMytD zlsQe+p0Q2|=G4{rD+>BYjU{c*c*kqJ<2AuatM!B#bLys7HO!e+TQ#T2Gr^|lIlu{e zKtw7q% zajTuU@zu@Y6Q)NbGd=7)BkUYC!&JD&sc?;xU`&nMD>XLZgk!?NTg{x_P+eVDTU9r; zW{RiQY24a~#w{2SRZB*mI>)QddL`6Vng8=Q)X$z#9aHaQ(z-sfAwBgrD-Zf=rqdfD zQYG+Nh$#&9{JYwPQ#H+q_ExTq#mhdE9i=D3YA$0is($9CGB$h>0axQ#qFoL<8G zFzz|tW*2{abA#z>I<4lIn3`j(PYi=GKTza1cEBIVckH49*Dj1>epT0S`M``;O*3dN z2X9Q6R&!hkZ`H_DtaCkVdIco|9D6`voXurQ{jAB>CBM}1$uAAK?@mtn{hSiy_p3DS zf&2l5F(nnvCG=O6R2X-EMQO2X+miBb)4?vZ>G{jb9sd&hPP{Vr-Hlh$-+eE!;RE?@ zIwc|7DOVsr5MN@N+ZMr7+Q(=IIxz|hTG=)$ENFK0SLFBilsCWT`^yS#7;Nj9;1Hq^ zZm3anVB(gUz{ZYrtZ*GGnjJ~D$W6AW+0kF&cWYScwosW{(=xY4Wo`?Vx#f52L5sO9 zQ5tFqH=aLa7sggJ&4|dTw9GT2c`WDq18%wl+E8z*h0ILrm)C~TcW9NnnUuRjq1=sM z?!*t|+o72saE3&Fpdd6Ptvlq#hND?4q9@F#Z>V!hQSKJGB9y8-z{}l%R$)`2-QD*h zH(Zh9&YVw}&{$j5I3t356uM~?+BlTRDVe{>EvervS&=&#{Gl}6Jc`|#6}v6d-;$~P zfZuI_VmE%V8?V@HG3T9dZk>wVauhq|@RwWihrins<^7z#E_cYJzufi!E${YFg3IEmq-{qS7rzMW_Yc+$!8!ho*Kv*S}wX&mp#v;tmN-9nT?l>^nWt->rRRq36&D z=SsKH3f#(8y4ep1c&f~h^e0p`Pi}C_ysW#EYKfiPbd#Oj?vN_AgBq%22X)BN@65|m ze?;_BOMeMz>2D~crHnXQ%4i)K<96;F+eU9w)sl+1QA^yYCE=))t=!4E#8UN8NVkk7 zk!36y;D#I!QO1%|*U>T#f1+&;!vy;n?e_5NQH?cItJ^jE+E*LIj)FkGtv@5(sefWs zsFxzMa@(lHZKJAcn_NOwGYt!s-fg#%aJ!MR%gpi}$5yTgXVY)x3UKx%F)Z~oGxI=c zS$vgYk~V~c+RT%zt3{oX!tYGQ6RMgCp+9kQ)s&fbnuaS}QQ**hY!k0A7Fnp-H;`Xy zeT{8>6DK2KRo7Nea}rOouMI|4v2og%_nFmA%>j~Khj0+v$^`*ub`<0nn@)ow7dwS@ zUz$lY9tPjjGvCs0$1{IV&jAsh1NQVRi0~}1o=$=MG)_bP%<8(x>gW6W=O;{_Q(If@ zRL=wr1pKa`m0xB#N&zwie{yi^QP!L$!i5b;+gU=AUl4EzO+kLZ4IGFp9pl2~O9a@n z(#9cFX^Za}GgN8g*`m_MAyjFL+nBAV)FT1ULeOlgYMAf#i9=!v96FpID7ADjknhlX zl5qwKc4Qr`>_^=kVp`zJ2iVPlXfcbv`ni@(K~-!Kk-uDtv(&N#n3-)^Ys4q7|Sbu)iuiF4fQ8>hLxO(EYV-0V2W?{7(PAitl(cR-v>{2{w2p?cO-c%rRe zZ1t?!P4gS8n-Z$)njPb+8=F`(HC0b_%U2#%F{hzEse0a&+NxQm-ZrkDcGR?J;Ha8- zV;_Vg>8r!i`7nZ zTud{>S{GAei!`}j5p7@_mZ}%UO{<^N;3i`*lZ>&QWDI7LF^EaV+To_OqlaXiQ4}aE z^;iNINSNNt$pW6~&SKE93^@w4iCCao2h)s*$qt3ZwvI{RrE7B_q*RxP2P_c}#97*H z247+`X4@Tr%`-2jsP&Bc`k7Ue>yN88rES~d&6c>OlPzXyi%@%(+;7=2EU&0}gya|aOC9p& zFU`*{c0{j&e9L1Y1*JA%!i@Q>$el6jl-8ebNE<0RRN3hcM|6d#xw9`&8CTQC{B4Xs z@0k$HQ@3(uHUuFZ(i6xpc7*og67sZj8-doLqJGC_CE!pXe}OA_74&nchH2YGOFx=h z+3}cM-!#MVFj#Yq1N|!Oio@8}F{-wvp~|V2vtA4M{f_A9cgl(6Z_AlqP~eoUAm3So zVuzE>FLeu6=tPDccxKhqu}$HmUE+}4d}lSB?{^jg{)*-WLch4WnmTueT8yb>Z9BCr zW+swFm|Bh^M4-EON0cf;O*A zY)Y(9T%sG2n_am&kneO*z!|}GplvKV-El7nxT0)XWpsU5zILQ=e?>(oRU4XIT#{em z4u^dA-Pu0zR}}eU;cAu;c??oJ&AywqiZSmrjCF5v+#^VtlOW8Y4S!&qR!tw7X1B6+ z{a$Muw={*$#0Uh+6K7X7u(1`I&`#g>x4~%q@+9lM=j3*Tef%rh&8}{!sh?`LP+3n; zwZo^w9&TpJ@Xy#Tf9!0dcxG3_VnU6&NerAyd&W zdQOSfwUda!B0Nk2PNVjB zg^&LEPGYvD`?-$&A|3Nfg+%`(9{sKH&K(3I(Yb>FoeqBdkKXxjjS_lhbp!Wj&Z=ra zYi`!eSu^=Z4yr0vpT+#Y%i#Poh(9C8Lyi&ZR4>Tsab5hLJ^~OXGNQw{je9ZSO{)wIm{)t*) zOf=FAZo;p(?k}mkz!U9>cId|5&|SUeeqPQU(OwarpMYi^4zo>z@6h@(E==`E`qB0B?#sB)FIz(|dK$Q8Pzle&` zzZ={9^e^r`KVO1x*IO_F%y*0#`st0$*aoAw6#D5Wo3TeD(tL>1*roawFd$Fbq@JB?a=&YM!)@$ez-h|+!CUk_T`M9k>&i*1Ngid|41>) zT=z}f)U;kbW8T!`j+@0dsnwx3bQtbw#u&Ap!YVm06u5D`P27kr<9P7K?xO20Q32DYH|M)dHw-K3? z-{e085?wLl9&O!CU8Q)-hQH97+?S4TfyGVhx&OpO56h%!T185upD4Kj%D9wSoS6xp zZ(Pv)H12O$mk!qKWL|@P=He_FmA7$~!Exy0S z_qX_XYd&mU%&3&&H0#pOx?E;(w7QJ@>DGOuEe}+Myhd6VGxnvNVDY;w-ou)?Htu;g zywT{Ca+nP>*5YGb-@_?+PwQg;uPp~#_W+#0xkn}zdcNmfGNF59I-@gabOE~wADUws zR(CP1w;0k{EVTNH$BGxx{c`_hY5vKoZ-xIDZBij;P&Q^X8gGIdyd0Za$AD5>wBm$# z99T`QAE4f5?3g)s6RrNfZI5?NE28sfM#u5ijI}Pit?wm{Xv^Keyue>vA&;J_w^Qk$>J|r z-zMwQU|n+fk8Cej=gYa(-qGqZI$TD7%hBF)^tOz?l+lWM!|Fj{tz^j=5PLj-v>Y4cTjs5;oiZz@97l$ zZcmqg|2;_ig@3_c96V%)eZrW%7c%_%Z(>k#5m{f;J|<=U`6`0YH*>RMaE;TG!7oiF z!Hg!i%a#bWeY5?=-U;s6>t@dX^WWa|!}RiBr4t$fe--Ax z;MeVMrV;Zu!~H=pZdBc?ra?HWo!iYcaL;!BgM=cSf6Q-fIsv2b9YAcXFvqE55DSz4sND?=K)s%HwE7@u1>h%OMh1e39kI}od4Pf$;i&< z(CGT>(0lv-RlMMKXNCknZ*KcolR`5^jcDuOy?dwK?2=2IH=Ez~LQD&``~|;u-uIeC zaL>qJh>DXw7j_rd#ukJx;d|avfp9w;Nsvb!PR@w?Gz{6pP50kHg5j4qzoa7 z7@9w3h6k58frBetdxuLue+h+ZPAmcm(O=?k3jP64#oe@TXypj6j{NOOS;Bs1*6eHc zheQ7{W=3f9ya|TF1WycMuI*6M2%fTs|KEBwYp9k#=F|?`u0I%V_9NO=vjBnhUH(I zY_XF#qan0k@^(vV`}Z>6n=6nnqc(B}!j=4lnTf&6+#)SGF!5As<8SUnbTE zp}q<};)KENmk_qM%}#*3Vh;NSA9HYL?GJ6+8QQA}hY4dLJ`#M9oPIJb6Z|CjeAvGw z7HZp2tdQ@Xw1Qr7*Y3_rJ`~Qe_YB?A*S!5D?`Xj*-4ws$j&2h#qD6W8iE_pt5KmOosdMp6#9pOT?kA46~Kezb9m3=>Y( z!T!o`&#-^}-g1<*eEYMQp;idfrQk&-Y_nvY$BB`7-(gJiGhF@u;?Im>rGpT$)xOL1$0-HB41U2{#7Hv#GfHjB7JMyKLQWRQ zE&L0OMb@829{5Jhav^22$BR^HC=@*M zBX{R}h#PZLNEQh`f)xFIsQtnz{WTU^p}48BQef5ism;rd4dZUt96!WmoU3%8ME}(UH{P!u_AJxH~HQ|0_>|Ou=at_t(kN9iI z?|)%Oj!;wE(4Jgyi<8@)Qr%wf@BJ0M4E5kYD7oTJwkU2@6Wk2fTllyO&d=R&1>6Id zXYYaQ%eOM zY~kLoReawsT0AdfWt{jxrl3W&g?rZ0#8z%p>&D$^Q@CsF6YdJTjvL!<;Et_M_KvNd z+_CirH>tg)dZKsMM-7xWbH~&$d9Nx_C45!yP`=*xAooQbB_HBesCxM@cS1GFmFObQ zCO16YORn7PB+->rkH~k-%}(+?ZgzTHeqio!lAHOe%|^MEJDax156wMIe7E&u z^|Ablo0`y$GIuq}Z_Le1@;mNi+9`kFJ|-=9a1&EB-w%w{v2v%mi-~UvCi9iL-Q2m9 zrDC{OskchzW~G2?Yi?Ci9k@ZMOm#H3C8;jll{86pHQ)46ncRNqS$~E^Qsh;LGB$dZ)NPLaZeDz25QdPnoM|Y`GZaKO~m7!UCuPW!JqZO)xyN*_=N^`@J8q3{A>lB*Z zXiJ*!zj)ASGheOp@F;Ba#2Z~LZs}{yU66b(9Xy3caYJ+mG>TJsQbb3dDDJB5gsygH zo{oGcste;Rji(KIe_iqM@IQQO%bZD)Htv;8FD;ikUR7(r+7 z^@12~>RX7ph_4yMa9iKGnB2*S?+x+>%q4OOF5Jtu^JFZX4z%P3zAW%; zZUUFw#Mc8nN9BO$s$B4%=+sImV;^wt=`!%~dOY|9Jpr71@kDEG#+yj^gY+bD=sq{kLif4%cZ!}O(#?0> zxMLaW&v(P1{HEOp6X5sr zjX@2!cmVuC{h;V-Zpahu%pG}r69E1p`oce+1m}J{Zi!XU5W_``K6o_L({PcFmWR;( zJke00IOwh6D4n5Nt?)B^r3-XB5vrcV(-!KNjJx49<{OIb&yP}1uRjW%3o*j`-6!#X*g{0zSdL~xo|%9sNW$mL0}{3ec4^d+A#cj+d=e5Hrw zE2At&>0miZFUv~?T3#}Mnhl2g4B;uDL_-N-I7&auQT&#p6d`?;lDd(<@-0V6u^c7K za+EB~QM$rWX48hp@1|^0-0wby{f)94O9m zpx6iw#4Wwe9H=)O=u1L=#ghjw`WoDDqcXUW;X^@qPkWd57+JELpI?RlFV!1JYfjkGCJ7Pr#Zi&5$ z@K^KY3(38(*MJ*7)YQN9RnIGlvTy@k0+zAt>-F#9q1cKJE?&xd)QjNo}` z+$y`9)P7f5q;rcb@|@*=nS1d+ZTX+a@;`0)pU3h)ZTX+a@;`0)pU3h)ZTX+a@;`0) zpU3h)ZTX+a@;`0)pU3h)ZTX+a@;`0)pU3h)ZTX+a@;`0)pU3h)ZTX+a@;_}kpSGOO zV>zF;oX=x9pSGMY#d1DvIiJUJKCKMr)0W$%SZ?iBcq;dZ%}+xacG8*aH> zuH|#tme29cP`(-(;9pbM!#{3NH^5_V-Er;_)aJa6P!|??mI9ylD;j$w*oWt8Sy^=g*N=D zr{z!mEq|)C{Hdol{Hd$uPuZ3~b+i1bo8?afEPond`BOK`pZNME{Ar|FqrhtoXX@c{ zCWj|E+{ocW4hM30Pnhd;uv{nAa-Ck5>kP77rz4@-XrWY)LZE7!&As(Tyngf zyKe0ILf6N;u6CaXx<1hLo{s%fUQ4P?njG;=N-|I9q)A=xF%KnZ@g#1fG)?xY18eyl zNhv93{5A2j;}4F{qqG6%iTmD`d6FB`i62VEOD(8}^H3X8pS>SbKl98pPh2b87Ne=} zo{v+1*Npu>cDUOjk?y8dXr0(>=W(&`W7BMVh1x7KhQV{})7fjEm@8tYc6}Vz!kBn( zpO`4m@19?1jTUY72XCj-pY*7yLyNX(*^c2i)Aw$xcAI2c#k0Y?zT0SnF%qy%`+ED> zF=5`!7@5TTAGI{??RhTzw8-D|h0_nAzG!OEBY)5a9KdC;Wbd z>AR%Kn6xhTp6z`^*VTM0`_YiQ=O#O%`^0|l`Gw~?o+~0B(|6984fU$i$2OMfQ{%td z#Kpy#6?^6AvMZ)eXro^zb^y52*ozcaVY^THoI%sG>< zV32+Q+_yQ|&4Yd;2FuR5?Mao);Oty&UUwjGtJoeHq;5mx5`+MSOB%jY& ztsAN4VJpw9WIxO!RSMWweg*6ARqT@`uw&Im<`|AFCmUWYrx-3QryBk%s}1MnA9<`f z<#fYsWv$_-a+cwwvd*j_Wxe5{a<<`~@>s(+WrN|EveE9q zHQ61wId%u`IPP9QTh4_)o-0q~p7l%R>2{~>EO_Geav^+hu{?(x)o+*Qahv*G@_cSn zUoJ1;U&s&03+;Z}MRvdK61(4ancZ)@!tS?SY4_W%HoQ+>3)kBsud};uOYE-O&4#_w7>PE`7*;f2fg68zAaJ|Kn(whPI){;dE^r=jK4aGCOQ2cD zK8av|M6fR+*aN{JZfk3Hw`Q@CY=SB3Lg3b5v04V){rj)#{@;JqD%e`b0@=YLod+Bq z9Hpy(DY(@DC*XD(uppSM&jlXB?P1_4U>&)%LVC|)7c7f!4HZ-R-prFUz8sRqEe&aW zJtU3qhorGTmc~v*nyLp+7u`AA^8*DyVenhNFSK380&zeJ?^Cd+2Dhtgf$M)+vW2jjSyMCSMHS?{a7*>Qa)H#_tClj+hF$YlRT?#T{s8O% zehfaXVuHI>ED#682VYSMKr5g%kO(9JZOEVR4O7ar;8HPyo*lz^*GlkJ!0O;qVlE}- zQerM8=2BuVCFW8+C}_T#9iCko%r5b#v&+MmQ3RYozyUJ&!ikW}0i&rNnTDC6k!cKx zoS`xiu4Q^Fl`+tX{f{(OzvkP|sjP_8m{V!gB#k`}vqzrBI7?&Yp2{jc4eF4}m`lUV zV2{J>aCC&4m{76oWot&Pfd9oZYK?ASETh)!WeCQifD6VlE;9aXhO(oODjI<%U=DB` zFqf3)k;{DSU}lYvu!Q-RZf(}6RHb0)9=IE#0_>B-JR8efA+WB(zI{f9KZ z1e3-tL>jvgshl9C@>Q5rzB`jDZUAls76VIwn}C~vTL^nAa2v3U^4|sAO_}ZimIL(_`+*052Z4uxhiRLY)O!`M8hC^@dK6d#JVttJfi1MnR%ZJ){67Rf1&kh$(a|*j zJK9123fA20Rmm8D9V;0Jbiv*3Lx9s3lD1&yA(efHGW>tY3y30v1gGcX*Z;WRQAqO<&C^A z29}WKO~B2-Ex@h7?Z6$tQeYWCyGhzj(q`uo3cLoq4r~P8 z0Nw&N0dE8E0Ph0t0q+AJ0Goj=z*b-z0PT_=0Ura~$>$T`Q{Xe;bKoo7zZV(u2Ve*A zBk&V-`5E{H_!Za*>;irRb_2fyK`1H|SV4i60yMyRm12IfGtx=La3T;3!~v|B6+6PJ z70?<;1d@O@>?j!Sb|~wAI2`)}vFbX&?hs(x-2v>ExFv;uFhbE|q3EpWfscVtaQh5E z%MFSypy)!EnjK@{IAAU?A2^e7nGA*7!ORxS>_eb(KSAYw7l)%MybU*__xY(EtuTI4 z+6-&~@FORasvuPXW#L=NZYs&*a#H*!vANNS*d~^;^ZWAP2EIAvK}#gwaN%+n-sPhWpASL*-Y>(XmNCmnAUZ7j>SERRfNN?-pX}r@1l0J~MzeGZlNN92uaaRM60FMIe z@p}o_0K5#m0=x>m2D}bz1l|DN0yY6}1MdLu0`CFu10MjJfi1vRU>oou@DcDa@CEh! z68H-IYi!y}ehYjDd>>qntg#$fV>z;irRb_2fy!Qi(FjZ7s0 z1!y1&hz2}B3}0i61>%7C;0mOU6-XT`kUCZ%b*w<@SfP@G->J4hJ0K0m$aktIoqDs{>2x}s z`ssA4r+VtuO*Xb=BwMzyB^Sw-o9MUzrnk_82_=p8hCslf7(xsILJJ{~@KOwUL;NbS z5S9V^(eFFE&lSmrB%j~!|2OR2X?AyZw$0AY&dwe}cy{Kvj5d+cCNkPYjzmD4$k7OB z6FC+EZ6e1bBqE?aMj=`pziX5nUnHDghlwh z7~e|}dJvW(jLp1=oyCjTS-gmy#f$QRnJ?t45UxhJ2H{$S>kzI-xB=ltgqsj|7*o?3RVIO2G)DUu(0dkfB9BmRDZ4w-95*%$39BmRDZ4w-95*%$3 z9BmRDZSwy)NBbB%mQPTJPZ5q{Z}u6&=LmmA_#eRjhVL&B{()WGzsvbFI7wj$b2(u0 z%p^lwVArBBv;}r83U)0Db}b5aEedum3U)0Db}b5aEedum3U)0Db}b5aEedum3U)0D zINKNCY+r!0eF4t)1>_L}C8I^!qR89%Ac_(`7`KdH|6Np;3gsxyA_EHi%6Gvk-xt>ga}v)*TROMlk%Nq<53 z5aGXZe*X~y`dIoDe}9HE?f*Y!e=W-CK=>XmfVVRJAXjN2-vlCr%zOZOW(xAm6y%v1 z%(#=7aTAbTCLp^^Kz2C^*<}JVE(@p46=H^L7Q?m;++a4*6GsMmuCKSuZo!eQin0pUf2-ypn% z@LPl<2rnbNg77}`)i8}k{$=LmmA_#46(2wx%o1j01JNrbNvzCrjF;S|DY1Z8I8jM=Tj z8t;z~fDj0IBM2cF0dgwjn`y{5(~xhbA>T|xzL|!6GY$FXOUO6VkZ-0T-%LZknTC8b z4f*CIRhM9m2GXWW90y4}5WS9xaFcXkrCLqI1K!%xs3^M^4W&$$I1Z0>A$S@O-VJ2kr z%*T*pCLqU5K#rM^3lPvgJ{)Y~3^`^E=oo`MvmIe4!et11XHG$$nT9+w4S8l7^2{{k znQ6!~(~xJTA<{0FeX~;9jAkR!go;e12W*YL$F~~F1kY|p`PlC>0 zAshxzW7t(f?+rd`!YA!)C47DfZRRxpbqv4$4jyU5E$V`qaqL^hF|Wrlug5Vb$1x|z z(Qo7Ew{i5_IQnfI{Wgw%8%JA=qblmt`gy`I0myNrJPQ&P@B#m_euWi6flZ9 ze+}3#I5nU0wt?Mj^%oB`j<*C+{{>(%Y8g$C zE2*AS!0gpmv|^RTiVAH+40N_}_#KayNJM+pV;n5ND!CA05yE1Gr3kA4KNq17VG!xh zLs*Zn0bvMX7-1vACWOrhTM!-q{s$3oKBD;v!cS4w&k&wKSx=&@Um-j-^A))H6u9{m zxcL;g`4qVM6u9{mxcL;g`4qVM6u9{mxcL;g`4qVM6u9{mxcL;g`4qT0I4LwQ&>O-( zriS5068k3t?mMtaXtcjXm_qn!=2J;G^Nr+>5P%Rk^OY2Y5R4FlphpNrczfnE=^cdk zXFiiRqP~8b-kD2flT5a7$*@K4gzJp747;JI*p^xt9c75r8cnH%#Qw()JjDqNxXJOvkeVHH89RBsNNXx!P+5nX3PHS+nUnsv}Y08I4_p6P{dupAR zpw4Dq=h>B>RcrJE`ogG}SV_w^7wUe+SW0anD&N-D@F+8HH29}g>1)nkjyAY_tDk>n zwTT*`cR_XVTIGkXaC5x72CeQI*bJ!#^e;YLJ6Yt;oysxkkn%puI9<;&Ic=z~hCC_D zR0kVU(O?11Y3L^#;P8pT`w1PwdxpkJlo1n$1#UQhM~S`Awu4o5Y}IeuP*qh_QBlOs zA0AdN9UgU4G6)>O0=F%~#+Xqyq7cK>?aJ?2`Q6`OY$~6!`N{*zoy@4xcsB7hW$tO(~!SO+HksF8aRDB1r=`( za?(sOP(I|OT%X?;w*9mFyg4?Uz6s-r#_2}%8ClcL8mZt4>bxM15E;$7FS-5pOD?_b zwo5yDdOF&Bdc+vz19fP5$3q><`3Hs-A6wO+K#Wm?XU-Xo27_mcxmZH&nzHhN>Vr2I zRXOeOT3ocMMqhnyk@5{2Ehz{rC_!Hp&2)RHgI3#jCe5WUf@T?d&t{7{gVbHQrCSp# z&rY!NWodpxQPN74ZW>Y1uoR(@uJQ_96!8o`b7xn;THEh%W}9<%BqY6be^Ggv{eJ0C z?!vO5nvU==sW9uW@5Uu7lWeymHz3bJeQH8mSD>wn-1Ead*orbT%;sFl%XO(RK4T)I zqhn&gslbL=ao6sK?TZ>3o6>4>lUxJE6&qVxw$^P=b7ncK=a*WOnuaQCw&_cUI^yG9 z^<}xq=GY)@SV2cs)j9cvYg$uNtwpi<*@@=309{x?S9R67#Y9saI*i7@j%qB%{13i zX4#oN6&!+rbLO!D^Yx|%TVcI1$y|_UGMcN+eJc(ptx8Xp^qKQ}YaD?o$w?-nqZxFN5BO+Uy3-so z8^PJY7dQc8Ih-@shZ|ZAhApFpAahk-b5pD4mblg z`6W3rEj1=njis{6e^an?KwmM^rSBT4DC{YYNwy^!8^9A>=~adCMN4c{D=Mkjr&fHu z;ZjQs=XrLcb}kExs2D_DDs!vNL57jdhVYhjceT{YM5jfv;M4UrNd>;u5@p5G$slnC zw4k@FsHoIvcYS0VpR~-{vZczoxw&bmLb*!nC@m@0uhQF>9y#q-XLr=UQkv>tw(3Zx1vtr7-5)=7%CVqQO2~WD0~|3zU;Enyo=6B+`pe) zvuaI2>8hmeRmv9879~VC%rxH+l;O++jQObz^Hpfart-r1HK%`3P`W1R8iO&me3PrQF|etlqbaZvVkX`* z00H!B0c3ToH=a?2E$`4OAAKJiz^#2e2yE^qHQaxl=oP2MR|AYH`sH4 z8EIaZUeIRXrVgGlk#+@X;8)|=Oo`?V4*gs}F}+2JNhs01-a2f$7$XKX3VQ68e@x$W zh*sO}Pyb`)<(EZj4c#D8SN`Tva1UDfLAM-%6JSgc&>dyt5^s$7g2?P9-}|F^iy@@a zR$OZcGgO!7w&slHT5{|oxt81_HsOdfXQ!oQXLgh+zhYI^oN|Zqf&1ItMkotXhPN!A zLZS$Fz02K1Y{HdTSme_{?(trPJ~pT_rSBL7AD%OVZ7tjC>bB7WQMa|Fs;jFCL0`G0 zGpuu@qGAMJTPoLfHwQL%^AEz`JX0cd06*svJ)Ra3{DFp!*=UHEHF`j_p|zo6ozuCt zGOr4A>YA$V1x|2@BhoKxlJnhjDm@$=H_3-h(0mf(Rd6H&I3E>2QZh|oK5*HrqS%Yz zMb){LC~L5yp(Wg~S($Mc#MBh%>f8&ox<0e0bH_v0>(cGXd3iax z$+ktd4Lu8o!q)p2n=`H1rew#mqD}fm-3b}l;c4*+@d5J!jg@ WBn3EMsDPe7sg0 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 Date: Wed, 22 Oct 2025 07:26:40 +0200 Subject: [PATCH 14/39] Switch from ProcessingTheme to PDETheme in window UI Replaces the use of ProcessingTheme with PDETheme in the PDEWindowContent composable --- app/src/processing/app/ui/theme/Window.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index 91d245089e..7b38e774cb 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -84,7 +84,7 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent } CompositionLocalProvider(LocalWindow provides window) { - ProcessingTheme { + PDETheme { val locale = LocalLocale.current window.title = locale[titleKey] LaunchedEffect(locale) { From 1c26bab4c2020b5d8da6126bfba99255ace35ed4 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 23 Oct 2025 08:26:22 +0200 Subject: [PATCH 15/39] Refactor preferences to Jetpack Compose UI Replaces the legacy PreferencesFrame with a new Jetpack Compose-based preferences UI. Adds reactive preferences management using a custom ReactiveProperties class, and introduces modular preference groups (General, Interface, Other) with composable controls. Updates Base.java to launch the new preferences window, and refactors theme and window code for Compose integration. --- app/src/processing/app/Base.java | 10 +- app/src/processing/app/Preferences.java | 8 + app/src/processing/app/Preferences.kt | 163 +++++++-- app/src/processing/app/ui/Preferences.kt | 325 ++++++++++++++++++ app/src/processing/app/ui/WelcomeToBeta.kt | 1 - .../processing/app/ui/preferences/General.kt | 121 +++++++ .../app/ui/preferences/Interface.kt | 168 +++++++++ .../processing/app/ui/preferences/Other.kt | 73 ++++ app/src/processing/app/ui/theme/Theme.kt | 2 +- app/src/processing/app/ui/theme/Window.kt | 122 +++++-- 10 files changed, 937 insertions(+), 56 deletions(-) create mode 100644 app/src/processing/app/ui/Preferences.kt create mode 100644 app/src/processing/app/ui/preferences/General.kt create mode 100644 app/src/processing/app/ui/preferences/Interface.kt create mode 100644 app/src/processing/app/ui/preferences/Other.kt diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 2551a54d64..e3eae12fb8 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -40,6 +40,7 @@ import processing.app.contrib.*; import processing.app.tools.Tool; import processing.app.ui.*; +import processing.app.ui.PreferencesKt; import processing.app.ui.Toolkit; import processing.core.*; import processing.data.StringList; @@ -2190,10 +2191,11 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { - if (preferencesFrame == null) { - preferencesFrame = new PreferencesFrame(this); - } - preferencesFrame.showFrame(); +// if (preferencesFrame == null) { +// preferencesFrame = new PreferencesFrame(this); +// } +// preferencesFrame.showFrame(); + PreferencesKt.show(); } diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index 640c77eade..8fcf7bb056 100644 --- a/app/src/processing/app/Preferences.java +++ b/app/src/processing/app/Preferences.java @@ -136,6 +136,14 @@ static public void skipInit() { initialized = true; } + /** + * Check whether Preferences.init() has been called. If not, we are probably not running the full application. + * @return true if Preferences has been initialized + */ + static public boolean isInitialized() { + return initialized; + } + static void handleProxy(String protocol, String hostProp, String portProp) { String proxyHost = get("proxy." + protocol + ".host"); diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c5645c9bbc..c54cbbd817 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -2,56 +2,183 @@ package processing.app import androidx.compose.runtime.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream import java.nio.file.* import java.util.Properties +/* + The ReactiveProperties class extends the standard Java Properties class + to provide reactive capabilities using Jetpack Compose's mutableStateMapOf. + This allows UI components to automatically update when preference values change. +*/ +class ReactiveProperties: Properties() { + val snapshotStateMap = mutableStateMapOf() + + override fun setProperty(key: String, value: String) { + super.setProperty(key, value) + snapshotStateMap[key] = value + } + + override fun getProperty(key: String): String? { + return snapshotStateMap[key] ?: super.getProperty(key) + } + + operator fun get(key: String): String? = getProperty(key) + + operator fun set(key: String, value: String) { + setProperty(key, value) + } +} + +/* + A CompositionLocal to provide access to the ReactiveProperties instance + throughout the composable hierarchy. + */ +val LocalPreferences = compositionLocalOf { error("No preferences provided") } const val PREFERENCES_FILE_NAME = "preferences.txt" const val DEFAULTS_FILE_NAME = "defaults.txt" -fun PlatformStart(){ - Platform.inst ?: Platform.init() -} +/* + This composable function sets up a preferences provider that manages application settings. + It initializes the preferences from a file, watches for changes to that file, and saves + any updates back to the file. It uses a ReactiveProperties class to allow for reactive + updates in the UI when preferences change. + usage: + PreferencesProvider { + // Your app content here + } + + to access preferences: + val preferences = LocalPreferences.current + val someSetting = preferences["someKey"] ?: "defaultValue" + preferences["someKey"] = "newValue" + + This will automatically save to the preferences file and update any UI components + that are observing that key. + + to override the preferences file (for testing, etc) + System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt") + to override the debounce time (in milliseconds) + System.setProperty("processing.app.preferences.debounce", "200") + + */ +@OptIn(FlowPreview::class) @Composable -fun loadPreferences(): Properties{ - PlatformStart() +fun PreferencesProvider(content: @Composable () -> Unit){ + val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) } + val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull() - val settingsFolder = Platform.getSettingsFolder() - val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME) + // Initialize the platform (if not already done) to ensure we have access to the settings folder + remember { + Platform.init() + } + // Grab the preferences file, creating it if it doesn't exist + // TODO: This functionality should be separated from the `Preferences` class itself + val settingsFolder = Platform.getSettingsFolder() + val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME) if(!preferencesFile.exists()){ + preferencesFile.mkdirs() preferencesFile.createNewFile() } - watchFile(preferencesFile) - return Properties().apply { - load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream()) - load(preferencesFile.inputStream()) + val update = watchFile(preferencesFile) + + + val properties = remember(preferencesFile, update) { + ReactiveProperties().apply { + val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) + ?: InputStream.nullInputStream() + load(defaultsStream + .reader(Charsets.UTF_8) + ) + load(preferencesFile + .inputStream() + .reader(Charsets.UTF_8) + ) + } + } + + val initialState = remember(properties) { properties.snapshotStateMap.toMap() } + + // Listen for changes to the preferences and save them to file + LaunchedEffect(properties) { + snapshotFlow { properties.snapshotStateMap.toMap() } + .dropWhile { it == initialState } + .debounce(preferencesDebounceOverride ?: 100) + .collect { + + // Save the preferences to file, sorted alphabetically + preferencesFile.outputStream().use { output -> + output.write( + properties.entries + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() }) + .joinToString("\n") { (key, value) -> "$key=$value" } + .toByteArray() + ) + } + } + } + + CompositionLocalProvider(LocalPreferences provides properties){ + content() } + } +/* + This composable function watches a specified file for modifications. When the file is modified, + it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates + or other actions in response to changes in the file. + + To watch the file at the fasted speed (for testing) set the following system property: + System.setProperty("processing.app.watchfile.forced", "true") + */ @Composable fun watchFile(file: File): Any? { + val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean() + val scope = rememberCoroutineScope() var event by remember(file) { mutableStateOf?> (null) } DisposableEffect(file){ val fileSystem = FileSystems.getDefault() val watcher = fileSystem.newWatchService() + var active = true + // In forced mode we just poll the last modified time of the file + // This is not efficient but works better for testing with temp files + val toWatch = { file.lastModified() } + var state = toWatch() + val path = file.toPath() val parent = path.parent val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY) scope.launch(Dispatchers.IO) { while (active) { - for (modified in key.pollEvents()) { - if (modified.context() != path.fileName) continue - event = modified + if(forcedWatch) { + if(toWatch() == state) continue + state = toWatch() + event = object : WatchEvent { + override fun count(): Int = 1 + override fun context(): Path = file.toPath().fileName + override fun kind(): WatchEvent.Kind = StandardWatchEventKinds.ENTRY_MODIFY + override fun toString(): String = "ForcedEvent(${context()})" + } + continue + }else{ + for (modified in key.pollEvents()) { + if (modified.context() != path.fileName) continue + event = modified + } } } } @@ -62,12 +189,4 @@ fun watchFile(file: File): Any? { } } return event -} -val LocalPreferences = compositionLocalOf { error("No preferences provided") } -@Composable -fun PreferencesProvider(content: @Composable () -> Unit){ - val preferences = loadPreferences() - CompositionLocalProvider(LocalPreferences provides preferences){ - content() - } } \ No newline at end of file diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt new file mode 100644 index 0000000000..7fd9f56350 --- /dev/null +++ b/app/src/processing/app/ui/Preferences.kt @@ -0,0 +1,325 @@ +package processing.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import processing.app.LocalPreferences +import processing.app.ui.PDEPreferences.Companion.preferences +import processing.app.ui.preferences.General +import processing.app.ui.preferences.Interface +import processing.app.ui.preferences.Other +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import java.awt.Dimension +import javax.swing.SwingUtilities + +val LocalPreferenceGroups = compositionLocalOf>> { + error("No Preference Groups Set") +} + +class PDEPreferences { + companion object{ + val groups = mutableStateMapOf>() + fun register(preference: PDEPreference) { + val list = groups[preference.group]?.toMutableList() ?: mutableListOf() + list.add(preference) + groups[preference.group] = list + } + init{ + General.register() + Interface.register() + Other.register() + } + + /** + * Composable function to display the preferences UI. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun preferences(){ + var visible by remember { mutableStateOf(groups) } + val sortedGroups = remember { + val keys = visible.keys + keys.toSortedSet { + a, b -> + when { + a.after == b -> 1 + b.after == a -> -1 + else -> a.name.compareTo(b.name) + } + } + } + var selected by remember { mutableStateOf(sortedGroups.first()) } + CompositionLocalProvider( + LocalPreferenceGroups provides visible + ) { + Row { + NavigationRail( + header = { + Text( + "Settings", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 42.dp) + ) + + }, + modifier = Modifier + .defaultMinSize(minWidth = 200.dp) + ) { + + for (group in sortedGroups) { + NavigationRailItem( + selected = selected == group, + enabled = visible.keys.contains(group), + onClick = { + selected = group + }, + icon = { + group.icon() + }, + label = { + Text(group.name) + } + ) + } + } + Box(modifier = Modifier.padding(top = 42.dp)) { + Column(modifier = Modifier + .fillMaxSize() + ) { + var query by remember { mutableStateOf("") } + val locale = LocalLocale.current + LaunchedEffect(query){ + + snapshotFlow { query } + .debounce(100) + .collect{ + if(it.isBlank()){ + visible = groups + return@collect + } + val filtered = mutableStateMapOf>() + for((group, preferences) in groups){ + val matching = preferences.filter { preference -> + if(preference.key == "other"){ + return@filter true + } + if(preference.key.contains(it, ignoreCase = true)){ + return@filter true + } + val description = locale[preference.descriptionKey] + description.contains(it, ignoreCase = true) + } + if(matching.isNotEmpty()){ + filtered[group] = matching + } + } + visible = filtered + } + + } + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { + query = it + }, + onSearch = { + + }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.align(Alignment.End).padding(16.dp) + ) { + + } + + val preferences = visible[selected] ?: emptyList() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(preferences){ preference -> + preference.showControl() + } + } + } + } + } + } + } + + + + @JvmStatic + fun main(args: Array) { + application { + Window(onCloseRequest = ::exitApplication){ + remember{ + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = true) { + preferences() + } + } + Window(onCloseRequest = ::exitApplication){ + remember{ + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = false) { + preferences() + } + } + } + } + } +} + +/** + * Data class representing a single preference in the preferences system. + * + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * group = somePreferenceGroup, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + */ +data class PDEPreference( + /** + * The key in the preferences file used to store this preference. + */ + val key: String, + /** + * The key for the description of this preference, used for localization. + */ + val descriptionKey: String, + /** + * The group this preference belongs to. + */ + val group: PDEPreferenceGroup, + /** + * A Composable function that defines the control used to modify this preference. + * It takes the current preference value and a function to update the preference. + */ + val control: @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit = { preference, updatePreference -> }, + + /** + * If true, no padding will be applied around this preference's UI. + */ + val noPadding: Boolean = false, +) + +/** + * Composable function to display the preference's description and control. + */ +@Composable +private fun PDEPreference.showControl() { + val locale = LocalLocale.current + val prefs = LocalPreferences.current + Text( + text = locale[descriptionKey], + modifier = Modifier.padding(horizontal = 20.dp), + style = MaterialTheme.typography.titleMedium + ) + val show = @Composable { + control(prefs[key]) { newValue -> + prefs[key] = newValue + } + } + + if(noPadding){ + show() + }else{ + Box(modifier = Modifier.padding(horizontal = 20.dp)) { + show() + } + } + +} + +/** + * Data class representing a group of preferences. + */ +data class PDEPreferenceGroup( + /** + * The name of this group. + */ + val name: String, + /** + * The icon representing this group. + */ + val icon: @Composable () -> Unit, + /** + * The group that comes before this one in the list. + */ + val after: PDEPreferenceGroup? = null, +) + +fun show(){ + SwingUtilities.invokeLater { + PDESwingWindow( + titleKey = "preferences", + fullWindowContent = true, + size = Dimension(800, 600) + ) { + PDETheme { + preferences() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt index 72c3050006..531c28f7ef 100644 --- a/app/src/processing/app/ui/WelcomeToBeta.kt +++ b/app/src/processing/app/ui/WelcomeToBeta.kt @@ -42,7 +42,6 @@ import processing.app.ui.theme.LocalLocale 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 import java.awt.event.KeyAdapter diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt new file mode 100644 index 0000000000..5f56187f46 --- /dev/null +++ b/app/src/processing/app/ui/preferences/General.kt @@ -0,0 +1,121 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Preferences +import processing.app.SketchName +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences + + +class General { + companion object{ + val general = PDEPreferenceGroup( + name = "General", + icon = { + Icon(Icons.Default.Settings, contentDescription = "A settings icon") + } + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "sketchbook.path.four", + descriptionKey = "preferences.sketchbook_location", + group = general, + control = { preference, updatePreference -> + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + TextField( + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + Button( + onClick = { + + } + ) { + Text("Browse") + } + } + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "sketch.name.approach", + descriptionKey = "preferences.sketch_naming", + group = general, + control = { preference, updatePreference -> + Row{ + for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + "timestamp", + "untitled", + "custom" + )) { + FilterChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + modifier = Modifier.padding(4.dp), + ) + } + } + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "update.check", + descriptionKey = "preferences.check_for_updates_on_startup", + group = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.show_welcome_screen_on_startup", + group = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt new file mode 100644 index 0000000000..fc384fbc59 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -0,0 +1,168 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.TextIncrease +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Language +import processing.app.Preferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences +import processing.app.ui.Toolkit +import processing.app.ui.preferences.General.Companion.general +import processing.app.ui.theme.LocalLocale +import java.util.Locale + +class Interface { + companion object{ + val interfaceAndFonts = PDEPreferenceGroup( + name = "Interface", + icon = { + Icon(Icons.Default.TextIncrease, contentDescription = "Interface") + }, + after = general + ) + + fun register() { + PDEPreferences.register(PDEPreference( + key = "language", + descriptionKey = "preferences.language", + group = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + var showOptions by remember { mutableStateOf(false) } + val languages = if(Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + TextField( + value = locale.locale.displayName, + readOnly = true, + onValueChange = { }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select Font Family", + modifier = Modifier + .clickable{ + showOptions = true + } + ) + } + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + languages.forEach { family -> + DropdownMenuItem( + text = { Text(family.value) }, + onClick = { + locale.set(Locale(family.key)) + showOptions = false + } + ) + } + } + } + )) + + PDEPreferences.register( + PDEPreference( + key = "editor.font.family", + descriptionKey = "preferences.editor_and_console_font", + group = interfaceAndFonts, + control = { preference, updatePreference -> + var showOptions by remember { mutableStateOf(false) } + val families = if(Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + TextField( + value = preference ?: families.firstOrNull().orEmpty(), + readOnly = true, + onValueChange = { updatePreference (it) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select Font Family", + modifier = Modifier + .clickable{ + showOptions = true + } + ) + } + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + families.forEach { family -> + DropdownMenuItem( + text = { Text(family) }, + onClick = { + updatePreference(family) + showOptions = false + } + ) + } + + } + } + ) + ) + + PDEPreferences.register(PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + group = interfaceAndFonts, + control = { preference, updatePreference -> + Column { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + )) + PDEPreferences.register(PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + group = interfaceAndFonts, + control = { preference, updatePreference -> + Column { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + )) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt new file mode 100644 index 0000000000..f5f65ea9c8 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -0,0 +1,73 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.LocalPreferenceGroups +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.theme.LocalLocale + +class Other { + companion object{ + val other = PDEPreferenceGroup( + name = "Other", + icon = { + Icon(Icons.Default.Map, contentDescription = "A map icon") + }, + after = interfaceAndFonts + ) + fun register() { + PDEPreferences.register( + PDEPreference( + key = "other", + descriptionKey = "preferences.other", + group = other, + noPadding = true, + control = { _, _ -> + val prefs = LocalPreferences.current + val groups = LocalPreferenceGroups.current + val restPrefs = remember { + val keys = prefs.keys.mapNotNull { it as? String } + val existing = groups.values.flatten().map { it.key } + keys.filter { it !in existing }.sorted() + } + val locale = LocalLocale.current + + for(prefKey in restPrefs){ + val value = prefs[prefKey] + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ){ + Text( + text = locale[prefKey], + modifier = Modifier.align(Alignment.CenterVertically) + ) + TextField(value ?: "", onValueChange = { + prefs[prefKey] = it + }) + } + } + + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 7cc70455f0..9e41227ed1 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -93,7 +93,7 @@ fun PDETheme( colorScheme = if(darkTheme) PDEDarkColor else PDELightColor, typography = PDETypography ){ - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background).fillMaxSize()) { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onBackground, LocalDensity provides Density(1.25f, 1.25f), diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index 7b38e774cb..bf998d5742 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -1,6 +1,8 @@ package processing.app.ui.theme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -9,16 +11,22 @@ 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.awt.ComposeWindow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo +import java.awt.Dimension import java.awt.event.KeyAdapter import java.awt.event.KeyEvent import javax.swing.JFrame +import javax.swing.UIManager val LocalWindow = compositionLocalOf { error("No Window Set") } @@ -37,32 +45,46 @@ val LocalWindow = compositionLocalOf { error("No Window Set") } * ``` * * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. If null, the window will use its default size. + * @param minSize The minimum size of the window. If null, no minimum size is set. + * @param maxSize The maximum size of the window. If null, no maximum size is 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, onClose: () -> Unit = {}, content: @Composable BoxScope.() -> Unit): JFrame(){ +// TODO: Add support for onClose callback +class PDESwingWindow( + titleKey: String = "", + size: Dimension? = null, + minSize: Dimension? = null, + maxSize: Dimension? = null, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ init{ - val window = this - defaultCloseOperation = DISPOSE_ON_CLOSE - ComposePanel().apply { + ComposeWindow().apply { + val window = this + defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE + size?.let { + window.size = it + } + minSize?.let { + window.minimumSize = it + } + maxSize?.let { + window.maximumSize = it + } + setLocationRelativeTo(null) 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) return - - window.dispose() - onClose() + window.addWindowStateListener { + if(it.newState == JFrame.DISPOSE_ON_CLOSE){ + onClose() + } } - }) - isResizable = false - isVisible = true - requestFocus() + isVisible = true + } } } @@ -76,7 +98,12 @@ class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, * @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){ +private fun PDEWindowContent( + window: ComposeWindow, + titleKey: String, + fullWindowContent: Boolean = false, + content: @Composable () -> Unit +){ val mac = SystemInfo.isMacOS && SystemInfo.isMacFullWindowContentSupported remember { window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) @@ -84,15 +111,10 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent } CompositionLocalProvider(LocalWindow provides window) { - PDETheme { + PDETheme{ 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) + content() } } } @@ -123,6 +145,10 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent * ``` * * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. Defaults to unspecified size which means the window will be + * fullscreen if it contains any of [fillMaxWidth]/[fillMaxSize]/[fillMaxHeight] etc. + * @param minSize The minimum size of the window. Defaults to unspecified size which means no minimum size is set. + * @param maxSize The maximum size of the window. Defaults to unspecified size which means no maximum size is set. * @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. @@ -132,12 +158,52 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent * */ @Composable -fun PDEComposeWindow(titleKey: String, fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable BoxScope.() -> Unit){ +fun PDEComposeWindow( + titleKey: String, + size: DpSize = DpSize.Unspecified, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ val windowState = rememberWindowState( - size = DpSize.Unspecified, + size = size, position = WindowPosition(Alignment.Center) ) Window(onCloseRequest = onClose, state = windowState, title = "") { + remember { + window.minimumSize = minSize.toDimension() + window.maximumSize = maxSize.toDimension() + } PDEWindowContent(window, titleKey, fullWindowContent, content) } +} + +fun DpSize.toDimension(): Dimension? { + if(this == DpSize.Unspecified) { return null } + + return Dimension( + this.width.value.toInt(), + this.height.value.toInt() + ) +} + +fun main(){ + application { + PDEComposeWindow( + onClose = ::exitApplication, + titleKey = "window.title", + size = DpSize(800.dp, 600.dp), + ){ + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text("Hello, World!") + } + } + } } \ No newline at end of file From 8fe058527743a09f9845ccc37925754f590d3dbb Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 23 Oct 2025 09:00:59 +0200 Subject: [PATCH 16/39] Remove obsolete TODO for onClose callback --- app/src/processing/app/ui/theme/Window.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index bf998d5742..98a4e00807 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -51,7 +51,6 @@ 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. */ -// TODO: Add support for onClose callback class PDESwingWindow( titleKey: String = "", size: Dimension? = null, From 5ae8b8440496979c9d159b719d32f0c7496463c6 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 23 Oct 2025 12:34:06 +0200 Subject: [PATCH 17/39] Refactor theme system to Material 3 color schemes Replaces legacy color definitions with Material 3 color schemes and introduces extended color support for warnings. Dialogs in Messages.kt are now implemented using Compose Material 3 components for a modern UI. Removes deprecated color sets and updates PDETheme to use new color schemes, improving consistency and maintainability. --- app/src/processing/app/Messages.kt | 173 +++++++++-- app/src/processing/app/ui/Preferences.kt | 2 - app/src/processing/app/ui/theme/Colors.kt | 46 +-- app/src/processing/app/ui/theme/Theme.kt | 132 +++++---- app/src/processing/app/ui/theme/m3/Color.kt | 248 ++++++++++++++++ app/src/processing/app/ui/theme/m3/Theme.kt | 301 ++++++++++++++++++++ 6 files changed, 784 insertions(+), 118 deletions(-) create mode 100644 app/src/processing/app/ui/theme/m3/Color.kt create mode 100644 app/src/processing/app/ui/theme/m3/Theme.kt diff --git a/app/src/processing/app/Messages.kt b/app/src/processing/app/Messages.kt index cae54e6e97..05bada362c 100644 --- a/app/src/processing/app/Messages.kt +++ b/app/src/processing/app/Messages.kt @@ -18,13 +18,41 @@ */ package processing.app +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Alarm +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.formdev.flatlaf.FlatLightLaf import processing.app.ui.Toolkit +import processing.app.ui.theme.PDETheme +import java.awt.Dimension import java.awt.EventQueue import java.awt.Frame import java.io.PrintWriter import java.io.StringWriter import javax.swing.JFrame import javax.swing.JOptionPane +import javax.swing.UIManager class Messages { companion object { @@ -37,10 +65,21 @@ class Messages { if (Base.isCommandLine()) { println("$title: $message") } else { - JOptionPane.showMessageDialog( - Frame(), message, title, - JOptionPane.INFORMATION_MESSAGE - ) + showDialog(title) { dismiss -> + AlertDialog( + onDismissRequest = { }, + icon = { Icon(Icons.Default.Info, contentDescription = "Info!") }, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button( + onClick = { dismiss() } + ) { + Text("OK") + } + } + ) + } } } @@ -57,10 +96,25 @@ class Messages { if (Base.isCommandLine()) { println("$title: $message") } else { - JOptionPane.showMessageDialog( - Frame(), message, title, - JOptionPane.WARNING_MESSAGE - ) + showDialog(title){ dismiss -> + AlertDialog( + onDismissRequest = { }, + icon = { Icon(Icons.Default.Warning, contentDescription = "Alert!") }, + iconContentColor = MaterialTheme.colorScheme.tertiary, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Text("OK") + } + } + ) + } } e?.printStackTrace() } @@ -80,11 +134,28 @@ class Messages { // proper parsing on the command line. Many have \n in them. println("$title: $primary\n$secondary") } else { - EventQueue.invokeLater { - JOptionPane.showMessageDialog( - JFrame(), - Toolkit.formatMessage(primary, secondary), - title, JOptionPane.WARNING_MESSAGE + showDialog(title){ dismiss -> + AlertDialog( + onDismissRequest = { }, + icon = { Icon(Icons.Default.Warning, contentDescription = "Alert!") }, + iconContentColor = MaterialTheme.colorScheme.tertiary, + title = { Text(title) }, + text = { + Column { + Text(primary, fontWeight = FontWeight.Bold) + Text(secondary) + } + }, + confirmButton = { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Text("OK") + } + } ) } } @@ -102,10 +173,26 @@ class Messages { if (Base.isCommandLine()) { System.err.println("$title: $message") } else { - JOptionPane.showMessageDialog( - Frame(), message, title, - JOptionPane.ERROR_MESSAGE - ) + showDialog(title){ dismiss -> + AlertDialog( + onDismissRequest = { }, + icon = { Icon(Icons.Default.Error, contentDescription = "Alert!") }, + iconContentColor = MaterialTheme.colorScheme.error, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text("OK") + } + } + ) + } } e?.printStackTrace() System.exit(1) @@ -139,6 +226,8 @@ class Messages { if (fatal) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE ) + + if (fatal) { System.exit(1) } @@ -271,6 +360,56 @@ class Messages { } } +fun showDialog(title: String, content: @Composable (dismiss: () -> Unit) -> Unit) { + ComposeDialog().apply { +// isUndecorated = true +// isTransparent = true + isModal = true + setTitle(title) + size = Dimension(400, 200) + + setContent { + PDETheme { + content(::dispose) + } + } + setLocationRelativeTo(null) + isVisible = true + } +} + +fun main(){ + val types = mapOf( + "message" to { Messages.showMessage("Test Title", "This is a test message.") }, + "warning" to { Messages.showWarning("Test Warning", "This is a test warning.", Exception("dfdsfjk")) }, + "trace" to { Messages.showTrace("Test Trace", "This is a test trace.", Exception("Test Exception"), false) }, + "tiered_warning" to { Messages.showWarningTiered("Test Tiered Warning", "Primary message", "Secondary message", null) }, + "yes_no" to { Messages.showYesNoQuestion(null, "Test Yes/No", "Do you want to continue?", "Choose yes or no.") }, + "custom_question" to { Messages.showCustomQuestion(null, "Test Custom Question", "Choose an option:", "Select one of the options below.", 1, "Option 1", "Option 2", "Option 3") }, + "error" to { Messages.showError("Test Error", "This is a test error.", null) }, + ) + Platform.init() + UIManager.setLookAndFeel(FlatLightLaf()) + application { + val state = rememberWindowState( + size = DpSize(500.dp, 300.dp) + ) + Window(state = state, onCloseRequest = ::exitApplication, title = "Test Messages") { + PDETheme { + Column { + for ((type, action) in types) { + Button(onClick = { action() }, modifier = Modifier.padding(8.dp)) { + Text("Show $type dialog") + } + } + } + } + } + + } + +} + // Helper functions to give the base classes a color fun String.formatClassName() = this .replace("processing.", "") diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt index 7fd9f56350..12e7c25ce4 100644 --- a/app/src/processing/app/ui/Preferences.kt +++ b/app/src/processing/app/ui/Preferences.kt @@ -101,7 +101,6 @@ class PDEPreferences { style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(top = 42.dp) ) - }, modifier = Modifier .defaultMinSize(minWidth = 200.dp) @@ -289,7 +288,6 @@ private fun PDEPreference.showControl() { show() } } - } /** diff --git a/app/src/processing/app/ui/theme/Colors.kt b/app/src/processing/app/ui/theme/Colors.kt index 61c6d6b55f..af423ba488 100644 --- a/app/src/processing/app/ui/theme/Colors.kt +++ b/app/src/processing/app/ui/theme/Colors.kt @@ -33,54 +33,10 @@ class ProcessingColors{ val foundationDark = Color(0xFF5501a4) val downloadInactive = Color(0xFF8890B3) - val downloadBackgroundActive = Color(0x14508BFF) + val downloadBackgroundActive = Color(0xFF14508B) } } -@Deprecated("Use PDE3LightColor instead") -val PDE2LightColors = Colors( - primary = ProcessingColors.blue, - primaryVariant = ProcessingColors.lightBlue, - onPrimary = ProcessingColors.white, - - secondary = ProcessingColors.deepBlue, - secondaryVariant = ProcessingColors.darkBlue, - onSecondary = ProcessingColors.white, - - background = ProcessingColors.white, - onBackground = ProcessingColors.darkBlue, - - surface = ProcessingColors.lightGray, - onSurface = ProcessingColors.darkerGray, - - error = ProcessingColors.error, - onError = ProcessingColors.white, - - isLight = true, -) - -@Deprecated("Use PDE3DarkColor instead") -val PDE2DarkColors = Colors( - primary = ProcessingColors.deepBlue, - primaryVariant = ProcessingColors.darkBlue, - onPrimary = ProcessingColors.white, - - secondary = ProcessingColors.lightBlue, - secondaryVariant = ProcessingColors.blue, - onSecondary = ProcessingColors.white, - - background = ProcessingColors.veryDarkGray, - onBackground = ProcessingColors.white, - - surface = ProcessingColors.darkerGray, - onSurface = ProcessingColors.lightGray, - - error = ProcessingColors.error, - onError = ProcessingColors.white, - - isLight = false, -) - val PDELightColor = lightColorScheme( primary = ProcessingColors.blue, onPrimary = ProcessingColors.white, diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 9e41227ed1..554b5f27e7 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.Density @@ -56,6 +57,8 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import darkScheme +import lightScheme import processing.app.PreferencesProvider /** @@ -90,12 +93,12 @@ fun PDETheme( PreferencesProvider { LocaleProvider { MaterialTheme( - colorScheme = if(darkTheme) PDEDarkColor else PDELightColor, + colorScheme = if(darkTheme) darkScheme else lightScheme, typography = PDETypography ){ - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onBackground, + LocalContentColor provides MaterialTheme.colorScheme.onSurface, LocalDensity provides Density(1.25f, 1.25f), content = content ) @@ -137,66 +140,79 @@ fun main() { verticalArrangement = Arrangement.spacedBy(16.dp), ) { ComponentPreview("Colors") { + val colors = listOf>( + Triple("Primary", MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), + Triple("Secondary", MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.onSecondary), + Triple("Tertiary", MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.onTertiary), + Triple("Primary Container", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer), + Triple("Secondary Container", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer), + Triple("Tertiary Container", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer), + Triple("Error Container", MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer), + Triple("Background", MaterialTheme.colorScheme.background, MaterialTheme.colorScheme.onBackground), + Triple("Surface", MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.onSurface), + Triple("Surface Variant", MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), + Triple("Error", MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), + + Triple("Surface Lowest", MaterialTheme.colorScheme.surfaceContainerLowest, MaterialTheme.colorScheme.onSurface), + Triple("Surface Low", MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.colorScheme.onSurface), + Triple("Surface", MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.onSurface), + Triple("Surface High", MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), + Triple("Surface Highest", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), + ) Column { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - onClick = {}) { - Text("Primary", color = MaterialTheme.colorScheme.onPrimary) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), - onClick = {}) { - Text("Secondary", color = MaterialTheme.colorScheme.onSecondary) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), - onClick = {}) { - Text("Tertiary", color = MaterialTheme.colorScheme.onTertiary) +// Button( +// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), +// onClick = {}) { +// Text("Primary", color = MaterialTheme.colorScheme.onPrimary) +// } +// Button( +// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), +// onClick = {}) { +// Text("Secondary", color = MaterialTheme.colorScheme.onSecondary) +// } +// Button( +// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), +// onClick = {}) { +// Text("Tertiary", color = MaterialTheme.colorScheme.onTertiary) +// } + val section = colors.subList(0,3) + for((name, color, onColor) in section){ + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primaryContainer), - onClick = {}) { - Text("Primary Container", color = MaterialTheme.colorScheme.onPrimaryContainer) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), - onClick = {}) { - Text("Secondary Container", color = MaterialTheme.colorScheme.onSecondaryContainer) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), - onClick = {}) { - Text("Tertiary Container", color = MaterialTheme.colorScheme.onTertiaryContainer) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer), - onClick = {}) { - Text("Error Container", color = MaterialTheme.colorScheme.onErrorContainer) + val section = colors.subList(3,7) + for((name, color, onColor) in section){ + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.background), - onClick = {}) { - Text("Background", color = MaterialTheme.colorScheme.onBackground) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surface), - onClick = {}) { - Text("Surface", color = MaterialTheme.colorScheme.onSurface) + val section = colors.subList(7,11) + for((name, color, onColor) in section){ + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - onClick = {}) { - Text("Surface Variant", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Button( - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - onClick = {}) { - Text("Error", color = MaterialTheme.colorScheme.onError) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val section = colors.subList(11, 16) + for ((name, color, onColor) in section) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } } } } @@ -337,10 +353,18 @@ fun main() { } + ComponentPreview("Card") { + Card{ + Text("Hello, Tabs!", modifier = Modifier.padding(20.dp)) + } + } + ComponentPreview("Scrollable View") { } + + ComponentPreview("Tabs") { } diff --git a/app/src/processing/app/ui/theme/m3/Color.kt b/app/src/processing/app/ui/theme/m3/Color.kt new file mode 100644 index 0000000000..b2047ce7e6 --- /dev/null +++ b/app/src/processing/app/ui/theme/m3/Color.kt @@ -0,0 +1,248 @@ +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF525A92) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFF293DAE) +val onPrimaryContainerLight = Color(0xFFABB5FF) +val secondaryLight = Color(0xFF555D7D) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFF8890B3) +val onSecondaryContainerLight = Color(0xFF212946) +val tertiaryLight = Color(0xFF0052CC) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFF0468FF) +val onTertiaryContainerLight = Color(0xFFFBF9FF) +val errorLight = Color(0xFFBB0026) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFE41D37) +val onErrorContainerLight = Color(0xFFFFFBFF) +val backgroundLight = Color(0xFFFBF8FF) +val onBackgroundLight = Color(0xFF1A1B22) +val surfaceLight = Color(0xFFFDF8F8) +val onSurfaceLight = Color(0xFF1C1B1C) +val surfaceVariantLight = Color(0xFFE4E1E8) +val onSurfaceVariantLight = Color(0xFF47464B) +val outlineLight = Color(0xFF77767C) +val outlineVariantLight = Color(0xFFC8C5CB) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF313030) +val inverseOnSurfaceLight = Color(0xFFF4F0EF) +val inversePrimaryLight = Color(0xFFBBC3FF) +val surfaceDimLight = Color(0xFFDDD9D9) +val surfaceBrightLight = Color(0xFFFDF8F8) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF7F3F2) +val surfaceContainerLight = Color(0xFFF1EDED) +val surfaceContainerHighLight = Color(0xFFEBE7E7) +val surfaceContainerHighestLight = Color(0xFFE5E2E1) + +val primaryLightMediumContrast = Color(0xFF525A92) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF293DAE) +val onPrimaryContainerLightMediumContrast = Color(0xFFE3E4FF) +val secondaryLightMediumContrast = Color(0xFF2D3553) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF646C8D) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF003080) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF0062F3) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF730013) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFD91030) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFBF8FF) +val onBackgroundLightMediumContrast = Color(0xFF1A1B22) +val surfaceLightMediumContrast = Color(0xFFFDF8F8) +val onSurfaceLightMediumContrast = Color(0xFF111111) +val surfaceVariantLightMediumContrast = Color(0xFFE4E1E8) +val onSurfaceVariantLightMediumContrast = Color(0xFF36363B) +val outlineLightMediumContrast = Color(0xFF525257) +val outlineVariantLightMediumContrast = Color(0xFF6D6C72) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF313030) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF4F0EF) +val inversePrimaryLightMediumContrast = Color(0xFFBBC3FF) +val surfaceDimLightMediumContrast = Color(0xFFC9C6C5) +val surfaceBrightLightMediumContrast = Color(0xFFFDF8F8) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF7F3F2) +val surfaceContainerLightMediumContrast = Color(0xFFEBE7E7) +val surfaceContainerHighLightMediumContrast = Color(0xFFE0DCDC) +val surfaceContainerHighestLightMediumContrast = Color(0xFFD4D1D0) + +val primaryLightHighContrast = Color(0xFF525A92) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF283CAD) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF222B48) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF404867) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF00276B) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF0042A8) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF60000E) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF97001C) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFBF8FF) +val onBackgroundLightHighContrast = Color(0xFF1A1B22) +val surfaceLightHighContrast = Color(0xFFFDF8F8) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE4E1E8) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF2C2C30) +val outlineVariantLightHighContrast = Color(0xFF49494E) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF313030) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFBBC3FF) +val surfaceDimLightHighContrast = Color(0xFFBBB8B8) +val surfaceBrightLightHighContrast = Color(0xFFFDF8F8) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF4F0EF) +val surfaceContainerLightHighContrast = Color(0xFFE5E2E1) +val surfaceContainerHighLightHighContrast = Color(0xFFD7D3D3) +val surfaceContainerHighestLightHighContrast = Color(0xFFC9C6C5) + +val primaryDark = Color(0xFFBBC3FF) +val onPrimaryDark = Color(0xFF001D93) +val primaryContainerDark = Color(0xFF293DAE) +val onPrimaryContainerDark = Color(0xFFABB5FF) +val secondaryDark = Color(0xFFBDC5EA) +val onSecondaryDark = Color(0xFF272F4D) +val secondaryContainerDark = Color(0xFF8890B3) +val onSecondaryContainerDark = Color(0xFF212946) +val tertiaryDark = Color(0xFFB2C5FF) +val onTertiaryDark = Color(0xFF002B74) +val tertiaryContainerDark = Color(0xFF0468FF) +val onTertiaryContainerDark = Color(0xFFFBF9FF) +val errorDark = Color(0xFFFFB3B0) +val onErrorDark = Color(0xFF680010) +val errorContainerDark = Color(0xFFFF5359) +val onErrorContainerDark = Color(0xFF220002) +val backgroundDark = Color(0xFF12131A) +val onBackgroundDark = Color(0xFFE3E1EB) +val surfaceDark = Color(0xFF141313) +val onSurfaceDark = Color(0xFFE5E2E1) +val surfaceVariantDark = Color(0xFF47464B) +val onSurfaceVariantDark = Color(0xFFC8C5CB) +val outlineDark = Color(0xFF919096) +val outlineVariantDark = Color(0xFF47464B) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE5E2E1) +val inverseOnSurfaceDark = Color(0xFF313030) +val inversePrimaryDark = Color(0xFF4053C3) +val surfaceDimDark = Color(0xFF141313) +val surfaceBrightDark = Color(0xFF3A3939) +val surfaceContainerLowestDark = Color(0xFF0E0E0E) +val surfaceContainerLowDark = Color(0xFF1C1B1C) +val surfaceContainerDark = Color(0xFF201F20) +val surfaceContainerHighDark = Color(0xFF2B2A2A) +val surfaceContainerHighestDark = Color(0xFF353435) + +val primaryDarkMediumContrast = Color(0xFFBBC3FF) +val onPrimaryDarkMediumContrast = Color(0xFF001677) +val primaryContainerDarkMediumContrast = Color(0xFF7587FA) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFD4DBFF) +val onSecondaryDarkMediumContrast = Color(0xFF1C2441) +val secondaryContainerDarkMediumContrast = Color(0xFF8890B3) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFD2DBFF) +val onTertiaryDarkMediumContrast = Color(0xFF00215E) +val tertiaryContainerDarkMediumContrast = Color(0xFF5D8BFF) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CF) +val onErrorDarkMediumContrast = Color(0xFF54000B) +val errorContainerDarkMediumContrast = Color(0xFFFF5359) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF12131A) +val onBackgroundDarkMediumContrast = Color(0xFFE3E1EB) +val surfaceDarkMediumContrast = Color(0xFF141313) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF47464B) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDEDBE1) +val outlineDarkMediumContrast = Color(0xFFB3B1B7) +val outlineVariantDarkMediumContrast = Color(0xFF918F95) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE5E2E1) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF2B2A2A) +val inversePrimaryDarkMediumContrast = Color(0xFF263AAC) +val surfaceDimDarkMediumContrast = Color(0xFF141313) +val surfaceBrightDarkMediumContrast = Color(0xFF454444) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF080707) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1E1D1E) +val surfaceContainerDarkMediumContrast = Color(0xFF282828) +val surfaceContainerHighDarkMediumContrast = Color(0xFF333232) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3E3D3D) + +val primaryDarkHighContrast = Color(0xFFBBC3FF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFB6BFFF) +val onPrimaryContainerDarkHighContrast = Color(0xFF000533) +val secondaryDarkHighContrast = Color(0xFFEEEFFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFB9C1E6) +val onSecondaryContainerDarkHighContrast = Color(0xFF020926) +val tertiaryDarkHighContrast = Color(0xFFEDEFFF) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFADC1FF) +val onTertiaryContainerDarkHighContrast = Color(0xFF000926) +val errorDarkHighContrast = Color(0xFFFFECEA) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFADAB) +val onErrorContainerDarkHighContrast = Color(0xFF220002) +val backgroundDarkHighContrast = Color(0xFF12131A) +val onBackgroundDarkHighContrast = Color(0xFFE3E1EB) +val surfaceDarkHighContrast = Color(0xFF141313) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF47464B) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFF2EFF5) +val outlineVariantDarkHighContrast = Color(0xFFC4C2C8) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE5E2E1) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF263AAC) +val surfaceDimDarkHighContrast = Color(0xFF141313) +val surfaceBrightDarkHighContrast = Color(0xFF515050) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF201F20) +val surfaceContainerDarkHighContrast = Color(0xFF313030) +val surfaceContainerHighDarkHighContrast = Color(0xFF3C3B3B) +val surfaceContainerHighestDarkHighContrast = Color(0xFF484646) + +val warningLight = Color(0xFF765B0B) +val onWarningLight = Color(0xFFFFFFFF) +val warningContainerLight = Color(0xFFFFDF97) +val onWarningContainerLight = Color(0xFF5A4300) + +val warningLightMediumContrast = Color(0xFF453400) +val onWarningLightMediumContrast = Color(0xFFFFFFFF) +val warningContainerLightMediumContrast = Color(0xFF86691C) +val onWarningContainerLightMediumContrast = Color(0xFFFFFFFF) + +val warningLightHighContrast = Color(0xFF392A00) +val onWarningLightHighContrast = Color(0xFFFFFFFF) +val warningContainerLightHighContrast = Color(0xFF5D4600) +val onWarningContainerLightHighContrast = Color(0xFFFFFFFF) + +val warningDark = Color(0xFFE6C26C) +val onWarningDark = Color(0xFF3E2E00) +val warningContainerDark = Color(0xFF5A4300) +val onWarningContainerDark = Color(0xFFFFDF97) + +val warningDarkMediumContrast = Color(0xFFFED87F) +val onWarningDarkMediumContrast = Color(0xFF312400) +val warningContainerDarkMediumContrast = Color(0xFFAD8D3D) +val onWarningContainerDarkMediumContrast = Color(0xFF000000) + +val warningDarkHighContrast = Color(0xFFFFEECF) +val onWarningDarkHighContrast = Color(0xFF000000) +val warningContainerDarkHighContrast = Color(0xFFE2BE69) +val onWarningContainerDarkHighContrast = Color(0xFF110A00) + diff --git a/app/src/processing/app/ui/theme/m3/Theme.kt b/app/src/processing/app/ui/theme/m3/Theme.kt new file mode 100644 index 0000000000..d1b2f403a8 --- /dev/null +++ b/app/src/processing/app/ui/theme/m3/Theme.kt @@ -0,0 +1,301 @@ + + +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class ExtendedColorScheme( + val warning: ColorFamily, +) + +val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +val extendedLight = ExtendedColorScheme( + warning = ColorFamily( + warningLight, + onWarningLight, + warningContainerLight, + onWarningContainerLight, + ), +) + +val extendedDark = ExtendedColorScheme( + warning = ColorFamily( + warningDark, + onWarningDark, + warningContainerDark, + onWarningContainerDark, + ), +) + +val extendedLightMediumContrast = ExtendedColorScheme( + warning = ColorFamily( + warningLightMediumContrast, + onWarningLightMediumContrast, + warningContainerLightMediumContrast, + onWarningContainerLightMediumContrast, + ), +) + +val extendedLightHighContrast = ExtendedColorScheme( + warning = ColorFamily( + warningLightHighContrast, + onWarningLightHighContrast, + warningContainerLightHighContrast, + onWarningContainerLightHighContrast, + ), +) + +val extendedDarkMediumContrast = ExtendedColorScheme( + warning = ColorFamily( + warningDarkMediumContrast, + onWarningDarkMediumContrast, + warningContainerDarkMediumContrast, + onWarningContainerDarkMediumContrast, + ), +) + +val extendedDarkHighContrast = ExtendedColorScheme( + warning = ColorFamily( + warningDarkHighContrast, + onWarningDarkHighContrast, + warningContainerDarkHighContrast, + onWarningContainerDarkHighContrast, + ), +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) \ No newline at end of file From ca298009e146f2b2d24cc4ce423d8fb9ecde8197 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 23 Oct 2025 21:09:54 +0200 Subject: [PATCH 18/39] Add PDEWelcome Composable UI screen Introduces a new PDEWelcome.kt file with a Composable UI for the Processing welcome screen. Includes layout with buttons for language selection, new sketch, examples, and sketchbook, as well as a placeholder for right-side content and a main entry point for launching the window. --- app/src/processing/app/ui/PDEWelcome.kt | 112 +++++++++++++++++++++++ app/src/processing/app/ui/theme/Theme.kt | 2 +- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 app/src/processing/app/ui/PDEWelcome.kt diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt new file mode 100644 index 0000000000..d061107ec4 --- /dev/null +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -0,0 +1,112 @@ +package processing.app.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Drafts +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Language +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MultiChoiceSegmentedButtonRow +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.application +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDEComposeWindow +import processing.app.ui.theme.PDETheme + +@Composable +fun PDEWelcome() { + Row (modifier = Modifier.fillMaxSize()){ + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .weight(0.8f) + .padding(32.dp) + ) { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + ){ + OutlinedButton( + onClick = {}, + modifier = Modifier.height(30.dp) + ) { + Icon(Icons.Default.Language, contentDescription = "") +// Text(LocalLocale.current.locale.displayName, style = MaterialTheme.typography.labelSmall) + Icon(Icons.Default.ArrowDropDown, contentDescription = "") + } + } + Row (horizontalArrangement = Arrangement.spacedBy(16.dp)){ + Box( + modifier = Modifier + .size(50.dp) + .background(MaterialTheme.colorScheme.primary) + ) + Text( + text = "Welcome to Processing!", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .align(Alignment.Bottom) + ) + } + Column { + TextButton(onClick = {}) { + Icon(Icons.Default.Drafts, contentDescription = "") + Text("New Empty Sketch") + } + TextButton(onClick = {}) { + Icon(Icons.Default.Image, contentDescription = "") + Text("Open Examples") + } + TextButton(onClick = {}) { + Icon(Icons.Default.Folder, contentDescription = "") + Text("Sketchbook") + } + } + } + VerticalDivider() + Column(modifier = Modifier + .sizeIn(minWidth = 250.dp) + ) { + Text("Right Side Content", style = MaterialTheme.typography.bodyLarge) + } + } +} + + + +fun main(){ + application { + PDEComposeWindow(titleKey = "welcome.title", size = DpSize(800.dp, 600.dp), fullWindowContent = true) { + PDETheme(darkTheme = false) { + PDEWelcome() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 554b5f27e7..7790aba776 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -99,7 +99,7 @@ fun PDETheme( Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface, - LocalDensity provides Density(1.25f, 1.25f), +// LocalDensity provides Density(1.25f, 1.25f), content = content ) } From 2fc56820fa8606f3de7ab621900e186ce188906e Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 27 Oct 2025 08:39:10 +0100 Subject: [PATCH 19/39] Initial layout --- .../processing/app/ui/WebFrame.java | 0 .../processing/app/ui/Welcome.java | 0 app/src/processing/app/Base.java | 8 +- app/src/processing/app/ui/PDEWelcome.kt | 290 +++++++++++++++--- java/src/processing/mode/java/JavaEditor.java | 8 +- 5 files changed, 250 insertions(+), 56 deletions(-) rename app/{src => ant}/processing/app/ui/WebFrame.java (100%) rename app/{src => ant}/processing/app/ui/Welcome.java (100%) diff --git a/app/src/processing/app/ui/WebFrame.java b/app/ant/processing/app/ui/WebFrame.java similarity index 100% rename from app/src/processing/app/ui/WebFrame.java rename to app/ant/processing/app/ui/WebFrame.java diff --git a/app/src/processing/app/ui/Welcome.java b/app/ant/processing/app/ui/Welcome.java similarity index 100% rename from app/src/processing/app/ui/Welcome.java rename to app/ant/processing/app/ui/Welcome.java diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index e3eae12fb8..43f089b288 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -375,13 +375,7 @@ static private void handleWelcomeScreen(Base base) { // Needs to be shown after the first editor window opens, so that it // shows up on top, and doesn't prevent an editor window from opening. if (Preferences.getBoolean("welcome.four.show")) { - try { - new Welcome(base); - } catch (IOException e) { - Messages.showTrace("Unwelcoming", - "Please report this error to\n" + - "https://github.com/processing/processing4/issues", e, false); - } + PDEWelcomeKt.showWelcomeScreen(); } } diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index d061107ec4..35bd64b778 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -1,10 +1,16 @@ package processing.app.ui -import androidx.compose.foundation.background +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,86 +19,280 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Drafts import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Language +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MultiChoiceSegmentedButtonRow -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.application -import processing.app.ui.theme.LocalLocale import processing.app.ui.theme.PDEComposeWindow +import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.PDETheme +import java.awt.Dimension @Composable fun PDEWelcome() { - Row (modifier = Modifier.fillMaxSize()){ + Row( + modifier = Modifier.fillMaxSize(), + ){ + val xsPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) + val xsModifier = Modifier + .defaultMinSize(minHeight = 1.dp) + .height(32.dp) + Column( - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier + .fillMaxHeight() .weight(0.8f) - .padding(32.dp) + .padding( + top = 48.dp, + start = 56.dp, + end = 64.dp, + bottom = 56.dp + ) ) { - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() ){ - OutlinedButton( - onClick = {}, - modifier = Modifier.height(30.dp) - ) { - Icon(Icons.Default.Language, contentDescription = "") -// Text(LocalLocale.current.locale.displayName, style = MaterialTheme.typography.labelSmall) - Icon(Icons.Default.ArrowDropDown, contentDescription = "") - } - } - Row (horizontalArrangement = Arrangement.spacedBy(16.dp)){ - Box( + Image( + painter = painterResource("logo.svg"), modifier = Modifier - .size(50.dp) - .background(MaterialTheme.colorScheme.primary) + .size(75.dp), + contentDescription = "Processing Logo" ) Text( text = "Welcome to Processing!", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier - .align(Alignment.Bottom) + .align(Alignment.CenterVertically) ) } - Column { - TextButton(onClick = {}) { - Icon(Icons.Default.Drafts, contentDescription = "") - Text("New Empty Sketch") + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier +// .background(Color.Blue) + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 12.dp) + ) { + val colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxHeight() + ) { + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + TextButton( + onClick = {}, + colors = colors, + modifier = Modifier + .sizeIn(minHeight = 56.dp) + ) { + Icon(Icons.Default.Drafts, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text("New Empty Sketch") + } + TextButton( + onClick = {}, + colors = colors, + modifier = Modifier + .sizeIn(minHeight = 56.dp) + ) { + Icon(Icons.Default.Image, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text("Open Examples") + } + } } - TextButton(onClick = {}) { - Icon(Icons.Default.Image, contentDescription = "") - Text("Open Examples") + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxHeight() + ) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + + TextButton( + onClick = {}, + contentPadding = xsPadding, + colors = colors, + modifier = xsModifier + ) { + Icon(Icons.Default.Folder, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text("Sketchbook", modifier = Modifier.align(Alignment.CenterVertically)) + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + colors = colors, + modifier = xsModifier + ) { + Icon(Icons.Default.Folder, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text("Settings", modifier = Modifier.align(Alignment.CenterVertically)) + } + Button( + onClick = {}, + contentPadding = xsPadding, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary + ), + modifier = xsModifier + ) { + Icon(Icons.Default.Folder, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text("Show this window on startup", modifier = Modifier.align(Alignment.CenterVertically)) + } + } } - TextButton(onClick = {}) { - Icon(Icons.Default.Folder, contentDescription = "") - Text("Sketchbook") + } + Card( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ){ + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .padding( + top = 18.dp, + end = 24.dp, + bottom = 24.dp, + start = 24.dp + ) + ) { + val colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ProvideTextStyle(MaterialTheme .typography.labelLarge) { + Column { + Text( + "Resources", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(start = 8.dp) + ) + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Video Course") + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Get Started") + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Tutorials") + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Documentation") + } + } + Column { + Text( + "Join our community", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(start = 8.dp) + ) + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Video Course") + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Get Started") + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Tutorials") + } + TextButton( + onClick = {}, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text("Documentation") + } + } + } } } } VerticalDivider() Column(modifier = Modifier - .sizeIn(minWidth = 250.dp) + .sizeIn(minWidth = 350.dp) ) { Text("Right Side Content", style = MaterialTheme.typography.bodyLarge) } @@ -100,10 +300,16 @@ fun PDEWelcome() { } +fun showWelcomeScreen(){ + PDESwingWindow(titleKey = "welcome.title", size = Dimension(970, 570), fullWindowContent = true) { + PDEWelcome() + } +} + fun main(){ application { - PDEComposeWindow(titleKey = "welcome.title", size = DpSize(800.dp, 600.dp), fullWindowContent = true) { + PDEComposeWindow(titleKey = "welcome.title", size = DpSize(970.dp, 570.dp), fullWindowContent = true) { PDETheme(darkTheme = false) { PDEWelcome() } diff --git a/java/src/processing/mode/java/JavaEditor.java b/java/src/processing/mode/java/JavaEditor.java index 3fab2c8b17..6998516b32 100644 --- a/java/src/processing/mode/java/JavaEditor.java +++ b/java/src/processing/mode/java/JavaEditor.java @@ -288,13 +288,7 @@ public JMenu buildHelpMenu() { item = new JMenuItem(Language.text("menu.help.welcome")); item.addActionListener(e -> { - try { - new Welcome(base); - } catch (IOException ioe) { - Messages.showWarning("Unwelcome Error", - "Please report this error to\n" + - "https://github.com/processing/processing4/issues", ioe); - } + PDEWelcomeKt.showWelcomeScreen(); }); menu.add(item); From 60372758e9c5339dfeef0e65979c335677378384 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 27 Oct 2025 09:58:35 +0100 Subject: [PATCH 20/39] Revamp welcome screen UI and add social icons Refactors the PDEWelcome screen to improve layout, update button icons, and add support for Discord, GitHub, and Instagram SVG icons. The welcome screen now receives a Base instance for proper action handling, and new methods replace deprecated ones in Base.java. Updates related menu actions to pass the Base instance as needed. --- app/src/main/resources/icons/Discord.svg | 3 + app/src/main/resources/icons/GitHub.svg | 10 + app/src/main/resources/icons/Instagram.svg | 3 + app/src/processing/app/Base.java | 6 +- app/src/processing/app/ui/PDEWelcome.kt | 233 ++++++++++++------ java/src/processing/mode/java/JavaEditor.java | 2 +- 6 files changed, 177 insertions(+), 80 deletions(-) create mode 100644 app/src/main/resources/icons/Discord.svg create mode 100644 app/src/main/resources/icons/GitHub.svg create mode 100644 app/src/main/resources/icons/Instagram.svg diff --git a/app/src/main/resources/icons/Discord.svg b/app/src/main/resources/icons/Discord.svg new file mode 100644 index 0000000000..54f918b869 --- /dev/null +++ b/app/src/main/resources/icons/Discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/resources/icons/GitHub.svg b/app/src/main/resources/icons/GitHub.svg new file mode 100644 index 0000000000..39b263b230 --- /dev/null +++ b/app/src/main/resources/icons/GitHub.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/resources/icons/Instagram.svg b/app/src/main/resources/icons/Instagram.svg new file mode 100644 index 0000000000..abb51a22e5 --- /dev/null +++ b/app/src/main/resources/icons/Instagram.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 43f089b288..fe20b82da1 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -375,7 +375,7 @@ static private void handleWelcomeScreen(Base base) { // Needs to be shown after the first editor window opens, so that it // shows up on top, and doesn't prevent an editor window from opening. if (Preferences.getBoolean("welcome.four.show")) { - PDEWelcomeKt.showWelcomeScreen(); + PDEWelcomeKt.showWelcomeScreen(base); } } @@ -603,7 +603,7 @@ public JMenu initDefaultFileMenu() { defaultFileMenu.add(item); item = Toolkit.newJMenuItemShift(Language.text("menu.file.examples"), 'O'); - item.addActionListener(e -> thinkDifferentExamples()); + item.addActionListener(e -> showExamplesFrame()); defaultFileMenu.add(item); return defaultFileMenu; @@ -1879,7 +1879,7 @@ public void handleRestart() { // } - public void thinkDifferentExamples() { + public void showExamplesFrame() { nextMode.showExamplesFrame(); } diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 35bd64b778..60a552a353 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -1,14 +1,13 @@ package processing.app.ui import androidx.compose.foundation.Image +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -19,10 +18,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Drafts -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.FolderSpecial +import androidx.compose.material.icons.outlined.NoteAdd +import androidx.compose.material.icons.outlined.PinDrop +import androidx.compose.material.icons.outlined.School +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -39,16 +45,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.application +import processing.app.Base +import processing.app.LocalPreferences +import processing.app.Messages +import processing.app.Platform import processing.app.ui.theme.PDEComposeWindow import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.PDETheme -import java.awt.Dimension +import processing.app.ui.theme.toDimension @Composable -fun PDEWelcome() { +fun PDEWelcome(base: Base? = null) { Row( modifier = Modifier.fillMaxSize(), ){ @@ -56,6 +65,7 @@ fun PDEWelcome() { val xsModifier = Modifier .defaultMinSize(minHeight = 1.dp) .height(32.dp) + val textColor = if(isSystemInDarkTheme()) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer Column( verticalArrangement = Arrangement.SpaceBetween, @@ -82,7 +92,7 @@ fun PDEWelcome() { Text( text = "Welcome to Processing!", style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = textColor, modifier = Modifier .align(Alignment.CenterVertically) ) @@ -90,13 +100,12 @@ fun PDEWelcome() { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier -// .background(Color.Blue) .fillMaxWidth() .height(IntrinsicSize.Min) .padding(vertical = 12.dp) ) { val colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + contentColor = textColor ) Column( verticalArrangement = Arrangement.SpaceBetween, @@ -105,22 +114,28 @@ fun PDEWelcome() { ) { ProvideTextStyle(MaterialTheme.typography.titleMedium) { TextButton( - onClick = {}, + onClick = { + base?.handleNew() ?: noBaseWarning() + }, colors = colors, modifier = Modifier .sizeIn(minHeight = 56.dp) ) { - Icon(Icons.Default.Drafts, contentDescription = "") + Icon(Icons.Outlined.NoteAdd, contentDescription = "") Spacer(Modifier.width(12.dp)) Text("New Empty Sketch") } TextButton( - onClick = {}, + onClick = { + base?.let{ + base.showExamplesFrame() + } ?: noBaseWarning() + }, colors = colors, modifier = Modifier .sizeIn(minHeight = 56.dp) ) { - Icon(Icons.Default.Image, contentDescription = "") + Icon(Icons.Outlined.FolderSpecial, contentDescription = "") Spacer(Modifier.width(12.dp)) Text("Open Examples") } @@ -132,37 +147,48 @@ fun PDEWelcome() { .fillMaxHeight() ) { ProvideTextStyle(MaterialTheme.typography.labelLarge) { - TextButton( - onClick = {}, + onClick = { + base?.let{ + base.showSketchbookFrame() + } ?: noBaseWarning() + }, contentPadding = xsPadding, colors = colors, modifier = xsModifier ) { - Icon(Icons.Default.Folder, contentDescription = "") + Icon(Icons.Outlined.FolderOpen, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) Text("Sketchbook", modifier = Modifier.align(Alignment.CenterVertically)) } TextButton( - onClick = {}, + onClick = { + base?.let{ + base.handlePrefs() + } ?: noBaseWarning() + }, contentPadding = xsPadding, colors = colors, modifier = xsModifier ) { - Icon(Icons.Default.Folder, contentDescription = "") + Icon(Icons.Outlined.Settings, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) Text("Settings", modifier = Modifier.align(Alignment.CenterVertically)) } + val preferences = LocalPreferences.current + val showOnStartup = preferences["welcome.four.show"].toBoolean() Button( - onClick = {}, + onClick = { + preferences["welcome.four.show"] = (!showOnStartup).toString() + }, contentPadding = xsPadding, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary + containerColor = if(showOnStartup) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.surfaceContainer, + contentColor = if (showOnStartup) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onSurface ), modifier = xsModifier ) { - Icon(Icons.Default.Folder, contentDescription = "") + Icon(if(showOnStartup) Icons.Default.Check else Icons.Default.Close, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) Text("Show this window on startup", modifier = Modifier.align(Alignment.CenterVertically)) } @@ -191,7 +217,7 @@ fun PDEWelcome() { val colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) - ProvideTextStyle(MaterialTheme .typography.labelLarge) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { Column { Text( "Resources", @@ -199,42 +225,50 @@ fun PDEWelcome() { modifier = Modifier.padding(start = 8.dp) ) TextButton( - onClick = {}, + onClick = { + Platform.openURL("https://hello.processing.org") + }, contentPadding = xsPadding, modifier = xsModifier, colors = colors ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.SmartDisplay, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) Text("Video Course") } TextButton( - onClick = {}, + onClick = { + Platform.openURL("https://processing.org/tutorials/gettingstarted") + }, contentPadding = xsPadding, modifier = xsModifier, colors = colors ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.PinDrop, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) Text("Get Started") } TextButton( - onClick = {}, + onClick = { + Platform.openURL("https://processing.org/tutorials") + }, contentPadding = xsPadding, modifier = xsModifier, colors = colors ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.School, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) Text("Tutorials") } TextButton( - onClick = {}, + onClick = { + Platform.openURL("https://processing.org/reference") + }, contentPadding = xsPadding, modifier = xsModifier, colors = colors ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.Book, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) Text("Documentation") } @@ -245,45 +279,79 @@ fun PDEWelcome() { style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 8.dp) ) - TextButton( - onClick = {}, - contentPadding = xsPadding, - modifier = xsModifier, - colors = colors + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxWidth() ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(4.dp)) - Text("Video Course") - } - TextButton( - onClick = {}, - contentPadding = xsPadding, - modifier = xsModifier, - colors = colors - ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(4.dp)) - Text("Get Started") - } - TextButton( - onClick = {}, - contentPadding = xsPadding, - modifier = xsModifier, - colors = colors - ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(4.dp)) - Text("Tutorials") - } - TextButton( - onClick = {}, - contentPadding = xsPadding, - modifier = xsModifier, - colors = colors - ) { - Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(4.dp)) - Text("Documentation") + Column { + TextButton( + onClick = { + Platform.openURL("https://discourse.processing.org") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Forum") + } + TextButton( + onClick = { + Platform.openURL("https://discord.processing.org") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + painterResource("icons/Discord.svg"), + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Discord") + } + } + Column { + TextButton( + onClick = { + Platform.openURL("https://www.instagram.com/processing_core/") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + painterResource("icons/GitHub.svg"), + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("GitHub") + } + TextButton( + onClick = { + Platform.openURL("https://github.com/processing/processing4") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + painterResource("icons/Instagram.svg"), + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Instagram") + } + } } } } @@ -299,17 +367,30 @@ fun PDEWelcome() { } } +fun noBaseWarning() { + Messages.showWarning( + "No Base", + "No Base instance provided, this ui is likely being previewed." + ) +} -fun showWelcomeScreen(){ - PDESwingWindow(titleKey = "welcome.title", size = Dimension(970, 570), fullWindowContent = true) { - PDEWelcome() +val size = DpSize(970.dp, 550.dp) + +fun showWelcomeScreen(base: Base? = null) { + PDESwingWindow(titleKey = "welcome.title", size = size.toDimension(), fullWindowContent = true) { + PDEWelcome(base) } } fun main(){ application { - PDEComposeWindow(titleKey = "welcome.title", size = DpSize(970.dp, 570.dp), fullWindowContent = true) { + PDEComposeWindow(titleKey = "welcome.title", size = size, fullWindowContent = true) { + PDETheme(darkTheme = true) { + PDEWelcome() + } + } + PDEComposeWindow(titleKey = "welcome.title", size = size, fullWindowContent = true) { PDETheme(darkTheme = false) { PDEWelcome() } diff --git a/java/src/processing/mode/java/JavaEditor.java b/java/src/processing/mode/java/JavaEditor.java index 6998516b32..7ce9e45be7 100644 --- a/java/src/processing/mode/java/JavaEditor.java +++ b/java/src/processing/mode/java/JavaEditor.java @@ -288,7 +288,7 @@ public JMenu buildHelpMenu() { item = new JMenuItem(Language.text("menu.help.welcome")); item.addActionListener(e -> { - PDEWelcomeKt.showWelcomeScreen(); + PDEWelcomeKt.showWelcomeScreen(base); }); menu.add(item); From 33ad602ebea7db3b7c92e1e5b72798a154521c4c Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 27 Oct 2025 11:58:16 +0100 Subject: [PATCH 21/39] Add example previews to welcome screen Replaces placeholder text on the right side of the PDEWelcome screen with a LazyColumn displaying example sketches. Each example attempts to show a preview image if available, or a placeholder icon otherwise. Introduces an Example data class and related image loading logic. --- app/src/processing/app/ui/PDEWelcome.kt | 68 ++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 60a552a353..0f5ac0474c 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -1,13 +1,16 @@ package processing.app.ui import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -17,6 +20,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close @@ -33,6 +39,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle @@ -40,8 +47,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.decodeToImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize @@ -55,6 +67,8 @@ import processing.app.ui.theme.PDEComposeWindow import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.PDETheme import processing.app.ui.theme.toDimension +import java.io.File +import java.nio.file.Path @Composable fun PDEWelcome(base: Base? = null) { @@ -362,11 +376,63 @@ fun PDEWelcome(base: Base? = null) { Column(modifier = Modifier .sizeIn(minWidth = 350.dp) ) { - Text("Right Side Content", style = MaterialTheme.typography.bodyLarge) + val examples = listOf( + Example(Platform.getContentFile("modes/java/examples/Basics/Arrays/Array")), + Example(Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective")), + Example(Platform.getContentFile("modes/java/examples/Basics/Color/Brightness")), + Example(Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ")), + ) + LazyColumn( + state = rememberLazyListState( + initialFirstVisibleItemScrollOffset = 150 + ), + modifier = Modifier.width(350.dp) + ) { + items(examples) { example -> + Box(Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxSize() + .aspectRatio(16 / 9f) + ){ + val image = remember { + val name = example.path.name + File(example.path,"$name.png").takeIf { it.exists() } + } + if(image == null){ + Icon( + painter = painterResource("logo.svg"), + modifier = Modifier + .size(75.dp) + .align(Alignment.Center) + , + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + contentDescription = "Processing Logo" + ) + HorizontalDivider() + }else { + val imageBitmap: ImageBitmap = remember(image) { + image.inputStream().readAllBytes().decodeToImageBitmap() + } + Image( + painter = BitmapPainter(imageBitmap), + modifier = Modifier +// .fillMaxSize() + , + contentDescription = example.path.name + ) + } + + } + } + } } } } +data class Example( + val path: File, +) + fun noBaseWarning() { Messages.showWarning( "No Base", From 20aac8164f366a25c3918f5c86d48daa9d6e75c3 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 27 Oct 2025 12:26:09 +0100 Subject: [PATCH 22/39] Add hover-activated play button to example previews Introduced a hover effect on example preview images in the welcome screen, displaying a play button that opens the example when clicked. Refactored title key usage for consistency. --- app/src/processing/app/ui/PDEWelcome.kt | 50 +++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 0f5ac0474c..7a414a3142 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -26,12 +26,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderSpecial import androidx.compose.material.icons.outlined.NoteAdd import androidx.compose.material.icons.outlined.PinDrop +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.School import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SmartDisplay @@ -39,21 +41,33 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.decodeToImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize @@ -70,6 +84,7 @@ import processing.app.ui.theme.toDimension import java.io.File import java.nio.file.Path +@OptIn(ExperimentalComposeUiApi::class) @Composable fun PDEWelcome(base: Base? = null) { Row( @@ -389,10 +404,17 @@ fun PDEWelcome(base: Base? = null) { modifier = Modifier.width(350.dp) ) { items(examples) { example -> + var hovered by remember { mutableStateOf(false) } Box(Modifier .background(MaterialTheme.colorScheme.surfaceVariant) .fillMaxSize() .aspectRatio(16 / 9f) + .onPointerEvent(PointerEventType.Enter){ + hovered = true + } + .onPointerEvent(PointerEventType.Exit){ + hovered = false + } ){ val image = remember { val name = example.path.name @@ -421,7 +443,26 @@ fun PDEWelcome(base: Base? = null) { contentDescription = example.path.name ) } - + if(hovered) { + FilledTonalIconButton( + onClick = { + base?.let { + base.handleOpen(example.path.resolve("${example.path.name}.pde").absolutePath) + } ?: noBaseWarning() + }, modifier = Modifier + .align(Alignment.Center), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ) + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = "Open Example", + tint = Color.White + ) + } + } } } } @@ -441,9 +482,10 @@ fun noBaseWarning() { } val size = DpSize(970.dp, 550.dp) +val titleKey = "menu.help.welcome" fun showWelcomeScreen(base: Base? = null) { - PDESwingWindow(titleKey = "welcome.title", size = size.toDimension(), fullWindowContent = true) { + PDESwingWindow(titleKey = titleKey, size = size.toDimension(), fullWindowContent = true) { PDEWelcome(base) } } @@ -451,12 +493,12 @@ fun showWelcomeScreen(base: Base? = null) { fun main(){ application { - PDEComposeWindow(titleKey = "welcome.title", size = size, fullWindowContent = true) { + PDEComposeWindow(titleKey = titleKey, size = size, fullWindowContent = true) { PDETheme(darkTheme = true) { PDEWelcome() } } - PDEComposeWindow(titleKey = "welcome.title", size = size, fullWindowContent = true) { + PDEComposeWindow(titleKey = titleKey, size = size, fullWindowContent = true) { PDETheme(darkTheme = false) { PDEWelcome() } From e036cdf79d136e402f34cd4ebae5e63ea4936269 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 27 Oct 2025 14:15:54 +0100 Subject: [PATCH 23/39] Localize welcome screen UI strings Replaced hardcoded strings in the PDEWelcome screen with localized values using the LocalLocale context. Added new keys for the welcome screen to the English and Dutch language property files to support internationalization. --- app/src/processing/app/ui/PDEWelcome.kt | 48 ++++++++++++++------ build/shared/lib/languages/PDE.properties | 16 +++++++ build/shared/lib/languages/PDE_nl.properties | 16 +++++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 7a414a3142..662e7268e8 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -77,6 +77,7 @@ import processing.app.Base import processing.app.LocalPreferences import processing.app.Messages import processing.app.Platform +import processing.app.ui.theme.LocalLocale import processing.app.ui.theme.PDEComposeWindow import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.PDETheme @@ -95,6 +96,7 @@ fun PDEWelcome(base: Base? = null) { .defaultMinSize(minHeight = 1.dp) .height(32.dp) val textColor = if(isSystemInDarkTheme()) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer + val locale = LocalLocale.current Column( verticalArrangement = Arrangement.SpaceBetween, @@ -116,10 +118,10 @@ fun PDEWelcome(base: Base? = null) { painter = painterResource("logo.svg"), modifier = Modifier .size(75.dp), - contentDescription = "Processing Logo" + contentDescription = locale["welcome.processing.logo"] ) Text( - text = "Welcome to Processing!", + text = locale["welcome.processing.title"], style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), color = textColor, modifier = Modifier @@ -152,7 +154,7 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.NoteAdd, contentDescription = "") Spacer(Modifier.width(12.dp)) - Text("New Empty Sketch") + Text(locale["welcome.actions.sketch.new"]) } TextButton( onClick = { @@ -166,7 +168,7 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.FolderSpecial, contentDescription = "") Spacer(Modifier.width(12.dp)) - Text("Open Examples") + Text(locale["welcome.actions.examples"] ) } } } @@ -188,7 +190,7 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.FolderOpen, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) - Text("Sketchbook", modifier = Modifier.align(Alignment.CenterVertically)) + Text(locale["sketchbook"], modifier = Modifier.align(Alignment.CenterVertically)) } TextButton( onClick = { @@ -202,7 +204,7 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.Settings, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) - Text("Settings", modifier = Modifier.align(Alignment.CenterVertically)) + Text(locale["preferences"], modifier = Modifier.align(Alignment.CenterVertically)) } val preferences = LocalPreferences.current val showOnStartup = preferences["welcome.four.show"].toBoolean() @@ -219,7 +221,7 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(if(showOnStartup) Icons.Default.Check else Icons.Default.Close, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) - Text("Show this window on startup", modifier = Modifier.align(Alignment.CenterVertically)) + Text(locale["welcome.actions.show_startup"], modifier = Modifier.align(Alignment.CenterVertically)) } } } @@ -249,7 +251,7 @@ fun PDEWelcome(base: Base? = null) { ProvideTextStyle(MaterialTheme.typography.labelLarge) { Column { Text( - "Resources", + text = locale["welcome.resources.title"], style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 8.dp) ) @@ -263,7 +265,9 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.SmartDisplay, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) - Text("Video Course") + Text( + text = locale["welcome.resources.video"], + ) } TextButton( onClick = { @@ -275,7 +279,9 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.PinDrop, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) - Text("Get Started") + Text( + text = locale["welcome.resources.get_started"], + ) } TextButton( onClick = { @@ -287,7 +293,9 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.School, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) - Text("Tutorials") + Text( + text = locale["welcome.resources.tutorials"], + ) } TextButton( onClick = { @@ -299,12 +307,14 @@ fun PDEWelcome(base: Base? = null) { ) { Icon(Icons.Outlined.Book, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) - Text("Documentation") + Text( + text = locale["welcome.resources.documentation"], + ) } } Column { Text( - "Join our community", + text = locale["welcome.community.title"], style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 8.dp) ) @@ -328,7 +338,9 @@ fun PDEWelcome(base: Base? = null) { modifier = Modifier.size(20.dp) ) Spacer(Modifier.width(4.dp)) - Text("Forum") + Text( + text = locale["welcome.community.forum"] + ) } TextButton( onClick = { @@ -462,6 +474,14 @@ fun PDEWelcome(base: Base? = null) { tint = Color.White ) } + Text( + text = example.path.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + ) } } } diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index 19a5c9f866..caed0d37d0 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -640,6 +640,22 @@ beta.button = Ok color_chooser = Color Selector color_chooser.select = Select + +# --------------------------------------- +# Welcome Screen +welcome.processing.logo = Processing Logo +welcome.processing.title = Welcome to Processing! +welcome.actions.sketch.new = Empty Sketch +welcome.actions.examples = Open Examples +welcome.actions.show_startup = Show this window at startup +welcome.resources.title = Resources +welcome.resources.video = Video Course +welcome.resources.get_started = Get Started +welcome.resources.tutorials = Tutorials +welcome.resources.documentation = Reference +welcome.community.title = Join our community +welcome.community.forum = Forum + # --------------------------------------- # Movie Maker diff --git a/build/shared/lib/languages/PDE_nl.properties b/build/shared/lib/languages/PDE_nl.properties index e7f11b0a1f..76865397b3 100644 --- a/build/shared/lib/languages/PDE_nl.properties +++ b/build/shared/lib/languages/PDE_nl.properties @@ -322,6 +322,22 @@ beta.title = Dankuwel voor het testen van deze Processing Beta! beta.message = Deze preview release laat ons feedback verzamelen en problemen oplossen. **Sommige functies werken mogelijk niet zoals verwacht.** Als u problemen ondervindt, [post dan op het forum](https://discourse.processing.org) of [open een GitHub issue](https://github.com/processing/processing4/issues). beta.button = Ok + +# --------------------------------------- +# Welcome Screen +welcome.processing.logo = Processing Logo +welcome.processing.title = Welkom bij Processing! +welcome.actions.sketch.new = Nieuwe Schets +welcome.actions.examples = Open Voorbeelden +welcome.actions.show_startup = Laat dit scherm zien bij opstarten +welcome.resources.title = Resources +welcome.resources.video = Video Cursus +welcome.resources.get_started = Om te beginnen +welcome.resources.tutorials = Tutorials +welcome.resources.documentation = Handleiding +welcome.community.title = Neem deel aan de Community +welcome.community.forum = Forum + # --------------------------------------- # Color Chooser color_chooser = Kies een kleur... From d8f9d75c24af363d927c128f3476704caf66572b Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 11:45:14 +0100 Subject: [PATCH 24/39] Add language selector and UI improvements to welcome screen Introduces a language selection dropdown to the PDE welcome screen using a shared composable from preferences. Refactors the layout for better spacing, updates example cards with animated overlays, and replaces the show-on-startup button with a checkbox. Also adds a new translation key for the open example button. --- app/src/processing/app/ui/PDEWelcome.kt | 233 +++++++++++------- .../app/ui/preferences/Interface.kt | 46 ++-- build/shared/lib/languages/PDE.properties | 1 + 3 files changed, 169 insertions(+), 111 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 662e7268e8..0b081afc03 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -1,7 +1,18 @@ package processing.app.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,8 +35,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.ChatBubbleOutline @@ -41,6 +54,8 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -48,6 +63,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -60,6 +76,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.decodeToImageBitmap @@ -71,12 +88,14 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.application import processing.app.Base import processing.app.LocalPreferences import processing.app.Messages import processing.app.Platform +import processing.app.ui.preferences.Interface.Companion.languagesDropdown import processing.app.ui.theme.LocalLocale import processing.app.ui.theme.PDEComposeWindow import processing.app.ui.theme.PDESwingWindow @@ -89,7 +108,9 @@ import java.nio.file.Path @Composable fun PDEWelcome(base: Base? = null) { Row( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow), ){ val xsPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) val xsModifier = Modifier @@ -111,29 +132,49 @@ fun PDEWelcome(base: Base? = null) { ) ) { Row ( - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ){ Image( painter = painterResource("logo.svg"), modifier = Modifier - .size(75.dp), + .size(50.dp), contentDescription = locale["welcome.processing.logo"] ) Text( text = locale["welcome.processing.title"], - style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), color = textColor, modifier = Modifier .align(Alignment.CenterVertically) ) + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.CenterVertically), + horizontalArrangement = Arrangement.End, + ){ + val showLanguageMenu = remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showLanguageMenu.value = !showLanguageMenu.value + }, + contentPadding = xsPadding, + modifier = xsModifier + ){ + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text(text = locale.locale.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = "", modifier = Modifier.size(20.dp)) + languagesDropdown(showLanguageMenu) + } + + } + } Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) - .padding(vertical = 12.dp) ) { val colors = ButtonDefaults.textButtonColors( contentColor = textColor @@ -144,13 +185,14 @@ fun PDEWelcome(base: Base? = null) { .fillMaxHeight() ) { ProvideTextStyle(MaterialTheme.typography.titleMedium) { + val medModifier = Modifier + .sizeIn(minHeight = 56.dp) TextButton( onClick = { base?.handleNew() ?: noBaseWarning() }, colors = colors, - modifier = Modifier - .sizeIn(minHeight = 56.dp) + modifier = medModifier ) { Icon(Icons.Outlined.NoteAdd, contentDescription = "") Spacer(Modifier.width(12.dp)) @@ -163,66 +205,25 @@ fun PDEWelcome(base: Base? = null) { } ?: noBaseWarning() }, colors = colors, - modifier = Modifier - .sizeIn(minHeight = 56.dp) + modifier = medModifier ) { Icon(Icons.Outlined.FolderSpecial, contentDescription = "") Spacer(Modifier.width(12.dp)) Text(locale["welcome.actions.examples"] ) } - } - } - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxHeight() - ) { - ProvideTextStyle(MaterialTheme.typography.labelLarge) { TextButton( onClick = { base?.let{ base.showSketchbookFrame() } ?: noBaseWarning() }, - contentPadding = xsPadding, colors = colors, - modifier = xsModifier + modifier = medModifier ) { Icon(Icons.Outlined.FolderOpen, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(12.dp)) Text(locale["sketchbook"], modifier = Modifier.align(Alignment.CenterVertically)) } - TextButton( - onClick = { - base?.let{ - base.handlePrefs() - } ?: noBaseWarning() - }, - contentPadding = xsPadding, - colors = colors, - modifier = xsModifier - ) { - Icon(Icons.Outlined.Settings, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(12.dp)) - Text(locale["preferences"], modifier = Modifier.align(Alignment.CenterVertically)) - } - val preferences = LocalPreferences.current - val showOnStartup = preferences["welcome.four.show"].toBoolean() - Button( - onClick = { - preferences["welcome.four.show"] = (!showOnStartup).toString() - }, - contentPadding = xsPadding, - colors = ButtonDefaults.buttonColors( - containerColor = if(showOnStartup) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.surfaceContainer, - contentColor = if (showOnStartup) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onSurface - ), - modifier = xsModifier - ) { - Icon(if(showOnStartup) Icons.Default.Check else Icons.Default.Close, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(12.dp)) - Text(locale["welcome.actions.show_startup"], modifier = Modifier.align(Alignment.CenterVertically)) - } } } } @@ -255,20 +256,6 @@ fun PDEWelcome(base: Base? = null) { style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 8.dp) ) - TextButton( - onClick = { - Platform.openURL("https://hello.processing.org") - }, - contentPadding = xsPadding, - modifier = xsModifier, - colors = colors - ) { - Icon(Icons.Outlined.SmartDisplay, contentDescription = "", modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(4.dp)) - Text( - text = locale["welcome.resources.video"], - ) - } TextButton( onClick = { Platform.openURL("https://processing.org/tutorials/gettingstarted") @@ -398,10 +385,35 @@ fun PDEWelcome(base: Base? = null) { } } } + val preferences = LocalPreferences.current + val showOnStartup = preferences["welcome.four.show"].toBoolean() + fun toggle(next: Boolean? = null){ + preferences["welcome.four.show"] = (next ?: !showOnStartup).toString() + } + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = ::toggle) + .padding(end = 8.dp) + .height(32.dp) + ) { + Checkbox( + checked = showOnStartup, + onCheckedChange = ::toggle, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.tertiary + ) + ) + Text( + text = locale["welcome.actions.show_startup"], + modifier = Modifier.align(Alignment.CenterVertically), + style = MaterialTheme.typography.labelLarge + ) + } } - VerticalDivider() Column(modifier = Modifier .sizeIn(minWidth = 350.dp) + .padding(end = 12.dp) ) { val examples = listOf( Example(Platform.getContentFile("modes/java/examples/Basics/Arrays/Array")), @@ -413,12 +425,17 @@ fun PDEWelcome(base: Base? = null) { state = rememberLazyListState( initialFirstVisibleItemScrollOffset = 150 ), - modifier = Modifier.width(350.dp) + modifier = Modifier + .width(350.dp), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(examples) { example -> var hovered by remember { mutableStateOf(false) } Box(Modifier - .background(MaterialTheme.colorScheme.surfaceVariant) + .border(BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant), shape = MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium) + .clip(MaterialTheme.shapes.medium) .fillMaxSize() .aspectRatio(16 / 9f) .onPointerEvent(PointerEventType.Enter){ @@ -455,33 +472,65 @@ fun PDEWelcome(base: Base? = null) { contentDescription = example.path.name ) } - if(hovered) { - FilledTonalIconButton( - onClick = { - base?.let { - base.handleOpen(example.path.resolve("${example.path.name}.pde").absolutePath) - } ?: noBaseWarning() - }, modifier = Modifier - .align(Alignment.Center), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary, + Column( + modifier = Modifier.align(Alignment.BottomCenter), + ) { + val duration = 150 + AnimatedVisibility( + visible = hovered, + enter = slideIn( + initialOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = EaseInOut + ) + ), + exit = slideOut ( + targetOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = LinearEasing + ) ) ) { - Icon( - Icons.Filled.PlayArrow, - contentDescription = "Open Example", - tint = Color.White - ) + Card( + modifier = Modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(12.dp) + .padding(start = 12.dp) + ) { + Text( + text = example.path.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(8.dp) + ) + Button( + onClick = { + base?.let { + base.handleOpen(example.path.resolve("${example.path.name}.pde").absolutePath) + } ?: noBaseWarning() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary + ) + ) { + Text( + text = locale["welcome.sketch.open"], + style = MaterialTheme.typography.bodyLarge + ) + } + } + } } - Text( - text = example.path.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp) - ) } } } @@ -501,7 +550,7 @@ fun noBaseWarning() { ) } -val size = DpSize(970.dp, 550.dp) +val size = DpSize(970.dp, 600.dp) val titleKey = "menu.help.welcome" fun showWelcomeScreen(base: Base? = null) { diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt index fc384fbc59..e9747a037d 100644 --- a/app/src/processing/app/ui/preferences/Interface.kt +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -12,6 +12,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,8 +47,7 @@ class Interface { group = interfaceAndFonts, control = { preference, updatePreference -> val locale = LocalLocale.current - var showOptions by remember { mutableStateOf(false) } - val languages = if(Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + val showOptions = remember { mutableStateOf(false) } TextField( value = locale.locale.displayName, readOnly = true, @@ -57,27 +58,12 @@ class Interface { contentDescription = "Select Font Family", modifier = Modifier .clickable{ - showOptions = true + showOptions.value = true } ) } ) - DropdownMenu( - expanded = showOptions, - onDismissRequest = { - showOptions = false - }, - ) { - languages.forEach { family -> - DropdownMenuItem( - text = { Text(family.value) }, - onClick = { - locale.set(Locale(family.key)) - showOptions = false - } - ) - } - } + languagesDropdown(showOptions) } )) @@ -164,5 +150,27 @@ class Interface { } )) } + + @Composable + fun languagesDropdown(showOptions: MutableState) { + val locale = LocalLocale.current + val languages = if (Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + DropdownMenu( + expanded = showOptions.value, + onDismissRequest = { + showOptions.value = false + }, + ) { + languages.forEach { family -> + DropdownMenuItem( + text = { Text(family.value) }, + onClick = { + locale.set(Locale(family.key)) + showOptions.value = false + } + ) + } + } + } } } \ No newline at end of file diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index caed0d37d0..c4a96dcd96 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -655,6 +655,7 @@ welcome.resources.tutorials = Tutorials welcome.resources.documentation = Reference welcome.community.title = Join our community welcome.community.forum = Forum +welcome.sketch.open = Open # --------------------------------------- # Movie Maker From 97ab23e8ff166608d1136e0c5856afee1c42295d Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 12:20:45 +0100 Subject: [PATCH 25/39] Refactor example listing and randomize welcome sketches Moved example folder listing logic in Contributions.ExamplesList to a companion object function for reuse. Updated PDEWelcome to display a randomized selection of sketches from all available examples, replacing the previous static list. --- app/src/processing/app/api/Contributions.kt | 210 +++++++++++--------- app/src/processing/app/ui/PDEWelcome.kt | 55 +++-- 2 files changed, 145 insertions(+), 120 deletions(-) diff --git a/app/src/processing/app/api/Contributions.kt b/app/src/processing/app/api/Contributions.kt index 25e693404b..7b35a30593 100644 --- a/app/src/processing/app/api/Contributions.kt +++ b/app/src/processing/app/api/Contributions.kt @@ -28,8 +28,6 @@ class Contributions: SuspendingCliktCommand(){ } class ExamplesList: SuspendingCliktCommand("list") { - - val serializer = Json { prettyPrint = true } @@ -37,107 +35,121 @@ class Contributions: SuspendingCliktCommand(){ override fun help(context: Context) = "List all examples" override suspend fun run() { Platform.init() - // TODO: Decouple modes listing from `Base` class, defaulting to Java mode for now - // TODO: Allow the user to change the sketchbook location - // TODO: Currently blocked since `Base.getSketchbookFolder()` is not available in headless mode - val sketchbookFolder = Platform.getDefaultSketchbookFolder() - val resourcesDir = System.getProperty("compose.application.resources.dir") - - val javaMode = "$resourcesDir/modes/java" - - val javaModeExamples = File("$javaMode/examples") - .listFiles() - ?.map { getSketches(it)} - ?: emptyList() - - val javaModeLibrariesExamples = File("$javaMode/libraries") - .listFiles{ it.isDirectory } - ?.map { library -> - val properties = library.resolve("library.properties") - val name = findNameInProperties(properties) ?: library.name - - val libraryExamples = getSketches(library.resolve("examples")) - Sketch.Companion.Folder( - type = "folder", - name = name, - path = library.absolutePath, - mode = "java", - children = libraryExamples?.children ?: emptyList(), - sketches = libraryExamples?.sketches ?: emptyList() - ) - } ?: emptyList() - val javaModeLibraries = Sketch.Companion.Folder( - type = "folder", - name = "Libraries", - path = "$javaMode/libraries", - mode = "java", - children = javaModeLibrariesExamples, - sketches = emptyList() - ) - - val contributedLibraries = sketchbookFolder.resolve("libraries") - .listFiles{ it.isDirectory } - ?.map { library -> - val properties = library.resolve("library.properties") - val name = findNameInProperties(properties) ?: library.name - // Get library name from library.properties if it exists - val libraryExamples = getSketches(library.resolve("examples")) - Sketch.Companion.Folder( - type = "folder", - name = name, - path = library.absolutePath, - mode = "java", - children = libraryExamples?.children ?: emptyList(), - sketches = libraryExamples?.sketches ?: emptyList() - ) - } ?: emptyList() - - val contributedLibrariesFolder = Sketch.Companion.Folder( - type = "folder", - name = "Contributed Libraries", - path = sketchbookFolder.resolve("libraries").absolutePath, - mode = "java", - children = contributedLibraries, - sketches = emptyList() - ) - - val contributedExamples = sketchbookFolder.resolve("examples") - .listFiles{ it.isDirectory } - ?.map { - val properties = it.resolve("examples.properties") - val name = findNameInProperties(properties) ?: it.name - - val sketches = getSketches(it.resolve("examples")) - Sketch.Companion.Folder( - type = "folder", - name, - path = it.absolutePath, - mode = "java", - children = sketches?.children ?: emptyList(), - sketches = sketches?.sketches ?: emptyList(), - ) - } - ?: emptyList() - val contributedExamplesFolder = Sketch.Companion.Folder( - type = "folder", - name = "Contributed Examples", - path = sketchbookFolder.resolve("examples").absolutePath, - mode = "java", - children = contributedExamples, - sketches = emptyList() - ) - - val json = serializer.encodeToString(javaModeExamples + javaModeLibraries + contributedLibrariesFolder + contributedExamplesFolder) + + val json = serializer.encodeToString(listAllExamples()) println(json) } - private fun findNameInProperties(properties: File): String? { - if (!properties.exists()) return null + companion object { + /** + * Get all example sketch folders + * @return List of example sketch folders + */ + fun listAllExamples(): List { + // TODO: Decouple modes listing from `Base` class, defaulting to Java mode for now + // TODO: Allow the user to change the sketchbook location + // TODO: Currently blocked since `Base.getSketchbookFolder()` is not available in headless mode + // TODO: Make non-blocking + // TODO: Add tests + + val sketchbookFolder = Platform.getDefaultSketchbookFolder() + val resourcesDir = System.getProperty("compose.application.resources.dir") + + val javaMode = "$resourcesDir/modes/java" + + val javaModeExamples = File("$javaMode/examples") + .listFiles() + ?.map { getSketches(it) } + ?: emptyList() + + val javaModeLibrariesExamples = File("$javaMode/libraries") + .listFiles { it.isDirectory } + ?.map { library -> + val properties = library.resolve("library.properties") + val name = findNameInProperties(properties) ?: library.name + + val libraryExamples = getSketches(library.resolve("examples")) + Sketch.Companion.Folder( + type = "folder", + name = name, + path = library.absolutePath, + mode = "java", + children = libraryExamples?.children ?: emptyList(), + sketches = libraryExamples?.sketches ?: emptyList() + ) + } ?: emptyList() + val javaModeLibraries = Sketch.Companion.Folder( + type = "folder", + name = "Libraries", + path = "$javaMode/libraries", + mode = "java", + children = javaModeLibrariesExamples, + sketches = emptyList() + ) + + val contributedLibraries = sketchbookFolder.resolve("libraries") + .listFiles { it.isDirectory } + ?.map { library -> + val properties = library.resolve("library.properties") + val name = findNameInProperties(properties) ?: library.name + // Get library name from library.properties if it exists + val libraryExamples = getSketches(library.resolve("examples")) + Sketch.Companion.Folder( + type = "folder", + name = name, + path = library.absolutePath, + mode = "java", + children = libraryExamples?.children ?: emptyList(), + sketches = libraryExamples?.sketches ?: emptyList() + ) + } ?: emptyList() + + val contributedLibrariesFolder = Sketch.Companion.Folder( + type = "folder", + name = "Contributed Libraries", + path = sketchbookFolder.resolve("libraries").absolutePath, + mode = "java", + children = contributedLibraries, + sketches = emptyList() + ) + + val contributedExamples = sketchbookFolder.resolve("examples") + .listFiles { it.isDirectory } + ?.map { + val properties = it.resolve("examples.properties") + val name = findNameInProperties(properties) ?: it.name + + val sketches = getSketches(it.resolve("examples")) + Sketch.Companion.Folder( + type = "folder", + name, + path = it.absolutePath, + mode = "java", + children = sketches?.children ?: emptyList(), + sketches = sketches?.sketches ?: emptyList(), + ) + } + ?: emptyList() + val contributedExamplesFolder = Sketch.Companion.Folder( + type = "folder", + name = "Contributed Examples", + path = sketchbookFolder.resolve("examples").absolutePath, + mode = "java", + children = contributedExamples, + sketches = emptyList() + ) + + return javaModeExamples + javaModeLibraries + contributedLibrariesFolder + contributedExamplesFolder + } + + private fun findNameInProperties(properties: File): String? { + if (!properties.exists()) return null - return properties.readLines().firstNotNullOfOrNull { line -> - line.split("=", limit = 2) - .takeIf { it.size == 2 && it[0].trim() == "name" } - ?.let { it[1].trim() } + return properties.readLines().firstNotNullOfOrNull { line -> + line.split("=", limit = 2) + .takeIf { it.size == 2 && it[0].trim() == "name" } + ?.let { it[1].trim() } + } } } } diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 0b081afc03..66f091abee 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -4,8 +4,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut import androidx.compose.foundation.BorderStroke @@ -36,40 +34,30 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderSpecial import androidx.compose.material.icons.outlined.NoteAdd import androidx.compose.material.icons.outlined.PinDrop -import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.School -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonColors -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -77,14 +65,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.decodeToImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize @@ -95,6 +80,8 @@ import processing.app.Base import processing.app.LocalPreferences import processing.app.Messages import processing.app.Platform +import processing.app.api.Contributions.ExamplesList.Companion.listAllExamples +import processing.app.api.Sketch.Companion.Sketch import processing.app.ui.preferences.Interface.Companion.languagesDropdown import processing.app.ui.theme.LocalLocale import processing.app.ui.theme.PDEComposeWindow @@ -102,7 +89,9 @@ import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.PDETheme import processing.app.ui.theme.toDimension import java.io.File -import java.nio.file.Path +import kotlin.concurrent.thread +import kotlin.io.path.Path +import kotlin.io.path.exists @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -415,12 +404,37 @@ fun PDEWelcome(base: Base? = null) { .sizeIn(minWidth = 350.dp) .padding(end = 12.dp) ) { - val examples = listOf( + val examples = remember { mutableStateListOf( Example(Platform.getContentFile("modes/java/examples/Basics/Arrays/Array")), Example(Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective")), Example(Platform.getContentFile("modes/java/examples/Basics/Color/Brightness")), Example(Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ")), - ) + )} + + remember { + val sketches = mutableListOf() + val sketchFolders = listAllExamples() + fun gatherSketches(folder: processing.app.api.Sketch.Companion.Folder?) { + if (folder == null) return + sketches.addAll(folder.sketches.filter { it -> Path(it.path).resolve("${it.name}.png").exists() }) + folder.children.forEach { child -> + gatherSketches(child) + } + } + sketchFolders.forEach { folder -> + gatherSketches(folder) + } + if(sketches.isEmpty()) { + return@remember + } + + val newExamples = sketches.shuffled().take(20).map { sketch -> + Example(File(sketch.path)) + } + examples.clear() + examples.addAll(newExamples) + } + LazyColumn( state = rememberLazyListState( initialFirstVisibleItemScrollOffset = 150 @@ -467,8 +481,7 @@ fun PDEWelcome(base: Base? = null) { Image( painter = BitmapPainter(imageBitmap), modifier = Modifier -// .fillMaxSize() - , + .fillMaxSize(), contentDescription = example.path.name ) } From 0ee8929a859bb02eebfb38cbe8d512f171755239 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 12:27:59 +0100 Subject: [PATCH 26/39] Refactor example handling to use Sketch objects Replaces Example objects with Sketch objects for managing example sketches in the welcome screen. Updates all relevant usages to reference Sketch properties, simplifying the code and improving clarity. --- app/src/processing/app/ui/PDEWelcome.kt | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 66f091abee..371febc49f 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -405,10 +405,10 @@ fun PDEWelcome(base: Base? = null) { .padding(end = 12.dp) ) { val examples = remember { mutableStateListOf( - Example(Platform.getContentFile("modes/java/examples/Basics/Arrays/Array")), - Example(Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective")), - Example(Platform.getContentFile("modes/java/examples/Basics/Color/Brightness")), - Example(Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ")), + Sketch(name = "Array", path = Platform.getContentFile("modes/java/examples/Basics/Arrays/Array").absolutePath), + Sketch(name = "Perspective", path = Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective").absolutePath), + Sketch(name = "Brightness", path = Platform.getContentFile("modes/java/examples/Basics/Color/Brightness").absolutePath), + Sketch(name = "LoadDisplayOBJ", path = Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ").absolutePath), )} remember { @@ -427,12 +427,8 @@ fun PDEWelcome(base: Base? = null) { if(sketches.isEmpty()) { return@remember } - - val newExamples = sketches.shuffled().take(20).map { sketch -> - Example(File(sketch.path)) - } examples.clear() - examples.addAll(newExamples) + examples.addAll(sketches.shuffled().take(20)) } LazyColumn( @@ -460,8 +456,7 @@ fun PDEWelcome(base: Base? = null) { } ){ val image = remember { - val name = example.path.name - File(example.path,"$name.png").takeIf { it.exists() } + File(example.path,"${example.name}.png").takeIf { it.exists() } } if(image == null){ Icon( @@ -482,7 +477,7 @@ fun PDEWelcome(base: Base? = null) { painter = BitmapPainter(imageBitmap), modifier = Modifier .fillMaxSize(), - contentDescription = example.path.name + contentDescription = example.name ) } Column( @@ -519,7 +514,7 @@ fun PDEWelcome(base: Base? = null) { .padding(start = 12.dp) ) { Text( - text = example.path.name, + text = example.name, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier @@ -528,7 +523,7 @@ fun PDEWelcome(base: Base? = null) { Button( onClick = { base?.let { - base.handleOpen(example.path.resolve("${example.path.name}.pde").absolutePath) + base.handleOpen("${example.path}/${example.name}.pde") } ?: noBaseWarning() }, colors = ButtonDefaults.buttonColors( From 8e32abf8ee4c95d096574638a265946494c5050f Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 13:02:00 +0100 Subject: [PATCH 27/39] Add vertical scrollbar to welcome screen examples Introduces a VerticalScrollbar to the examples list in the PDEWelcome screen for improved navigation. Also adjusts spacing and arrangement in several UI components for better layout consistency, and updates the welcome screen title in the language properties. --- app/src/processing/app/ui/PDEWelcome.kt | 231 ++++++++++++---------- build/shared/lib/languages/PDE.properties | 3 +- 2 files changed, 132 insertions(+), 102 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 371febc49f..d848ab3e35 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Language @@ -209,7 +211,7 @@ fun PDEWelcome(base: Base? = null) { colors = colors, modifier = medModifier ) { - Icon(Icons.Outlined.FolderOpen, contentDescription = "", modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.FolderOpen, contentDescription = "") Spacer(Modifier.width(12.dp)) Text(locale["sketchbook"], modifier = Modifier.align(Alignment.CenterVertically)) } @@ -226,7 +228,7 @@ fun PDEWelcome(base: Base? = null) { ) ){ Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.spacedBy(48.dp), modifier = Modifier .padding( top = 18.dp, @@ -239,7 +241,9 @@ fun PDEWelcome(base: Base? = null) { contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) ProvideTextStyle(MaterialTheme.typography.labelLarge) { - Column { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = locale["welcome.resources.title"], style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), @@ -288,18 +292,22 @@ fun PDEWelcome(base: Base? = null) { ) } } - Column { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = locale["welcome.community.title"], style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 8.dp) ) Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.spacedBy(48.dp), modifier = Modifier .fillMaxWidth() ) { - Column { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { TextButton( onClick = { Platform.openURL("https://discourse.processing.org") @@ -335,7 +343,9 @@ fun PDEWelcome(base: Base? = null) { Text("Discord") } } - Column { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { TextButton( onClick = { Platform.openURL("https://www.instagram.com/processing_core/") @@ -391,7 +401,9 @@ fun PDEWelcome(base: Base? = null) { onCheckedChange = ::toggle, colors = CheckboxDefaults.colors( checkedColor = MaterialTheme.colorScheme.tertiary - ) + ), + modifier = Modifier + .defaultMinSize(minHeight = 1.dp) ) Text( text = locale["welcome.actions.show_startup"], @@ -402,7 +414,6 @@ fun PDEWelcome(base: Base? = null) { } Column(modifier = Modifier .sizeIn(minWidth = 350.dp) - .padding(end = 12.dp) ) { val examples = remember { mutableStateListOf( Sketch(name = "Array", path = Platform.getContentFile("modes/java/examples/Basics/Arrays/Array").absolutePath), @@ -430,111 +441,125 @@ fun PDEWelcome(base: Base? = null) { examples.clear() examples.addAll(sketches.shuffled().take(20)) } - - LazyColumn( - state = rememberLazyListState( - initialFirstVisibleItemScrollOffset = 150 - ), + val state = rememberLazyListState( + initialFirstVisibleItemScrollOffset = 150 + ) + Box( modifier = Modifier - .width(350.dp), - contentPadding = PaddingValues(vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .width(350.dp) + .padding(end = 4.dp) ) { - items(examples) { example -> - var hovered by remember { mutableStateOf(false) } - Box(Modifier - .border(BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant), shape = MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium) - .clip(MaterialTheme.shapes.medium) - .fillMaxSize() - .aspectRatio(16 / 9f) - .onPointerEvent(PointerEventType.Enter){ - hovered = true - } - .onPointerEvent(PointerEventType.Exit){ - hovered = false - } - ){ - val image = remember { - File(example.path,"${example.name}.png").takeIf { it.exists() } - } - if(image == null){ - Icon( - painter = painterResource("logo.svg"), - modifier = Modifier - .size(75.dp) - .align(Alignment.Center) - , - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - contentDescription = "Processing Logo" + LazyColumn( + state = state, + contentPadding = PaddingValues(top = 12.dp, bottom = 12.dp, end = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(examples) { example -> + var hovered by remember { mutableStateOf(false) } + Box( + Modifier + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium ) - HorizontalDivider() - }else { - val imageBitmap: ImageBitmap = remember(image) { - image.inputStream().readAllBytes().decodeToImageBitmap() - } - Image( - painter = BitmapPainter(imageBitmap), - modifier = Modifier - .fillMaxSize(), - contentDescription = example.name + .background( + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium ) - } - Column( - modifier = Modifier.align(Alignment.BottomCenter), + .clip(MaterialTheme.shapes.medium) + .fillMaxSize() + .aspectRatio(16 / 9f) + .onPointerEvent(PointerEventType.Enter) { + hovered = true + } + .onPointerEvent(PointerEventType.Exit) { + hovered = false + } ) { - val duration = 150 - AnimatedVisibility( - visible = hovered, - enter = slideIn( - initialOffset = { fullSize -> IntOffset(0, fullSize.height) }, - animationSpec = tween( - durationMillis = duration, - easing = EaseInOut - ) - ), - exit = slideOut ( - targetOffset = { fullSize -> IntOffset(0, fullSize.height) }, - animationSpec = tween( - durationMillis = duration, - easing = LinearEasing - ) + val image = remember { + File(example.path, "${example.name}.png").takeIf { it.exists() } + } + if (image == null) { + Icon( + painter = painterResource("logo.svg"), + modifier = Modifier + .size(75.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + contentDescription = "Processing Logo" ) - ) { - Card( + HorizontalDivider() + } else { + val imageBitmap: ImageBitmap = remember(image) { + image.inputStream().readAllBytes().decodeToImageBitmap() + } + Image( + painter = BitmapPainter(imageBitmap), modifier = Modifier + .fillMaxSize(), + contentDescription = example.name + ) + } + Column( + modifier = Modifier.align(Alignment.BottomCenter), + ) { + val duration = 150 + AnimatedVisibility( + visible = hovered, + enter = slideIn( + initialOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = EaseInOut + ) + ), + exit = slideOut( + targetOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = LinearEasing + ) + ) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Card( modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .padding(12.dp) - .padding(start = 12.dp) ) { - Text( - text = example.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier - .padding(8.dp) - ) - Button( - onClick = { - base?.let { - base.handleOpen("${example.path}/${example.name}.pde") - } ?: noBaseWarning() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary - ) + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(12.dp) + .padding(start = 12.dp) ) { Text( - text = locale["welcome.sketch.open"], - style = MaterialTheme.typography.bodyLarge + text = example.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(8.dp) ) + Button( + onClick = { + base?.let { + base.handleOpen("${example.path}/${example.name}.pde") + } ?: noBaseWarning() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary + ), + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 4.dp + ), + ) { + Text( + text = locale["welcome.sketch.open"], + style = MaterialTheme.typography.bodyLarge + ) + } } } } @@ -542,6 +567,12 @@ fun PDEWelcome(base: Base? = null) { } } } + VerticalScrollbar( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(state) + ) } } } diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index c4a96dcd96..8001796f59 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -644,12 +644,11 @@ color_chooser.select = Select # --------------------------------------- # Welcome Screen welcome.processing.logo = Processing Logo -welcome.processing.title = Welcome to Processing! +welcome.processing.title = Welcome to Processing welcome.actions.sketch.new = Empty Sketch welcome.actions.examples = Open Examples welcome.actions.show_startup = Show this window at startup welcome.resources.title = Resources -welcome.resources.video = Video Course welcome.resources.get_started = Get Started welcome.resources.tutorials = Tutorials welcome.resources.documentation = Reference From 2769a07637ac0e66a197e1d941aeae9b6e7716ac Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 13:20:16 +0100 Subject: [PATCH 28/39] Add rounded corners to buttons in PDEWelcome Introduced a RoundedCornerShape with 12.dp radius and applied it to various buttons in the PDEWelcome screen for improved UI consistency and aesthetics. --- app/src/processing/app/ui/PDEWelcome.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index d848ab3e35..9597f1556f 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Language @@ -103,6 +104,7 @@ fun PDEWelcome(base: Base? = null) { .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainerLow), ){ + val shape = RoundedCornerShape(12.dp) val xsPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) val xsModifier = Modifier .defaultMinSize(minHeight = 1.dp) @@ -149,7 +151,8 @@ fun PDEWelcome(base: Base? = null) { showLanguageMenu.value = !showLanguageMenu.value }, contentPadding = xsPadding, - modifier = xsModifier + modifier = xsModifier, + shape = shape ){ Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) Spacer(Modifier.width(4.dp)) @@ -183,7 +186,8 @@ fun PDEWelcome(base: Base? = null) { base?.handleNew() ?: noBaseWarning() }, colors = colors, - modifier = medModifier + modifier = medModifier, + shape = shape ) { Icon(Icons.Outlined.NoteAdd, contentDescription = "") Spacer(Modifier.width(12.dp)) @@ -196,7 +200,8 @@ fun PDEWelcome(base: Base? = null) { } ?: noBaseWarning() }, colors = colors, - modifier = medModifier + modifier = medModifier, + shape = shape ) { Icon(Icons.Outlined.FolderSpecial, contentDescription = "") Spacer(Modifier.width(12.dp)) @@ -209,7 +214,8 @@ fun PDEWelcome(base: Base? = null) { } ?: noBaseWarning() }, colors = colors, - modifier = medModifier + modifier = medModifier, + shape = shape ) { Icon(Icons.Outlined.FolderOpen, contentDescription = "") Spacer(Modifier.width(12.dp)) From 3b5ea1dd33122853cb61599559e24174e76195f5 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 14:45:42 +0100 Subject: [PATCH 29/39] Refactor PDEWelcome UI and add Sketch card composable Refactored the PDEWelcome screen for improved structure and readability, including extracting the example preview into a reusable Sketch.card composable. Updated icon usage for RTL support, adjusted layout and padding, and improved the examples list initialization. Also, customized scrollbar style in PDETheme for a more consistent UI appearance. --- app/src/processing/app/ui/PDEWelcome.kt | 436 ++++++++++++----------- app/src/processing/app/ui/theme/Theme.kt | 28 +- 2 files changed, 239 insertions(+), 225 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index 9597f1556f..e469a9eaff 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -36,13 +36,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.NoteAdd import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderSpecial -import androidx.compose.material.icons.outlined.NoteAdd import androidx.compose.material.icons.outlined.PinDrop import androidx.compose.material.icons.outlined.School import androidx.compose.material3.Button @@ -87,12 +87,12 @@ import processing.app.api.Contributions.ExamplesList.Companion.listAllExamples import processing.app.api.Sketch.Companion.Sketch import processing.app.ui.preferences.Interface.Companion.languagesDropdown import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.Locale import processing.app.ui.theme.PDEComposeWindow import processing.app.ui.theme.PDESwingWindow import processing.app.ui.theme.PDETheme import processing.app.ui.theme.toDimension import java.io.File -import kotlin.concurrent.thread import kotlin.io.path.Path import kotlin.io.path.exists @@ -112,6 +112,9 @@ fun PDEWelcome(base: Base? = null) { val textColor = if(isSystemInDarkTheme()) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer val locale = LocalLocale.current + /** + * Left main column + */ Column( verticalArrangement = Arrangement.SpaceBetween, modifier = Modifier @@ -124,6 +127,9 @@ fun PDEWelcome(base: Base? = null) { bottom = 56.dp ) ) { + /** + * Title row + */ Row ( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() @@ -142,7 +148,9 @@ fun PDEWelcome(base: Base? = null) { .align(Alignment.CenterVertically) ) Row( - modifier = Modifier.fillMaxWidth().align(Alignment.CenterVertically), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), horizontalArrangement = Arrangement.End, ){ val showLanguageMenu = remember { mutableStateOf(false) } @@ -160,70 +168,63 @@ fun PDEWelcome(base: Base? = null) { Icon(Icons.Default.ArrowDropDown, contentDescription = "", modifier = Modifier.size(20.dp)) languagesDropdown(showLanguageMenu) } - } - } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - ) { - val colors = ButtonDefaults.textButtonColors( - contentColor = textColor - ) - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxHeight() - ) { - ProvideTextStyle(MaterialTheme.typography.titleMedium) { - val medModifier = Modifier - .sizeIn(minHeight = 56.dp) - TextButton( - onClick = { - base?.handleNew() ?: noBaseWarning() - }, - colors = colors, - modifier = medModifier, - shape = shape - ) { - Icon(Icons.Outlined.NoteAdd, contentDescription = "") - Spacer(Modifier.width(12.dp)) - Text(locale["welcome.actions.sketch.new"]) - } - TextButton( - onClick = { - base?.let{ - base.showExamplesFrame() - } ?: noBaseWarning() - }, - colors = colors, - modifier = medModifier, - shape = shape - ) { - Icon(Icons.Outlined.FolderSpecial, contentDescription = "") - Spacer(Modifier.width(12.dp)) - Text(locale["welcome.actions.examples"] ) - } - TextButton( - onClick = { - base?.let{ - base.showSketchbookFrame() - } ?: noBaseWarning() - }, - colors = colors, - modifier = medModifier, - shape = shape - ) { - Icon(Icons.Outlined.FolderOpen, contentDescription = "") - Spacer(Modifier.width(12.dp)) - Text(locale["sketchbook"], modifier = Modifier.align(Alignment.CenterVertically)) - } + /** + * New sketch, examples, sketchbook card + */ + val colors = ButtonDefaults.textButtonColors( + contentColor = textColor + ) + Column{ + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + val medModifier = Modifier + .sizeIn(minHeight = 56.dp) + TextButton( + onClick = { + base?.handleNew() ?: noBaseWarning() + }, + colors = colors, + modifier = medModifier, + shape = shape + ) { + Icon(Icons.AutoMirrored.Outlined.NoteAdd, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text(locale["welcome.actions.sketch.new"]) + } + TextButton( + onClick = { + base?.let{ + base.showExamplesFrame() + } ?: noBaseWarning() + }, + colors = colors, + modifier = medModifier, + shape = shape + ) { + Icon(Icons.Outlined.FolderSpecial, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text(locale["welcome.actions.examples"] ) + } + TextButton( + onClick = { + base?.let{ + base.showSketchbookFrame() + } ?: noBaseWarning() + }, + colors = colors, + modifier = medModifier, + shape = shape + ) { + Icon(Icons.Outlined.FolderOpen, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text(locale["sketchbook"], modifier = Modifier.align(Alignment.CenterVertically)) } } } + /** + * Resources and community card + */ Card( modifier = Modifier .fillMaxWidth() @@ -354,7 +355,7 @@ fun PDEWelcome(base: Base? = null) { ) { TextButton( onClick = { - Platform.openURL("https://www.instagram.com/processing_core/") + Platform.openURL("https://github.com/processing/processing4") }, contentPadding = xsPadding, modifier = xsModifier, @@ -370,7 +371,7 @@ fun PDEWelcome(base: Base? = null) { } TextButton( onClick = { - Platform.openURL("https://github.com/processing/processing4") + Platform.openURL("https://www.instagram.com/processing_core/") }, contentPadding = xsPadding, modifier = xsModifier, @@ -390,42 +391,54 @@ fun PDEWelcome(base: Base? = null) { } } } - val preferences = LocalPreferences.current - val showOnStartup = preferences["welcome.four.show"].toBoolean() - fun toggle(next: Boolean? = null){ - preferences["welcome.four.show"] = (next ?: !showOnStartup).toString() - } - Row( - modifier = Modifier - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = ::toggle) - .padding(end = 8.dp) - .height(32.dp) - ) { - Checkbox( - checked = showOnStartup, - onCheckedChange = ::toggle, - colors = CheckboxDefaults.colors( - checkedColor = MaterialTheme.colorScheme.tertiary - ), + /** + * Show on startup checkbox + */ + Row{ + val preferences = LocalPreferences.current + val showOnStartup = preferences["welcome.four.show"].toBoolean() + fun toggle(next: Boolean? = null) { + preferences["welcome.four.show"] = (next ?: !showOnStartup).toString() + } + Row( modifier = Modifier - .defaultMinSize(minHeight = 1.dp) - ) - Text( - text = locale["welcome.actions.show_startup"], - modifier = Modifier.align(Alignment.CenterVertically), - style = MaterialTheme.typography.labelLarge - ) + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = ::toggle) + .padding(end = 8.dp) + .height(32.dp) + ) { + Checkbox( + checked = showOnStartup, + onCheckedChange = ::toggle, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.tertiary + ), + modifier = Modifier + .defaultMinSize(minHeight = 1.dp) + ) + Text( + text = locale["welcome.actions.show_startup"], + modifier = Modifier.align(Alignment.CenterVertically), + style = MaterialTheme.typography.labelLarge + ) + } } } - Column(modifier = Modifier - .sizeIn(minWidth = 350.dp) + /** + * Examples list + */ + val scrollMargin = 35.dp + Column( + modifier = Modifier + .width(350.dp + scrollMargin) ) { val examples = remember { mutableStateListOf( - Sketch(name = "Array", path = Platform.getContentFile("modes/java/examples/Basics/Arrays/Array").absolutePath), - Sketch(name = "Perspective", path = Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective").absolutePath), - Sketch(name = "Brightness", path = Platform.getContentFile("modes/java/examples/Basics/Color/Brightness").absolutePath), - Sketch(name = "LoadDisplayOBJ", path = Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ").absolutePath), + *listOf( + Platform.getContentFile("modes/java/examples/Basics/Arrays/Array"), + Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective"), + Platform.getContentFile("modes/java/examples/Basics/Color/Brightness"), + Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ") + ).map{ Sketch(path = it.absolutePath, name = it.name) }.toTypedArray() )} remember { @@ -452,124 +465,18 @@ fun PDEWelcome(base: Base? = null) { ) Box( modifier = Modifier - .width(350.dp) .padding(end = 4.dp) ) { LazyColumn( state = state, - contentPadding = PaddingValues(top = 12.dp, bottom = 12.dp, end = 20.dp), + contentPadding = PaddingValues(top = 12.dp, bottom = 12.dp, end = 20.dp, start = scrollMargin), verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(examples) { example -> - var hovered by remember { mutableStateOf(false) } - Box( - Modifier - .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - shape = MaterialTheme.shapes.medium - ) - .background( - MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium - ) - .clip(MaterialTheme.shapes.medium) - .fillMaxSize() - .aspectRatio(16 / 9f) - .onPointerEvent(PointerEventType.Enter) { - hovered = true - } - .onPointerEvent(PointerEventType.Exit) { - hovered = false - } - ) { - val image = remember { - File(example.path, "${example.name}.png").takeIf { it.exists() } - } - if (image == null) { - Icon( - painter = painterResource("logo.svg"), - modifier = Modifier - .size(75.dp) - .align(Alignment.Center), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - contentDescription = "Processing Logo" - ) - HorizontalDivider() - } else { - val imageBitmap: ImageBitmap = remember(image) { - image.inputStream().readAllBytes().decodeToImageBitmap() - } - Image( - painter = BitmapPainter(imageBitmap), - modifier = Modifier - .fillMaxSize(), - contentDescription = example.name - ) - } - Column( - modifier = Modifier.align(Alignment.BottomCenter), - ) { - val duration = 150 - AnimatedVisibility( - visible = hovered, - enter = slideIn( - initialOffset = { fullSize -> IntOffset(0, fullSize.height) }, - animationSpec = tween( - durationMillis = duration, - easing = EaseInOut - ) - ), - exit = slideOut( - targetOffset = { fullSize -> IntOffset(0, fullSize.height) }, - animationSpec = tween( - durationMillis = duration, - easing = LinearEasing - ) - ) - ) { - Card( - modifier = Modifier - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .padding(12.dp) - .padding(start = 12.dp) - ) { - Text( - text = example.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(8.dp) - ) - Button( - onClick = { - base?.let { - base.handleOpen("${example.path}/${example.name}.pde") - } ?: noBaseWarning() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary - ), - contentPadding = PaddingValues( - horizontal = 12.dp, - vertical = 4.dp - ), - ) { - Text( - text = locale["welcome.sketch.open"], - style = MaterialTheme.typography.bodyLarge - ) - } - } - } - } - } + example.card{ + base?.let { + base.handleOpen("${example.path}/${example.name}.pde") + } ?: noBaseWarning() } } } @@ -584,9 +491,118 @@ fun PDEWelcome(base: Base? = null) { } } -data class Example( - val path: File, -) +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun Sketch.card(onOpen: () -> Unit = {}) { + val locale = LocalLocale.current + val sketch = this + var hovered by remember { mutableStateOf(false) } + Box( + Modifier + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium + ) + .background( + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium + ) + .clip(MaterialTheme.shapes.medium) + .fillMaxSize() + .aspectRatio(16 / 9f) + .onPointerEvent(PointerEventType.Enter) { + hovered = true + } + .onPointerEvent(PointerEventType.Exit) { + hovered = false + } + ) { + val image = remember { + File(sketch.path, "${sketch.name}.png").takeIf { it.exists() } + } + if (image == null) { + Icon( + painter = painterResource("logo.svg"), + modifier = Modifier + .size(75.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + contentDescription = "Processing Logo" + ) + HorizontalDivider() + } else { + val imageBitmap: ImageBitmap = remember(image) { + image.inputStream().readAllBytes().decodeToImageBitmap() + } + Image( + painter = BitmapPainter(imageBitmap), + modifier = Modifier + .fillMaxSize(), + contentDescription = sketch.name + ) + } + Column( + modifier = Modifier.align(Alignment.BottomCenter), + ) { + val duration = 150 + AnimatedVisibility( + visible = hovered, + enter = slideIn( + initialOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = EaseInOut + ) + ), + exit = slideOut( + targetOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = LinearEasing + ) + ) + ) { + Card( + modifier = Modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(12.dp) + .padding(start = 12.dp) + ) { + Text( + text = sketch.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(8.dp) + ) + Button( + onClick = onOpen, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary + ), + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 4.dp + ), + ) { + Text( + text = locale["welcome.sketch.open"], + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + } +} fun noBaseWarning() { Messages.showWarning( @@ -596,7 +612,7 @@ fun noBaseWarning() { } val size = DpSize(970.dp, 600.dp) -val titleKey = "menu.help.welcome" +const val titleKey = "menu.help.welcome" fun showWelcomeScreen(base: Base? = null) { PDESwingWindow(titleKey = titleKey, size = size.toDimension(), fullWindowContent = true) { diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 7790aba776..cebe2c39de 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -1,6 +1,9 @@ package processing.app.ui.theme +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.background +import androidx.compose.foundation.defaultScrollbarStyle import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,6 +12,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding 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.Map @@ -48,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.Density @@ -98,6 +103,14 @@ fun PDETheme( ){ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = MaterialTheme.shapes.extraSmall, + hoverDurationMillis = 300, + unhoverColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + hoverColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f) + ), LocalContentColor provides MaterialTheme.colorScheme.onSurface, // LocalDensity provides Density(1.25f, 1.25f), content = content @@ -161,21 +174,6 @@ fun main() { ) Column { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { -// Button( -// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), -// onClick = {}) { -// Text("Primary", color = MaterialTheme.colorScheme.onPrimary) -// } -// Button( -// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), -// onClick = {}) { -// Text("Secondary", color = MaterialTheme.colorScheme.onSecondary) -// } -// Button( -// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), -// onClick = {}) { -// Text("Tertiary", color = MaterialTheme.colorScheme.onTertiary) -// } val section = colors.subList(0,3) for((name, color, onColor) in section){ Button( From bef00f90459242891da2e0bdf73ded955b88de8d Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 15:05:36 +0100 Subject: [PATCH 30/39] Add unique window handling to prevent duplicates Introduces a 'unique' parameter to PDESwingWindow and PDEComposeWindow, allowing windows to be identified by a KClass and preventing multiple instances of the same window. If a window with the same unique identifier exists, it is brought to the front and the new one is disposed. This helps avoid duplicate welcome or other singleton windows. --- app/src/processing/app/ui/PDEWelcome.kt | 8 +++- app/src/processing/app/ui/theme/Window.kt | 53 +++++++++++++++++------ 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index e469a9eaff..d9cae78f15 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -613,9 +613,15 @@ fun noBaseWarning() { val size = DpSize(970.dp, 600.dp) const val titleKey = "menu.help.welcome" +class WelcomeScreen fun showWelcomeScreen(base: Base? = null) { - PDESwingWindow(titleKey = titleKey, size = size.toDimension(), fullWindowContent = true) { + PDESwingWindow( + titleKey = titleKey, + size = size.toDimension(), + unique = WelcomeScreen::class, + fullWindowContent = true + ) { PDEWelcome(base) } } diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index 98a4e00807..dad06087da 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -5,28 +5,24 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect 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.awt.ComposeWindow import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo import java.awt.Dimension -import java.awt.event.KeyAdapter -import java.awt.event.KeyEvent import javax.swing.JFrame -import javax.swing.UIManager +import kotlin.reflect.KClass val LocalWindow = compositionLocalOf { error("No Window Set") } @@ -48,6 +44,8 @@ val LocalWindow = compositionLocalOf { error("No Window Set") } * @param size The desired size of the window. If null, the window will use its default size. * @param minSize The minimum size of the window. If null, no minimum size is set. * @param maxSize The maximum size of the window. If null, no maximum size is set. + * @param unique An optional unique identifier for the window to prevent duplicates. + * @param onClose A lambda function to be called when the window is requested to close. * @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. */ @@ -56,6 +54,7 @@ class PDESwingWindow( size: Dimension? = null, minSize: Dimension? = null, maxSize: Dimension? = null, + unique: KClass<*>? = null, fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable () -> Unit @@ -75,7 +74,13 @@ class PDESwingWindow( } setLocationRelativeTo(null) setContent { - PDEWindowContent(window, titleKey, fullWindowContent, content) + PDEWindowContent( + window = window, + titleKey = titleKey, + unique = unique, + fullWindowContent = fullWindowContent, + content = content + ) } window.addWindowStateListener { if(it.newState == JFrame.DISPOSE_ON_CLOSE){ @@ -87,12 +92,15 @@ class PDESwingWindow( } } +private val windows = mutableMapOf, ComposeWindow>() + /** * 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 unique An optional unique identifier for the window to prevent duplicates. * @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. */ @@ -100,6 +108,7 @@ class PDESwingWindow( private fun PDEWindowContent( window: ComposeWindow, titleKey: String, + unique: KClass<*>? = null, fullWindowContent: Boolean = false, content: @Composable () -> Unit ){ @@ -108,6 +117,20 @@ private fun PDEWindowContent( window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) window.rootPane.putClientProperty("apple.awt.transparentTitleBar", mac && fullWindowContent) } + if(unique != null && windows.contains(unique) && windows[unique] != null){ + windows[unique]?.toFront() + window.dispose() + return + } + + DisposableEffect(unique){ + unique?.let { + windows[it] = window + } + onDispose { + windows.remove(unique) + } + } CompositionLocalProvider(LocalWindow provides window) { PDETheme{ @@ -148,13 +171,10 @@ private fun PDEWindowContent( * fullscreen if it contains any of [fillMaxWidth]/[fillMaxSize]/[fillMaxHeight] etc. * @param minSize The minimum size of the window. Defaults to unspecified size which means no minimum size is set. * @param maxSize The maximum size of the window. Defaults to unspecified size which means no maximum size is set. - * @param fullWindowContent If true, the content will extend into the title bar area on - * macOS. + * @param unique An optional unique identifier for the window to prevent duplicates. + * @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( @@ -162,6 +182,7 @@ fun PDEComposeWindow( size: DpSize = DpSize.Unspecified, minSize: DpSize = DpSize.Unspecified, maxSize: DpSize = DpSize.Unspecified, + unique: KClass<*>? = null, fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable () -> Unit @@ -175,7 +196,13 @@ fun PDEComposeWindow( window.minimumSize = minSize.toDimension() window.maximumSize = maxSize.toDimension() } - PDEWindowContent(window, titleKey, fullWindowContent, content) + PDEWindowContent( + window = window, + titleKey = titleKey, + unique = unique, + fullWindowContent = fullWindowContent, + content = content + ) } } From 07e05be938b3a98f8b5a02b8ce5f5787ef462fc9 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 16:28:30 +0100 Subject: [PATCH 31/39] Refactor dialog handling and improve AlertDialog UI Refactored the showDialog function to accept a modifier and updated all AlertDialog usages to use RectangleShape and the modifier parameter. Improved dialog sizing and positioning by dynamically adjusting the window size based on content, and set additional window properties for better integration on macOS. --- app/src/processing/app/Messages.kt | 52 ++++++++++++++++++------------ 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/app/src/processing/app/Messages.kt b/app/src/processing/app/Messages.kt index 05bada362c..e8f0979454 100644 --- a/app/src/processing/app/Messages.kt +++ b/app/src/processing/app/Messages.kt @@ -19,23 +19,19 @@ package processing.app import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Alarm import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -46,14 +42,13 @@ import com.formdev.flatlaf.FlatLightLaf import processing.app.ui.Toolkit import processing.app.ui.theme.PDETheme import java.awt.Dimension -import java.awt.EventQueue import java.awt.Frame import java.io.PrintWriter import java.io.StringWriter -import javax.swing.JFrame import javax.swing.JOptionPane import javax.swing.UIManager + class Messages { companion object { /** @@ -65,9 +60,11 @@ class Messages { if (Base.isCommandLine()) { println("$title: $message") } else { - showDialog(title) { dismiss -> + showDialog(title) { modifier, dismiss -> AlertDialog( + modifier = modifier, onDismissRequest = { }, + shape = RectangleShape, icon = { Icon(Icons.Default.Info, contentDescription = "Info!") }, title = { Text(title) }, text = { Text(message) }, @@ -96,9 +93,11 @@ class Messages { if (Base.isCommandLine()) { println("$title: $message") } else { - showDialog(title){ dismiss -> + showDialog(title){ modifier, dismiss -> AlertDialog( + modifier = modifier, onDismissRequest = { }, + shape = RectangleShape, icon = { Icon(Icons.Default.Warning, contentDescription = "Alert!") }, iconContentColor = MaterialTheme.colorScheme.tertiary, title = { Text(title) }, @@ -134,9 +133,11 @@ class Messages { // proper parsing on the command line. Many have \n in them. println("$title: $primary\n$secondary") } else { - showDialog(title){ dismiss -> + showDialog(title){ modifier, dismiss -> AlertDialog( + modifier = modifier, onDismissRequest = { }, + shape = RectangleShape, icon = { Icon(Icons.Default.Warning, contentDescription = "Alert!") }, iconContentColor = MaterialTheme.colorScheme.tertiary, title = { Text(title) }, @@ -173,9 +174,11 @@ class Messages { if (Base.isCommandLine()) { System.err.println("$title: $message") } else { - showDialog(title){ dismiss -> + showDialog(title){ modifier, dismiss -> AlertDialog( + modifier = modifier, onDismissRequest = { }, + shape = RectangleShape, icon = { Icon(Icons.Default.Error, contentDescription = "Alert!") }, iconContentColor = MaterialTheme.colorScheme.error, title = { Text(title) }, @@ -360,17 +363,24 @@ class Messages { } } -fun showDialog(title: String, content: @Composable (dismiss: () -> Unit) -> Unit) { +@OptIn(ExperimentalComposeUiApi::class) +fun showDialog(title: String, content: @Composable (modifier: Modifier, dismiss: () -> Unit) -> Unit) { ComposeDialog().apply { -// isUndecorated = true -// isTransparent = true isModal = true setTitle(title) - size = Dimension(400, 200) + size = Dimension(400, 400) + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + rootPane.putClientProperty("apple.awt.windowTitleVisible", false); + - setContent { + setContent { PDETheme { - content(::dispose) + val density = LocalDensity.current + content(Modifier.onSizeChanged{ + size = Dimension((it.width / density.density).toInt(), (it.height / density.density).toInt()) + setLocationRelativeTo(null) + },::dispose) } } setLocationRelativeTo(null) From 3bdd00ee537aedcc96a8a42368ffebe5123ca2e0 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Tue, 28 Oct 2025 20:28:31 +0100 Subject: [PATCH 32/39] Set application window icon using Toolkit.setIcon Added calls to Toolkit.setIcon(window) in Start.kt and Window.kt to ensure the application window icon is set consistent --- app/src/processing/app/ui/Start.kt | 2 ++ app/src/processing/app/ui/theme/Window.kt | 3 +++ 2 files changed, 5 insertions(+) diff --git a/app/src/processing/app/ui/Start.kt b/app/src/processing/app/ui/Start.kt index 7de371eec4..d7ed635ecf 100644 --- a/app/src/processing/app/ui/Start.kt +++ b/app/src/processing/app/ui/Start.kt @@ -46,6 +46,8 @@ class Start { var visible by remember { mutableStateOf(false) } val composition = rememberCoroutineScope() LaunchedEffect(Unit) { + Toolkit.setIcon(window) + visible = true composition.launch { delay(duration.toLong() + timeMargin) diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index dad06087da..f725a999b5 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -19,9 +19,11 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo +import processing.app.ui.Toolkit import java.awt.Dimension import javax.swing.JFrame +import javax.swing.JRootPane import kotlin.reflect.KClass val LocalWindow = compositionLocalOf { error("No Window Set") } @@ -116,6 +118,7 @@ private fun PDEWindowContent( remember { window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) window.rootPane.putClientProperty("apple.awt.transparentTitleBar", mac && fullWindowContent) + Toolkit.setIcon(window) } if(unique != null && windows.contains(unique) && windows[unique] != null){ windows[unique]?.toFront() From 65173d47abdb0a05934931d6e755574589382c67 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 29 Oct 2025 07:01:39 +0100 Subject: [PATCH 33/39] Simplify imports and update scrollbar colors in Theme.kt Consolidated import statements for Compose libraries using wildcard imports to reduce verbosity. Updated scrollbar hover and unhover colors to use the default outlineVariant color without alpha modification. --- app/src/processing/app/ui/theme/Theme.kt | 57 +++--------------------- 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index cebe2c39de..c59c5025cd 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -1,61 +1,16 @@ package processing.app.ui.theme -import androidx.compose.foundation.LocalScrollbarStyle -import androidx.compose.foundation.ScrollbarStyle -import androidx.compose.foundation.background -import androidx.compose.foundation.defaultScrollbarStyle -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Map -import androidx.compose.material3.AssistChip -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.Text -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.RangeSlider -import androidx.compose.material3.Slider -import androidx.compose.material3.Switch -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TriStateCheckbox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Color.Companion import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.state.ToggleableState -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window @@ -108,8 +63,8 @@ fun PDETheme( thickness = 8.dp, shape = MaterialTheme.shapes.extraSmall, hoverDurationMillis = 300, - unhoverColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - hoverColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f) + unhoverColor = MaterialTheme.colorScheme.outlineVariant, + hoverColor = MaterialTheme.colorScheme.outlineVariant ), LocalContentColor provides MaterialTheme.colorScheme.onSurface, // LocalDensity provides Density(1.25f, 1.25f), From 0ce1687abf5a0660d4dc15dafffd389fd461ab32 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 3 Nov 2025 12:01:42 +0100 Subject: [PATCH 34/39] Preferences screen Adds most of the options for the preferences screen based on the new design --- app/src/processing/app/Base.java | 33 +- app/src/processing/app/Preferences.kt | 12 +- app/src/processing/app/ui/EditorFooter.java | 51 +- app/src/processing/app/ui/PDEPreferences.kt | 544 ++++++++++++++++++ app/src/processing/app/ui/Preferences.kt | 323 ----------- .../processing/app/ui/preferences/Coding.kt | 69 +++ .../processing/app/ui/preferences/General.kt | 93 +-- .../app/ui/preferences/Interface.kt | 236 ++++---- .../processing/app/ui/preferences/Other.kt | 109 ++-- .../processing/app/ui/preferences/Sketches.kt | 146 +++++ build/shared/lib/languages/PDE.properties | 39 +- 11 files changed, 1096 insertions(+), 559 deletions(-) create mode 100644 app/src/processing/app/ui/PDEPreferences.kt delete mode 100644 app/src/processing/app/ui/Preferences.kt create mode 100644 app/src/processing/app/ui/preferences/Coding.kt create mode 100644 app/src/processing/app/ui/preferences/Sketches.kt diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index fe20b82da1..1d3bb27b4a 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -23,28 +23,29 @@ package processing.app; -import java.awt.*; -import java.awt.event.ActionListener; -import java.io.*; -import java.lang.reflect.InvocationTargetException; -import java.util.*; -import java.util.List; -import java.util.Map.Entry; - -import javax.swing.*; -import javax.swing.tree.DefaultMutableTreeNode; - import com.formdev.flatlaf.FlatDarkLaf; import com.formdev.flatlaf.FlatLaf; import com.formdev.flatlaf.FlatLightLaf; import processing.app.contrib.*; import processing.app.tools.Tool; import processing.app.ui.*; -import processing.app.ui.PreferencesKt; import processing.app.ui.Toolkit; -import processing.core.*; +import processing.core.PApplet; import processing.data.StringList; +import javax.swing.*; +import javax.swing.tree.DefaultMutableTreeNode; +import java.awt.*; +import java.awt.event.ActionListener; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.*; +import java.util.List; +import java.util.Map.Entry; + /** * The base class for the main processing application. * Primary role of this class is for platform identification and @@ -2185,11 +2186,7 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { -// if (preferencesFrame == null) { -// preferencesFrame = new PreferencesFrame(this); -// } -// preferencesFrame.showFrame(); - PreferencesKt.show(); + PDEPreferencesKt.show(); } diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c54cbbd817..c13309299f 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -8,8 +8,11 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream -import java.nio.file.* -import java.util.Properties +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchEvent +import java.util.* /* The ReactiveProperties class extends the standard Java Properties class @@ -28,6 +31,11 @@ class ReactiveProperties: Properties() { return snapshotStateMap[key] ?: super.getProperty(key) } + override fun remove(key: Any?): Any? { + snapshotStateMap.remove(key as String) + return super.remove(key) + } + operator fun get(key: String): String? = getProperty(key) operator fun set(key: String, value: String) { diff --git a/app/src/processing/app/ui/EditorFooter.java b/app/src/processing/app/ui/EditorFooter.java index bc09b2376a..94860a0abf 100644 --- a/app/src/processing/app/ui/EditorFooter.java +++ b/app/src/processing/app/ui/EditorFooter.java @@ -22,15 +22,14 @@ package processing.app.ui; -import java.awt.CardLayout; -import java.awt.Color; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Font; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Image; -import java.awt.datatransfer.Clipboard; +import processing.app.Base; +import processing.app.Mode; +import processing.app.Sketch; +import processing.app.contrib.ContributionManager; +import processing.data.StringDict; + +import javax.swing.*; +import java.awt.*; import java.awt.datatransfer.StringSelection; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -39,14 +38,6 @@ import java.util.ArrayList; import java.util.List; -import javax.swing.*; - -import processing.app.Base; -import processing.app.Mode; -import processing.app.Sketch; -import processing.app.contrib.ContributionManager; -import processing.data.StringDict; - /** * Console/error/whatever tabs at the bottom of the editor window. @@ -118,6 +109,18 @@ public void mousePressed(MouseEvent e) { Base.DEBUG = !Base.DEBUG; editor.updateDevelopMenu(); } + copyDebugInformationToClipboard(); + } + }); + + tabBar.add(version); + + add(tabBar); + + updateTheme(); + } + + public static void copyDebugInformationToClipboard() { var debugInformation = String.join("\n", "Version: " + Base.getVersionName(), "Revision: " + Base.getRevision(), @@ -127,18 +130,12 @@ public void mousePressed(MouseEvent e) { var stringSelection = new StringSelection(debugInformation); var clipboard = java.awt.Toolkit.getDefaultToolkit().getSystemClipboard(); clipboard.setContents(stringSelection, null); - } - }); - - tabBar.add(version); - - add(tabBar); - - updateTheme(); - } + } - /** Add a panel with no icon. */ + /** + * Add a panel with no icon. + */ public void addPanel(Component comp, String name) { addPanel(comp, name, null); } diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt new file mode 100644 index 0000000000..2ec90ebdb2 --- /dev/null +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -0,0 +1,544 @@ +package processing.app.ui + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography +import processing.app.LocalPreferences +import processing.app.ui.PDEPreferences.Companion.preferences +import processing.app.ui.preferences.* +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import java.awt.Dimension +import javax.swing.SwingUtilities + +fun show() { + SwingUtilities.invokeLater { + PDESwingWindow( + titleKey = "preferences", + fullWindowContent = true, + size = Dimension(800, 600) + ) { + PDETheme { + preferences() + } + } + } +} + +class PDEPreferences { + companion object{ + private val panes: PDEPreferencePanes = mutableStateMapOf() + + /** + * Registers a new preference in the preferences' system. + * If the preference's pane does not exist, it will be created. + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * pane = somePreferencePane, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + * + * @param preferences The preference to register. + */ + fun register(vararg preferences: PDEPreference) { + if (preferences.map { it.pane }.toSet().size != 1) { + throw IllegalArgumentException("All preferences must belong to the same pane") + } + val pane = preferences.first().pane + + val group = mutableStateListOf() + group.addAll(preferences) + + val groups = panes[pane] as? SnapshotStateList ?: mutableStateListOf() + groups.add(group) + panes[pane] = groups + } + + /** + * Static initializer to register default preference panes. + */ + init{ + General.register() + Interface.register() + Coding.register() + Sketches.register() + Other.register(panes) + } + + /** + * Composable function to display the preferences UI. + */ + @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) + @Composable + fun preferences(){ + val locale = LocalLocale.current + var preferencesQuery by remember { mutableStateOf("") } + + /** + * Filter panes based on the search query. + */ + val panesQuierried = remember(preferencesQuery, panes) { + if (preferencesQuery.isBlank()) { + panes.toMutableMap() + } else { + panes.entries.associate { (pane, preferences) -> + val matching = preferences.map { group -> + group.filter { preference -> + val description = locale[preference.descriptionKey] + when { + preference.key == "other" -> true + preference.key.contains(preferencesQuery, ignoreCase = true) -> true + description.contains(preferencesQuery, ignoreCase = true) -> true + else -> false + } + } + } + pane to matching + }.toMutableMap() + } + } + + /** + * Sort panes based on their 'after' property and name. + */ + val panesSorted = remember(panesQuierried) { + panesQuierried.keys.sortedWith { a, b -> + when { + a === b -> 0 + a.after == b -> 1 + b.after == a -> -1 + a.after == null && b.after != null -> -1 + b.after == null && a.after != null -> 1 + else -> a.nameKey.compareTo(b.nameKey) + } + } + } + + + /** + * Pre-select a pane that has at least one preference to show + * Also reset the selection when the query changes + * */ + var selected by remember(panesQuierried) { + mutableStateOf(panesSorted.firstOrNull() { panesQuierried[it].isNotEmpty() }) + } + + Column { + /** + * Header + */ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = locale["preferences"], + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), + ) + Text( + text = locale["preferences.description"], + style = MaterialTheme.typography.bodySmall, + ) + } + SearchBar( + modifier = Modifier, + inputField = { + SearchBarDefaults.InputField( + query = preferencesQuery, + onQueryChange = { + preferencesQuery = it + }, + onSearch = { + + }, + trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + ) { + + } + } + HorizontalDivider() + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + /** + * Sidebar + */ + Column( + modifier = Modifier + .width(IntrinsicSize.Min) + .padding(30.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + + for (pane in panesSorted) { + val shape = RoundedCornerShape(12.dp) + val isSelected = selected == pane + TextButton( + onClick = { + selected = pane + }, + enabled = panesQuierried[pane].isNotEmpty(), + colors = if (isSelected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(), + shape = shape + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + pane.icon() + Text(locale[pane.nameKey]) + } + } + } + } + + /** + * Content Area + */ + AnimatedContent( + targetState = selected, + transitionSpec = { + fadeIn( + animationSpec = tween(300) + ) togetherWith fadeOut( + animationSpec = tween(300) + ) + } + ) { selected -> + if (selected == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(30.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = locale["preferences.no_results"], + style = MaterialTheme.typography.bodyMedium + ) + } + return@AnimatedContent + } + + val groups = panesQuierried[selected] ?: emptyList() + selected.showPane(groups) + } + } + } + } + + /** + * Main function to run the preferences window standalone for testing & development. + */ + @JvmStatic + fun main(args: Array) { + application { + Window(onCloseRequest = ::exitApplication) { + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = true) { + preferences() + } + } + Window(onCloseRequest = ::exitApplication) { + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = false) { + preferences() + } + } + } + } + } +} + +typealias PDEPreferencePanes = MutableMap +typealias PDEPreferenceGroups = List +typealias PDEPreferenceGroup = List +typealias PDEPreferenceControl = @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit + +/** + * Data class representing a pane of preferences. + */ +data class PDEPreferencePane( + /** + * The name key of this pane from the Processing locale. + */ + val nameKey: String, + /** + * The icon representing this pane. + */ + val icon: @Composable () -> Unit, + /** + * The pane that comes before this one in the list. + */ + val after: PDEPreferencePane? = null, +) + +/** + * Composable function to display the contents of a preference pane. + */ +@Composable +fun PDEPreferencePane.showPane(groups: PDEPreferenceGroups) { + Box { + val locale = LocalLocale.current + val state = rememberLazyListState() + LazyColumn( + state = state, + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(top = 30.dp, end = 30.dp, bottom = 30.dp) + ) { + item { + Text( + text = locale[nameKey], + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium), + ) + } + items(groups) { group -> + Card( + modifier = Modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ), + ) { + group.forEachIndexed { index, preference -> + preference.showControl() + if (index != group.lastIndex) { + HorizontalDivider() + } + } + + } + } + item { + val prefs = LocalPreferences.current + TextButton( + onClick = { + groups.forEach { group -> + group.forEach { pref -> + prefs.remove(pref.key) + } + } + } + ) { + Text( + text = locale["preferences.reset"], + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(12.dp) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(state) + ) + } +} + +/** + * Data class representing a single preference in the preferences' system. + * + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * group = somePreferenceGroup, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + */ +data class PDEPreference( + /** + * The key in the preferences file used to store this preference. + */ + val key: String, + /** + * The key for the description of this preference, used for localization. + */ + val descriptionKey: String, + + /** + * The key for the label of this preference, used for localization. + * If null, the label will not be shown. + */ + val labelKey: String? = null, + /** + * The group this preference belongs to. + */ + val pane: PDEPreferencePane, + /** + * A Composable function that defines the control used to modify this preference. + * It takes the current preference value and a function to update the preference. + */ + val control: PDEPreferenceControl = { preference, updatePreference -> }, + + /** + * If true, no padding will be applied around this preference's UI. + */ + val noPadding: Boolean = false, + /** + * If true, the title will be omitted from this preference's UI. + */ + val noTitle: Boolean = false, +) + +/** + * Extension function to check if a list of preference groups is not empty. + */ +fun PDEPreferenceGroups?.isNotEmpty(): Boolean { + if (this == null) return false + for (group in this) { + if (group.isNotEmpty()) return true + } + return false +} + +/** + * Composable function to display the preference's description and control. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PDEPreference.showControl() { + val locale = LocalLocale.current + val prefs = LocalPreferences.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!noTitle) { + Column( + modifier = Modifier + .weight(1f) + + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(horizontal = 20.dp) + ) { + Text( + text = locale[descriptionKey], + style = MaterialTheme.typography.bodyMedium + ) + if (labelKey != null && locale.containsKey(labelKey)) { + Card { + Text( + text = locale[labelKey], + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(8.dp, 4.dp) + ) + } + } + } + if (locale.containsKey("$descriptionKey.tip")) { + Markdown( + content = locale["$descriptionKey.tip"], + colors = markdownColor( + text = MaterialTheme.colorScheme.onSurfaceVariant, + ), + typography = markdownTypography( + text = MaterialTheme.typography.bodySmall, + paragraph = MaterialTheme.typography.bodySmall, + textLink = TextLinkStyles( + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ).toSpanStyle() + ) + ), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 4.dp) + ) + } + } + } + val show = @Composable { + control(prefs[key]) { newValue -> + prefs[key] = newValue + } + } + + if (noPadding) { + show() + } else { + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + ) { + show() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt deleted file mode 100644 index 12e7c25ce4..0000000000 --- a/app/src/processing/app/ui/Preferences.kt +++ /dev/null @@ -1,323 +0,0 @@ -package processing.app.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.debounce -import processing.app.LocalPreferences -import processing.app.ui.PDEPreferences.Companion.preferences -import processing.app.ui.preferences.General -import processing.app.ui.preferences.Interface -import processing.app.ui.preferences.Other -import processing.app.ui.theme.LocalLocale -import processing.app.ui.theme.PDESwingWindow -import processing.app.ui.theme.PDETheme -import java.awt.Dimension -import javax.swing.SwingUtilities - -val LocalPreferenceGroups = compositionLocalOf>> { - error("No Preference Groups Set") -} - -class PDEPreferences { - companion object{ - val groups = mutableStateMapOf>() - fun register(preference: PDEPreference) { - val list = groups[preference.group]?.toMutableList() ?: mutableListOf() - list.add(preference) - groups[preference.group] = list - } - init{ - General.register() - Interface.register() - Other.register() - } - - /** - * Composable function to display the preferences UI. - */ - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun preferences(){ - var visible by remember { mutableStateOf(groups) } - val sortedGroups = remember { - val keys = visible.keys - keys.toSortedSet { - a, b -> - when { - a.after == b -> 1 - b.after == a -> -1 - else -> a.name.compareTo(b.name) - } - } - } - var selected by remember { mutableStateOf(sortedGroups.first()) } - CompositionLocalProvider( - LocalPreferenceGroups provides visible - ) { - Row { - NavigationRail( - header = { - Text( - "Settings", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(top = 42.dp) - ) - }, - modifier = Modifier - .defaultMinSize(minWidth = 200.dp) - ) { - - for (group in sortedGroups) { - NavigationRailItem( - selected = selected == group, - enabled = visible.keys.contains(group), - onClick = { - selected = group - }, - icon = { - group.icon() - }, - label = { - Text(group.name) - } - ) - } - } - Box(modifier = Modifier.padding(top = 42.dp)) { - Column(modifier = Modifier - .fillMaxSize() - ) { - var query by remember { mutableStateOf("") } - val locale = LocalLocale.current - LaunchedEffect(query){ - - snapshotFlow { query } - .debounce(100) - .collect{ - if(it.isBlank()){ - visible = groups - return@collect - } - val filtered = mutableStateMapOf>() - for((group, preferences) in groups){ - val matching = preferences.filter { preference -> - if(preference.key == "other"){ - return@filter true - } - if(preference.key.contains(it, ignoreCase = true)){ - return@filter true - } - val description = locale[preference.descriptionKey] - description.contains(it, ignoreCase = true) - } - if(matching.isNotEmpty()){ - filtered[group] = matching - } - } - visible = filtered - } - - } - SearchBar( - inputField = { - SearchBarDefaults.InputField( - query = query, - onQueryChange = { - query = it - }, - onSearch = { - - }, - expanded = false, - onExpandedChange = { }, - placeholder = { Text("Search") } - ) - }, - expanded = false, - onExpandedChange = {}, - modifier = Modifier.align(Alignment.End).padding(16.dp) - ) { - - } - - val preferences = visible[selected] ?: emptyList() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - items(preferences){ preference -> - preference.showControl() - } - } - } - } - } - } - } - - - - @JvmStatic - fun main(args: Array) { - application { - Window(onCloseRequest = ::exitApplication){ - remember{ - window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) - window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - } - PDETheme(darkTheme = true) { - preferences() - } - } - Window(onCloseRequest = ::exitApplication){ - remember{ - window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) - window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - } - PDETheme(darkTheme = false) { - preferences() - } - } - } - } - } -} - -/** - * Data class representing a single preference in the preferences system. - * - * Usage: - * ``` - * PDEPreferences.register( - * PDEPreference( - * key = "preference.key", - * descriptionKey = "preference.description", - * group = somePreferenceGroup, - * control = { preference, updatePreference -> - * // Composable UI to modify the preference - * } - * ) - * ) - * ``` - */ -data class PDEPreference( - /** - * The key in the preferences file used to store this preference. - */ - val key: String, - /** - * The key for the description of this preference, used for localization. - */ - val descriptionKey: String, - /** - * The group this preference belongs to. - */ - val group: PDEPreferenceGroup, - /** - * A Composable function that defines the control used to modify this preference. - * It takes the current preference value and a function to update the preference. - */ - val control: @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit = { preference, updatePreference -> }, - - /** - * If true, no padding will be applied around this preference's UI. - */ - val noPadding: Boolean = false, -) - -/** - * Composable function to display the preference's description and control. - */ -@Composable -private fun PDEPreference.showControl() { - val locale = LocalLocale.current - val prefs = LocalPreferences.current - Text( - text = locale[descriptionKey], - modifier = Modifier.padding(horizontal = 20.dp), - style = MaterialTheme.typography.titleMedium - ) - val show = @Composable { - control(prefs[key]) { newValue -> - prefs[key] = newValue - } - } - - if(noPadding){ - show() - }else{ - Box(modifier = Modifier.padding(horizontal = 20.dp)) { - show() - } - } -} - -/** - * Data class representing a group of preferences. - */ -data class PDEPreferenceGroup( - /** - * The name of this group. - */ - val name: String, - /** - * The icon representing this group. - */ - val icon: @Composable () -> Unit, - /** - * The group that comes before this one in the list. - */ - val after: PDEPreferenceGroup? = null, -) - -fun show(){ - SwingUtilities.invokeLater { - PDESwingWindow( - titleKey = "preferences", - fullWindowContent = true, - size = Dimension(800, 600) - ) { - PDETheme { - preferences() - } - } - } -} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Coding.kt b/app/src/processing/app/ui/preferences/Coding.kt new file mode 100644 index 0000000000..daee85b7f7 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Coding.kt @@ -0,0 +1,69 @@ +package processing.app.ui.preferences + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts + +class Coding { + companion object { + val coding = PDEPreferencePane( + nameKey = "preferences.pane.editor", + icon = { Icon(Icons.Default.EditNote, contentDescription = null) }, + after = interfaceAndFonts, + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "pdex.errorCheckEnabled", + descriptionKey = "preferences.continuously_check", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.warningsEnabled", + descriptionKey = "preferences.show_warnings", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.completion", + descriptionKey = "preferences.code_completion", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.suggest.imports", + descriptionKey = "preferences.suggest_imports", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt index 5f56187f46..c45abbf4c1 100644 --- a/app/src/processing/app/ui/preferences/General.kt +++ b/app/src/processing/app/ui/preferences/General.kt @@ -1,32 +1,29 @@ package processing.app.ui.preferences -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextField +import androidx.compose.material3.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import processing.app.Preferences import processing.app.SketchName +import processing.app.ui.EditorFooter.copyDebugInformationToClipboard import processing.app.ui.PDEPreference -import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences +import processing.app.ui.theme.LocalLocale class General { companion object{ - val general = PDEPreferenceGroup( - name = "General", + val general = PDEPreferencePane( + nameKey = "preferences.pane.general", icon = { - Icon(Icons.Default.Settings, contentDescription = "A settings icon") + Icon(Icons.Default.Settings, contentDescription = "General Preferences") } ) @@ -35,35 +32,27 @@ class General { PDEPreference( key = "sketchbook.path.four", descriptionKey = "preferences.sketchbook_location", - group = general, + pane = general, + noTitle = true, control = { preference, updatePreference -> - Row ( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - ) { - TextField( - value = preference ?: "", - onValueChange = { - updatePreference(it) - } - ) - Button( - onClick = { - - } - ) { - Text("Browse") + val locale = LocalLocale.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(locale["preferences.sketchbook_location"]) }, + value = preference ?: "", + onValueChange = { + updatePreference(it) + }, + trailingIcon = { + Icon(Icons.Default.Folder, contentDescription = null) } - } + ) } - ) - ) - PDEPreferences.register( + ), PDEPreference( key = "sketch.name.approach", descriptionKey = "preferences.sketch_naming", - group = general, + pane = general, control = { preference, updatePreference -> Row{ for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( @@ -84,13 +73,27 @@ class General { } } } + ), + PDEPreference( + key = "editor.sync_folder_and_filename", + labelKey = "preferences.new", + descriptionKey = "preferences.sync_folder_and_filename", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } ) ) PDEPreferences.register( PDEPreference( key = "update.check", - descriptionKey = "preferences.check_for_updates_on_startup", - group = general, + descriptionKey = "preferences.update_check", + pane = general, control = { preference, updatePreference -> Switch( checked = preference.toBoolean(), @@ -104,8 +107,8 @@ class General { PDEPreferences.register( PDEPreference( key = "welcome.show", - descriptionKey = "preferences.show_welcome_screen_on_startup", - group = general, + descriptionKey = "preferences.show_welcome_screen", + pane = general, control = { preference, updatePreference -> Switch( checked = preference.toBoolean(), @@ -116,6 +119,20 @@ class General { } ) ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.diagnostics", + pane = general, + control = { preference, updatePreference -> + Button(onClick = { + copyDebugInformationToClipboard() + }) { + Text(LocalLocale.current["preferences.diagnostics.button"]) + } + } + ) + ) } } } \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt index e9747a037d..9b3413506c 100644 --- a/app/src/processing/app/ui/preferences/Interface.kt +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -1,95 +1,131 @@ package processing.app.ui.preferences -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.TextIncrease -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import processing.app.Language +import processing.app.LocalPreferences import processing.app.Preferences import processing.app.ui.PDEPreference -import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences import processing.app.ui.Toolkit import processing.app.ui.preferences.General.Companion.general import processing.app.ui.theme.LocalLocale -import java.util.Locale +import java.util.* class Interface { companion object{ - val interfaceAndFonts = PDEPreferenceGroup( - name = "Interface", + val interfaceAndFonts = PDEPreferencePane( + nameKey = "preferences.pane.interface", icon = { Icon(Icons.Default.TextIncrease, contentDescription = "Interface") }, after = general ) + @OptIn(ExperimentalMaterial3Api::class) fun register() { - PDEPreferences.register(PDEPreference( - key = "language", - descriptionKey = "preferences.language", - group = interfaceAndFonts, - control = { preference, updatePreference -> - val locale = LocalLocale.current - val showOptions = remember { mutableStateOf(false) } - TextField( - value = locale.locale.displayName, - readOnly = true, - onValueChange = { }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = "Select Font Family", + PDEPreferences.register( + PDEPreference( + key = "language", + descriptionKey = "preferences.language", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + val showOptions = remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showOptions.value = true + }, + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Language, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(locale.locale.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + languagesDropdown(showOptions) + } + ), + PDEPreference( + key = "editor.input_method_support", + descriptionKey = "preferences.enable_complex_text", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val enabled = preference?.toBoolean() ?: true + Switch( + checked = enabled, + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "editor.zoom", + descriptionKey = "preferences.interface_scale", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val prefs = LocalPreferences.current + var currentZoom by remember(preference) { mutableStateOf(preference?.toFloatOrNull() ?: 100f) } + val automatic = currentZoom == 100f + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( modifier = Modifier - .clickable{ - showOptions.value = true - } - ) + .widthIn(max = 200.dp) + ) { + Text( + text = if (automatic) "Auto" else "${currentZoom.toInt()}%", + ) + Slider( + value = currentZoom, + onValueChange = { + currentZoom = it + }, + onValueChangeFinished = { + prefs["editor.zoom.auto"] = automatic + updatePreference(String.format(Locale.US, "%.2f", currentZoom)) + }, + valueRange = 100f..300f, + steps = 3 + ) + } } - ) - languagesDropdown(showOptions) - } - )) + } + ) + ) PDEPreferences.register( PDEPreference( key = "editor.font.family", descriptionKey = "preferences.editor_and_console_font", - group = interfaceAndFonts, + pane = interfaceAndFonts, control = { preference, updatePreference -> var showOptions by remember { mutableStateOf(false) } - val families = if(Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") - TextField( - value = preference ?: families.firstOrNull().orEmpty(), - readOnly = true, - onValueChange = { updatePreference (it) }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = "Select Font Family", - modifier = Modifier - .clickable{ - showOptions = true - } - ) - } - ) + val families = + if (Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + OutlinedButton( + onClick = { + showOptions = true + }, + modifier = Modifier.width(200.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(preference ?: families.firstOrNull().orEmpty()) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } DropdownMenu( expanded = showOptions, onDismissRequest = { @@ -108,47 +144,51 @@ class Interface { } } - ) - ) - - PDEPreferences.register(PDEPreference( - key = "editor.font.size", - descriptionKey = "preferences.editor_font_size", - group = interfaceAndFonts, - control = { preference, updatePreference -> - Column { - Text( - text = "${preference ?: "12"} pt", - modifier = Modifier.width(120.dp) - ) - Slider( - value = (preference ?: "12").toFloat(), - onValueChange = { updatePreference(it.toInt().toString()) }, - valueRange = 10f..48f, - steps = 18, - ) + ), + PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18 + ) + } } - } - )) - PDEPreferences.register(PDEPreference( - key = "console.font.size", - descriptionKey = "preferences.console_font_size", - group = interfaceAndFonts, - control = { preference, updatePreference -> - Column { - Text( - text = "${preference ?: "12"} pt", - modifier = Modifier.width(120.dp) - ) - Slider( - value = (preference ?: "12").toFloat(), - onValueChange = { updatePreference(it.toInt().toString()) }, - valueRange = 10f..48f, - steps = 18, - ) + ), PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } } - } - )) + ) + ) } @Composable diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt index f5f65ea9c8..79858b29ba 100644 --- a/app/src/processing/app/ui/preferences/Other.kt +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -1,70 +1,91 @@ package processing.app.ui.preferences -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Science import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import processing.app.LocalPreferences -import processing.app.ui.LocalPreferenceGroups import processing.app.ui.PDEPreference -import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferencePanes import processing.app.ui.PDEPreferences -import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts -import processing.app.ui.theme.LocalLocale +import processing.app.ui.preferences.Sketches.Companion.sketches class Other { companion object{ - val other = PDEPreferenceGroup( - name = "Other", + val other = PDEPreferencePane( + nameKey = "preferences.pane.other", icon = { - Icon(Icons.Default.Map, contentDescription = "A map icon") + Icon(Icons.Default.Science, contentDescription = "Other Preferences") }, - after = interfaceAndFonts + after = sketches ) - fun register() { + + fun register(panes: PDEPreferencePanes) { + // TODO: Move to individual preferences PDEPreferences.register( PDEPreference( - key = "other", + key = "preferences.show_other", descriptionKey = "preferences.other", - group = other, - noPadding = true, - control = { _, _ -> - val prefs = LocalPreferences.current - val groups = LocalPreferenceGroups.current - val restPrefs = remember { - val keys = prefs.keys.mapNotNull { it as? String } - val existing = groups.values.flatten().map { it.key } - keys.filter { it !in existing }.sorted() + pane = other, + control = { preference, setPreference -> + val showOther = preference?.toBoolean() ?: false + Switch( + checked = showOther, + onCheckedChange = { + setPreference(it.toString()) + } + ) + if (!showOther) { + return@PDEPreference } - val locale = LocalLocale.current + val prefs = LocalPreferences.current + DisposableEffect(Unit) { + // add all the other options to the same group as the current one + val group = + panes[other]?.find { group -> group.any { preference -> preference.key == "preferences.show_other" } } as? MutableList - for(prefKey in restPrefs){ - val value = prefs[prefKey] - Row ( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ){ - Text( - text = locale[prefKey], - modifier = Modifier.align(Alignment.CenterVertically) + val existing = panes.values.flatten().flatten().map { preference -> preference.key } + val keys = prefs.keys.mapNotNull { it as? String }.filter { it !in existing }.sorted() + + for (prefKey in keys) { + val preference = PDEPreference( + key = prefKey, + descriptionKey = prefKey, + pane = other, + control = { preference, updatePreference -> + if (preference?.toBooleanStrictOrNull() != null) { + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + return@PDEPreference + } + + OutlinedTextField( + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + } ) - TextField(value ?: "", onValueChange = { - prefs[prefKey] = it - }) + group?.add(preference) + } + onDispose { + group?.apply { + removeIf { it.key != "preferences.show_other" } + } } } - } ) ) diff --git a/app/src/processing/app/ui/preferences/Sketches.kt b/app/src/processing/app/ui/preferences/Sketches.kt new file mode 100644 index 0000000000..92de623df3 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Sketches.kt @@ -0,0 +1,146 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Monitor +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Coding.Companion.coding +import java.awt.GraphicsEnvironment +import javax.swing.JColorChooser + +class Sketches { + companion object { + val sketches = PDEPreferencePane( + nameKey = "preferences.pane.sketches", + icon = { Icon(Icons.Default.PlayArrow, contentDescription = null) }, + after = coding, + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "run.display", + descriptionKey = "preferences.run_sketches_on_display", + pane = sketches, + control = { preference, setPreference -> + val ge = GraphicsEnvironment.getLocalGraphicsEnvironment() + val defaultDevice = ge.defaultScreenDevice + val devices = ge.screenDevices + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + devices.toList().chunked(2).forEach { devices -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + devices.forEachIndexed { index, device -> + val displayNum = (index + 1).toString() + OutlinedButton( + colors = if (preference == displayNum) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.outlinedButtonColors() + }, + shape = RoundedCornerShape(12.dp), + onClick = { + setPreference(displayNum) + } + ) { + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box { + Icon( + Icons.Default.Monitor, + modifier = Modifier.size(32.dp), + contentDescription = null + ) + Text( + text = displayNum, + modifier = Modifier + .align(Alignment.Center) + .offset(0.dp, (-2).dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = "${device.displayMode.width} x ${device.displayMode.height}", + style = MaterialTheme.typography.bodySmall, + ) + if (device == defaultDevice) { + Text( + text = "Default", + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(0.5f), + ) + } + } + } + } + } + } + } + } + ), + PDEPreference( + key = "run.options.memory.maximum", + descriptionKey = "preferences.increase_max_memory", + pane = sketches, + control = { preference, setPreference -> + OutlinedTextField( + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + trailingIcon = { Text("MB") }, + onValueChange = { + setPreference(it) + } + ) + } + ), + PDEPreference( + key = "run.present.bgcolor", + descriptionKey = "preferences.background_color", + pane = sketches, + control = { preference, setPreference -> + val color = try { + java.awt.Color.decode(preference) + } catch (e: Exception) { + java.awt.Color.BLACK + } + Box( + modifier = Modifier + .size(40.dp) + .padding(4.dp) + .background( + color = Color(color.red, color.green, color.blue), + shape = RoundedCornerShape(4.dp) + ) + .clickable { + // TODO: Replace with Compose color picker when available + val newColor = JColorChooser.showDialog( + null, + "Choose Background Color", + color + ) ?: color + val hexColor = + String.format("#%02x%02x%02x", newColor.red, newColor.green, newColor.blue) + setPreference(hexColor) + } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index 8001796f59..bd1884cdc8 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -205,23 +205,40 @@ close.unsaved_changes = Save changes to %s? # Preferences (Frame) preferences = Preferences +preferences.description=Change how Processing works on your computer. These settings affect all Processing windows and stay the same even after you restart. +preferences.pane.general=General +preferences.pane.interface=Interface +preferences.pane.editor=Coding Stuff +preferences.pane.sketches=Sketches +preferences.pane.other=Experimental +preferences.new=New +preferences.reset=Reset to Defaults +preferences.no_results=No results found +preferences.sync_folder_and_filename=Folder name matches sketch name +preferences.sync_folder_and_filename.tip=When enabled, renaming a sketch will also rename its folder to match the sketch name. [Learn more](https://discourse.processing.org/t/sketch-folder-and-sketch-name-syncing/15345) +preferences.show_welcome_screen=Show welcome screen at startup +preferences.diagnostics=Generate diagnostic report for support +preferences.diagnostics.tip=Copies information about your installation into your clipboard, useful for troubleshooting issues. +preferences.diagnostics.button=Generate Report preferences.button.width = 80 preferences.restart_required = Restart Processing to apply changes preferences.sketchbook_location = Sketchbook folder preferences.sketchbook_location.popup = Sketchbook folder preferences.sketch_naming = Sketch name -preferences.language = Language: -preferences.editor_and_console_font = Editor and Console font: -preferences.editor_and_console_font.tip = Select the font used in the Editor and the Console.
Only monospaced (fixed-width) fonts may be used,
though the list may be imperfect. -preferences.editor_font_size = Editor font size: -preferences.console_font_size = Console font size: -preferences.interface_scale = Interface scale: +preferences.sketch_naming.tip=Choose how new sketches are named and numbered. +preferences.language=Language +preferences.editor_and_console_font=Editor and Console font +preferences.editor_and_console_font.tip=Installed Monospaced fonts will be displayed as options. +preferences.editor_font_size=Editor font size +preferences.console_font_size=Console font size +preferences.interface_scale=Interface scale +preferences.interface_scale.tip=Adjust the size of interface elements. preferences.interface_scale.auto = Automatic preferences.background_color = Background color when Presenting: -preferences.background_color.tip = Select the background color used when using Present.
Present is used to present a sketch in full-screen,
accessible from the Sketch menu. +preferences.background_color.tip=Select the background color used when using Present. Present is used to present a sketch in full-screen, accessible from the Sketch menu. preferences.use_smooth_text = Use smooth text in editor window preferences.enable_complex_text = Enable complex text input -preferences.enable_complex_text.tip = Using languages such as Chinese, Japanese, and Arabic
in the Editor window require additional features to be enabled. +preferences.enable_complex_text.tip=Using languages such as Chinese, Japanese, and Arabic in the Editor window require additional features to be enabled. preferences.continuously_check = Continuously check for errors preferences.show_warnings = Show warnings preferences.code_completion = Code completion with @@ -231,13 +248,17 @@ preferences.suggest_imports = Suggest import statements preferences.increase_max_memory = Increase maximum available memory to # preferences.delete_previous_folder_on_export = Delete previous folder on export preferences.check_for_updates_on_startup = Allow update checking (see FAQ for information shared) +preferences.update_check=Check for updates on startup +preferences.update_check.tip=No personal information is sent during this process. See the [FAQ](https://github.com/processing/processing4/wiki/FAQ#checking-for-updates) preferences.run_sketches_on_display = Run sketches on display -preferences.run_sketches_on_display.tip = Sets the display where sketches are initially placed.
As usual, if the sketch window is moved, it will re-open
at the same location, however when running in present
(full screen) mode, this display will always be used. +preferences.run_sketches_on_display.tip=Sets the display where sketches are initially placed. As usual, if the sketch window is moved, it will re-open at the same location, however when running in present (full screen) mode, this display will always be used. preferences.automatically_associate_pde_files = Automatically associate .pde files with Processing preferences.launch_programs_in = Launch programs in preferences.launch_programs_in.mode = mode preferences.file = More preferences can be edited directly in the file: preferences.file.hint = (Edit only when Processing is not running.) +preferences.other=Show experimental settings +preferences.other.tip=These settings are contained in the preferences.txt file and are not officially supported. They may be removed or changed without notice in future versions of Processing. # Sketchbook Location (Frame) sketchbook_location = Select new sketchbook folder From f0b408a8985456716213f8a96f6b2a6ceab594e5 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Wed, 5 Nov 2025 16:39:37 +0100 Subject: [PATCH 35/39] Replace Row with Column in sketch naming options Changed the layout container from Row to Column for the sketch naming options in the General preferences UI. This improves vertical arrangement and removes unnecessary padding modifiers. --- app/src/processing/app/ui/preferences/General.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt index c45abbf4c1..f8b3875583 100644 --- a/app/src/processing/app/ui/preferences/General.kt +++ b/app/src/processing/app/ui/preferences/General.kt @@ -1,14 +1,12 @@ package processing.app.ui.preferences -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import processing.app.Preferences import processing.app.SketchName import processing.app.ui.EditorFooter.copyDebugInformationToClipboard @@ -54,7 +52,7 @@ class General { descriptionKey = "preferences.sketch_naming", pane = general, control = { preference, updatePreference -> - Row{ + Column { for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( "timestamp", "untitled", @@ -68,7 +66,6 @@ class General { label = { Text(option) }, - modifier = Modifier.padding(4.dp), ) } } From 373321e8dd568bd5c70a0e72decbee4a2d404299 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 6 Nov 2025 07:38:16 +0100 Subject: [PATCH 36/39] Enhance preferences UI and add memory options Refactored preferences UI to swap primary and tertiary colors, improved sidebar button color handling, and updated search bar logic. Added clickable folder icon for sketchbook location selection. Improved interface scale slider logic and display. Added new preferences for increasing available memory and max memory, with enable/disable logic. Updated experimental preferences to use localized description keys if available. Extended ShimAWT to support folder selection via callback and refactored file/folder selection logic for better composability. Updated language properties with new preference keys and descriptions. --- app/src/processing/app/ui/PDEPreferences.kt | 223 ++++++++++-------- .../processing/app/ui/preferences/General.kt | 21 +- .../app/ui/preferences/Interface.kt | 20 +- .../processing/app/ui/preferences/Other.kt | 5 +- .../processing/app/ui/preferences/Sketches.kt | 21 +- build/shared/lib/languages/PDE.properties | 20 ++ core/src/processing/awt/ShimAWT.java | 124 ++++++---- 7 files changed, 270 insertions(+), 164 deletions(-) diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt index 2ec90ebdb2..62d0eaef3d 100644 --- a/app/src/processing/app/ui/PDEPreferences.kt +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -100,7 +100,7 @@ class PDEPreferences { */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable - fun preferences(){ + fun preferences() { val locale = LocalLocale.current var preferencesQuery by remember { mutableStateOf("") } @@ -153,123 +153,144 @@ class PDEPreferences { mutableStateOf(panesSorted.firstOrNull() { panesQuierried[it].isNotEmpty() }) } - Column { - /** - * Header - */ - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - Column( + /** + * Swapping primary and tertiary colors for the preferences window, probably should do that program-wide + */ + val originalScheme = MaterialTheme.colorScheme + MaterialTheme( + colorScheme = originalScheme.copy( + primary = originalScheme.tertiary, + onPrimary = originalScheme.onTertiary, + primaryContainer = originalScheme.tertiaryContainer, + onPrimaryContainer = originalScheme.onTertiaryContainer, + + tertiary = originalScheme.primary, + onTertiary = originalScheme.onPrimary, + tertiaryContainer = originalScheme.primaryContainer, + onTertiaryContainer = originalScheme.onPrimaryContainer, + ) + ) { + Column { + /** + * Header + */ + Row( modifier = Modifier - .weight(1f) + .fillMaxWidth() + .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom ) { - Text( - text = locale["preferences"], - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), - ) - Text( - text = locale["preferences.description"], - style = MaterialTheme.typography.bodySmall, - ) - } - SearchBar( - modifier = Modifier, - inputField = { - SearchBarDefaults.InputField( - query = preferencesQuery, - onQueryChange = { - preferencesQuery = it - }, - onSearch = { - - }, - trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - expanded = false, - onExpandedChange = { }, - placeholder = { Text("Search") } + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = locale["preferences"], + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), ) - }, - expanded = false, - onExpandedChange = {}, - ) { + Text( + text = locale["preferences.description"], + style = MaterialTheme.typography.bodySmall, + ) + } + SearchBar( + modifier = Modifier, + inputField = { + SearchBarDefaults.InputField( + query = preferencesQuery, + onQueryChange = { + preferencesQuery = it + }, + onSearch = { + + }, + trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + ) { + } } - } - HorizontalDivider() - Row( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - /** - * Sidebar - */ - Column( + HorizontalDivider() + Row( modifier = Modifier - .width(IntrinsicSize.Min) - .padding(30.dp) .background(MaterialTheme.colorScheme.surfaceVariant) ) { + /** + * Sidebar + */ + Column( + modifier = Modifier + .width(IntrinsicSize.Min) + .padding(30.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { - for (pane in panesSorted) { - val shape = RoundedCornerShape(12.dp) - val isSelected = selected == pane - TextButton( - onClick = { - selected = pane - }, - enabled = panesQuierried[pane].isNotEmpty(), - colors = if (isSelected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(), - shape = shape - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 4.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + for (pane in panesSorted) { + val shape = RoundedCornerShape(12.dp) + val isSelected = selected == pane + TextButton( + onClick = { + selected = pane + }, + enabled = panesQuierried[pane].isNotEmpty(), + colors = if (isSelected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + shape = shape ) { - pane.icon() - Text(locale[pane.nameKey]) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + pane.icon() + Text(locale[pane.nameKey]) + } } } } - } - /** - * Content Area - */ - AnimatedContent( - targetState = selected, - transitionSpec = { - fadeIn( - animationSpec = tween(300) - ) togetherWith fadeOut( - animationSpec = tween(300) - ) - } - ) { selected -> - if (selected == null) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(30.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = locale["preferences.no_results"], - style = MaterialTheme.typography.bodyMedium + /** + * Content Area + */ + AnimatedContent( + targetState = selected, + transitionSpec = { + fadeIn( + animationSpec = tween(300) + ) togetherWith fadeOut( + animationSpec = tween(300) ) } - return@AnimatedContent - } + ) { selected -> + if (selected == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(30.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = locale["preferences.no_results"], + style = MaterialTheme.typography.bodyMedium + ) + } + return@AnimatedContent + } - val groups = panesQuierried[selected] ?: emptyList() - selected.showPane(groups) + val groups = panesQuierried[selected] ?: emptyList() + selected.showPane(groups) + } } } } diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt index f8b3875583..b560bbc1d5 100644 --- a/app/src/processing/app/ui/preferences/General.kt +++ b/app/src/processing/app/ui/preferences/General.kt @@ -1,5 +1,6 @@ package processing.app.ui.preferences +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons @@ -14,6 +15,8 @@ import processing.app.ui.PDEPreference import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences import processing.app.ui.theme.LocalLocale +import processing.awt.ShimAWT.selectFolder +import java.io.File class General { @@ -42,7 +45,21 @@ class General { updatePreference(it) }, trailingIcon = { - Icon(Icons.Default.Folder, contentDescription = null) + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier + .clickable { + selectFolder( + locale["preferences.sketchbook_location.popup"], + File(preference ?: "") + ) { selectedFile: File? -> + if (selectedFile != null) { + updatePreference(selectedFile.absolutePath) + } + } + } + ) } ) } @@ -103,7 +120,7 @@ class General { ) PDEPreferences.register( PDEPreference( - key = "welcome.show", + key = "welcome.four.show", descriptionKey = "preferences.show_welcome_screen", pane = general, control = { preference, updatePreference -> diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt index 9b3413506c..0edbde0c29 100644 --- a/app/src/processing/app/ui/preferences/Interface.kt +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -76,9 +76,19 @@ class Interface { descriptionKey = "preferences.interface_scale", pane = interfaceAndFonts, control = { preference, updatePreference -> + val range = 100f..300f + val prefs = LocalPreferences.current - var currentZoom by remember(preference) { mutableStateOf(preference?.toFloatOrNull() ?: 100f) } - val automatic = currentZoom == 100f + var currentZoom by remember(preference) { + mutableStateOf( + preference + ?.replace("%", "") + ?.toFloatOrNull() + ?: range.start + ) + } + val automatic = currentZoom == range.start + val zoomPerc = "${currentZoom.toInt()}%" Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -87,7 +97,7 @@ class Interface { .widthIn(max = 200.dp) ) { Text( - text = if (automatic) "Auto" else "${currentZoom.toInt()}%", + text = if (automatic) "Auto" else zoomPerc, ) Slider( value = currentZoom, @@ -96,9 +106,9 @@ class Interface { }, onValueChangeFinished = { prefs["editor.zoom.auto"] = automatic - updatePreference(String.format(Locale.US, "%.2f", currentZoom)) + updatePreference(zoomPerc) }, - valueRange = 100f..300f, + valueRange = range, steps = 3 ) } diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt index 79858b29ba..b637a55f44 100644 --- a/app/src/processing/app/ui/preferences/Other.kt +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -15,6 +15,7 @@ import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferencePanes import processing.app.ui.PDEPreferences import processing.app.ui.preferences.Sketches.Companion.sketches +import processing.app.ui.theme.LocalLocale class Other { companion object{ @@ -45,6 +46,7 @@ class Other { return@PDEPreference } val prefs = LocalPreferences.current + val locale = LocalLocale.current DisposableEffect(Unit) { // add all the other options to the same group as the current one val group = @@ -54,9 +56,10 @@ class Other { val keys = prefs.keys.mapNotNull { it as? String }.filter { it !in existing }.sorted() for (prefKey in keys) { + val descriptionKey = "preferences.$prefKey" val preference = PDEPreference( key = prefKey, - descriptionKey = prefKey, + descriptionKey = if (locale.containsKey(descriptionKey)) descriptionKey else prefKey, pane = other, control = { preference, updatePreference -> if (preference?.toBooleanStrictOrNull() != null) { diff --git a/app/src/processing/app/ui/preferences/Sketches.kt b/app/src/processing/app/ui/preferences/Sketches.kt index 92de623df3..0a3b77375b 100644 --- a/app/src/processing/app/ui/preferences/Sketches.kt +++ b/app/src/processing/app/ui/preferences/Sketches.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences import processing.app.ui.PDEPreference import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences @@ -48,14 +49,14 @@ class Sketches { devices.forEachIndexed { index, device -> val displayNum = (index + 1).toString() OutlinedButton( - colors = if (preference == displayNum) { + colors = if (preference == displayNum || (device == defaultDevice && preference == "-1")) { ButtonDefaults.buttonColors() } else { ButtonDefaults.outlinedButtonColors() }, shape = RoundedCornerShape(12.dp), onClick = { - setPreference(displayNum) + setPreference(if (device == defaultDevice) "-1" else displayNum) } ) { @@ -93,12 +94,26 @@ class Sketches { } } ), + PDEPreference( + key = "run.options.memory", + descriptionKey = "preferences.increase_memory", + pane = sketches, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { + setPreference(it.toString()) + } + ) + } + ), PDEPreference( key = "run.options.memory.maximum", descriptionKey = "preferences.increase_max_memory", pane = sketches, control = { preference, setPreference -> OutlinedTextField( + enabled = LocalPreferences.current["run.options.memory"]?.toBoolean() ?: false, modifier = Modifier.widthIn(max = 300.dp), value = preference ?: "", trailingIcon = { Text("MB") }, @@ -120,7 +135,7 @@ class Sketches { } Box( modifier = Modifier - .size(40.dp) + .size(64.dp) .padding(4.dp) .background( color = Color(color.red, color.green, color.blue), diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index bd1884cdc8..f6b004e7a3 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -245,6 +245,7 @@ preferences.code_completion = Code completion with preferences.trigger_with = Trigger with preferences.cmd_space = space preferences.suggest_imports = Suggest import statements +preferences.increase_memory=Increase maximum available memory preferences.increase_max_memory = Increase maximum available memory to # preferences.delete_previous_folder_on_export = Delete previous folder on export preferences.check_for_updates_on_startup = Allow update checking (see FAQ for information shared) @@ -259,6 +260,25 @@ preferences.file = More preferences can be edited directly in the file: preferences.file.hint = (Edit only when Processing is not running.) preferences.other=Show experimental settings preferences.other.tip=These settings are contained in the preferences.txt file and are not officially supported. They may be removed or changed without notice in future versions of Processing. +# Preferences (Experimental Pane) +# Keys from the comments of defaults.txt (Nov 2025) +preferences.contribution.backup.on_remove=Backup contributions when "Remove" button is pressed +preferences.contribution.backup.on_remove.tip=When enabled, a backup copy of the contribution will be created in your sketchbook "tools", "modes", "libraries", or "examples" folder when you remove it via the Contribution Manager. +preferences.contribution.backup.on_install=Backup contributions when installing a newer version +preferences.contribution.backup.on_install.tip=When enabled, a backup copy of the contribution will be created in your sketchbook "tools", "modes", "libraries", or "examples" folder when you install a newer version via the Contribution Manager. +preferences.recent.count=Number of recent sketches to show +preferences.chooser.files.native=Use native file chooser dialogs +preferences.theme.gradient.method=Gradient method for themes +preferences.theme.gradient.method.tip=Set to 'lab' to interpolate theme gradients using L*a*b* color space +preferences.platform.auto_file_type_associations=Automatically set file type associations (Windows only) +preferences.platform.auto_file_type_associations.tip=When enabled, Processing will attempt to set itself as the default application for .pde files on Windows systems. +preferences.editor.window.width.default=Default editor window width +preferences.editor.window.height.default=Default editor window height +preferences.editor.window.width.min=Minimum editor window width +preferences.editor.window.height.min=Minimum editor window height +preferences.editor.smooth=Enable antialiasing in the code editor +preferences.editor.caret.blink=Blink the caret +preferences.editor.caret.block=Use block caret # Sketchbook Location (Frame) sketchbook_location = Select new sketchbook folder diff --git a/core/src/processing/awt/ShimAWT.java b/core/src/processing/awt/ShimAWT.java index 901f359bb2..304b8dd2ac 100644 --- a/core/src/processing/awt/ShimAWT.java +++ b/core/src/processing/awt/ShimAWT.java @@ -1,34 +1,29 @@ package processing.awt; +import processing.core.PApplet; +import processing.core.PConstants; +import processing.core.PImage; + +import javax.imageio.*; +import javax.imageio.metadata.IIOInvalidTreeException; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; +import javax.swing.*; +import javax.swing.filechooser.FileSystemView; import java.awt.*; import java.awt.color.ColorSpace; +import java.awt.geom.AffineTransform; import java.awt.image.*; -import java.io.*; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; import java.util.Iterator; -import java.awt.geom.AffineTransform; import java.util.Map; - -import javax.imageio.IIOImage; -import javax.imageio.ImageIO; -import javax.imageio.ImageTypeSpecifier; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.metadata.IIOInvalidTreeException; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.metadata.IIOMetadataNode; -import javax.swing.ImageIcon; -import javax.swing.JFileChooser; -import javax.swing.UIManager; - -// used by desktopFile() method -import javax.swing.filechooser.FileSystemView; - -import processing.core.PApplet; -import processing.core.PConstants; -import processing.core.PImage; +import java.util.function.Consumer; /** @@ -809,41 +804,51 @@ static public void selectImpl(final String prompt, final Object callbackObject, final Frame parentFrame, final int mode) { - File selectedFile = null; + selectImpl(prompt, defaultSelection, parentFrame, mode, (selectedFile) -> + PApplet.selectCallback(selectedFile, callbackMethod, callbackObject) + ); + } - if (PApplet.useNativeSelect) { - FileDialog dialog = new FileDialog(parentFrame, prompt, mode); - if (defaultSelection != null) { - dialog.setDirectory(defaultSelection.getParent()); - dialog.setFile(defaultSelection.getName()); - } + static public void selectImpl(final String prompt, + final File defaultSelection, + final Frame parentFrame, + final int mode, + final Consumer callback) { + File selectedFile = null; + + if (PApplet.useNativeSelect) { + FileDialog dialog = new FileDialog(parentFrame, prompt, mode); + if (defaultSelection != null) { + dialog.setDirectory(defaultSelection.getParent()); + dialog.setFile(defaultSelection.getName()); + } - dialog.setVisible(true); - String directory = dialog.getDirectory(); - String filename = dialog.getFile(); - if (filename != null) { - selectedFile = new File(directory, filename); - } + dialog.setVisible(true); + String directory = dialog.getDirectory(); + String filename = dialog.getFile(); + if (filename != null) { + selectedFile = new File(directory, filename); + } - } else { - JFileChooser chooser = new JFileChooser(); - chooser.setDialogTitle(prompt); - if (defaultSelection != null) { - chooser.setSelectedFile(defaultSelection); - } + } else { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(prompt); + if (defaultSelection != null) { + chooser.setSelectedFile(defaultSelection); + } - int result = -1; - if (mode == FileDialog.SAVE) { - result = chooser.showSaveDialog(parentFrame); - } else if (mode == FileDialog.LOAD) { - result = chooser.showOpenDialog(parentFrame); - } - if (result == JFileChooser.APPROVE_OPTION) { - selectedFile = chooser.getSelectedFile(); - } + int result = -1; + if (mode == FileDialog.SAVE) { + result = chooser.showSaveDialog(parentFrame); + } else if (mode == FileDialog.LOAD) { + result = chooser.showOpenDialog(parentFrame); + } + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile = chooser.getSelectedFile(); + } + } + callback.accept(selectedFile); } - PApplet.selectCallback(selectedFile, callbackMethod, callbackObject); - } static public void selectFolder(final String prompt, @@ -854,6 +859,12 @@ static public void selectFolder(final String prompt, defaultSelection, callbackObject, null)); } + static public void selectFolder(final String prompt, + final File defaultSelection, + final Consumer callback) { + selectFolderImpl(prompt, defaultSelection, null, callback); + } + /* static public void selectFolder(final String prompt, @@ -886,6 +897,15 @@ static public void selectFolderImpl(final String prompt, final File defaultSelection, final Object callbackObject, final Frame parentFrame) { + selectFolderImpl(prompt, defaultSelection, parentFrame, (selectedFile) -> + PApplet.selectCallback(selectedFile, callbackMethod, callbackObject) + ); + } + + static public void selectFolderImpl(final String prompt, + final File defaultSelection, + final Frame parentFrame, + final Consumer callback) { File selectedFile = null; if (PApplet.platform == PConstants.MACOS && PApplet.useNativeSelect) { FileDialog fileDialog = @@ -914,7 +934,7 @@ static public void selectFolderImpl(final String prompt, selectedFile = fileChooser.getSelectedFile(); } } - PApplet.selectCallback(selectedFile, callbackMethod, callbackObject); + callback.accept(selectedFile); } From 891aa8092da83159a606e2e0013fe1b6def7abde Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 6 Nov 2025 07:45:14 +0100 Subject: [PATCH 37/39] Fixed a color issue --- app/src/processing/app/ui/PDEPreferences.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt index 62d0eaef3d..735837fee6 100644 --- a/app/src/processing/app/ui/PDEPreferences.kt +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -374,7 +374,7 @@ fun PDEPreferencePane.showPane(groups: PDEPreferenceGroups) { modifier = Modifier .fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, ), border = BorderStroke( width = 1.dp, From 6ee68175cbdc99bb61d8f89aad306d1a1a22db5f Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 6 Nov 2025 07:58:57 +0100 Subject: [PATCH 38/39] Improve preferences UI layout and window size Increased the preferences window width from 800 to 850 pixels for better layout. Updated the General preferences to display FilterChip options in rows with spacing, improving visual organization and usability. --- app/src/processing/app/ui/PDEPreferences.kt | 2 +- .../processing/app/ui/preferences/General.kt | 30 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt index 735837fee6..8474fba5c2 100644 --- a/app/src/processing/app/ui/PDEPreferences.kt +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -38,7 +38,7 @@ fun show() { PDESwingWindow( titleKey = "preferences", fullWindowContent = true, - size = Dimension(800, 600) + size = Dimension(850, 600) ) { PDETheme { preferences() diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt index b560bbc1d5..282d10cd7b 100644 --- a/app/src/processing/app/ui/preferences/General.kt +++ b/app/src/processing/app/ui/preferences/General.kt @@ -1,13 +1,16 @@ package processing.app.ui.preferences import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import processing.app.Preferences import processing.app.SketchName import processing.app.ui.EditorFooter.copyDebugInformationToClipboard @@ -70,20 +73,25 @@ class General { pane = general, control = { preference, updatePreference -> Column { - for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + val options = if (Preferences.isInitialized()) SketchName.getOptions() else arrayOf( "timestamp", "untitled", "custom" - )) { - FilterChip( - selected = preference == option, - onClick = { - updatePreference(option) - }, - label = { - Text(option) - }, - ) + ) + options.toList().chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { option -> + FilterChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + ) + } + } } } } From af4228a13f79f2bd669222c2439e1c62e701d0a7 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 6 Nov 2025 14:58:17 +0100 Subject: [PATCH 39/39] Add theme selection and UI improvements to preferences Introduces a theme selector for the editor in the Interface preferences, allowing users to choose between system, dark, and light themes. Updates Coding and General preferences with improved layout and feedback, including a copied state for diagnostics. Updates localization strings to support new features and labels. --- app/src/processing/app/ui/PDEPreferences.kt | 7 +++- .../processing/app/ui/preferences/Coding.kt | 29 ++++++++++++-- .../processing/app/ui/preferences/General.kt | 21 ++++++++-- .../app/ui/preferences/Interface.kt | 39 +++++++++++++++++++ app/src/processing/app/ui/theme/Theme.kt | 11 +++++- build/shared/lib/languages/PDE.properties | 8 ++++ 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt index 8474fba5c2..747b5d92b5 100644 --- a/app/src/processing/app/ui/PDEPreferences.kt +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -38,7 +38,8 @@ fun show() { PDESwingWindow( titleKey = "preferences", fullWindowContent = true, - size = Dimension(850, 600) + size = Dimension(850, 600), + minSize = Dimension(700, 500), ) { PDETheme { preferences() @@ -194,8 +195,10 @@ class PDEPreferences { style = MaterialTheme.typography.bodySmall, ) } + Spacer(modifier = Modifier.width(96.dp)) SearchBar( - modifier = Modifier, + modifier = Modifier + .widthIn(max = 250.dp), inputField = { SearchBarDefaults.InputField( query = preferencesQuery, diff --git a/app/src/processing/app/ui/preferences/Coding.kt b/app/src/processing/app/ui/preferences/Coding.kt index daee85b7f7..dc6d0cbcae 100644 --- a/app/src/processing/app/ui/preferences/Coding.kt +++ b/app/src/processing/app/ui/preferences/Coding.kt @@ -1,13 +1,21 @@ package processing.app.ui.preferences +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EditNote import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import processing.app.ui.PDEPreference import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.theme.LocalLocale class Coding { companion object { @@ -45,11 +53,24 @@ class Coding { key = "pdex.completion", descriptionKey = "preferences.code_completion", pane = coding, + noTitle = true, control = { preference, setPreference -> - Switch( - checked = preference?.toBoolean() ?: false, - onCheckedChange = { setPreference(it.toString()) } - ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val locale = LocalLocale.current + Text( + text = locale["preferences.code_completion"] + " Ctrl-" + locale["preferences.cmd_space"], + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } } ), PDEPreference( diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt index 282d10cd7b..a8bd559033 100644 --- a/app/src/processing/app/ui/preferences/General.kt +++ b/app/src/processing/app/ui/preferences/General.kt @@ -9,8 +9,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import processing.app.Preferences import processing.app.SketchName import processing.app.ui.EditorFooter.copyDebugInformationToClipboard @@ -81,7 +83,7 @@ class General { options.toList().chunked(2).forEach { row -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { row.forEach { option -> - FilterChip( + InputChip( selected = preference == option, onClick = { updatePreference(option) @@ -98,7 +100,7 @@ class General { ), PDEPreference( key = "editor.sync_folder_and_filename", - labelKey = "preferences.new", + labelKey = "preferences.experimental", descriptionKey = "preferences.sync_folder_and_filename", pane = general, control = { preference, updatePreference -> @@ -147,10 +149,23 @@ class General { descriptionKey = "preferences.diagnostics", pane = general, control = { preference, updatePreference -> + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } Button(onClick = { copyDebugInformationToClipboard() + copied = true + }) { - Text(LocalLocale.current["preferences.diagnostics.button"]) + if (!copied) { + Text(LocalLocale.current["preferences.diagnostics.button"]) + } else { + Text(LocalLocale.current["preferences.diagnostics.button.copied"]) + } } } ) diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt index 0edbde0c29..b494db69d3 100644 --- a/app/src/processing/app/ui/preferences/Interface.kt +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -71,6 +71,45 @@ class Interface { ) ) PDEPreferences.register( + PDEPreference( + key = "editor.theme", + descriptionKey = "preferences.editor.theme", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InputChip( + selected = (preference ?: "") == "", + onClick = { + updatePreference("") + }, + label = { + Text(locale["preferences.editor.theme.system"]) + } + ) + InputChip( + selected = preference == "dark", + onClick = { + updatePreference("dark") + }, + label = { + Text(locale["preferences.editor.theme.dark"]) + } + ) + InputChip( + selected = preference == "light", + onClick = { + updatePreference("light") + }, + label = { + Text(locale["preferences.editor.theme.light"]) + } + ) + } + } + ), PDEPreference( key = "editor.zoom", descriptionKey = "preferences.interface_scale", diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index c59c5025cd..e9c9bb7516 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import darkScheme import lightScheme +import processing.app.LocalPreferences import processing.app.PreferencesProvider /** @@ -52,8 +53,16 @@ fun PDETheme( ){ PreferencesProvider { LocaleProvider { + val preferences = LocalPreferences.current + val theme = when { + preferences["editor.theme"] == "dark" -> darkScheme + preferences["editor.theme"] == "light" -> lightScheme + darkTheme -> darkScheme + else -> lightScheme + + } MaterialTheme( - colorScheme = if(darkTheme) darkScheme else lightScheme, + colorScheme = theme, typography = PDETypography ){ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index f6b004e7a3..c36d59ff02 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -213,6 +213,7 @@ preferences.pane.sketches=Sketches preferences.pane.other=Experimental preferences.new=New preferences.reset=Reset to Defaults +preferences.experimental=Experimental preferences.no_results=No results found preferences.sync_folder_and_filename=Folder name matches sketch name preferences.sync_folder_and_filename.tip=When enabled, renaming a sketch will also rename its folder to match the sketch name. [Learn more](https://discourse.processing.org/t/sketch-folder-and-sketch-name-syncing/15345) @@ -220,6 +221,7 @@ preferences.show_welcome_screen=Show welcome screen at startup preferences.diagnostics=Generate diagnostic report for support preferences.diagnostics.tip=Copies information about your installation into your clipboard, useful for troubleshooting issues. preferences.diagnostics.button=Generate Report +preferences.diagnostics.button.copied=Report copied to clipboard preferences.button.width = 80 preferences.restart_required = Restart Processing to apply changes preferences.sketchbook_location = Sketchbook folder @@ -231,6 +233,12 @@ preferences.editor_and_console_font=Editor and Console font preferences.editor_and_console_font.tip=Installed Monospaced fonts will be displayed as options. preferences.editor_font_size=Editor font size preferences.console_font_size=Console font size +preferences.editor.theme=Theme +preferences.editor.theme.tip=Choose a color theme for windows except for the editor. +preferences.editor.theme.system=System +preferences.editor.theme.light=Light +preferences.editor.theme.dark=Dark +preferences.interface_theme=Interface theme preferences.interface_scale=Interface scale preferences.interface_scale.tip=Adjust the size of interface elements. preferences.interface_scale.auto = Automatic