package data import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.json.JSONObject import java.io.File import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption import java.util.concurrent.ConcurrentHashMap // Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude) const val MIN_FONT_SIZE = 10 const val MAX_FONT_SIZE = 18 private fun pickerFontSize(size: Float): Float = Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat() data class WindowDisplaySettings( val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white val fontSize: Float = 14f, val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace val showUserList: Boolean = true, val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange val nickColorsEnabled: Boolean = true, val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host ) /** * A window's last floating position and size, in dp (which Compose Desktop maps 1:1 to * AWT's logical screen coordinates). Every field is optional in window_prefs.json, so files * written before this existed still load - a window with no saved bounds just gets a * cascaded default (see ui/WindowPlacement.kt). */ data class WindowBounds( val x: Float?, val y: Float?, val width: Float?, val height: Float? ) /** * Desktop equivalent of the Android DataStore-backed repository. Settings for every * buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage * id, loaded once at startup and rewritten on every change. Each buffer's settings are * exposed as a StateFlow so every open window for that buffer updates live, same as * DataStore's Flow did on Android. * * Window bounds live in the same JSON object per key (windowX/windowY/windowWidth/ * windowHeight) but are kept out of the display-settings StateFlow, so moving or resizing * a window doesn't recompose its content. */ class WindowPrefsRepository(rootDir: File) { private val file = File(rootDir, "window_prefs.json") private val lock = Any() private val flows = ConcurrentHashMap>() private val stored = mutableMapOf() private val storedBounds = mutableMapOf() init { loadAll() } fun settingsFor(bufferKeyId: String): StateFlow = flowFor(bufferKeyId).asStateFlow() fun setAlpha(bufferKeyId: String, alpha: Float) = update(bufferKeyId) { it.copy(backgroundAlpha = alpha) } fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) } fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) } fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) } fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) } fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) = update(bufferKeyId) { it.copy(mentionHighlightColorArgb = colorArgb) } fun setNickColorsEnabled(bufferKeyId: String, enabled: Boolean) = update(bufferKeyId) { it.copy(nickColorsEnabled = enabled) } fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) } fun boundsFor(bufferKeyId: String): WindowBounds? = synchronized(lock) { storedBounds[bufferKeyId] } fun setWindowBounds(bufferKeyId: String, bounds: WindowBounds) { synchronized(lock) { if (storedBounds[bufferKeyId] == bounds) return storedBounds[bufferKeyId] = bounds persist() } } private fun flowFor(bufferKeyId: String): MutableStateFlow = flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) } private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) { synchronized(lock) { val updated = transform(stored[bufferKeyId] ?: WindowDisplaySettings()) stored[bufferKeyId] = updated flowFor(bufferKeyId).value = updated persist() } } private fun JSONObject.optFloatOrNull(key: String): Float? = if (has(key) && !isNull(key)) optDouble(key).toFloat().takeIf { !it.isNaN() } else null private fun loadAll() { if (!file.exists()) return try { val obj = JSONObject(file.readText()) obj.keys().forEach { key -> val o = obj.getJSONObject(key) stored[key] = WindowDisplaySettings( backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), textColorMircIndex = o.optInt("textColorMircIndex", 0), // Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()), fontId = o.optString("fontId", "monospace"), showUserList = o.optBoolean("showUserList", true), mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(), nickColorsEnabled = o.optBoolean("nickColorsEnabled", true), showImagePreviews = o.optBoolean("showImagePreviews", false) ) val bounds = WindowBounds( x = o.optFloatOrNull("windowX"), y = o.optFloatOrNull("windowY"), width = o.optFloatOrNull("windowWidth"), height = o.optFloatOrNull("windowHeight") ) if (bounds != WindowBounds(null, null, null, null)) storedBounds[key] = bounds } } catch (e: Exception) { stored.clear() storedBounds.clear() } } // Written to a temp file and moved into place, same as MessageHistoryRepository - this // file is now rewritten every time a window is moved or resized, so a crash mid-write // must not be able to leave a truncated file (which loadAll would read as "no settings // at all" and the next save would make permanent). private fun persist() { val obj = JSONObject() (stored.keys + storedBounds.keys).forEach { key -> obj.put(key, JSONObject().apply { stored[key]?.let { settings -> put("backgroundAlpha", settings.backgroundAlpha) put("textColorMircIndex", settings.textColorMircIndex) put("fontSize", settings.fontSize) put("fontId", settings.fontId) put("showUserList", settings.showUserList) put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb) put("nickColorsEnabled", settings.nickColorsEnabled) put("showImagePreviews", settings.showImagePreviews) } storedBounds[key]?.let { bounds -> bounds.x?.let { put("windowX", it) } bounds.y?.let { put("windowY", it) } bounds.width?.let { put("windowWidth", it) } bounds.height?.let { put("windowHeight", it) } } }) } file.parentFile?.mkdirs() val tmp = File(file.parentFile, "${file.name}.tmp") tmp.writeText(obj.toString()) try { Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } catch (e: AtomicMoveNotSupportedException) { Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) } } } fun BufferKey.storageId(): String = "${serverId}__${name}"