diff --git a/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt b/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt index d83bbbce3..6d3a12a79 100644 --- a/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/github/zly2006/zhihu/ArticleScreenInstrumentedTest.kt @@ -19,6 +19,7 @@ package com.github.zly2006.zhihu import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.os.SystemClock import android.util.Log import androidx.compose.foundation.ComposeFoundationFlags @@ -30,13 +31,16 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.TextToolbar @@ -99,6 +103,8 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.File +import java.io.FileOutputStream import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong @@ -564,6 +570,64 @@ class ArticleScreenInstrumentedTest { composeRule.onNodeWithText("“$FORMATTED_HIGHLIGHT”").assertIsDisplayed() } + @Test + fun highlightedTextDrawsDashesAcrossEveryWrappedLine() { + composeRule.setScreenContent { + MaterialTheme( + colorScheme = lightColorScheme( + outlineVariant = Color.Magenta, + ), + ) { + RenderMarkdown( + html = WRAPPED_HIGHLIGHT_PARAGRAPH_HTML, + modifier = androidx.compose.ui.Modifier + .width(220.dp) + .testTag("wrapped-highlight-article"), + enableScroll = false, + ) + } + } + + val paragraph = composeRule.onNodeWithText(WRAPPED_HIGHLIGHT_PARAGRAPH) + val layouts = mutableListOf() + paragraph.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { getTextLayoutResult -> + assertTrue(getTextLayoutResult(layouts)) + } + val layout = layouts.single() + val highlightStart = WRAPPED_HIGHLIGHT_PREFIX.length + val highlightEnd = highlightStart + WRAPPED_HIGHLIGHT.length + val startLine = layout.getLineForOffset(highlightStart) + val endLine = layout.getLineForOffset(highlightEnd - 1) + assertTrue("Fixture must wrap the highlighted text onto at least three lines", endLine - startLine >= 2) + + val image = composeRule + .onNodeWithTag("wrapped-highlight-article") + .captureToImage() + val output = File( + requireNotNull(InstrumentationRegistry.getInstrumentation().targetContext.getExternalFilesDir(null)), + "segment-highlight-wrapped.png", + ) + FileOutputStream(output).use { stream -> + image.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, stream) + } + + val pixels = image.toPixelMap() + for (line in startLine..endLine) { + val top = (layout.getLineBottom(line) - 6f).toInt().coerceAtLeast(0) + val bottom = (layout.getLineBottom(line) + 2f).toInt().coerceAtMost(pixels.height - 1) + val magentaPixels = (top..bottom).sumOf { y -> + (0 until pixels.width).count { x -> + val color = pixels[x, y] + color.red > 0.8f && color.green < 0.2f && color.blue > 0.8f + } + } + assertTrue( + "Highlighted visual line $line must contain visible dash pixels; found $magentaPixels. Screenshot: ${output.absolutePath}", + magentaPixels >= 4, + ) + } + } + @Test fun highlightedParagraphTapOpensActionsInsideAnswerScreen() { val viewModel = seededAnswerViewModel(ANSWER) @@ -1387,6 +1451,12 @@ class ArticleScreenInstrumentedTest { const val FORMATTED_HIGHLIGHT_PREFIX = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW" const val FORMATTED_HIGHLIGHT = "划线命中" const val FORMATTED_HIGHLIGHT_PARAGRAPH = "$FORMATTED_HIGHLIGHT_PREFIX$FORMATTED_HIGHLIGHT 后缀" + const val WRAPPED_HIGHLIGHT_PREFIX = "普通前缀 " + const val WRAPPED_HIGHLIGHT = + "这是位于段落中间并且需要跨越多个视觉行的划线内容,用于验证每一行都能完整绘制虚线。" + const val WRAPPED_HIGHLIGHT_SUFFIX = " 普通后缀" + const val WRAPPED_HIGHLIGHT_PARAGRAPH = + "$WRAPPED_HIGHLIGHT_PREFIX$WRAPPED_HIGHLIGHT$WRAPPED_HIGHLIGHT_SUFFIX" val HIGHLIGHTED_PARAGRAPH_HTML = """

$FORMATTED_HIGHLIGHT 后缀

""".trimIndent() + val WRAPPED_HIGHLIGHT_PARAGRAPH_HTML = + """ +

$WRAPPED_HIGHLIGHT_PREFIX$WRAPPED_HIGHLIGHT$WRAPPED_HIGHLIGHT_SUFFIX

+ """.trimIndent() val ARTICLE = Article( type = ArticleType.Article, diff --git a/gradle.properties b/gradle.properties index 5125fda03..fcbc355c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -37,8 +37,8 @@ ksp.incremental=true # Enable parallel execution org.gradle.parallel=true -app.versionName=0.26.1 -app.versionCode=730 +app.versionName=0.27 +app.versionCode=732 # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true diff --git a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt index b1b73ff60..6f717756c 100644 --- a/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt +++ b/shared/src/commonMain/kotlin/com/github/zly2006/zhihu/ui/NotificationScreen.kt @@ -35,13 +35,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Comment +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.MarkChatRead +import androidx.compose.material.icons.filled.PersonAddAlt1 import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.ContactPage -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.CenterAlignedTopAppBar @@ -299,20 +299,6 @@ private fun NotificationInvitationRow( ) Spacer(Modifier.height(3.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - invitation.avatarUrls.take(2).forEachIndexed { index, avatar -> - AsyncImage( - model = avatar.url, - contentDescription = null, - modifier = Modifier - .padding(start = if (index == 0) 0.dp else 2.dp) - .size(22.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) - } - if (invitation.avatarUrls.isNotEmpty()) { - Spacer(Modifier.width(6.dp)) - } Text( text = invitation.textPrefix + invitation.text, style = MaterialTheme.typography.bodyMedium, @@ -549,9 +535,9 @@ fun NotificationItemView( private fun MobileNotificationCategory.homeIcon(): ImageVector = when (this) { MobileNotificationCategory.Comment -> Icons.AutoMirrored.Outlined.Comment - MobileNotificationCategory.Like -> Icons.Outlined.FavoriteBorder - MobileNotificationCategory.Favorite -> Icons.Outlined.StarOutline - MobileNotificationCategory.Follow -> Icons.Outlined.Info + MobileNotificationCategory.Like -> Icons.Filled.Favorite + MobileNotificationCategory.Favorite -> Icons.Filled.Bookmark + MobileNotificationCategory.Follow -> Icons.Filled.PersonAddAlt1 } internal fun MobileNotificationTimelineItem.displayTitle(): String = diff --git a/third_party/markdown/markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/block/ParagraphRenderer.kt b/third_party/markdown/markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/block/ParagraphRenderer.kt index e4a07c536..c2b795807 100644 --- a/third_party/markdown/markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/block/ParagraphRenderer.kt +++ b/third_party/markdown/markdown-renderer/src/commonMain/kotlin/com/hrm/markdown/renderer/block/ParagraphRenderer.kt @@ -164,8 +164,16 @@ private fun highlightedLineRects( return (startLine..endLine).map { line -> val lineStart = maxOf(safeStart, layout.getLineStart(line)) val lineEnd = minOf(safeEnd, layout.getLineEnd(line, visibleEnd = true)) - val left = if (lineStart < lineEnd) layout.getHorizontalPosition(lineStart, usePrimaryDirection = true) else 0f - val right = if (lineStart < lineEnd) layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true) else left + val left = when { + lineStart >= lineEnd -> 0f + line == startLine -> layout.getHorizontalPosition(lineStart, usePrimaryDirection = true) + else -> layout.getLineLeft(line) + } + val right = when { + lineStart >= lineEnd -> left + line == endLine -> layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true) + else -> layout.getLineRight(line) + } androidx.compose.ui.geometry.Rect( left = minOf(left, right), top = layout.getLineTop(line),