Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Textream/Textream/HighlightingTextEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ struct HighlightingTextEditor: NSViewRepresentable {
textView.string = text
context.coordinator.applyHighlighting(textView)

updateWritingDirection(textView, text: text)

return scrollView
}

Expand All @@ -74,6 +76,7 @@ struct HighlightingTextEditor: NSViewRepresentable {
textView.selectedRanges = selectedRanges
context.coordinator.applyHighlighting(textView)
}
updateWritingDirection(textView, text: text)

// Apply bump highlight on newly dictated range
if let range = highlightRange, range.location + range.length <= textView.string.count {
Expand All @@ -91,6 +94,18 @@ struct HighlightingTextEditor: NSViewRepresentable {
}
}

/// Set the text view's base writing direction based on the content's script.
private func updateWritingDirection(_ textView: NSTextView, text: String) {
switch textBaseDirection(in: text) {
case .rightToLeft:
textView.baseWritingDirection = .rightToLeft
case .leftToRight:
textView.baseWritingDirection = .leftToRight
case .natural:
textView.baseWritingDirection = .natural
}
}

class Coordinator: NSObject, NSTextViewDelegate {
var parent: HighlightingTextEditor
weak var textView: NSTextView?
Expand All @@ -107,6 +122,7 @@ struct HighlightingTextEditor: NSViewRepresentable {
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
parent.updateWritingDirection(textView, text: textView.string)
applyHighlighting(textView)
}

Expand Down
22 changes: 20 additions & 2 deletions Textream/Textream/MarqueeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,25 @@ struct WordFlowLayout: View {
return -1
}

private func isRightToLeft(items: [WordItem]) -> Bool {
for item in items where !item.isAnnotation {
switch textBaseDirection(in: item.word) {
case .rightToLeft:
return true
case .leftToRight:
return false
case .natural:
continue
}
}
return false
}

var body: some View {
let (items, lines) = cachedLayout()
let nextIdx = nextWordIndex(items: items)
let totalLines = lines.count
let rtl = isRightToLeft(items: items)

// Estimate line height for visibility culling using actual font metrics
let lineH = ceil(font.ascender - font.descender + font.leading) + lineSpacing
Expand All @@ -398,7 +413,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)
}
Expand All @@ -410,13 +427,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")
}

Expand Down
51 changes: 51 additions & 0 deletions Textream/Textream/TextDirection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

enum TextBaseDirection: Equatable {
case leftToRight
case rightToLeft
case natural
}

/// Infers the base direction from the first strong alphabetic character,
/// ignoring bracketed teleprompter cues such as `[pause]`.
func textBaseDirection(in text: String) -> TextBaseDirection {
var isInsideAnnotation = false

for scalar in text.unicodeScalars {
if scalar == "[" {
isInsideAnnotation = true
continue
}
if scalar == "]", isInsideAnnotation {
isInsideAnnotation = false
continue
}
guard !isInsideAnnotation, scalar.properties.isAlphabetic else { continue }
return isRightToLeftScript(scalar) ? .rightToLeft : .leftToRight
}

return .natural
}

private func isRightToLeftScript(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x0590...0x05FF, // Hebrew
0x0600...0x06FF, // Arabic
0x0700...0x074F, // Syriac
0x0750...0x077F, // Arabic Supplement
0x0780...0x07BF, // Thaana
0x07C0...0x07FF, // NKo
0x0800...0x083F, // Samaritan
0x0840...0x085F, // Mandaic
0x0870...0x089F, // Arabic Extended-B
0x08A0...0x08FF, // Arabic Extended-A
0xFB50...0xFDFF, // Arabic Presentation Forms-A
0xFE70...0xFEFF, // Arabic Presentation Forms-B
0x1E800...0x1E8DF, // Mende Kikakui
0x1E900...0x1E95F, // Adlam
0x1EE00...0x1EEFF: // Arabic Mathematical Alphabetic Symbols
return true
default:
return false
}
}