@@ -2,56 +2,183 @@ package processing.app
22
33import androidx.compose.runtime.*
44import kotlinx.coroutines.Dispatchers
5+ import kotlinx.coroutines.FlowPreview
6+ import kotlinx.coroutines.flow.debounce
7+ import kotlinx.coroutines.flow.dropWhile
58import kotlinx.coroutines.launch
69import java.io.File
710import java.io.InputStream
811import java.nio.file.*
912import 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
1244const val PREFERENCES_FILE_NAME = " preferences.txt"
1345const 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
38145fun 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