Files
wIRC-desktop/src/main/kotlin/ui/MainScreen.kt
T
weishauptandClaude Opus 5.5 d9a0297cc7 Add scrollback text selection/copy, route all /whois output to wIRC main, bump to 0.1.8
- Scrollback (channel windows and wIRC main): mouse drag selection, double-click word,
  triple-click line, shift-click extend, Ctrl+C / Ctrl+Insert, Ctrl+A, Esc, and a
  right-click menu (Copy / Copy line / Select all). Copies one line per message.
  Clipboard writes go off the UI thread with a timeout, like the paste handler.
  Auto-scroll pauses while selecting and catches up once the selection is cleared.
- /whois: header, every reply line, related numerics (401/402/716/263...) and server or
  services notices arriving right after it all go to wIRC main under a bold
  "===== WHOIS <nick> (<server>) =====" header, counted by its unread indicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:07:52 -04:00

420 lines
20 KiB
Kotlin

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<ServerConfig?>(null) }
var connectedIds by remember { mutableStateOf(setOf<String>()) }
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
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<String>,
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") }
}
}
}
}
}
}