use core::num::NonZeroU32;
use std::collections::BTreeMap;
use std::sync::Arc;

use bone_document::{Document, Sketch, SketchEntity, SketchRelation};
use bone_types::{Length, SketchEntityId};
use bone_ui::frame::FrameCtx;
use bone_ui::layout::{
    Axis, DockPanel, DockState, DockStateError, Layout, LayoutPos, LayoutPx, LayoutRect,
    LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout, SolvedNode, measure,
};
use bone_ui::strings::{StringKey, StringTable};
use bone_ui::theme::{ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme};
use bone_ui::widgets::{
    Clipboard, LabelText, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem,
    PropertyCell, PropertyEditor, PropertyGrid, PropertyRow, Ribbon, RibbonGroup, RibbonIconSize,
    RibbonState, RibbonTab, StatusAlign, StatusBar, StatusItem, ToolbarItem, TreeNode, TreeView,
    TreeViewState, WidgetPaint, show_menu_bar, show_property_grid, show_ribbon, show_status_bar,
    show_tree_view,
};
use bone_ui::{WidgetId, WidgetKey};
use uom::si::length::millimeter;

use crate::relation_tools::{Eligibility, RelationKind, eligibility};
use crate::sketch_mode::PendingDimension;
use crate::sketch_mode::{Mode, Plane, SketchTool};
use crate::smart_dimension;
use crate::strings;

const RIBBON_GROUP_PADDING_PX: f32 = 8.0;
const RIBBON_TOOLBAR_GAP_PX: f32 = 4.0;
const RIBBON_LABEL_HORIZONTAL_PADDING_PX: f32 = 12.0;
const RIBBON_LABEL_AVG_ADVANCE_RATIO: f32 = 0.6;
const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0);

