use std::collections::BTreeSet;

use crate::a11y::{AccessNode, Role};
use crate::frame::{FrameCtx, InteractDeclaration};
use crate::hit_test::Sense;
use crate::input::{KeyCode, ModifierMask, NamedKey, PointerButton};
use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringKey;
use crate::theme::{Color, Step12};
use crate::widget_id::{WidgetId, WidgetKey};

use bone_types::IconId;

use super::keys::{TakeKey, take_key};
use super::paint::{GlyphMark, HorizontalAlign, IconSlot, IconTint, LabelText, WidgetPaint};
use super::scrollbar::{row_window, wheel_scroll, window_scrollbar};
use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input};
use super::visuals::push_focus_ring;

#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum TreeBadge {
    RebuildNeeded,
    Warning,
    Error,
}

#[derive(Clone, Debug, PartialEq)]
pub struct TreeNode {
    pub id: WidgetId,
    pub label: LabelText,
    pub children: Vec<TreeNode>,
    pub disabled: bool,
    pub icon: Option<IconId>,
    pub badge: Option<TreeBadge>,
}

impl TreeNode {
    #[must_use]
    pub fn leaf(id: WidgetId, label: StringKey) -> Self {
        Self::with_label(id, LabelText::Key(label), Vec::new())
    }

    #[must_use]
    pub fn leaf_owned(id: WidgetId, label: String) -> Self {
        Self::with_label(id, LabelText::Owned(label), Vec::new())
    }

    #[must_use]
    pub fn parent(id: WidgetId, label: StringKey, children: Vec<TreeNode>) -> Self {
        Self::with_label(id, LabelText::Key(label), children)
    }

    #[must_use]
    pub fn parent_owned(id: WidgetId, label: String, children: Vec<TreeNode>) -> Self {
        Self::with_label(id, LabelText::Owned(label), children)
    }

    #[must_use]
    fn with_label(id: WidgetId, label: LabelText, children: Vec<TreeNode>) -> Self {
        Self {
            id,
            label,
            children,
            disabled: false,
            icon: None,
            badge: None,
        }
    }

    #[must_use]
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    #[must_use]
    pub fn with_icon(mut self, icon: IconId) -> Self {
        self.icon = Some(icon);
        self
    }

    #[must_use]
    pub fn with_badge(mut self, badge: Option<TreeBadge>) -> Self {
        self.badge = badge;
        self
    }

