Files
wIRC-desktop/src/main/kotlin/ui/MainScreen.kt
T
weishauptandClaude Opus 5.5 5bcd1a7bcd Snap windows to the desktop grid and to whole lines, font size picker (10 to 18), white text in settings dialogs, bump to 0.1.12
- After a move, windows snap to the Windows desktop icon grid; after a resize,
  the window height is trimmed so the scrollback ends on a whole line.
- The font size slider is replaced by square 10 to 18 buttons. Sizes saved by
  the old slider round to the nearest picker size.
- Settings and font dialogs always use white text, so a dark window text color
  no longer makes their labels invisible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:49:55 -04:00

451 lines
21 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,
onFontChange: (String) -> Unit
) {
var showFontDialog by remember { mutableStateOf(false) }
var servers by remember { mutableStateOf(repo.loadAll()) }
var showAddDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
// From ConnectionManager rather than local state, so a /quit typed in a channel window
// flips that server's card back to Disconnected with its Connect button.
val connectedIds by ConnectionManager.connectedIds.collectAsState()
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))
// Font for the server messages box (channel windows pick theirs in their own
// settings).
TextButton(onClick = { showFontDialog = true }) {
Text("Font")
}
Spacer(Modifier.width(8.dp))
// Disconnects every server and terminates the process. (A bare "/quit" in a
// channel window only disconnects that window's server.)
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 = {
// Also rejoins the channels this server was in before a /quit - their
// windows are still open, and opening one that already is is a no-op.
ConnectionManager.connect(server)
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 (showFontDialog) {
DialogWindow(
onCloseRequest = { showFontDialog = false },
title = "wIRC main font",
state = rememberDialogState(size = DpSize(300.dp, 360.dp)),
resizable = true
) {
MaterialTheme(colorScheme = colorScheme) {
Surface {
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) {
Text("Server messages font:", color = Color.White, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
FontPickerList(displaySettings.fontId, Color.White, onFontChange)
}
}
}
}
}
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) {
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
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") }
}
}
}
}
}
}