/** * 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 { // Fast paths for colour-coded art var mircCount = 0 for (ch in text) { if (ch == '\u001b') return true // any ANSI escape > always art if (ch == '\u0003') { mircCount++; if (mircCount >= 4) break } } // Plain ASCII art detection val plain = stripIrcFormatting(text) if (plain.length < 3) return false // Link/announce bots include URLs; art bots never do (it would break the grid). if ("http://" in plain || "https://" in plain) return false // Bots that style short announce lines (e.g. "📰 - YouTube") with ≥4 colour // codes must not be mistaken for art. Require the stripped content to be // substantive (≥20 non-space chars) before treating colour density as a signal. if (mircCount >= 4) { var nonSpaceCount = 0 for (ch in plain) { if (ch != ' ') nonSpaceCount++ } if (nonSpaceCount >= 20) return true } // Signal 1: intentional leading whitespace (≥3 spaces). if (plain[0] == ' ' && plain[1] == ' ' && plain[2] == ' ') return true // Signal 2: ≥30 % of non-space characters are structural symbols, // but only on lines long enough to be art — short expressions like // \o/ or ¯\_(ツ)_/¯ are common in chat and must never trigger this. var artCount = 0 var alphaCount = 0 for (ch in plain) { if (ch == ' ') continue if (ch.isLetterOrDigit()) alphaCount++ else artCount++ } val nonSpace = artCount + alphaCount return nonSpace >= 16 && artCount.toFloat() / nonSpace >= 0.30f } /** * 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 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]. * * Font sizing uses **one measurement + direct scale factor** per block instead * of the old per-line binary search — O(1 measurement per block) vs O(N×8). * The cache key is (size, newestId) rather than the full list so key comparison * is O(1) instead of O(n). */ @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 // O(1) cache key: IRC messages are append-only, so size + newest ID is sufficient. val n = reversedMessages.size val newestId = reversedMessages.firstOrNull()?.id ?: -1L return remember(n, newestId, availableWidthPx, naturalSizeSp) { if (reversedMessages.isEmpty()) return@remember emptyList() val result = mutableListOf() // artRun accumulates reversed-order messages; flushed when broken or at end. val artRun = mutableListOf() fun flushArtRun() { if (artRun.size < 2) { artRun.forEach { result.add(DisplayItem.Single(it)) } } else { // Chronological order for rendering (oldest first). val chrono = artRun.asReversed().toList() // Measure the top-3 longest stripped lines and take the widest. // Character count alone is an unreliable proxy (wide Unicode glyphs, // block characters) so we spend 3 measurements instead of 1 to avoid // under-sizing blocks that contain a mix of ASCII and wide characters. val plainLines = chrono.map { stripIrcFormatting(it.text) } .filter { it.isNotEmpty() } .sortedByDescending { it.length } .take(3) val fontSize = if (plainLines.isEmpty() || availableWidthPx <= 0f) { naturalSizeSp } else { val widestPx = 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 (widestPx <= availableWidthPx || widestPx == 0f) { naturalSizeSp } else { (naturalSizeSp * availableWidthPx / widestPx).coerceAtLeast(minFontSp) } } result.add(DisplayItem.Art(msgs = chrono, fontSizeSp = fontSize)) } artRun.clear() } for (msg in reversedMessages) { if (msg.from != null && looksLikeArt(msg.text)) { artRun.add(msg) } else { flushArtRun() result.add(DisplayItem.Single(msg)) } } flushArtRun() result } } /** * 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, ) }