From 807903fe0f1a744dd10896caa0aa5643ec550cc9 Mon Sep 17 00:00:00 2001 From: weishaupt Date: Mon, 5 Oct 2026 15:08:38 -0400 Subject: [PATCH] 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 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 "__" 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 --- build.gradle.kts | 4 +- src/main/kotlin/Main.kt | 35 +- src/main/kotlin/data/AliasRepository.kt | 22 +- src/main/kotlin/data/Models.kt | 29 + src/main/kotlin/data/ServerRepository.kt | 6 - src/main/kotlin/data/WindowPrefsRepository.kt | 98 ++- src/main/kotlin/irc/ConnectionManager.kt | 240 +++++-- src/main/kotlin/ui/ChannelScreen.kt | 592 +++++++++--------- src/main/kotlin/ui/ChannelWindow.kt | 10 +- src/main/kotlin/ui/MainScreen.kt | 61 +- src/main/kotlin/ui/WindowPlacement.kt | 158 +++++ 11 files changed, 866 insertions(+), 389 deletions(-) create mode 100644 src/main/kotlin/ui/WindowPlacement.kt diff --git a/build.gradle.kts b/build.gradle.kts index e712561..ae414d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tpuahsiew.wirc" -version = "0.1.6" +version = "0.1.7" // No project-level repositories{} block here: settings.gradle.kts sets // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already @@ -38,7 +38,7 @@ compose.desktop { appResourcesRootDir.set(project.file("installerResources")) targetFormats(TargetFormat.Msi, TargetFormat.Exe) packageName = "wIRC" - packageVersion = "0.1.6" + packageVersion = "0.1.7" windows { menuGroup = "wIRC" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index d13e6b5..417f90d 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -2,6 +2,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -9,16 +10,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState import data.AliasRepository import data.BufferKey +import data.MAIN_BUFFER_KEY import data.ServerRepository import data.WindowPrefsRepository +import data.storageId import irc.ConnectionManager import ui.ChannelWindow import ui.MainScreen +import ui.rememberPersistentWindowState import java.io.File // Material 3's darkColorScheme() defaults to a purple accent; this keeps everything @@ -51,6 +56,13 @@ fun main() = application { val prefsRepo = remember { WindowPrefsRepository(appRoot) } var openBuffers by remember { mutableStateOf(setOf()) } + var mainUnread by remember { mutableStateOf(0) } + + // ConnectionManager routes WHOIS replies to the window that asked only while that + // window is still open, so it needs to know which ones are. + LaunchedEffect(openBuffers) { + ConnectionManager.openWindows = openBuffers + } // Full shutdown: same steps /quit (bare, no args) already runs from a channel window - // disconnect every server, then end the process. Shared here so the "Exit wIRC" button @@ -65,31 +77,44 @@ fun main() = application { // ConnectionManager's sockets just live for as long as this process runs, and this // listener (which the Android foreground service used to own) can live directly at // the application level instead. + // + // Fires for every private message to or from a real user, so a query window the user + // closed reopens when that person messages again. Adding a key that's already open is + // a no-op (openBuffers is a set). LaunchedEffect(Unit) { - ConnectionManager.newQueryBuffers.collect { key -> + ConnectionManager.queryWindowRequests.collect { key -> openBuffers = openBuffers + key } } + // Restored from / saved to window_prefs.json under MAIN_BUFFER_KEY's storage id + // ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt. + val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp)) + val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState() + // There's no persistent tray/notification surface on this build, so closing the main // server-list window quits the whole app (and every channel window with it) - the // same "closing the primary window ends the session" convention most desktop chat // apps without a tray icon follow. Window( onCloseRequest = ::exitApplication, - title = "wIRC - Main", + // The unread count shows in the taskbar too, so new server messages are visible + // even when the main window is behind other windows. + title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main", icon = painterResource("icon.png"), - state = rememberWindowState() + state = mainWindowState ) { MaterialTheme(colorScheme = neutralDarkScheme) { Surface { MainScreen( repo = repo, colorScheme = neutralDarkScheme, + displaySettings = mainDisplaySettings, onOpenChannel = { serverId, channelName -> openBuffers = openBuffers + BufferKey(serverId, channelName) }, - onExitApp = shutdownApp + onExitApp = shutdownApp, + onUnreadChange = { mainUnread = it } ) } } diff --git a/src/main/kotlin/data/AliasRepository.kt b/src/main/kotlin/data/AliasRepository.kt index e65cda0..8cc7684 100644 --- a/src/main/kotlin/data/AliasRepository.kt +++ b/src/main/kotlin/data/AliasRepository.kt @@ -5,13 +5,6 @@ import java.io.File import java.net.URI import java.net.URLEncoder -// TEMP DEBUG (see task: alias multi-word arguments investigation) - remove once confirmed -// fixed. Traces the full alias pipeline: raw input -> matched alias -> substituted target -// -> the actual Desktop API call, so a silent failure anywhere in that chain is visible. -private fun aliasDebug(msg: String) { - println("[alias-debug] $msg") -} - // Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly, // before alias lookup even runs) - kept here too so a line in aliases.txt can never define // an alias that would shadow it. @@ -64,15 +57,9 @@ object AliasRepository { * "reload" name, which this repository never loads as an alias). */ fun tryRunAlias(command: String, args: String): Boolean { - aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"") val key = command.lowercase() if (key == RESERVED_RELOAD) return false - val action = aliases[key] - if (action == null) { - aliasDebug("no alias matched for \"$key\"") - return false - } - aliasDebug("matched \"$key\" -> \"$action\"") + val action = aliases[key] ?: return false runAction(action, args) return true } @@ -94,13 +81,12 @@ object AliasRepository { // literal, not a URL delimiter. val substitutedArg = if (type == "url") encodeArgForUrl(args) else args val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget - aliasDebug("type=\"$type\" rawTarget=\"$rawTarget\" substitutedArg=\"$substitutedArg\" -> target=\"$target\"") if (target.isBlank()) return try { when (type) { - "path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) } - "url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) } + "path" -> Desktop.getDesktop().open(File(target)) + "url" -> Desktop.getDesktop().browse(URI(target)) // Routed through "cmd /c start" (same launch path as double-clicking in // Explorer or the Run dialog) rather than exec'd directly. A direct // ProcessBuilder(target).start() calls CreateProcess, which does NOT honor @@ -110,7 +96,7 @@ object AliasRepository { // would. This also preserves bare-name resolution via Windows' App Paths // registry / PATH - not guaranteed to work for every app, since it depends // on that app having registered itself with Windows. - "run" -> { aliasDebug("ProcessBuilder start \"$target\""); ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() } + "run" -> ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"") } } catch (e: Exception) { diff --git a/src/main/kotlin/data/Models.kt b/src/main/kotlin/data/Models.kt index 84c4e54..adebd51 100644 --- a/src/main/kotlin/data/Models.kt +++ b/src/main/kotlin/data/Models.kt @@ -47,6 +47,35 @@ data class IrcMessage( /** Buffer key: server id + channel/query name (channel names include '#'). */ data class BufferKey(val serverId: String, val name: String) +/** + * wIRC main's own buffer: server messages (MOTD, numerics, errors, whois fallback) and + * services/server notices from every connected server. Server ids are built from the host + * with every non-alphanumeric character replaced by '_' (see ServerEditDialog), so one can + * never contain '-' - this id can't collide with a real server. Also used as the + * window_prefs.json key ("wirc-main__main") for the main window's saved position and size. + */ +val MAIN_BUFFER_KEY = BufferKey("wirc-main", "main") + +// Nicks that are network services rather than people. Their PRIVMSGs and NOTICEs go to +// wIRC main instead of opening a query window. Anything ending in "serv" is also treated +// as a service, which covers the less common ones (MemoServ, HostServ, SaslServ, ...). +private val KNOWN_SERVICE_NICKS = setOf("nickserv", "chanserv", "global", "alis", "q", "x") + +fun isServiceNick(nick: String): Boolean { + val lower = nick.lowercase() + return lower in KNOWN_SERVICE_NICKS || lower.endsWith("serv") +} + +/** + * True when a message's source is a service or the server itself rather than a real user. + * A user prefix always has the "nick!user@host" form; a missing prefix or one without '!' + * is a server name (e.g. "irc.rizon.io"). + */ +fun isServiceOrServerPrefix(prefix: String?): Boolean { + if (prefix == null || !prefix.contains('!')) return true + return isServiceNick(prefix.substringBefore('!')) +} + data class ChannelUser( val nick: String, val isOp: Boolean = false, diff --git a/src/main/kotlin/data/ServerRepository.kt b/src/main/kotlin/data/ServerRepository.kt index 41c46ff..e866e8b 100644 --- a/src/main/kotlin/data/ServerRepository.kt +++ b/src/main/kotlin/data/ServerRepository.kt @@ -42,16 +42,10 @@ class ServerRepository(rootDir: File) { } fun upsert(server: ServerConfig) { - // TEMP DEBUG (see task: on-connect identify command investigation) - confirms the - // Edit Server dialog's Save actually persists onConnectCommands, by writing then - // immediately re-reading the file back rather than trusting the in-memory value. - println("[ServerRepository] upsert(${server.id}): saving onConnectCommands=${server.onConnectCommands}") val current = loadAll().toMutableList() val idx = current.indexOfFirst { it.id == server.id } if (idx >= 0) current[idx] = server else current.add(server) saveAll(current) - val reloaded = loadAll().firstOrNull { it.id == server.id } - println("[ServerRepository] upsert(${server.id}): reloaded from disk onConnectCommands=${reloaded?.onConnectCommands}") } fun remove(serverId: String) { diff --git a/src/main/kotlin/data/WindowPrefsRepository.kt b/src/main/kotlin/data/WindowPrefsRepository.kt index 84b87c5..f3509a3 100644 --- a/src/main/kotlin/data/WindowPrefsRepository.kt +++ b/src/main/kotlin/data/WindowPrefsRepository.kt @@ -5,6 +5,9 @@ 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( @@ -17,19 +20,41 @@ data class WindowDisplaySettings( 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 var stored: MutableMap = loadAll() + private val stored = mutableMapOf() + private val storedBounds = mutableMapOf() + + init { + loadAll() + } fun settingsFor(bufferKeyId: String): StateFlow = flowFor(bufferKeyId).asStateFlow() @@ -47,8 +72,18 @@ class WindowPrefsRepository(rootDir: File) { 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(stored[bufferKeyId] ?: WindowDisplaySettings()) } + flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) } private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) { synchronized(lock) { @@ -59,14 +94,16 @@ class WindowPrefsRepository(rootDir: File) { } } - private fun loadAll(): MutableMap { - if (!file.exists()) return mutableMapOf() - return try { + 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()) - val result = mutableMapOf() obj.keys().forEach { key -> val o = obj.getJSONObject(key) - result[key] = WindowDisplaySettings( + stored[key] = WindowDisplaySettings( backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), textColorMircIndex = o.optInt("textColorMircIndex", 0), fontSize = o.optDouble("fontSize", 14.0).toFloat(), @@ -75,28 +112,53 @@ class WindowPrefsRepository(rootDir: File) { 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 } - result } catch (e: Exception) { - mutableMapOf() + 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.forEach { (key, settings) -> + (stored.keys + storedBounds.keys).forEach { key -> obj.put(key, JSONObject().apply { - 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) + 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() - file.writeText(obj.toString()) + 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) + } } } diff --git a/src/main/kotlin/irc/ConnectionManager.kt b/src/main/kotlin/irc/ConnectionManager.kt index 1c5b5d6..dff6ed4 100644 --- a/src/main/kotlin/irc/ConnectionManager.kt +++ b/src/main/kotlin/irc/ConnectionManager.kt @@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.concurrent.ConcurrentHashMap // Per-buffer message history cap. Named so it's a one-line change to temporarily lower @@ -28,6 +31,19 @@ private const val MESSAGE_HISTORY_CAP = 500 // reported slowness under real message volume. private const val SAVE_DEBOUNCE_MS = 2500L +// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target +// nick (params[1]) is what ties a reply line back to the /whois that asked for it. +// 318 is RPL_ENDOFWHOIS; 401/402 are the "no such nick/server" errors a WHOIS can get. +private val WHOIS_NUMERICS = setOf( + "275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319", + "320", "330", "335", "338", "378", "379", "401", "402", "671" +) + +// Numerics that only make sense inside a channel window and are already reflected there +// (353 fills the user list, see handleLine) or carry nothing worth showing on their own - +// kept out of wIRC main so joining a channel doesn't flood it. +private val CHANNEL_ONLY_NUMERICS = setOf("332", "333", "353", "366") + /** * App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all * windows read from the same source of truth via StateFlow. @@ -47,8 +63,19 @@ object ConnectionManager { private val _ownNicks = MutableStateFlow>(emptyMap()) val ownNicksFlow: StateFlow> = _ownNicks + // Buffers that currently have a window open, kept up to date by Main.kt. Read when a + // WHOIS reply arrives, to decide whether the window that asked is still there to show it. + @Volatile + var openWindows: Set = emptySet() + + // serverId -> (lowercased target nick -> the buffer whose window typed the /whois). + private val pendingWhois = ConcurrentHashMap>() + fun init(rootDir: File) { historyRepo = MessageHistoryRepository(rootDir) + // Seeds wIRC main from its saved history right away, so its scrollback is there at + // startup instead of appearing only once the first server line arrives. + enqueue { ensureBuffer(MAIN_BUFFER_KEY) } } private fun setOwnNick(serverId: String, nick: String) { @@ -56,11 +83,12 @@ object ConnectionManager { _ownNicks.update { it + (serverId to nick) } } - // Fires once for every brand-new private-message buffer (a query with someone - // who wasn't already an open window). Main.kt listens to this to auto-open a - // window for it, same as clicking a channel manually. - private val _newQueryBuffers = MutableSharedFlow(extraBufferCapacity = 16) - val newQueryBuffers: SharedFlow = _newQueryBuffers + // Fires for every private message to or from a real user (services and the server go + // to wIRC main instead). Main.kt adds the key to its set of open windows, which opens + // the query window if it isn't open - including reopening one the user closed - and + // is a no-op if it already is. + private val _queryWindowRequests = MutableSharedFlow(extraBufferCapacity = 64) + val queryWindowRequests: SharedFlow = _queryWindowRequests // Every buffer state change - incoming lines, local echoes of our own sends - goes // through this single FIFO queue, processed by one consumer coroutine. Previously each @@ -92,10 +120,6 @@ object ConnectionManager { } fun connect(config: ServerConfig) { - // TEMP DEBUG (see task: on-connect identify command investigation) - confirms - // whether the config this connect() call actually received has the on-connect - // commands the caller thinks it saved. - println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}") val existing = connections[config.id] if (existing != null) { // This used to just `return` here, making connect() a permanent no-op for the @@ -105,7 +129,6 @@ object ConnectionManager { // `config` was captured once at construction and never replaced. Tearing down // the old connection and replacing it means a connect() call always actually // uses whatever was most recently saved. - println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config") existing.disconnect() } setOwnNick(config.id, config.nick) @@ -113,7 +136,11 @@ object ConnectionManager { config = config, scope = scope, onLine = { line -> enqueue { handleLine(config.id, line) } }, - onStatus = { msg -> _statuses.update { it + (config.id to msg) } } + onStatus = { msg -> + _statuses.update { it + (config.id to msg) } + // Connection progress and errors aren't tied to any channel - wIRC main. + enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") } + } ) connections[config.id] = conn conn.connect() @@ -133,20 +160,32 @@ object ConnectionManager { fun sendChannelMessage(serverId: String, target: String, text: String) { connectionFor(serverId)?.sendMessage(target, text) val myNick = ownNicks[serverId] ?: return - val key = BufferKey(serverId, target) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) - enqueue { addMessage(key, msg) } + enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") } } // Same local-echo problem as sendChannelMessage, but for /me: the wire form is a // CTCP ACTION wrapper, while the buffer should just hold the plain action text // (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix). fun sendChannelAction(serverId: String, target: String, action: String) { - connectionFor(serverId)?.sendMessage(target, "ACTION $action") + connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001") val myNick = ownNicks[serverId] ?: return - val key = BufferKey(serverId, target) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) - enqueue { addMessage(key, msg) } + enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") } + } + + // Where a local echo goes: a channel's own buffer, wIRC main for a service (e.g. + // "/msg NickServ ..."), or a query buffer - opening its window - for a real user. + private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) { + when { + isChannelName(target) -> addMessage(BufferKey(serverId, target), msg) + isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText) + else -> { + val key = BufferKey(serverId, target) + addMessage(key, msg) + _queryWindowRequests.tryEmit(key) + } + } } // Raw passthrough for slash commands that don't have dedicated handling. @@ -154,6 +193,22 @@ object ConnectionManager { connectionFor(serverId)?.send(raw) } + /** + * Sends WHOIS and remembers which window asked, so the reply lines are shown in that + * window - or in wIRC main if it has been closed by the time they arrive. [args] is + * whatever followed "/whois"; its last word is the nick being looked up (the + * "/whois server nick" form puts the nick last too). + */ + fun whois(serverId: String, args: String, origin: BufferKey) { + val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() } + if (nick == null) { + addSystemMessage(serverId, origin.name, "Usage: /whois ") + return + } + pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin + sendRaw(serverId, "WHOIS ${args.trim()}") + } + // Local-only feedback (e.g. the /reload confirmation) that never touches the // wire - just appended to the buffer the same way any other SYSTEM line is. fun addSystemMessage(serverId: String, target: String, text: String) { @@ -177,12 +232,6 @@ object ConnectionManager { if (_buffers.value.containsKey(key)) return val seeded = historyRepo?.loadMessages(key) ?: emptyList() _buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) } - if (!isChannelName(key.name)) { - // tryEmit, not emit: this runs on the single, non-suspend bufferOps consumer, - // not its own coroutine, so there's nothing to suspend into. The SharedFlow's - // 16-slot extraBufferCapacity means this can't actually fail in practice. - _newQueryBuffers.tryEmit(key) - } } private fun addMessage(key: BufferKey, msg: IrcMessage) { @@ -195,6 +244,10 @@ object ConnectionManager { scheduleSave(key) } + private fun addMainLine(kind: MessageKind, sender: String?, text: String) { + addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text)) + } + // Coalesces rapid-fire saves for the same buffer into at most one write every // SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single // incoming message - the direct cause of reported slowness in a busy channel. Reads @@ -229,6 +282,57 @@ object ConnectionManager { } } + // A WHOIS reply line goes to the window that typed the /whois if it's still open, + // otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main. + private fun handleWhoisLine(serverId: String, line: ParsedLine) { + val target = line.params.getOrNull(1) ?: return + val pending = pendingWhois[serverId] + val origin = pending?.get(target.lowercase()) + if (line.command == "318") pending?.remove(target.lowercase()) + + val text = "[whois] " + formatWhoisLine(line) + val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text) + if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg) + } + + private fun formatWhoisLine(line: ParsedLine): String { + val p = line.params + val nick = p.getOrNull(1) ?: "" + return when (line.command) { + "311" -> "$nick is ${p.getOrNull(2)}@${p.getOrNull(3)} (${p.lastOrNull() ?: ""})" + "312" -> "$nick is on ${p.getOrNull(2)} (${p.lastOrNull() ?: ""})" + "317" -> { + val idleSecs = p.getOrNull(2)?.toLongOrNull() + val signon = p.getOrNull(3)?.toLongOrNull() + buildString { + append("$nick has been idle ${idleSecs?.let { formatDuration(it) } ?: "?"}") + if (signon != null) { + val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + append(", signed on ${fmt.format(Date(signon * 1000))}") + } + } + } + "319" -> "$nick is on channels: ${p.lastOrNull() ?: ""}" + "330" -> "$nick ${p.lastOrNull() ?: "is logged in as"} ${p.getOrNull(2) ?: ""}".trim() + "301" -> "$nick is away: ${p.lastOrNull() ?: ""}" + "318" -> "End of WHOIS for $nick" + else -> p.drop(1).joinToString(" ") + } + } + + private fun formatDuration(totalSecs: Long): String { + val d = totalSecs / 86400 + val h = (totalSecs % 86400) / 3600 + val m = (totalSecs % 3600) / 60 + val s = totalSecs % 60 + return buildString { + if (d > 0) append("${d}d ") + if (d > 0 || h > 0) append("${h}h ") + if (d > 0 || h > 0 || m > 0) append("${m}m ") + append("${s}s") + } + } + private fun handleLine(serverId: String, line: ParsedLine) { val now = System.currentTimeMillis() val nick = nickFromPrefix(line.prefix) ?: line.prefix @@ -237,16 +341,46 @@ object ConnectionManager { "PRIVMSG" -> { val target = line.params.getOrNull(0) ?: return val text = line.params.getOrNull(1) ?: return - val isAction = text.startsWith("ACTION") && text.endsWith("") - val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text - // Channel messages are keyed by the channel name. Private messages arrive - // with target == our own nick, so key those by the *sender* instead - - // that's the conversation identity a query window should use. - val bufferName = if (isChannelName(target)) target else (nick ?: target) - addMessage( - BufferKey(serverId, bufferName), - IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body) - ) + val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001") + val isAction = isCtcp && text.startsWith("\u0001ACTION") + val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text + if (isChannelName(target)) { + addMessage( + BufferKey(serverId, target), + IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body) + ) + } else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) { + // Services, the server itself, and CTCP requests other than ACTION + // (PING, TIME, ...) go to wIRC main instead of opening a query window. + // (VERSION is already answered and swallowed in IrcConnection.) + val shown = if (isCtcp && !isAction) "[CTCP ${text.trim('\u0001')} from $nick]" else body + addMainLine(MessageKind.CHAT, nick, shown) + } else { + // A private message from a real user arrives with target == our own + // nick, so key it by the *sender* - that's the conversation identity a + // query window should use - and (re)open that window. + val key = BufferKey(serverId, nick ?: target) + addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)) + _queryWindowRequests.tryEmit(key) + } + } + "NOTICE" -> { + val target = line.params.getOrNull(0) ?: return + val text = line.params.getOrNull(1) ?: return + if (isChannelName(target)) { + addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text)) + } else if (isServiceOrServerPrefix(line.prefix)) { + // NickServ/ChanServ replies (identify success/failure etc.) and server + // notices - wIRC main. The sender goes into the text because the + // NOTICE row layout doesn't show a nick column. + addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text") + } else { + // A notice directed AT us arrives with target == our own nick, so key + // it by the sender, same as a private message. + val key = BufferKey(serverId, nick ?: target) + addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text)) + _queryWindowRequests.tryEmit(key) + } } "JOIN" -> { val channel = line.params.getOrNull(0) ?: return @@ -270,9 +404,13 @@ object ConnectionManager { } "MODE" -> { val channel = line.params.getOrNull(0) ?: return - if (!channel.startsWith("#")) return val modeStr = line.params.getOrNull(1) ?: "" val modeTarget = line.params.getOrNull(2) + if (!isChannelName(channel)) { + // User mode on our own nick (e.g. +i, +r after identifying) - wIRC main. + addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel") + return + } addMessage( BufferKey(serverId, channel), IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim()) @@ -315,20 +453,6 @@ object ConnectionManager { } updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } } } - "NOTICE" -> { - val target = line.params.getOrNull(0) ?: return - val text = line.params.getOrNull(1) ?: return - // Same fix PRIVMSG already had: a notice directed AT us arrives with - // target == our own nick, not the sender. This mattered a lot in practice - - // NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.) - // via NOTICE rather than PRIVMSG, so without this fallback every identify - // success/failure notice was being filed under a buffer keyed by our OWN - // nick - which no window ever opens for - instead of under "NickServ", - // making it look like the automatic identify produced no feedback at all, - // whether or not it actually worked. - val bufferName = if (isChannelName(target)) target else (nick ?: target) - addMessage(BufferKey(serverId, bufferName), IrcMessage(now, MessageKind.NOTICE, nick, text)) - } "NICK" -> { val oldNick = nick ?: return val newNick = line.params.getOrNull(0) ?: return @@ -347,6 +471,7 @@ object ConnectionManager { } if (ownNicks[serverId] == oldNick) { setOwnNick(serverId, newNick) + addMainLine(MessageKind.NICK, null, "You are now known as $newNick") } } "QUIT" -> { @@ -360,6 +485,27 @@ object ConnectionManager { } } } + "INVITE" -> { + val channel = line.params.getOrNull(1) ?: return + addMainLine(MessageKind.SYSTEM, nick, "$nick invited you to $channel") + } + "ERROR" -> { + addMainLine(MessageKind.SYSTEM, null, "ERROR: ${line.params.joinToString(" ")}") + } + "PONG", "CAP" -> Unit + else -> { + when { + line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line) + line.command in CHANNEL_ONLY_NUMERICS -> Unit + // Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a + // server message; params[0] is just our own nick, so it's dropped. + line.command.all { it.isDigit() } -> + addMainLine(MessageKind.SYSTEM, line.prefix, line.params.drop(1).joinToString(" ")) + // Anything else not tied to a channel (WALLOPS, KILL, ...) - wIRC main as-is. + else -> + addMainLine(MessageKind.SYSTEM, nick, "${line.command} ${line.params.joinToString(" ")}".trim()) + } + } } } } diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt index 75d3ddc..ba6ab9d 100644 --- a/src/main/kotlin/ui/ChannelScreen.kt +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberDialogState import data.ChannelBuffer import data.ChannelUser +import data.IrcMessage import data.MessageKind import data.WindowDisplaySettings import irc.extractImageUrls @@ -287,108 +288,6 @@ fun WindowScope.ChannelScreen( val textColor = mircPalette[displaySettings.textColorMircIndex] val bgColor = Color.Black.copy(alpha = displaySettings.backgroundAlpha) - val listState = rememberLazyListState() - val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) } - - // Single source of truth for "what messages exist" - used both by the scroll-tracking - // effects below and by the LazyColumn's items() call further down, so the two can never - // disagree about item count (they're reading the literal same list, not two separately - // re-derived ones). - val messages = buffer?.messages ?: emptyList() - - // Standard "stick to bottom unless the user scrolled up" chat-client pattern - the - // ONLY code in this file that touches scroll position. A previous version of this had - // several LaunchedEffects independently reading and writing listState/stickToBottom - // (a resize-driven re-snap effect, a separate "first scroll" flag, extra debug-only - // effects) - multiple effects fighting over the same LazyListState was the suspected - // cause of the auto-scroll bug surviving several previous fix attempts, so this is - // deliberately reduced to exactly two effects and nothing else moves listState. - var stickToBottom by remember { mutableStateOf(true) } - - // The LazyColumn below uses reverseLayout=true with this reversed (newest-first) list, - // not the previous approach of a normal-order list plus scrollToItem(lastIndex) to jump - // to the end. That combination has a real, well-documented failure mode this app kept - // hitting: scrollToItem(lastIndex) asks for the LAST item to become the FIRST visible - // item (positioned at the top of the viewport) - there's nothing after the last item to - // fill the rest of the viewport, so whether that visually ends up "correct" (Compose - // pulling earlier items in above it to avoid a trailing gap) or "one item short" depends - // on exactly how much prior content exists and how the scroll/layout timing lines up - // under rapid successive calls - which matches the reported symptom (a channel with - // little content, and - independently - a fast-moving one, both showing the newest - // message perpetually one behind). reverseLayout flips which edge that ambiguity can - // ever land on: index 0 (the newest message) is always anchored flush against the - // viewport's bottom edge, and any shortage of content shows up as blank space ABOVE the - // (sparse) history instead of hiding the newest line - the standard, well-established - // pattern real Compose chat apps use for exactly this reason. - val reversedMessages = messages.asReversed() - - // How close to the very bottom (index 0, offset 0) counts as "still at the bottom" when - // a scroll comes to rest - a little slack so a pixel or two of residual offset doesn't - // read as "scrolled away". - val stickToBottomThresholdPx = with(LocalDensity.current) { 24.dp.toPx() } - - // Track whether the user is currently near the bottom (= near index 0, since the list - // is reversed) of the scrollback. If they've manually scrolled up to read backscroll, - // stop auto-scrolling until they scroll back down themselves. - // - // This only recomputes stickToBottom while isScrollInProgress is true, plus the single - // emission right after a scroll just ended (tracked via wasScrolling), so that scroll's - // final resting position still gets recorded. Previously this derived stickToBottom from - // firstVisibleItemIndex alone, which also changes from pure item-anchoring: reverseLayout - // anchors the previously-first-visible item by key when a new item is inserted at index 0, - // so every new message bumped that index by one with zero actual scrolling - two or more - // messages landing before this effect re-ran pushed the index past the old "<= 1" cutoff - // and flipped stickToBottom off for the rest of the session, silently disabling auto-scroll - // during any message burst. - LaunchedEffect(listState) { - var wasScrolling = false - snapshotFlow { - Triple( - listState.isScrollInProgress, - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset - ) - }.collect { (isScrolling, index, offset) -> - if (isScrolling || wasScrolling) { - stickToBottom = index == 0 && offset <= stickToBottomThresholdPx - } - wasScrolling = isScrolling - } - } - - // Whenever a new message actually lands, jump to index 0 (the newest, since the list is - // reversed) IF we were already sticking to the bottom. - // - // The trigger is msg.seq, not totalItemsCount: msg.seq is monotonically increasing and - // never repeats or saturates, while ConnectionManager caps every buffer at - // MESSAGE_HISTORY_CAP (500) messages, evicting the oldest one for every new one once a - // channel hits that cap - so totalItemsCount stops changing forever past that point, and - // an effect keyed on it would silently stop firing for any channel busy enough to fill its - // scrollback. - // - // The actual scrollToItem(0) call still has to wait for the LazyColumn's own layout pass - // to genuinely catch up to that new item - calling it immediately resolves against - // whatever layout still thinks exists, which can be one short, with nothing to - // re-correct it afterwards. Waiting for `totalItemsCount >= expectedItemCount` (an earlier - // version of this wait) is not cap-proof either: once a buffer sits at the 500 cap, - // totalItemsCount was already 500 before the new item got laid out (an old item was - // evicted for it), so that wait is satisfied immediately against stale layout and - // scrollToItem(0) can still land one message short. Instead, this captures the current - // reversedMessages list at launch and waits for the layout to actually reflect it: either - // there are no visible items yet, or the first visible item's key matches what that - // captured list says should be at that index - which can only be true once layout has - // processed up through at least that item. - val newestSeq = messages.lastOrNull()?.seq - LaunchedEffect(newestSeq) { - if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) { - val capturedList = reversedMessages - snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() } - .first { item -> - item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq) - } - listState.scrollToItem(0) - } - } Box( modifier = Modifier @@ -483,194 +382,13 @@ fun WindowScope.ChannelScreen( // Scrollback + user list, side by side, filling the available height Row(modifier = Modifier.weight(1f).fillMaxWidth()) { - // Scrollback height is snapped down to a whole multiple of one line's height so a - // resize never leaves a partially-rendered line visible - the leftover fractional - // space is absorbed by the Spacer below instead, leaving the rest of the layout - // (nicklist, input bar) unaffected. - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - ) { - // Investigated (see task: resized-window slowdown) whether this was - // re-measuring text or producing an unstable snappedHeight on every - // recomposition instead of only on a genuine font-size/window-size - // change - confirmed via real recomposition/measurement counters - // (temporarily added, since removed) across a side-by-side real-window - // test that it was not: TextMeasurer.measure() ran exactly once per - // window for the whole test (remember(displaySettings.fontSize) is - // correctly gating it), snappedHeight only changed on an actual resize - // and stayed byte-for-byte stable between messages otherwise, and - // recomposition volume was ~2.5% apart between a resized and an - // untouched window under identical message load - not the source of - // the reported slowdown. - val density = LocalDensity.current - val textMeasurer = rememberTextMeasurer() - // Keyed on density (screen DPI/font scale), not just fontSize - the - // measured value is a raw pixel height for the CURRENT density, and - // without density in the key this stayed cached at whatever density was - // in effect the first time this window measured a line, even after the - // window moved to a monitor with different scaling. lineHeightDp below - // then converts that now-stale pixel value using the NEW density, so the - // two silently disagreed until the window (and this remember scope) was - // recreated by closing and reopening it. - val lineHeightPx = remember(displaySettings.fontSize, density) { - textMeasurer.measure( - text = "Mg", - style = androidx.compose.ui.text.TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - ).size.height - } - val lineHeightDp = with(density) { lineHeightPx.toDp() } - // Ruled out (see task: newest-message-missing investigation) as the - // cause of the newest message being cut off - confirmed via a real, - // visual, screenshot-verified test that bypassing this snap entirely - // (using maxHeight raw) did not fix the missing-newest-message bug. The - // real cause was a race between the scroll effect and the LazyColumn's - // own layout catching up to new items - see the scroll effects below. - val snappedHeight = if (lineHeightDp > 0.dp) { - lineHeightDp * floor(maxHeight / lineHeightDp).toInt() - } else { - maxHeight - } - - // NOTE: this used to also re-snap the scroll *offset* to the first - // visible item on every resize/font-size change, to fix a partial line - // stuck at the TOP of the viewport (as opposed to the bottom, which - // snappedHeight above already handles). Removed - stickToBottom above is - // now the only thing that's allowed to touch listState, since multiple - // effects independently reading/writing it was the suspected cause of - // the auto-scroll bug surviving several previous fix attempts. If a - // top-cut-off-line-on-resize regression shows up again, it needs a fix - // that doesn't call scrollToItem itself - e.g. reporting the needed - // offset correction through stickToBottom's own effect instead of a - // second independent one. - - Column(modifier = Modifier.fillMaxSize()) { - LazyColumn( - state = listState, - reverseLayout = true, - modifier = Modifier - .height(snappedHeight) - .padding(horizontal = 6.dp) - ) { - items( - items = reversedMessages, - // msg.seq is a monotonically increasing, guaranteed-unique id (see - // IrcMessage) - the previous timestamp+sender+text-hash composite - // could collide in a busy channel (two messages same millisecond, - // same sender, same short text - not rare for "lol"/an emoji/etc), - // and Compose requires unique keys per item. - key = { msg -> msg.seq } - ) { msg -> - val ts = timeFormat.format(Date(msg.timestampMillis)) - val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor) - val mentioned = isMention(msg.rawText) - val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor - val mutedColor = textColor.copy(alpha = 0.7f) - Column(modifier = Modifier.fillMaxWidth()) { - // Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick - // pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp - // lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without - // this, those Texts get a much taller line box than the ClickableText body - // below (which uses its own explicit TextStyle and never merges with the - // ambient one), and Row's default top alignment then renders the body - // visibly offset above its own prefix. The Android version never wraps - // ChannelScreen in a MaterialTheme at all, so it never hits this mismatch. - CompositionLocalProvider(LocalTextStyle provides androidx.compose.ui.text.TextStyle.Default) { - Row( - modifier = Modifier - .fillMaxWidth() - .let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it } - ) { - when (msg.kind) { - MessageKind.CHAT -> { - Text( - text = ts, - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = " ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}", - color = nickColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - } - MessageKind.ACTION -> { - Text( - text = ts, - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = " * ", - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = "${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}", - color = nickColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - } - else -> { - Text( - text = ts, - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = " * ", - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - } - } - ClickableText( - text = bodyAnnotated, - style = androidx.compose.ui.text.TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ), - onClick = { offset -> - bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - openUrlInBrowser(annotation.item) - } - } - ) - } - } - if (displaySettings.showImagePreviews) { - extractImageUrls(msg.rawText).forEach { url -> - UrlImagePreview( - url = url, - modifier = Modifier - .padding(start = 8.dp, top = 2.dp, bottom = 4.dp) - .heightIn(max = 200.dp) - .clip(RoundedCornerShape(4.dp)), - onClick = { openUrlInBrowser(url) } - ) - } - } - } - } - } - Spacer(modifier = Modifier.weight(1f)) - } - } + MessageScrollback( + messages = buffer?.messages ?: emptyList(), + displaySettings = displaySettings, + textColor = textColor, + isMention = { isMention(it) }, + modifier = Modifier.weight(1f).fillMaxHeight() + ) // User list (tap and hold for OP actions) if (displaySettings.showUserList && !buffer?.users.isNullOrEmpty()) { @@ -804,6 +522,300 @@ fun WindowScope.ChannelScreen( } } +// Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main +// (MainScreen): the message rows, image previews, line-height snapping and the +// stick-to-bottom auto-scroll. Draws no background of its own - callers do. +@Composable +fun MessageScrollback( + messages: List, + displaySettings: WindowDisplaySettings, + textColor: Color, + isMention: (String) -> Boolean, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) } + + // Standard "stick to bottom unless the user scrolled up" chat-client pattern - the + // ONLY code in this file that touches scroll position. A previous version of this had + // several LaunchedEffects independently reading and writing listState/stickToBottom + // (a resize-driven re-snap effect, a separate "first scroll" flag, extra debug-only + // effects) - multiple effects fighting over the same LazyListState was the suspected + // cause of the auto-scroll bug surviving several previous fix attempts, so this is + // deliberately reduced to exactly two effects and nothing else moves listState. + var stickToBottom by remember { mutableStateOf(true) } + + // The LazyColumn below uses reverseLayout=true with this reversed (newest-first) list, + // not the previous approach of a normal-order list plus scrollToItem(lastIndex) to jump + // to the end. That combination has a real, well-documented failure mode this app kept + // hitting: scrollToItem(lastIndex) asks for the LAST item to become the FIRST visible + // item (positioned at the top of the viewport) - there's nothing after the last item to + // fill the rest of the viewport, so whether that visually ends up "correct" (Compose + // pulling earlier items in above it to avoid a trailing gap) or "one item short" depends + // on exactly how much prior content exists and how the scroll/layout timing lines up + // under rapid successive calls - which matches the reported symptom (a channel with + // little content, and - independently - a fast-moving one, both showing the newest + // message perpetually one behind). reverseLayout flips which edge that ambiguity can + // ever land on: index 0 (the newest message) is always anchored flush against the + // viewport's bottom edge, and any shortage of content shows up as blank space ABOVE the + // (sparse) history instead of hiding the newest line - the standard, well-established + // pattern real Compose chat apps use for exactly this reason. + val reversedMessages = messages.asReversed() + + // How close to the very bottom (index 0, offset 0) counts as "still at the bottom" when + // a scroll comes to rest - a little slack so a pixel or two of residual offset doesn't + // read as "scrolled away". + val stickToBottomThresholdPx = with(LocalDensity.current) { 24.dp.toPx() } + + // Track whether the user is currently near the bottom (= near index 0, since the list + // is reversed) of the scrollback. If they've manually scrolled up to read backscroll, + // stop auto-scrolling until they scroll back down themselves. + // + // This only recomputes stickToBottom while isScrollInProgress is true, plus the single + // emission right after a scroll just ended (tracked via wasScrolling), so that scroll's + // final resting position still gets recorded. Previously this derived stickToBottom from + // firstVisibleItemIndex alone, which also changes from pure item-anchoring: reverseLayout + // anchors the previously-first-visible item by key when a new item is inserted at index 0, + // so every new message bumped that index by one with zero actual scrolling - two or more + // messages landing before this effect re-ran pushed the index past the old "<= 1" cutoff + // and flipped stickToBottom off for the rest of the session, silently disabling auto-scroll + // during any message burst. + LaunchedEffect(listState) { + var wasScrolling = false + snapshotFlow { + Triple( + listState.isScrollInProgress, + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset + ) + }.collect { (isScrolling, index, offset) -> + if (isScrolling || wasScrolling) { + stickToBottom = index == 0 && offset <= stickToBottomThresholdPx + } + wasScrolling = isScrolling + } + } + + // Whenever a new message actually lands, jump to index 0 (the newest, since the list is + // reversed) IF we were already sticking to the bottom. + // + // The trigger is msg.seq, not totalItemsCount: msg.seq is monotonically increasing and + // never repeats or saturates, while ConnectionManager caps every buffer at + // MESSAGE_HISTORY_CAP (500) messages, evicting the oldest one for every new one once a + // channel hits that cap - so totalItemsCount stops changing forever past that point, and + // an effect keyed on it would silently stop firing for any channel busy enough to fill its + // scrollback. + // + // The actual scrollToItem(0) call still has to wait for the LazyColumn's own layout pass + // to genuinely catch up to that new item - calling it immediately resolves against + // whatever layout still thinks exists, which can be one short, with nothing to + // re-correct it afterwards. Waiting for `totalItemsCount >= expectedItemCount` (an earlier + // version of this wait) is not cap-proof either: once a buffer sits at the 500 cap, + // totalItemsCount was already 500 before the new item got laid out (an old item was + // evicted for it), so that wait is satisfied immediately against stale layout and + // scrollToItem(0) can still land one message short. Instead, this captures the current + // reversedMessages list at launch and waits for the layout to actually reflect it: either + // there are no visible items yet, or the first visible item's key matches what that + // captured list says should be at that index - which can only be true once layout has + // processed up through at least that item. + val newestSeq = messages.lastOrNull()?.seq + LaunchedEffect(newestSeq) { + if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) { + val capturedList = reversedMessages + snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() } + .first { item -> + item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq) + } + listState.scrollToItem(0) + } + } + + // Scrollback height is snapped down to a whole multiple of one line's height so a + // resize never leaves a partially-rendered line visible - the leftover fractional + // space is absorbed by the Spacer below instead, leaving the rest of the layout + // (nicklist, input bar) unaffected. + BoxWithConstraints(modifier = modifier) { + // Investigated (see task: resized-window slowdown) whether this was + // re-measuring text or producing an unstable snappedHeight on every + // recomposition instead of only on a genuine font-size/window-size + // change - confirmed via real recomposition/measurement counters + // (temporarily added, since removed) across a side-by-side real-window + // test that it was not: TextMeasurer.measure() ran exactly once per + // window for the whole test (remember(displaySettings.fontSize) is + // correctly gating it), snappedHeight only changed on an actual resize + // and stayed byte-for-byte stable between messages otherwise, and + // recomposition volume was ~2.5% apart between a resized and an + // untouched window under identical message load - not the source of + // the reported slowdown. + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + // Keyed on density (screen DPI/font scale), not just fontSize - the + // measured value is a raw pixel height for the CURRENT density, and + // without density in the key this stayed cached at whatever density was + // in effect the first time this window measured a line, even after the + // window moved to a monitor with different scaling. lineHeightDp below + // then converts that now-stale pixel value using the NEW density, so the + // two silently disagreed until the window (and this remember scope) was + // recreated by closing and reopening it. + val lineHeightPx = remember(displaySettings.fontSize, density) { + textMeasurer.measure( + text = "Mg", + style = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ) + ).size.height + } + val lineHeightDp = with(density) { lineHeightPx.toDp() } + // Ruled out (see task: newest-message-missing investigation) as the + // cause of the newest message being cut off - confirmed via a real, + // visual, screenshot-verified test that bypassing this snap entirely + // (using maxHeight raw) did not fix the missing-newest-message bug. The + // real cause was a race between the scroll effect and the LazyColumn's + // own layout catching up to new items - see the scroll effects below. + val snappedHeight = if (lineHeightDp > 0.dp) { + lineHeightDp * floor(maxHeight / lineHeightDp).toInt() + } else { + maxHeight + } + + // NOTE: this used to also re-snap the scroll *offset* to the first + // visible item on every resize/font-size change, to fix a partial line + // stuck at the TOP of the viewport (as opposed to the bottom, which + // snappedHeight above already handles). Removed - stickToBottom above is + // now the only thing that's allowed to touch listState, since multiple + // effects independently reading/writing it was the suspected cause of + // the auto-scroll bug surviving several previous fix attempts. If a + // top-cut-off-line-on-resize regression shows up again, it needs a fix + // that doesn't call scrollToItem itself - e.g. reporting the needed + // offset correction through stickToBottom's own effect instead of a + // second independent one. + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier + .height(snappedHeight) + .padding(horizontal = 6.dp) + ) { + items( + items = reversedMessages, + // msg.seq is a monotonically increasing, guaranteed-unique id (see + // IrcMessage) - the previous timestamp+sender+text-hash composite + // could collide in a busy channel (two messages same millisecond, + // same sender, same short text - not rare for "lol"/an emoji/etc), + // and Compose requires unique keys per item. + key = { msg -> msg.seq } + ) { msg -> + val ts = timeFormat.format(Date(msg.timestampMillis)) + val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor) + val mentioned = isMention(msg.rawText) + val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor + val mutedColor = textColor.copy(alpha = 0.7f) + Column(modifier = Modifier.fillMaxWidth()) { + // Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick + // pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp + // lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without + // this, those Texts get a much taller line box than the ClickableText body + // below (which uses its own explicit TextStyle and never merges with the + // ambient one), and Row's default top alignment then renders the body + // visibly offset above its own prefix. The Android version never wraps + // ChannelScreen in a MaterialTheme at all, so it never hits this mismatch. + CompositionLocalProvider(LocalTextStyle provides androidx.compose.ui.text.TextStyle.Default) { + Row( + modifier = Modifier + .fillMaxWidth() + .let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it } + ) { + when (msg.kind) { + MessageKind.CHAT -> { + Text( + text = ts, + color = mutedColor, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = displaySettings.fontSize.sp + ) + Text( + text = " ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}", + color = nickColor, + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ) + } + MessageKind.ACTION -> { + Text( + text = ts, + color = mutedColor, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = displaySettings.fontSize.sp + ) + Text( + text = " * ", + color = mutedColor, + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ) + Text( + text = "${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}", + color = nickColor, + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ) + } + else -> { + Text( + text = ts, + color = mutedColor, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = displaySettings.fontSize.sp + ) + Text( + text = " * ", + color = mutedColor, + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ) + } + } + ClickableText( + text = bodyAnnotated, + style = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ), + onClick = { offset -> + bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> + openUrlInBrowser(annotation.item) + } + } + ) + } + } + if (displaySettings.showImagePreviews) { + extractImageUrls(msg.rawText).forEach { url -> + UrlImagePreview( + url = url, + modifier = Modifier + .padding(start = 8.dp, top = 2.dp, bottom = 4.dp) + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(4.dp)), + onClick = { openUrlInBrowser(url) } + ) + } + } + } + } + } + Spacer(modifier = Modifier.weight(1f)) + } + } +} + // A 28dp title-bar IconButton that rests invisible and fades in while hovered. The fade // is a graphicsLayer alpha only - the button is always laid out, drawn and hit-tested, so // it still gets hover and clicks at alpha 0. The alpha is read inside the graphicsLayer diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt index a284732..26a7c14 100644 --- a/src/main/kotlin/ui/ChannelWindow.kt +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -10,8 +10,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window -import androidx.compose.ui.window.rememberWindowState import data.AliasRepository import data.BufferKey import data.WindowDisplaySettings @@ -59,7 +60,9 @@ fun ChannelWindow( val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } } val ownNicks by ConnectionManager.ownNicksFlow.collectAsState() val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } } - val windowState = rememberWindowState() + // Restored from window_prefs.json (or cascaded if there's no usable saved position) + // and saved back on move/resize/close - see WindowPlacement.kt. + val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp)) // undecorated + transparent trade the native OS title bar for real per-pixel window // transparency (Windows only honors transparency on undecorated windows) - ChannelScreen @@ -124,6 +127,9 @@ fun ChannelWindow( ConnectionManager.sendRaw(serverId, "JOIN $args") onOpenBuffer(BufferKey(serverId, args)) } + // Reply lines come back to this window, or to wIRC main if + // this window is closed before they arrive. + "WHOIS" -> ConnectionManager.whois(serverId, args, origin = bufferKey) "MSG" -> { val msgSpaceIdx = args.indexOf(' ') if (msgSpaceIdx >= 0) { diff --git a/src/main/kotlin/ui/MainScreen.kt b/src/main/kotlin/ui/MainScreen.kt index e1670cd..088135f 100644 --- a/src/main/kotlin/ui/MainScreen.kt +++ b/src/main/kotlin/ui/MainScreen.kt @@ -1,5 +1,7 @@ package ui +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -11,7 +13,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isShiftPressed @@ -23,9 +27,12 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.rememberDialogState +import data.MAIN_BUFFER_KEY import data.ServerConfig import data.ServerRepository +import data.WindowDisplaySettings import irc.ConnectionManager +import irc.mircPalette import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -33,8 +40,10 @@ import kotlinx.coroutines.launch fun MainScreen( repo: ServerRepository, colorScheme: ColorScheme, + displaySettings: WindowDisplaySettings, onOpenChannel: (serverId: String, channelName: String) -> Unit, - onExitApp: () -> Unit + onExitApp: () -> Unit, + onUnreadChange: (Int) -> Unit ) { var servers by remember { mutableStateOf(repo.loadAll()) } var showAddDialog by remember { mutableStateOf(false) } @@ -44,6 +53,26 @@ fun MainScreen( var openedChannels by remember { mutableStateOf(mapOf>()) } val coroutineScope = rememberCoroutineScope() + val buffers by ConnectionManager.buffers.collectAsState() + val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } } + val textColor = mircPalette[displaySettings.textColorMircIndex] + + // Unread indicator: lines that arrived in wIRC main while this window didn't have focus + // (e.g. while typing in a channel window). Shown next to the "Server messages" label and, + // via onUnreadChange, in the window title / taskbar. Cleared as soon as the window is + // focused. Compared by timestamp rather than count so history loaded at startup never + // counts as new. + val windowFocused = LocalWindowInfo.current.isWindowFocused + var lastSeenMillis by remember { mutableStateOf(System.currentTimeMillis()) } + val newest = mainMessages.lastOrNull() + LaunchedEffect(windowFocused, newest?.seq) { + if (windowFocused) { + lastSeenMillis = maxOf(lastSeenMillis, newest?.timestampMillis ?: 0L, System.currentTimeMillis()) + } + } + val unread = if (windowFocused) 0 else mainMessages.count { it.timestampMillis > lastSeenMillis } + LaunchedEffect(unread) { onUnreadChange(unread) } + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -125,6 +154,34 @@ fun MainScreen( Spacer(Modifier.height(12.dp)) } } + + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Server messages", style = MaterialTheme.typography.labelMedium) + if (unread > 0) { + Spacer(Modifier.width(8.dp)) + Text( + "\u25CF $unread new", + color = UNREAD_INDICATOR_COLOR, + style = MaterialTheme.typography.labelMedium + ) + } + } + Spacer(Modifier.height(4.dp)) + // MOTD, numerics, errors, services/server notices and WHOIS replies whose window + // is gone - see ConnectionManager. Same rendering and auto-scroll as a channel window. + MessageScrollback( + messages = mainMessages, + displaySettings = displaySettings, + textColor = textColor, + isMention = { false }, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.Black.copy(alpha = displaySettings.backgroundAlpha)) + .border(1.dp, Color.Gray.copy(alpha = 0.5f)) + .padding(vertical = 2.dp) + ) } if (showAddDialog) { @@ -141,6 +198,8 @@ fun MainScreen( } } +private val UNREAD_INDICATOR_COLOR = Color(0xFFFFA500) + @Composable private fun ServerCard( server: ServerConfig, diff --git a/src/main/kotlin/ui/WindowPlacement.kt b/src/main/kotlin/ui/WindowPlacement.kt new file mode 100644 index 0000000..255f14e --- /dev/null +++ b/src/main/kotlin/ui/WindowPlacement.kt @@ -0,0 +1,158 @@ +package ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.rememberWindowState +import data.WindowBounds +import data.WindowPrefsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withContext +import java.awt.GraphicsEnvironment +import java.awt.Rectangle +import java.awt.Toolkit + +// How long a window has to sit still after a move/resize before its bounds are written +// to window_prefs.json - dragging fires a position change per mouse event. +private const val BOUNDS_SAVE_DEBOUNCE_MS = 500L + +// Cascade layout for windows with no usable saved position: the first one opens +// CASCADE_MARGIN_DP in from the top-left of the primary screen's usable area (taskbar +// excluded), each next one CASCADE_STEP_DP further down and right, wrapping back to the +// start before a window would run off the screen. +private const val CASCADE_MARGIN_DP = 40 +private const val CASCADE_STEP_DP = 32 + +// A saved position only counts as "on a connected monitor" if at least this much of the +// window's top strip (where the drag handle / title bar is) lands on one screen, so a +// restored window can always be grabbed and moved. +private const val TITLE_STRIP_HEIGHT_DP = 32 +private const val MIN_VISIBLE_TITLE_WIDTH_DP = 80 + +// Saved sizes smaller than this are treated as invalid and replaced by the default. +private const val MIN_WINDOW_WIDTH_DP = 200 +private const val MIN_WINDOW_HEIGHT_DP = 120 + +private object WindowCascade { + private var next = 0 + + @Synchronized + fun nextPosition(size: DpSize): WindowPosition { + val area = primaryUsableArea() + val roomX = area.width - CASCADE_MARGIN_DP - size.width.value + val roomY = area.height - CASCADE_MARGIN_DP - size.height.value + val stepsThatFit = (minOf(roomX, roomY) / CASCADE_STEP_DP).toInt().coerceAtLeast(0) + val step = next++ % (stepsThatFit + 1) + val offset = CASCADE_MARGIN_DP + step * CASCADE_STEP_DP + return WindowPosition.Absolute((area.x + offset).dp, (area.y + offset).dp) + } +} + +private fun primaryUsableArea(): Rectangle = try { + val config = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration + val bounds = config.bounds + val insets = Toolkit.getDefaultToolkit().getScreenInsets(config) + Rectangle( + bounds.x + insets.left, + bounds.y + insets.top, + bounds.width - insets.left - insets.right, + bounds.height - insets.top - insets.bottom + ) +} catch (e: Exception) { + Rectangle(0, 0, 1280, 720) +} + +private fun screenBounds(): List = try { + GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds } +} catch (e: Exception) { + emptyList() +} + +private fun isOnConnectedScreen(x: Float, y: Float, width: Float): Boolean { + val strip = Rectangle(x.toInt(), y.toInt(), width.toInt().coerceAtLeast(1), TITLE_STRIP_HEIGHT_DP) + val minVisibleWidth = minOf(MIN_VISIBLE_TITLE_WIDTH_DP, strip.width) + return screenBounds().any { screen -> + val visible = screen.intersection(strip) + !visible.isEmpty && visible.width >= minVisibleWidth && visible.height >= TITLE_STRIP_HEIGHT_DP / 2 + } +} + +private fun resolveInitialPlacement(saved: WindowBounds?, defaultSize: DpSize): Pair { + val savedWidth = saved?.width + val savedHeight = saved?.height + val size = if (savedWidth != null && savedHeight != null && + savedWidth >= MIN_WINDOW_WIDTH_DP && savedHeight >= MIN_WINDOW_HEIGHT_DP + ) { + // Never restore a window bigger than the largest connected screen (e.g. it was + // saved on a monitor that has since been unplugged). + val screens = screenBounds() + val maxWidth = screens.maxOfOrNull { it.width }?.toFloat() ?: savedWidth + val maxHeight = screens.maxOfOrNull { it.height }?.toFloat() ?: savedHeight + DpSize(minOf(savedWidth, maxWidth).dp, minOf(savedHeight, maxHeight).dp) + } else { + defaultSize + } + + val savedX = saved?.x + val savedY = saved?.y + val position = if (savedX != null && savedY != null && isOnConnectedScreen(savedX, savedY, size.width.value)) { + WindowPosition.Absolute(savedX.dp, savedY.dp) + } else { + WindowCascade.nextPosition(size) + } + return position to size +} + +private fun saveBoundsIfFloating(state: WindowState, prefsRepo: WindowPrefsRepository, storageId: String) { + // Minimized windows report a parking position far off-screen on Windows, and a + // maximized/fullscreen window's bounds are the screen's - neither is a position the + // user chose, so only plain floating bounds are remembered. + if (state.isMinimized || state.placement != WindowPlacement.Floating) return + val position = state.position + val size = state.size + if (position !is WindowPosition.Absolute || !size.isSpecified) return + prefsRepo.setWindowBounds( + storageId, + WindowBounds(position.x.value, position.y.value, size.width.value, size.height.value) + ) +} + +/** + * A WindowState that starts at the window's saved position and size from window_prefs.json + * (if that position is still on a connected monitor), otherwise at the next cascade slot, + * and writes its bounds back whenever the window is moved or resized and when it closes. + */ +@Composable +fun rememberPersistentWindowState( + prefsRepo: WindowPrefsRepository, + storageId: String, + defaultSize: DpSize +): WindowState { + val (initialPosition, initialSize) = remember(storageId) { + resolveInitialPlacement(prefsRepo.boundsFor(storageId), defaultSize) + } + val state = rememberWindowState(position = initialPosition, size = initialSize) + + LaunchedEffect(state, storageId) { + snapshotFlow { listOf(state.position, state.size, state.placement, state.isMinimized) } + .collectLatest { + delay(BOUNDS_SAVE_DEBOUNCE_MS) + withContext(Dispatchers.IO) { saveBoundsIfFloating(state, prefsRepo, storageId) } + } + } + // Catches a move/resize made less than BOUNDS_SAVE_DEBOUNCE_MS before the window closed. + DisposableEffect(state, storageId) { + onDispose { saveBoundsIfFloating(state, prefsRepo, storageId) } + } + return state +}