/**
* Returns true when a chat message line looks like it belongs to a bot-generated
* ASCII/ANSI art block rather than normal coloured conversation.
*/
// 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 {
data class Single(val msg: UiMessage) : DisplayItem() {
override val key
: Long = msg.
id * 2L
}
data class Art(
val msgs: List<UiMessage>, // chronological: oldest first
) : 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<UiMessage>,
style: androidx.compose.ui.text.TextStyle,
): List<DisplayItem> {
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<DisplayItem>()
// artRun accumulates reversed-order messages; flushed when broken or at end.
val artRun = mutableListOf<UiMessage>()
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<String>,
style: androidx.compose.ui.text.TextStyle,
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(
style: androidx.compose.ui.text.TextStyle,
ansiColorsEnabled
: Boolean = false,
linkStyle: SpanStyle,
) {
IrcLinkifiedText(
text = text,
mircColorsEnabled = mircColorsEnabled,
ansiColorsEnabled = ansiColorsEnabled,
linkStyle = linkStyle,
onAnnotationClick = onAnnotationClick,
style = style.copy(fontSize = fontSizeSp.sp),
maxLines = 1,
overflow
= TextOverflow.
Clip,
)
}