use bone_types::IconId;
use uom::si::f64::{Angle, Length};

use crate::a11y::{AccessNode, Role};
use crate::frame::{FrameCtx, InteractDeclaration};
use crate::hit_test::Sense;
use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringKey;
use crate::theme::{Border, Color, Step12, StrokeWidth};
use crate::widget_id::WidgetId;

use super::checkbox::{Checkbox, CheckboxState, show_checkbox};
use super::dimensioned_input::{AngleUnit, DimensionedInput, angle_text_has_unit};
use super::dropdown::{Dropdown, DropdownItem, DropdownState, show_dropdown};
use super::keys::take_activation;
use super::paint::{HorizontalAlign, IconTint, LabelText, WidgetPaint};
use super::parsed_input::show_parsed_input;
use super::text_input::{AlwaysValid, Clipboard, TextInput, TextInputState, show_text_input};
use super::visuals::push_focus_ring;

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PropertyCell {
    pub row_id: WidgetId,
    pub label: StringKey,
    pub rect: LayoutRect,
    pub read_only: bool,
}

pub trait PropertyEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool;
}

pub struct PropertyRow<'a> {
    pub id: WidgetId,
    pub label: StringKey,
    pub editor: &'a mut dyn PropertyEditor,
    pub read_only: bool,
}

pub struct PropertyGrid<'a, 'rows> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub label: StringKey,
    pub rows: &'a mut [PropertyRow<'rows>],
    pub row_height: LayoutPx,
    pub label_width: LayoutPx,
    pub padding: LayoutPx,
}

