Add scrollback text selection/copy, route all /whois output to wIRC main, bump to 0.1.8

- 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>
This commit is contained in:
weishaupt
2026-10-05 17:07:52 -04:00
co-authored by Claude Opus 5.5
parent 807903fe0f
commit d9a0297cc7
7 changed files with 575 additions and 125 deletions
+2 -2
View File
@@ -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"
-6
View File
@@ -58,12 +58,6 @@ fun main() = application {
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
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.
+1 -1
View File
@@ -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
+103 -29
View File
@@ -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<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _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<BufferKey> = emptySet()
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
// "WHOIS <nick>" 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<String, ConcurrentHashMap<String, BufferKey>>()
// 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<String, MutableMap<String, PendingWhois>>()
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 <nick>")
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 <nick>" 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<String, PendingWhois>? {
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 <nick>" 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.
+464 -82
View File
@@ -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<IrcMessage>,
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<Pair<Long, Int>, PartLayout>() }
val containerCoords = remember { arrayOfNulls<LayoutCoordinates>(1) }
val scrollbackFocus = remember { FocusRequester() }
// Row under the last right-click, for the context menu's "Copy line".
var contextMenuRowSeq by remember { mutableStateOf<Long?>(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<Long, RowBounds>()
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<TextPos, TextPos> {
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, TextPos> =
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<TextPos> {
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<TextSelection?>(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<TextPos, TextPos>? = 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<Int, Int>? {
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<Int, Int>?,
partLayout: PartLayout
) {
var layout by remember { mutableStateOf<TextLayoutResult?>(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
+2 -3
View File
@@ -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) {
+3 -2
View File
@@ -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,