plushie-core 0.4.0

Extension SDK for Plushie
Documentation
//! Table widget -- scrollable data grid with sortable columns.
//!
//! Renders a header row (optional) and data rows from `columns` and
//! `rows` JSON props. Columns define key, label, alignment, width,
//! and sortability. Clicking a sortable column header emits a `sort`
//! event with the column key. Separator styling and text sizes are
//! configurable.

use iced::advanced::widget::operation::accessible;
use iced::widget::{button, column, container, row, rule, scrollable, text};
use iced::{Element, Fill, Length, alignment};
use serde_json::Value;

use super::a11y::{A11yOverride, A11yOverrides};
use super::helpers::*;
use crate::extensions::RenderCtx;
use crate::message::Message;
use crate::protocol::TreeNode;

/// Wrap an element with an accessibility role override.
fn with_role<'a>(element: Element<'a, Message>, role: accessible::Role) -> Element<'a, Message> {
    A11yOverride::wrap(
        element,
        A11yOverrides {
            role: Some(role),
            ..A11yOverrides::default()
        },
    )
    .into()
}

/// Parsed column descriptor from the "columns" prop.
struct TableColumn {
    key: String,
    label: String,
    align: alignment::Horizontal,
    width: Length,
    sortable: bool,
}

fn parse_table_columns(props: Props<'_>) -> Vec<TableColumn> {
    props
        .and_then(|p| p.get("columns"))
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|col| {
                    let key = col.get("key")?.as_str()?.to_owned();
                    let label = col
                        .get("label")
                        .and_then(|v| v.as_str())
                        .unwrap_or(&key)
                        .to_owned();
                    let align = col
                        .get("align")
                        .and_then(|v| v.as_str())
                        .and_then(value_to_horizontal_alignment)
                        .unwrap_or(alignment::Horizontal::Left);
                    let width = col
                        .get("width")
                        .and_then(value_to_length)
                        .unwrap_or(Length::FillPortion(1));
                    let sortable = col
                        .get("sortable")
                        .and_then(|v| v.as_bool())
                        .unwrap_or(false);
                    Some(TableColumn {
                        key,
                        label,
                        align,
                        width,
                        sortable,
                    })
                })
                .collect()
        })
        .unwrap_or_default()
}

pub(crate) fn render_table<'a>(node: &'a TreeNode, _ctx: RenderCtx<'a>) -> Element<'a, Message> {
    let props = node.props.as_object();
    let width = prop_length(props, "width", Length::Fill);
    let show_header = prop_bool_default(props, "header", true);
    let padding_val = parse_padding_value(props);
    let table_id = node.id.clone();

    let header_text_size = prop_f32(props, "header_text_size");
    let row_text_size = prop_f32(props, "row_text_size");

    let cell_spacing = prop_f32(props, "cell_spacing");
    let row_spacing = prop_f32(props, "row_spacing");
    let separator_thickness = prop_f32(props, "separator_thickness").unwrap_or(1.0);
    let separator_color = prop_color(props, "separator_color");

    let sort_by = prop_str(props, "sort_by");
    let sort_order = prop_str(props, "sort_order");

    let columns = parse_table_columns(props);

    // "rows" is an array of objects.
    let rows: Vec<&Value> = props
        .and_then(|p| p.get("rows"))
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().collect())
        .unwrap_or_default();

    if columns.is_empty() {
        return text("(empty table)").into();
    }

    let mut table_rows: Vec<Element<'a, Message>> = Vec::new();

    // Header row (conditional)
    if show_header {
        let header_cells: Vec<Element<'a, Message>> = columns
            .iter()
            .map(|col| {
                // Build sort indicator if this column is currently sorted.
                let sort_indicator = if sort_by.as_deref() == Some(&col.key) {
                    match sort_order.as_deref() {
                        Some("asc") => " \u{25B2}",
                        Some("desc") => " \u{25BC}",
                        _ => "",
                    }
                } else {
                    ""
                };

                let label_text = format!("{}{}", col.label, sort_indicator);

                let cell_elem: Element<'a, Message> = if col.sortable {
                    let click_id = table_id.clone();
                    let click_key = col.key.clone();
                    let mut label = text(label_text);
                    if let Some(sz) = header_text_size {
                        label = label.size(sz);
                    }
                    container(
                        button(label)
                            .on_press(Message::Event {
                                id: click_id,
                                data: serde_json::json!({"column": click_key}),
                                family: "sort".into(),
                            })
                            .style(button::text),
                    )
                    .width(col.width)
                    .align_x(col.align)
                    .into()
                } else {
                    let mut label = text(label_text);
                    if let Some(sz) = header_text_size {
                        label = label.size(sz);
                    }
                    container(label).width(col.width).align_x(col.align).into()
                };
                with_role(cell_elem, accessible::Role::ColumnHeader)
            })
            .collect();
        let mut header = row(header_cells).width(Fill);
        if let Some(cs) = cell_spacing {
            header = header.spacing(cs);
        }
        table_rows.push(with_role(header.into(), accessible::Role::Row));

        // Separator
        let show_separator = prop_bool_default(props, "separator", true);
        if show_separator {
            let sep: Element<'a, Message> = if let Some(sep_col) = separator_color {
                rule::horizontal(separator_thickness)
                    .style(move |_theme: &iced::Theme| rule::Style {
                        color: sep_col,
                        radius: Default::default(),
                        fill_mode: rule::FillMode::Full,
                        snap: true,
                    })
                    .into()
            } else {
                rule::horizontal(separator_thickness).into()
            };
            table_rows.push(sep);
        }
    }

    // Data rows
    for data_row in &rows {
        let cells: Vec<Element<'a, Message>> = columns
            .iter()
            .map(|col| {
                let cell_text = data_row
                    .get(&col.key)
                    .map(|v| match v {
                        Value::String(s) => s.clone(),
                        other => other.to_string(),
                    })
                    .unwrap_or_default();
                let mut cell = text(cell_text);
                if let Some(sz) = row_text_size {
                    cell = cell.size(sz);
                }
                let cell_elem: Element<'a, Message> =
                    container(cell).width(col.width).align_x(col.align).into();
                with_role(cell_elem, accessible::Role::Cell)
            })
            .collect();
        let mut data_row_elem = row(cells).width(Fill);
        if let Some(cs) = cell_spacing {
            data_row_elem = data_row_elem.spacing(cs);
        }
        table_rows.push(with_role(data_row_elem.into(), accessible::Role::Row));
    }

    let mut table_col = column(table_rows).width(width);

    if let Some(rs) = row_spacing {
        table_col = table_col.spacing(rs);
    }

    if let Some(p) = padding_val {
        table_col = table_col.padding(p);
    }

    with_role(scrollable(table_col).into(), accessible::Role::Table)
}