Skip to content

Commit 92baf58

Browse files
authored
Merge pull request #568 from elsassph/feat/rtl-layout
RTL layout support
2 parents af939ff + 39060ae commit 92baf58

File tree

17 files changed

+630
-30
lines changed

17 files changed

+630
-30
lines changed

docs/RTL/index.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Right-to-left (RTL) support
2+
3+
Lightning applications may have to be localised for regions where the language is written from right to left, like Hebrew or Arabic. Users expect not only text to be correctly rendered, but also expect the whole application layout to be mirrored. For instance rails would be populated from right to left, and a side navigation on the left would appear on the right instead.
4+
5+
By opposition, the default application layout and text direction is called "left-to-right" (LTR).
6+
7+
RTL support encompasses 2 aspects:
8+
9+
- RTL layout support; which means mirroring the application layout,
10+
- RTL text rendering support; which means accurately rendering (and wrapping) RTL text.
11+
12+
## How RTL layout works
13+
14+
To limit adaption effort for the application developer, Lightning has built-in and transparent support for RTL layout mirroring: leave `x` and flexbox directions as they are for LTR, and they will be interpreted automatically when RTL layout is enabled.
15+
16+
**There is however an important caveat:** in a LTR only application it is often possible to omit specifying a `w` for containers, but for automatic RTL mirroring to function, the widths need to be known, either through an explicit `w` or horizontal flexbox layout.
17+
18+
Here's a simplified diagram of the calculations:
19+
![LTR vs RTL layout calculations](./ltr-rtl.png)
20+
21+
Lightning elements (and components) have a `rtl` property to hint whether the elements children layout should be mirrored.
22+
23+
In practice, setting the application's `rtl` flag will mirror the entire application, as the property is inherited. It is however possible to set some element's `rtl` to an explicit `false` to prevent mirroring of a sub-tree of the application.
24+
25+
### How input works in RTL
26+
27+
A consequence of the choice of transparent mirroring is that the Left and Right key shoud be interpreted in accordance to the layout direction.
28+
29+
This is also automatic, and pressing a Left or Right key will result in the opposite Right or Left key event to be received by components when their layout is mirrored.
30+
31+
### How RTL text works
32+
33+
When the RTL flag is set, text alignement is mirrored, so left-aligned text becomes right-aligned.
34+
35+
But RTL text support also requires to properly wrap text and render punctuation at the right place. Text also may be a combination of RTL and LTR text.
36+
37+
TODO

docs/RTL/ltr-rtl.png

94.3 KB
Loading

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ The Reference Documentation for Lightning Core contains detailed descriptions ab
6969
* [Signal](Communication/Signal.md)
7070
* [Fire Ancestors](Communication/FireAncestors.md)
7171
* [Accessibility](Accessibility/index.md)
72+
* [Right-to-left support](RTL/index.md)
7273
* [TypeScript](TypeScript/index.md)
7374
* [Components](TypeScript/Components/index.md)
7475
* [Template Specs](TypeScript/Components/TemplateSpecs.md)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"author": "Metrological, Bas van Meurs <b.van.meurs@metrological.com>",
33
"name": "@lightningjs/core",
4-
"version": "2.14.1",
4+
"version": "2.16.0-beta.0",
55
"license": "Apache-2.0",
66
"type": "module",
77
"types": "dist/src/index.d.ts",

src/application/Application.d.mts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,9 @@ declare class Application<
186186
*/
187187
get focusPath(): Component[] | undefined;
188188

189-
// focusTopDownEvent(events: any, ...args: any[]): any;
190-
// focusBottomUpEvent(events: any, ...args: any[]): any;
189+
// getDirectionAwareEvents(events: string[], ...args: any[]): { eventsLtr: string[], eventsRtl: string[], isHorizontalDirection };
190+
// focusTopDownEvent(events: string[], ...args: any[]): any;
191+
// focusBottomUpEvent(events: string[], ...args: any[]): any;
191192
// _receiveKeydown(e: KeyboardEvent): void;
192193
// _receiveKeyup(e: KeyboardEvent): void;
193194
// _startLongpressTimer(key: any, element: any): void;

