diff --git a/app/src/processing/app/Base.java b/app/src/processing/app/Base.java index 2551a54d6..a1604ff0a 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,7 @@ static private Mode findSketchMode(File folder, List modeList) { * Show the Preferences window. */ public void handlePrefs() { - if (preferencesFrame == null) { - preferencesFrame = new PreferencesFrame(this); - } - preferencesFrame.showFrame(); + PreferencesKt.show(); } diff --git a/app/src/processing/app/Preferences.java b/app/src/processing/app/Preferences.java index 640c77ead..8fcf7bb05 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 c5645c9bb..c54cbbd81 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 000000000..7fd9f5635 --- /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 72c305000..531c28f7e 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 000000000..5f56187f4 --- /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 000000000..fc384fbc5 --- /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 000000000..f5f65ea9c --- /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 7cc70455f..9e41227ed 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 7b38e774c..98a4e0080 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,45 @@ 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(){ +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 +97,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 +110,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 +144,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 +157,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