diff --git a/app/src/processing/app/ui/WebFrame.java b/app/ant/processing/app/ui/WebFrame.java similarity index 100% rename from app/src/processing/app/ui/WebFrame.java rename to app/ant/processing/app/ui/WebFrame.java diff --git a/app/src/processing/app/ui/Welcome.java b/app/ant/processing/app/ui/Welcome.java similarity index 100% rename from app/src/processing/app/ui/Welcome.java rename to app/ant/processing/app/ui/Welcome.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0d3fcbd12d..6c8ac55f00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ import org.gradle.internal.jvm.Jvm import org.gradle.internal.os.OperatingSystem import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download @@ -16,6 +17,7 @@ plugins{ alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.serialization) alias(libs.plugins.download) } @@ -59,7 +61,7 @@ compose.desktop { ).map { "-D${it.first}=${it.second}" }.toTypedArray()) nativeDistributions{ - modules("jdk.jdi", "java.compiler", "jdk.accessibility", "java.management.rmi", "java.scripting", "jdk.httpserver") + modules("jdk.jdi", "java.compiler", "jdk.accessibility", "jdk.zipfs", "java.management.rmi", "java.scripting", "jdk.httpserver") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Processing" @@ -107,25 +109,29 @@ dependencies { implementation(compose.runtime) implementation(compose.foundation) - implementation(compose.material) implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + implementation(compose.materialIconsExtended) implementation(compose.desktop.currentOs) + implementation(libs.material3) implementation(libs.compottie) implementation(libs.kaml) implementation(libs.markdown) implementation(libs.markdownJVM) + implementation(libs.clikt) + implementation(libs.kotlinxSerializationJson) + + @OptIn(ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) testImplementation(kotlin("test")) testImplementation(libs.mockitoKotlin) testImplementation(libs.junitJupiter) testImplementation(libs.junitJupiterParams) - - implementation(libs.clikt) - implementation(libs.kotlinxSerializationJson) + } tasks.test { diff --git a/app/src/main/resources/icons/Discord.svg b/app/src/main/resources/icons/Discord.svg new file mode 100644 index 0000000000..54f918b869 --- /dev/null +++ b/app/src/main/resources/icons/Discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/resources/icons/GitHub.svg b/app/src/main/resources/icons/GitHub.svg new file mode 100644 index 0000000000..39b263b230 --- /dev/null +++ b/app/src/main/resources/icons/GitHub.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/resources/icons/Instagram.svg b/app/src/main/resources/icons/Instagram.svg new file mode 100644 index 0000000000..abb51a22e5 --- /dev/null +++ b/app/src/main/resources/icons/Instagram.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 2551a54d64..1d3bb27b4a 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -23,17 +23,6 @@ package processing.app; -import java.awt.*; -import java.awt.event.ActionListener; -import java.io.*; -import java.lang.reflect.InvocationTargetException; -import java.util.*; -import java.util.List; -import java.util.Map.Entry; - -import javax.swing.*; -import javax.swing.tree.DefaultMutableTreeNode; - import com.formdev.flatlaf.FlatDarkLaf; import com.formdev.flatlaf.FlatLaf; import com.formdev.flatlaf.FlatLightLaf; @@ -41,9 +30,22 @@ import processing.app.tools.Tool; import processing.app.ui.*; import processing.app.ui.Toolkit; -import processing.core.*; +import processing.core.PApplet; import processing.data.StringList; +import javax.swing.*; +import javax.swing.tree.DefaultMutableTreeNode; +import java.awt.*; +import java.awt.event.ActionListener; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.*; +import java.util.List; +import java.util.Map.Entry; + /** * The base class for the main processing application. * Primary role of this class is for platform identification and @@ -374,13 +376,7 @@ static private void handleWelcomeScreen(Base base) { // Needs to be shown after the first editor window opens, so that it // shows up on top, and doesn't prevent an editor window from opening. if (Preferences.getBoolean("welcome.four.show")) { - try { - new Welcome(base); - } catch (IOException e) { - Messages.showTrace("Unwelcoming", - "Please report this error to\n" + - "https://github.com/processing/processing4/issues", e, false); - } + PDEWelcomeKt.showWelcomeScreen(base); } } @@ -608,7 +604,7 @@ public JMenu initDefaultFileMenu() { defaultFileMenu.add(item); item = Toolkit.newJMenuItemShift(Language.text("menu.file.examples"), 'O'); - item.addActionListener(e -> thinkDifferentExamples()); + item.addActionListener(e -> showExamplesFrame()); defaultFileMenu.add(item); return defaultFileMenu; @@ -1884,7 +1880,7 @@ public void handleRestart() { // } - public void thinkDifferentExamples() { + public void showExamplesFrame() { nextMode.showExamplesFrame(); } @@ -2190,10 +2186,7 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { - if (preferencesFrame == null) { - preferencesFrame = new PreferencesFrame(this); - } - preferencesFrame.showFrame(); + PDEPreferencesKt.show(); } diff --git a/app/src/processing/app/Messages.kt b/app/src/processing/app/Messages.kt index cae54e6e97..e8f0979454 100644 --- a/app/src/processing/app/Messages.kt +++ b/app/src/processing/app/Messages.kt @@ -18,13 +18,36 @@ */ package processing.app +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.formdev.flatlaf.FlatLightLaf import processing.app.ui.Toolkit -import java.awt.EventQueue +import processing.app.ui.theme.PDETheme +import java.awt.Dimension import java.awt.Frame import java.io.PrintWriter import java.io.StringWriter -import javax.swing.JFrame import javax.swing.JOptionPane +import javax.swing.UIManager + class Messages { companion object { @@ -37,10 +60,23 @@ class Messages { if (Base.isCommandLine()) { println("$title: $message") } else { - JOptionPane.showMessageDialog( - Frame(), message, title, - JOptionPane.INFORMATION_MESSAGE - ) + showDialog(title) { modifier, dismiss -> + AlertDialog( + modifier = modifier, + onDismissRequest = { }, + shape = RectangleShape, + icon = { Icon(Icons.Default.Info, contentDescription = "Info!") }, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button( + onClick = { dismiss() } + ) { + Text("OK") + } + } + ) + } } } @@ -57,10 +93,27 @@ class Messages { if (Base.isCommandLine()) { println("$title: $message") } else { - JOptionPane.showMessageDialog( - Frame(), message, title, - JOptionPane.WARNING_MESSAGE - ) + showDialog(title){ modifier, dismiss -> + AlertDialog( + modifier = modifier, + onDismissRequest = { }, + shape = RectangleShape, + icon = { Icon(Icons.Default.Warning, contentDescription = "Alert!") }, + iconContentColor = MaterialTheme.colorScheme.tertiary, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Text("OK") + } + } + ) + } } e?.printStackTrace() } @@ -80,11 +133,30 @@ class Messages { // proper parsing on the command line. Many have \n in them. println("$title: $primary\n$secondary") } else { - EventQueue.invokeLater { - JOptionPane.showMessageDialog( - JFrame(), - Toolkit.formatMessage(primary, secondary), - title, JOptionPane.WARNING_MESSAGE + showDialog(title){ modifier, dismiss -> + AlertDialog( + modifier = modifier, + onDismissRequest = { }, + shape = RectangleShape, + icon = { Icon(Icons.Default.Warning, contentDescription = "Alert!") }, + iconContentColor = MaterialTheme.colorScheme.tertiary, + title = { Text(title) }, + text = { + Column { + Text(primary, fontWeight = FontWeight.Bold) + Text(secondary) + } + }, + confirmButton = { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Text("OK") + } + } ) } } @@ -102,10 +174,28 @@ class Messages { if (Base.isCommandLine()) { System.err.println("$title: $message") } else { - JOptionPane.showMessageDialog( - Frame(), message, title, - JOptionPane.ERROR_MESSAGE - ) + showDialog(title){ modifier, dismiss -> + AlertDialog( + modifier = modifier, + onDismissRequest = { }, + shape = RectangleShape, + icon = { Icon(Icons.Default.Error, contentDescription = "Alert!") }, + iconContentColor = MaterialTheme.colorScheme.error, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text("OK") + } + } + ) + } } e?.printStackTrace() System.exit(1) @@ -139,6 +229,8 @@ class Messages { if (fatal) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE ) + + if (fatal) { System.exit(1) } @@ -271,6 +363,63 @@ class Messages { } } +@OptIn(ExperimentalComposeUiApi::class) +fun showDialog(title: String, content: @Composable (modifier: Modifier, dismiss: () -> Unit) -> Unit) { + ComposeDialog().apply { + isModal = true + setTitle(title) + size = Dimension(400, 400) + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + rootPane.putClientProperty("apple.awt.windowTitleVisible", false); + + + setContent { + PDETheme { + val density = LocalDensity.current + content(Modifier.onSizeChanged{ + size = Dimension((it.width / density.density).toInt(), (it.height / density.density).toInt()) + setLocationRelativeTo(null) + },::dispose) + } + } + setLocationRelativeTo(null) + isVisible = true + } +} + +fun main(){ + val types = mapOf( + "message" to { Messages.showMessage("Test Title", "This is a test message.") }, + "warning" to { Messages.showWarning("Test Warning", "This is a test warning.", Exception("dfdsfjk")) }, + "trace" to { Messages.showTrace("Test Trace", "This is a test trace.", Exception("Test Exception"), false) }, + "tiered_warning" to { Messages.showWarningTiered("Test Tiered Warning", "Primary message", "Secondary message", null) }, + "yes_no" to { Messages.showYesNoQuestion(null, "Test Yes/No", "Do you want to continue?", "Choose yes or no.") }, + "custom_question" to { Messages.showCustomQuestion(null, "Test Custom Question", "Choose an option:", "Select one of the options below.", 1, "Option 1", "Option 2", "Option 3") }, + "error" to { Messages.showError("Test Error", "This is a test error.", null) }, + ) + Platform.init() + UIManager.setLookAndFeel(FlatLightLaf()) + application { + val state = rememberWindowState( + size = DpSize(500.dp, 300.dp) + ) + Window(state = state, onCloseRequest = ::exitApplication, title = "Test Messages") { + PDETheme { + Column { + for ((type, action) in types) { + Button(onClick = { action() }, modifier = Modifier.padding(8.dp)) { + Text("Show $type dialog") + } + } + } + } + } + + } + +} + // Helper functions to give the base classes a color fun String.formatClassName() = this .replace("processing.", "") diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index 640c77eade..8fcf7bb056 100644 --- a/app/src/processing/app/Preferences.java +++ b/app/src/processing/app/Preferences.java @@ -136,6 +136,14 @@ static public void skipInit() { initialized = true; } + /** + * Check whether Preferences.init() has been called. If not, we are probably not running the full application. + * @return true if Preferences has been initialized + */ + static public boolean isInitialized() { + return initialized; + } + static void handleProxy(String protocol, String hostProp, String portProp) { String proxyHost = get("proxy." + protocol + ".host"); diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c5645c9bbc..c13309299f 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -2,56 +2,191 @@ package processing.app import androidx.compose.runtime.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream -import java.nio.file.* -import java.util.Properties +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchEvent +import java.util.* +/* + The ReactiveProperties class extends the standard Java Properties class + to provide reactive capabilities using Jetpack Compose's mutableStateMapOf. + This allows UI components to automatically update when preference values change. +*/ +class ReactiveProperties: Properties() { + val snapshotStateMap = mutableStateMapOf() + + override fun setProperty(key: String, value: String) { + super.setProperty(key, value) + snapshotStateMap[key] = value + } + + override fun getProperty(key: String): String? { + return snapshotStateMap[key] ?: super.getProperty(key) + } + + override fun remove(key: Any?): Any? { + snapshotStateMap.remove(key as String) + return super.remove(key) + } + + operator fun get(key: String): String? = getProperty(key) + + operator fun set(key: String, value: String) { + setProperty(key, value) + } +} + +/* + A CompositionLocal to provide access to the ReactiveProperties instance + throughout the composable hierarchy. + */ +val LocalPreferences = compositionLocalOf { error("No preferences provided") } const val PREFERENCES_FILE_NAME = "preferences.txt" const val DEFAULTS_FILE_NAME = "defaults.txt" -fun PlatformStart(){ - Platform.inst ?: Platform.init() -} +/* + This composable function sets up a preferences provider that manages application settings. + It initializes the preferences from a file, watches for changes to that file, and saves + any updates back to the file. It uses a ReactiveProperties class to allow for reactive + updates in the UI when preferences change. + + usage: + PreferencesProvider { + // Your app content here + } + + to access preferences: + val preferences = LocalPreferences.current + val someSetting = preferences["someKey"] ?: "defaultValue" + preferences["someKey"] = "newValue" + + This will automatically save to the preferences file and update any UI components + that are observing that key. + + to override the preferences file (for testing, etc) + System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt") + to override the debounce time (in milliseconds) + System.setProperty("processing.app.preferences.debounce", "200") + */ +@OptIn(FlowPreview::class) @Composable -fun loadPreferences(): Properties{ - PlatformStart() +fun PreferencesProvider(content: @Composable () -> Unit){ + val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) } + val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull() - val settingsFolder = Platform.getSettingsFolder() - val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME) + // Initialize the platform (if not already done) to ensure we have access to the settings folder + remember { + Platform.init() + } + // Grab the preferences file, creating it if it doesn't exist + // TODO: This functionality should be separated from the `Preferences` class itself + val settingsFolder = Platform.getSettingsFolder() + val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME) if(!preferencesFile.exists()){ + preferencesFile.mkdirs() preferencesFile.createNewFile() } - watchFile(preferencesFile) - return Properties().apply { - load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream()) - load(preferencesFile.inputStream()) + val update = watchFile(preferencesFile) + + + val properties = remember(preferencesFile, update) { + ReactiveProperties().apply { + val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) + ?: InputStream.nullInputStream() + load(defaultsStream + .reader(Charsets.UTF_8) + ) + load(preferencesFile + .inputStream() + .reader(Charsets.UTF_8) + ) + } + } + + val initialState = remember(properties) { properties.snapshotStateMap.toMap() } + + // Listen for changes to the preferences and save them to file + LaunchedEffect(properties) { + snapshotFlow { properties.snapshotStateMap.toMap() } + .dropWhile { it == initialState } + .debounce(preferencesDebounceOverride ?: 100) + .collect { + + // Save the preferences to file, sorted alphabetically + preferencesFile.outputStream().use { output -> + output.write( + properties.entries + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() }) + .joinToString("\n") { (key, value) -> "$key=$value" } + .toByteArray() + ) + } + } + } + + CompositionLocalProvider(LocalPreferences provides properties){ + content() } + } +/* + This composable function watches a specified file for modifications. When the file is modified, + it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates + or other actions in response to changes in the file. + + To watch the file at the fasted speed (for testing) set the following system property: + System.setProperty("processing.app.watchfile.forced", "true") + */ @Composable fun watchFile(file: File): Any? { + val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean() + val scope = rememberCoroutineScope() var event by remember(file) { mutableStateOf?> (null) } DisposableEffect(file){ val fileSystem = FileSystems.getDefault() val watcher = fileSystem.newWatchService() + var active = true + // In forced mode we just poll the last modified time of the file + // This is not efficient but works better for testing with temp files + val toWatch = { file.lastModified() } + var state = toWatch() + val path = file.toPath() val parent = path.parent val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY) scope.launch(Dispatchers.IO) { while (active) { - for (modified in key.pollEvents()) { - if (modified.context() != path.fileName) continue - event = modified + if(forcedWatch) { + if(toWatch() == state) continue + state = toWatch() + event = object : WatchEvent { + override fun count(): Int = 1 + override fun context(): Path = file.toPath().fileName + override fun kind(): WatchEvent.Kind = StandardWatchEventKinds.ENTRY_MODIFY + override fun toString(): String = "ForcedEvent(${context()})" + } + continue + }else{ + for (modified in key.pollEvents()) { + if (modified.context() != path.fileName) continue + event = modified + } } } } @@ -62,12 +197,4 @@ fun watchFile(file: File): Any? { } } return event -} -val LocalPreferences = compositionLocalOf { error("No preferences provided") } -@Composable -fun PreferencesProvider(content: @Composable () -> Unit){ - val preferences = loadPreferences() - CompositionLocalProvider(LocalPreferences provides preferences){ - content() - } } \ No newline at end of file diff --git a/app/src/processing/app/api/Contributions.kt b/app/src/processing/app/api/Contributions.kt index 25e693404b..7b35a30593 100644 --- a/app/src/processing/app/api/Contributions.kt +++ b/app/src/processing/app/api/Contributions.kt @@ -28,8 +28,6 @@ class Contributions: SuspendingCliktCommand(){ } class ExamplesList: SuspendingCliktCommand("list") { - - val serializer = Json { prettyPrint = true } @@ -37,107 +35,121 @@ class Contributions: SuspendingCliktCommand(){ override fun help(context: Context) = "List all examples" override suspend fun run() { Platform.init() - // TODO: Decouple modes listing from `Base` class, defaulting to Java mode for now - // TODO: Allow the user to change the sketchbook location - // TODO: Currently blocked since `Base.getSketchbookFolder()` is not available in headless mode - val sketchbookFolder = Platform.getDefaultSketchbookFolder() - val resourcesDir = System.getProperty("compose.application.resources.dir") - - val javaMode = "$resourcesDir/modes/java" - - val javaModeExamples = File("$javaMode/examples") - .listFiles() - ?.map { getSketches(it)} - ?: emptyList() - - val javaModeLibrariesExamples = File("$javaMode/libraries") - .listFiles{ it.isDirectory } - ?.map { library -> - val properties = library.resolve("library.properties") - val name = findNameInProperties(properties) ?: library.name - - val libraryExamples = getSketches(library.resolve("examples")) - Sketch.Companion.Folder( - type = "folder", - name = name, - path = library.absolutePath, - mode = "java", - children = libraryExamples?.children ?: emptyList(), - sketches = libraryExamples?.sketches ?: emptyList() - ) - } ?: emptyList() - val javaModeLibraries = Sketch.Companion.Folder( - type = "folder", - name = "Libraries", - path = "$javaMode/libraries", - mode = "java", - children = javaModeLibrariesExamples, - sketches = emptyList() - ) - - val contributedLibraries = sketchbookFolder.resolve("libraries") - .listFiles{ it.isDirectory } - ?.map { library -> - val properties = library.resolve("library.properties") - val name = findNameInProperties(properties) ?: library.name - // Get library name from library.properties if it exists - val libraryExamples = getSketches(library.resolve("examples")) - Sketch.Companion.Folder( - type = "folder", - name = name, - path = library.absolutePath, - mode = "java", - children = libraryExamples?.children ?: emptyList(), - sketches = libraryExamples?.sketches ?: emptyList() - ) - } ?: emptyList() - - val contributedLibrariesFolder = Sketch.Companion.Folder( - type = "folder", - name = "Contributed Libraries", - path = sketchbookFolder.resolve("libraries").absolutePath, - mode = "java", - children = contributedLibraries, - sketches = emptyList() - ) - - val contributedExamples = sketchbookFolder.resolve("examples") - .listFiles{ it.isDirectory } - ?.map { - val properties = it.resolve("examples.properties") - val name = findNameInProperties(properties) ?: it.name - - val sketches = getSketches(it.resolve("examples")) - Sketch.Companion.Folder( - type = "folder", - name, - path = it.absolutePath, - mode = "java", - children = sketches?.children ?: emptyList(), - sketches = sketches?.sketches ?: emptyList(), - ) - } - ?: emptyList() - val contributedExamplesFolder = Sketch.Companion.Folder( - type = "folder", - name = "Contributed Examples", - path = sketchbookFolder.resolve("examples").absolutePath, - mode = "java", - children = contributedExamples, - sketches = emptyList() - ) - - val json = serializer.encodeToString(javaModeExamples + javaModeLibraries + contributedLibrariesFolder + contributedExamplesFolder) + + val json = serializer.encodeToString(listAllExamples()) println(json) } - private fun findNameInProperties(properties: File): String? { - if (!properties.exists()) return null + companion object { + /** + * Get all example sketch folders + * @return List of example sketch folders + */ + fun listAllExamples(): List { + // TODO: Decouple modes listing from `Base` class, defaulting to Java mode for now + // TODO: Allow the user to change the sketchbook location + // TODO: Currently blocked since `Base.getSketchbookFolder()` is not available in headless mode + // TODO: Make non-blocking + // TODO: Add tests + + val sketchbookFolder = Platform.getDefaultSketchbookFolder() + val resourcesDir = System.getProperty("compose.application.resources.dir") + + val javaMode = "$resourcesDir/modes/java" + + val javaModeExamples = File("$javaMode/examples") + .listFiles() + ?.map { getSketches(it) } + ?: emptyList() + + val javaModeLibrariesExamples = File("$javaMode/libraries") + .listFiles { it.isDirectory } + ?.map { library -> + val properties = library.resolve("library.properties") + val name = findNameInProperties(properties) ?: library.name + + val libraryExamples = getSketches(library.resolve("examples")) + Sketch.Companion.Folder( + type = "folder", + name = name, + path = library.absolutePath, + mode = "java", + children = libraryExamples?.children ?: emptyList(), + sketches = libraryExamples?.sketches ?: emptyList() + ) + } ?: emptyList() + val javaModeLibraries = Sketch.Companion.Folder( + type = "folder", + name = "Libraries", + path = "$javaMode/libraries", + mode = "java", + children = javaModeLibrariesExamples, + sketches = emptyList() + ) + + val contributedLibraries = sketchbookFolder.resolve("libraries") + .listFiles { it.isDirectory } + ?.map { library -> + val properties = library.resolve("library.properties") + val name = findNameInProperties(properties) ?: library.name + // Get library name from library.properties if it exists + val libraryExamples = getSketches(library.resolve("examples")) + Sketch.Companion.Folder( + type = "folder", + name = name, + path = library.absolutePath, + mode = "java", + children = libraryExamples?.children ?: emptyList(), + sketches = libraryExamples?.sketches ?: emptyList() + ) + } ?: emptyList() + + val contributedLibrariesFolder = Sketch.Companion.Folder( + type = "folder", + name = "Contributed Libraries", + path = sketchbookFolder.resolve("libraries").absolutePath, + mode = "java", + children = contributedLibraries, + sketches = emptyList() + ) + + val contributedExamples = sketchbookFolder.resolve("examples") + .listFiles { it.isDirectory } + ?.map { + val properties = it.resolve("examples.properties") + val name = findNameInProperties(properties) ?: it.name + + val sketches = getSketches(it.resolve("examples")) + Sketch.Companion.Folder( + type = "folder", + name, + path = it.absolutePath, + mode = "java", + children = sketches?.children ?: emptyList(), + sketches = sketches?.sketches ?: emptyList(), + ) + } + ?: emptyList() + val contributedExamplesFolder = Sketch.Companion.Folder( + type = "folder", + name = "Contributed Examples", + path = sketchbookFolder.resolve("examples").absolutePath, + mode = "java", + children = contributedExamples, + sketches = emptyList() + ) + + return javaModeExamples + javaModeLibraries + contributedLibrariesFolder + contributedExamplesFolder + } + + private fun findNameInProperties(properties: File): String? { + if (!properties.exists()) return null - return properties.readLines().firstNotNullOfOrNull { line -> - line.split("=", limit = 2) - .takeIf { it.size == 2 && it[0].trim() == "name" } - ?.let { it[1].trim() } + return properties.readLines().firstNotNullOfOrNull { line -> + line.split("=", limit = 2) + .takeIf { it.size == 2 && it[0].trim() == "name" } + ?.let { it[1].trim() } + } } } } diff --git a/app/src/processing/app/contrib/ui/ContributionManager.kt b/app/src/processing/app/contrib/ui/ContributionManager.kt deleted file mode 100644 index 2ad472159b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionManager.kt +++ /dev/null @@ -1,310 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.animation.Animatable -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import com.charleskorn.kaml.Yaml -import com.charleskorn.kaml.YamlConfiguration -import kotlinx.serialization.Serializable -import processing.app.Platform -import processing.app.loadPreferences -import java.net.URL -import java.util.* -import javax.swing.JFrame -import javax.swing.SwingUtilities -import kotlin.io.path.* - - -fun main() = application { - Window(onCloseRequest = ::exitApplication) { - contributionsManager() - } -} - -enum class Status { - VALID, - BROKEN, - DEPRECATED -} -enum class Type { - library, - mode, - tool, - examples, -} - -@Serializable -data class Author( - val name: String, - val url: String? = null, -) - -@Serializable -data class Contribution( - val id: Int, - val status: Status, - val source: String, - val type: Type, - val name: String? = null, - val categories: List? = emptyList(), - val authors: String? = null, - val authorList: List? = emptyList(), - val url: String? = null, - val sentence: String? = null, - val paragraph: String? = null, - val version: String? = null, - val prettyVersion: String? = null, - val minRevision: Int? = null, - val maxRevision: Int? = null, - val download: String? = null, - val isUpdate: Boolean? = null, - val isInstalled: Boolean? = null, -) - -@Serializable -data class Contributions( - val contributions: List -) - -fun openContributionsManager(){ - // open the compose window - - SwingUtilities.invokeLater { - val frame = JFrame("Contributions Manager") - frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - frame.setSize(800, 600) - - val composePanel = ComposePanel() - composePanel.setContent { - contributionsManager() - } - - frame.contentPane.add(composePanel) - frame.isVisible = true - } -} - -@Composable -fun contributionsManager(){ - var contributions by remember { mutableStateOf(listOf()) } - var localContributions by remember { mutableStateOf(listOf()) } - var error by remember { mutableStateOf(null) } - - val preferences = loadPreferences() - - LaunchedEffect(preferences){ - try { - localContributions = loadContributionProperties(preferences) - .map { (type, props) -> - Contribution( - id = 0, - status = Status.VALID, - source = "local", - type = type, - name = props.getProperty("name"), - authors = props.getProperty("authors"), - url = props.getProperty("url"), - sentence = props.getProperty("sentence"), - paragraph = props.getProperty("paragraph"), - version = props.getProperty("version"), - prettyVersion = props.getProperty("prettyVersion"), - minRevision = props.getProperty("minRevision")?.toIntOrNull(), - maxRevision = props.getProperty("maxRevision")?.toIntOrNull(), - download = props.getProperty("download"), - ) - } - } catch (e: Exception){ - error = e - } - } - - - LaunchedEffect(Unit){ - try { - val url = URL("https://github.com/mingness/processing-contributions-new/raw/refs/heads/main/contributions.yaml") - val connection = url.openConnection() - val inputStream = connection.getInputStream() - val yaml = inputStream.readAllBytes().decodeToString() - // TODO cache yaml in processing folder - - val parser = Yaml( - configuration = YamlConfiguration( - strictMode = false - ) - ) - val result = parser.decodeFromString(Contributions.serializer(), yaml) - - contributions = result.contributions - .filter { it.status == Status.VALID } - .map { - // TODO Parse better - val authorList = it.authors?.split(",")?.map { author -> - val parts = author.split("](") - val name = parts[0].removePrefix("[") - val url = parts.getOrNull(1)?.removeSuffix(")") - Author(name, url) - } ?: emptyList() - it.copy(authorList = authorList) - } - } catch (e: Exception){ - error = e - } - } - if(error != null){ - Text("Error loading contributions: ${error?.message}") - return - } - if(contributions.isEmpty()){ - Text("Loading contributions...") - return - } - - val contributionsByType = (contributions + localContributions) - .groupBy { it.name } - .map { (_, contributions) -> - if(contributions.size == 1) return@map contributions.first() - else{ - // check if they all have the same version, otherwise return the newest version - val versions = contributions.mapNotNull { it.version } - if(versions.toSet().size == 1) return@map contributions.first().copy(isInstalled = true) - else{ - val newest = contributions.maxByOrNull { it.version?.toIntOrNull() ?: 0 } - if(newest != null) return@map newest.copy(isUpdate = true, isInstalled = true) - else return@map contributions.first().copy(isUpdate = true, isInstalled = true) - } - } - } - .groupBy { it.type } - - val types = Type.entries - var selectedType by remember { mutableStateOf(types.first()) } - val contributionsForType = (contributionsByType[selectedType] ?: emptyList()) - .sortedBy { it.name } - - var selectedContribution by remember { mutableStateOf(null) } - Box{ - Column { - Row{ - for(type in types){ - val background = remember { Animatable(Color.Transparent) } - val color = remember { Animatable(Color.Black) } - LaunchedEffect(selectedType){ - if(selectedType == type){ - background.animateTo(Color(0xff0251c8)) - color.animateTo(Color.White) - }else{ - background.animateTo(Color.Transparent) - color.animateTo(Color.Black) - } - } - - Row(modifier = Modifier - .background(background.value) - .pointerHoverIcon(PointerIcon.Hand) - .clickable { - selectedType = type - selectedContribution = null - } - .padding(16.dp, 8.dp) - ){ - Text(type.name, color = color.value) - val updates = contributionsByType[type]?.count { it.isUpdate == true } ?: 0 - if(updates > 0){ - Text("($updates)") - } - } - } - } - - Box(modifier = Modifier.weight(1f)){ - val state = rememberLazyListState() - LazyColumn(state = state) { - item{ - // Table Header - } - items(contributionsForType){ contribution -> - Row(modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .clickable { selectedContribution = contribution } - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(modifier = Modifier.weight(1f)){ - if(contribution.isUpdate == true){ - Text("Update") - }else if(contribution.isInstalled == true){ - Text("Installed") - } - - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(8f)){ - Text(contribution.name ?: "Unnamed", fontWeight = FontWeight.Bold) - Text(contribution.sentence ?: "No description", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Row(modifier = Modifier.weight(4f)){ - Text(contribution.authorList?.joinToString { it.name } ?: "Unknown") - } - } - } - } - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .background(Color.LightGray) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter( - scrollState = state - ) - ) - } - ContributionPane( - contribution = selectedContribution, - onClose = { selectedContribution = null } - ) - } - - } - -} - - -fun loadContributionProperties(preferences: Properties): List>{ - val result = mutableListOf>() - val sketchBook = Path(preferences.getProperty("sketchbook.path.four", Platform.getDefaultSketchbookFolder().path)) - sketchBook.forEachDirectoryEntry{ contributionsFolder -> - if(!contributionsFolder.isDirectory()) return@forEachDirectoryEntry - val typeName = contributionsFolder.fileName.toString() - val type: Type = when(typeName){ - "libraries" -> Type.library - "modes" -> Type.mode - "tools" -> Type.tool - "examples" -> Type.examples - else -> return@forEachDirectoryEntry - } - contributionsFolder.forEachDirectoryEntry { contribution -> - if(!contribution.isDirectory()) return@forEachDirectoryEntry - contribution.forEachDirectoryEntry("*.properties"){ entry -> - val props = Properties() - props.load(entry.inputStream()) - result += Pair(type, props) - } - } - } - return result -} \ No newline at end of file diff --git a/app/src/processing/app/contrib/ui/ContributionPane.kt b/app/src/processing/app/contrib/ui/ContributionPane.kt deleted file mode 100644 index 2f4a96931b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionPane.kt +++ /dev/null @@ -1,79 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Window - -//--processing-blue-light: #82afff; -//--processing-blue-mid: #0564ff; -//--processing-blue-deep: #1e32aa; -//--processing-blue-dark: #0f195a; -//--processing-blue: #0251c8; - -@Composable -fun ContributionPane(contribution: Contribution?, onClose: () -> Unit) { - if(contribution == null) { - return - } - val typeName = when(contribution.type) { - Type.library -> "Library" - Type.tool -> "Tool" - Type.examples -> "Example" - Type.mode -> "Mode" - } - Window( - title = "${typeName}: ${contribution.name}", - onCloseRequest = onClose, - onKeyEvent = { - if(it.key == Key.Escape) { - onClose() - true - } else { - false - } - } - ){ - Box { - Column(modifier = Modifier.padding(10.dp)) { - Text(typeName, style = TextStyle(fontSize = 16.sp)) - Text(contribution.name ?: "", style = TextStyle(fontSize = 20.sp)) - Row(modifier = Modifier.padding(0.dp, 10.dp)) { - val action = when(contribution.isUpdate) { - true -> "Update" - false, null -> when(contribution.isInstalled) { - true -> "Uninstall" - false, null -> "Install" - } - } - Text(action, - style = TextStyle(fontSize = 14.sp, color = Color.White), - modifier = Modifier - .clickable { - - } - .pointerHoverIcon(PointerIcon.Hand) - .background(Color(0xff0251c8)) - .padding(24.dp,12.dp) - ) - } - Text(contribution.paragraph ?: "", style = TextStyle(fontSize = 14.sp)) - } - } - } - -} \ No newline at end of file diff --git a/app/src/processing/app/ui/Editor.java b/app/src/processing/app/ui/Editor.java index df2440d391..e4b4f15879 100644 --- a/app/src/processing/app/ui/Editor.java +++ b/app/src/processing/app/ui/Editor.java @@ -1057,6 +1057,7 @@ public void buildDevelopMenu(){ var updateTrigger = new JMenuItem(Language.text("menu.develop.check_for_updates")); updateTrigger.addActionListener(e -> { Preferences.unset("update.last"); + Preferences.setInteger("update.beta_welcome", 0); new UpdateCheck(base); }); developMenu.add(updateTrigger); diff --git a/app/src/processing/app/ui/EditorFooter.java b/app/src/processing/app/ui/EditorFooter.java index bc09b2376a..94860a0abf 100644 --- a/app/src/processing/app/ui/EditorFooter.java +++ b/app/src/processing/app/ui/EditorFooter.java @@ -22,15 +22,14 @@ package processing.app.ui; -import java.awt.CardLayout; -import java.awt.Color; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Font; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Image; -import java.awt.datatransfer.Clipboard; +import processing.app.Base; +import processing.app.Mode; +import processing.app.Sketch; +import processing.app.contrib.ContributionManager; +import processing.data.StringDict; + +import javax.swing.*; +import java.awt.*; import java.awt.datatransfer.StringSelection; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -39,14 +38,6 @@ import java.util.ArrayList; import java.util.List; -import javax.swing.*; - -import processing.app.Base; -import processing.app.Mode; -import processing.app.Sketch; -import processing.app.contrib.ContributionManager; -import processing.data.StringDict; - /** * Console/error/whatever tabs at the bottom of the editor window. @@ -118,6 +109,18 @@ public void mousePressed(MouseEvent e) { Base.DEBUG = !Base.DEBUG; editor.updateDevelopMenu(); } + copyDebugInformationToClipboard(); + } + }); + + tabBar.add(version); + + add(tabBar); + + updateTheme(); + } + + public static void copyDebugInformationToClipboard() { var debugInformation = String.join("\n", "Version: " + Base.getVersionName(), "Revision: " + Base.getRevision(), @@ -127,18 +130,12 @@ public void mousePressed(MouseEvent e) { var stringSelection = new StringSelection(debugInformation); var clipboard = java.awt.Toolkit.getDefaultToolkit().getSystemClipboard(); clipboard.setContents(stringSelection, null); - } - }); - - tabBar.add(version); - - add(tabBar); - - updateTheme(); - } + } - /** Add a panel with no icon. */ + /** + * Add a panel with no icon. + */ public void addPanel(Component comp, String name) { addPanel(comp, name, null); } diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt new file mode 100644 index 0000000000..747b5d92b5 --- /dev/null +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -0,0 +1,568 @@ +package processing.app.ui + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography +import processing.app.LocalPreferences +import processing.app.ui.PDEPreferences.Companion.preferences +import processing.app.ui.preferences.* +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import java.awt.Dimension +import javax.swing.SwingUtilities + +fun show() { + SwingUtilities.invokeLater { + PDESwingWindow( + titleKey = "preferences", + fullWindowContent = true, + size = Dimension(850, 600), + minSize = Dimension(700, 500), + ) { + PDETheme { + preferences() + } + } + } +} + +class PDEPreferences { + companion object{ + private val panes: PDEPreferencePanes = mutableStateMapOf() + + /** + * Registers a new preference in the preferences' system. + * If the preference's pane does not exist, it will be created. + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * pane = somePreferencePane, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + * + * @param preferences The preference to register. + */ + fun register(vararg preferences: PDEPreference) { + if (preferences.map { it.pane }.toSet().size != 1) { + throw IllegalArgumentException("All preferences must belong to the same pane") + } + val pane = preferences.first().pane + + val group = mutableStateListOf() + group.addAll(preferences) + + val groups = panes[pane] as? SnapshotStateList ?: mutableStateListOf() + groups.add(group) + panes[pane] = groups + } + + /** + * Static initializer to register default preference panes. + */ + init{ + General.register() + Interface.register() + Coding.register() + Sketches.register() + Other.register(panes) + } + + /** + * Composable function to display the preferences UI. + */ + @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) + @Composable + fun preferences() { + val locale = LocalLocale.current + var preferencesQuery by remember { mutableStateOf("") } + + /** + * Filter panes based on the search query. + */ + val panesQuierried = remember(preferencesQuery, panes) { + if (preferencesQuery.isBlank()) { + panes.toMutableMap() + } else { + panes.entries.associate { (pane, preferences) -> + val matching = preferences.map { group -> + group.filter { preference -> + val description = locale[preference.descriptionKey] + when { + preference.key == "other" -> true + preference.key.contains(preferencesQuery, ignoreCase = true) -> true + description.contains(preferencesQuery, ignoreCase = true) -> true + else -> false + } + } + } + pane to matching + }.toMutableMap() + } + } + + /** + * Sort panes based on their 'after' property and name. + */ + val panesSorted = remember(panesQuierried) { + panesQuierried.keys.sortedWith { a, b -> + when { + a === b -> 0 + a.after == b -> 1 + b.after == a -> -1 + a.after == null && b.after != null -> -1 + b.after == null && a.after != null -> 1 + else -> a.nameKey.compareTo(b.nameKey) + } + } + } + + + /** + * Pre-select a pane that has at least one preference to show + * Also reset the selection when the query changes + * */ + var selected by remember(panesQuierried) { + mutableStateOf(panesSorted.firstOrNull() { panesQuierried[it].isNotEmpty() }) + } + + /** + * Swapping primary and tertiary colors for the preferences window, probably should do that program-wide + */ + val originalScheme = MaterialTheme.colorScheme + MaterialTheme( + colorScheme = originalScheme.copy( + primary = originalScheme.tertiary, + onPrimary = originalScheme.onTertiary, + primaryContainer = originalScheme.tertiaryContainer, + onPrimaryContainer = originalScheme.onTertiaryContainer, + + tertiary = originalScheme.primary, + onTertiary = originalScheme.onPrimary, + tertiaryContainer = originalScheme.primaryContainer, + onTertiaryContainer = originalScheme.onPrimaryContainer, + ) + ) { + Column { + /** + * Header + */ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = locale["preferences"], + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), + ) + Text( + text = locale["preferences.description"], + style = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.width(96.dp)) + SearchBar( + modifier = Modifier + .widthIn(max = 250.dp), + inputField = { + SearchBarDefaults.InputField( + query = preferencesQuery, + onQueryChange = { + preferencesQuery = it + }, + onSearch = { + + }, + trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + ) { + + } + } + HorizontalDivider() + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + /** + * Sidebar + */ + Column( + modifier = Modifier + .width(IntrinsicSize.Min) + .padding(30.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + + for (pane in panesSorted) { + val shape = RoundedCornerShape(12.dp) + val isSelected = selected == pane + TextButton( + onClick = { + selected = pane + }, + enabled = panesQuierried[pane].isNotEmpty(), + colors = if (isSelected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + shape = shape + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + pane.icon() + Text(locale[pane.nameKey]) + } + } + } + } + + /** + * Content Area + */ + AnimatedContent( + targetState = selected, + transitionSpec = { + fadeIn( + animationSpec = tween(300) + ) togetherWith fadeOut( + animationSpec = tween(300) + ) + } + ) { selected -> + if (selected == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(30.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = locale["preferences.no_results"], + style = MaterialTheme.typography.bodyMedium + ) + } + return@AnimatedContent + } + + val groups = panesQuierried[selected] ?: emptyList() + selected.showPane(groups) + } + } + } + } + } + + /** + * Main function to run the preferences window standalone for testing & development. + */ + @JvmStatic + fun main(args: Array) { + application { + Window(onCloseRequest = ::exitApplication) { + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = true) { + preferences() + } + } + Window(onCloseRequest = ::exitApplication) { + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = false) { + preferences() + } + } + } + } + } +} + +typealias PDEPreferencePanes = MutableMap +typealias PDEPreferenceGroups = List +typealias PDEPreferenceGroup = List +typealias PDEPreferenceControl = @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit + +/** + * Data class representing a pane of preferences. + */ +data class PDEPreferencePane( + /** + * The name key of this pane from the Processing locale. + */ + val nameKey: String, + /** + * The icon representing this pane. + */ + val icon: @Composable () -> Unit, + /** + * The pane that comes before this one in the list. + */ + val after: PDEPreferencePane? = null, +) + +/** + * Composable function to display the contents of a preference pane. + */ +@Composable +fun PDEPreferencePane.showPane(groups: PDEPreferenceGroups) { + Box { + val locale = LocalLocale.current + val state = rememberLazyListState() + LazyColumn( + state = state, + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(top = 30.dp, end = 30.dp, bottom = 30.dp) + ) { + item { + Text( + text = locale[nameKey], + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium), + ) + } + items(groups) { group -> + Card( + modifier = Modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ), + ) { + group.forEachIndexed { index, preference -> + preference.showControl() + if (index != group.lastIndex) { + HorizontalDivider() + } + } + + } + } + item { + val prefs = LocalPreferences.current + TextButton( + onClick = { + groups.forEach { group -> + group.forEach { pref -> + prefs.remove(pref.key) + } + } + } + ) { + Text( + text = locale["preferences.reset"], + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(12.dp) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(state) + ) + } +} + +/** + * Data class representing a single preference in the preferences' system. + * + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * group = somePreferenceGroup, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + */ +data class PDEPreference( + /** + * The key in the preferences file used to store this preference. + */ + val key: String, + /** + * The key for the description of this preference, used for localization. + */ + val descriptionKey: String, + + /** + * The key for the label of this preference, used for localization. + * If null, the label will not be shown. + */ + val labelKey: String? = null, + /** + * The group this preference belongs to. + */ + val pane: PDEPreferencePane, + /** + * A Composable function that defines the control used to modify this preference. + * It takes the current preference value and a function to update the preference. + */ + val control: PDEPreferenceControl = { preference, updatePreference -> }, + + /** + * If true, no padding will be applied around this preference's UI. + */ + val noPadding: Boolean = false, + /** + * If true, the title will be omitted from this preference's UI. + */ + val noTitle: Boolean = false, +) + +/** + * Extension function to check if a list of preference groups is not empty. + */ +fun PDEPreferenceGroups?.isNotEmpty(): Boolean { + if (this == null) return false + for (group in this) { + if (group.isNotEmpty()) return true + } + return false +} + +/** + * Composable function to display the preference's description and control. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PDEPreference.showControl() { + val locale = LocalLocale.current + val prefs = LocalPreferences.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!noTitle) { + Column( + modifier = Modifier + .weight(1f) + + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(horizontal = 20.dp) + ) { + Text( + text = locale[descriptionKey], + style = MaterialTheme.typography.bodyMedium + ) + if (labelKey != null && locale.containsKey(labelKey)) { + Card { + Text( + text = locale[labelKey], + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(8.dp, 4.dp) + ) + } + } + } + if (locale.containsKey("$descriptionKey.tip")) { + Markdown( + content = locale["$descriptionKey.tip"], + colors = markdownColor( + text = MaterialTheme.colorScheme.onSurfaceVariant, + ), + typography = markdownTypography( + text = MaterialTheme.typography.bodySmall, + paragraph = MaterialTheme.typography.bodySmall, + textLink = TextLinkStyles( + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ).toSpanStyle() + ) + ), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 4.dp) + ) + } + } + } + val show = @Composable { + control(prefs[key]) { newValue -> + prefs[key] = newValue + } + } + + if (noPadding) { + show() + } else { + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + ) { + show() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt new file mode 100644 index 0000000000..d9cae78f15 --- /dev/null +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -0,0 +1,643 @@ +package processing.app.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.NoteAdd +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.FolderSpecial +import androidx.compose.material.icons.outlined.PinDrop +import androidx.compose.material.icons.outlined.School +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.decodeToImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.application +import processing.app.Base +import processing.app.LocalPreferences +import processing.app.Messages +import processing.app.Platform +import processing.app.api.Contributions.ExamplesList.Companion.listAllExamples +import processing.app.api.Sketch.Companion.Sketch +import processing.app.ui.preferences.Interface.Companion.languagesDropdown +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.Locale +import processing.app.ui.theme.PDEComposeWindow +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import processing.app.ui.theme.toDimension +import java.io.File +import kotlin.io.path.Path +import kotlin.io.path.exists + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PDEWelcome(base: Base? = null) { + Row( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ){ + val shape = RoundedCornerShape(12.dp) + val xsPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) + val xsModifier = Modifier + .defaultMinSize(minHeight = 1.dp) + .height(32.dp) + val textColor = if(isSystemInDarkTheme()) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSecondaryContainer + val locale = LocalLocale.current + + /** + * Left main column + */ + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxHeight() + .weight(0.8f) + .padding( + top = 48.dp, + start = 56.dp, + end = 64.dp, + bottom = 56.dp + ) + ) { + /** + * Title row + */ + Row ( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ){ + Image( + painter = painterResource("logo.svg"), + modifier = Modifier + .size(50.dp), + contentDescription = locale["welcome.processing.logo"] + ) + Text( + text = locale["welcome.processing.title"], + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = textColor, + modifier = Modifier + .align(Alignment.CenterVertically) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterVertically), + horizontalArrangement = Arrangement.End, + ){ + val showLanguageMenu = remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showLanguageMenu.value = !showLanguageMenu.value + }, + contentPadding = xsPadding, + modifier = xsModifier, + shape = shape + ){ + Icon(Icons.Default.Language, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text(text = locale.locale.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = "", modifier = Modifier.size(20.dp)) + languagesDropdown(showLanguageMenu) + } + } + } + /** + * New sketch, examples, sketchbook card + */ + val colors = ButtonDefaults.textButtonColors( + contentColor = textColor + ) + Column{ + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + val medModifier = Modifier + .sizeIn(minHeight = 56.dp) + TextButton( + onClick = { + base?.handleNew() ?: noBaseWarning() + }, + colors = colors, + modifier = medModifier, + shape = shape + ) { + Icon(Icons.AutoMirrored.Outlined.NoteAdd, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text(locale["welcome.actions.sketch.new"]) + } + TextButton( + onClick = { + base?.let{ + base.showExamplesFrame() + } ?: noBaseWarning() + }, + colors = colors, + modifier = medModifier, + shape = shape + ) { + Icon(Icons.Outlined.FolderSpecial, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text(locale["welcome.actions.examples"] ) + } + TextButton( + onClick = { + base?.let{ + base.showSketchbookFrame() + } ?: noBaseWarning() + }, + colors = colors, + modifier = medModifier, + shape = shape + ) { + Icon(Icons.Outlined.FolderOpen, contentDescription = "") + Spacer(Modifier.width(12.dp)) + Text(locale["sketchbook"], modifier = Modifier.align(Alignment.CenterVertically)) + } + } + } + /** + * Resources and community card + */ + Card( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ){ + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + modifier = Modifier + .padding( + top = 18.dp, + end = 24.dp, + bottom = 24.dp, + start = 24.dp + ) + ) { + val colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = locale["welcome.resources.title"], + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(start = 8.dp) + ) + TextButton( + onClick = { + Platform.openURL("https://processing.org/tutorials/gettingstarted") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Outlined.PinDrop, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = locale["welcome.resources.get_started"], + ) + } + TextButton( + onClick = { + Platform.openURL("https://processing.org/tutorials") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Outlined.School, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = locale["welcome.resources.tutorials"], + ) + } + TextButton( + onClick = { + Platform.openURL("https://processing.org/reference") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon(Icons.Outlined.Book, contentDescription = "", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = locale["welcome.resources.documentation"], + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = locale["welcome.community.title"], + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(start = 8.dp) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + modifier = Modifier + .fillMaxWidth() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton( + onClick = { + Platform.openURL("https://discourse.processing.org") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = locale["welcome.community.forum"] + ) + } + TextButton( + onClick = { + Platform.openURL("https://discord.processing.org") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + painterResource("icons/Discord.svg"), + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Discord") + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton( + onClick = { + Platform.openURL("https://github.com/processing/processing4") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + painterResource("icons/GitHub.svg"), + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("GitHub") + } + TextButton( + onClick = { + Platform.openURL("https://www.instagram.com/processing_core/") + }, + contentPadding = xsPadding, + modifier = xsModifier, + colors = colors + ) { + Icon( + painterResource("icons/Instagram.svg"), + contentDescription = "", + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Instagram") + } + } + } + } + } + } + } + /** + * Show on startup checkbox + */ + Row{ + val preferences = LocalPreferences.current + val showOnStartup = preferences["welcome.four.show"].toBoolean() + fun toggle(next: Boolean? = null) { + preferences["welcome.four.show"] = (next ?: !showOnStartup).toString() + } + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = ::toggle) + .padding(end = 8.dp) + .height(32.dp) + ) { + Checkbox( + checked = showOnStartup, + onCheckedChange = ::toggle, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.tertiary + ), + modifier = Modifier + .defaultMinSize(minHeight = 1.dp) + ) + Text( + text = locale["welcome.actions.show_startup"], + modifier = Modifier.align(Alignment.CenterVertically), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + /** + * Examples list + */ + val scrollMargin = 35.dp + Column( + modifier = Modifier + .width(350.dp + scrollMargin) + ) { + val examples = remember { mutableStateListOf( + *listOf( + Platform.getContentFile("modes/java/examples/Basics/Arrays/Array"), + Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective"), + Platform.getContentFile("modes/java/examples/Basics/Color/Brightness"), + Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ") + ).map{ Sketch(path = it.absolutePath, name = it.name) }.toTypedArray() + )} + + remember { + val sketches = mutableListOf() + val sketchFolders = listAllExamples() + fun gatherSketches(folder: processing.app.api.Sketch.Companion.Folder?) { + if (folder == null) return + sketches.addAll(folder.sketches.filter { it -> Path(it.path).resolve("${it.name}.png").exists() }) + folder.children.forEach { child -> + gatherSketches(child) + } + } + sketchFolders.forEach { folder -> + gatherSketches(folder) + } + if(sketches.isEmpty()) { + return@remember + } + examples.clear() + examples.addAll(sketches.shuffled().take(20)) + } + val state = rememberLazyListState( + initialFirstVisibleItemScrollOffset = 150 + ) + Box( + modifier = Modifier + .padding(end = 4.dp) + ) { + LazyColumn( + state = state, + contentPadding = PaddingValues(top = 12.dp, bottom = 12.dp, end = 20.dp, start = scrollMargin), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(examples) { example -> + example.card{ + base?.let { + base.handleOpen("${example.path}/${example.name}.pde") + } ?: noBaseWarning() + } + } + } + VerticalScrollbar( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(state) + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun Sketch.card(onOpen: () -> Unit = {}) { + val locale = LocalLocale.current + val sketch = this + var hovered by remember { mutableStateOf(false) } + Box( + Modifier + .border( + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium + ) + .background( + MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium + ) + .clip(MaterialTheme.shapes.medium) + .fillMaxSize() + .aspectRatio(16 / 9f) + .onPointerEvent(PointerEventType.Enter) { + hovered = true + } + .onPointerEvent(PointerEventType.Exit) { + hovered = false + } + ) { + val image = remember { + File(sketch.path, "${sketch.name}.png").takeIf { it.exists() } + } + if (image == null) { + Icon( + painter = painterResource("logo.svg"), + modifier = Modifier + .size(75.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + contentDescription = "Processing Logo" + ) + HorizontalDivider() + } else { + val imageBitmap: ImageBitmap = remember(image) { + image.inputStream().readAllBytes().decodeToImageBitmap() + } + Image( + painter = BitmapPainter(imageBitmap), + modifier = Modifier + .fillMaxSize(), + contentDescription = sketch.name + ) + } + Column( + modifier = Modifier.align(Alignment.BottomCenter), + ) { + val duration = 150 + AnimatedVisibility( + visible = hovered, + enter = slideIn( + initialOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = EaseInOut + ) + ), + exit = slideOut( + targetOffset = { fullSize -> IntOffset(0, fullSize.height) }, + animationSpec = tween( + durationMillis = duration, + easing = LinearEasing + ) + ) + ) { + Card( + modifier = Modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(12.dp) + .padding(start = 12.dp) + ) { + Text( + text = sketch.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(8.dp) + ) + Button( + onClick = onOpen, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary + ), + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 4.dp + ), + ) { + Text( + text = locale["welcome.sketch.open"], + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + } +} + +fun noBaseWarning() { + Messages.showWarning( + "No Base", + "No Base instance provided, this ui is likely being previewed." + ) +} + +val size = DpSize(970.dp, 600.dp) +const val titleKey = "menu.help.welcome" +class WelcomeScreen + +fun showWelcomeScreen(base: Base? = null) { + PDESwingWindow( + titleKey = titleKey, + size = size.toDimension(), + unique = WelcomeScreen::class, + fullWindowContent = true + ) { + PDEWelcome(base) + } +} + + +fun main(){ + application { + PDEComposeWindow(titleKey = titleKey, size = size, fullWindowContent = true) { + PDETheme(darkTheme = true) { + PDEWelcome() + } + } + PDEComposeWindow(titleKey = titleKey, size = size, fullWindowContent = true) { + PDETheme(darkTheme = false) { + PDEWelcome() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/Start.kt b/app/src/processing/app/ui/Start.kt index 7de371eec4..d7ed635ecf 100644 --- a/app/src/processing/app/ui/Start.kt +++ b/app/src/processing/app/ui/Start.kt @@ -46,6 +46,8 @@ class Start { var visible by remember { mutableStateOf(false) } val composition = rememberCoroutineScope() LaunchedEffect(Unit) { + Toolkit.setIcon(window) + visible = true composition.launch { delay(duration.toLong() + timeMargin) diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt index 7757e820f6..531c28f7ef 100644 --- a/app/src/processing/app/ui/WelcomeToBeta.kt +++ b/app/src/processing/app/ui/WelcomeToBeta.kt @@ -5,11 +5,11 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.MaterialTheme.colors -import androidx.compose.material.MaterialTheme.typography -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -31,17 +31,17 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo import com.mikepenz.markdown.compose.Markdown -import com.mikepenz.markdown.m2.markdownColor -import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.model.MarkdownColors import com.mikepenz.markdown.model.MarkdownTypography import processing.app.Preferences import processing.app.Base.getRevision import processing.app.Base.getVersionName import processing.app.ui.theme.LocalLocale -import processing.app.ui.theme.LocalTheme import processing.app.ui.theme.Locale -import processing.app.ui.theme.ProcessingTheme +import processing.app.ui.theme.PDEComposeWindow +import processing.app.ui.theme.PDESwingWindow import java.awt.Cursor import java.awt.Dimension import java.awt.event.KeyAdapter @@ -54,52 +54,26 @@ import javax.swing.SwingUtilities class WelcomeToBeta { companion object{ - val windowSize = Dimension(400, 200) - val windowTitle = Locale()["beta.window.title"] - @JvmStatic fun showWelcomeToBeta() { - val mac = SystemInfo.isMacFullWindowContentSupported SwingUtilities.invokeLater { - JFrame(windowTitle).apply { - val close = { - Preferences.set("update.beta_welcome", getRevision().toString()) - dispose() - } - rootPane.putClientProperty("apple.awt.transparentTitleBar", mac) - rootPane.putClientProperty("apple.awt.fullWindowContent", mac) - defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - contentPane.add(ComposePanel().apply { - size = windowSize - setContent { - ProcessingTheme { - Box(modifier = Modifier.padding(top = if (mac) 22.dp else 0.dp)) { - welcomeToBeta(close) - } - } - } - }) - pack() - background = java.awt.Color.white - setLocationRelativeTo(null) - addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - if (e.keyCode == KeyEvent.VK_ESCAPE) close() - } - }) - isResizable = false - isVisible = true - requestFocus() + val close = { + Preferences.set("update.beta_welcome", getRevision().toString()) + } + + PDESwingWindow("beta.window.title", onClose = close) { + welcomeToBeta(close) } } } + val windowSize = Dimension(400, 200) @Composable fun welcomeToBeta(close: () -> Unit = {}) { Row( modifier = Modifier .padding(20.dp, 10.dp) - .size(windowSize.width.dp, windowSize.height.dp), + .fillMaxSize(), horizontalArrangement = Arrangement .spacedBy(20.dp) ){ @@ -109,7 +83,7 @@ class WelcomeToBeta { contentDescription = locale["beta.logo"], modifier = Modifier .align(Alignment.CenterVertically) - .size(100.dp, 100.dp) + .size(120.dp) .offset(0.dp, (-25).dp) ) Column( @@ -123,7 +97,7 @@ class WelcomeToBeta { ) { Text( text = locale["beta.title"], - style = typography.subtitle1, + style = typography.titleLarge, ) val text = locale["beta.message"] .replace('$' + "version", getVersionName()) @@ -131,81 +105,30 @@ class WelcomeToBeta { Markdown( text, colors = markdownColor(), - typography = markdownTypography(text = typography.body1, link = typography.body1.copy(color = colors.primary)), + typography = markdownTypography(), modifier = Modifier.background(Color.Transparent).padding(bottom = 10.dp) ) Row { Spacer(modifier = Modifier.weight(1f)) - PDEButton(onClick = { + Button(onClick = { close() }) { Text( text = locale["beta.button"], - color = colors.onPrimary + color = MaterialTheme.colorScheme.onPrimary ) } } } } } - @OptIn(ExperimentalComposeUiApi::class) - @Composable - fun PDEButton(onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) { - val theme = LocalTheme.current - - var hover by remember { mutableStateOf(false) } - var clicked by remember { mutableStateOf(false) } - val offset by animateFloatAsState(if (hover) -5f else 5f) - val color by animateColorAsState(if(clicked) colors.primaryVariant else colors.primary) - - Box(modifier = Modifier.padding(end = 5.dp, top = 5.dp)) { - Box( - modifier = Modifier - .offset((-offset).dp, (offset).dp) - .background(theme.getColor("toolbar.button.pressed.field")) - .matchParentSize() - ) - Box( - modifier = Modifier - .onPointerEvent(PointerEventType.Press) { - clicked = true - } - .onPointerEvent(PointerEventType.Release) { - clicked = false - onClick() - } - .onPointerEvent(PointerEventType.Enter) { - hover = true - } - .onPointerEvent(PointerEventType.Exit) { - hover = false - } - .pointerHoverIcon(PointerIcon(Cursor(Cursor.HAND_CURSOR))) - .background(color) - .padding(10.dp) - .sizeIn(minWidth = 100.dp), - contentAlignment = Alignment.Center, - content = content - ) - } - } - @JvmStatic fun main(args: Array) { application { - val windowState = rememberWindowState( - size = DpSize.Unspecified, - position = WindowPosition(Alignment.Center) - ) - - Window(onCloseRequest = ::exitApplication, state = windowState, title = windowTitle) { - ProcessingTheme { - Surface(color = colors.background) { - welcomeToBeta { - exitApplication() - } - } + PDEComposeWindow(titleKey = "beta.window.title", onClose = ::exitApplication){ + welcomeToBeta { + exitApplication() } } } diff --git a/app/src/processing/app/ui/preferences/Coding.kt b/app/src/processing/app/ui/preferences/Coding.kt new file mode 100644 index 0000000000..dc6d0cbcae --- /dev/null +++ b/app/src/processing/app/ui/preferences/Coding.kt @@ -0,0 +1,90 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.theme.LocalLocale + +class Coding { + companion object { + val coding = PDEPreferencePane( + nameKey = "preferences.pane.editor", + icon = { Icon(Icons.Default.EditNote, contentDescription = null) }, + after = interfaceAndFonts, + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "pdex.errorCheckEnabled", + descriptionKey = "preferences.continuously_check", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.warningsEnabled", + descriptionKey = "preferences.show_warnings", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.completion", + descriptionKey = "preferences.code_completion", + pane = coding, + noTitle = true, + control = { preference, setPreference -> + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val locale = LocalLocale.current + Text( + text = locale["preferences.code_completion"] + " Ctrl-" + locale["preferences.cmd_space"], + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + } + ), + PDEPreference( + key = "pdex.suggest.imports", + descriptionKey = "preferences.suggest_imports", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt new file mode 100644 index 0000000000..a8bd559033 --- /dev/null +++ b/app/src/processing/app/ui/preferences/General.kt @@ -0,0 +1,175 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import processing.app.Preferences +import processing.app.SketchName +import processing.app.ui.EditorFooter.copyDebugInformationToClipboard +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.theme.LocalLocale +import processing.awt.ShimAWT.selectFolder +import java.io.File + + +class General { + companion object{ + val general = PDEPreferencePane( + nameKey = "preferences.pane.general", + icon = { + Icon(Icons.Default.Settings, contentDescription = "General Preferences") + } + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "sketchbook.path.four", + descriptionKey = "preferences.sketchbook_location", + pane = general, + noTitle = true, + control = { preference, updatePreference -> + val locale = LocalLocale.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(locale["preferences.sketchbook_location"]) }, + value = preference ?: "", + onValueChange = { + updatePreference(it) + }, + trailingIcon = { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier + .clickable { + selectFolder( + locale["preferences.sketchbook_location.popup"], + File(preference ?: "") + ) { selectedFile: File? -> + if (selectedFile != null) { + updatePreference(selectedFile.absolutePath) + } + } + } + ) + } + ) + } + ), + PDEPreference( + key = "sketch.name.approach", + descriptionKey = "preferences.sketch_naming", + pane = general, + control = { preference, updatePreference -> + Column { + val options = if (Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + "timestamp", + "untitled", + "custom" + ) + options.toList().chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { option -> + InputChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + ) + } + } + } + } + } + ), + PDEPreference( + key = "editor.sync_folder_and_filename", + labelKey = "preferences.experimental", + descriptionKey = "preferences.sync_folder_and_filename", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "update.check", + descriptionKey = "preferences.update_check", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.four.show", + descriptionKey = "preferences.show_welcome_screen", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.diagnostics", + pane = general, + control = { preference, updatePreference -> + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + Button(onClick = { + copyDebugInformationToClipboard() + copied = true + + }) { + if (!copied) { + Text(LocalLocale.current["preferences.diagnostics.button"]) + } else { + Text(LocalLocale.current["preferences.diagnostics.button.copied"]) + } + } + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt new file mode 100644 index 0000000000..b494db69d3 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -0,0 +1,265 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.TextIncrease +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Language +import processing.app.LocalPreferences +import processing.app.Preferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.Toolkit +import processing.app.ui.preferences.General.Companion.general +import processing.app.ui.theme.LocalLocale +import java.util.* + +class Interface { + companion object{ + val interfaceAndFonts = PDEPreferencePane( + nameKey = "preferences.pane.interface", + icon = { + Icon(Icons.Default.TextIncrease, contentDescription = "Interface") + }, + after = general + ) + + @OptIn(ExperimentalMaterial3Api::class) + fun register() { + PDEPreferences.register( + PDEPreference( + key = "language", + descriptionKey = "preferences.language", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + val showOptions = remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showOptions.value = true + }, + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Language, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(locale.locale.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + languagesDropdown(showOptions) + } + ), + PDEPreference( + key = "editor.input_method_support", + descriptionKey = "preferences.enable_complex_text", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val enabled = preference?.toBoolean() ?: true + Switch( + checked = enabled, + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "editor.theme", + descriptionKey = "preferences.editor.theme", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InputChip( + selected = (preference ?: "") == "", + onClick = { + updatePreference("") + }, + label = { + Text(locale["preferences.editor.theme.system"]) + } + ) + InputChip( + selected = preference == "dark", + onClick = { + updatePreference("dark") + }, + label = { + Text(locale["preferences.editor.theme.dark"]) + } + ) + InputChip( + selected = preference == "light", + onClick = { + updatePreference("light") + }, + label = { + Text(locale["preferences.editor.theme.light"]) + } + ) + } + } + ), + PDEPreference( + key = "editor.zoom", + descriptionKey = "preferences.interface_scale", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val range = 100f..300f + + val prefs = LocalPreferences.current + var currentZoom by remember(preference) { + mutableStateOf( + preference + ?.replace("%", "") + ?.toFloatOrNull() + ?: range.start + ) + } + val automatic = currentZoom == range.start + val zoomPerc = "${currentZoom.toInt()}%" + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .widthIn(max = 200.dp) + ) { + Text( + text = if (automatic) "Auto" else zoomPerc, + ) + Slider( + value = currentZoom, + onValueChange = { + currentZoom = it + }, + onValueChangeFinished = { + prefs["editor.zoom.auto"] = automatic + updatePreference(zoomPerc) + }, + valueRange = range, + steps = 3 + ) + } + } + } + ) + ) + + PDEPreferences.register( + PDEPreference( + key = "editor.font.family", + descriptionKey = "preferences.editor_and_console_font", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + var showOptions by remember { mutableStateOf(false) } + val families = + if (Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + OutlinedButton( + onClick = { + showOptions = true + }, + modifier = Modifier.width(200.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(preference ?: families.firstOrNull().orEmpty()) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + families.forEach { family -> + DropdownMenuItem( + text = { Text(family) }, + onClick = { + updatePreference(family) + showOptions = false + } + ) + } + + } + } + ), + PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18 + ) + } + } + ), PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + ) + ) + } + + @Composable + fun languagesDropdown(showOptions: MutableState) { + val locale = LocalLocale.current + val languages = if (Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + DropdownMenu( + expanded = showOptions.value, + onDismissRequest = { + showOptions.value = false + }, + ) { + languages.forEach { family -> + DropdownMenuItem( + text = { Text(family.value) }, + onClick = { + locale.set(Locale(family.key)) + showOptions.value = false + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt new file mode 100644 index 0000000000..b637a55f44 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -0,0 +1,97 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Science +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferencePanes +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Sketches.Companion.sketches +import processing.app.ui.theme.LocalLocale + +class Other { + companion object{ + val other = PDEPreferencePane( + nameKey = "preferences.pane.other", + icon = { + Icon(Icons.Default.Science, contentDescription = "Other Preferences") + }, + after = sketches + ) + + fun register(panes: PDEPreferencePanes) { + // TODO: Move to individual preferences + PDEPreferences.register( + PDEPreference( + key = "preferences.show_other", + descriptionKey = "preferences.other", + pane = other, + control = { preference, setPreference -> + val showOther = preference?.toBoolean() ?: false + Switch( + checked = showOther, + onCheckedChange = { + setPreference(it.toString()) + } + ) + if (!showOther) { + return@PDEPreference + } + val prefs = LocalPreferences.current + val locale = LocalLocale.current + DisposableEffect(Unit) { + // add all the other options to the same group as the current one + val group = + panes[other]?.find { group -> group.any { preference -> preference.key == "preferences.show_other" } } as? MutableList + + val existing = panes.values.flatten().flatten().map { preference -> preference.key } + val keys = prefs.keys.mapNotNull { it as? String }.filter { it !in existing }.sorted() + + for (prefKey in keys) { + val descriptionKey = "preferences.$prefKey" + val preference = PDEPreference( + key = prefKey, + descriptionKey = if (locale.containsKey(descriptionKey)) descriptionKey else prefKey, + pane = other, + control = { preference, updatePreference -> + if (preference?.toBooleanStrictOrNull() != null) { + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + return@PDEPreference + } + + OutlinedTextField( + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + } + ) + group?.add(preference) + } + onDispose { + group?.apply { + removeIf { it.key != "preferences.show_other" } + } + } + } + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Sketches.kt b/app/src/processing/app/ui/preferences/Sketches.kt new file mode 100644 index 0000000000..0a3b77375b --- /dev/null +++ b/app/src/processing/app/ui/preferences/Sketches.kt @@ -0,0 +1,161 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Monitor +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Coding.Companion.coding +import java.awt.GraphicsEnvironment +import javax.swing.JColorChooser + +class Sketches { + companion object { + val sketches = PDEPreferencePane( + nameKey = "preferences.pane.sketches", + icon = { Icon(Icons.Default.PlayArrow, contentDescription = null) }, + after = coding, + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "run.display", + descriptionKey = "preferences.run_sketches_on_display", + pane = sketches, + control = { preference, setPreference -> + val ge = GraphicsEnvironment.getLocalGraphicsEnvironment() + val defaultDevice = ge.defaultScreenDevice + val devices = ge.screenDevices + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + devices.toList().chunked(2).forEach { devices -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + devices.forEachIndexed { index, device -> + val displayNum = (index + 1).toString() + OutlinedButton( + colors = if (preference == displayNum || (device == defaultDevice && preference == "-1")) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.outlinedButtonColors() + }, + shape = RoundedCornerShape(12.dp), + onClick = { + setPreference(if (device == defaultDevice) "-1" else displayNum) + } + ) { + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box { + Icon( + Icons.Default.Monitor, + modifier = Modifier.size(32.dp), + contentDescription = null + ) + Text( + text = displayNum, + modifier = Modifier + .align(Alignment.Center) + .offset(0.dp, (-2).dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = "${device.displayMode.width} x ${device.displayMode.height}", + style = MaterialTheme.typography.bodySmall, + ) + if (device == defaultDevice) { + Text( + text = "Default", + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(0.5f), + ) + } + } + } + } + } + } + } + } + ), + PDEPreference( + key = "run.options.memory", + descriptionKey = "preferences.increase_memory", + pane = sketches, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { + setPreference(it.toString()) + } + ) + } + ), + PDEPreference( + key = "run.options.memory.maximum", + descriptionKey = "preferences.increase_max_memory", + pane = sketches, + control = { preference, setPreference -> + OutlinedTextField( + enabled = LocalPreferences.current["run.options.memory"]?.toBoolean() ?: false, + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + trailingIcon = { Text("MB") }, + onValueChange = { + setPreference(it) + } + ) + } + ), + PDEPreference( + key = "run.present.bgcolor", + descriptionKey = "preferences.background_color", + pane = sketches, + control = { preference, setPreference -> + val color = try { + java.awt.Color.decode(preference) + } catch (e: Exception) { + java.awt.Color.BLACK + } + Box( + modifier = Modifier + .size(64.dp) + .padding(4.dp) + .background( + color = Color(color.red, color.green, color.blue), + shape = RoundedCornerShape(4.dp) + ) + .clickable { + // TODO: Replace with Compose color picker when available + val newColor = JColorChooser.showDialog( + null, + "Choose Background Color", + color + ) ?: color + val hexColor = + String.format("#%02x%02x%02x", newColor.red, newColor.green, newColor.blue) + setPreference(hexColor) + } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Colors.kt b/app/src/processing/app/ui/theme/Colors.kt new file mode 100644 index 0000000000..af423ba488 --- /dev/null +++ b/app/src/processing/app/ui/theme/Colors.kt @@ -0,0 +1,90 @@ +package processing.app.ui.theme + +import androidx.compose.material.Colors +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +class ProcessingColors{ + companion object{ + val blue = Color(0xFF0251c8) + val lightBlue = Color(0xFF82AFFF) + + val deepBlue = Color(0xFF1e32aa) + val darkBlue = Color(0xFF0F195A) + + val white = Color(0xFFFFFFFF) + val lightGray = Color(0xFFF5F5F5) + val gray = Color(0xFFDBDBDB) + val darkGray = Color(0xFF898989) + val darkerGray = Color(0xFF727070) + val veryDarkGray = Color(0xFF1E1E1E) + val black = Color(0xFF0D0D0D) + + val error = Color(0xFFFF5757) + val errorContainer = Color(0xFFFFA6A6) + + val p5Light = Color(0xFFfd9db9) + val p5Mid = Color(0xFFff4077) + val p5Dark = Color(0xFFaf1f42) + + val foundationLight = Color(0xFFd4b2fe) + val foundationMid = Color(0xFF9c4bff) + val foundationDark = Color(0xFF5501a4) + + val downloadInactive = Color(0xFF8890B3) + val downloadBackgroundActive = Color(0xFF14508B) + } +} + +val PDELightColor = lightColorScheme( + primary = ProcessingColors.blue, + onPrimary = ProcessingColors.white, + + primaryContainer = ProcessingColors.downloadBackgroundActive, + onPrimaryContainer = ProcessingColors.darkBlue, + + secondary = ProcessingColors.deepBlue, + onSecondary = ProcessingColors.white, + + secondaryContainer = ProcessingColors.downloadInactive, + onSecondaryContainer = ProcessingColors.white, + + tertiary = ProcessingColors.p5Mid, + onTertiary = ProcessingColors.white, + + tertiaryContainer = ProcessingColors.p5Light, + onTertiaryContainer = ProcessingColors.p5Dark, + + background = ProcessingColors.white, + onBackground = ProcessingColors.darkBlue, + + surface = ProcessingColors.lightGray, + onSurface = ProcessingColors.darkerGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + errorContainer = ProcessingColors.errorContainer, + onErrorContainer = ProcessingColors.white +) + +val PDEDarkColor = darkColorScheme( + primary = ProcessingColors.deepBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.lightBlue, + onSecondary = ProcessingColors.white, + + tertiary = ProcessingColors.blue, + onTertiary = ProcessingColors.white, + + background = ProcessingColors.veryDarkGray, + onBackground = ProcessingColors.white, + + surface = ProcessingColors.darkerGray, + onSurface = ProcessingColors.lightGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, +) \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Locale.kt b/app/src/processing/app/ui/theme/Locale.kt index 254c0946c1..d760998185 100644 --- a/app/src/processing/app/ui/theme/Locale.kt +++ b/app/src/processing/app/ui/theme/Locale.kt @@ -1,24 +1,41 @@ package processing.app.ui.theme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import processing.app.LocalPreferences -import processing.app.Messages -import processing.app.Platform -import processing.app.PlatformStart -import processing.app.watchFile +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import processing.app.* import java.io.File import java.io.InputStream import java.util.* -class Locale(language: String = "") : Properties() { +/** + * The Locale class extends the standard Java Properties class + * to provide localization capabilities. + * It loads localization resources from property files based on the specified language code. + * The class also provides a method to change the current locale and update the application accordingly. + * Usage: + * ``` + * val locale = Locale("es") { newLocale -> + * // Handle locale change, e.g., update UI or restart application + * } + * val localizedString = locale["someKey"] + * ``` + */ +class Locale(language: String = "", val setLocale: ((java.util.Locale) -> Unit)? = null) : Properties() { + var locale: java.util.Locale = java.util.Locale.getDefault() + init { - val locale = java.util.Locale.getDefault() - load(ClassLoader.getSystemResourceAsStream("PDE.properties")) - load(ClassLoader.getSystemResourceAsStream("PDE_${locale.language}.properties") ?: InputStream.nullInputStream()) - load(ClassLoader.getSystemResourceAsStream("PDE_${locale.toLanguageTag()}.properties") ?: InputStream.nullInputStream()) - load(ClassLoader.getSystemResourceAsStream("PDE_${language}.properties") ?: InputStream.nullInputStream()) + loadResourceUTF8("PDE.properties") + loadResourceUTF8("PDE_${locale.language}.properties") + loadResourceUTF8("PDE_${locale.toLanguageTag()}.properties") + loadResourceUTF8("PDE_${language}.properties") + } + + fun loadResourceUTF8(path: String) { + val stream = ClassLoader.getSystemResourceAsStream(path) + stream?.reader(charset = Charsets.UTF_8)?.use { reader -> + load(reader) + } } @Deprecated("Use get instead", ReplaceWith("get(key)")) @@ -28,18 +45,86 @@ class Locale(language: String = "") : Properties() { return value } operator fun get(key: String): String = getProperty(key, key) + fun set(locale: java.util.Locale) { + setLocale?.invoke(locale) + } } -val LocalLocale = compositionLocalOf { Locale() } +/** + * A CompositionLocal to provide access to the Locale instance + * throughout the composable hierarchy. see [LocaleProvider] + * Usage: + * ``` + * val locale = LocalLocale.current + * val localizedString = locale["someKey"] + * ``` + */ +val LocalLocale = compositionLocalOf { error("No Locale Set") } + +/** + * This composable function sets up a locale provider that manages application localization. + * It initializes the locale from a language file, watches for changes to that file, and updates + * the locale accordingly. It uses a [Locale] class to handle loading of localized resources. + * + * Usage: + * ``` + * LocaleProvider { + * // Your app content here + * } + * ``` + * + * To access the locale: + * ``` + * val locale = LocalLocale.current + * val localizedString = locale["someKey"] + * ``` + * + * To change the locale: + * ``` + * locale.set(java.util.Locale("es")) + * ``` + * This will update the `language.txt` file and reload the locale. + */ @Composable fun LocaleProvider(content: @Composable () -> Unit) { - PlatformStart() + val preferencesFolderOverride: File? = System.getProperty("processing.app.preferences.folder")?.let { File(it) } + + val settingsFolder = preferencesFolderOverride ?: remember{ + Platform.init() + Platform.getSettingsFolder() + } + val languageFile = settingsFolder.resolve("language.txt") + remember(languageFile){ + if(languageFile.exists()) return@remember - val settingsFolder = Platform.getSettingsFolder() - val languageFile = File(settingsFolder, "language.txt") - watchFile(languageFile) + Messages.log("Creating language file at ${languageFile.absolutePath}") + settingsFolder.mkdirs() + languageFile.writeText(java.util.Locale.getDefault().language) + } + + val update = watchFile(languageFile) + var code by remember(languageFile, update){ mutableStateOf(languageFile.readText().substring(0, 2)) } + remember(code) { + val locale = java.util.Locale(code) + java.util.Locale.setDefault(locale) + } + + fun setLocale(locale: java.util.Locale) { + Messages.log("Setting locale to ${locale.language}") + languageFile.writeText(locale.language) + code = locale.language + } + + + val locale = Locale(code, ::setLocale) + remember(code) { Messages.log("Loaded Locale: $code") } + val dir = when(locale["locale.direction"]) { + "rtl" -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } - val locale = Locale(languageFile.readText().substring(0, 2)) - CompositionLocalProvider(LocalLocale provides locale) { - content() + CompositionLocalProvider(LocalLayoutDirection provides dir) { + CompositionLocalProvider(LocalLocale provides locale) { + content() + } } } \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 735d8e5b2a..e9c9bb7516 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -1,75 +1,350 @@ package processing.app.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import darkScheme +import lightScheme import processing.app.LocalPreferences import processing.app.PreferencesProvider -import java.io.InputStream -import java.util.Properties +/** + * Processing Theme for Jetpack Compose Desktop + * Based on Material3 + * + * Makes Material3 components follow Processing color scheme and typography + * We experimented with using the material3 theme builder, but it made it look too Android-y + * So we defined our own color scheme and typography based on Processing design guidelines + * + * This composable also provides Preferences and Locale context to all child composables + * + * Also, important: sets a default density of 1.25 for better scaling on desktop screens, [LocalDensity] + * + * Usage: + * ``` + * PDETheme { + * val pref = LocalPreferences.current + * val locale = LocalLocale.current + * ... + * // Your composables here + * } + * ``` + * + * @param darkTheme Whether to use dark theme or light theme. Defaults to system setting. + */ +@Composable +fun PDETheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +){ + PreferencesProvider { + LocaleProvider { + val preferences = LocalPreferences.current + val theme = when { + preferences["editor.theme"] == "dark" -> darkScheme + preferences["editor.theme"] == "light" -> lightScheme + darkTheme -> darkScheme + else -> lightScheme -class Theme(themeFile: String? = "") : Properties() { - init { - load(ClassLoader.getSystemResourceAsStream("theme.txt")) - load(ClassLoader.getSystemResourceAsStream(themeFile) ?: InputStream.nullInputStream()) - } - fun getColor(key: String): Color { - return Color(getProperty(key).toColorInt()) + } + MaterialTheme( + colorScheme = theme, + typography = PDETypography + ){ + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { + CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 16.dp, + thickness = 8.dp, + shape = MaterialTheme.shapes.extraSmall, + hoverDurationMillis = 300, + unhoverColor = MaterialTheme.colorScheme.outlineVariant, + hoverColor = MaterialTheme.colorScheme.outlineVariant + ), + LocalContentColor provides MaterialTheme.colorScheme.onSurface, +// LocalDensity provides Density(1.25f, 1.25f), + content = content + ) + } + } + } } } -val LocalTheme = compositionLocalOf { error("No theme provided") } - -@Composable -fun ProcessingTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable() () -> Unit -) { - PreferencesProvider { - val preferences = LocalPreferences.current - val theme = Theme(preferences.getProperty("theme")) - val colors = Colors( - primary = theme.getColor("editor.gradient.top"), - primaryVariant = theme.getColor("toolbar.button.pressed.field"), - secondary = theme.getColor("editor.gradient.bottom"), - secondaryVariant = theme.getColor("editor.scrollbar.thumb.pressed.color"), - background = theme.getColor("editor.bgcolor"), - surface = theme.getColor("editor.bgcolor"), - error = theme.getColor("status.error.bgcolor"), - onPrimary = theme.getColor("toolbar.button.enabled.field"), - onSecondary = theme.getColor("toolbar.button.enabled.field"), - onBackground = theme.getColor("editor.fgcolor"), - onSurface = theme.getColor("editor.fgcolor"), - onError = theme.getColor("status.error.fgcolor"), - isLight = theme.getProperty("laf.mode").equals("light") +/** + * Simple app to preview the Processing Theme components + * Includes buttons, text fields, checkboxes, sliders, etc. + * Run by executing the main() function by clicking the green arrow next to it in intelliJ IDEA + */ +fun main() { + application { + val windowState = rememberWindowState( + size = DpSize(800.dp, 600.dp), + position = WindowPosition(Alignment.Center) ) + var darkTheme by remember { mutableStateOf(false) } + Window(onCloseRequest = ::exitApplication, state = windowState, title = "Processing Theme") { + PDETheme(darkTheme = darkTheme) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Processing Theme Components", style = MaterialTheme.typography.titleLarge) + Card { + Row { + Checkbox(darkTheme, onCheckedChange = { darkTheme = !darkTheme }) + Text( + "Dark Theme", + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } + val scrollable = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(scrollable), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ComponentPreview("Colors") { + val colors = listOf>( + Triple("Primary", MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), + Triple("Secondary", MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.onSecondary), + Triple("Tertiary", MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.onTertiary), + Triple("Primary Container", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer), + Triple("Secondary Container", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer), + Triple("Tertiary Container", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer), + Triple("Error Container", MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer), + Triple("Background", MaterialTheme.colorScheme.background, MaterialTheme.colorScheme.onBackground), + Triple("Surface", MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.onSurface), + Triple("Surface Variant", MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), + Triple("Error", MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), + + Triple("Surface Lowest", MaterialTheme.colorScheme.surfaceContainerLowest, MaterialTheme.colorScheme.onSurface), + Triple("Surface Low", MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.colorScheme.onSurface), + Triple("Surface", MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.onSurface), + Triple("Surface High", MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), + Triple("Surface Highest", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), + ) + Column { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val section = colors.subList(0,3) + for((name, color, onColor) in section){ + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val section = colors.subList(3,7) + for((name, color, onColor) in section){ + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val section = colors.subList(7,11) + for((name, color, onColor) in section){ + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + val section = colors.subList(11, 16) + for ((name, color, onColor) in section) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = color), + onClick = {}) { + Text(name, color = onColor) + } + } + } + } + } + ComponentPreview("Text & Fonts") { + Column { + Text("displayLarge", style = MaterialTheme.typography.displayLarge) + Text("displayMedium", style = MaterialTheme.typography.displayMedium) + Text("displaySmall", style = MaterialTheme.typography.displaySmall) + + Text("headlineLarge", style = MaterialTheme.typography.headlineLarge) + Text("headlineMedium", style = MaterialTheme.typography.headlineMedium) + Text("headlineSmall", style = MaterialTheme.typography.headlineSmall) + + Text("titleLarge", style = MaterialTheme.typography.titleLarge) + Text("titleMedium", style = MaterialTheme.typography.titleMedium) + Text("titleSmall", style = MaterialTheme.typography.titleSmall) - CompositionLocalProvider(LocalTheme provides theme) { - LocaleProvider { - MaterialTheme( - colors = colors, - typography = Typography, - content = content - ) + Text("bodyLarge", style = MaterialTheme.typography.bodyLarge) + Text("bodyMedium", style = MaterialTheme.typography.bodyMedium) + Text("bodySmall", style = MaterialTheme.typography.bodySmall) + + Text("labelLarge", style = MaterialTheme.typography.labelLarge) + Text("labelMedium", style = MaterialTheme.typography.labelMedium) + Text("labelSmall", style = MaterialTheme.typography.labelSmall) + } + } + ComponentPreview("Buttons") { + Button(onClick = {}) { + Text("Filled") + } + Button(onClick = {}, enabled = false) { + Text("Disabled") + } + OutlinedButton(onClick = {}) { + Text("Outlined") + } + TextButton(onClick = {}) { + Text("Text") + } + } + ComponentPreview("Icon Buttons") { + IconButton(onClick = {}) { + Icon(Icons.Default.Map, contentDescription = "Icon Button") + } + } + ComponentPreview("Chip") { + AssistChip(onClick = {}, label = { + Text("Assist Chip") + }) + FilterChip(selected = false, onClick = {}, label = { + Text("Filter not Selected") + }) + FilterChip(selected = true, onClick = {}, label = { + Text("Filter Selected") + }) + } + ComponentPreview("Progress Indicator") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)){ + CircularProgressIndicator() + LinearProgressIndicator() + } + } + ComponentPreview("Radio Button") { + var state by remember { mutableStateOf(true) } + RadioButton(!state, onClick = { state = false }) + RadioButton(state, onClick = { state = true }) + + } + ComponentPreview("Checkbox") { + var state by remember { mutableStateOf(true) } + Checkbox(state, onCheckedChange = { state = it }) + Checkbox(!state, onCheckedChange = { state = !it }) + Checkbox(state, onCheckedChange = {}, enabled = false) + TriStateCheckbox(ToggleableState.Indeterminate, onClick = {}) + } + ComponentPreview("Switch") { + var state by remember { mutableStateOf(true) } + Switch(state, onCheckedChange = { state = it }) + Switch(!state, enabled = false, onCheckedChange = { state = it }) + } + ComponentPreview("Slider") { + Column{ + var state by remember { mutableStateOf(0.5f) } + Slider(state, onValueChange = { state = it }) + var rangeState by remember { mutableStateOf(0.25f..0.75f) } + RangeSlider(rangeState, onValueChange = { rangeState = it }) + } + + } + ComponentPreview("Badge") { + IconButton(onClick = {}) { + BadgedBox(badge = { Badge() }) { + Icon(Icons.Default.Map, contentDescription = "Icon with Badge") + } + } + } + ComponentPreview("Number Field") { + var number by remember { mutableStateOf("123") } + TextField(number, onValueChange = { + if(it.all { char -> char.isDigit() }) { + number = it + } + }, label = { Text("Number Field") }) + + } + ComponentPreview("Text Field") { + Row { + var text by remember { mutableStateOf("Text Field") } + TextField(text, onValueChange = { text = it }) + } + var text by remember { mutableStateOf("Outlined Text Field") } + OutlinedTextField(text, onValueChange = { text = it}) + } + ComponentPreview("Dropdown Menu") { + var show by remember { mutableStateOf(false) } + AssistChip( + onClick = { show = true }, + label = { Text("Show Menu") } + ) + DropdownMenu( + expanded = show, + onDismissRequest = { + show = false + }, + ) { + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 1", modifier = Modifier.padding(8.dp)) + }) + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 2", modifier = Modifier.padding(8.dp)) + }) + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 3", modifier = Modifier.padding(8.dp)) + }) + } + + + } + + ComponentPreview("Card") { + Card{ + Text("Hello, Tabs!", modifier = Modifier.padding(20.dp)) + } + } + + ComponentPreview("Scrollable View") { + + } + + + + ComponentPreview("Tabs") { + + } + } + } } } } } -fun String.toColorInt(): Int { - if (this[0] == '#') { - var color = substring(1).toLong(16) - if (length == 7) { - color = color or 0x00000000ff000000L - } else if (length != 9) { - throw IllegalArgumentException("Unknown color") +@Composable +private fun ComponentPreview(title: String, content: @Composable () -> Unit) { + Column { + Text(title, style = MaterialTheme.typography.titleLarge) + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp)) { + content() } - return color.toInt() + HorizontalDivider() } - throw IllegalArgumentException("Unknown color") } \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Typography.kt b/app/src/processing/app/ui/theme/Typography.kt index 5d87c490e6..6650ac7167 100644 --- a/app/src/processing/app/ui/theme/Typography.kt +++ b/app/src/processing/app/ui/theme/Typography.kt @@ -1,6 +1,5 @@ package processing.app.ui.theme -import androidx.compose.material.MaterialTheme.typography import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -21,18 +20,108 @@ val processingFont = FontFamily( style = FontStyle.Normal ) ) +val spaceGroteskFont = FontFamily( + Font( + resource = "SpaceGrotesk-Bold.ttf", + weight = FontWeight.Bold, + ), + Font( + resource = "SpaceGrotesk-Regular.ttf", + weight = FontWeight.Normal, + ), + Font( + resource = "SpaceGrotesk-Medium.ttf", + weight = FontWeight.Medium, + ), + Font( + resource = "SpaceGrotesk-SemiBold.ttf", + weight = FontWeight.SemiBold, + ), + Font( + resource = "SpaceGrotesk-Light.ttf", + weight = FontWeight.Light, + ) +) -val Typography = Typography( +@Deprecated("Use PDE3Typography instead") +val PDE2Typography = Typography( + defaultFontFamily = spaceGroteskFont, + h1 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 42.725.sp, + lineHeight = 48.sp + ), + h2 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 34.18.sp, + lineHeight = 40.sp + ), + h3 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 27.344.sp, + lineHeight = 32.sp + ), + h4 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 21.875.sp, + lineHeight = 28.sp + ), + h5 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 17.5.sp, + lineHeight = 22.sp + ), + h6 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 18.sp + ), body1 = TextStyle( - fontFamily = processingFont, fontWeight = FontWeight.Normal, - fontSize = 13.sp, + fontSize = 14.sp, + lineHeight = 18.sp + ), + body2 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.8.sp, lineHeight = 16.sp ), subtitle1 = TextStyle( - fontFamily = processingFont, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp + ), + subtitle2 = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 13.824.sp, + lineHeight = 16.sp, + ), + caption = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 11.2.sp, + lineHeight = 14.sp + ), + overline = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 8.96.sp, + lineHeight = 10.sp ) +) +val base = androidx.compose.material3.Typography() +val PDETypography = androidx.compose.material3.Typography( + displayLarge = base.displayLarge.copy(fontFamily = spaceGroteskFont), + displayMedium = base.displayMedium.copy(fontFamily = spaceGroteskFont), + displaySmall = base.displaySmall.copy(fontFamily = spaceGroteskFont), + headlineLarge = base.headlineLarge.copy(fontFamily = spaceGroteskFont), + headlineMedium = base.headlineMedium.copy(fontFamily = spaceGroteskFont), + headlineSmall = base.headlineSmall.copy(fontFamily = spaceGroteskFont), + titleLarge = base.titleLarge.copy(fontFamily = spaceGroteskFont), + titleMedium = base.titleMedium.copy(fontFamily = spaceGroteskFont), + titleSmall = base.titleSmall.copy(fontFamily = spaceGroteskFont), + bodyLarge = base.bodyLarge.copy(fontFamily = spaceGroteskFont), + bodyMedium = base.bodyMedium.copy(fontFamily = spaceGroteskFont), + bodySmall = base.bodySmall.copy(fontFamily = spaceGroteskFont), + labelLarge = base.labelLarge.copy(fontFamily = spaceGroteskFont), + labelMedium = base.labelMedium.copy(fontFamily = spaceGroteskFont), + labelSmall = base.labelSmall.copy(fontFamily = spaceGroteskFont), ) \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt new file mode 100644 index 0000000000..f725a999b5 --- /dev/null +++ b/app/src/processing/app/ui/theme/Window.kt @@ -0,0 +1,238 @@ +package processing.app.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.formdev.flatlaf.util.SystemInfo +import processing.app.ui.Toolkit +import java.awt.Dimension + +import javax.swing.JFrame +import javax.swing.JRootPane +import kotlin.reflect.KClass + +val LocalWindow = compositionLocalOf { error("No Window Set") } + +/** + * A utility class to create a new Window with Compose content in a Swing application. + * It sets up the window with some default properties and allows for custom content. + * Use this when creating a Compose based window from Swing. + * + * Usage example: + * ``` + * SwingUtilities.invokeLater { + * PDESwingWindow("menu.help.welcome", fullWindowContent = true) { + * + * } + * } + * ``` + * + * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. If null, the window will use its default size. + * @param minSize The minimum size of the window. If null, no minimum size is set. + * @param maxSize The maximum size of the window. If null, no maximum size is set. + * @param unique An optional unique identifier for the window to prevent duplicates. + * @param onClose A lambda function to be called when the window is requested to close. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param content The composable content to be displayed in the window. + */ +class PDESwingWindow( + titleKey: String = "", + size: Dimension? = null, + minSize: Dimension? = null, + maxSize: Dimension? = null, + unique: KClass<*>? = null, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ + init{ + ComposeWindow().apply { + val window = this + defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE + size?.let { + window.size = it + } + minSize?.let { + window.minimumSize = it + } + maxSize?.let { + window.maximumSize = it + } + setLocationRelativeTo(null) + setContent { + PDEWindowContent( + window = window, + titleKey = titleKey, + unique = unique, + fullWindowContent = fullWindowContent, + content = content + ) + } + window.addWindowStateListener { + if(it.newState == JFrame.DISPOSE_ON_CLOSE){ + onClose() + } + } + isVisible = true + } + } +} + +private val windows = mutableMapOf, ComposeWindow>() + +/** + * Internal Composable function to set up the window content with theming and localization. + * It also handles macOS specific properties for full window content. + * + * @param window The JFrame instance to be configured. + * @param titleKey The key for the window title, which will be localized. + * @param unique An optional unique identifier for the window to prevent duplicates. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param content The composable content to be displayed in the window. + */ +@Composable +private fun PDEWindowContent( + window: ComposeWindow, + titleKey: String, + unique: KClass<*>? = null, + fullWindowContent: Boolean = false, + content: @Composable () -> Unit +){ + val mac = SystemInfo.isMacOS && SystemInfo.isMacFullWindowContentSupported + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", mac && fullWindowContent) + Toolkit.setIcon(window) + } + if(unique != null && windows.contains(unique) && windows[unique] != null){ + windows[unique]?.toFront() + window.dispose() + return + } + + DisposableEffect(unique){ + unique?.let { + windows[it] = window + } + onDispose { + windows.remove(unique) + } + } + + CompositionLocalProvider(LocalWindow provides window) { + PDETheme{ + val locale = LocalLocale.current + window.title = locale[titleKey] + content() + } + } +} + +/** + * A Composable function to create and display a new window with the specified content. + * This function sets up the window state and handles the close request. + * Use this when creating a Compose based window from another Compose context. + * + * Usage example: + * ``` + * PDEComposeWindow("window.title", fullWindowContent = true, onClose = { /* handle close */ }) { + * // Your window content here + * Text("Hello, World!") + * } + * ``` + * + * This will create a new window with the title localized from "window.title" key, + * with content extending into the title bar area on macOS, and a custom close handler. + * + * Fully standalone example: + * ``` + * application { + * PDEComposeWindow("window.title", fullWindowContent = true, onClose = ::exitApplication) { + * // Your window content here + * } + * } + * ``` + * + * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. Defaults to unspecified size which means the window will be + * fullscreen if it contains any of [fillMaxWidth]/[fillMaxSize]/[fillMaxHeight] etc. + * @param minSize The minimum size of the window. Defaults to unspecified size which means no minimum size is set. + * @param maxSize The maximum size of the window. Defaults to unspecified size which means no maximum size is set. + * @param unique An optional unique identifier for the window to prevent duplicates. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param onClose A lambda function to be called when the window is requested to close. + * @param content The composable content to be displayed in the window. + */ +@Composable +fun PDEComposeWindow( + titleKey: String, + size: DpSize = DpSize.Unspecified, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + unique: KClass<*>? = null, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ + val windowState = rememberWindowState( + size = size, + position = WindowPosition(Alignment.Center) + ) + Window(onCloseRequest = onClose, state = windowState, title = "") { + remember { + window.minimumSize = minSize.toDimension() + window.maximumSize = maxSize.toDimension() + } + PDEWindowContent( + window = window, + titleKey = titleKey, + unique = unique, + fullWindowContent = fullWindowContent, + content = content + ) + } +} + +fun DpSize.toDimension(): Dimension? { + if(this == DpSize.Unspecified) { return null } + + return Dimension( + this.width.value.toInt(), + this.height.value.toInt() + ) +} + +fun main(){ + application { + PDEComposeWindow( + onClose = ::exitApplication, + titleKey = "window.title", + size = DpSize(800.dp, 600.dp), + ){ + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text("Hello, World!") + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/m3/Color.kt b/app/src/processing/app/ui/theme/m3/Color.kt new file mode 100644 index 0000000000..b2047ce7e6 --- /dev/null +++ b/app/src/processing/app/ui/theme/m3/Color.kt @@ -0,0 +1,248 @@ +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF525A92) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFF293DAE) +val onPrimaryContainerLight = Color(0xFFABB5FF) +val secondaryLight = Color(0xFF555D7D) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFF8890B3) +val onSecondaryContainerLight = Color(0xFF212946) +val tertiaryLight = Color(0xFF0052CC) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFF0468FF) +val onTertiaryContainerLight = Color(0xFFFBF9FF) +val errorLight = Color(0xFFBB0026) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFE41D37) +val onErrorContainerLight = Color(0xFFFFFBFF) +val backgroundLight = Color(0xFFFBF8FF) +val onBackgroundLight = Color(0xFF1A1B22) +val surfaceLight = Color(0xFFFDF8F8) +val onSurfaceLight = Color(0xFF1C1B1C) +val surfaceVariantLight = Color(0xFFE4E1E8) +val onSurfaceVariantLight = Color(0xFF47464B) +val outlineLight = Color(0xFF77767C) +val outlineVariantLight = Color(0xFFC8C5CB) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF313030) +val inverseOnSurfaceLight = Color(0xFFF4F0EF) +val inversePrimaryLight = Color(0xFFBBC3FF) +val surfaceDimLight = Color(0xFFDDD9D9) +val surfaceBrightLight = Color(0xFFFDF8F8) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF7F3F2) +val surfaceContainerLight = Color(0xFFF1EDED) +val surfaceContainerHighLight = Color(0xFFEBE7E7) +val surfaceContainerHighestLight = Color(0xFFE5E2E1) + +val primaryLightMediumContrast = Color(0xFF525A92) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF293DAE) +val onPrimaryContainerLightMediumContrast = Color(0xFFE3E4FF) +val secondaryLightMediumContrast = Color(0xFF2D3553) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF646C8D) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF003080) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF0062F3) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF730013) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFD91030) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFBF8FF) +val onBackgroundLightMediumContrast = Color(0xFF1A1B22) +val surfaceLightMediumContrast = Color(0xFFFDF8F8) +val onSurfaceLightMediumContrast = Color(0xFF111111) +val surfaceVariantLightMediumContrast = Color(0xFFE4E1E8) +val onSurfaceVariantLightMediumContrast = Color(0xFF36363B) +val outlineLightMediumContrast = Color(0xFF525257) +val outlineVariantLightMediumContrast = Color(0xFF6D6C72) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF313030) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF4F0EF) +val inversePrimaryLightMediumContrast = Color(0xFFBBC3FF) +val surfaceDimLightMediumContrast = Color(0xFFC9C6C5) +val surfaceBrightLightMediumContrast = Color(0xFFFDF8F8) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF7F3F2) +val surfaceContainerLightMediumContrast = Color(0xFFEBE7E7) +val surfaceContainerHighLightMediumContrast = Color(0xFFE0DCDC) +val surfaceContainerHighestLightMediumContrast = Color(0xFFD4D1D0) + +val primaryLightHighContrast = Color(0xFF525A92) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF283CAD) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF222B48) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF404867) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF00276B) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF0042A8) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF60000E) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF97001C) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFBF8FF) +val onBackgroundLightHighContrast = Color(0xFF1A1B22) +val surfaceLightHighContrast = Color(0xFFFDF8F8) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE4E1E8) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF2C2C30) +val outlineVariantLightHighContrast = Color(0xFF49494E) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF313030) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFBBC3FF) +val surfaceDimLightHighContrast = Color(0xFFBBB8B8) +val surfaceBrightLightHighContrast = Color(0xFFFDF8F8) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF4F0EF) +val surfaceContainerLightHighContrast = Color(0xFFE5E2E1) +val surfaceContainerHighLightHighContrast = Color(0xFFD7D3D3) +val surfaceContainerHighestLightHighContrast = Color(0xFFC9C6C5) + +val primaryDark = Color(0xFFBBC3FF) +val onPrimaryDark = Color(0xFF001D93) +val primaryContainerDark = Color(0xFF293DAE) +val onPrimaryContainerDark = Color(0xFFABB5FF) +val secondaryDark = Color(0xFFBDC5EA) +val onSecondaryDark = Color(0xFF272F4D) +val secondaryContainerDark = Color(0xFF8890B3) +val onSecondaryContainerDark = Color(0xFF212946) +val tertiaryDark = Color(0xFFB2C5FF) +val onTertiaryDark = Color(0xFF002B74) +val tertiaryContainerDark = Color(0xFF0468FF) +val onTertiaryContainerDark = Color(0xFFFBF9FF) +val errorDark = Color(0xFFFFB3B0) +val onErrorDark = Color(0xFF680010) +val errorContainerDark = Color(0xFFFF5359) +val onErrorContainerDark = Color(0xFF220002) +val backgroundDark = Color(0xFF12131A) +val onBackgroundDark = Color(0xFFE3E1EB) +val surfaceDark = Color(0xFF141313) +val onSurfaceDark = Color(0xFFE5E2E1) +val surfaceVariantDark = Color(0xFF47464B) +val onSurfaceVariantDark = Color(0xFFC8C5CB) +val outlineDark = Color(0xFF919096) +val outlineVariantDark = Color(0xFF47464B) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE5E2E1) +val inverseOnSurfaceDark = Color(0xFF313030) +val inversePrimaryDark = Color(0xFF4053C3) +val surfaceDimDark = Color(0xFF141313) +val surfaceBrightDark = Color(0xFF3A3939) +val surfaceContainerLowestDark = Color(0xFF0E0E0E) +val surfaceContainerLowDark = Color(0xFF1C1B1C) +val surfaceContainerDark = Color(0xFF201F20) +val surfaceContainerHighDark = Color(0xFF2B2A2A) +val surfaceContainerHighestDark = Color(0xFF353435) + +val primaryDarkMediumContrast = Color(0xFFBBC3FF) +val onPrimaryDarkMediumContrast = Color(0xFF001677) +val primaryContainerDarkMediumContrast = Color(0xFF7587FA) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFD4DBFF) +val onSecondaryDarkMediumContrast = Color(0xFF1C2441) +val secondaryContainerDarkMediumContrast = Color(0xFF8890B3) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFD2DBFF) +val onTertiaryDarkMediumContrast = Color(0xFF00215E) +val tertiaryContainerDarkMediumContrast = Color(0xFF5D8BFF) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CF) +val onErrorDarkMediumContrast = Color(0xFF54000B) +val errorContainerDarkMediumContrast = Color(0xFFFF5359) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF12131A) +val onBackgroundDarkMediumContrast = Color(0xFFE3E1EB) +val surfaceDarkMediumContrast = Color(0xFF141313) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF47464B) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDEDBE1) +val outlineDarkMediumContrast = Color(0xFFB3B1B7) +val outlineVariantDarkMediumContrast = Color(0xFF918F95) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE5E2E1) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF2B2A2A) +val inversePrimaryDarkMediumContrast = Color(0xFF263AAC) +val surfaceDimDarkMediumContrast = Color(0xFF141313) +val surfaceBrightDarkMediumContrast = Color(0xFF454444) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF080707) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1E1D1E) +val surfaceContainerDarkMediumContrast = Color(0xFF282828) +val surfaceContainerHighDarkMediumContrast = Color(0xFF333232) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3E3D3D) + +val primaryDarkHighContrast = Color(0xFFBBC3FF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFB6BFFF) +val onPrimaryContainerDarkHighContrast = Color(0xFF000533) +val secondaryDarkHighContrast = Color(0xFFEEEFFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFB9C1E6) +val onSecondaryContainerDarkHighContrast = Color(0xFF020926) +val tertiaryDarkHighContrast = Color(0xFFEDEFFF) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFADC1FF) +val onTertiaryContainerDarkHighContrast = Color(0xFF000926) +val errorDarkHighContrast = Color(0xFFFFECEA) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFADAB) +val onErrorContainerDarkHighContrast = Color(0xFF220002) +val backgroundDarkHighContrast = Color(0xFF12131A) +val onBackgroundDarkHighContrast = Color(0xFFE3E1EB) +val surfaceDarkHighContrast = Color(0xFF141313) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF47464B) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFF2EFF5) +val outlineVariantDarkHighContrast = Color(0xFFC4C2C8) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE5E2E1) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF263AAC) +val surfaceDimDarkHighContrast = Color(0xFF141313) +val surfaceBrightDarkHighContrast = Color(0xFF515050) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF201F20) +val surfaceContainerDarkHighContrast = Color(0xFF313030) +val surfaceContainerHighDarkHighContrast = Color(0xFF3C3B3B) +val surfaceContainerHighestDarkHighContrast = Color(0xFF484646) + +val warningLight = Color(0xFF765B0B) +val onWarningLight = Color(0xFFFFFFFF) +val warningContainerLight = Color(0xFFFFDF97) +val onWarningContainerLight = Color(0xFF5A4300) + +val warningLightMediumContrast = Color(0xFF453400) +val onWarningLightMediumContrast = Color(0xFFFFFFFF) +val warningContainerLightMediumContrast = Color(0xFF86691C) +val onWarningContainerLightMediumContrast = Color(0xFFFFFFFF) + +val warningLightHighContrast = Color(0xFF392A00) +val onWarningLightHighContrast = Color(0xFFFFFFFF) +val warningContainerLightHighContrast = Color(0xFF5D4600) +val onWarningContainerLightHighContrast = Color(0xFFFFFFFF) + +val warningDark = Color(0xFFE6C26C) +val onWarningDark = Color(0xFF3E2E00) +val warningContainerDark = Color(0xFF5A4300) +val onWarningContainerDark = Color(0xFFFFDF97) + +val warningDarkMediumContrast = Color(0xFFFED87F) +val onWarningDarkMediumContrast = Color(0xFF312400) +val warningContainerDarkMediumContrast = Color(0xFFAD8D3D) +val onWarningContainerDarkMediumContrast = Color(0xFF000000) + +val warningDarkHighContrast = Color(0xFFFFEECF) +val onWarningDarkHighContrast = Color(0xFF000000) +val warningContainerDarkHighContrast = Color(0xFFE2BE69) +val onWarningContainerDarkHighContrast = Color(0xFF110A00) + diff --git a/app/src/processing/app/ui/theme/m3/Theme.kt b/app/src/processing/app/ui/theme/m3/Theme.kt new file mode 100644 index 0000000000..d1b2f403a8 --- /dev/null +++ b/app/src/processing/app/ui/theme/m3/Theme.kt @@ -0,0 +1,301 @@ + + +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class ExtendedColorScheme( + val warning: ColorFamily, +) + +val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +val extendedLight = ExtendedColorScheme( + warning = ColorFamily( + warningLight, + onWarningLight, + warningContainerLight, + onWarningContainerLight, + ), +) + +val extendedDark = ExtendedColorScheme( + warning = ColorFamily( + warningDark, + onWarningDark, + warningContainerDark, + onWarningContainerDark, + ), +) + +val extendedLightMediumContrast = ExtendedColorScheme( + warning = ColorFamily( + warningLightMediumContrast, + onWarningLightMediumContrast, + warningContainerLightMediumContrast, + onWarningContainerLightMediumContrast, + ), +) + +val extendedLightHighContrast = ExtendedColorScheme( + warning = ColorFamily( + warningLightHighContrast, + onWarningLightHighContrast, + warningContainerLightHighContrast, + onWarningContainerLightHighContrast, + ), +) + +val extendedDarkMediumContrast = ExtendedColorScheme( + warning = ColorFamily( + warningDarkMediumContrast, + onWarningDarkMediumContrast, + warningContainerDarkMediumContrast, + onWarningContainerDarkMediumContrast, + ), +) + +val extendedDarkHighContrast = ExtendedColorScheme( + warning = ColorFamily( + warningDarkHighContrast, + onWarningDarkHighContrast, + warningContainerDarkHighContrast, + onWarningContainerDarkHighContrast, + ), +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) \ No newline at end of file diff --git a/app/test/processing/app/LocaleKtTest.kt b/app/test/processing/app/LocaleKtTest.kt new file mode 100644 index 0000000000..f8ed32164a --- /dev/null +++ b/app/test/processing/app/LocaleKtTest.kt @@ -0,0 +1,52 @@ +package processing.app + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.LocaleProvider +import kotlin.io.path.createTempDirectory +import kotlin.test.Test + +class LocaleKtTest { + @OptIn(ExperimentalTestApi::class) + @Test + fun testLocale() = runComposeUiTest { + val tempPreferencesDir = createTempDirectory("preferences") + + System.setProperty("processing.app.preferences.folder", tempPreferencesDir.toFile().absolutePath) + + setContent { + LocaleProvider { + val locale = LocalLocale.current + Text(locale["menu.file.new"], modifier = Modifier.testTag("localisedText")) + + Button(onClick = { + locale.set(java.util.Locale("es")) + }, modifier = Modifier.testTag("button")) { + Text("Change") + } + } + } + + // Check if usage generates the language file if it doesn't exist + val languageFile = tempPreferencesDir.resolve("language.txt").toFile() + assert(languageFile.exists()) + + // Check if the text is localised + onNodeWithTag("localisedText").assertTextEquals("New") + + // Change the locale to Spanish + onNodeWithTag("button").performClick() + onNodeWithTag("localisedText").assertTextEquals("Nuevo") + + // Check if the preference was saved to file + assert(languageFile.readText().substring(0, 2) == "es") + } +} \ No newline at end of file diff --git a/app/test/processing/app/PreferencesKtTest.kt b/app/test/processing/app/PreferencesKtTest.kt new file mode 100644 index 0000000000..6b5dbc5ea9 --- /dev/null +++ b/app/test/processing/app/PreferencesKtTest.kt @@ -0,0 +1,61 @@ +package processing.app + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import java.util.Properties +import kotlin.io.path.createFile +import kotlin.io.path.createTempDirectory +import kotlin.test.Test + +class PreferencesKtTest{ + @OptIn(ExperimentalTestApi::class) + @Test + fun testKeyReactivity() = runComposeUiTest { + val directory = createTempDirectory("preferences") + val tempPreferences = directory + .resolve("preferences.txt") + .createFile() + .toFile() + + // Set system properties for testing + System.setProperty("processing.app.preferences.file", tempPreferences.absolutePath) + System.setProperty("processing.app.preferences.debounce", "0") + System.setProperty("processing.app.watchfile.forced", "true") + + val newValue = (0..Int.MAX_VALUE).random().toString() + val testKey = "test.preferences.reactivity" + + setContent { + PreferencesProvider { + val preferences = LocalPreferences.current + Text(preferences[testKey] ?: "default", modifier = Modifier.testTag("text")) + + Button(onClick = { + preferences[testKey] = newValue + }, modifier = Modifier.testTag("button")) { + Text("Change") + } + } + } + + onNodeWithTag("text").assertTextEquals("default") + onNodeWithTag("button").performClick() + onNodeWithTag("text").assertTextEquals(newValue) + + val preferences = Properties() + preferences.load(tempPreferences.inputStream().reader(Charsets.UTF_8)) + + // Check if the preference was saved to file + assert(preferences[testKey] == newValue) + + + val nextValue = (0..Int.MAX_VALUE).random().toString() + // Overwrite the file to see if the UI updates + tempPreferences.writeText("$testKey=${nextValue}") + + onNodeWithTag("text").assertTextEquals(nextValue) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8e7ad44a7a..6c8c5262cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ plugins { kotlin("jvm") version libs.versions.kotlin apply false - alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.jetbrainsCompose) apply false diff --git a/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf b/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 0000000000..0408641c61 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt b/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt new file mode 100644 index 0000000000..6a314848b3 --- /dev/null +++ b/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/build/shared/lib/fonts/SpaceGrotesk-Light.ttf b/build/shared/lib/fonts/SpaceGrotesk-Light.ttf new file mode 100644 index 0000000000..d41bcccd86 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Light.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf b/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf new file mode 100644 index 0000000000..7d44b663b9 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 0000000000..981bcf5b2c Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf new file mode 100644 index 0000000000..e7e02e51e4 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf differ diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index 19a5c9f866..c36d59ff02 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -205,39 +205,88 @@ close.unsaved_changes = Save changes to %s? # Preferences (Frame) preferences = Preferences +preferences.description=Change how Processing works on your computer. These settings affect all Processing windows and stay the same even after you restart. +preferences.pane.general=General +preferences.pane.interface=Interface +preferences.pane.editor=Coding Stuff +preferences.pane.sketches=Sketches +preferences.pane.other=Experimental +preferences.new=New +preferences.reset=Reset to Defaults +preferences.experimental=Experimental +preferences.no_results=No results found +preferences.sync_folder_and_filename=Folder name matches sketch name +preferences.sync_folder_and_filename.tip=When enabled, renaming a sketch will also rename its folder to match the sketch name. [Learn more](https://discourse.processing.org/t/sketch-folder-and-sketch-name-syncing/15345) +preferences.show_welcome_screen=Show welcome screen at startup +preferences.diagnostics=Generate diagnostic report for support +preferences.diagnostics.tip=Copies information about your installation into your clipboard, useful for troubleshooting issues. +preferences.diagnostics.button=Generate Report +preferences.diagnostics.button.copied=Report copied to clipboard preferences.button.width = 80 preferences.restart_required = Restart Processing to apply changes preferences.sketchbook_location = Sketchbook folder preferences.sketchbook_location.popup = Sketchbook folder preferences.sketch_naming = Sketch name -preferences.language = Language: -preferences.editor_and_console_font = Editor and Console font: -preferences.editor_and_console_font.tip = Select the font used in the Editor and the Console.
Only monospaced (fixed-width) fonts may be used,
though the list may be imperfect. -preferences.editor_font_size = Editor font size: -preferences.console_font_size = Console font size: -preferences.interface_scale = Interface scale: +preferences.sketch_naming.tip=Choose how new sketches are named and numbered. +preferences.language=Language +preferences.editor_and_console_font=Editor and Console font +preferences.editor_and_console_font.tip=Installed Monospaced fonts will be displayed as options. +preferences.editor_font_size=Editor font size +preferences.console_font_size=Console font size +preferences.editor.theme=Theme +preferences.editor.theme.tip=Choose a color theme for windows except for the editor. +preferences.editor.theme.system=System +preferences.editor.theme.light=Light +preferences.editor.theme.dark=Dark +preferences.interface_theme=Interface theme +preferences.interface_scale=Interface scale +preferences.interface_scale.tip=Adjust the size of interface elements. preferences.interface_scale.auto = Automatic preferences.background_color = Background color when Presenting: -preferences.background_color.tip = Select the background color used when using Present.
Present is used to present a sketch in full-screen,
accessible from the Sketch menu. +preferences.background_color.tip=Select the background color used when using Present. Present is used to present a sketch in full-screen, accessible from the Sketch menu. preferences.use_smooth_text = Use smooth text in editor window preferences.enable_complex_text = Enable complex text input -preferences.enable_complex_text.tip = Using languages such as Chinese, Japanese, and Arabic
in the Editor window require additional features to be enabled. +preferences.enable_complex_text.tip=Using languages such as Chinese, Japanese, and Arabic in the Editor window require additional features to be enabled. preferences.continuously_check = Continuously check for errors preferences.show_warnings = Show warnings preferences.code_completion = Code completion with preferences.trigger_with = Trigger with preferences.cmd_space = space preferences.suggest_imports = Suggest import statements +preferences.increase_memory=Increase maximum available memory preferences.increase_max_memory = Increase maximum available memory to # preferences.delete_previous_folder_on_export = Delete previous folder on export preferences.check_for_updates_on_startup = Allow update checking (see FAQ for information shared) +preferences.update_check=Check for updates on startup +preferences.update_check.tip=No personal information is sent during this process. See the [FAQ](https://github.com/processing/processing4/wiki/FAQ#checking-for-updates) preferences.run_sketches_on_display = Run sketches on display -preferences.run_sketches_on_display.tip = Sets the display where sketches are initially placed.
As usual, if the sketch window is moved, it will re-open
at the same location, however when running in present
(full screen) mode, this display will always be used. +preferences.run_sketches_on_display.tip=Sets the display where sketches are initially placed. As usual, if the sketch window is moved, it will re-open at the same location, however when running in present (full screen) mode, this display will always be used. preferences.automatically_associate_pde_files = Automatically associate .pde files with Processing preferences.launch_programs_in = Launch programs in preferences.launch_programs_in.mode = mode preferences.file = More preferences can be edited directly in the file: preferences.file.hint = (Edit only when Processing is not running.) +preferences.other=Show experimental settings +preferences.other.tip=These settings are contained in the preferences.txt file and are not officially supported. They may be removed or changed without notice in future versions of Processing. +# Preferences (Experimental Pane) +# Keys from the comments of defaults.txt (Nov 2025) +preferences.contribution.backup.on_remove=Backup contributions when "Remove" button is pressed +preferences.contribution.backup.on_remove.tip=When enabled, a backup copy of the contribution will be created in your sketchbook "tools", "modes", "libraries", or "examples" folder when you remove it via the Contribution Manager. +preferences.contribution.backup.on_install=Backup contributions when installing a newer version +preferences.contribution.backup.on_install.tip=When enabled, a backup copy of the contribution will be created in your sketchbook "tools", "modes", "libraries", or "examples" folder when you install a newer version via the Contribution Manager. +preferences.recent.count=Number of recent sketches to show +preferences.chooser.files.native=Use native file chooser dialogs +preferences.theme.gradient.method=Gradient method for themes +preferences.theme.gradient.method.tip=Set to 'lab' to interpolate theme gradients using L*a*b* color space +preferences.platform.auto_file_type_associations=Automatically set file type associations (Windows only) +preferences.platform.auto_file_type_associations.tip=When enabled, Processing will attempt to set itself as the default application for .pde files on Windows systems. +preferences.editor.window.width.default=Default editor window width +preferences.editor.window.height.default=Default editor window height +preferences.editor.window.width.min=Minimum editor window width +preferences.editor.window.height.min=Minimum editor window height +preferences.editor.smooth=Enable antialiasing in the code editor +preferences.editor.caret.blink=Blink the caret +preferences.editor.caret.block=Use block caret # Sketchbook Location (Frame) sketchbook_location = Select new sketchbook folder @@ -640,6 +689,22 @@ beta.button = Ok color_chooser = Color Selector color_chooser.select = Select + +# --------------------------------------- +# Welcome Screen +welcome.processing.logo = Processing Logo +welcome.processing.title = Welcome to Processing +welcome.actions.sketch.new = Empty Sketch +welcome.actions.examples = Open Examples +welcome.actions.show_startup = Show this window at startup +welcome.resources.title = Resources +welcome.resources.get_started = Get Started +welcome.resources.tutorials = Tutorials +welcome.resources.documentation = Reference +welcome.community.title = Join our community +welcome.community.forum = Forum +welcome.sketch.open = Open + # --------------------------------------- # Movie Maker diff --git a/build/shared/lib/languages/PDE_nl.properties b/build/shared/lib/languages/PDE_nl.properties index e7f11b0a1f..76865397b3 100644 --- a/build/shared/lib/languages/PDE_nl.properties +++ b/build/shared/lib/languages/PDE_nl.properties @@ -322,6 +322,22 @@ beta.title = Dankuwel voor het testen van deze Processing Beta! beta.message = Deze preview release laat ons feedback verzamelen en problemen oplossen. **Sommige functies werken mogelijk niet zoals verwacht.** Als u problemen ondervindt, [post dan op het forum](https://discourse.processing.org) of [open een GitHub issue](https://github.com/processing/processing4/issues). beta.button = Ok + +# --------------------------------------- +# Welcome Screen +welcome.processing.logo = Processing Logo +welcome.processing.title = Welkom bij Processing! +welcome.actions.sketch.new = Nieuwe Schets +welcome.actions.examples = Open Voorbeelden +welcome.actions.show_startup = Laat dit scherm zien bij opstarten +welcome.resources.title = Resources +welcome.resources.video = Video Cursus +welcome.resources.get_started = Om te beginnen +welcome.resources.tutorials = Tutorials +welcome.resources.documentation = Handleiding +welcome.community.title = Neem deel aan de Community +welcome.community.forum = Forum + # --------------------------------------- # Color Chooser color_chooser = Kies een kleur... diff --git a/core/src/processing/awt/ShimAWT.java b/core/src/processing/awt/ShimAWT.java index 901f359bb2..304b8dd2ac 100644 --- a/core/src/processing/awt/ShimAWT.java +++ b/core/src/processing/awt/ShimAWT.java @@ -1,34 +1,29 @@ package processing.awt; +import processing.core.PApplet; +import processing.core.PConstants; +import processing.core.PImage; + +import javax.imageio.*; +import javax.imageio.metadata.IIOInvalidTreeException; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; +import javax.swing.*; +import javax.swing.filechooser.FileSystemView; import java.awt.*; import java.awt.color.ColorSpace; +import java.awt.geom.AffineTransform; import java.awt.image.*; -import java.io.*; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; import java.util.Iterator; -import java.awt.geom.AffineTransform; import java.util.Map; - -import javax.imageio.IIOImage; -import javax.imageio.ImageIO; -import javax.imageio.ImageTypeSpecifier; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.metadata.IIOInvalidTreeException; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.metadata.IIOMetadataNode; -import javax.swing.ImageIcon; -import javax.swing.JFileChooser; -import javax.swing.UIManager; - -// used by desktopFile() method -import javax.swing.filechooser.FileSystemView; - -import processing.core.PApplet; -import processing.core.PConstants; -import processing.core.PImage; +import java.util.function.Consumer; /** @@ -809,41 +804,51 @@ static public void selectImpl(final String prompt, final Object callbackObject, final Frame parentFrame, final int mode) { - File selectedFile = null; + selectImpl(prompt, defaultSelection, parentFrame, mode, (selectedFile) -> + PApplet.selectCallback(selectedFile, callbackMethod, callbackObject) + ); + } - if (PApplet.useNativeSelect) { - FileDialog dialog = new FileDialog(parentFrame, prompt, mode); - if (defaultSelection != null) { - dialog.setDirectory(defaultSelection.getParent()); - dialog.setFile(defaultSelection.getName()); - } + static public void selectImpl(final String prompt, + final File defaultSelection, + final Frame parentFrame, + final int mode, + final Consumer callback) { + File selectedFile = null; + + if (PApplet.useNativeSelect) { + FileDialog dialog = new FileDialog(parentFrame, prompt, mode); + if (defaultSelection != null) { + dialog.setDirectory(defaultSelection.getParent()); + dialog.setFile(defaultSelection.getName()); + } - dialog.setVisible(true); - String directory = dialog.getDirectory(); - String filename = dialog.getFile(); - if (filename != null) { - selectedFile = new File(directory, filename); - } + dialog.setVisible(true); + String directory = dialog.getDirectory(); + String filename = dialog.getFile(); + if (filename != null) { + selectedFile = new File(directory, filename); + } - } else { - JFileChooser chooser = new JFileChooser(); - chooser.setDialogTitle(prompt); - if (defaultSelection != null) { - chooser.setSelectedFile(defaultSelection); - } + } else { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(prompt); + if (defaultSelection != null) { + chooser.setSelectedFile(defaultSelection); + } - int result = -1; - if (mode == FileDialog.SAVE) { - result = chooser.showSaveDialog(parentFrame); - } else if (mode == FileDialog.LOAD) { - result = chooser.showOpenDialog(parentFrame); - } - if (result == JFileChooser.APPROVE_OPTION) { - selectedFile = chooser.getSelectedFile(); - } + int result = -1; + if (mode == FileDialog.SAVE) { + result = chooser.showSaveDialog(parentFrame); + } else if (mode == FileDialog.LOAD) { + result = chooser.showOpenDialog(parentFrame); + } + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile = chooser.getSelectedFile(); + } + } + callback.accept(selectedFile); } - PApplet.selectCallback(selectedFile, callbackMethod, callbackObject); - } static public void selectFolder(final String prompt, @@ -854,6 +859,12 @@ static public void selectFolder(final String prompt, defaultSelection, callbackObject, null)); } + static public void selectFolder(final String prompt, + final File defaultSelection, + final Consumer callback) { + selectFolderImpl(prompt, defaultSelection, null, callback); + } + /* static public void selectFolder(final String prompt, @@ -886,6 +897,15 @@ static public void selectFolderImpl(final String prompt, final File defaultSelection, final Object callbackObject, final Frame parentFrame) { + selectFolderImpl(prompt, defaultSelection, parentFrame, (selectedFile) -> + PApplet.selectCallback(selectedFile, callbackMethod, callbackObject) + ); + } + + static public void selectFolderImpl(final String prompt, + final File defaultSelection, + final Frame parentFrame, + final Consumer callback) { File selectedFile = null; if (PApplet.platform == PConstants.MACOS && PApplet.useNativeSelect) { FileDialog fileDialog = @@ -914,7 +934,7 @@ static public void selectFolderImpl(final String prompt, selectedFile = fileChooser.getSelectedFile(); } } - PApplet.selectCallback(selectedFile, callbackMethod, callbackObject); + callback.accept(selectedFile); } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 050502f4ca..a2a3edacc0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] -kotlin = "2.0.20" -compose-plugin = "1.7.1" +kotlin = "2.2.20" +compose-plugin = "1.9.1" jogl = "2.5.0" antlr = "4.13.2" jupiter = "5.12.0" +markdown = "0.37.0" [libraries] jogl = { module = "org.jogamp.jogl:jogl-all-main", version.ref = "jogl" } @@ -31,14 +32,14 @@ antlr4Runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr" } composeGradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-plugin" } kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinComposePlugin = { module = "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin", version.ref = "kotlin" } -markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m2", version = "0.31.0" } -markdownJVM = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version = "0.31.0" } +markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown" } +markdownJVM = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version.ref = "markdown" } clikt = { module = "com.github.ajalt.clikt:clikt", version = "5.0.2" } kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.6.3" } +material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.9.0" } [plugins] jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } download = { id = "de.undercouch.download", version = "5.6.0" } diff --git a/java/src/processing/mode/java/JavaEditor.java b/java/src/processing/mode/java/JavaEditor.java index 3fab2c8b17..7ce9e45be7 100644 --- a/java/src/processing/mode/java/JavaEditor.java +++ b/java/src/processing/mode/java/JavaEditor.java @@ -288,13 +288,7 @@ public JMenu buildHelpMenu() { item = new JMenuItem(Language.text("menu.help.welcome")); item.addActionListener(e -> { - try { - new Welcome(base); - } catch (IOException ioe) { - Messages.showWarning("Unwelcome Error", - "Please report this error to\n" + - "https://github.com/processing/processing4/issues", ioe); - } + PDEWelcomeKt.showWelcomeScreen(base); }); menu.add(item);