Skip to content

Commit 1c26bab

Browse files
committed
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.
1 parent 830ecea commit 1c26bab

File tree

10 files changed

+937
-56
lines changed

10 files changed

+937
-56
lines changed

app/src/processing/app/Base.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import processing.app.contrib.*;
4141
import processing.app.tools.Tool;
4242
import processing.app.ui.*;
43+
import processing.app.ui.PreferencesKt;
4344
import processing.app.ui.Toolkit;
4445
import processing.core.*;
4546
import processing.data.StringList;
@@ -2190,10 +2191,11 @@ static private Mode findSketchMode(File folder, List<Mode> modeList) {
21902191
* Show the Preferences window.
21912192
*/
21922193
public void handlePrefs() {
2193-
if (preferencesFrame == null) {
2194-
preferencesFrame = new PreferencesFrame(this);
2195-
}
2196-
preferencesFrame.showFrame();
2194+
// if (preferencesFrame == null) {
2195+
// preferencesFrame = new PreferencesFrame(this);
2196+
// }
2197+
// preferencesFrame.showFrame();
2198+
PreferencesKt.show();
21972199
}
21982200

21992201

app/src/processing/app/Preferences.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ static public void skipInit() {
136136
initialized = true;
137137
}
138138

139+
/**
140+
* Check whether Preferences.init() has been called. If not, we are probably not running the full application.
141+
* @return true if Preferences has been initialized
142+
*/
143+
static public boolean isInitialized() {
144+
return initialized;
145+
}
146+
139147

140148
static void handleProxy(String protocol, String hostProp, String portProp) {
141149
String proxyHost = get("proxy." + protocol + ".host");

app/src/processing/app/Preferences.kt

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,183 @@ package processing.app
22

33
import androidx.compose.runtime.*
44
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.FlowPreview
6+
import kotlinx.coroutines.flow.debounce
7+
import kotlinx.coroutines.flow.dropWhile
58
import kotlinx.coroutines.launch
69
import java.io.File
710
import java.io.InputStream
811
import java.nio.file.*
912
import java.util.Properties
1013

14+
/*
15+
The ReactiveProperties class extends the standard Java Properties class
16+
to provide reactive capabilities using Jetpack Compose's mutableStateMapOf.
17+
This allows UI components to automatically update when preference values change.
18+
*/
19+
class ReactiveProperties: Properties() {
20+
val snapshotStateMap = mutableStateMapOf<String, String>()
21+
22+
override fun setProperty(key: String, value: String) {
23+
super.setProperty(key, value)
24+
snapshotStateMap[key] = value
25+
}
26+
27+
override fun getProperty(key: String): String? {
28+
return snapshotStateMap[key] ?: super.getProperty(key)
29+
}
30+
31+
operator fun get(key: String): String? = getProperty(key)
32+
33+
operator fun set(key: String, value: String) {
34+
setProperty(key, value)
35+
}
36+
}
37+
38+
/*
39+
A CompositionLocal to provide access to the ReactiveProperties instance
40+
throughout the composable hierarchy.
41+
*/
42+
val LocalPreferences = compositionLocalOf<ReactiveProperties> { error("No preferences provided") }
1143

1244
const val PREFERENCES_FILE_NAME = "preferences.txt"
1345
const val DEFAULTS_FILE_NAME = "defaults.txt"
1446

15-
fun PlatformStart(){
16-
Platform.inst ?: Platform.init()
17-
}
47+
/*
48+
This composable function sets up a preferences provider that manages application settings.
49+
It initializes the preferences from a file, watches for changes to that file, and saves
50+
any updates back to the file. It uses a ReactiveProperties class to allow for reactive
51+
updates in the UI when preferences change.
1852
53+
usage:
54+
PreferencesProvider {
55+
// Your app content here
56+
}
57+
58+
to access preferences:
59+
val preferences = LocalPreferences.current
60+
val someSetting = preferences["someKey"] ?: "defaultValue"
61+
preferences["someKey"] = "newValue"
62+
63+
This will automatically save to the preferences file and update any UI components
64+
that are observing that key.
65+
66+
to override the preferences file (for testing, etc)
67+
System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
68+
to override the debounce time (in milliseconds)
69+
System.setProperty("processing.app.preferences.debounce", "200")
70+
71+
*/
72+
@OptIn(FlowPreview::class)
1973
@Composable
20-
fun loadPreferences(): Properties{
21-
PlatformStart()
74+
fun PreferencesProvider(content: @Composable () -> Unit){
75+
val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) }
76+
val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull()
2277

23-
val settingsFolder = Platform.getSettingsFolder()
24-
val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME)
78+
// Initialize the platform (if not already done) to ensure we have access to the settings folder
79+
remember {
80+
Platform.init()
81+
}
2582

83+
// Grab the preferences file, creating it if it doesn't exist
84+
// TODO: This functionality should be separated from the `Preferences` class itself
85+
val settingsFolder = Platform.getSettingsFolder()
86+
val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME)
2687
if(!preferencesFile.exists()){
88+
preferencesFile.mkdirs()
2789
preferencesFile.createNewFile()
2890
}
29-
watchFile(preferencesFile)
3091

