Initial commit

This commit is contained in:
weishaupt
2026-09-01 12:32:15 -04:00
commit defdefdb28
33 changed files with 3540 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
package ui
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.platform.LocalFocusManager
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.ServerConfig
import data.ServerRepository
import irc.ConnectionManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun MainScreen(
repo: ServerRepository,
colorScheme: ColorScheme,
onOpenChannel: (serverId: String, channelName: String) -> Unit,
onExitApp: () -> 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()
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))
}
}
}
if (showAddDialog) {
ServerEditDialog(
existing = editingServer,
colorScheme = colorScheme,
onDismiss = { showAddDialog = false },
onSave = { config ->
repo.upsert(config)
servers = repo.loadAll()
showAddDialog = false
}
)
}
}
@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") }
}
}
}
}
}
}