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/build.gradle.kts b/app/build.gradle.kts
index 0d3fcbd12d..6c8ac55f00 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
@@ -16,6 +17,7 @@ plugins{
alias(libs.plugins.compose.compiler)
alias(libs.plugins.jetbrainsCompose)
+
alias(libs.plugins.serialization)
alias(libs.plugins.download)
}
@@ -59,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"
@@ -107,25 +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/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 2551a54d64..1d3bb27b4a 100644
--- a/app/src/processing/app/Base.java
+++ b/app/src/processing/app/Base.java
@@ -23,17 +23,6 @@
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;
@@ -41,9 +30,22 @@
import processing.app.tools.Tool;
import processing.app.ui.*;
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
@@ -374,13 +376,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(base);
}
}
@@ -608,7 +604,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;
@@ -1884,7 +1880,7 @@ public void handleRestart() {
// }
- public void thinkDifferentExamples() {
+ public void showExamplesFrame() {
nextMode.showExamplesFrame();
}
@@ -2190,10 +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();
+ PDEPreferencesKt.show();
}
diff --git a/app/src/processing/app/Messages.kt b/app/src/processing/app/Messages.kt
index cae54e6e97..e8f0979454 100644
--- a/app/src/processing/app/Messages.kt
+++ b/app/src/processing/app/Messages.kt
@@ -18,13 +18,36 @@
*/
package processing.app
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+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.*
+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
+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 java.awt.EventQueue
+import processing.app.ui.theme.PDETheme
+import java.awt.Dimension
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 +60,23 @@ class Messages {
if (Base.isCommandLine()) {
println("$title: $message")
} else {
- JOptionPane.showMessageDialog(
- Frame(), message, title,
- JOptionPane.INFORMATION_MESSAGE
- )
+ showDialog(title) { modifier, dismiss ->
+ AlertDialog(
+ modifier = modifier,
+ onDismissRequest = { },
+ shape = RectangleShape,
+ icon = { Icon(Icons.Default.Info, contentDescription = "Info!") },
+ title = { Text(title) },
+ text = { Text(message) },
+ confirmButton = {
+ Button(
+ onClick = { dismiss() }
+ ) {
+ Text("OK")
+ }
+ }
+ )
+ }
}
}
@@ -57,10 +93,27 @@ class Messages {
if (Base.isCommandLine()) {
println("$title: $message")
} else {
- JOptionPane.showMessageDialog(
- Frame(), message, title,
- JOptionPane.WARNING_MESSAGE
- )
+ showDialog(title){ modifier, dismiss ->
+ AlertDialog(
+ modifier = modifier,
+ onDismissRequest = { },
+ shape = RectangleShape,
+ 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 +133,30 @@ 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){ modifier, dismiss ->
+ AlertDialog(
+ modifier = modifier,
+ onDismissRequest = { },
+ shape = RectangleShape,
+ 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 +174,28 @@ class Messages {
if (Base.isCommandLine()) {
System.err.println("$title: $message")
} else {
- JOptionPane.showMessageDialog(
- Frame(), message, title,
- JOptionPane.ERROR_MESSAGE
- )
+ showDialog(title){ modifier, dismiss ->
+ AlertDialog(
+ modifier = modifier,
+ onDismissRequest = { },
+ shape = RectangleShape,
+ 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 +229,8 @@ class Messages {
if (fatal) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
)
+
+
if (fatal) {
System.exit(1)
}
@@ -271,6 +363,63 @@ class Messages {
}
}
+@OptIn(ExperimentalComposeUiApi::class)
+fun showDialog(title: String, content: @Composable (modifier: Modifier, dismiss: () -> Unit) -> Unit) {
+ ComposeDialog().apply {
+ isModal = true
+ setTitle(title)
+ size = Dimension(400, 400)
+ rootPane.putClientProperty("apple.awt.fullWindowContent", true)
+ rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
+ rootPane.putClientProperty("apple.awt.windowTitleVisible", false);
+
+
+ setContent {
+ PDETheme {
+ val density = LocalDensity.current
+ content(Modifier.onSizeChanged{
+ size = Dimension((it.width / density.density).toInt(), (it.height / density.density).toInt())
+ setLocationRelativeTo(null)
+ },::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/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..c13309299f 100644
--- a/app/src/processing/app/Preferences.kt
+++ b/app/src/processing/app/Preferences.kt
@@ -2,56 +2,191 @@ 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
+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
+ 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)
+ }
+
+ 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) {
+ 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 +197,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/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/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
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/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..747b5d92b5
--- /dev/null
+++ b/app/src/processing/app/ui/PDEPreferences.kt
@@ -0,0 +1,568 @@
+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(850, 600),
+ minSize = Dimension(700, 500),
+ ) {
+ 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() })
+ }
+
+ /**
+ * 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
+ .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,
+ )
+ }
+ Spacer(modifier = Modifier.width(96.dp))
+ SearchBar(
+ modifier = Modifier
+ .widthIn(max = 250.dp),
+ 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(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ 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.surfaceContainerLowest,
+ ),
+ 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/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt
new file mode 100644
index 0000000000..d9cae78f15
--- /dev/null
+++ b/app/src/processing/app/ui/PDEWelcome.kt
@@ -0,0 +1,643 @@
+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.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
+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
+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.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.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.PinDrop
+import androidx.compose.material.icons.outlined.School
+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.HorizontalDivider
+import androidx.compose.material3.Icon
+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.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
+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.ImageBitmap
+import androidx.compose.ui.graphics.decodeToImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.onPointerEvent
+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.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.io.path.Path
+import kotlin.io.path.exists
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun PDEWelcome(base: Base? = null) {
+ Row(
+ modifier = Modifier
+ .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)
+ .height(32.dp)
+ val textColor = if(isSystemInDarkTheme()) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer
+ val locale = LocalLocale.current
+
+ /**
+ * Left main column
+ */
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxHeight()
+ .weight(0.8f)
+ .padding(
+ top = 48.dp,
+ start = 56.dp,
+ end = 64.dp,
+ bottom = 56.dp
+ )
+ ) {
+ /**
+ * Title row
+ */
+ Row (
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ){
+ Image(
+ painter = painterResource("logo.svg"),
+ modifier = Modifier
+ .size(50.dp),
+ contentDescription = locale["welcome.processing.logo"]
+ )
+ Text(
+ text = locale["welcome.processing.title"],
+ 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,
+ shape = shape
+ ){
+ 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)
+ }
+ }
+ }
+ /**
+ * 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()
+ .height(IntrinsicSize.Min),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ ){
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(48.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(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = locale["welcome.resources.title"],
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ TextButton(
+ onClick = {
+ Platform.openURL("https://processing.org/tutorials/gettingstarted")
+ },
+ contentPadding = xsPadding,
+ modifier = xsModifier,
+ colors = colors
+ ) {
+ Icon(Icons.Outlined.PinDrop, contentDescription = "", modifier = Modifier.size(20.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = locale["welcome.resources.get_started"],
+ )
+ }
+ TextButton(
+ onClick = {
+ Platform.openURL("https://processing.org/tutorials")
+ },
+ contentPadding = xsPadding,
+ modifier = xsModifier,
+ colors = colors
+ ) {
+ Icon(Icons.Outlined.School, contentDescription = "", modifier = Modifier.size(20.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = locale["welcome.resources.tutorials"],
+ )
+ }
+ TextButton(
+ onClick = {
+ Platform.openURL("https://processing.org/reference")
+ },
+ contentPadding = xsPadding,
+ modifier = xsModifier,
+ colors = colors
+ ) {
+ Icon(Icons.Outlined.Book, contentDescription = "", modifier = Modifier.size(20.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = locale["welcome.resources.documentation"],
+ )
+ }
+ }
+ 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(48.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ 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(
+ text = locale["welcome.community.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(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ TextButton(
+ onClick = {
+ Platform.openURL("https://github.com/processing/processing4")
+ },
+ 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://www.instagram.com/processing_core/")
+ },
+ contentPadding = xsPadding,
+ modifier = xsModifier,
+ colors = colors
+ ) {
+ Icon(
+ painterResource("icons/Instagram.svg"),
+ contentDescription = "",
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Instagram")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ /**
+ * 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
+ .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
+ )
+ }
+ }
+ }
+ /**
+ * Examples list
+ */
+ val scrollMargin = 35.dp
+ Column(
+ modifier = Modifier
+ .width(350.dp + scrollMargin)
+ ) {
+ val examples = remember { mutableStateListOf(
+ *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 {
+ 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
+ }
+ examples.clear()
+ examples.addAll(sketches.shuffled().take(20))
+ }
+ val state = rememberLazyListState(
+ initialFirstVisibleItemScrollOffset = 150
+ )
+ Box(
+ modifier = Modifier
+ .padding(end = 4.dp)
+ ) {
+ LazyColumn(
+ state = state,
+ contentPadding = PaddingValues(top = 12.dp, bottom = 12.dp, end = 20.dp, start = scrollMargin),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(examples) { example ->
+ example.card{
+ base?.let {
+ base.handleOpen("${example.path}/${example.name}.pde")
+ } ?: noBaseWarning()
+ }
+ }
+ }
+ VerticalScrollbar(
+ modifier = Modifier
+ .fillMaxHeight()
+ .align(Alignment.CenterEnd),
+ adapter = rememberScrollbarAdapter(state)
+ )
+ }
+ }
+ }
+}
+
+@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(
+ "No Base",
+ "No Base instance provided, this ui is likely being previewed."
+ )
+}
+
+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(),
+ unique = WelcomeScreen::class,
+ fullWindowContent = true
+ ) {
+ PDEWelcome(base)
+ }
+}
+
+
+fun main(){
+ application {
+ PDEComposeWindow(titleKey = titleKey, size = size, fullWindowContent = true) {
+ PDETheme(darkTheme = true) {
+ PDEWelcome()
+ }
+ }
+ PDEComposeWindow(titleKey = titleKey, size = size, fullWindowContent = true) {
+ PDETheme(darkTheme = false) {
+ PDEWelcome()
+ }
+ }
+ }
+}
\ No newline at end of file
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/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt
index 7757e820f6..531c28f7ef 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,17 @@ 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.PDEComposeWindow
+import processing.app.ui.theme.PDESwingWindow
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.KeyAdapter
@@ -54,52 +54,26 @@ 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(
modifier = Modifier
.padding(20.dp, 10.dp)
- .size(windowSize.width.dp, windowSize.height.dp),
+ .fillMaxSize(),
horizontalArrangement = Arrangement
.spacedBy(20.dp)
){
@@ -109,7 +83,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 +97,7 @@ class WelcomeToBeta {
) {
Text(
text = locale["beta.title"],
- style = typography.subtitle1,
+ style = typography.titleLarge,
)
val text = locale["beta.message"]
.replace('$' + "version", getVersionName())
@@ -131,81 +105,30 @@ 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,
- 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/preferences/Coding.kt b/app/src/processing/app/ui/preferences/Coding.kt
new file mode 100644
index 0000000000..dc6d0cbcae
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/Coding.kt
@@ -0,0 +1,90 @@
+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 {
+ 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,
+ noTitle = true,
+ control = { preference, setPreference ->
+ 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(
+ 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
new file mode 100644
index 0000000000..a8bd559033
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/General.kt
@@ -0,0 +1,175 @@
+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.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
+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 {
+ companion object{
+ val general = PDEPreferencePane(
+ nameKey = "preferences.pane.general",
+ icon = {
+ Icon(Icons.Default.Settings, contentDescription = "General Preferences")
+ }
+ )
+
+ fun register() {
+ PDEPreferences.register(
+ PDEPreference(
+ key = "sketchbook.path.four",
+ descriptionKey = "preferences.sketchbook_location",
+ pane = general,
+ noTitle = true,
+ control = { preference, updatePreference ->
+ 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,
+ modifier = Modifier
+ .clickable {
+ selectFolder(
+ locale["preferences.sketchbook_location.popup"],
+ File(preference ?: "")
+ ) { selectedFile: File? ->
+ if (selectedFile != null) {
+ updatePreference(selectedFile.absolutePath)
+ }
+ }
+ }
+ )
+ }
+ )
+ }
+ ),
+ PDEPreference(
+ key = "sketch.name.approach",
+ descriptionKey = "preferences.sketch_naming",
+ pane = general,
+ control = { preference, updatePreference ->
+ Column {
+ val options = if (Preferences.isInitialized()) SketchName.getOptions() else arrayOf(
+ "timestamp",
+ "untitled",
+ "custom"
+ )
+ options.toList().chunked(2).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ row.forEach { option ->
+ InputChip(
+ selected = preference == option,
+ onClick = {
+ updatePreference(option)
+ },
+ label = {
+ Text(option)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ ),
+ PDEPreference(
+ key = "editor.sync_folder_and_filename",
+ labelKey = "preferences.experimental",
+ 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.update_check",
+ pane = general,
+ control = { preference, updatePreference ->
+ Switch(
+ checked = preference.toBoolean(),
+ onCheckedChange = {
+ updatePreference(it.toString())
+ }
+ )
+ }
+ )
+ )
+ PDEPreferences.register(
+ PDEPreference(
+ key = "welcome.four.show",
+ descriptionKey = "preferences.show_welcome_screen",
+ pane = general,
+ control = { preference, updatePreference ->
+ Switch(
+ checked = preference.toBoolean(),
+ onCheckedChange = {
+ updatePreference(it.toString())
+ }
+ )
+ }
+ )
+ )
+ PDEPreferences.register(
+ PDEPreference(
+ key = "welcome.show",
+ 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
+
+ }) {
+ if (!copied) {
+ Text(LocalLocale.current["preferences.diagnostics.button"])
+ } else {
+ Text(LocalLocale.current["preferences.diagnostics.button.copied"])
+ }
+ }
+ }
+ )
+ )
+ }
+ }
+}
\ 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..b494db69d3
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/Interface.kt
@@ -0,0 +1,265 @@
+package processing.app.ui.preferences
+
+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.*
+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.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.*
+
+class Interface {
+ companion object{
+ 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",
+ 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.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",
+ pane = interfaceAndFonts,
+ control = { preference, updatePreference ->
+ val range = 100f..300f
+
+ val prefs = LocalPreferences.current
+ 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)
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = 200.dp)
+ ) {
+ Text(
+ text = if (automatic) "Auto" else zoomPerc,
+ )
+ Slider(
+ value = currentZoom,
+ onValueChange = {
+ currentZoom = it
+ },
+ onValueChangeFinished = {
+ prefs["editor.zoom.auto"] = automatic
+ updatePreference(zoomPerc)
+ },
+ valueRange = range,
+ steps = 3
+ )
+ }
+ }
+ }
+ )
+ )
+
+ PDEPreferences.register(
+ PDEPreference(
+ key = "editor.font.family",
+ descriptionKey = "preferences.editor_and_console_font",
+ pane = interfaceAndFonts,
+ control = { preference, updatePreference ->
+ var showOptions by remember { mutableStateOf(false) }
+ 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 = {
+ showOptions = false
+ },
+ ) {
+ families.forEach { family ->
+ DropdownMenuItem(
+ text = { Text(family) },
+ onClick = {
+ updatePreference(family)
+ showOptions = false
+ }
+ )
+ }
+
+ }
+ }
+ ),
+ 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
+ )
+ }
+ }
+ ), 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
+ 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/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt
new file mode 100644
index 0000000000..b637a55f44
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/Other.kt
@@ -0,0 +1,97 @@
+package processing.app.ui.preferences
+
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Science
+import androidx.compose.material3.Icon
+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.PDEPreference
+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{
+ val other = PDEPreferencePane(
+ nameKey = "preferences.pane.other",
+ icon = {
+ Icon(Icons.Default.Science, contentDescription = "Other Preferences")
+ },
+ after = sketches
+ )
+
+ fun register(panes: PDEPreferencePanes) {
+ // TODO: Move to individual preferences
+ PDEPreferences.register(
+ PDEPreference(
+ key = "preferences.show_other",
+ descriptionKey = "preferences.other",
+ pane = other,
+ control = { preference, setPreference ->
+ val showOther = preference?.toBoolean() ?: false
+ Switch(
+ checked = showOther,
+ onCheckedChange = {
+ setPreference(it.toString())
+ }
+ )
+ if (!showOther) {
+ 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 =
+ panes[other]?.find { group -> group.any { preference -> preference.key == "preferences.show_other" } } as? MutableList
+
+ 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 descriptionKey = "preferences.$prefKey"
+ val preference = PDEPreference(
+ key = prefKey,
+ descriptionKey = if (locale.containsKey(descriptionKey)) descriptionKey else 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)
+ }
+ )
+ }
+ )
+ group?.add(preference)
+ }
+ onDispose {
+ group?.apply {
+ removeIf { it.key != "preferences.show_other" }
+ }
+ }
+ }
+ }
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
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..0a3b77375b
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/Sketches.kt
@@ -0,0 +1,161 @@
+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.LocalPreferences
+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 || (device == defaultDevice && preference == "-1")) {
+ ButtonDefaults.buttonColors()
+ } else {
+ ButtonDefaults.outlinedButtonColors()
+ },
+ shape = RoundedCornerShape(12.dp),
+ onClick = {
+ setPreference(if (device == defaultDevice) "-1" else 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",
+ 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") },
+ 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(64.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/app/src/processing/app/ui/theme/Colors.kt b/app/src/processing/app/ui/theme/Colors.kt
new file mode 100644
index 0000000000..af423ba488
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Colors.kt
@@ -0,0 +1,90 @@
+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(0xFF14508B)
+ }
+}
+
+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/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/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt
index 735d8e5b2a..e9c9bb7516 100644
--- a/app/src/processing/app/ui/theme/Theme.kt
+++ b/app/src/processing/app/ui/theme/Theme.kt
@@ -1,75 +1,350 @@
package processing.app.ui.theme
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material.Colors
-import androidx.compose.material.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.compositionLocalOf
+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.*
+import androidx.compose.runtime.*
+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.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 darkScheme
+import lightScheme
import processing.app.LocalPreferences
import processing.app.PreferencesProvider
-import java.io.InputStream
-import java.util.Properties
+/**
+ * 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 PDETheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+){
+ PreferencesProvider {
+ LocaleProvider {
+ val preferences = LocalPreferences.current
+ val theme = when {
+ preferences["editor.theme"] == "dark" -> darkScheme
+ preferences["editor.theme"] == "light" -> lightScheme
+ darkTheme -> darkScheme
+ else -> lightScheme
-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())
+ }
+ MaterialTheme(
+ colorScheme = theme,
+ typography = PDETypography
+ ){
+ 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,
+ hoverColor = MaterialTheme.colorScheme.outlineVariant
+ ),
+ LocalContentColor provides MaterialTheme.colorScheme.onSurface,
+// LocalDensity provides Density(1.25f, 1.25f),
+ content = content
+ )
+ }
+ }
+ }
}
}
-val LocalTheme = compositionLocalOf { error("No theme provided") }
-
-@Composable
-fun ProcessingTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- 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")
+/**
+ * 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") {
+ 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)) {
+ 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)) {
+ 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)) {
+ val section = colors.subList(7,11)
+ for((name, color, onColor) in section){
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = color),
+ onClick = {}) {
+ Text(name, color = onColor)
+ }
+ }
+ }
+ 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)
+ }
+ }
+ }
+ }
+ }
+ 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)
+
+ Text("titleLarge", style = MaterialTheme.typography.titleLarge)
+ Text("titleMedium", style = MaterialTheme.typography.titleMedium)
+ Text("titleSmall", style = MaterialTheme.typography.titleSmall)
- CompositionLocalProvider(LocalTheme provides theme) {
- LocaleProvider {
- MaterialTheme(
- colors = colors,
- typography = Typography,
- content = content
- )
+ 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("Card") {
+ Card{
+ Text("Hello, Tabs!", modifier = Modifier.padding(20.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/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt
new file mode 100644
index 0000000000..f725a999b5
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Window.kt
@@ -0,0 +1,238 @@
+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.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.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.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") }
+
+/**
+ * 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 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.
+ */
+class PDESwingWindow(
+ titleKey: String = "",
+ size: Dimension? = null,
+ minSize: Dimension? = null,
+ maxSize: Dimension? = null,
+ unique: KClass<*>? = null,
+ fullWindowContent: Boolean = false,
+ onClose: () -> Unit = {},
+ content: @Composable () -> Unit
+){
+ init{
+ 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 = window,
+ titleKey = titleKey,
+ unique = unique,
+ fullWindowContent = fullWindowContent,
+ content = content
+ )
+ }
+ window.addWindowStateListener {
+ if(it.newState == JFrame.DISPOSE_ON_CLOSE){
+ onClose()
+ }
+ }
+ isVisible = true
+ }
+ }
+}
+
+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.
+ */
+@Composable
+private fun PDEWindowContent(
+ window: ComposeWindow,
+ titleKey: String,
+ unique: KClass<*>? = null,
+ fullWindowContent: Boolean = false,
+ content: @Composable () -> Unit
+){
+ val mac = SystemInfo.isMacOS && SystemInfo.isMacFullWindowContentSupported
+ 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()
+ window.dispose()
+ return
+ }
+
+ DisposableEffect(unique){
+ unique?.let {
+ windows[it] = window
+ }
+ onDispose {
+ windows.remove(unique)
+ }
+ }
+
+ CompositionLocalProvider(LocalWindow provides window) {
+ PDETheme{
+ val locale = LocalLocale.current
+ window.title = locale[titleKey]
+ 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 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 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(
+ titleKey: String,
+ size: DpSize = DpSize.Unspecified,
+ minSize: DpSize = DpSize.Unspecified,
+ maxSize: DpSize = DpSize.Unspecified,
+ unique: KClass<*>? = null,
+ fullWindowContent: Boolean = false,
+ onClose: () -> Unit = {},
+ content: @Composable () -> Unit
+){
+ val windowState = rememberWindowState(
+ size = size,
+ position = WindowPosition(Alignment.Center)
+ )
+ Window(onCloseRequest = onClose, state = windowState, title = "") {
+ remember {
+ window.minimumSize = minSize.toDimension()
+ window.maximumSize = maxSize.toDimension()
+ }
+ PDEWindowContent(
+ window = window,
+ titleKey = titleKey,
+ unique = unique,
+ fullWindowContent = fullWindowContent,
+ content = 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
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
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
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
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 0000000000..0408641c61
Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf differ
diff --git a/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt b/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt
new file mode 100644
index 0000000000..6a314848b3
--- /dev/null
+++ b/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/build/shared/lib/fonts/SpaceGrotesk-Light.ttf b/build/shared/lib/fonts/SpaceGrotesk-Light.ttf
new file mode 100644
index 0000000000..d41bcccd86
Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Light.ttf differ
diff --git a/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf b/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf
new file mode 100644
index 0000000000..7d44b663b9
Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf differ
diff --git a/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf
new file mode 100644
index 0000000000..981bcf5b2c
Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf differ
diff --git a/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf
new file mode 100644
index 0000000000..e7e02e51e4
Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf differ
diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties
index 19a5c9f866..c36d59ff02 100644
--- a/build/shared/lib/languages/PDE.properties
+++ b/build/shared/lib/languages/PDE.properties
@@ -205,39 +205,88 @@ 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.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)
+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
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.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
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
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)
+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.
+# 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
@@ -640,6 +689,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.get_started = Get Started
+welcome.resources.tutorials = Tutorials
+welcome.resources.documentation = Reference
+welcome.community.title = Join our community
+welcome.community.forum = Forum
+welcome.sketch.open = Open
+
# ---------------------------------------
# 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...
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);
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 050502f4ca..a2a3edacc0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,9 +1,10 @@
[versions]
-kotlin = "2.0.20"
-compose-plugin = "1.7.1"
+kotlin = "2.2.20"
+compose-plugin = "1.9.1"
jogl = "2.5.0"
antlr = "4.13.2"
jupiter = "5.12.0"
+markdown = "0.37.0"
[libraries]
jogl = { module = "org.jogamp.jogl:jogl-all-main", version.ref = "jogl" }
@@ -31,14 +32,14 @@ antlr4Runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr" }
composeGradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-plugin" }
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinComposePlugin = { module = "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin", version.ref = "kotlin" }
-markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m2", version = "0.31.0" }
-markdownJVM = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version = "0.31.0" }
+markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown" }
+markdownJVM = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version.ref = "markdown" }
clikt = { module = "com.github.ajalt.clikt:clikt", version = "5.0.2" }
kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.6.3" }
+material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.9.0" }
[plugins]
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
-kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
download = { id = "de.undercouch.download", version = "5.6.0" }
diff --git a/java/src/processing/mode/java/JavaEditor.java b/java/src/processing/mode/java/JavaEditor.java
index 3fab2c8b17..7ce9e45be7 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(base);
});
menu.add(item);