#[derive(Debug, thiserror::Error)]
pub enum ShellError {
    #[error("dock state: {0}")]
    Dock(#[from] DockStateError),
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ShellPanels {
    menu_bar: PanelId,
    ribbon: PanelId,
    feature_tree: PanelId,
    property_pane: PanelId,
    viewport: PanelId,
    status: PanelId,
}

impl ShellPanels {
    fn standard() -> Self {
        Self {
            menu_bar: panel(1),
            ribbon: panel(2),
            feature_tree: panel(3),
            property_pane: panel(4),
            viewport: panel(5),
            status: panel(6),
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ShellIds {
    dock_host: WidgetId,
    ribbon: WidgetId,
    ribbon_exit: WidgetId,
    ribbon_smart_dimension: WidgetId,
    feature_tree: WidgetId,
    property_pane: WidgetId,
    viewport: WidgetId,
    status_bar: WidgetId,
    feature_part: WidgetId,
    plane_xy: WidgetId,
    plane_yz: WidgetId,
    plane_zx: WidgetId,
    menu_bar: WidgetId,
    menu_file: WidgetId,
    menu_edit: WidgetId,
    menu_view: WidgetId,
    menu_insert: WidgetId,
    menu_tools: WidgetId,
    menu_window: WidgetId,
    menu_help: WidgetId,
    menu_file_quit: WidgetId,
    menu_edit_undo: WidgetId,
    menu_edit_redo: WidgetId,
    menu_view_zoom_fit: WidgetId,
}

impl ShellIds {
    fn standard() -> Self {
        let root = WidgetId::ROOT.child(WidgetKey::new("shell"));
        let feature_tree = root.child(WidgetKey::new("tree"));
        let feature_part = feature_tree.child(WidgetKey::new("part"));
        let ribbon = root.child(WidgetKey::new("ribbon"));
        let menu_bar = root.child(WidgetKey::new("menu"));
        let menu_file = menu_bar.child(WidgetKey::new("file"));
        let menu_edit = menu_bar.child(WidgetKey::new("edit"));
        let menu_view = menu_bar.child(WidgetKey::new("view"));
        Self {
            dock_host: root.child(WidgetKey::new("dock")),
            ribbon,
            ribbon_exit: ribbon.child(WidgetKey::new("tool.exit_sketch")),
            ribbon_smart_dimension: ribbon.child(WidgetKey::new("tool.smart_dimension")),
            feature_tree,
            property_pane: root.child(WidgetKey::new("props")),
            viewport: root.child(WidgetKey::new("viewport")),
            status_bar: root.child(WidgetKey::new("status")),
            feature_part,
            plane_xy: feature_part.child(WidgetKey::new("plane.xy")),
            plane_yz: feature_part.child(WidgetKey::new("plane.yz")),
            plane_zx: feature_part.child(WidgetKey::new("plane.zx")),
            menu_bar,
            menu_file,
            menu_edit,
            menu_view,
            menu_insert: menu_bar.child(WidgetKey::new("insert")),
            menu_tools: menu_bar.child(WidgetKey::new("tools")),
            menu_window: menu_bar.child(WidgetKey::new("window")),
            menu_help: menu_bar.child(WidgetKey::new("help")),
            menu_file_quit: menu_file.child(WidgetKey::new("quit")),
            menu_edit_undo: menu_edit.child(WidgetKey::new("undo")),
            menu_edit_redo: menu_edit.child(WidgetKey::new("redo")),
            menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")),
        }
    }

    fn plane_for(&self, id: WidgetId) -> Option<Plane> {
        [
            (self.plane_xy, Plane::Xy),
            (self.plane_yz, Plane::Yz),
            (self.plane_zx, Plane::Zx),
        ]
        .iter()
        .copied()
        .find_map(|(plane_id, plane)| (plane_id == id).then_some(plane))
    }

    fn menu_action_for(&self, id: WidgetId) -> Option<MenuAction> {
        [
            (self.menu_file_quit, MenuAction::Quit),
            (self.menu_edit_undo, MenuAction::Undo),
            (self.menu_edit_redo, MenuAction::Redo),
            (self.menu_view_zoom_fit, MenuAction::ZoomFit),
        ]
        .iter()
        .copied()
        .find_map(|(menu_id, action)| (menu_id == id).then_some(action))
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum MenuAction {
    Quit,
    Undo,
    Redo,
    ZoomFit,
}

pub struct Shell {
    panels: ShellPanels,
    ids: ShellIds,
    retained_layout: RetainedLayout,
    dock_state: Arc<DockState>,
    tool_index: BTreeMap<WidgetId, SketchTool>,
    relation_index: BTreeMap<WidgetId, RelationKind>,
    pub state: ShellState,
}

#[derive(Default)]
pub struct ShellState {
    pub ribbon: RibbonState,
    pub feature_tree: TreeViewState,
    pub clipboard: MemoryClipboard,
    pub menu_bar: MenuBarState,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ShellFrame {
    pub paints: Vec<WidgetPaint>,
    pub overlay_paints: Vec<WidgetPaint>,
    pub viewport_rect: LayoutRect,
    pub activated_tool: Option<SketchTool>,
    pub activated_relation: Option<SketchRelation>,
    pub activated_dimension: Option<PendingDimension>,
    pub plane_picked: Option<Plane>,
    pub exit_sketch: bool,
    pub menu_action: Option<MenuAction>,
}

impl ShellFrame {
    fn empty() -> Self {
        Self {
            paints: Vec::new(),
            overlay_paints: Vec::new(),
            viewport_rect: zero_rect(),
            activated_tool: None,
            activated_relation: None,
            activated_dimension: None,
            plane_picked: None,
            exit_sketch: false,
            menu_action: None,
        }
    }
}

impl Shell {
    fn build_layout(&self, gap: Spacing) -> Layout {
        Layout::dock_host(
            self.ids.dock_host,
            Arc::clone(&self.dock_state),
            vec![
                DockPanel {
                    id: self.panels.menu_bar,
                    child: Layout::leaf(self.ids.menu_bar),
                },
                DockPanel {
                    id: self.panels.ribbon,
                    child: Layout::leaf(self.ids.ribbon),
                },
                DockPanel {
                    id: self.panels.feature_tree,
                    child: Layout::leaf(self.ids.feature_tree),
                },
                DockPanel {
                    id: self.panels.property_pane,
                    child: Layout::leaf(self.ids.property_pane),
                },
                DockPanel {
                    id: self.panels.viewport,
                    child: Layout::leaf(self.ids.viewport),
                },
                DockPanel {
                    id: self.panels.status,
                    child: Layout::leaf(self.ids.status_bar),
                },
            ],
            gap,
        )
    }

    pub fn new() -> Result<Self, ShellError> {
        let panels = ShellPanels::standard();
        let ids = ShellIds::standard();
        let dock_state = Arc::new(DockState::solidworks_default(
            panels.menu_bar,
            panels.feature_tree,
            panels.property_pane,
            panels.ribbon,
            panels.viewport,
            panels.status,
        )?);
        let tool_index = build_tool_index(ids.ribbon);
        let relation_index = build_relation_index(ids.ribbon);
        let mut state = ShellState::default();
        state.feature_tree.expanded.insert(ids.feature_part);
        Ok(Self {
            panels,
            ids,
            retained_layout: RetainedLayout::default(),
            dock_state,
            tool_index,
            relation_index,
            state,
        })
    }

    pub fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        document: &Document,
        mode: &Mode,
        selection: &[SketchEntityId],
        viewport_size: LayoutSize,
    ) -> ShellFrame {
        let theme = ctx.theme();
        let direction = ctx.direction();
        let layout = self.build_layout(theme.spacing.md);
        let Ok(solved) = measure(&layout, viewport_size, &self.retained_layout, direction) else {
            return ShellFrame::empty();
        };
        let inset_px = theme.spacing.sm.value_px();
        let mut paints = paint_walk(&solved, solved.root_node(), theme, self.panels.viewport);
        let viewport_rect = panel_rect(&solved, self.panels.viewport).unwrap_or_else(zero_rect);
        let ribbon_rect = panel_rect(&solved, self.panels.ribbon).unwrap_or_else(zero_rect);
        let menu_bar_rect = panel_rect(&solved, self.panels.menu_bar).unwrap_or_else(zero_rect);
        let tree_rect = panel_rect(&solved, self.panels.feature_tree)
            .map_or_else(zero_rect, |r| inset_rect(r, inset_px));
        let property_rect = panel_rect(&solved, self.panels.property_pane)
            .map_or_else(zero_rect, |r| inset_rect(r, inset_px));
        let status_rect = panel_rect(&solved, self.panels.status).unwrap_or_else(zero_rect);
        let menu_action = render_menu_bar(
            ctx,
            menu_bar_rect,
            &self.ids,
            &mut self.state.menu_bar,
            &mut paints,
        );
        let active_sketch = active_sketch(document, mode);
        let activated_widget = render_ribbon(
            ctx,
            RibbonInputs {
                rect: ribbon_rect,
                ribbon: self.ids.ribbon,
                ribbon_exit: self.ids.ribbon_exit,
                ribbon_smart_dimension: self.ids.ribbon_smart_dimension,
                mode,
                sketch: active_sketch,
                selection,
            },
            &mut self.state.ribbon,
            &mut paints,
        );
        let double_activated = render_feature_tree(
            ctx,
            tree_rect,
            self.ids.feature_tree,
            self.ids.feature_part,
            &mut self.state.feature_tree,
            document,
            &mut paints,
        );
        render_property_pane(
            ctx,
            property_rect,
            self.ids.property_pane,
            &mut self.state.clipboard,
            PropertyState {
                document,
                mode,
                selection,
            },
            &mut paints,
        );
        render_status_bar(ctx, status_rect, self.ids.status_bar, mode, &mut paints);
        let exit_sketch = activated_widget == Some(self.ids.ribbon_exit);
        let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied());
        let activated_relation = resolve_activated_relation(
            activated_widget,
            &self.relation_index,
            active_sketch,
            selection,
        );
        let activated_dimension = resolve_activated_dimension(
            activated_widget,
            self.ids.ribbon_smart_dimension,
            active_sketch,
            selection,
        );
        let plane_picked = double_activated.and_then(|id| self.ids.plane_for(id));
        let (paints, overlay_paints) = partition_overlay(paints, ctx.theme());
        ShellFrame {
            paints,
            overlay_paints,
            viewport_rect,
            activated_tool,
            activated_relation,
            activated_dimension,
            plane_picked,
            exit_sketch,
            menu_action,
        }
    }
}

fn partition_overlay(
    paints: Vec<WidgetPaint>,
    theme: &Theme,
) -> (Vec<WidgetPaint>, Vec<WidgetPaint>) {
    paints.into_iter().fold(
        (Vec::new(), Vec::new()),
        |(mut main, mut overlay), paint| {
            match paint {
                WidgetPaint::Tooltip {
                    rect,
                    text,
                    elevation,
                    ..
                } => {
                    overlay.push(WidgetPaint::Surface {
                        rect,
                        fill: theme.colors.surface(elevation.surface),
                        border: elevation.border,
                        radius: theme.radius.sm,
                        elevation: Some(elevation),
                    });
                    overlay.push(WidgetPaint::Label {
                        rect,
                        text,
                        color: theme.colors.text_primary(),
                        role: theme.typography.caption,
                    });
                }
                other => main.push(other),
            }
            (main, overlay)
        },
    )
}

fn active_sketch<'a>(document: &'a Document, mode: &Mode) -> Option<&'a Sketch> {
    match mode {
        Mode::Sketch { sketch_id, .. } => document.sketch(*sketch_id),
        Mode::Idle => None,
    }
}

fn resolve_activated_relation(
    activated_widget: Option<WidgetId>,
    relation_index: &BTreeMap<WidgetId, RelationKind>,
    sketch: Option<&Sketch>,
    selection: &[SketchEntityId],
) -> Option<SketchRelation> {
    let id = activated_widget?;
    let kind = relation_index.get(&id).copied()?;
    let sketch = sketch?;
    match eligibility(kind, sketch, selection) {
        Eligibility::Eligible(rel) => Some(rel),
        Eligibility::Disabled(_) => None,
    }
}

fn resolve_activated_dimension(
    activated_widget: Option<WidgetId>,
    smart_dimension_id: WidgetId,
    sketch: Option<&Sketch>,
    selection: &[SketchEntityId],
) -> Option<PendingDimension> {
    if activated_widget? != smart_dimension_id {
        return None;
    }
    match smart_dimension::eligibility(sketch?, selection) {
        smart_dimension::Eligibility::Eligible(req) => Some(req),
        smart_dimension::Eligibility::Disabled(_) => None,
    }
}

fn smart_dimension_tool_item(
    id: WidgetId,
    sketch: Option<&Sketch>,
    selection: &[SketchEntityId],
    sketch_disabled: bool,
) -> ToolbarItem {
    let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION);
    if sketch_disabled {
        return item.disabled(true);
    }
    let Some(sketch) = sketch else {
        return item.disabled(true);
    };
    match smart_dimension::eligibility(sketch, selection) {
        smart_dimension::Eligibility::Eligible(_) => item,
        smart_dimension::Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason),
    }
}

fn render_menu_bar(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    ids: &ShellIds,
    state: &mut MenuBarState,
    paints: &mut Vec<WidgetPaint>,
) -> Option<MenuAction> {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let placeholder = |menu_id: WidgetId, key: &'static str| MenuItem::Action {
        id: menu_id.child(WidgetKey::new(key)),
        label: strings::MENU_PLACEHOLDER_COMING_SOON,
        shortcut: None,
        disabled: true,
    };
    let file_items = vec![
        MenuItem::Action {
            id: ids.menu_file.child(WidgetKey::new("new")),
            label: strings::MENU_FILE_NEW,
            shortcut: None,
            disabled: true,
        },
        MenuItem::Action {
            id: ids.menu_file.child(WidgetKey::new("open")),
            label: strings::MENU_FILE_OPEN,
            shortcut: None,
            disabled: true,
        },
        MenuItem::Action {
            id: ids.menu_file.child(WidgetKey::new("save")),
            label: strings::MENU_FILE_SAVE,
            shortcut: None,
            disabled: true,
        },
        MenuItem::Separator,
        MenuItem::Action {
            id: ids.menu_file_quit,
            label: strings::MENU_FILE_QUIT,
            shortcut: Some(strings::SHORTCUT_QUIT),
            disabled: false,
        },
    ];
    let edit_items = vec![
        MenuItem::Action {
            id: ids.menu_edit_undo,
            label: strings::MENU_EDIT_UNDO,
            shortcut: Some(strings::SHORTCUT_UNDO),
            disabled: false,
        },
        MenuItem::Action {
            id: ids.menu_edit_redo,
            label: strings::MENU_EDIT_REDO,
            shortcut: Some(strings::SHORTCUT_REDO),
            disabled: false,
        },
    ];
    let view_items = vec![MenuItem::Action {
        id: ids.menu_view_zoom_fit,
        label: strings::MENU_VIEW_ZOOM_FIT,
        shortcut: Some(strings::SHORTCUT_ZOOM_FIT),
        disabled: false,
    }];
    let entries = vec![
        MenuBarEntry {
            id: ids.menu_file,
            label: strings::MENU_FILE,
            items: file_items,
        },
        MenuBarEntry {
            id: ids.menu_edit,
            label: strings::MENU_EDIT,
            items: edit_items,
        },
        MenuBarEntry {
            id: ids.menu_view,
            label: strings::MENU_VIEW,
            items: view_items,
        },
        MenuBarEntry {
            id: ids.menu_insert,
            label: strings::MENU_INSERT,
            items: vec![placeholder(ids.menu_insert, "soon")],
        },
        MenuBarEntry {
            id: ids.menu_tools,
            label: strings::MENU_TOOLS,
            items: vec![placeholder(ids.menu_tools, "soon")],
        },
        MenuBarEntry {
            id: ids.menu_window,
            label: strings::MENU_WINDOW,
            items: vec![placeholder(ids.menu_window, "soon")],
        },
        MenuBarEntry {
            id: ids.menu_help,
            label: strings::MENU_HELP,
            items: vec![placeholder(ids.menu_help, "soon")],
        },
    ];
    let response = show_menu_bar(
        ctx,
        MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state),
    );
    paints.extend(response.paint);
    response.activated.and_then(|id| ids.menu_action_for(id))
}

