teksilo-widgets 0.9.1

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Accessibility wrappers for `TableView` / `TreeTableView`.
//!
//! AccessKit's table semantics work by labelling individual nodes with
//! `Role::Table` / `Role::Row` / `Role::Cell` and stamping each cell with
//! its row/column index. The cell delegate the user supplies typically
//! produces a generic widget (Text, Button, …) that wouldn't carry table
//! semantics by itself, so the body wraps each cell in a thin
//! `CellA11y` node. `TableView`'s row containers themselves
//! (`BodyRow`) carry `Role::Row` and
//! row-index metadata directly. `TreeTableView` adds an extra
//! `TreeRowA11y` wrapper around the tree column to declare `set_level`
//! and `set_expanded` for the row.
//!
//! These wrappers do not paint or affect layout: they pass the proposed
//! size straight through to their single child and forward all bounds.

use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;

use teksilo_data::SortDirection;

/// Wrapper that announces a `Role::Cell` (or `Role::RowHeader`) with
/// row/column indices and selection state.
#[derive(Debug)]
pub(crate) struct CellA11y {
    child: WidgetId,
    row_index_1based: usize,
    col_index_1based: usize,
    selected: bool,
    /// When true, emit `Role::RowHeader` instead of `Role::Cell` — used
    /// when the table promotes a column to row-header status.
    is_row_header: bool,
    /// Optional name override (when the cell content isn't textual).
    name: Option<String>,
}

impl CellA11y {
    pub(crate) fn new(
        child: WidgetId,
        row_index_1based: usize,
        col_index_1based: usize,
        selected: bool,
    ) -> Self {
        Self {
            child,
            row_index_1based,
            col_index_1based,
            selected,
            is_row_header: false,
            name: None,
        }
    }

    /// Promote to `Role::RowHeader` (`row_header_column` support).
    #[allow(dead_code)]
    pub(crate) fn with_role_row_header(mut self, is_row_header: bool) -> Self {
        self.is_row_header = is_row_header;
        self
    }

    /// Override the cell's accessible name (`cell_label_fn`).
    #[allow(dead_code)]
    pub(crate) fn with_name(mut self, name: Option<String>) -> Self {
        self.name = name;
        self
    }
}

impl Widget for CellA11y {
    fn layout_response(
        &self,
        proposal: SizeProposal,
        ctx: &LayoutContext,
    ) -> teksilo_core::widget::LayoutResponse {
        ctx.child_size(self.child, proposal)
            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
            .into()
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
        builder.set_role(if self.is_row_header {
            teksilo_core::accesskit::Role::RowHeader
        } else {
            teksilo_core::accesskit::Role::Cell
        });
        if let Some(ref name) = self.name {
            builder.set_name(name.clone());
        }
        builder.set_selected(self.selected);
        builder.set_row_index(self.row_index_1based);
        builder.set_column_index(self.col_index_1based);
    }

    fn children(&self) -> Vec<WidgetId> {
        vec![self.child]
    }
}

/// `TreeTableView`-flavoured row wrapper. Announces `Role::Row` and, in
/// addition to the row index, declares `set_level` (1-based depth),
/// `set_expanded` when the row has children, and `set_position_in_set`
/// among the row's siblings, mirroring what `TreeView`'s `TreeItemWrapper`
/// (`list_item_a11y.rs`) announces, so both widgets say which sibling a
/// row is.
///
/// No `size_of_set` goes with it, here or on the container: AccessKit
/// resolves a set size by walking up from the item, and a flattened tree has
/// no per-branch node to hold one, so neither widget can say "of 5". The
/// comment in `accessibility` below spells out what expressing it would cost.
#[derive(Debug)]
pub(crate) struct TreeRowA11y {
    child: WidgetId,
    row_index_1based: usize,
    /// 1-based hierarchy level (root rows are 1).
    level_1based: usize,
    /// `Some(true|false)` for non-leaf rows; `None` for leaves.
    expanded: Option<bool>,
    selected: bool,
    /// 1-based position among this row's siblings (`TreeSource::sibling_pos`).
    position_in_set: usize,
}

impl TreeRowA11y {
    pub(crate) fn new(
        child: WidgetId,
        row_index_1based: usize,
        level_1based: usize,
        expanded: Option<bool>,
        selected: bool,
        position_in_set: usize,
    ) -> Self {
        Self {
            child,
            row_index_1based,
            level_1based,
            expanded,
            selected,
            position_in_set,
        }
    }
}

impl Widget for TreeRowA11y {
    fn layout_response(
        &self,
        proposal: SizeProposal,
        ctx: &LayoutContext,
    ) -> teksilo_core::widget::LayoutResponse {
        ctx.child_size(self.child, proposal)
            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
            .into()
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
        builder.set_role(teksilo_core::accesskit::Role::Row);
        builder.set_selected(self.selected);
        if let Some(exp) = self.expanded {
            builder.set_expanded(exp);
        }
        builder.set_row_index(self.row_index_1based);
        // The floor keeps a caller that computed a depth of 0 on the root
        // level rather than one above it. `set_level` takes the ARIA 1-based
        // number and converts to AccessKit's zero-based property itself.
        builder.set_level(self.level_1based.max(1));
        builder.set_position_in_set(self.position_in_set);
        // No `size_of_set` here, and none on the container either.
        //
        // AccessKit resolves an item's set size by walking *up* from it
        // (`size_of_set_from_container`), so the only value a flattened tree
        // could publish is one shared by every row at every depth — which is
        // not what "the 2nd of 5 siblings" means. Expressing it correctly needs
        // a real `Role::Group` node per expanded branch, between the container
        // and its rows, and that changes the AT tree shape for every tree
        // widget. Writing the number on this node instead is dead: no adapter
        // on any platform reads it, and leaving it there makes a missing
        // feature look like a working one.
        //
        // The level and the expanded state still carry the hierarchy, and
        // `position_in_set` still says which sibling this is.
    }

    fn children(&self) -> Vec<WidgetId> {
        vec![self.child]
    }
}

/// Header column wrapper — `Role::ColumnHeader` with sort direction
/// when this is the active sort column.
#[derive(Debug)]
#[allow(dead_code)]
pub(crate) struct ColumnHeaderA11y {
    child: WidgetId,
    col_index_1based: usize,
    name: String,
    sort: Option<SortDirection>,
}

#[allow(dead_code)] // wired up by HeaderRow
impl ColumnHeaderA11y {
    pub(crate) fn new(
        child: WidgetId,
        col_index_1based: usize,
        name: impl Into<String>,
        sort: Option<SortDirection>,
    ) -> Self {
        Self {
            child,
            col_index_1based,
            name: name.into(),
            sort,
        }
    }
}

impl Widget for ColumnHeaderA11y {
    fn layout_response(
        &self,
        proposal: SizeProposal,
        ctx: &LayoutContext,
    ) -> teksilo_core::widget::LayoutResponse {
        ctx.child_size(self.child, proposal)
            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
            .into()
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
        builder.set_role(teksilo_core::accesskit::Role::ColumnHeader);
        builder.set_name(self.name.clone());
        builder.set_column_index(self.col_index_1based);
        if let Some(dir) = self.sort {
            let ak_dir = match dir {
                SortDirection::Ascending => teksilo_core::accesskit::SortDirection::Ascending,
                SortDirection::Descending => teksilo_core::accesskit::SortDirection::Descending,
            };
            builder.inner_mut().set_sort_direction(ak_dir);
        }
    }

    fn children(&self) -> Vec<WidgetId> {
        vec![self.child]
    }
}