diff --git a/build.gradle.kts b/build.gradle.kts index ae414d6..d7fb9c2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tpuahsiew.wirc" -version = "0.1.7" +version = "0.1.8" // No project-level repositories{} block here: settings.gradle.kts sets // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already @@ -38,7 +38,7 @@ compose.desktop { appResourcesRootDir.set(project.file("installerResources")) targetFormats(TargetFormat.Msi, TargetFormat.Exe) packageName = "wIRC" - packageVersion = "0.1.7" + packageVersion = "0.1.8" windows { menuGroup = "wIRC" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 417f90d..5c852b4 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -58,12 +58,6 @@ fun main() = application { var openBuffers by remember { mutableStateOf(setOf()) } var mainUnread by remember { mutableStateOf(0) } - // ConnectionManager routes WHOIS replies to the window that asked only while that - // window is still open, so it needs to know which ones are. - LaunchedEffect(openBuffers) { - ConnectionManager.openWindows = openBuffers - } - // Full shutdown: same steps /quit (bare, no args) already runs from a channel window - // disconnect every server, then end the process. Shared here so the "Exit wIRC" button // and every channel window's /quit reuse the exact same logic instead of duplicating it. diff --git a/src/main/kotlin/data/Models.kt b/src/main/kotlin/data/Models.kt index adebd51..4d31a1f 100644 --- a/src/main/kotlin/data/Models.kt +++ b/src/main/kotlin/data/Models.kt @@ -48,7 +48,7 @@ data class IrcMessage( data class BufferKey(val serverId: String, val name: String) /** - * wIRC main's own buffer: server messages (MOTD, numerics, errors, whois fallback) and + * wIRC main's own buffer: server messages (MOTD, numerics, errors, all whois output) and * services/server notices from every connected server. Server ids are built from the host * with every non-alphanumeric character replaced by '_' (see ServerEditDialog), so one can * never contain '-' - this id can't collide with a real server. Also used as the diff --git a/src/main/kotlin/irc/ConnectionManager.kt b/src/main/kotlin/irc/ConnectionManager.kt index dff6ed4..152bdb6 100644 --- a/src/main/kotlin/irc/ConnectionManager.kt +++ b/src/main/kotlin/irc/ConnectionManager.kt @@ -33,12 +33,22 @@ private const val SAVE_DEBOUNCE_MS = 2500L // WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target // nick (params[1]) is what ties a reply line back to the /whois that asked for it. -// 318 is RPL_ENDOFWHOIS; 401/402 are the "no such nick/server" errors a WHOIS can get. +// 318 is RPL_ENDOFWHOIS. These only ever come from a WHOIS, so they're shown as whois +// output even when no /whois of ours is pending (e.g. one sent through an alias). private val WHOIS_NUMERICS = setOf( "275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319", - "320", "330", "335", "338", "378", "379", "401", "402", "671" + "320", "330", "335", "338", "378", "379", "671" ) +// How long after a /whois is sent that a NOTICE from the server or services still counts +// as a response to it (e.g. a server-side ignore / caller-id notice), and so is labelled +// and grouped under that whois in wIRC main. +private const val WHOIS_NOTICE_WINDOW_MS = 10_000L + +// A /whois that never got its 318 (lost reply, server that doesn't send one) stops being +// pending after this long, so stray numerics much later aren't attributed to it. +private const val WHOIS_PENDING_TIMEOUT_MS = 60_000L + // Numerics that only make sense inside a channel window and are already reflected there // (353 fills the user list, see handleLine) or carry nothing worth showing on their own - // kept out of wIRC main so joining a channel doesn't flood it. @@ -63,13 +73,13 @@ object ConnectionManager { private val _ownNicks = MutableStateFlow>(emptyMap()) val ownNicksFlow: StateFlow> = _ownNicks - // Buffers that currently have a window open, kept up to date by Main.kt. Read when a - // WHOIS reply arrives, to decide whether the window that asked is still there to show it. - @Volatile - var openWindows: Set = emptySet() + // A /whois we sent and haven't seen the end of yet. headerShown tracks whether its + // "WHOIS " header line is already in wIRC main. + private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean) - // serverId -> (lowercased target nick -> the buffer whose window typed the /whois). - private val pendingWhois = ConcurrentHashMap>() + // serverId -> (lowercased target nick -> pending whois). Only ever touched from the + // bufferOps consumer coroutine (see enqueue), so a plain map is enough. + private val pendingWhois = HashMap>() fun init(rootDir: File) { historyRepo = MessageHistoryRepository(rootDir) @@ -194,21 +204,70 @@ object ConnectionManager { } /** - * Sends WHOIS and remembers which window asked, so the reply lines are shown in that - * window - or in wIRC main if it has been closed by the time they arrive. [args] is - * whatever followed "/whois"; its last word is the nick being looked up (the - * "/whois server nick" form puts the nick last too). + * Sends WHOIS. All of its output - the header, every reply line, and any server or + * services notice that comes back in response - goes to wIRC main, whichever window the + * command was typed in. [args] is whatever followed "/whois"; its last word is the nick + * being looked up (the "/whois server nick" form puts the nick last too), and may be a + * comma-separated list. */ - fun whois(serverId: String, args: String, origin: BufferKey) { - val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() } - if (nick == null) { - addSystemMessage(serverId, origin.name, "Usage: /whois ") + fun whois(serverId: String, args: String) { + val label = connectionFor(serverId)?.config?.label + val nicks = args.trim().split(Regex("\\s+")).lastOrNull() + ?.split(',')?.filter { it.isNotEmpty() }.orEmpty() + if (nicks.isEmpty() || label == null) { + val text = if (nicks.isEmpty()) "[whois] Usage: /whois " else "[whois] Not connected to this server" + enqueue { addMainLine(MessageKind.SYSTEM, null, text) } return } - pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin + // Enqueued before the WHOIS goes out, so the header is always in the buffer ahead + // of any reply (replies come in through the same queue, and can't arrive before + // the command is sent). Each further nick of a comma list gets its header when its + // first reply line arrives, so the lines end up grouped under the right one. + enqueue { + val now = System.currentTimeMillis() + val pending = pendingWhois.getOrPut(serverId) { HashMap() } + nicks.forEach { pending[it.lowercase()] = PendingWhois(it, now, headerShown = false) } + showWhoisHeader(serverId, pending.getValue(nicks.first().lowercase())) + } sendRaw(serverId, "WHOIS ${args.trim()}") } + private fun showWhoisHeader(serverId: String, pending: PendingWhois) { + if (pending.headerShown) return + pending.headerShown = true + val label = connectionFor(serverId)?.config?.label ?: serverId + // \u0002 is the mIRC bold toggle, so the header stands out from the reply lines. + addMainLine(MessageKind.SYSTEM, null, "\u0002===== WHOIS ${pending.nick} ($label) =====\u0002") + } + + // Pending whois for this server, with any that timed out dropped first. + private fun livePendingWhois(serverId: String): MutableMap? { + val pending = pendingWhois[serverId] ?: return null + val cutoff = System.currentTimeMillis() - WHOIS_PENDING_TIMEOUT_MS + pending.values.removeAll { it.sentAtMillis < cutoff } + return pending + } + + // A numeric is whois output if it's one of WHOIS_NUMERICS, or if it names a nick we + // have a /whois pending for (401/402 "no such nick/server", 716 "is in +g mode + // (server-side ignore)", ...), or if it's about the WHOIS command itself (263 + // "try again", 481, ...) while one is pending. + private fun isWhoisNumeric(serverId: String, line: ParsedLine): Boolean { + if (line.command in WHOIS_NUMERICS) return true + if (!line.command.all { it.isDigit() }) return false + val pending = livePendingWhois(serverId) + if (pending.isNullOrEmpty()) return false + val subject = line.params.getOrNull(1) ?: return false + return subject.lowercase() in pending || subject.equals("WHOIS", ignoreCase = true) + } + + // The most recently sent /whois on this server, if it went out recently enough that a + // server or services NOTICE arriving now is plausibly a response to it. + private fun whoisAwaitingNotice(serverId: String): PendingWhois? { + val recent = livePendingWhois(serverId)?.values?.maxByOrNull { it.sentAtMillis } ?: return null + return recent.takeIf { System.currentTimeMillis() - it.sentAtMillis <= WHOIS_NOTICE_WINDOW_MS } + } + // Local-only feedback (e.g. the /reload confirmation) that never touches the // wire - just appended to the buffer the same way any other SYSTEM line is. fun addSystemMessage(serverId: String, target: String, text: String) { @@ -282,17 +341,27 @@ object ConnectionManager { } } - // A WHOIS reply line goes to the window that typed the /whois if it's still open, - // otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main. + // Every WHOIS reply line goes to wIRC main, under its "WHOIS " header. A reply + // with no pending /whois (one sent through an alias or a raw command) gets a header + // of its own the same way, so every whois result in wIRC main starts with one. private fun handleWhoisLine(serverId: String, line: ParsedLine) { val target = line.params.getOrNull(1) ?: return - val pending = pendingWhois[serverId] - val origin = pending?.get(target.lowercase()) - if (line.command == "318") pending?.remove(target.lowercase()) - - val text = "[whois] " + formatWhoisLine(line) - val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text) - if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg) + val pending = livePendingWhois(serverId) + val entry = pending?.get(target.lowercase()) + ?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null + when { + entry != null -> showWhoisHeader(serverId, entry) + line.command in WHOIS_NUMERICS && line.command != "318" -> { + val unsolicited = PendingWhois(target, System.currentTimeMillis(), headerShown = false) + pendingWhois.getOrPut(serverId) { HashMap() }[target.lowercase()] = unsolicited + showWhoisHeader(serverId, unsolicited) + } + } + if (line.command == "318") { + // A comma-list whois ends with a single 318 naming the whole list. + target.split(',').forEach { pending?.remove(it.lowercase()) } + } + addMainLine(MessageKind.SYSTEM, null, "[whois] " + formatWhoisLine(line)) } private fun formatWhoisLine(line: ParsedLine): String { @@ -372,8 +441,13 @@ object ConnectionManager { } else if (isServiceOrServerPrefix(line.prefix)) { // NickServ/ChanServ replies (identify success/failure etc.) and server // notices - wIRC main. The sender goes into the text because the - // NOTICE row layout doesn't show a nick column. - addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text") + // NOTICE row layout doesn't show a nick column. One arriving right + // after a /whois (e.g. a server-side ignore notice) is labelled as part + // of that whois and goes under its header. + val whois = whoisAwaitingNotice(serverId) + if (whois != null) showWhoisHeader(serverId, whois) + val label = if (whois != null) "[whois] " else "" + addMainLine(MessageKind.NOTICE, nick, "$label-${nick ?: "server"}- $text") } else { // A notice directed AT us arrives with target == our own nick, so key // it by the sender, same as a private message. @@ -495,7 +569,7 @@ object ConnectionManager { "PONG", "CAP" -> Unit else -> { when { - line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line) + isWhoisNumeric(serverId, line) -> handleWhoisLine(serverId, line) line.command in CHANNEL_ONLY_NUMERICS -> Unit // Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a // server message; params[0] is just our own nick, so it's dropped. diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt index ba6ab9d..8b2d7ec 100644 --- a/src/main/kotlin/ui/ChannelScreen.kt +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -2,11 +2,18 @@ package ui import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.DarkDefaultContextMenuRepresentation import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalContextMenuRepresentation import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -18,8 +25,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -31,22 +38,41 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isShiftPressed +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -62,7 +88,10 @@ import data.WindowDisplaySettings import irc.extractImageUrls import irc.mircPalette import irc.parseMircToAnnotatedString +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -129,6 +158,25 @@ private fun openUrlInBrowser(url: String) { } } +// Writes to the system clipboard off the UI thread with a timeout - the same approach, +// for the same reason, as ChannelScreen's pasteFromClipboardSafely: the AWT clipboard is a +// blocking native call that can hang on Windows, and on the UI thread that hangs the whole +// app. Worst case here is a silently failed copy and one parked IO thread. +private fun copyToClipboardSafely(scope: CoroutineScope, text: String) { + scope.launch { + withContext(Dispatchers.IO) { + try { + withTimeoutOrNull(2000) { + java.awt.Toolkit.getDefaultToolkit().systemClipboard + .setContents(java.awt.datatransfer.StringSelection(text), null) + } + } catch (e: Exception) { + // Clipboard unavailable or held by another app - nothing to do. + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun WindowScope.ChannelScreen( @@ -162,6 +210,7 @@ fun WindowScope.ChannelScreen( var draftBeforeHistory by remember { mutableStateOf("") } val inputFocusRequester = remember { FocusRequester() } val coroutineScope = rememberCoroutineScope() + val scrollbackSelection = rememberScrollbackSelectionState() // Reading java.awt.Toolkit's system clipboard is a blocking native (JNI/AWT) call, and // on Windows it can genuinely hang - a well-documented class of bug where the OS-level @@ -387,7 +436,9 @@ fun WindowScope.ChannelScreen( displaySettings = displaySettings, textColor = textColor, isMention = { isMention(it) }, - modifier = Modifier.weight(1f).fillMaxHeight() + modifier = Modifier.weight(1f).fillMaxHeight(), + selectionState = scrollbackSelection, + onFocusInput = { inputFocusRequester.requestFocus() } ) // User list (tap and hold for OP actions) @@ -448,6 +499,9 @@ fun WindowScope.ChannelScreen( modifier = Modifier .fillMaxWidth() .focusRequester(inputFocusRequester) + // Going back to typing ends any scrollback selection, which also + // lets its paused auto-scroll catch up. + .onFocusChanged { if (it.isFocused) scrollbackSelection.clear() } .onPreviewKeyEvent { event -> // Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold, // Ctrl+I italic, Ctrl+V paste (see pasteFromClipboardSafely - @@ -523,18 +577,140 @@ fun WindowScope.ChannelScreen( } // Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main -// (MainScreen): the message rows, image previews, line-height snapping and the -// stick-to-bottom auto-scroll. Draws no background of its own - callers do. +// (MainScreen): the message rows, image previews, line-height snapping, the +// stick-to-bottom auto-scroll, and mouse text selection with copy (see +// ScrollbackSelectionState). Draws no background of its own - callers do. +// +// onFocusInput, if given, is called on a plain click in the scrollback and when a +// printable key is typed while the scrollback has focus, so the caller can put focus back +// in its input field - clicking the scrollback has to focus the scrollback itself first, +// or Ctrl+C would never reach it. @Composable fun MessageScrollback( messages: List, displaySettings: WindowDisplaySettings, textColor: Color, isMention: (String) -> Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + selectionState: ScrollbackSelectionState = rememberScrollbackSelectionState(), + onFocusInput: (() -> Unit)? = null ) { val listState = rememberLazyListState() val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) } + val coroutineScope = rememberCoroutineScope() + + // The pointer and key handlers below are installed once and outlive recompositions, + // so anything they read that can change between compositions goes through these. + val currentMessages by rememberUpdatedState(messages) + val currentOnFocusInput by rememberUpdatedState(onFocusInput) + + // (seq, part) -> where that part's text is on screen, for every row currently laid + // out. Rows add themselves when composed and remove themselves when disposed, so + // this only ever holds the visible rows. + val partLayouts = remember { HashMap, PartLayout>() } + val containerCoords = remember { arrayOfNulls(1) } + val scrollbackFocus = remember { FocusRequester() } + // Row under the last right-click, for the context menu's "Copy line". + var contextMenuRowSeq by remember { mutableStateOf(null) } + + fun prefixText(msg: IrcMessage): String = + rowPrefix(msg, timeFormat.format(Date(msg.timestampMillis)), textColor, textColor).text + + fun bodyText(msg: IrcMessage): String = parseMircToAnnotatedString(msg.rawText, textColor).text + + // The selected text, one line per message (rows are separate texts on screen, so + // without this the copied lines would run together). Built from the message list + // rather than from what's on screen, so a selection that's been scrolled partly out of + // view still copies in full. + fun textInRange(start: TextPos, end: TextPos): String = + currentMessages.filter { it.seq in start.seq..end.seq }.joinToString(System.lineSeparator()) { msg -> + buildString { + listOf(PART_PREFIX to prefixText(msg), PART_BODY to bodyText(msg)).forEach { (part, text) -> + partRange(start, end, msg.seq, part, text.length)?.let { append(text, it.first, it.second) } + } + } + } + + // Copying clears the selection (as Windows Terminal does), which lets auto-scroll + // resume straight away instead of staying paused until the next click. + fun copySelection(): Boolean { + val sel = selectionState.selection ?: return false + val text = textInRange(sel.start, sel.end) + selectionState.clear() + if (text.isNotEmpty()) copyToClipboardSafely(coroutineScope, text) + return true + } + + fun copyRow(seq: Long) { + val msg = currentMessages.firstOrNull { it.seq == seq } ?: return + copyToClipboardSafely(coroutineScope, prefixText(msg) + bodyText(msg)) + } + + fun selectAll() { + val first = currentMessages.firstOrNull() ?: return + val last = currentMessages.last() + selectionState.select(TextPos(first.seq, PART_PREFIX, 0), TextPos(last.seq, PART_BODY, bodyText(last).length)) + } + + // Maps a point in the scrollback (container coordinates) to the nearest character. + // The row is the one under the point, or the nearest visible one; a point above or + // below that row snaps to its start or end, so dragging past the top or bottom edge + // selects whole rows. + fun hitTest(p: Offset): TextPos? { + val container = containerCoords[0]?.takeIf { it.isAttached } ?: return null + class RowBounds(var top: Float, var bottom: Float, var bodyLeft: Float) + val rows = HashMap() + for ((key, part) in partLayouts) { + val coords = part.coords?.takeIf { it.isAttached } ?: continue + if (part.layout == null) continue + val b = container.localBoundingBoxOf(coords, clipBounds = false) + val row = rows.getOrPut(key.first) { RowBounds(b.top, b.bottom, Float.MAX_VALUE) } + row.top = minOf(row.top, b.top) + row.bottom = maxOf(row.bottom, b.bottom) + if (key.second == PART_BODY) row.bodyLeft = b.left + } + fun distance(r: RowBounds) = if (p.y < r.top) r.top - p.y else if (p.y >= r.bottom) p.y - r.bottom else 0f + val (seq, row) = rows.entries.minByOrNull { distance(it.value) }?.toPair() ?: return null + val body = partLayouts[seq to PART_BODY] + if (p.y < row.top) return TextPos(seq, PART_PREFIX, 0) + if (p.y >= row.bottom) return TextPos(seq, PART_BODY, body?.textLength ?: 0) + val part = if (p.x >= row.bodyLeft) PART_BODY else PART_PREFIX + val hit = partLayouts[seq to part] ?: return null + val layout = hit.layout ?: return null + val coords = hit.coords?.takeIf { it.isAttached } ?: return null + return TextPos(seq, part, layout.getOffsetForPosition(coords.localPositionOf(container, p))) + } + + // The URL under a point, if the point is actually on a link's text (not just level + // with it, past the end of the line). + fun urlAt(p: Offset): String? { + val container = containerCoords[0]?.takeIf { it.isAttached } ?: return null + for ((key, part) in partLayouts) { + if (key.second != PART_BODY) continue + val coords = part.coords?.takeIf { it.isAttached } ?: continue + val layout = part.layout ?: continue + val local = coords.localPositionOf(container, p) + if (local.y < 0 || local.y >= layout.size.height || local.x < 0) continue + val line = layout.getLineForVerticalPosition(local.y) + if (local.x > layout.getLineRight(line)) return null + val offset = layout.getOffsetForPosition(local) + return layout.layoutInput.text.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.item + } + return null + } + + fun wordAt(pos: TextPos): Pair { + val text = partLayouts[pos.seq to pos.part]?.layout?.layoutInput?.text?.text ?: return pos to pos + var start = pos.offset.coerceIn(0, text.length) + var end = start + while (start > 0 && !text[start - 1].isWhitespace()) start-- + while (end < text.length && !text[end].isWhitespace()) end++ + return pos.copy(offset = start) to pos.copy(offset = end) + } + + fun rowAt(pos: TextPos): Pair = + TextPos(pos.seq, PART_PREFIX, 0) to TextPos(pos.seq, PART_BODY, partLayouts[pos.seq to PART_BODY]?.textLength ?: 0) // Standard "stick to bottom unless the user scrolled up" chat-client pattern - the // ONLY code in this file that touches scroll position. A previous version of this had @@ -618,9 +794,16 @@ fun MessageScrollback( // there are no visible items yet, or the first visible item's key matches what that // captured list says should be at that index - which can only be true once layout has // processed up through at least that item. + // + // Paused while the user is selecting text (mid-drag, or a selection is showing), so the + // text never moves out from under the mouse. A new message landing meanwhile doesn't + // move anything either: reverseLayout keeps the first visible item anchored by key when + // one is inserted at index 0. selectionActive is a key so that clearing the selection + // relaunches this and catches up to whatever arrived while it was paused. val newestSeq = messages.lastOrNull()?.seq - LaunchedEffect(newestSeq) { - if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) { + val selectionActive = selectionState.isActive + LaunchedEffect(newestSeq, selectionActive) { + if (newestSeq != null && !selectionActive && stickToBottom && !listState.isScrollInProgress) { val capturedList = reversedMessages snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() } .first { item -> @@ -691,12 +874,148 @@ fun MessageScrollback( // offset correction through stickToBottom's own effect instead of a // second independent one. + val textStyle = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp + ) + val selection = selectionState.selection + Column(modifier = Modifier.fillMaxSize()) { + // Dark to match the black windows; the default context menu is light. + CompositionLocalProvider(LocalContextMenuRepresentation provides DarkDefaultContextMenuRepresentation) { + ContextMenuArea(items = { + buildList { + val rowSeq = contextMenuRowSeq + if (selectionState.selection != null) add(ContextMenuItem("Copy") { copySelection() }) + else if (rowSeq != null) add(ContextMenuItem("Copy line") { copyRow(rowSeq) }) + add(ContextMenuItem("Select all") { selectAll() }) + } + }) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(snappedHeight) + .onGloballyPositioned { containerCoords[0] = it } + .pointerHoverIcon(PointerIcon.Text) + // Remembers the row under a right-click for the menu's "Copy line". Watches the + // Initial pass, because ContextMenuArea takes the right-press there, before + // the handler below would see it. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { + contextMenuRowSeq = event.changes.firstOrNull()?.let { hitTest(it.position) }?.seq + } + } + } + } + .pointerInput(Unit) { + val multiClickMillis = + (java.awt.Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval") as? Int) ?: 500 + var lastClickMillis = 0L + var lastClickPos = Offset.Zero + var clickCount = 0 + // A link opens only once the double-click interval has passed with no + // second click, so double-clicking a link to select it doesn't open it. + var pendingLinkOpen: Job? = null + awaitEachGesture { + // requireUnconsumed: an image preview's own click handler consumes its + // press, so clicking an image opens it instead of starting a selection. + val down = awaitFirstDown(requireUnconsumed = true) + val downEvent = currentEvent + if (!downEvent.buttons.isPrimaryPressed) return@awaitEachGesture + down.consume() + pendingLinkOpen?.cancel() + scrollbackFocus.requestFocus() + + val sameSpot = (down.position - lastClickPos).getDistance() <= viewConfiguration.touchSlop + clickCount = if (down.uptimeMillis - lastClickMillis <= multiClickMillis && sameSpot) clickCount + 1 else 1 + lastClickMillis = down.uptimeMillis + lastClickPos = down.position + val shift = downEvent.keyboardModifiers.isShiftPressed + + // The anchor is what stays selected however far the drag goes: a point + // for a single click, the word for a double-click, the row for a triple. + val hit = hitTest(down.position) + val previousAnchor = selectionState.anchor + val anchor = when { + hit == null -> null + shift && previousAnchor != null -> previousAnchor + clickCount == 2 -> wordAt(hit) + clickCount >= 3 -> rowAt(hit) + else -> hit to hit + } + selectionState.anchor = anchor + fun extendTo(pos: TextPos) { + if (anchor != null) selectionState.select(minOf(anchor.first, pos), maxOf(anchor.second, pos)) + } + if (hit != null) extendTo(hit) else selectionState.clear() + + selectionState.dragging = true + var moved = false + var up: PointerInputChange? = null + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!moved && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) { + moved = true + } + // Also on mouse-wheel events mid-drag, so scrolling with the + // button held keeps extending the selection. + if (moved || event.type == PointerEventType.Scroll) hitTest(change.position)?.let { extendTo(it) } + if (event.type != PointerEventType.Scroll) change.consume() + if (!change.pressed) { + up = change + break + } + } + } finally { + selectionState.dragging = false + } + + // A plain single click: open the link under it, or hand focus back to + // the input field (what clicking the scrollback did before it was + // selectable). + if (up != null && !moved && clickCount == 1 && !shift) { + selectionState.clear() + val url = urlAt(up.position) + if (url != null) { + pendingLinkOpen = coroutineScope.launch { + delay(multiClickMillis.toLong()) + openUrlInBrowser(url) + } + } else { + currentOnFocusInput?.invoke() + } + } + } + } + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + val codePoint = event.utf16CodePoint + when { + event.isCtrlPressed && (event.key == Key.C || event.key == Key.Insert) -> copySelection() + event.isCtrlPressed && event.key == Key.A -> { selectAll(); true } + event.key == Key.Escape && selectionState.selection != null -> { selectionState.clear(); true } + // Typing while the scrollback has focus moves focus to the input field. + // Not consumed: the character that follows goes to the newly focused field. + !event.isCtrlPressed && !event.isAltPressed && codePoint in 32..0xFFFE && codePoint != 127 -> { + currentOnFocusInput?.invoke() + false + } + else -> false + } + } + .focusRequester(scrollbackFocus) + .focusable() + ) { LazyColumn( state = listState, reverseLayout = true, modifier = Modifier - .height(snappedHeight) + .fillMaxSize() .padding(horizontal = 6.dp) ) { items( @@ -713,88 +1032,42 @@ fun MessageScrollback( val mentioned = isMention(msg.rawText) val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor val mutedColor = textColor.copy(alpha = 0.7f) + val prefix = rowPrefix(msg, ts, mutedColor, nickColor) + + val prefixLayout = remember { PartLayout() } + val bodyLayout = remember { PartLayout() } + DisposableEffect(msg.seq) { + partLayouts[msg.seq to PART_PREFIX] = prefixLayout + partLayouts[msg.seq to PART_BODY] = bodyLayout + onDispose { + partLayouts.remove(msg.seq to PART_PREFIX) + partLayouts.remove(msg.seq to PART_BODY) + } + } + Column(modifier = Modifier.fillMaxWidth()) { - // Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick - // pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp - // lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without - // this, those Texts get a much taller line box than the ClickableText body - // below (which uses its own explicit TextStyle and never merges with the - // ambient one), and Row's default top alignment then renders the body - // visibly offset above its own prefix. The Android version never wraps - // ChannelScreen in a MaterialTheme at all, so it never hits this mismatch. - CompositionLocalProvider(LocalTextStyle provides androidx.compose.ui.text.TextStyle.Default) { + // Timestamp + nick are one text and the body another, both BasicText with + // the same explicit style - BasicText never merges the ambient LocalTextStyle, + // so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once + // rendered the body visibly offset from its prefix). The body is its own text + // so a wrapped message stays indented under the body column. Row( modifier = Modifier .fillMaxWidth() .let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it } ) { - when (msg.kind) { - MessageKind.CHAT -> { - Text( - text = ts, - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = " ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}", - color = nickColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - } - MessageKind.ACTION -> { - Text( - text = ts, - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = " * ", - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = "${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}", - color = nickColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - } - else -> { - Text( - text = ts, - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = displaySettings.fontSize.sp - ) - Text( - text = " * ", - color = mutedColor, - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ) - } - } - ClickableText( + SelectableRowText( + text = prefix, + style = textStyle, + selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_PREFIX, prefix.length) }, + partLayout = prefixLayout + ) + SelectableRowText( text = bodyAnnotated, - style = androidx.compose.ui.text.TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = displaySettings.fontSize.sp - ), - onClick = { offset -> - bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - openUrlInBrowser(annotation.item) - } - } + style = textStyle, + selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_BODY, bodyAnnotated.length) }, + partLayout = bodyLayout ) - } } if (displaySettings.showImagePreviews) { extractImageUrls(msg.rawText).forEach { url -> @@ -811,11 +1084,120 @@ fun MessageScrollback( } } } + } + } + } Spacer(modifier = Modifier.weight(1f)) } } } +// Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body. +private const val PART_PREFIX = 0 +private const val PART_BODY = 1 + +// A point in the scrollback's text: the message (by seq, which follows message order), +// which of its two texts, and a character offset into that text. +data class TextPos(val seq: Long, val part: Int, val offset: Int) : Comparable { + override fun compareTo(other: TextPos): Int = + compareValuesBy(this, other, { it.seq }, { it.part }, { it.offset }) +} + +// A non-empty selection, start before end. +data class TextSelection(val start: TextPos, val end: TextPos) + +/** + * Text selection in a MessageScrollback. Hoisted so a caller can clear it - ChannelScreen + * does when its input field gains focus, which also lets the paused auto-scroll catch up. + */ +class ScrollbackSelectionState { + var selection by mutableStateOf(null) + private set + + // True while the mouse button is held down in the scrollback. + var dragging by mutableStateOf(false) + internal set + + // The word/row/point the current selection was started from - see MessageScrollback. + internal var anchor: Pair? = null + + // Auto-scroll stays paused while this is true. + val isActive: Boolean get() = dragging || selection != null + + fun select(start: TextPos, end: TextPos) { + selection = if (start < end) TextSelection(start, end) else null + } + + fun clear() { + selection = null + anchor = null + } +} + +@Composable +fun rememberScrollbackSelectionState(): ScrollbackSelectionState = remember { ScrollbackSelectionState() } + +// Where one row text was last laid out, kept up to date by SelectableRowText. +private class PartLayout { + var coords: LayoutCoordinates? = null + var layout: TextLayoutResult? = null + val textLength: Int get() = layout?.layoutInput?.text?.length ?: 0 +} + +// The part of [start, end) that falls inside one row text, as a character range into it. +private fun partRange(start: TextPos, end: TextPos, seq: Long, part: Int, length: Int): Pair? { + val from = maxOf(start, TextPos(seq, part, 0)) + val to = minOf(end, TextPos(seq, part, length)) + return if (from < to) from.offset to to.offset else null +} + +private val SELECTION_COLOR = Color(0xFF3399FF).copy(alpha = 0.45f) + +// A row text that records its layout for hit-testing and draws the selection behind +// itself. The highlight is drawn rather than added as a background span, so changing the +// selection only redraws - it never re-measures the text. +@Composable +private fun SelectableRowText( + text: AnnotatedString, + style: androidx.compose.ui.text.TextStyle, + selected: Pair?, + partLayout: PartLayout +) { + var layout by remember { mutableStateOf(null) } + BasicText( + text = text, + style = style, + onTextLayout = { + layout = it + partLayout.layout = it + }, + modifier = Modifier + .onGloballyPositioned { partLayout.coords = it } + .drawBehind { + val l = layout + if (selected != null && l != null && selected.second <= l.layoutInput.text.length) { + drawPath(l.getPathForRange(selected.first, selected.second), SELECTION_COLOR) + } + } + ) +} + +// The timestamp + nick (or " * ") prefix of a row, styled as it's shown. +private fun rowPrefix(msg: IrcMessage, ts: String, mutedColor: Color, nickColor: Color): AnnotatedString = + buildAnnotatedString { + withStyle(SpanStyle(color = mutedColor, fontWeight = FontWeight.Bold)) { append(ts) } + when (msg.kind) { + MessageKind.CHAT -> withStyle(SpanStyle(color = nickColor)) { + append(" ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}") + } + MessageKind.ACTION -> { + withStyle(SpanStyle(color = mutedColor)) { append(" * ") } + withStyle(SpanStyle(color = nickColor)) { append("${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}") } + } + else -> withStyle(SpanStyle(color = mutedColor)) { append(" * ") } + } + } + // A 28dp title-bar IconButton that rests invisible and fades in while hovered. The fade // is a graphicsLayer alpha only - the button is always laid out, drawn and hit-tested, so // it still gets hover and clicks at alpha 0. The alpha is read inside the graphicsLayer diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt index 26a7c14..e04a98e 100644 --- a/src/main/kotlin/ui/ChannelWindow.kt +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -127,9 +127,8 @@ fun ChannelWindow( ConnectionManager.sendRaw(serverId, "JOIN $args") onOpenBuffer(BufferKey(serverId, args)) } - // Reply lines come back to this window, or to wIRC main if - // this window is closed before they arrive. - "WHOIS" -> ConnectionManager.whois(serverId, args, origin = bufferKey) + // All whois output goes to wIRC main, not this window. + "WHOIS" -> ConnectionManager.whois(serverId, args) "MSG" -> { val msgSpaceIdx = args.indexOf(' ') if (msgSpaceIdx >= 0) { diff --git a/src/main/kotlin/ui/MainScreen.kt b/src/main/kotlin/ui/MainScreen.kt index 088135f..1f8f761 100644 --- a/src/main/kotlin/ui/MainScreen.kt +++ b/src/main/kotlin/ui/MainScreen.kt @@ -168,8 +168,9 @@ fun MainScreen( } } Spacer(Modifier.height(4.dp)) - // MOTD, numerics, errors, services/server notices and WHOIS replies whose window - // is gone - see ConnectionManager. Same rendering and auto-scroll as a channel window. + // 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,