@@ -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,84 @@ class ReactiveProperties: Properties() {
3434 setProperty(key, value)
3535 }
3636}
37+
3738val LocalPreferences = compositionLocalOf<ReactiveProperties > { error(" No preferences provided" ) }
39+
40+ const val PREFERENCES_FILE_NAME = " preferences.txt"
41+ const val DEFAULTS_FILE_NAME = " defaults.txt"
42+
43+ /*
44+ This composable function sets up a preferences provider that manages application settings.
45+ It initializes the preferences from a file, watches for changes to that file, and saves
46+ any updates back to the file. It uses a ReactiveProperties class to allow for reactive
47+ updates in the UI when preferences change.
48+
49+ usage:
50+ PreferencesProvider {
51+ // Your app content here
52+ }
53+
54+ to access preferences:
55+ val preferences = LocalPreferences.current
56+ val someSetting = preferences["someKey"] ?: "defaultValue"
57+ preferences["someKey"] = "newValue"
58+
59+ This will automatically save to the preferences file and update any UI components
60+ that are observing that key.
61+
62+ to override the preferences file (for testing, etc)
63+ System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
64+ to override the debounce time (in milliseconds)
65+ System.setProperty("processing.app.preferences.debounce", "200")
66+
67+ */
3868@OptIn(FlowPreview ::class )
3969@Composable
4070fun PreferencesProvider (content : @Composable () -> Unit ){
71+ val preferencesFileOverride: File ? = System .getProperty(" processing.app.preferences.file" )?.let { File (it) }
72+ val preferencesDebounceOverride: Long? = System .getProperty(" processing.app.preferences.debounce" )?.toLongOrNull()
73+
74+ // Initialize the platform (if not already done) to ensure we have access to the settings folder
4175 remember {
4276 Platform .init ()
4377 }
4478
79+ // Grab the preferences file, creating it if it doesn't exist
80+ // TODO: This functionality should be separated from the `Preferences` class itself
4581 val settingsFolder = Platform .getSettingsFolder()
46- val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME )
82+ val preferencesFile = preferencesFileOverride ? : settingsFolder.resolve(PREFERENCES_FILE_NAME )
4783 if (! preferencesFile.exists()){
4884 preferencesFile.mkdirs()
4985 preferencesFile.createNewFile()
5086 }
5187
5288 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- }}
5789
58- val initialState = remember(properties) { properties._stateMap .toMap() }
5990
91+ val properties = remember(preferencesFile, update) {
92+ ReactiveProperties ().apply {
93+ val defaultsStream = ClassLoader .getSystemResourceAsStream(DEFAULTS_FILE_NAME )
94+ ? : InputStream .nullInputStream()
95+ load(defaultsStream
96+ .reader(Charsets .UTF_8 )
97+ )
98+ load(preferencesFile
99+ .inputStream()
100+ .reader(Charsets .UTF_8 )
101+ )
102+ }
103+ }
104+
105+ val initialState = remember(properties) { properties.snapshotStateMap.toMap() }
106+
107+ // Listen for changes to the preferences and save them to file
60108 LaunchedEffect (properties) {
61- snapshotFlow { properties._stateMap .toMap() }
109+ snapshotFlow { properties.snapshotStateMap .toMap() }
62110 .dropWhile { it == initialState }
63- .debounce(100 )
111+ .debounce(preferencesDebounceOverride ? : 100 )
64112 .collect {
113+
114+ // Save the preferences to file, sorted alphabetically
65115 preferencesFile.outputStream().use { output ->
66116 output.write(
67117 properties.entries
@@ -78,24 +128,53 @@ fun PreferencesProvider(content: @Composable () -> Unit){
78128 }
79129
80130}
131+
132+ /*
133+ This composable function watches a specified file for modifications. When the file is modified,
134+ it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates
135+ or other actions in response to changes in the file.
136+
137+ To watch the file at the fasted speed (for testing) set the following system property:
138+ System.setProperty("processing.app.watchfile.forced", "true")
139+ */
81140@Composable
82141fun watchFile (file : File ): Any? {
142+ val forcedWatch: Boolean = System .getProperty(" processing.app.watchfile.forced" ).toBoolean()
143+
83144 val scope = rememberCoroutineScope()
84145 var event by remember(file) { mutableStateOf<WatchEvent <* >? > (null ) }
85146
86147 DisposableEffect (file){
87148 val fileSystem = FileSystems .getDefault()
88149 val watcher = fileSystem.newWatchService()
150+
89151 var active = true
90152
153+ // In forced mode we just poll the last modified time of the file
154+ // This is not efficient but works better for testing with temp files
155+ val toWatch = { file.lastModified() }
156+ var state = toWatch()
157+
91158 val path = file.toPath()
92159 val parent = path.parent
93160 val key = parent.register(watcher, StandardWatchEventKinds .ENTRY_MODIFY )
94161 scope.launch(Dispatchers .IO ) {
95162 while (active) {
96- for (modified in key.pollEvents()) {
97- if (modified.context() != path.fileName) continue
98- event = modified
163+ if (forcedWatch) {
164+ if (toWatch() == state) continue
165+ state = toWatch()
166+ event = object : WatchEvent <Path > {
167+ override fun count (): Int = 1
168+ override fun context (): Path = file.toPath().fileName
169+ override fun kind (): WatchEvent .Kind <Path > = StandardWatchEventKinds .ENTRY_MODIFY
170+ override fun toString (): String = " ForcedEvent(${context()} )"
171+ }
172+ continue
173+ }else {
174+ for (modified in key.pollEvents()) {
175+ if (modified.context() != path.fileName) continue
176+ event = modified
177+ }
99178 }
100179 }
101180 }
0 commit comments