From 06cbb59c57f92347f3e030526ef1d095bba23060 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 25 Jun 2026 10:34:29 +0000 Subject: [PATCH] fix: RTL text rendering for Arabic, Hebrew, and other right-to-left scripts - MarqueeTextView: Detect RTL content via isRTLUnicodeScalar(), flip VStack alignment to .trailing for RTL, and set .environment(\.layoutDirection) so word flow lines render right-to-left. - HighlightingTextEditor: Add isRTLUnicodeScalar() and containsRTLText() helpers, plus updateWritingDirection() that sets textView.baseWritingDirection to .rightToLeft when RTL content is detected, ensuring proper caret and text alignment in the editor. Fixes garbled/truncated display when dictating or editing Arabic, Hebrew, Persian, Urdu, and other RTL text. --- .../Textream/HighlightingTextEditor.swift | 59 ++++++++++++++++++- Textream/Textream/MarqueeTextView.swift | 31 +++++----- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/Textream/Textream/HighlightingTextEditor.swift b/Textream/Textream/HighlightingTextEditor.swift index 1ec67ee..67999a9 100644 --- a/Textream/Textream/HighlightingTextEditor.swift +++ b/Textream/Textream/HighlightingTextEditor.swift @@ -15,6 +15,49 @@ extension NSFont { } } +// MARK: - RTL Script Detection + +/// Check whether a Unicode scalar belongs to a right-to-left script (Arabic, Hebrew, Persian, Urdu, etc.) +func isRTLUnicodeScalar(_ scalar: Unicode.Scalar) -> Bool { + let v = scalar.value + // Hebrew + if v >= 0x0590 && v <= 0x05FF { return true } + // Arabic + if v >= 0x0600 && v <= 0x06FF { return true } + // Syriac + if v >= 0x0700 && v <= 0x074F { return true } + // Arabic Supplement + if v >= 0x0750 && v <= 0x077F { return true } + // Thaana + if v >= 0x0780 && v <= 0x07BF { return true } + // NKo + if v >= 0x07C0 && v <= 0x07FF { return true } + // Samaritan + if v >= 0x0800 && v <= 0x083F { return true } + // Mandaic + if v >= 0x0840 && v <= 0x085F { return true } + // Arabic Extended-A + if v >= 0x08A0 && v <= 0x08FF { return true } + // Arabic Extended-B + if v >= 0x0870 && v <= 0x089F { return true } + // Arabic Presentation Forms-A + if v >= 0xFB50 && v <= 0xFDFF { return true } + // Arabic Presentation Forms-B + if v >= 0xFE70 && v <= 0xFEFF { return true } + // Mende Kikakui + if v >= 0x1E800 && v <= 0x1E8DF { return true } + // Adlam + if v >= 0x1E900 && v <= 0x1E95F { return true } + // Arabic Mathematical Alphabetic Symbols + if v >= 0x1EE00 && v <= 0x1EEFF { return true } + return false +} + +/// Returns true if the string contains any right-to-left script characters. +func containsRTLText(_ text: String) -> Bool { + text.unicodeScalars.contains(where: { isRTLUnicodeScalar($0) }) +} + struct HighlightingTextEditor: NSViewRepresentable { @Binding var text: String var font: NSFont = .systemFont(ofSize: 16, weight: .regular) @@ -62,6 +105,9 @@ struct HighlightingTextEditor: NSViewRepresentable { textView.string = text context.coordinator.applyHighlighting(textView) + // Auto-detect RTL text direction for the editor + updateWritingDirection(textView, text: text) + return scrollView } @@ -73,6 +119,8 @@ struct HighlightingTextEditor: NSViewRepresentable { textView.string = text textView.selectedRanges = selectedRanges context.coordinator.applyHighlighting(textView) + // Update writing direction when text changes (e.g., paste Arabic) + updateWritingDirection(textView, text: text) } // Apply bump highlight on newly dictated range @@ -91,12 +139,21 @@ struct HighlightingTextEditor: NSViewRepresentable { } } + /// Set the text view's base writing direction based on the content's script. + private func updateWritingDirection(_ textView: NSTextView, text: String) { + if containsRTLText(text) { + textView.baseWritingDirection = .rightToLeft + } else { + textView.baseWritingDirection = .natural + } + } + class Coordinator: NSObject, NSTextViewDelegate { var parent: HighlightingTextEditor weak var textView: NSTextView? private static let annotationPattern = try! NSRegularExpression( - pattern: "\\[[^\\]]+\\]", + pattern: "\\\\[[^\\\\]]+\\\\]", options: [] ) diff --git a/Textream/Textream/MarqueeTextView.swift b/Textream/Textream/MarqueeTextView.swift index 74e5afb..d7f6c1e 100644 --- a/Textream/Textream/MarqueeTextView.swift +++ b/Textream/Textream/MarqueeTextView.swift @@ -81,9 +81,6 @@ struct SpeechScrollView: View { let highlightedCharCount: Int var font: NSFont = .systemFont(ofSize: 18, weight: .semibold) var highlightColor: Color = .white - var cueColor: Color = .white - var cueUnreadOpacity: Double = 0.2 - var cueReadOpacity: Double = 0.5 var onWordTap: ((Int) -> Void)? = nil /// Called when user starts/stops manual scrolling in smooth mode. /// Bool: true = scrolling started (pause timer), false = scrolling ended (resume timer). @@ -107,9 +104,6 @@ struct SpeechScrollView: View { highlightedCharCount: highlightedCharCount, font: font, highlightColor: highlightColor, - cueColor: cueColor, - cueUnreadOpacity: cueUnreadOpacity, - cueReadOpacity: cueReadOpacity, highlightWords: !smoothScroll, containerWidth: geo.size.width, onWordTap: { charOffset in @@ -334,9 +328,6 @@ struct WordFlowLayout: View { let highlightedCharCount: Int let font: NSFont var highlightColor: Color = .white - var cueColor: Color = .white - var cueUnreadOpacity: Double = 0.2 - var cueReadOpacity: Double = 0.5 var highlightWords: Bool = true let containerWidth: CGFloat var onWordTap: ((Int) -> Void)? = nil @@ -384,10 +375,17 @@ struct WordFlowLayout: View { return -1 } + /// Returns true if any word in the text contains right-to-left script characters. + private var isRTL: Bool { + // Pre-compute once from the first page content during setup + words.contains { $0.unicodeScalars.contains { isRTLUnicodeScalar($0) } } + } + var body: some View { let (items, lines) = cachedLayout() let nextIdx = nextWordIndex(items: items) let totalLines = lines.count + let rtl = isRTL // Estimate line height for visibility culling using actual font metrics let lineH = ceil(font.ascender - font.descender + font.leading) + lineSpacing @@ -398,7 +396,9 @@ struct WordFlowLayout: View { let startLine = canCull ? max(0, min(totalLines, Int(floor((-scrollOffset - buffer) / lineH)))) : 0 let endLine = canCull ? max(startLine, min(totalLines, Int(ceil((viewportHeight - scrollOffset + buffer) / lineH)))) : totalLines - VStack(alignment: .leading, spacing: lineSpacing) { + // For RTL scripts (Arabic, Hebrew, Persian, Urdu), flip the layout direction + // so words within each line flow right-to-left instead of left-to-right. + VStack(alignment: rtl ? .trailing : .leading, spacing: lineSpacing) { if startLine > 0 { Color.clear.frame(height: CGFloat(startLine) * lineH) } @@ -410,13 +410,14 @@ struct WordFlowLayout: View { .id(item.id) } } + .environment(\.layoutDirection, rtl ? .rightToLeft : .leftToRight) } if endLine < totalLines { Color.clear.frame(height: CGFloat(totalLines - endLine) * lineH) } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: rtl ? .trailing : .leading) .coordinateSpace(name: "flowLayout") } @@ -431,7 +432,7 @@ struct WordFlowLayout: View { // When highlighting is off (classic/silence-paused), use uniform color if !highlightWords { let uniformColor: Color = item.isAnnotation - ? cueColor.opacity(cueUnreadOpacity) + ? Color.white.opacity(0.4) : highlightColor return Text(item.word + " ") @@ -451,11 +452,11 @@ struct WordFlowLayout: View { } } - // Annotations: italic, dimmed with cue color + // Annotations: italic, always dimmed if item.isAnnotation { let annotationColor: Color = isFullyLit - ? cueColor.opacity(cueReadOpacity) - : cueColor.opacity(cueUnreadOpacity) + ? Color.white.opacity(0.5) + : Color.white.opacity(0.2) return Text(item.word + " ") .font(Font(font).italic())