@@ -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" 
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 " 
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" 
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