@@ -3,29 +3,30 @@ package processing.app
33import androidx.compose.runtime.*
44import kotlinx.coroutines.Dispatchers
55import kotlinx.coroutines.FlowPreview
6+ import kotlinx.coroutines.delay
67import kotlinx.coroutines.flow.debounce
78import kotlinx.coroutines.flow.dropWhile
89import kotlinx.coroutines.launch
910import java.io.File
1011import java.io.InputStream
11- import java.io.OutputStream
1212import java.nio.file.*
1313import java.util.Properties
1414
15-
16- const val PREFERENCES_FILE_NAME = " preferences.txt"
17- const val DEFAULTS_FILE_NAME = " defaults.txt"
18-
15+ /*
16+ The ReactiveProperties class extends the standard Java Properties class
17+ to provide reactive capabilities using Jetpack Compose's mutableStateMapOf.
18+ This allows UI components to automatically update when preference values change.
19+ */
1920class ReactiveProperties : Properties () {
20- val _stateMap = mutableStateMapOf<String , String >()
21+ val snapshotStateMap = mutableStateMapOf<String , String >()
2122
2223 override fun setProperty (key : String , value : String ) {
2324 super .setProperty(key, value)
24- _stateMap [key] = value
25+ snapshotStateMap [key] = value
2526 }
2627
2728 override fun getProperty (key : String ): String? {
28- return _stateMap [key] ? : super .getProperty(key)
29+ return snapshotStateMap [key] ? : super .getProperty(key)
2930 }
3031
3132 operator fun get (key : String ): String? = getProperty(key)
@@ -34,34 +35,84 @@ class ReactiveProperties: Properties() {
3435 setProperty(key, value)
3536 }
3637}
38+
3739val LocalPreferences = compositionLocalOf<ReactiveProperties > { error(" No preferences provided" ) }
40+
41+ const val PREFERENCES_FILE_NAME = " preferences.txt"
42+ const val DEFAULTS_FILE_NAME = " defaults.txt"
43+
44+ /*
45+ This composable function sets up a preferences provider that manages application settings.
46+ It initializes the preferences from a file, watches for changes to that file, and saves
47+ any updates back to the file. It uses a ReactiveProperties class to allow for reactive
48+ updates in the UI when preferences change.
49+
50+ usage:
51+ PreferencesProvider {
52+ // Your app content here
53+ }
54+
55+ to access preferences:
56+ val preferences = LocalPreferences.current
57+ val someSetting = preferences["someKey"] ?: "defaultValue"
58+ preferences["someKey"] = "newValue"
59+
60+ This will automatically save to the preferences file and update any UI components
61+ that are observing that key.
62+
63+ to override the preferences file (for testing, etc)
64+ System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
65+ to override the debounce time (in milliseconds)
66+ System.setProperty("processing.app.preferences.debounce", "200")
67+
68+ */
3869@OptIn(FlowPreview ::class )
3970@Composable
4071fun PreferencesProvider (content : @Composable () -> Unit ){
72+ val preferencesFileOverride: File ? = System .getProperty(" processing.app.preferences.file" )?.let { File (it) }
73+ val preferencesDebounceOverride: Long? = System .getProperty(" processing.app.preferences.debounce" )?.toLongOrNull()
74+
75+ // Initialize the platform (if not already done) to ensure we have access to the settings folder
4176 remember {
4277 Platform .init ()
4378 }
4479
80+ // Grab the preferences file, creating it if it doesn't exist
81+ // TODO: This functionality should be separated from the `Preferences` class itself
4582 val settingsFolder = Platform .getSettingsFolder()
46- val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME )
83+ val preferencesFile = preferencesFileOverride ? : settingsFolder.resolve(PREFERENCES_FILE_NAME )
4784 if (! preferencesFile.exists()){
4885 preferencesFile.mkdirs()
4986 preferencesFile.createNewFile()
5087 }
5188
5289 val update = watchFile(preferencesFile)
53- val properties = remember(preferencesFile, update) { ReactiveProperties ().apply {
54- load((ClassLoader .getSystemResourceAsStream(DEFAULTS_FILE_NAME )? : InputStream .nullInputStream()).reader(Charsets .UTF_8 ))
55- load(preferencesFile.inputStream().reader(Charsets .UTF_8 ))
56- }}
5790
58- val initialState = remember(properties) { properties._stateMap .toMap() }
5991
92+ val properties = remember(preferencesFile, update) {
93+ ReactiveProperties ().apply {
94+ val defaultsStream = ClassLoader .getSystemResourceAsStream(DEFAULTS_FILE_NAME )
95+ ? : InputStream .nullInputStream()
96+ load(defaultsStream
97+ .reader(Charsets .UTF_8 )
98+ )
99+ load(preferencesFile
100+ .inputStream()
101+ .reader(Charsets .UTF_8 )
102+ )
103+ }
104+ }
105+
106+ val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
107+
108+ // Listen for changes to the preferences and save them to file
60109 LaunchedEffect (properties) {
61- snapshotFlow { properties._stateMap .toMap() }
110+ snapshotFlow { properties.snapshotStateMap .toMap() }
62111 .dropWhile { it == initialState }
63- .debounce(100 )
112+ .debounce(preferencesDebounceOverride ? : 100 )
64113 .collect {
114+
115+ // Save the preferences to file, sorted alphabetically
65116 preferencesFile.outputStream().use { output ->
66117 output.write(
67118 properties.entries
@@ -78,24 +129,53 @@ fun PreferencesProvider(content: @Composable () -> Unit){
78129 }
79130
80131}
132+
133+ /*
134+ This composable function watches a specified file for modifications. When the file is modified,
135+ it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates
136+ or other actions in response to changes in the file.
137+
138+ To watch the file at the fasted speed (for testing) set the following system property:
139+ System.setProperty("processing.app.watchfile.forced", "true")
140+ */
81141@Composable
82142fun watchFile (file : File ): Any? {
143+ val forcedWatch: Boolean = System .getProperty(" processing.app.watchfile.forced" ).toBoolean()
144+
83145 val scope = rememberCoroutineScope()
84146 var event by remember(file) { mutableStateOf<WatchEvent <* >? > (null ) }
85147
86148 DisposableEffect (file){
87149 val fileSystem = FileSystems .getDefault()
88150 val watcher = fileSystem.newWatchService()
151+
89152 var active = true
90153
154+ // In forced mode we just poll the last modified time of the file
155+ // This is not efficient but works better for testing with temp files
156+ val toWatch = { file.lastModified() }
157+ var state = toWatch()
158+
91159 val path = file.toPath()
92160 val parent = path.parent
93161 val key = parent.register(watcher, StandardWatchEventKinds .ENTRY_MODIFY )
94162 scope.launch(Dispatchers .IO ) {
95163 while (active) {
96- for (modified in key.pollEvents()) {
97- if (modified.context() != path.fileName) continue
98- event = modified
164+ if (forcedWatch) {
165+ if (toWatch() == state) continue
166+ state = toWatch()
167+ event = object : WatchEvent <Path > {
168+ override fun count (): Int = 1
169+ override fun context (): Path = file.toPath().fileName
170+ override fun kind (): WatchEvent .Kind <Path > = StandardWatchEventKinds .ENTRY_MODIFY
171+ override fun toString (): String = " ForcedEvent(${context()} )"
172+ }
173+ continue
174+ }else {
175+ for (modified in key.pollEvents()) {
176+ if (modified.context() != path.fileName) continue
177+ event = modified
178+ }
99179 }
100180 }
101181 }
0 commit comments