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"
|
group = "com.tpuahsiew.wirc"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
|
|
||||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||||
@@ -38,7 +38,7 @@ compose.desktop {
|
|||||||
appResourcesRootDir.set(project.file("installerResources"))
|
appResourcesRootDir.set(project.file("installerResources"))
|
||||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||||
packageName = "wIRC"
|
packageName = "wIRC"
|
||||||
packageVersion = "0.1.7"
|
packageVersion = "0.1.8"
|
||||||
windows {
|
windows {
|
||||||
menuGroup = "wIRC"
|
menuGroup = "wIRC"
|
||||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ fun main() = application {
|
|||||||
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||||
var mainUnread by remember { mutableStateOf(0) }
|
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 -
|
// 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
|
// 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.
|
// 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)
|
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
|
* 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
|
* 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
|
* 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
|
// 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.
|
// 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(
|
private val WHOIS_NUMERICS = setOf(
|
||||||
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
|
"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
|
// 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 -
|
// (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.
|
// 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())
|
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||||
|
|
||||||
// Buffers that currently have a window open, kept up to date by Main.kt. Read when a
|
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
|
||||||
// WHOIS reply arrives, to decide whether the window that asked is still there to show it.
|
// "WHOIS <nick>" header line is already in wIRC main.
|
||||||
@Volatile
|
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
|
||||||
var openWindows: Set<BufferKey> = emptySet()
|
|
||||||
|
|
||||||
// serverId -> (lowercased target nick -> the buffer whose window typed the /whois).
|
// serverId -> (lowercased target nick -> pending whois). Only ever touched from the
|
||||||
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>()
|
// bufferOps consumer coroutine (see enqueue), so a plain map is enough.
|
||||||
|
private val pendingWhois = HashMap<String, MutableMap<String, PendingWhois>>()
|
||||||
|
|
||||||
fun init(rootDir: File) {
|
fun init(rootDir: File) {
|
||||||
historyRepo = MessageHistoryRepository(rootDir)
|
historyRepo = MessageHistoryRepository(rootDir)
|
||||||
@@ -194,21 +204,70 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends WHOIS and remembers which window asked, so the reply lines are shown in that
|
* Sends WHOIS. All of its output - the header, every reply line, and any server or
|
||||||
* window - or in wIRC main if it has been closed by the time they arrive. [args] is
|
* services notice that comes back in response - goes to wIRC main, whichever window the
|
||||||
* whatever followed "/whois"; its last word is the nick being looked up (the
|
* command was typed in. [args] is whatever followed "/whois"; its last word is the nick
|
||||||
* "/whois server nick" form puts the nick last too).
|
* 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) {
|
fun whois(serverId: String, args: String) {
|
||||||
val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() }
|
val label = connectionFor(serverId)?.config?.label
|
||||||
if (nick == null) {
|
val nicks = args.trim().split(Regex("\\s+")).lastOrNull()
|
||||||
addSystemMessage(serverId, origin.name, "Usage: /whois <nick>")
|
?.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
|
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()}")
|
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
|
// 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.
|
// wire - just appended to the buffer the same way any other SYSTEM line is.
|
||||||
fun addSystemMessage(serverId: String, target: String, text: String) {
|
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,
|
// Every WHOIS reply line goes to wIRC main, under its "WHOIS <nick>" header. A reply
|
||||||
// otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main.
|
// 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) {
|
private fun handleWhoisLine(serverId: String, line: ParsedLine) {
|
||||||
val target = line.params.getOrNull(1) ?: return
|
val target = line.params.getOrNull(1) ?: return
|
||||||
val pending = pendingWhois[serverId]
|
val pending = livePendingWhois(serverId)
|
||||||
val origin = pending?.get(target.lowercase())
|
val entry = pending?.get(target.lowercase())
|
||||||
if (line.command == "318") pending?.remove(target.lowercase())
|
?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null
|
||||||
|
when {
|
||||||
val text = "[whois] " + formatWhoisLine(line)
|
entry != null -> showWhoisHeader(serverId, entry)
|
||||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text)
|
line.command in WHOIS_NUMERICS && line.command != "318" -> {
|
||||||
if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg)
|
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 {
|
private fun formatWhoisLine(line: ParsedLine): String {
|
||||||
@@ -372,8 +441,13 @@ object ConnectionManager {
|
|||||||
} else if (isServiceOrServerPrefix(line.prefix)) {
|
} else if (isServiceOrServerPrefix(line.prefix)) {
|
||||||
// NickServ/ChanServ replies (identify success/failure etc.) and server
|
// NickServ/ChanServ replies (identify success/failure etc.) and server
|
||||||
// notices - wIRC main. The sender goes into the text because the
|
// notices - wIRC main. The sender goes into the text because the
|
||||||
// NOTICE row layout doesn't show a nick column.
|
// NOTICE row layout doesn't show a nick column. One arriving right
|
||||||
addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text")
|
// 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 {
|
} else {
|
||||||
// A notice directed AT us arrives with target == our own nick, so key
|
// A notice directed AT us arrives with target == our own nick, so key
|
||||||
// it by the sender, same as a private message.
|
// it by the sender, same as a private message.
|
||||||
@@ -495,7 +569,7 @@ object ConnectionManager {
|
|||||||
"PONG", "CAP" -> Unit
|
"PONG", "CAP" -> Unit
|
||||||
else -> {
|
else -> {
|
||||||
when {
|
when {
|
||||||
line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line)
|
isWhoisNumeric(serverId, line) -> handleWhoisLine(serverId, line)
|
||||||
line.command in CHANNEL_ONLY_NUMERICS -> Unit
|
line.command in CHANNEL_ONLY_NUMERICS -> Unit
|
||||||
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
|
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
|
||||||
// server message; params[0] is just our own nick, so it's dropped.
|
// 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.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
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.Image
|
||||||
|
import androidx.compose.foundation.LocalContextMenuRepresentation
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
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.hoverable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.ClickableText
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -31,22 +38,41 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.input.key.*
|
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.ContentScale
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -62,7 +88,10 @@ import data.WindowDisplaySettings
|
|||||||
import irc.extractImageUrls
|
import irc.extractImageUrls
|
||||||
import irc.mircPalette
|
import irc.mircPalette
|
||||||
import irc.parseMircToAnnotatedString
|
import irc.parseMircToAnnotatedString
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun WindowScope.ChannelScreen(
|
fun WindowScope.ChannelScreen(
|
||||||
@@ -162,6 +210,7 @@ fun WindowScope.ChannelScreen(
|
|||||||
var draftBeforeHistory by remember { mutableStateOf("") }
|
var draftBeforeHistory by remember { mutableStateOf("") }
|
||||||
val inputFocusRequester = remember { FocusRequester() }
|
val inputFocusRequester = remember { FocusRequester() }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val scrollbackSelection = rememberScrollbackSelectionState()
|
||||||
|
|
||||||
// Reading java.awt.Toolkit's system clipboard is a blocking native (JNI/AWT) call, and
|
// 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
|
// 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,
|
displaySettings = displaySettings,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
isMention = { isMention(it) },
|
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)
|
// User list (tap and hold for OP actions)
|
||||||
@@ -448,6 +499,9 @@ fun WindowScope.ChannelScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRequester(inputFocusRequester)
|
.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 ->
|
.onPreviewKeyEvent { event ->
|
||||||
// Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold,
|
// Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold,
|
||||||
// Ctrl+I italic, Ctrl+V paste (see pasteFromClipboardSafely -
|
// 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
|
// Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main
|
||||||
// (MainScreen): the message rows, image previews, line-height snapping and the
|
// (MainScreen): the message rows, image previews, line-height snapping, the
|
||||||
// stick-to-bottom auto-scroll. Draws no background of its own - callers do.
|
// 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
|
@Composable
|
||||||
fun MessageScrollback(
|
fun MessageScrollback(
|
||||||
messages: List<IrcMessage>,
|
messages: List<IrcMessage>,
|
||||||
displaySettings: WindowDisplaySettings,
|
displaySettings: WindowDisplaySettings,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
isMention: (String) -> Boolean,
|
isMention: (String) -> Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
selectionState: ScrollbackSelectionState = rememberScrollbackSelectionState(),
|
||||||
|
onFocusInput: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) }
|
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
|
// 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
|
// 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
|
// 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
|
// captured list says should be at that index - which can only be true once layout has
|
||||||
// processed up through at least that item.
|
// 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
|
val newestSeq = messages.lastOrNull()?.seq
|
||||||
LaunchedEffect(newestSeq) {
|
val selectionActive = selectionState.isActive
|
||||||
if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) {
|
LaunchedEffect(newestSeq, selectionActive) {
|
||||||
|
if (newestSeq != null && !selectionActive && stickToBottom && !listState.isScrollInProgress) {
|
||||||
val capturedList = reversedMessages
|
val capturedList = reversedMessages
|
||||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() }
|
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() }
|
||||||
.first { item ->
|
.first { item ->
|
||||||
@@ -691,12 +874,148 @@ fun MessageScrollback(
|
|||||||
// offset correction through stickToBottom's own effect instead of a
|
// offset correction through stickToBottom's own effect instead of a
|
||||||
// second independent one.
|
// 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()) {
|
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(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
reverseLayout = true,
|
reverseLayout = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(snappedHeight)
|
.fillMaxSize()
|
||||||
.padding(horizontal = 6.dp)
|
.padding(horizontal = 6.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
@@ -713,88 +1032,42 @@ fun MessageScrollback(
|
|||||||
val mentioned = isMention(msg.rawText)
|
val mentioned = isMention(msg.rawText)
|
||||||
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
||||||
val mutedColor = textColor.copy(alpha = 0.7f)
|
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()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
// Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick
|
// Timestamp + nick are one text and the body another, both BasicText with
|
||||||
// pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp
|
// the same explicit style - BasicText never merges the ambient LocalTextStyle,
|
||||||
// lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without
|
// so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once
|
||||||
// this, those Texts get a much taller line box than the ClickableText body
|
// rendered the body visibly offset from its prefix). The body is its own text
|
||||||
// below (which uses its own explicit TextStyle and never merges with the
|
// so a wrapped message stays indented under the body column.
|
||||||
// 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) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
|
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
|
||||||
) {
|
) {
|
||||||
when (msg.kind) {
|
SelectableRowText(
|
||||||
MessageKind.CHAT -> {
|
text = prefix,
|
||||||
Text(
|
style = textStyle,
|
||||||
text = ts,
|
selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_PREFIX, prefix.length) },
|
||||||
color = mutedColor,
|
partLayout = prefixLayout
|
||||||
fontFamily = FontFamily.Monospace,
|
)
|
||||||
fontWeight = FontWeight.Bold,
|
SelectableRowText(
|
||||||
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(
|
|
||||||
text = bodyAnnotated,
|
text = bodyAnnotated,
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
style = textStyle,
|
||||||
fontFamily = FontFamily.Monospace,
|
selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_BODY, bodyAnnotated.length) },
|
||||||
fontSize = displaySettings.fontSize.sp
|
partLayout = bodyLayout
|
||||||
),
|
|
||||||
onClick = { offset ->
|
|
||||||
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
|
||||||
.firstOrNull()?.let { annotation ->
|
|
||||||
openUrlInBrowser(annotation.item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (displaySettings.showImagePreviews) {
|
if (displaySettings.showImagePreviews) {
|
||||||
extractImageUrls(msg.rawText).forEach { url ->
|
extractImageUrls(msg.rawText).forEach { url ->
|
||||||
@@ -811,11 +1084,120 @@ fun MessageScrollback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
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
|
// 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
|
// 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
|
// 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")
|
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
||||||
onOpenBuffer(BufferKey(serverId, args))
|
onOpenBuffer(BufferKey(serverId, args))
|
||||||
}
|
}
|
||||||
// Reply lines come back to this window, or to wIRC main if
|
// All whois output goes to wIRC main, not this window.
|
||||||
// this window is closed before they arrive.
|
"WHOIS" -> ConnectionManager.whois(serverId, args)
|
||||||
"WHOIS" -> ConnectionManager.whois(serverId, args, origin = bufferKey)
|
|
||||||
"MSG" -> {
|
"MSG" -> {
|
||||||
val msgSpaceIdx = args.indexOf(' ')
|
val msgSpaceIdx = args.indexOf(' ')
|
||||||
if (msgSpaceIdx >= 0) {
|
if (msgSpaceIdx >= 0) {
|
||||||
|
|||||||
@@ -168,8 +168,9 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
// MOTD, numerics, errors, services/server notices and WHOIS replies whose window
|
// MOTD, numerics, errors, services/server notices and all /whois output - see
|
||||||
// is gone - see ConnectionManager. Same rendering and auto-scroll as a channel window.
|
// ConnectionManager. Same rendering, auto-scroll and text selection as a channel
|
||||||
|
// window.
|
||||||
MessageScrollback(
|
MessageScrollback(
|
||||||
messages = mainMessages,
|
messages = mainMessages,
|
||||||
displaySettings = displaySettings,
|
displaySettings = displaySettings,
|
||||||
|
|||||||
Reference in New Issue
Block a user