Skip to content

Feature/rtl #573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/RTL/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Right-to-left (RTL) support

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.

By opposition, the default application layout and text direction is called "left-to-right" (LTR).

RTL support encompasses 2 aspects:

- RTL layout support; which means mirroring the application layout,
- RTL text rendering support; which means accurately rendering (and wrapping) RTL text.

## How RTL layout works

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.

**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.

Here's a simplified diagram of the calculations:
![LTR vs RTL layout calculations](./ltr-rtl.png)

Lightning elements (and components) have a `rtl` property to hint whether the elements children layout should be mirrored.

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.

### How input works in RTL

A consequence of the choice of transparent mirroring is that the Left and Right key shoud be interpreted in accordance to the layout direction.

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.

### How RTL text works

When the RTL flag is set, text alignement is mirrored, so left-aligned text becomes right-aligned.

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.

TODO
Binary file added docs/RTL/ltr-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ The Reference Documentation for Lightning Core contains detailed descriptions ab
* [Signal](Communication/Signal.md)
* [Fire Ancestors](Communication/FireAncestors.md)
* [Accessibility](Accessibility/index.md)
* [Right-to-left support](RTL/index.md)
* [TypeScript](TypeScript/index.md)
* [Components](TypeScript/Components/index.md)
* [Template Specs](TypeScript/Components/TemplateSpecs.md)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"author": "Metrological, Bas van Meurs <[email protected]>",
"name": "@lightningjs/core",
"version": "2.14.1",
"version": "2.16.0-beta.1",
"license": "Apache-2.0",
"type": "module",
"types": "dist/src/index.d.ts",
Expand All @@ -16,7 +16,7 @@
"./inspector": {
"types": "./devtools/lightning-inspect.d.ts",
"import": "./devtools/lightning-inspect.js",
"require": "./devtools/lightning-inspect.js"
"require": "./devtools/lightning-inspect.es5.js"
},
"./package.json": "./package.json"
},
Expand Down
5 changes: 3 additions & 2 deletions src/application/Application.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,9 @@ declare class Application<
*/
get focusPath(): Component[] | undefined;