src/application/Application.mjs

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export default class Application extends Component {
3636
this.__keypressTimers = new Map();
3737
this.__hoveredChild = null;
3838

39+
// Default to LTR direction
40+
this.core._ownRtl = false;
41+
3942
// We must construct while the application is not yet attached.
4043
// That's why we 'init' the stage later (which actually emits the attach event).
4144
this.stage.init();
@@ -261,18 +264,53 @@ export default class Application extends Component {
261264
return this._focusPath;
262265
}
263266

267+
/**
268+
* Return direction aware events: if the 1st event includes `Left` or `Right`,
269+
* this returns 2 different sets of events, where one is LTR (original) and one is RTL (reversed directions).
270+
*
271+
* Using the LTR or RTL variant of the events will depend on a component's direction.
272+
* @returns
273+
*/
274+
getDirectionAwareEvents(events) {
275+
if (events.length > 0) {
276+
if (events[0].indexOf('Left') > 0) {
277+
return {
278+
eventsLtr: events,
279+
eventsRtl: [events[0].replace('Left', 'Right'), ...events.slice(1)],
280+
isHorizontalDirection: true
281+
}
282+
} else if (events[0].indexOf('Right') > 0) {
283+
return {
284+
eventsLtr: events,
285+
eventsRtl: [events[0].replace('Right', 'Left'), ...events.slice(1)],
286+
isHorizontalDirection: true
287+
}
288+
}
289+
}
290+
return {
291+
eventsLtr: events,
292+
eventsRtl: events,
293+
isHorizontalDirection: false
294+
}
295+
}
296+
264297
/**
265298
* Injects an event in the state machines, top-down from application to focused component.
266299
*/
267300
focusTopDownEvent(events, ...args) {
268301
const path = this.focusPath;
269302
const n = path.length;
270303

304+
// RTL support
305+
const { eventsLtr, eventsRtl, isHorizontalDirection } = this.getDirectionAwareEvents(events);
306+
271307
// Multiple events.
272308
for (let i = 0; i < n; i++) {
273-
const event = path[i]._getMostSpecificHandledMember(events);
309+
const target = path[i];
310+
const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr;
311+
const event = target._getMostSpecificHandledMember(events);
274312
if (event !== undefined) {
275-
const returnValue = path[i][event](...args);
313+
const returnValue = target[event](...args);
276314
if (returnValue !== false) {
277315
return true;
278316
}
@@ -289,11 +327,16 @@ export default class Application extends Component {
289327
const path = this.focusPath;
290328
const n = path.length;
291329

330+
// RTL support
331+
const { eventsLtr, eventsRtl, isHorizontalDirection } = this.getDirectionAwareEvents(events);
332+
292333
// Multiple events.
293334
for (let i = n - 1; i >= 0; i--) {
294-
const event = path[i]._getMostSpecificHandledMember(events);
335+
const target = path[i];
336+
const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr;
337+
const event = target._getMostSpecificHandledMember(events);
295338
if (event !== undefined) {
296-
const returnValue = path[i][event](...args);
339+
const returnValue = target[event](...args);
297340
if (returnValue !== false) {
298341
return true;
299342
}
@@ -315,19 +358,20 @@ export default class Application extends Component {
315358

316359
if (keys) {
317360
for (let i = 0, n = keys.length; i < n; i++) {
318-
const hasTimer = this.__keypressTimers.has(keys[i]);
361+
const key = keys[i];
362+
const hasTimer = this.__keypressTimers.has(key);
319363
// prevent event from getting fired when the timeout is still active
320364
if (path[path.length - 1].longpress && hasTimer) {
321365
return;
322366
}
323367

324-
if (!this.stage.application.focusTopDownEvent([`_capture${keys[i]}`, "_captureKey"], obj)) {
325-
this.stage.application.focusBottomUpEvent([`_handle${keys[i]}`, "_handleKey"], obj);
368+
if (!this.focusTopDownEvent([`_capture${key}`, "_captureKey"], obj)) {
369+
this.focusBottomUpEvent([`_handle${key}`, "_handleKey"], obj);
326370
}
327371
}
328372
} else {
329-
if (!this.stage.application.focusTopDownEvent(["_captureKey"], obj)) {
330-
this.stage.application.focusBottomUpEvent(["_handleKey"], obj);
373+
if (!this.focusTopDownEvent(["_captureKey"], obj)) {
374+
this.focusBottomUpEvent(["_handleKey"], obj);
331375
}
332376
}
333377

@@ -361,13 +405,14 @@ export default class Application extends Component {
361405

362406
if (keys) {
363407
for (let i = 0, n = keys.length; i < n; i++) {
364-
if (!this.stage.application.focusTopDownEvent([`_capture${keys[i]}Release`, "_captureKeyRelease"], obj)) {
365-
this.stage.application.focusBottomUpEvent([`_handle${keys[i]}Release`, "_handleKeyRelease"], obj);
408+
const key = keys[i];
409+
if (!this.focusTopDownEvent([`_capture${key}Release`, "_captureKeyRelease"], obj)) {
410+
this.focusBottomUpEvent([`_handle${key}Release`, "_handleKeyRelease"], obj);
366411
}
367412
}
368413
} else {
369-
if (!this.stage.application.focusTopDownEvent(["_captureKeyRelease"], obj)) {
370-
this.stage.application.focusBottomUpEvent(["_handleKeyRelease"], obj);
414+
if (!this.focusTopDownEvent(["_captureKeyRelease"], obj)) {
415+
this.focusBottomUpEvent(["_handleKeyRelease"], obj);
371416
}
372417
}
373418

@@ -417,8 +462,8 @@ export default class Application extends Component {
417462
element._throwError("config value for longpress must be a number");
418463
} else {
419464
this.__keypressTimers.set(key, setTimeout(() => {
420-
if (!this.stage.application.focusTopDownEvent([`_capture${key}Long`, "_captureKey"], {})) {
421-
this.stage.application.focusBottomUpEvent([`_handle${key}Long`, "_handleKey"], {});
465+
if (!this.focusTopDownEvent([`_capture${key}Long`, "_captureKey"], {})) {
466+
this.focusBottomUpEvent([`_handle${key}Long`, "_handleKey"], {});
422467
}
423468

424469
this.__keypressTimers.delete(key);

src/textures/TextTexture.d.mts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ declare namespace TextTexture {
5959
* @defaultValue `""`
6060
*/
6161
text?: string;
62+
/**
63+
* Element has RTL (right-to-left) direction hint.
64+
* When true, left/right alignement is reversed.
65+
*
66+
* @defaultValue `false`
67+
*/
68+
rtl?: boolean;
6269
/**
6370
* Font style
6471
*
@@ -469,6 +476,9 @@ declare class TextTexture extends Texture implements Required<Omit<TextTexture.S
469476
get text(): string;
470477
set text(text: string);
471478

479+
get rtl(): boolean;
480+
set rtl(rtl: boolean);
481+
472482
get fontStyle(): string;
473483
set fontStyle(fontStyle: string);
474484

src/textures/TextTexture.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ export default class TextTexture extends Texture {
530530
if (this.highlightPaddingRight !== null) parts.push("hr" + this.highlightPaddingRight);
531531
if (this.letterSpacing !== null) parts.push("ls" + this.letterSpacing);
532532
if (this.textIndent !== null) parts.push("ti" + this.textIndent);
533+
if (this.rtl) parts.push("rtl");
533534

534535
if (this.cutSx) parts.push("csx" + this.cutSx);
535536
if (this.cutEx) parts.push("cex" + this.cutEx);
@@ -629,7 +630,7 @@ export default class TextTexture extends Texture {
629630
if (this.highlightPaddingRight !== 0) nonDefaults["highlightPaddingRight"] = this.highlightPaddingRight;
630631
if (this.letterSpacing !== 0) nonDefaults["letterSpacing"] = this.letterSpacing;
631632
if (this.textIndent !== 0) nonDefaults["textIndent"] = this.textIndent;
632-
if (this.rtl !== 0) nonDefaults["rtl"] = this.rtl;
633+
if (this.rtl) nonDefaults["rtl"] = this.rtl;
633634

634635
if (this.cutSx) nonDefaults["cutSx"] = this.cutSx;
635636
if (this.cutEx) nonDefaults["cutEx"] = this.cutEx;
@@ -725,7 +726,7 @@ proto._highlightPaddingLeft = 0;
725726
proto._highlightPaddingRight = 0;
726727
proto._letterSpacing = 0;
727728
proto._textIndent = 0;
728-
proto._rtl = 0;
729+
proto._rtl = false;
729730
proto._cutSx = 0;
730731
proto._cutEx = 0;
731732
proto._cutSy = 0;

src/textures/TextTextureRenderer.mjs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export default class TextTextureRenderer {
4343
this._stage.getOption('defaultFontFace'),
4444
);
4545
this._context.textBaseline = this._settings.textBaseline;
46-
this._context.direction = this._settings.rtl ? "rtl" : "ltr";
4746
};
4847

4948
_load() {
@@ -288,6 +287,12 @@ export default class TextTextureRenderer {
288287

289288
let drawLines = [];
290289

290+
let textAlign = this._settings.textAlign;
291+
if (this._settings.rtl) {
292+
if (!textAlign || textAlign === 'left') textAlign = 'right';
293+
else if (textAlign === 'right') textAlign = 'left';
294+
}
295+
291296
// Draw lines line by line.
292297
for (let i = 0, n = renderInfo.lines.length; i < n; i++) {
293298
linePositionX = i === 0 ? renderInfo.textIndent : 0;
@@ -301,15 +306,12 @@ export default class TextTextureRenderer {
301306
linePositionY += renderInfo.lineHeight - renderInfo.fontSize;
302307
}
303308

304-
if (this._settings.textAlign === 'right') {
309+
if (textAlign === 'right') {
305310
linePositionX += (renderInfo.innerWidth - renderInfo.lineWidths[i]);
306-
} else if (this._settings.textAlign === 'center') {
311+
} else if (textAlign === 'center') {
307312
linePositionX += ((renderInfo.innerWidth - renderInfo.lineWidths[i]) / 2);
308313
}
309314
linePositionX += renderInfo.paddingLeft;
310-
if (this._settings.rtl) {
311-
linePositionX += renderInfo.lineWidths[i];
312-
}
313315

314316
drawLines.push({text: renderInfo.lines[i], x: linePositionX, y: linePositionY, w: renderInfo.lineWidths[i]});
315317
}

src/tree/Element.d.mts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,16 @@ declare namespace Element {
549549
*/
550550
boundsMargin: [number, number, number, number] | null;
551551

552+
553+
/**
554+
* Set RTL (right-to-left) flag on the element
555+
*
556+
* Unless RTL is set, it is inherited from the parent element
557+
*
558+
* @defaultValue null
559+
*/
560+
rtl: boolean | null;
561+
552562
/**
553563
* X position of this Element
554564
*
@@ -1333,6 +1343,9 @@ declare class Element<
13331343

13341344
_onResize(): void;
13351345

1346+
/** RTL direction flag changed */
1347+
_onDirectionChanged(): void;
1348+
13361349
readonly renderWidth: number;
13371350

13381351
readonly renderHeight: number;
@@ -1516,6 +1529,14 @@ declare class Element<
15161529

15171530
boundsMargin: [number, number, number, number] | null;
15181531

1532+
/**
1533+
* RTL (right-to-left) layout direction flag
1534+
*
1535+
* Set `null` to inherit from parent
1536+
*/
1537+
get rtl(): boolean;
1538+
set rtl(value: boolean);
1539+
15191540
/**
15201541
* X position of this Element
15211542
*

0 commit comments

Comments
 (0)