diff --git a/app/src/processing/app/ui/Welcome.java b/app/ant/processing/app/ui/Welcome.java similarity index 100% rename from app/src/processing/app/ui/Welcome.java rename to app/ant/processing/app/ui/Welcome.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0d3fcbd12d..6c8ac55f00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ import org.gradle.internal.jvm.Jvm import org.gradle.internal.os.OperatingSystem import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download @@ -16,6 +17,7 @@ plugins{ alias(libs.plugins.compose.compiler) alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.serialization) alias(libs.plugins.download) } @@ -59,7 +61,7 @@ compose.desktop { ).map { "-D${it.first}=${it.second}" }.toTypedArray()) nativeDistributions{ - modules("jdk.jdi", "java.compiler", "jdk.accessibility", "java.management.rmi", "java.scripting", "jdk.httpserver") + modules("jdk.jdi", "java.compiler", "jdk.accessibility", "jdk.zipfs", "java.management.rmi", "java.scripting", "jdk.httpserver") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Processing" @@ -107,25 +109,29 @@ dependencies { implementation(compose.runtime) implementation(compose.foundation) - implementation(compose.material) implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + implementation(compose.materialIconsExtended) implementation(compose.desktop.currentOs) + implementation(libs.material3) implementation(libs.compottie) implementation(libs.kaml) implementation(libs.markdown) implementation(libs.markdownJVM) + implementation(libs.clikt) + implementation(libs.kotlinxSerializationJson) + + @OptIn(ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) testImplementation(kotlin("test")) testImplementation(libs.mockitoKotlin) testImplementation(libs.junitJupiter) testImplementation(libs.junitJupiterParams) - - implementation(libs.clikt) - implementation(libs.kotlinxSerializationJson) + } tasks.test { diff --git a/app/src/main/resources/default.png b/app/src/main/resources/default.png new file mode 100644 index 0000000000..df13f36105 Binary files /dev/null and b/app/src/main/resources/default.png differ diff --git a/app/src/main/resources/welcome/intro/bubble.svg b/app/src/main/resources/welcome/intro/bubble.svg new file mode 100644 index 0000000000..a3997b1e79 --- /dev/null +++ b/app/src/main/resources/welcome/intro/bubble.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/resources/welcome/intro/long.svg b/app/src/main/resources/welcome/intro/long.svg new file mode 100644 index 0000000000..004418ce1f --- /dev/null +++ b/app/src/main/resources/welcome/intro/long.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/resources/welcome/intro/short.svg b/app/src/main/resources/welcome/intro/short.svg new file mode 100644 index 0000000000..d08759c01c --- /dev/null +++ b/app/src/main/resources/welcome/intro/short.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/resources/welcome/intro/wavy.svg b/app/src/main/resources/welcome/intro/wavy.svg new file mode 100644 index 0000000000..b244066fa1 --- /dev/null +++ b/app/src/main/resources/welcome/intro/wavy.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 2551a54d64..e3eae12fb8 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -40,6 +40,7 @@ import processing.app.contrib.*; import processing.app.tools.Tool; import processing.app.ui.*; +import processing.app.ui.PreferencesKt; import processing.app.ui.Toolkit; import processing.core.*; import processing.data.StringList; @@ -2190,10 +2191,11 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { - if (preferencesFrame == null) { - preferencesFrame = new PreferencesFrame(this); - } - preferencesFrame.showFrame(); +// if (preferencesFrame == null) { +// preferencesFrame = new PreferencesFrame(this); +// } +// preferencesFrame.showFrame(); + PreferencesKt.show(); } diff --git a/app/src/processing/app/Language.java b/app/src/processing/app/Language.java index d55c8b710c..bcc4385a53 100644 --- a/app/src/processing/app/Language.java +++ b/app/src/processing/app/Language.java @@ -183,6 +183,12 @@ static public Language init() { return instance; } + static public void reload(){ + if(instance == null) return; + synchronized (Language.class) { + instance = new Language(); + } + } static private String get(String key) { LanguageBundle bundle = init().bundle; diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index 640c77eade..8fcf7bb056 100644 --- a/app/src/processing/app/Preferences.java +++ b/app/src/processing/app/Preferences.java @@ -136,6 +136,14 @@ static public void skipInit() { initialized = true; } + /** + * Check whether Preferences.init() has been called. If not, we are probably not running the full application. + * @return true if Preferences has been initialized + */ + static public boolean isInitialized() { + return initialized; + } + static void handleProxy(String protocol, String hostProp, String portProp) { String proxyHost = get("proxy." + protocol + ".host"); diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c5645c9bbc..c54cbbd817 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -2,56 +2,183 @@ package processing.app import androidx.compose.runtime.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream import java.nio.file.* import java.util.Properties +/* + The ReactiveProperties class extends the standard Java Properties class + to provide reactive capabilities using Jetpack Compose's mutableStateMapOf. + This allows UI components to automatically update when preference values change. +*/ +class ReactiveProperties: Properties() { + val snapshotStateMap = mutableStateMapOf() + + override fun setProperty(key: String, value: String) { + super.setProperty(key, value) + snapshotStateMap[key] = value + } + + override fun getProperty(key: String): String? { + return snapshotStateMap[key] ?: super.getProperty(key) + } + + operator fun get(key: String): String? = getProperty(key) + + operator fun set(key: String, value: String) { + setProperty(key, value) + } +} + +/* + A CompositionLocal to provide access to the ReactiveProperties instance + throughout the composable hierarchy. + */ +val LocalPreferences = compositionLocalOf { error("No preferences provided") } const val PREFERENCES_FILE_NAME = "preferences.txt" const val DEFAULTS_FILE_NAME = "defaults.txt" -fun PlatformStart(){ - Platform.inst ?: Platform.init() -} +/* + This composable function sets up a preferences provider that manages application settings. + It initializes the preferences from a file, watches for changes to that file, and saves + any updates back to the file. It uses a ReactiveProperties class to allow for reactive + updates in the UI when preferences change. + usage: + PreferencesProvider { + // Your app content here + } + + to access preferences: + val preferences = LocalPreferences.current + val someSetting = preferences["someKey"] ?: "defaultValue" + preferences["someKey"] = "newValue" + + This will automatically save to the preferences file and update any UI components + that are observing that key. + + to override the preferences file (for testing, etc) + System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt") + to override the debounce time (in milliseconds) + System.setProperty("processing.app.preferences.debounce", "200") + + */ +@OptIn(FlowPreview::class) @Composable -fun loadPreferences(): Properties{ - PlatformStart() +fun PreferencesProvider(content: @Composable () -> Unit){ + val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) } + val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull() - val settingsFolder = Platform.getSettingsFolder() - val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME) + // Initialize the platform (if not already done) to ensure we have access to the settings folder + remember { + Platform.init() + } + // Grab the preferences file, creating it if it doesn't exist + // TODO: This functionality should be separated from the `Preferences` class itself + val settingsFolder = Platform.getSettingsFolder() + val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME) if(!preferencesFile.exists()){ + preferencesFile.mkdirs() preferencesFile.createNewFile() } - watchFile(preferencesFile) - return Properties().apply { - load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream()) - load(preferencesFile.inputStream()) + val update = watchFile(preferencesFile) + + + val properties = remember(preferencesFile, update) { + ReactiveProperties().apply { + val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) + ?: InputStream.nullInputStream() + load(defaultsStream + .reader(Charsets.UTF_8) + ) + load(preferencesFile + .inputStream() + .reader(Charsets.UTF_8) + ) + } + } + + val initialState = remember(properties) { properties.snapshotStateMap.toMap() } + + // Listen for changes to the preferences and save them to file + LaunchedEffect(properties) { + snapshotFlow { properties.snapshotStateMap.toMap() } + .dropWhile { it == initialState } + .debounce(preferencesDebounceOverride ?: 100) + .collect { + + // Save the preferences to file, sorted alphabetically + preferencesFile.outputStream().use { output -> + output.write( + properties.entries + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() }) + .joinToString("\n") { (key, value) -> "$key=$value" } + .toByteArray() + ) + } + } + } + + CompositionLocalProvider(LocalPreferences provides properties){ + content() } + } +/* + This composable function watches a specified file for modifications. When the file is modified, + it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates + or other actions in response to changes in the file. + + To watch the file at the fasted speed (for testing) set the following system property: + System.setProperty("processing.app.watchfile.forced", "true") + */ @Composable fun watchFile(file: File): Any? { + val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean() + val scope = rememberCoroutineScope() var event by remember(file) { mutableStateOf?> (null) } DisposableEffect(file){ val fileSystem = FileSystems.getDefault() val watcher = fileSystem.newWatchService() + var active = true + // In forced mode we just poll the last modified time of the file + // This is not efficient but works better for testing with temp files + val toWatch = { file.lastModified() } + var state = toWatch() + val path = file.toPath() val parent = path.parent val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY) scope.launch(Dispatchers.IO) { while (active) { - for (modified in key.pollEvents()) { - if (modified.context() != path.fileName) continue - event = modified + if(forcedWatch) { + if(toWatch() == state) continue + state = toWatch() + event = object : WatchEvent { + override fun count(): Int = 1 + override fun context(): Path = file.toPath().fileName + override fun kind(): WatchEvent.Kind = StandardWatchEventKinds.ENTRY_MODIFY + override fun toString(): String = "ForcedEvent(${context()})" + } + continue + }else{ + for (modified in key.pollEvents()) { + if (modified.context() != path.fileName) continue + event = modified + } } } } @@ -62,12 +189,4 @@ fun watchFile(file: File): Any? { } } return event -} -val LocalPreferences = compositionLocalOf { error("No preferences provided") } -@Composable -fun PreferencesProvider(content: @Composable () -> Unit){ - val preferences = loadPreferences() - CompositionLocalProvider(LocalPreferences provides preferences){ - content() - } } \ No newline at end of file diff --git a/app/src/processing/app/contrib/ui/ContributionManager.kt b/app/src/processing/app/contrib/ui/ContributionManager.kt deleted file mode 100644 index 2ad472159b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionManager.kt +++ /dev/null @@ -1,310 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.animation.Animatable -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import com.charleskorn.kaml.Yaml -import com.charleskorn.kaml.YamlConfiguration -import kotlinx.serialization.Serializable -import processing.app.Platform -import processing.app.loadPreferences -import java.net.URL -import java.util.* -import javax.swing.JFrame -import javax.swing.SwingUtilities -import kotlin.io.path.* - - -fun main() = application { - Window(onCloseRequest = ::exitApplication) { - contributionsManager() - } -} - -enum class Status { - VALID, - BROKEN, - DEPRECATED -} -enum class Type { - library, - mode, - tool, - examples, -} - -@Serializable -data class Author( - val name: String, - val url: String? = null, -) - -@Serializable -data class Contribution( - val id: Int, - val status: Status, - val source: String, - val type: Type, - val name: String? = null, - val categories: List? = emptyList(), - val authors: String? = null, - val authorList: List? = emptyList(), - val url: String? = null, - val sentence: String? = null, - val paragraph: String? = null, - val version: String? = null, - val prettyVersion: String? = null, - val minRevision: Int? = null, - val maxRevision: Int? = null, - val download: String? = null, - val isUpdate: Boolean? = null, - val isInstalled: Boolean? = null, -) - -@Serializable -data class Contributions( - val contributions: List -) - -fun openContributionsManager(){ - // open the compose window - - SwingUtilities.invokeLater { - val frame = JFrame("Contributions Manager") - frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE - frame.setSize(800, 600) - - val composePanel = ComposePanel() - composePanel.setContent { - contributionsManager() - } - - frame.contentPane.add(composePanel) - frame.isVisible = true - } -} - -@Composable -fun contributionsManager(){ - var contributions by remember { mutableStateOf(listOf()) } - var localContributions by remember { mutableStateOf(listOf()) } - var error by remember { mutableStateOf(null) } - - val preferences = loadPreferences() - - LaunchedEffect(preferences){ - try { - localContributions = loadContributionProperties(preferences) - .map { (type, props) -> - Contribution( - id = 0, - status = Status.VALID, - source = "local", - type = type, - name = props.getProperty("name"), - authors = props.getProperty("authors"), - url = props.getProperty("url"), - sentence = props.getProperty("sentence"), - paragraph = props.getProperty("paragraph"), - version = props.getProperty("version"), - prettyVersion = props.getProperty("prettyVersion"), - minRevision = props.getProperty("minRevision")?.toIntOrNull(), - maxRevision = props.getProperty("maxRevision")?.toIntOrNull(), - download = props.getProperty("download"), - ) - } - } catch (e: Exception){ - error = e - } - } - - - LaunchedEffect(Unit){ - try { - val url = URL("https://github.com/mingness/processing-contributions-new/raw/refs/heads/main/contributions.yaml") - val connection = url.openConnection() - val inputStream = connection.getInputStream() - val yaml = inputStream.readAllBytes().decodeToString() - // TODO cache yaml in processing folder - - val parser = Yaml( - configuration = YamlConfiguration( - strictMode = false - ) - ) - val result = parser.decodeFromString(Contributions.serializer(), yaml) - - contributions = result.contributions - .filter { it.status == Status.VALID } - .map { - // TODO Parse better - val authorList = it.authors?.split(",")?.map { author -> - val parts = author.split("](") - val name = parts[0].removePrefix("[") - val url = parts.getOrNull(1)?.removeSuffix(")") - Author(name, url) - } ?: emptyList() - it.copy(authorList = authorList) - } - } catch (e: Exception){ - error = e - } - } - if(error != null){ - Text("Error loading contributions: ${error?.message}") - return - } - if(contributions.isEmpty()){ - Text("Loading contributions...") - return - } - - val contributionsByType = (contributions + localContributions) - .groupBy { it.name } - .map { (_, contributions) -> - if(contributions.size == 1) return@map contributions.first() - else{ - // check if they all have the same version, otherwise return the newest version - val versions = contributions.mapNotNull { it.version } - if(versions.toSet().size == 1) return@map contributions.first().copy(isInstalled = true) - else{ - val newest = contributions.maxByOrNull { it.version?.toIntOrNull() ?: 0 } - if(newest != null) return@map newest.copy(isUpdate = true, isInstalled = true) - else return@map contributions.first().copy(isUpdate = true, isInstalled = true) - } - } - } - .groupBy { it.type } - - val types = Type.entries - var selectedType by remember { mutableStateOf(types.first()) } - val contributionsForType = (contributionsByType[selectedType] ?: emptyList()) - .sortedBy { it.name } - - var selectedContribution by remember { mutableStateOf(null) } - Box{ - Column { - Row{ - for(type in types){ - val background = remember { Animatable(Color.Transparent) } - val color = remember { Animatable(Color.Black) } - LaunchedEffect(selectedType){ - if(selectedType == type){ - background.animateTo(Color(0xff0251c8)) - color.animateTo(Color.White) - }else{ - background.animateTo(Color.Transparent) - color.animateTo(Color.Black) - } - } - - Row(modifier = Modifier - .background(background.value) - .pointerHoverIcon(PointerIcon.Hand) - .clickable { - selectedType = type - selectedContribution = null - } - .padding(16.dp, 8.dp) - ){ - Text(type.name, color = color.value) - val updates = contributionsByType[type]?.count { it.isUpdate == true } ?: 0 - if(updates > 0){ - Text("($updates)") - } - } - } - } - - Box(modifier = Modifier.weight(1f)){ - val state = rememberLazyListState() - LazyColumn(state = state) { - item{ - // Table Header - } - items(contributionsForType){ contribution -> - Row(modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .clickable { selectedContribution = contribution } - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(modifier = Modifier.weight(1f)){ - if(contribution.isUpdate == true){ - Text("Update") - }else if(contribution.isInstalled == true){ - Text("Installed") - } - - } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(8f)){ - Text(contribution.name ?: "Unnamed", fontWeight = FontWeight.Bold) - Text(contribution.sentence ?: "No description", maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Row(modifier = Modifier.weight(4f)){ - Text(contribution.authorList?.joinToString { it.name } ?: "Unknown") - } - } - } - } - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .background(Color.LightGray) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter( - scrollState = state - ) - ) - } - ContributionPane( - contribution = selectedContribution, - onClose = { selectedContribution = null } - ) - } - - } - -} - - -fun loadContributionProperties(preferences: Properties): List>{ - val result = mutableListOf>() - val sketchBook = Path(preferences.getProperty("sketchbook.path.four", Platform.getDefaultSketchbookFolder().path)) - sketchBook.forEachDirectoryEntry{ contributionsFolder -> - if(!contributionsFolder.isDirectory()) return@forEachDirectoryEntry - val typeName = contributionsFolder.fileName.toString() - val type: Type = when(typeName){ - "libraries" -> Type.library - "modes" -> Type.mode - "tools" -> Type.tool - "examples" -> Type.examples - else -> return@forEachDirectoryEntry - } - contributionsFolder.forEachDirectoryEntry { contribution -> - if(!contribution.isDirectory()) return@forEachDirectoryEntry - contribution.forEachDirectoryEntry("*.properties"){ entry -> - val props = Properties() - props.load(entry.inputStream()) - result += Pair(type, props) - } - } - } - return result -} \ No newline at end of file diff --git a/app/src/processing/app/contrib/ui/ContributionPane.kt b/app/src/processing/app/contrib/ui/ContributionPane.kt deleted file mode 100644 index 2f4a96931b..0000000000 --- a/app/src/processing/app/contrib/ui/ContributionPane.kt +++ /dev/null @@ -1,79 +0,0 @@ -package processing.app.contrib.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Window - -//--processing-blue-light: #82afff; -//--processing-blue-mid: #0564ff; -//--processing-blue-deep: #1e32aa; -//--processing-blue-dark: #0f195a; -//--processing-blue: #0251c8; - -@Composable -fun ContributionPane(contribution: Contribution?, onClose: () -> Unit) { - if(contribution == null) { - return - } - val typeName = when(contribution.type) { - Type.library -> "Library" - Type.tool -> "Tool" - Type.examples -> "Example" - Type.mode -> "Mode" - } - Window( - title = "${typeName}: ${contribution.name}", - onCloseRequest = onClose, - onKeyEvent = { - if(it.key == Key.Escape) { - onClose() - true - } else { - false - } - } - ){ - Box { - Column(modifier = Modifier.padding(10.dp)) { - Text(typeName, style = TextStyle(fontSize = 16.sp)) - Text(contribution.name ?: "", style = TextStyle(fontSize = 20.sp)) - Row(modifier = Modifier.padding(0.dp, 10.dp)) { - val action = when(contribution.isUpdate) { - true -> "Update" - false, null -> when(contribution.isInstalled) { - true -> "Uninstall" - false, null -> "Install" - } - } - Text(action, - style = TextStyle(fontSize = 14.sp, color = Color.White), - modifier = Modifier - .clickable { - - } - .pointerHoverIcon(PointerIcon.Hand) - .background(Color(0xff0251c8)) - .padding(24.dp,12.dp) - ) - } - Text(contribution.paragraph ?: "", style = TextStyle(fontSize = 14.sp)) - } - } - } - -} \ No newline at end of file diff --git a/app/src/processing/app/ui/Editor.java b/app/src/processing/app/ui/Editor.java index df2440d391..5b752b4046 100644 --- a/app/src/processing/app/ui/Editor.java +++ b/app/src/processing/app/ui/Editor.java @@ -1061,6 +1061,14 @@ public void buildDevelopMenu(){ }); developMenu.add(updateTrigger); + var error = new JMenuItem(Language.text("menu.develop.trigger.messages")); + error.addActionListener(e -> { + Messages.showWarning("Hello", "This is a test of the emergency broadcast system.\n\nIf this had been an actual emergency, you would be instructed to tune to your local news station.", new Exception("This is a test exception for developers.")); + Messages.showYesNoQuestion(this, "Test Question", "Did you see the exception in the console?", null); + Messages.showError("Test Error", "This is a test error message. the editor will quit after this", new Exception("This is a test exception for developers.")); + }); + developMenu.add(error); + } public void updateDevelopMenu(){ diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt new file mode 100644 index 0000000000..7fd9f56350 --- /dev/null +++ b/app/src/processing/app/ui/Preferences.kt @@ -0,0 +1,325 @@ +package processing.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import processing.app.LocalPreferences +import processing.app.ui.PDEPreferences.Companion.preferences +import processing.app.ui.preferences.General +import processing.app.ui.preferences.Interface +import processing.app.ui.preferences.Other +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import java.awt.Dimension +import javax.swing.SwingUtilities + +val LocalPreferenceGroups = compositionLocalOf>> { + error("No Preference Groups Set") +} + +class PDEPreferences { + companion object{ + val groups = mutableStateMapOf>() + fun register(preference: PDEPreference) { + val list = groups[preference.group]?.toMutableList() ?: mutableListOf() + list.add(preference) + groups[preference.group] = list + } + init{ + General.register() + Interface.register() + Other.register() + } + + /** + * Composable function to display the preferences UI. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun preferences(){ + var visible by remember { mutableStateOf(groups) } + val sortedGroups = remember { + val keys = visible.keys + keys.toSortedSet { + a, b -> + when { + a.after == b -> 1 + b.after == a -> -1 + else -> a.name.compareTo(b.name) + } + } + } + var selected by remember { mutableStateOf(sortedGroups.first()) } + CompositionLocalProvider( + LocalPreferenceGroups provides visible + ) { + Row { + NavigationRail( + header = { + Text( + "Settings", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 42.dp) + ) + + }, + modifier = Modifier + .defaultMinSize(minWidth = 200.dp) + ) { + + for (group in sortedGroups) { + NavigationRailItem( + selected = selected == group, + enabled = visible.keys.contains(group), + onClick = { + selected = group + }, + icon = { + group.icon() + }, + label = { + Text(group.name) + } + ) + } + } + Box(modifier = Modifier.padding(top = 42.dp)) { + Column(modifier = Modifier + .fillMaxSize() + ) { + var query by remember { mutableStateOf("") } + val locale = LocalLocale.current + LaunchedEffect(query){ + + snapshotFlow { query } + .debounce(100) + .collect{ + if(it.isBlank()){ + visible = groups + return@collect + } + val filtered = mutableStateMapOf>() + for((group, preferences) in groups){ + val matching = preferences.filter { preference -> + if(preference.key == "other"){ + return@filter true + } + if(preference.key.contains(it, ignoreCase = true)){ + return@filter true + } + val description = locale[preference.descriptionKey] + description.contains(it, ignoreCase = true) + } + if(matching.isNotEmpty()){ + filtered[group] = matching + } + } + visible = filtered + } + + } + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { + query = it + }, + onSearch = { + + }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.align(Alignment.End).padding(16.dp) + ) { + + } + + val preferences = visible[selected] ?: emptyList() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(preferences){ preference -> + preference.showControl() + } + } + } + } + } + } + } + + + + @JvmStatic + fun main(args: Array) { + application { + Window(onCloseRequest = ::exitApplication){ + remember{ + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = true) { + preferences() + } + } + Window(onCloseRequest = ::exitApplication){ + remember{ + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = false) { + preferences() + } + } + } + } + } +} + +/** + * Data class representing a single preference in the preferences system. + * + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * group = somePreferenceGroup, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + */ +data class PDEPreference( + /** + * The key in the preferences file used to store this preference. + */ + val key: String, + /** + * The key for the description of this preference, used for localization. + */ + val descriptionKey: String, + /** + * The group this preference belongs to. + */ + val group: PDEPreferenceGroup, + /** + * A Composable function that defines the control used to modify this preference. + * It takes the current preference value and a function to update the preference. + */ + val control: @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit = { preference, updatePreference -> }, + + /** + * If true, no padding will be applied around this preference's UI. + */ + val noPadding: Boolean = false, +) + +/** + * Composable function to display the preference's description and control. + */ +@Composable +private fun PDEPreference.showControl() { + val locale = LocalLocale.current + val prefs = LocalPreferences.current + Text( + text = locale[descriptionKey], + modifier = Modifier.padding(horizontal = 20.dp), + style = MaterialTheme.typography.titleMedium + ) + val show = @Composable { + control(prefs[key]) { newValue -> + prefs[key] = newValue + } + } + + if(noPadding){ + show() + }else{ + Box(modifier = Modifier.padding(horizontal = 20.dp)) { + show() + } + } + +} + +/** + * Data class representing a group of preferences. + */ +data class PDEPreferenceGroup( + /** + * The name of this group. + */ + val name: String, + /** + * The icon representing this group. + */ + val icon: @Composable () -> Unit, + /** + * The group that comes before this one in the list. + */ + val after: PDEPreferenceGroup? = null, +) + +fun show(){ + SwingUtilities.invokeLater { + PDESwingWindow( + titleKey = "preferences", + fullWindowContent = true, + size = Dimension(800, 600) + ) { + PDETheme { + preferences() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/Welcome.kt b/app/src/processing/app/ui/Welcome.kt new file mode 100644 index 0000000000..e79d3e749c --- /dev/null +++ b/app/src/processing/app/ui/Welcome.kt @@ -0,0 +1,273 @@ +package processing.app.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.MaterialTheme.typography +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.application +import com.formdev.flatlaf.util.SystemInfo +import processing.app.* +import processing.app.ui.components.LanguageChip +import processing.app.ui.components.examples.examples +import processing.app.ui.theme.* +import java.awt.Desktop +import java.io.IOException +import java.net.URI +import javax.swing.SwingUtilities + + +class Welcome @Throws(IOException::class) constructor(base: Base) { + init { + SwingUtilities.invokeLater { + PDESwingWindow("menu.help.welcome", fullWindowContent = true) { + CompositionLocalProvider(LocalBase provides base) { + welcome() + } + } + } + } + + companion object { + val LocalBase = compositionLocalOf { null } + @Composable + fun welcome() { + Column( + modifier = Modifier + .background( + Brush.linearGradient( + colorStops = arrayOf(0f to Color.Transparent, 1f to Color(0xFFC0D7FF)), + start = Offset(815f, 0f), + end = Offset(815f * 2, 450f) + ) + ) + .padding(horizontal = 32.dp) + .padding(bottom = 32.dp) + .padding(top = if (SystemInfo.isMacFullWindowContentSupported) 22.dp else 0.dp) + .height(IntrinsicSize.Max) + .width(IntrinsicSize.Max) + ) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .align(Alignment.End) + ) { + LanguageChip() + } + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + Column { + intro() + } + Box{ + Column { + examples() + actions() + } + val locale = LocalLocale.current + Image( + painter = painterResource("welcome/intro/wavy.svg"), + contentDescription = locale["welcome.intro.long"], + modifier = Modifier + .height(200.dp) + .offset (32.dp) + .align(Alignment.BottomEnd) + .scale(when(LocalLayoutDirection.current) { + LayoutDirection.Rtl -> -1f + else -> 1f + }, 1f) + ) + } + } + } + } + + @Composable + fun intro(){ + val locale = LocalLocale.current + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxHeight() + .width(IntrinsicSize.Max) + ) { + Column { + Text( + text = locale["welcome.intro.title"], + style = typography.h4, + modifier = Modifier + .sizeIn(maxWidth = 305.dp) + ) + Text( + text = locale["welcome.intro.message"], + style = typography.body1, + modifier = Modifier + .sizeIn(maxWidth = 305.dp) + ) + } + Column( + modifier = Modifier + .offset(y = 32.dp) + ){ + Text( + text = locale["welcome.intro.suggestion"], + style = typography.body1, + color = colors.onPrimary, + modifier = Modifier + .padding(top = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(colors.primary) + .padding(horizontal = 24.dp) + .padding(top = 16.dp, bottom = 24.dp) + .sizeIn(maxWidth = 200.dp) + ) + Image( + painter = painterResource("welcome/intro/bubble.svg"), + contentDescription = locale["welcome.intro.long"], + modifier = Modifier + .align(Alignment.Start) + .scale(when(LocalLayoutDirection.current) { + LayoutDirection.Rtl -> -1f + else -> 1f + }, 1f) + .padding(start = 64.dp) + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier. + fillMaxWidth() + ) { + Image( + painter = painterResource("welcome/intro/long.svg"), + contentDescription = locale["welcome.intro.long"], + modifier = Modifier + .offset(x = -32.dp) + .scale(when(LocalLayoutDirection.current) { + LayoutDirection.Rtl -> -1f + else -> 1f + }, 1f) + ) + Image( + painter = painterResource("welcome/intro/short.svg"), + contentDescription = locale["welcome.intro.short"], + modifier = Modifier + .align(Alignment.Bottom) + .offset(x = 16.dp, y = -16.dp) + .scale(when(LocalLayoutDirection.current) { + LayoutDirection.Rtl -> -1f + else -> 1f + }, 1f) + ) + } + } + } + } + + @Composable + fun actions(){ + val locale = LocalLocale.current + val base = LocalBase.current + PDEChip(onClick = { + base?.defaultMode?.showExamplesFrame() + }) { + Text( + text = locale["welcome.action.examples"], + ) + Image( + imageVector = Icons.AutoMirrored.Default.ArrowForward, + contentDescription = locale["welcome.action.tutorials"], + colorFilter = ColorFilter.tint(color = LocalContentColor.current), + modifier = Modifier + .padding(start = 8.dp) + .size(typography.body1.fontSize.value.dp) + ) + } + PDEChip(onClick = { + if (!Desktop.isDesktopSupported()) return@PDEChip + val desktop = Desktop.getDesktop() + if(!desktop.isSupported(Desktop.Action.BROWSE)) return@PDEChip + try { + desktop.browse(URI(System.getProperty("processing.tutorials"))) + } catch (e: Exception) { + e.printStackTrace() + } + }) { + Text( + text = locale["welcome.action.tutorials"], + ) + Image( + imageVector = Icons.AutoMirrored.Default.ArrowForward, + contentDescription = locale["welcome.action.tutorials"], + colorFilter = ColorFilter.tint(color = LocalContentColor.current), + modifier = Modifier + .padding(start = 8.dp) + .size(typography.body1.fontSize.value.dp) + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .offset(-32.dp) + ) { + val preferences = LocalPreferences.current + Checkbox( + checked = preferences["welcome.four.show"]?.equals("true") ?: false, + onCheckedChange = { + preferences.setProperty("welcome.four.show", it.toString()) + }, + modifier = Modifier + .size(24.dp) + ) + Text( + text = locale["welcome.action.startup"], + ) + } + val window = LocalWindow.current + PDEButton(onClick = { + window.dispose() + }) { + Text( + text = locale["welcome.action.go"], + modifier = Modifier + ) + } + } + } + + + + @JvmStatic + fun main(args: Array) { + application { + PDEComposeWindow("menu.help.welcome", fullWindowContent = true, onClose = ::exitApplication) { + welcome() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt index 7757e820f6..2725a78176 100644 --- a/app/src/processing/app/ui/WelcomeToBeta.kt +++ b/app/src/processing/app/ui/WelcomeToBeta.kt @@ -5,11 +5,11 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.MaterialTheme.colors -import androidx.compose.material.MaterialTheme.typography -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -31,17 +31,16 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo import com.mikepenz.markdown.compose.Markdown -import com.mikepenz.markdown.m2.markdownColor -import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.model.MarkdownColors import com.mikepenz.markdown.model.MarkdownTypography import processing.app.Preferences import processing.app.Base.getRevision import processing.app.Base.getVersionName import processing.app.ui.theme.LocalLocale -import processing.app.ui.theme.LocalTheme import processing.app.ui.theme.Locale -import processing.app.ui.theme.ProcessingTheme +import processing.app.ui.theme.PDETheme import java.awt.Cursor import java.awt.Dimension import java.awt.event.KeyAdapter @@ -54,7 +53,7 @@ import javax.swing.SwingUtilities class WelcomeToBeta { companion object{ - val windowSize = Dimension(400, 200) + val windowSize = Dimension(400, 250) val windowTitle = Locale()["beta.window.title"] @JvmStatic @@ -72,7 +71,7 @@ class WelcomeToBeta { contentPane.add(ComposePanel().apply { size = windowSize setContent { - ProcessingTheme { + PDETheme(darkTheme = false) { Box(modifier = Modifier.padding(top = if (mac) 22.dp else 0.dp)) { welcomeToBeta(close) } @@ -99,7 +98,7 @@ class WelcomeToBeta { Row( modifier = Modifier .padding(20.dp, 10.dp) - .size(windowSize.width.dp, windowSize.height.dp), + .fillMaxSize(), horizontalArrangement = Arrangement .spacedBy(20.dp) ){ @@ -109,7 +108,7 @@ class WelcomeToBeta { contentDescription = locale["beta.logo"], modifier = Modifier .align(Alignment.CenterVertically) - .size(100.dp, 100.dp) + .size(120.dp) .offset(0.dp, (-25).dp) ) Column( @@ -123,7 +122,7 @@ class WelcomeToBeta { ) { Text( text = locale["beta.title"], - style = typography.subtitle1, + style = typography.titleLarge, ) val text = locale["beta.message"] .replace('$' + "version", getVersionName()) @@ -131,80 +130,36 @@ class WelcomeToBeta { Markdown( text, colors = markdownColor(), - typography = markdownTypography(text = typography.body1, link = typography.body1.copy(color = colors.primary)), + typography = markdownTypography(), modifier = Modifier.background(Color.Transparent).padding(bottom = 10.dp) ) Row { Spacer(modifier = Modifier.weight(1f)) - PDEButton(onClick = { + Button(onClick = { close() }) { Text( text = locale["beta.button"], - color = colors.onPrimary + color = MaterialTheme.colorScheme.onPrimary ) } } } } } - @OptIn(ExperimentalComposeUiApi::class) - @Composable - fun PDEButton(onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) { - val theme = LocalTheme.current - - var hover by remember { mutableStateOf(false) } - var clicked by remember { mutableStateOf(false) } - val offset by animateFloatAsState(if (hover) -5f else 5f) - val color by animateColorAsState(if(clicked) colors.primaryVariant else colors.primary) - - Box(modifier = Modifier.padding(end = 5.dp, top = 5.dp)) { - Box( - modifier = Modifier - .offset((-offset).dp, (offset).dp) - .background(theme.getColor("toolbar.button.pressed.field")) - .matchParentSize() - ) - Box( - modifier = Modifier - .onPointerEvent(PointerEventType.Press) { - clicked = true - } - .onPointerEvent(PointerEventType.Release) { - clicked = false - onClick() - } - .onPointerEvent(PointerEventType.Enter) { - hover = true - } - .onPointerEvent(PointerEventType.Exit) { - hover = false - } - .pointerHoverIcon(PointerIcon(Cursor(Cursor.HAND_CURSOR))) - .background(color) - .padding(10.dp) - .sizeIn(minWidth = 100.dp), - contentAlignment = Alignment.Center, - content = content - ) - } - } - @JvmStatic fun main(args: Array) { application { val windowState = rememberWindowState( - size = DpSize.Unspecified, + size = windowSize.let { DpSize(it.width.dp, it.height.dp) }, position = WindowPosition(Alignment.Center) ) Window(onCloseRequest = ::exitApplication, state = windowState, title = windowTitle) { - ProcessingTheme { - Surface(color = colors.background) { - welcomeToBeta { - exitApplication() - } + PDETheme(darkTheme = false) { + welcomeToBeta { + exitApplication() } } } diff --git a/app/src/processing/app/ui/components/LanuageSelector.kt b/app/src/processing/app/ui/components/LanuageSelector.kt new file mode 100644 index 0000000000..5c42443fe4 --- /dev/null +++ b/app/src/processing/app/ui/components/LanuageSelector.kt @@ -0,0 +1,126 @@ +package processing.app.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme.typography +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.outlined.Language +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.dp +import processing.app.Platform +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDEChip +import processing.app.watchFile +import java.io.File +import java.nio.file.FileSystem +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Paths +import java.util.* +import kotlin.io.path.inputStream + +data class Language( + val name: String, + val code: String, + val locale: Locale, + val properties: Properties +) + +var jarFs: FileSystem? = null + +@Composable +fun LanguageChip(){ + var expanded by remember { mutableStateOf(false) } + + val settingsFolder = Platform.getSettingsFolder() + val languageFile = File(settingsFolder, "language.txt") + watchFile(languageFile) + + val main = ClassLoader.getSystemResource("PDE.properties")?: return + + val languages = remember { + val list = when(main.protocol){ + "file" -> { + val path = Paths.get(main.toURI()) + Files.list(path.parent) + } + "jar" -> { + val uri = main.toURI() + jarFs = jarFs ?: FileSystems.newFileSystem(uri, emptyMap()) ?: return@remember null + Files.list(jarFs!!.getPath("/")) + } + else -> null + } ?: return@remember null + + list.toList() + .map { Pair(it, it.fileName.toString()) } + .filter { (_, fileName) -> fileName.startsWith("PDE_") && fileName.endsWith(".properties") } + .map { (path, _) -> + path.inputStream().reader(Charsets.UTF_8).use { + val properties = Properties() + properties.load(it) + + val code = path.fileName.toString().removeSuffix(".properties").replace("PDE_", "") + val locale = Locale.forLanguageTag(code) + val name = locale.getDisplayName(locale) + + return@map Language( + name, + code, + locale, + properties + ) + } + } + .sortedBy { it.name.lowercase() } + } ?: return + + val current = languageFile.readText(Charsets.UTF_8).substring(0, 2) + val currentLanguage = remember(current) { languages.find { it.code.startsWith(current) } ?: languages.first()} + + val locale = LocalLocale.current + + PDEChip(onClick = { expanded = !expanded }, leadingIcon = { + Image( + imageVector = Icons.Outlined.Language, + contentDescription = "Language", + colorFilter = ColorFilter.tint(color = LocalContentColor.current), + modifier = Modifier + .padding(start = 8.dp) + .size(typography.body1.fontSize.value.dp) + ) + }) { + Text(currentLanguage.name) + Image( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = locale["welcome.action.tutorials"], + colorFilter = ColorFilter.tint(color = LocalContentColor.current), + modifier = Modifier + .size(typography.body1.fontSize.value.dp) + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + }, + ){ + for (language in languages){ + DropdownMenuItem(onClick = { + locale.set(language.locale) + expanded = false + }) { + Text(language.name) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/components/examples/Examples.kt b/app/src/processing/app/ui/components/examples/Examples.kt new file mode 100644 index 0000000000..4c0a9045cb --- /dev/null +++ b/app/src/processing/app/ui/components/examples/Examples.kt @@ -0,0 +1,194 @@ +package processing.app.ui.components.examples + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.MaterialTheme.typography +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.decodeToImageBitmap +import processing.app.LocalPreferences +import processing.app.Messages +import processing.app.Platform +import processing.app.ui.Welcome.Companion.LocalBase +import java.awt.Cursor +import java.io.File +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.isDirectory + +data class Example( + val folder: Path, + val library: Path, + val path: String = library.resolve("examples").relativize(folder).toString(), + val title: String = folder.fileName.toString(), + val image: Path = folder.resolve("$title.png") +) + +@Composable +fun loadExamples(): List { + val sketchbook = rememberSketchbookPath() + val resources = File(System.getProperty("compose.application.resources.dir") ?: "") + var examples by remember { mutableStateOf(emptyList()) } + + val settingsFolder = Platform.getSettingsFolder() + val examplesCache = settingsFolder.resolve("examples.cache") + LaunchedEffect(sketchbook, resources){ + if (!examplesCache.exists()) return@LaunchedEffect + withContext(Dispatchers.IO) { + examples = examplesCache.readText().lines().map { + val (library, folder) = it.split(",") + Example( + folder = File(folder).toPath(), + library = File(library).toPath() + ) + } + } + } + + LaunchedEffect(sketchbook, resources){ + withContext(Dispatchers.IO) { + // TODO: Optimize + Messages.log("Start scanning for examples in $sketchbook and $resources") + // Folders that can contain contributions with examples + val scanned = listOf("libraries", "examples", "modes") + .flatMap { listOf(sketchbook.resolve(it), resources.resolve(it)) } + .filter { it.exists() && it.isDirectory() } + // Find contributions within those folders + .flatMap { Files.list(it.toPath()).toList() } + .filter { Files.isDirectory(it) } + // Find examples within those contributions + .flatMap { library -> + val fs = FileSystems.getDefault() + val matcher = fs.getPathMatcher("glob:**/*.pde") + val exampleFolders = mutableListOf() + val examples = library.resolve("examples") + if (!Files.exists(examples) || !examples.isDirectory()) return@flatMap emptyList() + + Files.walkFileTree(library, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (matcher.matches(file)) { + exampleFolders.add(file.parent) + } + return FileVisitResult.CONTINUE + } + }) + return@flatMap exampleFolders.map { folder -> + Example( + folder, + library, + ) + } + } + .filter { it.image.exists() } + Messages.log("Done scanning for examples in $sketchbook and $resources") + if(scanned.isEmpty()) return@withContext + examples = scanned + examplesCache.writeText(examples.joinToString("\n") { "${it.library},${it.folder}" }) + } + } + + return examples + +} + +@Composable +fun rememberSketchbookPath(): File { + val preferences = LocalPreferences.current + val sketchbookPath = remember(preferences["sketchbook.path.four"]) { + preferences["sketchbook.path.four"] ?: Platform.getDefaultSketchbookFolder().toString() + } + return File(sketchbookPath) +} + + + +@Composable +fun examples(){ + val examples = loadExamples() + + + var randoms = examples.shuffled().take(4) + if(randoms.size < 4){ + randoms = randoms + List(4 - randoms.size) { Example( + folder = Paths.get(""), + library = Paths.get(""), + title = "Example", + image = ClassLoader.getSystemResource("default.png")?.toURI()?.let { Paths.get(it) } ?: Paths.get(""), + ) } + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + randoms.chunked(2).forEach { row -> + Row ( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ){ + row.forEach { example -> + Example(example) + } + } + } + } +} +@OptIn(ExperimentalResourceApi::class) +@Composable +fun Example(example: Example){ + val base = LocalBase.current + Button( + onClick = { + base?.handleOpenExample("${example.folder}/${example.title}.pde", base.defaultMode) + }, + contentPadding = PaddingValues(0.dp), + elevation = null, + shape = RectangleShape, + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = colors.onBackground + ), + ) { + Column( + modifier = Modifier + .width(185.dp) + ) { + val imageBitmap: ImageBitmap = remember(example.image) { + example.image.inputStream().readAllBytes().decodeToImageBitmap() + } + Image( + painter = BitmapPainter(imageBitmap), + contentDescription = example.title, + modifier = Modifier + .background(colors.primary) + .aspectRatio(16f / 9f) + ) + Text( + example.title, + style = typography.body1, + maxLines = 1 + ) + } + } +} diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt new file mode 100644 index 0000000000..5f56187f46 --- /dev/null +++ b/app/src/processing/app/ui/preferences/General.kt @@ -0,0 +1,121 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Preferences +import processing.app.SketchName +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences + + +class General { + companion object{ + val general = PDEPreferenceGroup( + name = "General", + icon = { + Icon(Icons.Default.Settings, contentDescription = "A settings icon") + } + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "sketchbook.path.four", + descriptionKey = "preferences.sketchbook_location", + group = general, + control = { preference, updatePreference -> + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + TextField( + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + Button( + onClick = { + + } + ) { + Text("Browse") + } + } + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "sketch.name.approach", + descriptionKey = "preferences.sketch_naming", + group = general, + control = { preference, updatePreference -> + Row{ + for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + "timestamp", + "untitled", + "custom" + )) { + FilterChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + modifier = Modifier.padding(4.dp), + ) + } + } + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "update.check", + descriptionKey = "preferences.check_for_updates_on_startup", + group = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.show_welcome_screen_on_startup", + group = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt new file mode 100644 index 0000000000..fc384fbc59 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -0,0 +1,168 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.TextIncrease +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Language +import processing.app.Preferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences +import processing.app.ui.Toolkit +import processing.app.ui.preferences.General.Companion.general +import processing.app.ui.theme.LocalLocale +import java.util.Locale + +class Interface { + companion object{ + val interfaceAndFonts = PDEPreferenceGroup( + name = "Interface", + icon = { + Icon(Icons.Default.TextIncrease, contentDescription = "Interface") + }, + after = general + ) + + fun register() { + PDEPreferences.register(PDEPreference( + key = "language", + descriptionKey = "preferences.language", + group = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + var showOptions by remember { mutableStateOf(false) } + val languages = if(Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + TextField( + value = locale.locale.displayName, + readOnly = true, + onValueChange = { }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select Font Family", + modifier = Modifier + .clickable{ + showOptions = true + } + ) + } + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + languages.forEach { family -> + DropdownMenuItem( + text = { Text(family.value) }, + onClick = { + locale.set(Locale(family.key)) + showOptions = false + } + ) + } + } + } + )) + + PDEPreferences.register( + PDEPreference( + key = "editor.font.family", + descriptionKey = "preferences.editor_and_console_font", + group = interfaceAndFonts, + control = { preference, updatePreference -> + var showOptions by remember { mutableStateOf(false) } + val families = if(Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + TextField( + value = preference ?: families.firstOrNull().orEmpty(), + readOnly = true, + onValueChange = { updatePreference (it) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select Font Family", + modifier = Modifier + .clickable{ + showOptions = true + } + ) + } + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + families.forEach { family -> + DropdownMenuItem( + text = { Text(family) }, + onClick = { + updatePreference(family) + showOptions = false + } + ) + } + + } + } + ) + ) + + PDEPreferences.register(PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + group = interfaceAndFonts, + control = { preference, updatePreference -> + Column { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + )) + PDEPreferences.register(PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + group = interfaceAndFonts, + control = { preference, updatePreference -> + Column { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + )) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt new file mode 100644 index 0000000000..f5f65ea9c8 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -0,0 +1,73 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.LocalPreferenceGroups +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.theme.LocalLocale + +class Other { + companion object{ + val other = PDEPreferenceGroup( + name = "Other", + icon = { + Icon(Icons.Default.Map, contentDescription = "A map icon") + }, + after = interfaceAndFonts + ) + fun register() { + PDEPreferences.register( + PDEPreference( + key = "other", + descriptionKey = "preferences.other", + group = other, + noPadding = true, + control = { _, _ -> + val prefs = LocalPreferences.current + val groups = LocalPreferenceGroups.current + val restPrefs = remember { + val keys = prefs.keys.mapNotNull { it as? String } + val existing = groups.values.flatten().map { it.key } + keys.filter { it !in existing }.sorted() + } + val locale = LocalLocale.current + + for(prefKey in restPrefs){ + val value = prefs[prefKey] + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ){ + Text( + text = locale[prefKey], + modifier = Modifier.align(Alignment.CenterVertically) + ) + TextField(value ?: "", onValueChange = { + prefs[prefKey] = it + }) + } + } + + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Button.kt b/app/src/processing/app/ui/theme/Button.kt new file mode 100644 index 0000000000..bec6dd3bcd --- /dev/null +++ b/app/src/processing/app/ui/theme/Button.kt @@ -0,0 +1,52 @@ +package processing.app.ui.theme + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import java.awt.Cursor + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PDEButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + var hover by remember { mutableStateOf(false) } + val offset by animateFloatAsState(if (hover) -3f else 3f) + + Box { + Box( + modifier = Modifier + .offset((-offset).dp, (offset).dp) + .matchParentSize() + .padding(vertical = 6.dp) + .background(colors.secondary) + + ) + Button( + onClick = onClick, + shape = RectangleShape, + contentPadding = PaddingValues(vertical = 8.dp, horizontal = 32.dp), + modifier = Modifier + .onPointerEvent(PointerEventType.Enter) { + hover = true + } + .onPointerEvent(PointerEventType.Exit) { + hover = false + } + .pointerHoverIcon(PointerIcon(Cursor(Cursor.HAND_CURSOR))), + content = content + ) + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Chip.kt b/app/src/processing/app/ui/theme/Chip.kt new file mode 100644 index 0000000000..baab6e8ef9 --- /dev/null +++ b/app/src/processing/app/ui/theme/Chip.kt @@ -0,0 +1,31 @@ +package processing.app.ui.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.Chip +import androidx.compose.material.ChipDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PDEChip( + onClick: () -> Unit = {}, + leadingIcon: @Composable (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit +){ + Chip( + onClick = onClick, + border = BorderStroke(1.dp, colors.secondary), + colors = ChipDefaults.chipColors( + backgroundColor = colors.background, + contentColor = colors.primaryVariant + ), + leadingIcon = leadingIcon, + modifier = Modifier, + content = content + ) +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Colors.kt b/app/src/processing/app/ui/theme/Colors.kt new file mode 100644 index 0000000000..61c6d6b55f --- /dev/null +++ b/app/src/processing/app/ui/theme/Colors.kt @@ -0,0 +1,134 @@ +package processing.app.ui.theme + +import androidx.compose.material.Colors +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +class ProcessingColors{ + companion object{ + val blue = Color(0xFF0251c8) + val lightBlue = Color(0xFF82AFFF) + + val deepBlue = Color(0xFF1e32aa) + val darkBlue = Color(0xFF0F195A) + + val white = Color(0xFFFFFFFF) + val lightGray = Color(0xFFF5F5F5) + val gray = Color(0xFFDBDBDB) + val darkGray = Color(0xFF898989) + val darkerGray = Color(0xFF727070) + val veryDarkGray = Color(0xFF1E1E1E) + val black = Color(0xFF0D0D0D) + + val error = Color(0xFFFF5757) + val errorContainer = Color(0xFFFFA6A6) + + val p5Light = Color(0xFFfd9db9) + val p5Mid = Color(0xFFff4077) + val p5Dark = Color(0xFFaf1f42) + + val foundationLight = Color(0xFFd4b2fe) + val foundationMid = Color(0xFF9c4bff) + val foundationDark = Color(0xFF5501a4) + + val downloadInactive = Color(0xFF8890B3) + val downloadBackgroundActive = Color(0x14508BFF) + } +} + +@Deprecated("Use PDE3LightColor instead") +val PDE2LightColors = Colors( + primary = ProcessingColors.blue, + primaryVariant = ProcessingColors.lightBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.deepBlue, + secondaryVariant = ProcessingColors.darkBlue, + onSecondary = ProcessingColors.white, + + background = ProcessingColors.white, + onBackground = ProcessingColors.darkBlue, + + surface = ProcessingColors.lightGray, + onSurface = ProcessingColors.darkerGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + isLight = true, +) + +@Deprecated("Use PDE3DarkColor instead") +val PDE2DarkColors = Colors( + primary = ProcessingColors.deepBlue, + primaryVariant = ProcessingColors.darkBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.lightBlue, + secondaryVariant = ProcessingColors.blue, + onSecondary = ProcessingColors.white, + + background = ProcessingColors.veryDarkGray, + onBackground = ProcessingColors.white, + + surface = ProcessingColors.darkerGray, + onSurface = ProcessingColors.lightGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + isLight = false, +) + +val PDELightColor = lightColorScheme( + primary = ProcessingColors.blue, + onPrimary = ProcessingColors.white, + + primaryContainer = ProcessingColors.downloadBackgroundActive, + onPrimaryContainer = ProcessingColors.darkBlue, + + secondary = ProcessingColors.deepBlue, + onSecondary = ProcessingColors.white, + + secondaryContainer = ProcessingColors.downloadInactive, + onSecondaryContainer = ProcessingColors.white, + + tertiary = ProcessingColors.p5Mid, + onTertiary = ProcessingColors.white, + + tertiaryContainer = ProcessingColors.p5Light, + onTertiaryContainer = ProcessingColors.p5Dark, + + background = ProcessingColors.white, + onBackground = ProcessingColors.darkBlue, + + surface = ProcessingColors.lightGray, + onSurface = ProcessingColors.darkerGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, + + errorContainer = ProcessingColors.errorContainer, + onErrorContainer = ProcessingColors.white +) + +val PDEDarkColor = darkColorScheme( + primary = ProcessingColors.deepBlue, + onPrimary = ProcessingColors.white, + + secondary = ProcessingColors.lightBlue, + onSecondary = ProcessingColors.white, + + tertiary = ProcessingColors.blue, + onTertiary = ProcessingColors.white, + + background = ProcessingColors.veryDarkGray, + onBackground = ProcessingColors.white, + + surface = ProcessingColors.darkerGray, + onSurface = ProcessingColors.lightGray, + + error = ProcessingColors.error, + onError = ProcessingColors.white, +) \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Locale.kt b/app/src/processing/app/ui/theme/Locale.kt index 254c0946c1..d760998185 100644 --- a/app/src/processing/app/ui/theme/Locale.kt +++ b/app/src/processing/app/ui/theme/Locale.kt @@ -1,24 +1,41 @@ package processing.app.ui.theme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import processing.app.LocalPreferences -import processing.app.Messages -import processing.app.Platform -import processing.app.PlatformStart -import processing.app.watchFile +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import processing.app.* import java.io.File import java.io.InputStream import java.util.* -class Locale(language: String = "") : Properties() { +/** + * The Locale class extends the standard Java Properties class + * to provide localization capabilities. + * It loads localization resources from property files based on the specified language code. + * The class also provides a method to change the current locale and update the application accordingly. + * Usage: + * ``` + * val locale = Locale("es") { newLocale -> + * // Handle locale change, e.g., update UI or restart application + * } + * val localizedString = locale["someKey"] + * ``` + */ +class Locale(language: String = "", val setLocale: ((java.util.Locale) -> Unit)? = null) : Properties() { + var locale: java.util.Locale = java.util.Locale.getDefault() + init { - val locale = java.util.Locale.getDefault() - load(ClassLoader.getSystemResourceAsStream("PDE.properties")) - load(ClassLoader.getSystemResourceAsStream("PDE_${locale.language}.properties") ?: InputStream.nullInputStream()) - load(ClassLoader.getSystemResourceAsStream("PDE_${locale.toLanguageTag()}.properties") ?: InputStream.nullInputStream()) - load(ClassLoader.getSystemResourceAsStream("PDE_${language}.properties") ?: InputStream.nullInputStream()) + loadResourceUTF8("PDE.properties") + loadResourceUTF8("PDE_${locale.language}.properties") + loadResourceUTF8("PDE_${locale.toLanguageTag()}.properties") + loadResourceUTF8("PDE_${language}.properties") + } + + fun loadResourceUTF8(path: String) { + val stream = ClassLoader.getSystemResourceAsStream(path) + stream?.reader(charset = Charsets.UTF_8)?.use { reader -> + load(reader) + } } @Deprecated("Use get instead", ReplaceWith("get(key)")) @@ -28,18 +45,86 @@ class Locale(language: String = "") : Properties() { return value } operator fun get(key: String): String = getProperty(key, key) + fun set(locale: java.util.Locale) { + setLocale?.invoke(locale) + } } -val LocalLocale = compositionLocalOf { Locale() } +/** + * A CompositionLocal to provide access to the Locale instance + * throughout the composable hierarchy. see [LocaleProvider] + * Usage: + * ``` + * val locale = LocalLocale.current + * val localizedString = locale["someKey"] + * ``` + */ +val LocalLocale = compositionLocalOf { error("No Locale Set") } + +/** + * This composable function sets up a locale provider that manages application localization. + * It initializes the locale from a language file, watches for changes to that file, and updates + * the locale accordingly. It uses a [Locale] class to handle loading of localized resources. + * + * Usage: + * ``` + * LocaleProvider { + * // Your app content here + * } + * ``` + * + * To access the locale: + * ``` + * val locale = LocalLocale.current + * val localizedString = locale["someKey"] + * ``` + * + * To change the locale: + * ``` + * locale.set(java.util.Locale("es")) + * ``` + * This will update the `language.txt` file and reload the locale. + */ @Composable fun LocaleProvider(content: @Composable () -> Unit) { - PlatformStart() + val preferencesFolderOverride: File? = System.getProperty("processing.app.preferences.folder")?.let { File(it) } + + val settingsFolder = preferencesFolderOverride ?: remember{ + Platform.init() + Platform.getSettingsFolder() + } + val languageFile = settingsFolder.resolve("language.txt") + remember(languageFile){ + if(languageFile.exists()) return@remember - val settingsFolder = Platform.getSettingsFolder() - val languageFile = File(settingsFolder, "language.txt") - watchFile(languageFile) + Messages.log("Creating language file at ${languageFile.absolutePath}") + settingsFolder.mkdirs() + languageFile.writeText(java.util.Locale.getDefault().language) + } + + val update = watchFile(languageFile) + var code by remember(languageFile, update){ mutableStateOf(languageFile.readText().substring(0, 2)) } + remember(code) { + val locale = java.util.Locale(code) + java.util.Locale.setDefault(locale) + } + + fun setLocale(locale: java.util.Locale) { + Messages.log("Setting locale to ${locale.language}") + languageFile.writeText(locale.language) + code = locale.language + } + + + val locale = Locale(code, ::setLocale) + remember(code) { Messages.log("Loaded Locale: $code") } + val dir = when(locale["locale.direction"]) { + "rtl" -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } - val locale = Locale(languageFile.readText().substring(0, 2)) - CompositionLocalProvider(LocalLocale provides locale) { - content() + CompositionLocalProvider(LocalLayoutDirection provides dir) { + CompositionLocalProvider(LocalLocale provides locale) { + content() + } } } \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/ProcessingTheme.kt b/app/src/processing/app/ui/theme/ProcessingTheme.kt new file mode 100644 index 0000000000..cfcaa6cacf --- /dev/null +++ b/app/src/processing/app/ui/theme/ProcessingTheme.kt @@ -0,0 +1,356 @@ +package processing.app.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Badge +import androidx.compose.material.BadgedBox +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.Checkbox +import androidx.compose.material.Chip +import androidx.compose.material.ChipDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FilterChip +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.LocalRippleConfiguration +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.RadioButton +import androidx.compose.material.RippleConfiguration +import androidx.compose.material.Slider +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextField +import androidx.compose.material.TriStateCheckbox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import processing.app.LocalPreferences +import processing.app.PreferencesProvider +import java.io.InputStream +import java.util.Properties + +class ProcessingTheme(themeFile: String? = "") : Properties() { + init { + load(ClassLoader.getSystemResourceAsStream("theme.txt")) + load(ClassLoader.getSystemResourceAsStream(themeFile ?: "") ?: InputStream.nullInputStream()) + } + fun getColor(key: String): Color { + return Color(getProperty(key).toColorInt()) + } + + fun String.toColorInt(): Int { + if (this[0] == '#') { + var color = substring(1).toLong(16) + if (length == 7) { + color = color or 0x00000000ff000000L + } else if (length != 9) { + throw IllegalArgumentException("Unknown color") + } + return color.toInt() + } + throw IllegalArgumentException("Unknown color") + } +} + +val LocalProcessingTheme = compositionLocalOf { error("No theme provided") } + +@Deprecated("Use PDETheme instead", ReplaceWith("PDETheme")) +@Composable +fun ProcessingTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable() () -> Unit +) { + PreferencesProvider { + val preferences = LocalPreferences.current + val theme = ProcessingTheme(preferences.getProperty("theme")) + CompositionLocalProvider(LocalProcessingTheme provides theme,LocalDensity provides Density(1.25f, 1.25f),) { + LocaleProvider { + MaterialTheme( + colors = if (darkTheme) PDE2DarkColors else PDE2LightColors, + typography = PDE2Typography, + shapes = PDE2Shapes + ) { + CompositionLocalProvider( + LocalRippleConfiguration provides RippleConfiguration( + color = MaterialTheme.colors.primary, + ) + ) { + Box(modifier = Modifier.background(MaterialTheme.colors.background).fillMaxSize()) { + Surface( + color = MaterialTheme.colors.background, + contentColor = MaterialTheme.colors.onBackground + ) { + content() + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +fun main(){ + application { + val windowState = rememberWindowState( + size = DpSize(800.dp, 600.dp), + position = WindowPosition(Alignment.Center) + ) + var darkTheme by remember { mutableStateOf(false) } + Window(onCloseRequest = ::exitApplication, state = windowState, title = "Processing Theme") { + ProcessingTheme(darkTheme) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Processing Theme Components", style = MaterialTheme.typography.h1) + Card { + Row { + Checkbox(darkTheme, onCheckedChange = { darkTheme = !darkTheme }) + Text( + "Dark Theme", + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } + val scrollable = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(scrollable), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ComponentPreview("Colors") { + Column { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary), + onClick = {}) { + Text("Primary", color = MaterialTheme.colors.onPrimary) + } + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primaryVariant), + onClick = {}) { + Text("Primary Variant", color = MaterialTheme.colors.onPrimary) + } + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = {}) { + Text("Secondary", color = MaterialTheme.colors.onSecondary) + } + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondaryVariant), + onClick = {}) { + Text("Secondary Variant", color = MaterialTheme.colors.onSecondary) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.background), + onClick = {}) { + Text("Background", color = MaterialTheme.colors.onBackground) + } + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.surface), + onClick = {}) { + Text("Surface", color = MaterialTheme.colors.onSurface) + } + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error), + onClick = {}) { + Text("Error", color = MaterialTheme.colors.onError) + } + } + } + } + ComponentPreview("Text & Fonts") { + Column { + Text("Heading 1", style = MaterialTheme.typography.h1) + Text("Heading 2", style = MaterialTheme.typography.h2) + Text("Heading 3", style = MaterialTheme.typography.h3) + Text("Heading 4", style = MaterialTheme.typography.h4) + Text("Heading 5", style = MaterialTheme.typography.h5) + Text("Heading 6", style = MaterialTheme.typography.h6) + + Text("Subtitle 1", style = MaterialTheme.typography.subtitle1) + Text("Subtitle 2", style = MaterialTheme.typography.subtitle2) + + Text("Body 1", style = MaterialTheme.typography.body1) + Text("Body 2", style = MaterialTheme.typography.body2) + + Text("Caption", style = MaterialTheme.typography.caption) + Text("Overline", style = MaterialTheme.typography.overline) + } + } + ComponentPreview("Buttons") { + Button(onClick = {}) { + Text("Filled") + } + Button(onClick = {}, enabled = false) { + Text("Disabled") + } + OutlinedButton(onClick = {}) { + Text("Outlined") + } + TextButton(onClick = {}) { + Text("Text") + } + } + ComponentPreview("Icon Buttons") { + IconButton(onClick = {}) { + Icon(Icons.Default.Map, contentDescription = "Icon Button") + } + } + ComponentPreview("Chip") { + Chip(onClick = {}){ + Text("Chip") + } + Chip(onClick = {}, colors = ChipDefaults.outlinedChipColors(), border = ChipDefaults.outlinedBorder){ + Text("Outlined") + } + FilterChip(selected = false, onClick = {}){ + Text("Filter not Selected") + } + FilterChip(selected = true, onClick = {}){ + Text("Filter Selected") + } + } + ComponentPreview("Progress Indicator") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)){ + CircularProgressIndicator() + LinearProgressIndicator() + } + } + ComponentPreview("Radio Button") { + var state by remember { mutableStateOf(true) } + RadioButton(!state, onClick = { state = false }) + RadioButton(state, onClick = { state = true }) + + } + ComponentPreview("Checkbox") { + var state by remember { mutableStateOf(true) } + Checkbox(state, onCheckedChange = { state = it }) + Checkbox(!state, onCheckedChange = { state = !it }) + Checkbox(state, onCheckedChange = {}, enabled = false) + TriStateCheckbox(ToggleableState.Indeterminate, onClick = {}) + } + ComponentPreview("Switch") { + var state by remember { mutableStateOf(true) } + Switch(state, onCheckedChange = { state = it }) + } + ComponentPreview("Slider") { + var state by remember { mutableStateOf(0f) } + Slider(state, onValueChange = { state = it }) + + } + ComponentPreview("Badge") { + IconButton(onClick = {}) { + BadgedBox(badge = { Badge() }) { + Icon(Icons.Default.Map, contentDescription = "Icon with Badge") + } + } + } + ComponentPreview("Number Field") { + var number by remember { mutableStateOf("123") } + TextField(number, onValueChange = { + if(it.all { char -> char.isDigit() }) { + number = it + } + }, label = { Text("Number Field") }) + + } + ComponentPreview("Text Field") { + Row { + var text by remember { mutableStateOf("Text Field") } + TextField(text, onValueChange = { text = it }) + } + var text by remember { mutableStateOf("Outlined Text Field") } + OutlinedTextField(text, onValueChange = { text = it}) + } + ComponentPreview("Dropdown Menu") { + var show by remember { mutableStateOf(false) } + TextField("Dropdown", onValueChange = {}, readOnly = true, modifier = Modifier + .padding(8.dp) + .background(Color.Transparent) + .clickable { show = true } + ) + DropdownMenu( + expanded = show, + onDismissRequest = { + show = false + }, + ) { + DropdownMenuItem(onClick = { show = false }) { + Text("Menu Item 1", modifier = Modifier.padding(8.dp)) + } + DropdownMenuItem(onClick = { show = false }) { + Text("Menu Item 2", modifier = Modifier.padding(8.dp)) + } + DropdownMenuItem(onClick = { show = false }) { + Text("Menu Item 3", modifier = Modifier.padding(8.dp)) + } + } + + + } + + ComponentPreview("Scrollable View") { + + } + + ComponentPreview("Tabs") { + + } + } + } + } + } + } +} + +@Composable +private fun ComponentPreview(title: String, content: @Composable () -> Unit) { + Column { + Text(title, style = MaterialTheme.typography.h4) + Divider() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp)) { + content() + } + Divider() + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Shapes.kt b/app/src/processing/app/ui/theme/Shapes.kt new file mode 100644 index 0000000000..95fefb345e --- /dev/null +++ b/app/src/processing/app/ui/theme/Shapes.kt @@ -0,0 +1,11 @@ +package processing.app.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val PDE2Shapes = Shapes( + small = RoundedCornerShape(30.dp), + medium = RoundedCornerShape(6.dp), + large = RoundedCornerShape(6.dp) +) \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 735d8e5b2a..9e41227ed1 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -1,75 +1,364 @@ package processing.app.ui.theme +import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.graphics.Color -import processing.app.LocalPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import processing.app.PreferencesProvider -import java.io.InputStream -import java.util.Properties - - -class Theme(themeFile: String? = "") : Properties() { - init { - load(ClassLoader.getSystemResourceAsStream("theme.txt")) - load(ClassLoader.getSystemResourceAsStream(themeFile) ?: InputStream.nullInputStream()) - } - fun getColor(key: String): Color { - return Color(getProperty(key).toColorInt()) - } -} - -val LocalTheme = compositionLocalOf { error("No theme provided") } +/** + * Processing Theme for Jetpack Compose Desktop + * Based on Material3 + * + * Makes Material3 components follow Processing color scheme and typography + * We experimented with using the material3 theme builder, but it made it look too Android-y + * So we defined our own color scheme and typography based on Processing design guidelines + * + * This composable also provides Preferences and Locale context to all child composables + * + * Also, important: sets a default density of 1.25 for better scaling on desktop screens, [LocalDensity] + * + * Usage: + * ``` + * PDETheme { + * val pref = LocalPreferences.current + * val locale = LocalLocale.current + * ... + * // Your composables here + * } + * ``` + * + * @param darkTheme Whether to use dark theme or light theme. Defaults to system setting. + */ @Composable -fun ProcessingTheme( +fun PDETheme( darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable() () -> Unit -) { + content: @Composable () -> Unit +){ PreferencesProvider { - val preferences = LocalPreferences.current - val theme = Theme(preferences.getProperty("theme")) - val colors = Colors( - primary = theme.getColor("editor.gradient.top"), - primaryVariant = theme.getColor("toolbar.button.pressed.field"), - secondary = theme.getColor("editor.gradient.bottom"), - secondaryVariant = theme.getColor("editor.scrollbar.thumb.pressed.color"), - background = theme.getColor("editor.bgcolor"), - surface = theme.getColor("editor.bgcolor"), - error = theme.getColor("status.error.bgcolor"), - onPrimary = theme.getColor("toolbar.button.enabled.field"), - onSecondary = theme.getColor("toolbar.button.enabled.field"), - onBackground = theme.getColor("editor.fgcolor"), - onSurface = theme.getColor("editor.fgcolor"), - onError = theme.getColor("status.error.fgcolor"), - isLight = theme.getProperty("laf.mode").equals("light") + LocaleProvider { + MaterialTheme( + colorScheme = if(darkTheme) PDEDarkColor else PDELightColor, + typography = PDETypography + ){ + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onBackground, + LocalDensity provides Density(1.25f, 1.25f), + content = content + ) + } + } + } + } +} + +/** + * Simple app to preview the Processing Theme components + * Includes buttons, text fields, checkboxes, sliders, etc. + * Run by executing the main() function by clicking the green arrow next to it in intelliJ IDEA + */ +fun main() { + application { + val windowState = rememberWindowState( + size = DpSize(800.dp, 600.dp), + position = WindowPosition(Alignment.Center) ) + var darkTheme by remember { mutableStateOf(false) } + Window(onCloseRequest = ::exitApplication, state = windowState, title = "Processing Theme") { + PDETheme(darkTheme = darkTheme) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Processing Theme Components", style = MaterialTheme.typography.titleLarge) + Card { + Row { + Checkbox(darkTheme, onCheckedChange = { darkTheme = !darkTheme }) + Text( + "Dark Theme", + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } + val scrollable = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(scrollable), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ComponentPreview("Colors") { + Column { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), + onClick = {}) { + Text("Primary", color = MaterialTheme.colorScheme.onPrimary) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), + onClick = {}) { + Text("Secondary", color = MaterialTheme.colorScheme.onSecondary) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), + onClick = {}) { + Text("Tertiary", color = MaterialTheme.colorScheme.onTertiary) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + onClick = {}) { + Text("Primary Container", color = MaterialTheme.colorScheme.onPrimaryContainer) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + onClick = {}) { + Text("Secondary Container", color = MaterialTheme.colorScheme.onSecondaryContainer) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + onClick = {}) { + Text("Tertiary Container", color = MaterialTheme.colorScheme.onTertiaryContainer) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer), + onClick = {}) { + Text("Error Container", color = MaterialTheme.colorScheme.onErrorContainer) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.background), + onClick = {}) { + Text("Background", color = MaterialTheme.colorScheme.onBackground) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = {}) { + Text("Surface", color = MaterialTheme.colorScheme.onSurface) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + onClick = {}) { + Text("Surface Variant", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Button( + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onClick = {}) { + Text("Error", color = MaterialTheme.colorScheme.onError) + } + } + } + } + ComponentPreview("Text & Fonts") { + Column { + Text("displayLarge", style = MaterialTheme.typography.displayLarge) + Text("displayMedium", style = MaterialTheme.typography.displayMedium) + Text("displaySmall", style = MaterialTheme.typography.displaySmall) + + Text("headlineLarge", style = MaterialTheme.typography.headlineLarge) + Text("headlineMedium", style = MaterialTheme.typography.headlineMedium) + Text("headlineSmall", style = MaterialTheme.typography.headlineSmall) - CompositionLocalProvider(LocalTheme provides theme) { - LocaleProvider { - MaterialTheme( - colors = colors, - typography = Typography, - content = content - ) + Text("titleLarge", style = MaterialTheme.typography.titleLarge) + Text("titleMedium", style = MaterialTheme.typography.titleMedium) + Text("titleSmall", style = MaterialTheme.typography.titleSmall) + + Text("bodyLarge", style = MaterialTheme.typography.bodyLarge) + Text("bodyMedium", style = MaterialTheme.typography.bodyMedium) + Text("bodySmall", style = MaterialTheme.typography.bodySmall) + + Text("labelLarge", style = MaterialTheme.typography.labelLarge) + Text("labelMedium", style = MaterialTheme.typography.labelMedium) + Text("labelSmall", style = MaterialTheme.typography.labelSmall) + } + } + ComponentPreview("Buttons") { + Button(onClick = {}) { + Text("Filled") + } + Button(onClick = {}, enabled = false) { + Text("Disabled") + } + OutlinedButton(onClick = {}) { + Text("Outlined") + } + TextButton(onClick = {}) { + Text("Text") + } + } + ComponentPreview("Icon Buttons") { + IconButton(onClick = {}) { + Icon(Icons.Default.Map, contentDescription = "Icon Button") + } + } + ComponentPreview("Chip") { + AssistChip(onClick = {}, label = { + Text("Assist Chip") + }) + FilterChip(selected = false, onClick = {}, label = { + Text("Filter not Selected") + }) + FilterChip(selected = true, onClick = {}, label = { + Text("Filter Selected") + }) + } + ComponentPreview("Progress Indicator") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)){ + CircularProgressIndicator() + LinearProgressIndicator() + } + } + ComponentPreview("Radio Button") { + var state by remember { mutableStateOf(true) } + RadioButton(!state, onClick = { state = false }) + RadioButton(state, onClick = { state = true }) + + } + ComponentPreview("Checkbox") { + var state by remember { mutableStateOf(true) } + Checkbox(state, onCheckedChange = { state = it }) + Checkbox(!state, onCheckedChange = { state = !it }) + Checkbox(state, onCheckedChange = {}, enabled = false) + TriStateCheckbox(ToggleableState.Indeterminate, onClick = {}) + } + ComponentPreview("Switch") { + var state by remember { mutableStateOf(true) } + Switch(state, onCheckedChange = { state = it }) + Switch(!state, enabled = false, onCheckedChange = { state = it }) + } + ComponentPreview("Slider") { + Column{ + var state by remember { mutableStateOf(0.5f) } + Slider(state, onValueChange = { state = it }) + var rangeState by remember { mutableStateOf(0.25f..0.75f) } + RangeSlider(rangeState, onValueChange = { rangeState = it }) + } + + } + ComponentPreview("Badge") { + IconButton(onClick = {}) { + BadgedBox(badge = { Badge() }) { + Icon(Icons.Default.Map, contentDescription = "Icon with Badge") + } + } + } + ComponentPreview("Number Field") { + var number by remember { mutableStateOf("123") } + TextField(number, onValueChange = { + if(it.all { char -> char.isDigit() }) { + number = it + } + }, label = { Text("Number Field") }) + + } + ComponentPreview("Text Field") { + Row { + var text by remember { mutableStateOf("Text Field") } + TextField(text, onValueChange = { text = it }) + } + var text by remember { mutableStateOf("Outlined Text Field") } + OutlinedTextField(text, onValueChange = { text = it}) + } + ComponentPreview("Dropdown Menu") { + var show by remember { mutableStateOf(false) } + AssistChip( + onClick = { show = true }, + label = { Text("Show Menu") } + ) + DropdownMenu( + expanded = show, + onDismissRequest = { + show = false + }, + ) { + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 1", modifier = Modifier.padding(8.dp)) + }) + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 2", modifier = Modifier.padding(8.dp)) + }) + DropdownMenuItem(onClick = { show = false }, text = { + Text("Menu Item 3", modifier = Modifier.padding(8.dp)) + }) + } + + + } + + ComponentPreview("Scrollable View") { + + } + + ComponentPreview("Tabs") { + + } + } + } } } } } -fun String.toColorInt(): Int { - if (this[0] == '#') { - var color = substring(1).toLong(16) - if (length == 7) { - color = color or 0x00000000ff000000L - } else if (length != 9) { - throw IllegalArgumentException("Unknown color") +@Composable +private fun ComponentPreview(title: String, content: @Composable () -> Unit) { + Column { + Text(title, style = MaterialTheme.typography.titleLarge) + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp)) { + content() } - return color.toInt() + HorizontalDivider() } - throw IllegalArgumentException("Unknown color") } \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Typography.kt b/app/src/processing/app/ui/theme/Typography.kt index 5d87c490e6..6650ac7167 100644 --- a/app/src/processing/app/ui/theme/Typography.kt +++ b/app/src/processing/app/ui/theme/Typography.kt @@ -1,6 +1,5 @@ package processing.app.ui.theme -import androidx.compose.material.MaterialTheme.typography import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -21,18 +20,108 @@ val processingFont = FontFamily( style = FontStyle.Normal ) ) +val spaceGroteskFont = FontFamily( + Font( + resource = "SpaceGrotesk-Bold.ttf", + weight = FontWeight.Bold, + ), + Font( + resource = "SpaceGrotesk-Regular.ttf", + weight = FontWeight.Normal, + ), + Font( + resource = "SpaceGrotesk-Medium.ttf", + weight = FontWeight.Medium, + ), + Font( + resource = "SpaceGrotesk-SemiBold.ttf", + weight = FontWeight.SemiBold, + ), + Font( + resource = "SpaceGrotesk-Light.ttf", + weight = FontWeight.Light, + ) +) -val Typography = Typography( +@Deprecated("Use PDE3Typography instead") +val PDE2Typography = Typography( + defaultFontFamily = spaceGroteskFont, + h1 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 42.725.sp, + lineHeight = 48.sp + ), + h2 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 34.18.sp, + lineHeight = 40.sp + ), + h3 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 27.344.sp, + lineHeight = 32.sp + ), + h4 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 21.875.sp, + lineHeight = 28.sp + ), + h5 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 17.5.sp, + lineHeight = 22.sp + ), + h6 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 18.sp + ), body1 = TextStyle( - fontFamily = processingFont, fontWeight = FontWeight.Normal, - fontSize = 13.sp, + fontSize = 14.sp, + lineHeight = 18.sp + ), + body2 = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.8.sp, lineHeight = 16.sp ), subtitle1 = TextStyle( - fontFamily = processingFont, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp + ), + subtitle2 = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 13.824.sp, + lineHeight = 16.sp, + ), + caption = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 11.2.sp, + lineHeight = 14.sp + ), + overline = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 8.96.sp, + lineHeight = 10.sp ) +) +val base = androidx.compose.material3.Typography() +val PDETypography = androidx.compose.material3.Typography( + displayLarge = base.displayLarge.copy(fontFamily = spaceGroteskFont), + displayMedium = base.displayMedium.copy(fontFamily = spaceGroteskFont), + displaySmall = base.displaySmall.copy(fontFamily = spaceGroteskFont), + headlineLarge = base.headlineLarge.copy(fontFamily = spaceGroteskFont), + headlineMedium = base.headlineMedium.copy(fontFamily = spaceGroteskFont), + headlineSmall = base.headlineSmall.copy(fontFamily = spaceGroteskFont), + titleLarge = base.titleLarge.copy(fontFamily = spaceGroteskFont), + titleMedium = base.titleMedium.copy(fontFamily = spaceGroteskFont), + titleSmall = base.titleSmall.copy(fontFamily = spaceGroteskFont), + bodyLarge = base.bodyLarge.copy(fontFamily = spaceGroteskFont), + bodyMedium = base.bodyMedium.copy(fontFamily = spaceGroteskFont), + bodySmall = base.bodySmall.copy(fontFamily = spaceGroteskFont), + labelLarge = base.labelLarge.copy(fontFamily = spaceGroteskFont), + labelMedium = base.labelMedium.copy(fontFamily = spaceGroteskFont), + labelSmall = base.labelSmall.copy(fontFamily = spaceGroteskFont), ) \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt new file mode 100644 index 0000000000..5104f247bd --- /dev/null +++ b/app/src/processing/app/ui/theme/Window.kt @@ -0,0 +1,203 @@ +package processing.app.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.formdev.flatlaf.util.SystemInfo +import java.awt.Dimension + +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.JFrame +import javax.swing.UIManager + +val LocalWindow = compositionLocalOf { error("No Window Set") } + +/** + * A utility class to create a new Window with Compose content in a Swing application. + * It sets up the window with some default properties and allows for custom content. + * Use this when creating a Compose based window from Swing. + * + * Usage example: + * ``` + * SwingUtilities.invokeLater { + * PDESwingWindow("menu.help.welcome", fullWindowContent = true) { + * + * } + * } + * ``` + * + * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. If null, the window will use its default size. + * @param minSize The minimum size of the window. If null, no minimum size is set. + * @param maxSize The maximum size of the window. If null, no maximum size is set. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param content The composable content to be displayed in the window. + */ +// TODO: Add support for onClose callback +class PDESwingWindow( + titleKey: String = "", + size: Dimension? = null, + minSize: Dimension? = null, + maxSize: Dimension? = null, + fullWindowContent: Boolean = false, + content: @Composable () -> Unit +){ + init{ + ComposeWindow().apply { + val window = this + defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE + size?.let { + window.size = it + } + minSize?.let { + window.minimumSize = it + } + maxSize?.let { + window.maximumSize = it + } + setLocationRelativeTo(null) + setContent { + PDEWindowContent(window, titleKey, fullWindowContent, content) + } + isVisible = true + } + } +} + +/** + * Internal Composable function to set up the window content with theming and localization. + * It also handles macOS specific properties for full window content. + * + * @param window The JFrame instance to be configured. + * @param titleKey The key for the window title, which will be localized. + * @param fullWindowContent If true, the content will extend into the title bar area on macOS. + * @param content The composable content to be displayed in the window. + */ +@Composable +private fun PDEWindowContent( + window: ComposeWindow, + titleKey: String, + fullWindowContent: Boolean = false, + content: @Composable () -> Unit +){ + val mac = SystemInfo.isMacOS && SystemInfo.isMacFullWindowContentSupported + remember { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", mac && fullWindowContent) + } + + CompositionLocalProvider(LocalWindow provides window) { + PDETheme{ + val locale = LocalLocale.current + window.title = locale[titleKey] + content() + } + } +} + +/** + * A Composable function to create and display a new window with the specified content. + * This function sets up the window state and handles the close request. + * Use this when creating a Compose based window from another Compose context. + * + * Usage example: + * ``` + * PDEComposeWindow("window.title", fullWindowContent = true, onClose = { /* handle close */ }) { + * // Your window content here + * Text("Hello, World!") + * } + * ``` + * + * This will create a new window with the title localized from "window.title" key, + * with content extending into the title bar area on macOS, and a custom close handler. + * + * Fully standalone example: + * ``` + * application { + * PDEComposeWindow("window.title", fullWindowContent = true, onClose = ::exitApplication) { + * // Your window content here + * } + * } + * ``` + * + * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. Defaults to unspecified size which means the window will be + * fullscreen if it contains any of [fillMaxWidth]/[fillMaxSize]/[fillMaxHeight] etc. + * @param minSize The minimum size of the window. Defaults to unspecified size which means no minimum size is set. + * @param maxSize The maximum size of the window. Defaults to unspecified size which means no maximum size is set. + * @param fullWindowContent If true, the content will extend into the title bar area on + * macOS. + * @param onClose A lambda function to be called when the window is requested to close. + * @param content The composable content to be displayed in the window. + * + * + * + */ +@Composable +fun PDEComposeWindow( + titleKey: String, + size: DpSize = DpSize.Unspecified, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ + val windowState = rememberWindowState( + size = size, + position = WindowPosition(Alignment.Center) + ) + Window(onCloseRequest = onClose, state = windowState, title = "") { + remember { + window.minimumSize = minSize.toDimension() + window.maximumSize = maxSize.toDimension() + } + PDEWindowContent(window, titleKey, fullWindowContent, content) + } +} + +fun DpSize.toDimension(): Dimension? { + if(this == DpSize.Unspecified) { return null } + + return Dimension( + this.width.value.toInt(), + this.height.value.toInt() + ) +} + +fun main(){ + application { + PDEComposeWindow( + onClose = ::exitApplication, + titleKey = "window.title", + size = DpSize(800.dp, 600.dp), + ){ + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text("Hello, World!") + } + } + } +} \ No newline at end of file diff --git a/app/test/processing/app/LocaleKtTest.kt b/app/test/processing/app/LocaleKtTest.kt new file mode 100644 index 0000000000..f8ed32164a --- /dev/null +++ b/app/test/processing/app/LocaleKtTest.kt @@ -0,0 +1,52 @@ +package processing.app + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.LocaleProvider +import kotlin.io.path.createTempDirectory +import kotlin.test.Test + +class LocaleKtTest { + @OptIn(ExperimentalTestApi::class) + @Test + fun testLocale() = runComposeUiTest { + val tempPreferencesDir = createTempDirectory("preferences") + + System.setProperty("processing.app.preferences.folder", tempPreferencesDir.toFile().absolutePath) + + setContent { + LocaleProvider { + val locale = LocalLocale.current + Text(locale["menu.file.new"], modifier = Modifier.testTag("localisedText")) + + Button(onClick = { + locale.set(java.util.Locale("es")) + }, modifier = Modifier.testTag("button")) { + Text("Change") + } + } + } + + // Check if usage generates the language file if it doesn't exist + val languageFile = tempPreferencesDir.resolve("language.txt").toFile() + assert(languageFile.exists()) + + // Check if the text is localised + onNodeWithTag("localisedText").assertTextEquals("New") + + // Change the locale to Spanish + onNodeWithTag("button").performClick() + onNodeWithTag("localisedText").assertTextEquals("Nuevo") + + // Check if the preference was saved to file + assert(languageFile.readText().substring(0, 2) == "es") + } +} \ No newline at end of file diff --git a/app/test/processing/app/PreferencesKtTest.kt b/app/test/processing/app/PreferencesKtTest.kt new file mode 100644 index 0000000000..6b5dbc5ea9 --- /dev/null +++ b/app/test/processing/app/PreferencesKtTest.kt @@ -0,0 +1,61 @@ +package processing.app + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import java.util.Properties +import kotlin.io.path.createFile +import kotlin.io.path.createTempDirectory +import kotlin.test.Test + +class PreferencesKtTest{ + @OptIn(ExperimentalTestApi::class) + @Test + fun testKeyReactivity() = runComposeUiTest { + val directory = createTempDirectory("preferences") + val tempPreferences = directory + .resolve("preferences.txt") + .createFile() + .toFile() + + // Set system properties for testing + System.setProperty("processing.app.preferences.file", tempPreferences.absolutePath) + System.setProperty("processing.app.preferences.debounce", "0") + System.setProperty("processing.app.watchfile.forced", "true") + + val newValue = (0..Int.MAX_VALUE).random().toString() + val testKey = "test.preferences.reactivity" + + setContent { + PreferencesProvider { + val preferences = LocalPreferences.current + Text(preferences[testKey] ?: "default", modifier = Modifier.testTag("text")) + + Button(onClick = { + preferences[testKey] = newValue + }, modifier = Modifier.testTag("button")) { + Text("Change") + } + } + } + + onNodeWithTag("text").assertTextEquals("default") + onNodeWithTag("button").performClick() + onNodeWithTag("text").assertTextEquals(newValue) + + val preferences = Properties() + preferences.load(tempPreferences.inputStream().reader(Charsets.UTF_8)) + + // Check if the preference was saved to file + assert(preferences[testKey] == newValue) + + + val nextValue = (0..Int.MAX_VALUE).random().toString() + // Overwrite the file to see if the UI updates + tempPreferences.writeText("$testKey=${nextValue}") + + onNodeWithTag("text").assertTextEquals(nextValue) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8e7ad44a7a..6c8c5262cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ plugins { kotlin("jvm") version libs.versions.kotlin apply false - alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.jetbrainsCompose) apply false diff --git a/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf b/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 0000000000..0408641c61 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Bold.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt b/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt new file mode 100644 index 0000000000..6a314848b3 --- /dev/null +++ b/build/shared/lib/fonts/SpaceGrotesk-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/build/shared/lib/fonts/SpaceGrotesk-Light.ttf b/build/shared/lib/fonts/SpaceGrotesk-Light.ttf new file mode 100644 index 0000000000..d41bcccd86 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Light.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf b/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf new file mode 100644 index 0000000000..7d44b663b9 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Medium.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 0000000000..981bcf5b2c Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-Regular.ttf differ diff --git a/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf new file mode 100644 index 0000000000..e7e02e51e4 Binary files /dev/null and b/build/shared/lib/fonts/SpaceGrotesk-SemiBold.ttf differ diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index 19a5c9f866..85241406bb 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -238,6 +238,7 @@ preferences.launch_programs_in = Launch programs in preferences.launch_programs_in.mode = mode preferences.file = More preferences can be edited directly in the file: preferences.file.hint = (Edit only when Processing is not running.) +preferences.other = Other Preferences (advanced users) # Sketchbook Location (Frame) sketchbook_location = Select new sketchbook folder @@ -627,6 +628,24 @@ update_check = Update update_check.updates_available.core = A new version of Processing is available,\nwould you like to visit the Processing download page? update_check.updates_available.contributions = There are updates available for some of the installed contributions,\nwould you like to open the the Contribution Manager now? + +# --------------------------------------- +# Welcome +welcome.intro.title = Welcome to Processing +welcome.intro.message = A flexible software sketchbook and a language for learning how to code. +welcome.intro.suggestion = Is it your first time using Processing? Try one of the examples on the right. +welcome.action.examples = More examples +welcome.action.tutorials = Tutorials +welcome.action.startup = Show this window at startup +welcome.action.go = Let's go! + +# --------------------------------------- +# Beta +beta.window.title = Welcome to Beta +beta.title = Welcome to the Processing Beta +beta.message = Thank you for trying out the new version of Processing. We're very grateful!\n\nPlease report any bugs on the forums. +beta.button = Got it! + # --------------------------------------- # Beta beta.window.title = Welcome to Beta diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8f7211b131..6708e269dc 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -36,7 +36,7 @@ dependencies { } mavenPublishing{ - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) signAllPublications() pom{ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 050502f4ca..a2a3edacc0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] -kotlin = "2.0.20" -compose-plugin = "1.7.1" +kotlin = "2.2.20" +compose-plugin = "1.9.1" jogl = "2.5.0" antlr = "4.13.2" jupiter = "5.12.0" +markdown = "0.37.0" [libraries] jogl = { module = "org.jogamp.jogl:jogl-all-main", version.ref = "jogl" } @@ -31,14 +32,14 @@ antlr4Runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr" } composeGradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-plugin" } kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlinComposePlugin = { module = "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin", version.ref = "kotlin" } -markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m2", version = "0.31.0" } -markdownJVM = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version = "0.31.0" } +markdown = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown" } +markdownJVM = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version.ref = "markdown" } clikt = { module = "com.github.ajalt.clikt:clikt", version = "5.0.2" } kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.6.3" } +material3 = { module = "org.jetbrains.compose.material3:material3", version = "1.9.0" } [plugins] jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } -kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } download = { id = "de.undercouch.download", version = "5.6.0" } diff --git a/java/preprocessor/build.gradle.kts b/java/preprocessor/build.gradle.kts index d58fa3e7b9..6eb71a1242 100644 --- a/java/preprocessor/build.gradle.kts +++ b/java/preprocessor/build.gradle.kts @@ -47,7 +47,7 @@ publishing{ } mavenPublishing{ - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) // Only sign if signing is set up if(project.hasProperty("signing.keyId") || project.hasProperty("signingInMemoryKey")) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8f8cb74c7f..7eacb06877 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ include( "core", "core:examples", "app", + "app:utils", "java", "java:preprocessor", "java:libraries:dxf", @@ -11,5 +12,4 @@ include( "java:libraries:pdf", "java:libraries:serial", "java:libraries:svg", -) -include("app:utils") \ No newline at end of file +) \ No newline at end of file