diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index fe20b82da1..1d3bb27b4a 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -23,28 +23,29 @@ package processing.app; -import java.awt.*; -import java.awt.event.ActionListener; -import java.io.*; -import java.lang.reflect.InvocationTargetException; -import java.util.*; -import java.util.List; -import java.util.Map.Entry; - -import javax.swing.*; -import javax.swing.tree.DefaultMutableTreeNode; - import com.formdev.flatlaf.FlatDarkLaf; import com.formdev.flatlaf.FlatLaf; import com.formdev.flatlaf.FlatLightLaf; import processing.app.contrib.*; import processing.app.tools.Tool; import processing.app.ui.*; -import processing.app.ui.PreferencesKt; import processing.app.ui.Toolkit; -import processing.core.*; +import processing.core.PApplet; import processing.data.StringList; +import javax.swing.*; +import javax.swing.tree.DefaultMutableTreeNode; +import java.awt.*; +import java.awt.event.ActionListener; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.*; +import java.util.List; +import java.util.Map.Entry; + /** * The base class for the main processing application. * Primary role of this class is for platform identification and @@ -2185,11 +2186,7 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { -// if (preferencesFrame == null) { -// preferencesFrame = new PreferencesFrame(this); -// } -// preferencesFrame.showFrame(); - PreferencesKt.show(); + PDEPreferencesKt.show(); } diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c54cbbd817..c13309299f 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -8,8 +8,11 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream -import java.nio.file.* -import java.util.Properties +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchEvent +import java.util.* /* The ReactiveProperties class extends the standard Java Properties class @@ -28,6 +31,11 @@ class ReactiveProperties: Properties() { return snapshotStateMap[key] ?: super.getProperty(key) } + override fun remove(key: Any?): Any? { + snapshotStateMap.remove(key as String) + return super.remove(key) + } + operator fun get(key: String): String? = getProperty(key) operator fun set(key: String, value: String) { diff --git a/app/src/processing/app/ui/EditorFooter.java b/app/src/processing/app/ui/EditorFooter.java index bc09b2376a..94860a0abf 100644 --- a/app/src/processing/app/ui/EditorFooter.java +++ b/app/src/processing/app/ui/EditorFooter.java @@ -22,15 +22,14 @@ package processing.app.ui; -import java.awt.CardLayout; -import java.awt.Color; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.Font; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.Image; -import java.awt.datatransfer.Clipboard; +import processing.app.Base; +import processing.app.Mode; +import processing.app.Sketch; +import processing.app.contrib.ContributionManager; +import processing.data.StringDict; + +import javax.swing.*; +import java.awt.*; import java.awt.datatransfer.StringSelection; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -39,14 +38,6 @@ import java.util.ArrayList; import java.util.List; -import javax.swing.*; - -import processing.app.Base; -import processing.app.Mode; -import processing.app.Sketch; -import processing.app.contrib.ContributionManager; -import processing.data.StringDict; - /** * Console/error/whatever tabs at the bottom of the editor window. @@ -118,6 +109,18 @@ public void mousePressed(MouseEvent e) { Base.DEBUG = !Base.DEBUG; editor.updateDevelopMenu(); } + copyDebugInformationToClipboard(); + } + }); + + tabBar.add(version); + + add(tabBar); + + updateTheme(); + } + + public static void copyDebugInformationToClipboard() { var debugInformation = String.join("\n", "Version: " + Base.getVersionName(), "Revision: " + Base.getRevision(), @@ -127,18 +130,12 @@ public void mousePressed(MouseEvent e) { var stringSelection = new StringSelection(debugInformation); var clipboard = java.awt.Toolkit.getDefaultToolkit().getSystemClipboard(); clipboard.setContents(stringSelection, null); - } - }); - - tabBar.add(version); - - add(tabBar); - - updateTheme(); - } + } - /** Add a panel with no icon. */ + /** + * Add a panel with no icon. + */ public void addPanel(Component comp, String name) { addPanel(comp, name, null); } diff --git a/app/src/processing/app/ui/PDEPreferences.kt b/app/src/processing/app/ui/PDEPreferences.kt new file mode 100644 index 0000000000..ac5bf2609b --- /dev/null +++ b/app/src/processing/app/ui/PDEPreferences.kt @@ -0,0 +1,762 @@ +package processing.app.ui + +import androidx.compose.animation.* +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOutBounce +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.application +import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography +import processing.app.LocalPreferences +import processing.app.ReactiveProperties +import processing.app.ui.PDEPreferences.Companion.preferences +import processing.app.ui.preferences.* +import processing.app.ui.theme.* +import java.awt.Dimension +import java.awt.event.WindowEvent +import java.awt.event.WindowListener +import javax.swing.SwingUtilities +import javax.swing.WindowConstants + + +fun show() { + SwingUtilities.invokeLater { + PDESwingWindow( + titleKey = "preferences", + fullWindowContent = true, + size = Dimension(850, 600), + minSize = Dimension(700, 500), + ) { + PDETheme { + preferences() + } + } + } +} + +class PDEPreferences { + companion object{ + private val panes: PDEPreferencePanes = mutableStateMapOf() + + /** + * Registers a new preference in the preferences' system. + * If the preference's pane does not exist, it will be created. + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * pane = somePreferencePane, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + * + * @param preferences The preference to register. + */ + fun register(vararg preferences: PDEPreference) { + if (preferences.map { it.pane }.toSet().size != 1) { + throw IllegalArgumentException("All preferences must belong to the same pane") + } + val pane = preferences.first().pane + + val group = mutableStateListOf() + group.addAll(preferences) + + val groups = panes[pane] as? SnapshotStateList ?: mutableStateListOf() + groups.add(group) + panes[pane] = groups + } + + /** + * Static initializer to register default preference panes. + */ + init{ + General.register() + Interface.register() + Coding.register() + Sketches.register() + Other.register(panes) + } + + /** + * Composable function to display the preferences UI. + */ + @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) + @Composable + fun preferences() { + val locale = LocalLocale.current + var preferencesQuery by remember { mutableStateOf("") } + + /** + * Filter panes based on the search query. + */ + val panesQuierried = remember(preferencesQuery, panes) { + if (preferencesQuery.isBlank()) { + panes.toMutableMap() + } else { + panes.entries.associate { (pane, preferences) -> + val matching = preferences.map { group -> + group.filter { preference -> + val description = locale[preference.descriptionKey] + when { + preference.key == "other" -> true + preference.key.contains(preferencesQuery, ignoreCase = true) -> true + description.contains(preferencesQuery, ignoreCase = true) -> true + else -> false + } + } + } + pane to matching + }.toMutableMap() + } + } + + /** + * Sort panes based on their 'after' property and name. + */ + val panesSorted = remember(panesQuierried) { + panesQuierried.keys.sortedWith { a, b -> + when { + a === b -> 0 + a.after == b -> 1 + b.after == a -> -1 + a.after == null && b.after != null -> -1 + b.after == null && a.after != null -> 1 + else -> a.nameKey.compareTo(b.nameKey) + } + } + } + + + /** + * Pre-select a pane that has at least one preference to show + * Also reset the selection when the query changes + * */ + var selected by remember(panesQuierried) { + mutableStateOf(panesSorted.firstOrNull() { panesQuierried[it].isNotEmpty() }) + } + + /** + * Swapping primary and tertiary colors for the preferences window, probably should do that program-wide + */ + val originalScheme = MaterialTheme.colorScheme + MaterialTheme( + colorScheme = originalScheme.copy( + primary = originalScheme.tertiary, + onPrimary = originalScheme.onTertiary, + primaryContainer = originalScheme.tertiaryContainer, + onPrimaryContainer = originalScheme.onTertiaryContainer, + + tertiary = originalScheme.primary, + onTertiary = originalScheme.onPrimary, + tertiaryContainer = originalScheme.primaryContainer, + onTertiaryContainer = originalScheme.onPrimaryContainer, + ) + ) { + CapturePreferences { + Column { + /** + * Header + */ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 36.dp, top = 48.dp, end = 24.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = locale["preferences"], + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), + ) + Text( + text = locale["preferences.description"], + style = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.width(96.dp)) + SearchBar( + modifier = Modifier + .widthIn(max = 250.dp), + inputField = { + SearchBarDefaults.InputField( + query = preferencesQuery, + onQueryChange = { + preferencesQuery = it + }, + onSearch = { + + }, + trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + ) { + + } + } + HorizontalDivider() + Box { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + /** + * Sidebar + */ + Column( + modifier = Modifier + .width(IntrinsicSize.Min) + .padding(30.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + + for (pane in panesSorted) { + val shape = RoundedCornerShape(12.dp) + val isSelected = selected == pane + TextButton( + onClick = { + selected = pane + }, + enabled = panesQuierried[pane].isNotEmpty(), + colors = if (isSelected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + shape = shape + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + pane.icon() + Text(locale[pane.nameKey]) + } + } + } + } + + /** + * Content Area + */ + AnimatedContent( + targetState = selected, + transitionSpec = { + fadeIn( + animationSpec = tween(300) + ) togetherWith fadeOut( + animationSpec = tween(300) + ) + } + ) { selected -> + if (selected == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(30.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = locale["preferences.no_results"], + style = MaterialTheme.typography.bodyMedium + ) + } + return@AnimatedContent + } + + val groups = panesQuierried[selected] ?: emptyList() + selected.showPane(groups) + } + } + /** + * Unconfirmed changes banner + */ + Column( + modifier = Modifier.align(Alignment.BottomEnd) + ) { + val modifiable = LocalModifiablePreferences.current + val wiggle = remember { Animatable(0f) } + if (modifiable.lastCloseAttempt != null) { + LaunchedEffect(modifiable.lastCloseAttempt) { + wiggle.animateTo( + targetValue = 50f, + animationSpec = tween(100, easing = EaseOutBounce) + ) + wiggle.animateTo( + targetValue = -50f, + animationSpec = tween(100, easing = EaseOutBounce) + ) + wiggle.animateTo( + targetValue = 0f, + animationSpec = tween(300, easing = EaseOutBounce) + ) + } + } + AnimatedVisibility( + visible = modifiable.isModified, + enter = fadeIn( + animationSpec = tween(300) + ) + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(300), + ), + exit = fadeOut( + animationSpec = tween(300) + ) + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(300), + ), + modifier = Modifier + .graphicsLayer { + translationX = wiggle.value + } + ) { + val shape = RoundedCornerShape(8.dp) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier + .padding(24.dp) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + shape + ), + ) { + Row( + modifier = Modifier + .padding(16.dp, 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(locale["preferences.unconfirmed_changes"]) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + onClick = { + modifiable.reset() + }, + shape = shape + ) { + Text(locale["preferences.reset_changes"]) + } + Button( + onClick = { + modifiable.apply() + }, + shape = shape + ) { + Text(locale["preferences.apply_changes"]) + } + } + } + } + } + } + } + } + } + } + } + + /** + * Main function to run the preferences window standalone for testing & development. + */ + @JvmStatic + fun main(args: Array) { + application { + PDEComposeWindow( + titleKey = "preferences", + fullWindowContent = true, + size = DpSize(850.dp, 600.dp), + minSize = DpSize(850.dp, 600.dp), + ) { + PDETheme(darkTheme = true) { + preferences() + } + } + PDEComposeWindow( + titleKey = "preferences", + fullWindowContent = true, + size = DpSize(850.dp, 600.dp), + minSize = DpSize(850.dp, 600.dp), + ) { + PDETheme(darkTheme = false) { + preferences() + } + } + } + } + } +} + + +private data class ModifiablePreference( + val lastCloseAttempt: Long? = null, + val isModified: Boolean, + val apply: () -> Unit, + val reset: () -> Unit, +) + +private val LocalModifiablePreferences = + compositionLocalOf { ModifiablePreference(null, false, { }, {}) } + +/** + * Composable function that provides a modifiable copy of the current preferences. + * This allows for temporary changes to preferences that can be reset or applied later. + * + * @param content The composable content that will have access to the modifiable preferences. + */ +@Composable +private fun CapturePreferences(content: @Composable () -> Unit) { + val prefs = LocalPreferences.current + + var lastCloseAttempt by remember { mutableStateOf(null) } + val modified = remember { + ReactiveProperties().apply { + prefs.entries.forEach { (key, value) -> + setProperty(key as String, value as String) + } + } + } + val isModified = remember( + prefs, + // TODO: Learn how to modify preferences so listening to the object is enough + prefs.snapshotStateMap.toMap(), + modified, + modified.snapshotStateMap.toMap(), + ) { + prefs.entries.any { (key, value) -> + modified[key] != value + } + } + if (isModified) { + val window = LocalWindow.current + DisposableEffect(window) { + val operation = window.defaultCloseOperation + window.defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE + window.rootPane.putClientProperty("Window.documentModified", true); + val listener = object : WindowListener { + override fun windowOpened(e: WindowEvent?) {} + override fun windowClosing(e: WindowEvent?) { + lastCloseAttempt = System.currentTimeMillis() + } + + override fun windowClosed(e: WindowEvent?) {} + override fun windowIconified(e: WindowEvent?) {} + override fun windowDeiconified(e: WindowEvent?) {} + override fun windowActivated(e: WindowEvent?) {} + override fun windowDeactivated(e: WindowEvent?) {} + + } + window.addWindowListener(listener) + onDispose { + window.removeWindowListener(listener) + window.defaultCloseOperation = operation + window.rootPane.putClientProperty("Window.documentModified", false); + } + } + } + + val apply = { + modified.entries.forEach { (key, value) -> + prefs.setProperty(key as String, (value ?: "") as String) + } + } + val reset = { + modified.entries.forEach { (key, value) -> + modified.setProperty(key as String, prefs[key] ?: "") + } + } + val state = ModifiablePreference( + isModified = isModified, + apply = apply, + lastCloseAttempt = lastCloseAttempt, + reset = reset + ) + + CompositionLocalProvider( + LocalPreferences provides modified, + LocalModifiablePreferences provides state + ) { + content() + } +} + +typealias PDEPreferencePanes = MutableMap +typealias PDEPreferenceGroups = List +typealias PDEPreferenceGroup = List +typealias PDEPreferenceControl = @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit + +/** + * Data class representing a pane of preferences. + */ +data class PDEPreferencePane( + /** + * The name key of this pane from the Processing locale. + */ + val nameKey: String, + /** + * The icon representing this pane. + */ + val icon: @Composable () -> Unit, + /** + * The pane that comes before this one in the list. + */ + val after: PDEPreferencePane? = null, +) + +/** + * Composable function to display the contents of a preference pane. + */ +@Composable +fun PDEPreferencePane.showPane(groups: PDEPreferenceGroups) { + Box { + val locale = LocalLocale.current + val state = rememberLazyListState() + LazyColumn( + state = state, + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(top = 30.dp, end = 30.dp, bottom = 30.dp) + ) { + item { + Text( + text = locale[nameKey], + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium), + ) + } + items(groups) { group -> + Card( + modifier = Modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ), + ) { + group.forEachIndexed { index, preference -> + preference.showControl() + if (index != group.lastIndex) { + HorizontalDivider() + } + } + + } + } + item { + val prefs = LocalPreferences.current + TextButton( + onClick = { + groups.forEach { group -> + group.forEach { pref -> + prefs.remove(pref.key) + } + } + } + ) { + Text( + text = locale["preferences.reset"], + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(12.dp) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(state) + ) + } +} + +/** + * Data class representing a single preference in the preferences' system. + * + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * group = somePreferenceGroup, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + */ +data class PDEPreference( + /** + * The key in the preferences file used to store this preference. + */ + val key: String, + /** + * The key for the description of this preference, used for localization. + */ + val descriptionKey: String, + + /** + * The key for the label of this preference, used for localization. + * If null, the label will not be shown. + */ + val labelKey: String? = null, + /** + * The group this preference belongs to. + */ + val pane: PDEPreferencePane, + /** + * A Composable function that defines the control used to modify this preference. + * It takes the current preference value and a function to update the preference. + */ + val control: PDEPreferenceControl = { preference, updatePreference -> }, + + /** + * If true, no padding will be applied around this preference's UI. + */ + val noPadding: Boolean = false, + /** + * If true, the title will be omitted from this preference's UI. + */ + val noTitle: Boolean = false, +) + +/** + * Extension function to check if a list of preference groups is not empty. + */ +fun PDEPreferenceGroups?.isNotEmpty(): Boolean { + if (this == null) return false + for (group in this) { + if (group.isNotEmpty()) return true + } + return false +} + +/** + * Composable function to display the preference's description and control. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PDEPreference.showControl() { + val locale = LocalLocale.current + val prefs = LocalPreferences.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!noTitle) { + Column( + modifier = Modifier + .weight(1f) + + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(horizontal = 20.dp) + ) { + Text( + text = locale[descriptionKey], + style = MaterialTheme.typography.bodyMedium + ) + if (labelKey != null && locale.containsKey(labelKey)) { + Card { + Text( + text = locale[labelKey], + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(8.dp, 4.dp) + ) + } + } + } + if (locale.containsKey("$descriptionKey.tip")) { + Markdown( + content = locale["$descriptionKey.tip"], + colors = markdownColor( + text = MaterialTheme.colorScheme.onSurfaceVariant, + ), + typography = markdownTypography( + text = MaterialTheme.typography.bodySmall, + paragraph = MaterialTheme.typography.bodySmall, + textLink = TextLinkStyles( + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ).toSpanStyle() + ) + ), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 4.dp) + ) + } + } + } + val show = @Composable { + control(prefs[key]) { newValue -> + prefs[key] = newValue + } + } + + if (noPadding) { + show() + } else { + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + ) { + show() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt deleted file mode 100644 index 12e7c25ce4..0000000000 --- a/app/src/processing/app/ui/Preferences.kt +++ /dev/null @@ -1,323 +0,0 @@ -package processing.app.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.debounce -import processing.app.LocalPreferences -import processing.app.ui.PDEPreferences.Companion.preferences -import processing.app.ui.preferences.General -import processing.app.ui.preferences.Interface -import processing.app.ui.preferences.Other -import processing.app.ui.theme.LocalLocale -import processing.app.ui.theme.PDESwingWindow -import processing.app.ui.theme.PDETheme -import java.awt.Dimension -import javax.swing.SwingUtilities - -val LocalPreferenceGroups = compositionLocalOf>> { - error("No Preference Groups Set") -} - -class PDEPreferences { - companion object{ - val groups = mutableStateMapOf>() - fun register(preference: PDEPreference) { - val list = groups[preference.group]?.toMutableList() ?: mutableListOf() - list.add(preference) - groups[preference.group] = list - } - init{ - General.register() - Interface.register() - Other.register() - } - - /** - * Composable function to display the preferences UI. - */ - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun preferences(){ - var visible by remember { mutableStateOf(groups) } - val sortedGroups = remember { - val keys = visible.keys - keys.toSortedSet { - a, b -> - when { - a.after == b -> 1 - b.after == a -> -1 - else -> a.name.compareTo(b.name) - } - } - } - var selected by remember { mutableStateOf(sortedGroups.first()) } - CompositionLocalProvider( - LocalPreferenceGroups provides visible - ) { - Row { - NavigationRail( - header = { - Text( - "Settings", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(top = 42.dp) - ) - }, - modifier = Modifier - .defaultMinSize(minWidth = 200.dp) - ) { - - for (group in sortedGroups) { - NavigationRailItem( - selected = selected == group, - enabled = visible.keys.contains(group), - onClick = { - selected = group - }, - icon = { - group.icon() - }, - label = { - Text(group.name) - } - ) - } - } - Box(modifier = Modifier.padding(top = 42.dp)) { - Column(modifier = Modifier - .fillMaxSize() - ) { - var query by remember { mutableStateOf("") } - val locale = LocalLocale.current - LaunchedEffect(query){ - - snapshotFlow { query } - .debounce(100) - .collect{ - if(it.isBlank()){ - visible = groups - return@collect - } - val filtered = mutableStateMapOf>() - for((group, preferences) in groups){ - val matching = preferences.filter { preference -> - if(preference.key == "other"){ - return@filter true - } - if(preference.key.contains(it, ignoreCase = true)){ - return@filter true - } - val description = locale[preference.descriptionKey] - description.contains(it, ignoreCase = true) - } - if(matching.isNotEmpty()){ - filtered[group] = matching - } - } - visible = filtered - } - - } - SearchBar( - inputField = { - SearchBarDefaults.InputField( - query = query, - onQueryChange = { - query = it - }, - onSearch = { - - }, - expanded = false, - onExpandedChange = { }, - placeholder = { Text("Search") } - ) - }, - expanded = false, - onExpandedChange = {}, - modifier = Modifier.align(Alignment.End).padding(16.dp) - ) { - - } - - val preferences = visible[selected] ?: emptyList() - LazyColumn( - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - items(preferences){ preference -> - preference.showControl() - } - } - } - } - } - } - } - - - - @JvmStatic - fun main(args: Array) { - application { - Window(onCloseRequest = ::exitApplication){ - remember{ - window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) - window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - } - PDETheme(darkTheme = true) { - preferences() - } - } - Window(onCloseRequest = ::exitApplication){ - remember{ - window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) - window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - } - PDETheme(darkTheme = false) { - preferences() - } - } - } - } - } -} - -/** - * Data class representing a single preference in the preferences system. - * - * Usage: - * ``` - * PDEPreferences.register( - * PDEPreference( - * key = "preference.key", - * descriptionKey = "preference.description", - * group = somePreferenceGroup, - * control = { preference, updatePreference -> - * // Composable UI to modify the preference - * } - * ) - * ) - * ``` - */ -data class PDEPreference( - /** - * The key in the preferences file used to store this preference. - */ - val key: String, - /** - * The key for the description of this preference, used for localization. - */ - val descriptionKey: String, - /** - * The group this preference belongs to. - */ - val group: PDEPreferenceGroup, - /** - * A Composable function that defines the control used to modify this preference. - * It takes the current preference value and a function to update the preference. - */ - val control: @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit = { preference, updatePreference -> }, - - /** - * If true, no padding will be applied around this preference's UI. - */ - val noPadding: Boolean = false, -) - -/** - * Composable function to display the preference's description and control. - */ -@Composable -private fun PDEPreference.showControl() { - val locale = LocalLocale.current - val prefs = LocalPreferences.current - Text( - text = locale[descriptionKey], - modifier = Modifier.padding(horizontal = 20.dp), - style = MaterialTheme.typography.titleMedium - ) - val show = @Composable { - control(prefs[key]) { newValue -> - prefs[key] = newValue - } - } - - if(noPadding){ - show() - }else{ - Box(modifier = Modifier.padding(horizontal = 20.dp)) { - show() - } - } -} - -/** - * Data class representing a group of preferences. - */ -data class PDEPreferenceGroup( - /** - * The name of this group. - */ - val name: String, - /** - * The icon representing this group. - */ - val icon: @Composable () -> Unit, - /** - * The group that comes before this one in the list. - */ - val after: PDEPreferenceGroup? = null, -) - -fun show(){ - SwingUtilities.invokeLater { - PDESwingWindow( - titleKey = "preferences", - fullWindowContent = true, - size = Dimension(800, 600) - ) { - PDETheme { - preferences() - } - } - } -} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Coding.kt b/app/src/processing/app/ui/preferences/Coding.kt new file mode 100644 index 0000000000..21b87ad5a7 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Coding.kt @@ -0,0 +1,90 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.theme.LocalLocale + +class Coding { + companion object { + val coding = PDEPreferencePane( + nameKey = "preferences.pane.editor", + icon = { Icon(Icons.Default.Code, contentDescription = null) }, + after = interfaceAndFonts, + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "pdex.errorCheckEnabled", + descriptionKey = "preferences.continuously_check", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.warningsEnabled", + descriptionKey = "preferences.show_warnings", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ), + PDEPreference( + key = "pdex.completion", + descriptionKey = "preferences.code_completion", + pane = coding, + noTitle = true, + control = { preference, setPreference -> + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val locale = LocalLocale.current + Text( + text = locale["preferences.code_completion"] + " Ctrl-" + locale["preferences.cmd_space"], + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + } + ), + PDEPreference( + key = "pdex.suggest.imports", + descriptionKey = "preferences.suggest_imports", + pane = coding, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { setPreference(it.toString()) } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt index 5f56187f46..a8bd559033 100644 --- a/app/src/processing/app/ui/preferences/General.kt +++ b/app/src/processing/app/ui/preferences/General.kt @@ -1,32 +1,35 @@ package processing.app.ui.preferences +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextField +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import processing.app.Preferences import processing.app.SketchName +import processing.app.ui.EditorFooter.copyDebugInformationToClipboard import processing.app.ui.PDEPreference -import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences +import processing.app.ui.theme.LocalLocale +import processing.awt.ShimAWT.selectFolder +import java.io.File class General { companion object{ - val general = PDEPreferenceGroup( - name = "General", + val general = PDEPreferencePane( + nameKey = "preferences.pane.general", icon = { - Icon(Icons.Default.Settings, contentDescription = "A settings icon") + Icon(Icons.Default.Settings, contentDescription = "General Preferences") } ) @@ -35,62 +38,86 @@ class General { PDEPreference( key = "sketchbook.path.four", descriptionKey = "preferences.sketchbook_location", - group = general, + pane = general, + noTitle = true, control = { preference, updatePreference -> - Row ( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - ) { - TextField( - value = preference ?: "", - onValueChange = { - updatePreference(it) - } - ) - Button( - onClick = { - - } - ) { - Text("Browse") + val locale = LocalLocale.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(locale["preferences.sketchbook_location"]) }, + value = preference ?: "", + onValueChange = { + updatePreference(it) + }, + trailingIcon = { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier + .clickable { + selectFolder( + locale["preferences.sketchbook_location.popup"], + File(preference ?: "") + ) { selectedFile: File? -> + if (selectedFile != null) { + updatePreference(selectedFile.absolutePath) + } + } + } + ) } - } + ) } - ) - ) - PDEPreferences.register( + ), PDEPreference( key = "sketch.name.approach", descriptionKey = "preferences.sketch_naming", - group = general, + pane = general, control = { preference, updatePreference -> - Row{ - for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + Column { + val options = 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), - ) + ) + options.toList().chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { option -> + InputChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + ) + } + } } } } + ), + PDEPreference( + key = "editor.sync_folder_and_filename", + labelKey = "preferences.experimental", + descriptionKey = "preferences.sync_folder_and_filename", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } ) ) PDEPreferences.register( PDEPreference( key = "update.check", - descriptionKey = "preferences.check_for_updates_on_startup", - group = general, + descriptionKey = "preferences.update_check", + pane = general, control = { preference, updatePreference -> Switch( checked = preference.toBoolean(), @@ -103,9 +130,9 @@ class General { ) PDEPreferences.register( PDEPreference( - key = "welcome.show", - descriptionKey = "preferences.show_welcome_screen_on_startup", - group = general, + key = "welcome.four.show", + descriptionKey = "preferences.show_welcome_screen", + pane = general, control = { preference, updatePreference -> Switch( checked = preference.toBoolean(), @@ -116,6 +143,33 @@ class General { } ) ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.diagnostics", + pane = general, + control = { preference, updatePreference -> + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + Button(onClick = { + copyDebugInformationToClipboard() + copied = true + + }) { + if (!copied) { + Text(LocalLocale.current["preferences.diagnostics.button"]) + } else { + Text(LocalLocale.current["preferences.diagnostics.button.copied"]) + } + } + } + ) + ) } } } \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt index e9747a037d..be0ee833c0 100644 --- a/app/src/processing/app/ui/preferences/Interface.kt +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -1,95 +1,180 @@ package processing.app.ui.preferences -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.TextIncrease -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.filled.Brush +import androidx.compose.material.icons.filled.Language +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import processing.app.Language +import processing.app.LocalPreferences import processing.app.Preferences import processing.app.ui.PDEPreference -import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferencePane import processing.app.ui.PDEPreferences import processing.app.ui.Toolkit import processing.app.ui.preferences.General.Companion.general import processing.app.ui.theme.LocalLocale -import java.util.Locale +import java.util.* class Interface { companion object{ - val interfaceAndFonts = PDEPreferenceGroup( - name = "Interface", + val interfaceAndFonts = PDEPreferencePane( + nameKey = "preferences.pane.interface", icon = { - Icon(Icons.Default.TextIncrease, contentDescription = "Interface") + Icon(Icons.Default.Brush, contentDescription = "Interface") }, after = general ) + @OptIn(ExperimentalMaterial3Api::class) fun register() { - PDEPreferences.register(PDEPreference( - key = "language", - descriptionKey = "preferences.language", - group = interfaceAndFonts, - control = { preference, updatePreference -> - val locale = LocalLocale.current - val showOptions = remember { mutableStateOf(false) } - TextField( - value = locale.locale.displayName, - readOnly = true, - onValueChange = { }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = "Select Font Family", - modifier = Modifier - .clickable{ - showOptions.value = true - } + PDEPreferences.register( + PDEPreference( + key = "language", + descriptionKey = "preferences.language", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + val showOptions = remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showOptions.value = true + }, + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Language, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(locale.locale.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + languagesDropdown(showOptions) + } + ), + PDEPreference( + key = "editor.input_method_support", + descriptionKey = "preferences.enable_complex_text", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val enabled = preference?.toBoolean() ?: true + Switch( + checked = enabled, + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "editor.theme", + descriptionKey = "preferences.editor.theme", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InputChip( + selected = (preference ?: "") == "", + onClick = { + updatePreference("") + }, + label = { + Text(locale["preferences.editor.theme.system"]) + } + ) + InputChip( + selected = preference == "dark", + onClick = { + updatePreference("dark") + }, + label = { + Text(locale["preferences.editor.theme.dark"]) + } + ) + InputChip( + selected = preference == "light", + onClick = { + updatePreference("light") + }, + label = { + Text(locale["preferences.editor.theme.light"]) + } ) } - ) - languagesDropdown(showOptions) - } - )) + } + ), + PDEPreference( + key = "editor.zoom", + descriptionKey = "preferences.interface_scale", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val range = 100f..300f + + val prefs = LocalPreferences.current + var currentZoom by remember(preference) { + mutableStateOf( + preference + ?.replace("%", "") + ?.toFloatOrNull() + ?: range.start + ) + } + val automatic = currentZoom == range.start + val zoomPerc = "${currentZoom.toInt()}%" + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .widthIn(max = 200.dp) + ) { + Text( + text = if (automatic) "Auto" else zoomPerc, + ) + Slider( + value = currentZoom, + onValueChange = { + currentZoom = it + }, + onValueChangeFinished = { + prefs["editor.zoom.auto"] = automatic + updatePreference(zoomPerc) + }, + valueRange = range, + steps = 3 + ) + } + } + } + ) + ) PDEPreferences.register( PDEPreference( key = "editor.font.family", descriptionKey = "preferences.editor_and_console_font", - group = interfaceAndFonts, + pane = interfaceAndFonts, control = { preference, updatePreference -> var showOptions by remember { mutableStateOf(false) } - val families = if(Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") - TextField( - value = preference ?: families.firstOrNull().orEmpty(), - readOnly = true, - onValueChange = { updatePreference (it) }, - trailingIcon = { - Icon( - Icons.Default.ArrowDropDown, - contentDescription = "Select Font Family", - modifier = Modifier - .clickable{ - showOptions = true - } - ) - } - ) + val families = + if (Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + OutlinedButton( + onClick = { + showOptions = true + }, + modifier = Modifier.width(200.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(preference ?: families.firstOrNull().orEmpty()) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } DropdownMenu( expanded = showOptions, onDismissRequest = { @@ -108,47 +193,51 @@ class Interface { } } - ) - ) - - PDEPreferences.register(PDEPreference( - key = "editor.font.size", - descriptionKey = "preferences.editor_font_size", - group = interfaceAndFonts, - control = { preference, updatePreference -> - Column { - Text( - text = "${preference ?: "12"} pt", - modifier = Modifier.width(120.dp) - ) - Slider( - value = (preference ?: "12").toFloat(), - onValueChange = { updatePreference(it.toInt().toString()) }, - valueRange = 10f..48f, - steps = 18, - ) + ), + PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18 + ) + } } - } - )) - PDEPreferences.register(PDEPreference( - key = "console.font.size", - descriptionKey = "preferences.console_font_size", - group = interfaceAndFonts, - control = { preference, updatePreference -> - Column { - Text( - text = "${preference ?: "12"} pt", - modifier = Modifier.width(120.dp) - ) - Slider( - value = (preference ?: "12").toFloat(), - onValueChange = { updatePreference(it.toInt().toString()) }, - valueRange = 10f..48f, - steps = 18, - ) + ), PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } } - } - )) + ) + ) } @Composable diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt index f5f65ea9c8..8544f76945 100644 --- a/app/src/processing/app/ui/preferences/Other.kt +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -1,70 +1,93 @@ package processing.app.ui.preferences -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Lightbulb import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import processing.app.LocalPreferences -import processing.app.ui.LocalPreferenceGroups import processing.app.ui.PDEPreference -import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferencePanes import processing.app.ui.PDEPreferences -import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.preferences.Sketches.Companion.sketches import processing.app.ui.theme.LocalLocale class Other { companion object{ - val other = PDEPreferenceGroup( - name = "Other", + val other = PDEPreferencePane( + nameKey = "preferences.pane.other", icon = { - Icon(Icons.Default.Map, contentDescription = "A map icon") + Icon(Icons.Default.Lightbulb, contentDescription = "Other Preferences") }, - after = interfaceAndFonts + after = sketches ) - fun register() { + + fun register(panes: PDEPreferencePanes) { PDEPreferences.register( PDEPreference( - key = "other", + key = "preferences.show_other", descriptionKey = "preferences.other", - group = other, - noPadding = true, - control = { _, _ -> - val prefs = LocalPreferences.current - val groups = LocalPreferenceGroups.current - val restPrefs = remember { - val keys = prefs.keys.mapNotNull { it as? String } - val existing = groups.values.flatten().map { it.key } - keys.filter { it !in existing }.sorted() + pane = other, + control = { preference, setPreference -> + val showOther = preference?.toBoolean() ?: false + Switch( + checked = showOther, + onCheckedChange = { + setPreference(it.toString()) + } + ) + if (!showOther) { + return@PDEPreference } + val prefs = LocalPreferences.current val locale = LocalLocale.current + DisposableEffect(Unit) { + // add all the other options to the same group as the current one + val group = + panes[other]?.find { group -> group.any { preference -> preference.key == "preferences.show_other" } } as? MutableList - for(prefKey in restPrefs){ - val value = prefs[prefKey] - Row ( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - ){ - Text( - text = locale[prefKey], - modifier = Modifier.align(Alignment.CenterVertically) + val existing = panes.values.flatten().flatten().map { preference -> preference.key } + val keys = prefs.keys.mapNotNull { it as? String }.filter { it !in existing }.sorted() + + for (prefKey in keys) { + val descriptionKey = "preferences.$prefKey" + val preference = PDEPreference( + key = prefKey, + descriptionKey = if (locale.containsKey(descriptionKey)) descriptionKey else prefKey, + pane = other, + control = { preference, updatePreference -> + if (preference?.toBooleanStrictOrNull() != null) { + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + return@PDEPreference + } + + OutlinedTextField( + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + } ) - TextField(value ?: "", onValueChange = { - prefs[prefKey] = it - }) + group?.add(preference) + } + onDispose { + group?.apply { + removeIf { it.key != "preferences.show_other" } + } } } - } ) ) diff --git a/app/src/processing/app/ui/preferences/Sketches.kt b/app/src/processing/app/ui/preferences/Sketches.kt new file mode 100644 index 0000000000..b3fef23cd0 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Sketches.kt @@ -0,0 +1,219 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Monitor +import androidx.compose.material3.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Coding.Companion.coding +import java.awt.GraphicsEnvironment +import javax.swing.JColorChooser + +class Sketches { + companion object { + val sketches = PDEPreferencePane( + nameKey = "preferences.pane.sketches", + icon = { Icon(Select_window, contentDescription = null) }, + after = coding, + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "run.display", + descriptionKey = "preferences.run_sketches_on_display", + pane = sketches, + control = { preference, setPreference -> + val ge = GraphicsEnvironment.getLocalGraphicsEnvironment() + val defaultDevice = ge.defaultScreenDevice + val devices = ge.screenDevices + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + devices.toList().chunked(2).forEach { devices -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + devices.forEachIndexed { index, device -> + val displayNum = (index + 1).toString() + OutlinedButton( + colors = if (preference == displayNum || (device == defaultDevice && preference == "-1")) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.outlinedButtonColors() + }, + shape = RoundedCornerShape(12.dp), + onClick = { + setPreference(if (device == defaultDevice) "-1" else displayNum) + } + ) { + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box { + Icon( + Icons.Default.Monitor, + modifier = Modifier.size(32.dp), + contentDescription = null + ) + Text( + text = displayNum, + modifier = Modifier + .align(Alignment.Center) + .offset(0.dp, (-2).dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = "${device.displayMode.width} x ${device.displayMode.height}", + style = MaterialTheme.typography.bodySmall, + ) + if (device == defaultDevice) { + Text( + text = "Default", + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(0.5f), + ) + } + } + } + } + } + } + } + } + ), + PDEPreference( + key = "run.options.memory", + descriptionKey = "preferences.increase_memory", + pane = sketches, + control = { preference, setPreference -> + Switch( + checked = preference?.toBoolean() ?: false, + onCheckedChange = { + setPreference(it.toString()) + } + ) + } + ), + PDEPreference( + key = "run.options.memory.maximum", + descriptionKey = "preferences.increase_max_memory", + pane = sketches, + control = { preference, setPreference -> + OutlinedTextField( + enabled = LocalPreferences.current["run.options.memory"]?.toBoolean() ?: false, + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + trailingIcon = { Text("MB") }, + onValueChange = { + setPreference(it) + } + ) + } + ), + PDEPreference( + key = "run.present.bgcolor", + descriptionKey = "preferences.background_color", + pane = sketches, + control = { preference, setPreference -> + val color = try { + java.awt.Color.decode(preference) + } catch (e: Exception) { + java.awt.Color.BLACK + } + Box( + modifier = Modifier + .size(64.dp) + .padding(4.dp) + .background( + color = Color(color.red, color.green, color.blue), + shape = RoundedCornerShape(4.dp) + ) + .clickable { + // TODO: Replace with Compose color picker when available + val newColor = JColorChooser.showDialog( + null, + "Choose Background Color", + color + ) ?: color + val hexColor = + String.format("#%02x%02x%02x", newColor.red, newColor.green, newColor.blue) + setPreference(hexColor) + } + ) + } + ) + ) + } + val Select_window: ImageVector + get() { + if (_Select_window != null) return _Select_window!! + + _Select_window = ImageVector.Builder( + name = "Select_window", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + path( + fill = SolidColor(Color(0xFF000000)) + ) { + moveTo(160f, 880f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(80f, 800f) + verticalLineToRelative(-360f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(160f, 360f) + horizontalLineToRelative(80f) + verticalLineToRelative(-200f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(320f, 80f) + horizontalLineToRelative(480f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(880f, 160f) + verticalLineToRelative(360f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(800f, 600f) + horizontalLineToRelative(-80f) + verticalLineToRelative(200f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(640f, 880f) + close() + moveToRelative(0f, -80f) + horizontalLineToRelative(480f) + verticalLineToRelative(-280f) + horizontalLineTo(160f) + close() + moveToRelative(560f, -280f) + horizontalLineToRelative(80f) + verticalLineToRelative(-280f) + horizontalLineTo(320f) + verticalLineToRelative(120f) + horizontalLineToRelative(320f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(720f, 440f) + close() + } + }.build() + + return _Select_window!! + } + + private var _Select_window: ImageVector? = null + } +} \ 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 c59c5025cd..e9c9bb7516 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import darkScheme import lightScheme +import processing.app.LocalPreferences import processing.app.PreferencesProvider /** @@ -52,8 +53,16 @@ fun PDETheme( ){ PreferencesProvider { LocaleProvider { + val preferences = LocalPreferences.current + val theme = when { + preferences["editor.theme"] == "dark" -> darkScheme + preferences["editor.theme"] == "light" -> lightScheme + darkTheme -> darkScheme + else -> lightScheme + + } MaterialTheme( - colorScheme = if(darkTheme) darkScheme else lightScheme, + colorScheme = theme, typography = PDETypography ){ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { diff --git a/build/shared/lib/languages/PDE.properties b/build/shared/lib/languages/PDE.properties index 8001796f59..9814dbd998 100644 --- a/build/shared/lib/languages/PDE.properties +++ b/build/shared/lib/languages/PDE.properties @@ -205,39 +205,91 @@ close.unsaved_changes = Save changes to %s? # Preferences (Frame) preferences = Preferences +preferences.description=Change how Processing works on your computer. These settings affect all Processing windows and stay the same even after you restart. +preferences.pane.general=General +preferences.pane.interface=Appearance +preferences.pane.editor=Code +preferences.pane.sketches=Sketches +preferences.pane.other=Advanced +preferences.new=New +preferences.reset=Reset to Defaults +preferences.reset_changes=Reset +preferences.unconfirmed_changes=You have unsaved changes! +preferences.apply_changes=Confirm Changes +preferences.experimental=Experimental +preferences.no_results=No results found +preferences.sync_folder_and_filename=Folder name matches sketch name +preferences.sync_folder_and_filename.tip=When enabled, renaming a sketch will also rename its folder to match the sketch name. [Learn more](https://discourse.processing.org/t/sketch-folder-and-sketch-name-syncing/15345) +preferences.show_welcome_screen=Show welcome screen at startup +preferences.diagnostics=Generate diagnostic report for support +preferences.diagnostics.tip=Copies information about your installation into your clipboard, useful for troubleshooting issues. +preferences.diagnostics.button=Generate Report +preferences.diagnostics.button.copied=Report copied to clipboard preferences.button.width = 80 preferences.restart_required = Restart Processing to apply changes preferences.sketchbook_location = Sketchbook folder preferences.sketchbook_location.popup = Sketchbook folder preferences.sketch_naming = Sketch name -preferences.language = Language: -preferences.editor_and_console_font = Editor and Console font: -preferences.editor_and_console_font.tip = Select the font used in the Editor and the Console.
Only monospaced (fixed-width) fonts may be used,
though the list may be imperfect. -preferences.editor_font_size = Editor font size: -preferences.console_font_size = Console font size: -preferences.interface_scale = Interface scale: +preferences.sketch_naming.tip=Choose how new sketches are named and numbered. +preferences.language=Language +preferences.editor_and_console_font=Editor and Console font +preferences.editor_and_console_font.tip=Installed Monospaced fonts will be displayed as options. +preferences.editor_font_size=Editor font size +preferences.console_font_size=Console font size +preferences.editor.theme=Theme +preferences.editor.theme.tip=Choose a color theme for windows except for the editor. +preferences.editor.theme.system=System +preferences.editor.theme.light=Light +preferences.editor.theme.dark=Dark +preferences.interface_theme=Interface theme +preferences.interface_scale=Interface scale +preferences.interface_scale.tip=Adjust the size of interface elements. preferences.interface_scale.auto = Automatic preferences.background_color = Background color when Presenting: -preferences.background_color.tip = Select the background color used when using Present.
Present is used to present a sketch in full-screen,
accessible from the Sketch menu. +preferences.background_color.tip=Select the background color used when using Present. Present is used to present a sketch in full-screen, accessible from the Sketch menu. preferences.use_smooth_text = Use smooth text in editor window preferences.enable_complex_text = Enable complex text input -preferences.enable_complex_text.tip = Using languages such as Chinese, Japanese, and Arabic
in the Editor window require additional features to be enabled. +preferences.enable_complex_text.tip=Using languages such as Chinese, Japanese, and Arabic in the Editor window require additional features to be enabled. preferences.continuously_check = Continuously check for errors preferences.show_warnings = Show warnings preferences.code_completion = Code completion with preferences.trigger_with = Trigger with preferences.cmd_space = space preferences.suggest_imports = Suggest import statements +preferences.increase_memory=Increase maximum available memory preferences.increase_max_memory = Increase maximum available memory to # preferences.delete_previous_folder_on_export = Delete previous folder on export preferences.check_for_updates_on_startup = Allow update checking (see FAQ for information shared) +preferences.update_check=Check for updates on startup +preferences.update_check.tip=No personal information is sent during this process. See the [FAQ](https://github.com/processing/processing4/wiki/FAQ#checking-for-updates) preferences.run_sketches_on_display = Run sketches on display -preferences.run_sketches_on_display.tip = Sets the display where sketches are initially placed.
As usual, if the sketch window is moved, it will re-open
at the same location, however when running in present
(full screen) mode, this display will always be used. +preferences.run_sketches_on_display.tip=Sets the display where sketches are initially placed. As usual, if the sketch window is moved, it will re-open at the same location, however when running in present (full screen) mode, this display will always be used. preferences.automatically_associate_pde_files = Automatically associate .pde files with Processing preferences.launch_programs_in = Launch programs in preferences.launch_programs_in.mode = mode preferences.file = More preferences can be edited directly in the file: preferences.file.hint = (Edit only when Processing is not running.) +preferences.other=Show experimental settings +preferences.other.tip=These settings are contained in the preferences.txt file and are not officially supported. They may be removed or changed without notice in future versions of Processing. +# Preferences (Experimental Pane) +# Keys from the comments of defaults.txt (Nov 2025) +preferences.contribution.backup.on_remove=Backup contributions when "Remove" button is pressed +preferences.contribution.backup.on_remove.tip=When enabled, a backup copy of the contribution will be created in your sketchbook "tools", "modes", "libraries", or "examples" folder when you remove it via the Contribution Manager. +preferences.contribution.backup.on_install=Backup contributions when installing a newer version +preferences.contribution.backup.on_install.tip=When enabled, a backup copy of the contribution will be created in your sketchbook "tools", "modes", "libraries", or "examples" folder when you install a newer version via the Contribution Manager. +preferences.recent.count=Number of recent sketches to show +preferences.chooser.files.native=Use native file chooser dialogs +preferences.theme.gradient.method=Gradient method for themes +preferences.theme.gradient.method.tip=Set to 'lab' to interpolate theme gradients using L*a*b* color space +preferences.platform.auto_file_type_associations=Automatically set file type associations (Windows only) +preferences.platform.auto_file_type_associations.tip=When enabled, Processing will attempt to set itself as the default application for .pde files on Windows systems. +preferences.editor.window.width.default=Default editor window width +preferences.editor.window.height.default=Default editor window height +preferences.editor.window.width.min=Minimum editor window width +preferences.editor.window.height.min=Minimum editor window height +preferences.editor.smooth=Enable antialiasing in the code editor +preferences.editor.caret.blink=Blink the caret +preferences.editor.caret.block=Use block caret # Sketchbook Location (Frame) sketchbook_location = Select new sketchbook folder diff --git a/core/src/processing/awt/ShimAWT.java b/core/src/processing/awt/ShimAWT.java index 901f359bb2..304b8dd2ac 100644 --- a/core/src/processing/awt/ShimAWT.java +++ b/core/src/processing/awt/ShimAWT.java @@ -1,34 +1,29 @@ package processing.awt; +import processing.core.PApplet; +import processing.core.PConstants; +import processing.core.PImage; + +import javax.imageio.*; +import javax.imageio.metadata.IIOInvalidTreeException; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; +import javax.swing.*; +import javax.swing.filechooser.FileSystemView; import java.awt.*; import java.awt.color.ColorSpace; +import java.awt.geom.AffineTransform; import java.awt.image.*; -import java.io.*; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; import java.util.Iterator; -import java.awt.geom.AffineTransform; import java.util.Map; - -import javax.imageio.IIOImage; -import javax.imageio.ImageIO; -import javax.imageio.ImageTypeSpecifier; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.metadata.IIOInvalidTreeException; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.metadata.IIOMetadataNode; -import javax.swing.ImageIcon; -import javax.swing.JFileChooser; -import javax.swing.UIManager; - -// used by desktopFile() method -import javax.swing.filechooser.FileSystemView; - -import processing.core.PApplet; -import processing.core.PConstants; -import processing.core.PImage; +import java.util.function.Consumer; /** @@ -809,41 +804,51 @@ static public void selectImpl(final String prompt, final Object callbackObject, final Frame parentFrame, final int mode) { - File selectedFile = null; + selectImpl(prompt, defaultSelection, parentFrame, mode, (selectedFile) -> + PApplet.selectCallback(selectedFile, callbackMethod, callbackObject) + ); + } - if (PApplet.useNativeSelect) { - FileDialog dialog = new FileDialog(parentFrame, prompt, mode); - if (defaultSelection != null) { - dialog.setDirectory(defaultSelection.getParent()); - dialog.setFile(defaultSelection.getName()); - } + static public void selectImpl(final String prompt, + final File defaultSelection, + final Frame parentFrame, + final int mode, + final Consumer callback) { + File selectedFile = null; + + if (PApplet.useNativeSelect) { + FileDialog dialog = new FileDialog(parentFrame, prompt, mode); + if (defaultSelection != null) { + dialog.setDirectory(defaultSelection.getParent()); + dialog.setFile(defaultSelection.getName()); + } - dialog.setVisible(true); - String directory = dialog.getDirectory(); - String filename = dialog.getFile(); - if (filename != null) { - selectedFile = new File(directory, filename); - } + dialog.setVisible(true); + String directory = dialog.getDirectory(); + String filename = dialog.getFile(); + if (filename != null) { + selectedFile = new File(directory, filename); + } - } else { - JFileChooser chooser = new JFileChooser(); - chooser.setDialogTitle(prompt); - if (defaultSelection != null) { - chooser.setSelectedFile(defaultSelection); - } + } else { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(prompt); + if (defaultSelection != null) { + chooser.setSelectedFile(defaultSelection); + } - int result = -1; - if (mode == FileDialog.SAVE) { - result = chooser.showSaveDialog(parentFrame); - } else if (mode == FileDialog.LOAD) { - result = chooser.showOpenDialog(parentFrame); - } - if (result == JFileChooser.APPROVE_OPTION) { - selectedFile = chooser.getSelectedFile(); - } + int result = -1; + if (mode == FileDialog.SAVE) { + result = chooser.showSaveDialog(parentFrame); + } else if (mode == FileDialog.LOAD) { + result = chooser.showOpenDialog(parentFrame); + } + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile = chooser.getSelectedFile(); + } + } + callback.accept(selectedFile); } - PApplet.selectCallback(selectedFile, callbackMethod, callbackObject); - } static public void selectFolder(final String prompt, @@ -854,6 +859,12 @@ static public void selectFolder(final String prompt, defaultSelection, callbackObject, null)); } + static public void selectFolder(final String prompt, + final File defaultSelection, + final Consumer callback) { + selectFolderImpl(prompt, defaultSelection, null, callback); + } + /* static public void selectFolder(final String prompt, @@ -886,6 +897,15 @@ static public void selectFolderImpl(final String prompt, final File defaultSelection, final Object callbackObject, final Frame parentFrame) { + selectFolderImpl(prompt, defaultSelection, parentFrame, (selectedFile) -> + PApplet.selectCallback(selectedFile, callbackMethod, callbackObject) + ); + } + + static public void selectFolderImpl(final String prompt, + final File defaultSelection, + final Frame parentFrame, + final Consumer callback) { File selectedFile = null; if (PApplet.platform == PConstants.MACOS && PApplet.useNativeSelect) { FileDialog fileDialog = @@ -914,7 +934,7 @@ static public void selectFolderImpl(final String prompt, selectedFile = fileChooser.getSelectedFile(); } } - PApplet.selectCallback(selectedFile, callbackMethod, callbackObject); + callback.accept(selectedFile); }