#[derive(Copy, Clone)]
struct RibbonInputs<'a> {
    rect: LayoutRect,
    ribbon: WidgetId,
    ribbon_exit: WidgetId,
    ribbon_smart_dimension: WidgetId,
    mode: &'a Mode,
    sketch: Option<&'a Sketch>,
    selection: &'a [SketchEntityId],
}

fn render_ribbon(
    ctx: &mut FrameCtx<'_>,
    inputs: RibbonInputs<'_>,
    state: &mut RibbonState,
    paints: &mut Vec<WidgetPaint>,
) -> Option<WidgetId> {
    let RibbonInputs {
        rect,
        ribbon,
        ribbon_exit,
        ribbon_smart_dimension,
        mode,
        sketch,
        selection,
    } = inputs;
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let active_tool = match mode {
        Mode::Sketch { session, .. } => session.tool,
        Mode::Idle => None,
    };
    let tools_disabled = !mode.is_sketch();
    let label_font_size_px = ctx.theme().typography.caption.size.as_px_f32();
    let size_item = |item: ToolbarItem, min_width: LayoutPx| -> ToolbarItem {
        let resolved = ctx.strings.resolve(item.label);
        let width = estimate_label_width(resolved, label_font_size_px, min_width);
        item.with_width(width)
    };
    let large_min = RibbonIconSize::Large.item_px();
    let small_min = RibbonIconSize::Small.item_px();
    let entity_items: Vec<ToolbarItem> = SketchTool::ENTITIES
        .iter()
        .copied()
        .map(|t| {
            size_item(
                ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t))
                    .active(active_tool == Some(t))
                    .disabled(tools_disabled),
                large_min,
            )
        })
        .collect();
    let dimension_items = vec![size_item(
        smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection, tools_disabled),
        large_min,
    )];
    let relation_items: Vec<ToolbarItem> =
        relation_tool_buttons(ribbon, sketch, selection, tools_disabled)
            .into_iter()
            .map(|item| size_item(item, small_min))
            .collect();
    let exit_items = vec![size_item(
        ToolbarItem::new(ribbon_exit, strings::TOOL_EXIT_SKETCH),
        large_min,
    )];
    let exit_preferred = group_width_for(&exit_items, large_min);
    let tab_id = ribbon.child(WidgetKey::new("tab.sketch"));
    let exit_group = mode.is_sketch().then(|| RibbonGroup {
        id: ribbon.child(WidgetKey::new("group.exit")),
        label: strings::RIBBON_GROUP_EXIT,
        min_width: exit_preferred,
        width: exit_preferred,
        items: exit_items,
        icon_size: RibbonIconSize::Large,
    });
    let dimensions_preferred = group_width_for(&dimension_items, large_min);
    let groups: Vec<RibbonGroup> = [
        RibbonGroup {
            id: ribbon.child(WidgetKey::new("group.entities")),
            label: strings::RIBBON_GROUP_ENTITIES,
            min_width: group_min_width(large_min, entity_items.len()),
            width: group_width_for(&entity_items, large_min),
            items: entity_items,
            icon_size: RibbonIconSize::Large,
        },
        RibbonGroup {
            id: ribbon.child(WidgetKey::new("group.relations")),
            label: strings::RIBBON_GROUP_RELATIONS,
            min_width: group_min_width(small_min, relation_items.len()),
            width: group_width_for(&relation_items, small_min),
            items: relation_items,
            icon_size: RibbonIconSize::Small,
        },
        RibbonGroup {
            id: ribbon.child(WidgetKey::new("group.dimensions")),
            label: strings::RIBBON_GROUP_DIMENSIONS,
            min_width: dimensions_preferred,
            width: dimensions_preferred,
            items: dimension_items,
            icon_size: RibbonIconSize::Large,
        },
    ]
    .into_iter()
    .chain(exit_group)
    .collect();
    let tabs = [RibbonTab::new(tab_id, strings::RIBBON_TAB_SKETCH, groups)];
    let response = show_ribbon(
        ctx,
        Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, tab_id, state),
    );
    paints.extend(response.paint);
    response.activated_tool
}

