From d55f513acde80ed50cb995f803e7d0bfdbf68053 Mon Sep 17 00:00:00 2001 From: Lewis Date: Thu, 21 May 2026 23:35:39 +0300 Subject: [PATCH] feat(ui): chrome bands under rtl locale Lewis: May this revision serve well! --- crates/bone-app/src/shell.rs | 141 ++++++++++++++++++++++- crates/bone-app/src/strings.rs | 58 ++++++++++ crates/bone-render/src/pipelines/text.rs | 98 ++++++++++++++++ crates/bone-text/src/fonts.rs | 19 +++ crates/bone-ui/src/layout/geometry.rs | 21 ++++ crates/bone-ui/src/widgets/menu.rs | 15 ++- crates/bone-ui/src/widgets/ribbon.rs | 24 +++- crates/bone-ui/src/widgets/status_bar.rs | 6 +- crates/bone-ui/src/widgets/toolbar.rs | 32 ++++- 9 files changed, 400 insertions(+), 14 deletions(-) diff --git a/crates/bone-app/src/shell.rs b/crates/bone-app/src/shell.rs index 7fc2873..f50c60b 100644 --- a/crates/bone-app/src/shell.rs +++ b/crates/bone-app/src/shell.rs @@ -2713,7 +2713,7 @@ mod tests { use bone_ui::hit_test::{HitFrame, HitState}; use bone_ui::hotkey::HotkeyTable; use bone_ui::input::{FrameInstant, InputSnapshot}; - use bone_ui::strings::StringTable; + use bone_ui::strings::{Locale, StringKey, StringTable}; use bone_ui::theme::Theme; use bone_ui::widgets::LabelText; use std::sync::Arc; @@ -2738,13 +2738,14 @@ mod tests { ) } - fn render_into_shell( + fn render_with_strings( shell: &mut Shell, theme: Theme, size: LayoutSize, document: &Document, mode: &Mode, selection: &Selection, + strings: &StringTable, ) -> ShellFrame { let theme = Arc::new(theme); let table = HotkeyTable::new(); @@ -2759,7 +2760,7 @@ mod tests { &mut input, &mut focus, &table, - StringTable::empty(), + strings, &mut hits, &prev, &mut a11y, @@ -2776,6 +2777,41 @@ mod tests { ) } + fn render_into_shell( + shell: &mut Shell, + theme: Theme, + size: LayoutSize, + document: &Document, + mode: &Mode, + selection: &Selection, + ) -> ShellFrame { + render_with_strings( + shell, + theme, + size, + document, + mode, + selection, + StringTable::empty(), + ) + } + + fn label_rect(paints: &[WidgetPaint], target: StringKey) -> Option { + paints.iter().find_map(|p| match p { + WidgetPaint::Label { + rect, + text: LabelText::Key(k), + .. + } + | WidgetPaint::AlignedLabel { + rect, + text: LabelText::Key(k), + .. + } if *k == target => Some(*rect), + _ => None, + }) + } + #[test] fn tools_options_menu_id_maps_to_open_settings_action() { let shell = Shell::new(); @@ -3913,4 +3949,103 @@ mod tests { "double-click on sketch row must emit sketch_activated for that sketch", ); } + + fn render_with_locale(size: LayoutSize, locale: Locale) -> ShellFrame { + let strings = crate::strings::make_strings(locale); + let mut shell = Shell::new(); + render_with_strings( + &mut shell, + Theme::light(), + size, + &sample_document(), + &Mode::Idle, + &Selection::default(), + &strings, + ) + } + + const CHROME_BAND_KEYS: [StringKey; 3] = [ + strings::MENU_FILE, + strings::RIBBON_TAB_SKETCH, + strings::STATUS_READY, + ]; + + fn assert_chrome_label_mirrors_under_rtl(key: StringKey) { + let size = layout_size(1600.0, 900.0); + let ltr = render_with_locale(size, Locale::EnUs); + let rtl = render_with_locale(size, Locale::ArXb); + let ltr_rect = label_rect(<r.paints, key) + .unwrap_or_else(|| panic!("ltr paint missing for {key}")); + let rtl_rect = label_rect(&rtl.paints, key) + .unwrap_or_else(|| panic!("rtl paint missing for {key}")); + let half = size.width.value() * 0.5; + assert!( + ltr_rect.origin.x.value() < half, + "{key} must sit on the left half under ltr, got x={}", + ltr_rect.origin.x.value(), + ); + assert!( + rtl_rect.origin.x.value() > half, + "{key} must mirror to the right half under rtl, got x={}", + rtl_rect.origin.x.value(), + ); + } + + #[test] + fn rtl_locale_flips_viewport_to_the_left_side() { + let size = layout_size(1600.0, 900.0); + let ltr = render_with_locale(size, Locale::EnUs); + let rtl = render_with_locale(size, Locale::ArXb); + assert!( + ltr.viewport_rect.size.width.value() > 0.0, + "ltr viewport must have width", + ); + assert!( + rtl.viewport_rect.size.width.value() > 0.0, + "rtl viewport must have width", + ); + assert!( + ltr.viewport_rect.origin.x.value() > size.width.value() * 0.1, + "ltr viewport sits right of the left pane, got x={}", + ltr.viewport_rect.origin.x.value(), + ); + assert!( + rtl.viewport_rect.origin.x.value() < size.width.value() * 0.1, + "rtl viewport must hug the left edge, got x={}", + rtl.viewport_rect.origin.x.value(), + ); + assert!( + (ltr.viewport_rect.size.width.value() - rtl.viewport_rect.size.width.value()).abs() + < 1.0, + "viewport width is independent of direction", + ); + } + + #[test] + fn rtl_locale_still_renders_every_chrome_band() { + let size = layout_size(1600.0, 900.0); + let rtl = render_with_locale(size, Locale::ArXb); + assert!(!rtl.paints.is_empty(), "rtl shell must emit chrome paints"); + CHROME_BAND_KEYS.into_iter().for_each(|key| { + assert!( + label_rect(&rtl.paints, key).is_some(), + "rtl shell must emit a label paint for {key}", + ); + }); + } + + #[test] + fn rtl_locale_mirrors_menu_bar_file_label() { + assert_chrome_label_mirrors_under_rtl(strings::MENU_FILE); + } + + #[test] + fn rtl_locale_mirrors_ribbon_sketch_tab() { + assert_chrome_label_mirrors_under_rtl(strings::RIBBON_TAB_SKETCH); + } + + #[test] + fn rtl_locale_mirrors_status_bar_mode_label() { + assert_chrome_label_mirrors_under_rtl(strings::STATUS_READY); + } } diff --git a/crates/bone-app/src/strings.rs b/crates/bone-app/src/strings.rs index 9d338fc..75ef71b 100644 --- a/crates/bone-app/src/strings.rs +++ b/crates/bone-app/src/strings.rs @@ -710,3 +710,61 @@ const AR_XB: &[(StringKey, &str)] = &[ (PROPERTY_VALUE_DRIVING, "[!! Drîving !!]"), (PROPERTY_VALUE_DRIVEN, "[!! Drîven !!]"), ]; + +#[cfg(test)] +mod tests { + use super::{AR_XB, EN_US, make_strings}; + use bone_ui::layout::LayoutDirection; + use bone_ui::strings::{Locale, StringKey}; + use std::collections::BTreeSet; + + fn keys(entries: &[(StringKey, &str)]) -> BTreeSet { + entries.iter().map(|(k, _)| *k).collect() + } + + #[test] + fn ar_xb_bundle_covers_every_en_us_key() { + let en: BTreeSet = keys(EN_US); + let ar: BTreeSet = keys(AR_XB); + let missing: Vec = en.difference(&ar).copied().collect(); + assert!( + missing.is_empty(), + "ar-XB missing translations for: {missing:?}", + ); + } + + #[test] + fn ar_xb_does_not_invent_unknown_keys() { + let en: BTreeSet = keys(EN_US); + let ar: BTreeSet = keys(AR_XB); + let stray: Vec = ar.difference(&en).copied().collect(); + assert!( + stray.is_empty(), + "ar-XB defines keys not declared in en-US: {stray:?}", + ); + } + + #[test] + fn loading_ar_xb_yields_rtl_table_with_non_empty_entries() { + let table = make_strings(Locale::ArXb); + assert_eq!(table.direction(), LayoutDirection::Rtl); + AR_XB.iter().for_each(|(key, _)| { + assert!( + !table.resolve(*key).is_empty(), + "ar-XB entry for {key} resolved to empty string", + ); + }); + } + + #[test] + fn loading_en_us_yields_ltr_table_with_non_empty_entries() { + let table = make_strings(Locale::EnUs); + assert_eq!(table.direction(), LayoutDirection::Ltr); + EN_US.iter().for_each(|(key, _)| { + assert!( + !table.resolve(*key).is_empty(), + "en-US entry for {key} resolved to empty string", + ); + }); + } +} diff --git a/crates/bone-render/src/pipelines/text.rs b/crates/bone-render/src/pipelines/text.rs index efdd0b0..0b75ffd 100644 --- a/crates/bone-render/src/pipelines/text.rs +++ b/crates/bone-render/src/pipelines/text.rs @@ -351,3 +351,101 @@ fn label_center(layout: &ShapedText, cap_height: f32) -> ZenoPoint { .map_or(0.0, ShapedLine::visible_advance_px); ZenoPoint::new(-visible_advance * 0.5, -cap_height * 0.5) } + +#[cfg(test)] +mod tests { + use super::{ + FontFace, ShapedText, Shaper, TessellatedOutline, label_center, load_font, shape_line, + tessellate, + }; + use lyon_tessellation::FillTessellator; + use swash::scale::ScaleContext; + + const DIM_FONT_SIZE_PX: f32 = 14.0; + + fn shape_only(text: &str) -> ShapedText { + let mut shaper = Shaper::new(); + shape_line(text, DIM_FONT_SIZE_PX, FontFace::Mono, &mut shaper) + } + + fn run_tessellate(text: &str) -> TessellatedOutline { + let font = load_font(FontFace::Mono); + let mut shaper = Shaper::new(); + let mut scale_ctx = ScaleContext::new(); + let mut fill = FillTessellator::new(); + tessellate( + text, + DIM_FONT_SIZE_PX, + &font, + &mut shaper, + &mut scale_ctx, + &mut fill, + ) + } + + #[test] + fn arabic_dimension_label_tessellates_to_visible_geometry() { + let text = "\u{0627}\u{0644}\u{0637}\u{0648}\u{0644}"; + let shaped = shape_only(text); + let rtl_glyph_count: usize = shaped + .lines + .iter() + .flat_map(|line| line.runs.iter()) + .filter(|run| run.is_rtl) + .map(|run| run.glyphs.len()) + .sum(); + assert_eq!( + rtl_glyph_count, + text.chars().count(), + "ar shaping must emit one rtl glyph per codepoint, got {} for {} chars", + rtl_glyph_count, + text.chars().count(), + ); + let result = run_tessellate(text); + assert!( + !result.is_empty(), + "complex-script dim label must produce geometry", + ); + assert!(result.indices.len().is_multiple_of(3)); + } + + #[test] + fn bidi_dimension_label_tessellates_to_visible_geometry() { + let text = "R 5.00 \u{0645}\u{0645}"; + let shaped = shape_only(text); + let runs: Vec<_> = shaped.lines.iter().flat_map(|l| l.runs.iter()).collect(); + assert!( + runs.iter().any(|run| !run.is_rtl), + "bidi dim label must retain its ltr ascii prefix", + ); + assert!( + runs.iter().any(|run| run.is_rtl), + "bidi dim label must shape its rtl arabic suffix", + ); + let result = run_tessellate(text); + assert!( + !result.is_empty(), + "mixed-direction dim label must produce geometry", + ); + assert!(result.indices.len().is_multiple_of(3)); + } + + #[test] + fn arabic_label_center_offsets_by_half_visible_advance() { + let mut shaper = Shaper::new(); + let font = load_font(FontFace::Mono); + let layout = shape_line( + "\u{0627}\u{0644}\u{0637}\u{0648}\u{0644}", + DIM_FONT_SIZE_PX, + FontFace::Mono, + &mut shaper, + ); + let metrics = font.metrics(&[]).scale(DIM_FONT_SIZE_PX); + let center = label_center(&layout, metrics.cap_height); + assert!( + center.x < 0.0, + "label_center must shift the anchor by half the visible advance, got x={}", + center.x, + ); + } +} diff --git a/crates/bone-text/src/fonts.rs b/crates/bone-text/src/fonts.rs index bb9133c..b02c163 100644 --- a/crates/bone-text/src/fonts.rs +++ b/crates/bone-text/src/fonts.rs @@ -90,6 +90,25 @@ mod tests { assert!(mono.charmap().map(u32::from('A')) > 0); } + #[test] + fn bundled_fonts_cover_arabic_baseline_for_complex_script_dim_labels() { + let sans = load_font(FontFace::Sans); + let mono = load_font(FontFace::Mono); + ['\u{0627}', '\u{0644}', '\u{0637}', '\u{0648}', '\u{0645}'] + .into_iter() + .for_each(|ch| { + let cp = u32::from(ch); + assert!( + sans.charmap().map(cp) > 0, + "sans must cover arabic codepoint U+{cp:04X}", + ); + assert!( + mono.charmap().map(cp) > 0, + "mono must cover arabic codepoint U+{cp:04X}", + ); + }); + } + #[test] fn parley_weight_round_trips_each_step() { assert_eq!(parley_weight(FontWeight::Regular), ParleyFontWeight::NORMAL); diff --git a/crates/bone-ui/src/layout/geometry.rs b/crates/bone-ui/src/layout/geometry.rs index 6666bf6..726c513 100644 --- a/crates/bone-ui/src/layout/geometry.rs +++ b/crates/bone-ui/src/layout/geometry.rs @@ -203,6 +203,27 @@ impl LayoutRect { ), ) } + + #[must_use] + pub fn mirror_horizontally_within( + self, + container: Self, + direction: super::axis::LayoutDirection, + ) -> Self { + match direction { + super::axis::LayoutDirection::Ltr => self, + super::axis::LayoutDirection::Rtl => { + let container_left = container.origin.x.value(); + let container_right = container_left + container.size.width.value(); + let mirrored_x = + container_right - (self.origin.x.value() - container_left) - self.size.width.value(); + Self::new( + LayoutPos::new(LayoutPx::new(mirrored_x), self.origin.y), + self.size, + ) + } + } + } } #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/bone-ui/src/widgets/menu.rs b/crates/bone-ui/src/widgets/menu.rs index b11a0e9..545f6f4 100644 --- a/crates/bone-ui/src/widgets/menu.rs +++ b/crates/bone-ui/src/widgets/menu.rs @@ -2,7 +2,7 @@ use crate::a11y::{AccessNode, Role}; use crate::frame::{FrameCtx, InteractDeclaration}; use crate::hit_test::{Sense, ZLayer}; use crate::input::{KeyCode, NamedKey}; -use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; use crate::strings::StringKey; use crate::theme::{Border, Color, Step12, StrokeWidth}; use crate::widget_id::{WidgetId, WidgetKey}; @@ -723,7 +723,12 @@ pub fn show_menu_bar(ctx: &mut FrameCtx<'_>, bar: MenuBar<'_, '_>) -> MenuBarRes LayoutPx::new(total.max(min_item_width.value())) }) .collect(); - let entry_layouts = entry_rects(rect, &widths); + let direction = ctx.direction(); + let raw_entry_layouts = entry_rects(rect, &widths); + let entry_layouts: Vec = raw_entry_layouts + .iter() + .map(|r| r.mirror_horizontally_within(rect, direction)) + .collect(); entries .iter() .zip(entry_layouts.iter()) @@ -743,7 +748,8 @@ pub fn show_menu_bar(ctx: &mut FrameCtx<'_>, bar: MenuBar<'_, '_>) -> MenuBarRes rect, request, item_padding, - entry_layouts.as_slice(), + raw_entry_layouts.as_slice(), + direction, )); } let mut popover_paint = Vec::new(); @@ -876,6 +882,7 @@ fn trailing_label_paint( request: ShapeRequest, item_padding: LayoutPx, entry_layouts: &[LayoutRect], + direction: LayoutDirection, ) -> WidgetPaint { let resolved = label_text.resolve(ctx.strings); let advance = ctx @@ -901,7 +908,7 @@ fn trailing_label_paint( ), ); WidgetPaint::AlignedLabel { - rect: trailing_rect, + rect: trailing_rect.mirror_horizontally_within(bar_rect, direction), text: label_text, color: ctx.theme().colors.text_primary(), role: ctx.theme().typography.label, diff --git a/crates/bone-ui/src/widgets/ribbon.rs b/crates/bone-ui/src/widgets/ribbon.rs index a1cd2bc..a41a349 100644 --- a/crates/bone-ui/src/widgets/ribbon.rs +++ b/crates/bone-ui/src/widgets/ribbon.rs @@ -1,6 +1,6 @@ use crate::a11y::{AccessNode, Role}; use crate::frame::FrameCtx; -use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; use crate::strings::StringKey; use crate::theme::{Border, Step12, StrokeWidth}; use crate::widget_id::{WidgetId, WidgetKey}; @@ -141,8 +141,15 @@ pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_>) -> RibbonResponse ), LayoutSize::new(rect.size.width, tab_strip_height), ); + let direction = ctx.direction(); let label_font_px = ctx.theme().typography.label.size.as_px_f32(); - let tab_views: Vec = build_tab_strip(ctx, tabs, strip_rect, label_font_px); + let tab_views: Vec = build_tab_strip(ctx, tabs, strip_rect, label_font_px) + .into_iter() + .map(|t| Tab { + rect: t.rect.mirror_horizontally_within(strip_rect, direction), + ..t + }) + .collect(); ctx.a11y .push(id, rect, AccessNode::new(Role::TabPanel).with_label(label)); let mut paint = vec![WidgetPaint::Surface { @@ -179,6 +186,7 @@ pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_>) -> RibbonResponse group_label_height, group_gap, group_padding, + direction, }, &mut activated_tool, &mut overflow_toggled, @@ -235,6 +243,7 @@ struct GroupLayout { group_label_height: LayoutPx, group_gap: LayoutPx, group_padding: LayoutPx, + direction: LayoutDirection, } fn render_groups( @@ -251,10 +260,15 @@ fn render_groups( group_label_height, group_gap, group_padding, + direction, } = layout; let mut paint = Vec::new(); - let layouts = group_rects(body_rect, groups, group_gap); - paint.extend(group_dividers(&layouts, body_rect, group_gap, ctx)); + let raw_layouts = group_rects(body_rect, groups, group_gap); + let layouts: Vec = raw_layouts + .iter() + .map(|r| r.mirror_horizontally_within(body_rect, direction)) + .collect(); + paint.extend(group_dividers(&raw_layouts, body_rect, group_gap, direction, ctx)); groups .iter() .zip(layouts.iter()) @@ -305,6 +319,7 @@ fn group_dividers( layouts: &[LayoutRect], body: LayoutRect, gap: LayoutPx, + direction: LayoutDirection, ctx: &FrameCtx<'_>, ) -> Vec { let thickness = StrokeWidth::HAIRLINE.value_px(); @@ -326,6 +341,7 @@ fn group_dividers( LayoutPx::saturating_nonneg(body.size.height.value() - 2.0 * inset_y), ), ) + .mirror_horizontally_within(body, direction) }) .map(|rect| WidgetPaint::Surface { rect, diff --git a/crates/bone-ui/src/widgets/status_bar.rs b/crates/bone-ui/src/widgets/status_bar.rs index b41bd4f..160fe0e 100644 --- a/crates/bone-ui/src/widgets/status_bar.rs +++ b/crates/bone-ui/src/widgets/status_bar.rs @@ -110,7 +110,11 @@ pub fn show_status_bar(ctx: &mut FrameCtx<'_>, bar: StatusBar<'_>) -> StatusBarR radius: ctx.theme().radius.none, elevation: None, }]; - let layouts = lay_out_items(bar.rect, bar.items); + let direction = ctx.direction(); + let layouts: Vec = lay_out_items(bar.rect, bar.items) + .into_iter() + .map(|r| r.mirror_horizontally_within(bar.rect, direction)) + .collect(); let mut activated: Option = None; bar.items .iter() diff --git a/crates/bone-ui/src/widgets/toolbar.rs b/crates/bone-ui/src/widgets/toolbar.rs index 139a367..619fd2b 100644 --- a/crates/bone-ui/src/widgets/toolbar.rs +++ b/crates/bone-ui/src/widgets/toolbar.rs @@ -1,7 +1,7 @@ use crate::a11y::{AccessNode, Role}; use crate::frame::{FrameCtx, InteractDeclaration}; use crate::hit_test::{Sense, ZLayer}; -use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; use crate::strings::StringKey; use crate::theme::Step12; use crate::widget_id::{WidgetId, WidgetKey}; @@ -169,7 +169,13 @@ pub fn show_toolbar(ctx: &mut FrameCtx<'_>, toolbar: Toolbar<'_>) -> ToolbarResp orientation, overflow, } = toolbar; - let plan = layout_with_overflow(rect, items, item_size, item_gap, orientation, overflow); + let direction = ctx.direction(); + let plan = mirror_plan( + layout_with_overflow(rect, items, item_size, item_gap, orientation, overflow), + rect, + orientation, + direction, + ); ctx.a11y .push(id, rect, AccessNode::new(Role::Toolbar).with_label(label)); let mut paint = Vec::new(); @@ -544,6 +550,28 @@ struct LayoutPlan { hidden_count: usize, } +fn mirror_plan( + plan: LayoutPlan, + bar: LayoutRect, + orientation: ToolbarOrientation, + direction: LayoutDirection, +) -> LayoutPlan { + match (orientation, direction) { + (ToolbarOrientation::Horizontal, LayoutDirection::Rtl) => LayoutPlan { + visible: plan + .visible + .into_iter() + .map(|r| r.mirror_horizontally_within(bar, direction)) + .collect(), + chevron: plan + .chevron + .map(|r| r.mirror_horizontally_within(bar, direction)), + hidden_count: plan.hidden_count, + }, + _ => plan, + } +} + fn layout_with_overflow( rect: LayoutRect, items: &[ToolbarItem], -- 2.51.2