From 40633221282bf3a67cbad46ad3884a5fb4cb94b2 Mon Sep 17 00:00:00 2001 From: Marco Gomiero Date: Mon, 26 Oct 2026 21:18:47 +0100 Subject: [PATCH] Lay out right-to-left articles right-to-left in the desktop reader --- .../reaadermode/DesktopReaderContent.kt | 43 ++++++++++++++--- .../reaadermode/DesktopReaderContentTest.kt | 28 +++++++++++ .../shared/domain/ReaderModeMarkdown.kt | 34 ++++++++++++++ .../shared/domain/ReaderModeMarkdownTest.kt | 46 +++++++++++++++++++ 4 files changed, 145 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContent.kt b/desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContent.kt index 6b7161de4..dfe1c5c63 100644 --- a/desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContent.kt +++ b/desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContent.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -24,10 +25,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.ImageLoader @@ -37,6 +40,7 @@ import com.prof18.feedflow.core.model.AudioEnclosure import com.prof18.feedflow.core.model.FeedItemUrlInfo import com.prof18.feedflow.core.model.ReaderModeData import com.prof18.feedflow.core.model.ReaderModeState +import com.prof18.feedflow.core.utils.ContentDirection import com.prof18.feedflow.desktop.ui.LocalFeedFlowStrings import com.prof18.feedflow.desktop.ui.LocalReduceMotion import com.prof18.feedflow.desktop.ui.components.DesktopDesignTokens @@ -44,6 +48,7 @@ import com.prof18.feedflow.desktop.ui.components.FeedFlowVerticalScrollbar import com.prof18.feedflow.desktop.ui.components.TopToolbarContentFade import com.prof18.feedflow.shared.domain.buildReaderModeMarkdown import com.prof18.feedflow.shared.domain.readerLineHeightToTextLineHeightSp +import com.prof18.feedflow.shared.domain.readerModeContentDirection import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -92,6 +97,11 @@ internal fun DesktopReaderContent( ReaderModeState.Loading -> ReaderArticle( articleKey = loadingArticle?.id, + direction = readerModeContentDirection( + title = loadingArticle?.title, + siteName = loadingArticle?.feedSourceTitle, + markdown = null, + ), title = loadingArticle?.title, siteName = loadingArticle?.feedSourceTitle, fontSize = fontSize, @@ -100,6 +110,13 @@ internal fun DesktopReaderContent( is ReaderModeState.Success -> ReaderArticle( articleKey = s.readerModeData.id.id, + direction = remember(s.readerModeData) { + readerModeContentDirection( + title = s.readerModeData.title, + siteName = s.readerModeData.siteName, + markdown = s.readerModeData.content, + ) + }, title = s.readerModeData.title, siteName = s.readerModeData.siteName, fontSize = fontSize, @@ -137,6 +154,7 @@ internal fun DesktopReaderContent( @Composable private fun ReaderArticle( articleKey: String?, + direction: ContentDirection?, title: String?, siteName: String?, fontSize: Int, @@ -154,12 +172,25 @@ private fun ReaderArticle( .testTag(READER_DOCUMENT_TAG), contentAlignment = Alignment.TopCenter, ) { - Column( - modifier = Modifier.widthIn(max = readerModeMaxContentWidth).fillMaxWidth() - .padding(horizontal = 32.dp).padding(top = 24.dp, bottom = 88.dp), - ) { - ReaderArticleHeader(title = title, siteName = siteName, fontSize = fontSize, lineHeight = lineHeight) - if (body != null) body() else DelayedReaderSpinner(articleKey) + // The article is laid out from its own text, not the app locale, like the web reader's dir='auto'. + val layoutDirection = when (direction) { + ContentDirection.LEFT_TO_RIGHT -> LayoutDirection.Ltr + ContentDirection.RIGHT_TO_LEFT -> LayoutDirection.Rtl + null -> LocalLayoutDirection.current + } + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + Column( + modifier = Modifier.widthIn(max = readerModeMaxContentWidth).fillMaxWidth() + .padding(horizontal = 32.dp).padding(top = 24.dp, bottom = 88.dp), + ) { + ReaderArticleHeader( + title = title, + siteName = siteName, + fontSize = fontSize, + lineHeight = lineHeight, + ) + if (body != null) body() else DelayedReaderSpinner(articleKey) + } } } FeedFlowVerticalScrollbar(scrollState) diff --git a/desktopApp/src/jvmTest/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContentTest.kt b/desktopApp/src/jvmTest/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContentTest.kt index ffd28c267..03a287fb8 100644 --- a/desktopApp/src/jvmTest/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContentTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/prof18/feedflow/desktop/reaadermode/DesktopReaderContentTest.kt @@ -146,6 +146,32 @@ class DesktopReaderContentTest { rule.onAllNodesWithText(" + val bounds = rule.onNodeWithText(text, substring = true).fetchSemanticsNode().boundsInRoot + assertTrue( + bounds.right - bounds.left < document.width / 2, + "Fixture text must stay on one short line, got $bounds in $document", + ) + assertTrue( + bounds.center.x > document.center.x, + "Right-to-left text must start at the right edge, got $bounds in $document", + ) + } + } + private fun showBody(state: ReaderModeState, onOpenInBrowser: (String) -> Unit = {}) { rule.setContent { CompositionLocalProvider(LocalFeedFlowStrings provides EnFeedFlowStrings) { @@ -216,6 +242,8 @@ class DesktopReaderContentTest { private companion object { const val BODY_TEXT = "Content retained from the feed." const val ARTICLE_URL = "https://example.com/article" + const val RTL_TITLE = "خبر فوری" + const val RTL_BODY = "متن کوتاه مقاله" } } diff --git a/shared/src/jvmMain/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdown.kt b/shared/src/jvmMain/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdown.kt index e92c70c30..4fbc5f93c 100644 --- a/shared/src/jvmMain/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdown.kt +++ b/shared/src/jvmMain/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdown.kt @@ -1,5 +1,8 @@ package com.prof18.feedflow.shared.domain +import com.prof18.feedflow.core.utils.ContentDirection +import com.prof18.feedflow.core.utils.ContentDirectionDetector + /** * Builds the Markdown document shown by the Desktop reader: the Markdown counterpart of * [getReaderModeStyledHtml]. [imageUrl] is added as a hero only when the content does not @@ -34,3 +37,34 @@ private val leadingImageRegex = Regex( private fun hasLeadingMarkdownImage(content: String): Boolean = leadingImageRegex.containsMatchIn(content.take(LEADING_IMAGE_SCAN_WINDOW)) + +/** + * Resolves the reading direction of a Desktop reader article, the Markdown counterpart of the + * `dir='auto'` that [getReaderModeStyledHtml] puts on the web reader: the first strong character + * of the title, site name and body decides. Image and link destinations, HTML tags and bare URLs + * are skipped, so a body opening with a hero image is still read from its first word. + */ +fun readerModeContentDirection( + title: String?, + siteName: String?, + markdown: String?, +): ContentDirection? = ContentDirectionDetector.detect( + listOfNotNull(title, siteName, markdown?.take(DIRECTION_SCAN_WINDOW)?.withoutMarkupForDirection()), +) + +private fun String.withoutMarkupForDirection(): String = directionMarkupRegexes.fold(this) { text, regex -> + regex.replace(text, " ") +} + +private const val DIRECTION_SCAN_WINDOW = 8000 + +// A link destination as Klead writes it: parentheses escaped, wrapped in <> when it has spaces, +// optionally followed by a quoted title with escaped quotes. +private const val MARKDOWN_DESTINATION = + """\((?:<(?:\\.|[^>\\])*>|(?:\\.|[^)\\\s])*)(?:\s+"(?:\\.|[^"\\])*")?\)""" +private val directionMarkupRegexes = listOf( + Regex("""!\[[^]]*]$MARKDOWN_DESTINATION"""), + Regex("""]$MARKDOWN_DESTINATION"""), + Regex("<[^>]*>"), + Regex("https?://\\S+", RegexOption.IGNORE_CASE), +) diff --git a/shared/src/jvmTest/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdownTest.kt b/shared/src/jvmTest/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdownTest.kt index cb09111cf..bff735ae1 100644 --- a/shared/src/jvmTest/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdownTest.kt +++ b/shared/src/jvmTest/kotlin/com/prof18/feedflow/shared/domain/ReaderModeMarkdownTest.kt @@ -1,7 +1,9 @@ package com.prof18.feedflow.shared.domain +import com.prof18.feedflow.core.utils.ContentDirection import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class ReaderModeMarkdownTest { @@ -72,4 +74,48 @@ class ReaderModeMarkdownTest { markdown, ) } + + @Test + fun `reader direction follows a right-to-left title`() { + assertEquals( + ContentDirection.RIGHT_TO_LEFT, + readerModeContentDirection(title = "خبر فوری", siteName = null, markdown = "Body"), + ) + } + + @Test + fun `reader direction skips a neutral title and markup before the first word of the body`() { + val markdown = "![hero](https://example.com/hero.jpg)\n\n" + + "

[https://example.com](https://example.com) متن مقاله

" + + assertEquals( + ContentDirection.RIGHT_TO_LEFT, + readerModeContentDirection(title = "2026 — 12:45", siteName = null, markdown = markdown), + ) + } + + @Test + fun `reader direction stays left-to-right for a Latin article`() { + assertEquals( + ContentDirection.LEFT_TO_RIGHT, + readerModeContentDirection(title = "Article title", siteName = null, markdown = "متن"), + ) + } + + @Test + fun `reader direction is unknown without any strong character`() { + assertNull(readerModeContentDirection(title = null, siteName = null, markdown = "![](https://x.com/a.jpg) 42")) + } + + @Test + fun `reader direction skips escaped parentheses angle brackets and titles in Klead destinations`() { + val markdown = "![hero](https://example.com/photo\\(1\\).jpg)\n\n" + + "![]( \"Latin \\\"title\\\"\")\n\n" + + "[](https://example.com/wiki/Iran_\\(country\\) \"Wiki\") متن مقاله" + + assertEquals( + ContentDirection.RIGHT_TO_LEFT, + readerModeContentDirection(title = null, siteName = null, markdown = markdown), + ) + } }