fn render_feature_tree(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    tree_id: WidgetId,
    part_id: WidgetId,
    state: &mut TreeViewState,
    document: &Document,
    paints: &mut Vec<WidgetPaint>,
) -> Option<WidgetId> {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let leaf = |key: &'static str, label: StringKey| {
        TreeNode::leaf(part_id.child(WidgetKey::new(key)), label)
    };
    let sketches: Vec<TreeNode> = document
        .sketches()
        .enumerate()
        .map(|(idx, _)| {
            TreeNode::leaf(
                part_id.child_indexed(WidgetKey::new("sketch"), idx as u64),
                strings::FEATURE_SKETCH_DEFAULT,
            )
        })
        .collect();
    let children: Vec<TreeNode> = [
        leaf("origin", strings::FEATURE_ORIGIN),
        leaf("plane.xy", strings::FEATURE_PLANE_XY),
        leaf("plane.yz", strings::FEATURE_PLANE_YZ),
        leaf("plane.zx", strings::FEATURE_PLANE_ZX),
    ]
    .into_iter()
    .chain(sketches)
    .collect();
    let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children);
    let roots = [part];
    let response = show_tree_view(
        ctx,
        TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state),
    );
    paints.extend(response.paint);
    response.double_activated
}

#[derive(Copy, Clone)]
struct PropertyState<'a> {
    document: &'a Document,
    mode: &'a Mode,
    selection: &'a [SketchEntityId],
}

fn render_property_pane(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    state: PropertyState<'_>,
    paints: &mut Vec<WidgetPaint>,
) {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return;
    }
    let active_sketch = match state.mode {
        Mode::Sketch { sketch_id, .. } => state.document.sketch(*sketch_id),
        Mode::Idle => None,
    };
    let resolved = match state.selection {
        [id] => active_sketch.and_then(|s| s.entities().get(*id).map(|e| (*e, s))),
        _ => None,
    };
    let mut editors = match resolved {
        Some((entity, sketch)) => entity_editors(ctx.strings, entity, sketch),
        None => vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")],
    };
    let mut rows: Vec<PropertyRow<'_>> = editors
        .iter_mut()
        .map(|(row_id, label, editor)| PropertyRow {
            id: *row_id,
            label: *label,
            editor: editor.as_mut(),
            read_only: true,
        })
        .collect();
    let response = show_property_grid(
        ctx,
        PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows),
        clipboard,
    );
    paints.extend(response.paint);
}

type PropertyRowSpec = (WidgetId, StringKey, Box<dyn PropertyEditor>);

fn row_editor(label: StringKey, value: impl Into<String>) -> PropertyRowSpec {
    let row_id = WidgetId::ROOT
        .child(WidgetKey::new("props.row"))
        .child(WidgetKey::new(label.id()));
    let editor: Box<dyn PropertyEditor> = Box::new(StaticTextEditor::new(value.into()));
    (row_id, label, editor)
}

