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:
co-authored by
Claude Opus 5.5
parent
807903fe0f
commit
d9a0297cc7
+2
-2
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user