From 1c26bab4c2020b5d8da6126bfba99255ace35ed4 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 23 Oct 2025 08:26:22 +0200 Subject: [PATCH 1/3] Refactor preferences to Jetpack Compose UI Replaces the legacy PreferencesFrame with a new Jetpack Compose-based preferences UI. Adds reactive preferences management using a custom ReactiveProperties class, and introduces modular preference groups (General, Interface, Other) with composable controls. Updates Base.java to launch the new preferences window, and refactors theme and window code for Compose integration. --- app/src/processing/app/Base.java | 10 +- app/src/processing/app/Preferences.java | 8 + app/src/processing/app/Preferences.kt | 163 +++++++-- app/src/processing/app/ui/Preferences.kt | 325 ++++++++++++++++++ app/src/processing/app/ui/WelcomeToBeta.kt | 1 - .../processing/app/ui/preferences/General.kt | 121 +++++++ .../app/ui/preferences/Interface.kt | 168 +++++++++ .../processing/app/ui/preferences/Other.kt | 73 ++++ app/src/processing/app/ui/theme/Theme.kt | 2 +- app/src/processing/app/ui/theme/Window.kt | 122 +++++-- 10 files changed, 937 insertions(+), 56 deletions(-) create mode 100644 app/src/processing/app/ui/Preferences.kt create mode 100644 app/src/processing/app/ui/preferences/General.kt create mode 100644 app/src/processing/app/ui/preferences/Interface.kt create mode 100644 app/src/processing/app/ui/preferences/Other.kt diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 2551a54d64..e3eae12fb8 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -40,6 +40,7 @@ import processing.app.contrib.*; import processing.app.tools.Tool; import processing.app.ui.*; +import processing.app.ui.PreferencesKt; import processing.app.ui.Toolkit; import processing.core.*; import processing.data.StringList; @@ -2190,10 +2191,11 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { - if (preferencesFrame == null) { - preferencesFrame = new PreferencesFrame(this); - } - preferencesFrame.showFrame(); +// if (preferencesFrame == null) { +// preferencesFrame = new PreferencesFrame(this); +// } +// preferencesFrame.showFrame(); + PreferencesKt.show(); } diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index 640c77eade..8fcf7bb056 100644 --- a/app/src/processing/app/Preferences.java +++ b/app/src/processing/app/Preferences.java @@ -136,6 +136,14 @@ static public void skipInit() { initialized = true; } + /** + * Check whether Preferences.init() has been called. If not, we are probably not running the full application. + * @return true if Preferences has been initialized + */ + static public boolean isInitialized() { + return initialized; + } + static void handleProxy(String protocol, String hostProp, String portProp) { String proxyHost = get("proxy." + protocol + ".host"); diff --git a/app/src/processing/app/Preferences.kt b/app/src/processing/app/Preferences.kt index c5645c9bbc..c54cbbd817 100644 --- a/app/src/processing/app/Preferences.kt +++ b/app/src/processing/app/Preferences.kt @@ -2,56 +2,183 @@ package processing.app import androidx.compose.runtime.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.launch import java.io.File import java.io.InputStream import java.nio.file.* import java.util.Properties +/* + The ReactiveProperties class extends the standard Java Properties class + to provide reactive capabilities using Jetpack Compose's mutableStateMapOf. + This allows UI components to automatically update when preference values change. +*/ +class ReactiveProperties: Properties() { + val snapshotStateMap = mutableStateMapOf() + + override fun setProperty(key: String, value: String) { + super.setProperty(key, value) + snapshotStateMap[key] = value + } + + override fun getProperty(key: String): String? { + return snapshotStateMap[key] ?: super.getProperty(key) + } + + operator fun get(key: String): String? = getProperty(key) + + operator fun set(key: String, value: String) { + setProperty(key, value) + } +} + +/* + A CompositionLocal to provide access to the ReactiveProperties instance + throughout the composable hierarchy. + */ +val LocalPreferences = compositionLocalOf { error("No preferences provided") } const val PREFERENCES_FILE_NAME = "preferences.txt" const val DEFAULTS_FILE_NAME = "defaults.txt" -fun PlatformStart(){ - Platform.inst ?: Platform.init() -} +/* + This composable function sets up a preferences provider that manages application settings. + It initializes the preferences from a file, watches for changes to that file, and saves + any updates back to the file. It uses a ReactiveProperties class to allow for reactive + updates in the UI when preferences change. + usage: + PreferencesProvider { + // Your app content here + } + + to access preferences: + val preferences = LocalPreferences.current + val someSetting = preferences["someKey"] ?: "defaultValue" + preferences["someKey"] = "newValue" + + This will automatically save to the preferences file and update any UI components + that are observing that key. + + to override the preferences file (for testing, etc) + System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt") + to override the debounce time (in milliseconds) + System.setProperty("processing.app.preferences.debounce", "200") + + */ +@OptIn(FlowPreview::class) @Composable -fun loadPreferences(): Properties{ - PlatformStart() +fun PreferencesProvider(content: @Composable () -> Unit){ + val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) } + val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull() - val settingsFolder = Platform.getSettingsFolder() - val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME) + // Initialize the platform (if not already done) to ensure we have access to the settings folder + remember { + Platform.init() + } + // Grab the preferences file, creating it if it doesn't exist + // TODO: This functionality should be separated from the `Preferences` class itself + val settingsFolder = Platform.getSettingsFolder() + val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME) if(!preferencesFile.exists()){ + preferencesFile.mkdirs() preferencesFile.createNewFile() } - watchFile(preferencesFile) - return Properties().apply { - load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream()) - load(preferencesFile.inputStream()) + val update = watchFile(preferencesFile) + + + val properties = remember(preferencesFile, update) { + ReactiveProperties().apply { + val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) + ?: InputStream.nullInputStream() + load(defaultsStream + .reader(Charsets.UTF_8) + ) + load(preferencesFile + .inputStream() + .reader(Charsets.UTF_8) + ) + } + } + + val initialState = remember(properties) { properties.snapshotStateMap.toMap() } + + // Listen for changes to the preferences and save them to file + LaunchedEffect(properties) { + snapshotFlow { properties.snapshotStateMap.toMap() } + .dropWhile { it == initialState } + .debounce(preferencesDebounceOverride ?: 100) + .collect { + + // Save the preferences to file, sorted alphabetically + preferencesFile.outputStream().use { output -> + output.write( + properties.entries + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() }) + .joinToString("\n") { (key, value) -> "$key=$value" } + .toByteArray() + ) + } + } + } + + CompositionLocalProvider(LocalPreferences provides properties){ + content() } + } +/* + This composable function watches a specified file for modifications. When the file is modified, + it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates + or other actions in response to changes in the file. + + To watch the file at the fasted speed (for testing) set the following system property: + System.setProperty("processing.app.watchfile.forced", "true") + */ @Composable fun watchFile(file: File): Any? { + val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean() + val scope = rememberCoroutineScope() var event by remember(file) { mutableStateOf?> (null) } DisposableEffect(file){ val fileSystem = FileSystems.getDefault() val watcher = fileSystem.newWatchService() + var active = true + // In forced mode we just poll the last modified time of the file + // This is not efficient but works better for testing with temp files + val toWatch = { file.lastModified() } + var state = toWatch() + val path = file.toPath() val parent = path.parent val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY) scope.launch(Dispatchers.IO) { while (active) { - for (modified in key.pollEvents()) { - if (modified.context() != path.fileName) continue - event = modified + if(forcedWatch) { + if(toWatch() == state) continue + state = toWatch() + event = object : WatchEvent { + override fun count(): Int = 1 + override fun context(): Path = file.toPath().fileName + override fun kind(): WatchEvent.Kind = StandardWatchEventKinds.ENTRY_MODIFY + override fun toString(): String = "ForcedEvent(${context()})" + } + continue + }else{ + for (modified in key.pollEvents()) { + if (modified.context() != path.fileName) continue + event = modified + } } } } @@ -62,12 +189,4 @@ fun watchFile(file: File): Any? { } } return event -} -val LocalPreferences = compositionLocalOf { error("No preferences provided") } -@Composable -fun PreferencesProvider(content: @Composable () -> Unit){ - val preferences = loadPreferences() - CompositionLocalProvider(LocalPreferences provides preferences){ - content() - } } \ No newline at end of file diff --git a/app/src/processing/app/ui/Preferences.kt b/app/src/processing/app/ui/Preferences.kt new file mode 100644 index 0000000000..7fd9f56350 --- /dev/null +++ b/app/src/processing/app/ui/Preferences.kt @@ -0,0 +1,325 @@ +package processing.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import processing.app.LocalPreferences +import processing.app.ui.PDEPreferences.Companion.preferences +import processing.app.ui.preferences.General +import processing.app.ui.preferences.Interface +import processing.app.ui.preferences.Other +import processing.app.ui.theme.LocalLocale +import processing.app.ui.theme.PDESwingWindow +import processing.app.ui.theme.PDETheme +import java.awt.Dimension +import javax.swing.SwingUtilities + +val LocalPreferenceGroups = compositionLocalOf>> { + error("No Preference Groups Set") +} + +class PDEPreferences { + companion object{ + val groups = mutableStateMapOf>() + fun register(preference: PDEPreference) { + val list = groups[preference.group]?.toMutableList() ?: mutableListOf() + list.add(preference) + groups[preference.group] = list + } + init{ + General.register() + Interface.register() + Other.register() + } + + /** + * Composable function to display the preferences UI. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun preferences(){ + var visible by remember { mutableStateOf(groups) } + val sortedGroups = remember { + val keys = visible.keys + keys.toSortedSet { + a, b -> + when { + a.after == b -> 1 + b.after == a -> -1 + else -> a.name.compareTo(b.name) + } + } + } + var selected by remember { mutableStateOf(sortedGroups.first()) } + CompositionLocalProvider( + LocalPreferenceGroups provides visible + ) { + Row { + NavigationRail( + header = { + Text( + "Settings", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 42.dp) + ) + + }, + modifier = Modifier + .defaultMinSize(minWidth = 200.dp) + ) { + + for (group in sortedGroups) { + NavigationRailItem( + selected = selected == group, + enabled = visible.keys.contains(group), + onClick = { + selected = group + }, + icon = { + group.icon() + }, + label = { + Text(group.name) + } + ) + } + } + Box(modifier = Modifier.padding(top = 42.dp)) { + Column(modifier = Modifier + .fillMaxSize() + ) { + var query by remember { mutableStateOf("") } + val locale = LocalLocale.current + LaunchedEffect(query){ + + snapshotFlow { query } + .debounce(100) + .collect{ + if(it.isBlank()){ + visible = groups + return@collect + } + val filtered = mutableStateMapOf>() + for((group, preferences) in groups){ + val matching = preferences.filter { preference -> + if(preference.key == "other"){ + return@filter true + } + if(preference.key.contains(it, ignoreCase = true)){ + return@filter true + } + val description = locale[preference.descriptionKey] + description.contains(it, ignoreCase = true) + } + if(matching.isNotEmpty()){ + filtered[group] = matching + } + } + visible = filtered + } + + } + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { + query = it + }, + onSearch = { + + }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search") } + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.align(Alignment.End).padding(16.dp) + ) { + + } + + val preferences = visible[selected] ?: emptyList() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(preferences){ preference -> + preference.showControl() + } + } + } + } + } + } + } + + + + @JvmStatic + fun main(args: Array) { + application { + Window(onCloseRequest = ::exitApplication){ + remember{ + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = true) { + preferences() + } + } + Window(onCloseRequest = ::exitApplication){ + remember{ + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + } + PDETheme(darkTheme = false) { + preferences() + } + } + } + } + } +} + +/** + * Data class representing a single preference in the preferences system. + * + * Usage: + * ``` + * PDEPreferences.register( + * PDEPreference( + * key = "preference.key", + * descriptionKey = "preference.description", + * group = somePreferenceGroup, + * control = { preference, updatePreference -> + * // Composable UI to modify the preference + * } + * ) + * ) + * ``` + */ +data class PDEPreference( + /** + * The key in the preferences file used to store this preference. + */ + val key: String, + /** + * The key for the description of this preference, used for localization. + */ + val descriptionKey: String, + /** + * The group this preference belongs to. + */ + val group: PDEPreferenceGroup, + /** + * A Composable function that defines the control used to modify this preference. + * It takes the current preference value and a function to update the preference. + */ + val control: @Composable (preference: String?, updatePreference: (newValue: String) -> Unit) -> Unit = { preference, updatePreference -> }, + + /** + * If true, no padding will be applied around this preference's UI. + */ + val noPadding: Boolean = false, +) + +/** + * Composable function to display the preference's description and control. + */ +@Composable +private fun PDEPreference.showControl() { + val locale = LocalLocale.current + val prefs = LocalPreferences.current + Text( + text = locale[descriptionKey], + modifier = Modifier.padding(horizontal = 20.dp), + style = MaterialTheme.typography.titleMedium + ) + val show = @Composable { + control(prefs[key]) { newValue -> + prefs[key] = newValue + } + } + + if(noPadding){ + show() + }else{ + Box(modifier = Modifier.padding(horizontal = 20.dp)) { + show() + } + } + +} + +/** + * Data class representing a group of preferences. + */ +data class PDEPreferenceGroup( + /** + * The name of this group. + */ + val name: String, + /** + * The icon representing this group. + */ + val icon: @Composable () -> Unit, + /** + * The group that comes before this one in the list. + */ + val after: PDEPreferenceGroup? = null, +) + +fun show(){ + SwingUtilities.invokeLater { + PDESwingWindow( + titleKey = "preferences", + fullWindowContent = true, + size = Dimension(800, 600) + ) { + PDETheme { + preferences() + } + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/WelcomeToBeta.kt b/app/src/processing/app/ui/WelcomeToBeta.kt index 72c3050006..531c28f7ef 100644 --- a/app/src/processing/app/ui/WelcomeToBeta.kt +++ b/app/src/processing/app/ui/WelcomeToBeta.kt @@ -42,7 +42,6 @@ import processing.app.ui.theme.LocalLocale import processing.app.ui.theme.Locale import processing.app.ui.theme.PDEComposeWindow import processing.app.ui.theme.PDESwingWindow -import processing.app.ui.theme.ProcessingTheme import java.awt.Cursor import java.awt.Dimension import java.awt.event.KeyAdapter diff --git a/app/src/processing/app/ui/preferences/General.kt b/app/src/processing/app/ui/preferences/General.kt new file mode 100644 index 0000000000..5f56187f46 --- /dev/null +++ b/app/src/processing/app/ui/preferences/General.kt @@ -0,0 +1,121 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Preferences +import processing.app.SketchName +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences + + +class General { + companion object{ + val general = PDEPreferenceGroup( + name = "General", + icon = { + Icon(Icons.Default.Settings, contentDescription = "A settings icon") + } + ) + + fun register() { + PDEPreferences.register( + PDEPreference( + key = "sketchbook.path.four", + descriptionKey = "preferences.sketchbook_location", + group = general, + control = { preference, updatePreference -> + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + TextField( + value = preference ?: "", + onValueChange = { + updatePreference(it) + } + ) + Button( + onClick = { + + } + ) { + Text("Browse") + } + } + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "sketch.name.approach", + descriptionKey = "preferences.sketch_naming", + group = general, + control = { preference, updatePreference -> + Row{ + for (option in if(Preferences.isInitialized()) SketchName.getOptions() else arrayOf( + "timestamp", + "untitled", + "custom" + )) { + FilterChip( + selected = preference == option, + onClick = { + updatePreference(option) + }, + label = { + Text(option) + }, + modifier = Modifier.padding(4.dp), + ) + } + } + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "update.check", + descriptionKey = "preferences.check_for_updates_on_startup", + group = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + PDEPreferences.register( + PDEPreference( + key = "welcome.show", + descriptionKey = "preferences.show_welcome_screen_on_startup", + group = general, + control = { preference, updatePreference -> + Switch( + checked = preference.toBoolean(), + onCheckedChange = { + updatePreference(it.toString()) + } + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Interface.kt b/app/src/processing/app/ui/preferences/Interface.kt new file mode 100644 index 0000000000..fc384fbc59 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Interface.kt @@ -0,0 +1,168 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.TextIncrease +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.Language +import processing.app.Preferences +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences +import processing.app.ui.Toolkit +import processing.app.ui.preferences.General.Companion.general +import processing.app.ui.theme.LocalLocale +import java.util.Locale + +class Interface { + companion object{ + val interfaceAndFonts = PDEPreferenceGroup( + name = "Interface", + icon = { + Icon(Icons.Default.TextIncrease, contentDescription = "Interface") + }, + after = general + ) + + fun register() { + PDEPreferences.register(PDEPreference( + key = "language", + descriptionKey = "preferences.language", + group = interfaceAndFonts, + control = { preference, updatePreference -> + val locale = LocalLocale.current + var showOptions by remember { mutableStateOf(false) } + val languages = if(Preferences.isInitialized()) Language.getLanguages() else mapOf("en" to "English") + TextField( + value = locale.locale.displayName, + readOnly = true, + onValueChange = { }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select Font Family", + modifier = Modifier + .clickable{ + showOptions = true + } + ) + } + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + languages.forEach { family -> + DropdownMenuItem( + text = { Text(family.value) }, + onClick = { + locale.set(Locale(family.key)) + showOptions = false + } + ) + } + } + } + )) + + PDEPreferences.register( + PDEPreference( + key = "editor.font.family", + descriptionKey = "preferences.editor_and_console_font", + group = interfaceAndFonts, + control = { preference, updatePreference -> + var showOptions by remember { mutableStateOf(false) } + val families = if(Preferences.isInitialized()) Toolkit.getMonoFontFamilies() else arrayOf("Monospaced") + TextField( + value = preference ?: families.firstOrNull().orEmpty(), + readOnly = true, + onValueChange = { updatePreference (it) }, + trailingIcon = { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select Font Family", + modifier = Modifier + .clickable{ + showOptions = true + } + ) + } + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { + showOptions = false + }, + ) { + families.forEach { family -> + DropdownMenuItem( + text = { Text(family) }, + onClick = { + updatePreference(family) + showOptions = false + } + ) + } + + } + } + ) + ) + + PDEPreferences.register(PDEPreference( + key = "editor.font.size", + descriptionKey = "preferences.editor_font_size", + group = interfaceAndFonts, + control = { preference, updatePreference -> + Column { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + )) + PDEPreferences.register(PDEPreference( + key = "console.font.size", + descriptionKey = "preferences.console_font_size", + group = interfaceAndFonts, + control = { preference, updatePreference -> + Column { + Text( + text = "${preference ?: "12"} pt", + modifier = Modifier.width(120.dp) + ) + Slider( + value = (preference ?: "12").toFloat(), + onValueChange = { updatePreference(it.toInt().toString()) }, + valueRange = 10f..48f, + steps = 18, + ) + } + } + )) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/preferences/Other.kt b/app/src/processing/app/ui/preferences/Other.kt new file mode 100644 index 0000000000..f5f65ea9c8 --- /dev/null +++ b/app/src/processing/app/ui/preferences/Other.kt @@ -0,0 +1,73 @@ +package processing.app.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import processing.app.LocalPreferences +import processing.app.ui.LocalPreferenceGroups +import processing.app.ui.PDEPreference +import processing.app.ui.PDEPreferenceGroup +import processing.app.ui.PDEPreferences +import processing.app.ui.preferences.Interface.Companion.interfaceAndFonts +import processing.app.ui.theme.LocalLocale + +class Other { + companion object{ + val other = PDEPreferenceGroup( + name = "Other", + icon = { + Icon(Icons.Default.Map, contentDescription = "A map icon") + }, + after = interfaceAndFonts + ) + fun register() { + PDEPreferences.register( + PDEPreference( + key = "other", + descriptionKey = "preferences.other", + group = other, + noPadding = true, + control = { _, _ -> + val prefs = LocalPreferences.current + val groups = LocalPreferenceGroups.current + val restPrefs = remember { + val keys = prefs.keys.mapNotNull { it as? String } + val existing = groups.values.flatten().map { it.key } + keys.filter { it !in existing }.sorted() + } + val locale = LocalLocale.current + + for(prefKey in restPrefs){ + val value = prefs[prefKey] + Row ( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ){ + Text( + text = locale[prefKey], + modifier = Modifier.align(Alignment.CenterVertically) + ) + TextField(value ?: "", onValueChange = { + prefs[prefKey] = it + }) + } + } + + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/processing/app/ui/theme/Theme.kt b/app/src/processing/app/ui/theme/Theme.kt index 7cc70455f0..9e41227ed1 100644 --- a/app/src/processing/app/ui/theme/Theme.kt +++ b/app/src/processing/app/ui/theme/Theme.kt @@ -93,7 +93,7 @@ fun PDETheme( colorScheme = if(darkTheme) PDEDarkColor else PDELightColor, typography = PDETypography ){ - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background).fillMaxSize()) { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onBackground, LocalDensity provides Density(1.25f, 1.25f), diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index 7b38e774cb..bf998d5742 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -1,6 +1,8 @@ package processing.app.ui.theme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -9,16 +11,22 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.formdev.flatlaf.util.SystemInfo +import java.awt.Dimension import java.awt.event.KeyAdapter import java.awt.event.KeyEvent import javax.swing.JFrame +import javax.swing.UIManager val LocalWindow = compositionLocalOf { error("No Window Set") } @@ -37,32 +45,46 @@ val LocalWindow = compositionLocalOf { error("No Window Set") } * ``` * * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. If null, the window will use its default size. + * @param minSize The minimum size of the window. If null, no minimum size is set. + * @param maxSize The maximum size of the window. If null, no maximum size is set. * @param fullWindowContent If true, the content will extend into the title bar area on macOS. * @param content The composable content to be displayed in the window. */ -class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable BoxScope.() -> Unit): JFrame(){ +// TODO: Add support for onClose callback +class PDESwingWindow( + titleKey: String = "", + size: Dimension? = null, + minSize: Dimension? = null, + maxSize: Dimension? = null, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ init{ - val window = this - defaultCloseOperation = DISPOSE_ON_CLOSE - ComposePanel().apply { + ComposeWindow().apply { + val window = this + defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE + size?.let { + window.size = it + } + minSize?.let { + window.minimumSize = it + } + maxSize?.let { + window.maximumSize = it + } + setLocationRelativeTo(null) setContent { PDEWindowContent(window, titleKey, fullWindowContent, content) } - window.add(this) - } - background = java.awt.Color.white - setLocationRelativeTo(null) - addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - if (e.keyCode != KeyEvent.VK_ESCAPE) return - - window.dispose() - onClose() + window.addWindowStateListener { + if(it.newState == JFrame.DISPOSE_ON_CLOSE){ + onClose() + } } - }) - isResizable = false - isVisible = true - requestFocus() + isVisible = true + } } } @@ -76,7 +98,12 @@ class PDESwingWindow(titleKey: String = "", fullWindowContent: Boolean = false, * @param content The composable content to be displayed in the window. */ @Composable -private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent: Boolean = false, content: @Composable BoxScope.() -> Unit){ +private fun PDEWindowContent( + window: ComposeWindow, + titleKey: String, + fullWindowContent: Boolean = false, + content: @Composable () -> Unit +){ val mac = SystemInfo.isMacOS && SystemInfo.isMacFullWindowContentSupported remember { window.rootPane.putClientProperty("apple.awt.fullWindowContent", mac && fullWindowContent) @@ -84,15 +111,10 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent } CompositionLocalProvider(LocalWindow provides window) { - PDETheme { + PDETheme{ val locale = LocalLocale.current window.title = locale[titleKey] - LaunchedEffect(locale) { - window.pack() - window.setLocationRelativeTo(null) - } - - Box(modifier = Modifier.padding(top = if (mac && !fullWindowContent) 22.dp else 0.dp),content = content) + content() } } } @@ -123,6 +145,10 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent * ``` * * @param titleKey The key for the window title, which will be localized. + * @param size The desired size of the window. Defaults to unspecified size which means the window will be + * fullscreen if it contains any of [fillMaxWidth]/[fillMaxSize]/[fillMaxHeight] etc. + * @param minSize The minimum size of the window. Defaults to unspecified size which means no minimum size is set. + * @param maxSize The maximum size of the window. Defaults to unspecified size which means no maximum size is set. * @param fullWindowContent If true, the content will extend into the title bar area on * macOS. * @param onClose A lambda function to be called when the window is requested to close. @@ -132,12 +158,52 @@ private fun PDEWindowContent(window: JFrame, titleKey: String, fullWindowContent * */ @Composable -fun PDEComposeWindow(titleKey: String, fullWindowContent: Boolean = false, onClose: () -> Unit = {}, content: @Composable BoxScope.() -> Unit){ +fun PDEComposeWindow( + titleKey: String, + size: DpSize = DpSize.Unspecified, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + fullWindowContent: Boolean = false, + onClose: () -> Unit = {}, + content: @Composable () -> Unit +){ val windowState = rememberWindowState( - size = DpSize.Unspecified, + size = size, position = WindowPosition(Alignment.Center) ) Window(onCloseRequest = onClose, state = windowState, title = "") { + remember { + window.minimumSize = minSize.toDimension() + window.maximumSize = maxSize.toDimension() + } PDEWindowContent(window, titleKey, fullWindowContent, content) } +} + +fun DpSize.toDimension(): Dimension? { + if(this == DpSize.Unspecified) { return null } + + return Dimension( + this.width.value.toInt(), + this.height.value.toInt() + ) +} + +fun main(){ + application { + PDEComposeWindow( + onClose = ::exitApplication, + titleKey = "window.title", + size = DpSize(800.dp, 600.dp), + ){ + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text("Hello, World!") + } + } + } } \ No newline at end of file From 8fe058527743a09f9845ccc37925754f590d3dbb Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Thu, 23 Oct 2025 09:00:59 +0200 Subject: [PATCH 2/3] Remove obsolete TODO for onClose callback --- app/src/processing/app/ui/theme/Window.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/processing/app/ui/theme/Window.kt b/app/src/processing/app/ui/theme/Window.kt index bf998d5742..98a4e00807 100644 --- a/app/src/processing/app/ui/theme/Window.kt +++ b/app/src/processing/app/ui/theme/Window.kt @@ -51,7 +51,6 @@ val LocalWindow = compositionLocalOf { error("No Window Set") } * @param fullWindowContent If true, the content will extend into the title bar area on macOS. * @param content The composable content to be displayed in the window. */ -// TODO: Add support for onClose callback class PDESwingWindow( titleKey: String = "", size: Dimension? = null, From 5a6f7fa5bd34781e27739b78e17ec039446193f6 Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Fri, 24 Oct 2025 11:32:23 +0200 Subject: [PATCH 3/3] Clean up handlePrefs method by removing comments Removed commented-out code for preferences frame initialization. --- app/src/processing/app/Base.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index e3eae12fb8..a1604ff0a0 100644 --- a/app/src/processing/app/Base.java +++ b/app/src/processing/app/Base.java @@ -2191,10 +2191,6 @@ 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(); }