Files
wIRC-desktop/src/main/kotlin/Main.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

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()
}
)
}
}
}