UserThemeManager.kt
src/jvmMain/kotlin/afield/app/server/UserThemeManager.kt · 143 lines
package afield.app.server
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.app.protocol.Message
import afield.app.protocol.UserTheme
import afield.app.protocol._send
import afield.app.protocol.payload
import afield.audio.synth.*
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.app.diagnostics.logging.lprintln
import io.ktor.websocket.WebSocketSession
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
Serves the user's CSS theme from ~/.afield/theme.css to the browser and pushes it again whenever the file changes.
UserThemeManager.start creates the file with seed tokens if it is missing, then polls its modification time and size and broadcasts the new CSS to every client on a change. UserThemeManager.sendTo sends the current theme to one session.
object UserThemeManager
Owns ~/.afield/theme.css and its delivery to connected clients.
object UserThemeManager {
private val themeDir = File(System.getProperty("user.home"), ".afield")
private val themeFile = File(themeDir, "theme.css")
private const val seedCss = """/* Field user theme tokens.
* Edit this file while Field is running; changes are applied live.
* Field applies the field-user-theme class to the document root.
*/
.field-user-theme {
--field-font: "Iosevka Web", monospace;
--field-font-system: "Iosevka Web", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--field-font-code: "Iosevka Web", Monaco, Menlo, Consolas, "Courier New", monospace;
--field-font-code-fallback: "Iosevka", "JetBrains Mono", Menlo, Consolas, monospace;
/* Palette: the small set to override for a new theme. */
--field-black-rgb: 0, 0, 0;
--field-white-rgb: 255, 255, 255;
--field-base-rgb: 20, 20, 20;
--field-editor-rgb: 30, 30, 30;
--field-accent-rgb: 72, 201, 255;
--field-danger-rgb: 255, 80, 80;
--field-warning-rgb: 235, 150, 70;
--field-search-rgb: 255, 209, 102;
--field-info-rgb: 100, 150, 255;
--field-success-rgb: 80, 180, 100;
}
"""
@Volatile
private var started = false
@Volatile
private var lastSignature: String = ""
fun start()
@OptIn(DelicateCoroutinesApi::class)
fun start() {
if (started) return
started = true
ensureThemeFile()
lastSignature = signature()
GlobalScope.launch(Dispatchers.IO) {
while (true) {
try {
ensureThemeFile()
val next = signature()
if (next != lastSignature) {
lastSignature = next
broadcast()
}
} catch (t: Throwable) {
lprintln("error,user-theme", "Error watching ${themeFile.absolutePath}: ${t.message}")
}
delay(750.milliseconds)
}
}
}
fun sendTo(session: WebSocketSession?)
body — 45 lines, from line 98
fun sendTo(session: WebSocketSession?) {
ensureThemeFile()
_send(session, Message("set-user-theme").payload(readTheme()))
}
private fun broadcast() {
val theme = readTheme()
lprintln("info,user-theme", "Reloaded user theme from ${theme.path}")
_send(Message("set-user-theme").payload(theme))
}
private fun ensureThemeFile() {
if (!themeDir.exists()) themeDir.mkdirs()
if (!themeFile.exists()) {
themeFile.writeText(seedCss)
lprintln("info,user-theme", "Created user theme file at ${themeFile.absolutePath}")
} else {
val css = themeFile.readText()
if (shouldWrapBareThemeCss(css)) {
themeFile.writeText(wrapBareThemeCss(css))
lprintln("info,user-theme", "Wrapped user theme file as valid CSS at ${themeFile.absolutePath}")
}
}
}
private fun shouldWrapBareThemeCss(css: String): Boolean {
val trimmed = css.trim()
return trimmed.isNotEmpty() && !trimmed.contains("{")
}
private fun wrapBareThemeCss(css: String): String =
".field-user-theme {\n${css.trim().prependIndent(" ")}\n}\n"
private fun readTheme(): UserTheme =
UserTheme(
css = themeFile.readText(),
path = themeFile.absolutePath,
lastModified = themeFile.lastModified()
)
private fun signature(): String {
if (!themeFile.exists()) return "missing"
return "${themeFile.lastModified()}:${themeFile.length()}"
}
}