diff --git a/src/i18n/ar-display.test.mjs b/src/i18n/ar-display.test.mjs index 6a8a152f7..f650a9a1d 100644 --- a/src/i18n/ar-display.test.mjs +++ b/src/i18n/ar-display.test.mjs @@ -71,13 +71,16 @@ test('Arabic formats with Western digits whatever ICU defaults to', () => { } }); +/** The text without the bidi isolates t() puts around values in RTL. */ +const plain = (text) => text.replace(/[\u2068\u2069]/g, ''); + test('Arabic uses all six plural forms and spaced HUD units', () => { const previous = getLocale(); try { setLocale('ar'); assert.deepEqual( [0, 1, 2, 3, 11, 100].map((n) => - tn(n, '{count} SOURCE', '{count} SOURCES'), + plain(tn(n, '{count} SOURCE', '{count} SOURCES')), ), [ '0 مصدر', @@ -96,11 +99,16 @@ test('Arabic uses all six plural forms and spaced HUD units', () => { 'Sentinel-2 · \u206830\u00a0م\u2069', ); assert.equal( - LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }), + plain(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 })), 'القطاع 21.33ش 77.50غ', ); assert.equal(READOUT_WORDS.daysAgo(9), 'قبل 9 أيام'); assert.equal(t('SAVE KEYS'), 'حفظ المفاتيح'); + // Each value is isolated, so a time keeps its order inside Arabic text. + assert.equal( + t('Issued: {time}', { time: '10/28 00:00 UTC' }), + 'صدر: \u206810/28 00:00 UTC\u2069', + ); setLocale('ru'); assert.equal(formatCockpitDistance(14000), '14 КМ'); } finally { diff --git a/src/i18n/index.js b/src/i18n/index.js index babaa72b0..bd28fa346 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -81,9 +81,13 @@ const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; * stays visible instead of silently vanishing. */ function interpolate(pattern, values) { if (!values) return pattern; - return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => - Object.hasOwn(values, name) ? String(values[name]) : literal, - ); + // In a right-to-left page each value is isolated: a time, a code or an + // English name keeps its own order instead of mixing with the sentence. + return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => { + if (!Object.hasOwn(values, name)) return literal; + const value = String(values[name]); + return value === '' ? value : isolate(value); + }); } /** A catalog string with something to show; an empty one counts as missing. */ diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 14b875bf8..d260f55ab 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { formatNumber, t } from '../../i18n/index.js'; +import { formatNumber, isolate, t } from '../../i18n/index.js'; import { satelliteShortName, fireDetectionKey, @@ -513,7 +513,7 @@ export function createModel({ lon >= 0 ? t('{value}°E', { value: amount(lon) }) : t('{value}°W', { value: amount(lon) }); - return `${latPart} ${lonPart}`; + return `${isolate(latPart)} ${isolate(lonPart)}`; } /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 4f3faac9d..265783154 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,5 +1,5 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ -import { formatNumber, getLocale, t, msg } from '../i18n/index.js'; +import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -124,18 +124,19 @@ export function formatCockpitCoordinate(value, positive, negative) { maximumFractionDigits: 3, }); const code = value >= 0 ? positive : negative; - // Not «30,203°С»: after a degree sign «С» reads as Celsius. + // Not «30,203°С»: after a degree sign «С» reads as Celsius. Isolated, so + // an Arabic hemisphere letter does not pull the next number into its run. switch (code) { case 'N': - return t('{value}°N', { value: amount }); + return isolate(t('{value}°N', { value: amount })); case 'S': - return t('{value}°S', { value: amount }); + return isolate(t('{value}°S', { value: amount })); case 'E': - return t('{value}°E', { value: amount }); + return isolate(t('{value}°E', { value: amount })); case 'W': - return t('{value}°W', { value: amount }); + return isolate(t('{value}°W', { value: amount })); default: - return `${amount}°${compassPointLabel(code)}`; + return isolate(`${amount}°${compassPointLabel(code)}`); } } diff --git a/src/ui/styles/rtl.css b/src/ui/styles/rtl.css index b669dc3d5..a611f34a5 100644 --- a/src/ui/styles/rtl.css +++ b/src/ui/styles/rtl.css @@ -68,16 +68,22 @@ html:lang(ar) :is(#intel-hud, #cockpit-hud) { } /* The right rail sits on the left: its expand/collapse arrows point the - other way. */ -html[dir='rtl'] #right-context-rail .panel-collapse-btn { + other way, and the start window's "go" arrows point along the reading + direction. */ +html[dir='rtl'] #right-context-rail .panel-collapse-btn, +html[dir='rtl'] .first-run-arrow { transform: scaleX(-1); } +html[dir='rtl'] .first-run-choices button:hover .first-run-arrow, +html[dir='rtl'] .first-run-choices button:focus-visible .first-run-arrow { + transform: translateX(-2px) scaleX(-1); +} /* The cockpit is an aircraft panel, not page chrome: its windows keep their physical sides, so the data-layer stack goes back to the left edge instead - of landing on the utility controls. Gauges, tapes, the roll counters and the - position readout stay left to right (speed left, altitude right, -10 not - 10-); the cards keep reading right to left. */ + of landing on the utility controls. Gauges, tapes and the roll counters stay + left to right (speed left, altitude right, -10 not 10-); the cards keep + reading right to left. */ html[dir='rtl'] body.cockpit-mode #left-panel-stack { left: var(--left-stack-x); right: auto; @@ -90,7 +96,6 @@ html[dir='rtl'] .cockpit-pitch-rail, .cockpit-altitude-rim, .cockpit-roll-arc, - .cockpit-position-readout, .cockpit-vision-controls ) { direction: ltr; @@ -110,9 +115,17 @@ html[dir='rtl'] unicode-bidi: plaintext; } -/* Physical controls keep their left-to-right sense: media transport - (previous / play / next) and the radio dial. */ +/* Clock readouts (~0:05 / 5:19) are numbers, not prose. */ +html[dir='rtl'] #bhote-koshi-event-panel [data-role='time'] { + direction: ltr; + unicode-bidi: isolate; +} + +/* Physical controls keep their left-to-right sense: the glyph-only media + transport (◀◀ ▶ ▶▶), FM seek down/up and the radio dial. The worded + PREV / PLAY / NEXT row follows the reading direction like any other + button row. */ html[dir='rtl'] - :is(.radio-transport, .context-radio-mini-transport, .radio-tuner) { + :is(.context-radio-mini-transport, .sdr-seek-row, .radio-tuner) { direction: ltr; }