From 76ca32d2083f20756a12523ab0bb741d5d638a88 Mon Sep 17 00:00:00 2001 From: mcuteangel Date: Fri, 28 Aug 2026 05:52:21 +0000 Subject: [PATCH 1/6] fix(ui): derive chat text direction from content for correct RTL/LTR bidi Closes #57 The chat markdown renderer never set textDirection, so Compose fell back to LocalLayoutDirection (driven by the app locale). A Persian message rendered inside an English-locale app was therefore forced LTR and scrambled, including embedded English words / code snippets. - Set ParagraphStyle(textDirection = TextDirection.Content) so each paragraph derives its base direction from the content's first strong character. - Wrap the rendered AnnotatedString in Unicode directional isolates (FSI ... PDI), the Compose/KMP-portable equivalent of BidiFormatter.unicodeWrap(), so embedded opposite-direction runs (and messages starting with a number/code) stay laid out correctly regardless of the app's layout direction. Applied to both the Android chat UI (app/.../ui/MarkdownRenderer.kt) and the shared iOS renderer (shared/.../ui/SharedMarkdownRenderer.kt). Code fences are left untouched (code stays LTR). --- .../com/zhousl/aether/ui/MarkdownRenderer.kt | 26 ++++++++++++++--- .../aether/ui/SharedMarkdownRenderer.kt | 29 +++++++++++++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt index 6eae46e1..c6d69b92 100644 --- a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt +++ b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextDirection import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -1164,6 +1165,23 @@ private fun MarkdownTextHtmlBlock( } @Composable +/** + * Wraps the content in Unicode directional isolates (FSI … PDI) — the Compose/KMP-portable + * equivalent of [androidx.core.text.BidiFormatter.unicodeWrap]. This forces the paragraph's + * base direction to be derived from the content's first strong character, so an embedded + * opposite-direction run (e.g. an English word or code snippet inside a Persian sentence, + * or a message that starts with a number/code) is laid out correctly regardless of the + * app's layout direction. + */ +private fun AnnotatedString.bidiIsolated(): AnnotatedString { + val source = this + return buildAnnotatedString { + append('\u2068') // FSI — First Strong Isolate + append(source) + append('\u2069') // PDI — Pop Directional Isolate + } +} + private fun MarkdownText( text: AnnotatedString, style: androidx.compose.ui.text.TextStyle, @@ -1179,8 +1197,8 @@ private fun MarkdownText( if (!hasLinks) { Text( - text = text, - style = style, + text = text.bidiIsolated(), + style = style.copy(textDirection = TextDirection.Content), color = color, modifier = modifier, ) @@ -1188,8 +1206,8 @@ private fun MarkdownText( } ClickableText( - text = text, - style = style.copy(color = color), + text = text.bidiIsolated(), + style = style.copy(color = color, textDirection = TextDirection.Content), modifier = modifier, ) { offset -> text.getStringAnnotations( diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt index 14eb4af9..4dff0545 100644 --- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt +++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextDirection import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -366,6 +367,23 @@ private fun SharedMarkdownTableRow( } @Composable +/** + * Wraps the content in Unicode directional isolates (FSI … PDI) — the Compose/KMP-portable + * equivalent of [androidx.core.text.BidiFormatter.unicodeWrap]. This forces the paragraph's + * base direction to be derived from the content's first strong character, so an embedded + * opposite-direction run (e.g. an English word or code snippet inside a Persian sentence, + * or a message that starts with a number/code) is laid out correctly regardless of the + * app's layout direction. + */ +private fun AnnotatedString.bidiIsolated(): AnnotatedString { + val source = this + return buildAnnotatedString { + append('\u2068') // FSI — First Strong Isolate + append(source) + append('\u2069') // PDI — Pop Directional Isolate + } +} + private fun SharedMarkdownRichTextBlock( text: SharedMarkdownSourceText, style: TextStyle, @@ -391,12 +409,17 @@ private fun SharedMarkdownRichTextBlock( end = annotated.length, ).isNotEmpty() if (!hasLinks) { - Text(annotated, style = style, color = color, modifier = modifier) + Text( + annotated.bidiIsolated(), + style = style.copy(textDirection = TextDirection.Content), + color = color, + modifier = modifier, + ) } else { @Suppress("DEPRECATION") ClickableText( - text = annotated, - style = style.copy(color = color), + text = annotated.bidiIsolated(), + style = style.copy(color = color, textDirection = TextDirection.Content), modifier = modifier, onClick = { offset -> annotated.getStringAnnotations(SharedMarkdownLinkAnnotationTag, offset, offset) From 2f1bf2e5f688f863fdc7438c618000d41e4abf24 Mon Sep 17 00:00:00 2001 From: mcuteangel Date: Fri, 28 Aug 2026 06:02:11 +0000 Subject: [PATCH 2/6] fix(ui): use isolated AnnotatedString for link hit testing (addresses review) CodeRabbit flagged that bidiIsolated() prepends one UTF-16 code unit, shifting the copied link annotations by one, while ClickableText.onClick queried the original (unshifted) AnnotatedString. That offset mismatch broke link taps (the final char of each link fell outside the queried range). Capture the isolated AnnotatedString once and use it for both the Text/ClickableText content and the getStringAnnotations hit-test in both renderers. --- app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt | 7 ++++--- .../kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt index c6d69b92..10f93b37 100644 --- a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt +++ b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt @@ -1189,6 +1189,7 @@ private fun MarkdownText( modifier: Modifier = Modifier, onLinkClick: (String) -> Unit, ) { + val isolated = text.bidiIsolated() val hasLinks = text.getStringAnnotations( tag = LinkAnnotationTag, start = 0, @@ -1197,7 +1198,7 @@ private fun MarkdownText( if (!hasLinks) { Text( - text = text.bidiIsolated(), + text = isolated, style = style.copy(textDirection = TextDirection.Content), color = color, modifier = modifier, @@ -1206,11 +1207,11 @@ private fun MarkdownText( } ClickableText( - text = text.bidiIsolated(), + text = isolated, style = style.copy(color = color, textDirection = TextDirection.Content), modifier = modifier, ) { offset -> - text.getStringAnnotations( + isolated.getStringAnnotations( tag = LinkAnnotationTag, start = offset, end = offset, diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt index 4dff0545..347b029e 100644 --- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt +++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt @@ -403,6 +403,7 @@ private fun SharedMarkdownRichTextBlock( return } val annotated = sharedInlineMarkdown(text, fadeSpan) + val isolated = annotated.bidiIsolated() val hasLinks = annotated.getStringAnnotations( tag = SharedMarkdownLinkAnnotationTag, start = 0, @@ -410,7 +411,7 @@ private fun SharedMarkdownRichTextBlock( ).isNotEmpty() if (!hasLinks) { Text( - annotated.bidiIsolated(), + isolated, style = style.copy(textDirection = TextDirection.Content), color = color, modifier = modifier, @@ -418,11 +419,11 @@ private fun SharedMarkdownRichTextBlock( } else { @Suppress("DEPRECATION") ClickableText( - text = annotated.bidiIsolated(), + text = isolated, style = style.copy(color = color, textDirection = TextDirection.Content), modifier = modifier, onClick = { offset -> - annotated.getStringAnnotations(SharedMarkdownLinkAnnotationTag, offset, offset) + isolated.getStringAnnotations(SharedMarkdownLinkAnnotationTag, offset, offset) .firstOrNull()?.let { onOpenLink(it.item) } }, ) From af1086b6e291399be28fd0692fcf9bf110af8d96 Mon Sep 17 00:00:00 2001 From: mcuteangel Date: Fri, 28 Aug 2026 07:16:37 +0000 Subject: [PATCH 3/6] fix(ui): place @Composable directly on renderers (addresses review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bidiIsolated() helper was inserted between the existing @Composable annotation and the renderer declaration, so by Kotlin grammar the annotation attached to bidiIsolated() and the renderer (MarkdownText / SharedMarkdownRichTextBlock) lost its @Composable context — which would fail to compile since they call Text/ClickableText. Move @Composable to sit directly above each renderer and keep bidiIsolated() as a plain (non-composable) AnnotatedString extension. --- app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt | 2 +- .../kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt index 10f93b37..2d0b6790 100644 --- a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt +++ b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt @@ -1164,7 +1164,6 @@ private fun MarkdownTextHtmlBlock( ) } -@Composable /** * Wraps the content in Unicode directional isolates (FSI … PDI) — the Compose/KMP-portable * equivalent of [androidx.core.text.BidiFormatter.unicodeWrap]. This forces the paragraph's @@ -1182,6 +1181,7 @@ private fun AnnotatedString.bidiIsolated(): AnnotatedString { } } +@Composable private fun MarkdownText( text: AnnotatedString, style: androidx.compose.ui.text.TextStyle, diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt index 347b029e..bf8d5654 100644 --- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt +++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt @@ -366,7 +366,6 @@ private fun SharedMarkdownTableRow( } } -@Composable /** * Wraps the content in Unicode directional isolates (FSI … PDI) — the Compose/KMP-portable * equivalent of [androidx.core.text.BidiFormatter.unicodeWrap]. This forces the paragraph's @@ -384,6 +383,7 @@ private fun AnnotatedString.bidiIsolated(): AnnotatedString { } } +@Composable private fun SharedMarkdownRichTextBlock( text: SharedMarkdownSourceText, style: TextStyle, From 155db34a02b65aee8cfdbdf0d057f080048e3071 Mon Sep 17 00:00:00 2001 From: mcuteangel Date: Fri, 28 Aug 2026 07:29:30 +0000 Subject: [PATCH 4/6] fix(ui): isolate inline code spans with LRI/PDI (addresses review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline code inside an RTL message should render as a stable LTR run so its characters/punctuation are not reordered by the surrounding RTL context. Wrap each backtick code span in a directional isolate (LRI U+2066 … PDI U+2069) in the inline-markdown builders for both the Android chat UI and the shared iOS renderer. The outer FSI/PDI paragraph isolate from bidiIsolated() is preserved. The review's line reference pointed at bidiIsolated(); the actual inline-code styling lives in inlineMarkdown()/sharedInlineMarkdown(), which is where this change is applied. Regression UI tests (RTL locale + inline code) would require instrumented Compose tests/emulator and are not added here. --- app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt | 2 ++ .../kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt index 2d0b6790..c9cf098f 100644 --- a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt +++ b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt @@ -2015,6 +2015,7 @@ private fun AnnotatedString.Builder.appendInline( if (text.startsWith("`", index)) { val end = text.indexOf('`', index + 1) if (end > index + 1) { + append('\u2066') // LRI — isolate inline code as a stable LTR run pushStyle( SpanStyle( fontFamily = FontFamily.Monospace, @@ -2027,6 +2028,7 @@ private fun AnnotatedString.Builder.appendInline( fadeSpan = fadeSpan, ) pop() + append('\u2069') // PDI index = end + 1 continue } diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt index bf8d5654..4b1df860 100644 --- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt +++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt @@ -758,9 +758,11 @@ private fun AnnotatedString.Builder.appendSharedInline( if (text[index] == '`') { val end = text.indexOf('`', index + 1) if (end > index + 1) { + append('\u2066') // LRI — isolate inline code as a stable LTR run pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = AetherSurfaceHigh)) appendSharedSourceSegment(text.substring(index + 1, end), sourceOffset + index + 1, fadeSpan) pop() + append('\u2069') // PDI index = end + 1 continue } From f6244ca268bdd1f439be7758ee374e665ad1a9be Mon Sep 17 00:00:00 2001 From: BaimoQilin Date: Sat, 29 Aug 2026 20:47:34 +0800 Subject: [PATCH 5/6] fix(ui): keep content-directed markdown copy-safe --- .../com/zhousl/aether/ui/MarkdownRenderer.kt | 30 ++++--------------- .../zhousl/aether/ui/MarkdownRendererTest.kt | 12 ++++++++ .../aether/ui/SharedMarkdownRenderer.kt | 30 ++++--------------- .../aether/ui/SharedMarkdownRendererTest.kt | 11 +++++++ 4 files changed, 33 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt index c9cf098f..48228da5 100644 --- a/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt +++ b/app/src/main/java/com/zhousl/aether/ui/MarkdownRenderer.kt @@ -58,12 +58,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextDirection import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -1164,23 +1164,6 @@ private fun MarkdownTextHtmlBlock( ) } -/** - * Wraps the content in Unicode directional isolates (FSI … PDI) — the Compose/KMP-portable - * equivalent of [androidx.core.text.BidiFormatter.unicodeWrap]. This forces the paragraph's - * base direction to be derived from the content's first strong character, so an embedded - * opposite-direction run (e.g. an English word or code snippet inside a Persian sentence, - * or a message that starts with a number/code) is laid out correctly regardless of the - * app's layout direction. - */ -private fun AnnotatedString.bidiIsolated(): AnnotatedString { - val source = this - return buildAnnotatedString { - append('\u2068') // FSI — First Strong Isolate - append(source) - append('\u2069') // PDI — Pop Directional Isolate - } -} - @Composable private fun MarkdownText( text: AnnotatedString, @@ -1189,7 +1172,6 @@ private fun MarkdownText( modifier: Modifier = Modifier, onLinkClick: (String) -> Unit, ) { - val isolated = text.bidiIsolated() val hasLinks = text.getStringAnnotations( tag = LinkAnnotationTag, start = 0, @@ -1198,7 +1180,7 @@ private fun MarkdownText( if (!hasLinks) { Text( - text = isolated, + text = text, style = style.copy(textDirection = TextDirection.Content), color = color, modifier = modifier, @@ -1207,11 +1189,11 @@ private fun MarkdownText( } ClickableText( - text = isolated, + text = text, style = style.copy(color = color, textDirection = TextDirection.Content), modifier = modifier, ) { offset -> - isolated.getStringAnnotations( + text.getStringAnnotations( tag = LinkAnnotationTag, start = offset, end = offset, @@ -1963,7 +1945,7 @@ private fun parseInlineMarkdownLink( ) } -private fun inlineMarkdown( +internal fun inlineMarkdown( text: String, sourceOffset: Int, fadeSpan: MarkdownFadeSpan?, @@ -2015,7 +1997,6 @@ private fun AnnotatedString.Builder.appendInline( if (text.startsWith("`", index)) { val end = text.indexOf('`', index + 1) if (end > index + 1) { - append('\u2066') // LRI — isolate inline code as a stable LTR run pushStyle( SpanStyle( fontFamily = FontFamily.Monospace, @@ -2028,7 +2009,6 @@ private fun AnnotatedString.Builder.appendInline( fadeSpan = fadeSpan, ) pop() - append('\u2069') // PDI index = end + 1 continue } diff --git a/app/src/test/java/com/zhousl/aether/ui/MarkdownRendererTest.kt b/app/src/test/java/com/zhousl/aether/ui/MarkdownRendererTest.kt index f0619b3b..7b116a68 100644 --- a/app/src/test/java/com/zhousl/aether/ui/MarkdownRendererTest.kt +++ b/app/src/test/java/com/zhousl/aether/ui/MarkdownRendererTest.kt @@ -8,6 +8,18 @@ import org.junit.Assert.assertTrue import org.junit.Test class MarkdownRendererTest { + @Test + fun inlineMarkdownDoesNotInjectBidiControlsIntoSelectableText() { + val rendered = inlineMarkdown( + text = "سلام `printf('%s', value)` دنیا", + sourceOffset = 0, + fadeSpan = null, + ) + + assertEquals("سلام printf('%s', value) دنیا", rendered.text) + assertFalse(rendered.text.any { it in "\u2066\u2067\u2068\u2069" }) + } + @Test(timeout = 1_000L) fun parseMarkdownTreatsIncompleteTableLineAsParagraph() { val blocks = parseMarkdownBlocks("| Effort | Share |") diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt index 4b1df860..3bc999ea 100644 --- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt +++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/SharedMarkdownRenderer.kt @@ -29,12 +29,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextDirection import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.zhousl.aether.platform.PlatformWebView @@ -366,23 +366,6 @@ private fun SharedMarkdownTableRow( } } -/** - * Wraps the content in Unicode directional isolates (FSI … PDI) — the Compose/KMP-portable - * equivalent of [androidx.core.text.BidiFormatter.unicodeWrap]. This forces the paragraph's - * base direction to be derived from the content's first strong character, so an embedded - * opposite-direction run (e.g. an English word or code snippet inside a Persian sentence, - * or a message that starts with a number/code) is laid out correctly regardless of the - * app's layout direction. - */ -private fun AnnotatedString.bidiIsolated(): AnnotatedString { - val source = this - return buildAnnotatedString { - append('\u2068') // FSI — First Strong Isolate - append(source) - append('\u2069') // PDI — Pop Directional Isolate - } -} - @Composable private fun SharedMarkdownRichTextBlock( text: SharedMarkdownSourceText, @@ -403,7 +386,6 @@ private fun SharedMarkdownRichTextBlock( return } val annotated = sharedInlineMarkdown(text, fadeSpan) - val isolated = annotated.bidiIsolated() val hasLinks = annotated.getStringAnnotations( tag = SharedMarkdownLinkAnnotationTag, start = 0, @@ -411,7 +393,7 @@ private fun SharedMarkdownRichTextBlock( ).isNotEmpty() if (!hasLinks) { Text( - isolated, + annotated, style = style.copy(textDirection = TextDirection.Content), color = color, modifier = modifier, @@ -419,11 +401,11 @@ private fun SharedMarkdownRichTextBlock( } else { @Suppress("DEPRECATION") ClickableText( - text = isolated, + text = annotated, style = style.copy(color = color, textDirection = TextDirection.Content), modifier = modifier, onClick = { offset -> - isolated.getStringAnnotations(SharedMarkdownLinkAnnotationTag, offset, offset) + annotated.getStringAnnotations(SharedMarkdownLinkAnnotationTag, offset, offset) .firstOrNull()?.let { onOpenLink(it.item) } }, ) @@ -718,7 +700,7 @@ private fun splitSharedMarkdownTableCellsWithOffsets(line: String): List index + 1) { - append('\u2066') // LRI — isolate inline code as a stable LTR run pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = AetherSurfaceHigh)) appendSharedSourceSegment(text.substring(index + 1, end), sourceOffset + index + 1, fadeSpan) pop() - append('\u2069') // PDI index = end + 1 continue } diff --git a/shared/src/iosTest/kotlin/com/zhousl/aether/ui/SharedMarkdownRendererTest.kt b/shared/src/iosTest/kotlin/com/zhousl/aether/ui/SharedMarkdownRendererTest.kt index b3db2bb3..06b0790b 100644 --- a/shared/src/iosTest/kotlin/com/zhousl/aether/ui/SharedMarkdownRendererTest.kt +++ b/shared/src/iosTest/kotlin/com/zhousl/aether/ui/SharedMarkdownRendererTest.kt @@ -9,6 +9,17 @@ import kotlin.test.assertIs import kotlin.test.assertTrue class SharedMarkdownRendererTest { + @Test + fun inlineMarkdownDoesNotInjectBidiControlsIntoSelectableText() { + val rendered = sharedInlineMarkdown( + source = SharedMarkdownSourceText("سلام `printf('%s', value)` دنیا", 0), + fadeSpan = null, + ) + + assertEquals("سلام printf('%s', value) دنیا", rendered.text) + assertFalse(rendered.text.any { it in "\u2066\u2067\u2068\u2069" }) + } + @Test fun incompleteTableLineRemainsParagraphWhileStreaming() { val blocks = parseSharedMarkdownTextBlocks("| Effort | Share |") From a276a93a8c91a315559d2ec38924a49e1b00f92d Mon Sep 17 00:00:00 2001 From: BaimoQilin Date: Sun, 30 Aug 2026 18:25:13 +0800 Subject: [PATCH 6/6] fix(ios): localize native settings in Persian --- iosApp/App/Settings/NativeSettingsView.swift | 263 ++++++++++++++++-- iosApp/Tests/Runtime/AetherRuntimeTests.swift | 10 + 2 files changed, 257 insertions(+), 16 deletions(-) diff --git a/iosApp/App/Settings/NativeSettingsView.swift b/iosApp/App/Settings/NativeSettingsView.swift index 6e048bc6..94be0d6e 100644 --- a/iosApp/App/Settings/NativeSettingsView.swift +++ b/iosApp/App/Settings/NativeSettingsView.swift @@ -4,6 +4,214 @@ import Foundation import SwiftUI import UIKit +private let nativeSettingsPersianTranslations: [String: String] = [ + "API key": "کلید API", + "About": "درباره", + "Account authorized.": "حساب تأیید شد.", + "Add": "افزودن", + "Add Provider": "افزودن ارائه‌دهنده", + "Add Skill": "افزودن مهارت", + "Add a provider to choose models for Aether.": "برای انتخاب مدل‌های Aether یک ارائه‌دهنده اضافه کنید.", + "Add provider": "افزودن ارائه‌دهنده", + "Advanced settings": "تنظیمات پیشرفته", + "Agent Skills": "مهارت‌های عامل", + "Allowed tools": "ابزارهای مجاز", + "Any": "همه", + "App data": "داده‌های برنامه", + "Authenticate": "احراز هویت", + "Authentication": "احراز هویت", + "Author": "نویسنده", + "Automatic": "خودکار", + "Automatically clean old history": "پاک‌سازی خودکار تاریخچه قدیمی", + "Available Models": "مدل‌های موجود", + "Average latency": "میانگین تأخیر", + "Average speed": "میانگین سرعت", + "Average turn": "میانگین هر نوبت", + "Back": "بازگشت", + "Back up or restore settings, providers, skills, and conversations.": "از تنظیمات، ارائه‌دهندگان، مهارت‌ها و گفتگوها پشتیبان بگیرید یا آن‌ها را بازیابی کنید.", + "Base URL": "نشانی پایه", + "Browser login": "ورود با مرورگر", + "Cancel": "لغو", + "Check your connection and try again.": "اتصال خود را بررسی کرده و دوباره تلاش کنید.", + "Choose Folder": "انتخاب پوشه", + "Choose Zip": "انتخاب فایل Zip", + "Choose a login method and finish authorization.": "یک روش ورود انتخاب کرده و مجوزدهی را کامل کنید.", + "Choose a provider": "انتخاب ارائه‌دهنده", + "Choose backup": "انتخاب نسخه پشتیبان", + "Choose models": "انتخاب مدل‌ها", + "Choose the language used throughout Aether.": "زبان مورد استفاده در سراسر Aether را انتخاب کنید.", + "Command history": "تاریخچه فرمان‌ها", + "Compatibility": "سازگاری", + "Compatibility warning": "هشدار سازگاری", + "Configure credentials provided by the runtime environment.": "اعتبارنامه‌های ارائه‌شده توسط محیط اجرا را پیکربندی کنید.", + "Connect your provider": "اتصال ارائه‌دهنده", + "Connect your subscription account to continue.": "برای ادامه، حساب اشتراک خود را متصل کنید.", + "Connection": "اتصال", + "Continue": "ادامه", + "Continue authentication": "ادامه احراز هویت", + "Copy": "کپی", + "Credentials are read from the provider environment.": "اعتبارنامه‌ها از محیط ارائه‌دهنده خوانده می‌شوند.", + "Custom headers": "سرآیندهای سفارشی", + "Custom instructions": "دستورالعمل‌های سفارشی", + "Dark": "تیره", + "Data and diagnostics": "داده‌ها و عیب‌یابی", + "Day": "روز", + "Default Models": "مدل‌های پیش‌فرض", + "Delete": "حذف", + "Dependencies": "وابستگی‌ها", + "Developer": "توسعه‌دهنده", + "Developer message fallbacks": "جایگزین‌های پیام توسعه‌دهنده", + "Device code login": "ورود با کد دستگاه", + "Diagnostics": "عیب‌یابی", + "Disconnect": "قطع اتصال", + "Discover": "کاوش", + "Done": "تمام", + "Downloads": "دانلودها", + "Edit": "ویرایش", + "Edit Provider": "ویرایش ارائه‌دهنده", + "Enabled": "فعال", + "Enter an API key or use the provider's credential flow.": "یک کلید API وارد کنید یا از فرایند اعتبارنامه ارائه‌دهنده استفاده کنید.", + "Environment": "محیط", + "Environment Presets": "پیش‌تنظیم‌های محیط", + "Environment variables": "متغیرهای محیطی", + "Existing local data will be replaced by the selected backup.": "داده‌های محلی موجود با نسخه پشتیبان انتخاب‌شده جایگزین می‌شوند.", + "Export": "خروجی گرفتن", + "Export app data": "خروجی گرفتن از داده‌های برنامه", + "Export app data?": "از داده‌های برنامه خروجی گرفته شود؟", + "Export logs": "خروجی گرفتن از گزارش‌ها", + "Failed": "ناموفق", + "Fetch models": "دریافت مدل‌ها", + "Files": "فایل‌ها", + "General Settings": "تنظیمات عمومی", + "Git & Search Tools": "ابزارهای Git و جستجو", + "History": "تاریخچه", + "How do you want to authenticate?": "چگونه می‌خواهید احراز هویت کنید؟", + "Idle timeout": "مهلت بی‌کاری", + "Import Extension": "وارد کردن افزونه", + "Import app data": "وارد کردن داده‌های برنامه", + "Import app data?": "داده‌های برنامه وارد شوند؟", + "Initialize": "راه‌اندازی", + "Input": "ورودی", + "Install": "نصب", + "Install common development tools into the Alpine environment.": "ابزارهای رایج توسعه را در محیط Alpine نصب کنید.", + "Install from URL": "نصب از نشانی اینترنتی", + "Installed": "نصب‌شده", + "Installing...": "در حال نصب...", + "Keep tasks running in background": "ادامه اجرای وظایف در پس‌زمینه", + "Language": "زبان", + "Largest turn": "بزرگ‌ترین نوبت", + "License": "مجوز", + "Light": "روشن", + "Loading extensions...": "در حال بارگیری افزونه‌ها...", + "Loading package...": "در حال بارگیری بسته...", + "Local": "محلی", + "Manual model IDs, one per line": "شناسه‌های مدل به‌صورت دستی، هر کدام در یک خط", + "Method": "روش", + "Model Providers": "ارائه‌دهندگان مدل", + "Models": "مدل‌ها", + "Multitasking": "چندوظیفگی", + "Name": "نام", + "No Pi extensions installed": "هیچ افزونه Pi نصب نشده است", + "No extensions found": "افزونه‌ای یافت نشد", + "No models were returned. Add a model ID above or try again.": "مدلی دریافت نشد. یک شناسه مدل در بالا اضافه کنید یا دوباره تلاش کنید.", + "No providers": "ارائه‌دهنده‌ای وجود ندارد", + "No skills installed": "هیچ مهارتی نصب نشده است", + "Node Environment": "محیط Node", + "Not installed": "نصب نشده", + "Notify when tasks finish": "اعلان پس از پایان وظایف", + "Open": "باز کردن", + "Open authorization page": "باز کردن صفحه مجوزدهی", + "Open verification page": "باز کردن صفحه تأیید", + "Output": "خروجی", + "Overview": "نمای کلی", + "Package Information": "اطلاعات بسته", + "Package details unavailable": "جزئیات بسته در دسترس نیست", + "Path": "مسیر", + "Peak day": "روز اوج", + "Personalization": "شخصی‌سازی", + "Pi Extensions": "افزونه‌های Pi", + "Privacy Policy": "سیاست حفظ حریم خصوصی", + "Prompt extensions may not work in this environment.": "ممکن است افزونه‌های پرامپت در این محیط کار نکنند.", + "Provider": "ارائه‌دهنده", + "Provider ID": "شناسه ارائه‌دهنده", + "Provider ID is required.": "شناسه ارائه‌دهنده الزامی است.", + "Published": "منتشرشده", + "Python Environment": "محیط Python", + "Ready": "آماده", + "Reasoning": "استدلال", + "Recent 7 days": "۷ روز اخیر", + "Recent speed": "سرعت اخیر", + "Reconnect": "اتصال مجدد", + "Recorded turns": "نوبت‌های ثبت‌شده", + "Refresh": "تازه‌سازی", + "Reinstall": "نصب مجدد", + "Release": "نسخه انتشار", + "Reliability": "پایداری", + "Remote": "راه دور", + "Remove": "حذف", + "Remove Skill": "حذف مهارت", + "Replay Alpine setup preview": "نمایش دوباره پیش‌نمایش راه‌اندازی Alpine", + "Replay follow-up onboarding": "نمایش دوباره راهنمای پیگیری", + "Repository": "مخزن", + "Reset": "بازنشانی", + "Reset Alpine Data": "بازنشانی داده‌های Alpine", + "Reset Alpine data?": "داده‌های Alpine بازنشانی شوند؟", + "Retention": "مدت نگهداری", + "Retry": "تلاش دوباره", + "SSH Tools": "ابزارهای SSH", + "Sample": "نمونه", + "Save": "ذخیره", + "Search providers": "جستجوی ارائه‌دهندگان", + "Sessions": "جلسه‌ها", + "Settings": "تنظیمات", + "Setup previews": "پیش‌نمایش‌های راه‌اندازی", + "Setup required": "نیازمند راه‌اندازی", + "Sign in with an account or subscription.": "با یک حساب یا اشتراک وارد شوید.", + "Sign in with browser": "ورود با مرورگر", + "Sign in with device code": "ورود با کد دستگاه", + "Size": "اندازه", + "Source": "منبع", + "Status": "وضعیت", + "Step": "مرحله", + "Submit": "ارسال", + "Subscription": "اشتراک", + "System": "سیستم", + "Terminal": "پایانه", + "The Alpine environment stays inside Aether's private app storage.": "محیط Alpine در فضای ذخیره‌سازی خصوصی Aether باقی می‌ماند.", + "The backup may contain conversation content and API credentials. Store it securely.": "نسخه پشتیبان ممکن است شامل محتوای گفتگو و اعتبارنامه‌های API باشد. آن را در مکانی امن نگه دارید.", + "The export redacts stored credentials and includes runtime diagnostics.": "خروجی، اعتبارنامه‌های ذخیره‌شده را حذف کرده و اطلاعات عیب‌یابی زمان اجرا را در بر می‌گیرد.", + "Theme": "پوسته", + "Theme extensions are not supported here.": "افزونه‌های پوسته در اینجا پشتیبانی نمی‌شوند.", + "These instructions are included when Aether starts an agent turn.": "این دستورالعمل‌ها هنگام آغاز یک نوبت عامل توسط Aether اضافه می‌شوند.", + "These providers rejected the developer message role, so Aether uses system messages for them. Turn a provider off to try developer messages again.": "این ارائه‌دهندگان نقش پیام توسعه‌دهنده را نپذیرفتند، بنابراین Aether برای آن‌ها از پیام‌های سیستم استفاده می‌کند. برای امتحان دوباره پیام‌های توسعه‌دهنده، ارائه‌دهنده را غیرفعال کنید.", + "This extension may not be compatible with the current platform.": "ممکن است این افزونه با پلتفرم فعلی سازگار نباشد.", + "This extension requires interactive terminal UI features.": "این افزونه به قابلیت‌های تعاملی رابط پایانه نیاز دارد.", + "This provider ID is already in use.": "این شناسه ارائه‌دهنده قبلاً استفاده شده است.", + "This removes the runtime and all files stored inside it.": "این کار محیط اجرا و همه فایل‌های ذخیره‌شده در آن را حذف می‌کند.", + "Token and session activity": "فعالیت توکن‌ها و جلسه‌ها", + "Token history": "تاریخچه توکن‌ها", + "Token mix": "ترکیب توکن‌ها", + "Total tokens": "مجموع توکن‌ها", + "Type": "نوع", + "Types": "انواع", + "Unavailable": "در دسترس نیست", + "Update": "به‌روزرسانی", + "Updating skills...": "در حال به‌روزرسانی مهارت‌ها...", + "Usage Statistics": "آمار استفاده", + "Use a provider-issued API key.": "از کلید API صادرشده توسط ارائه‌دهنده استفاده کنید.", + "Use as Default Runtime": "استفاده به‌عنوان محیط اجرای پیش‌فرض", + "Use credentials already available in the runtime.": "از اعتبارنامه‌های موجود در محیط اجرا استفاده کنید.", + "Use lowercase letters, numbers, and underscores only.": "فقط از حروف کوچک، اعداد و زیرخط استفاده کنید.", + "Use the system appearance or choose a fixed theme.": "از ظاهر سیستم استفاده کنید یا یک پوسته ثابت برگزینید.", + "User agent": "عامل کاربر", + "Value": "مقدار", + "Version": "نسخه", + "Version ": "نسخه ", + "View": "نمایش", + "Website": "وب‌سایت", + "Working...": "در حال انجام...", +] + @MainActor final class NativeSettingsModel: NSObject, ObservableObject, @preconcurrency IosNativeSettingsListener { @Published var snapshot: [String: Any] = [:] @@ -48,7 +256,7 @@ final class NativeSettingsModel: NSObject, ObservableObject, @preconcurrency Ios func text(_ english: String, _ chinese: String, _ persian: String? = nil) -> String { switch language { case "zh-CN": chinese - case "fa": persian ?? english + case "fa": persian ?? nativeSettingsPersianTranslations[english] ?? english default: english } } @@ -160,6 +368,7 @@ struct NativeSettingsView: View { } } .preferredColorScheme(preferredColorScheme) + .environment(\.layoutDirection, model.language == "fa" ? .rightToLeft : .leftToRight) } private var preferredColorScheme: ColorScheme? { @@ -177,7 +386,7 @@ struct NativeSettingsView: View { private var providerSummary: String { let enabled = model.providers.filter { model.bool($0, "isEnabled", fallback: true) } if enabled.count == 1 { return model.string(enabled[0], "name") } - return model.text("\(enabled.count) enabled", "已启用 \(enabled.count) 个") + return model.text("\(enabled.count) enabled", "已启用 \(enabled.count) 个", "\(enabled.count) فعال") } private var personalizationSummary: String { @@ -188,13 +397,14 @@ struct NativeSettingsView: View { private var reliabilitySummary: String { model.text( "Reconnect after \(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) seconds", - "闲置 \(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) 秒后重连" + "闲置 \(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) 秒后重连", + "اتصال مجدد پس از \(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) ثانیه" ) } private var extensionSummary: String { let count = (model.piExtensions["installed"] as? [[String: Any]] ?? []).count - return model.text("\(count) installed", "已安装 \(count) 个") + return model.text("\(count) installed", "已安装 \(count) 个", "\(count) نصب‌شده") } private var alpineSummary: String { @@ -341,7 +551,11 @@ private struct NativeReliabilitySettingsView: View { Stepper(value: intBinding("llmInactivityReconnectTimeoutSeconds", 30...3600), in: 30...3600, step: 30) { LabeledContent( model.text("Idle timeout", "闲置超时"), - value: model.text("\(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) sec", "\(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) 秒") + value: model.text( + "\(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) sec", + "\(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) 秒", + "\(model.settingInt("llmInactivityReconnectTimeoutSeconds", fallback: 360)) ثانیه" + ) ) } } @@ -416,7 +630,11 @@ private struct NativeProviderListView: View { private func modelSummary(_ provider: [String: Any]) -> String { let available = Set(((provider["cachedModels"] as? [String]) ?? []) + ((provider["manualModelIds"] as? [String]) ?? [])) let enabled = (provider["enabledModelIds"] as? [String] ?? []).filter(available.contains).count - return model.text("\(enabled) of \(available.count) models enabled", "已启用 \(enabled)/\(available.count) 个模型") + return model.text( + "\(enabled) of \(available.count) models enabled", + "已启用 \(enabled)/\(available.count) 个模型", + "\(enabled) مدل از \(available.count) مدل فعال است" + ) } private func deleteButton(_ provider: [String: Any]) -> some View { @@ -453,7 +671,9 @@ private struct NativeDefaultModelsView: View { let automatic = model.snapshot["automaticModels"] as? [String: Any] ?? [:] let resolved = automatic[purpose] as? [String: Any] ?? [:] let label = model.string(resolved, "label") - return label.isEmpty ? model.text("Automatic", "自动") : model.text("Automatic: \(label)", "自动:\(label)") + return label.isEmpty + ? model.text("Automatic", "自动") + : model.text("Automatic: \(label)", "自动:\(label)", "خودکار: \(label)") } return model.modelOptions.first { model.string($0, "key") == selected }.map { model.string($0, "fullLabel") } ?? model.text("Unavailable", "不可用") @@ -751,7 +971,11 @@ private struct NativeProviderEditor: View { TextEditor(text: $draft.modelIDs).frame(minHeight: 80) } } header: { wizardHeader(3) } footer: { - Text(model.text("Select the models Aether can use with \(model.string(definition, "displayName")).", "选择 Aether 可通过 \(model.string(definition, "displayName")) 使用的模型。")) + Text(model.text( + "Select the models Aether can use with \(model.string(definition, "displayName")).", + "选择 Aether 可通过 \(model.string(definition, "displayName")) 使用的模型。", + "مدل‌هایی را انتخاب کنید که Aether می‌تواند با \(model.string(definition, "displayName")) استفاده کند." + )) } Section(model.text("Available Models", "可用模型")) { Button { fetchModels() } label: { @@ -816,7 +1040,7 @@ private struct NativeProviderEditor: View { private func wizardHeader(_ stage: Int) -> some View { VStack(alignment: .leading, spacing: 5) { - Text(model.text("Step \(stage + 1) of 4", "第 \(stage + 1) 步,共 4 步")) + Text(model.text("Step \(stage + 1) of 4", "第 \(stage + 1) 步,共 4 步", "مرحله \(stage + 1) از ۴")) .font(.subheadline) .foregroundStyle(.secondary) .textCase(nil) @@ -915,7 +1139,7 @@ private struct NativeProviderEditor: View { return model.string(relevantAuth, "statusMessage") } if let account = oauthAccountLabel { - return model.text("Connected as \(account)", "已授权账号:\(account)") + return model.text("Connected as \(account)", "已授权账号:\(account)", "متصل به‌عنوان \(account)") } return draft.oauthCredentialJson.isEmpty ? model.text("Connect your subscription account to continue.", "连接订阅账户后继续。") @@ -1366,13 +1590,13 @@ private struct NativeExtensionsOverview: View { else if !model.string(model.piExtensions, "message").isEmpty { Section { Text(model.string(model.piExtensions, "message")).foregroundStyle(.secondary) } } } private func resources(_ item: [String: Any]) -> String { - [model.text("\(model.int(item, "extensionCount")) extensions", "\(model.int(item, "extensionCount")) 个 Extension"), - model.text("\(model.int(item, "skillCount")) skills", "\(model.int(item, "skillCount")) 个 Skill"), - model.text("\(model.int(item, "promptCount")) prompts", "\(model.int(item, "promptCount")) 个 Prompt")].joined(separator: " · ") + [model.text("\(model.int(item, "extensionCount")) extensions", "\(model.int(item, "extensionCount")) 个 Extension", "\(model.int(item, "extensionCount")) افزونه"), + model.text("\(model.int(item, "skillCount")) skills", "\(model.int(item, "skillCount")) 个 Skill", "\(model.int(item, "skillCount")) مهارت"), + model.text("\(model.int(item, "promptCount")) prompts", "\(model.int(item, "promptCount")) 个 Prompt", "\(model.int(item, "promptCount")) پرامپت")].joined(separator: " · ") } private func downloads(_ item: [String: Any]) -> String { let value = (item["monthlyDownloads"] as? NSNumber)?.int64Value ?? 0 - return model.text("\(value) monthly", "每月 \(value) 次") + return model.text("\(value) monthly", "每月 \(value) 次", "ماهانه \(value)") } } @@ -2114,7 +2338,14 @@ private struct NativeDeveloperSettingsView: View { get: { model.settingInt("oldCommandHistoryRetentionHours", fallback: 6) }, set: { model.patch(["oldCommandHistoryRetentionHours": $0]) } ), in: 1...168) { - LabeledContent(model.text("Retention", "保留时间"), value: model.text("\(model.settingInt("oldCommandHistoryRetentionHours", fallback: 6)) hours", "\(model.settingInt("oldCommandHistoryRetentionHours", fallback: 6)) 小时")) + LabeledContent( + model.text("Retention", "保留时间"), + value: model.text( + "\(model.settingInt("oldCommandHistoryRetentionHours", fallback: 6)) hours", + "\(model.settingInt("oldCommandHistoryRetentionHours", fallback: 6)) 小时", + "\(model.settingInt("oldCommandHistoryRetentionHours", fallback: 6)) ساعت" + ) + ) } } } @@ -2152,7 +2383,7 @@ private struct NativeAboutSettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .padding(.top, 24) Text("Aether").font(.largeTitle.bold()) - Text(model.text("Release \(version)", "正式版 \(version)")) + Text(model.text("Release \(version)", "正式版 \(version)", "نسخه \(version)")) .font(.subheadline).foregroundStyle(.secondary) VStack(spacing: 0) { aboutRow(model.text("Author", "作者"), "Zhou-Shilin") diff --git a/iosApp/Tests/Runtime/AetherRuntimeTests.swift b/iosApp/Tests/Runtime/AetherRuntimeTests.swift index 8347a259..0e645733 100644 --- a/iosApp/Tests/Runtime/AetherRuntimeTests.swift +++ b/iosApp/Tests/Runtime/AetherRuntimeTests.swift @@ -9,6 +9,16 @@ final class AetherRuntimeTests: XCTestCase { continueAfterFailure = false } + @MainActor + func testNativeSettingsUsesPersianTranslations() { + let model = NativeSettingsModel() + model.snapshot = ["settings": ["language": "fa"]] + + XCTAssertEqual(model.text("Settings", "设置"), "تنظیمات") + XCTAssertEqual(model.text("Model Providers", "模型提供商"), "ارائه‌دهندگان مدل") + XCTAssertEqual(model.text("2 installed", "已安装 2 个", "2 نصب‌شده"), "2 نصب‌شده") + } + func testInternetPermissionProbeUsesFiniteHeadRequest() throws { let request = makeInternetPermissionRequest()