// focusTopDownEvent(events: any, ...args: any[]): any;
// focusBottomUpEvent(events: any, ...args: any[]): any;
// getDirectionAwareEvents(events: string[], ...args: any[]): { eventsLtr: string[], eventsRtl: string[], isHorizontalDirection };
// focusTopDownEvent(events: string[], ...args: any[]): any;
// focusBottomUpEvent(events: string[], ...args: any[]): any;
// _receiveKeydown(e: KeyboardEvent): void;
// _receiveKeyup(e: KeyboardEvent): void;
// _startLongpressTimer(key: any, element: any): void;
Expand Down
75 changes: 60 additions & 15 deletions src/application/Application.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default class Application extends Component {
this.__keypressTimers = new Map();
this.__hoveredChild = null;

// Default to LTR direction
this.core._ownRtl = false;

// We must construct while the application is not yet attached.
// That's why we 'init' the stage later (which actually emits the attach event).
this.stage.init();
Expand Down Expand Up @@ -261,18 +264,53 @@ export default class Application extends Component {
return this._focusPath;
}

/**
* Return direction aware events: if the 1st event includes `Left` or `Right`,
* this returns 2 different sets of events, where one is LTR (original) and one is RTL (reversed directions).
*
* Using the LTR or RTL variant of the events will depend on a component's direction.
* @returns
*/
getDirectionAwareEvents(events) {
if (events.length > 0) {
if (events[0].indexOf('Left') > 0) {
return {
eventsLtr: events,
eventsRtl: [events[0].replace('Left', 'Right'), ...events.slice(1)],
isHorizontalDirection: true
}
} else if (events[0].indexOf('Right') > 0) {
return {
eventsLtr: events,
eventsRtl: [events[0].replace('Right', 'Left'), ...events.slice(1)],
isHorizontalDirection: true
}
}
}
return {
eventsLtr: events,
eventsRtl: events,
isHorizontalDirection: false
}
}

/**
* Injects an event in the state machines, top-down from application to focused component.
*/
focusTopDownEvent(events, ...args) {
const path = this.focusPath;
const n = path.length;

// RTL support
const { eventsLtr, eventsRtl, isHorizontalDirection } = this.getDirectionAwareEvents(events);

// Multiple events.
for (let i = 0; i < n; i++) {
const event = path[i]._getMostSpecificHandledMember(events);
const target = path[i];
const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr;
const event = target._getMostSpecificHandledMember(events);
if (event !== undefined) {
const returnValue = path[i][event](...args);
const returnValue = target[event](...args);
if (returnValue !== false) {
return true;
}
Expand All @@ -289,11 +327,16 @@ export default class Application extends Component {
const path = this.focusPath;
const n = path.length;

// RTL support
const { eventsLtr, eventsRtl, isHorizontalDirection } = this.getDirectionAwareEvents(events);

// Multiple events.
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;
const event = target._getMostSpecificHandledMember(events);
if (event !== undefined) {
const returnValue = path[i][event](...args);
const returnValue = target[event](...args);
if (returnValue !== false) {
return true;
}
Expand All @@ -315,19 +358,20 @@ export default class Application extends Component {

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

if (!this.stage.application.focusTopDownEvent([`_capture${keys[i]}`, "_captureKey"], obj)) {
this.stage.application.focusBottomUpEvent([`_handle${keys[i]}`, "_handleKey"], obj);
if (!this.focusTopDownEvent([`_capture${key}`, "_captureKey"], obj)) {
this.focusBottomUpEvent([`_handle${key}`, "_handleKey"], obj);
}
}
} else {
if (!this.stage.application.focusTopDownEvent(["_captureKey"], obj)) {
this.stage.application.focusBottomUpEvent(["_handleKey"], obj);
if (!this.focusTopDownEvent(["_captureKey"], obj)) {
this.focusBottomUpEvent(["_handleKey"], obj);
}
}

Expand Down Expand Up @@ -361,13 +405,14 @@ export default class Application extends Component {

if (keys) {
for (let i = 0, n = keys.length; i < n; i++) {
if (!this.stage.application.focusTopDownEvent([`_capture${keys[i]}Release`, "_captureKeyRelease"], obj)) {
this.stage.application.focusBottomUpEvent([`_handle${keys[i]}Release`, "_handleKeyRelease"], obj);
const key = keys[i];
if (!this.focusTopDownEvent([`_capture${key}Release`, "_captureKeyRelease"], obj)) {
this.focusBottomUpEvent([`_handle${key}Release`, "_handleKeyRelease"], obj);
}
}
} else {
if (!this.stage.application.focusTopDownEvent(["_captureKeyRelease"], obj)) {
this.stage.application.focusBottomUpEvent(["_handleKeyRelease"], obj);
if (!this.focusTopDownEvent(["_captureKeyRelease"], obj)) {
this.focusBottomUpEvent(["_handleKeyRelease"], obj);
}
}

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

this.__keypressTimers.delete(key);
Expand Down
10 changes: 10 additions & 0 deletions src/textures/TextTexture.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ declare namespace TextTexture {
* @defaultValue `""`
*/
text?: string;
/**
* Element has RTL (right-to-left) direction hint.
* When true, left/right alignement is reversed.
*
* @defaultValue `false`
*/
rtl?: boolean;
/**
* Font style
*
Expand Down Expand Up @@ -469,6 +476,9 @@ declare class TextTexture extends Texture implements Required<Omit<TextTexture.S
get text(): string;
set text(text: string);

get rtl(): boolean;
set rtl(rtl: boolean);

get fontStyle(): string;
set fontStyle(fontStyle: string);

Expand Down
5 changes: 3 additions & 2 deletions src/textures/TextTexture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ export default class TextTexture extends Texture {
if (this.highlightPaddingRight !== null) parts.push("hr" + this.highlightPaddingRight);
if (this.letterSpacing !== null) parts.push("ls" + this.letterSpacing);
if (this.textIndent !== null) parts.push("ti" + this.textIndent);
if (this.rtl) parts.push("rtl");

if (this.cutSx) parts.push("csx" + this.cutSx);
if (this.cutEx) parts.push("cex" + this.cutEx);
Expand Down Expand Up @@ -629,7 +630,7 @@ export default class TextTexture extends Texture {
if (this.highlightPaddingRight !== 0) nonDefaults["highlightPaddingRight"] = this.highlightPaddingRight;
if (this.letterSpacing !== 0) nonDefaults["letterSpacing"] = this.letterSpacing;
if (this.textIndent !== 0) nonDefaults["textIndent"] = this.textIndent;
if (this.rtl !== 0) nonDefaults["rtl"] = this.rtl;
if (this.rtl) nonDefaults["rtl"] = this.rtl;

if (this.cutSx) nonDefaults["cutSx"] = this.cutSx;
if (this.cutEx) nonDefaults["cutEx"] = this.cutEx;
Expand Down Expand Up @@ -725,7 +726,7 @@ proto._highlightPaddingLeft = 0;
proto._highlightPaddingRight = 0;
proto._letterSpacing = 0;
proto._textIndent = 0;
proto._rtl = 0;
proto._rtl = false;
proto._cutSx = 0;
proto._cutEx = 0;
proto._cutSy = 0;
Expand Down
14 changes: 8 additions & 6 deletions src/textures/TextTextureRenderer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export default class TextTextureRenderer {
this._stage.getOption('defaultFontFace'),
);
this._context.textBaseline = this._settings.textBaseline;
this._context.direction = this._settings.rtl ? "rtl" : "ltr";
};

_load() {
Expand Down Expand Up @@ -288,6 +287,12 @@ export default class TextTextureRenderer {

let drawLines = [];

let textAlign = this._settings.textAlign;
if (this._settings.rtl) {
if (!textAlign || textAlign === 'left') textAlign = 'right';
else if (textAlign === 'right') textAlign = 'left';
}

// Draw lines line by line.
for (let i = 0, n = renderInfo.lines.length; i < n; i++) {
linePositionX = i === 0 ? renderInfo.textIndent : 0;
Expand All @@ -301,15 +306,12 @@ export default class TextTextureRenderer {
linePositionY += renderInfo.lineHeight - renderInfo.fontSize;
}

if (this._settings.textAlign === 'right') {
if (textAlign === 'right') {
linePositionX += (renderInfo.innerWidth - renderInfo.lineWidths[i]);
} else if (this._settings.textAlign === 'center') {
} else if (textAlign === 'center') {
linePositionX += ((renderInfo.innerWidth - renderInfo.lineWidths[i]) / 2);
}
linePositionX += renderInfo.paddingLeft;
if (this._settings.rtl) {
linePositionX += renderInfo.lineWidths[i];
}

drawLines.push({text: renderInfo.lines[i], x: linePositionX, y: linePositionY, w: renderInfo.lineWidths[i]});
}
Expand Down
8 changes: 7 additions & 1 deletion src/textures/TextTextureRendererAdvanced.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ export default class TextTextureRendererAdvanced {
// Set font properties.
renderInfo.baseFont = this.setFontProperties();

let textAlign = this._settings.textAlign;
if (this._settings.rtl) {
if (!textAlign || textAlign === 'left') textAlign = 'right';
else if (textAlign === 'right') textAlign = 'left';
}

renderInfo.w = w;
renderInfo.width = w;
renderInfo.text = this._settings.text;
Expand All @@ -116,7 +122,7 @@ export default class TextTextureRendererAdvanced {
renderInfo.fontBaselineRatio = this._settings.fontBaselineRatio;
renderInfo.lineHeight = lineHeight;
renderInfo.letterSpacing = letterSpacing;
renderInfo.textAlign = this._settings.textAlign;
renderInfo.textAlign = textAlign;
renderInfo.textColor = this._settings.textColor;
renderInfo.verticalAlign = this._settings.verticalAlign;
renderInfo.highlight = this._settings.highlight;
Expand Down
21 changes: 21 additions & 0 deletions src/tree/Element.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,16 @@ declare namespace Element {
*/
boundsMargin: [number, number, number, number] | null;


/**
* Set RTL (right-to-left) flag on the element
*
* Unless RTL is set, it is inherited from the parent element
*
* @defaultValue null
*/
rtl: boolean | null;

/**
* X position of this Element
*
Expand Down Expand Up @@ -1333,6 +1343,9 @@ declare class Element<

_onResize(): void;

/** RTL direction flag changed */
_onDirectionChanged(): void;

readonly renderWidth: number;

readonly renderHeight: number;
Expand Down Expand Up @@ -1516,6 +1529,14 @@ declare class Element<

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

/**
* RTL (right-to-left) layout direction flag
*
* Set `null` to inherit from parent
*/
get rtl(): boolean;
set rtl(value: boolean);

/**
* X position of this Element
*
Expand Down
Loading