1#[cfg(feature = "a11y-table")]
46use oxiui_accessibility::tree::{
47 build_table_a11y as upstream_build_table, column_header_node, table_cell_node, table_row_node,
48 A11yNode, WidgetRole,
49};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum A11yRole {
60 Group,
62 ColumnHeader,
64 TableRow,
66 TableCell,
68}
69
70#[derive(Debug)]
80pub struct LightNode {
81 pub id: u64,
83 pub role: A11yRole,
85 pub label: Option<String>,
87 pub description: Option<String>,
89 pub is_selected: bool,
91 pub children: Vec<LightNode>,
93}
94
95pub struct TableA11yParams<'a> {
99 pub row_count: usize,
101 pub col_headers: &'a [&'a str],
103 pub selected_rows: &'a [usize],
105 pub first_node_id: u64,
107}
108
109pub fn build_table_a11y_tree(params: &TableA11yParams<'_>) -> LightNode {
123 let col_count = params.col_headers.len();
124 let mut next_id = params.first_node_id;
125
126 let mut root = LightNode {
127 id: next_id,
128 role: A11yRole::Group,
129 label: None,
130 description: None,
131 is_selected: false,
132 children: Vec::with_capacity(col_count + params.row_count),
133 };
134 next_id += 1;
135
136 for (col_idx, &header) in params.col_headers.iter().enumerate() {
138 root.children.push(LightNode {
139 id: next_id,
140 role: A11yRole::ColumnHeader,
141 label: Some(header.to_owned()),
142 description: Some(format!("Column {} header", col_idx + 1)),
143 is_selected: false,
144 children: Vec::new(),
145 });
146 next_id += 1;
147 }
148
149 for row_idx in 0..params.row_count {
151 let is_row_selected = params.selected_rows.contains(&row_idx);
152 let mut row = LightNode {
153 id: next_id,
154 role: A11yRole::TableRow,
155 label: None,
156 description: Some(format!("Row {}", row_idx + 1)),
157 is_selected: is_row_selected,
158 children: Vec::with_capacity(col_count),
159 };
160 next_id += 1;
161
162 for col_idx in 0..col_count {
166 row.children.push(LightNode {
167 id: next_id,
168 role: A11yRole::TableCell,
169 label: None,
170 description: Some(format!("Row {} Column {}", row_idx + 1, col_idx + 1)),
171 is_selected: false,
172 children: Vec::new(),
173 });
174 next_id += 1;
175 }
176
177 root.children.push(row);
178 }
179
180 root
181}
182
183pub struct TableA11yWithTextParams<'a> {
185 pub base: TableA11yParams<'a>,
187 pub cell_text: &'a [Vec<String>],
189}
190
191pub fn build_table_a11y_with_text(params: &TableA11yWithTextParams<'_>) -> LightNode {
198 let col_count = params.base.col_headers.len();
199 let mut next_id = params.base.first_node_id;
200
201 let mut root = LightNode {
202 id: next_id,
203 role: A11yRole::Group,
204 label: None,
205 description: None,
206 is_selected: false,
207 children: Vec::with_capacity(col_count + params.base.row_count),
208 };
209 next_id += 1;
210
211 for (col_idx, &header) in params.base.col_headers.iter().enumerate() {
213 root.children.push(LightNode {
214 id: next_id,
215 role: A11yRole::ColumnHeader,
216 label: Some(header.to_owned()),
217 description: Some(format!("Column {} header", col_idx + 1)),
218 is_selected: false,
219 children: Vec::new(),
220 });
221 next_id += 1;
222 }
223
224 for row_idx in 0..params.base.row_count {
226 let is_row_selected = params.base.selected_rows.contains(&row_idx);
227 let row_cells: &[String] = params
228 .cell_text
229 .get(row_idx)
230 .map(|v| v.as_slice())
231 .unwrap_or(&[]);
232
233 let mut row = LightNode {
234 id: next_id,
235 role: A11yRole::TableRow,
236 label: None,
237 description: Some(format!("Row {}", row_idx + 1)),
238 is_selected: is_row_selected,
239 children: Vec::with_capacity(col_count),
240 };
241 next_id += 1;
242
243 for col_idx in 0..col_count {
244 let cell_label = row_cells.get(col_idx).cloned();
245 row.children.push(LightNode {
246 id: next_id,
247 role: A11yRole::TableCell,
248 label: cell_label,
249 description: Some(format!("Row {} Column {}", row_idx + 1, col_idx + 1)),
250 is_selected: false,
251 children: Vec::new(),
252 });
253 next_id += 1;
254 }
255
256 root.children.push(row);
257 }
258
259 root
260}
261
262#[cfg(feature = "a11y-table")]
272pub fn build_table_a11y_full(row_count: usize, col_count: usize, col_headers: &[&str]) -> A11yNode {
273 upstream_build_table(row_count, col_count, col_headers)
274}
275
276#[cfg(feature = "a11y-table")]
283pub fn build_table_a11y_full_with_text(
284 row_count: usize,
285 col_headers: &[&str],
286 col_count: usize,
287 cell_text: &[Vec<String>],
288 selected_rows: &[usize],
289) -> A11yNode {
290 use accesskit::NodeId; let mut next_id: u64 = 0;
293
294 let mut root = A11yNode::simple(NodeId(next_id), WidgetRole::Group, None);
295 next_id += 1;
296
297 for (col_idx, &header) in col_headers.iter().enumerate() {
299 let node = column_header_node(NodeId(next_id), col_idx, header);
300 next_id += 1;
301 root.children.push(node);
302 }
303
304 for row_idx in 0..row_count {
306 let mut row = table_row_node(NodeId(next_id), row_idx);
307 next_id += 1;
308
309 if selected_rows.contains(&row_idx) {
311 row.props.selected = Some(true);
312 }
313
314 let row_cells: &[String] = cell_text.get(row_idx).map(|v| v.as_slice()).unwrap_or(&[]);
315
316 for col_idx in 0..col_count {
317 let text = row_cells.get(col_idx).map(|s| s.as_str()).unwrap_or("");
318 let cell = table_cell_node(NodeId(next_id), row_idx, col_idx, text);
319 next_id += 1;
320 row.children.push(cell);
321 }
322
323 root.children.push(row);
324 }
325
326 root
327}
328
329#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn make_params<'a>(
336 rows: usize,
337 headers: &'a [&'a str],
338 selected: &'a [usize],
339 ) -> TableA11yParams<'a> {
340 TableA11yParams {
341 row_count: rows,
342 col_headers: headers,
343 selected_rows: selected,
344 first_node_id: 1,
345 }
346 }
347
348 #[test]
349 fn root_role_is_group() {
350 let params = make_params(2, &["A", "B"], &[]);
351 let root = build_table_a11y_tree(¶ms);
352 assert_eq!(root.role, A11yRole::Group);
353 }
354
355 #[test]
356 fn column_header_count_matches() {
357 let params = make_params(3, &["Col1", "Col2", "Col3"], &[]);
358 let root = build_table_a11y_tree(¶ms);
359 let headers: Vec<_> = root
360 .children
361 .iter()
362 .filter(|n| n.role == A11yRole::ColumnHeader)
363 .collect();
364 assert_eq!(headers.len(), 3);
365 }
366
367 #[test]
368 fn row_count_matches() {
369 let params = make_params(5, &["A", "B"], &[]);
370 let root = build_table_a11y_tree(¶ms);
371 let rows: Vec<_> = root
372 .children
373 .iter()
374 .filter(|n| n.role == A11yRole::TableRow)
375 .collect();
376 assert_eq!(rows.len(), 5);
377 }
378
379 #[test]
380 fn each_row_has_correct_cell_count() {
381 let params = make_params(2, &["X", "Y", "Z"], &[]);
382 let root = build_table_a11y_tree(¶ms);
383 for row in root
384 .children
385 .iter()
386 .filter(|n| n.role == A11yRole::TableRow)
387 {
388 assert_eq!(row.children.len(), 3, "each row must have 3 cells");
389 }
390 }
391
392 #[test]
393 fn selected_row_is_marked() {
394 let params = make_params(3, &["A"], &[1]);
395 let root = build_table_a11y_tree(¶ms);
396 let rows: Vec<_> = root
397 .children
398 .iter()
399 .filter(|n| n.role == A11yRole::TableRow)
400 .collect();
401 assert!(!rows[0].is_selected, "row 0 should not be selected");
402 assert!(rows[1].is_selected, "row 1 should be selected");
403 assert!(!rows[2].is_selected, "row 2 should not be selected");
404 }
405
406 #[test]
407 fn column_header_label_matches() {
408 let params = make_params(0, &["Name", "Age"], &[]);
409 let root = build_table_a11y_tree(¶ms);
410 let headers: Vec<_> = root
411 .children
412 .iter()
413 .filter(|n| n.role == A11yRole::ColumnHeader)
414 .collect();
415 assert_eq!(headers[0].label.as_deref(), Some("Name"));
416 assert_eq!(headers[1].label.as_deref(), Some("Age"));
417 }
418
419 #[test]
420 fn row_description_is_one_based() {
421 let params = make_params(2, &["A"], &[]);
422 let root = build_table_a11y_tree(¶ms);
423 let rows: Vec<_> = root
424 .children
425 .iter()
426 .filter(|n| n.role == A11yRole::TableRow)
427 .collect();
428 assert_eq!(rows[0].description.as_deref(), Some("Row 1"));
429 assert_eq!(rows[1].description.as_deref(), Some("Row 2"));
430 }
431
432 #[test]
433 fn cell_description_has_row_and_col() {
434 let params = make_params(1, &["A", "B"], &[]);
435 let root = build_table_a11y_tree(¶ms);
436 let row = root
437 .children
438 .iter()
439 .find(|n| n.role == A11yRole::TableRow)
440 .expect("must have a row");
441 assert_eq!(
442 row.children[0].description.as_deref(),
443 Some("Row 1 Column 1")
444 );
445 assert_eq!(
446 row.children[1].description.as_deref(),
447 Some("Row 1 Column 2")
448 );
449 }
450
451 #[test]
452 fn zero_rows_produces_only_headers() {
453 let params = make_params(0, &["A", "B"], &[]);
454 let root = build_table_a11y_tree(¶ms);
455 assert_eq!(root.children.len(), 2); for child in &root.children {
457 assert_eq!(child.role, A11yRole::ColumnHeader);
458 }
459 }
460
461 #[test]
462 fn zero_cols_produces_only_rows_no_cells() {
463 let params = make_params(3, &[], &[]);
464 let root = build_table_a11y_tree(¶ms);
465 let rows: Vec<_> = root
466 .children
467 .iter()
468 .filter(|n| n.role == A11yRole::TableRow)
469 .collect();
470 assert_eq!(rows.len(), 3);
471 for row in rows {
472 assert!(row.children.is_empty(), "rows with 0 cols have no cells");
473 }
474 }
475
476 #[test]
477 fn node_ids_are_unique_and_sequential() {
478 let params = make_params(2, &["A", "B"], &[]);
479 let root = build_table_a11y_tree(¶ms);
480
481 fn collect_ids(node: &LightNode, out: &mut Vec<u64>) {
483 out.push(node.id);
484 for child in &node.children {
485 collect_ids(child, out);
486 }
487 }
488 let mut ids = Vec::new();
489 collect_ids(&root, &mut ids);
490
491 let mut sorted = ids.clone();
493 sorted.sort_unstable();
494 sorted.dedup();
495 assert_eq!(sorted.len(), ids.len(), "all node IDs must be unique");
496 }
497
498 #[test]
499 fn with_text_fills_cell_labels() {
500 let headers: &[&str] = &["Name", "Age"];
501 let selected: &[usize] = &[];
502 let cell_text: Vec<Vec<String>> = vec![
503 vec!["Alice".to_string(), "30".to_string()],
504 vec!["Bob".to_string(), "25".to_string()],
505 ];
506 let params = TableA11yWithTextParams {
507 base: TableA11yParams {
508 row_count: 2,
509 col_headers: headers,
510 selected_rows: selected,
511 first_node_id: 1,
512 },
513 cell_text: &cell_text,
514 };
515 let root = build_table_a11y_with_text(¶ms);
516 let rows: Vec<_> = root
517 .children
518 .iter()
519 .filter(|n| n.role == A11yRole::TableRow)
520 .collect();
521 assert_eq!(rows[0].children[0].label.as_deref(), Some("Alice"));
522 assert_eq!(rows[0].children[1].label.as_deref(), Some("30"));
523 assert_eq!(rows[1].children[0].label.as_deref(), Some("Bob"));
524 assert_eq!(rows[1].children[1].label.as_deref(), Some("25"));
525 }
526
527 #[test]
528 fn column_header_description_contains_column_number() {
529 let params = make_params(0, &["Name", "City"], &[]);
530 let root = build_table_a11y_tree(¶ms);
531 let headers: Vec<_> = root
532 .children
533 .iter()
534 .filter(|n| n.role == A11yRole::ColumnHeader)
535 .collect();
536 assert!(
537 headers[0]
538 .description
539 .as_deref()
540 .unwrap_or("")
541 .contains("Column 1"),
542 "first header description must contain 'Column 1'"
543 );
544 assert!(
545 headers[1]
546 .description
547 .as_deref()
548 .unwrap_or("")
549 .contains("Column 2"),
550 "second header description must contain 'Column 2'"
551 );
552 }
553
554 #[cfg(feature = "a11y-table")]
555 #[test]
556 fn full_build_produces_correct_child_count() {
557 let root = build_table_a11y_full(2, 3, &["A", "B", "C"]);
558 assert_eq!(
560 root.children.len(),
561 5,
562 "expected 3 headers + 2 rows = 5 children"
563 );
564 }
565
566 #[cfg(feature = "a11y-table")]
567 #[test]
568 fn full_build_with_text_selected_row() {
569 let cell_text: Vec<Vec<String>> = vec![
570 vec!["A1".to_string(), "A2".to_string()],
571 vec!["B1".to_string(), "B2".to_string()],
572 ];
573 let root = build_table_a11y_full_with_text(2, &["H1", "H2"], 2, &cell_text, &[1]);
574 let rows: Vec<_> = root
576 .children
577 .iter()
578 .filter(|n| n.role == WidgetRole::TableRow)
579 .collect();
580 assert_eq!(rows.len(), 2);
581 assert_eq!(rows[1].props.selected, Some(true), "row 1 must be selected");
582 }
583}