31-
return Properties().apply {
32-
load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream())
33-
load(preferencesFile.inputStream())
92+
val update = watchFile(preferencesFile)
93+
94+
95+
val properties = remember(preferencesFile, update) {
96+
ReactiveProperties().apply {
97+
val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)
98+
?: InputStream.nullInputStream()
99+
load(defaultsStream
100+
.reader(Charsets.UTF_8)
101+
)
102+
load(preferencesFile
103+
.inputStream()
104+
.reader(Charsets.UTF_8)
105+
)
106+
}
107+
}
108+
109+
val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
110+
111+
// Listen for changes to the preferences and save them to file
112+
LaunchedEffect(properties) {
113+
snapshotFlow { properties.snapshotStateMap.toMap() }
114+
.dropWhile { it == initialState }
115+
.debounce(preferencesDebounceOverride ?: 100)
116+
.collect {
117+
118+
// Save the preferences to file, sorted alphabetically
119+
preferencesFile.outputStream().use { output ->
120+
output.write(
121+
properties.entries
122+
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() })
123+
.joinToString("\n") { (key, value) -> "$key=$value" }
124+
.toByteArray()
125+
)
126+
}
127+
}
128+
}
129+
130+
CompositionLocalProvider(LocalPreferences provides properties){
131+
content()
34132
}
133+
35134
}
36135

136+
/*
137+
This composable function watches a specified file for modifications. When the file is modified,
138+
it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates
139+
or other actions in response to changes in the file.
140+
141+
To watch the file at the fasted speed (for testing) set the following system property:
142+
System.setProperty("processing.app.watchfile.forced", "true")
143+
*/
37144
@Composable
38145
fun watchFile(file: File): Any? {
146+
val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean()
147+
39148
val scope = rememberCoroutineScope()
40149
var event by remember(file) { mutableStateOf<WatchEvent<*>?> (null) }
41150

42151
DisposableEffect(file){
43152
val fileSystem = FileSystems.getDefault()
44153
val watcher = fileSystem.newWatchService()
154+
45155
var active = true
46156

157+
// In forced mode we just poll the last modified time of the file
158+
// This is not efficient but works better for testing with temp files
159+
val toWatch = { file.lastModified() }
160+
var state = toWatch()
161+
47162
val path = file.toPath()
48163
val parent = path.parent
49164
val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY)
50165
scope.launch(Dispatchers.IO) {
51166
while (active) {
52-
for (modified in key.pollEvents()) {
53-
if (modified.context() != path.fileName) continue
54-
event = modified
167+
if(forcedWatch) {
168+
if(toWatch() == state) continue
169+
state = toWatch()
170+
event = object : WatchEvent<Path> {
171+
override fun count(): Int = 1
172+
override fun context(): Path = file.toPath().fileName
173+
override fun kind(): WatchEvent.Kind<Path> = StandardWatchEventKinds.ENTRY_MODIFY
174+
override fun toString(): String = "ForcedEvent(${context()})"
175+
}
176+
continue
177+
}else{
178+
for (modified in key.pollEvents()) {
179+
if (modified.context() != path.fileName) continue
180+
event = modified
181+
}
55182
}
56183
}
57184
}
@@ -62,12 +189,4 @@ fun watchFile(file: File): Any? {
62189
}
63190
}
64191
return event
65-
}
66-
val LocalPreferences = compositionLocalOf<Properties> { error("No preferences provided") }
67-
@Composable
68-
fun PreferencesProvider(content: @Composable () -> Unit){
69-
val preferences = loadPreferences()
70-
CompositionLocalProvider(LocalPreferences provides preferences){
71-
content()
72-
}
73192
}

0 commit comments

Comments
 (0)