core, ui: terminal chat layout for LLM and service bot chats (#7811)

* plan: UI improvements to improve LLM usage via SimpleX

* core, ui: types for chat layout

* core, ui: settings for alternative terminal-like chat layout for managing LLMs or other service bots

* plan: message priority

* fixes

* query plan

* update plans

* remove silent messages plan

* rename migrations

* implement layout

* amend

* fix

* fix

* fix

* improve

* time on the right, sender name in blue

* no avatar in direct chats

* better previews

* update, fixes

* update

* time gap

* changes

* ui: terminal layout line heights, gaps and time placement

* ui: simplify terminal meta layout, correct its docs and tests

* ui: right-to-left emoji edge in terminal rows, time placement tests

* android, desktop: raise Kotlin compiler memory to 4 GB

* ui: terminal captions as follow-up text, classic event separators

* ui: terminal media edge and overlay time, plan decisions

* ui: simplify terminal row separation, correct doc line references

* ui: keep the media time inside terminal rows, restore caption conditions

* ui: terminal heading capitals at large font scales, idle live message time

* ui: rename the classic voice-with-text flag, correct the meta copy docs

* ui: smaller terminal layout diff, sender gap and meta names

* ui: terminal row as tall as the media time

* docs: remove the chat layout section from the bots readme

* docs: shorten the terminal layout section of the chat view

* core: address settings API as on master, /auto_accept reads the stored layout

* ui: smaller terminal headings

* ui: classic sender rows and avatar rule as on master

* ui: classic reply quote text colour as on master

* ios: keep the chat layout setting with other UI settings

* ui: go-to button at the end of terminal rows, no gutter in direct chats

* ui: forwarded arrow on the header line of terminal rows, above the time

* ui: forwarded arrow centred above the time line of terminal rows

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Evgeny @ SimpleX ChatandEvgeny Poberezkin authored and GitHub committed 2026-10-25 18:03:11 +00:00
1 parent 4f81c6f632
commit 7810fc4752
103 files changed
+5098 -901

No files matched your search

@@ -0,0 +1,53 @@
package chat.simplex.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.chat.item.*
import kotlinx.datetime.Clock
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ChatEventTextTest {
private val event = ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead()), CIContent.RcvDirectE2EEInfo(E2EEInfo(pqEnabled = false)), reactions = listOf())
private fun separatorAndTime(terminal: Boolean): Pair<AnnotatedString, AnnotatedString> {
var result: Pair<AnnotatedString, AnnotatedString>? = null
renderScene {
SimpleXTheme {
CompositionLocalProvider(LocalTerminalLayout provides terminal) {
ChatEventWithMeta(event) { separator, ts ->
result = separator to ts
Box(Modifier.size(1.dp))
}
}
}
}
return result!!
}
@Test
fun classicEventTextUnchanged() {
val (separator, ts) = separatorAndTime(terminal = false)
assertEquals(AnnotatedString(" "), separator)
assertEquals(AnnotatedString(event.timestampText), ts)
val base = buildAnnotatedString { withStyle(chatEventStyle) { append("alice joined the group ${event.timestampText}") } }
assertEquals(base, chatEventText("alice joined the group", separator + ts))
}
@Test
fun terminalSeparatorJoinsCopyByNoBreakSpaces() {
val (separator, ts) = separatorAndTime(terminal = true)
assertEquals("\u00A0\u00A0\u00A0", separator.text)
assertEquals(listOf(chatEventStyle, reserveTimestampStyle), separator.spanStyles.map { it.item })
assertTrue(ts.text.startsWith("\u2060\u200E") && ts.text.endsWith(event.timestampText.replace(' ', '\u00A0')), ts.text)
}
}
@@ -0,0 +1,34 @@
package chat.simplex.app
import androidx.compose.runtime.Composable
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.roundToInt
data class Placed(val x: Int, val y: Int, val width: Int, val height: Int)
fun Modifier.record(into: MutableMap<String, Placed>, name: String): Modifier = onGloballyPositioned {
val p = it.positionInRoot()
into[name] = Placed(p.x.roundToInt(), p.y.roundToInt(), it.size.width, it.size.height)
}
fun Modifier.fixed(width: Int, height: Int, baseline: Int?): Modifier = layout { measurable, _ ->
val placeable = measurable.measure(Constraints.fixed(width, height))
layout(width, height, if (baseline == null) emptyMap() else mapOf(FirstBaseline to baseline, LastBaseline to baseline)) { placeable.place(0, 0) }
}
fun renderScene(direction: LayoutDirection = LayoutDirection.Ltr, content: @Composable () -> Unit) {
val scene = ImageComposeScene(width = 400, height = 400, density = Density(1f), layoutDirection = direction, content = content)
scene.render()
scene.close()
}
fun Placed.relativeTo(origin: Placed) = Placed(x - origin.x, y - origin.y, width, height)
@@ -0,0 +1,66 @@
package chat.simplex.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID
import chat.simplex.common.views.chat.item.PriorityLayout
import kotlin.test.Test
import kotlin.test.assertEquals
class PriorityLayoutTest {
private val placed = mutableMapOf<String, Placed>()
private fun layOut(limitToPriorityWidth: Boolean, direction: LayoutDirection = LayoutDirection.Ltr): Map<String, Placed> {
renderScene(direction) {
Box(Modifier.width(300.dp)) {
PriorityLayout(Modifier.record(placed, "layout"), CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth) {
Box(Modifier.width(280.dp).height(30.dp).record(placed, "header"))
Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).record(placed, "media").size(200.dp, 100.dp))
Box(Modifier.fillMaxWidth().height(40.dp).record(placed, "caption"))
}
}
}
val layout = placed.getValue("layout")
return listOf("layout", "header", "media", "caption").associateWith { placed.getValue(it).relativeTo(layout) }
}
@Test
fun terminalElementsAtTheirOwnWidth() {
val r = layOut(limitToPriorityWidth = false)
assertEquals(Placed(0, 0, 300, 170), r["layout"])
assertEquals(Placed(0, 0, 280, 30), r["header"])
assertEquals(Placed(0, 30, 200, 100), r["media"])
assertEquals(Placed(0, 130, 300, 40), r["caption"])
}
@Test
fun classicElementsAtMediaWidth() {
val r = layOut(limitToPriorityWidth = true)
assertEquals(Placed(0, 0, 200, 170), r["layout"])
assertEquals(Placed(0, 0, 200, 30), r["header"])
assertEquals(Placed(0, 30, 200, 100), r["media"])
assertEquals(Placed(0, 130, 200, 40), r["caption"])
}
@Test
fun terminalElementsAtStartEdgeRightToLeft() {
val r = layOut(limitToPriorityWidth = false, direction = LayoutDirection.Rtl)
assertEquals(Placed(20, 0, 280, 30), r["header"])
assertEquals(Placed(100, 30, 200, 100), r["media"])
assertEquals(Placed(0, 130, 300, 40), r["caption"])
}
@Test
fun classicPlacementUnmirroredRightToLeft() {
val r = layOut(limitToPriorityWidth = true, direction = LayoutDirection.Rtl)
assertEquals(Placed(0, 0, 200, 30), r["header"])
assertEquals(Placed(0, 30, 200, 100), r["media"])
}
}
@@ -0,0 +1,158 @@
package chat.simplex.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.Inter
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.chat.item.LocalTerminalLayout
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.chat.item.reserveSpaceForMeta
import chat.simplex.common.views.chat.item.terminalReserveSpaceForMeta
import kotlinx.datetime.Clock
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TerminalMetaCopyTest {
private val resolver = createFontFamilyResolver()
private val iconSpace = " \u00A0\u00A0\u00A0"
private val sentEdited = CIMeta.getSample(1, Clock.System.now(), "", status = CIStatus.SndSent(SndCIStatusProgress.Complete), itemEdited = true)
private val receivedTimed = CIMeta.getSample(2, Clock.System.now(), "", status = CIStatus.RcvRead(), itemTimed = CITimed(30, null))
private val sentViaProxy = CIMeta.getSample(3, Clock.System.now(), "", status = CIStatus.SndSent(SndCIStatusProgress.Complete), sentViaProxy = true)
private fun copy(meta: CIMeta, density: Density, direction: LayoutDirection, showViaProxy: Boolean = false) =
terminalReserveSpaceForMeta(
meta, null, null, null, Color.Gray, showStatus = true, showEdited = true, showViaProxy = showViaProxy, showTimestamp = true,
density = density, bodyFontSize = 16.sp, direction = direction
)
private fun paragraph(text: AnnotatedString, density: Density, textDirection: TextDirection, width: Int) =
Paragraph(
text = text.text,
style = TextStyle(fontFamily = Inter, fontSize = 16.sp, textDirection = textDirection),
constraints = Constraints(maxWidth = width),
density = density,
fontFamilyResolver = resolver,
spanStyles = text.spanStyles
)
private fun Paragraph.bounds(range: IntRange): Rect =
range.map { getBoundingBox(it) }.reduce { a, b -> Rect(minOf(a.left, b.left), minOf(a.top, b.top), maxOf(a.right, b.right), maxOf(a.bottom, b.bottom)) }
@Test
fun classicReserveUnchanged() {
assertEquals(iconSpace + iconSpace + "\u00A0" + sentEdited.timestampText, reserveSpaceForMeta(sentEdited, null, null, Color.Gray, showTimestamp = true))
assertEquals(iconSpace + shortTimeText(30) + "\u00A0" + iconSpace + "\u00A0" + receivedTimed.timestampText, reserveSpaceForMeta(receivedTimed, null, null, Color.Gray, showTimestamp = true))
assertEquals(iconSpace + iconSpace + iconSpace, reserveSpaceForMeta(sentEdited, null, null, Color.Gray, showTimestamp = false))
assertEquals(iconSpace + iconSpace + "\u00A0" + sentViaProxy.timestampText, reserveSpaceForMeta(sentViaProxy, null, null, Color.Gray, showViaProxy = true, showTimestamp = true))
}
@Test
fun copyHasClassicSpacesAndDirectionMark() {
val ltr = copy(sentEdited, Density(1f), LayoutDirection.Ltr)
assertEquals("\u2060\u200E" + (iconSpace + iconSpace + "\u00A0" + "\u00A0" + sentEdited.timestampText).replace(' ', '\u00A0'), ltr.text)
val rtl = copy(receivedTimed, Density(1f), LayoutDirection.Rtl)
assertEquals("\u2060\u200F" + (iconSpace + shortTimeText(30) + "\u00A0" + iconSpace + "\u00A0" + receivedTimed.timestampText).replace(' ', '\u00A0'), rtl.text)
val proxy = copy(sentViaProxy, Density(1f), LayoutDirection.Ltr, showViaProxy = true)
assertEquals("\u2060\u200E" + (iconSpace + "\u00A0" + iconSpace + "\u00A0" + "\u00A0" + sentViaProxy.timestampText).replace(' ', '\u00A0'), proxy.text)
}
@Test
fun iconSpacesKeepWidthAcrossFontScales() {
val ts = sentEdited.timestampText
val widths = listOf(0.7f, 0.75f, 1f, 1.3f).associateWith { s ->
val density = Density(1f, s)
val text = copy(sentEdited, density, LayoutDirection.Ltr)
val p = paragraph(text, density, TextDirection.Ltr, 1000)
val timeStart = text.length - ts.length
assertTrue(p.getHorizontalPosition(2, true) < 0.5f, "marks at scale $s: ${p.getHorizontalPosition(2, true)} px")
val icons = p.getHorizontalPosition(timeStart, true) - p.getHorizontalPosition(2, true)
val time = p.getHorizontalPosition(text.length, true) - p.getHorizontalPosition(timeStart, true)
icons to time
}
val (icons1, time1) = widths.getValue(1f)
for ((s, w) in widths) {
assertTrue(abs(w.first - icons1) < 0.5f, "icon spaces at scale $s: ${w.first} px, at scale 1: $icons1 px")
assertTrue(abs(w.second / time1 - s) < 0.05f, "time at scale $s: ${w.second} px, at scale 1: $time1 px")
}
}
@Test
fun ruleOnlyMessageAsWideAsCopy() {
val placed = mutableMapOf<String, Placed>()
renderScene {
SimpleXTheme {
CompositionLocalProvider(LocalTerminalLayout provides true, LocalDensity provides Density(1f)) {
Box(Modifier.width(IntrinsicSize.Max)) {
MarkdownText(
"---", listOf(FormattedText("---")), listOf(MarkdownBlock.Rule(SegmentRange(0, 1))), meta = sentEdited,
toggleSecrets = false, linkMode = SimplexLinkMode.DESCRIPTION, blockLayout = true, modifier = Modifier.record(placed, "rule")
)
}
}
}
}
val copyWidth = paragraph(copy(sentEdited, Density(1f), LayoutDirection.Ltr), Density(1f), TextDirection.Ltr, 1000).maxIntrinsicWidth
assertTrue(abs(placed.getValue("rule").width - copyWidth) <= 1f, "rule ${placed.getValue("rule").width} px, copy $copyWidth px")
}
@Test
fun copyAfterSpacesStaysWithLastWord() {
val density = Density(1f)
val head = "The bot replied at once"
val text = buildAnnotatedString {
append("$head ")
append(copy(sentEdited, density, LayoutDirection.Ltr))
}
val oneLine = paragraph(text, density, TextDirection.Ltr, 10000)
val width = (oneLine.getHorizontalPosition(head.length + 3, true) + oneLine.maxIntrinsicWidth) / 2
val p = paragraph(text, density, TextDirection.Ltr, width.toInt())
assertEquals(2, p.lineCount)
assertEquals(p.getLineForOffset(head.lastIndexOf(' ') + 1), p.getLineForOffset(text.length - 1))
}
@Test
fun copyStaysAtLineEndAfterWordInOtherDirection() {
val cases = listOf(
"שלום לכולם, זו הפסקה האחרונה של ההודעה" to LayoutDirection.Ltr,
"This is the last paragraph of the message" to LayoutDirection.Rtl,
)
for ((head, direction) in cases) {
val density = Density(1f)
val text = buildAnnotatedString {
append(head)
append(copy(sentEdited, density, direction))
}
val p = paragraph(text, density, if (direction == LayoutDirection.Ltr) TextDirection.Ltr else TextDirection.Rtl, 230)
val lastLine = p.lineCount - 1
val lineStart = p.getLineStart(lastLine)
assertTrue(lineStart in 1 until head.length, "$direction: last line start $lineStart")
assertEquals(lastLine, p.getLineForOffset(text.length - 1), "$direction: time line")
val words = p.bounds(lineStart until head.length)
val time = p.bounds(head.length + 2 until text.length)
if (direction == LayoutDirection.Ltr) {
assertTrue(time.left >= words.right - 0.5f, "$direction: copy ${time.left}..${time.right}, words ${words.left}..${words.right}")
} else {
assertTrue(time.right <= words.left + 0.5f, "$direction: copy ${time.left}..${time.right}, words ${words.left}..${words.right}")
}
}
}
}
@@ -0,0 +1,160 @@
package chat.simplex.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.chat.item.*
import kotlin.test.Test
import kotlin.test.assertEquals
class TerminalMetaLayoutTest {
private val density = Density(1f)
private val rowWidth = 300
private val metaWidth = 60
private val metaHeight = 16
private val metaBaseline = 12
private val metaBoxWidth = metaWidth + 12
private val gap = with(density) { TERMINAL_META_GAP.roundToPx() }
private val blockGap = with(density) { TERMINAL_BLOCK_BOTTOM_GAP.roundToPx() }
private val placed = mutableMapOf<String, Placed>()
private class Result(val row: Placed, val content: Placed, val meta: Placed?, val overlay: Placed?, val endButton: Placed?, val drawn: Set<String>)
private fun layOut(
direction: LayoutDirection = LayoutDirection.Ltr,
metaReserved: Boolean = true,
overlay: Boolean = false,
endButton: Boolean = false,
content: @Composable (Modifier) -> Unit
): Result {
val drawn = mutableSetOf<String>()
renderScene(direction) {
CompositionLocalProvider(LocalTerminalLayout provides true) {
Box(Modifier.width(rowWidth.dp).record(placed, "row")) {
ItemWithMeta(
meta = { Box(Modifier.record(placed, "meta").drawBehind { drawn += "meta" }.fixed(metaWidth, metaHeight, metaBaseline)) },
overlayMeta = if (overlay) { { Box(Modifier.record(placed, "overlay").drawBehind { drawn += "overlay" }.fixed(metaWidth, metaHeight, metaBaseline)) } } else null,
metaReserved = metaReserved,
endButton = if (endButton) { { Box(Modifier.record(placed, "endButton").fixed(22, 22, null)) } } else null
) { content(Modifier.record(placed, "content")) }
}
}
}
val row = placed.getValue("row")
return Result(Placed(0, 0, row.width, row.height), placed.getValue("content").relativeTo(row), placed["meta"]?.relativeTo(row), placed["overlay"]?.relativeTo(row), placed["endButton"]?.relativeTo(row), drawn)
}
@Test
fun metaBaselineOnContentLastBaseline() {
val r = layOut(metaReserved = false) { Box(it.fixed(100, 40, 30)) }
assertEquals(Placed(0, 0, 100, 40), r.content)
assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta)
assertEquals(40, r.row.height)
}
@Test
fun contentNarrowedByMetaWithoutReserve() {
val r = layOut(metaReserved = false) { Box(it.fillMaxWidth().height(40.dp)) }
assertEquals(Placed(0, 0, rowWidth - metaBoxWidth, 40), r.content)
}
@Test
fun metaBesideLastLineWithRoom() {
val r = layOut { Box(it.messageLastLine().fixed(100, 40, 30)) }
assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta)
assertEquals(40, r.row.height)
}
@Test
fun metaUnderLastLineWithoutRoom() {
val r = layOut { Box(it.messageLastLine().fixed(260, 40, 30)) }
assertEquals(Placed(rowWidth - metaBoxWidth, 40 + gap, metaWidth, metaHeight), r.meta)
assertEquals(40 + gap + metaHeight, r.row.height)
}
@Test
fun blockGapBelowLastBlockWithMetaBeside() {
val r = layOut { Box(it.messageLastBlock().fixed(100, 40, 30)) }
assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta)
assertEquals(40 + blockGap, r.row.height)
}
@Test
fun metaAboveBottomOfContentWithoutBaseline() {
val r = layOut(metaReserved = false) { Box(it.fixed(100, 50, null)) }
assertEquals(Placed(rowWidth - metaBoxWidth, 50 - gap - metaHeight, metaWidth, metaHeight), r.meta)
assertEquals(50, r.row.height)
}
@Test
fun rowAtLeastAsTallAsMeta() {
val r = layOut { Box(it.fixed(100, 10, 8)) }
assertEquals(Placed(rowWidth - metaBoxWidth, 0, metaWidth, metaHeight), r.meta)
assertEquals(metaHeight, r.row.height)
}
@Test
fun overlayMetaAtContentEndWhenRowIsFull() {
val r = layOut(metaReserved = false, overlay = true) { Box(it.fixed(260, 100, null)) }
assertEquals(Placed(260 - metaBoxWidth, 100 - gap - metaHeight, metaWidth, metaHeight), r.overlay)
assertEquals(setOf("overlay"), r.drawn)
assertEquals(100, r.row.height)
}
@Test
fun overlayMetaAtTopOfShortContent() {
val r = layOut(metaReserved = false, overlay = true) { Box(it.fixed(260, 10, null)) }
assertEquals(Placed(260 - metaBoxWidth, 0, metaWidth, metaHeight), r.overlay)
assertEquals(setOf("overlay"), r.drawn)
assertEquals(metaHeight, r.row.height)
}
@Test
fun overlayMetaOnMediaUnderWiderQuote() {
val r = layOut(metaReserved = false, overlay = true) {
Column(it.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth = true) {
Box(Modifier.width(290.dp).height(30.dp))
Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).record(placed, "media").size(260.dp, 100.dp))
}
}
}
val media = placed.getValue("media").relativeTo(placed.getValue("row"))
assertEquals(Placed(media.x + media.width - metaBoxWidth, media.y + media.height - gap - metaHeight, metaWidth, metaHeight), r.overlay)
assertEquals(setOf("overlay"), r.drawn)
}
@Test
fun endButtonCentredAboveTimeLine() {
val r = layOut(endButton = true) {
Column(it.fillMaxWidth()) {
Box(Modifier.fixed(80, 24, 18))
Box(Modifier.fixed(100, 40, 30))
}
}
assertEquals(Placed(0, 0, rowWidth - 12 - 22, 64), r.content)
assertEquals(Placed(rowWidth - metaBoxWidth, 54 - metaBaseline, metaWidth, metaHeight), r.meta)
assertEquals(Placed(rowWidth - 12 - 22, (54 - metaBaseline - 22) / 2, 22, 22), r.endButton)
assertEquals(64, r.row.height)
}
@Test
fun metaBesideLastLineRightToLeft() {
val r = layOut(direction = LayoutDirection.Rtl) { Box(it.messageLastLine().fixed(100, 40, 30)) }
assertEquals(Placed(rowWidth - 100, 0, 100, 40), r.content)
assertEquals(Placed(metaBoxWidth - metaWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta)
assertEquals(40, r.row.height)
}
}