impl<'a, 'rows> PropertyGrid<'a, 'rows> {
    #[must_use]
    pub fn new(
        id: WidgetId,
        rect: LayoutRect,
        label: StringKey,
        rows: &'a mut [PropertyRow<'rows>],
    ) -> Self {
        Self {
            id,
            rect,
            label,
            rows,
            row_height: LayoutPx::new(22.0),
            label_width: LayoutPx::new(120.0),
            padding: LayoutPx::new(8.0),
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct PropertyGridResponse {
    pub changed_rows: Vec<WidgetId>,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_property_grid(
    ctx: &mut FrameCtx<'_>,
    grid: PropertyGrid<'_, '_>,
    clipboard: &mut dyn Clipboard,
) -> PropertyGridResponse {
    let PropertyGrid {
        id,
        rect,
        label,
        rows,
        row_height,
        label_width,
        padding,
    } = grid;
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::Form).with_label(label));
    let mut paint = vec![WidgetPaint::Surface {
        rect,
        fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    let changed_rows = rows
        .iter_mut()
        .enumerate()
        .filter_map(|(idx, row)| {
            let row_rect = property_row_rect(rect, idx, row_height);
            paint.push(WidgetPaint::AlignedLabel {
                rect: label_rect_at(row_rect, label_width, padding),
                text: LabelText::Key(row.label),
                color: ctx.theme().colors.text_secondary(),
                role: ctx.theme().typography.label,
                align: HorizontalAlign::Start,
            });
            paint.push(WidgetPaint::Surface {
                rect: divider_rect(row_rect),
                fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
                border: None,
                radius: ctx.theme().radius.none,
                elevation: None,
            });
            let cell = PropertyCell {
                row_id: row.id,
                label: row.label,
                rect: editor_rect_at(row_rect, label_width, padding),
                read_only: row.read_only,
            };
            row.editor
                .render(ctx, cell, clipboard, &mut paint)
                .then_some(row.id)
        })
        .collect();
    PropertyGridResponse {
        changed_rows,
        paint,
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum PropertyPaneAction {
    Accept,
    Cancel,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PropertyPaneHeader {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub title: StringKey,
    pub accept_id: WidgetId,
    pub cancel_id: WidgetId,
}

#[derive(Clone, Debug, PartialEq)]
pub struct PropertyPaneHeaderResponse {
    pub action: Option<PropertyPaneAction>,
    pub paint: Vec<WidgetPaint>,
}

const HEADER_PAD: f32 = 8.0;
const HEADER_TITLE_HEIGHT: f32 = 22.0;
const HEADER_BUTTON: f32 = 22.0;
const HEADER_BUTTON_GAP: f32 = 4.0;

#[must_use]
pub fn show_property_pane_header(
    ctx: &mut FrameCtx<'_>,
    header: PropertyPaneHeader,
) -> PropertyPaneHeaderResponse {
    let PropertyPaneHeader {
        id,
        rect,
        title,
        accept_id,
        cancel_id,
    } = header;
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::Pane).with_label(title));
    let mut paint = vec![WidgetPaint::Surface {
        rect,
        fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BG),
        border: Some(Border {
            width: StrokeWidth::HAIRLINE,
            color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
        }),
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    paint.push(WidgetPaint::AlignedLabel {
        rect: header_title_rect(rect),
        text: LabelText::Key(title),
        color: ctx.theme().colors.text_primary(),
        role: ctx.theme().typography.title,
        align: HorizontalAlign::Start,
    });
    let accept_color = ctx.theme().colors.success.step(Step12::SOLID);
    let cancel_color = ctx.theme().colors.danger.step(Step12::SOLID);
    let accepted = header_action_button(
        ctx,
        accept_id,
        header_button_rect(rect, 0),
        IconId::Check,
        accept_color,
        title,
        &mut paint,
    );
    let cancelled = header_action_button(
        ctx,
        cancel_id,
        header_button_rect(rect, 1),
        IconId::Cross,
        cancel_color,
        title,
        &mut paint,
    );
    let action = match (accepted, cancelled) {
        (true, _) => Some(PropertyPaneAction::Accept),
        (false, true) => Some(PropertyPaneAction::Cancel),
        (false, false) => None,
    };
    PropertyPaneHeaderResponse { action, paint }
}

fn header_action_button(
    ctx: &mut FrameCtx<'_>,
    id: WidgetId,
    rect: LayoutRect,
    icon: IconId,
    glyph_color: Color,
    label: StringKey,
    paint: &mut Vec<WidgetPaint>,
) -> bool {
    let interaction = ctx.interact(
        InteractDeclaration::new(id, rect, Sense::INTERACTIVE)
            .focusable(true)
            .a11y(AccessNode::new(Role::Button).with_label(label)),
    );
    let live_focused = ctx.is_focused(id);
    if interaction.hover() || interaction.pressed() {
        let step = if interaction.pressed() {
            Step12::SELECTED_BG
        } else {
            Step12::HOVER_BG
        };
        paint.push(WidgetPaint::Surface {
            rect,
            fill: ctx.theme().colors.neutral.step(step),
            border: None,
            radius: ctx.theme().radius.sm,
            elevation: None,
        });
    }
    paint.push(WidgetPaint::Icon {
        rect,
        icon,
        tint: IconTint::Solid(glyph_color),
    });
    push_focus_ring(ctx, paint, rect, ctx.theme().radius.sm, live_focused);
    interaction.click() || (live_focused && take_activation(ctx.input))
}

fn header_title_rect(rect: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(rect.origin.x.value() + HEADER_PAD),
            rect.origin.y,
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(rect.size.width.value() - 2.0 * HEADER_PAD),
            LayoutPx::new(HEADER_TITLE_HEIGHT),
        ),
    )
}

fn header_button_rect(rect: LayoutRect, index: u8) -> LayoutRect {
    let x =
        rect.origin.x.value() + HEADER_PAD + f32::from(index) * (HEADER_BUTTON + HEADER_BUTTON_GAP);
    let y = rect.origin.y.value() + HEADER_TITLE_HEIGHT;
    LayoutRect::new(
        LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
        LayoutSize::new(LayoutPx::new(HEADER_BUTTON), LayoutPx::new(HEADER_BUTTON)),
    )
}

fn property_row_rect(grid: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect {
    #[allow(clippy::cast_precision_loss, reason = "row index fits f32 mantissa")]
    let i = idx as f32;
    LayoutRect::new(
        LayoutPos::new(
            grid.origin.x,
            LayoutPx::new(grid.origin.y.value() + i * row_height.value()),
        ),
        LayoutSize::new(grid.size.width, row_height),
    )
}

fn label_rect_at(row: LayoutRect, label_width: LayoutPx, padding: LayoutPx) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(row.origin.x.value() + padding.value()),
            row.origin.y,
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(label_width.value() - padding.value()),
            row.size.height,
        ),
    )
}

fn editor_rect_at(row: LayoutRect, label_width: LayoutPx, padding: LayoutPx) -> LayoutRect {
    let editor_x = row.origin.x.value() + label_width.value();
    let editor_w = (row.size.width.value() - label_width.value() - padding.value()).max(0.0);
    let row_pad = 4.0;
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(editor_x),
            LayoutPx::new(row.origin.y.value() + row_pad),
        ),
        LayoutSize::new(
            LayoutPx::new(editor_w),
            LayoutPx::saturating_nonneg(row.size.height.value() - 2.0 * row_pad),
        ),
    )
}

