Files
wIRC-desktop/src/main/kotlin/data/WindowPrefsRepository.kt
T
weishauptandClaude Opus 5.5 807903fe0f Add wIRC main scrollback, route services to it, remember window bounds, bump to 0.1.7
wIRC main scrollback
- The main window now has a "Server messages" scrollback using the same
  rendering and stick-to-bottom code as channel windows (extracted from
  ChannelScreen into a shared MessageScrollback composable).
- MOTD, numerics, errors, connection status, user modes, INVITE, ERROR,
  non-ACTION CTCP requests and notices/messages from services
  (NickServ, ChanServ, *Serv, Global, ...) and the server go there. Its
  history persists like any other buffer (history/wirc-main__main.json).
- Unread indicator: lines arriving while the main window is unfocused
  show as "N new" next to the label and in the window title/taskbar.

Private messages and /whois
- A PM or notice from a real user opens its query window, and reopens it
  if it was closed. Services and the server no longer open windows.
- /whois <nick> sends WHOIS and shows the formatted reply in the window
  it was typed in, or in wIRC main if that window has since closed.
- /me now sends a proper CTCP ACTION (the \x01 wrapper was missing).

Window position and size
- Main, channel and PM windows remember position and size in
  window_prefs.json under the existing "<serverId>__<name>" key (main
  uses "wirc-main__main"). The new windowX/windowY/windowWidth/
  windowHeight fields are optional, so older files still load.
- A saved position is only used if the window's title strip is on a
  connected monitor; otherwise, and for windows with no saved position,
  windows cascade from the primary screen's top-left with a 32dp offset.
- window_prefs.json is now written via a temp file + atomic move.

Cleanup
- Remove the TEMP DEBUG aliasDebug tracing, ServerRepository.upsert
  printlns and connmgr-debug printlns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:08:38 -04:00

166 lines
7.5 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
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 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 = fontSize) }
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),
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
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("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}"