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/default.png b/app/src/main/resources/default.png
new file mode 100644
index 0000000000..df13f36105
Binary files /dev/null and b/app/src/main/resources/default.png differ
diff --git a/app/src/main/resources/welcome/intro/bubble.svg b/app/src/main/resources/welcome/intro/bubble.svg
new file mode 100644
index 0000000000..a3997b1e79
--- /dev/null
+++ b/app/src/main/resources/welcome/intro/bubble.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/src/main/resources/welcome/intro/long.svg b/app/src/main/resources/welcome/intro/long.svg
new file mode 100644
index 0000000000..004418ce1f
--- /dev/null
+++ b/app/src/main/resources/welcome/intro/long.svg
@@ -0,0 +1,7 @@
+
diff --git a/app/src/main/resources/welcome/intro/short.svg b/app/src/main/resources/welcome/intro/short.svg
new file mode 100644
index 0000000000..d08759c01c
--- /dev/null
+++ b/app/src/main/resources/welcome/intro/short.svg
@@ -0,0 +1,17 @@
+
diff --git a/app/src/main/resources/welcome/intro/wavy.svg b/app/src/main/resources/welcome/intro/wavy.svg
new file mode 100644
index 0000000000..b244066fa1
--- /dev/null
+++ b/app/src/main/resources/welcome/intro/wavy.svg
@@ -0,0 +1,7 @@
+
diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java
index 2551a54d64..e3eae12fb8 100644
--- a/app/src/processing/app/Base.java
+++ b/app/src/processing/app/Base.java
@@ -40,6 +40,7 @@
import processing.app.contrib.*;
import processing.app.tools.Tool;
import processing.app.ui.*;
+import processing.app.ui.PreferencesKt;
import processing.app.ui.Toolkit;
import processing.core.*;
import processing.data.StringList;
@@ -2190,10 +2191,11 @@ static private Mode findSketchMode(File folder, List modeList) {
* Show the Preferences window.
*/
public void handlePrefs() {
- if (preferencesFrame == null) {
- preferencesFrame = new PreferencesFrame(this);
- }
- preferencesFrame.showFrame();
+// if (preferencesFrame == null) {
+// preferencesFrame = new PreferencesFrame(this);
+// }
+// preferencesFrame.showFrame();
+ PreferencesKt.show();
}
diff --git a/app/src/processing/app/Language.java b/app/src/processing/app/Language.java
index d55c8b710c..bcc4385a53 100644
--- a/app/src/processing/app/Language.java
+++ b/app/src/processing/app/Language.java
@@ -183,6 +183,12 @@ static public Language init() {
return instance;
}
+ static public void reload(){
+ if(instance == null) return;
+ synchronized (Language.class) {
+ instance = new Language();
+ }
+ }
static private String get(String key) {
LanguageBundle bundle = init().bundle;
diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java
index 640c77eade..8fcf7bb056 100644
--- a/app/src/processing/app/Preferences.java
+++ b/app/src/processing/app/Preferences.java
@@ -136,6 +136,14 @@ static public void skipInit() {
initialized = true;
}
+ /**
+ * Check whether Preferences.init() has been called. If not, we are probably not running the full application.
+ * @return true if Preferences has been initialized
+ */
+ static public boolean isInitialized() {
+ return initialized;
+ }
+
static void handleProxy(String protocol, String hostProp, String portProp) {
String proxyHost = get("proxy." + protocol + ".host");
diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt
index c5645c9bbc..c54cbbd817 100644
--- a/app/src/processing/app/Preferences.kt
+++ b/app/src/processing/app/Preferences.kt
@@ -2,56 +2,183 @@ package processing.app
import androidx.compose.runtime.*
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.launch
import java.io.File
import java.io.InputStream
import java.nio.file.*
import java.util.Properties
+/*
+ The ReactiveProperties class extends the standard Java Properties class
+ to provide reactive capabilities using Jetpack Compose's mutableStateMapOf.
+ This allows UI components to automatically update when preference values change.
+*/
+class ReactiveProperties: Properties() {
+ val snapshotStateMap = mutableStateMapOf()
+
+ override fun setProperty(key: String, value: String) {
+ super.setProperty(key, value)
+ snapshotStateMap[key] = value
+ }
+
+ override fun getProperty(key: String): String? {
+ return snapshotStateMap[key] ?: super.getProperty(key)
+ }
+
+ operator fun get(key: String): String? = getProperty(key)
+
+ operator fun set(key: String, value: String) {
+ setProperty(key, value)
+ }
+}
+
+/*
+ A CompositionLocal to provide access to the ReactiveProperties instance
+ throughout the composable hierarchy.
+ */
+val LocalPreferences = compositionLocalOf { error("No preferences provided") }
const val PREFERENCES_FILE_NAME = "preferences.txt"
const val DEFAULTS_FILE_NAME = "defaults.txt"
-fun PlatformStart(){
- Platform.inst ?: Platform.init()
-}
+/*
+ This composable function sets up a preferences provider that manages application settings.
+ It initializes the preferences from a file, watches for changes to that file, and saves
+ any updates back to the file. It uses a ReactiveProperties class to allow for reactive
+ updates in the UI when preferences change.
+ usage:
+ PreferencesProvider {
+ // Your app content here
+ }
+
+ to access preferences:
+ val preferences = LocalPreferences.current
+ val someSetting = preferences["someKey"] ?: "defaultValue"
+ preferences["someKey"] = "newValue"
+
+ This will automatically save to the preferences file and update any UI components
+ that are observing that key.
+
+ to override the preferences file (for testing, etc)
+ System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
+ to override the debounce time (in milliseconds)
+ System.setProperty("processing.app.preferences.debounce", "200")
+
+ */
+@OptIn(FlowPreview::class)
@Composable
-fun loadPreferences(): Properties{
- PlatformStart()
+fun PreferencesProvider(content: @Composable () -> Unit){
+ val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) }
+ val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull()
- val settingsFolder = Platform.getSettingsFolder()
- val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME)
+ // Initialize the platform (if not already done) to ensure we have access to the settings folder
+ remember {
+ Platform.init()
+ }
+ // Grab the preferences file, creating it if it doesn't exist
+ // TODO: This functionality should be separated from the `Preferences` class itself
+ val settingsFolder = Platform.getSettingsFolder()
+ val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME)
if(!preferencesFile.exists()){
+ preferencesFile.mkdirs()
preferencesFile.createNewFile()
}
- watchFile(preferencesFile)
- return Properties().apply {
- load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream())
- load(preferencesFile.inputStream())
+ val update = watchFile(preferencesFile)
+
+
+ val properties = remember(preferencesFile, update) {
+ ReactiveProperties().apply {
+ val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)
+ ?: InputStream.nullInputStream()
+ load(defaultsStream
+ .reader(Charsets.UTF_8)
+ )
+ load(preferencesFile
+ .inputStream()
+ .reader(Charsets.UTF_8)
+ )
+ }
+ }
+
+ val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
+
+ // Listen for changes to the preferences and save them to file
+ LaunchedEffect(properties) {
+ snapshotFlow { properties.snapshotStateMap.toMap() }
+ .dropWhile { it == initialState }
+ .debounce(preferencesDebounceOverride ?: 100)
+ .collect {
+
+ // Save the preferences to file, sorted alphabetically
+ preferencesFile.outputStream().use { output ->
+ output.write(
+ properties.entries
+ .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() })
+ .joinToString("\n") { (key, value) -> "$key=$value" }
+ .toByteArray()
+ )
+ }
+ }
+ }
+
+ CompositionLocalProvider(LocalPreferences provides properties){
+ content()
}
+
}
+/*
+ This composable function watches a specified file for modifications. When the file is modified,
+ it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates
+ or other actions in response to changes in the file.
+
+ To watch the file at the fasted speed (for testing) set the following system property:
+ System.setProperty("processing.app.watchfile.forced", "true")
+ */
@Composable
fun watchFile(file: File): Any? {
+ val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean()
+
val scope = rememberCoroutineScope()
var event by remember(file) { mutableStateOf?> (null) }
DisposableEffect(file){
val fileSystem = FileSystems.getDefault()
val watcher = fileSystem.newWatchService()
+
var active = true
+ // In forced mode we just poll the last modified time of the file
+ // This is not efficient but works better for testing with temp files
+ val toWatch = { file.lastModified() }
+ var state = toWatch()
+
val path = file.toPath()
val parent = path.parent
val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY)
scope.launch(Dispatchers.IO) {
while (active) {
- for (modified in key.pollEvents()) {
- if (modified.context() != path.fileName) continue
- event = modified
+ if(forcedWatch) {
+ if(toWatch() == state) continue
+ state = toWatch()
+ event = object : WatchEvent {
+ override fun count(): Int = 1
+ override fun context(): Path = file.toPath().fileName
+ override fun kind(): WatchEvent.Kind = StandardWatchEventKinds.ENTRY_MODIFY
+ override fun toString(): String = "ForcedEvent(${context()})"
+ }
+ continue
+ }else{
+ for (modified in key.pollEvents()) {
+ if (modified.context() != path.fileName) continue
+ event = modified
+ }
}
}
}
@@ -62,12 +189,4 @@ fun watchFile(file: File): Any? {
}
}
return event
-}
-val LocalPreferences = compositionLocalOf { error("No preferences provided") }
-@Composable
-fun PreferencesProvider(content: @Composable () -> Unit){
- val preferences = loadPreferences()
- CompositionLocalProvider(LocalPreferences provides preferences){
- content()
- }
}
\ No newline at end of file
diff --git a/app/src/processing/app/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..5b752b4046 100644
--- a/app/src/processing/app/ui/Editor.java
+++ b/app/src/processing/app/ui/Editor.java
@@ -1061,6 +1061,14 @@ public void buildDevelopMenu(){
});
developMenu.add(updateTrigger);
+ var error = new JMenuItem(Language.text("menu.develop.trigger.messages"));
+ error.addActionListener(e -> {
+ Messages.showWarning("Hello", "This is a test of the emergency broadcast system.\n\nIf this had been an actual emergency, you would be instructed to tune to your local news station.", new Exception("This is a test exception for developers."));
+ Messages.showYesNoQuestion(this, "Test Question", "Did you see the exception in the console?", null);
+ Messages.showError("Test Error", "This is a test error message. the editor will quit after this", new Exception("This is a test exception for developers."));
+ });
+ developMenu.add(error);
+
}
public void updateDevelopMenu(){
diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt
new file mode 100644
index 0000000000..7fd9f56350
--- /dev/null
+++ b/app/src/processing/app/ui/Preferences.kt
@@ -0,0 +1,325 @@
+package processing.app.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPlacement
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.debounce
+import processing.app.LocalPreferences
+import processing.app.ui.PDEPreferences.Companion.preferences
+import processing.app.ui.preferences.General
+import processing.app.ui.preferences.Interface
+import processing.app.ui.preferences.Other
+import processing.app.ui.theme.LocalLocale
+import processing.app.ui.theme.PDESwingWindow
+import processing.app.ui.theme.PDETheme
+import java.awt.Dimension
+import javax.swing.SwingUtilities
+
+val LocalPreferenceGroups = compositionLocalOf>> {
+ error("No Preference Groups Set")
+}
+
+class PDEPreferences {
+ companion object{
+ val groups = mutableStateMapOf>()
+ fun register(preference: PDEPreference) {
+ val list = groups[preference.group]?.toMutableList() ?: mutableListOf()
+ list.add(preference)
+ groups[preference.group] = list
+ }
+ init{
+ General.register()
+ Interface.register()
+ Other.register()
+ }
+
+ /**
+ * Composable function to display the preferences UI.
+ */
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun preferences(){
+ var visible by remember { mutableStateOf(groups) }
+ val sortedGroups = remember {
+ val keys = visible.keys
+ keys.toSortedSet {
+ a, b ->
+ when {
+ a.after == b -> 1
+ b.after == a -> -1
+ else -> a.name.compareTo(b.name)
+ }
+ }
+ }
+ var selected by remember { mutableStateOf(sortedGroups.first()) }
+ CompositionLocalProvider(
+ LocalPreferenceGroups provides visible
+ ) {
+ Row {
+ NavigationRail(
+ header = {
+ Text(
+ "Settings",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(top = 42.dp)
+ )
+
+ },
+ modifier = Modifier
+ .defaultMinSize(minWidth = 200.dp)
+ ) {
+
+ for (group in sortedGroups) {
+ NavigationRailItem(
+ selected = selected == group,
+ enabled = visible.keys.contains(group),
+ onClick = {
+ selected = group
+ },
+ icon = {
+ group.icon()
+ },
+ label = {
+ Text(group.name)
+ }
+ )
+ }
+ }
+ Box(modifier = Modifier.padding(top = 42.dp)) {
+ Column(modifier = Modifier
+ .fillMaxSize()
+ ) {
+ var query by remember { mutableStateOf("") }
+ val locale = LocalLocale.current
+ LaunchedEffect(query){
+
+ snapshotFlow { query }
+ .debounce(100)
+ .collect{
+ if(it.isBlank()){
+ visible = groups
+ return@collect
+ }
+ val filtered = mutableStateMapOf>()
+ for((group, preferences) in groups){
+ val matching = preferences.filter { preference ->
+ if(preference.key == "other"){
+ return@filter true
+ }
+ if(preference.key.contains(it, ignoreCase = true)){
+ return@filter true
+ }
+ val description = locale[preference.descriptionKey]
+ description.contains(it, ignoreCase = true)
+ }
+ if(matching.isNotEmpty()){
+ filtered[group] = matching
+ }
+ }
+ visible = filtered
+ }
+
+ }
+ SearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query,
+ onQueryChange = {
+ query = it
+ },
+ onSearch = {
+
+ },
+ expanded = false,
+ onExpandedChange = { },
+ placeholder = { Text("Search") }
+ )
+ },
+ expanded = false,
+ onExpandedChange = {},
+ modifier = Modifier.align(Alignment.End).padding(16.dp)
+ ) {
+
+ }
+
+ val preferences = visible[selected] ?: emptyList()
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ items(preferences){ preference ->
+ preference.showControl()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+
+ @JvmStatic
+ fun main(args: Array) {
+ application {
+ Window(onCloseRequest = ::exitApplication){
+ remember{
+ window.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
+ window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
+ }
+ PDETheme(darkTheme = true) {
+ preferences()
+ }
+ }
+ Window(onCloseRequest = ::exitApplication){
+ remember{
+ window.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
+ window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
+ }
+ PDETheme(darkTheme = false) {
+ preferences()
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Data class representing a single preference in the preferences system.
+ *
+ * Usage:
+ * ```
+ * PDEPreferences.register(
+ * PDEPreference(
+ * key = "preference.key",
+ * descriptionKey = "preference.description",
+ * group = somePreferenceGroup,
+ * control = { preference, updatePreference ->
+ * // Composable UI to modify the preference
+ * }
+ * )
+ * )
+ * ```
+ */
+data class PDEPreference(
+ /**
+ * The key in the preferences file used to store this preference.
+ */
+ val key: String,
+ /**
+ * The key for the description of this preference, used for localization.
+ */
+ val descriptionKey: String,
+ /**
+ * The group this preference belongs to.
+ */
+ val group: PDEPreferenceGroup,
+ /**
+ * A Composable function that defines the control used to modify this preference.
+ * It takes the current preference value and a function to update the preference.
+ */
+ val control: @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit = { preference, updatePreference -> },
+
+ /**
+ * If true, no padding will be applied around this preference's UI.
+ */
+ val noPadding: Boolean = false,
+)
+
+/**
+ * Composable function to display the preference's description and control.
+ */
+@Composable
+private fun PDEPreference.showControl() {
+ val locale = LocalLocale.current
+ val prefs = LocalPreferences.current
+ Text(
+ text = locale[descriptionKey],
+ modifier = Modifier.padding(horizontal = 20.dp),
+ style = MaterialTheme.typography.titleMedium
+ )
+ val show = @Composable {
+ control(prefs[key]) { newValue ->
+ prefs[key] = newValue
+ }
+ }
+
+ if(noPadding){
+ show()
+ }else{
+ Box(modifier = Modifier.padding(horizontal = 20.dp)) {
+ show()
+ }
+ }
+
+}
+
+/**
+ * Data class representing a group of preferences.
+ */
+data class PDEPreferenceGroup(
+ /**
+ * The name of this group.
+ */
+ val name: String,
+ /**
+ * The icon representing this group.
+ */
+ val icon: @Composable () -> Unit,
+ /**
+ * The group that comes before this one in the list.
+ */
+ val after: PDEPreferenceGroup? = null,
+)
+
+fun show(){
+ SwingUtilities.invokeLater {
+ PDESwingWindow(
+ titleKey = "preferences",
+ fullWindowContent = true,
+ size = Dimension(800, 600)
+ ) {
+ PDETheme {
+ preferences()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/Welcome.kt b/app/src/processing/app/ui/Welcome.kt
new file mode 100644
index 0000000000..e79d3e749c
--- /dev/null
+++ b/app/src/processing/app/ui/Welcome.kt
@@ -0,0 +1,273 @@
+package processing.app.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material.MaterialTheme.colors
+import androidx.compose.material.MaterialTheme.typography
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.application
+import com.formdev.flatlaf.util.SystemInfo
+import processing.app.*
+import processing.app.ui.components.LanguageChip
+import processing.app.ui.components.examples.examples
+import processing.app.ui.theme.*
+import java.awt.Desktop
+import java.io.IOException
+import java.net.URI
+import javax.swing.SwingUtilities
+
+
+class Welcome @Throws(IOException::class) constructor(base: Base) {
+ init {
+ SwingUtilities.invokeLater {
+ PDESwingWindow("menu.help.welcome", fullWindowContent = true) {
+ CompositionLocalProvider(LocalBase provides base) {
+ welcome()
+ }
+ }
+ }
+ }
+
+ companion object {
+ val LocalBase = compositionLocalOf { null }
+ @Composable
+ fun welcome() {
+ Column(
+ modifier = Modifier
+ .background(
+ Brush.linearGradient(
+ colorStops = arrayOf(0f to Color.Transparent, 1f to Color(0xFFC0D7FF)),
+ start = Offset(815f, 0f),
+ end = Offset(815f * 2, 450f)
+ )
+ )
+ .padding(horizontal = 32.dp)
+ .padding(bottom = 32.dp)
+ .padding(top = if (SystemInfo.isMacFullWindowContentSupported) 22.dp else 0.dp)
+ .height(IntrinsicSize.Max)
+ .width(IntrinsicSize.Max)
+ ) {
+ Column(
+ horizontalAlignment = Alignment.End,
+ modifier = Modifier
+ .align(Alignment.End)
+ ) {
+ LanguageChip()
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(48.dp),
+ ) {
+ Column {
+ intro()
+ }
+ Box{
+ Column {
+ examples()
+ actions()
+ }
+ val locale = LocalLocale.current
+ Image(
+ painter = painterResource("welcome/intro/wavy.svg"),
+ contentDescription = locale["welcome.intro.long"],
+ modifier = Modifier
+ .height(200.dp)
+ .offset (32.dp)
+ .align(Alignment.BottomEnd)
+ .scale(when(LocalLayoutDirection.current) {
+ LayoutDirection.Rtl -> -1f
+ else -> 1f
+ }, 1f)
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun intro(){
+ val locale = LocalLocale.current
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(IntrinsicSize.Max)
+ ) {
+ Column {
+ Text(
+ text = locale["welcome.intro.title"],
+ style = typography.h4,
+ modifier = Modifier
+ .sizeIn(maxWidth = 305.dp)
+ )
+ Text(
+ text = locale["welcome.intro.message"],
+ style = typography.body1,
+ modifier = Modifier
+ .sizeIn(maxWidth = 305.dp)
+ )
+ }
+ Column(
+ modifier = Modifier
+ .offset(y = 32.dp)
+ ){
+ Text(
+ text = locale["welcome.intro.suggestion"],
+ style = typography.body1,
+ color = colors.onPrimary,
+ modifier = Modifier
+ .padding(top = 16.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(colors.primary)
+ .padding(horizontal = 24.dp)
+ .padding(top = 16.dp, bottom = 24.dp)
+ .sizeIn(maxWidth = 200.dp)
+ )
+ Image(
+ painter = painterResource("welcome/intro/bubble.svg"),
+ contentDescription = locale["welcome.intro.long"],
+ modifier = Modifier
+ .align(Alignment.Start)
+ .scale(when(LocalLayoutDirection.current) {
+ LayoutDirection.Rtl -> -1f
+ else -> 1f
+ }, 1f)
+ .padding(start = 64.dp)
+ )
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.
+ fillMaxWidth()
+ ) {
+ Image(
+ painter = painterResource("welcome/intro/long.svg"),
+ contentDescription = locale["welcome.intro.long"],
+ modifier = Modifier
+ .offset(x = -32.dp)
+ .scale(when(LocalLayoutDirection.current) {
+ LayoutDirection.Rtl -> -1f
+ else -> 1f
+ }, 1f)
+ )
+ Image(
+ painter = painterResource("welcome/intro/short.svg"),
+ contentDescription = locale["welcome.intro.short"],
+ modifier = Modifier
+ .align(Alignment.Bottom)
+ .offset(x = 16.dp, y = -16.dp)
+ .scale(when(LocalLayoutDirection.current) {
+ LayoutDirection.Rtl -> -1f
+ else -> 1f
+ }, 1f)
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun actions(){
+ val locale = LocalLocale.current
+ val base = LocalBase.current
+ PDEChip(onClick = {
+ base?.defaultMode?.showExamplesFrame()
+ }) {
+ Text(
+ text = locale["welcome.action.examples"],
+ )
+ Image(
+ imageVector = Icons.AutoMirrored.Default.ArrowForward,
+ contentDescription = locale["welcome.action.tutorials"],
+ colorFilter = ColorFilter.tint(color = LocalContentColor.current),
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(typography.body1.fontSize.value.dp)
+ )
+ }
+ PDEChip(onClick = {
+ if (!Desktop.isDesktopSupported()) return@PDEChip
+ val desktop = Desktop.getDesktop()
+ if(!desktop.isSupported(Desktop.Action.BROWSE)) return@PDEChip
+ try {
+ desktop.browse(URI(System.getProperty("processing.tutorials")))
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }) {
+ Text(
+ text = locale["welcome.action.tutorials"],
+ )
+ Image(
+ imageVector = Icons.AutoMirrored.Default.ArrowForward,
+ contentDescription = locale["welcome.action.tutorials"],
+ colorFilter = ColorFilter.tint(color = LocalContentColor.current),
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(typography.body1.fontSize.value.dp)
+ )
+ }
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .offset(-32.dp)
+ ) {
+ val preferences = LocalPreferences.current
+ Checkbox(
+ checked = preferences["welcome.four.show"]?.equals("true") ?: false,
+ onCheckedChange = {
+ preferences.setProperty("welcome.four.show", it.toString())
+ },
+ modifier = Modifier
+ .size(24.dp)
+ )
+ Text(
+ text = locale["welcome.action.startup"],
+ )
+ }
+ val window = LocalWindow.current
+ PDEButton(onClick = {
+ window.dispose()
+ }) {
+ Text(
+ text = locale["welcome.action.go"],
+ modifier = Modifier
+ )
+ }
+ }
+ }
+
+
+
+ @JvmStatic
+ fun main(args: Array) {
+ application {
+ PDEComposeWindow("menu.help.welcome", fullWindowContent = true, onClose = ::exitApplication) {
+ welcome()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt
index 7757e820f6..2725a78176 100644
--- a/app/src/processing/app/ui/WelcomeToBeta.kt
+++ b/app/src/processing/app/ui/WelcomeToBeta.kt
@@ -5,11 +5,11 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.MaterialTheme.colors
-import androidx.compose.material.MaterialTheme.typography
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -31,17 +31,16 @@ import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.formdev.flatlaf.util.SystemInfo
import com.mikepenz.markdown.compose.Markdown
-import com.mikepenz.markdown.m2.markdownColor
-import com.mikepenz.markdown.m2.markdownTypography
+import com.mikepenz.markdown.m3.markdownColor
+import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.MarkdownColors
import com.mikepenz.markdown.model.MarkdownTypography
import processing.app.Preferences
import processing.app.Base.getRevision
import processing.app.Base.getVersionName
import processing.app.ui.theme.LocalLocale
-import processing.app.ui.theme.LocalTheme
import processing.app.ui.theme.Locale
-import processing.app.ui.theme.ProcessingTheme
+import processing.app.ui.theme.PDETheme
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.KeyAdapter
@@ -54,7 +53,7 @@ import javax.swing.SwingUtilities
class WelcomeToBeta {
companion object{
- val windowSize = Dimension(400, 200)
+ val windowSize = Dimension(400, 250)
val windowTitle = Locale()["beta.window.title"]
@JvmStatic
@@ -72,7 +71,7 @@ class WelcomeToBeta {
contentPane.add(ComposePanel().apply {
size = windowSize
setContent {
- ProcessingTheme {
+ PDETheme(darkTheme = false) {
Box(modifier = Modifier.padding(top = if (mac) 22.dp else 0.dp)) {
welcomeToBeta(close)
}
@@ -99,7 +98,7 @@ class WelcomeToBeta {
Row(
modifier = Modifier
.padding(20.dp, 10.dp)
- .size(windowSize.width.dp, windowSize.height.dp),
+ .fillMaxSize(),
horizontalArrangement = Arrangement
.spacedBy(20.dp)
){
@@ -109,7 +108,7 @@ class WelcomeToBeta {
contentDescription = locale["beta.logo"],
modifier = Modifier
.align(Alignment.CenterVertically)
- .size(100.dp, 100.dp)
+ .size(120.dp)
.offset(0.dp, (-25).dp)
)
Column(
@@ -123,7 +122,7 @@ class WelcomeToBeta {
) {
Text(
text = locale["beta.title"],
- style = typography.subtitle1,
+ style = typography.titleLarge,
)
val text = locale["beta.message"]
.replace('$' + "version", getVersionName())
@@ -131,80 +130,36 @@ class WelcomeToBeta {
Markdown(
text,
colors = markdownColor(),
- typography = markdownTypography(text = typography.body1, link = typography.body1.copy(color = colors.primary)),
+ typography = markdownTypography(),
modifier = Modifier.background(Color.Transparent).padding(bottom = 10.dp)
)
Row {
Spacer(modifier = Modifier.weight(1f))
- PDEButton(onClick = {
+ Button(onClick = {
close()
}) {
Text(
text = locale["beta.button"],
- color = colors.onPrimary
+ color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}
- @OptIn(ExperimentalComposeUiApi::class)
- @Composable
- fun PDEButton(onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
- val theme = LocalTheme.current
-
- var hover by remember { mutableStateOf(false) }
- var clicked by remember { mutableStateOf(false) }
- val offset by animateFloatAsState(if (hover) -5f else 5f)
- val color by animateColorAsState(if(clicked) colors.primaryVariant else colors.primary)
-
- Box(modifier = Modifier.padding(end = 5.dp, top = 5.dp)) {
- Box(
- modifier = Modifier
- .offset((-offset).dp, (offset).dp)
- .background(theme.getColor("toolbar.button.pressed.field"))
- .matchParentSize()
- )
- Box(
- modifier = Modifier
- .onPointerEvent(PointerEventType.Press) {
- clicked = true
- }
- .onPointerEvent(PointerEventType.Release) {
- clicked = false
- onClick()
- }
- .onPointerEvent(PointerEventType.Enter) {
- hover = true
- }
- .onPointerEvent(PointerEventType.Exit) {
- hover = false
- }
- .pointerHoverIcon(PointerIcon(Cursor(Cursor.HAND_CURSOR)))
- .background(color)
- .padding(10.dp)
- .sizeIn(minWidth = 100.dp),
- contentAlignment = Alignment.Center,
- content = content
- )
- }
- }
-
@JvmStatic
fun main(args: Array) {
application {
val windowState = rememberWindowState(
- size = DpSize.Unspecified,
+ size = windowSize.let { DpSize(it.width.dp, it.height.dp) },
position = WindowPosition(Alignment.Center)
)
Window(onCloseRequest = ::exitApplication, state = windowState, title = windowTitle) {
- ProcessingTheme {
- Surface(color = colors.background) {
- welcomeToBeta {
- exitApplication()
- }
+ PDETheme(darkTheme = false) {
+ welcomeToBeta {
+ exitApplication()
}
}
}
diff --git a/app/src/processing/app/ui/components/LanuageSelector.kt b/app/src/processing/app/ui/components/LanuageSelector.kt
new file mode 100644
index 0000000000..5c42443fe4
--- /dev/null
+++ b/app/src/processing/app/ui/components/LanuageSelector.kt
@@ -0,0 +1,126 @@
+package processing.app.ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.DropdownMenu
+import androidx.compose.material.DropdownMenuItem
+import androidx.compose.material.LocalContentColor
+import androidx.compose.material.MaterialTheme.typography
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.outlined.Language
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.unit.dp
+import processing.app.Platform
+import processing.app.ui.theme.LocalLocale
+import processing.app.ui.theme.PDEChip
+import processing.app.watchFile
+import java.io.File
+import java.nio.file.FileSystem
+import java.nio.file.FileSystems
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.util.*
+import kotlin.io.path.inputStream
+
+data class Language(
+ val name: String,
+ val code: String,
+ val locale: Locale,
+ val properties: Properties
+)
+
+var jarFs: FileSystem? = null
+
+@Composable
+fun LanguageChip(){
+ var expanded by remember { mutableStateOf(false) }
+
+ val settingsFolder = Platform.getSettingsFolder()
+ val languageFile = File(settingsFolder, "language.txt")
+ watchFile(languageFile)
+
+ val main = ClassLoader.getSystemResource("PDE.properties")?: return
+
+ val languages = remember {
+ val list = when(main.protocol){
+ "file" -> {
+ val path = Paths.get(main.toURI())
+ Files.list(path.parent)
+ }
+ "jar" -> {
+ val uri = main.toURI()
+ jarFs = jarFs ?: FileSystems.newFileSystem(uri, emptyMap()) ?: return@remember null
+ Files.list(jarFs!!.getPath("/"))
+ }
+ else -> null
+ } ?: return@remember null
+
+ list.toList()
+ .map { Pair(it, it.fileName.toString()) }
+ .filter { (_, fileName) -> fileName.startsWith("PDE_") && fileName.endsWith(".properties") }
+ .map { (path, _) ->
+ path.inputStream().reader(Charsets.UTF_8).use {
+ val properties = Properties()
+ properties.load(it)
+
+ val code = path.fileName.toString().removeSuffix(".properties").replace("PDE_", "")
+ val locale = Locale.forLanguageTag(code)
+ val name = locale.getDisplayName(locale)
+
+ return@map Language(
+ name,
+ code,
+ locale,
+ properties
+ )
+ }
+ }
+ .sortedBy { it.name.lowercase() }
+ } ?: return
+
+ val current = languageFile.readText(Charsets.UTF_8).substring(0, 2)
+ val currentLanguage = remember(current) { languages.find { it.code.startsWith(current) } ?: languages.first()}
+
+ val locale = LocalLocale.current
+
+ PDEChip(onClick = { expanded = !expanded }, leadingIcon = {
+ Image(
+ imageVector = Icons.Outlined.Language,
+ contentDescription = "Language",
+ colorFilter = ColorFilter.tint(color = LocalContentColor.current),
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(typography.body1.fontSize.value.dp)
+ )
+ }) {
+ Text(currentLanguage.name)
+ Image(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = locale["welcome.action.tutorials"],
+ colorFilter = ColorFilter.tint(color = LocalContentColor.current),
+ modifier = Modifier
+ .size(typography.body1.fontSize.value.dp)
+ )
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = {
+ expanded = false
+ },
+ ){
+ for (language in languages){
+ DropdownMenuItem(onClick = {
+ locale.set(language.locale)
+ expanded = false
+ }) {
+ Text(language.name)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/components/examples/Examples.kt b/app/src/processing/app/ui/components/examples/Examples.kt
new file mode 100644
index 0000000000..4c0a9045cb
--- /dev/null
+++ b/app/src/processing/app/ui/components/examples/Examples.kt
@@ -0,0 +1,194 @@
+package processing.app.ui.components.examples
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.MaterialTheme.colors
+import androidx.compose.material.MaterialTheme.typography
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.onPointerEvent
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.jetbrains.compose.resources.ExperimentalResourceApi
+import org.jetbrains.compose.resources.decodeToImageBitmap
+import processing.app.LocalPreferences
+import processing.app.Messages
+import processing.app.Platform
+import processing.app.ui.Welcome.Companion.LocalBase
+import java.awt.Cursor
+import java.io.File
+import java.nio.file.*
+import java.nio.file.attribute.BasicFileAttributes
+import kotlin.io.path.exists
+import kotlin.io.path.inputStream
+import kotlin.io.path.isDirectory
+
+data class Example(
+ val folder: Path,
+ val library: Path,
+ val path: String = library.resolve("examples").relativize(folder).toString(),
+ val title: String = folder.fileName.toString(),
+ val image: Path = folder.resolve("$title.png")
+)
+
+@Composable
+fun loadExamples(): List {
+ val sketchbook = rememberSketchbookPath()
+ val resources = File(System.getProperty("compose.application.resources.dir") ?: "")
+ var examples by remember { mutableStateOf(emptyList()) }
+
+ val settingsFolder = Platform.getSettingsFolder()
+ val examplesCache = settingsFolder.resolve("examples.cache")
+ LaunchedEffect(sketchbook, resources){
+ if (!examplesCache.exists()) return@LaunchedEffect
+ withContext(Dispatchers.IO) {
+ examples = examplesCache.readText().lines().map {
+ val (library, folder) = it.split(",")
+ Example(
+ folder = File(folder).toPath(),
+ library = File(library).toPath()
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(sketchbook, resources){
+ withContext(Dispatchers.IO) {
+ // TODO: Optimize
+ Messages.log("Start scanning for examples in $sketchbook and $resources")
+ // Folders that can contain contributions with examples
+ val scanned = listOf("libraries", "examples", "modes")
+ .flatMap { listOf(sketchbook.resolve(it), resources.resolve(it)) }
+ .filter { it.exists() && it.isDirectory() }
+ // Find contributions within those folders
+ .flatMap { Files.list(it.toPath()).toList() }
+ .filter { Files.isDirectory(it) }
+ // Find examples within those contributions
+ .flatMap { library ->
+ val fs = FileSystems.getDefault()
+ val matcher = fs.getPathMatcher("glob:**/*.pde")
+ val exampleFolders = mutableListOf()
+ val examples = library.resolve("examples")
+ if (!Files.exists(examples) || !examples.isDirectory()) return@flatMap emptyList()
+
+ Files.walkFileTree(library, object : SimpleFileVisitor() {
+ override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+ if (matcher.matches(file)) {
+ exampleFolders.add(file.parent)
+ }
+ return FileVisitResult.CONTINUE
+ }
+ })
+ return@flatMap exampleFolders.map { folder ->
+ Example(
+ folder,
+ library,
+ )
+ }
+ }
+ .filter { it.image.exists() }
+ Messages.log("Done scanning for examples in $sketchbook and $resources")
+ if(scanned.isEmpty()) return@withContext
+ examples = scanned
+ examplesCache.writeText(examples.joinToString("\n") { "${it.library},${it.folder}" })
+ }
+ }
+
+ return examples
+
+}
+
+@Composable
+fun rememberSketchbookPath(): File {
+ val preferences = LocalPreferences.current
+ val sketchbookPath = remember(preferences["sketchbook.path.four"]) {
+ preferences["sketchbook.path.four"] ?: Platform.getDefaultSketchbookFolder().toString()
+ }
+ return File(sketchbookPath)
+}
+
+
+
+@Composable
+fun examples(){
+ val examples = loadExamples()
+
+
+ var randoms = examples.shuffled().take(4)
+ if(randoms.size < 4){
+ randoms = randoms + List(4 - randoms.size) { Example(
+ folder = Paths.get(""),
+ library = Paths.get(""),
+ title = "Example",
+ image = ClassLoader.getSystemResource("default.png")?.toURI()?.let { Paths.get(it) } ?: Paths.get(""),
+ ) }
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ randoms.chunked(2).forEach { row ->
+ Row (
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ){
+ row.forEach { example ->
+ Example(example)
+ }
+ }
+ }
+ }
+}
+@OptIn(ExperimentalResourceApi::class)
+@Composable
+fun Example(example: Example){
+ val base = LocalBase.current
+ Button(
+ onClick = {
+ base?.handleOpenExample("${example.folder}/${example.title}.pde", base.defaultMode)
+ },
+ contentPadding = PaddingValues(0.dp),
+ elevation = null,
+ shape = RectangleShape,
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = Color.Transparent,
+ contentColor = colors.onBackground
+ ),
+ ) {
+ Column(
+ modifier = Modifier
+ .width(185.dp)
+ ) {
+ val imageBitmap: ImageBitmap = remember(example.image) {
+ example.image.inputStream().readAllBytes().decodeToImageBitmap()
+ }
+ Image(
+ painter = BitmapPainter(imageBitmap),
+ contentDescription = example.title,
+ modifier = Modifier
+ .background(colors.primary)
+ .aspectRatio(16f / 9f)
+ )
+ Text(
+ example.title,
+ style = typography.body1,
+ maxLines = 1
+ )
+ }
+ }
+}
diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt
new file mode 100644
index 0000000000..5f56187f46
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/General.kt
@@ -0,0 +1,121 @@
+package processing.app.ui.preferences
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Button
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import processing.app.Preferences
+import processing.app.SketchName
+import processing.app.ui.PDEPreference
+import processing.app.ui.PDEPreferenceGroup
+import processing.app.ui.PDEPreferences
+
+
+class General {
+ companion object{
+ val general = PDEPreferenceGroup(
+ name = "General",
+ icon = {
+ Icon(Icons.Default.Settings, contentDescription = "A settings icon")
+ }
+ )
+
+ fun register() {
+ PDEPreferences.register(
+ PDEPreference(
+ key = "sketchbook.path.four",
+ descriptionKey = "preferences.sketchbook_location",
+ group = general,
+ control = { preference, updatePreference ->
+ Row (
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ TextField(
+ value = preference ?: "",
+ onValueChange = {
+ updatePreference(it)
+ }
+ )
+ Button(
+ onClick = {
+
+ }
+ ) {
+ Text("Browse")
+ }
+ }
+ }
+ )
+ )
+ PDEPreferences.register(
+ PDEPreference(
+ key = "sketch.name.approach",
+ descriptionKey = "preferences.sketch_naming",
+ group = general,
+ control = { preference, updatePreference ->
+ Row{
+ for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf(
+ "timestamp",
+ "untitled",
+ "custom"
+ )) {
+ FilterChip(
+ selected = preference == option,
+ onClick = {
+ updatePreference(option)
+ },
+ label = {
+ Text(option)
+ },
+ modifier = Modifier.padding(4.dp),
+ )
+ }
+ }
+ }
+ )
+ )
+ PDEPreferences.register(
+ PDEPreference(
+ key = "update.check",
+ descriptionKey = "preferences.check_for_updates_on_startup",
+ group = general,
+ control = { preference, updatePreference ->
+ Switch(
+ checked = preference.toBoolean(),
+ onCheckedChange = {
+ updatePreference(it.toString())
+ }
+ )
+ }
+ )
+ )
+ PDEPreferences.register(
+ PDEPreference(
+ key = "welcome.show",
+ descriptionKey = "preferences.show_welcome_screen_on_startup",
+ group = general,
+ control = { preference, updatePreference ->
+ Switch(
+ checked = preference.toBoolean(),
+ onCheckedChange = {
+ updatePreference(it.toString())
+ }
+ )
+ }
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt
new file mode 100644
index 0000000000..fc384fbc59
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/Interface.kt
@@ -0,0 +1,168 @@
+package processing.app.ui.preferences
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.TextIncrease
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import processing.app.Language
+import processing.app.Preferences
+import processing.app.ui.PDEPreference
+import processing.app.ui.PDEPreferenceGroup
+import processing.app.ui.PDEPreferences
+import processing.app.ui.Toolkit
+import processing.app.ui.preferences.General.Companion.general
+import processing.app.ui.theme.LocalLocale
+import java.util.Locale
+
+class Interface {
+ companion object{
+ val interfaceAndFonts = PDEPreferenceGroup(
+ name = "Interface",
+ icon = {
+ Icon(Icons.Default.TextIncrease, contentDescription = "Interface")
+ },
+ after = general
+ )
+
+ fun register() {
+ PDEPreferences.register(PDEPreference(
+ key = "language",
+ descriptionKey = "preferences.language",
+ group = interfaceAndFonts,
+ control = { preference, updatePreference ->
+ val locale = LocalLocale.current
+ var showOptions by remember { mutableStateOf(false) }
+ val languages = if(Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English")
+ TextField(
+ value = locale.locale.displayName,
+ readOnly = true,
+ onValueChange = { },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ contentDescription = "Select Font Family",
+ modifier = Modifier
+ .clickable{
+ showOptions = true
+ }
+ )
+ }
+ )
+ DropdownMenu(
+ expanded = showOptions,
+ onDismissRequest = {
+ showOptions = false
+ },
+ ) {
+ languages.forEach { family ->
+ DropdownMenuItem(
+ text = { Text(family.value) },
+ onClick = {
+ locale.set(Locale(family.key))
+ showOptions = false
+ }
+ )
+ }
+ }
+ }
+ ))
+
+ PDEPreferences.register(
+ PDEPreference(
+ key = "editor.font.family",
+ descriptionKey = "preferences.editor_and_console_font",
+ group = interfaceAndFonts,
+ control = { preference, updatePreference ->
+ var showOptions by remember { mutableStateOf(false) }
+ val families = if(Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced")
+ TextField(
+ value = preference ?: families.firstOrNull().orEmpty(),
+ readOnly = true,
+ onValueChange = { updatePreference (it) },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ contentDescription = "Select Font Family",
+ modifier = Modifier
+ .clickable{
+ showOptions = true
+ }
+ )
+ }
+ )
+ DropdownMenu(
+ expanded = showOptions,
+ onDismissRequest = {
+ showOptions = false
+ },
+ ) {
+ families.forEach { family ->
+ DropdownMenuItem(
+ text = { Text(family) },
+ onClick = {
+ updatePreference(family)
+ showOptions = false
+ }
+ )
+ }
+
+ }
+ }
+ )
+ )
+
+ PDEPreferences.register(PDEPreference(
+ key = "editor.font.size",
+ descriptionKey = "preferences.editor_font_size",
+ group = interfaceAndFonts,
+ control = { preference, updatePreference ->
+ Column {
+ Text(
+ text = "${preference ?: "12"} pt",
+ modifier = Modifier.width(120.dp)
+ )
+ Slider(
+ value = (preference ?: "12").toFloat(),
+ onValueChange = { updatePreference(it.toInt().toString()) },
+ valueRange = 10f..48f,
+ steps = 18,
+ )
+ }
+ }
+ ))
+ PDEPreferences.register(PDEPreference(
+ key = "console.font.size",
+ descriptionKey = "preferences.console_font_size",
+ group = interfaceAndFonts,
+ control = { preference, updatePreference ->
+ Column {
+ Text(
+ text = "${preference ?: "12"} pt",
+ modifier = Modifier.width(120.dp)
+ )
+ Slider(
+ value = (preference ?: "12").toFloat(),
+ onValueChange = { updatePreference(it.toInt().toString()) },
+ valueRange = 10f..48f,
+ steps = 18,
+ )
+ }
+ }
+ ))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt
new file mode 100644
index 0000000000..f5f65ea9c8
--- /dev/null
+++ b/app/src/processing/app/ui/preferences/Other.kt
@@ -0,0 +1,73 @@
+package processing.app.ui.preferences
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import processing.app.LocalPreferences
+import processing.app.ui.LocalPreferenceGroups
+import processing.app.ui.PDEPreference
+import processing.app.ui.PDEPreferenceGroup
+import processing.app.ui.PDEPreferences
+import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts
+import processing.app.ui.theme.LocalLocale
+
+class Other {
+ companion object{
+ val other = PDEPreferenceGroup(
+ name = "Other",
+ icon = {
+ Icon(Icons.Default.Map, contentDescription = "A map icon")
+ },
+ after = interfaceAndFonts
+ )
+ fun register() {
+ PDEPreferences.register(
+ PDEPreference(
+ key = "other",
+ descriptionKey = "preferences.other",
+ group = other,
+ noPadding = true,
+ control = { _, _ ->
+ val prefs = LocalPreferences.current
+ val groups = LocalPreferenceGroups.current
+ val restPrefs = remember {
+ val keys = prefs.keys.mapNotNull { it as? String }
+ val existing = groups.values.flatten().map { it.key }
+ keys.filter { it !in existing }.sorted()
+ }
+ val locale = LocalLocale.current
+
+ for(prefKey in restPrefs){
+ val value = prefs[prefKey]
+ Row (
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp)
+ ){
+ Text(
+ text = locale[prefKey],
+ modifier = Modifier.align(Alignment.CenterVertically)
+ )
+ TextField(value ?: "", onValueChange = {
+ prefs[prefKey] = it
+ })
+ }
+ }
+
+ }
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/theme/Button.kt b/app/src/processing/app/ui/theme/Button.kt
new file mode 100644
index 0000000000..bec6dd3bcd
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Button.kt
@@ -0,0 +1,52 @@
+package processing.app.ui.theme
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Button
+import androidx.compose.material.MaterialTheme.colors
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.onPointerEvent
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.unit.dp
+import java.awt.Cursor
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun PDEButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
+ var hover by remember { mutableStateOf(false) }
+ val offset by animateFloatAsState(if (hover) -3f else 3f)
+
+ Box {
+ Box(
+ modifier = Modifier
+ .offset((-offset).dp, (offset).dp)
+ .matchParentSize()
+ .padding(vertical = 6.dp)
+ .background(colors.secondary)
+
+ )
+ Button(
+ onClick = onClick,
+ shape = RectangleShape,
+ contentPadding = PaddingValues(vertical = 8.dp, horizontal = 32.dp),
+ modifier = Modifier
+ .onPointerEvent(PointerEventType.Enter) {
+ hover = true
+ }
+ .onPointerEvent(PointerEventType.Exit) {
+ hover = false
+ }
+ .pointerHoverIcon(PointerIcon(Cursor(Cursor.HAND_CURSOR))),
+ content = content
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/theme/Chip.kt b/app/src/processing/app/ui/theme/Chip.kt
new file mode 100644
index 0000000000..baab6e8ef9
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Chip.kt
@@ -0,0 +1,31 @@
+package processing.app.ui.theme
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material.Chip
+import androidx.compose.material.ChipDefaults
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.MaterialTheme.colors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun PDEChip(
+ onClick: () -> Unit = {},
+ leadingIcon: @Composable (() -> Unit)? = null,
+ content: @Composable RowScope.() -> Unit
+){
+ Chip(
+ onClick = onClick,
+ border = BorderStroke(1.dp, colors.secondary),
+ colors = ChipDefaults.chipColors(
+ backgroundColor = colors.background,
+ contentColor = colors.primaryVariant
+ ),
+ leadingIcon = leadingIcon,
+ modifier = Modifier,
+ content = content
+ )
+}
\ 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..61c6d6b55f
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Colors.kt
@@ -0,0 +1,134 @@
+package processing.app.ui.theme
+
+import androidx.compose.material.Colors
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.ui.graphics.Color
+
+class ProcessingColors{
+ companion object{
+ val blue = Color(0xFF0251c8)
+ val lightBlue = Color(0xFF82AFFF)
+
+ val deepBlue = Color(0xFF1e32aa)
+ val darkBlue = Color(0xFF0F195A)
+
+ val white = Color(0xFFFFFFFF)
+ val lightGray = Color(0xFFF5F5F5)
+ val gray = Color(0xFFDBDBDB)
+ val darkGray = Color(0xFF898989)
+ val darkerGray = Color(0xFF727070)
+ val veryDarkGray = Color(0xFF1E1E1E)
+ val black = Color(0xFF0D0D0D)
+
+ val error = Color(0xFFFF5757)
+ val errorContainer = Color(0xFFFFA6A6)
+
+ val p5Light = Color(0xFFfd9db9)
+ val p5Mid = Color(0xFFff4077)
+ val p5Dark = Color(0xFFaf1f42)
+
+ val foundationLight = Color(0xFFd4b2fe)
+ val foundationMid = Color(0xFF9c4bff)
+ val foundationDark = Color(0xFF5501a4)
+
+ val downloadInactive = Color(0xFF8890B3)
+ val downloadBackgroundActive = Color(0x14508BFF)
+ }
+}
+
+@Deprecated("Use PDE3LightColor instead")
+val PDE2LightColors = Colors(
+ primary = ProcessingColors.blue,
+ primaryVariant = ProcessingColors.lightBlue,
+ onPrimary = ProcessingColors.white,
+
+ secondary = ProcessingColors.deepBlue,
+ secondaryVariant = ProcessingColors.darkBlue,
+ onSecondary = ProcessingColors.white,
+
+ background = ProcessingColors.white,
+ onBackground = ProcessingColors.darkBlue,
+
+ surface = ProcessingColors.lightGray,
+ onSurface = ProcessingColors.darkerGray,
+
+ error = ProcessingColors.error,
+ onError = ProcessingColors.white,
+
+ isLight = true,
+)
+
+@Deprecated("Use PDE3DarkColor instead")
+val PDE2DarkColors = Colors(
+ primary = ProcessingColors.deepBlue,
+ primaryVariant = ProcessingColors.darkBlue,
+ onPrimary = ProcessingColors.white,
+
+ secondary = ProcessingColors.lightBlue,
+ secondaryVariant = ProcessingColors.blue,
+ onSecondary = ProcessingColors.white,
+
+ background = ProcessingColors.veryDarkGray,
+ onBackground = ProcessingColors.white,
+
+ surface = ProcessingColors.darkerGray,
+ onSurface = ProcessingColors.lightGray,
+
+ error = ProcessingColors.error,
+ onError = ProcessingColors.white,
+
+ isLight = false,
+)
+
+val PDELightColor = lightColorScheme(
+ primary = ProcessingColors.blue,
+ onPrimary = ProcessingColors.white,
+
+ primaryContainer = ProcessingColors.downloadBackgroundActive,
+ onPrimaryContainer = ProcessingColors.darkBlue,
+
+ secondary = ProcessingColors.deepBlue,
+ onSecondary = ProcessingColors.white,
+
+ secondaryContainer = ProcessingColors.downloadInactive,
+ onSecondaryContainer = ProcessingColors.white,
+
+ tertiary = ProcessingColors.p5Mid,
+ onTertiary = ProcessingColors.white,
+
+ tertiaryContainer = ProcessingColors.p5Light,
+ onTertiaryContainer = ProcessingColors.p5Dark,
+
+ background = ProcessingColors.white,
+ onBackground = ProcessingColors.darkBlue,
+
+ surface = ProcessingColors.lightGray,
+ onSurface = ProcessingColors.darkerGray,
+
+ error = ProcessingColors.error,
+ onError = ProcessingColors.white,
+
+ errorContainer = ProcessingColors.errorContainer,
+ onErrorContainer = ProcessingColors.white
+)
+
+val PDEDarkColor = darkColorScheme(
+ primary = ProcessingColors.deepBlue,
+ onPrimary = ProcessingColors.white,
+
+ secondary = ProcessingColors.lightBlue,
+ onSecondary = ProcessingColors.white,
+
+ tertiary = ProcessingColors.blue,
+ onTertiary = ProcessingColors.white,
+
+ background = ProcessingColors.veryDarkGray,
+ onBackground = ProcessingColors.white,
+
+ surface = ProcessingColors.darkerGray,
+ onSurface = ProcessingColors.lightGray,
+
+ error = ProcessingColors.error,
+ onError = ProcessingColors.white,
+)
\ No newline at end of file
diff --git a/app/src/processing/app/ui/theme/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/ProcessingTheme.kt b/app/src/processing/app/ui/theme/ProcessingTheme.kt
new file mode 100644
index 0000000000..cfcaa6cacf
--- /dev/null
+++ b/app/src/processing/app/ui/theme/ProcessingTheme.kt
@@ -0,0 +1,356 @@
+package processing.app.ui.theme
+
+import androidx.compose.foundation.background
+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.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Badge
+import androidx.compose.material.BadgedBox
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Card
+import androidx.compose.material.Checkbox
+import androidx.compose.material.Chip
+import androidx.compose.material.ChipDefaults
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Divider
+import androidx.compose.material.DropdownMenu
+import androidx.compose.material.DropdownMenuItem
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.FilterChip
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.LinearProgressIndicator
+import androidx.compose.material.LocalRippleConfiguration
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedButton
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.RadioButton
+import androidx.compose.material.RippleConfiguration
+import androidx.compose.material.Slider
+import androidx.compose.material.Surface
+import androidx.compose.material.Switch
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.material.TextField
+import androidx.compose.material.TriStateCheckbox
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import processing.app.LocalPreferences
+import processing.app.PreferencesProvider
+import java.io.InputStream
+import java.util.Properties
+
+class ProcessingTheme(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())
+ }
+
+ 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")
+ }
+ return color.toInt()
+ }
+ throw IllegalArgumentException("Unknown color")
+ }
+}
+
+val LocalProcessingTheme = compositionLocalOf { error("No theme provided") }
+
+@Deprecated("Use PDETheme instead", ReplaceWith("PDETheme"))
+@Composable
+fun ProcessingTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable() () -> Unit
+) {
+ PreferencesProvider {
+ val preferences = LocalPreferences.current
+ val theme = ProcessingTheme(preferences.getProperty("theme"))
+ CompositionLocalProvider(LocalProcessingTheme provides theme,LocalDensity provides Density(1.25f, 1.25f),) {
+ LocaleProvider {
+ MaterialTheme(
+ colors = if (darkTheme) PDE2DarkColors else PDE2LightColors,
+ typography = PDE2Typography,
+ shapes = PDE2Shapes
+ ) {
+ CompositionLocalProvider(
+ LocalRippleConfiguration provides RippleConfiguration(
+ color = MaterialTheme.colors.primary,
+ )
+ ) {
+ Box(modifier = Modifier.background(MaterialTheme.colors.background).fillMaxSize()) {
+ Surface(
+ color = MaterialTheme.colors.background,
+ contentColor = MaterialTheme.colors.onBackground
+ ) {
+ content()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterialApi::class)
+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") {
+ ProcessingTheme(darkTheme) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Processing Theme Components", style = MaterialTheme.typography.h1)
+ Card {
+ Row {
+ Checkbox(darkTheme, onCheckedChange = { darkTheme = !darkTheme })
+ Text(
+ "Dark Theme",
+ modifier = Modifier.align(Alignment.CenterVertically)
+ )
+ }
+ }
+ val scrollable = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollable),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ComponentPreview("Colors") {
+ Column {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary),
+ onClick = {}) {
+ Text("Primary", color = MaterialTheme.colors.onPrimary)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primaryVariant),
+ onClick = {}) {
+ Text("Primary Variant", color = MaterialTheme.colors.onPrimary)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
+ onClick = {}) {
+ Text("Secondary", color = MaterialTheme.colors.onSecondary)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondaryVariant),
+ onClick = {}) {
+ Text("Secondary Variant", color = MaterialTheme.colors.onSecondary)
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.background),
+ onClick = {}) {
+ Text("Background", color = MaterialTheme.colors.onBackground)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.surface),
+ onClick = {}) {
+ Text("Surface", color = MaterialTheme.colors.onSurface)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error),
+ onClick = {}) {
+ Text("Error", color = MaterialTheme.colors.onError)
+ }
+ }
+ }
+ }
+ ComponentPreview("Text & Fonts") {
+ Column {
+ Text("Heading 1", style = MaterialTheme.typography.h1)
+ Text("Heading 2", style = MaterialTheme.typography.h2)
+ Text("Heading 3", style = MaterialTheme.typography.h3)
+ Text("Heading 4", style = MaterialTheme.typography.h4)
+ Text("Heading 5", style = MaterialTheme.typography.h5)
+ Text("Heading 6", style = MaterialTheme.typography.h6)
+
+ Text("Subtitle 1", style = MaterialTheme.typography.subtitle1)
+ Text("Subtitle 2", style = MaterialTheme.typography.subtitle2)
+
+ Text("Body 1", style = MaterialTheme.typography.body1)
+ Text("Body 2", style = MaterialTheme.typography.body2)
+
+ Text("Caption", style = MaterialTheme.typography.caption)
+ Text("Overline", style = MaterialTheme.typography.overline)
+ }
+ }
+ 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") {
+ Chip(onClick = {}){
+ Text("Chip")
+ }
+ Chip(onClick = {}, colors = ChipDefaults.outlinedChipColors(), border = ChipDefaults.outlinedBorder){
+ Text("Outlined")
+ }
+ FilterChip(selected = false, onClick = {}){
+ Text("Filter not Selected")
+ }
+ FilterChip(selected = true, onClick = {}){
+ 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 })
+ }
+ ComponentPreview("Slider") {
+ var state by remember { mutableStateOf(0f) }
+ Slider(state, onValueChange = { state = 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) }
+ TextField("Dropdown", onValueChange = {}, readOnly = true, modifier = Modifier
+ .padding(8.dp)
+ .background(Color.Transparent)
+ .clickable { show = true }
+ )
+ DropdownMenu(
+ expanded = show,
+ onDismissRequest = {
+ show = false
+ },
+ ) {
+ DropdownMenuItem(onClick = { show = false }) {
+ Text("Menu Item 1", modifier = Modifier.padding(8.dp))
+ }
+ DropdownMenuItem(onClick = { show = false }) {
+ Text("Menu Item 2", modifier = Modifier.padding(8.dp))
+ }
+ DropdownMenuItem(onClick = { show = false }) {
+ Text("Menu Item 3", modifier = Modifier.padding(8.dp))
+ }
+ }
+
+
+ }
+
+ ComponentPreview("Scrollable View") {
+
+ }
+
+ ComponentPreview("Tabs") {
+
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ComponentPreview(title: String, content: @Composable () -> Unit) {
+ Column {
+ Text(title, style = MaterialTheme.typography.h4)
+ Divider()
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp)) {
+ content()
+ }
+ Divider()
+ }
+}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/theme/Shapes.kt b/app/src/processing/app/ui/theme/Shapes.kt
new file mode 100644
index 0000000000..95fefb345e
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Shapes.kt
@@ -0,0 +1,11 @@
+package processing.app.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Shapes
+import androidx.compose.ui.unit.dp
+
+val PDE2Shapes = Shapes(
+ small = RoundedCornerShape(30.dp),
+ medium = RoundedCornerShape(6.dp),
+ large = RoundedCornerShape(6.dp)
+)
\ 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..9e41227ed1 100644
--- a/app/src/processing/app/ui/theme/Theme.kt
+++ b/app/src/processing/app/ui/theme/Theme.kt
@@ -1,75 +1,364 @@
package processing.app.ui.theme
+import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material.Colors
-import androidx.compose.material.MaterialTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.Text
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.RangeSlider
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TriStateCheckbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.compositionLocalOf
-import androidx.compose.ui.graphics.Color
-import processing.app.LocalPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.state.ToggleableState
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
import processing.app.PreferencesProvider
-import java.io.InputStream
-import java.util.Properties
-
-
-class Theme(themeFile: String? = "") : Properties() {
- init {
- load(ClassLoader.getSystemResourceAsStream("theme.txt"))
- load(ClassLoader.getSystemResourceAsStream(themeFile) ?: InputStream.nullInputStream())
- }
- fun getColor(key: String): Color {
- return Color(getProperty(key).toColorInt())
- }
-}
-
-val LocalTheme = compositionLocalOf { error("No theme provided") }
+/**
+ * Processing Theme for Jetpack Compose Desktop
+ * Based on Material3
+ *
+ * Makes Material3 components follow Processing color scheme and typography
+ * We experimented with using the material3 theme builder, but it made it look too Android-y
+ * So we defined our own color scheme and typography based on Processing design guidelines
+ *
+ * This composable also provides Preferences and Locale context to all child composables
+ *
+ * Also, important: sets a default density of 1.25 for better scaling on desktop screens, [LocalDensity]
+ *
+ * Usage:
+ * ```
+ * PDETheme {
+ * val pref = LocalPreferences.current
+ * val locale = LocalLocale.current
+ * ...
+ * // Your composables here
+ * }
+ * ```
+ *
+ * @param darkTheme Whether to use dark theme or light theme. Defaults to system setting.
+ */
@Composable
-fun ProcessingTheme(
+fun PDETheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- content: @Composable() () -> Unit
-) {
+ content: @Composable () -> Unit
+){
PreferencesProvider {
- val preferences = LocalPreferences.current
- val theme = Theme(preferences.getProperty("theme"))
- val colors = Colors(
- primary = theme.getColor("editor.gradient.top"),
- primaryVariant = theme.getColor("toolbar.button.pressed.field"),
- secondary = theme.getColor("editor.gradient.bottom"),
- secondaryVariant = theme.getColor("editor.scrollbar.thumb.pressed.color"),
- background = theme.getColor("editor.bgcolor"),
- surface = theme.getColor("editor.bgcolor"),
- error = theme.getColor("status.error.bgcolor"),
- onPrimary = theme.getColor("toolbar.button.enabled.field"),
- onSecondary = theme.getColor("toolbar.button.enabled.field"),
- onBackground = theme.getColor("editor.fgcolor"),
- onSurface = theme.getColor("editor.fgcolor"),
- onError = theme.getColor("status.error.fgcolor"),
- isLight = theme.getProperty("laf.mode").equals("light")
+ LocaleProvider {
+ MaterialTheme(
+ colorScheme = if(darkTheme) PDEDarkColor else PDELightColor,
+ typography = PDETypography
+ ){
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ CompositionLocalProvider(
+ LocalContentColor provides MaterialTheme.colorScheme.onBackground,
+ LocalDensity provides Density(1.25f, 1.25f),
+ content = content
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Simple app to preview the Processing Theme components
+ * Includes buttons, text fields, checkboxes, sliders, etc.
+ * Run by executing the main() function by clicking the green arrow next to it in intelliJ IDEA
+ */
+fun main() {
+ application {
+ val windowState = rememberWindowState(
+ size = DpSize(800.dp, 600.dp),
+ position = WindowPosition(Alignment.Center)
)
+ var darkTheme by remember { mutableStateOf(false) }
+ Window(onCloseRequest = ::exitApplication, state = windowState, title = "Processing Theme") {
+ PDETheme(darkTheme = darkTheme) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Processing Theme Components", style = MaterialTheme.typography.titleLarge)
+ Card {
+ Row {
+ Checkbox(darkTheme, onCheckedChange = { darkTheme = !darkTheme })
+ Text(
+ "Dark Theme",
+ modifier = Modifier.align(Alignment.CenterVertically)
+ )
+ }
+ }
+ val scrollable = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollable),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ComponentPreview("Colors") {
+ Column {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
+ onClick = {}) {
+ Text("Primary", color = MaterialTheme.colorScheme.onPrimary)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
+ onClick = {}) {
+ Text("Secondary", color = MaterialTheme.colorScheme.onSecondary)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary),
+ onClick = {}) {
+ Text("Tertiary", color = MaterialTheme.colorScheme.onTertiary)
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
+ onClick = {}) {
+ Text("Primary Container", color = MaterialTheme.colorScheme.onPrimaryContainer)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
+ onClick = {}) {
+ Text("Secondary Container", color = MaterialTheme.colorScheme.onSecondaryContainer)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
+ onClick = {}) {
+ Text("Tertiary Container", color = MaterialTheme.colorScheme.onTertiaryContainer)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer),
+ onClick = {}) {
+ Text("Error Container", color = MaterialTheme.colorScheme.onErrorContainer)
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.background),
+ onClick = {}) {
+ Text("Background", color = MaterialTheme.colorScheme.onBackground)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surface),
+ onClick = {}) {
+ Text("Surface", color = MaterialTheme.colorScheme.onSurface)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ onClick = {}) {
+ Text("Surface Variant", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Button(
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
+ onClick = {}) {
+ Text("Error", color = MaterialTheme.colorScheme.onError)
+ }
+ }
+ }
+ }
+ ComponentPreview("Text & Fonts") {
+ Column {
+ Text("displayLarge", style = MaterialTheme.typography.displayLarge)
+ Text("displayMedium", style = MaterialTheme.typography.displayMedium)
+ Text("displaySmall", style = MaterialTheme.typography.displaySmall)
+
+ Text("headlineLarge", style = MaterialTheme.typography.headlineLarge)
+ Text("headlineMedium", style = MaterialTheme.typography.headlineMedium)
+ Text("headlineSmall", style = MaterialTheme.typography.headlineSmall)
- CompositionLocalProvider(LocalTheme provides theme) {
- LocaleProvider {
- MaterialTheme(
- colors = colors,
- typography = Typography,
- content = content
- )
+ Text("titleLarge", style = MaterialTheme.typography.titleLarge)
+ Text("titleMedium", style = MaterialTheme.typography.titleMedium)
+ Text("titleSmall", style = MaterialTheme.typography.titleSmall)
+
+ Text("bodyLarge", style = MaterialTheme.typography.bodyLarge)
+ Text("bodyMedium", style = MaterialTheme.typography.bodyMedium)
+ Text("bodySmall", style = MaterialTheme.typography.bodySmall)
+
+ Text("labelLarge", style = MaterialTheme.typography.labelLarge)
+ Text("labelMedium", style = MaterialTheme.typography.labelMedium)
+ Text("labelSmall", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ ComponentPreview("Buttons") {
+ Button(onClick = {}) {
+ Text("Filled")
+ }
+ Button(onClick = {}, enabled = false) {
+ Text("Disabled")
+ }
+ OutlinedButton(onClick = {}) {
+ Text("Outlined")
+ }
+ TextButton(onClick = {}) {
+ Text("Text")
+ }
+ }
+ ComponentPreview("Icon Buttons") {
+ IconButton(onClick = {}) {
+ Icon(Icons.Default.Map, contentDescription = "Icon Button")
+ }
+ }
+ ComponentPreview("Chip") {
+ AssistChip(onClick = {}, label = {
+ Text("Assist Chip")
+ })
+ FilterChip(selected = false, onClick = {}, label = {
+ Text("Filter not Selected")
+ })
+ FilterChip(selected = true, onClick = {}, label = {
+ Text("Filter Selected")
+ })
+ }
+ ComponentPreview("Progress Indicator") {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)){
+ CircularProgressIndicator()
+ LinearProgressIndicator()
+ }
+ }
+ ComponentPreview("Radio Button") {
+ var state by remember { mutableStateOf(true) }
+ RadioButton(!state, onClick = { state = false })
+ RadioButton(state, onClick = { state = true })
+
+ }
+ ComponentPreview("Checkbox") {
+ var state by remember { mutableStateOf(true) }
+ Checkbox(state, onCheckedChange = { state = it })
+ Checkbox(!state, onCheckedChange = { state = !it })
+ Checkbox(state, onCheckedChange = {}, enabled = false)
+ TriStateCheckbox(ToggleableState.Indeterminate, onClick = {})
+ }
+ ComponentPreview("Switch") {
+ var state by remember { mutableStateOf(true) }
+ Switch(state, onCheckedChange = { state = it })
+ Switch(!state, enabled = false, onCheckedChange = { state = it })
+ }
+ ComponentPreview("Slider") {
+ Column{
+ var state by remember { mutableStateOf(0.5f) }
+ Slider(state, onValueChange = { state = it })
+ var rangeState by remember { mutableStateOf(0.25f..0.75f) }
+ RangeSlider(rangeState, onValueChange = { rangeState = it })
+ }
+
+ }
+ ComponentPreview("Badge") {
+ IconButton(onClick = {}) {
+ BadgedBox(badge = { Badge() }) {
+ Icon(Icons.Default.Map, contentDescription = "Icon with Badge")
+ }
+ }
+ }
+ ComponentPreview("Number Field") {
+ var number by remember { mutableStateOf("123") }
+ TextField(number, onValueChange = {
+ if(it.all { char -> char.isDigit() }) {
+ number = it
+ }
+ }, label = { Text("Number Field") })
+
+ }
+ ComponentPreview("Text Field") {
+ Row {
+ var text by remember { mutableStateOf("Text Field") }
+ TextField(text, onValueChange = { text = it })
+ }
+ var text by remember { mutableStateOf("Outlined Text Field") }
+ OutlinedTextField(text, onValueChange = { text = it})
+ }
+ ComponentPreview("Dropdown Menu") {
+ var show by remember { mutableStateOf(false) }
+ AssistChip(
+ onClick = { show = true },
+ label = { Text("Show Menu") }
+ )
+ DropdownMenu(
+ expanded = show,
+ onDismissRequest = {
+ show = false
+ },
+ ) {
+ DropdownMenuItem(onClick = { show = false }, text = {
+ Text("Menu Item 1", modifier = Modifier.padding(8.dp))
+ })
+ DropdownMenuItem(onClick = { show = false }, text = {
+ Text("Menu Item 2", modifier = Modifier.padding(8.dp))
+ })
+ DropdownMenuItem(onClick = { show = false }, text = {
+ Text("Menu Item 3", modifier = Modifier.padding(8.dp))
+ })
+ }
+
+
+ }
+
+ ComponentPreview("Scrollable View") {
+
+ }
+
+ ComponentPreview("Tabs") {
+
+ }
+ }
+ }
}
}
}
}
-fun String.toColorInt(): Int {
- if (this[0] == '#') {
- var color = substring(1).toLong(16)
- if (length == 7) {
- color = color or 0x00000000ff000000L
- } else if (length != 9) {
- throw IllegalArgumentException("Unknown color")
+@Composable
+private fun ComponentPreview(title: String, content: @Composable () -> Unit) {
+ Column {
+ Text(title, style = MaterialTheme.typography.titleLarge)
+ HorizontalDivider()
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp)) {
+ content()
}
- return color.toInt()
+ HorizontalDivider()
}
- throw IllegalArgumentException("Unknown color")
}
\ No newline at end of file
diff --git a/app/src/processing/app/ui/theme/Typography.kt b/app/src/processing/app/ui/theme/Typography.kt
index 5d87c490e6..6650ac7167 100644
--- a/app/src/processing/app/ui/theme/Typography.kt
+++ b/app/src/processing/app/ui/theme/Typography.kt
@@ -1,6 +1,5 @@
package processing.app.ui.theme
-import androidx.compose.material.MaterialTheme.typography
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -21,18 +20,108 @@ val processingFont = FontFamily(
style = FontStyle.Normal
)
)
+val spaceGroteskFont = FontFamily(
+ Font(
+ resource = "SpaceGrotesk-Bold.ttf",
+ weight = FontWeight.Bold,
+ ),
+ Font(
+ resource = "SpaceGrotesk-Regular.ttf",
+ weight = FontWeight.Normal,
+ ),
+ Font(
+ resource = "SpaceGrotesk-Medium.ttf",
+ weight = FontWeight.Medium,
+ ),
+ Font(
+ resource = "SpaceGrotesk-SemiBold.ttf",
+ weight = FontWeight.SemiBold,
+ ),
+ Font(
+ resource = "SpaceGrotesk-Light.ttf",
+ weight = FontWeight.Light,
+ )
+)
-val Typography = Typography(
+@Deprecated("Use PDE3Typography instead")
+val PDE2Typography = Typography(
+ defaultFontFamily = spaceGroteskFont,
+ h1 = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 42.725.sp,
+ lineHeight = 48.sp
+ ),
+ h2 = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 34.18.sp,
+ lineHeight = 40.sp
+ ),
+ h3 = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 27.344.sp,
+ lineHeight = 32.sp
+ ),
+ h4 = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 21.875.sp,
+ lineHeight = 28.sp
+ ),
+ h5 = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 17.5.sp,
+ lineHeight = 22.sp
+ ),
+ h6 = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 18.sp
+ ),
body1 = TextStyle(
- fontFamily = processingFont,
fontWeight = FontWeight.Normal,
- fontSize = 13.sp,
+ fontSize = 14.sp,
+ lineHeight = 18.sp
+ ),
+ body2 = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.8.sp,
lineHeight = 16.sp
),
subtitle1 = TextStyle(
- fontFamily = processingFont,
- fontWeight = FontWeight.Bold,
+ fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp
+ ),
+ subtitle2 = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 13.824.sp,
+ lineHeight = 16.sp,
+ ),
+ caption = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 11.2.sp,
+ lineHeight = 14.sp
+ ),
+ overline = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 8.96.sp,
+ lineHeight = 10.sp
)
+)
+val base = androidx.compose.material3.Typography()
+val PDETypography = androidx.compose.material3.Typography(
+ displayLarge = base.displayLarge.copy(fontFamily = spaceGroteskFont),
+ displayMedium = base.displayMedium.copy(fontFamily = spaceGroteskFont),
+ displaySmall = base.displaySmall.copy(fontFamily = spaceGroteskFont),
+ headlineLarge = base.headlineLarge.copy(fontFamily = spaceGroteskFont),
+ headlineMedium = base.headlineMedium.copy(fontFamily = spaceGroteskFont),
+ headlineSmall = base.headlineSmall.copy(fontFamily = spaceGroteskFont),
+ titleLarge = base.titleLarge.copy(fontFamily = spaceGroteskFont),
+ titleMedium = base.titleMedium.copy(fontFamily = spaceGroteskFont),
+ titleSmall = base.titleSmall.copy(fontFamily = spaceGroteskFont),
+ bodyLarge = base.bodyLarge.copy(fontFamily = spaceGroteskFont),
+ bodyMedium = base.bodyMedium.copy(fontFamily = spaceGroteskFont),
+ bodySmall = base.bodySmall.copy(fontFamily = spaceGroteskFont),
+ labelLarge = base.labelLarge.copy(fontFamily = spaceGroteskFont),
+ labelMedium = base.labelMedium.copy(fontFamily = spaceGroteskFont),
+ labelSmall = base.labelSmall.copy(fontFamily = spaceGroteskFont),
)
\ No newline at end of file
diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt
new file mode 100644
index 0000000000..5104f247bd
--- /dev/null
+++ b/app/src/processing/app/ui/theme/Window.kt
@@ -0,0 +1,203 @@
+package processing.app.ui.theme
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.awt.ComposePanel
+import androidx.compose.ui.awt.ComposeWindow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPlacement
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import com.formdev.flatlaf.util.SystemInfo
+import java.awt.Dimension
+
+import java.awt.event.KeyAdapter
+import java.awt.event.KeyEvent
+import javax.swing.JFrame
+import javax.swing.UIManager
+
+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 fullWindowContent If true, the content will extend into the title bar area on macOS.
+ * @param content The composable content to be displayed in the window.
+ */
+// TODO: Add support for onClose callback
+class PDESwingWindow(
+ titleKey: String = "",
+ size: Dimension? = null,
+ minSize: Dimension? = null,
+ maxSize: Dimension? = null,
+ fullWindowContent: Boolean = false,
+ 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, titleKey, fullWindowContent, content)
+ }
+ isVisible = true
+ }
+ }
+}
+
+/**
+ * Internal Composable function to set up the window content with theming and localization.
+ * It also handles macOS specific properties for full window content.
+ *
+ * @param window The JFrame instance to be configured.
+ * @param titleKey The key for the window title, which will be localized.
+ * @param fullWindowContent If true, the content will extend into the title bar area on macOS.
+ * @param content The composable content to be displayed in the window.
+ */
+@Composable
+private fun PDEWindowContent(
+ window: ComposeWindow,
+ titleKey: String,
+ 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)
+ }
+
+ 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 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,
+ 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, titleKey, fullWindowContent, content)
+ }
+}
+
+fun DpSize.toDimension(): Dimension? {
+ if(this == DpSize.Unspecified) { return null }
+
+ return Dimension(
+ this.width.value.toInt(),
+ this.height.value.toInt()
+ )
+}
+
+fun main(){
+ application {
+ PDEComposeWindow(
+ onClose = ::exitApplication,
+ titleKey = "window.title",
+ size = DpSize(800.dp, 600.dp),
+ ){
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("Hello, World!")
+ }
+ }
+ }
+}
\ No newline at end of file
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..85241406bb 100644
--- a/build/shared/lib/languages/PDE.properties
+++ b/build/shared/lib/languages/PDE.properties
@@ -238,6 +238,7 @@ 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 = Other Preferences (advanced users)
# Sketchbook Location (Frame)
sketchbook_location = Select new sketchbook folder
@@ -627,6 +628,24 @@ update_check = Update
update_check.updates_available.core = A new version of Processing is available,\nwould you like to visit the Processing download page?
update_check.updates_available.contributions = There are updates available for some of the installed contributions,\nwould you like to open the the Contribution Manager now?
+
+# ---------------------------------------
+# Welcome
+welcome.intro.title = Welcome to Processing
+welcome.intro.message = A flexible software sketchbook and a language for learning how to code.
+welcome.intro.suggestion = Is it your first time using Processing? Try one of the examples on the right.
+welcome.action.examples = More examples
+welcome.action.tutorials = Tutorials
+welcome.action.startup = Show this window at startup
+welcome.action.go = Let's go!
+
+# ---------------------------------------
+# Beta
+beta.window.title = Welcome to Beta
+beta.title = Welcome to the Processing Beta
+beta.message = Thank you for trying out the new version of Processing. We're very grateful!\n\nPlease report any bugs on the forums.
+beta.button = Got it!
+
# ---------------------------------------
# Beta
beta.window.title = Welcome to Beta
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index 8f7211b131..6708e269dc 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -36,7 +36,7 @@ dependencies {
}
mavenPublishing{
- publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
+ publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
signAllPublications()
pom{
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/preprocessor/build.gradle.kts b/java/preprocessor/build.gradle.kts
index d58fa3e7b9..6eb71a1242 100644
--- a/java/preprocessor/build.gradle.kts
+++ b/java/preprocessor/build.gradle.kts
@@ -47,7 +47,7 @@ publishing{
}
mavenPublishing{
- publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
+ publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
// Only sign if signing is set up
if(project.hasProperty("signing.keyId") || project.hasProperty("signingInMemoryKey"))
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8f8cb74c7f..7eacb06877 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -3,6 +3,7 @@ include(
"core",
"core:examples",
"app",
+ "app:utils",
"java",
"java:preprocessor",
"java:libraries:dxf",
@@ -11,5 +12,4 @@ include(
"java:libraries:pdf",
"java:libraries:serial",
"java:libraries:svg",
-)
-include("app:utils")
\ No newline at end of file
+)
\ No newline at end of file