diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt index 29ae596..714fdc6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt @@ -15,11 +15,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import top.yeij.cyrene.domain.chat.DocumentChips @@ -247,15 +251,15 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…" * 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、 * 输入框与按钮原地不动。 * - * ## 正文为什么只给三行 + * ## 正文给三行,但三行之外是「滚」不是「省略号」 * 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板 - * 就成了一个阅读器。三行之外用省略号收口,不假装那是全文。 + * 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短 + * 变化。 * - * ⚠️ W53X3 之后**面板里再没有第二个地方放着这段正文**:输入框已经改成「用户附加的描述」(初值恒为 - * 空),不再是正文的可编辑副本。也就是说超过三行的分享正文在面板上只看得到开头——**这不影响发出去的 - * 内容**([shareConfirmBody] 用的是完整的那一段,一字不丢),但它确实少了一处「发送前读全文」的地方。 - * 这是 W53X 那一版的老行为,本批只把它如实写下来、**没有顺手改**:正文块要不要能展开属于观感取舍, - * 得由主人拍板。 + * 但三行之外**不再收成省略号**(W54D 主人拍板改掉了 W53X 的老行为):W53X3 之后**面板里再没有第二 + * 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断 + * 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗 + * 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。 */ @Composable private fun ShareConfirmPreviews(preview: ShareConfirmPreview) { @@ -303,22 +307,70 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) { style = MiuixTheme.textStyles.footnote2, color = MiuixTheme.colorScheme.onSurfaceVariantSummary, ) - Text( - text = preview.text, - style = MiuixTheme.textStyles.footnote1, - maxLines = SHARE_PREVIEW_TEXT_LINES, - overflow = TextOverflow.Ellipsis, - ) + ShareConfirmBody(preview.text) } } } } +/** + * 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews])。 + * + * ## 为什么量行高,而不是写一个 dp 常量 + * 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」 + * 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号, + * `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。 + * 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。 + * 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。 + * + * 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度 + * 与样式都没变,行位置与首帧一致,量出来的值也一样。 + * + * ⚠️ 代价是**首帧先按完整正文排一次**、下一帧才收到三行:正文很长时,这一窗理论上会有一帧高出一截、 + * 然后回落。另一条路——写死一个 dp 上限——没有这一帧,但在非默认字号档上必然切字,两害相权取了 + * 「量」。这一帧在实际渲染里看不看得见(弹层这时正在进场,可能被动画盖住)**没有在真机上验过**, + * 留给主人真机验收。 + * + * ## 两层滚动怎么分工 + * 这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块合起来的高度 + * 上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来就走嵌套滚动, + * 不用额外接线。 + */ +@Composable +private fun ShareConfirmBody(text: String) { + val density = LocalDensity.current + var cap by remember(text) { mutableStateOf(Dp.Infinity) } + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = cap) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = text, + style = MiuixTheme.textStyles.footnote1, + onTextLayout = { layout -> + if (cap == Dp.Infinity && layout.lineCount > 0) { + // 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。 + val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1 + cap = with(density) { layout.getLineBottom(lastVisible).toDp() } + } + }, + ) + } +} + /** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */ private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp -/** 预览里正文给几行(超出的部分收成省略号,见 [ShareConfirmPreviews] 的 KDoc)。 */ -private const val SHARE_PREVIEW_TEXT_LINES = 3 +/** + * 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。 + * + * `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有 + * Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。 + */ +internal const val SHARE_PREVIEW_TEXT_LINES = 3 /** * 分享进来的一份文件 ⇒ 待发附件。 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt index 36ddf5a..b6210a6 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt @@ -32,6 +32,13 @@ import top.yeij.cyrene.domain.share.SharedDocument * ⚠️ 「输入框初值为空」本身是 Compose 状态(`ChatScreen` 里那格 `remember`),单测碰不到——它由 * 主人真机验收(输入框空着、预览里有正文)。这份测试能钉住的是它的**后果**:描述为空时,拼出来的 * 正文就是分享正文本身、而且只出现**一次**(见 `the caption is not a copy of the shared text`)。 + * + * ## W54D 又改掉了什么 + * 正文那一窗从「三行 + 省略号」改成「三行窗口 + 内部滚动」(主人拍板):面板里再没有第二处放着这段 + * 正文,收成省略号就等于「按发送之前读不到要发的那段正文」。滚动是渲染层的事、单测够不着(本模块 + * 没有 Compose UI 测试的依赖),所以这里钉的是它的两个前提——**正文完整地进到预览**(一个字都不少) + * 与**这一窗仍然是三行**(面板不随正文变高),见 `the whole body reaches the preview …` 与 + * `the preview window stays three lines tall`。 */ class ShareConfirmTest { @@ -54,6 +61,33 @@ class ShareConfirmTest { assertFalse(preview.isEmpty) } + @Test + fun `the whole body reaches the preview even when it is longer than the window`() { + val long = (1..40).joinToString("\n") { "第 $it 行" } + + // W54D 主人拍板:预览的正文那一窗恒为三行高,但**里面能滚**——三行之外不再收成省略号。这条钉 + // 的是那一窗的输入:纯函数层把**完整**的正文交给渲染层(这一段一个字都不许在这里少),少了就 + // 一定读不到——面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」)。 + val preview = shareConfirmPreview(SharedContent(text = long)) + assertEquals(long, preview.text) + assertEquals(40, preview.text.lines().size) + assertTrue( + "这一档必须真的超过三行,否则上面那条断言什么也证明不了", + preview.text.lines().size > SHARE_PREVIEW_TEXT_LINES, + ) + + // 发出去的那一段同样一个字不少(两边同源,见 [shareConfirmBody])。 + assertEquals(long, shareConfirmBody(SharedContent(text = long), "")) + } + + @Test + fun `the preview window stays three lines tall`() { + // 3 这个字面量就是这条测试的全部内容:滚动是渲染层的事、单测够不着(本模块没有 Compose UI + // 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。改大 ⇒ 面板会随长正文变高(主人 + // 拍板的高度上限没了);改小 ⇒ 正文的可见窗口白缩一行。 + assertEquals(3, SHARE_PREVIEW_TEXT_LINES) + } + @Test fun `blank pieces count as nothing to show`() { val preview = shareConfirmPreview(