@@ -8,24 +8,24 @@ import kotlinx.coroutines.flow.dropWhile
88import kotlinx.coroutines.launch
99import java.io.File
1010import java.io.InputStream
11- import java.io.OutputStream
1211import java.nio.file.*
1312import java.util.Properties
1413
15-
16- const val PREFERENCES_FILE_NAME = " preferences.txt"
17- const val DEFAULTS_FILE_NAME = " defaults.txt"
18-
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+ */
1919class ReactiveProperties : Properties () {
20- val _stateMap = mutableStateMapOf<String , String >()
20+ val snapshotStateMap = mutableStateMapOf<String , String >()
2121
2222 override fun setProperty (key : String , value : String ) {
2323 super .setProperty(key, value)
24- _stateMap [key] = value
24+ snapshotStateMap [key] = value
2525 }
2626
2727 override fun getProperty (key : String ): String? {
28- return _stateMap [key] ? : super .getProperty(key)
28+ return snapshotStateMap [key] ? : super .getProperty(key)
2929 }
3030
3131 operator fun get (key : String ): String? = getProperty(key)
@@ -34,34 +34,88 @@ class ReactiveProperties: Properties() {
3434 setProperty(key, value)
3535 }
3636}
37+
38+ /*
39+ A CompositionLocal to provide access to the ReactiveProperties instance
40+ throughout the composable hierarchy.
41+ */
3742val LocalPreferences = compositionLocalOf<ReactiveProperties > { error(" No preferences provided" ) }
43+
44+ const val PREFERENCES_FILE_NAME = " preferences.txt"
45+ const val DEFAULTS_FILE_NAME = " defaults.txt"
46+
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.
52+
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+ */
3872@OptIn(FlowPreview ::class )
3973@Composable
4074fun 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()
77+
78+ // Initialize the platform (if not already done) to ensure we have access to the settings folder
4179 remember {
4280 Platform .init ()
4381 }
4482
83+ // Grab the preferences file, creating it if it doesn't exist
84+ // TODO: This functionality should be separated from the `Preferences` class itself
4585 val settingsFolder = Platform .getSettingsFolder()
46- val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME )
86+ val preferencesFile = preferencesFileOverride ? : settingsFolder.resolve(PREFERENCES_FILE_NAME )
4787 if (! preferencesFile.exists()){
4888 preferencesFile.mkdirs()
4989 preferencesFile.createNewFile()
5090 }
5191
5292 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- }}
5793
58- val initialState = remember(properties) { properties._stateMap .toMap() }
5994
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
60112 LaunchedEffect (properties) {
61- snapshotFlow { properties._stateMap .toMap() }
113+ snapshotFlow { properties.snapshotStateMap .toMap() }
62114 .dropWhile { it == initialState }
63- .debounce(100 )
115+ .debounce(preferencesDebounceOverride ? : 100 )
64116 .collect {
117+
118+ // Save the preferences to file, sorted alphabetically
65119 preferencesFile.outputStream().use { output ->
66120 output.write(
67121 properties.entries
@@ -78,24 +132,53 @@ fun PreferencesProvider(content: @Composable () -> Unit){
78132 }
79133
80134}
135+
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+ */
81144@Composable
82145fun watchFile (file : File ): Any? {
146+ val forcedWatch: Boolean = System .getProperty(" processing.app.watchfile.forced" ).toBoolean()
147+
83148 val scope = rememberCoroutineScope()
84149 var event by remember(file) { mutableStateOf<WatchEvent <* >? > (null ) }
85150
86151 DisposableEffect (file){
87152 val fileSystem = FileSystems .getDefault()
88153 val watcher = fileSystem.newWatchService()
154+
89155 var active = true
90156
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+
91162 val path = file.toPath()
92163 val parent = path.parent
93164 val key = parent.register(watcher, StandardWatchEventKinds .ENTRY_MODIFY )
94165 scope.launch(Dispatchers .IO ) {
95166 while (active) {
96- for (modified in key.pollEvents()) {
97- if (modified.context() != path.fileName) continue
98- 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+ }
99182 }
100183 }
101184 }
0 commit comments