fn entity_editors(
    strings_table: &StringTable,
    entity: SketchEntity,
    sketch: &Sketch,
) -> Vec<PropertyRowSpec> {
    let yes_no = |b: bool| {
        if b {
            strings_table
                .resolve(strings::PROPERTY_VALUE_YES)
                .to_owned()
        } else {
            strings_table.resolve(strings::PROPERTY_VALUE_NO).to_owned()
        }
    };
    match entity {
        SketchEntity::Point(p) => {
            let (x, y) = p.at().coords_mm();
            vec![
                row_editor(
                    strings::PROPERTY_ROW_KIND,
                    strings_table
                        .resolve(strings::PROPERTY_KIND_POINT)
                        .to_owned(),
                ),
                row_editor(strings::PROPERTY_ROW_X, format_mm(x)),
                row_editor(strings::PROPERTY_ROW_Y, format_mm(y)),
            ]
        }
        SketchEntity::Line(l) => {
            let from = endpoint_label(strings_table, sketch, l.a());
            let to = endpoint_label(strings_table, sketch, l.b());
            vec![
                row_editor(
                    strings::PROPERTY_ROW_KIND,
                    strings_table
                        .resolve(strings::PROPERTY_KIND_LINE)
                        .to_owned(),
                ),
                row_editor(strings::PROPERTY_ROW_FROM, from),
                row_editor(strings::PROPERTY_ROW_TO, to),
                row_editor(
                    strings::PROPERTY_ROW_CONSTRUCTION,
                    yes_no(l.for_construction()),
                ),
            ]
        }
        SketchEntity::Arc(a) => {
            let center = endpoint_label(strings_table, sketch, a.center());
            let start = endpoint_label(strings_table, sketch, a.start());
            let end = endpoint_label(strings_table, sketch, a.end());
            vec![
                row_editor(
                    strings::PROPERTY_ROW_KIND,
                    strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(),
                ),
                row_editor(strings::PROPERTY_ROW_CENTER, center),
                row_editor(strings::PROPERTY_ROW_START, start),
                row_editor(strings::PROPERTY_ROW_END, end),
                row_editor(
                    strings::PROPERTY_ROW_CONSTRUCTION,
                    yes_no(a.for_construction()),
                ),
            ]
        }
        SketchEntity::Circle(c) => {
            let center = endpoint_label(strings_table, sketch, c.center());
            vec![
                row_editor(
                    strings::PROPERTY_ROW_KIND,
                    strings_table
                        .resolve(strings::PROPERTY_KIND_CIRCLE)
                        .to_owned(),
                ),
                row_editor(strings::PROPERTY_ROW_CENTER, center),
                row_editor(strings::PROPERTY_ROW_RADIUS, format_length(c.radius())),
                row_editor(
                    strings::PROPERTY_ROW_CONSTRUCTION,
                    yes_no(c.for_construction()),
                ),
            ]
        }
    }
    .into_iter()
    .enumerate()
    .map(|(idx, (_default_id, label, editor))| {
        let row_id = WidgetId::ROOT
            .child(WidgetKey::new("props.entity"))
            .child_indexed(WidgetKey::new("row"), idx as u64);
        (row_id, label, editor)
    })
    .collect()
}

fn endpoint_label(strings_table: &StringTable, sketch: &Sketch, id: SketchEntityId) -> String {
    match sketch.entities().get(id) {
        Some(SketchEntity::Point(p)) => {
            let (x, y) = p.at().coords_mm();
            format!("({}, {})", format_mm(x), format_mm(y))
        }
        Some(_) => strings_table
            .resolve(strings::PROPERTY_PANE_NO_SELECTION)
            .to_owned(),
        None => "?".to_owned(),
    }
}

fn format_mm(value: f64) -> String {
    format!("{value:.3} mm")
}

fn format_length(length: Length) -> String {
    format_mm(length.get::<millimeter>())
}

struct StaticTextEditor {
    value: String,
}

impl StaticTextEditor {
    fn new(value: String) -> Self {
        Self { value }
    }
}

impl PropertyEditor for StaticTextEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        _clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        paint.push(WidgetPaint::Label {
            rect: cell.rect,
            text: LabelText::Owned(self.value.clone()),
            color: ctx.theme().colors.text_primary(),
            role: ctx.theme().typography.label,
        });
        false
    }
}

fn render_status_bar(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    mode: &Mode,
    paints: &mut Vec<WidgetPaint>,
) {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return;
    }
    let label = match mode {
        Mode::Idle => strings::STATUS_READY,
        Mode::Sketch { .. } => strings::STATUS_SKETCH_ACTIVE,
    };
    let item = StatusItem::new(
        id.child(WidgetKey::new("mode")),
        label,
        StatusAlign::Start,
        STATUS_MODE_WIDTH,
    );
    let items = [item];
    let response = show_status_bar(
        ctx,
        StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items),
    );
    paints.extend(response.paint);
}

fn estimate_label_width(text: &str, font_size_px: f32, min_width: LayoutPx) -> LayoutPx {
    #[allow(
        clippy::cast_precision_loss,
        reason = "string lengths fit in f32 mantissa for any realistic label"
    )]
    let chars = text.chars().count() as f32;
    let est = chars * font_size_px * RIBBON_LABEL_AVG_ADVANCE_RATIO
        + 2.0 * RIBBON_LABEL_HORIZONTAL_PADDING_PX;
    LayoutPx::new(est.max(min_width.value()))
}

fn group_width_for(items: &[ToolbarItem], fallback_item_size: LayoutPx) -> LayoutPx {
    let total: f32 = items
        .iter()
        .enumerate()
        .map(|(i, it)| {
            it.width.unwrap_or(fallback_item_size).value()
                + if i == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX }
        })
        .sum();
    LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX)
}

fn group_min_width(item_size: LayoutPx, item_count: usize) -> LayoutPx {
    let min_items_extent = match item_count {
        0 => 0.0,
        1 => item_size.value(),
        _ => 2.0 * item_size.value() + RIBBON_TOOLBAR_GAP_PX,
    };
    LayoutPx::new(min_items_extent + 2.0 * RIBBON_GROUP_PADDING_PX)
}

fn relation_tool_buttons(
    ribbon: WidgetId,
    sketch: Option<&Sketch>,
    selection: &[SketchEntityId],
    sketch_disabled: bool,
) -> Vec<ToolbarItem> {
    RelationKind::ALL
        .iter()
        .copied()
        .map(|kind| relation_tool_item(ribbon, kind, sketch, selection, sketch_disabled))
        .collect()
}

fn relation_tool_item(
    ribbon: WidgetId,
    kind: RelationKind,
    sketch: Option<&Sketch>,
    selection: &[SketchEntityId],
    sketch_disabled: bool,
) -> ToolbarItem {
    let item = ToolbarItem::new(relation_widget_id(ribbon, kind), kind.label());
    if sketch_disabled {
        return item.disabled(true);
    }
    let Some(sketch) = sketch else {
        return item.disabled(true);
    };
    match eligibility(kind, sketch, selection) {
        Eligibility::Eligible(_) => item,
        Eligibility::Disabled(reason) => item.disabled(true).with_tooltip(reason),
    }
}

fn build_relation_index(ribbon: WidgetId) -> BTreeMap<WidgetId, RelationKind> {
    RelationKind::ALL
        .iter()
        .copied()
        .map(|k| (relation_widget_id(ribbon, k), k))
        .collect()
}

fn relation_widget_id(ribbon: WidgetId, kind: RelationKind) -> WidgetId {
    ribbon.child(WidgetKey::new(kind.key()))
}

fn build_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, SketchTool> {
    SketchTool::ENTITIES
        .iter()
        .copied()
        .map(|t| (tool_widget_id(ribbon, t), t))
        .collect()
}

fn tool_widget_id(ribbon: WidgetId, tool: SketchTool) -> WidgetId {
    ribbon.child(WidgetKey::new(tool_key(tool)))
}

