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>
138 lines
5.8 KiB
Kotlin
138 lines
5.8 KiB
Kotlin
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
|
|
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 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
|
|
// (OutlinedTextField borders, Button/TextButton labels, etc.) neutral black-and-white
|
|
// to match the rest of the app's deliberately plain look, same as the Android version.
|
|
private val neutralDarkScheme = darkColorScheme(
|
|
primary = Color.White,
|
|
onPrimary = Color.Black,
|
|
secondary = Color.White,
|
|
onSecondary = Color.Black,
|
|
background = Color.Black,
|
|
onBackground = Color.White,
|
|
surface = Color.Black,
|
|
onSurface = Color.White,
|
|
outline = Color.White
|
|
)
|
|
|
|
// All persistence lives under the user's home directory - the desktop analogue of
|
|
// Android's app-private filesDir, since a desktop app has no sandboxed storage of its own.
|
|
private val appRoot = File(System.getProperty("user.home"), ".wirc")
|
|
|
|
fun main() = application {
|
|
if (!appRoot.exists()) appRoot.mkdirs()
|
|
ConnectionManager.init(appRoot)
|
|
// Loaded once here (not per-window) so the alias map is truly global - every
|
|
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
|
AliasRepository.init()
|
|
|
|
val repo = remember { ServerRepository(appRoot) }
|
|
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
|
|
|
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
|
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
|
|
// and every channel window's /quit reuse the exact same logic instead of duplicating it.
|
|
val shutdownApp: () -> Unit = {
|
|
ConnectionManager.disconnectAll()
|
|
exitApplication()
|
|
}
|
|
|
|
// Desktop OSes don't kill backgrounded network connections for battery reasons the
|
|
// way Android does, so there's no foreground-service equivalent needed here -
|
|
// 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.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,
|
|
// 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 = mainWindowState
|
|
) {
|
|
MaterialTheme(colorScheme = neutralDarkScheme) {
|
|
Surface {
|
|
MainScreen(
|
|
repo = repo,
|
|
colorScheme = neutralDarkScheme,
|
|
displaySettings = mainDisplaySettings,
|
|
onOpenChannel = { serverId, channelName ->
|
|
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
|
},
|
|
onExitApp = shutdownApp,
|
|
onUnreadChange = { mainUnread = it }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
openBuffers.forEach { bufferKey ->
|
|
key(bufferKey) {
|
|
ChannelWindow(
|
|
bufferKey = bufferKey,
|
|
prefsRepo = prefsRepo,
|
|
colorScheme = neutralDarkScheme,
|
|
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
|
onCloseSelf = { openBuffers = openBuffers - bufferKey },
|
|
onQuitApp = {
|
|
exitApplication()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|