txt-bidi — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited txt-bidi (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
Bidi correctness is layered. SwiftUI's environment, NSParagraphStyle, the .writingDirection attribute, UITextInput, and NSTextContainer each control direction at a different scope; getting RTL right usually means getting the right layer involved, not all of them. The single concept that explains most bidi bugs is the gap between logical order (how characters live in storage) and visual order (how they end up on screen after the Unicode Bidi Algorithm reorders them) — a single logical position can map to two visual positions at a direction boundary, which is why cursors appear to jump. The patterns below are starting points; before quoting any specific API signature, fetch the current Apple docs via Sosumi (sosumi.ai/documentation/uikit/uitextinput) and verify against the actual code, especially when the question involves cursor movement.
Stock UITextView and UITextField handle bidi correctly with .natural writing direction. Most "bidi is broken" reports against stock views turn out to be .left alignment hardcoded somewhere, or a phone number reordering because no LRM was added. Custom UITextInput views handle bidi only as well as their author writes it.
SwiftUI environment. The top-level switch for a SwiftUI hierarchy. Reading @Environment(\.layoutDirection) gives the current direction; .environment(\.layoutDirection, .rightToLeft) forces it on a subtree. SwiftUI Text ignores the .writingDirection attributed-string key, so direction control in SwiftUI happens through the environment, not through attributes.
VStack { ... }
.environment(\.layoutDirection, .rightToLeft)
Image("arrow")
.flipsForRightToLeftLayoutDirection(true)NSParagraphStyle.baseWritingDirection. The most common control for an attributed string. .natural defers to the Unicode Bidi Algorithm rules P2/P3, which pick direction from the first strong directional character. .leftToRight and .rightToLeft force it. Forcing direction is appropriate when you know the content's language; .natural is appropriate for user-supplied text of unknown direction.
.writingDirection attribute. For inline overrides within a paragraph, equivalent to Unicode bidi control sequences (LRE/RLE/PDF). The value is an array of NSNumber packing direction and format type:
let ltrEmbed: [NSNumber] = [
NSNumber(value: NSWritingDirection.leftToRight.rawValue
| NSWritingDirectionFormatType.embedding.rawValue)
]
attrString.addAttribute(.writingDirection, value: ltrEmbed, range: range).embedding respects each character's inherent directionality within the override (the typesetter still bidis inside). .override forces every character to display in the named direction regardless of inherent direction — used rarely, mostly for showing raw bidi-control text literally.
UITextInput. Custom text views set per-range direction at runtime via setBaseWritingDirection(_:for:) and read it via baseWritingDirection(for:in:). These methods drive marked text and selection in bidi content; a UITextInput implementation that returns a stale direction will show the cursor in the wrong place.
AttributedString (iOS 26+). AttributedString.writingDirection exposes the same control as a typed property: .leftToRight or .rightToLeft.
NSTextContainer. lineFragmentRect(forProposedRect:at:writingDirection:remaining:) takes the writing direction as a parameter — line fragments advance from the leading edge in that direction. A custom container subclass that ignores the parameter produces lines that advance the wrong way in RTL.
Logical order is storage order. In a string like "Hello مرحبا World", the Arabic word's characters are stored in their natural reading order (right-to-left in Arabic, which is left-to-right in the string buffer). Visual order is what appears on screen after the Bidi Algorithm reorders the Arabic run to display right-to-left while leaving the Latin runs left-to-right.
A consequence of this gap: a single cursor position can map to two visual positions at a direction boundary. Place the caret between the last Latin character and the first Arabic character, and the caret can legitimately appear at the right edge of the Latin run or at the left edge of the Arabic run. Pre-iOS 26 selection uses a single contiguous NSRange that follows logical order, so a selection that spans a direction boundary appears visually disjoint — the user sees two highlighted regions for one selection.
Cursor movement in bidi content is therefore inherently non-linear. Right-arrow in mixed content does not always move the visual cursor right. The user's expectation tracks the system's; what looks wrong is usually a UITextInput view that doesn't honor visual movement.
The Unicode Bidi Algorithm decides direction from the first strong directional character. Numbers and weak characters inherit direction from the surrounding run, which is the source of most "phone number reordered" bugs. The fix is an inline marker:
// Wrap LTR content inside RTL with LRM markers
let phone = "\u{200E}555-1234\u{200E}"
let line = "اتصل بـ \(phone)"The relevant control characters:
| Char | Name | Use |
|---|---|---|
| U+200E | LRM (Left-to-Right Mark) | Invisible LTR direction marker |
| U+200F | RLM (Right-to-Left Mark) | Invisible RTL direction marker |
| U+202A | LRE (Left-to-Right Embedding) | Start an LTR embedding |
| U+202B | RLE (Right-to-Left Embedding) | Start an RTL embedding |
| U+202C | PDF (Pop Directional Formatting) | End an LRE/RLE |
| U+2068 | FSI (First Strong Isolate) | Start an isolate using first-strong direction |
| U+2069 | PDI (Pop Directional Isolate) | End an FSI |
For unknown-direction user content (a username, a title), wrap with FSI/PDI rather than picking a direction:
let displayName = "\u{2068}\(user.name)\u{2069}"Isolates are preferable to embeddings in modern code: they prevent the inner text from interfering with the surrounding paragraph's bidi resolution, which is almost always what you want.
Pre-iOS 26, selectedRange is a single NSRange. In bidi text that spans a direction boundary, this produces visually disjoint selections. iOS 26 introduces selectedRanges: [NSRange] on UITextView — multiple visually contiguous ranges that together represent the user's selection. The companion delegate method is textView(_:shouldChangeTextInRanges:replacementStrings:).
textView.selectedRanges // [NSRange]
func textView(_ textView: UITextView,
shouldChangeTextInRanges ranges: [NSRange],
replacementStrings: [String]?) -> Bool {
return true
}Natural Selection requires TextKit 2. Touching textView.layoutManager flips the view to TextKit 1 and disables it. The single-range selectedRange still works for source compatibility; it will be deprecated in a future release.
.natural alignment flips with the writing direction: leading-aligned in LTR, trailing-aligned in RTL. .left and .right are absolute and do not flip. Hardcoded .left is the common bug — labels stay left-aligned in an Arabic locale instead of flipping. The fix is .natural for body text, leading/trailing constraints for layout.
In a UIKit view inside an Arabic locale, leading is right and trailing is left. SwiftUI's .leading and .trailing alignments behave the same way. A constraint pinned to .leading of the superview lands on the right edge in RTL — the right answer. A constraint pinned to .left lands on the left edge in either locale — almost never the right answer.
.natural; layout should use leading/trailing. The symptom is text that looks right in English and stays left-aligned in Arabic.\u{200E}) on each side, or with FSI/PDI for unknown-direction content. The same applies to URLs, email addresses, and code identifiers embedded in Arabic or Hebrew prose.\.layoutDirection) or through AttributedString.writingDirection on iOS 26+.selectedRanges on iOS 26+; accept the visual artifact on older OSes or build a custom selection layer.textView.layoutManager falls the view back to TextKit 1, which disables selectedRanges and any TK2-only direction handling. Audit for any code path that asks for layoutManager on a view that should be TK2; replace with textLayoutManager or with viewport-aware APIs.makeTextWritingDirectionRightToLeft(_:) requires the view to be first responder and editable. Calling it from elsewhere silently fails.NSLayoutManager rect APIs return incorrect geometry for RTL ranges. Precision RTL geometry is more reliable on TextKit 2 or via Core Text./skill txt-uitextinput — UITextInput protocol details for custom views (marked text, selection, direction)/skill txt-attribute-keys — .writingDirection attribute key reference/skill txt-selection-menus — selection UI and edit menu behavior/skill txt-appkit-vs-uikit — platform differences in RTL support~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.