    #[must_use]
    pub fn has_children(&self) -> bool {
        !self.children.is_empty()
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TreeSelectionMode {
    Single,
    Multi,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum DropPlacement {
    Before,
    After,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct DropTarget {
    pub anchor: WidgetId,
    pub placement: DropPlacement,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ContextMenuRequest {
    pub target: WidgetId,
    pub at: LayoutPos,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RollbackTarget {
    AtEnd,
    Above(WidgetId),
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct RollbackBar<'a> {
    pub stops: &'a [WidgetId],
    pub marker: RollbackTarget,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct TreeViewState {
    pub expanded: BTreeSet<WidgetId>,
    pub selection: BTreeSet<WidgetId>,
    pub focused: Option<WidgetId>,
    pub renaming: Option<WidgetId>,
    pub rename_buffer: TextInputState,
    pub clipboard: MemoryClipboard,
    pub drag_source: Option<WidgetId>,
    pub drop_target: Option<DropTarget>,
    pub pending_rename: Option<PendingRename>,
    pub scroll_offset: LayoutPx,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct PendingRename {
    pub id: WidgetId,
    pub at: crate::input::FrameInstant,
}

#[derive(Debug, PartialEq)]
pub struct TreeView<'a, 'state> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub label: StringKey,
    pub roots: &'a [TreeNode],
    pub state: &'state mut TreeViewState,
    pub mode: TreeSelectionMode,
    pub row_height: LayoutPx,
    pub indent_step: LayoutPx,
    pub renamable: &'a [WidgetId],
    pub illegal_drop: bool,
    pub rollback: Option<RollbackBar<'a>>,
}

impl<'a, 'state> TreeView<'a, 'state> {
    #[must_use]
    pub const fn new(
        id: WidgetId,
        rect: LayoutRect,
        label: StringKey,
        roots: &'a [TreeNode],
        state: &'state mut TreeViewState,
    ) -> Self {
        Self {
            id,
            rect,
            label,
            roots,
            state,
            mode: TreeSelectionMode::Single,
            row_height: LayoutPx::new(20.0),
            indent_step: LayoutPx::new(16.0),
            renamable: &[],
            illegal_drop: false,
            rollback: None,
        }
    }

    #[must_use]
    pub const fn mode(self, mode: TreeSelectionMode) -> Self {
        Self { mode, ..self }
    }

    #[must_use]
    pub const fn renamable(self, renamable: &'a [WidgetId]) -> Self {
        Self { renamable, ..self }
    }

    #[must_use]
    pub const fn illegal_drop(self, illegal_drop: bool) -> Self {
        Self {
            illegal_drop,
            ..self
        }
    }

    #[must_use]
    pub const fn rollback(self, rollback: Option<RollbackBar<'a>>) -> Self {
        Self { rollback, ..self }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenameCommit {
    pub id: WidgetId,
    pub text: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct TreeViewResponse {
    pub activated: Option<WidgetId>,
    pub double_activated: Option<WidgetId>,
    pub rename_committed: Option<RenameCommit>,
    pub rename_cancelled: Option<WidgetId>,
    pub drop_committed: Option<(WidgetId, DropTarget)>,
    pub context_menu: Option<ContextMenuRequest>,
    pub rollback_moved: Option<RollbackTarget>,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_tree_view(ctx: &mut FrameCtx<'_>, view: TreeView<'_, '_>) -> TreeViewResponse {
    let TreeView {
        id,
        rect,
        label,
        roots,
        state,
        mode,
        row_height,
        indent_step,
        renamable,
        illegal_drop,
        rollback,
    } = view;
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::Tree).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 visible: Vec<VisibleRow> = flatten(roots, &state.expanded, 0);
    let requested = wheel_scroll(ctx, rect, state.scroll_offset);
    let window = row_window(rect, visible.len(), row_height, requested);
    state.scroll_offset = window.offset;
    let content_rect = window.content_rect;
    let entry_id = state
        .focused
        .filter(|f| visible.iter().any(|r| r.id == *f))
        .or_else(|| visible.first().map(|r| r.id));
    if let Some(id) = entry_id {
        ctx.focus.register_tab_stop(id);
    }
    let row_rect_of = |idx: usize| row_rect_at(content_rect, idx - window.first_row, row_height);
    let (row_paint, interactions) = paint_tree_rows(
        ctx,
        &visible,
        state,
        renamable,
        RowFrame {
            content_rect,
            row_height,
            indent_step,
            first_row: window.first_row,
            last_row: window.last_row,
            mode,
            illegal_drop,
        },
    );
    let RowInteractions {
        mut activated,
        double_activated,
        drop_committed,
        context_menu,
    } = interactions;
    paint.extend(row_paint);
    let rollback_moved = rollback.and_then(|bar| {
        let (bar_paint, moved) = render_rollback_bar(
            ctx,
            id,
            bar,
            &visible,
            content_rect,
            window.first_row,
            row_height,
        );
        paint.extend(bar_paint);
        moved
    });
    let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset);
    state.scroll_offset = next_offset;
    paint.extend(bar_paint);
    let pending_row_rect = state.pending_rename.and_then(|pending| {
        visible
            .iter()
            .position(|row| row.id == pending.id)
            .filter(|idx| (window.first_row..window.last_row).contains(idx))
            .map(row_rect_of)
    });
    commit_pending_rename(ctx, &visible, state, renamable, pending_row_rect);
    let (rename_committed, rename_cancelled) = resolve_rename(ctx, state);
    if state.renaming.is_none() {
        handle_keyboard(ctx, &visible, state, renamable, &mut activated);
    }
    TreeViewResponse {
        activated,
        double_activated,
        rename_committed,
        rename_cancelled,
        drop_committed,
        context_menu,
        rollback_moved,
        paint,
    }
}

#[derive(Copy, Clone)]
struct RowFrame {
    content_rect: LayoutRect,
    row_height: LayoutPx,
    indent_step: LayoutPx,
    first_row: usize,
    last_row: usize,
    mode: TreeSelectionMode,
    illegal_drop: bool,
}

struct RowInteractions {
    activated: Option<WidgetId>,
    double_activated: Option<WidgetId>,
    drop_committed: Option<(WidgetId, DropTarget)>,
    context_menu: Option<ContextMenuRequest>,
}

fn paint_tree_rows(
    ctx: &mut FrameCtx<'_>,
    visible: &[VisibleRow],
    state: &mut TreeViewState,
    renamable: &[WidgetId],
    frame: RowFrame,
) -> (Vec<WidgetPaint>, RowInteractions) {
    let mut out = RowInteractions {
        activated: None,
        double_activated: None,
        drop_committed: None,
        context_menu: None,
    };
    let paint = visible
        .iter()
        .enumerate()
        .skip(frame.first_row)
        .take(frame.last_row - frame.first_row)
        .flat_map(|(idx, row)| {
            let row_rect = row_rect_at(frame.content_rect, idx - frame.first_row, frame.row_height);
            draw_row(
                ctx,
                RowDrawArgs {
                    row,
                    row_rect,
                    indent_step: frame.indent_step,
                    state,
                    mode: frame.mode,
                    renamable,
                    illegal_drop: frame.illegal_drop,
                },
                &mut RowOutcomes {
                    activated: &mut out.activated,
                    double_activated: &mut out.double_activated,
                    drop_committed: &mut out.drop_committed,
                    context_menu: &mut out.context_menu,
                },
            )
        })
        .collect();
    (paint, out)
}

fn rollback_boundaries(
    bar: RollbackBar<'_>,
    visible: &[VisibleRow],
    content_rect: LayoutRect,
    first_row: usize,
    row_height: LayoutPx,
) -> Vec<(f32, RollbackTarget)> {
    let row_top = |index: usize| {
        row_rect_at(content_rect, index.saturating_sub(first_row), row_height)
            .origin
            .y
            .value()
    };
    let stops: Vec<(usize, WidgetId)> = bar
        .stops
        .iter()
        .filter_map(|stop| {
            visible
                .iter()
                .position(|row| row.id == *stop)
                .map(|index| (index, *stop))
        })
        .collect();
    let above = stops
        .iter()
        .map(|(index, id)| (row_top(*index), RollbackTarget::Above(*id)));
    let at_end = stops
        .last()
        .map(|(index, _)| (row_top(*index) + row_height.value(), RollbackTarget::AtEnd));
    above.chain(at_end).collect()
}

fn nearest_boundary(
    boundaries: &[(f32, RollbackTarget)],
    pointer_y: f32,
) -> Option<RollbackTarget> {
    boundaries
        .iter()
        .min_by(|a, b| (a.0 - pointer_y).abs().total_cmp(&(b.0 - pointer_y).abs()))
        .map(|(_, target)| *target)
}

fn render_rollback_bar(
    ctx: &mut FrameCtx<'_>,
    tree_id: WidgetId,
    bar: RollbackBar<'_>,
    visible: &[VisibleRow],
    content_rect: LayoutRect,
    first_row: usize,
    row_height: LayoutPx,
) -> (Vec<WidgetPaint>, Option<RollbackTarget>) {
    let boundaries = rollback_boundaries(bar, visible, content_rect, first_row, row_height);
    let Some(&(current_y, _)) = boundaries.iter().find(|(_, target)| *target == bar.marker) else {
        return (Vec::new(), None);
    };
    let bar_id = tree_id.child(WidgetKey::new("rollback_bar"));
    let hit_rect = LayoutRect::new(
        LayoutPos::new(content_rect.origin.x, LayoutPx::new(current_y - 3.0)),
        LayoutSize::new(content_rect.size.width, LayoutPx::new(6.0)),
    );
    let interaction =
        ctx.interact(InteractDeclaration::new(bar_id, hit_rect, Sense::DRAGGABLE).focusable(false));
    let dragging = interaction.drag_button == Some(PointerButton::Primary);
    let pointer_target = ctx
        .input
        .pointer
        .and_then(|sample| nearest_boundary(&boundaries, sample.position.y.value()));
    let moved = interaction
        .drag_release()
        .then_some(pointer_target)
        .flatten()
        .filter(|target| *target != bar.marker);
    let line_rect = |y: f32| {
        LayoutRect::new(
            LayoutPos::new(content_rect.origin.x, LayoutPx::new(y - 1.0)),
            LayoutSize::new(content_rect.size.width, LayoutPx::new(2.0)),
        )
    };
    let mut paint = vec![WidgetPaint::Surface {
        rect: line_rect(current_y),
        fill: ctx.theme().colors.accent.step(Step12::SOLID),
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    let preview_y = dragging
        .then_some(pointer_target)
        .flatten()
        .and_then(|target| {
            boundaries
                .iter()
                .find(|(_, b)| *b == target)
                .map(|(y, _)| *y)
        })
        .filter(|y| (*y - current_y).abs() > f32::EPSILON);
    if let Some(y) = preview_y {
        paint.push(WidgetPaint::Surface {
            rect: line_rect(y),
            fill: ctx.theme().colors.accent.step(Step12::HOVER_BORDER),
            border: None,
            radius: ctx.theme().radius.none,
            elevation: None,
        });
    }
    (paint, moved)
}

#[derive(Clone, Debug, PartialEq)]
struct VisibleRow {
    id: WidgetId,
    label: LabelText,
    depth: usize,
    has_children: bool,
    disabled: bool,
    icon: Option<IconId>,
    badge: Option<TreeBadge>,
}

fn flatten(roots: &[TreeNode], expanded: &BTreeSet<WidgetId>, depth: usize) -> Vec<VisibleRow> {
    roots
        .iter()
        .flat_map(|node| {
            let row = VisibleRow {
                id: node.id,
                label: node.label.clone(),
                depth,
                has_children: node.has_children(),
                disabled: node.disabled,
                icon: node.icon,
                badge: node.badge,
            };
            let children = if expanded.contains(&node.id) {
                flatten(&node.children, expanded, depth + 1)
            } else {
                Vec::new()
            };
            std::iter::once(row).chain(children)
        })
        .collect()
}

fn resolve_rename(
    ctx: &mut FrameCtx<'_>,
    state: &mut TreeViewState,
) -> (Option<RenameCommit>, Option<WidgetId>) {
    let committed = if let Some(id) = state.renaming
        && take_key(ctx.input, &[TakeKey::named(NamedKey::Enter)]).is_some()
    {
        let text = state.rename_buffer.text.clone();
        state.renaming = None;
        state.rename_buffer = TextInputState::default();
        Some(RenameCommit { id, text })
    } else {
        None
    };
    let cancelled = if let Some(id) = state.renaming
        && take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some()
    {
        state.renaming = None;
        state.rename_buffer = TextInputState::default();
        Some(id)
    } else {
        None
    };
    (committed, cancelled)
}

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

struct RowDrawArgs<'a> {
    row: &'a VisibleRow,
    row_rect: LayoutRect,
    indent_step: LayoutPx,
    state: &'a mut TreeViewState,
    mode: TreeSelectionMode,
    renamable: &'a [WidgetId],
    illegal_drop: bool,
}

fn draw_row(
    ctx: &mut FrameCtx<'_>,
    args: RowDrawArgs<'_>,
    outcomes: &mut RowOutcomes<'_>,
) -> Vec<WidgetPaint> {
    let RowDrawArgs {
        row,
        row_rect,
        indent_step,
        state,
        mode,
        renamable,
        illegal_drop,
    } = args;
    #[allow(clippy::cast_precision_loss, reason = "tree depth fits f32 mantissa")]
    let indent = LayoutPx::new(row.depth as f32 * indent_step.value());
    let disclosure_icon_rect = disclosure_icon_rect_at(row_rect, indent);
    let disclosure_hit_rect = disclosure_hit_rect_at(row_rect, indent);
    let label_rect = label_rect_at(row_rect, indent);
    let selected = state.selection.contains(&row.id);
    let expanded = state.expanded.contains(&row.id);
    let interaction = ctx.interact(
        InteractDeclaration::new(row.id, row_rect, Sense::DRAGGABLE)
            .focusable(false)
            .active(selected)
            .a11y({
                let node = AccessNode::new(Role::TreeItem)
                    .with_label_text(row.label.clone())
                    .with_selected(selected);
                if row.has_children {
                    node.with_expanded(expanded)
                } else {
                    node
                }
            }),
    );
    let live_focused = ctx.is_focused(row.id);
    apply_row_interaction(
        ctx,
        RowInteractionArgs {
            row,
            row_rect,
            interaction: &interaction,
            state,
            mode,
            renamable,
        },
        outcomes,
    );
    let mut paint = vec![WidgetPaint::Surface {
        rect: row_rect,
        fill: row_fill(ctx, &interaction, state.selection.contains(&row.id)),
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    if row.has_children {
        paint.extend(draw_disclosure(
            ctx,
            row,
            disclosure_icon_rect,
            disclosure_hit_rect,
            state,
            row.disabled,
        ));
    }
    let content_rect = badge_content_rect(label_rect, row.badge.is_some());
    if state.renaming == Some(row.id) {
        paint.extend(draw_rename_editor(
            ctx,
            row.id,
            &row.label,
            content_rect,
            state,
        ));
    } else {
        paint.extend(label_with_glyph_paint(ctx, row, content_rect));
    }
    if let Some(badge) = row.badge {
        paint.push(badge_dot_paint(ctx, badge, label_rect));
    }
    push_focus_ring(
        ctx,
        &mut paint,
        row_rect,
        ctx.theme().radius.none,
        live_focused,
    );
    if let Some(target) = state.drop_target
        && target.anchor == row.id
    {
        let fill = if illegal_drop {
            ctx.theme().colors.danger.step(Step12::SOLID)
        } else {
            ctx.theme().colors.accent.step(Step12::SOLID)
        };
        paint.push(WidgetPaint::Surface {
            rect: drop_indicator_rect(row_rect, target.placement),
            fill,
            border: None,
            radius: ctx.theme().radius.none,
            elevation: None,
        });
    }
    paint
}

const BADGE_GUTTER_PX: f32 = 11.0;

fn badge_content_rect(label_rect: LayoutRect, has_badge: bool) -> LayoutRect {
    if !has_badge {
        return label_rect;
    }
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(label_rect.origin.x.value() + BADGE_GUTTER_PX),
            label_rect.origin.y,
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(label_rect.size.width.value() - BADGE_GUTTER_PX),
            label_rect.size.height,
        ),
    )
}

fn badge_dot_paint(ctx: &FrameCtx<'_>, badge: TreeBadge, label_rect: LayoutRect) -> WidgetPaint {
    const DOT_PX: f32 = 7.0;
    let rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(label_rect.origin.x.value() + (BADGE_GUTTER_PX - DOT_PX) / 2.0),
            LayoutPx::new(
                label_rect.origin.y.value() + (label_rect.size.height.value() - DOT_PX) / 2.0,
            ),
        ),
        LayoutSize::new(LayoutPx::new(DOT_PX), LayoutPx::new(DOT_PX)),
    );
    let fill = match badge {
        TreeBadge::Error => ctx.theme().colors.danger.step(Step12::SOLID),
        TreeBadge::Warning => ctx.theme().colors.warning.step(Step12::SOLID),
        TreeBadge::RebuildNeeded => ctx.theme().colors.accent.step(Step12::SOLID),
    };
    WidgetPaint::Surface {
        rect,
        fill,
        border: None,
        radius: ctx.theme().radius.sm,
        elevation: None,
    }
}

struct RowOutcomes<'a> {
    activated: &'a mut Option<WidgetId>,
    double_activated: &'a mut Option<WidgetId>,
    drop_committed: &'a mut Option<(WidgetId, DropTarget)>,
    context_menu: &'a mut Option<ContextMenuRequest>,
}

struct RowInteractionArgs<'a> {
    row: &'a VisibleRow,
    row_rect: LayoutRect,
    interaction: &'a crate::hit_test::Interaction,
    state: &'a mut TreeViewState,
    mode: TreeSelectionMode,
    renamable: &'a [WidgetId],
}

fn commit_pending_rename(
    ctx: &mut FrameCtx<'_>,
    visible: &[VisibleRow],
    state: &mut TreeViewState,
    renamable: &[WidgetId],
    pending_row_rect: Option<LayoutRect>,
) {
    let Some(pending) = state.pending_rename else {
        return;
    };
    if state.renaming.is_some() || !renamable.contains(&pending.id) {
        state.pending_rename = None;
        return;
    }
    let pressed_off_row = ctx.input.buttons_pressed.contains(PointerButton::Primary)
        && !pending_row_rect
            .zip(ctx.input.pointer.map(|sample| sample.position))
            .is_some_and(|(row, cursor)| row.contains(cursor));
    if pressed_off_row && pending.at != ctx.input.frame {
        state.pending_rename = None;
        return;
    }
    let Some(row) = visible.iter().find(|r| r.id == pending.id) else {
        state.pending_rename = None;
        return;
    };
    let elapsed = ctx.input.frame.since(pending.at);
    if elapsed < ctx.input.double_click_window.duration() {
        return;
    }
    let text = match &row.label {
        LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(),
        LabelText::Owned(s) => s.clone(),
    };
    state.renaming = Some(pending.id);
    state.rename_buffer = TextInputState::from_text(&text);
    state.pending_rename = None;
}

fn apply_row_interaction(
    ctx: &mut FrameCtx<'_>,
    args: RowInteractionArgs<'_>,
    outcomes: &mut RowOutcomes<'_>,
) {
    let RowInteractionArgs {
        row,
        row_rect,
        interaction,
        state,
        mode,
        renamable,
    } = args;
    let RowOutcomes {
        activated,
        double_activated,
        drop_committed,
        context_menu,
    } = outcomes;
    if row.disabled {
        return;
    }
    let secondary = interaction.click_button == Some(PointerButton::Secondary);
    if secondary && interaction.click() {
        if !state.selection.contains(&row.id) {
            update_selection(state, row.id, ModifierMask::NONE, mode);
        }
        state.focused = Some(row.id);
        ctx.focus.request_focus(row.id);
        state.pending_rename = None;
        if context_menu.is_none()
            && let Some(at) = ctx.input.pointer.map(|sample| sample.position)
        {
            **context_menu = Some(ContextMenuRequest { target: row.id, at });
        }
        return;
    }
    if interaction.click() {
        let was_selected = state.selection.contains(&row.id);
        let is_double = interaction.double_click();
        update_selection(state, row.id, ctx.input.modifiers, mode);
        if activated.is_none() {
            **activated = Some(row.id);
        }
        state.focused = Some(row.id);
        ctx.focus.request_focus(row.id);
        let row_pending = state.pending_rename.is_some_and(|p| p.id == row.id);
        if !is_double && was_selected && state.renaming.is_none() && renamable.contains(&row.id) {
            if !row_pending {
                state.pending_rename = Some(PendingRename {
                    id: row.id,
                    at: ctx.input.frame,
                });
            }
        } else if is_double || (!was_selected && row_pending) {
            state.pending_rename = None;
        }
    }
    if interaction.double_click() {
        if state.pending_rename.is_some_and(|p| p.id == row.id) {
            state.pending_rename = None;
        }
        if double_activated.is_none() {
            **double_activated = Some(row.id);
        }
    }
    if interaction.drag_start() {
        state.drag_source = Some(row.id);
    }
    if interaction.drag_release()
        && let Some(src) = state.drag_source
        && let Some(target) = state.drop_target
    {
        if drop_committed.is_none() && src != target.anchor {
            **drop_committed = Some((src, target));
        }
        state.drag_source = None;
        state.drop_target = None;
    }
    if state.drag_source.is_some()
        && interaction.hover()
        && let Some(pointer) = ctx.input.pointer
    {
        state.drop_target = Some(DropTarget {
            anchor: row.id,
            placement: placement_from_pointer(pointer.position, row_rect),
        });
    }
}

fn draw_disclosure(
    ctx: &mut FrameCtx<'_>,
    row: &VisibleRow,
    icon_rect: LayoutRect,
    hit_rect: LayoutRect,
    state: &mut TreeViewState,
    disabled: bool,
) -> Vec<WidgetPaint> {
    let disclosure_id = row.id.child(WidgetKey::new("disclosure"));
    let sense = if disabled {
        Sense::HOVER
    } else {
        Sense::INTERACTIVE
    };
    let disclosure_interaction = ctx.interact(
        InteractDeclaration::new(disclosure_id, hit_rect, sense)
            .disabled(disabled)
            .a11y(
                AccessNode::new(Role::DisclosureTriangle)
                    .with_label_text(row.label.clone())
                    .with_expanded(state.expanded.contains(&row.id))
                    .with_disabled(disabled),
            ),
    );
    if !disabled && disclosure_interaction.click() {
        toggle_expanded(state, row.id);
    }
    vec![WidgetPaint::Mark {
        rect: icon_rect,
        kind: if state.expanded.contains(&row.id) {
            GlyphMark::DisclosureOpen
        } else {
            GlyphMark::DisclosureClosed
        },
        color: ctx.theme().colors.text_secondary(),
    }]
}

const TREE_GLYPH_COLUMN_PX: f32 = 18.0;
const TREE_ICON_PX: f32 = 14.0;

fn label_with_glyph_paint(
    ctx: &FrameCtx<'_>,
    row: &VisibleRow,
    label_rect: LayoutRect,
) -> Vec<WidgetPaint> {
    let color = if row.disabled {
        ctx.theme().colors.text_disabled()
    } else {
        ctx.theme().colors.text_primary()
    };
    let glyph_paint = row.icon.map(|icon| {
        IconSlot::new(icon, LayoutPx::new(TREE_ICON_PX)).paint_in(
            glyph_slot_rect(label_rect),
            IconTint::from_disabled(row.disabled),
        )
    });
    let text_rect = if row.icon.is_some() {
        text_after_glyph_rect(label_rect)
    } else {
        label_rect
    };
    glyph_paint
        .into_iter()
        .chain(core::iter::once(WidgetPaint::AlignedLabel {
            rect: text_rect,
            text: row.label.clone(),
            color,
            role: ctx.theme().typography.label,
            align: HorizontalAlign::Start,
        }))
        .collect()
}

fn glyph_slot_rect(label_rect: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        label_rect.origin,
        LayoutSize::new(LayoutPx::new(TREE_GLYPH_COLUMN_PX), label_rect.size.height),
    )
}

fn text_after_glyph_rect(label_rect: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(label_rect.origin.x.value() + TREE_GLYPH_COLUMN_PX),
            label_rect.origin.y,
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(label_rect.size.width.value() - TREE_GLYPH_COLUMN_PX),
            label_rect.size.height,
        ),
    )
}

const RENAME_FALLBACK_PLACEHOLDER: StringKey = StringKey::new("tree.rename.placeholder");

fn draw_rename_editor(
    ctx: &mut FrameCtx<'_>,
    row_id: WidgetId,
    label: &LabelText,
    label_rect: LayoutRect,
    state: &mut TreeViewState,
) -> Vec<WidgetPaint> {
    let placeholder = match label {
        LabelText::Key(k) => *k,
        LabelText::Owned(_) => RENAME_FALLBACK_PLACEHOLDER,
    };
    let rename_id = row_id.child(WidgetKey::new("rename"));
    let response = show_text_input(
        ctx,
        TextInput {
            id: rename_id,
            rect: label_rect,
            placeholder,
            state: &mut state.rename_buffer,
            disabled: false,
            validator: AlwaysValid,
        },
        &mut state.clipboard,
    );
    if !ctx.is_focused(rename_id) {
        ctx.focus.request_focus(rename_id);
    }
    response.paint
}

fn placement_from_pointer(pointer: LayoutPos, row: LayoutRect) -> DropPlacement {
    let local = pointer.y.value() - row.origin.y.value();
    if local * 2.0 < row.size.height.value() {
        DropPlacement::Before
    } else {
        DropPlacement::After
    }
}

fn drop_indicator_rect(row: LayoutRect, placement: DropPlacement) -> LayoutRect {
    let stripe = LayoutPx::new(2.0);
    match placement {
        DropPlacement::Before => {
            LayoutRect::new(row.origin, LayoutSize::new(row.size.width, stripe))
        }
        DropPlacement::After => LayoutRect::new(
            LayoutPos::new(
                row.origin.x,
                LayoutPx::new(row.origin.y.value() + row.size.height.value() - stripe.value()),
            ),
            LayoutSize::new(row.size.width, stripe),
        ),
    }
}

fn row_fill(
    ctx: &FrameCtx<'_>,
    interaction: &crate::hit_test::Interaction,
    selected: bool,
) -> Color {
    let neutral = ctx.theme().colors.neutral;
    if selected {
        ctx.theme().colors.accent.step(Step12::HOVER_BG)
    } else if interaction.hover() {
        neutral.step(Step12::HOVER_BG)
    } else {
        Color::TRANSPARENT
    }
}

const DISCLOSURE_ICON_SIZE_PX: f32 = 14.0;
const DISCLOSURE_COLUMN_WIDTH_PX: f32 = 20.0;

fn disclosure_icon_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect {
    let pad_y = (row.size.height.value() - DISCLOSURE_ICON_SIZE_PX) / 2.0;
    let pad_x = (DISCLOSURE_COLUMN_WIDTH_PX - DISCLOSURE_ICON_SIZE_PX) / 2.0;
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(row.origin.x.value() + indent.value() + pad_x),
            LayoutPx::new(row.origin.y.value() + pad_y),
        ),
        LayoutSize::new(
            LayoutPx::new(DISCLOSURE_ICON_SIZE_PX),
            LayoutPx::new(DISCLOSURE_ICON_SIZE_PX),
        ),
    )
}

fn disclosure_hit_rect_at(row: LayoutRect, indent: LayoutPx) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(row.origin.x.value() + indent.value()),
            row.origin.y,
        ),
        LayoutSize::new(LayoutPx::new(DISCLOSURE_COLUMN_WIDTH_PX), row.size.height),
    )
}

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

