diff --git a/app/src/main/resources/languages/PDE.properties b/app/src/main/resources/languages/PDE.properties index 254a6c84f4..3c1ad2ab70 100644 --- a/app/src/main/resources/languages/PDE.properties +++ b/app/src/main/resources/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/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index ec22572d85..a083d139a0 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -2174,10 +2174,7 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { - if (preferencesFrame == null) { - preferencesFrame = new PreferencesFrame(this); - } - preferencesFrame.showFrame(); + PDEPreferencesKt.show(); } diff --git a/app/src/processing/app/Messages.kt b/app/src/processing/app/Messages.kt index fa9bc54a63..9b4ac7cd00 100644 --- a/app/src/processing/app/Messages.kt +++ b/app/src/processing/app/Messages.kt @@ -29,9 +29,15 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.FlatLightLaf +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import processing.app.ui.Toolkit import processing.app.ui.theme.PDETheme import java.awt.EventQueue +import java.awt.Dimension import java.awt.Frame import java.io.PrintWriter import java.io.StringWriter @@ -284,14 +290,65 @@ class Messages { } } } -fun main(){ + +@OptIn(ExperimentalComposeUiApi::class) +fun showDialog(title: String, content: @Composable (modifier: Modifier, dismiss: () -> Unit) -> Unit) { + ComposeDialog().apply { + isModal = true + setTitle(title) + size = Dimension(400, 400) + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + rootPane.putClientProperty("apple.awt.windowTitleVisible", false); + + + setContent { + PDETheme { + val density = LocalDensity.current + content(Modifier.onSizeChanged { + size = Dimension((it.width / density.density).toInt(), (it.height / density.density).toInt()) + setLocationRelativeTo(null) + }, ::dispose) + } + } + setLocationRelativeTo(null) + isVisible = true + } +} + +fun main() { val types = mapOf( "message" to { Messages.showMessage("Test Title", "This is a test message.") }, "warning" to { Messages.showWarning("Test Warning", "This is a test warning.", Exception("dfdsfjk")) }, "trace" to { Messages.showTrace("Test Trace", "This is a test trace.", Exception("Test Exception"), false) }, - "tiered_warning" to { Messages.showWarningTiered("Test Tiered Warning", "Primary message", "Secondary message", null) }, - "yes_no" to { Messages.showYesNoQuestion(null, "Test Yes/No", "Do you want to continue?", "Choose yes or no.") }, - "custom_question" to { Messages.showCustomQuestion(null, "Test Custom Question", "Choose an option:", "Select one of the options below.", 1, "Option 1", "Option 2", "Option 3") }, + "tiered_warning" to { + Messages.showWarningTiered( + "Test Tiered Warning", + "Primary message", + "Secondary message", + null + ) + }, + "yes_no" to { + Messages.showYesNoQuestion( + null, + "Test Yes/No", + "Do you want to continue?", + "Choose yes or no." + ) + }, + "custom_question" to { + Messages.showCustomQuestion( + null, + "Test Custom Question", + "Choose an option:", + "Select one of the options below.", + 1, + "Option 1", + "Option 2", + "Option 3" + ) + }, "error" to { Messages.showError("Test Error", "This is a test error.", null) }, ) Platform.init() @@ -322,6 +379,7 @@ fun String.formatClassName() = this .replace(".", "/") .padEnd(40) .colorizePathParts() + fun String.colorizePathParts() = split("/").joinToString("/") { part -> "\u001B[${31 + (part.hashCode() and 0x7).rem(6)}m$part\u001B[0m" } \ No newline at end of file diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index 52b8f8dc3e..b0371592eb 100644 --- a/app/src/processing/app/Preferences.java +++ b/app/src/processing/app/Preferences.java @@ -87,8 +87,13 @@ static public void init() { setBoolean("editor.input_method_support", true); } + // next load user preferences file preferencesFile = Base.getSettingsFile(PREFS_FILE); + var preferencesFileOverride = System.getProperty("processing.app.preferences.file"); + if (preferencesFileOverride != null && !preferencesFileOverride.isEmpty()) { + preferencesFile = new File(preferencesFileOverride); + } boolean firstRun = !preferencesFile.exists(); if (!firstRun) { try { @@ -179,9 +184,11 @@ static public void load(InputStream input) throws IOException { String[] lines = PApplet.loadStrings(input); // Reads as UTF-8 for (String line : lines) { - if ((line.length() == 0) || + if ((line.isEmpty()) || (line.charAt(0) == '#')) continue; + line = line.replace("\\", "/"); // normalize slashes in paths + // this won't properly handle = signs being in the text int equals = line.indexOf('='); if (equals != -1) { diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index 490110f8ac..1332ada00b 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -87,6 +87,14 @@ fun PreferencesProvider(content: @Composable () -> Unit) { preferencesFile.createNewFile() } + remember { + // check if the file has backward slashes + if (preferencesFile.readText().contains("\\")) { + val correctedText = preferencesFile.readText().replace("\\", "/") + preferencesFile.writeText(correctedText) + } + } + val update = watchFile(preferencesFile) 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/PDEWelcome.kt b/app/src/processing/app/ui/PDEWelcome.kt index bfea548dd9..0370fc7533 100644 --- a/app/src/processing/app/ui/PDEWelcome.kt +++ b/app/src/processing/app/ui/PDEWelcome.kt @@ -379,14 +379,16 @@ fun PDEWelcome(base: Base? = null) { modifier = Modifier .width(350.dp + scrollMargin) ) { - val examples = remember { mutableStateListOf( - *listOf( - Platform.getContentFile("modes/java/examples/Basics/Arrays/Array"), - Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective"), - Platform.getContentFile("modes/java/examples/Basics/Color/Brightness"), - Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ") - ).map{ Sketch(path = it.absolutePath, name = it.name) }.toTypedArray() - )} + val examples = remember { + mutableStateListOf( + *listOf( + Platform.getContentFile("modes/java/examples/Basics/Arrays/Array"), + Platform.getContentFile("modes/java/examples/Basics/Camera/Perspective"), + Platform.getContentFile("modes/java/examples/Basics/Color/Brightness"), + Platform.getContentFile("modes/java/examples/Basics/Shape/LoadDisplayOBJ") + ).map { Sketch(path = it.absolutePath, name = it.name) }.toTypedArray() + ) + } remember { val sketches = mutableListOf() @@ -401,7 +403,7 @@ fun PDEWelcome(base: Base? = null) { sketchFolders.forEach { folder -> gatherSketches(folder) } - if(sketches.isEmpty()) { + if (sketches.isEmpty()) { return@remember } examples.clear() 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 new file mode 100644 index 0000000000..a8bd559033 --- /dev/null +++ b/app/src/processing/app/ui/preferences/General.kt @@ -0,0 +1,175 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import processing.app.Preferences +import processing.app.SketchName +import processing.app.ui.EditorFooter.copyDebugInformationToClipboard +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.theme.LocalLocale +import processing.awt.ShimAWT.selectFolder +import java.io.File + + +class General { + companion object{ + val general = PDEPreferencePane( + nameKey = "preferences.pane.general", + icon = { + Icon(Icons.Default.Settings, contentDescription = "General Preferences") + } + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "sketchbook.path.four", + descriptionKey = "preferences.sketchbook_location", + pane = general, + noTitle = true, + control = { preference, updatePreference -> + val locale = LocalLocale.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(locale["preferences.sketchbook_location"]) }, + value = preference ?: "", + onValueChange = { + updatePreference(it) + }, + trailingIcon = { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier + .clickable { + selectFolder( + locale["preferences.sketchbook_location.popup"], + File(preference ?: "") + ) { selectedFile: File? -> + if (selectedFile != null) { + updatePreference(selectedFile.absolutePath) + } + } + } + ) + } + ) + } + ), + PDEPreference( + key = "sketch.name.approach", + descriptionKey = "preferences.sketch_naming", + pane = general, + control = { preference, updatePreference -> + Column { + val options = if (Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + "timestamp", + "untitled", + "custom" + ) + options.toList().chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { option -> + InputChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + ) + } + } + } + } + } + ), + PDEPreference( + key = "editor.sync_folder_and_filename", + labelKey = "preferences.experimental", + descriptionKey = "preferences.sync_folder_and_filename", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "update.check", + descriptionKey = "preferences.update_check", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.four.show", + descriptionKey = "preferences.show_welcome_screen", + pane = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.diagnostics", + pane = general, + control = { preference, updatePreference -> + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + Button(onClick = { + copyDebugInformationToClipboard() + copied = true + + }) { + if (!copied) { + Text(LocalLocale.current["preferences.diagnostics.button"]) + } else { + Text(LocalLocale.current["preferences.diagnostics.button.copied"]) + } + } + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt new file mode 100644 index 0000000000..be0ee833c0 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -0,0 +1,265 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.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.PDEPreferencePane +import processing.app.ui.PDEPreferences +import processing.app.ui.Toolkit +import processing.app.ui.preferences.General.Companion.general +import processing.app.ui.theme.LocalLocale +import java.util.* + +class Interface { + companion object{ + val interfaceAndFonts = PDEPreferencePane( + nameKey = "preferences.pane.interface", + icon = { + Icon(Icons.Default.Brush, contentDescription = "Interface") + }, + after = general + ) + + @OptIn(ExperimentalMaterial3Api::class) + fun register() { + PDEPreferences.register( + PDEPreference( + key = "language", + descriptionKey = "preferences.language", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + val showOptions = remember { mutableStateOf(false) } + OutlinedButton( + onClick = { + showOptions.value = true + }, + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.Language, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(locale.locale.displayName) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + languagesDropdown(showOptions) + } + ), + PDEPreference( + key = "editor.input_method_support", + descriptionKey = "preferences.enable_complex_text", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val enabled = preference?.toBoolean() ?: true + Switch( + checked = enabled, + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "editor.theme", + descriptionKey = "preferences.editor.theme", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InputChip( + selected = (preference ?: "") == "", + onClick = { + updatePreference("") + }, + label = { + Text(locale["preferences.editor.theme.system"]) + } + ) + InputChip( + selected = preference == "dark", + onClick = { + updatePreference("dark") + }, + label = { + Text(locale["preferences.editor.theme.dark"]) + } + ) + InputChip( + selected = preference == "light", + onClick = { + updatePreference("light") + }, + label = { + Text(locale["preferences.editor.theme.light"]) + } + ) + } + } + ), + PDEPreference( + key = "editor.zoom", + descriptionKey = "preferences.interface_scale", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + val range = 100f..300f + + val prefs = LocalPreferences.current + var currentZoom by remember(preference) { + mutableStateOf( + preference + ?.replace("%", "") + ?.toFloatOrNull() + ?: range.start + ) + } + val automatic = currentZoom == range.start + val zoomPerc = "${currentZoom.toInt()}%" + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .widthIn(max = 200.dp) + ) { + Text( + text = if (automatic) "Auto" else zoomPerc, + ) + Slider( + value = currentZoom, + onValueChange = { + currentZoom = it + }, + onValueChangeFinished = { + prefs["editor.zoom.auto"] = automatic + updatePreference(zoomPerc) + }, + valueRange = range, + steps = 3 + ) + } + } + } + ) + ) + + PDEPreferences.register( + PDEPreference( + key = "editor.font.family", + descriptionKey = "preferences.editor_and_console_font", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + var showOptions by remember { mutableStateOf(false) } + val families = + if (Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + OutlinedButton( + onClick = { + showOptions = true + }, + modifier = Modifier.width(200.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(preference ?: families.firstOrNull().orEmpty()) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + families.forEach { family -> + DropdownMenuItem( + text = { Text(family) }, + onClick = { + updatePreference(family) + showOptions = false + } + ) + } + + } + } + ), + PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18 + ) + } + } + ), PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + pane = interfaceAndFonts, + control = { preference, updatePreference -> + Column( + modifier = Modifier + .widthIn(max = 300.dp) + ) { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + ) + ) + } + + @Composable + fun languagesDropdown(showOptions: MutableState) { + val locale = LocalLocale.current + val languages = if (Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + DropdownMenu( + expanded = showOptions.value, + onDismissRequest = { + showOptions.value = false + }, + ) { + languages.forEach { family -> + DropdownMenuItem( + text = { Text(family.value) }, + onClick = { + locale.set(Locale(family.key)) + showOptions.value = false + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt new file mode 100644 index 0000000000..8544f76945 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -0,0 +1,96 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferencePane +import processing.app.ui.PDEPreferencePanes +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Sketches.Companion.sketches +import processing.app.ui.theme.LocalLocale + +class Other { + companion object{ + val other = PDEPreferencePane( + nameKey = "preferences.pane.other", + icon = { + Icon(Icons.Default.Lightbulb, contentDescription = "Other Preferences") + }, + after = sketches + ) + + fun register(panes: PDEPreferencePanes) { + PDEPreferences.register( + PDEPreference( + key = "preferences.show_other", + descriptionKey = "preferences.other", + pane = other, + control = { preference, setPreference -> + val showOther = preference?.toBoolean() ?: false + Switch( + checked = showOther, + onCheckedChange = { + setPreference(it.toString()) + } + ) + if (!showOther) { + return@PDEPreference + } + val prefs = LocalPreferences.current + val locale = LocalLocale.current + DisposableEffect(Unit) { + // add all the other options to the same group as the current one + val group = + panes[other]?.find { group -> group.any { preference -> preference.key == "preferences.show_other" } } as? MutableList + + val existing = panes.values.flatten().flatten().map { preference -> preference.key } + val keys = prefs.keys.mapNotNull { it as? String }.filter { it !in existing }.sorted() + + for (prefKey in keys) { + val descriptionKey = "preferences.$prefKey" + val preference = PDEPreference( + key = prefKey, + descriptionKey = if (locale.containsKey(descriptionKey)) descriptionKey else prefKey, + pane = other, + control = { preference, updatePreference -> + if (preference?.toBooleanStrictOrNull() != null) { + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + return@PDEPreference + } + + OutlinedTextField( + modifier = Modifier.widthIn(max = 300.dp), + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + } + ) + group?.add(preference) + } + onDispose { + group?.apply { + removeIf { it.key != "preferences.show_other" } + } + } + } + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Sketches.kt b/app/src/processing/app/ui/preferences/Sketches.kt new file mode 100644 index 0000000000..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..80dadb1fc7 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 /** @@ -49,13 +50,21 @@ import processing.app.PreferencesProvider fun PDETheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit -){ +) { PreferencesProvider { LocaleProvider { + val preferences = LocalPreferences.current + val theme = when { + preferences["editor.theme"] == "dark" -> darkScheme + preferences["editor.theme"] == "light" -> lightScheme + darkTheme -> darkScheme + else -> lightScheme + + } MaterialTheme( - colorScheme = if(darkTheme) darkScheme else lightScheme, + colorScheme = theme, typography = PDETypography - ){ + ) { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLowest)) { CompositionLocalProvider( LocalScrollbarStyle provides ScrollbarStyle( @@ -109,38 +118,98 @@ fun main() { ) { ComponentPreview("Colors") { val colors = listOf>( - Triple("Primary", MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), - Triple("Secondary", MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.onSecondary), - Triple("Tertiary", MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.onTertiary), - Triple("Primary Container", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer), - Triple("Secondary Container", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer), - Triple("Tertiary Container", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer), - Triple("Error Container", MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer), - Triple("Background", MaterialTheme.colorScheme.background, MaterialTheme.colorScheme.onBackground), - Triple("Surface", MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.onSurface), - Triple("Surface Variant", MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), + Triple( + "Primary", + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.onPrimary + ), + Triple( + "Secondary", + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.onSecondary + ), + Triple( + "Tertiary", + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.onTertiary + ), + Triple( + "Primary Container", + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer + ), + Triple( + "Secondary Container", + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer + ), + Triple( + "Tertiary Container", + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.onTertiaryContainer + ), + Triple( + "Error Container", + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.onErrorContainer + ), + Triple( + "Background", + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.onBackground + ), + Triple( + "Surface", + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.onSurface + ), + Triple( + "Surface Variant", + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant + ), Triple("Error", MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), - Triple("Surface Lowest", MaterialTheme.colorScheme.surfaceContainerLowest, MaterialTheme.colorScheme.onSurface), - Triple("Surface Low", MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.colorScheme.onSurface), - Triple("Surface", MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.onSurface), - Triple("Surface High", MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), - Triple("Surface Highest", MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), + Triple( + "Surface Lowest", + MaterialTheme.colorScheme.surfaceContainerLowest, + MaterialTheme.colorScheme.onSurface + ), + Triple( + "Surface Low", + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.colorScheme.onSurface + ), + Triple( + "Surface", + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.onSurface + ), + Triple( + "Surface High", + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.onSurface + ), + Triple( + "Surface Highest", + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.onSurface + ), ) Column { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - val section = colors.subList(0,3) - for((name, color, onColor) in section){ + val section = colors.subList(0, 3) + for ((name, color, onColor) in section) { Button( colors = ButtonDefaults.buttonColors(containerColor = color), onClick = {}) { Text(name, color = onColor) - } + } } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - val section = colors.subList(3,7) - for((name, color, onColor) in section){ + val section = colors.subList(3, 7) + for ((name, color, onColor) in section) { Button( colors = ButtonDefaults.buttonColors(containerColor = color), onClick = {}) { @@ -149,8 +218,8 @@ fun main() { } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - val section = colors.subList(7,11) - for((name, color, onColor) in section){ + val section = colors.subList(7, 11) + for ((name, color, onColor) in section) { Button( colors = ButtonDefaults.buttonColors(containerColor = color), onClick = {}) { @@ -224,7 +293,10 @@ fun main() { }) } ComponentPreview("Progress Indicator") { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)){ + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { CircularProgressIndicator() LinearProgressIndicator() } @@ -248,7 +320,7 @@ fun main() { Switch(!state, enabled = false, onCheckedChange = { state = it }) } ComponentPreview("Slider") { - Column{ + Column { var state by remember { mutableStateOf(0.5f) } Slider(state, onValueChange = { state = it }) var rangeState by remember { mutableStateOf(0.25f..0.75f) } @@ -266,7 +338,7 @@ fun main() { ComponentPreview("Number Field") { var number by remember { mutableStateOf("123") } TextField(number, onValueChange = { - if(it.all { char -> char.isDigit() }) { + if (it.all { char -> char.isDigit() }) { number = it } }, label = { Text("Number Field") }) @@ -278,7 +350,7 @@ fun main() { TextField(text, onValueChange = { text = it }) } var text by remember { mutableStateOf("Outlined Text Field") } - OutlinedTextField(text, onValueChange = { text = it}) + OutlinedTextField(text, onValueChange = { text = it }) } ComponentPreview("Dropdown Menu") { var show by remember { mutableStateOf(false) } @@ -307,7 +379,7 @@ fun main() { } ComponentPreview("Card") { - Card{ + Card { Text("Hello, Tabs!", modifier = Modifier.padding(20.dp)) } } diff --git a/app/test/processing/app/PreferencesKtTest.kt b/app/test/processing/app/PreferencesKtTest.kt index 6b5dbc5ea9..7dfcba3017 100644 --- a/app/test/processing/app/PreferencesKtTest.kt +++ b/app/test/processing/app/PreferencesKtTest.kt @@ -5,10 +5,11 @@ 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 java.util.* import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory import kotlin.test.Test +import kotlin.test.assertEquals class PreferencesKtTest{ @OptIn(ExperimentalTestApi::class) @@ -58,4 +59,35 @@ class PreferencesKtTest{ onNodeWithTag("text").assertTextEquals(nextValue) } + + @OptIn(ExperimentalTestApi::class) + @Test + fun testWithBackwardSlashes() = runComposeUiTest { + val directory = createTempDirectory("preferences") + val tempPreferences = directory + .resolve("preferences.txt") + .createFile() + .toFile() + + System.setProperty("processing.app.preferences.file", tempPreferences.absolutePath) + System.setProperty("processing.app.preferences.debounce", "0") + System.setProperty("processing.app.watchfile.forced", "true") + val testKey = "test.preferences.backward.slash" + + val value = "C:\\Users\\Test\\Documents" + tempPreferences.writeText("$testKey=$value") + val replacedValue = value.replace("\\", "/") + + setContent { + PreferencesProvider { + val preferences = LocalPreferences.current + Text(preferences[testKey] ?: "default", modifier = Modifier.testTag("text")) + } + } + + onNodeWithTag("text").assertTextEquals(replacedValue) + + Preferences.init() + assertEquals(replacedValue, Preferences.get(testKey)) + } } \ No newline at end of file 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); }