Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import org.gradle.internal.jvm.Jvm
import org.gradle.internal.os.OperatingSystem
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.compose.ExperimentalComposeLibrary
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download
Expand Down Expand Up @@ -119,6 +120,8 @@ dependencies {
implementation(libs.markdown)
implementation(libs.markdownJVM)

@OptIn(ExperimentalComposeLibrary::class)
testImplementation(compose.uiTest)
testImplementation(kotlin("test"))
testImplementation(libs.mockitoKotlin)
testImplementation(libs.junitJupiter)
Expand Down
163 changes: 141 additions & 22 deletions app/src/processing/app/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,183 @@ package processing.app

import androidx.compose.runtime.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.launch
import java.io.File
import java.io.InputStream
import java.nio.file.*
import java.util.Properties

/*
The ReactiveProperties class extends the standard Java Properties class
to provide reactive capabilities using Jetpack Compose's mutableStateMapOf.
This allows UI components to automatically update when preference values change.
*/
class ReactiveProperties: Properties() {
val snapshotStateMap = mutableStateMapOf<String, String>()

override fun setProperty(key: String, value: String) {
super.setProperty(key, value)
snapshotStateMap[key] = value
}

override fun getProperty(key: String): String? {
return snapshotStateMap[key] ?: super.getProperty(key)
}

operator fun get(key: String): String? = getProperty(key)

operator fun set(key: String, value: String) {
setProperty(key, value)
}
}

/*
A CompositionLocal to provide access to the ReactiveProperties instance
throughout the composable hierarchy.
*/
val LocalPreferences = compositionLocalOf<ReactiveProperties> { error("No preferences provided") }

const val PREFERENCES_FILE_NAME = "preferences.txt"
const val DEFAULTS_FILE_NAME = "defaults.txt"

fun PlatformStart(){
Platform.inst ?: Platform.init()
}
/*
This composable function sets up a preferences provider that manages application settings.
It initializes the preferences from a file, watches for changes to that file, and saves
any updates back to the file. It uses a ReactiveProperties class to allow for reactive
updates in the UI when preferences change.

usage:
PreferencesProvider {
// Your app content here
}

to access preferences:
val preferences = LocalPreferences.current
val someSetting = preferences["someKey"] ?: "defaultValue"
preferences["someKey"] = "newValue"

This will automatically save to the preferences file and update any UI components
that are observing that key.

to override the preferences file (for testing, etc)
System.setProperty("processing.app.preferences.file", "/path/to/your/preferences.txt")
to override the debounce time (in milliseconds)
System.setProperty("processing.app.preferences.debounce", "200")

*/
@OptIn(FlowPreview::class)
@Composable
fun loadPreferences(): Properties{
PlatformStart()
fun PreferencesProvider(content: @Composable () -> Unit){
val preferencesFileOverride: File? = System.getProperty("processing.app.preferences.file")?.let { File(it) }
val preferencesDebounceOverride: Long? = System.getProperty("processing.app.preferences.debounce")?.toLongOrNull()

val settingsFolder = Platform.getSettingsFolder()
val preferencesFile = settingsFolder.resolve(PREFERENCES_FILE_NAME)
// Initialize the platform (if not already done) to ensure we have access to the settings folder
remember {
Platform.init()
}

// Grab the preferences file, creating it if it doesn't exist
// TODO: This functionality should be separated from the `Preferences` class itself
val settingsFolder = Platform.getSettingsFolder()
val preferencesFile = preferencesFileOverride ?: settingsFolder.resolve(PREFERENCES_FILE_NAME)
if(!preferencesFile.exists()){
preferencesFile.mkdirs()
preferencesFile.createNewFile()
}
watchFile(preferencesFile)

return Properties().apply {
load(ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME) ?: InputStream.nullInputStream())
load(preferencesFile.inputStream())
val update = watchFile(preferencesFile)


val properties = remember(preferencesFile, update) {
ReactiveProperties().apply {
val defaultsStream = ClassLoader.getSystemResourceAsStream(DEFAULTS_FILE_NAME)
?: InputStream.nullInputStream()
load(defaultsStream
.reader(Charsets.UTF_8)
)
load(preferencesFile
.inputStream()
.reader(Charsets.UTF_8)
)
}
}

val initialState = remember(properties) { properties.snapshotStateMap.toMap() }

// Listen for changes to the preferences and save them to file
LaunchedEffect(properties) {
snapshotFlow { properties.snapshotStateMap.toMap() }
.dropWhile { it == initialState }
.debounce(preferencesDebounceOverride ?: 100)
.collect {

// Save the preferences to file, sorted alphabetically
preferencesFile.outputStream().use { output ->
output.write(
properties.entries
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.key.toString() })
.joinToString("\n") { (key, value) -> "$key=$value" }
.toByteArray()
)
}
}
}

CompositionLocalProvider(LocalPreferences provides properties){
content()
}

}

/*
This composable function watches a specified file for modifications. When the file is modified,
it updates a state variable with the latest WatchEvent. This can be useful for triggering UI updates
or other actions in response to changes in the file.

To watch the file at the fasted speed (for testing) set the following system property:
System.setProperty("processing.app.watchfile.forced", "true")
*/
@Composable
fun watchFile(file: File): Any? {
val forcedWatch: Boolean = System.getProperty("processing.app.watchfile.forced").toBoolean()

val scope = rememberCoroutineScope()
var event by remember(file) { mutableStateOf<WatchEvent<*>?> (null) }

DisposableEffect(file){
val fileSystem = FileSystems.getDefault()
val watcher = fileSystem.newWatchService()

var active = true

// In forced mode we just poll the last modified time of the file
// This is not efficient but works better for testing with temp files
val toWatch = { file.lastModified() }
var state = toWatch()

val path = file.toPath()
val parent = path.parent
val key = parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY)
scope.launch(Dispatchers.IO) {
while (active) {
for (modified in key.pollEvents()) {
if (modified.context() != path.fileName) continue
event = modified
if(forcedWatch) {
if(toWatch() == state) continue
state = toWatch()
event = object : WatchEvent<Path> {
override fun count(): Int = 1
override fun context(): Path = file.toPath().fileName
override fun kind(): WatchEvent.Kind<Path> = StandardWatchEventKinds.ENTRY_MODIFY
override fun toString(): String = "ForcedEvent(${context()})"
}
continue
}else{
for (modified in key.pollEvents()) {
if (modified.context() != path.fileName) continue
event = modified
}
}
}
}
Expand All @@ -62,12 +189,4 @@ fun watchFile(file: File): Any? {
}
}
return event
}
val LocalPreferences = compositionLocalOf<Properties> { error("No preferences provided") }
@Composable
fun PreferencesProvider(content: @Composable () -> Unit){
val preferences = loadPreferences()
CompositionLocalProvider(LocalPreferences provides preferences){
content()
}
}
Loading