Right-to-left layout for Arabic, Hebrew, Persian, Urdu and every RTL language in the Claude Code terminal

<img src="assets/ClaudeCode Launchpad CLI.jpeg" width="700" alt="ClaudeCode Launchpad CLI">
<img src="assets/claudecode-launchpad_v2.6.9.gif" width="700" alt="ClaudeCode Launchpad CLI in action - installer wizard, terminal launch, status bar">
<em>📹 Demo: ClaudeCode Launchpad CLI, one-click install, folder picker, and launch. Full video (1:56): <a href="https://github.com/noambrand/launchpad-cli/releases/download/v2.6.9/claudecode-launchpad_v2.6.9.mp4">download MP4 (2.2 MB)</a>.</em>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a> <a href="https://github.com/noambrand/launchpad-cli/releases/latest"><img src="https://img.shields.io/github/v/release/noambrand/launchpad-cli?label=version&color=brightgreen&cb=v2.8.1-lc" alt="Latest release"></a> <img src="https://img.shields.io/badge/platform-Windows%2010%2F11%20%7C%20macOS%2012%2B-lightgrey" alt="Platform"> <img src="https://img.shields.io/badge/languages-24%2B-orange" alt="24+ Languages"> <a href="https://github.com/noambrand/launchpad-cli/stargazers"><img src="https://img.shields.io/github/stars/noambrand/launchpad-cli?style=flat&color=yellow&cb=lc1" alt="GitHub Stars"></a> <img src="https://img.shields.io/github/last-commit/noambrand/launchpad-cli?label=last%20commit&color=brightgreen&cb=lc1" alt="Last Commit"> <img src="https://img.shields.io/badge/downloads-1491%2B-blue" alt="Total Downloads">
<h3 align="center">Point it at a folder, describe the job in your own language, and Anthropic's AI agent does the work on your own files.</h3>
<b>Built for people who do not write code.</b> Claude Code is a genuinely capable AI agent, but it lives in a terminal built for programmers, and that is where most people stop. This puts a normal window in front of it: pick your folder, type a sentence, watch it work. Nothing to configure, no commands to memorise, no administrator rights.
<table align="center"> <tr><th align="left">Instead of doing this by hand</th><th align="left">You type this</th></tr> <tr><td>Combining 40 monthly spreadsheets into one summary</td><td><em>"Take all the files in this folder and build one summary sheet, one row per month."</em></td></tr> <tr><td>Renaming and sorting a folder of hundreds of documents</td><td><em>"Rename these by date and client, and sort them into folders per year."</em></td></tr> <tr><td>Hunting for what changed between two versions</td><td><em>"Compare last month's list to this one and show me only what changed."</em></td></tr> <tr><td>Reading a file someone else built and left behind</td><td><em>"Explain what this workbook calculates and flag anything broken."</em></td></tr> </table>
<a href="#-quick-start">Quick Start</a> • <a href="#-why-launchpad-cli">Why Launchpad CLI?</a> • <a href="#-status-bar">Status Bar</a> • <a href="#-architecture">Architecture</a> • <a href="#-configuration">Configuration</a> • <a href="docs/CHANGELOG.md">Changelog</a> • <a href="TROUBLESHOOTING.md">Troubleshooting</a>
| Manual Setup | Launchpad CLI | |
|---|---|---|
| Get Claude Code running | Find Node.js, Git, and Claude installers, run them in order, fix PATH | One installer, one click |
| Live status bar (model, context %, usage) | Write your own statusline script + configure settings.json | Pre-installed |
| Desktop shortcut + right-click "Open with..." | Manual .lnk files + registry edits | Included |
| Pick a folder before launching | cd into every project | GUI picker dialog (browse tree or paste a path) |
| Default Claude flags + startup slash commands | Type them every session | Set once in the picker, reused every launch |
| Named profiles per project (folder + model + flags + env vars + startup slash-commands) | Track combos in your head, retype every session | 🆕 v2.6.0 — chip row at top of picker, click to switch; ANTHROPIC_API_KEY etc. masked in preview by default |
| Time to first prompt | 20+ minutes | ~1 minute |
<a href="https://github.com/noambrand/launchpad-cli/releases/latest/download/ClaudeCode_Launchpad_CLI_Setup.exe"><img src="https://img.shields.io/badge/Download%20for%20Windows-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZmZmZiI%2BPHBhdGggZD0iTTAgMy40NDkgOS43NSAyLjF2OS40NTFIMG0xMC45NDktOS42MDJMMjQgMHYxMS40SDEwLjk0OU0wIDEyLjZoOS43NXY5LjQ1MUwwIDIwLjY5OU0xMC45NDkgMTIuNkgyNFYyNGwtMTIuOS0xLjgwMSIvPjwvc3ZnPg%3D%3D&logoColor=white" alt="Download for Windows" height="42"></a> <a href="https://github.com/noambrand/launchpad-cli/releases/latest/download/ClaudeCode_Launchpad_CLI_Setup_mac.pkg"><img src="https://img.shields.io/badge/Download%20for%20macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" height="42"></a>
<img src="assets/picker.png" alt="ClaudeCode Launchpad CLI folder picker (v2.6.16) — folder selection up top, an Advanced options toggle that hides model / flags / startup slash-commands / env-vars by default, and a yellow 'Update available' banner that checks GitHub Releases on launch and offers a one-click Download button" width="780">
The desktop shortcut opens this picker: pick a profile from the chip row at the top (or + New to save the current setup as a named profile — folder + model + flags + startup commands + env vars), type/paste a Windows path or browse the tree, optionally pick a model (Opus / Sonnet / Haiku), tap chips for common options (Respond in Hebrew, High effort, Auto-accept file edits, Read-only, Don't fail if Opus is busy, Confirm before changes), and add startup slash commands like /voicemode:converse that get typed into Claude after it opens.
There are two projects in this family. Pick whichever fits how you work:
| Launchpad CLI (this repo) | Kivun Terminal wsl | |
|---|---|---|
| Live status bar (model, context %, usage) | ✅ | ✅ |
| Light-blue Kivun theme | ✅ Windows Terminal | ✅ Konsole |
| Right-click "Open with..." on a folder | ✅ Windows Explorer | ✅ Windows Explorer + Linux file managers |
| Folder picker dialog with model + flag chips | ✅ | ✅ |
| Named profiles per project (folder + model + flags + env vars + startup slash-commands) | ✅ v2.6.0 | ✅ v1.4.0 |
| Hebrew / Arabic / Persian text right-aligned | ✅ in Claude's replies, via the bundled rtl-terminal plugin (Windows) | ✅ whole screen |
| English/code mixed inside a Hebrew sentence | ✅ in Claude's replies (Windows) | ✅ |
| Supported RTL languages | All RTL scripts in replies (Windows); macOS: LTR only | 11 (Hebrew, Arabic, Persian, Urdu, Pashto, Kurdish, Dari, Uyghur, Sindhi, Yiddish, Syriac) |
| Startup time | ~2 s | ~6 s |
| Install size on Windows | ~150 MB | ~2 GB (includes Ubuntu + Konsole via WSL2) |
| Windows support | Native (Windows Terminal) | WSL2 + Ubuntu + Konsole |
| macOS support | ✅ | ❌ Deprecated as of v1.2.4 (no native Mac terminal handles mixed Hebrew + English) |
| Linux support | ❌ | ✅ apt / dnf / pacman / zypper |
Pick Launchpad CLI if you want the lightest, fastest install. On Windows it now right-aligns Hebrew, Arabic and other RTL replies too. Pick Kivun Terminal if you're on Linux, or you need RTL across the whole screen (tool output, dialogs, the input box) and clean mouse copy of Hebrew text.
New in 3.2.0: Claude's replies in Hebrew, Arabic, Persian, Urdu and the other right-to-left languages now read right to left in Windows Terminal. They sit against the right edge, bullets and numbers move to the right, and brackets face the right way. This comes from the bundled rtl-terminal plugin and is on after you install or upgrade. Type /rtl off inside Claude to turn it off.
Real screenshots from two Windows Terminal windows of the same size, Launchpad 3.1.0 above and 3.2.0 below.
A real answer in Hebrew. Before, lines that start with an English word came out scrambled and the list numbers sat at the wrong end.
<img src="assets/rtl/rtl-conversation.png" width="760" alt="A Hebrew answer about Git and GitHub: left-aligned and partly scrambled in Launchpad 3.1.0, right-aligned with numbers on the right in 3.2.0">
Bullets and nested bullets.
<img src="assets/rtl/rtl-bullets.png" width="760" alt="A Hebrew bullet list: bullets on the left and English-first lines scrambled in 3.1.0, bullets on the right and every line in order in 3.2.0">
Brackets, dates and prices.
<img src="assets/rtl/rtl-brackets.png" width="760" alt="Hebrew text with a version number, a date, a percentage and a price: brackets face backwards in 3.1.0 and the right way in 3.2.0">
A preview while you type. The input box itself stays as Claude Code draws it, so the plugin shows above it how your message will read.
<img src="assets/rtl/rtl-preview.png" width="760" alt="A Hebrew draft in the input box: no preview in 3.1.0, a right-aligned preview line above the input box in 3.2.0">
ClaudeCode_Launchpad_CLI_Setup.exeSmartScreen / antivirus note: the installer is code-signed by its verified publisher. A brand-new signature still builds Microsoft SmartScreen reputation over time, so for a short while you may still see "Windows protected your PC" (click More info → Run anyway), and some antivirus (e.g. McAfee) may warn. This is a false positive. Launchpad CLI is open-source (MIT) with auditable source, installs only official tools (Node.js, Git, Windows Terminal, and Claude via Anthropic's official installer), and deliberately avoids the download/elevation tricks antivirus watches for. You can scan the file yourself on VirusTotal. More detail in TROUBLESHOOTING.
.pkg installerclaudeFirst time? You'll need a Claude Pro/Max subscription or Anthropic API key.
A two-line live status bar at the bottom of every session:
BookWriter | 🟢 Sonnet 4.6 | Context 🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜ 51% | tokens: 284K | 24:13
Session 🟨🟨🟨🟨🟨🟨🟨🟨⬜⬜ 77% resets in 4h15m | Weekly 🟩🟩⬜⬜⬜⬜⬜⬜⬜⬜ 16% resets in 6d18h
| Field | What it shows |
|---|---|
| Model | Active Claude model (color-coded: green = Opus, yellow = Sonnet/Haiku) |
| Context | % of context window consumed (green/yellow/red) |
| Tokens | Combined input + output tokens this session |
| Session / Weekly | Usage limit % with countdown to reset |
Short spoken clips so you don't have to watch the screen. Set up automatically and on by default — each tied to the moment that actually means it:
| Alert | Plays when | Event |
|---|---|---|
| done | Claude has genuinely finished — nothing left to do | on-demand (Claude runs it) |
| permission | A genuine allow this tool? request (a file edit, a command) | PermissionRequest — real tool permissions only; question boxes & plan approval play waiting instead |
| waiting | Claude has been waiting on you (~60s idle), or a question / plan-approval prompt is up | Notification (idle) + reclassified prompts |
| save | Manual intervention — act by hand | on-demand |
Regular or Funny mode — every alert has a plain recording and a joke one (e.g. done: "Done." vs "Done. I'll pretend that took effort."). Switch with Regular Sounds ON / Funny Sounds ON or node ~/.claude/sounds/voice.js mode regular|funny.
An optional repeat reminder (off by default) re-plays the waiting clip every couple of minutes once you've gone idle, until you respond. Playback uses Windows Media Player on Windows (no PowerShell) and afplay on macOS — no Python, no extra installs.
Controls: double-click Sound ON/OFF, Regular/Funny Sounds ON, Test Sounds in ~/.claude/sounds/, or node ~/.claude/sounds/voice.js on|off|mode <m>|repeat on|off|status. Full details: ~/.claude/sounds/README.md.
The on/off setting is global — a single switch for every project, every folder, and every window. It is not per-project and not per-profile. Turning it off silences all of it: the four alerts and the repeat reminder.
Two ways to do it, no commands needed:
Sound OFF.cmd in C:\Users\<you>\.claude\sounds.When does it take effect? Immediately — in every window that's already open, with no restart. (The setting is re-read before every sound, so the very next alert obeys it.) It also survives updates and reinstalls, so once it's off it stays off until you set it back to On the same way. Nothing is removed or uninstalled — it's a reversible switch.
| Component | Technology | Purpose |
|---|---|---|
| Windows installer | NSIS | Silent/wizard install with dependency detection |
| macOS installer | pkgbuild | .pkg with postinstall script via Homebrew |
| Launcher | Batch / Shell | Folder picker, flag passing, WT/CMD fallback |
| Terminal profile | Windows Terminal JSON Fragment | Custom "Noam" color scheme (#C8E6FF) |
| Status bar | Node.js (statusline.mjs) | Live model, context, and usage display |
| Config scripts | Node.js | WT settings injection, statusline setup |
| CI/CD | GitHub Actions | Automated macOS .pkg builds |
Set the terminal color right in the folder picker → Advanced options (with an Apply now button that recolors Windows Terminal instantly), or edit %LOCALAPPDATA%\Kivun\config.txt:
RESPONSE_LANGUAGE=english # 24+ languages supported
TERMINAL_COLOR=kivun # kivun / dark / black / white / default, or a hex like #1e1e2e
CLAUDE_FLAGS= # e.g. --continue
AUTO_CONTINUE=false # auto-type "continue" when the 5-hour limit resets (see below)
When Claude Code hits the 5-hour usage cap, the session stays alive but idle until you come back and type something after the limit resets. Turn on Auto-continue when limit resets (folder picker → Advanced options, or set AUTO_CONTINUE=true in config.txt) and a small background watcher waits until the limit's real reset time passes, focuses this tab, and resumes your work once so it continues on its own. By default it doesn't type a bare continue — it asks Claude to re-check its own git/file state first (see Mid-edit resume below).
This does not bypass the limit — it waits for the real reset time and then resumes. It is off by default, conservative, and capped:
AUTO_CONTINUE=false # master switch (off by default)
AUTO_CONTINUE_MAX=5 # most times it will auto-continue in one run, then stop
AUTO_CONTINUE_FALLBACK_MIN=300 # if blocked with no known reset time, wait this many minutes
AUTO_CONTINUE_QUIET= # optional quiet hours "HH:MM-HH:MM" (local); never fire inside
AUTO_CONTINUE_SAFE_RESUME=true # re-check git/file state before resuming (see Mid-edit resume)
Caveats — read before turning it on:
continue. If you happen to be typing in another app at that exact moment, the word may land there. At 3am this is theoretical.--permission-mode acceptEdits) helps, but it only auto-accepts file-edit prompts — a pending command or tool prompt still gets the keys.Kivun vs Kivun_all) could send continue to the wrong project's window. It's opt-in partly for this reason.continue can make Claude redo work it already did — or re-apply a half-written patch (people have seen a function get duplicated this way). AUTO_CONTINUE_SAFE_RESUME=true (the default) guards this by asking Claude to run git status and re-read the file it was editing before continuing, so an edit that already landed is reconciled instead of repeated. It's a genuine mitigation, not a guarantee — the real safety net is to commit (or git add) before you leave it running and prefer whole-function edits, so a duplicate shows up in git status instead of shipping. Set it to false for the old plain continue.Contributions are welcome! Areas where help is especially useful:
Looking for Linux, or RTL across the whole screen? Use the sister project kivun-terminal-wsl — Windows-via-WSL + Linux installers with full BiDi rendering.
Fork the repo, make your changes, and open a PR.
Submitted to awesome lists (pending review):
Right-to-left layout in Claude's replies comes from rtl-terminal by MohammedSaud404 (MIT), bundled unmodified in source/plugins/rtl-terminal/ with its license. Turn it off any time with /rtl off inside Claude.
<strong>Made by <a href="https://github.com/noambrand">Noam Brand</a></strong> <a href="https://github.com/noambrand"><img src="https://img.shields.io/badge/GitHub-noambrand-181717?logo=github" alt="GitHub"></a> <a href="https://www.linkedin.com/in/noambrand/"><img src="https://img.shields.io/badge/LinkedIn-noambrand-0A66C2?logo=linkedin&logoColor=white" alt="LinkedIn"></a> <a href="https://www.facebook.com/noambbb/"><img src="https://img.shields.io/badge/Facebook-noambbb-1877F2?logo=facebook&logoColor=white" alt="Facebook"></a> <a href="mailto:noambbb@gmail.com"><img src="https://img.shields.io/badge/Email-noambbb%40gmail.com-EA4335?logo=gmail&logoColor=white" alt="Email"></a>
hooks/register.tsx 292 lines1import type { Elements as SurfaceElements, EngineInterface, Register, RenderElement } from 'claude-code'
2
3import { LRM } from './bidi'
4import { itemMarker, layoutRtl, parseInline, splitBlocks, writeOutLinks } from './markdown'
5import type { Block, RtlBlock, Span } from './markdown'
6import { layoutPlainText, needsPreview, previewRows } from './preview'
7import { asMode, hasHyperlinks, reorderedBy } from './target'
8import type { Mode } from './target'
9
10// Cells the transcript keeps left of a reply's text for its `●` mark.
11const MESSAGE_GUTTER = 2
12// A cell kept free at the right, so a scrollbar or a rounding never wraps
13// a right-aligned row.
14const RIGHT_MARGIN = 1
15// Cells a prompt row of the transcript keeps around its text: the `❯ ` mark,
16// the box fullscreen draws it in, and room to spare, so the rows wrapped here
17// are never wrapped again.
18const PROMPT_ROW_CHROME = 6
19// Drawn dim at the right of a draft's preview, where a list keeps its bullet.
20const PREVIEW_GUTTER = ' ✎'
21// How often a shown preview checks the prompt box: clearing it (Ctrl+C, Esc,
22// history) raises no edit, and the preview must not outlive the draft.
23const PREVIEW_CHECK_MS = 300
24
25type Elements = SurfaceElements['terminal']
26
27function spanElement(els: Elements, span: Span, hasLinks: boolean) {
28 const { Link, Text } = els
29 const props: Record<string, unknown> = {}
30 if (span.bold) props.bold = true
31 if (span.italic) props.italic = true
32 if (span.underline) props.underline = true
33 if (span.strike) props.strikethrough = true
34 if (span.code) props.color = 'permission'
35 if (span.href && !span.code) {
36 if (!hasLinks) return <Text {...props} color="blueBright">{span.text}</Text>
37 return (
38 <Text {...props} color="blueBright">
39 <Link href={span.href}>{span.text}</Link>
40 </Text>
41 )
42 }
43 return <Text {...props}>{span.text}</Text>
44}
45
46function gutterOf(block: RtlBlock): { text: string; isDim: boolean } {
47 if (block.role === 'item') {
48 return { text: ` ${itemMarker(block.marker, block.depth)}${' '.repeat(block.depth)}`, isDim: false }
49 }
50 if (block.role === 'quote') return { text: ' ▎', isDim: true }
51 return { text: '', isDim: false }
52}
53
54function blockRows(els: Elements, block: RtlBlock, width: number, hasLinks: boolean) {
55 const { Box, Text } = els
56 const gutter = gutterOf(block)
57 const room = width - gutter.text.length
58 const style: Omit<Span, 'text'> =
59 block.role === 'heading'
60 ? block.headingLevel === 1
61 ? { bold: true, italic: true, underline: true }
62 : { bold: true }
63 : block.role === 'quote'
64 ? { italic: true }
65 : {}
66 const rows = block.lines.flatMap(line => {
67 const spans = parseInline(line, style)
68 return layoutRtl(hasLinks ? spans : writeOutLinks(spans), room)
69 })
70
71 return rows.map((row, r) => {
72 const mark = r === 0 || block.role === 'quote' ? gutter.text : ' '.repeat(gutter.text.length)
73 return (
74 <Box flexDirection="row">
75 <Text>{row.map(span => spanElement(els, span, hasLinks))}</Text>
76 {gutter.text !== '' && (
77 <Text dimColor={gutter.isDim}>{LRM + mark}</Text>
78 )}
79 </Box>
80 )
81 })
82}
83
84function drawBlocks(els: Elements, blocks: Block[], width: number, isFirstOfReply: boolean, hasLinks: boolean) {
85 const { Box, Markdown, Text } = els
86 return (
87 <Box flexDirection="row">
88 <Box width={MESSAGE_GUTTER}>
89 <Text color="text">{isFirstOfReply ? '●' : ' '}</Text>
90 </Box>
91 <Box flexDirection="column" width={width}>
92 {blocks.map((block, n) => {
93 const marginTop = block.gapBefore && n > 0 ? 1 : 0
94 if (block.kind === 'markdown') {
95 return (
96 <Box marginTop={marginTop}>
97 <Markdown text={block.source} />
98 </Box>
99 )
100 }
101 return (
102 <Box marginTop={marginTop} flexDirection="column" alignItems="flex-end">
103 {blockRows(els, block, width, hasLinks)}
104 </Box>
105 )
106 })}
107 </Box>
108 </Box>
109 )
110}
111
112function drawPreview(els: Elements, text: string, width: number, maxRows: number) {
113 const { Box, Text } = els
114 const rows = previewRows(text, width - PREVIEW_GUTTER.length).slice(-Math.max(1, maxRows))
115
116 return (
117 <Box flexDirection="column" width={width}>
118 {rows.map((row, r) => (
119 <Box flexDirection="row" alignSelf={row.isRtl ? 'flex-end' : 'flex-start'}>
120 <Text>{row.spans.map(span => spanElement(els, span, false))}</Text>
121 {row.isRtl && <Text dimColor>{LRM + (r === 0 ? PREVIEW_GUTTER : ' ')}</Text>}
122 </Box>
123 ))}
124 </Box>
125 )
126}
127
128// The band above the prompt is shared with the engine and other plugins: what
129// they draw there stays, and the preview goes under it, next to the prompt.
130export function drawBand(els: Elements, beneath: RenderElement, text: string, width: number, maxRows: number) {
131 const { Box } = els
132
133 return (
134 <Box flexDirection="column">
135 {beneath}
136 {drawPreview(els, text, width, maxRows)}
137 </Box>
138 )
139}
140
141const HELP = [
142 'Usage: /rtl [on | off] RTL layout of replies',
143 ' /rtl input [on | off] live preview of an RTL draft above the prompt',
144 ' /rtl mode [auto | claude | terminal]',
145 ' who reorders RTL rows: claude is Claude Code',
146 ' itself (Windows Terminal, conhost, VS Code), where',
147 ' the plugin lays them out; terminal is the',
148 " terminal's own engine (Linux, macOS), left to it;",
149 ' auto works it out',
150].join('\n')
151
152// Who reorders rows in this session, from the stored mode and the environment.
153async function resolveReorderedBy($: EngineInterface, mode: Mode): Promise<'claude' | 'terminal'> {
154 const os = await $.env.get('OS')
155 const termProgram = await $.env.get('TERM_PROGRAM')
156 return reorderedBy(mode, { os, termProgram })
157}
158
159// Whether this terminal shows clickable links, as Claude Code decides it.
160async function resolveHasLinks($: EngineInterface): Promise<boolean> {
161 return hasHyperlinks({
162 forceHyperlink: await $.env.get('FORCE_HYPERLINK'),
163 wtSession: await $.env.get('WT_SESSION'),
164 termProgram: await $.env.get('TERM_PROGRAM'),
165 terminalEmulator: await $.env.get('TERMINAL_EMULATOR'),
166 })
167}
168
169export const register: Register = on => {
170 // The session's settings, loaded from the plugin's store when the session
171 // starts (and again on every reload); a change redraws what they shape.
172 let isEnabled = true
173 let isPreviewOn = true
174 let whoReorders: 'claude' | 'terminal' = 'claude'
175 let hasLinks = false
176 let draft = ''
177
178 // Lays rows out only where Claude Code reorders them, and only when on.
179 const isLaidOut = () => isEnabled && whoReorders === 'claude'
180
181 on('session.start', async ($, e, next) => {
182 const saved = await $.store.get('isEnabled')
183 if (typeof saved === 'boolean') isEnabled = saved
184 const savedPreview = await $.store.get('isPreviewOn')
185 if (typeof savedPreview === 'boolean') isPreviewOn = savedPreview
186 whoReorders = await resolveReorderedBy($, asMode(await $.store.get('mode')))
187 hasLinks = await resolveHasLinks($)
188 $.ui.invalidate('ui.render')
189
190 await $.command.register({
191 name: 'rtl',
192 description: 'Right-to-left layout for Arabic, Hebrew, Persian, Urdu and other RTL replies',
193 argumentHint: '[input] on | off | mode auto | claude | terminal',
194 })
195
196 $.clock.every(PREVIEW_CHECK_MS, async () => {
197 if (draft === '') return
198 const box = await $.prompt.read()
199 if (box.text === draft) return
200 draft = box.text
201 $.ui.invalidate('ui.render')
202 })
203
204 return next(e)
205 })
206
207 on('command.run', { command: 'rtl' }, async ($, e) => {
208 const words = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
209
210 if (words[0] === 'mode') {
211 const asked = words[1]
212 if (words.length > 2 || (asked !== undefined && asMode(asked) !== asked)) return { text: HELP }
213 const mode = asMode(asked ?? (await $.store.get('mode')))
214 await $.store.set('mode', mode)
215 whoReorders = await resolveReorderedBy($, mode)
216 $.ui.invalidate('ui.render')
217 return {
218 text:
219 whoReorders === 'claude'
220 ? `RTL mode is ${mode}: Claude Code reorders RTL rows here, and the plugin lays them out.`
221 : `RTL mode is ${mode}: this terminal reorders RTL rows itself, and the plugin leaves them to it.`,
222 }
223 }
224
225 const isInput = words[0] === 'input'
226 const arg = isInput ? words[1] : words[0]
227 if ((arg !== undefined && arg !== 'on' && arg !== 'off') || words.length > (isInput ? 2 : 1)) {
228 return { text: HELP }
229 }
230
231 const choose = (current: boolean) => (arg === 'on' ? true : arg === 'off' ? false : !current)
232
233 if (isInput) {
234 isPreviewOn = choose(isPreviewOn)
235 await $.store.set('isPreviewOn', isPreviewOn)
236 $.ui.invalidate('ui.render')
237 return { text: `RTL input preview is ${isPreviewOn ? 'on' : 'off'}.` }
238 }
239
240 isEnabled = choose(isEnabled)
241 await $.store.set('isEnabled', isEnabled)
242 $.ui.invalidate('ui.render')
243 return { text: `RTL layout is ${isEnabled ? 'on' : 'off'}.` }
244 })
245
246 // Reads the draft after each edit, for the preview; the edit itself goes on
247 // unchanged.
248 on('prompt.edit', async ($, e, next) => {
249 const box = await next(e)
250 draft = box.text
251 $.ui.invalidate('ui.render')
252 return box
253 }).catch(($, e, next) => next(e))
254
255 // Clears the preview once a prompt is sent; the prompt itself goes on
256 // unchanged.
257 on('prompt.submit', async ($, e, next) => {
258 draft = ''
259 $.ui.invalidate('ui.render')
260 return next(e)
261 }).catch(($, e, next) => next(e))
262
263 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
264 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
265 if (!isLaidOut() || !isPreviewOn) return next(e)
266 if (!needsPreview(draft, e.props.bodyColumns - 4)) return next(e)
267
268 const beneath = await next(e)
269 return drawBand($.ui.resolve(e), beneath, draft, e.props.bodyColumns, e.props.maxRows)
270 })
271
272 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
273 if (e.surface !== 'terminal' || !isLaidOut()) return next(e)
274
275 const blocks = splitBlocks(e.props.text)
276 if (!blocks) return next(e)
277
278 const columns = e.viewport?.columns ?? 80
279 const width = Math.max(20, columns - MESSAGE_GUTTER - RIGHT_MARGIN)
280 return drawBlocks($.ui.resolve(e), blocks, width, e.props.isFirstOfReply, hasLinks)
281 })
282
283 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
284 if (e.surface !== 'terminal' || !isLaidOut()) return next(e)
285
286 const columns = e.viewport?.columns ?? 80
287 const text = layoutPlainText(e.props.text, Math.max(20, columns - PROMPT_ROW_CHROME))
288
289 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
290 })
291}
292hooks/bidi.ts 206 lines1// Character facts the layout needs: bidi strength, display width, and the
2// levels the Unicode Bidirectional Algorithm gives a right-to-left paragraph.
3
4import { getEmbeddingLevels, getMirroredCharactersMap } from './vendor/bidi-js/index.js'
5import { WIDE_RANGES } from './wide'
6
7export const RLM = '\u200F'
8export const LRM = '\u200E'
9
10// Strong right-to-left characters (bidi classes R and AL) of every RTL
11// script: Hebrew, Arabic (Persian, Urdu, Pashto, Kurdish, Sindhi, Uyghur...),
12// Syriac, Thaana, NKo, Samaritan, Mandaic, Adlam, Hanifi Rohingya, Yezidi,
13// Mende Kikakui and the old scripts of 10800-10FFF, their presentation forms,
14// and RLM. Digits and combining marks are left out: they are not strong.
15const RTL_LETTER =
16 /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05F4\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u070D\u0710-\u074A\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA-\u0815\u081A\u0824\u0828\u0830-\u0858\u085E-\u088E\u08A0-\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFD3D\uFD50-\uFDFC\uFE70-\uFEFC\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u
17const LETTER = /[\p{L}\u200E]/u
18
19export type Strong = 'R' | 'L' | null
20
21export function strongOf(ch: string): Strong {
22 if (RTL_LETTER.test(ch)) return 'R'
23 if (LETTER.test(ch)) return 'L'
24 return null
25}
26
27export function hasRtl(text: string): boolean {
28 return RTL_LETTER.test(text)
29}
30
31export function firstStrong(text: string): Strong {
32 for (const ch of text) {
33 const dir = strongOf(ch)
34 if (dir) return dir
35 }
36 return null
37}
38
39export function lastStrong(text: string): Strong {
40 const chars = Array.from(text)
41 for (let i = chars.length - 1; i >= 0; i--) {
42 const dir = strongOf(chars[i] ?? '')
43 if (dir) return dir
44 }
45 return null
46}
47
48// Characters that join the cluster before them: marks, joiners, variation
49// selectors, skin tones, tag characters and the keycap.
50const EXTENDS =
51 /[\p{M}\u200C\u200D\uFE00-\uFE0F\u{1F3FB}-\u{1F3FF}\u{E0020}-\u{E007F}\u20E3]/u
52const REGIONAL = /[\u{1F1E6}-\u{1F1FF}]/u
53const NOTHING_DRAWN = /^[\p{M}\p{Cf}\p{Cc}\u2060-\u206F\uFE00-\uFE0F\uFEFF]+$/u
54// What makes a cluster an emoji the terminal draws two wide whatever its base:
55// the emoji selector, a skin tone, a joined sequence, tags or a keycap.
56const EMOJI_MARKS = /[\uFE0F\u200D\u20E3\u{1F3FB}-\u{1F3FF}\u{E0020}-\u{E007F}]/u
57const EMOJI_BASE = /[\p{Extended_Pictographic}#*0-9]/u
58// Drawn as emoji by default; covers emoji newer than the width table.
59const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u
60
61const segmenter =
62 typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
63 ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
64 : null
65
66/** The grapheme clusters of `text`: what a terminal draws as one glyph. */
67export function clusters(text: string): string[] {
68 if (segmenter) return Array.from(segmenter.segment(text), part => part.segment)
69
70 const out: string[] = []
71 let joinNext = false
72 for (const ch of text) {
73 const last = out.length - 1
74 const prev = out[last]
75 const pairsFlag =
76 prev !== undefined && REGIONAL.test(ch) && /^[\u{1F1E6}-\u{1F1FF}]$/u.test(prev)
77 if (prev !== undefined && (joinNext || EXTENDS.test(ch) || pairsFlag)) {
78 out[last] = prev + ch
79 } else {
80 out.push(ch)
81 }
82 joinNext = ch === '\u200D'
83 }
84 return out
85}
86
87function isWide(cp: number): boolean {
88 let lo = 0
89 let hi = WIDE_RANGES.length / 2 - 1
90 while (lo <= hi) {
91 const mid = (lo + hi) >> 1
92 const start = WIDE_RANGES[mid * 2] ?? 0
93 const end = WIDE_RANGES[mid * 2 + 1] ?? 0
94 if (cp < start) hi = mid - 1
95 else if (cp > end) lo = mid + 1
96 else return true
97 }
98 return false
99}
100
101/** Terminal cells one grapheme cluster takes. */
102export function clusterWidth(cluster: string): number {
103 const cp = cluster.codePointAt(0) ?? 0
104 if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || NOTHING_DRAWN.test(cluster)) return 0
105 const base = String.fromCodePoint(cp)
106 if (REGIONAL.test(base)) return 2
107 if (EMOJI_BASE.test(base) && EMOJI_MARKS.test(cluster.slice(base.length))) return 2
108 return isWide(cp) || EMOJI_PRESENTATION.test(base) ? 2 : 1
109}
110
111/**
112 * Terminal cells `text` takes, as Claude Code measures it: marks and format
113 * characters none; East Asian wide and fullwidth characters, emoji drawn as
114 * emoji (by default or by the emoji selector), skin-toned and joined
115 * sequences, flags and keycaps two; the rest one.
116 */
117export function widthOf(text: string): number {
118 let width = 0
119 for (const cluster of clusters(text)) width += clusterWidth(cluster)
120 return width
121}
122
123// Pictographs a terminal draws from a color emoji font two cells wide even
124// when Unicode says they are one: Miscellaneous Symbols, Dingbats, arrows and
125// stars of 2B00, and the emoji planes. Given the emoji selector, they are laid
126// out two wide too, and stop overlapping what follows.
127const PICTOGRAPH_BLOCKS = /[\u2600-\u27BF\u2B00-\u2BFF\u{1F000}-\u{1FAFF}]/u
128const PICTOGRAPH = /\p{Extended_Pictographic}/u
129
130/** `text` with every lone text-style pictograph given the emoji selector. */
131export function asEmoji(text: string): string {
132 if (!PICTOGRAPH_BLOCKS.test(text)) return text
133 return clusters(text)
134 .map(cluster => {
135 const base = String.fromCodePoint(cluster.codePointAt(0) ?? 0)
136 const isTextStyle =
137 cluster === base &&
138 PICTOGRAPH_BLOCKS.test(base) &&
139 PICTOGRAPH.test(base) &&
140 !EMOJI_PRESENTATION.test(base)
141 return isTextStyle ? `${base}\uFE0F` : cluster
142 })
143 .join('')
144}
145
146const OPENERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' }
147const CLOSERS: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
148
149// Where a word begins or ends: a space, an end of the text, or (after a
150// closing bracket) punctuation.
151const BREAK = /\s/
152const AFTER_CLOSE = /[\s.,:;!?\u060C\u061B\u061F]/
153
154/**
155 * Turns each bracket that pairs with nothing the way its place says, as RTL
156 * keyboard layouts often type them backwards (`)x(`, `(x(`, `)x)`): one
157 * between a break and a word opens, one between a word and a break closes;
158 * one with a word or a break on both sides stays as it is (`1)`, `:)`).
159 * `editable` marks the characters that may change (code and links are left as
160 * written); `chars` is changed in place.
161 */
162export function fixTypedBrackets(chars: string[], editable: boolean[]): void {
163 const matched = new Set<number>()
164 const stack: { at: number; close: string }[] = []
165 chars.forEach((ch, i) => {
166 if (!editable[i]) return
167 const close = OPENERS[ch]
168 if (close) {
169 stack.push({ at: i, close })
170 return
171 }
172 for (let s = stack.length - 1; s >= 0; s--) {
173 const open = stack[s]
174 if (open?.close === ch) {
175 matched.add(open.at)
176 matched.add(i)
177 stack.length = s
178 break
179 }
180 }
181 })
182
183 chars.forEach((ch, i) => {
184 if (!editable[i] || matched.has(i)) return
185 const before = chars[i - 1]
186 const after = chars[i + 1]
187 const opensWord = (before === undefined || BREAK.test(before)) && after !== undefined && !BREAK.test(after)
188 const closesWord =
189 before !== undefined && !BREAK.test(before) && (after === undefined || AFTER_CLOSE.test(after))
190 const opener = CLOSERS[ch]
191 const closer = OPENERS[ch]
192 if (opener && opensWord && !closesWord) chars[i] = opener
193 else if (closer && closesWord && !opensWord) chars[i] = closer
194 })
195}
196
197/**
198 * The levels the full Unicode Bidirectional Algorithm gives each UTF-16 unit
199 * of a paragraph of base direction RTL (odd right-to-left, even
200 * left-to-right), and the characters those levels draw mirrored.
201 */
202export function resolveRtl(text: string): { levels: Uint8Array; mirrors: Map<number, string> } {
203 const embedding = getEmbeddingLevels(text, 'rtl')
204 return { levels: embedding.levels, mirrors: getMirroredCharactersMap(text, embedding.levels) }
205}
206hooks/markdown.ts 601 lines1// Splits a reply's markdown into blocks, decides which read right-to-left,
2// and lays those out as rows: inline styles parsed, levels resolved by the
3// Unicode Bidirectional Algorithm, words wrapped to a width, and each row
4// pinned so the terminal's renderer draws exactly those levels.
5
6import {
7 LRM,
8 RLM,
9 asEmoji,
10 clusters,
11 firstStrong,
12 fixTypedBrackets,
13 hasRtl,
14 resolveRtl,
15 widthOf,
16} from './bidi'
17
18export type Span = {
19 text: string
20 bold?: boolean
21 italic?: boolean
22 underline?: boolean
23 strike?: boolean
24 code?: boolean
25 href?: string
26 // The bidi level the paragraph gives this text, once resolved.
27 level?: number
28}
29
30export type RtlBlock = {
31 kind: 'rtl'
32 role: 'paragraph' | 'heading' | 'item' | 'quote'
33 // Each source line starts a row of its own, as the terminal draws them.
34 lines: string[]
35 depth: number
36 marker: string
37 headingLevel: number
38 gapBefore: boolean
39}
40
41export type MarkdownBlock = { kind: 'markdown'; source: string; gapBefore: boolean }
42
43export type Block = RtlBlock | MarkdownBlock
44
45const FENCE = /^\s{0,3}(`{3,}|~{3,})/
46const TABLE_ROW = /^\s*\|/
47const RULE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/
48const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/
49const QUOTE = /^\s{0,3}>\s?(.*)$/
50const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
51const HTML_BLOCK = /^\s{0,3}<\/?[a-zA-Z][^>]*>\s*$/
52
53type Parsed =
54 | { kind: 'markdown'; source: string[]; gapBefore: boolean }
55 | {
56 kind: 'text'
57 role: RtlBlock['role']
58 source: string[]
59 lines: string[]
60 depth: number
61 marker: string
62 headingLevel: number
63 gapBefore: boolean
64 }
65
66function startsBlock(line: string): boolean {
67 return (
68 FENCE.test(line) ||
69 TABLE_ROW.test(line) ||
70 RULE.test(line) ||
71 HEADING.test(line) ||
72 QUOTE.test(line) ||
73 ITEM.test(line)
74 )
75}
76
77function parseBlocks(markdown: string): Parsed[] {
78 const lines = markdown.split('\n')
79 const blocks: Parsed[] = []
80 let gap = false
81 let i = 0
82 const take = () => {
83 const was = gap
84 gap = false
85 return was
86 }
87
88 while (i < lines.length) {
89 const line = lines[i] ?? ''
90 if (line.trim() === '') {
91 gap = blocks.length > 0
92 i++
93 continue
94 }
95
96 const fence = FENCE.exec(line)?.[1]
97 if (fence) {
98 const source = [line]
99 i++
100 while (i < lines.length) {
101 const next = lines[i] ?? ''
102 source.push(next)
103 i++
104 const close = FENCE.exec(next)?.[1]
105 if (close && close[0] === fence[0] && close.length >= fence.length) break
106 }
107 blocks.push({ kind: 'markdown', source, gapBefore: take() })
108 continue
109 }
110
111 if (TABLE_ROW.test(line)) {
112 const source: string[] = []
113 while (i < lines.length && TABLE_ROW.test(lines[i] ?? '')) source.push(lines[i++] ?? '')
114 blocks.push({ kind: 'markdown', source, gapBefore: take() })
115 continue
116 }
117
118 if (RULE.test(line) || HTML_BLOCK.test(line)) {
119 blocks.push({ kind: 'markdown', source: [line], gapBefore: take() })
120 i++
121 continue
122 }
123
124 const heading = HEADING.exec(line)
125 if (heading) {
126 blocks.push({
127 kind: 'text',
128 role: 'heading',
129 source: [line],
130 lines: [heading[2] ?? ''],
131 depth: 0,
132 marker: '',
133 headingLevel: heading[1]?.length ?? 1,
134 gapBefore: take(),
135 })
136 i++
137 continue
138 }
139
140 if (QUOTE.test(line)) {
141 const source: string[] = []
142 const body: string[] = []
143 while (i < lines.length && QUOTE.test(lines[i] ?? '')) {
144 source.push(lines[i] ?? '')
145 body.push(QUOTE.exec(lines[i] ?? '')?.[1] ?? '')
146 i++
147 }
148 blocks.push({
149 kind: 'text',
150 role: 'quote',
151 source,
152 lines: body,
153 depth: 0,
154 marker: '',
155 headingLevel: 0,
156 gapBefore: take(),
157 })
158 continue
159 }
160
161 const item = ITEM.exec(line)
162 if (item) {
163 const indent = widthOf((item[1] ?? '').replace(/\t/g, ' '))
164 const source = [line]
165 const body = [item[3] ?? '']
166 i++
167 while (i < lines.length) {
168 const next = lines[i] ?? ''
169 if (next.trim() === '' || startsBlock(next)) break
170 source.push(next)
171 body.push(next.trim())
172 i++
173 }
174 blocks.push({
175 kind: 'text',
176 role: 'item',
177 source,
178 lines: body,
179 depth: Math.floor(indent / 2),
180 marker: item[2] ?? '-',
181 headingLevel: 0,
182 gapBefore: take(),
183 })
184 continue
185 }
186
187 const source: string[] = []
188 while (i < lines.length) {
189 const next = lines[i] ?? ''
190 if (next.trim() === '' || (source.length > 0 && startsBlock(next))) break
191 source.push(next)
192 i++
193 }
194 blocks.push({
195 kind: 'text',
196 role: 'paragraph',
197 source,
198 lines: source.map(l => l.trim()),
199 depth: 0,
200 marker: '',
201 headingLevel: 0,
202 gapBefore: take(),
203 })
204 }
205 return blocks
206}
207
208// What a reader sees of a text: code spans, link targets, tags and URLs out.
209function visibleText(text: string): string {
210 return text
211 .replace(/(`+)[^`]*?\1/g, ' ')
212 .replace(/\]\([^)]*\)/g, ']')
213 .replace(/<[^>\s]+>/g, ' ')
214 .replace(/(?:https?:\/\/|www\.)\S+/g, ' ')
215}
216
217function countWords(text: string): { rtl: number; ltr: number } {
218 const count = { rtl: 0, ltr: 0 }
219 for (const word of visibleText(text).split(/\s+/)) {
220 const dir = firstStrong(word)
221 if (dir === 'R') count.rtl++
222 else if (dir === 'L') count.ltr++
223 }
224 return count
225}
226
227/**
228 * Whether a paragraph reads right-to-left: it opens with an RTL letter, as a
229 * browser's `dir="auto"` decides, or at least a third of its words are RTL,
230 * or it holds any RTL word in a reply that is RTL as a whole.
231 */
232export function isRtlText(text: string, isRtlContext = false): boolean {
233 const visible = visibleText(text)
234 const { rtl, ltr } = countWords(visible)
235 if (rtl === 0) return false
236 return firstStrong(visible) === 'R' || isRtlContext || rtl * 2 >= ltr
237}
238
239function dedent(lines: string[]): string[] {
240 const indents = lines.filter(l => l.trim() !== '').map(l => /^\s*/.exec(l)?.[0].length ?? 0)
241 const min = Math.min(...indents)
242 return min > 0 && Number.isFinite(min) ? lines.map(l => l.slice(min)) : lines
243}
244
245/**
246 * The blocks of `markdown`: right-to-left prose as `rtl` blocks, and every
247 * stretch of anything else (code, tables, English prose) as one `markdown`
248 * block to draw as the terminal always does. Null when nothing reads RTL.
249 */
250export function splitBlocks(markdown: string): Block[] | null {
251 if (!hasRtl(markdown)) return null
252 const parsed = parseBlocks(markdown)
253 const prose = parsed.flatMap(b => (b.kind === 'text' ? b.lines : []))
254 const total = countWords(prose.join('\n'))
255 const isRtlMessage = total.rtl > 0 && total.rtl * 2 >= total.ltr
256
257 const blocks: Block[] = []
258 let pending: { lines: string[]; gapBefore: boolean } | null = null
259 const flush = () => {
260 if (pending) blocks.push({ kind: 'markdown', source: dedent(pending.lines).join('\n'), gapBefore: pending.gapBefore })
261 pending = null
262 }
263 for (const block of parsed) {
264 if (block.kind === 'text' && isRtlText(block.lines.join(' '), isRtlMessage)) {
265 flush()
266 blocks.push({
267 kind: 'rtl',
268 role: block.role,
269 lines: block.lines,
270 depth: block.depth,
271 marker: block.marker,
272 headingLevel: block.headingLevel,
273 gapBefore: block.gapBefore,
274 })
275 continue
276 }
277 if (pending) {
278 if (block.gapBefore) pending.lines.push('')
279 pending.lines.push(...block.source)
280 } else {
281 pending = { lines: [...block.source], gapBefore: block.gapBefore }
282 }
283 }
284 flush()
285 return blocks.some(b => b.kind === 'rtl') ? blocks : null
286}
287
288const ESCAPABLE = /^\\([\\`*_{}[\]()#+\-.!~|<>])/
289const CODE = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/
290const STRONG = /^(?:\*\*(?=\S)([\s\S]*?\S)\*\*|__(?=\S)([\s\S]*?\S)__(?![\p{L}\p{N}]))/u
291const STRIKE = /^~~(?=\S)([\s\S]*?\S)~~/
292const EMPHASIS = /^(?:\*(?=[^\s*])([\s\S]*?[^\s*])\*(?!\*)|_(?=\S)([\s\S]*?\S)_(?![\p{L}\p{N}]))/u
293const LINK = /^\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/
294const AUTOLINK = /^<((?:https?|mailto):[^>\s]+)>/
295const BARE_URL = /^(?:https?:\/\/|www\.)[^\s<]*[^\s<.,:;"')\]!?،؛؟]/
296
297type Style = Omit<Span, 'text'>
298
299/** The styled spans of one line of inline markdown. */
300export function parseInline(src: string, style: Style = {}): Span[] {
301 const out: Span[] = []
302 let buf = ''
303 const flush = () => {
304 if (buf) out.push({ ...style, text: buf })
305 buf = ''
306 }
307 let i = 0
308 while (i < src.length) {
309 const rest = src.slice(i)
310 const prev = i > 0 ? (src[i - 1] ?? '') : ''
311 let m: RegExpExecArray | null
312
313 if ((m = ESCAPABLE.exec(rest))) {
314 buf += m[1]
315 i += m[0].length
316 continue
317 }
318 if ((m = CODE.exec(rest))) {
319 flush()
320 const body = m[2] ?? ''
321 const text = /^ .* $/.test(body) && body.trim() !== '' ? body.slice(1, -1) : body
322 out.push({ ...style, code: true, text })
323 i += m[0].length
324 continue
325 }
326 if ((m = STRONG.exec(rest))) {
327 flush()
328 out.push(...parseInline(m[1] ?? m[2] ?? '', { ...style, bold: true }))
329 i += m[0].length
330 continue
331 }
332 if ((m = STRIKE.exec(rest))) {
333 flush()
334 out.push(...parseInline(m[1] ?? '', { ...style, strike: true }))
335 i += m[0].length
336 continue
337 }
338 if ((rest[0] === '*' || !/[\p{L}\p{N}]/u.test(prev)) && (m = EMPHASIS.exec(rest))) {
339 flush()
340 out.push(...parseInline(m[1] ?? m[2] ?? '', { ...style, italic: true }))
341 i += m[0].length
342 continue
343 }
344 if ((m = LINK.exec(rest))) {
345 flush()
346 const label = m[1] ?? ''
347 out.push(...parseInline(label === '' ? (m[2] ?? '') : label, { ...style, href: m[2] ?? '' }))
348 i += m[0].length
349 continue
350 }
351 if ((m = AUTOLINK.exec(rest))) {
352 flush()
353 out.push({ ...style, href: m[1] ?? '', text: m[1] ?? '' })
354 i += m[0].length
355 continue
356 }
357 if ((prev === '' || /[\s(]/.test(prev)) && (m = BARE_URL.exec(rest))) {
358 flush()
359 out.push({ ...style, href: m[0], text: m[0] })
360 i += m[0].length
361 continue
362 }
363 buf += src[i]
364 i++
365 }
366 flush()
367 return out
368}
369
370/**
371 * The spans of a line for a terminal that can't show clickable links, written
372 * out as Claude Code's own markdown writes them there: a link's URL after its
373 * text, in brackets, unless the text is the URL itself.
374 */
375export function writeOutLinks(spans: Span[]): Span[] {
376 const out: Span[] = []
377 let label = ''
378 spans.forEach((span, n) => {
379 out.push(span)
380 const href = span.href
381 if (!href) return
382 label += span.text
383 if (spans[n + 1]?.href === href) return
384 if (label !== href) {
385 const style = { bold: span.bold, italic: span.italic, underline: span.underline, strike: span.strike }
386 out.push({ ...style, text: ' ' }, { ...style, href, text: `(${href})` })
387 }
388 label = ''
389 })
390 return out
391}
392
393function sameStyle(a: Span, b: Span): boolean {
394 return (
395 !!a.bold === !!b.bold &&
396 !!a.italic === !!b.italic &&
397 !!a.underline === !!b.underline &&
398 !!a.strike === !!b.strike &&
399 !!a.code === !!b.code &&
400 a.href === b.href &&
401 a.level === b.level
402 )
403}
404
405function merge(spans: Span[]): Span[] {
406 const out: Span[] = []
407 for (const span of spans) {
408 const last = out[out.length - 1]
409 if (last && sameStyle(last, span)) last.text += span.text
410 else out.push({ ...span })
411 }
412 return out
413}
414
415// Cuts a word wider than a row into pieces that fit, never inside a cluster.
416function splitWide(piece: Span, width: number): Span[] {
417 const out: Span[] = []
418 let text = ''
419 for (const ch of clusters(piece.text)) {
420 if (text !== '' && widthOf(text + ch) > width) {
421 out.push({ ...piece, text })
422 text = ''
423 }
424 text += ch
425 }
426 if (text !== '') out.push({ ...piece, text })
427 return out
428}
429
430/** Wraps spans into rows no wider than `width` cells, breaking at spaces. */
431export function wrapSpans(spans: Span[], width: number): Span[][] {
432 const room = Math.max(1, width)
433 const pieces = spans.flatMap(span =>
434 span.text
435 .split(/(\s+)/)
436 .filter(text => text !== '')
437 .map(text => ({ ...span, text })),
438 )
439 const rows: Span[][] = []
440 let row: Span[] = []
441 let used = 0
442 const push = () => {
443 while (row.length > 0 && /^\s+$/.test(row[row.length - 1]?.text ?? '')) row.pop()
444 rows.push(merge(row))
445 row = []
446 used = 0
447 }
448 for (const piece of pieces) {
449 const isSpace = /^\s+$/.test(piece.text)
450 if (isSpace) {
451 if (row.length === 0) continue
452 const w = widthOf(piece.text)
453 if (used + w > room) push()
454 else {
455 row.push({ ...piece, text: ' ' })
456 used += 1
457 }
458 continue
459 }
460 const w = widthOf(piece.text)
461 if (used + w <= room) {
462 row.push(piece)
463 used += w
464 continue
465 }
466 if (row.length > 0) push()
467 if (w <= room) {
468 row.push(piece)
469 used = w
470 continue
471 }
472 const parts = splitWide(piece, room)
473 parts.slice(0, -1).forEach(part => {
474 row.push(part)
475 push()
476 })
477 const lastPart = parts[parts.length - 1]
478 if (lastPart) {
479 row.push(lastPart)
480 used = widthOf(lastPart.text)
481 }
482 }
483 if (row.length > 0 || rows.length === 0) push()
484 return rows
485}
486
487/**
488 * Readies the spans of an RTL line for wrapping: bracket pairs typed
489 * backwards put the right way round, and text-style pictographs given the
490 * emoji selector so they take the two cells a terminal draws them in. Code
491 * and links are left as written.
492 */
493export function prepareSpans(spans: Span[]): Span[] {
494 const chars: string[] = []
495 const editable: boolean[] = []
496 const lengths = spans.map(span => {
497 const own = Array.from(span.text)
498 const isEditable = !span.code && !span.href
499 for (const ch of own) {
500 chars.push(ch)
501 editable.push(isEditable)
502 }
503 return own.length
504 })
505 fixTypedBrackets(chars, editable)
506
507 let at = 0
508 return spans.map((span, n) => {
509 const count = lengths[n] ?? 0
510 const text = chars.slice(at, at + count).join('')
511 at += count
512 return { ...span, text: span.code || span.href ? text : asEmoji(text) }
513 })
514}
515
516const LRI = '\u2066'
517const PDI = '\u2069'
518
519// Punctuation that opens a Latin word: a slash command (`/rtl`), a flag
520// (`--model`), a file type (`.env`) or a path (`~/notes`, `@scope/pkg`).
521const LATIN_LEAD = /(^|[\s([{\u00ab"'])([/.\-~@#$]+)(?=[A-Za-z])/g
522// Stands in for code and links when looking for those words, so punctuation
523// right after them never counts as opening one.
524const HELD_OUT = '\uFFFC'
525
526// Opens a row with RLM and puts each run between two marks of its level's
527// direction, RLM for odd and LRM for even: a renderer that knows only strong
528// characters then has nothing of its own left to resolve.
529function pinRow(row: Span[]): Span[] {
530 const pinned = row.map(span => {
531 const mark = (span.level ?? 1) % 2 === 1 ? RLM : LRM
532 return { ...span, text: mark + span.text + mark }
533 })
534 const first = pinned[0]
535 if (first && !first.text.startsWith(RLM)) first.text = RLM + first.text
536 return pinned
537}
538
539/**
540 * Lays out one right-to-left paragraph of styled spans as rows of at most
541 * `width` cells, for a renderer that reorders each row by its strong
542 * characters alone (no embeddings, overrides or isolates) and mirrors
543 * nothing, as Claude Code's own does on Windows terminals and VS Code's.
544 *
545 * Levels come from the full Unicode Bidirectional Algorithm, resolved once
546 * for the whole paragraph as a browser does, with code and links isolated
547 * left-to-right as a browser isolates `<code>`. Characters at odd levels are
548 * drawn mirrored, and every row is pinned to those levels.
549 *
550 * Punctuation that opens a Latin word in prose (`/rtl`, `--model`, `.env`)
551 * is joined to the word: on its own the algorithm gives it the paragraph's
552 * direction and draws it on the word's far side (`rtl/`). An LRM before it,
553 * there for resolving only, keeps it on the left, where the reader expects it.
554 */
555export function layoutRtl(spans: Span[], width: number): Span[][] {
556 const prepared = prepareSpans(spans)
557 const plain = prepared
558 .map(span => (span.code || span.href ? HELD_OUT.repeat(span.text.length) : span.text))
559 .join('')
560 const leads = new Set<number>()
561 for (const match of plain.matchAll(LATIN_LEAD)) leads.add((match.index ?? 0) + (match[1]?.length ?? 0))
562
563 let paragraph = ''
564 let offset = 0
565 const places: number[][] = []
566 for (const span of prepared) {
567 const isIsolated = span.code || span.href
568 if (isIsolated) paragraph += LRI
569 const own: number[] = []
570 for (let i = 0; i < span.text.length; i++) {
571 if (leads.has(offset + i)) paragraph += LRM
572 own.push(paragraph.length)
573 paragraph += span.text[i]
574 }
575 places.push(own)
576 offset += span.text.length
577 if (isIsolated) paragraph += PDI
578 }
579 const { levels, mirrors } = resolveRtl(paragraph)
580
581 const runs: Span[] = []
582 prepared.forEach((span, n) => {
583 const own = places[n] ?? []
584 for (let i = 0; i < span.text.length; i++) {
585 const at = own[i] ?? 0
586 const unit = { ...span, text: mirrors.get(at) ?? span.text[i] ?? '', level: levels[at] ?? 1 }
587 const last = runs[runs.length - 1]
588 if (last && sameStyle(last, unit)) last.text += unit.text
589 else runs.push(unit)
590 }
591 })
592 return wrapSpans(runs, width).map(pinRow)
593}
594
595/** Marker drawn at a list item's right, in the order the eye meets it. */
596export function itemMarker(marker: string, depth: number): string {
597 const ordered = /^(\d+)[.)]$/.exec(marker)
598 if (ordered) return `.${ordered[1] ?? ''}`
599 return ['•', '◦', '▪'][depth % 3] ?? '•'
600}
601hooks/preview.ts 54 lines1// Plain text the person wrote, as opposed to a reply's markdown: the draft in
2// the prompt box and the prompt rows of the transcript. The box draws a draft
3// left-aligned with brackets unmirrored, each wrapped row in its own
4// direction, and its text is what gets sent, so it is left alone and
5// previewed above; a prompt row is redrawn in place, the model's copy
6// untouched.
7
8import { hasRtl, strongOf, widthOf } from './bidi'
9import { isRtlText, layoutRtl, wrapSpans } from './markdown'
10import type { Span } from './markdown'
11
12const MIRRORABLE = /[()[\]{}<>«»‹›]/
13
14/**
15 * Whether the prompt box would draw `draft` wrong at `width` cells: RTL text
16 * mixed with Latin words, holding brackets, over several lines, or wrapping.
17 */
18export function needsPreview(draft: string, width: number): boolean {
19 if (!hasRtl(draft)) return false
20 if (draft.includes('\n') || MIRRORABLE.test(draft)) return true
21 if (widthOf(draft) > width) return true
22 return Array.from(draft).some(ch => strongOf(ch) === 'L')
23}
24
25export type PreviewRow = { isRtl: boolean; spans: Span[] }
26
27/** The rows of plain `text` at `width` cells, each RTL row ready to draw. */
28export function previewRows(text: string, width: number): PreviewRow[] {
29 return text.split('\n').flatMap(line => {
30 const isRtl = isRtlText(line)
31 const rows = isRtl ? layoutRtl([{ text: line }], width) : wrapSpans([{ text: line }], width)
32 return rows.map(spans => ({ isRtl, spans }))
33 })
34}
35
36/**
37 * Plain `text` rewritten for a renderer that keeps it as one block: each RTL
38 * line laid out at `width` cells by hand, every row pinned to the levels the
39 * Unicode Bidirectional Algorithm gives it. Text with no RTL comes back
40 * unchanged.
41 */
42export function layoutPlainText(text: string, width: number): string {
43 if (!hasRtl(text)) return text
44 return text
45 .split('\n')
46 .map(line => {
47 if (!isRtlText(line)) return line
48 return layoutRtl([{ text: line }], width)
49 .map(row => row.map(span => span.text).join(''))
50 .join('\n')
51 })
52 .join('\n')
53}
54hooks/target.ts 57 lines1// Who reorders RTL rows once drawn, and so whether this plugin lays them out.
2//
3// Claude Code reorders RTL rows itself, by the bidi rules from strong
4// characters (RLM and LRM included), in the terminals with no bidi of their
5// own: Windows Terminal and conhost (its changelog, 2.1.74) and VS Code's
6// terminal on every system (seen in CI). There the plugin lays rows out.
7//
8// Elsewhere (Linux and macOS terminals) Claude Code writes rows in logical
9// order and the terminal's own engine reorders them; Claude Code drops RLM and
10// LRM on the way and turns isolates into U+FFFD (seen in CI), so no standard
11// direction control reaches that engine, and the plugin leaves rows to it.
12
13export type Mode = 'auto' | 'claude' | 'terminal'
14
15export type Environment = { os?: string; termProgram?: string }
16
17export function asMode(value: unknown): Mode {
18 return value === 'claude' || value === 'terminal' ? value : 'auto'
19}
20
21/** Who reorders rows in `environment` under `mode`. */
22export function reorderedBy(mode: Mode, environment: Environment): 'claude' | 'terminal' {
23 if (mode !== 'auto') return mode
24 const isWindows = environment.os === 'Windows_NT'
25 const isVsCode = environment.termProgram === 'vscode'
26 return isWindows || isVsCode ? 'claude' : 'terminal'
27}
28
29// Whether Claude Code draws links here as clickable spans (OSC 8). Where it
30// can't, its Link element prints the URL after the text, which a laid-out row
31// has no room for, so the plugin writes links out itself, as Claude Code's own
32// markdown does there.
33//
34// Claude Code's check (2.1.296), for the terminals the plugin lays out in:
35// FORCE_HYPERLINK decides when set; Windows Terminal and JetBrains' terminal
36// have links; conhost and VS Code's terminal don't. Its `hyperlinks` setting
37// isn't readable from a plugin. Unsure counts as no: a link written out stays
38// readable, while a link drawn where there are none breaks the row.
39
40export type LinkEnvironment = {
41 forceHyperlink?: string
42 wtSession?: string
43 termProgram?: string
44 terminalEmulator?: string
45}
46
47const LINKING_PROGRAMS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal']
48
49/** Whether Claude Code shows clickable links in `environment`. */
50export function hasHyperlinks(environment: LinkEnvironment): boolean {
51 const force = environment.forceHyperlink
52 if (force !== undefined && force !== '') return parseInt(force, 10) !== 0
53 if (environment.wtSession !== undefined) return true
54 if (environment.termProgram !== undefined && LINKING_PROGRAMS.includes(environment.termProgram)) return true
55 return environment.terminalEmulator === 'JetBrains-JediTerm'
56}
57hooks/vendor/bidi-js/index.js 6 lines1export { getEmbeddingLevels } from './embeddingLevels.js'
2export { getReorderSegments, getReorderedIndices, getReorderedString } from './reordering.js'
3export { getBidiCharType, getBidiCharTypeName } from './charTypes.js'
4export { getMirroredCharacter, getMirroredCharactersMap } from './mirroring.js'
5export { closingToOpeningBracket, openingToClosingBracket, getCanonicalBracket } from './brackets.js'
6hooks/wide.ts 38 lines1// Code point ranges whose East Asian Width is W or F, from the Unicode
2// Character Database 17.0.0 (EastAsianWidth.txt), merged, as start, end
3// pairs: what a terminal draws two cells wide.
4
5export const WIDE_RANGES: readonly number[] = [
6 0x1100, 0x115f, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec,
7 0x23f0, 0x23f0, 0x23f3, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615,
8 0x2630, 0x2637, 0x2648, 0x2653, 0x267f, 0x267f, 0x268a, 0x268f,
9 0x2693, 0x2693, 0x26a1, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be,
10 0x26c4, 0x26c5, 0x26ce, 0x26ce, 0x26d4, 0x26d4, 0x26ea, 0x26ea,
11 0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa, 0x26fa, 0x26fd, 0x26fd,
12 0x2705, 0x2705, 0x270a, 0x270b, 0x2728, 0x2728, 0x274c, 0x274c,
13 0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795, 0x2797,
14 0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50,
15 0x2b55, 0x2b55, 0x2e80, 0x2e99, 0x2e9b, 0x2ef3, 0x2f00, 0x2fd5,
16 0x2ff0, 0x303e, 0x3041, 0x3096, 0x3099, 0x30ff, 0x3105, 0x312f,
17 0x3131, 0x318e, 0x3190, 0x31e5, 0x31ef, 0x321e, 0x3220, 0x3247,
18 0x3250, 0xa48c, 0xa490, 0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3,
19 0xf900, 0xfaff, 0xfe10, 0xfe19, 0xfe30, 0xfe52, 0xfe54, 0xfe66,
20 0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0, 0xffe6, 0x16fe0, 0x16fe4,
21 0x16ff0, 0x16ff6, 0x17000, 0x18cd5, 0x18cff, 0x18d1e, 0x18d80, 0x18df2,
22 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd, 0x1affe, 0x1b000, 0x1b122,
23 0x1b132, 0x1b132, 0x1b150, 0x1b152, 0x1b155, 0x1b155, 0x1b164, 0x1b167,
24 0x1b170, 0x1b2fb, 0x1d300, 0x1d356, 0x1d360, 0x1d376, 0x1f004, 0x1f004,
25 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a, 0x1f200, 0x1f202,
26 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265,
27 0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393,
28 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4,
29 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d,
30 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596,
31 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc,
32 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d8, 0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec,
33 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a,
34 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa8a,
35 0x1fa8e, 0x1fac6, 0x1fac8, 0x1fac8, 0x1facd, 0x1fadc, 0x1fadf, 0x1faea,
36 0x1faef, 0x1faf8, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
37]
38hooks/vendor/bidi-js/embeddingLevels.js 691 lines1import {
2 BN_LIKE_TYPES,
3 getBidiCharType,
4 ISOLATE_INIT_TYPES,
5 NEUTRAL_ISOLATE_TYPES,
6 STRONG_TYPES,
7 TRAILING_TYPES,
8 TYPES
9} from './charTypes.js'
10import { closingToOpeningBracket, getCanonicalBracket, openingToClosingBracket } from './brackets.js'
11
12// Local type aliases
13const {
14 L: TYPE_L,
15 R: TYPE_R,
16 EN: TYPE_EN,
17 ES: TYPE_ES,
18 ET: TYPE_ET,
19 AN: TYPE_AN,
20 CS: TYPE_CS,
21 B: TYPE_B,
22 S: TYPE_S,
23 ON: TYPE_ON,
24 BN: TYPE_BN,
25 NSM: TYPE_NSM,
26 AL: TYPE_AL,
27 LRO: TYPE_LRO,
28 RLO: TYPE_RLO,
29 LRE: TYPE_LRE,
30 RLE: TYPE_RLE,
31 PDF: TYPE_PDF,
32 LRI: TYPE_LRI,
33 RLI: TYPE_RLI,
34 FSI: TYPE_FSI,
35 PDI: TYPE_PDI
36} = TYPES
37
38/**
39 * @typedef {object} GetEmbeddingLevelsResult
40 * @property {{start, end, level}[]} paragraphs
41 * @property {Uint8Array} levels
42 */
43
44/**
45 * This function applies the Bidirectional Algorithm to a string, returning the resolved embedding levels
46 * in a single Uint8Array plus a list of objects holding each paragraph's start and end indices and resolved
47 * base embedding level.
48 *
49 * @param {string} string - The input string
50 * @param {"ltr"|"rtl"|"auto"} [baseDirection] - Use "ltr" or "rtl" to force a base paragraph direction,
51 * otherwise a direction will be chosen automatically from each paragraph's contents.
52 * @return {GetEmbeddingLevelsResult}
53 */
54export function getEmbeddingLevels (string, baseDirection) {
55 const MAX_DEPTH = 125
56
57 // Start by mapping all characters to their unicode type, as a bitmask integer
58 const charTypes = new Uint32Array(string.length)
59 for (let i = 0; i < string.length; i++) {
60 charTypes[i] = getBidiCharType(string[i])
61 }
62
63 const charTypeCounts = new Map() //will be cleared at start of each paragraph
64 function changeCharType(i, type) {
65 const oldType = charTypes[i]
66 charTypes[i] = type
67 charTypeCounts.set(oldType, charTypeCounts.get(oldType) - 1)
68 if (oldType & NEUTRAL_ISOLATE_TYPES) {
69 charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) - 1)
70 }
71 charTypeCounts.set(type, (charTypeCounts.get(type) || 0) + 1)
72 if (type & NEUTRAL_ISOLATE_TYPES) {
73 charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1)
74 }
75 }
76
77 const embedLevels = new Uint8Array(string.length)
78 const isolationPairs = new Map() //init->pdi and pdi->init
79
80 // === 3.3.1 The Paragraph Level ===
81 // 3.3.1 P1: Split the text into paragraphs
82 const paragraphs = [] // [{start, end, level}, ...]
83 let paragraph = null
84 for (let i = 0; i < string.length; i++) {
85 if (!paragraph) {
86 paragraphs.push(paragraph = {
87 start: i,
88 end: string.length - 1,
89 // 3.3.1 P2-P3: Determine the paragraph level
90 level: baseDirection === 'rtl' ? 1 : baseDirection === 'ltr' ? 0 : determineAutoEmbedLevel(i, false)
91 })
92 }
93 if (charTypes[i] & TYPE_B) {
94 paragraph.end = i
95 paragraph = null
96 }
97 }
98
99 const FORMATTING_TYPES = TYPE_RLE | TYPE_LRE | TYPE_RLO | TYPE_LRO | ISOLATE_INIT_TYPES | TYPE_PDI | TYPE_PDF | TYPE_B
100 const nextEven = n => n + ((n & 1) ? 1 : 2)
101 const nextOdd = n => n + ((n & 1) ? 2 : 1)
102
103 // Everything from here on will operate per paragraph.
104 for (let paraIdx = 0; paraIdx < paragraphs.length; paraIdx++) {
105 paragraph = paragraphs[paraIdx]
106 const statusStack = [{
107 _level: paragraph.level,
108 _override: 0, //0=neutral, 1=L, 2=R
109 _isolate: 0 //bool
110 }]
111 let stackTop
112 let overflowIsolateCount = 0
113 let overflowEmbeddingCount = 0
114 let validIsolateCount = 0
115 charTypeCounts.clear()
116
117 // === 3.3.2 Explicit Levels and Directions ===
118 for (let i = paragraph.start; i <= paragraph.end; i++) {
119 let charType = charTypes[i]
120 stackTop = statusStack[statusStack.length - 1]
121
122 // Set initial counts
123 charTypeCounts.set(charType, (charTypeCounts.get(charType) || 0) + 1)
124 if (charType & NEUTRAL_ISOLATE_TYPES) {
125 charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1)
126 }
127
128 // Explicit Embeddings: 3.3.2 X2 - X3
129 if (charType & FORMATTING_TYPES) { //prefilter all formatters
130 if (charType & (TYPE_RLE | TYPE_LRE)) {
131 embedLevels[i] = stackTop._level // 5.2
132 const level = (charType === TYPE_RLE ? nextOdd : nextEven)(stackTop._level)
133 if (level <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) {
134 statusStack.push({
135 _level: level,
136 _override: 0,
137 _isolate: 0
138 })
139 } else if (!overflowIsolateCount) {
140 overflowEmbeddingCount++
141 }
142 }
143
144 // Explicit Overrides: 3.3.2 X4 - X5
145 else if (charType & (TYPE_RLO | TYPE_LRO)) {
146 embedLevels[i] = stackTop._level // 5.2
147 const level = (charType === TYPE_RLO ? nextOdd : nextEven)(stackTop._level)
148 if (level <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) {
149 statusStack.push({
150 _level: level,
151 _override: (charType & TYPE_RLO) ? TYPE_R : TYPE_L,
152 _isolate: 0
153 })
154 } else if (!overflowIsolateCount) {
155 overflowEmbeddingCount++
156 }
157 }
158
159 // Isolates: 3.3.2 X5a - X5c
160 else if (charType & ISOLATE_INIT_TYPES) {
161 // X5c - FSI becomes either RLI or LRI
162 if (charType & TYPE_FSI) {
163 charType = determineAutoEmbedLevel(i + 1, true) === 1 ? TYPE_RLI : TYPE_LRI
164 }
165
166 embedLevels[i] = stackTop._level
167 if (stackTop._override) {
168 changeCharType(i, stackTop._override)
169 }
170 const level = (charType === TYPE_RLI ? nextOdd : nextEven)(stackTop._level)
171 if (level <= MAX_DEPTH && overflowIsolateCount === 0 && overflowEmbeddingCount === 0) {
172 validIsolateCount++
173 statusStack.push({
174 _level: level,
175 _override: 0,
176 _isolate: 1,
177 _isolInitIndex: i
178 })
179 } else {
180 overflowIsolateCount++
181 }
182 }
183
184 // Terminating Isolates: 3.3.2 X6a
185 else if (charType & TYPE_PDI) {
186 if (overflowIsolateCount > 0) {
187 overflowIsolateCount--
188 } else if (validIsolateCount > 0) {
189 overflowEmbeddingCount = 0
190 while (!statusStack[statusStack.length - 1]._isolate) {
191 statusStack.pop()
192 }
193 // Add to isolation pairs bidirectional mapping:
194 const isolInitIndex = statusStack[statusStack.length - 1]._isolInitIndex
195 if (isolInitIndex != null) {
196 isolationPairs.set(isolInitIndex, i)
197 isolationPairs.set(i, isolInitIndex)
198 }
199 statusStack.pop()
200 validIsolateCount--
201 }
202 stackTop = statusStack[statusStack.length - 1]
203 embedLevels[i] = stackTop._level
204 if (stackTop._override) {
205 changeCharType(i, stackTop._override)
206 }
207 }
208
209
210 // Terminating Embeddings and Overrides: 3.3.2 X7
211 else if (charType & TYPE_PDF) {
212 if (overflowIsolateCount === 0) {
213 if (overflowEmbeddingCount > 0) {
214 overflowEmbeddingCount--
215 } else if (!stackTop._isolate && statusStack.length > 1) {
216 statusStack.pop()
217 stackTop = statusStack[statusStack.length - 1]
218 }
219 }
220 embedLevels[i] = stackTop._level // 5.2
221 }
222
223 // End of Paragraph: 3.3.2 X8
224 else if (charType & TYPE_B) {
225 embedLevels[i] = paragraph.level
226 }
227 }
228
229 // Non-formatting characters: 3.3.2 X6
230 else {
231 embedLevels[i] = stackTop._level
232 // NOTE: This exclusion of BN seems to go against what section 5.2 says, but is required for test passage
233 if (stackTop._override && charType !== TYPE_BN) {
234 changeCharType(i, stackTop._override)
235 }
236 }
237 }
238
239 // === 3.3.3 Preparations for Implicit Processing ===
240
241 // Remove all RLE, LRE, RLO, LRO, PDF, and BN characters: 3.3.3 X9
242 // Note: Due to section 5.2, we won't remove them, but we'll use the BN_LIKE_TYPES bitset to
243 // easily ignore them all from here on out.
244
245 // 3.3.3 X10
246 // Compute the set of isolating run sequences as specified by BD13
247 const levelRuns = []
248 let currentRun = null
249 let isolationLevel = 0
250 for (let i = paragraph.start; i <= paragraph.end; i++) {
251 const charType = charTypes[i]
252 if (!(charType & BN_LIKE_TYPES)) {
253 const lvl = embedLevels[i]
254 const isIsolInit = charType & ISOLATE_INIT_TYPES
255 const isPDI = charType === TYPE_PDI
256 if (isIsolInit) {
257 isolationLevel++
258 }
259 if (currentRun && lvl === currentRun._level) {
260 currentRun._end = i
261 currentRun._endsWithIsolInit = isIsolInit
262 } else {
263 levelRuns.push(currentRun = {
264 _start: i,
265 _end: i,
266 _level: lvl,
267 _startsWithPDI: isPDI,
268 _endsWithIsolInit: isIsolInit
269 })
270 }
271 if (isPDI) {
272 isolationLevel--
273 }
274 }
275 }
276 const isolatingRunSeqs = [] // [{seqIndices: [], sosType: L|R, eosType: L|R}]
277 for (let runIdx = 0; runIdx < levelRuns.length; runIdx++) {
278 const run = levelRuns[runIdx]
279 if (!run._startsWithPDI || (run._startsWithPDI && !isolationPairs.has(run._start))) {
280 const seqRuns = [currentRun = run]
281 for (let pdiIndex; currentRun && currentRun._endsWithIsolInit && (pdiIndex = isolationPairs.get(currentRun._end)) != null;) {
282 for (let i = runIdx + 1; i < levelRuns.length; i++) {
283 if (levelRuns[i]._start === pdiIndex) {
284 seqRuns.push(currentRun = levelRuns[i])
285 break
286 }
287 }
288 }
289 // build flat list of indices across all runs:
290 const seqIndices = []
291 for (let i = 0; i < seqRuns.length; i++) {
292 const run = seqRuns[i]
293 for (let j = run._start; j <= run._end; j++) {
294 seqIndices.push(j)
295 }
296 }
297 // determine the sos/eos types:
298 let firstLevel = embedLevels[seqIndices[0]]
299 let prevLevel = paragraph.level
300 for (let i = seqIndices[0] - 1; i >= 0; i--) {
301 if (!(charTypes[i] & BN_LIKE_TYPES)) { //5.2
302 prevLevel = embedLevels[i]
303 break
304 }
305 }
306 const lastIndex = seqIndices[seqIndices.length - 1]
307 let lastLevel = embedLevels[lastIndex]
308 let nextLevel = paragraph.level
309 if (!(charTypes[lastIndex] & ISOLATE_INIT_TYPES)) {
310 for (let i = lastIndex + 1; i <= paragraph.end; i++) {
311 if (!(charTypes[i] & BN_LIKE_TYPES)) { //5.2
312 nextLevel = embedLevels[i]
313 break
314 }
315 }
316 }
317 isolatingRunSeqs.push({
318 _seqIndices: seqIndices,
319 _sosType: Math.max(prevLevel, firstLevel) % 2 ? TYPE_R : TYPE_L,
320 _eosType: Math.max(nextLevel, lastLevel) % 2 ? TYPE_R : TYPE_L
321 })
322 }
323 }
324
325 // The next steps are done per isolating run sequence
326 for (let seqIdx = 0; seqIdx < isolatingRunSeqs.length; seqIdx++) {
327 const { _seqIndices: seqIndices, _sosType: sosType, _eosType: eosType } = isolatingRunSeqs[seqIdx]
328 /**
329 * All the level runs in an isolating run sequence have the same embedding level.
330 *
331 * DO NOT change any `embedLevels[i]` within the current scope.
332 */
333 const embedDirection = ((embedLevels[seqIndices[0]]) & 1) ? TYPE_R : TYPE_L;
334
335 // === 3.3.4 Resolving Weak Types ===
336
337 // W1 + 5.2. Search backward from each NSM to the first character in the isolating run sequence whose
338 // bidirectional type is not BN, and set the NSM to ON if it is an isolate initiator or PDI, and to its
339 // type otherwise. If the NSM is the first non-BN character, change the NSM to the type of sos.
340 if (charTypeCounts.get(TYPE_NSM)) {
341 for (let si = 0; si < seqIndices.length; si++) {
342 const i = seqIndices[si]
343 if (charTypes[i] & TYPE_NSM) {
344 let prevType = sosType
345 for (let sj = si - 1; sj >= 0; sj--) {
346 if (!(charTypes[seqIndices[sj]] & BN_LIKE_TYPES)) { //5.2 scan back to first non-BN
347 prevType = charTypes[seqIndices[sj]]
348 break
349 }
350 }
351 changeCharType(i, (prevType & (ISOLATE_INIT_TYPES | TYPE_PDI)) ? TYPE_ON : prevType)
352 }
353 }
354 }
355
356 // W2. Search backward from each instance of a European number until the first strong type (R, L, AL, or sos)
357 // is found. If an AL is found, change the type of the European number to Arabic number.
358 if (charTypeCounts.get(TYPE_EN)) {
359 for (let si = 0; si < seqIndices.length; si++) {
360 const i = seqIndices[si]
361 if (charTypes[i] & TYPE_EN) {
362 for (let sj = si - 1; sj >= -1; sj--) {
363 const prevCharType = sj === -1 ? sosType : charTypes[seqIndices[sj]]
364 if (prevCharType & STRONG_TYPES) {
365 if (prevCharType === TYPE_AL) {
366 changeCharType(i, TYPE_AN)
367 }
368 break
369 }
370 }
371 }
372 }
373 }
374
375 // W3. Change all ALs to R
376 if (charTypeCounts.get(TYPE_AL)) {
377 for (let si = 0; si < seqIndices.length; si++) {
378 const i = seqIndices[si]
379 if (charTypes[i] & TYPE_AL) {
380 changeCharType(i, TYPE_R)
381 }
382 }
383 }
384
385 // W4. A single European separator between two European numbers changes to a European number. A single common
386 // separator between two numbers of the same type changes to that type.
387 if (charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) {
388 for (let si = 1; si < seqIndices.length - 1; si++) {
389 const i = seqIndices[si]
390 if (charTypes[i] & (TYPE_ES | TYPE_CS)) {
391 let prevType = 0, nextType = 0
392 for (let sj = si - 1; sj >= 0; sj--) {
393 prevType = charTypes[seqIndices[sj]]
394 if (!(prevType & BN_LIKE_TYPES)) { //5.2
395 break
396 }
397 }
398 for (let sj = si + 1; sj < seqIndices.length; sj++) {
399 nextType = charTypes[seqIndices[sj]]
400 if (!(nextType & BN_LIKE_TYPES)) { //5.2
401 break
402 }
403 }
404 if (prevType === nextType && (charTypes[i] === TYPE_ES ? prevType === TYPE_EN : (prevType & (TYPE_EN | TYPE_AN)))) {
405 changeCharType(i, prevType)
406 }
407 }
408 }
409 }
410
411 // W5. A sequence of European terminators adjacent to European numbers changes to all European numbers.
412 if (charTypeCounts.get(TYPE_EN)) {
413 for (let si = 0; si < seqIndices.length; si++) {
414 const i = seqIndices[si]
415 if (charTypes[i] & TYPE_EN) {
416 for (let sj = si - 1; sj >= 0 && (charTypes[seqIndices[sj]] & (TYPE_ET | BN_LIKE_TYPES)); sj--) {
417 changeCharType(seqIndices[sj], TYPE_EN)
418 }
419 for (si++; si < seqIndices.length && (charTypes[seqIndices[si]] & (TYPE_ET | BN_LIKE_TYPES | TYPE_EN)); si++) {
420 if (charTypes[seqIndices[si]] !== TYPE_EN) {
421 changeCharType(seqIndices[si], TYPE_EN)
422 }
423 }
424 }
425 }
426 }
427
428 // W6. Otherwise, separators and terminators change to Other Neutral.
429 if (charTypeCounts.get(TYPE_ET) || charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) {
430 for (let si = 0; si < seqIndices.length; si++) {
431 const i = seqIndices[si]
432 if (charTypes[i] & (TYPE_ET | TYPE_ES | TYPE_CS)) {
433 changeCharType(i, TYPE_ON)
434 // 5.2 transform adjacent BNs too:
435 for (let sj = si - 1; sj >= 0 && (charTypes[seqIndices[sj]] & BN_LIKE_TYPES); sj--) {
436 changeCharType(seqIndices[sj], TYPE_ON)
437 }
438 for (let sj = si + 1; sj < seqIndices.length && (charTypes[seqIndices[sj]] & BN_LIKE_TYPES); sj++) {
439 changeCharType(seqIndices[sj], TYPE_ON)
440 }
441 }
442 }
443 }
444
445 // W7. Search backward from each instance of a European number until the first strong type (R, L, or sos)
446 // is found. If an L is found, then change the type of the European number to L.
447 // NOTE: implemented in single forward pass for efficiency
448 if (charTypeCounts.get(TYPE_EN)) {
449 for (let si = 0, prevStrongType = sosType; si < seqIndices.length; si++) {
450 const i = seqIndices[si]
451 const type = charTypes[i]
452 if (type & TYPE_EN) {
453 if (prevStrongType === TYPE_L) {
454 changeCharType(i, TYPE_L)
455 }
456 } else if (type & STRONG_TYPES) {
457 prevStrongType = type
458 }
459 }
460 }
461
462 // === 3.3.5 Resolving Neutral and Isolate Formatting Types ===
463
464 if (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES)) {
465 // N0. Process bracket pairs in an isolating run sequence sequentially in the logical order of the text
466 // positions of the opening paired brackets using the logic given below. Within this scope, bidirectional
467 // types EN and AN are treated as R.
468 const R_TYPES_FOR_N_STEPS = (TYPE_R | TYPE_EN | TYPE_AN)
469 const STRONG_TYPES_FOR_N_STEPS = R_TYPES_FOR_N_STEPS | TYPE_L
470
471 // * Identify the bracket pairs in the current isolating run sequence according to BD16.
472 const bracketPairs = []
473 {
474 const openerStack = []
475 for (let si = 0; si < seqIndices.length; si++) {
476 // NOTE: for any potential bracket character we also test that it still carries a NI
477 // type, as that may have been changed earlier. This doesn't seem to be explicitly
478 // called out in the spec, but is required for passage of certain tests.
479 if (charTypes[seqIndices[si]] & NEUTRAL_ISOLATE_TYPES) {
480 const char = string[seqIndices[si]]
481 let oppositeBracket
482 // Opening bracket
483 if (openingToClosingBracket(char) !== null) {
484 if (openerStack.length < 63) {
485 openerStack.push({ char, seqIndex: si })
486 } else {
487 break
488 }
489 }
490 // Closing bracket
491 else if ((oppositeBracket = closingToOpeningBracket(char)) !== null) {
492 for (let stackIdx = openerStack.length - 1; stackIdx >= 0; stackIdx--) {
493 const stackChar = openerStack[stackIdx].char
494 if (stackChar === oppositeBracket ||
495 stackChar === closingToOpeningBracket(getCanonicalBracket(char)) ||
496 openingToClosingBracket(getCanonicalBracket(stackChar)) === char
497 ) {
498 bracketPairs.push([openerStack[stackIdx].seqIndex, si])
499 openerStack.length = stackIdx //pop the matching bracket and all following
500 break
501 }
502 }
503 }
504 }
505 }
506 bracketPairs.sort((a, b) => a[0] - b[0])
507 }
508 // * For each bracket-pair element in the list of pairs of text positions
509 for (let pairIdx = 0; pairIdx < bracketPairs.length; pairIdx++) {
510 const [openSeqIdx, closeSeqIdx] = bracketPairs[pairIdx]
511 // a. Inspect the bidirectional types of the characters enclosed within the bracket pair.
512 // b. If any strong type (either L or R) matching the embedding direction is found, set the type for both
513 // brackets in the pair to match the embedding direction.
514 let foundStrongType = false
515 let useStrongType = 0
516 for (let si = openSeqIdx + 1; si < closeSeqIdx; si++) {
517 const i = seqIndices[si]
518 if (charTypes[i] & STRONG_TYPES_FOR_N_STEPS) {
519 foundStrongType = true
520 const lr = (charTypes[i] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
521 if (lr === embedDirection) {
522 useStrongType = lr
523 break
524 }
525 }
526 }
527 // c. Otherwise, if there is a strong type it must be opposite the embedding direction. Therefore, test
528 // for an established context with a preceding strong type by checking backwards before the opening paired
529 // bracket until the first strong type (L, R, or sos) is found.
530 // 1. If the preceding strong type is also opposite the embedding direction, context is established, so
531 // set the type for both brackets in the pair to that direction.
532 // 2. Otherwise set the type for both brackets in the pair to the embedding direction.
533 if (foundStrongType && !useStrongType) {
534 useStrongType = sosType
535 for (let si = openSeqIdx - 1; si >= 0; si--) {
536 const i = seqIndices[si]
537 if (charTypes[i] & STRONG_TYPES_FOR_N_STEPS) {
538 const lr = (charTypes[i] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
539 if (lr !== embedDirection) {
540 useStrongType = lr
541 } else {
542 useStrongType = embedDirection
543 }
544 break
545 }
546 }
547 }
548 if (useStrongType) {
549 charTypes[seqIndices[openSeqIdx]] = charTypes[seqIndices[closeSeqIdx]] = useStrongType
550 // * Any number of characters that had original bidirectional character type NSM prior to the application
551 // of W1 that immediately follow a paired bracket which changed to L or R under N0 should change to match
552 // the type of their preceding bracket.
553 if (useStrongType !== embedDirection) {
554 for (let si = openSeqIdx + 1; si < seqIndices.length; si++) {
555 if (!(charTypes[seqIndices[si]] & BN_LIKE_TYPES)) {
556 if (getBidiCharType(string[seqIndices[si]]) & TYPE_NSM) {
557 charTypes[seqIndices[si]] = useStrongType
558 }
559 break
560 }
561 }
562 }
563 if (useStrongType !== embedDirection) {
564 for (let si = closeSeqIdx + 1; si < seqIndices.length; si++) {
565 if (!(charTypes[seqIndices[si]] & BN_LIKE_TYPES)) {
566 if (getBidiCharType(string[seqIndices[si]]) & TYPE_NSM) {
567 charTypes[seqIndices[si]] = useStrongType
568 }
569 break
570 }
571 }
572 }
573 }
574 }
575
576 // N1. A sequence of NIs takes the direction of the surrounding strong text if the text on both sides has the
577 // same direction.
578 // N2. Any remaining NIs take the embedding direction.
579 for (let si = 0; si < seqIndices.length; si++) {
580 if (charTypes[seqIndices[si]] & NEUTRAL_ISOLATE_TYPES) {
581 let niRunStart = si, niRunEnd = si
582 let prevType = sosType //si === 0 ? sosType : (charTypes[seqIndices[si - 1]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
583 for (let si2 = si - 1; si2 >= 0; si2--) {
584 if (charTypes[seqIndices[si2]] & BN_LIKE_TYPES) {
585 niRunStart = si2 //5.2 treat BNs adjacent to NIs as NIs
586 } else {
587 prevType = (charTypes[seqIndices[si2]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
588 break
589 }
590 }
591 let nextType = eosType
592 for (let si2 = si + 1; si2 < seqIndices.length; si2++) {
593 if (charTypes[seqIndices[si2]] & (NEUTRAL_ISOLATE_TYPES | BN_LIKE_TYPES)) {
594 niRunEnd = si2
595 } else {
596 nextType = (charTypes[seqIndices[si2]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
597 break
598 }
599 }
600 for (let sj = niRunStart; sj <= niRunEnd; sj++) {
601 charTypes[seqIndices[sj]] = prevType === nextType ? prevType : embedDirection
602 }
603 si = niRunEnd
604 }
605 }
606 }
607 }
608
609 // === 3.3.6 Resolving Implicit Levels ===
610
611 for (let i = paragraph.start; i <= paragraph.end; i++) {
612 const level = embedLevels[i]
613 const type = charTypes[i]
614 // I2. For all characters with an odd (right-to-left) embedding level, those of type L, EN or AN go up one level.
615 if (level & 1) {
616 if (type & (TYPE_L | TYPE_EN | TYPE_AN)) {
617 embedLevels[i]++
618 }
619 }
620 // I1. For all characters with an even (left-to-right) embedding level, those of type R go up one level
621 // and those of type AN or EN go up two levels.
622 else {
623 if (type & TYPE_R) {
624 embedLevels[i]++
625 } else if (type & (TYPE_AN | TYPE_EN)) {
626 embedLevels[i] += 2
627 }
628 }
629
630 // 5.2: Resolve any LRE, RLE, LRO, RLO, PDF, or BN to the level of the preceding character if there is one,
631 // and otherwise to the base level.
632 if (type & BN_LIKE_TYPES) {
633 embedLevels[i] = i === 0 ? paragraph.level : embedLevels[i - 1]
634 }
635
636 // 3.4 L1.1-4: Reset the embedding level of segment/paragraph separators, and any sequence of whitespace or
637 // isolate formatting characters preceding them or the end of the paragraph, to the paragraph level.
638 // NOTE: this will also need to be applied to each individual line ending after line wrapping occurs.
639 if (i === paragraph.end || getBidiCharType(string[i]) & (TYPE_S | TYPE_B)) {
640 for (let j = i; j >= 0 && (getBidiCharType(string[j]) & TRAILING_TYPES); j--) {
641 embedLevels[j] = paragraph.level
642 }
643 }
644 }
645 }
646
647 // DONE! The resolved levels can then be used, after line wrapping, to flip runs of characters
648 // according to section 3.4 Reordering Resolved Levels
649 return {
650 levels: embedLevels,
651 paragraphs
652 }
653
654 function determineAutoEmbedLevel (start, isFSI) {
655 // 3.3.1 P2 - P3
656 for (let i = start; i < string.length; i++) {
657 const charType = charTypes[i]
658 if (charType & (TYPE_R | TYPE_AL)) {
659 return 1
660 }
661 if ((charType & (TYPE_B | TYPE_L)) || (isFSI && charType === TYPE_PDI)) {
662 return 0
663 }
664 if (charType & ISOLATE_INIT_TYPES) {
665 const pdi = indexOfMatchingPDI(i)
666 i = pdi === -1 ? string.length : pdi
667 }
668 }
669 return 0
670 }
671
672 function indexOfMatchingPDI (isolateStart) {
673 // 3.1.2 BD9
674 let isolationLevel = 1
675 for (let i = isolateStart + 1; i < string.length; i++) {
676 const charType = charTypes[i]
677 if (charType & TYPE_B) {
678 break
679 }
680 if (charType & TYPE_PDI) {
681 if (--isolationLevel === 0) {
682 return i
683 }
684 } else if (charType & ISOLATE_INIT_TYPES) {
685 isolationLevel++
686 }
687 }
688 return -1
689 }
690}
691hooks/vendor/bidi-js/reordering.js 100 lines1import { getBidiCharType, TRAILING_TYPES } from './charTypes.js'
2import { getMirroredCharacter } from './mirroring.js'
3
4/**
5 * Given a start and end denoting a single line within a string, and a set of precalculated
6 * bidi embedding levels, produce a list of segments whose ordering should be flipped, in sequence.
7 * @param {string} string - the full input string
8 * @param {GetEmbeddingLevelsResult} embeddingLevelsResult - the result object from getEmbeddingLevels
9 * @param {number} [start] - first character in a subset of the full string
10 * @param {number} [end] - last character in a subset of the full string
11 * @return {number[][]} - the list of start/end segments that should be flipped, in order.
12 */
13export function getReorderSegments(string, embeddingLevelsResult, start, end) {
14 let strLen = string.length
15 start = Math.max(0, start == null ? 0 : +start)
16 end = Math.min(strLen - 1, end == null ? strLen - 1 : +end)
17
18 const segments = []
19 embeddingLevelsResult.paragraphs.forEach(paragraph => {
20 const lineStart = Math.max(start, paragraph.start)
21 const lineEnd = Math.min(end, paragraph.end)
22 if (lineStart < lineEnd) {
23 // Local slice for mutation
24 const lineLevels = embeddingLevelsResult.levels.slice(lineStart, lineEnd + 1)
25
26 // 3.4 L1.4: Reset any sequence of whitespace characters and/or isolate formatting characters at the
27 // end of the line to the paragraph level.
28 for (let i = lineEnd; i >= lineStart && (getBidiCharType(string[i]) & TRAILING_TYPES); i--) {
29 lineLevels[i] = paragraph.level
30 }
31
32 // L2. From the highest level found in the text to the lowest odd level on each line, including intermediate levels
33 // not actually present in the text, reverse any contiguous sequence of characters that are at that level or higher.
34 let maxLevel = paragraph.level
35 let minOddLevel = Infinity
36 for (let i = 0; i < lineLevels.length; i++) {
37 const level = lineLevels[i]
38 if (level > maxLevel) maxLevel = level
39 if (level < minOddLevel) minOddLevel = level | 1
40 }
41 for (let lvl = maxLevel; lvl >= minOddLevel; lvl--) {
42 for (let i = 0; i < lineLevels.length; i++) {
43 if (lineLevels[i] >= lvl) {
44 const segStart = i
45 while (i + 1 < lineLevels.length && lineLevels[i + 1] >= lvl) {
46 i++
47 }
48 if (i > segStart) {
49 segments.push([segStart + lineStart, i + lineStart])
50 }
51 }
52 }
53 }
54 }
55 })
56 return segments
57}
58
59/**
60 * @param {string} string
61 * @param {GetEmbeddingLevelsResult} embedLevelsResult
62 * @param {number} [start]
63 * @param {number} [end]
64 * @return {string} the new string with bidi segments reordered
65 */
66export function getReorderedString(string, embedLevelsResult, start, end) {
67 const indices = getReorderedIndices(string, embedLevelsResult, start, end)
68 const chars = [...string]
69 indices.forEach((charIndex, i) => {
70 chars[i] = (
71 (embedLevelsResult.levels[charIndex] & 1) ? getMirroredCharacter(string[charIndex]) : null
72 ) || string[charIndex]
73 })
74 return chars.join('')
75}
76
77/**
78 * @param {string} string
79 * @param {GetEmbeddingLevelsResult} embedLevelsResult
80 * @param {number} [start]
81 * @param {number} [end]
82 * @return {number[]} an array with character indices in their new bidi order
83 */
84export function getReorderedIndices(string, embedLevelsResult, start, end) {
85 const segments = getReorderSegments(string, embedLevelsResult, start, end)
86 // Fill an array with indices
87 const indices = []
88 for (let i = 0; i < string.length; i++) {
89 indices[i] = i
90 }
91 // Reverse each segment in order
92 segments.forEach(([start, end]) => {
93 const slice = indices.slice(start, end + 1)
94 for (let i = slice.length; i--;) {
95 indices[end - i] = slice[i]
96 }
97 })
98 return indices
99}
100hooks/vendor/bidi-js/charTypes.js 67 lines1import DATA from './data/bidiCharTypes.data.js'
2
3const TYPES = {}
4const TYPES_TO_NAMES = {}
5TYPES.L = 1 //L is the default
6TYPES_TO_NAMES[1] = 'L'
7Object.keys(DATA).forEach((type, i) => {
8 TYPES[type] = 1 << (i + 1)
9 TYPES_TO_NAMES[TYPES[type]] = type
10})
11Object.freeze(TYPES)
12
13const ISOLATE_INIT_TYPES = TYPES.LRI | TYPES.RLI | TYPES.FSI
14const STRONG_TYPES = TYPES.L | TYPES.R | TYPES.AL
15const NEUTRAL_ISOLATE_TYPES = TYPES.B | TYPES.S | TYPES.WS | TYPES.ON | TYPES.FSI | TYPES.LRI | TYPES.RLI | TYPES.PDI
16const BN_LIKE_TYPES = TYPES.BN | TYPES.RLE | TYPES.LRE | TYPES.RLO | TYPES.LRO | TYPES.PDF
17const TRAILING_TYPES = TYPES.S | TYPES.WS | TYPES.B | ISOLATE_INIT_TYPES | TYPES.PDI | BN_LIKE_TYPES
18
19let map = null
20
21function parseData () {
22 if (!map) {
23 //const start = performance.now()
24 map = new Map()
25 for (let type in DATA) {
26 if (DATA.hasOwnProperty(type)) {
27 let lastCode = 0
28 DATA[type].split(',').forEach(range => {
29 let [skip, step] = range.split('+')
30 skip = parseInt(skip, 36)
31 step = step ? parseInt(step, 36) : 0
32 map.set(lastCode += skip, TYPES[type])
33 for (let i = 0; i < step; i++) {
34 map.set(++lastCode, TYPES[type])
35 }
36 })
37 }
38 }
39 //console.log(`char types parsed in ${performance.now() - start}ms`)
40 }
41}
42
43/**
44 * @param {string} char
45 * @return {number}
46 */
47function getBidiCharType (char) {
48 parseData()
49 return map.get(char.codePointAt(0)) || TYPES.L
50}
51
52function getBidiCharTypeName(char) {
53 return TYPES_TO_NAMES[getBidiCharType(char)]
54}
55
56export {
57 getBidiCharType,
58 getBidiCharTypeName,
59 TYPES,
60 TYPES_TO_NAMES,
61 ISOLATE_INIT_TYPES,
62 STRONG_TYPES,
63 NEUTRAL_ISOLATE_TYPES,
64 BN_LIKE_TYPES,
65 TRAILING_TYPES
66}
67hooks/vendor/bidi-js/mirroring.js 49 lines1import data from './data/bidiMirroring.data.js'
2import { parseCharacterMap } from './util/parseCharacterMap.js'
3
4let mirrorMap
5
6function parse () {
7 if (!mirrorMap) {
8 //const start = performance.now()
9 const { map, reverseMap } = parseCharacterMap(data, true)
10 // Combine both maps into one
11 reverseMap.forEach((value, key) => {
12 map.set(key, value)
13 })
14 mirrorMap = map
15 //console.log(`mirrored chars parsed in ${performance.now() - start}ms`)
16 }
17}
18
19export function getMirroredCharacter (char) {
20 parse()
21 return mirrorMap.get(char) || null
22}
23
24/**
25 * Given a string and its resolved embedding levels, build a map of indices to replacement chars
26 * for any characters in right-to-left segments that have defined mirrored characters.
27 * @param string
28 * @param embeddingLevels
29 * @param [start]
30 * @param [end]
31 * @return {Map<number, string>}
32 */
33export function getMirroredCharactersMap(string, embeddingLevels, start, end) {
34 let strLen = string.length
35 start = Math.max(0, start == null ? 0 : +start)
36 end = Math.min(strLen - 1, end == null ? strLen - 1 : +end)
37
38 const map = new Map()
39 for (let i = start; i <= end; i++) {
40 if (embeddingLevels[i] & 1) { //only odd (rtl) levels
41 const mirror = getMirroredCharacter(string[i])
42 if (mirror !== null) {
43 map.set(i, mirror)
44 }
45 }
46 }
47 return map
48}
49hooks/vendor/bidi-js/brackets.js 31 lines1import data from './data/bidiBrackets.data.js'
2import { parseCharacterMap } from './util/parseCharacterMap.js'
3
4let openToClose, closeToOpen, canonical
5
6function parse () {
7 if (!openToClose) {
8 //const start = performance.now()
9 let { map, reverseMap } = parseCharacterMap(data.pairs, true)
10 openToClose = map
11 closeToOpen = reverseMap
12 canonical = parseCharacterMap(data.canonical, false).map
13 //console.log(`brackets parsed in ${performance.now() - start}ms`)
14 }
15}
16
17export function openingToClosingBracket (char) {
18 parse()
19 return openToClose.get(char) || null
20}
21
22export function closingToOpeningBracket (char) {
23 parse()
24 return closeToOpen.get(char) || null
25}
26
27export function getCanonicalBracket (char) {
28 parse()
29 return canonical.get(char) || null
30}
31