Untitled

Java Guest 12 Views Size: 27.04 KB Posted on: Mar 17, 26 @ 11:11 PM
  1. /**
  2.  * Full mIRC/IRCv3 colour table: 0-15 legacy + 16-98 extended (99 total).
  3.  *
  4.  * Codes 0-15 are the original mIRC palette used by essentially all IRC clients.
  5.  * Codes 16-98 are the modern IRCv3 extension published at
  6.  * https://modern.ircdocs.horse/formatting.html#color - supported by mIRC 7+,
  7.  * WeeChat, HexChat, and most modern clients.
  8.  *
  9.  * Each entry is a 0xAARRGGBB value.
  10.  */
  11. /**
  12.  * Canonical mIRC / IRCv3 colour palette — 99 entries (codes 0–98).
  13.  *
  14.  * Source: https://modern.ircdocs.horse/formatting.html#color (the "IRC Colour" specification).
  15.  * These are the exact RGB hex values that mIRC 7+, HexChat, WeeChat, and other modern
  16.  * clients use. Codes 0–15 are the original mIRC palette; codes 16–97 are the extended
  17.  * IRCv3 block (6 rows of 16, laid out as a gradient grid); code 98 is the spec-defined
  18.  * "transparent/default" entry which maps to white for rendering purposes.
  19.  *
  20.  * Layout of codes 16–97 in the grid (each row = 16 entries, darkest → lightest):
  21.  *   Row 1 (16–27):  greys darkening right → pure blacks at left
  22.  *   Row 2 (28–39):  dark shades — red, orange, yellow, green, cyan, blue, purple, pink
  23.  *   Row 3 (40–51):  mid shades
  24.  *   Row 4 (52–63):  bright / saturated
  25.  *   Row 5 (64–75):  light / pastel
  26.  *   Row 6 (76–87):  very light / near-white pastels
  27.  *   Row 7 (88–98):  greyscale ramp (black → white), code 98 = white alias
  28.  */
  29. private val MIRC_PALETTE: IntArray = intArrayOf(
  30.     // ── 0–15: classic mIRC palette ────────────────────────────────────────────
  31.     0xFFFFFFFF.toInt(), //  0  White
  32.     0xFF000000.toInt(), //  1  Black
  33.     0xFF00007F.toInt(), //  2  Blue (navy)
  34.     0xFF009300.toInt(), //  3  Green
  35.     0xFFFF0000.toInt(), //  4  Red
  36.     0xFF7F0000.toInt(), //  5  Brown (maroon)
  37.     0xFF9C009C.toInt(), //  6  Purple
  38.     0xFFFC7F00.toInt(), //  7  Orange
  39.     0xFFFFFF00.toInt(), //  8  Yellow
  40.     0xFF00FC00.toInt(), //  9  Light green (lime)
  41.     0xFF009393.toInt(), // 10  Teal
  42.     0xFF00FFFF.toInt(), // 11  Light cyan (aqua)
  43.     0xFF0000FC.toInt(), // 12  Light blue (royal)
  44.     0xFFFF00FF.toInt(), // 13  Pink (magenta / fuchsia)
  45.     0xFF7F7F7F.toInt(), // 14  Grey
  46.     0xFFD2D2D2.toInt(), // 15  Light grey
  47.     // ── 16–27: darkest shades (row 1 of extended block) ──────────────────────
  48.     0xFF470000.toInt(), // 16  Dark maroon
  49.     0xFF472100.toInt(), // 17  Very dark orange
  50.     0xFF474700.toInt(), // 18  Dark olive
  51.     0xFF324700.toInt(), // 19  Very dark green
  52.     0xFF004732.toInt(), // 20  Very dark teal-green
  53.     0xFF00472C.toInt(), // 21  Very dark teal (alt)
  54.     0xFF004747.toInt(), // 22  Very dark teal
  55.     0xFF002747.toInt(), // 23  Very dark slate blue
  56.     0xFF000047.toInt(), // 24  Very dark navy
  57.     0xFF2E0047.toInt(), // 25  Very dark violet
  58.     0xFF470047.toInt(), // 26  Very dark purple-magenta
  59.     0xFF47002A.toInt(), // 27  Very dark crimson
  60.     // ── 28–39: dark shades (row 2) ───────────────────────────────────────────
  61.     0xFF740000.toInt(), // 28  Dark red
  62.     0xFF743A00.toInt(), // 29  Dark brown-orange
  63.     0xFF747400.toInt(), // 30  Dark yellow-olive
  64.     0xFF517400.toInt(), // 31  Dark chartreuse
  65.     0xFF007400.toInt(), // 32  Dark green
  66.     0xFF007449.toInt(), // 33  Dark sea-green
  67.     0xFF007474.toInt(), // 34  Dark teal
  68.     0xFF004074.toInt(), // 35  Dark dodger blue
  69.     0xFF000074.toInt(), // 36  Dark blue
  70.     0xFF4B0074.toInt(), // 37  Dark purple-blue
  71.     0xFF740074.toInt(), // 38  Dark magenta
  72.     0xFF740045.toInt(), // 39  Dark hot pink
  73.     // ── 40–51: mid shades (row 3) ────────────────────────────────────────────
  74.     0xFFB50000.toInt(), // 40  Medium red
  75.     0xFFB56300.toInt(), // 41  Medium orange
  76.     0xFFB5B500.toInt(), // 42  Medium yellow-green
  77.     0xFF7DB500.toInt(), // 43  Chartreuse
  78.     0xFF00B500.toInt(), // 44  Medium green
  79.     0xFF00B573.toInt(), // 45  Medium mint
  80.     0xFF00B5B5.toInt(), // 46  Medium teal
  81.     0xFF0063B5.toInt(), // 47  Medium dodger blue
  82.     0xFF0000B5.toInt(), // 48  Medium blue
  83.     0xFF7500B5.toInt(), // 49  Medium violet
  84.     0xFFB500B5.toInt(), // 50  Medium magenta
  85.     0xFFB5006B.toInt(), // 51  Medium hot pink
  86.     // ── 52–63: bright / saturated (row 4) ────────────────────────────────────
  87.     0xFFFF0000.toInt(), // 52  Bright red
  88.     0xFFFF9200.toInt(), // 53  Bright orange / gold
  89.     0xFFFFFF00.toInt(), // 54  Bright yellow
  90.     0xFFB9FF00.toInt(), // 55  Bright yellow-green
  91.     0xFF00FF00.toInt(), // 56  Bright lime green
  92.     0xFF00FFA8.toInt(), // 57  Bright spring green
  93.     0xFF00FFFF.toInt(), // 58  Bright cyan / aqua
  94.     0xFF009BFF.toInt(), // 59  Bright azure / sky blue
  95.     0xFF0000FF.toInt(), // 60  Bright blue
  96.     0xFFAD00FF.toInt(), // 61  Bright electric purple
  97.     0xFFFF00FF.toInt(), // 62  Bright magenta / fuchsia
  98.     0xFFFF0092.toInt(), // 63  Bright rose / hot pink
  99.     // ── 64–75: light / pastel (row 5) ────────────────────────────────────────
  100.     0xFFFF6666.toInt(), // 64  Light red
  101.     0xFFFFB466.toInt(), // 65  Light orange / peach
  102.     0xFFFFFF66.toInt(), // 66  Light yellow
  103.     0xFFCCFF66.toInt(), // 67  Light chartreuse
  104.     0xFF66FF66.toInt(), // 68  Light green
  105.     0xFF66FFB4.toInt(), // 69  Light mint
  106.     0xFF66FFFF.toInt(), // 70  Light cyan
  107.     0xFF66B4FF.toInt(), // 71  Light sky blue
  108.     0xFF6666FF.toInt(), // 72  Light blue-purple
  109.     0xFFCC66FF.toInt(), // 73  Light violet
  110.     0xFFFF66FF.toInt(), // 74  Light magenta / orchid
  111.     0xFFFF66B4.toInt(), // 75  Light pink
  112.     // ── 76–87: very light / near-white pastels (row 6) ───────────────────────
  113.     0xFFFFB4B4.toInt(), // 76  Very light red / salmon
  114.     0xFFFFDEB4.toInt(), // 77  Very light orange / bisque
  115.     0xFFFFFFB4.toInt(), // 78  Very light yellow / cream
  116.     0xFFE6FFB4.toInt(), // 79  Very light chartreuse / honeydew
  117.     0xFFB4FFB4.toInt(), // 80  Very light green / mint cream
  118.     0xFFB4FFE6.toInt(), // 81  Very light mint / azure-mint
  119.     0xFFB4FFFF.toInt(), // 82  Very light cyan / azure
  120.     0xFFB4DEFF.toInt(), // 83  Very light sky blue / alice blue
  121.     0xFFB4B4FF.toInt(), // 84  Very light lavender
  122.     0xFFDEB4FF.toInt(), // 85  Very light violet / lavender blush
  123.     0xFFFFB4FF.toInt(), // 86  Very light magenta / thistle
  124.     0xFFFFB4DE.toInt(), // 87  Very light pink / lavender rose
  125.     // ── 88–98: greyscale ramp (row 7) ────────────────────────────────────────
  126.     0xFF000000.toInt(), // 88  Black
  127.     0xFF141414.toInt(), // 89  Near-black
  128.     0xFF282828.toInt(), // 90  Very dark grey
  129.     0xFF3C3C3C.toInt(), // 91  Dark grey
  130.     0xFF505050.toInt(), // 92  Dark-mid grey
  131.     0xFF646464.toInt(), // 93  Mid grey
  132.     0xFF787878.toInt(), // 94  Mid-light grey
  133.     0xFF8C8C8C.toInt(), // 95  Light-mid grey
  134.     0xFFA0A0A0.toInt(), // 96  Light grey
  135.     0xFFB4B4B4.toInt(), // 97  Pale grey
  136.     0xFFC8C8C8.toInt(), // 98  Silver / near-white (spec "default" alias)
  137. )
  138.  
  139. private fun mircColor(code: Int): Color? =
  140.     MIRC_PALETTE.getOrNull(code)?.let { Color(it.toLong() and 0xFFFFFFFFL) }
  141.  
  142. /** How many mIRC colour codes are defined (0-based, inclusive of 0). */
  143. private const val MIRC_COLOR_COUNT = 99
  144.  
  145. private fun MircStyleState.toSpanStyle(): SpanStyle {
  146.     val fgCode = if (reverse) bg else fg
  147.     val bgCode = if (reverse) fg else bg
  148.     val fgColor = fgCode?.let(::mircColor) ?: Color.Unspecified
  149.     val bgColor = bgCode?.let(::mircColor) ?: Color.Unspecified
  150.  
  151.     return SpanStyle(
  152.         color = fgColor,
  153.         background = bgColor,
  154.         fontWeight = if (bold) FontWeight.Bold else null,
  155.         fontStyle = if (italic) FontStyle.Italic else null,
  156.         textDecoration = if (underline) TextDecoration.Underline else null,
  157.     )
  158. }
  159.  
  160. private fun parseMircRuns(input: String): List<MircRun> {
  161.     if (input.isEmpty()) return emptyList()
  162.  
  163.     val out = mutableListOf<MircRun>()
  164.     val buf = StringBuilder()
  165.     val st = MircStyleState()
  166.  
  167.     fun flush() {
  168.         if (buf.isNotEmpty()) {
  169.             out += MircRun(buf.toString(), st.snapshot())
  170.             buf.setLength(0)
  171.         }
  172.     }
  173.  
  174.     fun parseOneOrTwoDigits(startIndex: Int): Pair<Int?, Int> {
  175.         var i = startIndex
  176.         if (i >= input.length || !input[i].isDigit()) return (null to i)
  177.         val first = input[i]
  178.         i++
  179.         if (i < input.length && input[i].isDigit()) {
  180.             val num = ("$first${input[i]}").toIntOrNull()
  181.             i++
  182.             return (num to i)
  183.         }
  184.         return (first.toString().toIntOrNull() to i)
  185.     }
  186.  
  187.     var i = 0
  188.     while (i < input.length) {
  189.         when (val c = input[i]) {
  190.             '\u0003' -> { // colour
  191.                 flush()
  192.                 i++
  193.                 val (fg, ni) = parseOneOrTwoDigits(i)
  194.                 i = ni
  195.                 if (fg == null) {
  196.                     // \x03 alone resets colours.
  197.                     st.fg = null
  198.                     st.bg = null
  199.                 } else {
  200.                     st.fg = fg
  201.                     // Optional ,bg
  202.                     if (i < input.length && input[i] == ',') {
  203.                         i++
  204.                         val (bg, n2) = parseOneOrTwoDigits(i)
  205.                         i = n2
  206.                         st.bg = bg
  207.                     }
  208.                 }
  209.             }
  210.  
  211.             '\u000F' -> { // reset
  212.                 flush()
  213.                 st.reset()
  214.                 i++
  215.             }
  216.  
  217.             '\u0002' -> { // bold
  218.                 flush(); st.bold = !st.bold; i++
  219.             }
  220.  
  221.             '\u001D' -> { // italic
  222.                 flush(); st.italic = !st.italic; i++
  223.             }
  224.  
  225.             '\u001F' -> { // underline
  226.                 flush(); st.underline = !st.underline; i++
  227.             }
  228.  
  229.             '\u0016' -> { // reverse
  230.                 flush(); st.reverse = !st.reverse; i++
  231.             }
  232.  
  233.             else -> {
  234.                 // Drop other C0 controls (except common whitespace).
  235.                 if (c.code < 0x20 && c != '\n' && c != '\t' && c != '\r') {
  236.                     i++
  237.                 } else {
  238.                     buf.append(c)
  239.                     i++
  240.                 }
  241.             }
  242.         }
  243.     }
  244.     flush()
  245.     return out
  246. }
  247.  
  248. private fun AnnotatedString.Builder.appendIrcStyledLinkified(
  249.     text: String,
  250.     linkStyle: SpanStyle,
  251.     mircColorsEnabled: Boolean,
  252.     ansiColorsEnabled: Boolean = false,
  253. ) {
  254.     val hasAnsi = ansiColorsEnabled && text.contains('\u001b')
  255.     val hasMirc = mircColorsEnabled && !hasAnsi &&
  256.         (text.contains('\u0003') || text.contains('\u0004') || text.contains('\u0002') ||
  257.          text.contains('\u001D') || text.contains('\u001F') || text.contains('\u0016'))
  258.  
  259.     when {
  260.         hasAnsi -> {
  261.             val runs = parseAnsiRuns(text)
  262.             if (runs.isEmpty()) return
  263.             for (r in runs) {
  264.                 if (r.style.hasAnyStyle()) {
  265.                     withStyle(r.style.ansiToSpanStyle()) { appendLinkified(this, r.text, linkStyle) }
  266.                 } else {
  267.                     appendLinkified(this, r.text, linkStyle)
  268.                 }
  269.             }
  270.         }
  271.         hasMirc -> {
  272.             val runs = parseMircRuns(text)
  273.             if (runs.isEmpty()) return
  274.             for (r in runs) {
  275.                 if (r.style.hasAnyStyle()) {
  276.                     withStyle(r.style.toSpanStyle()) { appendLinkified(this, r.text, linkStyle) }
  277.                 } else {
  278.                     appendLinkified(this, r.text, linkStyle)
  279.                 }
  280.             }
  281.         }
  282.         else -> appendLinkified(this, stripIrcFormatting(text), linkStyle)
  283.     }
  284. }
  285.  
  286. @Composable
  287. private fun AnnotatedClickableText(
  288.     text: AnnotatedString,
  289.     onAnnotationClick: (String, String) -> Unit,
  290.     modifier: Modifier = Modifier,
  291.     style: androidx.compose.ui.text.TextStyle = LocalTextStyle.current,
  292.     maxLines: Int = Int.MAX_VALUE,
  293.     overflow: TextOverflow = TextOverflow.Clip,
  294.     onTextLayout: ((TextLayoutResult) -> Unit)? = null,
  295. ) {
  296.     var layout: TextLayoutResult? by remember { mutableStateOf(null) }
  297.     Text(
  298.         text = text,
  299.         style = style,
  300.         maxLines = maxLines,
  301.         overflow = overflow,
  302.         onTextLayout = {
  303.             layout = it
  304.             onTextLayout?.invoke(it)
  305.         },
  306.         modifier = modifier.pointerInput(text) {
  307.             val vc = viewConfiguration
  308.             awaitEachGesture {
  309.                 // Don't consume gestures: allow selection (long-press/drag) to work.
  310.                 val down = awaitFirstDown(requireUnconsumed = false)
  311.                 val downPos = down.position
  312.                 val downTime = down.uptimeMillis
  313.  
  314.                 val up = waitForUpOrCancellation() ?: return@awaitEachGesture
  315.                 val dt = up.uptimeMillis - downTime
  316.                 val dist = (up.position - downPos).getDistance()
  317.  
  318.                 // Treat only quick taps as clicks so selection gestures don't accidentally open links.
  319.                 if (dt <= 200 && dist <= vc.touchSlop) {
  320.                     val l = layout ?: return@awaitEachGesture
  321.                     val offset = l.getOffsetForPosition(up.position)
  322.                     val ann = text.getStringAnnotations(start = offset, end = offset).firstOrNull()
  323.                     if (ann != null) onAnnotationClick(ann.tag, ann.item)
  324.                 }
  325.             }
  326.         }
  327.     )
  328. }
  329.  
  330. @Composable
  331. private fun IrcLinkifiedText(
  332.     text: String,
  333.     mircColorsEnabled: Boolean,
  334.     ansiColorsEnabled: Boolean = false,
  335.     linkStyle: SpanStyle,
  336.     onAnnotationClick: (String, String) -> Unit,
  337.     modifier: Modifier = Modifier,
  338.     style: androidx.compose.ui.text.TextStyle = LocalTextStyle.current,
  339.     maxLines: Int = Int.MAX_VALUE,
  340.     overflow: TextOverflow = TextOverflow.Clip,
  341.     onTextLayout: ((TextLayoutResult) -> Unit)? = null,
  342. ) {
  343.     val annotated = remember(text, linkStyle, mircColorsEnabled, ansiColorsEnabled) {
  344.         buildAnnotatedString { appendIrcStyledLinkified(text, linkStyle, mircColorsEnabled, ansiColorsEnabled) }
  345.     }
  346.     AnnotatedClickableText(
  347.         text = annotated,
  348.         onAnnotationClick = onAnnotationClick,
  349.         modifier = modifier,
  350.         style = style,
  351.         maxLines = maxLines,
  352.         overflow = overflow,
  353.         onTextLayout = onTextLayout,
  354.     )
  355. }
  356.  
  357. /**
  358.  * Returns true when a chat message line looks like it belongs to a bot-generated
  359.  * ASCII/ANSI art block rather than normal coloured conversation.
  360.  */
  361. private fun looksLikeArt(text: String): Boolean {
  362.     // Examine stripped content for structural shape, plus ANSI SGR and mIRC colour
  363.     // codes as additional signals.  Colour detection is intentionally kept loose
  364.     // here because the block-size gate (≥2 consecutive lines) does the real false-
  365.     // positive filtering — a single coloured line never triggers art rendering.
  366.     val plain = stripIrcFormatting(text)
  367.     if (plain.length < 3) return false
  368.  
  369.     // ANSI SGR colour sequences ([…m): almost never appear in normal IRC chat,
  370.     // so even one is a strong signal.  Other ANSI escapes ([3~ = Delete key,
  371.     //  = cursor up) are NOT art — check explicitly for the SGR terminator 'm'.
  372.     var i = 0
  373.     while (i < text.length) {
  374.         if (text[i] == '' && i + 1 < text.length && text[i + 1] == '[') {
  375.             var j = i + 2
  376.             while (j < text.length && (text[j].isDigit() || text[j] == ';')) j++
  377.             if (j < text.length && text[j] == 'm') return true
  378.             i = j + 1
  379.         } else i++
  380.     }
  381.  
  382.     // mIRC colour codes (): common in normal chat, so require ≥4 to reduce noise.
  383.     // False positives at this per-line level are acceptable because the ≥2 consecutive
  384.     // lines requirement in the block builder will catch them before rendering.
  385.     if (text.count { it == '' } >= 4) return true
  386.  
  387.     // Signal 1: ≥2 leading spaces AND first non-space char is structural.
  388.     // Art bots indent for column alignment and their content opens with box/drawing
  389.     // characters.  Prose continuation lines (word-wrapped bot output like weather
  390.     // forecasts) also indent but start with a letter or digit after the spaces.
  391.     if (plain.length >= 3 && plain[0] == ' ' && plain[1] == ' ') {
  392.         val firstNs = plain.trimStart()
  393.         if (firstNs.isNotEmpty() && !firstNs[0].isLetterOrDigit()) return true
  394.     }
  395.  
  396.     // Signal 2: high structural-symbol density — ≥30 % of non-space chars are
  397.     // drawing characters, on a line long enough to rule out typos/emoji.
  398.     var artCount = 0
  399.     var alphaCount = 0
  400.     for (ch in plain) {
  401.         if (ch == ' ') continue
  402.         if (ch.isLetterOrDigit()) alphaCount++ else artCount++
  403.     }
  404.     val nonSpace = artCount + alphaCount
  405.     if (nonSpace >= 16 && artCount.toFloat() / nonSpace >= 0.30f) return true
  406.  
  407.     // Signal 3: single-space indent + border character patterns.
  408.     if (plain.length >= 4 && plain[0] == ' ' && plain[1] != ' ') {
  409.         val c1 = plain[1]; val c2 = plain[2]
  410.         // 3a: border char followed immediately by space or another border char.
  411.         if (c1 in "|/\\_-=+[]" && (c2 == ' ' || c2 in "|/\\_-=+[]")) return true
  412.         // 3b: framed label — first AND last non-space chars are both border chars.
  413.         val lastNs = plain.trimEnd().lastOrNull()
  414.         if (lastNs != null && c1 in "|/\\_-=+[]" && lastNs in "|/\\_-=+[]") return true
  415.     }
  416.  
  417.     return false
  418. }
  419.  
  420. /**
  421.  * A heterogeneous list item for the chat LazyColumn.
  422.  *
  423.  * [Single] wraps one normal message.
  424.  * [Art] wraps an entire run of consecutive art lines as one item, so all
  425.  * lines are rendered inside a single [Column] with zero inter-line gaps.
  426.  * Keys use even/odd Long split to avoid collisions between the two types.
  427.  */
  428. private sealed class RawItem {
  429.     data class Single(val msg: UiMessage) : RawItem()
  430.     data class Art(val msgs: List<UiMessage>) : RawItem()  // chronological
  431. }
  432.  
  433. private sealed class DisplayItem {
  434.     abstract val key: Long
  435.     data class Single(val msg: UiMessage) : DisplayItem() {
  436.         override val key: Long = msg.id * 2L
  437.     }
  438.     data class Art(
  439.         val msgs: List<UiMessage>,  // chronological: oldest first
  440.         val fontSizeSp: Float,
  441.     ) : DisplayItem() {
  442.         override val key: Long = msgs.first().id * 2L + 1L
  443.     }
  444. }
  445.  
  446. /**
  447.  * Builds the [DisplayItem] list consumed by the chat [LazyColumn].
  448.  *
  449.  * Consecutive art-like messages (from any sender) are merged into a single
  450.  * [DisplayItem.Art] item so they render gap-free inside one [Column].
  451.  *
  452.  * Performance — two-phase approach:
  453.  *
  454.  * Phase 1 (block detection): scans [reversedMessages] with [looksLikeArt] and
  455.  * groups consecutive art lines into [RawArt] blocks.  Pure string ops, no
  456.  * allocation beyond the result list.  Cached by (n, newestId) so it only
  457.  * re-runs when a message is added or removed.
  458.  *
  459.  * Phase 2 (font sizing): measures each [RawArt] block to find the font size
  460.  * that fits the widest line.  Results are stored in a [HashMap] keyed by
  461.  * (firstMsgId, blockSize) so that:
  462.  *   - A new non-art message arriving → Phase 1 re-runs (fast), Phase 2
  463.  *     re-iterates but every art block is a cache hit → zero [TextMeasurer]
  464.  *     calls.
  465.  *   - A new art message extending a block → cache key changes (blockSize++)
  466.  *     → only that block is re-measured, all others are hits.
  467.  *   - Layout width or font size changes → cache is cleared and all blocks
  468.  *     are re-measured once.
  469.  */
  470. @Composable
  471. private fun rememberDisplayItems(
  472.     reversedMessages: List<UiMessage>,
  473.     availableWidthPx: Float,
  474.     style: androidx.compose.ui.text.TextStyle,
  475.     minFontSp: Float = 6f,
  476. ): List<DisplayItem> {
  477.     val textMeasurer = rememberTextMeasurer()
  478.     val naturalSizeSp = style.fontSize.value.takeIf { !it.isNaN() && it > 0f } ?: 14f
  479.  
  480.     // ── Phase 1: block detection — O(n) string ops, no measurement ───────────
  481.     val n        = reversedMessages.size
  482.     val newestId = reversedMessages.firstOrNull()?.id ?: -1L
  483.  
  484.     val rawItems: List<RawItem> = remember(n, newestId) {
  485.         if (reversedMessages.isEmpty()) return@remember emptyList()
  486.         val result  = mutableListOf<RawItem>()
  487.         val artRun  = mutableListOf<UiMessage>()  // accumulated in reversed order
  488.  
  489.         fun flushArtRun() {
  490.             if (artRun.size >= 2) result.add(RawItem.Art(artRun.asReversed().toList()))
  491.             else artRun.forEach { result.add(RawItem.Single(it)) }
  492.             artRun.clear()
  493.         }
  494.  
  495.         for (msg in reversedMessages) {
  496.             if (msg.from != null && looksLikeArt(msg.text)) artRun.add(msg)
  497.             else { flushArtRun(); result.add(RawItem.Single(msg)) }
  498.         }
  499.         flushArtRun()
  500.         result
  501.     }
  502.  
  503.     // ── Phase 2: font sizing — only measures blocks missing from cache ────────
  504.     // Key: (firstMsgId * MAX_BLOCK + blockSize) — uniquely identifies a block's
  505.     // content since IRC message lists are append-only and blocks only grow by
  506.     // having newer messages added at the end of the chronological order.
  507.     // Cache is cleared when layout dimensions change so all blocks are re-sized.
  508.     val fontSizeCache = remember { HashMap<Long, Float>() }
  509.     val prevWidth  = remember { mutableStateOf(availableWidthPx) }
  510.     val prevNatSp  = remember { mutableStateOf(naturalSizeSp) }
  511.     if (prevWidth.value != availableWidthPx || prevNatSp.value != naturalSizeSp) {
  512.         fontSizeCache.clear()
  513.         prevWidth.value  = availableWidthPx
  514.         prevNatSp.value  = naturalSizeSp
  515.     }
  516.  
  517.     fun fontSizeForBlock(msgs: List<UiMessage>): Float {
  518.         // (firstMsgId shifted left 17 bits) OR blockSize — collision-free for
  519.         // any realistic block size (<131072 lines) and message ID space.
  520.         val cacheKey = (msgs.first().id shl 17) or msgs.size.toLong()
  521.         fontSizeCache[cacheKey]?.let { return it }
  522.  
  523.         // Not cached — run the binary search (same algorithm as rememberMotdFontSizeSp).
  524.         val plainLines = msgs.map { stripIrcFormatting(it.text) }.filter { it.isNotEmpty() }
  525.         val sp = if (plainLines.isEmpty() || availableWidthPx <= 0f) {
  526.             naturalSizeSp
  527.         } else {
  528.             val widestAtNatural = plainLines.maxOf { line ->
  529.                 textMeasurer.measure(
  530.                     text = line,
  531.                     style = style.copy(fontSize = naturalSizeSp.sp),
  532.                     constraints = Constraints(maxWidth = Int.MAX_VALUE),
  533.                     maxLines = 1,
  534.                     softWrap = false,
  535.                 ).size.width.toFloat()
  536.             }
  537.             if (widestAtNatural <= availableWidthPx) {
  538.                 naturalSizeSp
  539.             } else {
  540.                 var lo = minFontSp
  541.                 var hi = naturalSizeSp
  542.                 repeat(8) {
  543.                     val mid = (lo + hi) / 2f
  544.                     val widest = plainLines.maxOf { line ->
  545.                         textMeasurer.measure(
  546.                             text = line,
  547.                             style = style.copy(fontSize = mid.sp),
  548.                             constraints = Constraints(maxWidth = Int.MAX_VALUE),
  549.                             maxLines = 1,
  550.                             softWrap = false,
  551.                         ).size.width.toFloat()
  552.                     }
  553.                     if (widest <= availableWidthPx) lo = mid else hi = mid
  554.                 }
  555.                 lo
  556.             }
  557.         }
  558.         fontSizeCache[cacheKey] = sp
  559.         return sp
  560.     }
  561.  
  562.     return rawItems.map { raw ->
  563.         when (raw) {
  564.             is RawItem.Single -> DisplayItem.Single(raw.msg)
  565.             is RawItem.Art    -> DisplayItem.Art(raw.msgs, fontSizeForBlock(raw.msgs))
  566.         }
  567.     }
  568. }
  569.  
  570. /**
  571.  * Computes the single font size (in sp) that makes the widest MOTD line fit within
  572.  * [availableWidthPx] at the given [style]. Every MOTD line must be rendered at this
  573.  * same size so that monospace ASCII art columns stay aligned.
  574.  *
  575.  * Returns [style]'s natural size when all lines already fit, or [minFontSp] as a floor.
  576.  */
  577. @Composable
  578. private fun rememberMotdFontSizeSp(
  579.     motdLines: List<String>,
  580.     style: androidx.compose.ui.text.TextStyle,
  581.     availableWidthPx: Float,
  582.     minFontSp: Float = 6f,
  583. ): Float {
  584.     val textMeasurer = rememberTextMeasurer()
  585.     val naturalSizeSp = style.fontSize.value.takeIf { !it.isNaN() && it > 0f } ?: 14f
  586.  
  587.     // Strip IRC formatting from every line for measurement (formatting chars have no width).
  588.     val plainLines = remember(motdLines) { motdLines.map { stripIrcFormatting(it) } }
  589.  
  590.     return remember(plainLines, availableWidthPx, naturalSizeSp) {
  591.         if (availableWidthPx <= 0f || plainLines.isEmpty()) return@remember naturalSizeSp
  592.  
  593.         // Find the widest line at the natural font size.
  594.         val widestAtNatural = plainLines.maxOf { line ->
  595.             textMeasurer.measure(
  596.                 text = line,
  597.                 style = style.copy(fontSize = naturalSizeSp.sp),
  598.                 constraints = Constraints(maxWidth = Int.MAX_VALUE),
  599.                 maxLines = 1,
  600.                 softWrap = false,
  601.             ).size.width
  602.         }
  603.         if (widestAtNatural <= availableWidthPx) return@remember naturalSizeSp
  604.  
  605.         // Binary-search a single shared size that fits even the widest line.
  606.         var lo = minFontSp
  607.         var hi = naturalSizeSp
  608.         repeat(8) {
  609.             val mid = (lo + hi) / 2f
  610.             val widest = plainLines.maxOf { line ->
  611.                 textMeasurer.measure(
  612.                     text = line,
  613.                     style = style.copy(fontSize = mid.sp),
  614.                     constraints = Constraints(maxWidth = Int.MAX_VALUE),
  615.                     maxLines = 1,
  616.                     softWrap = false,
  617.                 ).size.width
  618.             }
  619.             if (widest <= availableWidthPx) lo = mid else hi = mid
  620.         }
  621.         lo
  622.     }
  623. }
  624.  
  625. /**
  626.  * Renders a single MOTD line at [fontSizeSp]. The caller is responsible for computing
  627.  * a shared font size across all MOTD lines (via [rememberMotdFontSizeSp]) so that
  628.  * monospace ASCII art columns remain aligned across lines of different lengths.
  629.  */
  630. @Composable
  631. private fun MotdLine(
  632.     text: String,
  633.     fontSizeSp: Float,
  634.     style: androidx.compose.ui.text.TextStyle,
  635.     mircColorsEnabled: Boolean,
  636.     ansiColorsEnabled: Boolean = false,
  637.     linkStyle: SpanStyle,
  638.     onAnnotationClick: (String, String) -> Unit,
  639. ) {
  640.     IrcLinkifiedText(
  641.         text = text,
  642.         mircColorsEnabled = mircColorsEnabled,
  643.         ansiColorsEnabled = ansiColorsEnabled,
  644.         linkStyle = linkStyle,
  645.         onAnnotationClick = onAnnotationClick,
  646.         style = style.copy(fontSize = fontSizeSp.sp),
  647.         maxLines = 1,
  648.         overflow = TextOverflow.Clip,
  649.     )
  650. }

Raw Paste

Comments 0
Login to post a comment.
  • No comments yet. Be the first.
Login to post a comment. Login or Register
We use cookies. To comply with GDPR in the EU and the UK we have to show you these.

We use cookies and similar technologies to keep this website functional (including spam protection via Google reCAPTCHA or Cloudflare Turnstile), and — with your consent — to measure usage and show ads. See Privacy.