fn divider_rect(row: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            row.origin.x,
            LayoutPx::new(row.origin.y.value() + row.size.height.value() - 1.0),
        ),
        LayoutSize::new(row.size.width, LayoutPx::new(1.0)),
    )
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoolEditor {
    pub value: bool,
}

impl BoolEditor {
    #[must_use]
    pub const fn new(value: bool) -> Self {
        Self { value }
    }
}

impl PropertyEditor for BoolEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        _clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        let cb_state = if self.value {
            CheckboxState::Checked
        } else {
            CheckboxState::Unchecked
        };
        let response = show_checkbox(
            ctx,
            Checkbox::new(cell.row_id, cell.rect, cell.label, cb_state).disabled(cell.read_only),
        );
        paint.extend(response.paint);
        if response.toggled {
            self.value = response.state.is_active();
            true
        } else {
            false
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct TextEditor {
    pub value: String,
    pub buffer: TextInputState,
}

impl TextEditor {
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        let value = value.into();
        Self {
            buffer: TextInputState::from_text(value.clone()),
            value,
        }
    }
}

impl PropertyEditor for TextEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        let editing = ctx.is_focused(cell.row_id);
        if !editing && self.buffer.text != self.value {
            self.buffer = TextInputState::from_text(self.value.clone());
        }
        let response = show_text_input(
            ctx,
            TextInput {
                id: cell.row_id,
                rect: cell.rect,
                placeholder: cell.label,
                state: &mut self.buffer,
                disabled: cell.read_only,
                validator: AlwaysValid,
            },
            clipboard,
        );
        paint.extend(response.paint);
        if response.edits.is_empty() {
            false
        } else {
            self.value = self.buffer.text.clone();
            true
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct LengthEditor {
    pub value: Length,
    pub buffer: TextInputState,
}

impl LengthEditor {
    #[must_use]
    pub fn new(value: Length) -> Self {
        Self {
            buffer: TextInputState::from_text(format_length(value)),
            value,
        }
    }
}

impl PropertyEditor for LengthEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        let editing = ctx.is_focused(cell.row_id);
        let formatted = format_length(self.value);
        if !editing && self.buffer.text != formatted {
            self.buffer = TextInputState::from_text(formatted);
        }
        let response = show_parsed_input::<Length, _>(
            ctx,
            DimensionedInput::<Length>::new(cell.row_id, cell.rect, cell.label, &mut self.buffer)
                .disabled(cell.read_only),
            clipboard,
        );
        paint.extend(response.paint);
        match response.committed {
            Some(v) => {
                self.value = v;
                true
            }
            None => false,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct AngleEditor {
    pub value: Angle,
    pub unit: AngleUnit,
    pub buffer: TextInputState,
}

impl AngleEditor {
    #[must_use]
    pub fn new(value: Angle) -> Self {
        Self::new_in(value, AngleUnit::Degrees)
    }

    #[must_use]
    pub fn new_in(value: Angle, unit: AngleUnit) -> Self {
        Self {
            buffer: TextInputState::from_text(format_angle(value, unit)),
            value,
            unit,
        }
    }
}

impl PropertyEditor for AngleEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        let editing = ctx.is_focused(cell.row_id);
        let formatted = format_angle(self.value, self.unit);
        if !editing && self.buffer.text != formatted {
            self.buffer = TextInputState::from_text(formatted);
        }
        let response = show_parsed_input::<Angle, _>(
            ctx,
            DimensionedInput::<Angle>::new(cell.row_id, cell.rect, cell.label, &mut self.buffer)
                .disabled(cell.read_only),
            clipboard,
        );
        paint.extend(response.paint);
        match response.committed {
            Some(v) => {
                self.value = reinterpret_in_unit(&self.buffer.text, v, self.unit);
                true
            }
            None => false,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct PropertyOption {
    pub label: StringKey,
}

#[derive(Clone, Debug, PartialEq)]
pub struct SelectionEditor {
    pub options: Vec<PropertyOption>,
    pub current: Option<usize>,
    pub state: DropdownState,
}

impl SelectionEditor {
    #[must_use]
    pub fn new(options: Vec<PropertyOption>, current: Option<usize>) -> Self {
        Self {
            options,
            current,
            state: DropdownState::closed(),
        }
    }
}

impl PropertyEditor for SelectionEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        _clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        let items: Vec<DropdownItem<usize>> = self
            .options
            .iter()
            .enumerate()
            .map(|(i, opt)| DropdownItem {
                value: i,
                label: opt.label,
            })
            .collect();
        let response = show_dropdown(
            ctx,
            Dropdown::new(
                cell.row_id,
                cell.rect,
                LayoutPx::new(24.0),
                items,
                self.current,
                cell.label,
                &mut self.state,
            )
            .disabled(cell.read_only),
        );
        paint.extend(response.paint);
        if response.changed {
            self.current = response.selected;
            true
        } else {
            false
        }
    }
}

fn format_length(value: Length) -> String {
    use uom::si::length::millimeter;
    format!("{} mm", value.get::<millimeter>())
}

fn format_angle(value: Angle, unit: AngleUnit) -> String {
    format!("{} {}", unit.measure(value), unit.suffix())
}

fn reinterpret_in_unit(text: &str, parsed: Angle, unit: AngleUnit) -> Angle {
    if angle_text_has_unit(text) {
        return parsed;
    }
    match text.trim().parse::<f64>() {
        Ok(value) if value.is_finite() => unit.make(value),
        _ => parsed,
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::{
        BoolEditor, PropertyGrid, PropertyRow, SelectionEditor, TextEditor, show_property_grid,
    };
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState, resolve};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
    };
    use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::{StringKey, StringTable};
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};
    use crate::widgets::text_input::MemoryClipboard;

    fn grid_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("grid"))
    }

    fn rect() -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(280.0), LayoutPx::new(200.0)),
        )
    }

    fn press(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
        s
    }

    fn release(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
        s
    }

    fn idle(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s
    }

    #[test]
    fn click_bool_row_marks_row_changed_and_flips_value() {
        let row_id = grid_id().child(WidgetKey::new("show_grid"));
        let mut bool_editor = BoolEditor::new(false);
        let mut clipboard = MemoryClipboard::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(160.0), LayoutPx::new(14.0));
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut last: Option<super::PropertyGridResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let mut hits = HitFrame::new();
                let response = {
                    let mut shaper = bone_text::Shaper::new();
                    let mut a11y = crate::a11y::AccessTreeBuilder::new();
                    let mut ctx = FrameCtx::new(
                        theme.clone(),
                        &mut snap,
                        &mut focus,
                        &table,
                        StringTable::empty(),
                        &mut hits,
                        &prev,
                        &mut a11y,
                        &mut shaper,
                    );
                    let mut rows = [PropertyRow {
                        id: row_id,
                        label: StringKey::new("prop.show_grid"),
                        editor: &mut bool_editor,
                        read_only: false,
                    }];
                    show_property_grid(
                        &mut ctx,
                        PropertyGrid::new(
                            grid_id(),
                            rect(),
                            StringKey::new("test.grid"),
                            &mut rows,
                        ),
                        &mut clipboard,
                    )
                };
                last = Some(response);
                prev = resolve(&prev, &hits, &snap, focus.focused());
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.changed_rows, vec![row_id]);
        assert!(bool_editor.value);
    }

    #[test]
    fn click_accept_button_emits_accept_action() {
        let header_id = WidgetId::ROOT.child(WidgetKey::new("pane"));
        let accept_id = WidgetId::ROOT.child(WidgetKey::new("accept"));
        let cancel_id = WidgetId::ROOT.child(WidgetKey::new("cancel"));
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(19.0), LayoutPx::new(33.0));
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut last: Option<super::PropertyPaneHeaderResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let mut hits = HitFrame::new();
                let response = {
                    let mut shaper = bone_text::Shaper::new();
                    let mut a11y = crate::a11y::AccessTreeBuilder::new();
                    let mut ctx = FrameCtx::new(
                        theme.clone(),
                        &mut snap,
                        &mut focus,
                        &table,
                        StringTable::empty(),
                        &mut hits,
                        &prev,
                        &mut a11y,
                        &mut shaper,
                    );
                    super::show_property_pane_header(
                        &mut ctx,
                        super::PropertyPaneHeader {
                            id: header_id,
                            rect: LayoutRect::new(
                                LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
                                LayoutSize::new(LayoutPx::new(240.0), LayoutPx::new(48.0)),
                            ),
                            title: StringKey::new("test.pane"),
                            accept_id,
                            cancel_id,
                        },
                    )
                };
                last = Some(response);
                prev = resolve(&prev, &hits, &snap, focus.focused());
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.action, Some(super::PropertyPaneAction::Accept));
    }

    #[test]
    fn unfocused_text_editor_adopts_external_value_change() {
        let row_id = grid_id().child(WidgetKey::new("name"));
        let mut text_editor = TextEditor::new("alpha");
        let mut clipboard = MemoryClipboard::default();
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme.clone(),
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let mut rows = [PropertyRow {
                id: row_id,
                label: StringKey::new("prop.name"),
                editor: &mut text_editor,
                read_only: false,
            }];
            let _ = show_property_grid(
                &mut ctx,
                PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
                &mut clipboard,
            );
        }
        text_editor.value = "beta".into();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let mut hits = HitFrame::new();
        {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let mut rows = [PropertyRow {
                id: row_id,
                label: StringKey::new("prop.name"),
                editor: &mut text_editor,
                read_only: false,
            }];
            let _ = show_property_grid(
                &mut ctx,
                PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
                &mut clipboard,
            );
        }
        assert_eq!(text_editor.buffer.text, "beta");
    }

    #[test]
    fn tab_advances_focus_through_rows_in_order() {
        use crate::focus::FocusRequest;

        let mut bool_a = BoolEditor::new(false);
        let mut bool_b = BoolEditor::new(false);
        let mut clipboard = MemoryClipboard::default();
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let alpha_id = grid_id().child(WidgetKey::new("row_a"));
        let beta_id = grid_id().child(WidgetKey::new("row_b"));
        let mut render = |focus: &mut FocusManager,
                          bool_a: &mut BoolEditor,
                          bool_b: &mut BoolEditor,
                          mut snap: InputSnapshot| {
            let mut hits = HitFrame::new();
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme.clone(),
                &mut snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let mut rows = [
                PropertyRow {
                    id: alpha_id,
                    label: StringKey::new("prop.a"),
                    editor: bool_a,
                    read_only: false,
                },
                PropertyRow {
                    id: beta_id,
                    label: StringKey::new("prop.b"),
                    editor: bool_b,
                    read_only: false,
                },
            ];
            let _ = show_property_grid(
                &mut ctx,
                PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
                &mut clipboard,
            );
        };

        focus.request(FocusRequest::Advance);
        render(
            &mut focus,
            &mut bool_a,
            &mut bool_b,
            InputSnapshot::idle(FrameInstant::ZERO),
        );
        assert_eq!(focus.focused(), Some(alpha_id));

        focus.request(FocusRequest::Advance);
        render(
            &mut focus,
            &mut bool_a,
            &mut bool_b,
            InputSnapshot::idle(FrameInstant::ZERO),
        );
        assert_eq!(focus.focused(), Some(beta_id));

        focus.request(FocusRequest::Retreat);
        render(
            &mut focus,
            &mut bool_a,
            &mut bool_b,
            InputSnapshot::idle(FrameInstant::ZERO),
        );
        assert_eq!(focus.focused(), Some(alpha_id));
    }

    #[test]
    fn rows_paint_label_per_row() {
        let mut bool_editor = BoolEditor::new(false);
        let mut select_editor = SelectionEditor::new(
            vec![super::PropertyOption {
                label: StringKey::new("opt"),
            }],
            None,
        );
        let mut clipboard = MemoryClipboard::default();
        let mut focus = FocusManager::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let prev = HitState::new();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let response = {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let mut rows = [
                PropertyRow {
                    id: grid_id().child(WidgetKey::new("a")),
                    label: StringKey::new("prop.a"),
                    editor: &mut bool_editor,
                    read_only: false,
                },
                PropertyRow {
                    id: grid_id().child(WidgetKey::new("b")),
                    label: StringKey::new("prop.b"),
                    editor: &mut select_editor,
                    read_only: false,
                },
            ];
            show_property_grid(
                &mut ctx,
                PropertyGrid::new(grid_id(), rect(), StringKey::new("test.grid"), &mut rows),
                &mut clipboard,
            )
        };
        let label_count = response
            .paint
            .iter()
            .filter(|p| matches!(p, super::WidgetPaint::Label { .. }))
            .count();
        assert!(label_count >= 2);
    }
}
