/** * Full mIRC/IRCv3 colour table: 0-15 legacy + 16-98 extended (99 total). * * Codes 0-15 are the original mIRC palette used by essentially all IRC clients. * Codes 16-98 are the modern IRCv3 extension published at * https://modern.ircdocs.horse/formatting.html#color - supported by mIRC 7+, * WeeChat, HexChat, and most modern clients. * * Each entry is a 0xAARRGGBB value. */ /** * Canonical mIRC / IRCv3 colour palette — 99 entries (codes 0–98). * * Source: https://modern.ircdocs.horse/formatting.html#color (the "IRC Colour" specification). * These are the exact RGB hex values that mIRC 7+, HexChat, WeeChat, and other modern * clients use. Codes 0–15 are the original mIRC palette; codes 16–97 are the extended * IRCv3 block (6 rows of 16, laid out as a gradient grid); code 98 is the spec-defined * "transparent/default" entry which maps to white for rendering purposes. * * Layout of codes 16–97 in the grid (each row = 16 entries, darkest → lightest): * Row 1 (16–27): greys darkening right → pure blacks at left * Row 2 (28–39): dark shades — red, orange, yellow, green, cyan, blue, purple, pink * Row 3 (40–51): mid shades * Row 4 (52–63): bright / saturated * Row 5 (64–75): light / pastel * Row 6 (76–87): very light / near-white pastels * Row 7 (88–98): greyscale ramp (black → white), code 98 = white alias */ private val MIRC_PALETTE: IntArray = intArrayOf( // ── 0–15: classic mIRC palette ──────────────────────────────────────────── 0xFFFFFFFF.toInt(), // 0 White 0xFF000000.toInt(), // 1 Black 0xFF00007F.toInt(), // 2 Blue (navy) 0xFF009300.toInt(), // 3 Green 0xFFFF0000.toInt(), // 4 Red 0xFF7F0000.toInt(), // 5 Brown (maroon) 0xFF9C009C.toInt(), // 6 Purple 0xFFFC7F00.toInt(), // 7 Orange 0xFFFFFF00.toInt(), // 8 Yellow 0xFF00FC00.toInt(), // 9 Light green (lime) 0xFF009393.toInt(), // 10 Teal 0xFF00FFFF.toInt(), // 11 Light cyan (aqua) 0xFF0000FC.toInt(), // 12 Light blue (royal) 0xFFFF00FF.toInt(), // 13 Pink (magenta / fuchsia) 0xFF7F7F7F.toInt(), // 14 Grey 0xFFD2D2D2.toInt(), // 15 Light grey // ── 16–27: darkest shades (row 1 of extended block) ────────────────────── 0xFF470000.toInt(), // 16 Dark maroon 0xFF472100.toInt(), // 17 Very dark orange 0xFF474700.toInt(), // 18 Dark olive 0xFF324700.toInt(), // 19 Very dark green 0xFF004732.toInt(), // 20 Very dark teal-green 0xFF00472C.toInt(), // 21 Very dark teal (alt) 0xFF004747.toInt(), // 22 Very dark teal 0xFF002747.toInt(), // 23 Very dark slate blue 0xFF000047.toInt(), // 24 Very dark navy 0xFF2E0047.toInt(), // 25 Very dark violet 0xFF470047.toInt(), // 26 Very dark purple-magenta 0xFF47002A.toInt(), // 27 Very dark crimson // ── 28–39: dark shades (row 2) ─────────────────────────────────────────── 0xFF740000.toInt(), // 28 Dark red 0xFF743A00.toInt(), // 29 Dark brown-orange 0xFF747400.toInt(), // 30 Dark yellow-olive 0xFF517400.toInt(), // 31 Dark chartreuse 0xFF007400.toInt(), // 32 Dark green 0xFF007449.toInt(), // 33 Dark sea-green 0xFF007474.toInt(), // 34 Dark teal 0xFF004074.toInt(), // 35 Dark dodger blue 0xFF000074.toInt(), // 36 Dark blue 0xFF4B0074.toInt(), // 37 Dark purple-blue 0xFF740074.toInt(), // 38 Dark magenta 0xFF740045.toInt(), // 39 Dark hot pink // ── 40–51: mid shades (row 3) ──────────────────────────────────────────── 0xFFB50000.toInt(), // 40 Medium red 0xFFB56300.toInt(), // 41 Medium orange 0xFFB5B500.toInt(), // 42 Medium yellow-green 0xFF7DB500.toInt(), // 43 Chartreuse 0xFF00B500.toInt(), // 44 Medium green 0xFF00B573.toInt(), // 45 Medium mint 0xFF00B5B5.toInt(), // 46 Medium teal 0xFF0063B5.toInt(), // 47 Medium dodger blue 0xFF0000B5.toInt(), // 48 Medium blue 0xFF7500B5.toInt(), // 49 Medium violet 0xFFB500B5.toInt(), // 50 Medium magenta 0xFFB5006B.toInt(), // 51 Medium hot pink // ── 52–63: bright / saturated (row 4) ──────────────────────────────────── 0xFFFF0000.toInt(), // 52 Bright red 0xFFFF9200.toInt(), // 53 Bright orange / gold 0xFFFFFF00.toInt(), // 54 Bright yellow 0xFFB9FF00.toInt(), // 55 Bright yellow-green 0xFF00FF00.toInt(), // 56 Bright lime green 0xFF00FFA8.toInt(), // 57 Bright spring green 0xFF00FFFF.toInt(), // 58 Bright cyan / aqua 0xFF009BFF.toInt(), // 59 Bright azure / sky blue 0xFF0000FF.toInt(), // 60 Bright blue 0xFFAD00FF.toInt(), // 61 Bright electric purple 0xFFFF00FF.toInt(), // 62 Bright magenta / fuchsia 0xFFFF0092.toInt(), // 63 Bright rose / hot pink // ── 64–75: light / pastel (row 5) ──────────────────────────────────────── 0xFFFF6666.toInt(), // 64 Light red 0xFFFFB466.toInt(), // 65 Light orange / peach 0xFFFFFF66.toInt(), // 66 Light yellow 0xFFCCFF66.toInt(), // 67 Light chartreuse 0xFF66FF66.toInt(), // 68 Light green 0xFF66FFB4.toInt(), // 69 Light mint 0xFF66FFFF.toInt(), // 70 Light cyan 0xFF66B4FF.toInt(), // 71 Light sky blue 0xFF6666FF.toInt(), // 72 Light blue-purple 0xFFCC66FF.toInt(), // 73 Light violet 0xFFFF66FF.toInt(), // 74 Light magenta / orchid 0xFFFF66B4.toInt(), // 75 Light pink // ── 76–87: very light / near-white pastels (row 6) ─────────────────────── 0xFFFFB4B4.toInt(), // 76 Very light red / salmon 0xFFFFDEB4.toInt(), // 77 Very light orange / bisque 0xFFFFFFB4.toInt(), // 78 Very light yellow / cream 0xFFE6FFB4.toInt(), // 79 Very light chartreuse / honeydew 0xFFB4FFB4.toInt(), // 80 Very light green / mint cream 0xFFB4FFE6.toInt(), // 81 Very light mint / azure-mint 0xFFB4FFFF.toInt(), // 82 Very light cyan / azure 0xFFB4DEFF.toInt(), // 83 Very light sky blue / alice blue 0xFFB4B4FF.toInt(), // 84 Very light lavender 0xFFDEB4FF.toInt(), // 85 Very light violet / lavender blush 0xFFFFB4FF.toInt(), // 86 Very light magenta / thistle 0xFFFFB4DE.toInt(), // 87 Very light pink / lavender rose // ── 88–98: greyscale ramp (row 7) ──────────────────────────────────────── 0xFF000000.toInt(), // 88 Black 0xFF141414.toInt(), // 89 Near-black 0xFF282828.toInt(), // 90 Very dark grey 0xFF3C3C3C.toInt(), // 91 Dark grey 0xFF505050.toInt(), // 92 Dark-mid grey 0xFF646464.toInt(), // 93 Mid grey 0xFF787878.toInt(), // 94 Mid-light grey 0xFF8C8C8C.toInt(), // 95 Light-mid grey 0xFFA0A0A0.toInt(), // 96 Light grey 0xFFB4B4B4.toInt(), // 97 Pale grey 0xFFC8C8C8.toInt(), // 98 Silver / near-white (spec "default" alias) ) private fun mircColor(code: Int): Color? = MIRC_PALETTE.getOrNull(code)?.let { Color(it.toLong() and 0xFFFFFFFFL) } /** How many mIRC colour codes are defined (0-based, inclusive of 0). */ private const val MIRC_COLOR_COUNT = 99 private fun MircStyleState.toSpanStyle(): SpanStyle { val fgCode = if (reverse) bg else fg val bgCode = if (reverse) fg else bg val fgColor = fgCode?.let(::mircColor) ?: Color.Unspecified val bgColor = bgCode?.let(::mircColor) ?: Color.Unspecified return SpanStyle( color = fgColor, background = bgColor, fontWeight = if (bold) FontWeight.Bold else null, fontStyle = if (italic) FontStyle.Italic else null, textDecoration = if (underline) TextDecoration.Underline else null, ) } private fun parseMircRuns(input: String): List { if (input.isEmpty()) return emptyList() val out = mutableListOf() val buf = StringBuilder() val st = MircStyleState() fun flush() { if (buf.isNotEmpty()) { out += MircRun(buf.toString(), st.snapshot()) buf.setLength(0) } } fun parseOneOrTwoDigits(startIndex: Int): Pair { var i = startIndex if (i >= input.length || !input[i].isDigit()) return (null to i) val first = input[i] i++ if (i < input.length && input[i].isDigit()) { val num = ("$first${input[i]}").toIntOrNull() i++ return (num to i) } return (first.toString().toIntOrNull() to i) } var i = 0 while (i < input.length) { when (val c = input[i]) { '\u0003' -> { // colour flush() i++ val (fg, ni) = parseOneOrTwoDigits(i) i = ni if (fg == null) { // \x03 alone resets colours. st.fg = null st.bg = null } else { st.fg = fg // Optional ,bg if (i < input.length && input[i] == ',') { i++ val (bg, n2) = parseOneOrTwoDigits(i) i = n2 st.bg = bg } } } '\u000F' -> { // reset flush() st.reset() i++ } '\u0002' -> { // bold flush(); st.bold = !st.bold; i++ } '\u001D' -> { // italic flush(); st.italic = !st.italic; i++ } '\u001F' -> { // underline flush(); st.underline = !st.underline; i++ } '\u0016' -> { // reverse flush(); st.reverse = !st.reverse; i++ } else -> { // Drop other C0 controls (except common whitespace). if (c.code < 0x20 && c != '\n' && c != '\t' && c != '\r') { i++ } else { buf.append(c) i++ } } } } flush() return out } private fun AnnotatedString.Builder.appendIrcStyledLinkified( text: String, linkStyle: SpanStyle, mircColorsEnabled: Boolean, ansiColorsEnabled: Boolean = false, ) { val hasAnsi = ansiColorsEnabled && text.contains('\u001b') val hasMirc = mircColorsEnabled && !hasAnsi && (text.contains('\u0003') || text.contains('\u0004') || text.contains('\u0002') || text.contains('\u001D') || text.contains('\u001F') || text.contains('\u0016')) when { hasAnsi -> { val runs = parseAnsiRuns(text) if (runs.isEmpty()) return for (r in runs) { if (r.style.hasAnyStyle()) { withStyle(r.style.ansiToSpanStyle()) { appendLinkified(this, r.text, linkStyle) } } else { appendLinkified(this, r.text, linkStyle) } } } hasMirc -> { val runs = parseMircRuns(text) if (runs.isEmpty()) return for (r in runs) { if (r.style.hasAnyStyle()) { withStyle(r.style.toSpanStyle()) { appendLinkified(this, r.text, linkStyle) } } else { appendLinkified(this, r.text, linkStyle) } } } else -> appendLinkified(this, stripIrcFormatting(text), linkStyle) } } @Composable private fun AnnotatedClickableText( text: AnnotatedString, onAnnotationClick: (String, String) -> Unit, modifier: Modifier = Modifier, style: androidx.compose.ui.text.TextStyle = LocalTextStyle.current, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { var layout: TextLayoutResult? by remember { mutableStateOf(null) } Text( text = text, style = style, maxLines = maxLines, overflow = overflow, onTextLayout = { layout = it onTextLayout?.invoke(it) }, modifier = modifier.pointerInput(text) { val vc = viewConfiguration awaitEachGesture { // Don't consume gestures: allow selection (long-press/drag) to work. val down = awaitFirstDown(requireUnconsumed = false) val downPos = down.position val downTime = down.uptimeMillis val up = waitForUpOrCancellation() ?: return@awaitEachGesture val dt = up.uptimeMillis - downTime val dist = (up.position - downPos).getDistance() // Treat only quick taps as clicks so selection gestures don't accidentally open links. if (dt <= 200 && dist <= vc.touchSlop) { val l = layout ?: return@awaitEachGesture val offset = l.getOffsetForPosition(up.position) val ann = text.getStringAnnotations(start = offset, end = offset).firstOrNull() if (ann != null) onAnnotationClick(ann.tag, ann.item) } } } ) } @Composable private fun IrcLinkifiedText( text: String, mircColorsEnabled: Boolean, ansiColorsEnabled: Boolean = false, linkStyle: SpanStyle, onAnnotationClick: (String, String) -> Unit, modifier: Modifier = Modifier, style: androidx.compose.ui.text.TextStyle = LocalTextStyle.current, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { val annotated = remember(text, linkStyle, mircColorsEnabled, ansiColorsEnabled) { buildAnnotatedString { appendIrcStyledLinkified(text, linkStyle, mircColorsEnabled, ansiColorsEnabled) } } AnnotatedClickableText( text = annotated, onAnnotationClick = onAnnotationClick, modifier = modifier, style = style, maxLines = maxLines, overflow = overflow, onTextLayout = onTextLayout, ) } /** * Returns true when a chat message line looks like it belongs to a bot-generated * ASCII/ANSI art block rather than normal coloured conversation. */ private fun looksLikeArt(text: String): Boolean { // Examine stripped content for structural shape, plus ANSI SGR and mIRC colour // codes as additional signals. Colour detection is intentionally kept loose // here because the block-size gate (≥2 consecutive lines) does the real false- // positive filtering — a single coloured line never triggers art rendering. val plain = stripIrcFormatting(text) if (plain.length < 3) return false // ANSI SGR colour sequences ([…m): almost never appear in normal IRC chat, // so even one is a strong signal. Other ANSI escapes ([3~ = Delete key, //  = cursor up) are NOT art — check explicitly for the SGR terminator 'm'. var i = 0 while (i < text.length) { if (text[i] == '' && i + 1 < text.length && text[i + 1] == '[') { var j = i + 2 while (j < text.length && (text[j].isDigit() || text[j] == ';')) j++ if (j < text.length && text[j] == 'm') return true i = j + 1 } else i++ } // mIRC colour codes (): common in normal chat, so require ≥4 to reduce noise. // False positives at this per-line level are acceptable because the ≥2 consecutive // lines requirement in the block builder will catch them before rendering. if (text.count { it == '' } >= 4) return true // Signal 1: ≥2 leading spaces AND first non-space char is structural. // Art bots indent for column alignment and their content opens with box/drawing // characters. Prose continuation lines (word-wrapped bot output like weather // forecasts) also indent but start with a letter or digit after the spaces. if (plain.length >= 3 && plain[0] == ' ' && plain[1] == ' ') { val firstNs = plain.trimStart() if (firstNs.isNotEmpty() && !firstNs[0].isLetterOrDigit()) return true } // Signal 2: high structural-symbol density — ≥30 % of non-space chars are // drawing characters, on a line long enough to rule out typos/emoji. var artCount = 0 var alphaCount = 0 for (ch in plain) { if (ch == ' ') continue if (ch.isLetterOrDigit()) alphaCount++ else artCount++ } val nonSpace = artCount + alphaCount if (nonSpace >= 16 && artCount.toFloat() / nonSpace >= 0.30f) return true // Signal 3: single-space indent + border character patterns. if (plain.length >= 4 && plain[0] == ' ' && plain[1] != ' ') { val c1 = plain[1]; val c2 = plain[2] // 3a: border char followed immediately by space or another border char. if (c1 in "|/\\_-=+[]" && (c2 == ' ' || c2 in "|/\\_-=+[]")) return true // 3b: framed label — first AND last non-space chars are both border chars. val lastNs = plain.trimEnd().lastOrNull() if (lastNs != null && c1 in "|/\\_-=+[]" && lastNs in "|/\\_-=+[]") return true } return false } /** * A heterogeneous list item for the chat LazyColumn. * * [Single] wraps one normal message. * [Art] wraps an entire run of consecutive art lines as one item, so all * lines are rendered inside a single [Column] with zero inter-line gaps. * Keys use even/odd Long split to avoid collisions between the two types. */ private sealed class RawItem { data class Single(val msg: UiMessage) : RawItem() data class Art(val msgs: List) : RawItem() // chronological } private sealed class DisplayItem { abstract val key: Long data class Single(val msg: UiMessage) : DisplayItem() { override val key: Long = msg.id * 2L } data class Art( val msgs: List, // chronological: oldest first val fontSizeSp: Float, ) : DisplayItem() { override val key: Long = msgs.first().id * 2L + 1L } } /** * Builds the [DisplayItem] list consumed by the chat [LazyColumn]. * * Consecutive art-like messages (from any sender) are merged into a single * [DisplayItem.Art] item so they render gap-free inside one [Column]. * * Performance — two-phase approach: * * Phase 1 (block detection): scans [reversedMessages] with [looksLikeArt] and * groups consecutive art lines into [RawArt] blocks. Pure string ops, no * allocation beyond the result list. Cached by (n, newestId) so it only * re-runs when a message is added or removed. * * Phase 2 (font sizing): measures each [RawArt] block to find the font size * that fits the widest line. Results are stored in a [HashMap] keyed by * (firstMsgId, blockSize) so that: * - A new non-art message arriving → Phase 1 re-runs (fast), Phase 2 * re-iterates but every art block is a cache hit → zero [TextMeasurer] * calls. * - A new art message extending a block → cache key changes (blockSize++) * → only that block is re-measured, all others are hits. * - Layout width or font size changes → cache is cleared and all blocks * are re-measured once. */ @Composable private fun rememberDisplayItems( reversedMessages: List, availableWidthPx: Float, style: androidx.compose.ui.text.TextStyle, minFontSp: Float = 6f, ): List { val textMeasurer = rememberTextMeasurer() val naturalSizeSp = style.fontSize.value.takeIf { !it.isNaN() && it > 0f } ?: 14f // ── Phase 1: block detection — O(n) string ops, no measurement ─────────── val n = reversedMessages.size val newestId = reversedMessages.firstOrNull()?.id ?: -1L val rawItems: List = remember(n, newestId) { if (reversedMessages.isEmpty()) return@remember emptyList() val result = mutableListOf() val artRun = mutableListOf() // accumulated in reversed order fun flushArtRun() { if (artRun.size >= 2) result.add(RawItem.Art(artRun.asReversed().toList())) else artRun.forEach { result.add(RawItem.Single(it)) } artRun.clear() } for (msg in reversedMessages) { if (msg.from != null && looksLikeArt(msg.text)) artRun.add(msg) else { flushArtRun(); result.add(RawItem.Single(msg)) } } flushArtRun() result } // ── Phase 2: font sizing — only measures blocks missing from cache ──────── // Key: (firstMsgId * MAX_BLOCK + blockSize) — uniquely identifies a block's // content since IRC message lists are append-only and blocks only grow by // having newer messages added at the end of the chronological order. // Cache is cleared when layout dimensions change so all blocks are re-sized. val fontSizeCache = remember { HashMap() } val prevWidth = remember { mutableStateOf(availableWidthPx) } val prevNatSp = remember { mutableStateOf(naturalSizeSp) } if (prevWidth.value != availableWidthPx || prevNatSp.value != naturalSizeSp) { fontSizeCache.clear() prevWidth.value = availableWidthPx prevNatSp.value = naturalSizeSp } fun fontSizeForBlock(msgs: List): Float { // (firstMsgId shifted left 17 bits) OR blockSize — collision-free for // any realistic block size (<131072 lines) and message ID space. val cacheKey = (msgs.first().id shl 17) or msgs.size.toLong() fontSizeCache[cacheKey]?.let { return it } // Not cached — run the binary search (same algorithm as rememberMotdFontSizeSp). val plainLines = msgs.map { stripIrcFormatting(it.text) }.filter { it.isNotEmpty() } val sp = if (plainLines.isEmpty() || availableWidthPx <= 0f) { naturalSizeSp } else { val widestAtNatural = plainLines.maxOf { line -> textMeasurer.measure( text = line, style = style.copy(fontSize = naturalSizeSp.sp), constraints = Constraints(maxWidth = Int.MAX_VALUE), maxLines = 1, softWrap = false, ).size.width.toFloat() } if (widestAtNatural <= availableWidthPx) { naturalSizeSp } else { var lo = minFontSp var hi = naturalSizeSp repeat(8) { val mid = (lo + hi) / 2f val widest = plainLines.maxOf { line -> textMeasurer.measure( text = line, style = style.copy(fontSize = mid.sp), constraints = Constraints(maxWidth = Int.MAX_VALUE), maxLines = 1, softWrap = false, ).size.width.toFloat() } if (widest <= availableWidthPx) lo = mid else hi = mid } lo } } fontSizeCache[cacheKey] = sp return sp } return rawItems.map { raw -> when (raw) { is RawItem.Single -> DisplayItem.Single(raw.msg) is RawItem.Art -> DisplayItem.Art(raw.msgs, fontSizeForBlock(raw.msgs)) } } } /** * Computes the single font size (in sp) that makes the widest MOTD line fit within * [availableWidthPx] at the given [style]. Every MOTD line must be rendered at this * same size so that monospace ASCII art columns stay aligned. * * Returns [style]'s natural size when all lines already fit, or [minFontSp] as a floor. */ @Composable private fun rememberMotdFontSizeSp( motdLines: List, style: androidx.compose.ui.text.TextStyle, availableWidthPx: Float, minFontSp: Float = 6f, ): Float { val textMeasurer = rememberTextMeasurer() val naturalSizeSp = style.fontSize.value.takeIf { !it.isNaN() && it > 0f } ?: 14f // Strip IRC formatting from every line for measurement (formatting chars have no width). val plainLines = remember(motdLines) { motdLines.map { stripIrcFormatting(it) } } return remember(plainLines, availableWidthPx, naturalSizeSp) { if (availableWidthPx <= 0f || plainLines.isEmpty()) return@remember naturalSizeSp // Find the widest line at the natural font size. val widestAtNatural = plainLines.maxOf { line -> textMeasurer.measure( text = line, style = style.copy(fontSize = naturalSizeSp.sp), constraints = Constraints(maxWidth = Int.MAX_VALUE), maxLines = 1, softWrap = false, ).size.width } if (widestAtNatural <= availableWidthPx) return@remember naturalSizeSp // Binary-search a single shared size that fits even the widest line. var lo = minFontSp var hi = naturalSizeSp repeat(8) { val mid = (lo + hi) / 2f val widest = plainLines.maxOf { line -> textMeasurer.measure( text = line, style = style.copy(fontSize = mid.sp), constraints = Constraints(maxWidth = Int.MAX_VALUE), maxLines = 1, softWrap = false, ).size.width } if (widest <= availableWidthPx) lo = mid else hi = mid } lo } } /** * Renders a single MOTD line at [fontSizeSp]. The caller is responsible for computing * a shared font size across all MOTD lines (via [rememberMotdFontSizeSp]) so that * monospace ASCII art columns remain aligned across lines of different lengths. */ @Composable private fun MotdLine( text: String, fontSizeSp: Float, style: androidx.compose.ui.text.TextStyle, mircColorsEnabled: Boolean, ansiColorsEnabled: Boolean = false, linkStyle: SpanStyle, onAnnotationClick: (String, String) -> Unit, ) { IrcLinkifiedText( text = text, mircColorsEnabled = mircColorsEnabled, ansiColorsEnabled = ansiColorsEnabled, linkStyle = linkStyle, onAnnotationClick = onAnnotationClick, style = style.copy(fontSize = fontSizeSp.sp), maxLines = 1, overflow = TextOverflow.Clip, ) }