Conversation
RTL layout support
Fix text texture RTL propagation
Fix advanced renderer text alignment mirroring
Fix ES5 package
- allow disabling truncation (Arabic) - WIP wordBreak (normal renderer) - WIP unit tests
Bidirectional text rendering (RTL)
Fix canvas 2d renderer mirroring
Feature: expose lines layout in renderInfo
There was a problem hiding this comment.
Pull request overview
This PR introduces comprehensive Right-to-Left (RTL) layout support and significantly refactors the text rendering system in Lightning Core. The changes enable proper rendering and layout of RTL languages like Arabic and Hebrew, including bidirectional text support.
Changes:
- Added RTL layout support with automatic mirroring of element positioning and input handling
- Complete refactoring of text rendering system from
.mjsto TypeScript with new modular architecture - New bidirectional text tokenization system with optional bidi-js integration
- Performance improvements including optimized idle loop handling and alpha-based rendering optimization
Reviewed changes
Copilot reviewed 51 out of 53 changed files in this pull request and generated 22 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Version update to 2.17.0-beta.6, added bidi-js dependency and bidiTokenizer export |
| vite.config.js | Added BUILD_BIDI_TOKENIZER build configuration and test exclusions |
| src/tree/core/ElementCore.mjs | Implemented RTL layout calculations and direction propagation |
| src/tree/Element.mjs | Added RTL property getter/setter and direction change hooks |
| src/application/Application.mjs | Added direction-aware event routing for Left/Right key swapping |
| src/textures/TextTexture*.ts | Complete rewrite of text rendering in TypeScript with modular architecture |
| src/textures/bidiTokenizer.ts | New bidirectional text tokenizer for RTL language support |
| src/textures/TextTokenizer.ts | New pluggable text tokenization system |
| src/renderer/c2d/shaders/DefaultShader.mjs | Added texture mirroring support for Canvas2D renderer |
| src/platforms/browser/WebPlatform.mjs | Optimized idle loop handling and removed redundant code |
| tests/* | Added comprehensive tests for RTL layout, text mirroring, and bidirectional rendering |
| docs/* | Added RTL documentation and updated text rendering guides |
| playwright.config.ts | Added font rendering flags for consistent screenshot testing |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
tests/textures/test.text.js
Outdated
| app.children = [element]; | ||
| const texture = app.tag("Item").texture; | ||
| stage.drawFrame(); | ||
| console.log(texture.source.renderInfo.lines); |
There was a problem hiding this comment.
Console.log statements should be removed before merging to production. These appear to be debug statements left in the test code.
| for (let i = n - 1; i >= 0; i--) { | ||
| const event = path[i]._getMostSpecificHandledMember(events); | ||
| const target = path[i]; | ||
| const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr; |
There was a problem hiding this comment.
Variable shadowing: the events parameter is shadowed by the local events variable on line 336. This makes the code confusing and could lead to bugs. Consider renaming the local variable to something like targetEvents or resolvedEvents.
tests/textures/test.text.js
Outdated
| app.children = [element]; | ||
| const texture = app.tag("Item").texture; | ||
| stage.drawFrame(); | ||
| console.log(texture.source.renderInfo.lines); |
There was a problem hiding this comment.
Console.log statements should be removed before merging to production. These appear to be debug statements left in the test code.
| let mirroring = sourceW < 0 || sourceH < 0; | ||
| if (mirroring) { | ||
| ctx.save(); | ||
| if (sourceW < 0) { | ||
| drawSourceX = sourceX + sourceW; | ||
| drawSourceW = -sourceW; | ||
| ctx.scale(-1, 1); | ||
| destX = -vc.w; | ||
| } | ||
| if (sourceH < 0) { | ||
| drawSourceY = sourceY + sourceH; | ||
| drawSourceH = -sourceH; | ||
| ctx.scale(1, -1); | ||
| destY = -vc.h; | ||
| } | ||
| } |
There was a problem hiding this comment.
The mirroring logic uses sourceW < 0 || sourceH < 0 to detect mirroring, but doesn't handle the case where the texture dimensions might legitimately be negative for other reasons (if that's possible). Additionally, when both width and height are negative, the scale operations ctx.scale(-1, 1) and ctx.scale(1, -1) are applied separately. These should be combined into a single ctx.scale(-1, -1) for clarity and potential performance improvement.
There was a problem hiding this comment.
No textures aren't meant to have negative dimensions
| stage.drawFrame(); | ||
| chai.assert(texture.source.renderInfo.lines.length === 10); | ||
| chai.assert(texture.source.renderInfo.lines.slice(-1)[0].substr(-6) == 'eget..'); | ||
| chai.assert(getLineText(texture.source.renderInfo.lines.slice(-1)[0]).substr(-5) == 'neq..'); |
There was a problem hiding this comment.
The expected suffix changed from 'eget..' (6 characters) to 'neq..' (5 characters), but the substr(-6) was changed to substr(-5). This suggests the expected text changed. Please verify this is the correct expected value for this test case, as this appears to be a behavioral change rather than just a refactoring.
| let { text, width, style, rtl } = last; | ||
| if (style) { | ||
| ctx.font = style.font; | ||
| } | ||
| const reversed = primaryRtl !== rtl; | ||
| do { | ||
| text = reversed ? trimWordStart(text, rtl) : trimWordEnd(text, rtl); | ||
| width = measureText(ctx, text, letterSpacing); | ||
| } while (width > maxWidth); |
There was a problem hiding this comment.
The initial value of width is unused, since it is always overwritten.
| let { text, width, style, rtl } = last; | |
| if (style) { | |
| ctx.font = style.font; | |
| } | |
| const reversed = primaryRtl !== rtl; | |
| do { | |
| text = reversed ? trimWordStart(text, rtl) : trimWordEnd(text, rtl); | |
| width = measureText(ctx, text, letterSpacing); | |
| } while (width > maxWidth); | |
| let { text, style, rtl } = last; | |
| if (style) { | |
| ctx.font = style.font; | |
| } | |
| const reversed = primaryRtl !== rtl; | |
| let width = measureText(ctx, text, letterSpacing); | |
| while (width > maxWidth) { | |
| text = reversed ? trimWordStart(text, rtl) : trimWordEnd(text, rtl); | |
| width = measureText(ctx, text, letterSpacing); | |
| } |
There was a problem hiding this comment.
No we want to use the value in last instead of calling measureText for the initial value
| entry = resolve(__dirname, 'src/textures/bidiTokenizer.ts'); | ||
| outputBase = 'bidiTokenizer'; | ||
| sourcemap = true; | ||
| useDts = true; |
There was a problem hiding this comment.
The value assigned to useDts here is unused.
src/textures/bidiTokenizer.ts
Outdated
|
|
||
| // https://www.unicode.org/reports/tr9/ | ||
| const reZeroWidthSpace = /[\u200B\u200E\u200F\u061C]/; | ||
| const reDirectionalFormat = /[\u202A\u202B\u202C\u202D\u202E\u202E\u2066\u2067\u2068\u2069]/; |
There was a problem hiding this comment.
Character '\u202E' is repeated in the same character class.
| break; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Avoid automated semicolon insertion (97% of all statements in the enclosing function have an explicit semicolon).
| }; | ||
| default: | ||
| return { | ||
| suffix: textOverflow || maxLinesSuffix, |
There was a problem hiding this comment.
This use of variable 'textOverflow' always evaluates to true.
| suffix: textOverflow || maxLinesSuffix, | |
| suffix: textOverflow, |
|
I see we have a blackduck violation, assuming it's not an actual violation I'll see into getting it waivered |
Address some Copilot feedback
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 53 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr; | ||
| const event = target._getMostSpecificHandledMember(events); |
There was a problem hiding this comment.
Variable name collision: On line 336, the variable events shadows the parameter events from line 326. This could lead to confusion. Consider renaming the local variable to targetEvents to match the pattern used in focusTopDownEvent on line 310.
| const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr; | |
| const event = target._getMostSpecificHandledMember(events); | |
| const targetEvents = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr; | |
| const event = target._getMostSpecificHandledMember(targetEvents); |
| if (wordBreak && wordWidth > wrapWidth) { | ||
| const broken = breakWord(context, word, wrapWidth, letterSpacing); | ||
| let last = broken.pop()!; | ||
| for (const k of broken) { | ||
| resultLines.push({ | ||
| text: k.text, | ||
| width: k.width, | ||
| }); | ||
| } | ||
| result = last.text; | ||
| wordWidth = last.width; | ||
| } |
There was a problem hiding this comment.
The word breaking logic on line 126-137 can produce more lines than maxLines, as noted in the comment. After breaking words, the code should check if resultLines.length >= maxLines and break early if needed, to avoid exceeding the specified limit.
| const maxWidth = maxLineWidth - line.width; | ||
|
|
||
| if (allowTruncation && maxWidth > 0) { | ||
| let { text, width, style, rtl } = last; |
There was a problem hiding this comment.
The initial value of width is unused, since it is always overwritten.
| let { text, width, style, rtl } = last; | |
| let { text, style, rtl } = last; | |
| let width: number; |
No description provided.