Another project
1
fork

Configure Feed

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

feat(ui): locales

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

+765 -51
+32 -10
crates/bone-ui/src/frame.rs
··· 4 4 use crate::hit_test::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer}; 5 5 use crate::hotkey::{ActionId, HotkeyScopes, HotkeyTable, KeyChord}; 6 6 use crate::input::{InputSnapshot, KeyEvent}; 7 - use crate::layout::LayoutRect; 8 - use crate::strings::StringTable; 7 + use crate::layout::{LayoutDirection, LayoutRect}; 8 + use crate::strings::{Locale, StringTable}; 9 9 use crate::theme::Theme; 10 10 use crate::widget_id::WidgetId; 11 11 ··· 95 95 } 96 96 97 97 #[must_use] 98 + pub fn locale(&self) -> Locale { 99 + self.strings.locale() 100 + } 101 + 102 + #[must_use] 103 + pub fn direction(&self) -> LayoutDirection { 104 + self.strings.direction() 105 + } 106 + 107 + #[must_use] 98 108 pub fn is_focused(&self, id: WidgetId) -> bool { 99 109 self.focus.focused() == Some(id) 100 110 } ··· 173 183 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, PointerButton, 174 184 PointerButtonMask, PointerSample, 175 185 }; 176 - use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 177 - use crate::strings::StringTable; 186 + use crate::layout::{LayoutDirection, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 187 + use crate::strings::{Locale, StringTable}; 178 188 use crate::theme::{Theme, ThemeMode}; 179 189 use crate::widget_id::{WidgetId, WidgetKey}; 180 190 ··· 407 417 |_| Theme::dark(), 408 418 |frame| { 409 419 let outer_mode = frame.theme().mode; 410 - let inner_mode = 411 - frame.theme_scope(|_| Theme::light(), |frame| frame.theme().mode); 420 + let inner_mode = frame.theme_scope(|_| Theme::light(), |frame| frame.theme().mode); 412 421 let after_inner = frame.theme().mode; 413 422 (outer_mode, inner_mode, after_inner) 414 423 }, 415 424 ); 416 - assert_eq!( 417 - modes, 418 - (ThemeMode::Dark, ThemeMode::Light, ThemeMode::Dark) 419 - ); 425 + assert_eq!(modes, (ThemeMode::Dark, ThemeMode::Light, ThemeMode::Dark)); 420 426 assert_eq!(frame.theme().mode, ThemeMode::Light); 421 427 } 422 428 ··· 499 505 assert_ne!(surface_a, surface_b); 500 506 assert!(surface_a.relative_luminance() > surface_b.relative_luminance()); 501 507 assert!(text_a.relative_luminance() < text_b.relative_luminance()); 508 + } 509 + 510 + #[test] 511 + fn locale_and_direction_follow_string_table() { 512 + let theme = Arc::new(Theme::light()); 513 + let mut focus = FocusManager::new(); 514 + let hotkeys = HotkeyTable::new(); 515 + let mut hits = HitFrame::new(); 516 + let prev = HitState::new(); 517 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 518 + let strings = StringTable::for_locale(Locale::ArXb); 519 + let frame = FrameCtx::new( 520 + theme, &mut input, &mut focus, &hotkeys, &strings, &mut hits, &prev, 521 + ); 522 + assert_eq!(frame.locale(), Locale::ArXb); 523 + assert_eq!(frame.direction(), LayoutDirection::Rtl); 502 524 } 503 525 }
+7
crates/bone-ui/src/layout/axis.rs
··· 6 6 Vertical, 7 7 } 8 8 9 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] 10 + pub enum LayoutDirection { 11 + #[default] 12 + Ltr, 13 + Rtl, 14 + } 15 + 9 16 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] 10 17 pub enum MainAxisJustify { 11 18 Start,
+28 -14
crates/bone-ui/src/layout/engine.rs
··· 6 6 GridTemplateComponent, JustifyContent, Layout as TaffyLayout, Line as TaffyLine, NodeId, Style, 7 7 TaffyAuto, TaffyTree, auto, fr, length, 8 8 }; 9 - use taffy::style::Overflow; 9 + use taffy::style::{Direction as TaffyDirection, Overflow}; 10 10 11 - use super::axis::{Axis, CrossAxisAlign, MainAxisJustify}; 11 + use super::axis::{Axis, CrossAxisAlign, LayoutDirection, MainAxisJustify}; 12 12 use super::dock::{DockNode, DockState, PanelId, SplitFraction}; 13 13 use super::geometry::{EdgeInsets, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 14 14 use super::primitives::{DockPanel, GridChild, Layout}; ··· 101 101 layout: &Layout, 102 102 available: LayoutSize, 103 103 retained: &RetainedLayout, 104 + direction: LayoutDirection, 104 105 ) -> Result<SolvedLayout, LayoutError> { 105 - let mut engine = Engine::new(); 106 + let mut engine = Engine::new(direction); 106 107 let root_taffy = engine.lower(layout, retained)?; 107 108 let mut root_style = engine.style_of(root_taffy)?; 108 109 root_style.size = TaffySize { ··· 127 128 struct Engine { 128 129 tree: TaffyTree<NodeKind>, 129 130 solved: Vec<SolvedNode>, 131 + direction: LayoutDirection, 130 132 } 131 133 132 134 impl Engine { 133 - fn new() -> Self { 135 + fn new(direction: LayoutDirection) -> Self { 134 136 Self { 135 137 tree: TaffyTree::new(), 136 138 solved: Vec::new(), 139 + direction, 137 140 } 138 141 } 139 142 ··· 170 173 } 171 174 } 172 175 176 + const fn taffy_direction(direction: LayoutDirection) -> TaffyDirection { 177 + match direction { 178 + LayoutDirection::Ltr => TaffyDirection::Ltr, 179 + LayoutDirection::Rtl => TaffyDirection::Rtl, 180 + } 181 + } 182 + 173 183 fn fill_along_main(style: Style) -> Style { 174 184 Style { 175 185 flex_grow: 1.0, ··· 179 189 } 180 190 } 181 191 182 - fn flex_stretch_style(axis: Axis) -> Style { 192 + fn flex_stretch_style(axis: Axis, direction: LayoutDirection) -> Style { 183 193 fill_along_main(Style { 184 194 display: Display::Flex, 185 195 flex_direction: flex_direction_for(axis), 186 196 align_items: Some(AlignItems::Stretch), 197 + direction: taffy_direction(direction), 187 198 ..Style::DEFAULT 188 199 }) 189 200 } ··· 284 295 CrossAxisAlign::End => AlignItems::FlexEnd, 285 296 CrossAxisAlign::Stretch => AlignItems::Stretch, 286 297 }), 298 + direction: taffy_direction(self.direction), 287 299 ..Style::DEFAULT 288 300 }); 289 301 self.parent(NodeKind::Pass, style, &kids) ··· 308 320 grid_template_rows: vec![fr::<f32, GridTemplateComponent<String>>(1.0)], 309 321 justify_items: Some(AlignItems::Stretch), 310 322 align_items: Some(AlignItems::Stretch), 323 + direction: taffy_direction(self.direction), 311 324 ..Style::DEFAULT 312 325 }); 313 326 kids.iter() ··· 365 378 width: length(column_gap.value_px()), 366 379 height: length(row_gap.value_px()), 367 380 }, 381 + direction: taffy_direction(self.direction), 368 382 ..Style::DEFAULT 369 383 }); 370 384 self.parent(NodeKind::Pass, style, &kids) ··· 379 393 let kid = self.lower(child, retained)?; 380 394 let style = Style { 381 395 padding: TaffyRect { 382 - left: length(padding.left.value_px()), 383 - right: length(padding.right.value_px()), 396 + left: length(padding.physical_left(self.direction).value_px()), 397 + right: length(padding.physical_right(self.direction).value_px()), 384 398 top: length(padding.top.value_px()), 385 399 bottom: length(padding.bottom.value_px()), 386 400 }, 387 - ..flex_stretch_style(Axis::Vertical) 401 + ..flex_stretch_style(Axis::Vertical, self.direction) 388 402 }; 389 403 self.parent(NodeKind::Pass, style, &[kid]) 390 404 } ··· 422 436 x: scroll_overflow(axes.allows_horizontal()), 423 437 y: scroll_overflow(axes.allows_vertical()), 424 438 }, 425 - ..flex_stretch_style(Axis::Vertical) 439 + ..flex_stretch_style(Axis::Vertical, self.direction) 426 440 }; 427 441 let offset = retained.scroll_for(id); 428 442 self.parent(NodeKind::ScrollRegion { id, axes, offset }, style, &[kid]) ··· 443 457 self.apply_split_grow(a_id, b_id, fraction)?; 444 458 self.parent( 445 459 NodeKind::Splitter { id, axis, fraction }, 446 - flex_stretch_style(axis), 460 + flex_stretch_style(axis, self.direction), 447 461 &[a_id, b_id], 448 462 ) 449 463 } ··· 462 476 let main = self.dock_node(&state.main, panels, tab_strip_height, retained)?; 463 477 self.parent( 464 478 NodeKind::DockHost { id }, 465 - flex_stretch_style(Axis::Vertical), 479 + flex_stretch_style(Axis::Vertical, self.direction), 466 480 &[main], 467 481 ) 468 482 } ··· 489 503 axis: *axis, 490 504 fraction: *fraction, 491 505 }, 492 - flex_stretch_style(*axis), 506 + flex_stretch_style(*axis, self.direction), 493 507 &[a_id, b_id], 494 508 ) 495 509 } ··· 506 520 let body = self.lower(&panel.child, retained)?; 507 521 let body_panel = self.parent( 508 522 NodeKind::DockPanel { id: active_id }, 509 - flex_stretch_style(Axis::Vertical), 523 + flex_stretch_style(Axis::Vertical, self.direction), 510 524 &[body], 511 525 )?; 512 526 if tabs.len() <= 1 { ··· 527 541 )?; 528 542 self.parent( 529 543 NodeKind::Pass, 530 - flex_stretch_style(Axis::Vertical), 544 + flex_stretch_style(Axis::Vertical, self.direction), 531 545 &[strip, body_panel], 532 546 ) 533 547 }
+20 -4
crates/bone-ui/src/layout/geometry.rs
··· 192 192 193 193 #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 194 194 pub struct EdgeInsets { 195 - pub left: crate::theme::Spacing, 196 - pub right: crate::theme::Spacing, 195 + pub start: crate::theme::Spacing, 196 + pub end: crate::theme::Spacing, 197 197 pub top: crate::theme::Spacing, 198 198 pub bottom: crate::theme::Spacing, 199 199 } ··· 202 202 #[must_use] 203 203 pub const fn all(value: crate::theme::Spacing) -> Self { 204 204 Self { 205 - left: value, 206 - right: value, 205 + start: value, 206 + end: value, 207 207 top: value, 208 208 bottom: value, 209 + } 210 + } 211 + 212 + #[must_use] 213 + pub fn physical_left(self, direction: super::axis::LayoutDirection) -> crate::theme::Spacing { 214 + match direction { 215 + super::axis::LayoutDirection::Ltr => self.start, 216 + super::axis::LayoutDirection::Rtl => self.end, 217 + } 218 + } 219 + 220 + #[must_use] 221 + pub fn physical_right(self, direction: super::axis::LayoutDirection) -> crate::theme::Spacing { 222 + match direction { 223 + super::axis::LayoutDirection::Ltr => self.end, 224 + super::axis::LayoutDirection::Rtl => self.start, 209 225 } 210 226 } 211 227 }
+1 -1
crates/bone-ui/src/layout/mod.rs
··· 11 11 mod tests; 12 12 mod track; 13 13 14 - pub use axis::{Axis, CrossAxisAlign, MainAxisJustify}; 14 + pub use axis::{Axis, CrossAxisAlign, LayoutDirection, MainAxisJustify}; 15 15 pub use dock::{ 16 16 DockNode, DockState, DockStateError, FloatingSurface, PanelId, SplitFraction, 17 17 SplitFractionError, TabIndex,
+235 -8
crates/bone-ui/src/layout/tests.rs
··· 1 1 use core::num::{NonZeroU16, NonZeroU32, NonZeroU64}; 2 2 3 - use super::axis::{Axis, CrossAxisAlign, MainAxisJustify}; 3 + use super::axis::{Axis, CrossAxisAlign, LayoutDirection, MainAxisJustify}; 4 4 use super::dock::{ 5 5 DockNode, DockState, DockStateError, FloatingSurface, PanelId, SplitFraction, TabIndex, 6 6 }; ··· 52 52 } 53 53 54 54 fn solve(layout: &Layout, available: LayoutSize, retained: &RetainedLayout) -> SolvedLayout { 55 - measure(layout, available, retained).unwrap_or_else(|e| panic!("measure failed: {e:?}")) 55 + solve_with(layout, available, retained, LayoutDirection::Ltr) 56 + } 57 + 58 + fn solve_with( 59 + layout: &Layout, 60 + available: LayoutSize, 61 + retained: &RetainedLayout, 62 + direction: LayoutDirection, 63 + ) -> SolvedLayout { 64 + measure(layout, available, retained, direction) 65 + .unwrap_or_else(|e| panic!("measure failed: {e:?}")) 56 66 } 57 67 58 68 const PX_EPSILON: f32 = 1e-3; ··· 220 230 child: Layout::leaf(wid(1)), 221 231 }], 222 232 }; 223 - let result = measure(&layout, size(100.0, 30.0), &RetainedLayout::default()); 233 + let result = measure( 234 + &layout, 235 + size(100.0, 30.0), 236 + &RetainedLayout::default(), 237 + LayoutDirection::Ltr, 238 + ); 224 239 let Err(LayoutError::UnknownTrackName(name)) = result else { 225 240 panic!("expected UnknownTrackName, got {result:?}"); 226 241 }; ··· 238 253 } 239 254 240 255 #[test] 256 + fn inset_start_lives_on_left_in_ltr() { 257 + let padding = EdgeInsets { 258 + start: sp(20.0), 259 + end: sp(4.0), 260 + top: sp(0.0), 261 + bottom: sp(0.0), 262 + }; 263 + let layout = Layout::inset(padding, Layout::leaf(wid(1))); 264 + let solved = solve(&layout, size(100.0, 30.0), &RetainedLayout::default()); 265 + let child = solved.node(solved.root_node().children[0]); 266 + assert!(approx_eq(child.rect.min_x().value(), 20.0, PX_EPSILON)); 267 + assert!(approx_eq(child.rect.size.width.value(), 76.0, PX_EPSILON)); 268 + } 269 + 270 + #[test] 271 + fn inset_start_flips_to_right_in_rtl() { 272 + let padding = EdgeInsets { 273 + start: sp(20.0), 274 + end: sp(4.0), 275 + top: sp(0.0), 276 + bottom: sp(0.0), 277 + }; 278 + let layout = Layout::inset(padding, Layout::leaf(wid(1))); 279 + let solved = solve_with( 280 + &layout, 281 + size(100.0, 30.0), 282 + &RetainedLayout::default(), 283 + LayoutDirection::Rtl, 284 + ); 285 + let child = solved.node(solved.root_node().children[0]); 286 + assert!(approx_eq(child.rect.min_x().value(), 4.0, PX_EPSILON)); 287 + assert!(approx_eq(child.rect.size.width.value(), 76.0, PX_EPSILON)); 288 + } 289 + 290 + #[test] 291 + fn row_in_rtl_places_first_child_on_the_right() { 292 + let layout = Layout::Row { 293 + gap: sp(0.0), 294 + justify: MainAxisJustify::Start, 295 + cross: CrossAxisAlign::Stretch, 296 + children: vec![ 297 + Layout::Gap { size: sp(30.0) }, 298 + Layout::Gap { size: sp(40.0) }, 299 + ], 300 + }; 301 + let ltr = solve(&layout, size(100.0, 10.0), &RetainedLayout::default()); 302 + let rtl = solve_with( 303 + &layout, 304 + size(100.0, 10.0), 305 + &RetainedLayout::default(), 306 + LayoutDirection::Rtl, 307 + ); 308 + let ltr_first = ltr.node(ltr.root_node().children[0]); 309 + let rtl_first = rtl.node(rtl.root_node().children[0]); 310 + assert!(approx_eq(ltr_first.rect.min_x().value(), 0.0, PX_EPSILON)); 311 + assert!(approx_eq(rtl_first.rect.min_x().value(), 70.0, PX_EPSILON)); 312 + } 313 + 314 + #[test] 315 + fn grid_columns_flip_in_rtl() { 316 + let Some(span) = GridSpan::rect(gl(1), gl(2), gl(1), gl(2)) else { 317 + panic!("valid span"); 318 + }; 319 + let layout = Layout::Grid { 320 + columns: vec![ 321 + GridTrack::unnamed(TrackSize::Fixed(sp(20.0))), 322 + GridTrack::unnamed(TrackSize::Fixed(sp(80.0))), 323 + ], 324 + rows: vec![GridTrack::unnamed(TrackSize::Fixed(sp(10.0)))], 325 + column_gap: sp(0.0), 326 + row_gap: sp(0.0), 327 + children: vec![GridChild { 328 + span, 329 + child: Layout::leaf(wid(1)), 330 + }], 331 + }; 332 + let ltr = solve(&layout, size(100.0, 10.0), &RetainedLayout::default()); 333 + let rtl = solve_with( 334 + &layout, 335 + size(100.0, 10.0), 336 + &RetainedLayout::default(), 337 + LayoutDirection::Rtl, 338 + ); 339 + let ltr_cell = ltr.node(ltr.root_node().children[0]); 340 + let rtl_cell = rtl.node(rtl.root_node().children[0]); 341 + assert!(approx_eq(ltr_cell.rect.min_x().value(), 0.0, PX_EPSILON)); 342 + assert!(approx_eq(rtl_cell.rect.min_x().value(), 80.0, PX_EPSILON)); 343 + assert!(approx_eq(ltr_cell.rect.size.width.value(), 20.0, PX_EPSILON)); 344 + assert!(approx_eq(rtl_cell.rect.size.width.value(), 20.0, PX_EPSILON)); 345 + } 346 + 347 + #[test] 348 + fn horizontal_splitter_in_rtl_swaps_panel_sides() { 349 + let id = wid(7); 350 + let mut retained = RetainedLayout::default(); 351 + retained.set_split(id, SplitFraction::clamped(0.25)); 352 + let layout = Layout::Splitter { 353 + id, 354 + axis: Axis::Horizontal, 355 + default_fraction: SplitFraction::HALF, 356 + a: Box::new(Layout::leaf(wid(1))), 357 + b: Box::new(Layout::leaf(wid(2))), 358 + }; 359 + let ltr = solve(&layout, size(200.0, 50.0), &retained); 360 + let rtl = solve_with(&layout, size(200.0, 50.0), &retained, LayoutDirection::Rtl); 361 + let ltr_a = ltr.node(ltr.root_node().children[0]); 362 + let rtl_a = rtl.node(rtl.root_node().children[0]); 363 + assert!(approx_eq(ltr_a.rect.min_x().value(), 0.0, PX_EPSILON)); 364 + assert!(approx_eq(rtl_a.rect.min_x().value(), 150.0, PX_EPSILON)); 365 + assert!(approx_eq(ltr_a.rect.size.width.value(), 50.0, 1.0)); 366 + assert!(approx_eq(rtl_a.rect.size.width.value(), 50.0, 1.0)); 367 + } 368 + 369 + #[test] 370 + fn horizontal_dock_split_in_rtl_swaps_panel_sides() { 371 + let a = pid(10); 372 + let b = pid(11); 373 + let dock = DockState::new(DockNode::Split { 374 + axis: Axis::Horizontal, 375 + fraction: SplitFraction::clamped(0.25), 376 + a: Box::new(DockNode::tabs(vec![a])), 377 + b: Box::new(DockNode::tabs(vec![b])), 378 + }); 379 + let panels = vec![ 380 + DockPanel { 381 + id: a, 382 + child: Layout::leaf(wid(1)), 383 + }, 384 + DockPanel { 385 + id: b, 386 + child: Layout::leaf(wid(2)), 387 + }, 388 + ]; 389 + let layout = Layout::DockHost { 390 + id: wid(50), 391 + state: dock, 392 + panels, 393 + tab_strip_height: sp(24.0), 394 + }; 395 + let ltr = solve(&layout, size(200.0, 100.0), &RetainedLayout::default()); 396 + let rtl = solve_with( 397 + &layout, 398 + size(200.0, 100.0), 399 + &RetainedLayout::default(), 400 + LayoutDirection::Rtl, 401 + ); 402 + let leaf_x = |solved: &SolvedLayout, target: WidgetId| { 403 + solved 404 + .nodes 405 + .iter() 406 + .find_map(|n| match n.kind { 407 + NodeKind::Leaf(id) if id == target => Some(n.rect.min_x().value()), 408 + _ => None, 409 + }) 410 + .unwrap_or_else(|| panic!("leaf {target:?} not found")) 411 + }; 412 + assert!(approx_eq(leaf_x(&ltr, wid(1)), 0.0, PX_EPSILON)); 413 + assert!(approx_eq(leaf_x(&rtl, wid(1)), 150.0, PX_EPSILON)); 414 + } 415 + 416 + #[test] 417 + fn column_cross_axis_start_flips_to_right_in_rtl() { 418 + let layout = Layout::Column { 419 + gap: sp(0.0), 420 + justify: MainAxisJustify::Start, 421 + cross: CrossAxisAlign::Start, 422 + children: vec![Layout::Row { 423 + gap: sp(0.0), 424 + justify: MainAxisJustify::Start, 425 + cross: CrossAxisAlign::Stretch, 426 + children: vec![Layout::Gap { size: sp(20.0) }], 427 + }], 428 + }; 429 + let ltr = solve(&layout, size(100.0, 50.0), &RetainedLayout::default()); 430 + let rtl = solve_with( 431 + &layout, 432 + size(100.0, 50.0), 433 + &RetainedLayout::default(), 434 + LayoutDirection::Rtl, 435 + ); 436 + let ltr_row = ltr.node(ltr.root_node().children[0]); 437 + let rtl_row = rtl.node(rtl.root_node().children[0]); 438 + assert!(approx_eq(ltr_row.rect.min_x().value(), 0.0, PX_EPSILON)); 439 + assert!(approx_eq(rtl_row.rect.min_x().value(), 80.0, PX_EPSILON)); 440 + } 441 + 442 + #[test] 241 443 fn spacer_consumes_remaining_main_axis_space() { 242 444 let layout = Layout::Row { 243 445 gap: sp(0.0), ··· 797 999 panels: Vec::new(), 798 1000 tab_strip_height: sp(24.0), 799 1001 }; 800 - let result = measure(&layout, size(100.0, 100.0), &RetainedLayout::default()); 1002 + let result = measure( 1003 + &layout, 1004 + size(100.0, 100.0), 1005 + &RetainedLayout::default(), 1006 + LayoutDirection::Ltr, 1007 + ); 801 1008 assert!(matches!(result, Err(LayoutError::EmptyDockTabs))); 802 1009 } 803 1010 ··· 967 1174 child: Layout::leaf(wid(1)), 968 1175 }], 969 1176 }; 970 - let result = measure(&layout, size(100.0, 30.0), &RetainedLayout::default()); 1177 + let result = measure( 1178 + &layout, 1179 + size(100.0, 30.0), 1180 + &RetainedLayout::default(), 1181 + LayoutDirection::Ltr, 1182 + ); 971 1183 let Err(LayoutError::DuplicateTrackName(name)) = result else { 972 1184 panic!("expected DuplicateTrackName, got {result:?}"); 973 1185 }; ··· 1016 1228 child: Layout::leaf(wid(1)), 1017 1229 }], 1018 1230 }; 1019 - let result = measure(&layout, size(100.0, 30.0), &RetainedLayout::default()); 1231 + let result = measure( 1232 + &layout, 1233 + size(100.0, 30.0), 1234 + &RetainedLayout::default(), 1235 + LayoutDirection::Ltr, 1236 + ); 1020 1237 assert!(matches!( 1021 1238 result, 1022 1239 Err(LayoutError::GridColumnSpanNotIncreasing) ··· 1046 1263 child: Layout::leaf(wid(1)), 1047 1264 }], 1048 1265 }; 1049 - let result = measure(&layout, size(60.0, 60.0), &RetainedLayout::default()); 1266 + let result = measure( 1267 + &layout, 1268 + size(60.0, 60.0), 1269 + &RetainedLayout::default(), 1270 + LayoutDirection::Ltr, 1271 + ); 1050 1272 assert!(matches!(result, Err(LayoutError::GridRowSpanNotIncreasing))); 1051 1273 } 1052 1274 ··· 1167 1389 }], 1168 1390 tab_strip_height: sp(24.0), 1169 1391 }; 1170 - let result = measure(&layout, size(200.0, 200.0), &RetainedLayout::default()); 1392 + let result = measure( 1393 + &layout, 1394 + size(200.0, 200.0), 1395 + &RetainedLayout::default(), 1396 + LayoutDirection::Ltr, 1397 + ); 1171 1398 assert!(matches!( 1172 1399 result, 1173 1400 Err(LayoutError::UnsupportedFloatingSurfaces)
+1 -1
crates/bone-ui/src/lib.rs
··· 25 25 ClickCount, DoubleClickWindow, DragThreshold, FrameInstant, InputSnapshot, KeyChar, KeyCode, 26 26 KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, 27 27 }; 28 - pub use strings::{StringKey, StringTable}; 28 + pub use strings::{Locale, PluralCategory, PluralEntry, StringKey, StringTable}; 29 29 pub use text::{ 30 30 AtlasEntry, CaretMove, GlyphId, MaxWidth, OutlineTessellator, SdfAtlas, SdfAtlasError, 31 31 SdfAtlasParams, Selection, SelectionAction, ShapeRequest, ShapedText, Shaper, SourceByteIndex,
+364 -8
crates/bone-ui/src/strings.rs
··· 4 4 5 5 use serde::Serialize; 6 6 7 + use crate::layout::LayoutDirection; 8 + 7 9 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] 8 10 #[serde(transparent)] 9 11 pub struct StringKey(&'static str); ··· 26 28 } 27 29 } 28 30 29 - #[derive(Clone, Debug, Default)] 31 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)] 32 + pub enum Locale { 33 + EnGb, 34 + ArXb, 35 + } 36 + 37 + impl Locale { 38 + pub const DEFAULT: Self = Self::EnGb; 39 + 40 + #[must_use] 41 + pub const fn as_bcp47(self) -> &'static str { 42 + match self { 43 + Self::EnGb => "en-GB", 44 + Self::ArXb => "ar-XB", 45 + } 46 + } 47 + 48 + #[must_use] 49 + pub const fn direction(self) -> LayoutDirection { 50 + match self { 51 + Self::EnGb => LayoutDirection::Ltr, 52 + Self::ArXb => LayoutDirection::Rtl, 53 + } 54 + } 55 + } 56 + 57 + impl Default for Locale { 58 + fn default() -> Self { 59 + Self::DEFAULT 60 + } 61 + } 62 + 63 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)] 64 + pub enum PluralCategory { 65 + Zero, 66 + One, 67 + Two, 68 + Few, 69 + Many, 70 + Other, 71 + } 72 + 73 + impl Locale { 74 + #[must_use] 75 + pub const fn plural_category(self, n: u64) -> PluralCategory { 76 + match self { 77 + Self::EnGb => { 78 + if n == 1 { 79 + PluralCategory::One 80 + } else { 81 + PluralCategory::Other 82 + } 83 + } 84 + Self::ArXb => arabic_plural(n), 85 + } 86 + } 87 + } 88 + 89 + const fn arabic_plural(n: u64) -> PluralCategory { 90 + match n { 91 + 0 => PluralCategory::Zero, 92 + 1 => PluralCategory::One, 93 + 2 => PluralCategory::Two, 94 + _ => match n % 100 { 95 + 3..=10 => PluralCategory::Few, 96 + 11..=99 => PluralCategory::Many, 97 + _ => PluralCategory::Other, 98 + }, 99 + } 100 + } 101 + 102 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 103 + pub struct PluralEntry { 104 + pub other: String, 105 + pub zero: Option<String>, 106 + pub one: Option<String>, 107 + pub two: Option<String>, 108 + pub few: Option<String>, 109 + pub many: Option<String>, 110 + } 111 + 112 + impl PluralEntry { 113 + #[must_use] 114 + pub fn other(other: impl Into<String>) -> Self { 115 + Self { 116 + other: other.into(), 117 + zero: None, 118 + one: None, 119 + two: None, 120 + few: None, 121 + many: None, 122 + } 123 + } 124 + 125 + #[must_use] 126 + pub fn new(one: impl Into<String>, other: impl Into<String>) -> Self { 127 + Self::other(other).with_one(one) 128 + } 129 + 130 + #[must_use] 131 + pub fn with_zero(mut self, value: impl Into<String>) -> Self { 132 + self.zero = Some(value.into()); 133 + self 134 + } 135 + 136 + #[must_use] 137 + pub fn with_one(mut self, value: impl Into<String>) -> Self { 138 + self.one = Some(value.into()); 139 + self 140 + } 141 + 142 + #[must_use] 143 + pub fn with_two(mut self, value: impl Into<String>) -> Self { 144 + self.two = Some(value.into()); 145 + self 146 + } 147 + 148 + #[must_use] 149 + pub fn with_few(mut self, value: impl Into<String>) -> Self { 150 + self.few = Some(value.into()); 151 + self 152 + } 153 + 154 + #[must_use] 155 + pub fn with_many(mut self, value: impl Into<String>) -> Self { 156 + self.many = Some(value.into()); 157 + self 158 + } 159 + 160 + fn pick(&self, category: PluralCategory) -> &str { 161 + let variant = match category { 162 + PluralCategory::Zero => self.zero.as_deref(), 163 + PluralCategory::One => self.one.as_deref(), 164 + PluralCategory::Two => self.two.as_deref(), 165 + PluralCategory::Few => self.few.as_deref(), 166 + PluralCategory::Many => self.many.as_deref(), 167 + PluralCategory::Other => return &self.other, 168 + }; 169 + variant.unwrap_or(&self.other) 170 + } 171 + } 172 + 173 + #[derive(Clone, Debug)] 30 174 pub struct StringTable { 175 + locale: Locale, 31 176 entries: HashMap<StringKey, String>, 177 + plural_entries: HashMap<StringKey, PluralEntry>, 178 + } 179 + 180 + impl Default for StringTable { 181 + fn default() -> Self { 182 + Self::for_locale(Locale::DEFAULT) 183 + } 32 184 } 33 185 34 186 impl StringTable { 35 187 #[must_use] 36 188 pub fn new() -> Self { 37 189 Self::default() 190 + } 191 + 192 + #[must_use] 193 + pub fn for_locale(locale: Locale) -> Self { 194 + Self { 195 + locale, 196 + entries: HashMap::new(), 197 + plural_entries: HashMap::new(), 198 + } 38 199 } 39 200 40 201 #[must_use] ··· 48 209 where 49 210 I: IntoIterator<Item = (StringKey, String)>, 50 211 { 51 - Self { 52 - entries: iter.into_iter().collect(), 53 - } 212 + let mut table = Self::default(); 213 + table.entries.extend(iter); 214 + table 215 + } 216 + 217 + #[must_use] 218 + pub const fn locale(&self) -> Locale { 219 + self.locale 220 + } 221 + 222 + #[must_use] 223 + pub const fn direction(&self) -> LayoutDirection { 224 + self.locale.direction() 54 225 } 55 226 56 227 pub fn insert(&mut self, key: StringKey, value: String) -> Option<String> { 57 228 self.entries.insert(key, value) 58 229 } 59 230 231 + pub fn insert_plural(&mut self, key: StringKey, plural: PluralEntry) -> Option<PluralEntry> { 232 + self.plural_entries.insert(key, plural) 233 + } 234 + 60 235 #[must_use] 61 236 pub fn resolve(&self, key: StringKey) -> &str { 62 237 self.entries.get(&key).map_or(key.0, String::as_str) 63 238 } 64 239 65 240 #[must_use] 241 + pub fn resolve_plural(&self, key: StringKey, n: u64) -> &str { 242 + let category = self.locale.plural_category(n); 243 + self.plural_entries 244 + .get(&key) 245 + .map_or(key.0, |entry| entry.pick(category)) 246 + } 247 + 248 + #[must_use] 249 + pub fn format_count(&self, n: u64) -> String { 250 + format_integer(n, self.locale) 251 + } 252 + 253 + #[must_use] 254 + pub fn format_plural(&self, key: StringKey, n: u64) -> String { 255 + self.resolve_plural(key, n) 256 + .replace("{n}", &self.format_count(n)) 257 + } 258 + 259 + #[must_use] 66 260 pub fn contains(&self, key: StringKey) -> bool { 67 - self.entries.contains_key(&key) 261 + self.entries.contains_key(&key) || self.plural_entries.contains_key(&key) 68 262 } 69 263 70 264 #[must_use] 71 265 pub fn len(&self) -> usize { 72 - self.entries.len() 266 + self.entries.len() + self.plural_entries.len() 73 267 } 74 268 75 269 #[must_use] 76 270 pub fn is_empty(&self) -> bool { 77 - self.entries.is_empty() 271 + self.entries.is_empty() && self.plural_entries.is_empty() 272 + } 273 + } 274 + 275 + fn format_integer(n: u64, locale: Locale) -> String { 276 + let raw = group_thousands(n, thousands_separator(locale)); 277 + match locale { 278 + Locale::EnGb => raw, 279 + Locale::ArXb => raw.chars().map(map_arabic_indic).collect(), 280 + } 281 + } 282 + 283 + const fn thousands_separator(locale: Locale) -> char { 284 + match locale { 285 + Locale::EnGb => ',', 286 + Locale::ArXb => '\u{066C}', 287 + } 288 + } 289 + 290 + fn group_thousands(n: u64, separator: char) -> String { 291 + let digits = n.to_string(); 292 + let head_len = digits.len() % 3; 293 + let separator = separator.to_string(); 294 + [&digits[..head_len]] 295 + .into_iter() 296 + .filter(|head| !head.is_empty()) 297 + .chain( 298 + (head_len..digits.len()) 299 + .step_by(3) 300 + .map(|start| &digits[start..start + 3]), 301 + ) 302 + .collect::<Vec<_>>() 303 + .join(&separator) 304 + } 305 + 306 + fn map_arabic_indic(c: char) -> char { 307 + if c.is_ascii_digit() { 308 + let offset = u32::from(c) - u32::from('0'); 309 + char::from_u32(0x0660 + offset).unwrap_or(c) 310 + } else { 311 + c 78 312 } 79 313 } 80 314 81 315 #[cfg(test)] 82 316 mod tests { 83 - use super::{StringKey, StringTable}; 317 + use super::{Locale, PluralCategory, PluralEntry, StringKey, StringTable}; 318 + use crate::layout::LayoutDirection; 84 319 85 320 const GREETING: StringKey = StringKey::new("dialog.greeting"); 86 321 const FAREWELL: StringKey = StringKey::new("dialog.farewell"); 322 + const ITEMS: StringKey = StringKey::new("status.items"); 87 323 88 324 #[test] 89 325 fn key_round_trips_id() { ··· 137 373 ]); 138 374 assert_eq!(table.len(), 2); 139 375 assert!(!table.is_empty()); 376 + } 377 + 378 + #[test] 379 + fn default_locale_is_en_gb_ltr() { 380 + let table = StringTable::new(); 381 + assert_eq!(table.locale(), Locale::EnGb); 382 + assert_eq!(table.direction(), LayoutDirection::Ltr); 383 + } 384 + 385 + #[test] 386 + fn ar_xb_pseudo_locale_is_rtl() { 387 + let table = StringTable::for_locale(Locale::ArXb); 388 + assert_eq!(table.direction(), LayoutDirection::Rtl); 389 + assert_eq!(table.locale().as_bcp47(), "ar-XB"); 390 + } 391 + 392 + #[test] 393 + fn english_plural_picks_one_or_other() { 394 + assert_eq!(Locale::EnGb.plural_category(0), PluralCategory::Other); 395 + assert_eq!(Locale::EnGb.plural_category(1), PluralCategory::One); 396 + assert_eq!(Locale::EnGb.plural_category(2), PluralCategory::Other); 397 + assert_eq!(Locale::EnGb.plural_category(42), PluralCategory::Other); 398 + } 399 + 400 + #[test] 401 + fn arabic_plural_covers_every_category() { 402 + assert_eq!(Locale::ArXb.plural_category(0), PluralCategory::Zero); 403 + assert_eq!(Locale::ArXb.plural_category(1), PluralCategory::One); 404 + assert_eq!(Locale::ArXb.plural_category(2), PluralCategory::Two); 405 + assert_eq!(Locale::ArXb.plural_category(5), PluralCategory::Few); 406 + assert_eq!(Locale::ArXb.plural_category(20), PluralCategory::Many); 407 + assert_eq!(Locale::ArXb.plural_category(100), PluralCategory::Other); 408 + assert_eq!(Locale::ArXb.plural_category(102), PluralCategory::Other); 409 + assert_eq!(Locale::ArXb.plural_category(105), PluralCategory::Few); 410 + } 411 + 412 + #[test] 413 + fn plural_entry_picks_variant_per_category() { 414 + let mut table = StringTable::new(); 415 + table.insert_plural(ITEMS, PluralEntry::new("{n} item", "{n} items")); 416 + assert_eq!(table.resolve_plural(ITEMS, 1), "{n} item"); 417 + assert_eq!(table.resolve_plural(ITEMS, 0), "{n} items"); 418 + assert_eq!(table.resolve_plural(ITEMS, 5), "{n} items"); 419 + } 420 + 421 + #[test] 422 + fn plural_entry_falls_back_to_other_when_variant_absent() { 423 + let mut table = StringTable::for_locale(Locale::ArXb); 424 + table.insert_plural(ITEMS, PluralEntry::new("عنصر واحد", "{n} عناصر")); 425 + assert_eq!(table.resolve_plural(ITEMS, 0), "{n} عناصر"); 426 + assert_eq!(table.resolve_plural(ITEMS, 1), "عنصر واحد"); 427 + assert_eq!(table.resolve_plural(ITEMS, 5), "{n} عناصر"); 428 + } 429 + 430 + #[test] 431 + fn plural_entry_uses_explicit_variant_when_present() { 432 + let mut table = StringTable::for_locale(Locale::ArXb); 433 + table.insert_plural( 434 + ITEMS, 435 + PluralEntry::new("one", "other") 436 + .with_zero("zero") 437 + .with_two("two") 438 + .with_few("few") 439 + .with_many("many"), 440 + ); 441 + assert_eq!(table.resolve_plural(ITEMS, 0), "zero"); 442 + assert_eq!(table.resolve_plural(ITEMS, 1), "one"); 443 + assert_eq!(table.resolve_plural(ITEMS, 2), "two"); 444 + assert_eq!(table.resolve_plural(ITEMS, 5), "few"); 445 + assert_eq!(table.resolve_plural(ITEMS, 20), "many"); 446 + assert_eq!(table.resolve_plural(ITEMS, 100), "other"); 447 + } 448 + 449 + #[test] 450 + fn missing_plural_key_falls_back_to_id() { 451 + let table = StringTable::new(); 452 + assert_eq!(table.resolve_plural(ITEMS, 1), "status.items"); 453 + } 454 + 455 + #[test] 456 + fn english_number_formatting_groups_with_comma() { 457 + let table = StringTable::for_locale(Locale::EnGb); 458 + assert_eq!(table.format_count(0), "0"); 459 + assert_eq!(table.format_count(42), "42"); 460 + assert_eq!(table.format_count(1234), "1,234"); 461 + assert_eq!(table.format_count(1_000_000), "1,000,000"); 462 + } 463 + 464 + #[test] 465 + fn arabic_number_formatting_uses_indic_digits() { 466 + let table = StringTable::for_locale(Locale::ArXb); 467 + assert_eq!(table.format_count(42), "\u{0664}\u{0662}"); 468 + assert_eq!( 469 + table.format_count(1234), 470 + "\u{0661}\u{066C}\u{0662}\u{0663}\u{0664}", 471 + ); 472 + } 473 + 474 + #[test] 475 + fn format_plural_substitutes_count_into_template() { 476 + let mut table = StringTable::new(); 477 + table.insert_plural(ITEMS, PluralEntry::new("{n} item", "{n} items")); 478 + assert_eq!(table.format_plural(ITEMS, 1), "1 item"); 479 + assert_eq!(table.format_plural(ITEMS, 1234), "1,234 items"); 480 + } 481 + 482 + #[test] 483 + fn format_plural_routes_count_through_locale_digits() { 484 + let mut table = StringTable::for_locale(Locale::ArXb); 485 + table.insert_plural(ITEMS, PluralEntry::other("{n}")); 486 + assert_eq!(table.format_plural(ITEMS, 42), "\u{0664}\u{0662}"); 487 + } 488 + 489 + #[test] 490 + fn plural_entry_other_only_falls_back_to_other_for_every_category() { 491 + let mut table = StringTable::new(); 492 + table.insert_plural(ITEMS, PluralEntry::other("{n} 件")); 493 + assert_eq!(table.resolve_plural(ITEMS, 0), "{n} 件"); 494 + assert_eq!(table.resolve_plural(ITEMS, 1), "{n} 件"); 495 + assert_eq!(table.resolve_plural(ITEMS, 5), "{n} 件"); 140 496 } 141 497 }
+1 -3
crates/bone-ui/src/widgets/property_grid.rs
··· 11 11 use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown}; 12 12 use super::paint::{LabelText, WidgetPaint}; 13 13 use super::parsed_input::show_parsed_input; 14 - use super::text_input::{ 15 - AlwaysValid, Clipboard, TextInput, TextInputState, show_text_input, 16 - }; 14 + use super::text_input::{AlwaysValid, Clipboard, TextInput, TextInputState, show_text_input}; 17 15 18 16 #[derive(Copy, Clone, Debug, PartialEq)] 19 17 pub struct PropertyCell {
+7 -1
crates/bone-ui/src/widgets/tabs.rs
··· 185 185 color: tab_label_color(ctx, is_active, tab.disabled), 186 186 }); 187 187 } 188 - push_focus_ring(ctx, &mut paint, tab.rect, ctx.theme().radius.sm, live_focused); 188 + push_focus_ring( 189 + ctx, 190 + &mut paint, 191 + tab.rect, 192 + ctx.theme().radius.sm, 193 + live_focused, 194 + ); 189 195 let activated = (interactive && !is_active && interaction.click()).then_some(tab.id); 190 196 PerTab { 191 197 activated,
+3 -1
crates/bone-ui/src/widgets/tree_view.rs
··· 312 312 paint.extend(draw_disclosure(ctx, row, disclosure_rect, state)); 313 313 } 314 314 if state.renaming == Some(row.id) { 315 - paint.extend(draw_rename_editor(ctx, row.id, row.label, label_rect, state)); 315 + paint.extend(draw_rename_editor( 316 + ctx, row.id, row.label, label_rect, state, 317 + )); 316 318 } else { 317 319 paint.push(label_paint(ctx, row, label_rect)); 318 320 }
+66
crates/bone-ui/tests/widget_label_typing.rs
··· 1 + use std::fs; 2 + use std::path::Path; 3 + 4 + const ALLOWED_STRING_FIELDS: &[(&str, &str)] = &[ 5 + ("text_input.rs", "pub text: String,"), 6 + ("text_input.rs", "pub before_text: String,"), 7 + ("text_input.rs", "pub after_text: String,"), 8 + ("dropdown.rs", "pub filter: String,"), 9 + ("tree_view.rs", "pub text: String,"), 10 + ("property_grid.rs", "pub value: String,"), 11 + ]; 12 + 13 + #[test] 14 + fn widget_public_strings_stay_on_the_user_input_allow_list() { 15 + let widgets = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/widgets"); 16 + let Ok(entries) = fs::read_dir(&widgets) else { 17 + panic!("widgets dir readable: {}", widgets.display()); 18 + }; 19 + let violations: Vec<String> = entries 20 + .filter_map(Result::ok) 21 + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "rs")) 22 + .flat_map(|entry| scan_file(&entry.path())) 23 + .collect(); 24 + assert!( 25 + violations.is_empty(), 26 + "found pub String/&str fields in widget public surface that are not on the user-input allow-list. \ 27 + Convert label-shaped fields to StringKey, or add the field to ALLOWED_STRING_FIELDS if it carries user-typed content:\n{}", 28 + violations.join("\n"), 29 + ); 30 + } 31 + 32 + fn scan_file(path: &Path) -> Vec<String> { 33 + let Some(name) = path.file_name().and_then(|f| f.to_str()) else { 34 + return Vec::new(); 35 + }; 36 + let Ok(contents) = fs::read_to_string(path) else { 37 + return Vec::new(); 38 + }; 39 + contents 40 + .lines() 41 + .enumerate() 42 + .filter_map(|(idx, raw)| { 43 + let line = raw.trim(); 44 + if !is_pub_string_field(line) { 45 + return None; 46 + } 47 + if ALLOWED_STRING_FIELDS 48 + .iter() 49 + .any(|(file, snippet)| *file == name && *snippet == line) 50 + { 51 + return None; 52 + } 53 + Some(format!("{name}:{}: {line}", idx + 1)) 54 + }) 55 + .collect() 56 + } 57 + 58 + fn is_pub_string_field(line: &str) -> bool { 59 + if !line.starts_with("pub ") { 60 + return false; 61 + } 62 + line.contains(": String,") 63 + || line.contains(": &str,") 64 + || line.contains(": &'static str,") 65 + || line.contains(": Cow<'static, str>,") 66 + }