Skip to content

Commit 7965931

Browse files
authored
Merge pull request #1304 from Stefterv/preferences-screen
Refactor preferences to Jetpack Compose UI
2 parents 830ecea + 5a6f7fa commit 7965931

File tree

10 files changed

+932
-56
lines changed

10 files changed

+932
-56
lines changed

app/src/processing/app/Base.java

Lines changed: 2 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,7 @@ 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+
PreferencesKt.show();
21972195
}
21982196

21992197

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)