- After a move, windows snap to the Windows desktop icon grid; after a resize, the window height is trimmed so the scrollback ends on a whole line. - The font size slider is replaced by square 10 to 18 buttons. Sizes saved by the old slider round to the nearest picker size. - Settings and font dialogs always use white text, so a dark window text color no longer makes their labels invisible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
8.2 KiB
Kotlin
179 lines
8.2 KiB
Kotlin
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<String, MutableStateFlow<WindowDisplaySettings>>()
|
|
private val stored = mutableMapOf<String, WindowDisplaySettings>()
|
|
private val storedBounds = mutableMapOf<String, WindowBounds>()
|
|
|
|
init {
|
|
loadAll()
|
|
}
|
|
|
|
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = 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<WindowDisplaySettings> =
|
|
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}"
|