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 import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* 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 import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.input.ImeAction 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 @Composable fun MainScreen( repo: ServerRepository, colorScheme: ColorScheme, displaySettings: WindowDisplaySettings, onOpenChannel: (serverId: String, channelName: String) -> Unit, onExitApp: () -> Unit, onUnreadChange: (Int) -> Unit ) { var servers by remember { mutableStateOf(repo.loadAll()) } var showAddDialog by remember { mutableStateOf(false) } var editingServer by remember { mutableStateOf(null) } var connectedIds by remember { mutableStateOf(setOf()) } var channelInputs by remember { mutableStateOf(mapOf()) } 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(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text("wIRC", style = MaterialTheme.typography.headlineSmall) Row { Button(onClick = { editingServer = null; showAddDialog = true }) { Text("+ Add Server") } Spacer(Modifier.width(8.dp)) // Same end result as typing "/quit" alone in a channel window - disconnects // every server and terminates the process - just reachable without needing // a channel window open first. TextButton(onClick = onExitApp) { Text("Exit wIRC") } } } Spacer(Modifier.height(16.dp)) if (servers.isEmpty()) { Text("No saved servers yet. Click \"+ Add Server\" to set one up.") } LazyColumn(modifier = Modifier.weight(1f)) { items(servers) { server -> ServerCard( server = server, isConnected = server.id in connectedIds, channelInput = channelInputs[server.id] ?: "#", openedChannels = openedChannels[server.id] ?: emptyList(), onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, onConnect = { ConnectionManager.connect(server) connectedIds = connectedIds + server.id if (server.autoJoinChannels.isNotEmpty()) { // Auto-join sends JOIN correctly on connect (see IrcConnection), // but nothing else opens a window for it like the manual // "Join + Open" flow does - so give the server a moment to // actually join us before opening windows for them. coroutineScope.launch { delay(500) server.autoJoinChannels.forEach { ch -> val current = openedChannels[server.id] ?: emptyList() if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch)) onOpenChannel(server.id, ch) } } } }, onEdit = { editingServer = server; showAddDialog = true }, onDelete = { repo.remove(server.id) servers = repo.loadAll() }, onJoinAndOpen = { ch -> if (ch.isNotBlank()) { ConnectionManager.connectionFor(server.id)?.joinChannel(ch) val current = openedChannels[server.id] ?: emptyList() if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch)) onOpenChannel(server.id, ch) } }, onReopenWindow = { ch -> // Re-send JOIN even though joinedChannels *should* already have // rejoined this channel on any reconnect while the window was // closed. JOIN is a no-op on the server if we're already in the // channel, so this costs nothing - but it means "reopen" can never // produce a window that looks open while the connection silently // isn't actually in the channel (e.g. if a reconnect's rejoin was // dropped, delayed, or raced - see IrcConnection's outbox/ircDebug // logging). Same guarantee onJoinAndOpen already had. ConnectionManager.connectionFor(server.id)?.joinChannel(ch) onOpenChannel(server.id, ch) } ) 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 all /whois output - see // ConnectionManager. Same rendering, auto-scroll and text selection 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) { ServerEditDialog( existing = editingServer, colorScheme = colorScheme, onDismiss = { showAddDialog = false }, onSave = { config -> repo.upsert(config) servers = repo.loadAll() showAddDialog = false } ) } } private val UNREAD_INDICATOR_COLOR = Color(0xFFFFA500) @Composable private fun ServerCard( server: ServerConfig, isConnected: Boolean, channelInput: String, openedChannels: List, onChannelInputChange: (String) -> Unit, onConnect: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, onJoinAndOpen: (String) -> Unit, onReopenWindow: (String) -> Unit ) { OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column { Text(server.label, style = MaterialTheme.typography.titleMedium) Text( "${server.host}:${server.port}${if (server.useTls) " (TLS)" else ""} ยท nick: ${server.nick}", style = MaterialTheme.typography.bodySmall ) if (server.onConnectCommands.isNotEmpty()) { Text( "${server.onConnectCommands.size} on-connect command(s) saved", style = MaterialTheme.typography.bodySmall ) } } Row { TextButton(onClick = onEdit) { Text("Edit") } TextButton(onClick = onDelete) { Text("Delete") } } } Spacer(Modifier.height(8.dp)) if (!isConnected) { Button(onClick = onConnect) { Text("Connect") } } else { Text("Connected", style = MaterialTheme.typography.bodySmall) Spacer(Modifier.height(4.dp)) Row { OutlinedTextField( value = channelInput, onValueChange = onChannelInputChange, label = { Text("Channel") }, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(8.dp)) Button(onClick = { onJoinAndOpen(channelInput.trim()) }) { Text("Join + Open") } } if (openedChannels.isNotEmpty()) { Spacer(Modifier.height(4.dp)) Text("Windows:", style = MaterialTheme.typography.labelSmall) openedChannels.forEach { ch -> TextButton(onClick = { onReopenWindow(ch) }) { Text(ch) } } } } } } } @Composable private fun ServerEditDialog( existing: ServerConfig?, colorScheme: ColorScheme, onDismiss: () -> Unit, onSave: (ServerConfig) -> Unit ) { var label by remember { mutableStateOf(existing?.label ?: "") } var host by remember { mutableStateOf(existing?.host ?: "") } var port by remember { mutableStateOf((existing?.port ?: 6667).toString()) } var useTls by remember { mutableStateOf(existing?.useTls ?: false) } var nick by remember { mutableStateOf(existing?.nick ?: "") } var autoJoin by remember { mutableStateOf(existing?.autoJoinChannels?.joinToString(", ") ?: "") } // One raw IRC command per line, e.g.: // PRIVMSG NickServ :IDENTIFY hunter2 var onConnectCommands by remember { mutableStateOf(existing?.onConnectCommands?.joinToString("\n") ?: "") } val focusManager = LocalFocusManager.current fun save() { val id = existing?.id ?: host.replace(Regex("[^a-zA-Z0-9]"), "_") onSave( ServerConfig( id = id, label = label.ifBlank { host }, host = host, port = port.toIntOrNull() ?: 6667, useTls = useTls, nick = nick.ifBlank { "guest${(1000..9999).random()}" }, autoJoinChannels = autoJoin.split(",").map { it.trim() }.filter { it.isNotBlank() }, // This dialog has no SASL fields of its own (SASL is currently only // settable by hand-editing servers.json), so without carrying these // over explicitly, saving any OTHER field here - like fixing an // identify command - would silently null out an existing server's // saslUser/saslPass on every edit. saslUser = existing?.saslUser, saslPass = existing?.saslPass, onConnectCommands = onConnectCommands.split("\n").map { it.trim() }.filter { it.isNotBlank() } ) ) } // A real, separate top-level window (DialogWindow), not a material3 AlertDialog. An // AlertDialog on this Compose Multiplatform Desktop target renders as an overlay layer // within the calling window's own canvas (a ComposeSceneLayer, not a native window of // its own), so its outer bounds are hard-clipped to whatever size the main wIRC window // currently has - the previous fix here (heightIn(max=420.dp) + an internal scroll on // just the fields Column) only ever addressed the fields overflowing THEIR OWN space; it // did nothing for a main window smaller than that, which could still clip the dialog's // outer chrome outright. A DialogWindow is a genuine native OS window, sized and // positioned on its own terms, so the main window's current size can no longer clip it - // the same underlying fix as the channel-window settings panel (see ChannelScreen). DialogWindow( onCloseRequest = onDismiss, title = if (existing == null) "Add Server" else "Edit Server", state = rememberDialogState(size = DpSize(460.dp, 720.dp)), resizable = true ) { MaterialTheme(colorScheme = colorScheme) { Surface { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column( modifier = Modifier .weight(1f) .verticalScroll(rememberScrollState()) .onPreviewKeyEvent { event -> if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) { focusManager.moveFocus(if (event.isShiftPressed) FocusDirection.Up else FocusDirection.Down) true } else false } ) { OutlinedTextField( value = label, onValueChange = { label = it }, label = { Text("Display name") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) Spacer(Modifier.height(6.dp)) OutlinedTextField( value = host, onValueChange = { host = it }, label = { Text("Host") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) Spacer(Modifier.height(6.dp)) OutlinedTextField( value = port, onValueChange = { port = it }, label = { Text("Port") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = useTls, onCheckedChange = { useTls = it }) Text("Use TLS") } OutlinedTextField( value = nick, onValueChange = { nick = it }, label = { Text("Nick") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) Spacer(Modifier.height(6.dp)) OutlinedTextField( value = autoJoin, onValueChange = { autoJoin = it }, label = { Text("Auto-join channels (comma separated)") }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) ) Spacer(Modifier.height(6.dp)) OutlinedTextField( value = onConnectCommands, onValueChange = { onConnectCommands = it }, label = { Text("On-connect / identify commands, one per line") }, placeholder = { Text("PRIVMSG NickServ :IDENTIFY yourpassword") }, modifier = Modifier.fillMaxWidth(), minLines = 3, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) Text( "Saved and sent automatically every time you connect, once the server " + "finishes sending its MOTD and before auto-join. See the code comment " + "on ServerRepository for a note on how this is (and isn't) protected at rest.", style = MaterialTheme.typography.labelSmall ) } Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = { save() }, enabled = host.isNotBlank()) { Text("Save") } } } } } } }