fn tool_key(tool: SketchTool) -> &'static str {
    match tool {
        SketchTool::Point => "tool.point",
        SketchTool::Line => "tool.line",
        SketchTool::CenterpointArc => "tool.centerpoint_arc",
        SketchTool::TangentArc => "tool.tangent_arc",
        SketchTool::ThreePointArc => "tool.three_point_arc",
        SketchTool::Circle => "tool.circle",
        SketchTool::PerimeterCircle => "tool.perimeter_circle",
        SketchTool::CornerRectangle => "tool.corner_rectangle",
        SketchTool::CenterRectangle => "tool.center_rectangle",
        SketchTool::ThreePointCornerRectangle => "tool.three_point_corner_rectangle",
        SketchTool::ThreePointCenterRectangle => "tool.three_point_center_rectangle",
        SketchTool::Parallelogram => "tool.parallelogram",
    }
}

fn tool_label(tool: SketchTool) -> StringKey {
    match tool {
        SketchTool::Point => strings::TOOL_POINT,
        SketchTool::Line => strings::TOOL_LINE,
        SketchTool::CenterpointArc => strings::TOOL_CENTERPOINT_ARC,
        SketchTool::TangentArc => strings::TOOL_TANGENT_ARC,
        SketchTool::ThreePointArc => strings::TOOL_THREE_POINT_ARC,
        SketchTool::Circle => strings::TOOL_CIRCLE,
        SketchTool::PerimeterCircle => strings::TOOL_PERIMETER_CIRCLE,
        SketchTool::CornerRectangle => strings::TOOL_CORNER_RECTANGLE,
        SketchTool::CenterRectangle => strings::TOOL_CENTER_RECTANGLE,
        SketchTool::ThreePointCornerRectangle => strings::TOOL_THREE_POINT_CORNER_RECTANGLE,
        SketchTool::ThreePointCenterRectangle => strings::TOOL_THREE_POINT_CENTER_RECTANGLE,
        SketchTool::Parallelogram => strings::TOOL_PARALLELOGRAM,
    }
}

fn paint_walk(
    layout: &SolvedLayout,
    node: &SolvedNode,
    theme: &Theme,
    viewport: PanelId,
) -> Vec<WidgetPaint> {
    let walk_children = || {
        node.children
            .iter()
            .flat_map(|c| paint_walk(layout, layout.node(*c), theme, viewport))
    };
    match &node.kind {
        NodeKind::DockHost { .. }
        | NodeKind::Pass
        | NodeKind::Leaf(_)
        | NodeKind::ScrollRegion { .. } => walk_children().collect(),
        NodeKind::DockSplit { axis, .. } | NodeKind::Splitter { axis, .. } => walk_children()
            .chain(divider_paint(layout, node, *axis, theme))
            .collect(),
        NodeKind::DockTabStrip { .. } => {
            core::iter::once(surface_for(node.rect, theme.elevation.level2, theme))
                .chain(walk_children())
                .collect()
        }
        NodeKind::DockPanel { id } if *id == viewport => Vec::new(),
        NodeKind::DockPanel { .. } => {
            core::iter::once(surface_for(node.rect, theme.elevation.level1, theme))
                .chain(walk_children())
                .collect()
        }
    }
}

fn divider_paint(
    layout: &SolvedLayout,
    node: &SolvedNode,
    axis: Axis,
    theme: &Theme,
) -> Option<WidgetPaint> {
    let [first_idx, _] = match node.children.as_slice() {
        [a, b] => [*a, *b],
        _ => return None,
    };
    let first = layout.node(first_idx);
    let rect = divider_between(axis, first.rect, node.rect);
    let color = theme.colors.neutral.step(Step12::BORDER);
    Some(WidgetPaint::Surface {
        rect,
        fill: color,
        border: None,
        radius: Radius::px(0.0),
        elevation: None,
    })
}

fn divider_between(axis: Axis, first: LayoutRect, parent: LayoutRect) -> LayoutRect {
    let thickness = LayoutPx::new(StrokeWidth::HAIRLINE.value_px());
    match axis {
        Axis::Horizontal => LayoutRect::new(
            LayoutPos::new(first.max_x(), parent.min_y()),
            LayoutSize::new(thickness, parent.size.height),
        ),
        Axis::Vertical => LayoutRect::new(
            LayoutPos::new(parent.min_x(), first.max_y()),
            LayoutSize::new(parent.size.width, thickness),
        ),
    }
}

fn surface_for(rect: LayoutRect, elevation: ElevationLevel, theme: &Theme) -> WidgetPaint {
    WidgetPaint::Surface {
        rect,
        fill: theme.colors.surface(elevation.surface),
        border: elevation.border,
        radius: Radius::px(0.0),
        elevation: Some(elevation),
    }
}

fn panel_rect(solved: &SolvedLayout, id: PanelId) -> Option<LayoutRect> {
    solved
        .nodes
        .iter()
        .find(|n| matches!(n.kind, NodeKind::DockPanel { id: pid } if pid == id))
        .map(|n| n.rect)
}

fn inset_rect(rect: LayoutRect, by: f32) -> LayoutRect {
    let w = (rect.size.width.value() - 2.0 * by).max(0.0);
    let h = (rect.size.height.value() - 2.0 * by).max(0.0);
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::saturating(rect.origin.x.value() + by),
            LayoutPx::saturating(rect.origin.y.value() + by),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(w),
            LayoutPx::saturating_nonneg(h),
        ),
    )
}

fn zero_rect() -> LayoutRect {
    LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO)
}

const fn panel(value: u32) -> PanelId {
    let Some(nz) = NonZeroU32::new(value) else {
        panic!("PanelId value must be non-zero");
    };
    PanelId::new(nz)
}

#[cfg(test)]
mod tests {
    use super::*;
    use bone_document::Document;
    use bone_types::{DocumentId, SketchId};
    use bone_ui::a11y::AccessTreeBuilder;
    use bone_ui::focus::FocusManager;
    use bone_ui::hit_test::{HitFrame, HitState};
    use bone_ui::hotkey::HotkeyTable;
    use bone_ui::input::{FrameInstant, InputSnapshot};
    use bone_ui::strings::StringTable;
    use bone_ui::theme::Theme;
    use bone_ui::widgets::LabelText;
    use std::sync::Arc;

