Initial commit
This commit is contained in:
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user