Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): chrome bands under rtl locale

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 21, 2026, 11:35 PM +0300) commit d55f513a parent 1ce6215b change-id wsrtwylu
+400 -14
+138 -3
crates/bone-app/src/shell.rs
··· 2713 2713 use bone_ui::hit_test::{HitFrame, HitState}; 2714 2714 use bone_ui::hotkey::HotkeyTable; 2715 2715 use bone_ui::input::{FrameInstant, InputSnapshot}; 2716 - use bone_ui::strings::StringTable; 2716 + use bone_ui::strings::{Locale, StringKey, StringTable}; 2717 2717 use bone_ui::theme::Theme; 2718 2718 use bone_ui::widgets::LabelText; 2719 2719 use std::sync::Arc; ··· 2738 2738 ) 2739 2739 } 2740 2740 2741 - fn render_into_shell( 2741 + fn render_with_strings( 2742 2742 shell: &mut Shell, 2743 2743 theme: Theme, 2744 2744 size: LayoutSize, 2745 2745 document: &Document, 2746 2746 mode: &Mode, 2747 2747 selection: &Selection, 2748 + strings: &StringTable, 2748 2749 ) -> ShellFrame { 2749 2750 let theme = Arc::new(theme); 2750 2751 let table = HotkeyTable::new(); ··· 2759 2760 &mut input, 2760 2761 &mut focus, 2761 2762 &table, 2762 - StringTable::empty(), 2763 + strings, 2763 2764 &mut hits, 2764 2765 &prev, 2765 2766 &mut a11y, ··· 2774 2775 size, 2775 2776 None, 2776 2777 ) 2778 + } 2779 + 2780 + fn render_into_shell( 2781 + shell: &mut Shell, 2782 + theme: Theme, 2783 + size: LayoutSize, 2784 + document: &Document, 2785 + mode: &Mode, 2786 + selection: &Selection, 2787 + ) -> ShellFrame { 2788 + render_with_strings( 2789 + shell, 2790 + theme, 2791 + size, 2792 + document, 2793 + mode, 2794 + selection, 2795 + StringTable::empty(), 2796 + ) 2797 + } 2798 + 2799 + fn label_rect(paints: &[WidgetPaint], target: StringKey) -> Option<LayoutRect> { 2800 + paints.iter().find_map(|p| match p { 2801 + WidgetPaint::Label { 2802 + rect, 2803 + text: LabelText::Key(k), 2804 + .. 2805 + } 2806 + | WidgetPaint::AlignedLabel { 2807 + rect, 2808 + text: LabelText::Key(k), 2809 + .. 2810 + } if *k == target => Some(*rect), 2811 + _ => None, 2812 + }) 2777 2813 } 2778 2814 2779 2815 #[test] ··· 3912 3948 Some(sketch_id), 3913 3949 "double-click on sketch row must emit sketch_activated for that sketch", 3914 3950 ); 3951 + } 3952 + 3953 + fn render_with_locale(size: LayoutSize, locale: Locale) -> ShellFrame { 3954 + let strings = crate::strings::make_strings(locale); 3955 + let mut shell = Shell::new(); 3956 + render_with_strings( 3957 + &mut shell, 3958 + Theme::light(), 3959 + size, 3960 + &sample_document(), 3961 + &Mode::Idle, 3962 + &Selection::default(), 3963 + &strings, 3964 + ) 3965 + } 3966 + 3967 + const CHROME_BAND_KEYS: [StringKey; 3] = [ 3968 + strings::MENU_FILE, 3969 + strings::RIBBON_TAB_SKETCH, 3970 + strings::STATUS_READY, 3971 + ]; 3972 + 3973 + fn assert_chrome_label_mirrors_under_rtl(key: StringKey) { 3974 + let size = layout_size(1600.0, 900.0); 3975 + let ltr = render_with_locale(size, Locale::EnUs); 3976 + let rtl = render_with_locale(size, Locale::ArXb); 3977 + let ltr_rect = label_rect(&ltr.paints, key) 3978 + .unwrap_or_else(|| panic!("ltr paint missing for {key}")); 3979 + let rtl_rect = label_rect(&rtl.paints, key) 3980 + .unwrap_or_else(|| panic!("rtl paint missing for {key}")); 3981 + let half = size.width.value() * 0.5; 3982 + assert!( 3983 + ltr_rect.origin.x.value() < half, 3984 + "{key} must sit on the left half under ltr, got x={}", 3985 + ltr_rect.origin.x.value(), 3986 + ); 3987 + assert!( 3988 + rtl_rect.origin.x.value() > half, 3989 + "{key} must mirror to the right half under rtl, got x={}", 3990 + rtl_rect.origin.x.value(), 3991 + ); 3992 + } 3993 + 3994 + #[test] 3995 + fn rtl_locale_flips_viewport_to_the_left_side() { 3996 + let size = layout_size(1600.0, 900.0); 3997 + let ltr = render_with_locale(size, Locale::EnUs); 3998 + let rtl = render_with_locale(size, Locale::ArXb); 3999 + assert!( 4000 + ltr.viewport_rect.size.width.value() > 0.0, 4001 + "ltr viewport must have width", 4002 + ); 4003 + assert!( 4004 + rtl.viewport_rect.size.width.value() > 0.0, 4005 + "rtl viewport must have width", 4006 + ); 4007 + assert!( 4008 + ltr.viewport_rect.origin.x.value() > size.width.value() * 0.1, 4009 + "ltr viewport sits right of the left pane, got x={}", 4010 + ltr.viewport_rect.origin.x.value(), 4011 + ); 4012 + assert!( 4013 + rtl.viewport_rect.origin.x.value() < size.width.value() * 0.1, 4014 + "rtl viewport must hug the left edge, got x={}", 4015 + rtl.viewport_rect.origin.x.value(), 4016 + ); 4017 + assert!( 4018 + (ltr.viewport_rect.size.width.value() - rtl.viewport_rect.size.width.value()).abs() 4019 + < 1.0, 4020 + "viewport width is independent of direction", 4021 + ); 4022 + } 4023 + 4024 + #[test] 4025 + fn rtl_locale_still_renders_every_chrome_band() { 4026 + let size = layout_size(1600.0, 900.0); 4027 + let rtl = render_with_locale(size, Locale::ArXb); 4028 + assert!(!rtl.paints.is_empty(), "rtl shell must emit chrome paints"); 4029 + CHROME_BAND_KEYS.into_iter().for_each(|key| { 4030 + assert!( 4031 + label_rect(&rtl.paints, key).is_some(), 4032 + "rtl shell must emit a label paint for {key}", 4033 + ); 4034 + }); 4035 + } 4036 + 4037 + #[test] 4038 + fn rtl_locale_mirrors_menu_bar_file_label() { 4039 + assert_chrome_label_mirrors_under_rtl(strings::MENU_FILE); 4040 + } 4041 + 4042 + #[test] 4043 + fn rtl_locale_mirrors_ribbon_sketch_tab() { 4044 + assert_chrome_label_mirrors_under_rtl(strings::RIBBON_TAB_SKETCH); 4045 + } 4046 + 4047 + #[test] 4048 + fn rtl_locale_mirrors_status_bar_mode_label() { 4049 + assert_chrome_label_mirrors_under_rtl(strings::STATUS_READY); 3915 4050 } 3916 4051 }
+58
crates/bone-app/src/strings.rs
··· 710 710 (PROPERTY_VALUE_DRIVING, "[!! Drîving !!]"), 711 711 (PROPERTY_VALUE_DRIVEN, "[!! Drîven !!]"), 712 712 ]; 713 + 714 + #[cfg(test)] 715 + mod tests { 716 + use super::{AR_XB, EN_US, make_strings}; 717 + use bone_ui::layout::LayoutDirection; 718 + use bone_ui::strings::{Locale, StringKey}; 719 + use std::collections::BTreeSet; 720 + 721 + fn keys(entries: &[(StringKey, &str)]) -> BTreeSet<StringKey> { 722 + entries.iter().map(|(k, _)| *k).collect() 723 + } 724 + 725 + #[test] 726 + fn ar_xb_bundle_covers_every_en_us_key() { 727 + let en: BTreeSet<StringKey> = keys(EN_US); 728 + let ar: BTreeSet<StringKey> = keys(AR_XB); 729 + let missing: Vec<StringKey> = en.difference(&ar).copied().collect(); 730 + assert!( 731 + missing.is_empty(), 732 + "ar-XB missing translations for: {missing:?}", 733 + ); 734 + } 735 + 736 + #[test] 737 + fn ar_xb_does_not_invent_unknown_keys() { 738 + let en: BTreeSet<StringKey> = keys(EN_US); 739 + let ar: BTreeSet<StringKey> = keys(AR_XB); 740 + let stray: Vec<StringKey> = ar.difference(&en).copied().collect(); 741 + assert!( 742 + stray.is_empty(), 743 + "ar-XB defines keys not declared in en-US: {stray:?}", 744 + ); 745 + } 746 + 747 + #[test] 748 + fn loading_ar_xb_yields_rtl_table_with_non_empty_entries() { 749 + let table = make_strings(Locale::ArXb); 750 + assert_eq!(table.direction(), LayoutDirection::Rtl); 751 + AR_XB.iter().for_each(|(key, _)| { 752 + assert!( 753 + !table.resolve(*key).is_empty(), 754 + "ar-XB entry for {key} resolved to empty string", 755 + ); 756 + }); 757 + } 758 + 759 + #[test] 760 + fn loading_en_us_yields_ltr_table_with_non_empty_entries() { 761 + let table = make_strings(Locale::EnUs); 762 + assert_eq!(table.direction(), LayoutDirection::Ltr); 763 + EN_US.iter().for_each(|(key, _)| { 764 + assert!( 765 + !table.resolve(*key).is_empty(), 766 + "en-US entry for {key} resolved to empty string", 767 + ); 768 + }); 769 + } 770 + }
+98
crates/bone-render/src/pipelines/text.rs
··· 351 351 .map_or(0.0, ShapedLine::visible_advance_px); 352 352 ZenoPoint::new(-visible_advance * 0.5, -cap_height * 0.5) 353 353 } 354 + 355 + #[cfg(test)] 356 + mod tests { 357 + use super::{ 358 + FontFace, ShapedText, Shaper, TessellatedOutline, label_center, load_font, shape_line, 359 + tessellate, 360 + }; 361 + use lyon_tessellation::FillTessellator; 362 + use swash::scale::ScaleContext; 363 + 364 + const DIM_FONT_SIZE_PX: f32 = 14.0; 365 + 366 + fn shape_only(text: &str) -> ShapedText { 367 + let mut shaper = Shaper::new(); 368 + shape_line(text, DIM_FONT_SIZE_PX, FontFace::Mono, &mut shaper) 369 + } 370 + 371 + fn run_tessellate(text: &str) -> TessellatedOutline { 372 + let font = load_font(FontFace::Mono); 373 + let mut shaper = Shaper::new(); 374 + let mut scale_ctx = ScaleContext::new(); 375 + let mut fill = FillTessellator::new(); 376 + tessellate( 377 + text, 378 + DIM_FONT_SIZE_PX, 379 + &font, 380 + &mut shaper, 381 + &mut scale_ctx, 382 + &mut fill, 383 + ) 384 + } 385 + 386 + #[test] 387 + fn arabic_dimension_label_tessellates_to_visible_geometry() { 388 + let text = "\u{0627}\u{0644}\u{0637}\u{0648}\u{0644}"; 389 + let shaped = shape_only(text); 390 + let rtl_glyph_count: usize = shaped 391 + .lines 392 + .iter() 393 + .flat_map(|line| line.runs.iter()) 394 + .filter(|run| run.is_rtl) 395 + .map(|run| run.glyphs.len()) 396 + .sum(); 397 + assert_eq!( 398 + rtl_glyph_count, 399 + text.chars().count(), 400 + "ar shaping must emit one rtl glyph per codepoint, got {} for {} chars", 401 + rtl_glyph_count, 402 + text.chars().count(), 403 + ); 404 + let result = run_tessellate(text); 405 + assert!( 406 + !result.is_empty(), 407 + "complex-script dim label must produce geometry", 408 + ); 409 + assert!(result.indices.len().is_multiple_of(3)); 410 + } 411 + 412 + #[test] 413 + fn bidi_dimension_label_tessellates_to_visible_geometry() { 414 + let text = "R 5.00 \u{0645}\u{0645}"; 415 + let shaped = shape_only(text); 416 + let runs: Vec<_> = shaped.lines.iter().flat_map(|l| l.runs.iter()).collect(); 417 + assert!( 418 + runs.iter().any(|run| !run.is_rtl), 419 + "bidi dim label must retain its ltr ascii prefix", 420 + ); 421 + assert!( 422 + runs.iter().any(|run| run.is_rtl), 423 + "bidi dim label must shape its rtl arabic suffix", 424 + ); 425 + let result = run_tessellate(text); 426 + assert!( 427 + !result.is_empty(), 428 + "mixed-direction dim label must produce geometry", 429 + ); 430 + assert!(result.indices.len().is_multiple_of(3)); 431 + } 432 + 433 + #[test] 434 + fn arabic_label_center_offsets_by_half_visible_advance() { 435 + let mut shaper = Shaper::new(); 436 + let font = load_font(FontFace::Mono); 437 + let layout = shape_line( 438 + "\u{0627}\u{0644}\u{0637}\u{0648}\u{0644}", 439 + DIM_FONT_SIZE_PX, 440 + FontFace::Mono, 441 + &mut shaper, 442 + ); 443 + let metrics = font.metrics(&[]).scale(DIM_FONT_SIZE_PX); 444 + let center = label_center(&layout, metrics.cap_height); 445 + assert!( 446 + center.x < 0.0, 447 + "label_center must shift the anchor by half the visible advance, got x={}", 448 + center.x, 449 + ); 450 + } 451 + }
+19
crates/bone-text/src/fonts.rs
··· 91 91 } 92 92 93 93 #[test] 94 + fn bundled_fonts_cover_arabic_baseline_for_complex_script_dim_labels() { 95 + let sans = load_font(FontFace::Sans); 96 + let mono = load_font(FontFace::Mono); 97 + ['\u{0627}', '\u{0644}', '\u{0637}', '\u{0648}', '\u{0645}'] 98 + .into_iter() 99 + .for_each(|ch| { 100 + let cp = u32::from(ch); 101 + assert!( 102 + sans.charmap().map(cp) > 0, 103 + "sans must cover arabic codepoint U+{cp:04X}", 104 + ); 105 + assert!( 106 + mono.charmap().map(cp) > 0, 107 + "mono must cover arabic codepoint U+{cp:04X}", 108 + ); 109 + }); 110 + } 111 + 112 + #[test] 94 113 fn parley_weight_round_trips_each_step() { 95 114 assert_eq!(parley_weight(FontWeight::Regular), ParleyFontWeight::NORMAL); 96 115 assert_eq!(parley_weight(FontWeight::Medium), ParleyFontWeight::MEDIUM);
+21
crates/bone-ui/src/layout/geometry.rs
··· 203 203 ), 204 204 ) 205 205 } 206 + 207 + #[must_use] 208 + pub fn mirror_horizontally_within( 209 + self, 210 + container: Self, 211 + direction: super::axis::LayoutDirection, 212 + ) -> Self { 213 + match direction { 214 + super::axis::LayoutDirection::Ltr => self, 215 + super::axis::LayoutDirection::Rtl => { 216 + let container_left = container.origin.x.value(); 217 + let container_right = container_left + container.size.width.value(); 218 + let mirrored_x = 219 + container_right - (self.origin.x.value() - container_left) - self.size.width.value(); 220 + Self::new( 221 + LayoutPos::new(LayoutPx::new(mirrored_x), self.origin.y), 222 + self.size, 223 + ) 224 + } 225 + } 226 + } 206 227 } 207 228 208 229 #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
+11 -4
crates/bone-ui/src/widgets/menu.rs
··· 2 2 use crate::frame::{FrameCtx, InteractDeclaration}; 3 3 use crate::hit_test::{Sense, ZLayer}; 4 4 use crate::input::{KeyCode, NamedKey}; 5 - use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6 6 use crate::strings::StringKey; 7 7 use crate::theme::{Border, Color, Step12, StrokeWidth}; 8 8 use crate::widget_id::{WidgetId, WidgetKey}; ··· 723 723 LayoutPx::new(total.max(min_item_width.value())) 724 724 }) 725 725 .collect(); 726 - let entry_layouts = entry_rects(rect, &widths); 726 + let direction = ctx.direction(); 727 + let raw_entry_layouts = entry_rects(rect, &widths); 728 + let entry_layouts: Vec<LayoutRect> = raw_entry_layouts 729 + .iter() 730 + .map(|r| r.mirror_horizontally_within(rect, direction)) 731 + .collect(); 727 732 entries 728 733 .iter() 729 734 .zip(entry_layouts.iter()) ··· 743 748 rect, 744 749 request, 745 750 item_padding, 746 - entry_layouts.as_slice(), 751 + raw_entry_layouts.as_slice(), 752 + direction, 747 753 )); 748 754 } 749 755 let mut popover_paint = Vec::new(); ··· 876 882 request: ShapeRequest, 877 883 item_padding: LayoutPx, 878 884 entry_layouts: &[LayoutRect], 885 + direction: LayoutDirection, 879 886 ) -> WidgetPaint { 880 887 let resolved = label_text.resolve(ctx.strings); 881 888 let advance = ctx ··· 901 908 ), 902 909 ); 903 910 WidgetPaint::AlignedLabel { 904 - rect: trailing_rect, 911 + rect: trailing_rect.mirror_horizontally_within(bar_rect, direction), 905 912 text: label_text, 906 913 color: ctx.theme().colors.text_primary(), 907 914 role: ctx.theme().typography.label,
+20 -4
crates/bone-ui/src/widgets/ribbon.rs
··· 1 1 use crate::a11y::{AccessNode, Role}; 2 2 use crate::frame::FrameCtx; 3 - use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 3 + use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 4 use crate::strings::StringKey; 5 5 use crate::theme::{Border, Step12, StrokeWidth}; 6 6 use crate::widget_id::{WidgetId, WidgetKey}; ··· 141 141 ), 142 142 LayoutSize::new(rect.size.width, tab_strip_height), 143 143 ); 144 + let direction = ctx.direction(); 144 145 let label_font_px = ctx.theme().typography.label.size.as_px_f32(); 145 - let tab_views: Vec<Tab> = build_tab_strip(ctx, tabs, strip_rect, label_font_px); 146 + let tab_views: Vec<Tab> = build_tab_strip(ctx, tabs, strip_rect, label_font_px) 147 + .into_iter() 148 + .map(|t| Tab { 149 + rect: t.rect.mirror_horizontally_within(strip_rect, direction), 150 + ..t 151 + }) 152 + .collect(); 146 153 ctx.a11y 147 154 .push(id, rect, AccessNode::new(Role::TabPanel).with_label(label)); 148 155 let mut paint = vec![WidgetPaint::Surface { ··· 179 186 group_label_height, 180 187 group_gap, 181 188 group_padding, 189 + direction, 182 190 }, 183 191 &mut activated_tool, 184 192 &mut overflow_toggled, ··· 235 243 group_label_height: LayoutPx, 236 244 group_gap: LayoutPx, 237 245 group_padding: LayoutPx, 246 + direction: LayoutDirection, 238 247 } 239 248 240 249 fn render_groups( ··· 251 260 group_label_height, 252 261 group_gap, 253 262 group_padding, 263 + direction, 254 264 } = layout; 255 265 let mut paint = Vec::new(); 256 - let layouts = group_rects(body_rect, groups, group_gap); 257 - paint.extend(group_dividers(&layouts, body_rect, group_gap, ctx)); 266 + let raw_layouts = group_rects(body_rect, groups, group_gap); 267 + let layouts: Vec<LayoutRect> = raw_layouts 268 + .iter() 269 + .map(|r| r.mirror_horizontally_within(body_rect, direction)) 270 + .collect(); 271 + paint.extend(group_dividers(&raw_layouts, body_rect, group_gap, direction, ctx)); 258 272 groups 259 273 .iter() 260 274 .zip(layouts.iter()) ··· 305 319 layouts: &[LayoutRect], 306 320 body: LayoutRect, 307 321 gap: LayoutPx, 322 + direction: LayoutDirection, 308 323 ctx: &FrameCtx<'_>, 309 324 ) -> Vec<WidgetPaint> { 310 325 let thickness = StrokeWidth::HAIRLINE.value_px(); ··· 326 341 LayoutPx::saturating_nonneg(body.size.height.value() - 2.0 * inset_y), 327 342 ), 328 343 ) 344 + .mirror_horizontally_within(body, direction) 329 345 }) 330 346 .map(|rect| WidgetPaint::Surface { 331 347 rect,
+5 -1
crates/bone-ui/src/widgets/status_bar.rs
··· 110 110 radius: ctx.theme().radius.none, 111 111 elevation: None, 112 112 }]; 113 - let layouts = lay_out_items(bar.rect, bar.items); 113 + let direction = ctx.direction(); 114 + let layouts: Vec<LayoutRect> = lay_out_items(bar.rect, bar.items) 115 + .into_iter() 116 + .map(|r| r.mirror_horizontally_within(bar.rect, direction)) 117 + .collect(); 114 118 let mut activated: Option<WidgetId> = None; 115 119 bar.items 116 120 .iter()
+30 -2
crates/bone-ui/src/widgets/toolbar.rs
··· 1 1 use crate::a11y::{AccessNode, Role}; 2 2 use crate::frame::{FrameCtx, InteractDeclaration}; 3 3 use crate::hit_test::{Sense, ZLayer}; 4 - use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 + use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 5 use crate::strings::StringKey; 6 6 use crate::theme::Step12; 7 7 use crate::widget_id::{WidgetId, WidgetKey}; ··· 169 169 orientation, 170 170 overflow, 171 171 } = toolbar; 172 - let plan = layout_with_overflow(rect, items, item_size, item_gap, orientation, overflow); 172 + let direction = ctx.direction(); 173 + let plan = mirror_plan( 174 + layout_with_overflow(rect, items, item_size, item_gap, orientation, overflow), 175 + rect, 176 + orientation, 177 + direction, 178 + ); 173 179 ctx.a11y 174 180 .push(id, rect, AccessNode::new(Role::Toolbar).with_label(label)); 175 181 let mut paint = Vec::new(); ··· 542 548 visible: Vec<LayoutRect>, 543 549 chevron: Option<LayoutRect>, 544 550 hidden_count: usize, 551 + } 552 + 553 + fn mirror_plan( 554 + plan: LayoutPlan, 555 + bar: LayoutRect, 556 + orientation: ToolbarOrientation, 557 + direction: LayoutDirection, 558 + ) -> LayoutPlan { 559 + match (orientation, direction) { 560 + (ToolbarOrientation::Horizontal, LayoutDirection::Rtl) => LayoutPlan { 561 + visible: plan 562 + .visible 563 + .into_iter() 564 + .map(|r| r.mirror_horizontally_within(bar, direction)) 565 + .collect(), 566 + chevron: plan 567 + .chevron 568 + .map(|r| r.mirror_horizontally_within(bar, direction)), 569 + hidden_count: plan.hidden_count, 570 + }, 571 + _ => plan, 572 + } 545 573 } 546 574 547 575 fn layout_with_overflow(