fn update_selection(
    state: &mut TreeViewState,
    id: WidgetId,
    modifiers: ModifierMask,
    mode: TreeSelectionMode,
) {
    match mode {
        TreeSelectionMode::Single => {
            state.selection = BTreeSet::from([id]);
        }
        TreeSelectionMode::Multi => {
            if modifiers.contains(ModifierMask::CTRL) {
                if !state.selection.insert(id) {
                    state.selection.remove(&id);
                }
            } else {
                state.selection = BTreeSet::from([id]);
            }
        }
    }
}

fn toggle_expanded(state: &mut TreeViewState, id: WidgetId) {
    if !state.expanded.insert(id) {
        state.expanded.remove(&id);
    }
}

fn handle_keyboard(
    ctx: &mut FrameCtx<'_>,
    visible: &[VisibleRow],
    state: &mut TreeViewState,
    renamable: &[WidgetId],
    activated: &mut Option<WidgetId>,
) {
    let in_tree_focus = ctx
        .focus
        .focused()
        .is_some_and(|f| visible.iter().any(|r| r.id == f));
    if !in_tree_focus {
        return;
    }
    let focused = ctx.focus.focused();
    let event = take_key(
        ctx.input,
        &[
            TakeKey::named(NamedKey::ArrowUp),
            TakeKey::named(NamedKey::ArrowDown),
            TakeKey::named(NamedKey::ArrowLeft),
            TakeKey::named(NamedKey::ArrowRight),
            TakeKey::named(NamedKey::Home),
            TakeKey::named(NamedKey::End),
            TakeKey::named(NamedKey::Enter),
            TakeKey::named(NamedKey::Space),
            TakeKey::named(NamedKey::F2),
        ],
    );
    let Some(event) = event else { return };
    let current = focused.and_then(|f| visible.iter().position(|r| r.id == f));
    match event.code {
        KeyCode::Named(NamedKey::ArrowDown) => {
            if let Some(idx) = current
                && idx + 1 < visible.len()
            {
                ctx.focus.request_focus(visible[idx + 1].id);
                state.focused = Some(visible[idx + 1].id);
            }
        }
        KeyCode::Named(NamedKey::ArrowUp) => {
            if let Some(idx) = current
                && idx > 0
            {
                ctx.focus.request_focus(visible[idx - 1].id);
                state.focused = Some(visible[idx - 1].id);
            }
        }
        KeyCode::Named(NamedKey::ArrowRight) => {
            if let Some(idx) = current
                && visible[idx].has_children
                && !state.expanded.contains(&visible[idx].id)
            {
                state.expanded.insert(visible[idx].id);
            }
        }
        KeyCode::Named(NamedKey::ArrowLeft) => {
            if let Some(idx) = current
                && state.expanded.contains(&visible[idx].id)
            {
                state.expanded.remove(&visible[idx].id);
            }
        }
        KeyCode::Named(NamedKey::Home) => {
            if let Some(first) = visible.first() {
                ctx.focus.request_focus(first.id);
                state.focused = Some(first.id);
            }
        }
        KeyCode::Named(NamedKey::End) => {
            if let Some(last) = visible.last() {
                ctx.focus.request_focus(last.id);
                state.focused = Some(last.id);
            }
        }
        KeyCode::Named(NamedKey::Enter | NamedKey::Space) => {
            if let Some(idx) = current {
                let id = visible[idx].id;
                state.selection = BTreeSet::from([id]);
                if activated.is_none() {
                    *activated = Some(id);
                }
            }
        }
        KeyCode::Named(NamedKey::F2) => {
            if let Some(idx) = current {
                let row = &visible[idx];
                if !row.disabled && renamable.contains(&row.id) {
                    let text = match &row.label {
                        LabelText::Key(k) => ctx.strings.resolve(*k).to_owned(),
                        LabelText::Owned(s) => s.clone(),
                    };
                    state.renaming = Some(row.id);
                    state.rename_buffer = TextInputState::from_text(&text);
                }
            }
        }
        _ => {}
    }
}

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

    use super::{
        PendingRename, TreeNode, TreeSelectionMode, TreeView, TreeViewState, show_tree_view,
    };
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState, resolve};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        DoubleClickWindow, FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey,
        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};

    fn root_id(name: &'static str) -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new(name))
    }

    fn sample_tree() -> Vec<TreeNode> {
        vec![
            TreeNode::parent(
                root_id("origin"),
                StringKey::new("tree.origin"),
                vec![
                    TreeNode::leaf(root_id("plane_xy"), StringKey::new("tree.xy")),
                    TreeNode::leaf(root_id("plane_yz"), StringKey::new("tree.yz")),
                ],
            ),
            TreeNode::leaf(root_id("sketch"), StringKey::new("tree.sketch")),
        ]
    }

    fn render(
        roots: &[TreeNode],
        state: &mut TreeViewState,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
    ) -> (super::TreeViewResponse, HitState) {
        render_with(roots, state, focus, snap, prev, &[])
    }

    fn render_with(
        roots: &[TreeNode],
        state: &mut TreeViewState,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
        renamable: &[WidgetId],
    ) -> (super::TreeViewResponse, HitState) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
        );
        let response = {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            show_tree_view(
                &mut ctx,
                TreeView::new(
                    WidgetId::ROOT.child(WidgetKey::new("tree")),
                    rect,
                    StringKey::new("test.tree"),
                    roots,
                    state,
                )
                .renamable(renamable),
            )
        };
        let next = resolve(prev, &hits, snap, focus.focused());
        (response, next)
    }

    fn press(pos: LayoutPos) -> InputSnapshot {
        press_at(pos, FrameInstant::ZERO)
    }

    fn release(pos: LayoutPos) -> InputSnapshot {
        release_at(pos, FrameInstant::ZERO)
    }

    fn idle(pos: LayoutPos) -> InputSnapshot {
        idle_at(pos, FrameInstant::ZERO)
    }

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

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

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

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

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

    #[test]
    fn secondary_click_requests_context_menu_and_selects_row() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0));
        let mut last: Option<super::TreeViewResponse> = None;
        [secondary_press(pos), secondary_release(pos), idle(pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(request) = last.and_then(|r| r.context_menu) else {
            panic!("secondary click requests a context menu");
        };
        assert_eq!(request.target, root_id("sketch"));
        assert!(state.selection.contains(&root_id("sketch")));
    }

    #[test]
    fn secondary_click_does_not_activate_row() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(30.0));
        [secondary_press(pos), secondary_release(pos), idle(pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                assert!(response.activated.is_none());
                prev = next;
            });
    }

    #[test]
    fn click_disclosure_toggles_expansion() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let pos = LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(11.0));
        [press(pos), release(pos), idle(pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                prev = next;
            });
        assert!(state.expanded.contains(&roots[0].id));
    }

    #[test]
    fn click_indent_column_outside_old_icon_still_toggles() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let pos = LayoutPos::new(LayoutPx::new(18.0), LayoutPx::new(11.0));
        [press(pos), release(pos), idle(pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                prev = next;
            });
        assert!(
            state.expanded.contains(&roots[0].id),
            "indent column click toggles even outside the 14px icon",
        );
    }

    #[test]
    fn click_disclosure_toggles_back_after_collapse() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        state.expanded.insert(roots[0].id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let pos = LayoutPos::new(LayoutPx::new(8.0), LayoutPx::new(11.0));
        [press(pos), release(pos), idle(pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                prev = next;
            });
        assert!(!state.expanded.contains(&roots[0].id), "first click closes");
        [press(pos), release(pos), idle(pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                prev = next;
            });
        assert!(
            state.expanded.contains(&roots[0].id),
            "second click reopens",
        );
    }

    #[test]
    fn click_label_selects_node_and_marks_active() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let mut last: Option<super::TreeViewResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.activated, Some(roots[0].id));
        assert!(state.selection.contains(&roots[0].id));
    }

    #[test]
    fn arrow_down_moves_focus_to_next_visible() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        state.expanded.insert(roots[0].id);
        let mut focus = FocusManager::new();
        focus.register_focusable(roots[0].id);
        focus.request_focus(roots[0].id);
        focus.end_frame();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowDown),
            ModifierMask::NONE,
        ));
        let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev);
        assert_eq!(state.focused, Some(roots[0].children[0].id));
    }

    #[test]
    fn arrow_right_expands_collapsed_parent() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        focus.register_focusable(roots[0].id);
        focus.request_focus(roots[0].id);
        focus.end_frame();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowRight),
            ModifierMask::NONE,
        ));
        let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev);
        assert!(state.expanded.contains(&roots[0].id));
    }

    #[test]
    fn ctrl_click_in_multi_mode_toggles_selection_membership() {
        let roots = sample_tree();
        let mut state = TreeViewState {
            selection: BTreeSet::from([roots[1].id]),
            ..TreeViewState::default()
        };
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
        );
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                snap.modifiers = ModifierMask::CTRL;
                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 _ = show_tree_view(
                        &mut ctx,
                        TreeView::new(
                            WidgetId::ROOT.child(WidgetKey::new("tree")),
                            rect,
                            StringKey::new("test.tree"),
                            &roots,
                            &mut state,
                        )
                        .mode(TreeSelectionMode::Multi),
                    );
                }
                prev = resolve(&prev, &hits, &snap, focus.focused());
            });
        assert!(state.selection.contains(&roots[0].id));
        assert!(state.selection.contains(&roots[1].id));
    }

    #[test]
    fn tree_registers_single_tab_stop_so_tab_does_not_walk_every_row() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        state.expanded.insert(roots[0].id);
        let mut focus = FocusManager::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let prev = HitState::new();
        let _ = render(&roots, &mut state, &mut focus, &mut snap, &prev);
        let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect();
        assert_eq!(stops, vec![roots[0].id]);
    }

    #[test]
    fn click_row_focuses_it_so_arrow_keys_engage() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (_, next) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
                prev = next;
            });
        assert_eq!(focus.focused(), Some(roots[0].id));
    }

    #[test]
    fn enter_with_renaming_clears_state_and_emits_committed() {
        use super::RenameCommit;
        use crate::widgets::text_input::TextInputState;
        let roots = sample_tree();
        let mut state = TreeViewState {
            renaming: Some(roots[0].id),
            rename_buffer: TextInputState::from_text("renamed"),
            ..TreeViewState::default()
        };
        let mut focus = FocusManager::new();
        focus.register_focusable(roots[0].id);
        focus.request_focus(roots[0].id);
        focus.end_frame();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
        assert_eq!(
            response.rename_committed,
            Some(RenameCommit {
                id: roots[0].id,
                text: "renamed".into(),
            }),
        );
        assert!(state.renaming.is_none());
    }

    #[test]
    fn keyboard_enter_on_focused_row_emits_activated() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        focus.register_focusable(roots[0].id);
        focus.request_focus(roots[0].id);
        focus.end_frame();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let (response, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
        assert_eq!(response.activated, Some(roots[0].id));
    }

    #[test]
    fn f2_on_renamable_focused_row_starts_rename_prefilled_with_label() {
        let sketch_id = root_id("sketch");
        let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())];
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        focus.register_focusable(sketch_id);
        focus.request_focus(sketch_id);
        focus.end_frame();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::F2),
            ModifierMask::NONE,
        ));
        let (_, _) = render_with(
            &roots,
            &mut state,
            &mut focus,
            &mut snap,
            &prev,
            &[sketch_id],
        );
        assert_eq!(state.renaming, Some(sketch_id));
        assert_eq!(state.rename_buffer.text, "Profile");
    }

    #[test]
    fn slow_click_on_already_selected_renamable_row_starts_rename() {
        let sketch_id = root_id("sketch");
        let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())];
        let mut state = TreeViewState {
            selection: BTreeSet::from([sketch_id]),
            ..TreeViewState::default()
        };
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let window = DoubleClickWindow::DEFAULT.duration();
        let after_click = FrameInstant::from_duration(core::time::Duration::from_millis(10));
        let after_window = FrameInstant::from_duration(
            after_click.duration() + window + core::time::Duration::from_millis(5),
        );
        let frames = [
            press_at(click_pos, FrameInstant::ZERO),
            release_at(click_pos, FrameInstant::ZERO),
            idle_at(click_pos, after_click),
            idle_at(click_pos, after_window),
        ];
        frames.into_iter().for_each(|mut snap| {
            let (_, next) = render_with(
                &roots,
                &mut state,
                &mut focus,
                &mut snap,
                &prev,
                &[sketch_id],
            );
            prev = next;
        });
        assert_eq!(state.renaming, Some(sketch_id));
        assert_eq!(state.rename_buffer.text, "Profile");
    }

    #[test]
    fn primary_press_outside_pending_row_cancels_rename() {
        let sketch_id = root_id("sketch");
        let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())];
        let mut state = TreeViewState {
            selection: BTreeSet::from([sketch_id]),
            ..TreeViewState::default()
        };
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let row_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let elsewhere = LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(500.0));
        let armed = FrameInstant::from_duration(core::time::Duration::from_millis(10));
        let pressed_away = FrameInstant::from_duration(core::time::Duration::from_millis(20));
        let frames = [
            press_at(row_pos, FrameInstant::ZERO),
            release_at(row_pos, FrameInstant::ZERO),
            idle_at(row_pos, armed),
            press_at(elsewhere, pressed_away),
        ];
        frames.into_iter().for_each(|mut snap| {
            let (_, next) = render_with(
                &roots,
                &mut state,
                &mut focus,
                &mut snap,
                &prev,
                &[sketch_id],
            );
            prev = next;
        });
        assert_eq!(
            state.pending_rename, None,
            "pressing outside the pending row cancels the slow-click rename",
        );
        assert_eq!(state.renaming, None, "no rename editor opens");
    }

    #[test]
    fn extra_slow_click_on_already_pending_row_does_not_reset_pending_at() {
        let sketch_id = root_id("sketch");
        let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())];
        let original_at = FrameInstant::from_duration(core::time::Duration::from_millis(5));
        let mut state = TreeViewState {
            selection: BTreeSet::from([sketch_id]),
            pending_rename: Some(PendingRename {
                id: sketch_id,
                at: original_at,
            }),
            ..TreeViewState::default()
        };
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let new_click = FrameInstant::from_duration(core::time::Duration::from_millis(120));
        let new_visible = FrameInstant::from_duration(
            new_click.duration() + core::time::Duration::from_millis(10),
        );
        let frames = [
            press_at(click_pos, new_click),
            release_at(click_pos, new_click),
            idle_at(click_pos, new_visible),
        ];
        frames.into_iter().for_each(|mut snap| {
            let (_, next) = render_with(
                &roots,
                &mut state,
                &mut focus,
                &mut snap,
                &prev,
                &[sketch_id],
            );
            prev = next;
        });
        let Some(pending) = state.pending_rename else {
            panic!("pending rename must persist across slow extra clicks");
        };
        assert_eq!(
            pending.at, original_at,
            "subsequent slow click on already-pending row must keep the original at",
        );
    }

    #[test]
    fn double_click_on_already_selected_renamable_row_does_not_start_rename() {
        let sketch_id = root_id("sketch");
        let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())];
        let mut state = TreeViewState {
            selection: BTreeSet::from([sketch_id]),
            ..TreeViewState::default()
        };
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let window = DoubleClickWindow::DEFAULT.duration();
        let fast = FrameInstant::from_duration(core::time::Duration::from_millis(60));
        let after_window = FrameInstant::from_duration(
            fast.duration() + window + core::time::Duration::from_millis(5),
        );
        let frames = [
            press_at(click_pos, FrameInstant::ZERO),
            release_at(click_pos, FrameInstant::ZERO),
            press_at(click_pos, fast),
            release_at(click_pos, fast),
            idle_at(click_pos, after_window),
        ];
        frames.into_iter().for_each(|mut snap| {
            let (_, next) = render_with(
                &roots,
                &mut state,
                &mut focus,
                &mut snap,
                &prev,
                &[sketch_id],
            );
            prev = next;
        });
        assert_eq!(
            state.renaming, None,
            "fast double-click on already-selected row must not start rename",
        );
        assert_eq!(
            state.pending_rename, None,
            "double-click must clear any deferred rename",
        );
    }

    #[test]
    fn double_click_on_renamable_row_does_not_start_rename() {
        let sketch_id = root_id("sketch");
        let roots = vec![TreeNode::leaf_owned(sketch_id, "Profile".to_owned())];
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(11.0));
        let frames = [
            press(click_pos),
            release(click_pos),
            idle(click_pos),
            press(click_pos),
            release(click_pos),
            idle(click_pos),
        ];
        let mut last: Option<super::TreeViewResponse> = None;
        frames.into_iter().for_each(|mut snap| {
            let (response, next) = render_with(
                &roots,
                &mut state,
                &mut focus,
                &mut snap,
                &prev,
                &[sketch_id],
            );
            last = Some(response);
            prev = next;
        });
        assert!(
            state.renaming.is_none(),
            "fast double-click must enter via double_activated, not rename",
        );
        let Some(response) = last else {
            panic!("response captured");
        };
        assert_eq!(response.double_activated, Some(sketch_id));
    }

    #[test]
    fn f2_on_non_renamable_row_is_ignored() {
        let roots = sample_tree();
        let mut state = TreeViewState::default();
        let mut focus = FocusManager::new();
        focus.register_focusable(roots[1].id);
        focus.request_focus(roots[1].id);
        focus.end_frame();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::F2),
            ModifierMask::NONE,
        ));
        let (_, _) = render(&roots, &mut state, &mut focus, &mut snap, &prev);
        assert!(state.renaming.is_none());
    }

    fn tall_roots(n: u64) -> Vec<TreeNode> {
        (0..n)
            .map(|i| {
                TreeNode::leaf_owned(
                    WidgetId::ROOT.child_indexed(WidgetKey::new("row"), i),
                    format!("row{i}"),
                )
            })
            .collect()
    }

    fn render_scroll(
        roots: &[TreeNode],
        state: &mut TreeViewState,
        height: f32,
    ) -> (Vec<super::WidgetPaint>, bool) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(height)),
        );
        let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree"));
        let scrollbar_id = tree_id.child(WidgetKey::new("scrollbar"));
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let paint = {
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            show_tree_view(
                &mut ctx,
                TreeView::new(tree_id, rect, StringKey::new("test.tree"), roots, state),
            )
            .paint
        };
        (paint, a11y.contains(scrollbar_id))
    }

    fn paints_owned_label(paint: &[super::WidgetPaint], text: &str) -> bool {
        use super::{HorizontalAlign, LabelText, WidgetPaint};
        paint.iter().any(|p| {
            matches!(
                p,
                WidgetPaint::AlignedLabel { text: LabelText::Owned(t), align: HorizontalAlign::Start, .. }
                    if t == text
            )
        })
    }

    #[test]
    fn tall_tree_culls_offscreen_rows_and_shows_a_scrollbar() {
        let roots = tall_roots(40);
        let mut state = TreeViewState::default();
        let (paint, has_bar) = render_scroll(&roots, &mut state, 400.0);
        assert!(has_bar, "an overflowing tree shows a scrollbar");
        assert!(paints_owned_label(&paint, "row0"), "the first row paints");
        assert!(
            !paints_owned_label(&paint, "row39"),
            "the last row is culled offscreen",
        );
        assert_eq!(
            state.scroll_offset,
            LayoutPx::ZERO,
            "offset snaps to the top"
        );
    }

    #[test]
    fn scrolling_offset_reveals_later_rows() {
        let roots = tall_roots(40);
        let mut state = TreeViewState {
            scroll_offset: LayoutPx::new(400.0),
            ..TreeViewState::default()
        };
        let (paint, _) = render_scroll(&roots, &mut state, 400.0);
        assert!(
            paints_owned_label(&paint, "row20"),
            "the scrolled-to row paints",
        );
        assert!(
            !paints_owned_label(&paint, "row0"),
            "the top row is culled after scrolling",
        );
    }

    #[test]
    fn tree_that_fits_shows_no_scrollbar() {
        let roots = tall_roots(3);
        let mut state = TreeViewState::default();
        let (paint, has_bar) = render_scroll(&roots, &mut state, 400.0);
        assert!(!has_bar, "a tree that fits its viewport shows no scrollbar");
        assert!(paints_owned_label(&paint, "row2"));
    }

    fn render_scroll_wheel(
        roots: &[TreeNode],
        state: &mut TreeViewState,
        scroll_y: f32,
    ) -> Vec<super::WidgetPaint> {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.pointer = Some(PointerSample::new(LayoutPos::new(
            LayoutPx::new(100.0),
            LayoutPx::new(100.0),
        )));
        snap.scroll_y = scroll_y;
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
        );
        let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree"));
        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,
        );
        show_tree_view(
            &mut ctx,
            TreeView::new(tree_id, rect, StringKey::new("test.tree"), roots, state),
        )
        .paint
    }

    #[test]
    fn wheel_over_the_tree_reveals_later_rows() {
        let roots = tall_roots(40);
        let mut state = TreeViewState::default();
        let paint = render_scroll_wheel(&roots, &mut state, 100.0);
        assert!(
            state.scroll_offset.value() > 0.0,
            "a wheel notch over the tree advances the offset",
        );
        assert!(
            paints_owned_label(&paint, "row5"),
            "a later row is revealed"
        );
        assert!(
            !paints_owned_label(&paint, "row0"),
            "the top row scrolls off",
        );
    }

    #[test]
    fn wheel_off_the_tree_does_not_scroll() {
        let roots = tall_roots(40);
        let mut state = TreeViewState::default();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.pointer = Some(PointerSample::new(LayoutPos::new(
            LayoutPx::new(900.0),
            LayoutPx::new(900.0),
        )));
        snap.scroll_y = 100.0;
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
        );
        let tree_id = WidgetId::ROOT.child(WidgetKey::new("tree"));
        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 _ = show_tree_view(
                &mut ctx,
                TreeView::new(
                    tree_id,
                    rect,
                    StringKey::new("test.tree"),
                    &roots,
                    &mut state,
                ),
            );
        }
        assert_eq!(
            state.scroll_offset,
            LayoutPx::ZERO,
            "a wheel notch away from the tree leaves it unscrolled",
        );
    }
}
