Untitled

Java Guest 13 Views Size: 9.01 KB Posted on: Mar 16, 26 @ 3:41 PM
  1. /**
  2.  * Returns true when a chat message line looks like it belongs to a bot-generated
  3.  * ASCII/ANSI art block rather than normal coloured conversation.
  4.  */
  5. private fun looksLikeArt(text: String): Boolean {
  6.     // Fast paths for colour-coded art
  7.     var mircCount = 0
  8.     for (ch in text) {
  9.         if (ch == '\u001b') return true   // any ANSI escape > always art
  10.         if (ch == '\u0003') { mircCount++; if (mircCount >= 4) break }
  11.     }
  12.  
  13.     // Plain ASCII art detection
  14.     val plain = stripIrcFormatting(text)
  15.     if (plain.length < 3) return false
  16.  
  17.     // Link/announce bots include URLs; art bots never do (it would break the grid).
  18.     if ("http://" in plain || "https://" in plain) return false
  19.  
  20.     // Bots that style short announce lines (e.g. "📰 - YouTube") with ≥4 colour
  21.     // codes must not be mistaken for art.  Require the stripped content to be
  22.     // substantive (≥20 non-space chars) before treating colour density as a signal.
  23.     if (mircCount >= 4) {
  24.         var nonSpaceCount = 0
  25.         for (ch in plain) { if (ch != ' ') nonSpaceCount++ }
  26.         if (nonSpaceCount >= 20) return true
  27.     }
  28.  
  29.     // Signal 1: intentional leading whitespace (≥3 spaces).
  30.     if (plain[0] == ' ' && plain[1] == ' ' && plain[2] == ' ') return true
  31.  
  32.     // Signal 2: ≥30 % of non-space characters are structural symbols,
  33.     // but only on lines long enough to be art — short expressions like
  34.     // \o/ or ¯\_(ツ)_/¯ are common in chat and must never trigger this.
  35.     var artCount = 0
  36.     var alphaCount = 0
  37.     for (ch in plain) {
  38.         if (ch == ' ') continue
  39.         if (ch.isLetterOrDigit()) alphaCount++ else artCount++
  40.     }
  41.     val nonSpace = artCount + alphaCount
  42.     return nonSpace >= 16 && artCount.toFloat() / nonSpace >= 0.30f
  43. }
  44.  
  45. /**
  46.  * A heterogeneous list item for the chat LazyColumn.
  47.  *
  48.  * [Single] wraps one normal message.
  49.  * [Art] wraps an entire run of consecutive art lines as one item, so all
  50.  * lines are rendered inside a single [Column] with zero inter-line gaps.
  51.  * Keys use even/odd Long split to avoid collisions between the two types.
  52.  */
  53. private sealed class DisplayItem {
  54.     abstract val key: Long
  55.     data class Single(val msg: UiMessage) : DisplayItem() {
  56.         override val key: Long = msg.id * 2L
  57.     }
  58.     data class Art(
  59.         val msgs: List<UiMessage>,  // chronological: oldest first
  60.         val fontSizeSp: Float,
  61.     ) : DisplayItem() {
  62.         override val key: Long = msgs.first().id * 2L + 1L
  63.     }
  64. }
  65.  
  66. /**
  67.  * Builds the [DisplayItem] list consumed by the chat [LazyColumn].
  68.  *
  69.  * Consecutive art-like messages (from any sender) are merged into a single
  70.  * [DisplayItem.Art] item so they render gap-free inside one [Column].
  71.  *
  72.  * Font sizing uses **one measurement + direct scale factor** per block instead
  73.  * of the old per-line binary search — O(1 measurement per block) vs O(N×8).
  74.  * The cache key is (size, newestId) rather than the full list so key comparison
  75.  * is O(1) instead of O(n).
  76.  */
  77. @Composable
  78. private fun rememberDisplayItems(
  79.     reversedMessages: List<UiMessage>,
  80.     availableWidthPx: Float,
  81.     style: androidx.compose.ui.text.TextStyle,
  82.     minFontSp: Float = 6f,
  83. ): List<DisplayItem> {
  84.     val textMeasurer = rememberTextMeasurer()
  85.     val naturalSizeSp = style.fontSize.value.takeIf { !it.isNaN() && it > 0f } ?: 14f
  86.  
  87.     // O(1) cache key: IRC messages are append-only, so size + newest ID is sufficient.
  88.     val n = reversedMessages.size
  89.     val newestId = reversedMessages.firstOrNull()?.id ?: -1L
  90.  
  91.     return remember(n, newestId, availableWidthPx, naturalSizeSp) {
  92.         if (reversedMessages.isEmpty()) return@remember emptyList()
  93.  
  94.         val result = mutableListOf<DisplayItem>()
  95.         // artRun accumulates reversed-order messages; flushed when broken or at end.
  96.         val artRun = mutableListOf<UiMessage>()
  97.  
  98.         fun flushArtRun() {
  99.             if (artRun.size < 2) {
  100.                 artRun.forEach { result.add(DisplayItem.Single(it)) }
  101.             } else {
  102.                 // Chronological order for rendering (oldest first).
  103.                 val chrono = artRun.asReversed().toList()
  104.  
  105.                 // Measure the top-3 longest stripped lines and take the widest.
  106.                 // Character count alone is an unreliable proxy (wide Unicode glyphs,
  107.                 // block characters) so we spend 3 measurements instead of 1 to avoid
  108.                 // under-sizing blocks that contain a mix of ASCII and wide characters.
  109.                 val plainLines = chrono.map { stripIrcFormatting(it.text) }
  110.                     .filter { it.isNotEmpty() }
  111.                     .sortedByDescending { it.length }
  112.                     .take(3)
  113.  
  114.                 val fontSize = if (plainLines.isEmpty() || availableWidthPx <= 0f) {
  115.                     naturalSizeSp
  116.                 } else {
  117.                     val widestPx = plainLines.maxOf { line ->
  118.                         textMeasurer.measure(
  119.                             text = line,
  120.                             style = style.copy(fontSize = naturalSizeSp.sp),
  121.                             constraints = Constraints(maxWidth = Int.MAX_VALUE),
  122.                             maxLines = 1,
  123.                             softWrap = false,
  124.                         ).size.width.toFloat()
  125.                     }
  126.                     if (widestPx <= availableWidthPx || widestPx == 0f) {
  127.                         naturalSizeSp
  128.                     } else {
  129.                         (naturalSizeSp * availableWidthPx / widestPx).coerceAtLeast(minFontSp)
  130.                     }
  131.                 }
  132.  
  133.                 result.add(DisplayItem.Art(msgs = chrono, fontSizeSp = fontSize))
  134.             }
  135.             artRun.clear()
  136.         }
  137.  
  138.         for (msg in reversedMessages) {
  139.             if (msg.from != null && looksLikeArt(msg.text)) {
  140.                 artRun.add(msg)
  141.             } else {
  142.                 flushArtRun()
  143.                 result.add(DisplayItem.Single(msg))
  144.             }
  145.         }
  146.         flushArtRun()
  147.         result
  148.     }
  149. }
  150.  
  151.  
  152. /**
  153.  * Computes the single font size (in sp) that makes the widest MOTD line fit within
  154.  * [availableWidthPx] at the given [style]. Every MOTD line must be rendered at this
  155.  * same size so that monospace ASCII art columns stay aligned.
  156.  *
  157.  * Returns [style]'s natural size when all lines already fit, or [minFontSp] as a floor.
  158.  */
  159. @Composable
  160. private fun rememberMotdFontSizeSp(
  161.     motdLines: List<String>,
  162.     style: androidx.compose.ui.text.TextStyle,
  163.     availableWidthPx: Float,
  164.     minFontSp: Float = 6f,
  165. ): Float {
  166.     val textMeasurer = rememberTextMeasurer()
  167.     val naturalSizeSp = style.fontSize.value.takeIf { !it.isNaN() && it > 0f } ?: 14f
  168.  
  169.     // Strip IRC formatting from every line for measurement (formatting chars have no width).
  170.     val plainLines = remember(motdLines) { motdLines.map { stripIrcFormatting(it) } }
  171.  
  172.     return remember(plainLines, availableWidthPx, naturalSizeSp) {
  173.         if (availableWidthPx <= 0f || plainLines.isEmpty()) return@remember naturalSizeSp
  174.  
  175.         // Find the widest line at the natural font size.
  176.         val widestAtNatural = plainLines.maxOf { line ->
  177.             textMeasurer.measure(
  178.                 text = line,
  179.                 style = style.copy(fontSize = naturalSizeSp.sp),
  180.                 constraints = Constraints(maxWidth = Int.MAX_VALUE),
  181.                 maxLines = 1,
  182.                 softWrap = false,
  183.             ).size.width
  184.         }
  185.         if (widestAtNatural <= availableWidthPx) return@remember naturalSizeSp
  186.  
  187.         // Binary-search a single shared size that fits even the widest line.
  188.         var lo = minFontSp
  189.         var hi = naturalSizeSp
  190.         repeat(8) {
  191.             val mid = (lo + hi) / 2f
  192.             val widest = plainLines.maxOf { line ->
  193.                 textMeasurer.measure(
  194.                     text = line,
  195.                     style = style.copy(fontSize = mid.sp),
  196.                     constraints = Constraints(maxWidth = Int.MAX_VALUE),
  197.                     maxLines = 1,
  198.                     softWrap = false,
  199.                 ).size.width
  200.             }
  201.             if (widest <= availableWidthPx) lo = mid else hi = mid
  202.         }
  203.         lo
  204.     }
  205. }
  206.  
  207. /**
  208.  * Renders a single MOTD line at [fontSizeSp]. The caller is responsible for computing
  209.  * a shared font size across all MOTD lines (via [rememberMotdFontSizeSp]) so that
  210.  * monospace ASCII art columns remain aligned across lines of different lengths.
  211.  */
  212. @Composable
  213. private fun MotdLine(
  214.     text: String,
  215.     fontSizeSp: Float,
  216.     style: androidx.compose.ui.text.TextStyle,
  217.     mircColorsEnabled: Boolean,
  218.     ansiColorsEnabled: Boolean = false,
  219.     linkStyle: SpanStyle,
  220.     onAnnotationClick: (String, String) -> Unit,
  221. ) {
  222.     IrcLinkifiedText(
  223.         text = text,
  224.         mircColorsEnabled = mircColorsEnabled,
  225.         ansiColorsEnabled = ansiColorsEnabled,
  226.         linkStyle = linkStyle,
  227.         onAnnotationClick = onAnnotationClick,
  228.         style = style.copy(fontSize = fontSizeSp.sp),
  229.         maxLines = 1,
  230.         overflow = TextOverflow.Clip,
  231.     )
  232. }

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.