mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 09:28:09 +00:00
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:
1 parent
4f81c6f632
commit
7810fc4752
103 files changed
+5098
-901
No files matched your search
+53
@@ -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)
|
||||
+66
@@ -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"])
|
||||
}
|
||||
}
|
||||
+158
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user