- 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>
420 lines
20 KiB
Kotlin
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") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|