    fn layout_size(w: f32, h: f32) -> LayoutSize {
        LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h))
    }

    fn sample_document() -> Document {
        Document::new(DocumentId::default(), "Sample".to_owned())
    }

    fn render_with(theme: Theme, size: LayoutSize, document: &Document, mode: &Mode) -> ShellFrame {
        let Ok(mut shell) = Shell::new() else {
            panic!("shell init");
        };
        let theme = Arc::new(theme);
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        shell.render(&mut ctx, document, mode, &[], size)
    }

    #[test]
    fn shell_renders_with_non_empty_paint_list() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        assert!(!frame.paints.is_empty());
    }

    #[test]
    fn shell_carves_out_viewport_region() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        let v = frame.viewport_rect;
        assert!(v.size.width.value() > 0.0);
        assert!(v.size.height.value() > 0.0);
        assert!(v.min_x().value() > 0.0, "feature tree carved on left");
        assert!(v.min_y().value() > 0.0, "ribbon carved on top");
        assert!(v.max_x().value() < 1280.0, "property pane carved on right");
        assert!(v.max_y().value() < 800.0, "status bar carved on bottom");
    }

    #[test]
    fn shell_does_not_paint_viewport_panel_body() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        let viewport_rect = frame.viewport_rect;
        let center = LayoutPos::new(
            LayoutPx::new(viewport_rect.min_x().value() + viewport_rect.size.width.value() * 0.5),
            LayoutPx::new(viewport_rect.min_y().value() + viewport_rect.size.height.value() * 0.5),
        );
        let any_paint_covers_center = frame.paints.iter().any(|p| match p {
            WidgetPaint::Surface { rect, .. } => rect.contains(center),
            _ => false,
        });
        assert!(!any_paint_covers_center);
    }

    #[test]
    fn shell_seeds_part_node_expanded() {
        let Ok(shell) = Shell::new() else {
            panic!("shell init");
        };
        assert!(
            shell
                .state
                .feature_tree
                .expanded
                .contains(&shell.ids.feature_part)
        );
    }

    #[test]
    fn tool_index_round_trips_every_entity_tool() {
        let ids = ShellIds::standard();
        let index = build_tool_index(ids.ribbon);
        SketchTool::ENTITIES.iter().copied().for_each(|t| {
            let id = tool_widget_id(ids.ribbon, t);
            assert_eq!(index.get(&id).copied(), Some(t));
        });
    }

    #[test]
    fn tool_index_omits_smart_dimension() {
        let ids = ShellIds::standard();
        let index = build_tool_index(ids.ribbon);
        assert!(!index.contains_key(&ids.ribbon_smart_dimension));
    }

    #[test]
    fn plane_for_recognizes_each_principal_plane() {
        let ids = ShellIds::standard();
        assert_eq!(ids.plane_for(ids.plane_xy), Some(Plane::Xy));
        assert_eq!(ids.plane_for(ids.plane_yz), Some(Plane::Yz));
        assert_eq!(ids.plane_for(ids.plane_zx), Some(Plane::Zx));
        assert_eq!(ids.plane_for(ids.feature_tree), None);
        assert_eq!(ids.plane_for(ids.ribbon_exit), None);
    }

    #[test]
    fn ribbon_exit_id_is_distinct_from_tool_ids() {
        let ids = ShellIds::standard();
        let tools = build_tool_index(ids.ribbon);
        assert!(!tools.contains_key(&ids.ribbon_exit));
    }

    #[test]
    fn idle_render_emits_no_state_machine_signals() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        assert_eq!(frame.plane_picked, None);
        assert!(!frame.exit_sketch);
        assert!(frame.activated_tool.is_none());
    }

    #[test]
    fn idle_render_omits_exit_group_label() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        assert!(!frame.paints.iter().any(is_exit_group_label));
    }

    #[test]
    fn sketch_render_includes_exit_group_label() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
        );
        assert!(frame.paints.iter().any(is_exit_group_label));
    }

    fn is_exit_group_label(paint: &WidgetPaint) -> bool {
        matches!(
            paint,
            WidgetPaint::Label { text: LabelText::Key(key), .. }
                if *key == crate::strings::RIBBON_GROUP_EXIT
        )
    }

    #[test]
    fn relation_index_covers_every_kind() {
        let ids = ShellIds::standard();
        let index = build_relation_index(ids.ribbon);
        RelationKind::ALL.iter().copied().for_each(|kind| {
            let id = relation_widget_id(ids.ribbon, kind);
            assert_eq!(index.get(&id).copied(), Some(kind));
        });
    }

    #[test]
    fn relation_tool_item_disabled_without_sketch() {
        let ids = ShellIds::standard();
        let item = relation_tool_item(ids.ribbon, RelationKind::Horizontal, None, &[], false);
        assert!(item.disabled);
        assert!(item.tooltip.is_none(), "no sketch, no per-relation reason");
    }

    #[test]
    fn relation_tool_item_disabled_when_sketch_disabled_flag_set() {
        let ids = ShellIds::standard();
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let item = relation_tool_item(
            ids.ribbon,
            RelationKind::Horizontal,
            Some(&sketch),
            &[],
            true,
        );
        assert!(item.disabled);
    }

    #[test]
    fn relation_tool_item_carries_reason_tooltip_when_eligibility_fails() {
        let ids = ShellIds::standard();
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let item = relation_tool_item(
            ids.ribbon,
            RelationKind::Horizontal,
            Some(&sketch),
            &[],
            false,
        );
        assert!(item.disabled);
        assert_eq!(item.tooltip, Some(strings::REL_HINT_ONE_LINE));
    }

    #[test]
    fn relation_tool_item_enabled_when_eligibility_passes() {
        let ids = ShellIds::standard();
        let (sketch, line) = sample_sketch_with_line();
        let item = relation_tool_item(
            ids.ribbon,
            RelationKind::Horizontal,
            Some(&sketch),
            &[line],
            false,
        );
        assert!(!item.disabled);
        assert!(item.tooltip.is_none());
    }

    #[test]
    fn resolve_activated_relation_returns_relation_for_eligible_selection() {
        let ids = ShellIds::standard();
        let index = build_relation_index(ids.ribbon);
        let (sketch, line) = sample_sketch_with_line();
        let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal);
        let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[line]);
        assert_eq!(resolved, Some(SketchRelation::Horizontal(line)));
    }

    #[test]
    fn resolve_activated_relation_drops_when_selection_invalid() {
        let ids = ShellIds::standard();
        let index = build_relation_index(ids.ribbon);
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let id = relation_widget_id(ids.ribbon, RelationKind::Horizontal);
        let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[]);
        assert_eq!(resolved, None);
    }

    #[test]
    fn resolve_activated_relation_returns_relation_for_multi_selection() {
        let ids = ShellIds::standard();
        let index = build_relation_index(ids.ribbon);
        let (sketch, l1, l2) = sample_sketch_with_two_lines();
        let id = relation_widget_id(ids.ribbon, RelationKind::Parallel);
        let resolved = resolve_activated_relation(Some(id), &index, Some(&sketch), &[l1, l2]);
        assert_eq!(resolved, Some(SketchRelation::Parallel(l1, l2)));
    }

    #[test]
    fn feature_tree_panel_rect_is_independent_of_pending_dim() {
        let document = sample_document();
        let idle = render_with(
            Theme::light(),
            layout_size(1600.0, 900.0),
            &document,
            &Mode::Idle,
        );
        let in_sketch = render_with(
            Theme::light(),
            layout_size(1600.0, 900.0),
            &document,
            &Mode::enter_sketch(SketchId::default()),
        );
        let tree_rect_idle = panel_surface(&idle.paints, |x| x < 300.0);
        let tree_rect_sketch = panel_surface(&in_sketch.paints, |x| x < 300.0);
        assert_eq!(
            tree_rect_idle, tree_rect_sketch,
            "feature tree panel must not change between idle and sketch mode",
        );
    }

    fn panel_surface(paints: &[WidgetPaint], filter: impl Fn(f32) -> bool) -> Option<LayoutRect> {
        paints.iter().find_map(|p| match p {
            WidgetPaint::Surface { rect, .. } if filter(rect.min_x().value()) => Some(*rect),
            _ => None,
        })
    }

    #[test]
    fn smart_dimension_paints_at_typical_window_with_real_strings() {
        use crate::strings as app_strings;
        use bone_ui::strings::Locale;
        let table = app_strings::make_strings(Locale::EnUs);
        let Ok(mut shell) = Shell::new() else {
            panic!("shell init");
        };
        let theme = Arc::new(Theme::light());
        let hk = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &hk,
            &table,
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        let frame = shell.render(
            &mut ctx,
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
            &[],
            layout_size(1600.0, 900.0),
        );
        let any_smart_dim_label = frame.paints.iter().any(|p| {
            matches!(
                p,
                WidgetPaint::Label { text: LabelText::Key(key), .. }
                    if *key == strings::TOOL_SMART_DIMENSION
            )
        });
        assert!(any_smart_dim_label);
    }

    #[test]
    fn smart_dimension_button_paints_even_in_narrow_ribbon() {
        let frame = render_with(
            Theme::light(),
            layout_size(800.0, 600.0),
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
        );
        let any_smart_dim_label = frame.paints.iter().any(|p| {
            matches!(
                p,
                WidgetPaint::Label { text: LabelText::Key(key), .. }
                    if *key == strings::TOOL_SMART_DIMENSION
            )
        });
        assert!(
            any_smart_dim_label,
            "Smart Dimension button must remain reachable on a narrow ribbon",
        );
    }

    #[test]
    fn smart_dimension_item_disabled_without_sketch() {
        let ids = ShellIds::standard();
        let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[], false);
        assert!(item.disabled);
    }

    #[test]
    fn smart_dimension_item_disabled_when_sketch_disabled_flag_set() {
        let ids = ShellIds::standard();
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], true);
        assert!(item.disabled);
    }

    #[test]
    fn smart_dimension_item_carries_reason_tooltip_when_no_selection() {
        let ids = ShellIds::standard();
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let item = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[], false);
        assert!(item.disabled);
        assert_eq!(item.tooltip, Some(strings::DIM_HINT_GENERIC));
    }

    #[test]
    fn smart_dimension_item_enabled_for_eligible_line() {
        let ids = ShellIds::standard();
        let (sketch, line) = sample_sketch_with_line();
        let item =
            smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line], false);
        assert!(!item.disabled);
        assert!(item.tooltip.is_none());
    }

    #[test]
    fn resolve_activated_dimension_returns_request_for_eligible_selection() {
        let ids = ShellIds::standard();
        let (sketch, line) = sample_sketch_with_line();
        let id = ids.ribbon_smart_dimension;
        let resolved = resolve_activated_dimension(Some(id), id, Some(&sketch), &[line]);
        let Some(req) = resolved else {
            panic!("expected eligible request");
        };
        assert!(matches!(
            req.proto,
            bone_document::SketchDimension::Linear { .. }
        ));
    }

    #[test]
    fn resolve_activated_dimension_drops_when_widget_id_mismatches() {
        let ids = ShellIds::standard();
        let (sketch, line) = sample_sketch_with_line();
        let other = relation_widget_id(ids.ribbon, RelationKind::Horizontal);
        let resolved = resolve_activated_dimension(
            Some(other),
            ids.ribbon_smart_dimension,
            Some(&sketch),
            &[line],
        );
        assert_eq!(resolved, None);
    }

    #[test]
    fn resolve_activated_dimension_drops_when_selection_is_invalid() {
        let ids = ShellIds::standard();
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let resolved = resolve_activated_dimension(
            Some(ids.ribbon_smart_dimension),
            ids.ribbon_smart_dimension,
            Some(&sketch),
            &[],
        );
        assert_eq!(resolved, None);
    }

    #[test]
    fn partition_overlay_extracts_tooltips_into_overlay_layer() {
        let theme = Theme::light();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(20.0)),
            LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(18.0)),
        );
        let inputs = vec![
            WidgetPaint::Surface {
                rect,
                fill: theme.colors.surface(theme.elevation.level1.surface),
                border: None,
                radius: theme.radius.none,
                elevation: None,
            },
            WidgetPaint::Tooltip {
                rect,
                text: LabelText::Owned("hint".to_owned()),
                anchor: WidgetId::ROOT,
                elevation: theme.elevation.level2,
            },
        ];
        let (main, overlay) = partition_overlay(inputs, &theme);
        assert_eq!(main.len(), 1, "non-tooltip stays in main");
        assert!(matches!(main[0], WidgetPaint::Surface { .. }));
        assert_eq!(overlay.len(), 2, "tooltip expands to surface + label");
        assert!(matches!(overlay[0], WidgetPaint::Surface { .. }));
        assert!(matches!(overlay[1], WidgetPaint::Label { .. }));
    }

    fn sample_sketch_with_two_lines() -> (bone_document::Sketch, SketchEntityId, SketchEntityId) {
        use bone_types::Point2;
        let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (s, p0) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0));
        let (s, p1) = crate::tools::add_point(s, Point2::from_mm(1.0, 0.0));
        let (s, p2) = crate::tools::add_point(s, Point2::from_mm(0.0, 1.0));
        let (s, p3) = crate::tools::add_point(s, Point2::from_mm(1.0, 1.0));
        let (s, l1) = crate::tools::add_line(s, p0, p1, false);
        let (s, l2) = crate::tools::add_line(s, p2, p3, false);
        (s, l1, l2)
    }

    fn sample_sketch_with_line() -> (bone_document::Sketch, SketchEntityId) {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (sketch, a) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(0.0, 0.0));
        let (sketch, b) = crate::tools::add_point(sketch, bone_types::Point2::from_mm(5.0, 0.0));
        let (sketch, line) = crate::tools::add_line(sketch, a, b, false);
        (sketch, line)
    }
}
