use core::num::NonZeroU32;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;

use bone_document::{
    DimensionKind, DimensionValue, Document, ExtrudeEndCondition, ExtrudeFeature, FeatureEdge,
    FeatureNode, MergeResult, Sketch, SketchDimension, SketchEntity, SketchRelation,
    SketchStatusReport, SketchVersion,
};
use bone_types::{
    Angle, Camera3, ExtrudeId, FeatureId, IconId, Length, Point2, PositiveLength, RollbackMarker,
    SketchDimensionId, SketchEntityId, SketchId,
};
use bone_ui::a11y::{AccessNode, Role};
use bone_ui::frame::{FrameCtx, InteractDeclaration};
use bone_ui::hit_test::Sense;
use bone_ui::input::PointerButton;
use bone_ui::layout::{
    Axis, DockNode, DockPanel, DockState, GridChild, GridLine, GridSpan, GridTrack, Layout,
    LayoutPos, LayoutPx, LayoutRect, LayoutSize, NodeKind, PanelId, RetainedLayout, SolvedLayout,
    SolvedNode, SplitFraction, TrackSize, measure,
};
use bone_ui::strings::{StringKey, StringTable};
use bone_ui::theme::{Border, ElevationLevel, Radius, Spacing, Step12, StrokeWidth, Theme};
use bone_ui::widgets::IconTint;
use bone_ui::widgets::{
    AngleEditor, BoolEditor, Checkbox, CheckboxState, Clipboard, ContextMenu, ContextMenuRequest,
    Dialog, DialogButton, DropPlacement, DropTarget, HorizontalAlign, HotkeyCapture,
    HotkeyCaptureState, LabelText, LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry,
    MenuBarState, MenuItem, MenuState, Panel, PanelState, PanelTitlebar, PanelVariant,
    PropertyCell, PropertyEditor, PropertyGrid, PropertyOption, PropertyPaneAction,
    PropertyPaneHeader, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize, RibbonTab,
    RollbackBar, RollbackTarget, SelectionEditor, Slider, SliderRange, SliderStep, StatusAlign,
    StatusBar, StatusItem, Tab, Tabs, TabsOrientation, ToolbarItem, TreeBadge, TreeNode, TreeView,
    TreeViewState, WidgetPaint, show_checkbox, show_context_menu, show_dialog, show_hotkey_capture,
    show_menu_bar, show_panel, show_property_grid, show_property_pane_header, show_ribbon,
    show_slider, show_status_bar, show_tabs, show_tree_view,
};
use bone_ui::{WidgetId, WidgetKey};
use uom::si::angle::degree;
use uom::si::length::millimeter;

use bone_render::PickAperture;

use crate::relation_tools::{Eligibility, RelationKind, eligibility};
use crate::selection::Selection;
use crate::settings::Settings;
use crate::sketch_mode::PendingDimension;
use crate::sketch_mode::{
    EndConditionKind, ExtrudeArming, FeatureTool, Mode, Plane, SketchTool, default_extrude_depth,
    end_condition_depth,
};
use crate::smart_dimension;
use crate::status_badge::{
    ExtrudeStatus, extrude_badge_style, extrude_badge_widget_id, extrude_panel_widget_id,
    render_extrude_panel, render_status_panel, status_badge_widget_id, status_color,
    status_label_key, status_panel_widget_id,
};
use crate::strings;
use crate::view_cube::{
    ViewCubeInputs, ViewCubeMenuAction, ViewPick, ViewUi, render_view_cube, render_view_selector,
};

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);
const STATUS_UNITS_WIDTH: LayoutPx = LayoutPx::new(80.0);
const STATUS_COORDS_WIDTH: LayoutPx = LayoutPx::new(180.0);
const STATUS_STATUS_WIDTH: LayoutPx = LayoutPx::new(180.0);

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ShellPanels {
    left_pane: PanelId,
    viewport: PanelId,
}

impl ShellPanels {
    fn standard() -> Self {
        Self {
            left_pane: panel(3),
            viewport: panel(5),
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ShellIds {
    dock_host: WidgetId,
    ribbon: WidgetId,
    ribbon_smart_dimension: WidgetId,
    left_pane: WidgetId,
    left_pane_tab_tree: WidgetId,
    left_pane_tab_properties: WidgetId,
    left_pane_tab_configuration: WidgetId,
    left_pane_tab_dimension_expert: WidgetId,
    left_pane_tab_display: WidgetId,
    feature_tree: WidgetId,
    property_pane: WidgetId,
    viewport: WidgetId,
    confirm_accept: WidgetId,
    confirm_cancel: WidgetId,
    view_cube: WidgetId,
    view_cube_menu: WidgetId,
    view_selector: WidgetId,
    heads_up: WidgetId,
    status_bar: WidgetId,
    doc_tabs: WidgetId,
    doc_tab_model: 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_sketch: WidgetId,
    menu_window: WidgetId,
    menu_help: WidgetId,
    menu_file_new: WidgetId,
    menu_file_open: WidgetId,
    menu_file_save: WidgetId,
    menu_file_save_as: WidgetId,
    menu_file_import: WidgetId,
    menu_file_export: WidgetId,
    menu_file_export_step: WidgetId,
    menu_file_quit: WidgetId,
    menu_edit_undo: WidgetId,
    menu_edit_redo: WidgetId,
    menu_view_zoom_fit: WidgetId,
    menu_tools_options: WidgetId,
    menu_tools_keyboard: WidgetId,
    menu_sketch_exit: WidgetId,
    settings_dialog: WidgetId,
    settings_aperture_slider: WidgetId,
    settings_reduce_motion: WidgetId,
    settings_reset: WidgetId,
    settings_close: WidgetId,
    keyboard_dialog: WidgetId,
    keyboard_dialog_reset: WidgetId,
    keyboard_dialog_close: WidgetId,
}

impl ShellIds {
    fn standard() -> Self {
        let root = WidgetId::ROOT.child(WidgetKey::new("shell"));
        let left_pane = root.child(WidgetKey::new("left"));
        let feature_tree = left_pane.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"));
        let menu_tools = menu_bar.child(WidgetKey::new("tools"));
        let menu_sketch = menu_bar.child(WidgetKey::new("sketch"));
        let settings_dialog = root.child(WidgetKey::new("settings.dialog"));
        let keyboard_dialog = root.child(WidgetKey::new("keyboard.dialog"));
        let viewport = root.child(WidgetKey::new("viewport"));
        Self {
            dock_host: root.child(WidgetKey::new("dock")),
            ribbon,
            ribbon_smart_dimension: ribbon.child(WidgetKey::new("tool.smart_dimension")),
            left_pane,
            left_pane_tab_tree: left_pane.child(WidgetKey::new("tab.tree")),
            left_pane_tab_properties: left_pane.child(WidgetKey::new("tab.props")),
            left_pane_tab_configuration: left_pane.child(WidgetKey::new("tab.config")),
            left_pane_tab_dimension_expert: left_pane.child(WidgetKey::new("tab.dimxpert")),
            left_pane_tab_display: left_pane.child(WidgetKey::new("tab.display")),
            feature_tree,
            property_pane: left_pane.child(WidgetKey::new("props")),
            viewport,
            confirm_accept: viewport.child(WidgetKey::new("confirm.accept")),
            confirm_cancel: viewport.child(WidgetKey::new("confirm.cancel")),
            view_cube: viewport.child(WidgetKey::new("view_cube")),
            view_cube_menu: viewport.child(WidgetKey::new("view_cube.menu")),
            view_selector: viewport.child(WidgetKey::new("view_selector")),
            heads_up: viewport.child(WidgetKey::new("heads_up")),
            status_bar: root.child(WidgetKey::new("status")),
            doc_tabs: root.child(WidgetKey::new("doc_tabs")),
            doc_tab_model: root.child(WidgetKey::new("doc_tabs.model")),
            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_sketch,
            menu_window: menu_bar.child(WidgetKey::new("window")),
            menu_help: menu_bar.child(WidgetKey::new("help")),
            menu_file_new: menu_file.child(WidgetKey::new("new")),
            menu_file_open: menu_file.child(WidgetKey::new("open")),
            menu_file_save: menu_file.child(WidgetKey::new("save")),
            menu_file_save_as: menu_file.child(WidgetKey::new("save_as")),
            menu_file_import: menu_file.child(WidgetKey::new("import")),
            menu_file_export: menu_file.child(WidgetKey::new("export")),
            menu_file_export_step: menu_file
                .child(WidgetKey::new("export"))
                .child(WidgetKey::new("step")),
            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")),
            menu_tools_options: menu_tools.child(WidgetKey::new("options")),
            menu_tools_keyboard: menu_tools.child(WidgetKey::new("keyboard")),
            menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")),
            settings_dialog,
            settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")),
            settings_reduce_motion: settings_dialog.child(WidgetKey::new("reduce_motion.checkbox")),
            settings_reset: settings_dialog.child(WidgetKey::new("button.reset")),
            settings_close: settings_dialog.child(WidgetKey::new("button.close")),
            keyboard_dialog,
            keyboard_dialog_reset: keyboard_dialog.child(WidgetKey::new("button.reset")),
            keyboard_dialog_close: keyboard_dialog.child(WidgetKey::new("button.close")),
        }
    }

    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_new, MenuAction::NewDocument),
            (self.menu_file_open, MenuAction::OpenDocument),
            (self.menu_file_save, MenuAction::SaveDocument),
            (self.menu_file_save_as, MenuAction::SaveDocumentAs),
            (self.menu_file_import, MenuAction::ImportStep),
            (self.menu_file_export_step, MenuAction::ExportStep),
            (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),
            (self.menu_tools_options, MenuAction::OpenSettings),
            (self.menu_tools_keyboard, MenuAction::OpenKeyboardCustomize),
            (self.menu_sketch_exit, MenuAction::ExitSketch),
        ]
        .iter()
        .copied()
        .find_map(|(menu_id, action)| (menu_id == id).then_some(action))
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum MenuAction {
    NewDocument,
    OpenDocument,
    SaveDocument,
    SaveDocumentAs,
    ImportStep,
    ExportStep,
    Quit,
    Undo,
    Redo,
    ZoomFit,
    OpenSettings,
    OpenKeyboardCustomize,
    ExitSketch,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FeatureTarget {
    Sketch(SketchId),
    Extrude(ExtrudeId),
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FeatureCommand {
    Suppress(FeatureId),
    Unsuppress(FeatureId),
    RollbackToHere(FeatureId),
    Delete(FeatureTarget),
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct FeatureReorder {
    pub moved: FeatureId,
    pub anchor: FeatureId,
    pub before: bool,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RollbackChange {
    ToEnd,
    ToFeature(FeatureId),
}

#[derive(Clone, Debug, PartialEq)]
pub struct WhatsWrong {
    pub label: String,
    pub message: StringKey,
    pub is_error: bool,
    pub reattach: Option<SketchId>,
}

#[derive(Default)]
pub struct FeatureMenu {
    open: Option<FeatureMenuAnchor>,
    menu: MenuState,
}

#[derive(Copy, Clone)]
struct FeatureMenuAnchor {
    target: FeatureTarget,
    anchor: LayoutPos,
}

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

#[derive(Default)]
#[allow(
    clippy::struct_excessive_bools,
    reason = "shell aggregates independent dialog and panel toggles"
)]
pub struct ShellState {
    pub feature_tree: TreeViewState,
    pub feature_menu: FeatureMenu,
    pub relationships: Option<FeatureTarget>,
    pub whats_wrong_open: bool,
    pub clipboard: MemoryClipboard,
    pub menu_bar: MenuBarState,
    pub dim_property: Option<DimPropertyEditor>,
    pub extrude_property: Option<ExtrudePropertyEditor>,
    pub settings_dialog_open: bool,
    pub keyboard_dialog_open: bool,
    pub hotkey_capture: BTreeMap<bone_ui::hotkey::ActionId, HotkeyCaptureState>,
    pub left_pane: LeftPane,
    last_left_pane_interesting: bool,
    last_mode_was_sketch: bool,
    pub status_panel_open: bool,
    pub status_panel: PanelState,
    pub extrude_panel_open: bool,
    pub extrude_panel: PanelState,
    pub property_groups: BTreeMap<WidgetId, PanelState>,
    status_cache: Option<(SketchVersion, SketchStatusReport)>,
    pub ribbon_overflow_open: BTreeMap<WidgetId, bool>,
    pub ribbon_active_tab: Option<WidgetId>,
}

#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum LeftPane {
    #[default]
    Tree,
    Properties,
}

pub enum DimPropertyEditor {
    Length {
        sketch_id: SketchId,
        id: SketchDimensionId,
        editor: LengthEditor,
    },
    Angle {
        sketch_id: SketchId,
        id: SketchDimensionId,
        editor: AngleEditor,
    },
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ExtrudeEdit {
    EndCondition(EndConditionKind),
    Depth(PositiveLength),
    Merge(MergeResult),
}

impl ExtrudeEdit {
    #[must_use]
    pub fn apply(self, feature: ExtrudeFeature) -> ExtrudeFeature {
        match self {
            Self::EndCondition(kind) => match end_condition_depth(&feature.end_condition) {
                Some(depth) => ExtrudeFeature {
                    end_condition: kind.with_depth(depth),
                    ..feature
                },
                None => feature,
            },
            Self::Depth(depth) => match EndConditionKind::of(&feature.end_condition) {
                Some(kind) => ExtrudeFeature {
                    end_condition: kind.with_depth(depth),
                    ..feature
                },
                None => feature,
            },
            Self::Merge(merge_result) => ExtrudeFeature {
                merge_result,
                ..feature
            },
        }
    }
}

pub struct ExtrudePropertyEditor {
    sketch: SketchId,
    end_condition: SelectionEditor,
    depth: LengthEditor,
    draft_enabled: BoolEditor,
    draft_angle: AngleEditor,
    direction_two: BoolEditor,
    thin: BoolEditor,
    merge: BoolEditor,
}

impl ExtrudePropertyEditor {
    fn new(feature: ExtrudeFeature) -> Self {
        Self {
            sketch: feature.sketch,
            end_condition: SelectionEditor::new(
                end_condition_options(),
                Some(kind_index(&feature.end_condition)),
            ),
            depth: LengthEditor::new(current_depth(&feature)),
            draft_enabled: BoolEditor::new(feature.draft.is_some()),
            draft_angle: AngleEditor::new(current_draft_angle(&feature)),
            direction_two: BoolEditor::new(false),
            thin: BoolEditor::new(feature.thin_wall.is_some()),
            merge: BoolEditor::new(matches!(feature.merge_result, MergeResult::Merge)),
        }
    }

    fn synced(mut self, feature: ExtrudeFeature) -> Self {
        self.end_condition.current = Some(kind_index(&feature.end_condition));
        self.depth.value = current_depth(&feature);
        self.draft_enabled.value = feature.draft.is_some();
        self.draft_angle.value = current_draft_angle(&feature);
        self.direction_two.value = false;
        self.thin.value = feature.thin_wall.is_some();
        self.merge.value = matches!(feature.merge_result, MergeResult::Merge);
        self
    }
}

fn end_condition_options() -> Vec<PropertyOption> {
    EndConditionKind::SUPPORTED
        .iter()
        .map(|kind| PropertyOption {
            label: end_condition_label(*kind),
        })
        .collect()
}

fn end_condition_label(kind: EndConditionKind) -> StringKey {
    match kind {
        EndConditionKind::Blind => strings::EXTRUDE_END_BLIND,
        EndConditionKind::MidPlane => strings::EXTRUDE_END_MIDPLANE,
    }
}

fn kind_index(condition: &ExtrudeEndCondition) -> usize {
    let kind = EndConditionKind::of(condition).unwrap_or(EndConditionKind::Blind);
    EndConditionKind::SUPPORTED
        .iter()
        .position(|candidate| *candidate == kind)
        .unwrap_or(0)
}

fn kind_from_index(index: Option<usize>) -> Option<EndConditionKind> {
    index.and_then(|i| EndConditionKind::SUPPORTED.get(i).copied())
}

fn current_depth(feature: &ExtrudeFeature) -> Length {
    end_condition_depth(&feature.end_condition).map_or_else(
        || {
            debug_assert!(
                false,
                "an armed extrude always carries a Blind or MidPlane depth"
            );
            default_extrude_depth().get()
        },
        PositiveLength::get,
    )
}

fn current_draft_angle(feature: &ExtrudeFeature) -> Angle {
    feature
        .draft
        .map_or_else(|| Angle::new::<degree>(0.0), |draft| draft.angle().get())
}

#[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_feature_tool: Option<FeatureTool>,
    pub activated_relation: Option<SketchRelation>,
    pub activated_dimension: Option<PendingDimension>,
    pub dimension_edit: Option<DimensionEdit>,
    pub extrude_edit: Option<ExtrudeEdit>,
    pub plane_picked: Option<Plane>,
    pub sketch_activated: Option<SketchId>,
    pub sketch_rename: Option<SketchRenameRequest>,
    pub extrude_activated: Option<ExtrudeId>,
    pub extrude_rename: Option<ExtrudeRenameRequest>,
    pub feature_command: Option<FeatureCommand>,
    pub feature_reorder: Option<FeatureReorder>,
    pub rollback_change: Option<RollbackChange>,
    pub reattach_request: Option<SketchId>,
    pub exit_sketch: bool,
    pub confirm_action: Option<ConfirmAction>,
    pub menu_action: Option<MenuAction>,
    pub settings_change: Option<crate::settings::Settings>,
    pub view_pick: Option<ViewPick>,
    pub view_menu: Option<ViewCubeMenuAction>,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct DimensionEdit {
    pub id: SketchDimensionId,
    pub value: DimensionValue,
}

impl ShellFrame {
    fn empty() -> Self {
        Self {
            paints: Vec::new(),
            overlay_paints: Vec::new(),
            viewport_rect: zero_rect(),
            activated_tool: None,
            activated_feature_tool: None,
            activated_relation: None,
            activated_dimension: None,
            dimension_edit: None,
            extrude_edit: None,
            plane_picked: None,
            sketch_activated: None,
            sketch_rename: None,
            extrude_activated: None,
            extrude_rename: None,
            feature_command: None,
            feature_reorder: None,
            rollback_change: None,
            reattach_request: None,
            exit_sketch: false,
            confirm_action: None,
            menu_action: None,
            settings_change: None,
            view_pick: None,
            view_menu: None,
        }
    }
}

impl Shell {
    fn build_layout(&self, gap: Spacing) -> Layout {
        let center = Layout::dock_host(
            self.ids.dock_host,
            Arc::clone(&self.dock_state),
            vec![
                DockPanel {
                    id: self.panels.left_pane,
                    child: Layout::leaf(self.ids.left_pane),
                },
                DockPanel {
                    id: self.panels.viewport,
                    child: Layout::leaf(self.ids.viewport),
                },
            ],
            gap,
        );
        chrome_grid(ChromeRows {
            menu: Layout::leaf(self.ids.menu_bar),
            ribbon: Layout::leaf(self.ids.ribbon),
            center,
            doc_tabs: Layout::leaf(self.ids.doc_tabs),
            status: Layout::leaf(self.ids.status_bar),
        })
    }

    #[must_use]
    pub fn new() -> Self {
        let panels = ShellPanels::standard();
        let ids = ShellIds::standard();
        let dock_state = Arc::new(DockState::new(build_dock_main(panels)));
        let tool_index = build_tool_index(ids.ribbon);
        let feature_tool_index = build_feature_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);
        Self {
            panels,
            ids,
            retained_layout: RetainedLayout::default(),
            dock_state,
            tool_index,
            feature_tool_index,
            relation_index,
            state,
        }
    }

    #[allow(
        clippy::too_many_lines,
        clippy::too_many_arguments,
        reason = "shell.render orchestrates the chrome layout pipeline"
    )]
    pub fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        settings: &Settings,
        viewport_size: LayoutSize,
        cursor_world: Option<Point2>,
        camera3: Option<Camera3>,
        extrude_status: Option<ExtrudeStatus<'_>>,
        view: &mut ViewUi,
        feature_badges: &BTreeMap<FeatureId, TreeBadge>,
        needs_rebuild: bool,
        whats_wrong: &[WhatsWrong],
    ) -> 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 = leaf_rect(&solved, self.ids.ribbon).unwrap_or_else(zero_rect);
        let menu_bar_rect = leaf_rect(&solved, self.ids.menu_bar).unwrap_or_else(zero_rect);
        let left_pane_rect = panel_rect(&solved, self.panels.left_pane)
            .map_or_else(zero_rect, |r| inset_rect(r, inset_px));
        let LeftPaneSplit {
            tab_strip_rect,
            content_rect,
        } = split_left_pane(left_pane_rect);
        let status_rect = leaf_rect(&solved, self.ids.status_bar).unwrap_or_else(zero_rect);
        let doc_tabs_rect = leaf_rect(&solved, self.ids.doc_tabs).unwrap_or_else(zero_rect);
        let mut popover_paints: Vec<WidgetPaint> = Vec::new();
        let menu_action = render_menu_bar(
            ctx,
            menu_bar_rect,
            &self.ids,
            &mut self.state.menu_bar,
            document,
            mode.is_sketch(),
            &settings.hotkey_overrides,
            &mut paints,
            &mut popover_paints,
        );
        let active_sketch = active_sketch(document, mode);
        let entity_ids = selection.entity_ids();
        sync_ribbon_tab_to_mode(&mut self.state, self.ids.ribbon, mode);
        let activated_widget = render_ribbon(
            ctx,
            RibbonInputs {
                rect: ribbon_rect,
                ribbon: self.ids.ribbon,
                ribbon_smart_dimension: self.ids.ribbon_smart_dimension,
                mode,
                sketch: active_sketch,
                selection: entity_ids,
            },
            &mut paints,
            &mut popover_paints,
            &mut self.state.ribbon_overflow_open,
            &mut self.state.ribbon_active_tab,
        );
        update_left_pane_auto(
            &mut self.state,
            selection,
            mode.active_tool(),
            mode.is_extrude(),
        );
        let tab_clicked = render_left_pane_tabs(
            ctx,
            tab_strip_rect,
            &self.ids,
            self.state.left_pane,
            &mut paints,
        );
        if let Some(target) = tab_clicked {
            self.state.left_pane = target;
        }
        let active_pane = self.state.left_pane;
        let (tree_rect, property_rect) = match active_pane {
            LeftPane::Tree => (content_rect, zero_rect()),
            LeftPane::Properties => (zero_rect(), content_rect),
        };
        let feature_tree = render_feature_tree(
            ctx,
            &mut self.state.feature_tree,
            FeatureTreeInputs {
                rect: tree_rect,
                tree_id: self.ids.feature_tree,
                part_id: self.ids.feature_part,
                document,
                badges: feature_badges,
                needs_rebuild,
            },
            &mut paints,
        );
        if let Some((target, anchor)) = feature_tree.context_menu {
            self.state.feature_menu.open = Some(FeatureMenuAnchor { target, anchor });
            self.state.feature_menu.menu = MenuState::default();
        }
        let feature_menu_base = self.ids.feature_tree;
        let feature_menu_outcome = render_feature_context_menu(
            ctx,
            feature_menu_base,
            &mut self.state,
            document,
            &mut popover_paints,
        );
        let pane = render_property_pane(
            ctx,
            property_rect,
            self.ids.property_pane,
            &mut self.state.clipboard,
            &mut PaneEditors {
                dim: &mut self.state.dim_property,
                extrude: &mut self.state.extrude_property,
                groups: &mut self.state.property_groups,
            },
            PropertyState {
                mode,
                sketch: active_sketch,
                selection,
            },
            &mut paints,
        );
        let dimension_edit = pane.dimension_edit;
        let extrude_edit = pane.extrude_edit;
        render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints);
        let status_report: Option<&SketchStatusReport> = if let Some(s) = active_sketch {
            let v = s.version();
            if self
                .state
                .status_cache
                .as_ref()
                .is_none_or(|(cv, _)| *cv != v)
            {
                self.state.status_cache = Some((v, s.status()));
            }
            self.state.status_cache.as_ref().map(|(_, r)| r)
        } else {
            self.state.status_cache = None;
            None
        };
        let status_badge_id = status_badge_widget_id(self.ids.status_bar);
        let extrude_badge_id = extrude_badge_widget_id(self.ids.status_bar);
        let badge_activated = render_status_bar(
            ctx,
            status_rect,
            self.ids.status_bar,
            mode,
            document,
            cursor_world,
            status_report,
            status_badge_id,
            extrude_status,
            extrude_badge_id,
            &mut paints,
        );
        if badge_activated == Some(status_badge_id) {
            self.state.status_panel_open = !self.state.status_panel_open;
        }
        if status_report.is_none_or(|r| r.offending().is_empty()) {
            self.state.status_panel_open = false;
        }
        if self.state.status_panel_open {
            if let (Some(report), Some(sketch)) = (status_report, active_sketch) {
                render_status_panel(
                    ctx,
                    status_panel_widget_id(self.ids.status_bar),
                    &mut self.state.status_panel,
                    status_rect,
                    report,
                    sketch,
                    &mut popover_paints,
                );
            } else {
                self.state.status_panel_open = false;
            }
        }
        if badge_activated == Some(extrude_badge_id) {
            self.state.extrude_panel_open = !self.state.extrude_panel_open;
        }
        let extrude_error = extrude_status.and_then(ExtrudeStatus::error);
        if self.state.extrude_panel_open {
            match extrude_error {
                Some(error) => render_extrude_panel(
                    ctx,
                    extrude_panel_widget_id(self.ids.status_bar),
                    &mut self.state.extrude_panel,
                    status_rect,
                    error,
                    &mut popover_paints,
                ),
                None => self.state.extrude_panel_open = false,
            }
        }
        let confirm_visible =
            mode.is_sketch() || matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. }));
        let confirm =
            render_confirm_corner(ctx, viewport_rect, &self.ids, confirm_visible, &mut paints);
        let confirm_action = confirm.or(pane.confirm);
        let normal_to_available = active_sketch.is_some();
        let (view_pick, view_menu) = render_view_controls(
            ctx,
            ViewControlInputs {
                viewport: viewport_rect,
                camera3,
                ids: &self.ids,
                view,
                normal_to_available,
                confirm_visible,
            },
            &mut paints,
            &mut popover_paints,
        );
        let heads_up_action = crate::heads_up::render_heads_up_toolbar(
            ctx,
            viewport_rect,
            self.ids.heads_up,
            &mut paints,
        );
        let menu_action = menu_action.or(heads_up_action);
        let exit_sketch = confirm_action.is_some() || menu_action == Some(MenuAction::ExitSketch);
        let activated_tool = activated_widget.and_then(|id| self.tool_index.get(&id).copied());
        let activated_feature_tool =
            activated_widget.and_then(|id| self.feature_tool_index.get(&id).copied());
        let activated_relation = resolve_activated_relation(
            activated_widget,
            &self.relation_index,
            active_sketch,
            entity_ids,
        );
        let activated_dimension = resolve_activated_dimension(
            activated_widget,
            self.ids.ribbon_smart_dimension,
            active_sketch,
            entity_ids,
        );
        let plane_picked = feature_tree
            .double_activated
            .and_then(|id| self.ids.plane_for(id));
        let mut sketch_activated = feature_tree.sketch_activated;
        let sketch_rename = feature_tree.sketch_rename;
        let mut extrude_activated = feature_tree.extrude_activated;
        let extrude_rename = feature_tree.extrude_rename;
        let feature_reorder = feature_tree.reorder;
        let rollback_change = feature_tree.rollback;
        let mut feature_command = None;
        match feature_menu_outcome {
            Some(FeatureMenuOutcome::EditSketch(sketch_id)) => {
                sketch_activated = sketch_activated.or(Some(sketch_id));
            }
            Some(FeatureMenuOutcome::EditExtrude(extrude_id)) => {
                extrude_activated = extrude_activated.or(Some(extrude_id));
            }
            Some(FeatureMenuOutcome::ShowRelationships(target)) => {
                self.state.relationships = Some(target);
            }
            Some(FeatureMenuOutcome::Command(command)) => feature_command = Some(command),
            None => {}
        }
        if let Some(target) = self.state.relationships {
            let relationships_base = self.ids.feature_tree;
            let close = render_relationships_panel(
                ctx,
                relationships_base,
                viewport_rect,
                target,
                document,
                &mut popover_paints,
            );
            if close {
                self.state.relationships = None;
            }
        }
        if feature_tree.part_activated && !whats_wrong.is_empty() {
            self.state.whats_wrong_open = !self.state.whats_wrong_open;
        }
        if whats_wrong.is_empty() {
            self.state.whats_wrong_open = false;
        }
        let mut reattach_request = None;
        if self.state.whats_wrong_open {
            let whats_wrong_base = self.ids.feature_tree;
            let outcome = render_whats_wrong_panel(
                ctx,
                whats_wrong_base,
                viewport_rect,
                whats_wrong,
                &mut popover_paints,
            );
            reattach_request = outcome.reattach;
            if outcome.close || reattach_request.is_some() {
                self.state.whats_wrong_open = false;
            }
        }
        let mut dialog_paints: Vec<WidgetPaint> = Vec::new();
        let settings_change = render_settings_dialog(
            ctx,
            viewport_size,
            &self.ids,
            &mut self.state,
            settings,
            &mut dialog_paints,
        );
        let keyboard_change = render_keyboard_dialog(
            ctx,
            viewport_size,
            &self.ids,
            &mut self.state,
            settings,
            &mut dialog_paints,
        );
        let settings_change = keyboard_change.or(settings_change);
        let (paints, mut overlay_paints) = partition_overlay(paints, ctx.theme());
        overlay_paints.extend(popover_paints);
        overlay_paints.extend(dialog_paints);
        ShellFrame {
            paints,
            overlay_paints,
            viewport_rect,
            activated_tool,
            activated_feature_tool,
            activated_relation,
            activated_dimension,
            dimension_edit,
            extrude_edit,
            plane_picked,
            sketch_activated,
            sketch_rename,
            extrude_activated,
            extrude_rename,
            feature_command,
            feature_reorder,
            rollback_change,
            reattach_request,
            exit_sketch,
            confirm_action,
            menu_action,
            settings_change,
            view_pick,
            view_menu,
        }
    }
}

struct ViewControlInputs<'a> {
    viewport: LayoutRect,
    camera3: Option<Camera3>,
    ids: &'a ShellIds,
    view: &'a mut ViewUi,
    normal_to_available: bool,
    confirm_visible: bool,
}

fn render_view_controls(
    ctx: &mut FrameCtx<'_>,
    inputs: ViewControlInputs<'_>,
    paints: &mut Vec<WidgetPaint>,
    popover_paints: &mut Vec<WidgetPaint>,
) -> (Option<ViewPick>, Option<ViewCubeMenuAction>) {
    let ViewControlInputs {
        viewport,
        camera3,
        ids,
        view,
        normal_to_available,
        confirm_visible,
    } = inputs;
    let Some(camera) = camera3 else {
        return (None, None);
    };
    let outcome = render_view_cube(
        ctx,
        ViewCubeInputs {
            viewport,
            camera,
            base: ids.view_cube,
            menu_id: ids.view_cube_menu,
            view,
            normal_to_available,
            confirm_visible,
        },
        paints,
        popover_paints,
    );
    let selected = render_view_selector(
        ctx,
        viewport,
        ids.view_selector,
        view,
        normal_to_available,
        popover_paints,
    );
    let pick = outcome.pick.or_else(|| selected.map(ViewPick::Standard));
    (pick, outcome.menu)
}

const SETTINGS_DIALOG_WIDTH: f32 = 420.0;
const SETTINGS_DIALOG_HEIGHT: f32 = 256.0;
const SETTINGS_DIALOG_GUTTER: f32 = 16.0;
const SETTINGS_LABEL_HEIGHT: f32 = 20.0;
const SETTINGS_HINT_HEIGHT: f32 = 36.0;
const SETTINGS_SLIDER_HEIGHT: f32 = 28.0;
const SETTINGS_CHECKBOX_HEIGHT: f32 = 24.0;
const SETTINGS_LABEL_TO_HINT_GAP: f32 = 6.0;
const SETTINGS_HINT_TO_SLIDER_GAP: f32 = 12.0;
const SETTINGS_SLIDER_TO_CHECKBOX_GAP: f32 = 12.0;
const PICK_APERTURE_MIN_PX: i32 = 1;
const PICK_APERTURE_MAX_PX: i32 = 30;

fn render_settings_dialog(
    ctx: &mut FrameCtx<'_>,
    viewport_size: LayoutSize,
    ids: &ShellIds,
    state: &mut ShellState,
    settings: &Settings,
    paints: &mut Vec<WidgetPaint>,
) -> Option<Settings> {
    if !state.settings_dialog_open {
        return None;
    }
    let viewport = LayoutRect::new(
        LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
        viewport_size,
    );
    let buttons = [
        DialogButton::secondary(ids.settings_reset, strings::SETTINGS_RESET),
        DialogButton::primary(ids.settings_close, strings::SETTINGS_CLOSE),
    ];
    let dialog_size = LayoutSize::new(
        LayoutPx::new(SETTINGS_DIALOG_WIDTH),
        LayoutPx::new(SETTINGS_DIALOG_HEIGHT),
    );
    let aperture_label_text = format!(
        "{}: {} px",
        ctx.strings.resolve(strings::SETTINGS_PICK_APERTURE_LABEL),
        settings.pick_aperture.radius_px(),
    );
    let aperture_slider_id = ids.settings_aperture_slider;
    let reduce_motion_id = ids.settings_reduce_motion;
    let (response, body_change) = show_dialog(
        ctx,
        Dialog::new(
            ids.settings_dialog,
            viewport,
            dialog_size,
            strings::SETTINGS_DIALOG_TITLE,
            &buttons,
        ),
        |ctx, body_rect, paint| {
            settings_dialog_body(
                ctx,
                body_rect,
                aperture_slider_id,
                reduce_motion_id,
                settings,
                aperture_label_text,
                paint,
            )
        },
    );
    paints.extend(response.paint);
    if response.dismissed || response.activated == Some(ids.settings_close) {
        state.settings_dialog_open = false;
    }
    if response.activated == Some(ids.settings_reset) {
        return Some(Settings {
            hotkey_overrides: settings.hotkey_overrides.clone(),
            ..Settings::default()
        });
    }
    body_change
}

fn settings_dialog_body(
    ctx: &mut FrameCtx<'_>,
    body_rect: LayoutRect,
    aperture_slider_id: WidgetId,
    reduce_motion_id: WidgetId,
    settings: &Settings,
    aperture_label_text: String,
    paint: &mut Vec<WidgetPaint>,
) -> Option<Settings> {
    let label_rect = settings_label_rect(body_rect);
    paint.push(WidgetPaint::Label {
        rect: label_rect,
        text: LabelText::Owned(aperture_label_text),
        color: ctx.theme().colors.text_primary(),
        role: ctx.theme().typography.label,
    });
    let hint_rect = settings_hint_rect(body_rect);
    paint.push(WidgetPaint::Label {
        rect: hint_rect,
        text: LabelText::Key(strings::SETTINGS_PICK_APERTURE_HINT),
        color: ctx.theme().colors.text_secondary(),
        role: ctx.theme().typography.caption,
    });
    let Ok(range) = SliderRange::try_new(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX) else {
        unreachable!("PICK_APERTURE_MIN_PX < PICK_APERTURE_MAX_PX is statically guaranteed");
    };
    let Ok(step) = SliderStep::try_new(1i32) else {
        unreachable!("integer step of 1 is positive");
    };
    let initial = i32::try_from(settings.pick_aperture.radius_px()).unwrap_or(PICK_APERTURE_MAX_PX);
    let response = show_slider(
        ctx,
        Slider::new(
            aperture_slider_id,
            settings_slider_rect(body_rect),
            strings::SETTINGS_PICK_APERTURE_LABEL,
            initial,
            range,
            step,
        ),
    );
    paint.extend(response.paint);
    let slider_change = response.changed.then(|| {
        let clamped = response
            .value
            .clamp(PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX);
        #[allow(
            clippy::cast_sign_loss,
            reason = "value clamped to [PICK_APERTURE_MIN_PX, PICK_APERTURE_MAX_PX] which is non-negative"
        )]
        let radius = clamped as u32;
        Settings {
            pick_aperture: PickAperture::new(radius),
            ..settings.clone()
        }
    });
    let checkbox_state = if settings.reduce_motion {
        CheckboxState::Checked
    } else {
        CheckboxState::Unchecked
    };
    let checkbox = show_checkbox(
        ctx,
        Checkbox::new(
            reduce_motion_id,
            settings_checkbox_rect(body_rect),
            strings::SETTINGS_REDUCE_MOTION_LABEL,
            checkbox_state,
        ),
    );
    paint.extend(checkbox.paint);
    let checkbox_change = checkbox.toggled.then(|| Settings {
        reduce_motion: !settings.reduce_motion,
        ..settings.clone()
    });
    slider_change.or(checkbox_change)
}

fn settings_row_rect(body: LayoutRect, top_offset: f32, height: f32) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER),
            LayoutPx::new(body.origin.y.value() + SETTINGS_DIALOG_GUTTER + top_offset),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER),
            LayoutPx::new(height),
        ),
    )
}

const SETTINGS_HINT_TOP: f32 = SETTINGS_LABEL_HEIGHT + SETTINGS_LABEL_TO_HINT_GAP;
const SETTINGS_SLIDER_TOP: f32 =
    SETTINGS_HINT_TOP + SETTINGS_HINT_HEIGHT + SETTINGS_HINT_TO_SLIDER_GAP;
const SETTINGS_CHECKBOX_TOP: f32 =
    SETTINGS_SLIDER_TOP + SETTINGS_SLIDER_HEIGHT + SETTINGS_SLIDER_TO_CHECKBOX_GAP;

fn settings_label_rect(body: LayoutRect) -> LayoutRect {
    settings_row_rect(body, 0.0, SETTINGS_LABEL_HEIGHT)
}

fn settings_hint_rect(body: LayoutRect) -> LayoutRect {
    settings_row_rect(body, SETTINGS_HINT_TOP, SETTINGS_HINT_HEIGHT)
}

fn settings_slider_rect(body: LayoutRect) -> LayoutRect {
    settings_row_rect(body, SETTINGS_SLIDER_TOP, SETTINGS_SLIDER_HEIGHT)
}

fn settings_checkbox_rect(body: LayoutRect) -> LayoutRect {
    settings_row_rect(body, SETTINGS_CHECKBOX_TOP, SETTINGS_CHECKBOX_HEIGHT)
}

const KEYBOARD_DIALOG_WIDTH: f32 = 460.0;
const KEYBOARD_DIALOG_HEIGHT: f32 = 420.0;
const KEYBOARD_ROW_HEIGHT: f32 = 32.0;
const KEYBOARD_ROW_GAP: f32 = 6.0;
const KEYBOARD_CAPTURE_WIDTH: f32 = 180.0;
const KEYBOARD_HINT_HEIGHT: f32 = 20.0;
const KEYBOARD_HINT_TO_ROWS_GAP: f32 = 12.0;

fn render_keyboard_dialog(
    ctx: &mut FrameCtx<'_>,
    viewport_size: LayoutSize,
    ids: &ShellIds,
    state: &mut ShellState,
    settings: &Settings,
    paints: &mut Vec<WidgetPaint>,
) -> Option<Settings> {
    if !state.keyboard_dialog_open {
        return None;
    }
    let viewport = LayoutRect::new(
        LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
        viewport_size,
    );
    let buttons = [
        DialogButton::secondary(ids.keyboard_dialog_reset, strings::SETTINGS_RESET),
        DialogButton::primary(ids.keyboard_dialog_close, strings::SETTINGS_CLOSE),
    ];
    let dialog_size = LayoutSize::new(
        LayoutPx::new(KEYBOARD_DIALOG_WIDTH),
        LayoutPx::new(KEYBOARD_DIALOG_HEIGHT),
    );
    let mut next_overrides: Option<crate::hotkeys::HotkeyOverrides> = None;
    let (response, _) = show_dialog(
        ctx,
        Dialog::new(
            ids.keyboard_dialog,
            viewport,
            dialog_size,
            strings::KEYBOARD_DIALOG_TITLE,
            &buttons,
        ),
        |ctx, body_rect, paint| {
            next_overrides = keyboard_dialog_body(
                ctx,
                body_rect,
                ids.keyboard_dialog,
                state,
                &settings.hotkey_overrides,
                paint,
            );
            Some(())
        },
    );
    paints.extend(response.paint);
    if response.dismissed || response.activated == Some(ids.keyboard_dialog_close) {
        state.keyboard_dialog_open = false;
        state.hotkey_capture.clear();
    }
    if response.activated == Some(ids.keyboard_dialog_reset) {
        return Some(Settings {
            hotkey_overrides: crate::hotkeys::HotkeyOverrides::default(),
            ..settings.clone()
        });
    }
    next_overrides.map(|overrides| Settings {
        hotkey_overrides: overrides,
        ..settings.clone()
    })
}

fn keyboard_dialog_header_rects(body_rect: LayoutRect) -> (LayoutRect, LayoutRect, f32) {
    let gutter = SETTINGS_DIALOG_GUTTER;
    let heading_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body_rect.origin.x.value() + gutter),
            LayoutPx::new(body_rect.origin.y.value() + gutter),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter),
            LayoutPx::new(KEYBOARD_HINT_HEIGHT),
        ),
    );
    let hint_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body_rect.origin.x.value() + gutter),
            LayoutPx::new(
                body_rect.origin.y.value() + gutter + KEYBOARD_HINT_HEIGHT + KEYBOARD_ROW_GAP,
            ),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body_rect.size.width.value() - 2.0 * gutter),
            LayoutPx::new(KEYBOARD_HINT_HEIGHT),
        ),
    );
    let rows_origin_y = body_rect.origin.y.value()
        + gutter
        + 2.0 * KEYBOARD_HINT_HEIGHT
        + KEYBOARD_ROW_GAP
        + KEYBOARD_HINT_TO_ROWS_GAP;
    (heading_rect, hint_rect, rows_origin_y)
}

fn keyboard_row_rects(body_rect: LayoutRect, row_y: f32) -> (LayoutRect, LayoutRect) {
    let gutter = SETTINGS_DIALOG_GUTTER;
    let label_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body_rect.origin.x.value() + gutter),
            LayoutPx::new(row_y + 4.0),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(
                body_rect.size.width.value() - 3.0 * gutter - KEYBOARD_CAPTURE_WIDTH,
            ),
            LayoutPx::new(KEYBOARD_ROW_HEIGHT),
        ),
    );
    let capture_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(
                body_rect.origin.x.value() + body_rect.size.width.value()
                    - gutter
                    - KEYBOARD_CAPTURE_WIDTH,
            ),
            LayoutPx::new(row_y),
        ),
        LayoutSize::new(
            LayoutPx::new(KEYBOARD_CAPTURE_WIDTH),
            LayoutPx::new(KEYBOARD_ROW_HEIGHT),
        ),
    );
    (label_rect, capture_rect)
}

fn keyboard_dialog_body(
    ctx: &mut FrameCtx<'_>,
    body_rect: LayoutRect,
    dialog_id: WidgetId,
    state: &mut ShellState,
    overrides: &crate::hotkeys::HotkeyOverrides,
    paint: &mut Vec<WidgetPaint>,
) -> Option<crate::hotkeys::HotkeyOverrides> {
    let (heading_rect, hint_rect, rows_origin_y) = keyboard_dialog_header_rects(body_rect);
    paint.push(WidgetPaint::Label {
        rect: heading_rect,
        text: LabelText::Key(strings::HOTKEY_SECTION_HEADING),
        color: ctx.theme().colors.text_primary(),
        role: ctx.theme().typography.label,
    });
    paint.push(WidgetPaint::Label {
        rect: hint_rect,
        text: LabelText::Key(strings::HOTKEY_RECORDING_HINT),
        color: ctx.theme().colors.text_secondary(),
        role: ctx.theme().typography.caption,
    });
    let entries = crate::hotkeys::remap_entries();
    let row_advance = KEYBOARD_ROW_HEIGHT + KEYBOARD_ROW_GAP;
    let captures_changed = entries
        .iter()
        .fold(
            (
                rows_origin_y,
                Vec::<(bone_ui::hotkey::ActionId, bone_ui::hotkey::KeyChord)>::new(),
            ),
            |(row_y, mut acc), entry| {
                let (label_rect, capture_rect) = keyboard_row_rects(body_rect, row_y);
                paint.push(WidgetPaint::Label {
                    rect: label_rect,
                    text: LabelText::Key(entry.label),
                    color: ctx.theme().colors.text_primary(),
                    role: ctx.theme().typography.label,
                });
                let chord_now = current_chord(overrides, entry);
                let placeholder = chord_now.map_or(strings::HOTKEY_UNBOUND_LABEL, |_| entry.label);
                let capture_state = state.hotkey_capture.entry(entry.action).or_insert_with(|| {
                    HotkeyCaptureState {
                        recording: false,
                        chord: chord_now,
                    }
                });
                if capture_state.chord.is_none() {
                    capture_state.chord = chord_now;
                }
                let response = show_hotkey_capture(
                    ctx,
                    HotkeyCapture::new(
                        capture_widget_id(dialog_id, entry.action),
                        capture_rect,
                        placeholder,
                        strings::HOTKEY_RECORDING_PROMPT,
                        capture_state,
                    ),
                );
                paint.extend(response.paint);
                if let Some(chord) = response.captured {
                    acc.push((entry.action, chord));
                }
                (row_y + row_advance, acc)
            },
        )
        .1;
    if captures_changed.is_empty() {
        return None;
    }
    let next = captures_changed
        .into_iter()
        .fold(overrides.clone(), |mut acc, (action, chord)| {
            acc.set(action, chord);
            acc
        });
    Some(next)
}

fn current_chord(
    overrides: &crate::hotkeys::HotkeyOverrides,
    entry: &crate::hotkeys::RemapEntry,
) -> Option<bone_ui::hotkey::KeyChord> {
    overrides.lookup(entry.action).or(entry.default_chord)
}

fn capture_widget_id(dialog_id: WidgetId, action: bone_ui::hotkey::ActionId) -> WidgetId {
    dialog_id.child_indexed(WidgetKey::new("capture"), u64::from(action.get().get()))
}

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,
                    });
                }
                WidgetPaint::Popup { paints } => {
                    let (inner_main, inner_overlay) = partition_overlay(paints, theme);
                    overlay.extend(inner_main);
                    overlay.extend(inner_overlay);
                }
                other => main.push(other),
            }
            (main, overlay)
        },
    )
}

fn active_sketch<'a>(document: &'a Document, mode: &Mode) -> Option<&'a Sketch> {
    mode.sketch_id().and_then(|id| document.sketch(id))
}

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)
        .with_icon(RibbonIconSize::Large.slot(IconId::SmartDimension));
    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),
    }
}

#[allow(
    clippy::too_many_arguments,
    reason = "menu bar render bundles a handful of shell-owned references"
)]
fn render_menu_bar(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    ids: &ShellIds,
    state: &mut MenuBarState,
    document: &Document,
    is_sketch: bool,
    overrides: &crate::hotkeys::HotkeyOverrides,
    paints: &mut Vec<WidgetPaint>,
    popover_paints: &mut Vec<WidgetPaint>,
) -> Option<MenuAction> {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let entries = build_menu_entries(ids, is_sketch, overrides);
    let response = show_menu_bar(
        ctx,
        MenuBar::new(ids.menu_bar, rect, strings::MENU_BAR_LABEL, &entries, state)
            .with_document_label(LabelText::Owned(document.name().to_owned())),
    );
    paints.extend(response.paint);
    popover_paints.extend(response.popover_paint);
    response.activated.and_then(|id| ids.menu_action_for(id))
}

#[allow(
    clippy::too_many_lines,
    reason = "menu entries are flat data; splitting would scatter related strings"
)]
fn build_menu_entries(
    ids: &ShellIds,
    is_sketch: bool,
    overrides: &crate::hotkeys::HotkeyOverrides,
) -> Vec<MenuBarEntry> {
    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 action_with_accel =
        |id: WidgetId, label: StringKey, accel: Option<bone_ui::hotkey::ActionId>| {
            let shortcut = accel
                .and_then(|a| crate::hotkeys::accelerator_label(a, overrides))
                .map(LabelText::Owned);
            MenuItem::Action {
                id,
                label,
                shortcut,
                disabled: false,
            }
        };
    let file = ids.menu_file;
    let mut entries = vec![
        MenuBarEntry {
            id: file,
            label: strings::MENU_FILE,
            items: vec![
                action_with_accel(
                    ids.menu_file_new,
                    strings::MENU_FILE_NEW,
                    Some(crate::hotkeys::NEW_DOCUMENT_ACTION),
                ),
                action_with_accel(
                    ids.menu_file_open,
                    strings::MENU_FILE_OPEN,
                    Some(crate::hotkeys::OPEN_DOCUMENT_ACTION),
                ),
                action_with_accel(
                    ids.menu_file_save,
                    strings::MENU_FILE_SAVE,
                    Some(crate::hotkeys::SAVE_DOCUMENT_ACTION),
                ),
                action_with_accel(ids.menu_file_save_as, strings::MENU_FILE_SAVE_AS, None),
                MenuItem::Separator,
                action_with_accel(
                    ids.menu_file_import,
                    strings::MENU_FILE_IMPORT,
                    Some(crate::hotkeys::IMPORT_STEP_ACTION),
                ),
                MenuItem::Submenu {
                    id: ids.menu_file_export,
                    label: strings::MENU_FILE_EXPORT,
                    items: vec![action_with_accel(
                        ids.menu_file_export_step,
                        strings::MENU_FILE_EXPORT_STEP,
                        Some(crate::hotkeys::EXPORT_STEP_ACTION),
                    )],
                },
                MenuItem::Separator,
                action_with_accel(
                    ids.menu_file_quit,
                    strings::MENU_FILE_QUIT,
                    Some(crate::hotkeys::QUIT_ACTION),
                ),
            ],
        },
        MenuBarEntry {
            id: ids.menu_edit,
            label: strings::MENU_EDIT,
            items: vec![
                action_with_accel(
                    ids.menu_edit_undo,
                    strings::MENU_EDIT_UNDO,
                    Some(crate::sketch_mode::UNDO_ACTION),
                ),
                action_with_accel(
                    ids.menu_edit_redo,
                    strings::MENU_EDIT_REDO,
                    Some(crate::sketch_mode::REDO_ACTION),
                ),
            ],
        },
        MenuBarEntry {
            id: ids.menu_view,
            label: strings::MENU_VIEW,
            items: vec![action_with_accel(
                ids.menu_view_zoom_fit,
                strings::MENU_VIEW_ZOOM_FIT,
                Some(crate::hotkeys::ZOOM_FIT_ACTION),
            )],
        },
        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![
                action_with_accel(ids.menu_tools_options, strings::MENU_TOOLS_OPTIONS, None),
                action_with_accel(ids.menu_tools_keyboard, strings::MENU_TOOLS_KEYBOARD, None),
            ],
        },
    ];
    if is_sketch {
        entries.push(MenuBarEntry {
            id: ids.menu_sketch,
            label: strings::MENU_SKETCH,
            items: vec![action_with_accel(
                ids.menu_sketch_exit,
                strings::MENU_SKETCH_EXIT,
                Some(crate::sketch_mode::ESCAPE_ACTION),
            )],
        });
    }
    entries.extend([
        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")],
        },
    ]);
    entries
}

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

fn sketch_tab_id(ribbon: WidgetId) -> WidgetId {
    ribbon.child(WidgetKey::new("tab.sketch"))
}

fn features_tab_id(ribbon: WidgetId) -> WidgetId {
    ribbon.child(WidgetKey::new("tab.features"))
}

fn sync_ribbon_tab_to_mode(state: &mut ShellState, ribbon: WidgetId, mode: &Mode) {
    let in_sketch = mode.is_sketch();
    if in_sketch && !state.last_mode_was_sketch {
        state.ribbon_active_tab = Some(sketch_tab_id(ribbon));
    }
    state.last_mode_was_sketch = in_sketch;
}

fn render_ribbon(
    ctx: &mut FrameCtx<'_>,
    inputs: RibbonInputs<'_>,
    paints: &mut Vec<WidgetPaint>,
    popover_paints: &mut Vec<WidgetPaint>,
    overflow_open: &mut BTreeMap<WidgetId, bool>,
    active_tab: &mut Option<WidgetId>,
) -> Option<WidgetId> {
    let RibbonInputs {
        rect,
        ribbon,
        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 = mode.active_tool();
    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))
                    .with_icon(RibbonIconSize::Large.slot(tool_icon(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 feature_items = feature_tool_items(ctx, ribbon, mode, large_min);
    let sketch_tab_id = sketch_tab_id(ribbon);
    let features_tab_id = features_tab_id(ribbon);
    let groups = build_sketch_groups(
        ribbon,
        entity_items,
        relation_items,
        dimension_items,
        large_min,
        small_min,
        overflow_open,
    );
    let feature_groups = build_feature_groups(ribbon, feature_items, large_min, overflow_open);
    let placeholder_tab = |key: &'static str, label: StringKey| {
        RibbonTab::new(ribbon.child(WidgetKey::new(key)), label, Vec::new()).disabled(true)
    };
    let tabs = [
        RibbonTab::new(
            features_tab_id,
            strings::RIBBON_TAB_FEATURES,
            feature_groups,
        ),
        RibbonTab::new(sketch_tab_id, strings::RIBBON_TAB_SKETCH, groups),
        placeholder_tab("tab.surfaces", strings::RIBBON_TAB_SURFACES),
        placeholder_tab("tab.evaluate", strings::RIBBON_TAB_EVALUATE),
    ];
    let selected_tab = active_tab.unwrap_or(sketch_tab_id);
    let pointer_pressed = !ctx.input.buttons_pressed.is_empty();
    let response = show_ribbon(
        ctx,
        Ribbon::new(ribbon, rect, strings::RIBBON_LABEL, &tabs, selected_tab),
    );
    if let Some(tab) = response.activated_tab {
        *active_tab = Some(tab);
    }
    process_ribbon_response(
        response,
        paints,
        popover_paints,
        overflow_open,
        pointer_pressed,
    )
}

fn build_sketch_groups(
    ribbon: WidgetId,
    entity_items: Vec<ToolbarItem>,
    relation_items: Vec<ToolbarItem>,
    dimension_items: Vec<ToolbarItem>,
    large_min: LayoutPx,
    small_min: LayoutPx,
    overflow_open: &BTreeMap<WidgetId, bool>,
) -> Vec<RibbonGroup> {
    let large_rows = RibbonIconSize::Large.rows();
    let small_rows = RibbonIconSize::Small.rows();
    let dimensions_preferred = group_width_for(&dimension_items, large_min, large_rows);
    let relations_width = group_width_for(&relation_items, small_min, small_rows);
    let entities_id = ribbon.child(WidgetKey::new("group.entities"));
    let relations_id = ribbon.child(WidgetKey::new("group.relations"));
    let dimensions_id = ribbon.child(WidgetKey::new("group.dimensions"));
    let open_of = |id: WidgetId| overflow_open.get(&id).copied().unwrap_or(false);
    vec![
        RibbonGroup {
            id: entities_id,
            label: strings::RIBBON_GROUP_ENTITIES,
            min_width: group_min_width(large_min, entity_items.len()),
            width: group_width_for(&entity_items, large_min, large_rows),
            items: entity_items,
            icon_size: RibbonIconSize::Large,
            overflow_open: open_of(entities_id),
            overflow_label: Some(strings::TOOLBAR_OVERFLOW),
        },
        RibbonGroup {
            id: relations_id,
            label: strings::RIBBON_GROUP_RELATIONS,
            min_width: relations_width,
            width: relations_width,
            items: relation_items,
            icon_size: RibbonIconSize::Small,
            overflow_open: open_of(relations_id),
            overflow_label: Some(strings::TOOLBAR_OVERFLOW),
        },
        RibbonGroup {
            id: dimensions_id,
            label: strings::RIBBON_GROUP_DIMENSIONS,
            min_width: dimensions_preferred,
            width: dimensions_preferred,
            items: dimension_items,
            icon_size: RibbonIconSize::Large,
            overflow_open: open_of(dimensions_id),
            overflow_label: Some(strings::TOOLBAR_OVERFLOW),
        },
    ]
}

fn build_feature_groups(
    ribbon: WidgetId,
    feature_items: Vec<ToolbarItem>,
    large_min: LayoutPx,
    overflow_open: &BTreeMap<WidgetId, bool>,
) -> Vec<RibbonGroup> {
    let extrude_id = ribbon.child(WidgetKey::new("group.extrude"));
    vec![RibbonGroup {
        id: extrude_id,
        label: strings::RIBBON_GROUP_EXTRUDE,
        min_width: group_min_width(large_min, feature_items.len()),
        width: group_width_for(&feature_items, large_min, RibbonIconSize::Large.rows()),
        items: feature_items,
        icon_size: RibbonIconSize::Large,
        overflow_open: overflow_open.get(&extrude_id).copied().unwrap_or(false),
        overflow_label: Some(strings::TOOLBAR_OVERFLOW),
    }]
}

fn process_ribbon_response(
    response: bone_ui::widgets::RibbonResponse,
    paints: &mut Vec<WidgetPaint>,
    popover_paints: &mut Vec<WidgetPaint>,
    overflow_open: &mut BTreeMap<WidgetId, bool>,
    pointer_pressed: bool,
) -> Option<WidgetId> {
    paints.extend(response.paint);
    popover_paints.extend(response.popover_paint);
    response.overflow_toggled.iter().for_each(|id| {
        let entry = overflow_open.entry(*id).or_insert(false);
        *entry = !*entry;
    });
    if let Some(toggled_id) = response.overflow_toggled.first().copied()
        && overflow_open.get(&toggled_id).copied().unwrap_or(false)
    {
        overflow_open
            .iter_mut()
            .filter(|(k, _)| **k != toggled_id)
            .for_each(|(_, v)| *v = false);
    }
    let any_open = overflow_open.values().any(|v| *v);
    let activated_anything = response.activated_tool.is_some();
    let outside_click = pointer_pressed
        && any_open
        && response.overflow_toggled.is_empty()
        && !response.popup_consumed_click;
    if activated_anything || outside_click {
        overflow_open.values_mut().for_each(|v| *v = false);
    }
    response.activated_tool
}

#[derive(Clone, Debug, PartialEq)]
pub struct SketchRenameRequest {
    pub id: SketchId,
    pub label: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ExtrudeRenameRequest {
    pub id: ExtrudeId,
    pub label: String,
}

struct FeatureTreeOutcome {
    double_activated: Option<WidgetId>,
    sketch_activated: Option<SketchId>,
    sketch_rename: Option<SketchRenameRequest>,
    extrude_activated: Option<ExtrudeId>,
    extrude_rename: Option<ExtrudeRenameRequest>,
    context_menu: Option<(FeatureTarget, LayoutPos)>,
    reorder: Option<FeatureReorder>,
    rollback: Option<RollbackChange>,
    part_activated: bool,
}

fn sketch_widget_id(part_id: WidgetId, sketch_id: SketchId) -> WidgetId {
    part_id.child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64())
}

fn extrude_widget_id(part_id: WidgetId, extrude_id: ExtrudeId) -> WidgetId {
    part_id.child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64())
}

fn sketch_leaf(
    document: &Document,
    part_id: WidgetId,
    sketch_id: SketchId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> TreeNode {
    let widget_id = sketch_widget_id(part_id, sketch_id);
    let label = document.sketch_label(sketch_id).unwrap_or("").to_owned();
    let feature = document.feature_tree().feature_of_sketch(sketch_id);
    let badge = feature.and_then(|feature| badges.get(&feature).copied());
    let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature));
    TreeNode::leaf_owned(widget_id, label)
        .with_icon(IconId::TreeSketch)
        .with_badge(badge)
        .disabled(rolled_back)
}

fn source_sketch_of_extrude(document: &Document, extrude_id: ExtrudeId) -> Option<SketchId> {
    let tree = document.feature_tree();
    let extrude_feature = tree.feature_of_extrude(extrude_id)?;
    tree.parents(extrude_feature)
        .into_iter()
        .find_map(|parent| match tree.node(parent) {
            Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id),
            _ => None,
        })
}

fn extrude_feature_node(
    document: &Document,
    part_id: WidgetId,
    extrude_id: ExtrudeId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> TreeNode {
    let widget_id = extrude_widget_id(part_id, extrude_id);
    let label = document.extrude_label(extrude_id).unwrap_or("").to_owned();
    let nested: Vec<TreeNode> = source_sketch_of_extrude(document, extrude_id)
        .map(|sketch_id| sketch_leaf(document, part_id, sketch_id, badges))
        .into_iter()
        .collect();
    let feature = document.feature_tree().feature_of_extrude(extrude_id);
    let own = feature.and_then(|feature| badges.get(&feature).copied());
    let child = nested.iter().filter_map(|node| node.badge).max();
    let badge = [own, child].into_iter().flatten().max();
    let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature));
    TreeNode::parent_owned(widget_id, label, nested)
        .with_icon(IconId::TreeFeature)
        .with_badge(badge)
        .disabled(rolled_back)
}

fn consumed_sketches(document: &Document) -> BTreeSet<SketchId> {
    let tree = document.feature_tree();
    tree.edges()
        .iter()
        .filter_map(|edge| match edge {
            FeatureEdge::SketchToExtrude { sketch, .. } => match tree.node(*sketch) {
                Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id),
                _ => None,
            },
            FeatureEdge::FaceToSketch { .. } => None,
        })
        .collect()
}

fn feature_tree_children(
    document: &Document,
    part_id: WidgetId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> Vec<TreeNode> {
    let leaf = |key: &'static str, label: StringKey| {
        TreeNode::leaf(part_id.child(WidgetKey::new(key)), label)
    };
    let feature_leaf =
        |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreeFeature);
    let placeholder = |key: &'static str, label: StringKey| feature_leaf(key, label).disabled(true);
    let plane_leaf =
        |key: &'static str, label: StringKey| leaf(key, label).with_icon(IconId::TreePlane);
    [
        placeholder("history", strings::FEATURE_HISTORY),
        placeholder("sensors", strings::FEATURE_SENSORS),
        placeholder("annotations", strings::FEATURE_ANNOTATIONS),
        placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES),
        placeholder("material", strings::FEATURE_MATERIAL),
        plane_leaf("plane.xy", strings::FEATURE_PLANE_XY),
        plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ),
        plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX),
        leaf("origin", strings::FEATURE_ORIGIN).with_icon(IconId::TreeOrigin),
    ]
    .into_iter()
    .chain(feature_rows(document, part_id, badges))
    .collect()
}

fn tree_illegal_drop(
    document: &Document,
    state: &TreeViewState,
    widget_to_sketch: &BTreeMap<WidgetId, SketchId>,
    widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>,
) -> bool {
    state
        .drag_source
        .zip(state.drop_target)
        .is_some_and(|(src, target)| {
            drop_to_reorder(document, widget_to_sketch, widget_to_extrude, src, target).is_none()
        })
}

fn feature_rows(
    document: &Document,
    part_id: WidgetId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> Vec<TreeNode> {
    let consumed = consumed_sketches(document);
    document
        .feature_tree()
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Extrude(extrude_id) => {
                Some(extrude_feature_node(document, part_id, extrude_id, badges))
            }
            FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => {
                Some(sketch_leaf(document, part_id, sketch_id, badges))
            }
            _ => None,
        })
        .collect()
}

fn sketch_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(SketchId, WidgetId)> {
    document
        .sketches()
        .map(|(sketch_id, _)| (sketch_id, sketch_widget_id(part_id, sketch_id)))
        .collect()
}

fn extrude_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(ExtrudeId, WidgetId)> {
    document
        .feature_tree()
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Extrude(extrude_id) => Some(extrude_id),
            _ => None,
        })
        .map(|extrude_id| (extrude_id, extrude_widget_id(part_id, extrude_id)))
        .collect()
}

#[derive(Copy, Clone)]
struct FeatureTreeInputs<'a> {
    rect: LayoutRect,
    tree_id: WidgetId,
    part_id: WidgetId,
    document: &'a Document,
    badges: &'a BTreeMap<FeatureId, TreeBadge>,
    needs_rebuild: bool,
}

impl FeatureTreeOutcome {
    fn empty() -> Self {
        Self {
            double_activated: None,
            sketch_activated: None,
            sketch_rename: None,
            extrude_activated: None,
            extrude_rename: None,
            context_menu: None,
            reorder: None,
            rollback: None,
            part_activated: false,
        }
    }
}

fn resolve_renames(
    commit: Option<&RenameCommit>,
    widget_to_sketch: &BTreeMap<WidgetId, SketchId>,
    widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>,
) -> (Option<SketchRenameRequest>, Option<ExtrudeRenameRequest>) {
    let sketch = commit.and_then(|RenameCommit { id, text }| {
        widget_to_sketch
            .get(id)
            .copied()
            .map(|sketch_id| SketchRenameRequest {
                id: sketch_id,
                label: text.clone(),
            })
    });
    let extrude = commit.and_then(|RenameCommit { id, text }| {
        widget_to_extrude
            .get(id)
            .copied()
            .map(|extrude_id| ExtrudeRenameRequest {
                id: extrude_id,
                label: text.clone(),
            })
    });
    (sketch, extrude)
}

fn render_feature_tree(
    ctx: &mut FrameCtx<'_>,
    state: &mut TreeViewState,
    inputs: FeatureTreeInputs<'_>,
    paints: &mut Vec<WidgetPaint>,
) -> FeatureTreeOutcome {
    let FeatureTreeInputs {
        rect,
        tree_id,
        part_id,
        document,
        badges,
        needs_rebuild,
    } = inputs;
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return FeatureTreeOutcome::empty();
    }
    let sketch_ids = sketch_widget_ids(document, part_id);
    let extrude_ids = extrude_widget_ids(document, part_id);
    let renamable: Vec<WidgetId> = sketch_ids
        .iter()
        .map(|(_, w)| *w)
        .chain(extrude_ids.iter().map(|(_, w)| *w))
        .collect();
    let widget_to_sketch: BTreeMap<WidgetId, SketchId> =
        sketch_ids.iter().map(|(s, w)| (*w, *s)).collect();
    let widget_to_extrude: BTreeMap<WidgetId, ExtrudeId> =
        extrude_ids.iter().map(|(e, w)| (*w, *e)).collect();
    let children = feature_tree_children(document, part_id, badges);
    let part_badge = [
        badges.values().copied().max(),
        needs_rebuild.then_some(TreeBadge::RebuildNeeded),
    ]
    .into_iter()
    .flatten()
    .max();
    let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children)
        .with_badge(part_badge);
    let roots = [part];
    let illegal_drop = tree_illegal_drop(document, state, &widget_to_sketch, &widget_to_extrude);
    let stops = rollback_stops(document, part_id);
    let rollback_bar = (!stops.is_empty()).then(|| RollbackBar {
        stops: &stops,
        marker: current_rollback_target(document, part_id),
    });
    let response = show_tree_view(
        ctx,
        TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state)
            .renamable(&renamable)
            .illegal_drop(illegal_drop)
            .rollback(rollback_bar),
    );
    paints.extend(response.paint);
    let reorder = response.drop_committed.and_then(|(src, target)| {
        drop_to_reorder(document, &widget_to_sketch, &widget_to_extrude, src, target)
    });
    let rollback = response.rollback_moved.and_then(|target| {
        resolve_rollback_change(document, &widget_to_sketch, &widget_to_extrude, target)
    });
    let sketch_activated = response
        .double_activated
        .and_then(|id| widget_to_sketch.get(&id).copied());
    let extrude_activated = response
        .double_activated
        .and_then(|id| widget_to_extrude.get(&id).copied());
    let (sketch_rename, extrude_rename) = resolve_renames(
        response.rename_committed.as_ref(),
        &widget_to_sketch,
        &widget_to_extrude,
    );
    let context_menu =
        resolve_context_menu_target(response.context_menu, &widget_to_sketch, &widget_to_extrude);
    FeatureTreeOutcome {
        double_activated: response.double_activated,
        sketch_activated,
        sketch_rename,
        extrude_activated,
        extrude_rename,
        context_menu,
        reorder,
        rollback,
        part_activated: response.activated == Some(part_id),
    }
}

fn widget_feature(
    document: &Document,
    widget: WidgetId,
    widget_to_sketch: &BTreeMap<WidgetId, SketchId>,
    widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>,
) -> Option<FeatureId> {
    let tree = document.feature_tree();
    widget_to_sketch
        .get(&widget)
        .and_then(|sketch_id| tree.feature_of_sketch(*sketch_id))
        .or_else(|| {
            widget_to_extrude
                .get(&widget)
                .and_then(|extrude_id| tree.feature_of_extrude(*extrude_id))
        })
}

fn drop_to_reorder(
    document: &Document,
    widget_to_sketch: &BTreeMap<WidgetId, SketchId>,
    widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>,
    source: WidgetId,
    target: DropTarget,
) -> Option<FeatureReorder> {
    let moved = widget_feature(document, source, widget_to_sketch, widget_to_extrude)?;
    let anchor = widget_feature(document, target.anchor, widget_to_sketch, widget_to_extrude)?;
    let before = match target.placement {
        DropPlacement::Before => true,
        DropPlacement::After => false,
    };
    document
        .reorder_is_legal(moved, anchor, before)
        .then_some(FeatureReorder {
            moved,
            anchor,
            before,
        })
}

fn feature_widget_id(
    document: &Document,
    part_id: WidgetId,
    feature: FeatureId,
) -> Option<WidgetId> {
    match document.feature_tree().node(feature)? {
        FeatureNode::Sketch(sketch_id) => Some(sketch_widget_id(part_id, sketch_id)),
        FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)),
        _ => None,
    }
}

fn rollback_stops(document: &Document, part_id: WidgetId) -> Vec<WidgetId> {
    let consumed = consumed_sketches(document);
    document
        .feature_tree()
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Extrude(extrude_id) => Some(extrude_widget_id(part_id, extrude_id)),
            FeatureNode::Sketch(sketch_id) if !consumed.contains(&sketch_id) => {
                Some(sketch_widget_id(part_id, sketch_id))
            }
            _ => None,
        })
        .collect()
}

fn current_rollback_target(document: &Document, part_id: WidgetId) -> RollbackTarget {
    match document.rollback() {
        RollbackMarker::AtEnd => RollbackTarget::AtEnd,
        RollbackMarker::Above(feature) => feature_widget_id(document, part_id, feature)
            .map_or(RollbackTarget::AtEnd, RollbackTarget::Above),
    }
}

fn resolve_rollback_change(
    document: &Document,
    widget_to_sketch: &BTreeMap<WidgetId, SketchId>,
    widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>,
    target: RollbackTarget,
) -> Option<RollbackChange> {
    match target {
        RollbackTarget::AtEnd => Some(RollbackChange::ToEnd),
        RollbackTarget::Above(widget) => {
            widget_feature(document, widget, widget_to_sketch, widget_to_extrude)
                .map(RollbackChange::ToFeature)
        }
    }
}

fn resolve_context_menu_target(
    request: Option<ContextMenuRequest>,
    widget_to_sketch: &BTreeMap<WidgetId, SketchId>,
    widget_to_extrude: &BTreeMap<WidgetId, ExtrudeId>,
) -> Option<(FeatureTarget, LayoutPos)> {
    let ContextMenuRequest { target, at } = request?;
    widget_to_sketch
        .get(&target)
        .map(|sketch_id| FeatureTarget::Sketch(*sketch_id))
        .or_else(|| {
            widget_to_extrude
                .get(&target)
                .map(|extrude_id| FeatureTarget::Extrude(*extrude_id))
        })
        .map(|feature_target| (feature_target, at))
}

struct FeatureMenuIds {
    root: WidgetId,
    edit_feature: WidgetId,
    edit_sketch: WidgetId,
    suppress: WidgetId,
    unsuppress: WidgetId,
    rollback: WidgetId,
    delete: WidgetId,
    relationships: WidgetId,
}

impl FeatureMenuIds {
    fn new(base: WidgetId) -> Self {
        let root = base.child(WidgetKey::new("ctxmenu"));
        Self {
            edit_feature: root.child(WidgetKey::new("edit_feature")),
            edit_sketch: root.child(WidgetKey::new("edit_sketch")),
            suppress: root.child(WidgetKey::new("suppress")),
            unsuppress: root.child(WidgetKey::new("unsuppress")),
            rollback: root.child(WidgetKey::new("rollback")),
            delete: root.child(WidgetKey::new("delete")),
            relationships: root.child(WidgetKey::new("relationships")),
            root,
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum FeatureMenuOutcome {
    EditSketch(SketchId),
    EditExtrude(ExtrudeId),
    ShowRelationships(FeatureTarget),
    Command(FeatureCommand),
}

fn feature_id_of(document: &Document, target: FeatureTarget) -> Option<FeatureId> {
    let tree = document.feature_tree();
    match target {
        FeatureTarget::Sketch(sketch_id) => tree.feature_of_sketch(sketch_id),
        FeatureTarget::Extrude(extrude_id) => tree.feature_of_extrude(extrude_id),
    }
}

fn feature_target_sketch(document: &Document, target: FeatureTarget) -> Option<SketchId> {
    match target {
        FeatureTarget::Sketch(sketch_id) => Some(sketch_id),
        FeatureTarget::Extrude(extrude_id) => source_sketch_of_extrude(document, extrude_id),
    }
}

fn feature_menu_items(
    ids: &FeatureMenuIds,
    target: FeatureTarget,
    suppressed: bool,
    has_sketch: bool,
) -> Vec<MenuItem> {
    let action = |id, label| MenuItem::Action {
        id,
        label,
        shortcut: None,
        disabled: false,
    };
    let edit_feature = matches!(target, FeatureTarget::Extrude(_))
        .then(|| action(ids.edit_feature, strings::FEATURE_CTX_EDIT_FEATURE));
    let edit_sketch = has_sketch.then(|| action(ids.edit_sketch, strings::FEATURE_CTX_EDIT_SKETCH));
    let suppression = if suppressed {
        action(ids.unsuppress, strings::FEATURE_CTX_UNSUPPRESS)
    } else {
        action(ids.suppress, strings::FEATURE_CTX_SUPPRESS)
    };
    edit_feature
        .into_iter()
        .chain(edit_sketch)
        .chain([
            suppression,
            action(ids.rollback, strings::FEATURE_CTX_ROLLBACK),
            MenuItem::Separator,
            action(ids.delete, strings::FEATURE_CTX_DELETE),
            MenuItem::Separator,
            action(ids.relationships, strings::FEATURE_CTX_RELATIONSHIPS),
        ])
        .collect()
}

fn feature_menu_outcome_for(
    ids: &FeatureMenuIds,
    activated: WidgetId,
    target: FeatureTarget,
    document: &Document,
) -> Option<FeatureMenuOutcome> {
    let feature = feature_id_of(document, target)?;
    if activated == ids.edit_feature {
        match target {
            FeatureTarget::Extrude(extrude_id) => Some(FeatureMenuOutcome::EditExtrude(extrude_id)),
            FeatureTarget::Sketch(_) => None,
        }
    } else if activated == ids.edit_sketch {
        feature_target_sketch(document, target).map(FeatureMenuOutcome::EditSketch)
    } else if activated == ids.suppress {
        Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress(
            feature,
        )))
    } else if activated == ids.unsuppress {
        Some(FeatureMenuOutcome::Command(FeatureCommand::Unsuppress(
            feature,
        )))
    } else if activated == ids.rollback {
        Some(FeatureMenuOutcome::Command(FeatureCommand::RollbackToHere(
            feature,
        )))
    } else if activated == ids.delete {
        Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target)))
    } else if activated == ids.relationships {
        Some(FeatureMenuOutcome::ShowRelationships(target))
    } else {
        None
    }
}

fn render_feature_context_menu(
    ctx: &mut FrameCtx<'_>,
    base_id: WidgetId,
    state: &mut ShellState,
    document: &Document,
    paints: &mut Vec<WidgetPaint>,
) -> Option<FeatureMenuOutcome> {
    let open = state.feature_menu.open?;
    let target = open.target;
    let ids = FeatureMenuIds::new(base_id);
    let suppressed = feature_id_of(document, target)
        .is_some_and(|feature| document.suppression_state(feature).is_suppressed());
    let has_sketch = feature_target_sketch(document, target).is_some();
    let items = feature_menu_items(&ids, target, suppressed, has_sketch);
    let response = show_context_menu(
        ctx,
        ContextMenu::at_cursor(
            ids.root,
            open.anchor,
            strings::FEATURE_TREE_LABEL,
            &items,
            &mut state.feature_menu.menu,
        ),
    );
    paints.extend(response.paint);
    let outcome = response
        .activated
        .and_then(|id| feature_menu_outcome_for(&ids, id, target, document));
    let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary)
        && ctx
            .input
            .pointer
            .is_some_and(|sample| !response.rect.contains(sample.position));
    if outcome.is_some() || response.close || pressed_outside {
        state.feature_menu.open = None;
        state.feature_menu.menu = MenuState::default();
    }
    outcome
}

const REL_ROW_PX: f32 = 22.0;
const REL_PANEL_WIDTH_PX: f32 = 280.0;
const REL_PAD_PX: f32 = 10.0;

#[derive(Copy, Clone)]
enum RelTone {
    Heading,
    Item,
    Empty,
}

#[derive(Clone)]
struct RelRow {
    text: LabelText,
    indent: f32,
    tone: RelTone,
}

fn feature_label(document: &Document, feature: FeatureId) -> Option<String> {
    match document.feature_tree().node(feature)? {
        FeatureNode::Sketch(sketch_id) => {
            Some(document.sketch_label(sketch_id).unwrap_or("").to_owned())
        }
        FeatureNode::Extrude(extrude_id) => {
            Some(document.extrude_label(extrude_id).unwrap_or("").to_owned())
        }
        _ => None,
    }
}

fn relationship_section(
    document: &Document,
    header: StringKey,
    features: &[FeatureId],
) -> Vec<RelRow> {
    let head = RelRow {
        text: LabelText::Key(header),
        indent: REL_PAD_PX,
        tone: RelTone::Heading,
    };
    let items: Vec<RelRow> = features
        .iter()
        .filter_map(|feature| feature_label(document, *feature))
        .map(|label| RelRow {
            text: LabelText::Owned(label),
            indent: REL_PAD_PX + 12.0,
            tone: RelTone::Item,
        })
        .collect();
    let body = if items.is_empty() {
        vec![RelRow {
            text: LabelText::Key(strings::FEATURE_REL_NONE),
            indent: REL_PAD_PX + 12.0,
            tone: RelTone::Empty,
        }]
    } else {
        items
    };
    std::iter::once(head).chain(body).collect()
}

fn relationship_rows(document: &Document, feature: FeatureId) -> Vec<RelRow> {
    let tree = document.feature_tree();
    let parents = tree.parents(feature);
    let children = tree.children(feature);
    relationship_section(document, strings::FEATURE_REL_PARENTS, &parents)
        .into_iter()
        .chain(relationship_section(
            document,
            strings::FEATURE_REL_CHILDREN,
            &children,
        ))
        .collect()
}

fn relationships_panel_rect(viewport: LayoutRect, content_height: f32) -> LayoutRect {
    let height = REL_PAD_PX * 2.0 + REL_ROW_PX + content_height;
    let x = viewport.origin.x.value() + (viewport.size.width.value() - REL_PANEL_WIDTH_PX) / 2.0;
    let y = viewport.origin.y.value() + (viewport.size.height.value() - height) / 2.0;
    LayoutRect::new(
        LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
        LayoutSize::new(LayoutPx::new(REL_PANEL_WIDTH_PX), LayoutPx::new(height)),
    )
}

fn relationships_close_rect(panel: LayoutRect) -> LayoutRect {
    let width = 56.0;
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(panel.origin.x.value() + panel.size.width.value() - width - REL_PAD_PX),
            LayoutPx::new(panel.origin.y.value() + REL_PAD_PX),
        ),
        LayoutSize::new(LayoutPx::new(width), LayoutPx::new(REL_ROW_PX)),
    )
}

fn rel_row_paint(ctx: &FrameCtx<'_>, body: LayoutRect, row: &RelRow, top: f32) -> WidgetPaint {
    let rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body.origin.x.value() + row.indent),
            LayoutPx::new(top),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() - row.indent - REL_PAD_PX),
            LayoutPx::new(REL_ROW_PX),
        ),
    );
    let (color, role) = match row.tone {
        RelTone::Heading => (
            ctx.theme().colors.text_secondary(),
            ctx.theme().typography.label,
        ),
        RelTone::Item => (
            ctx.theme().colors.text_primary(),
            ctx.theme().typography.body,
        ),
        RelTone::Empty => (
            ctx.theme().colors.text_disabled(),
            ctx.theme().typography.body,
        ),
    };
    WidgetPaint::AlignedLabel {
        rect,
        text: row.text.clone(),
        color,
        role,
        align: HorizontalAlign::Start,
    }
}

fn render_relationships_panel(
    ctx: &mut FrameCtx<'_>,
    base_id: WidgetId,
    viewport: LayoutRect,
    target: FeatureTarget,
    document: &Document,
    paints: &mut Vec<WidgetPaint>,
) -> bool {
    let Some(feature) = feature_id_of(document, target) else {
        return true;
    };
    let rows = relationship_rows(document, feature);
    let content_height = rows.iter().fold(0.0_f32, |acc, _| acc + REL_ROW_PX);
    let rect = relationships_panel_rect(viewport, content_height);
    let panel_id = base_id.child(WidgetKey::new("relationships"));
    let mut panel_state = PanelState::open();
    let response = show_panel(
        ctx,
        Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card),
    );
    paints.extend(response.paint);
    let Some(body) = response.body_rect else {
        return false;
    };
    paints.push(WidgetPaint::AlignedLabel {
        rect: LayoutRect::new(
            LayoutPos::new(
                LayoutPx::new(body.origin.x.value() + REL_PAD_PX),
                LayoutPx::new(body.origin.y.value() + REL_PAD_PX),
            ),
            LayoutSize::new(
                LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX),
                LayoutPx::new(REL_ROW_PX),
            ),
        ),
        text: LabelText::Key(strings::FEATURE_CTX_RELATIONSHIPS),
        color: ctx.theme().colors.text_primary(),
        role: ctx.theme().typography.title,
        align: HorizontalAlign::Start,
    });
    let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX;
    paints.extend(rows.iter().scan(start_y, |top, row| {
        let current = *top;
        *top += REL_ROW_PX;
        Some(rel_row_paint(ctx, body, row, current))
    }));
    let close_id = panel_id.child(WidgetKey::new("close"));
    let close_rect = relationships_close_rect(rect);
    let close = ctx.interact(
        InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE)
            .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)),
    );
    paints.push(WidgetPaint::AlignedLabel {
        rect: close_rect,
        text: LabelText::Key(strings::FEATURE_REL_CLOSE),
        color: if close.hover() {
            ctx.theme().colors.text_primary()
        } else {
            ctx.theme().colors.text_secondary()
        },
        role: ctx.theme().typography.label,
        align: HorizontalAlign::End,
    });
    let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary)
        && ctx
            .input
            .pointer
            .is_some_and(|sample| !rect.contains(sample.position));
    close.click() || pressed_outside
}

struct WhatsWrongOutcome {
    close: bool,
    reattach: Option<SketchId>,
}

fn whats_wrong_row_paint(
    ctx: &mut FrameCtx<'_>,
    panel_id: WidgetId,
    body: LayoutRect,
    entry: &WhatsWrong,
    top: f32,
    paints: &mut Vec<WidgetPaint>,
) -> Option<SketchId> {
    let message_color = if entry.is_error {
        ctx.theme().colors.danger.step(Step12::SOLID)
    } else {
        ctx.theme().colors.warning.step(Step12::SOLID)
    };
    let message_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body.origin.x.value() + REL_PAD_PX),
            LayoutPx::new(top),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() * 0.55 - REL_PAD_PX),
            LayoutPx::new(REL_ROW_PX),
        ),
    );
    paints.push(WidgetPaint::AlignedLabel {
        rect: message_rect,
        text: LabelText::Key(entry.message),
        color: message_color,
        role: ctx.theme().typography.body,
        align: HorizontalAlign::Start,
    });
    let right_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body.origin.x.value() + body.size.width.value() * 0.55),
            LayoutPx::new(top),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() * 0.45 - REL_PAD_PX),
            LayoutPx::new(REL_ROW_PX),
        ),
    );
    let Some(sketch_id) = entry.reattach else {
        paints.push(WidgetPaint::AlignedLabel {
            rect: right_rect,
            text: LabelText::Owned(entry.label.clone()),
            color: ctx.theme().colors.text_secondary(),
            role: ctx.theme().typography.caption,
            align: HorizontalAlign::End,
        });
        return None;
    };
    let reattach_id = panel_id.child_indexed(WidgetKey::new("reattach"), sketch_id.as_u64());
    let interaction = ctx.interact(
        InteractDeclaration::new(reattach_id, right_rect, Sense::INTERACTIVE)
            .a11y(AccessNode::new(Role::Button).with_label(strings::WHATS_WRONG_REATTACH)),
    );
    paints.push(WidgetPaint::AlignedLabel {
        rect: right_rect,
        text: LabelText::Key(strings::WHATS_WRONG_REATTACH),
        color: ctx.theme().colors.accent.step(Step12::SOLID),
        role: ctx.theme().typography.label,
        align: HorizontalAlign::End,
    });
    interaction.click().then_some(sketch_id)
}

fn render_whats_wrong_panel(
    ctx: &mut FrameCtx<'_>,
    base_id: WidgetId,
    viewport: LayoutRect,
    entries: &[WhatsWrong],
    paints: &mut Vec<WidgetPaint>,
) -> WhatsWrongOutcome {
    let line_count = entries.len().max(1);
    let content_height = (0..line_count).fold(0.0_f32, |height, _| height + REL_ROW_PX);
    let rect = relationships_panel_rect(viewport, content_height);
    let panel_id = base_id.child(WidgetKey::new("whats_wrong"));
    let mut panel_state = PanelState::open();
    let response = show_panel(
        ctx,
        Panel::new(panel_id, rect, &mut panel_state).variant(PanelVariant::Card),
    );
    paints.extend(response.paint);
    let Some(body) = response.body_rect else {
        return WhatsWrongOutcome {
            close: false,
            reattach: None,
        };
    };
    paints.push(WidgetPaint::AlignedLabel {
        rect: LayoutRect::new(
            LayoutPos::new(
                LayoutPx::new(body.origin.x.value() + REL_PAD_PX),
                LayoutPx::new(body.origin.y.value() + REL_PAD_PX),
            ),
            LayoutSize::new(
                LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX),
                LayoutPx::new(REL_ROW_PX),
            ),
        ),
        text: LabelText::Key(strings::WHATS_WRONG_TITLE),
        color: ctx.theme().colors.text_primary(),
        role: ctx.theme().typography.title,
        align: HorizontalAlign::Start,
    });
    let start_y = body.origin.y.value() + REL_PAD_PX + REL_ROW_PX;
    let reattach = if entries.is_empty() {
        paints.push(WidgetPaint::AlignedLabel {
            rect: LayoutRect::new(
                LayoutPos::new(
                    LayoutPx::new(body.origin.x.value() + REL_PAD_PX),
                    LayoutPx::new(start_y),
                ),
                LayoutSize::new(
                    LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * REL_PAD_PX),
                    LayoutPx::new(REL_ROW_PX),
                ),
            ),
            text: LabelText::Key(strings::WHATS_WRONG_NONE),
            color: ctx.theme().colors.text_disabled(),
            role: ctx.theme().typography.body,
            align: HorizontalAlign::Start,
        });
        None
    } else {
        entries
            .iter()
            .enumerate()
            .fold(None, |acc, (index, entry)| {
                let top = start_y + index_offset(index);
                acc.or(whats_wrong_row_paint(
                    ctx, panel_id, body, entry, top, paints,
                ))
            })
    };
    let close_id = panel_id.child(WidgetKey::new("close"));
    let close_rect = relationships_close_rect(rect);
    let close = ctx.interact(
        InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE)
            .a11y(AccessNode::new(Role::Button).with_label(strings::FEATURE_REL_CLOSE)),
    );
    paints.push(WidgetPaint::AlignedLabel {
        rect: close_rect,
        text: LabelText::Key(strings::FEATURE_REL_CLOSE),
        color: if close.hover() {
            ctx.theme().colors.text_primary()
        } else {
            ctx.theme().colors.text_secondary()
        },
        role: ctx.theme().typography.label,
        align: HorizontalAlign::End,
    });
    let pressed_outside = ctx.input.buttons_pressed.contains(PointerButton::Primary)
        && ctx
            .input
            .pointer
            .is_some_and(|sample| !rect.contains(sample.position));
    WhatsWrongOutcome {
        close: close.click() || pressed_outside,
        reattach,
    }
}

fn index_offset(index: usize) -> f32 {
    f32::from(u16::try_from(index).unwrap_or(u16::MAX)) * REL_ROW_PX
}

#[derive(Copy, Clone)]
struct PropertyState<'a> {
    mode: &'a Mode,
    sketch: Option<&'a Sketch>,
    selection: &'a Selection,
}

#[derive(Default)]
struct PropertyPaneOutcome {
    dimension_edit: Option<DimensionEdit>,
    extrude_edit: Option<ExtrudeEdit>,
    confirm: Option<ConfirmAction>,
}

struct PaneEditors<'a> {
    dim: &'a mut Option<DimPropertyEditor>,
    extrude: &'a mut Option<ExtrudePropertyEditor>,
    groups: &'a mut BTreeMap<WidgetId, PanelState>,
}

fn render_property_pane(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    editors: &mut PaneEditors<'_>,
    state: PropertyState<'_>,
    paints: &mut Vec<WidgetPaint>,
) -> PropertyPaneOutcome {
    let in_sketch = matches!(state.mode, Mode::Sketch { .. });
    let active_sketch_id = state.mode.sketch_id();
    let resolved = state
        .sketch
        .filter(|_| in_sketch)
        .and_then(|s| resolve_selection_target(s, state.selection).map(|t| (s, t)));
    if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) {
        *editors.dim = None;
    }
    if !matches!(state.mode, Mode::Extrude(ExtrudeArming::Profile { .. })) {
        *editors.extrude = None;
    }
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return PropertyPaneOutcome::default();
    }
    if let Mode::Extrude(arming) = state.mode {
        return match arming {
            ExtrudeArming::Profile { feature, .. } => {
                let outcome =
                    render_extrude_rows(ctx, rect, id, clipboard, editors, *feature, paints);
                PropertyPaneOutcome {
                    dimension_edit: None,
                    extrude_edit: outcome.edit,
                    confirm: outcome.confirm,
                }
            }
            ExtrudeArming::AwaitingSketch => {
                let mut editors = vec![row_editor(strings::EXTRUDE_PROMPT_SELECT_SKETCH, "")];
                render_static_rows(ctx, rect, id, clipboard, &mut editors, paints);
                PropertyPaneOutcome::default()
            }
        };
    }
    match resolved {
        Some((sketch, SelectionTarget::Entity(entity))) => {
            let mut editors = entity_editors(ctx.strings, entity, sketch);
            render_static_rows(ctx, rect, id, clipboard, &mut editors, paints);
            PropertyPaneOutcome::default()
        }
        Some((sketch, SelectionTarget::Relation(rel))) => {
            let mut editors = relation_editors(ctx.strings, rel, sketch);
            render_static_rows(ctx, rect, id, clipboard, &mut editors, paints);
            PropertyPaneOutcome::default()
        }
        Some((sketch, SelectionTarget::Dimension(dim_id, dim))) => {
            let Some(sketch_id) = active_sketch_id else {
                return PropertyPaneOutcome::default();
            };
            PropertyPaneOutcome {
                dimension_edit: render_dimension_rows(
                    ctx,
                    rect,
                    id,
                    clipboard,
                    editors.dim,
                    sketch_id,
                    dim_id,
                    dim,
                    sketch,
                    paints,
                ),
                extrude_edit: None,
                confirm: None,
            }
        }
        None => {
            let mut editors = vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")];
            render_static_rows(ctx, rect, id, clipboard, &mut editors, paints);
            PropertyPaneOutcome::default()
        }
    }
}

fn sync_extrude_editor(
    slot: &mut Option<ExtrudePropertyEditor>,
    feature: ExtrudeFeature,
) -> &mut ExtrudePropertyEditor {
    let editor = match slot.take() {
        Some(editor) if editor.sketch == feature.sketch => editor.synced(feature),
        _ => ExtrudePropertyEditor::new(feature),
    };
    slot.insert(editor)
}

const PM_HEADER_HEIGHT: f32 = 44.0;
const PM_GROUP_TITLE_HEIGHT: f32 = 22.0;
const PM_GROUP_ROW_HEIGHT: f32 = 22.0;
const PM_GROUP_GAP: f32 = 6.0;

struct ExtrudeRowsOutcome {
    edit: Option<ExtrudeEdit>,
    confirm: Option<ConfirmAction>,
}

#[derive(Copy, Clone)]
struct PropertyGroupSpec {
    id: WidgetId,
    title: StringKey,
    top_left: LayoutPos,
    width: LayoutPx,
}

fn extrude_row_id(key: &'static str) -> WidgetId {
    WidgetId::ROOT
        .child(WidgetKey::new("props.extrude"))
        .child(WidgetKey::new(key))
}

fn direction_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> {
    vec![
        PropertyRow {
            id: extrude_row_id("end"),
            label: strings::PROPERTY_ROW_EXTRUDE_END,
            editor: &mut editor.end_condition,
            read_only: false,
        },
        PropertyRow {
            id: extrude_row_id("depth"),
            label: strings::PROPERTY_ROW_EXTRUDE_DEPTH,
            editor: &mut editor.depth,
            read_only: false,
        },
        PropertyRow {
            id: extrude_row_id("draft"),
            label: strings::PROPERTY_ROW_EXTRUDE_DRAFT,
            editor: &mut editor.draft_enabled,
            read_only: true,
        },
        PropertyRow {
            id: extrude_row_id("draft_angle"),
            label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE,
            editor: &mut editor.draft_angle,
            read_only: true,
        },
        PropertyRow {
            id: extrude_row_id("direction_two"),
            label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO,
            editor: &mut editor.direction_two,
            read_only: true,
        },
    ]
}

fn scope_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> {
    vec![
        PropertyRow {
            id: extrude_row_id("thin"),
            label: strings::PROPERTY_ROW_EXTRUDE_THIN,
            editor: &mut editor.thin,
            read_only: true,
        },
        PropertyRow {
            id: extrude_row_id("merge"),
            label: strings::PROPERTY_ROW_EXTRUDE_MERGE,
            editor: &mut editor.merge,
            read_only: false,
        },
    ]
}

fn render_property_group(
    ctx: &mut FrameCtx<'_>,
    clipboard: &mut MemoryClipboard,
    groups: &mut BTreeMap<WidgetId, PanelState>,
    spec: PropertyGroupSpec,
    rows: &mut Vec<PropertyRow<'_>>,
    paints: &mut Vec<WidgetPaint>,
) -> (LayoutPx, Vec<WidgetId>) {
    let collapsed = groups.get(&spec.id).is_some_and(|s| s.collapsed);
    #[allow(
        clippy::cast_precision_loss,
        reason = "property row counts fit the f32 mantissa"
    )]
    let body_height = if collapsed {
        0.0
    } else {
        rows.len() as f32 * PM_GROUP_ROW_HEIGHT
    };
    let group_rect = LayoutRect::new(
        spec.top_left,
        LayoutSize::new(
            spec.width,
            LayoutPx::new(PM_GROUP_TITLE_HEIGHT + body_height),
        ),
    );
    let state = groups.entry(spec.id).or_default();
    let response = show_panel(
        ctx,
        Panel::new(spec.id, group_rect, state)
            .variant(PanelVariant::Card)
            .titlebar(PanelTitlebar {
                label: spec.title,
                height: LayoutPx::new(PM_GROUP_TITLE_HEIGHT),
                collapsible: true,
            }),
    );
    paints.extend(response.paint);
    let changed = match response.body_rect {
        Some(body) => {
            let grid = show_property_grid(
                ctx,
                PropertyGrid::new(
                    spec.id.child(WidgetKey::new("grid")),
                    body,
                    spec.title,
                    rows,
                ),
                clipboard,
            );
            paints.extend(grid.paint);
            grid.changed_rows
        }
        None => Vec::new(),
    };
    let next_y =
        LayoutPx::new(group_rect.origin.y.value() + group_rect.size.height.value() + PM_GROUP_GAP);
    (next_y, changed)
}

fn extrude_pane_header(
    ctx: &mut FrameCtx<'_>,
    id: WidgetId,
    rect: LayoutRect,
) -> (Option<ConfirmAction>, Vec<WidgetPaint>) {
    let header_id = id.child(WidgetKey::new("header"));
    let header = show_property_pane_header(
        ctx,
        PropertyPaneHeader {
            id: header_id,
            rect: LayoutRect::new(
                rect.origin,
                LayoutSize::new(rect.size.width, LayoutPx::new(PM_HEADER_HEIGHT)),
            ),
            title: strings::PROPERTY_PANE_EXTRUDE_TITLE,
            accept_id: header_id.child(WidgetKey::new("accept")),
            cancel_id: header_id.child(WidgetKey::new("cancel")),
        },
    );
    let confirm = match header.action {
        Some(PropertyPaneAction::Accept) => Some(ConfirmAction::Accept),
        Some(PropertyPaneAction::Cancel) => Some(ConfirmAction::Cancel),
        None => None,
    };
    (confirm, header.paint)
}

fn render_extrude_rows(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    editors: &mut PaneEditors<'_>,
    feature: ExtrudeFeature,
    paints: &mut Vec<WidgetPaint>,
) -> ExtrudeRowsOutcome {
    ctx.a11y.push(
        id,
        rect,
        AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL),
    );
    let (confirm, header_paint) = extrude_pane_header(ctx, id, rect);
    paints.extend(header_paint);

    let editor = sync_extrude_editor(editors.extrude, feature);
    let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP);
    let mut changed: Vec<WidgetId> = Vec::new();
    let scope_top = {
        let mut rows = direction_group_rows(editor);
        let (next_y, ch) = render_property_group(
            ctx,
            clipboard,
            editors.groups,
            PropertyGroupSpec {
                id: id.child(WidgetKey::new("group.direction1")),
                title: strings::PROPERTY_GROUP_DIRECTION_1,
                top_left: LayoutPos::new(rect.origin.x, groups_top),
                width: rect.size.width,
            },
            &mut rows,
            paints,
        );
        changed.extend(ch);
        next_y
    };
    {
        let mut rows = scope_group_rows(editor);
        let (_next_y, ch) = render_property_group(
            ctx,
            clipboard,
            editors.groups,
            PropertyGroupSpec {
                id: id.child(WidgetKey::new("group.feature_scope")),
                title: strings::PROPERTY_GROUP_FEATURE_SCOPE,
                top_left: LayoutPos::new(rect.origin.x, scope_top),
                width: rect.size.width,
            },
            &mut rows,
            paints,
        );
        changed.extend(ch);
    }

    let edit = if changed.contains(&extrude_row_id("end")) {
        kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition)
    } else if changed.contains(&extrude_row_id("depth")) {
        PositiveLength::new(editor.depth.value)
            .ok()
            .map(ExtrudeEdit::Depth)
    } else if changed.contains(&extrude_row_id("merge")) {
        Some(ExtrudeEdit::Merge(if editor.merge.value {
            MergeResult::Merge
        } else {
            MergeResult::Separate
        }))
    } else {
        None
    };
    ExtrudeRowsOutcome { edit, confirm }
}

enum SelectionTarget {
    Entity(SketchEntity),
    Relation(SketchRelation),
    Dimension(SketchDimensionId, SketchDimension),
}

fn resolve_selection_target(sketch: &Sketch, selection: &Selection) -> Option<SelectionTarget> {
    match selection {
        Selection::Entities(ids) => match ids.as_slice() {
            [id] => sketch
                .entities()
                .get(*id)
                .copied()
                .map(SelectionTarget::Entity),
            _ => None,
        },
        Selection::Relation(id) => sketch
            .relations()
            .get(*id)
            .copied()
            .map(SelectionTarget::Relation),
        Selection::Dimension(id) => sketch
            .dimensions()
            .get(*id)
            .copied()
            .map(|d| SelectionTarget::Dimension(*id, d)),
    }
}

fn render_static_rows(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    editors: &mut [PropertyRowSpec],
    paints: &mut Vec<WidgetPaint>,
) {
    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);
}

#[allow(
    clippy::too_many_arguments,
    reason = "splitting the property-pane render call harms locality"
)]
fn render_dimension_rows(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    dim_property: &mut Option<DimPropertyEditor>,
    sketch_id: SketchId,
    dim_id: SketchDimensionId,
    dim: SketchDimension,
    sketch: &Sketch,
    paints: &mut Vec<WidgetPaint>,
) -> Option<DimensionEdit> {
    let driving = matches!(dim.kind(), DimensionKind::Driving);
    let kind_label = dimension_kind_label(dim);
    let kind_value_key = dimension_drive_key(dim.kind());
    let value_row_id = WidgetId::ROOT
        .child(WidgetKey::new("props.dim"))
        .child(WidgetKey::new("value"));
    let dim_property_slot = sync_dim_editor(dim_property, sketch_id, dim_id, dim);
    let mut static_specs: Vec<PropertyRowSpec> = vec![row_editor(
        strings::PROPERTY_ROW_DIM_KIND,
        ctx.strings.resolve(kind_label).to_owned(),
    )];
    static_specs.extend(dimension_static_rows(ctx.strings, dim, sketch));
    static_specs.push(row_editor(
        strings::PROPERTY_ROW_DIM_DRIVES,
        ctx.strings.resolve(kind_value_key).to_owned(),
    ));
    let mut rows: Vec<PropertyRow<'_>> = static_specs
        .iter_mut()
        .map(|(row_id, label, editor)| PropertyRow {
            id: *row_id,
            label: *label,
            editor: editor.as_mut(),
            read_only: true,
        })
        .collect();
    let value_label = match dim {
        SketchDimension::Linear { .. } => strings::PROPERTY_ROW_DIM_LENGTH,
        SketchDimension::Radius { .. } => strings::PROPERTY_ROW_RADIUS,
        SketchDimension::Diameter { .. } => strings::PROPERTY_ROW_DIM_DIAMETER,
        SketchDimension::Angular { .. } => strings::PROPERTY_ROW_DIM_ANGLE,
    };
    let editor_ref: &mut dyn PropertyEditor = match dim_property_slot {
        DimPropertyEditor::Length { editor, .. } => editor,
        DimPropertyEditor::Angle { editor, .. } => editor,
    };
    rows.push(PropertyRow {
        id: value_row_id,
        label: value_label,
        editor: editor_ref,
        read_only: !driving,
    });
    let response = show_property_grid(
        ctx,
        PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows),
        clipboard,
    );
    paints.extend(response.paint);
    if !driving || !response.changed_rows.contains(&value_row_id) {
        return None;
    }
    Some(DimensionEdit {
        id: dim_id,
        value: match dim_property_slot {
            DimPropertyEditor::Length { editor, .. } => DimensionValue::Length(editor.value),
            DimPropertyEditor::Angle { editor, .. } => DimensionValue::Angle(editor.value),
        },
    })
}

fn sync_dim_editor(
    slot: &mut Option<DimPropertyEditor>,
    sketch_id: SketchId,
    dim_id: SketchDimensionId,
    dim: SketchDimension,
) -> &mut DimPropertyEditor {
    let editor = match (slot.take(), dim.value()) {
        (
            Some(DimPropertyEditor::Length {
                sketch_id: prev_sketch,
                id,
                mut editor,
            }),
            DimensionValue::Length(v),
        ) if prev_sketch == sketch_id && id == dim_id => {
            editor.value = v;
            DimPropertyEditor::Length {
                sketch_id,
                id,
                editor,
            }
        }
        (
            Some(DimPropertyEditor::Angle {
                sketch_id: prev_sketch,
                id,
                mut editor,
            }),
            DimensionValue::Angle(v),
        ) if prev_sketch == sketch_id && id == dim_id => {
            editor.value = v;
            DimPropertyEditor::Angle {
                sketch_id,
                id,
                editor,
            }
        }
        (_, DimensionValue::Length(v)) => DimPropertyEditor::Length {
            sketch_id,
            id: dim_id,
            editor: LengthEditor::new(v),
        },
        (_, DimensionValue::Angle(v)) => DimPropertyEditor::Angle {
            sketch_id,
            id: dim_id,
            editor: AngleEditor::new(v),
        },
    };
    slot.insert(editor)
}

fn dimension_kind_label(dim: SketchDimension) -> StringKey {
    match dim {
        SketchDimension::Linear { .. } => strings::PROPERTY_KIND_DIM_LINEAR,
        SketchDimension::Radius { .. } => strings::PROPERTY_KIND_DIM_RADIUS,
        SketchDimension::Diameter { .. } => strings::PROPERTY_KIND_DIM_DIAMETER,
        SketchDimension::Angular { .. } => strings::PROPERTY_KIND_DIM_ANGULAR,
    }
}

fn dimension_drive_key(kind: DimensionKind) -> StringKey {
    match kind {
        DimensionKind::Driving => strings::PROPERTY_VALUE_DRIVING,
        DimensionKind::Driven => strings::PROPERTY_VALUE_DRIVEN,
    }
}

fn dimension_static_rows(
    strings_table: &StringTable,
    dim: SketchDimension,
    sketch: &Sketch,
) -> Vec<PropertyRowSpec> {
    let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id);
    match dim {
        SketchDimension::Linear { a, b, .. } | SketchDimension::Angular { a, b, .. } => vec![
            row_editor(strings::PROPERTY_ROW_FROM, label(a)),
            row_editor(strings::PROPERTY_ROW_TO, label(b)),
        ],
        SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => {
            vec![row_editor(strings::PROPERTY_ROW_TARGET, label(target))]
        }
    }
}

fn relation_editors(
    strings_table: &StringTable,
    rel: SketchRelation,
    sketch: &Sketch,
) -> Vec<PropertyRowSpec> {
    let kind_key = relation_kind_key(rel);
    let kind = row_editor(
        strings::PROPERTY_ROW_KIND,
        strings_table.resolve(kind_key).to_owned(),
    );
    let label = |id: SketchEntityId| endpoint_or_entity_label(strings_table, sketch, id);
    let mut specs = vec![kind];
    match rel {
        SketchRelation::Coincident(a, b)
        | SketchRelation::Parallel(a, b)
        | SketchRelation::Perpendicular(a, b)
        | SketchRelation::Tangent(a, b)
        | SketchRelation::Equal(a, b)
        | SketchRelation::Concentric(a, b) => {
            specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a)));
            specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b)));
        }
        SketchRelation::Midpoint { point, line } => {
            specs.push(row_editor(strings::PROPERTY_ROW_POINT, label(point)));
            specs.push(row_editor(strings::PROPERTY_ROW_LINE, label(line)));
        }
        SketchRelation::Symmetric { a, b, axis } => {
            specs.push(row_editor(strings::PROPERTY_ROW_FIRST, label(a)));
            specs.push(row_editor(strings::PROPERTY_ROW_SECOND, label(b)));
            specs.push(row_editor(strings::PROPERTY_ROW_AXIS, label(axis)));
        }
        SketchRelation::Horizontal(a) | SketchRelation::Vertical(a) | SketchRelation::Fix(a) => {
            specs.push(row_editor(strings::PROPERTY_ROW_TARGET, label(a)));
        }
    }
    specs
        .into_iter()
        .enumerate()
        .map(|(idx, (_default_id, label, editor))| {
            let row_id = WidgetId::ROOT
                .child(WidgetKey::new("props.relation"))
                .child_indexed(WidgetKey::new("row"), idx as u64);
            (row_id, label, editor)
        })
        .collect()
}

fn relation_kind_key(rel: SketchRelation) -> StringKey {
    match rel {
        SketchRelation::Coincident(_, _) => strings::TOOL_COINCIDENT,
        SketchRelation::Horizontal(_) => strings::TOOL_HORIZONTAL,
        SketchRelation::Vertical(_) => strings::TOOL_VERTICAL,
        SketchRelation::Parallel(_, _) => strings::TOOL_PARALLEL,
        SketchRelation::Perpendicular(_, _) => strings::TOOL_PERPENDICULAR,
        SketchRelation::Tangent(_, _) => strings::TOOL_TANGENT,
        SketchRelation::Equal(_, _) => strings::TOOL_EQUAL,
        SketchRelation::Concentric(_, _) => strings::TOOL_CONCENTRIC,
        SketchRelation::Midpoint { .. } => strings::TOOL_MIDPOINT,
        SketchRelation::Symmetric { .. } => strings::TOOL_SYMMETRIC,
        SketchRelation::Fix(_) => strings::TOOL_FIX,
    }
}

fn endpoint_or_entity_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(SketchEntity::Line(_)) => strings_table
            .resolve(strings::PROPERTY_KIND_LINE)
            .to_owned(),
        Some(SketchEntity::Arc(_)) => strings_table.resolve(strings::PROPERTY_KIND_ARC).to_owned(),
        Some(SketchEntity::Circle(_)) => strings_table
            .resolve(strings::PROPERTY_KIND_CIRCLE)
            .to_owned(),
        None => "?".to_owned(),
    }
}

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_or_entity_label(strings_table, sketch, l.a());
            let to = endpoint_or_entity_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_or_entity_label(strings_table, sketch, a.center());
            let start = endpoint_or_entity_label(strings_table, sketch, a.start());
            let end = endpoint_or_entity_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_or_entity_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 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 {
        let label = ctx.strings.resolve(cell.label);
        let a11y_label = if self.value.is_empty() {
            label.to_owned()
        } else {
            format!("{label}: {}", self.value)
        };
        ctx.a11y.push(
            cell.row_id,
            cell.rect,
            AccessNode::new(Role::Label).with_label_text(LabelText::Owned(a11y_label)),
        );
        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
    }
}

const DOC_TAB_WIDTH_PX: f32 = 80.0;

fn render_doc_tabs(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    ids: &ShellIds,
    paints: &mut Vec<WidgetPaint>,
) {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return;
    }
    let theme = ctx.theme();
    paints.push(surface_for(rect, theme.elevation.level1, theme));
    let tab_rect = LayoutRect::new(
        rect.origin,
        LayoutSize::new(LayoutPx::new(DOC_TAB_WIDTH_PX), rect.size.height),
    );
    let tabs = [Tab::new(
        ids.doc_tab_model,
        tab_rect,
        strings::DOC_TAB_MODEL,
    )];
    let response = show_tabs(
        ctx,
        Tabs::new(
            ids.doc_tabs,
            TabsOrientation::Top,
            strings::DOC_TABS_LABEL,
            tabs.as_slice(),
            ids.doc_tab_model,
        ),
    );
    paints.extend(response.paint);
}

#[allow(
    clippy::too_many_arguments,
    reason = "status bar bundles mode + cursor + status diagnostics in one render pass"
)]
fn render_status_bar(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    mode: &Mode,
    document: &Document,
    cursor_world: Option<Point2>,
    status_report: Option<&SketchStatusReport>,
    status_badge_id: WidgetId,
    extrude_status: Option<ExtrudeStatus<'_>>,
    extrude_badge_id: WidgetId,
    paints: &mut Vec<WidgetPaint>,
) -> Option<WidgetId> {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let mode_label = mode_status_label(ctx.strings, mode, document);
    let mode_item = StatusItem::with_text(
        id.child(WidgetKey::new("mode")),
        mode_label,
        StatusAlign::Start,
        STATUS_MODE_WIDTH,
    );
    let units_item = StatusItem::new(
        id.child(WidgetKey::new("units")),
        strings::STATUS_UNITS_MM,
        StatusAlign::End,
        STATUS_UNITS_WIDTH,
    );
    let coords_item = mode
        .is_sketch()
        .then(|| {
            cursor_world.map(|world| {
                let (x_mm, y_mm) = world.coords_mm();
                StatusItem::with_text(
                    id.child(WidgetKey::new("coords")),
                    LabelText::Owned(format!("{x_mm:.2}, {y_mm:.2} mm")),
                    StatusAlign::Center,
                    STATUS_COORDS_WIDTH,
                )
            })
        })
        .flatten();
    let status_item = status_report.map(|report| {
        let has_panel_content = !report.offending().is_empty();
        StatusItem::new(
            status_badge_id,
            status_label_key(report.status()),
            StatusAlign::End,
            STATUS_STATUS_WIDTH,
        )
        .interactive(has_panel_content)
        .badge(status_color(report.status(), &ctx.theme().cad))
    });
    let extrude_item = extrude_status.map(|status| {
        let (label, color) = extrude_badge_style(status, &ctx.theme().cad);
        StatusItem::new(
            extrude_badge_id,
            label,
            StatusAlign::End,
            STATUS_STATUS_WIDTH,
        )
        .interactive(status.error().is_some())
        .badge(color)
    });
    let mut items: Vec<StatusItem> = vec![mode_item];
    if let Some(coords) = coords_item {
        items.push(coords);
    }
    if let Some(status) = status_item {
        items.push(status);
    }
    if let Some(extrude) = extrude_item {
        items.push(extrude);
    }
    items.push(units_item);
    let response = show_status_bar(
        ctx,
        StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items),
    );
    paints.extend(response.paint);
    response
        .activated
        .filter(|id| *id == status_badge_id || *id == extrude_badge_id)
}

fn mode_status_label(strings_table: &StringTable, mode: &Mode, document: &Document) -> LabelText {
    match mode {
        Mode::Idle => LabelText::Key(strings::STATUS_READY),
        Mode::Extrude(_) => LabelText::Key(strings::STATUS_EXTRUDE_ACTIVE),
        Mode::Sketch { sketch_id, .. } => {
            let Some(label) = document.sketch_label(*sketch_id) else {
                tracing::warn!(?sketch_id, "active sketch missing from document");
                return LabelText::Key(strings::STATUS_READY);
            };
            let prefix = strings_table.resolve(strings::STATUS_SKETCH_ACTIVE);
            LabelText::Owned(format!("{prefix} {label}"))
        }
    }
}

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, rows: usize) -> LayoutPx {
    let rows = rows.max(1);
    let col_width = |col: usize| -> f32 {
        (0..rows)
            .filter_map(|r| items.get(col * rows + r))
            .map(|it| it.width.unwrap_or(fallback_item_size).value())
            .fold(0.0_f32, f32::max)
    };
    let total: f32 = (0..items.len().div_ceil(rows))
        .map(|col| col_width(col) + if col == 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())
        .with_icon(RibbonIconSize::Small.slot(kind.icon()));
    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,
    }
}

const fn tool_icon(tool: SketchTool) -> IconId {
    match tool {
        SketchTool::Point => IconId::Point,
        SketchTool::Line => IconId::Line,
        SketchTool::CenterpointArc => IconId::CenterpointArc,
        SketchTool::TangentArc => IconId::TangentArc,
        SketchTool::ThreePointArc => IconId::ThreePointArc,
        SketchTool::Circle => IconId::Circle,
        SketchTool::PerimeterCircle => IconId::PerimeterCircle,
        SketchTool::CornerRectangle => IconId::CornerRectangle,
        SketchTool::CenterRectangle => IconId::CenterRectangle,
        SketchTool::ThreePointCornerRectangle => IconId::ThreePointCornerRectangle,
        SketchTool::ThreePointCenterRectangle => IconId::ThreePointCenterRectangle,
        SketchTool::Parallelogram => IconId::Parallelogram,
    }
}

fn build_feature_tool_index(ribbon: WidgetId) -> BTreeMap<WidgetId, FeatureTool> {
    FeatureTool::ALL
        .iter()
        .copied()
        .map(|t| (feature_tool_widget_id(ribbon, t), t))
        .collect()
}

fn feature_tool_widget_id(ribbon: WidgetId, tool: FeatureTool) -> WidgetId {
    ribbon.child(WidgetKey::new(feature_tool_key(tool)))
}

fn feature_tool_key(tool: FeatureTool) -> &'static str {
    match tool {
        FeatureTool::ExtrudedBossBase => "tool.extruded_boss_base",
        FeatureTool::ExtrudedCut => "tool.extruded_cut",
    }
}

fn feature_tool_label(tool: FeatureTool) -> StringKey {
    match tool {
        FeatureTool::ExtrudedBossBase => strings::TOOL_EXTRUDED_BOSS_BASE,
        FeatureTool::ExtrudedCut => strings::TOOL_EXTRUDED_CUT,
    }
}

const fn feature_tool_icon(tool: FeatureTool) -> IconId {
    match tool {
        FeatureTool::ExtrudedBossBase => IconId::ExtrudedBossBase,
        FeatureTool::ExtrudedCut => IconId::ExtrudedCut,
    }
}

fn feature_tool_items(
    ctx: &FrameCtx<'_>,
    ribbon: WidgetId,
    mode: &Mode,
    large_min: LayoutPx,
) -> Vec<ToolbarItem> {
    let active = match mode {
        Mode::Extrude(_) => Some(FeatureTool::ExtrudedBossBase),
        Mode::Idle | Mode::Sketch { .. } => None,
    };
    let font = ctx.theme().typography.caption.size.as_px_f32();
    FeatureTool::ALL
        .iter()
        .copied()
        .map(|t| {
            let base = ToolbarItem::new(feature_tool_widget_id(ribbon, t), feature_tool_label(t))
                .with_icon(RibbonIconSize::Large.slot(feature_tool_icon(t)))
                .active(active == Some(t));
            let item = if mode.is_sketch() {
                base.disabled(true)
                    .with_tooltip(strings::FEATURE_HINT_EXIT_SKETCH)
            } else {
                base
            };
            let width = estimate_label_width(ctx.strings.resolve(item.label), font, large_min);
            item.with_width(width)
        })
        .collect()
}

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 leaf_rect(solved: &SolvedLayout, id: WidgetId) -> Option<LayoutRect> {
    solved
        .nodes
        .iter()
        .find(|n| matches!(n.kind, NodeKind::Leaf(wid) if wid == id))
        .map(|n| n.rect)
}

const MENU_BAR_HEIGHT_PX: f32 = 24.0;
const RIBBON_HEIGHT_PX: f32 = 82.0;
const DOC_TABS_HEIGHT_PX: f32 = 22.0;
const STATUS_BAR_HEIGHT_PX: f32 = 22.0;

struct ChromeRows {
    menu: Layout,
    ribbon: Layout,
    center: Layout,
    doc_tabs: Layout,
    status: Layout,
}

fn chrome_grid(rows: ChromeRows) -> Layout {
    let ChromeRows {
        menu,
        ribbon,
        center,
        doc_tabs,
        status,
    } = rows;
    let one = grid_line(1);
    let two = grid_line(2);
    let three = grid_line(3);
    let four = grid_line(4);
    let five = grid_line(5);
    let six = grid_line(6);
    let span_row = |row_start: GridLine, row_end: GridLine, child: Layout| {
        let Some(span) = GridSpan::rect(one, two, row_start, row_end) else {
            panic!("chrome row span must be increasing");
        };
        GridChild { span, child }
    };
    Layout::Grid {
        columns: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        rows: vec![
            GridTrack::unnamed(TrackSize::Fixed(Spacing::px(MENU_BAR_HEIGHT_PX))),
            GridTrack::unnamed(TrackSize::Fixed(Spacing::px(RIBBON_HEIGHT_PX))),
            GridTrack::unnamed(TrackSize::FLEX_1),
            GridTrack::unnamed(TrackSize::Fixed(Spacing::px(DOC_TABS_HEIGHT_PX))),
            GridTrack::unnamed(TrackSize::Fixed(Spacing::px(STATUS_BAR_HEIGHT_PX))),
        ],
        column_gap: Spacing::px(0.0),
        row_gap: Spacing::px(0.0),
        children: vec![
            span_row(one, two, menu),
            span_row(two, three, ribbon),
            span_row(three, four, center),
            span_row(four, five, doc_tabs),
            span_row(five, six, status),
        ],
    }
}

fn grid_line(n: u16) -> GridLine {
    let Some(nz) = core::num::NonZeroU16::new(n) else {
        panic!("grid line must be non-zero");
    };
    GridLine::new(nz)
}

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)
}

const LEFT_PANE_TAB_STRIP_HEIGHT: f32 = 28.0;

struct LeftPaneSplit {
    tab_strip_rect: LayoutRect,
    content_rect: LayoutRect,
}

fn split_left_pane(rect: LayoutRect) -> LeftPaneSplit {
    let strip_height = LayoutPx::new(LEFT_PANE_TAB_STRIP_HEIGHT.min(rect.size.height.value()));
    let tab_strip_rect =
        LayoutRect::new(rect.origin, LayoutSize::new(rect.size.width, strip_height));
    let content_rect = LayoutRect::new(
        LayoutPos::new(
            rect.origin.x,
            LayoutPx::new(rect.origin.y.value() + strip_height.value()),
        ),
        LayoutSize::new(
            rect.size.width,
            LayoutPx::saturating_nonneg(rect.size.height.value() - strip_height.value()),
        ),
    );
    LeftPaneSplit {
        tab_strip_rect,
        content_rect,
    }
}

fn update_left_pane_auto(
    state: &mut ShellState,
    selection: &Selection,
    active_tool: Option<SketchTool>,
    armed_extrude: bool,
) {
    let interesting = !selection.is_empty() || active_tool.is_some() || armed_extrude;
    if interesting && !state.last_left_pane_interesting {
        state.left_pane = LeftPane::Properties;
    } else if !interesting && state.last_left_pane_interesting {
        state.left_pane = LeftPane::Tree;
    }
    state.last_left_pane_interesting = interesting;
}

const LEFT_PANE_TAB_WIDTH_PX: f32 = 28.0;

#[derive(Copy, Clone)]
struct LeftPaneTabSpec {
    id: WidgetId,
    label: StringKey,
    icon: IconId,
    target: Option<LeftPane>,
}

fn render_left_pane_tabs(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    ids: &ShellIds,
    active: LeftPane,
    paints: &mut Vec<WidgetPaint>,
) -> Option<LeftPane> {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let specs = [
        LeftPaneTabSpec {
            id: ids.left_pane_tab_tree,
            label: strings::FEATURE_TREE_LABEL,
            icon: IconId::TabTree,
            target: Some(LeftPane::Tree),
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_properties,
            label: strings::PROPERTY_PANE_LABEL,
            icon: IconId::TabProperties,
            target: Some(LeftPane::Properties),
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_configuration,
            label: strings::LEFT_PANE_TAB_CONFIGURATION,
            icon: IconId::TabConfiguration,
            target: None,
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_dimension_expert,
            label: strings::LEFT_PANE_TAB_DIMENSION_EXPERT,
            icon: IconId::TabDimensionExpert,
            target: None,
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_display,
            label: strings::LEFT_PANE_TAB_DISPLAY,
            icon: IconId::TabDisplay,
            target: None,
        },
    ];
    let tab_views: Vec<Tab> = specs
        .iter()
        .scan(rect.origin.x.value(), |x, spec| {
            let tab_rect = LayoutRect::new(
                LayoutPos::new(LayoutPx::new(*x), rect.origin.y),
                LayoutSize::new(LayoutPx::new(LEFT_PANE_TAB_WIDTH_PX), rect.size.height),
            );
            *x += LEFT_PANE_TAB_WIDTH_PX;
            Some(
                Tab::new(spec.id, tab_rect, spec.label)
                    .with_icon(spec.icon)
                    .disabled(spec.target.is_none()),
            )
        })
        .collect();
    let active_id = specs
        .iter()
        .find_map(|spec| (spec.target == Some(active)).then_some(spec.id))
        .unwrap_or(ids.left_pane_tab_tree);
    let response = show_tabs(
        ctx,
        Tabs::new(
            ids.left_pane.child(WidgetKey::new("tabs")),
            TabsOrientation::Top,
            strings::LEFT_PANE_LABEL,
            tab_views.as_slice(),
            active_id,
        ),
    );
    paints.extend(response.paint);
    response.activated.and_then(|id| {
        specs
            .iter()
            .find_map(|spec| (spec.id == id).then_some(spec.target).flatten())
    })
}

const CONFIRM_BUTTON_PX: f32 = 36.0;
const CONFIRM_BUTTON_GAP: f32 = 6.0;
const CONFIRM_CORNER_INSET: f32 = 12.0;

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

fn render_confirm_corner(
    ctx: &mut FrameCtx<'_>,
    viewport: LayoutRect,
    ids: &ShellIds,
    visible: bool,
    paints: &mut Vec<WidgetPaint>,
) -> Option<ConfirmAction> {
    let pair_width = 2.0 * CONFIRM_BUTTON_PX + CONFIRM_BUTTON_GAP;
    let min_width = pair_width + 2.0 * CONFIRM_CORNER_INSET;
    let min_height = CONFIRM_BUTTON_PX + 2.0 * CONFIRM_CORNER_INSET;
    if !visible
        || viewport.size.width.value() < min_width
        || viewport.size.height.value() < min_height
    {
        return None;
    }
    let top_y = viewport.origin.y.value() + CONFIRM_CORNER_INSET;
    let cancel_x = viewport.origin.x.value() + viewport.size.width.value()
        - CONFIRM_CORNER_INSET
        - CONFIRM_BUTTON_PX;
    let accept_x = cancel_x - CONFIRM_BUTTON_GAP - CONFIRM_BUTTON_PX;
    let accept_rect = LayoutRect::new(
        LayoutPos::new(LayoutPx::new(accept_x), LayoutPx::new(top_y)),
        LayoutSize::new(
            LayoutPx::new(CONFIRM_BUTTON_PX),
            LayoutPx::new(CONFIRM_BUTTON_PX),
        ),
    );
    let cancel_rect = LayoutRect::new(
        LayoutPos::new(LayoutPx::new(cancel_x), LayoutPx::new(top_y)),
        LayoutSize::new(
            LayoutPx::new(CONFIRM_BUTTON_PX),
            LayoutPx::new(CONFIRM_BUTTON_PX),
        ),
    );
    let accept_clicked = paint_confirm_button(
        ctx,
        ids.confirm_accept,
        accept_rect,
        IconId::Check,
        strings::CONFIRM_ACCEPT,
        ConfirmTone::Accept,
        paints,
    );
    let cancel_clicked = paint_confirm_button(
        ctx,
        ids.confirm_cancel,
        cancel_rect,
        IconId::Cross,
        strings::CONFIRM_CANCEL,
        ConfirmTone::Cancel,
        paints,
    );
    if accept_clicked {
        Some(ConfirmAction::Accept)
    } else if cancel_clicked {
        Some(ConfirmAction::Cancel)
    } else {
        None
    }
}

#[derive(Copy, Clone)]
enum ConfirmTone {
    Accept,
    Cancel,
}

fn paint_confirm_button(
    ctx: &mut FrameCtx<'_>,
    id: WidgetId,
    rect: LayoutRect,
    icon: IconId,
    label: StringKey,
    tone: ConfirmTone,
    paints: &mut Vec<WidgetPaint>,
) -> bool {
    let interaction = ctx.interact(
        InteractDeclaration::new(id, rect, Sense::INTERACTIVE)
            .focusable(true)
            .a11y(AccessNode::new(Role::Button).with_label(label)),
    );
    let theme = ctx.theme();
    let palette = match tone {
        ConfirmTone::Accept => theme.colors.success,
        ConfirmTone::Cancel => theme.colors.danger,
    };
    let (fill, glyph_color) = if interaction.pressed() {
        (
            palette.step(Step12::SELECTED_BG),
            palette.step(Step12::HOVER_SOLID),
        )
    } else if interaction.hover() {
        (palette.step(Step12::HOVER_BG), palette.step(Step12::SOLID))
    } else {
        (
            theme.colors.surface(theme.elevation.level3.surface),
            palette.step(Step12::SOLID),
        )
    };
    paints.push(WidgetPaint::Surface {
        rect,
        fill,
        border: Some(Border {
            width: StrokeWidth::HAIRLINE,
            color: palette.step(Step12::SOLID),
        }),
        radius: theme.radius.sm,
        elevation: Some(theme.elevation.level3),
    });
    paints.push(WidgetPaint::Icon {
        rect,
        icon,
        tint: IconTint::Solid(glyph_color),
    });
    interaction.click()
}

fn build_dock_main(panels: ShellPanels) -> DockNode {
    const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.12);
    DockNode::split(
        Axis::Horizontal,
        LEFT_PANE_RATIO,
        DockNode::tabs(vec![panels.left_pane]),
        DockNode::tabs(vec![panels.viewport]),
    )
}

#[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::{Locale, StringKey, 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 mut shell = Shell::new();
        render_into_shell(
            &mut shell,
            theme,
            size,
            document,
            mode,
            &Selection::default(),
        )
    }

    fn render_with_strings(
        shell: &mut Shell,
        theme: Theme,
        size: LayoutSize,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        strings: &StringTable,
    ) -> ShellFrame {
        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,
            strings,
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        shell.render(
            &mut ctx,
            document,
            mode,
            selection,
            &Settings::default(),
            size,
            None,
            None,
            None,
            &mut ViewUi::default(),
            &BTreeMap::new(),
            false,
            &[],
        )
    }

    fn render_into_shell(
        shell: &mut Shell,
        theme: Theme,
        size: LayoutSize,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
    ) -> ShellFrame {
        render_with_strings(
            shell,
            theme,
            size,
            document,
            mode,
            selection,
            StringTable::empty(),
        )
    }

    fn label_rect(paints: &[WidgetPaint], target: StringKey) -> Option<LayoutRect> {
        paints.iter().find_map(|p| match p {
            WidgetPaint::Label {
                rect,
                text: LabelText::Key(k),
                ..
            }
            | WidgetPaint::AlignedLabel {
                rect,
                text: LabelText::Key(k),
                ..
            } if *k == target => Some(*rect),
            _ => None,
        })
    }

    fn blind_extrude(sketch: SketchId) -> ExtrudeFeature {
        use bone_document::{ExtrudeDirection, ExtrudeSense};
        let Ok(depth) = PositiveLength::new(Length::new::<bone_types::millimeter>(10.0)) else {
            panic!("ten millimeters is a positive depth");
        };
        ExtrudeFeature {
            sketch,
            direction: ExtrudeDirection::Normal {
                sense: ExtrudeSense::Forward,
            },
            end_condition: ExtrudeEndCondition::Blind { depth },
            draft: None,
            thin_wall: None,
            merge_result: MergeResult::Separate,
        }
    }

    fn push_plain_chain(document: &mut Document, label: &str) -> (SketchId, ExtrudeId) {
        let sketch_id = document.allocate_sketch();
        document.insert_sketch(sketch_id, label.to_owned(), Sketch::new(Plane::Xy.basis()));
        let extrude_id = document.commit_extrude(blind_extrude(sketch_id));
        (sketch_id, extrude_id)
    }

    fn test_part_id() -> WidgetId {
        use std::num::NonZeroU64;
        WidgetId::from_raw(NonZeroU64::MIN).child(WidgetKey::new("part"))
    }

    #[test]
    fn feature_rows_nest_sketch_under_consuming_extrude() {
        let mut document = sample_document();
        let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1");
        let (sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2");
        let part_id = test_part_id();
        let rows = feature_rows(&document, part_id, &BTreeMap::new());
        assert_eq!(
            rows.len(),
            2,
            "two extrudes are the only top-level features"
        );
        let assert_chain = |node: &TreeNode, extrude_id: ExtrudeId, sketch_id: SketchId| {
            assert_eq!(node.id, extrude_widget_id(part_id, extrude_id));
            assert_eq!(node.children.len(), 1, "the extrude absorbs its sketch");
            assert_eq!(node.children[0].id, sketch_widget_id(part_id, sketch_id));
        };
        assert_chain(&rows[0], extrude_a, sketch_a);
        assert_chain(&rows[1], extrude_b, sketch_b);
    }

    #[test]
    fn feature_rows_keep_unconsumed_sketch_at_top_level() {
        let mut document = sample_document();
        let sketch_id = document.allocate_sketch();
        document.insert_sketch(
            sketch_id,
            "Sketch1".to_owned(),
            Sketch::new(Plane::Xy.basis()),
        );
        let part_id = test_part_id();
        let rows = feature_rows(&document, part_id, &BTreeMap::new());
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].id, sketch_widget_id(part_id, sketch_id));
        assert!(
            rows[0].children.is_empty(),
            "an unconsumed sketch has no nested feature"
        );
    }

    fn menu_labels(items: &[MenuItem]) -> Vec<StringKey> {
        items
            .iter()
            .filter_map(|item| match item {
                MenuItem::Action { label, .. } => Some(*label),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn feature_menu_items_for_extrude_include_edit_feature_and_suppress() {
        let ids = FeatureMenuIds::new(test_part_id());
        let items = feature_menu_items(
            &ids,
            FeatureTarget::Extrude(ExtrudeId::default()),
            false,
            true,
        );
        let labels = menu_labels(&items);
        assert!(labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE));
        assert!(labels.contains(&strings::FEATURE_CTX_EDIT_SKETCH));
        assert!(labels.contains(&strings::FEATURE_CTX_SUPPRESS));
        assert!(!labels.contains(&strings::FEATURE_CTX_UNSUPPRESS));
        assert!(labels.contains(&strings::FEATURE_CTX_DELETE));
        assert!(labels.contains(&strings::FEATURE_CTX_RELATIONSHIPS));
    }

    #[test]
    fn feature_menu_items_for_suppressed_sketch_omit_edit_feature_and_offer_unsuppress() {
        let ids = FeatureMenuIds::new(test_part_id());
        let items =
            feature_menu_items(&ids, FeatureTarget::Sketch(SketchId::default()), true, true);
        let labels = menu_labels(&items);
        assert!(!labels.contains(&strings::FEATURE_CTX_EDIT_FEATURE));
        assert!(labels.contains(&strings::FEATURE_CTX_UNSUPPRESS));
        assert!(!labels.contains(&strings::FEATURE_CTX_SUPPRESS));
    }

    #[test]
    fn feature_menu_outcome_maps_ids_to_commands() {
        let mut document = sample_document();
        let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let target = FeatureTarget::Extrude(extrude);
        let ids = FeatureMenuIds::new(test_part_id());
        let Some(feature) = feature_id_of(&document, target) else {
            panic!("the extrude has a feature id");
        };
        assert_eq!(
            feature_menu_outcome_for(&ids, ids.suppress, target, &document),
            Some(FeatureMenuOutcome::Command(FeatureCommand::Suppress(
                feature
            ))),
        );
        assert_eq!(
            feature_menu_outcome_for(&ids, ids.delete, target, &document),
            Some(FeatureMenuOutcome::Command(FeatureCommand::Delete(target))),
        );
        assert_eq!(
            feature_menu_outcome_for(&ids, ids.edit_sketch, target, &document),
            Some(FeatureMenuOutcome::EditSketch(sketch)),
            "edit sketch on an extrude resolves to its source sketch",
        );
        assert_eq!(
            feature_menu_outcome_for(&ids, ids.relationships, target, &document),
            Some(FeatureMenuOutcome::ShowRelationships(target)),
        );
    }

    #[test]
    fn rollback_target_and_change_round_trip_through_the_marker() {
        let mut document = sample_document();
        let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let part_id = test_part_id();
        assert_eq!(
            current_rollback_target(&document, part_id),
            RollbackTarget::AtEnd,
            "a fresh document is rolled to the end",
        );
        let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the extrude resolves to a feature id");
        };
        document.roll_to_here(feature);
        let widget = extrude_widget_id(part_id, extrude);
        assert_eq!(
            current_rollback_target(&document, part_id),
            RollbackTarget::Above(widget),
        );
        let w2s: BTreeMap<WidgetId, SketchId> = sketch_widget_ids(&document, part_id)
            .into_iter()
            .map(|(s, w)| (w, s))
            .collect();
        let w2e: BTreeMap<WidgetId, ExtrudeId> = extrude_widget_ids(&document, part_id)
            .into_iter()
            .map(|(e, w)| (w, e))
            .collect();
        assert_eq!(
            resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::AtEnd),
            Some(RollbackChange::ToEnd),
        );
        assert_eq!(
            resolve_rollback_change(&document, &w2s, &w2e, RollbackTarget::Above(widget)),
            Some(RollbackChange::ToFeature(feature)),
        );
    }

    #[test]
    fn rolled_back_feature_node_is_disabled() {
        let mut document = sample_document();
        let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the extrude resolves to a feature id");
        };
        document.roll_to_here(feature);
        let part_id = test_part_id();
        let rows = feature_rows(&document, part_id, &BTreeMap::new());
        assert!(
            rows[0].disabled,
            "a rolled-back feature is greyed and inert"
        );
    }

    #[test]
    fn extrude_node_shows_its_own_error_badge() {
        let mut document = sample_document();
        let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let part_id = test_part_id();
        let Some(feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the extrude resolves to a feature id");
        };
        let badges = BTreeMap::from([(feature, TreeBadge::Error)]);
        let rows = feature_rows(&document, part_id, &badges);
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].badge, Some(TreeBadge::Error));
    }

    #[test]
    fn extrude_node_rolls_up_a_warning_from_its_nested_sketch() {
        let mut document = sample_document();
        let (sketch, _extrude) = push_plain_chain(&mut document, "Sketch1");
        let part_id = test_part_id();
        let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else {
            panic!("the sketch resolves to a feature id");
        };
        let badges = BTreeMap::from([(sketch_feature, TreeBadge::Warning)]);
        let rows = feature_rows(&document, part_id, &badges);
        assert_eq!(
            rows[0].badge,
            Some(TreeBadge::Warning),
            "the extrude rolls up its nested sketch's warning",
        );
        assert_eq!(rows[0].children[0].badge, Some(TreeBadge::Warning));
    }

    #[test]
    fn extrude_node_error_outranks_a_nested_warning() {
        let mut document = sample_document();
        let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let part_id = test_part_id();
        let Some(sketch_feature) = document.feature_tree().feature_of_sketch(sketch) else {
            panic!("the sketch resolves to a feature id");
        };
        let Some(extrude_feature) = document.feature_tree().feature_of_extrude(extrude) else {
            panic!("the extrude resolves to a feature id");
        };
        let badges = BTreeMap::from([
            (extrude_feature, TreeBadge::Warning),
            (sketch_feature, TreeBadge::Error),
        ]);
        let rows = feature_rows(&document, part_id, &badges);
        assert_eq!(
            rows[0].badge,
            Some(TreeBadge::Error),
            "an error on the nested sketch outranks the extrude's own warning",
        );
    }

    #[test]
    fn drop_to_reorder_accepts_legal_move_and_rejects_illegal() {
        let mut document = sample_document();
        let (sketch_a, extrude_a) = push_plain_chain(&mut document, "Sketch1");
        let (_sketch_b, extrude_b) = push_plain_chain(&mut document, "Sketch2");
        let part_id = test_part_id();
        let w2s: BTreeMap<WidgetId, SketchId> = sketch_widget_ids(&document, part_id)
            .into_iter()
            .map(|(s, w)| (w, s))
            .collect();
        let w2e: BTreeMap<WidgetId, ExtrudeId> = extrude_widget_ids(&document, part_id)
            .into_iter()
            .map(|(e, w)| (w, e))
            .collect();
        let src = extrude_widget_id(part_id, extrude_a);
        let after_b = DropTarget {
            anchor: extrude_widget_id(part_id, extrude_b),
            placement: DropPlacement::After,
        };
        let Some(reorder) = drop_to_reorder(&document, &w2s, &w2e, src, after_b) else {
            panic!("moving the first extrude after the second is legal");
        };
        assert!(!reorder.before);
        let before_own_sketch = DropTarget {
            anchor: sketch_widget_id(part_id, sketch_a),
            placement: DropPlacement::Before,
        };
        assert!(
            drop_to_reorder(&document, &w2s, &w2e, src, before_own_sketch).is_none(),
            "an extrude cannot move before its own sketch",
        );
    }

    #[test]
    fn relationship_rows_list_the_extrude_parent_sketch_and_no_children() {
        let mut document = sample_document();
        let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let Some(feature) = feature_id_of(&document, FeatureTarget::Extrude(extrude)) else {
            panic!("the extrude has a feature id");
        };
        let rows = relationship_rows(&document, feature);
        let owned: Vec<&str> = rows
            .iter()
            .filter_map(|row| match &row.text {
                LabelText::Owned(label) => Some(label.as_str()),
                LabelText::Key(_) => None,
            })
            .collect();
        assert_eq!(
            owned,
            vec!["Sketch1"],
            "the only parent is the source sketch"
        );
        let has_none = rows.iter().any(
            |row| matches!(&row.text, LabelText::Key(key) if *key == strings::FEATURE_REL_NONE),
        );
        assert!(has_none, "a childless extrude shows None under children");
    }

    #[test]
    fn tools_options_menu_id_maps_to_open_settings_action() {
        let shell = Shell::new();
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_tools_options),
            Some(MenuAction::OpenSettings),
        );
    }

    #[test]
    fn file_menu_ids_map_to_file_actions() {
        let shell = Shell::new();
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_file_new),
            Some(MenuAction::NewDocument),
        );
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_file_open),
            Some(MenuAction::OpenDocument),
        );
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_file_save),
            Some(MenuAction::SaveDocument),
        );
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_file_save_as),
            Some(MenuAction::SaveDocumentAs),
        );
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_file_import),
            Some(MenuAction::ImportStep),
        );
        assert_eq!(
            shell.ids.menu_action_for(shell.ids.menu_file_export_step),
            Some(MenuAction::ExportStep),
        );
        assert_eq!(shell.ids.menu_action_for(shell.ids.menu_file_export), None);
    }

    #[test]
    fn file_menu_actions_are_enabled() {
        let entries = build_menu_entries(
            &ShellIds::standard(),
            false,
            &crate::hotkeys::HotkeyOverrides::default(),
        );
        let Some(file_menu) = entries.iter().find(|e| e.label == strings::MENU_FILE) else {
            panic!("file menu entry missing");
        };
        let actions: Vec<(StringKey, bool)> = file_menu
            .items
            .iter()
            .filter_map(|i| match i {
                MenuItem::Action {
                    label, disabled, ..
                } => Some((*label, *disabled)),
                _ => None,
            })
            .collect();
        let entry_for = |key: StringKey| {
            actions
                .iter()
                .find(|(l, _)| *l == key)
                .copied()
                .unwrap_or((key, true))
        };
        assert!(!entry_for(strings::MENU_FILE_NEW).1);
        assert!(!entry_for(strings::MENU_FILE_OPEN).1);
        assert!(!entry_for(strings::MENU_FILE_SAVE).1);
        assert!(!entry_for(strings::MENU_FILE_SAVE_AS).1);
        assert!(!entry_for(strings::MENU_FILE_IMPORT).1);
        let export_formats: Vec<(StringKey, bool)> = file_menu
            .items
            .iter()
            .filter_map(|i| match i {
                MenuItem::Submenu { label, items, .. } if *label == strings::MENU_FILE_EXPORT => {
                    Some(items)
                }
                _ => None,
            })
            .flatten()
            .filter_map(|i| match i {
                MenuItem::Action {
                    label, disabled, ..
                } => Some((*label, *disabled)),
                _ => None,
            })
            .collect();
        assert_eq!(
            export_formats,
            vec![(strings::MENU_FILE_EXPORT_STEP, false)],
        );
    }

    #[test]
    fn settings_dialog_does_not_render_when_closed() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        let title_visible = frame
            .paints
            .iter()
            .chain(frame.overlay_paints.iter())
            .any(|p| {
                matches!(
                    p,
                    WidgetPaint::Label {
                        text: LabelText::Key(k),
                        ..
                    } if *k == strings::SETTINGS_DIALOG_TITLE
                )
            });
        assert!(!title_visible, "settings dialog must not paint when closed");
        assert!(frame.settings_change.is_none());
    }

    #[test]
    fn settings_dialog_paints_title_and_aperture_label_when_open() {
        let mut shell = Shell::new();
        shell.state.settings_dialog_open = true;
        let frame = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
            &Selection::default(),
        );
        let has_title = frame.overlay_paints.iter().any(|p| {
            matches!(
                p,
                WidgetPaint::Label {
                    text: LabelText::Key(k),
                    ..
                } if *k == strings::SETTINGS_DIALOG_TITLE
            )
        });
        assert!(has_title, "open dialog should paint its title key");
        let has_aperture_text = frame.overlay_paints.iter().any(|p| {
            matches!(
                p,
                WidgetPaint::Label {
                    text: LabelText::Owned(text),
                    ..
                } if text.contains("px")
            )
        });
        assert!(
            has_aperture_text,
            "aperture label should include unit suffix px"
        );
    }

    #[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, "left pane carved on left");
        assert!(v.min_y().value() > 0.0, "ribbon carved on top");
        assert!(
            v.max_x().value() <= 1280.0,
            "viewport bounded by window width"
        );
        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 shell = Shell::new();
        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 feature_tool_index_round_trips_both_extrude_buttons() {
        let ids = ShellIds::standard();
        let index = build_feature_tool_index(ids.ribbon);
        FeatureTool::ALL.iter().copied().for_each(|t| {
            let id = feature_tool_widget_id(ids.ribbon, t);
            assert_eq!(index.get(&id).copied(), Some(t));
        });
    }

    #[test]
    fn features_tab_renders_extrude_group_only_when_selected() {
        let document = sample_document();
        let size = layout_size(1280.0, 800.0);
        let sketch_view = render_with(Theme::light(), size, &document, &Mode::Idle);
        assert!(
            label_rect(&sketch_view.paints, strings::TOOL_EXTRUDED_BOSS_BASE).is_none(),
            "extrude tools hidden while the sketch tab is active",
        );
        assert!(
            label_rect(&sketch_view.paints, strings::TOOL_POINT).is_some(),
            "sketch tab shows its entity tools",
        );

        let mut shell = Shell::new();
        shell.state.ribbon_active_tab = Some(features_tab_id(shell.ids.ribbon));
        let features_view = render_into_shell(
            &mut shell,
            Theme::light(),
            size,
            &document,
            &Mode::Idle,
            &Selection::default(),
        );
        assert!(
            label_rect(&features_view.paints, strings::TOOL_EXTRUDED_BOSS_BASE).is_some(),
            "selecting the features tab reveals the extrude tools",
        );
    }

    #[test]
    fn awaiting_sketch_extrude_prompts_in_property_pane() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Extrude(ExtrudeArming::AwaitingSketch),
        );
        assert!(
            label_rect(&frame.paints, strings::EXTRUDE_PROMPT_SELECT_SKETCH).is_some(),
            "awaiting-sketch arm prompts via the property pane",
        );
    }

    fn profile_feature() -> ExtrudeFeature {
        let ExtrudeArming::Profile { feature, .. } = ExtrudeArming::profile(SketchId::default())
        else {
            unreachable!("profile arming holds a feature");
        };
        feature
    }

    #[test]
    fn extrude_edit_sets_depth_keeping_kind() {
        let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(3.0)) else {
            unreachable!("3 mm is positive");
        };
        let next = ExtrudeEdit::Depth(depth).apply(profile_feature());
        assert!(matches!(
            next.end_condition,
            ExtrudeEndCondition::Blind { depth: d } if d == depth
        ));
    }

    #[test]
    fn extrude_edit_changes_kind_keeping_depth() {
        let feature = profile_feature();
        let before = end_condition_depth(&feature.end_condition);
        let next = ExtrudeEdit::EndCondition(EndConditionKind::MidPlane).apply(feature);
        assert!(matches!(
            next.end_condition,
            ExtrudeEndCondition::MidPlane { .. }
        ));
        assert_eq!(end_condition_depth(&next.end_condition), before);
    }

    #[test]
    fn extrude_edit_toggles_merge_result() {
        let next = ExtrudeEdit::Merge(MergeResult::Separate).apply(profile_feature());
        assert_eq!(next.merge_result, MergeResult::Separate);
    }

    #[test]
    fn profiled_extrude_shows_property_controls() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Extrude(ExtrudeArming::profile(SketchId::default())),
        );
        [
            strings::PROPERTY_ROW_EXTRUDE_END,
            strings::PROPERTY_ROW_EXTRUDE_DEPTH,
            strings::PROPERTY_ROW_EXTRUDE_MERGE,
        ]
        .into_iter()
        .for_each(|key| {
            assert!(
                label_rect(&frame.paints, key).is_some(),
                "extrude property pane shows {key:?}",
            );
        });
    }

    #[test]
    fn feature_tools_disabled_while_sketching() {
        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 input = InputSnapshot::idle(FrameInstant::ZERO);
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        let ribbon = ShellIds::standard().ribbon;
        let large = RibbonIconSize::Large.item_px();

        let sketching = feature_tool_items(
            &ctx,
            ribbon,
            &Mode::enter_sketch(SketchId::default()),
            large,
        );
        assert!(
            sketching.iter().all(|item| item.disabled),
            "feature tools disable mid-sketch so a ribbon click can't drop the session",
        );
        assert!(
            sketching
                .iter()
                .all(|item| item.tooltip == Some(strings::FEATURE_HINT_EXIT_SKETCH)),
            "disabled feature tools explain why through a tooltip",
        );

        let idle = feature_tool_items(&ctx, ribbon, &Mode::Idle, large);
        assert!(
            idle.iter().all(|item| !item.disabled),
            "feature tools stay live from idle",
        );
        assert!(
            idle.iter().all(|item| item.tooltip.is_none()),
            "live feature tools carry no disabled-reason tooltip",
        );
    }

    #[test]
    fn entering_sketch_snaps_ribbon_to_sketch_tab() {
        let ids = ShellIds::standard();
        let mut state = ShellState {
            ribbon_active_tab: Some(features_tab_id(ids.ribbon)),
            ..ShellState::default()
        };
        sync_ribbon_tab_to_mode(
            &mut state,
            ids.ribbon,
            &Mode::enter_sketch(SketchId::default()),
        );
        assert_eq!(state.ribbon_active_tab, Some(sketch_tab_id(ids.ribbon)));
    }

    #[test]
    fn ribbon_tab_sync_only_fires_on_the_sketch_rising_edge() {
        let ids = ShellIds::standard();
        let features = Some(features_tab_id(ids.ribbon));

        let mut already_sketching = ShellState {
            last_mode_was_sketch: true,
            ribbon_active_tab: features,
            ..ShellState::default()
        };
        sync_ribbon_tab_to_mode(
            &mut already_sketching,
            ids.ribbon,
            &Mode::enter_sketch(SketchId::default()),
        );
        assert_eq!(
            already_sketching.ribbon_active_tab, features,
            "no rising edge, the user's tab choice stands",
        );

        let mut idle = ShellState {
            ribbon_active_tab: features,
            ..ShellState::default()
        };
        sync_ribbon_tab_to_mode(&mut idle, ids.ribbon, &Mode::Idle);
        assert_eq!(idle.ribbon_active_tab, features, "idle never forces a tab");
    }

    #[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.confirm_accept), None);
    }

    #[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_confirm_corner() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        assert!(!frame.paints.iter().any(is_confirm_glyph));
    }

    #[test]
    fn sketch_render_includes_confirm_corner() {
        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_confirm_glyph));
    }

    fn is_confirm_glyph(paint: &WidgetPaint) -> bool {
        matches!(
            paint,
            WidgetPaint::Icon { icon, .. } if matches!(icon, IconId::Check | IconId::Cross)
        )
    }

    #[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 mut shell = Shell::new();
        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()),
            &Selection::default(),
            &Settings::default(),
            layout_size(1600.0, 900.0),
            None,
            None,
            None,
            &mut ViewUi::default(),
            &BTreeMap::new(),
            false,
            &[],
        );
        let any_smart_dim_label = frame.paints.iter().any(|p| {
            matches!(
                p,
                WidgetPaint::Label { text: LabelText::Key(key), .. }
                | WidgetPaint::AlignedLabel { text: LabelText::Key(key), .. }
                    if *key == strings::TOOL_SMART_DIMENSION
            )
        });
        assert!(any_smart_dim_label);
    }

    #[test]
    fn smart_dimension_stays_reachable_in_a_narrow_ribbon() {
        let mut shell = Shell::new();
        let dimensions_group = shell.ids.ribbon.child(WidgetKey::new("group.dimensions"));
        shell
            .state
            .ribbon_overflow_open
            .insert(dimensions_group, true);
        let frame = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(800.0, 600.0),
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
            &Selection::default(),
        );
        let reachable = frame
            .paints
            .iter()
            .chain(frame.overlay_paints.iter())
            .any(|p| {
                matches!(
                    p,
                    WidgetPaint::Label { text: LabelText::Key(key), .. }
                    | WidgetPaint::AlignedLabel { text: LabelText::Key(key), .. }
                        if *key == strings::TOOL_SMART_DIMENSION
                )
            });
        assert!(
            reachable,
            "Smart Dimension must stay reachable on a narrow ribbon, inline or via its group overflow",
        );
    }

    #[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 { .. }));
    }

    #[test]
    fn partition_overlay_floats_popup_paints_above_main() {
        let theme = Theme::light();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(4.0), LayoutPx::new(6.0)),
            LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
        );
        let surface = |fill| WidgetPaint::Surface {
            rect,
            fill,
            border: None,
            radius: theme.radius.none,
            elevation: None,
        };
        let inputs = vec![
            surface(theme.colors.surface(bone_ui::theme::SurfaceLevel::L0)),
            WidgetPaint::Popup {
                paints: vec![
                    surface(theme.colors.surface(bone_ui::theme::SurfaceLevel::L1)),
                    WidgetPaint::Label {
                        rect,
                        text: LabelText::Owned("Blind".to_owned()),
                        color: theme.colors.text_primary(),
                        role: theme.typography.body,
                    },
                ],
            },
        ];
        let (main, overlay) = partition_overlay(inputs, &theme);
        assert_eq!(
            main.len(),
            1,
            "the row surface beneath the popup stays in main"
        );
        assert_eq!(
            overlay.len(),
            2,
            "the popup's surface + label float into the overlay so they draw on top",
        );
        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)
    }

    fn sketch_with_dim(kind: DimensionKind) -> (bone_document::Sketch, SketchDimensionId) {
        use bone_document::{EditOutcome, SketchEdit};
        use bone_types::Point2;
        use uom::si::length::millimeter;
        let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0));
        let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0));
        let dim = SketchDimension::Linear {
            a,
            b,
            value: Length::new::<millimeter>(5.0),
            kind,
        };
        let Ok((s, EditOutcome::Dimension(id))) = s.apply(SketchEdit::AddDimension(dim)) else {
            panic!("expected dimension outcome");
        };
        (s, id)
    }

    fn sketch_with_relation() -> (
        bone_document::Sketch,
        bone_types::SketchRelationId,
        SketchEntityId,
    ) {
        use bone_document::{EditOutcome, SketchEdit};
        use bone_types::Point2;
        let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0));
        let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0));
        let (s, line) = crate::tools::add_line(s, a, b, false);
        let Ok((s, EditOutcome::Relation(id))) =
            s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line)))
        else {
            panic!("expected relation outcome");
        };
        (s, id, line)
    }

    fn document_with_sketch(sketch: bone_document::Sketch) -> (Document, SketchId) {
        let mut doc = sample_document();
        let id = SketchId::default();
        doc.insert_sketch(id, "Sketch1".to_owned(), sketch);
        (doc, id)
    }

    #[test]
    fn property_pane_for_driving_dim_populates_editor_with_value() {
        let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving);
        let (doc, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let frame = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::Dimension(dim_id),
        );
        let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property else {
            panic!("expected length editor populated");
        };
        assert_eq!(*id, dim_id);
        assert!(
            (editor.value.get::<millimeter>() - 5.0).abs() < 1e-9,
            "editor value: {}",
            editor.value.get::<millimeter>()
        );
        assert!(frame.dimension_edit.is_none(), "no input commit yet");
    }

    #[test]
    fn property_pane_keeps_driven_editor_but_marks_read_only() {
        let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driven);
        let (doc, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let _ = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::Dimension(dim_id),
        );
        let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else {
            panic!("expected length editor populated");
        };
        assert_eq!(*id, dim_id);
    }

    #[test]
    fn property_pane_drops_dim_editor_when_selection_changes_off_dim() {
        let (sketch, dim_id) = sketch_with_dim(DimensionKind::Driving);
        let (doc, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let _ = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::Dimension(dim_id),
        );
        assert!(shell.state.dim_property.is_some());
        let _ = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::default(),
        );
        assert!(shell.state.dim_property.is_none());
    }

    #[test]
    fn property_pane_swaps_editor_when_dim_id_changes() {
        use bone_document::{EditOutcome, SketchEdit};
        use bone_types::Point2;
        use uom::si::length::millimeter;
        let s = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (s, a) = crate::tools::add_point(s, Point2::from_mm(0.0, 0.0));
        let (s, b) = crate::tools::add_point(s, Point2::from_mm(5.0, 0.0));
        let (s, c) = crate::tools::add_point(s, Point2::from_mm(0.0, 5.0));
        let Ok((s, EditOutcome::Dimension(dim_a))) =
            s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
                a,
                b,
                value: Length::new::<millimeter>(5.0),
                kind: DimensionKind::Driving,
            }))
        else {
            panic!("expected first Dimension outcome");
        };
        let Ok((s, EditOutcome::Dimension(dim_b))) =
            s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
                a,
                b: c,
                value: Length::new::<millimeter>(5.0),
                kind: DimensionKind::Driving,
            }))
        else {
            panic!("expected second Dimension outcome");
        };
        assert_ne!(dim_a, dim_b);
        let (doc, sketch_id) = document_with_sketch(s);
        let mut shell = Shell::new();
        let _ = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::Dimension(dim_a),
        );
        let _ = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::Dimension(dim_b),
        );
        let Some(DimPropertyEditor::Length { id, .. }) = &shell.state.dim_property else {
            panic!("expected length editor for second dim");
        };
        assert_eq!(*id, dim_b);
    }

    #[test]
    fn property_pane_renders_relation_kind_label() {
        let (sketch, _rel_id, _line) = sketch_with_relation();
        let (doc, sketch_id) = document_with_sketch(sketch);
        let Some(sketch_ref) = doc.sketch(sketch_id) else {
            panic!("expected inserted sketch");
        };
        let Some(rel_id) = sketch_ref.relation_order().first().copied() else {
            panic!("expected relation present");
        };
        let mut shell = Shell::new();
        let frame = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &doc,
            &Mode::enter_sketch(sketch_id),
            &Selection::Relation(rel_id),
        );
        let any_horizontal_label = frame.paints.iter().any(|p| match p {
            WidgetPaint::Label {
                text: LabelText::Owned(text),
                ..
            } => text == StringTable::empty().resolve(strings::TOOL_HORIZONTAL),
            _ => false,
        });
        assert!(any_horizontal_label, "relation kind label should appear");
        assert!(
            shell.state.dim_property.is_none(),
            "relation does not own dim editor"
        );
    }

    fn shell_drive(
        shell: &mut Shell,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        focus: &mut FocusManager,
        prev: &mut HitState,
        snap: &mut InputSnapshot,
    ) -> (ShellFrame, HitFrame) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let frame = {
            let mut ctx = FrameCtx::new(
                theme,
                snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            shell.render(
                &mut ctx,
                document,
                mode,
                selection,
                &Settings::default(),
                layout_size(1280.0, 800.0),
                None,
                None,
                None,
                &mut ViewUi::default(),
                &BTreeMap::new(),
                false,
                &[],
            )
        };
        *prev = bone_ui::hit_test::resolve(prev, &hits, snap, focus.focused());
        (frame, hits)
    }

    fn sketch_widget(ids: &ShellIds, sketch_id: SketchId) -> WidgetId {
        ids.feature_part
            .child_indexed(WidgetKey::new("sketch"), sketch_id.as_u64())
    }

    fn extrude_widget(ids: &ShellIds, extrude_id: ExtrudeId) -> WidgetId {
        ids.feature_part
            .child_indexed(WidgetKey::new("extrude"), extrude_id.as_u64())
    }

    fn document_with_extrude() -> (Document, ExtrudeId) {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (mut document, sketch_id) = document_with_sketch(sketch);
        let extrude_id =
            document.commit_extrude(crate::sketch_mode::default_extrude_feature(sketch_id));
        (document, extrude_id)
    }

    #[test]
    fn f2_with_focused_sketch_row_starts_rename_in_full_shell() {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (document, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let widget = sketch_widget(&shell.ids, sketch_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();

        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, _) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        focus.request_focus(widget);
        let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, _) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm2,
        );
        assert_eq!(
            focus.focused(),
            Some(widget),
            "sketch row must be focusable+focused after second render",
        );

        let mut f2 = InputSnapshot::idle(FrameInstant::ZERO);
        f2.keys_pressed.push(bone_ui::input::KeyEvent::new(
            bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2),
            bone_ui::input::ModifierMask::NONE,
        ));
        let (_, _) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut f2,
        );
        assert_eq!(
            shell.state.feature_tree.renaming,
            Some(widget),
            "F2 with sketch row focused must enter rename",
        );
    }

    fn drive_with_snap(
        shell: &mut Shell,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        focus: &mut FocusManager,
        prev: &mut HitState,
        snap: InputSnapshot,
    ) -> (ShellFrame, HitFrame) {
        let mut snap = snap;
        shell_drive(shell, document, mode, selection, focus, prev, &mut snap)
    }

    #[test]
    fn status_bar_uses_current_sketch_label_when_in_sketch_mode() {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (mut document, sketch_id) = document_with_sketch(sketch);
        let Ok(()) = document.rename_sketch(sketch_id, "Profile") else {
            panic!("rename must accept non-empty label");
        };
        let label = super::mode_status_label(
            StringTable::empty(),
            &Mode::enter_sketch(sketch_id),
            &document,
        );
        let LabelText::Owned(text) = label else {
            panic!("sketch-mode status label is owned text");
        };
        assert!(
            text.contains("Profile"),
            "status text must include current sketch label, got {text:?}",
        );
        assert!(
            !text.contains("Sketch1"),
            "status text must not show the prior label after rename, got {text:?}",
        );
    }

    #[test]
    fn sketch_row_hit_rect_lies_within_left_pane_bounds() {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (document, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let widget = sketch_widget(&shell.ids, sketch_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (frame, hits) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        let Some(row_rect) = hits
            .items()
            .iter()
            .find(|item| item.id == widget)
            .map(|item| item.rect)
        else {
            panic!("sketch row must register a hit item");
        };
        let viewport = frame.viewport_rect;
        let row_right = row_rect.origin.x.value() + row_rect.size.width.value();
        let row_bottom = row_rect.origin.y.value() + row_rect.size.height.value();
        assert!(
            row_right <= viewport.origin.x.value(),
            "sketch row must sit left of the viewport, row_right={row_right} viewport_x={}",
            viewport.origin.x.value(),
        );
        assert!(
            row_rect.origin.y.value() >= 0.0,
            "sketch row origin y >= 0, got {}",
            row_rect.origin.y.value(),
        );
        assert!(
            row_bottom <= 800.0,
            "sketch row must fit within 800px tall window, row_bottom={row_bottom}",
        );
    }

    #[test]
    fn property_header_accept_emits_confirm_via_full_shell() {
        use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample};
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (document, sketch_id) = document_with_sketch(sketch);
        let mode = Mode::Extrude(ExtrudeArming::profile(sketch_id));
        let mut shell = Shell::new();
        let accept_id = shell
            .ids
            .property_pane
            .child(WidgetKey::new("header"))
            .child(WidgetKey::new("accept"));
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, hits) = shell_drive(
            &mut shell,
            &document,
            &mode,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        let Some(rect) = hits
            .items()
            .iter()
            .find(|item| item.id == accept_id)
            .map(|item| item.rect)
        else {
            panic!("property header accept must register a hit item in the extrude pane");
        };
        let center = LayoutPos::new(
            LayoutPx::new(rect.origin.x.value() + rect.size.width.value() / 2.0),
            LayoutPx::new(rect.origin.y.value() + rect.size.height.value() / 2.0),
        );
        let mut hover = InputSnapshot::idle(FrameInstant::ZERO);
        hover.pointer = Some(PointerSample::new(center));
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &mode,
            &Selection::default(),
            &mut focus,
            &mut prev,
            hover,
        );
        let mut press = InputSnapshot::idle(FrameInstant::ZERO);
        press.pointer = Some(PointerSample::new(center));
        press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &mode,
            &Selection::default(),
            &mut focus,
            &mut prev,
            press,
        );
        let mut release = InputSnapshot::idle(FrameInstant::ZERO);
        release.pointer = Some(PointerSample::new(center));
        release.buttons_released = PointerButtonMask::just(PointerButton::Primary);
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &mode,
            &Selection::default(),
            &mut focus,
            &mut prev,
            release,
        );
        let mut settle = InputSnapshot::idle(FrameInstant::ZERO);
        settle.pointer = Some(PointerSample::new(center));
        let (frame, _) = drive_with_snap(
            &mut shell,
            &document,
            &mode,
            &Selection::default(),
            &mut focus,
            &mut prev,
            settle,
        );
        assert_eq!(
            frame.confirm_action,
            Some(ConfirmAction::Accept),
            "clicking the property header check must accept like the confirm corner",
        );
    }

    #[test]
    fn click_on_sketch_row_then_f2_enters_rename_via_full_shell() {
        use bone_ui::input::{
            KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask,
            PointerSample,
        };
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (document, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let widget = sketch_widget(&shell.ids, sketch_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();

        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, hits) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        let Some(row_rect) = hits
            .items()
            .iter()
            .find(|item| item.id == widget)
            .map(|item| item.rect)
        else {
            panic!("sketch row must register a hit item in the feature tree");
        };
        let center = LayoutPos::new(
            LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0),
            LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0),
        );

        let mut press = InputSnapshot::idle(FrameInstant::ZERO);
        press.pointer = Some(PointerSample::new(center));
        press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            press,
        );

        let mut release = InputSnapshot::idle(FrameInstant::ZERO);
        release.pointer = Some(PointerSample::new(center));
        release.buttons_released = PointerButtonMask::just(PointerButton::Primary);
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            release,
        );

        let mut idle = InputSnapshot::idle(FrameInstant::ZERO);
        idle.pointer = Some(PointerSample::new(center));
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            idle,
        );

        assert_eq!(
            focus.focused(),
            Some(widget),
            "click on sketch row must focus it before F2 is pressed",
        );

        let mut f2 = InputSnapshot::idle(FrameInstant::ZERO);
        f2.pointer = Some(PointerSample::new(center));
        f2.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::F2),
            ModifierMask::NONE,
        ));
        let _ = drive_with_snap(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            f2,
        );
        assert_eq!(
            shell.state.feature_tree.renaming,
            Some(widget),
            "click-then-F2 must enter rename mode on sketch row",
        );
    }

    #[test]
    fn double_click_sketch_row_emits_sketch_activated() {
        use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample};
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (document, sketch_id) = document_with_sketch(sketch);
        let mut shell = Shell::new();
        let widget = sketch_widget(&shell.ids, sketch_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();

        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, hits) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        let Some(row_rect) = hits
            .items()
            .iter()
            .find(|item| item.id == widget)
            .map(|item| item.rect)
        else {
            panic!("sketch row must register a hit item");
        };
        let center = LayoutPos::new(
            LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0),
            LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0),
        );

        let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| {
            let mut press = InputSnapshot::idle(FrameInstant::ZERO);
            press.pointer = Some(PointerSample::new(center));
            press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
            let _ = drive_with_snap(
                shell,
                &document,
                &Mode::Idle,
                &Selection::default(),
                focus,
                prev,
                press,
            );
            let mut release = InputSnapshot::idle(FrameInstant::ZERO);
            release.pointer = Some(PointerSample::new(center));
            release.buttons_released = PointerButtonMask::just(PointerButton::Primary);
            drive_with_snap(
                shell,
                &document,
                &Mode::Idle,
                &Selection::default(),
                focus,
                prev,
                release,
            )
        };

        let _ = click(&mut shell, &mut focus, &mut prev);
        let _ = click(&mut shell, &mut focus, &mut prev);
        let mut idle = InputSnapshot::idle(FrameInstant::ZERO);
        idle.pointer = Some(PointerSample::new(center));
        let (frame, _) = drive_with_snap(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            idle,
        );
        assert_eq!(
            frame.sketch_activated,
            Some(sketch_id),
            "double-click on sketch row must emit sketch_activated for that sketch",
        );
    }

    #[test]
    fn double_click_extrude_row_emits_extrude_activated() {
        use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample};
        let (document, extrude_id) = document_with_extrude();
        let mut shell = Shell::new();
        let widget = extrude_widget(&shell.ids, extrude_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();

        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, hits) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        let Some(row_rect) = hits
            .items()
            .iter()
            .find(|item| item.id == widget)
            .map(|item| item.rect)
        else {
            panic!("extrude row must register a hit item");
        };
        let center = LayoutPos::new(
            LayoutPx::new(row_rect.origin.x.value() + row_rect.size.width.value() / 2.0),
            LayoutPx::new(row_rect.origin.y.value() + row_rect.size.height.value() / 2.0),
        );
        let click = |shell: &mut Shell, focus: &mut FocusManager, prev: &mut HitState| {
            let mut press = InputSnapshot::idle(FrameInstant::ZERO);
            press.pointer = Some(PointerSample::new(center));
            press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
            let _ = drive_with_snap(
                shell,
                &document,
                &Mode::Idle,
                &Selection::default(),
                focus,
                prev,
                press,
            );
            let mut release = InputSnapshot::idle(FrameInstant::ZERO);
            release.pointer = Some(PointerSample::new(center));
            release.buttons_released = PointerButtonMask::just(PointerButton::Primary);
            drive_with_snap(
                shell,
                &document,
                &Mode::Idle,
                &Selection::default(),
                focus,
                prev,
                release,
            )
        };
        let _ = click(&mut shell, &mut focus, &mut prev);
        let _ = click(&mut shell, &mut focus, &mut prev);
        let mut idle = InputSnapshot::idle(FrameInstant::ZERO);
        idle.pointer = Some(PointerSample::new(center));
        let (frame, _) = drive_with_snap(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            idle,
        );
        assert_eq!(
            frame.extrude_activated,
            Some(extrude_id),
            "double-click on extrude row must emit extrude_activated for that extrude",
        );
    }

    #[test]
    fn f2_with_focused_extrude_row_starts_rename_in_full_shell() {
        let (document, extrude_id) = document_with_extrude();
        let mut shell = Shell::new();
        let widget = extrude_widget(&shell.ids, extrude_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();

        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, _) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm,
        );
        focus.request_focus(widget);
        let mut warm2 = InputSnapshot::idle(FrameInstant::ZERO);
        let (_, _) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut warm2,
        );

        let mut f2 = InputSnapshot::idle(FrameInstant::ZERO);
        f2.keys_pressed.push(bone_ui::input::KeyEvent::new(
            bone_ui::input::KeyCode::Named(bone_ui::input::NamedKey::F2),
            bone_ui::input::ModifierMask::NONE,
        ));
        let (_, _) = shell_drive(
            &mut shell,
            &document,
            &Mode::Idle,
            &Selection::default(),
            &mut focus,
            &mut prev,
            &mut f2,
        );
        assert_eq!(
            shell.state.feature_tree.renaming,
            Some(widget),
            "F2 with extrude row focused must enter rename",
        );
    }

    fn render_with_locale(size: LayoutSize, locale: Locale) -> ShellFrame {
        let strings = crate::strings::make_strings(locale);
        let mut shell = Shell::new();
        render_with_strings(
            &mut shell,
            Theme::light(),
            size,
            &sample_document(),
            &Mode::Idle,
            &Selection::default(),
            &strings,
        )
    }

    const CHROME_BAND_KEYS: [StringKey; 3] = [
        strings::MENU_FILE,
        strings::RIBBON_TAB_SKETCH,
        strings::STATUS_READY,
    ];

    fn assert_chrome_label_mirrors_under_rtl(key: StringKey) {
        let size = layout_size(1600.0, 900.0);
        let ltr = render_with_locale(size, Locale::EnUs);
        let rtl = render_with_locale(size, Locale::ArXb);
        let ltr_rect =
            label_rect(&ltr.paints, key).unwrap_or_else(|| panic!("ltr paint missing for {key}"));
        let rtl_rect =
            label_rect(&rtl.paints, key).unwrap_or_else(|| panic!("rtl paint missing for {key}"));
        let half = size.width.value() * 0.5;
        assert!(
            ltr_rect.origin.x.value() < half,
            "{key} must sit on the left half under ltr, got x={}",
            ltr_rect.origin.x.value(),
        );
        assert!(
            rtl_rect.origin.x.value() > half,
            "{key} must mirror to the right half under rtl, got x={}",
            rtl_rect.origin.x.value(),
        );
    }

    #[test]
    fn rtl_locale_flips_viewport_to_the_left_side() {
        let size = layout_size(1600.0, 900.0);
        let ltr = render_with_locale(size, Locale::EnUs);
        let rtl = render_with_locale(size, Locale::ArXb);
        assert!(
            ltr.viewport_rect.size.width.value() > 0.0,
            "ltr viewport must have width",
        );
        assert!(
            rtl.viewport_rect.size.width.value() > 0.0,
            "rtl viewport must have width",
        );
        assert!(
            ltr.viewport_rect.origin.x.value() > size.width.value() * 0.1,
            "ltr viewport sits right of the left pane, got x={}",
            ltr.viewport_rect.origin.x.value(),
        );
        assert!(
            rtl.viewport_rect.origin.x.value() < size.width.value() * 0.1,
            "rtl viewport must hug the left edge, got x={}",
            rtl.viewport_rect.origin.x.value(),
        );
        assert!(
            (ltr.viewport_rect.size.width.value() - rtl.viewport_rect.size.width.value()).abs()
                < 1.0,
            "viewport width is independent of direction",
        );
    }

    #[test]
    fn rtl_locale_still_renders_every_chrome_band() {
        let size = layout_size(1600.0, 900.0);
        let rtl = render_with_locale(size, Locale::ArXb);
        assert!(!rtl.paints.is_empty(), "rtl shell must emit chrome paints");
        CHROME_BAND_KEYS.into_iter().for_each(|key| {
            assert!(
                label_rect(&rtl.paints, key).is_some(),
                "rtl shell must emit a label paint for {key}",
            );
        });
    }

    #[test]
    fn rtl_locale_mirrors_menu_bar_file_label() {
        assert_chrome_label_mirrors_under_rtl(strings::MENU_FILE);
    }

    #[test]
    fn rtl_locale_mirrors_ribbon_sketch_tab() {
        assert_chrome_label_mirrors_under_rtl(strings::RIBBON_TAB_SKETCH);
    }

    #[test]
    fn rtl_locale_mirrors_status_bar_mode_label() {
        assert_chrome_label_mirrors_under_rtl(strings::STATUS_READY);
    }

    fn render_a11y_scenario(
        canvas: LayoutSize,
        table: &StringTable,
        doc: &Document,
        sketch_id: SketchId,
        selection: &Selection,
        configure: impl FnOnce(&mut Shell),
    ) -> (Shell, AccessTreeBuilder, FocusManager) {
        let mut shell = Shell::new();
        configure(&mut shell);
        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(
                Arc::clone(&theme),
                &mut input,
                &mut focus,
                &hk,
                table,
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let _ = shell.render(
                &mut ctx,
                doc,
                &Mode::enter_sketch(sketch_id),
                selection,
                &Settings::default(),
                canvas,
                None,
                None,
                None,
                &mut ViewUi::default(),
                &BTreeMap::new(),
                false,
                &[],
            );
        }
        (shell, a11y, focus)
    }

    fn collect_reachable(
        update: &accesskit::TreeUpdate,
    ) -> std::collections::BTreeSet<accesskit::NodeId> {
        let nodes: std::collections::BTreeMap<accesskit::NodeId, &accesskit::Node> =
            update.nodes.iter().map(|(id, node)| (*id, node)).collect();
        let mut seen = std::collections::BTreeSet::new();
        if let Some(tree) = update.tree.as_ref() {
            visit_reachable(tree.root, &nodes, &mut seen);
        }
        seen
    }

    fn visit_reachable(
        id: accesskit::NodeId,
        nodes: &std::collections::BTreeMap<accesskit::NodeId, &accesskit::Node>,
        seen: &mut std::collections::BTreeSet<accesskit::NodeId>,
    ) {
        if !seen.insert(id) {
            return;
        }
        if let Some(node) = nodes.get(&id) {
            node.children()
                .iter()
                .copied()
                .for_each(|c| visit_reachable(c, nodes, seen));
        }
    }

    fn entity_row_id(idx: usize) -> WidgetId {
        WidgetId::ROOT
            .child(WidgetKey::new("props.entity"))
            .child_indexed(WidgetKey::new("row"), idx as u64)
    }

    fn relation_row_id(idx: usize) -> WidgetId {
        WidgetId::ROOT
            .child(WidgetKey::new("props.relation"))
            .child_indexed(WidgetKey::new("row"), idx as u64)
    }

    fn static_row_id(label: StringKey) -> WidgetId {
        WidgetId::ROOT
            .child(WidgetKey::new("props.row"))
            .child(WidgetKey::new(label.id()))
    }

    fn dim_value_row_id() -> WidgetId {
        WidgetId::ROOT
            .child(WidgetKey::new("props.dim"))
            .child(WidgetKey::new("value"))
    }

    fn build_a11y_update(
        canvas: LayoutSize,
        table: &StringTable,
        doc: &Document,
        sketch_id: SketchId,
        selection: &Selection,
        configure: impl FnOnce(&mut Shell),
    ) -> (Shell, accesskit::TreeUpdate) {
        let (shell, a11y, focus) =
            render_a11y_scenario(canvas, table, doc, sketch_id, selection, configure);
        let update = a11y.build(table, focus.focused());
        (shell, update)
    }

    fn build_update(
        canvas: LayoutSize,
        table: &StringTable,
        doc: &Document,
        sketch_id: SketchId,
        selection: &Selection,
        configure: impl FnOnce(&mut Shell),
    ) -> accesskit::TreeUpdate {
        build_a11y_update(canvas, table, doc, sketch_id, selection, configure).1
    }

    fn chrome_widgets(ids: &ShellIds, sketch_id: SketchId) -> Vec<(WidgetId, &'static str)> {
        vec![
            (ids.ribbon, "ribbon"),
            (ids.ribbon_smart_dimension, "ribbon.smart_dimension"),
            (ids.menu_bar, "menu_bar"),
            (ids.menu_file, "menu.file"),
            (ids.menu_edit, "menu.edit"),
            (ids.menu_view, "menu.view"),
            (ids.menu_insert, "menu.insert"),
            (ids.menu_tools, "menu.tools"),
            (ids.menu_sketch, "menu.sketch"),
            (ids.menu_window, "menu.window"),
            (ids.menu_help, "menu.help"),
            (ids.status_bar, "status_bar"),
            (ids.feature_tree, "feature_tree"),
            (ids.feature_part, "feature_part"),
            (ids.plane_xy, "plane.xy"),
            (ids.plane_yz, "plane.yz"),
            (ids.plane_zx, "plane.zx"),
            (sketch_widget_id(ids.feature_part, sketch_id), "sketch.row"),
            (ids.property_pane, "property_pane"),
            (ids.doc_tabs, "doc_tabs"),
            (ids.doc_tab_model, "doc_tabs.model"),
            (ids.left_pane_tab_tree, "left_pane.tab.tree"),
            (ids.left_pane_tab_properties, "left_pane.tab.properties"),
            (
                ids.left_pane_tab_configuration,
                "left_pane.tab.configuration",
            ),
            (
                ids.left_pane_tab_dimension_expert,
                "left_pane.tab.dimension_expert",
            ),
            (ids.left_pane_tab_display, "left_pane.tab.display"),
            (ids.confirm_accept, "confirm.accept"),
            (ids.confirm_cancel, "confirm.cancel"),
        ]
    }

    fn tool_widgets(ribbon: WidgetId) -> Vec<(WidgetId, &'static str)> {
        SketchTool::ENTITIES
            .iter()
            .map(|t| (tool_widget_id(ribbon, *t), tool_key(*t)))
            .collect()
    }

    fn relation_widgets(ribbon: WidgetId) -> Vec<(WidgetId, &'static str)> {
        RelationKind::ALL
            .iter()
            .map(|k| (relation_widget_id(ribbon, *k), k.key()))
            .collect()
    }

    fn menu_dropdown_groups(ids: &ShellIds) -> Vec<(WidgetId, Vec<(WidgetId, &'static str)>)> {
        vec![
            (
                ids.menu_file,
                vec![
                    (ids.menu_file_new, "menu.file.new"),
                    (ids.menu_file_open, "menu.file.open"),
                    (ids.menu_file_save, "menu.file.save"),
                    (ids.menu_file_save_as, "menu.file.save_as"),
                    (ids.menu_file_quit, "menu.file.quit"),
                ],
            ),
            (
                ids.menu_edit,
                vec![
                    (ids.menu_edit_undo, "menu.edit.undo"),
                    (ids.menu_edit_redo, "menu.edit.redo"),
                ],
            ),
            (
                ids.menu_view,
                vec![(ids.menu_view_zoom_fit, "menu.view.zoom_fit")],
            ),
            (
                ids.menu_insert,
                vec![(
                    ids.menu_insert.child(WidgetKey::new("soon")),
                    "menu.insert.soon",
                )],
            ),
            (
                ids.menu_tools,
                vec![
                    (ids.menu_tools_options, "menu.tools.options"),
                    (ids.menu_tools_keyboard, "menu.tools.keyboard"),
                ],
            ),
            (
                ids.menu_sketch,
                vec![(ids.menu_sketch_exit, "menu.sketch.exit")],
            ),
            (
                ids.menu_window,
                vec![(
                    ids.menu_window.child(WidgetKey::new("soon")),
                    "menu.window.soon",
                )],
            ),
            (
                ids.menu_help,
                vec![(
                    ids.menu_help.child(WidgetKey::new("soon")),
                    "menu.help.soon",
                )],
            ),
        ]
    }

    fn line_entity_rows() -> Vec<(WidgetId, &'static str)> {
        vec![
            (entity_row_id(0), "entity.row.kind"),
            (entity_row_id(1), "entity.row.from"),
            (entity_row_id(2), "entity.row.to"),
            (entity_row_id(3), "entity.row.construction"),
        ]
    }

    fn horizontal_relation_rows() -> Vec<(WidgetId, &'static str)> {
        vec![
            (relation_row_id(0), "relation.row.kind"),
            (relation_row_id(1), "relation.row.target"),
        ]
    }

    fn linear_dim_rows() -> Vec<(WidgetId, &'static str)> {
        vec![
            (
                static_row_id(strings::PROPERTY_ROW_DIM_KIND),
                "dim.row.kind",
            ),
            (static_row_id(strings::PROPERTY_ROW_FROM), "dim.row.from"),
            (static_row_id(strings::PROPERTY_ROW_TO), "dim.row.to"),
            (
                static_row_id(strings::PROPERTY_ROW_DIM_DRIVES),
                "dim.row.drives",
            ),
            (dim_value_row_id(), "dim.row.value"),
        ]
    }

    fn assert_ribbon_fully_present(
        reachable: &std::collections::BTreeSet<accesskit::NodeId>,
        ribbon: WidgetId,
    ) {
        use bone_ui::a11y::widget_node_id;
        SketchTool::ENTITIES.iter().for_each(|t| {
            let nid = widget_node_id(tool_widget_id(ribbon, *t));
            assert!(
                reachable.contains(&nid),
                "{} culled by ribbon overflow at base canvas",
                tool_key(*t)
            );
        });
        RelationKind::ALL.iter().for_each(|k| {
            let nid = widget_node_id(relation_widget_id(ribbon, *k));
            assert!(
                reachable.contains(&nid),
                "{} culled by ribbon overflow at base canvas",
                k.key()
            );
        });
    }

    fn assert_widgets_reachable_and_labeled(
        expected: impl Iterator<Item = (WidgetId, &'static str)>,
        reachable: &std::collections::BTreeSet<accesskit::NodeId>,
        nodes: &std::collections::BTreeMap<accesskit::NodeId, accesskit::Node>,
    ) {
        use bone_ui::a11y::widget_node_id;
        expected.for_each(|(id, name)| {
            let nid = widget_node_id(id);
            assert!(
                reachable.contains(&nid),
                "{name} not reachable in a11y tree"
            );
            let node = nodes
                .get(&nid)
                .unwrap_or_else(|| panic!("{name} missing from a11y tree"));
            let label = node
                .label()
                .unwrap_or_else(|| panic!("{name} has no a11y label"));
            assert!(!label.is_empty(), "{name} has an empty a11y label");
        });
    }

    fn pane_updates(
        canvas: LayoutSize,
        table: &StringTable,
        doc: &Document,
        sketch_id: SketchId,
        selection: &Selection,
    ) -> (Shell, accesskit::TreeUpdate, accesskit::TreeUpdate) {
        let (shell, tree_update) =
            build_a11y_update(canvas, table, doc, sketch_id, selection, |s| {
                s.state.left_pane = LeftPane::Tree;
            });
        let props_update = build_update(canvas, table, doc, sketch_id, selection, |s| {
            s.state.left_pane = LeftPane::Properties;
        });
        (shell, tree_update, props_update)
    }

    fn open_menu_updates(
        canvas: LayoutSize,
        table: &StringTable,
        doc: &Document,
        sketch_id: SketchId,
        selection: &Selection,
        menus: &[(WidgetId, Vec<(WidgetId, &'static str)>)],
    ) -> Vec<accesskit::TreeUpdate> {
        menus
            .iter()
            .map(|(menu_id, _)| {
                let menu_id = *menu_id;
                build_update(canvas, table, doc, sketch_id, selection, |s| {
                    s.state.left_pane = LeftPane::Tree;
                    s.state.menu_bar.open = Some(menu_id);
                })
            })
            .collect()
    }

    fn selection_updates(canvas: LayoutSize, table: &StringTable) -> [accesskit::TreeUpdate; 3] {
        let (shared_sketch, rel_id, line_id) = sketch_with_relation();
        let (doc_shared, sketch_id_shared) = document_with_sketch(shared_sketch);
        let entity_update = build_update(
            canvas,
            table,
            &doc_shared,
            sketch_id_shared,
            &Selection::Entities(vec![line_id]),
            |s| s.state.left_pane = LeftPane::Properties,
        );
        let rel_update = build_update(
            canvas,
            table,
            &doc_shared,
            sketch_id_shared,
            &Selection::Relation(rel_id),
            |s| s.state.left_pane = LeftPane::Properties,
        );
        let (dim_sketch, dim_id) = sketch_with_dim(DimensionKind::Driving);
        let (doc_dim, sketch_id_dim) = document_with_sketch(dim_sketch);
        let dim_update = build_update(
            canvas,
            table,
            &doc_dim,
            sketch_id_dim,
            &Selection::Dimension(dim_id),
            |s| s.state.left_pane = LeftPane::Properties,
        );
        [entity_update, rel_update, dim_update]
    }

    fn union_reachable<'a>(
        updates: impl Iterator<Item = &'a accesskit::TreeUpdate>,
    ) -> (
        std::collections::BTreeSet<accesskit::NodeId>,
        std::collections::BTreeMap<accesskit::NodeId, accesskit::Node>,
    ) {
        let updates: Vec<&accesskit::TreeUpdate> = updates.collect();
        let reachable = updates.iter().flat_map(|u| collect_reachable(u)).collect();
        let nodes = updates
            .iter()
            .flat_map(|u| u.nodes.iter().map(|(id, node)| (*id, node.clone())))
            .collect();
        (reachable, nodes)
    }

    #[test]
    fn a11y_smoke_sketch_surface_is_reachable_and_named() {
        let table = crate::strings::make_strings(Locale::EnUs);
        let canvas = layout_size(3600.0, 900.0);

        let (doc_empty, sketch_id_empty) = document_with_sketch(bone_document::Sketch::new(
            crate::sketch_mode::Plane::Xy.basis(),
        ));
        let empty_sel = Selection::default();
        let (shell, tree_update, props_update) =
            pane_updates(canvas, &table, &doc_empty, sketch_id_empty, &empty_sel);

        let ids = &shell.ids;
        let menu_dropdowns = menu_dropdown_groups(ids);
        let menu_updates = open_menu_updates(
            canvas,
            &table,
            &doc_empty,
            sketch_id_empty,
            &empty_sel,
            &menu_dropdowns,
        );
        let [entity_update, rel_update, dim_update] = selection_updates(canvas, &table);

        let (reachable, nodes) = union_reachable(
            std::iter::once(&tree_update)
                .chain(std::iter::once(&props_update))
                .chain(menu_updates.iter())
                .chain([&entity_update, &rel_update, &dim_update]),
        );

        let chrome = chrome_widgets(ids, sketch_id_empty);
        let tools = tool_widgets(ids.ribbon);
        let relations = relation_widgets(ids.ribbon);
        let menu_items: Vec<(WidgetId, &'static str)> = menu_dropdowns
            .iter()
            .flat_map(|(_, items)| items.iter().copied())
            .collect();
        let expected = chrome
            .iter()
            .copied()
            .chain(tools.iter().copied())
            .chain(relations.iter().copied())
            .chain(menu_items)
            .chain(line_entity_rows())
            .chain(horizontal_relation_rows())
            .chain(linear_dim_rows());
        assert_widgets_reachable_and_labeled(expected, &reachable, &nodes);

        let tree_reachable = collect_reachable(&tree_update);
        assert_ribbon_fully_present(&tree_reachable, ids.ribbon);

        let tree_min = chrome.len() + tools.len() + relations.len();
        assert!(
            tree_reachable.len() >= tree_min,
            "base tree render shrank: {} a11y nodes, expected at least {tree_min}",
            tree_reachable.len()
        );
    }

    fn isometric_camera() -> Camera3 {
        use bone_types::{Point3, Projection, UnitVec3};
        use uom::si::{f64::Length as UomLength, length::millimeter};
        let Ok(projection) = Projection::orthographic(UomLength::new::<millimeter>(2.0)) else {
            unreachable!("a positive half height yields a projection");
        };
        let Ok(camera) = Camera3::new(
            Point3::from_mm(10.0, 10.0, 10.0),
            Point3::origin(),
            UnitVec3::z_axis(),
            projection,
        ) else {
            unreachable!("a non-degenerate isometric camera");
        };
        camera
    }

    fn render_phase2(
        size: LayoutSize,
        strings: &StringTable,
        document: &Document,
        mode: &Mode,
        camera3: Option<Camera3>,
        configure: impl FnOnce(&mut Shell),
    ) -> (ShellFrame, accesskit::TreeUpdate) {
        let mut shell = Shell::new();
        configure(&mut shell);
        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 view = ViewUi::default();
        let frame = {
            let mut ctx = FrameCtx::new(
                Arc::clone(&theme),
                &mut input,
                &mut focus,
                &hk,
                strings,
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            shell.render(
                &mut ctx,
                document,
                mode,
                &Selection::default(),
                &Settings::default(),
                size,
                None,
                camera3,
                None,
                &mut view,
                &BTreeMap::new(),
                false,
                &[],
            )
        };
        let update = a11y.build(strings, focus.focused());
        (frame, update)
    }

    fn any_label_rect(frame: &ShellFrame, key: StringKey) -> Option<LayoutRect> {
        label_rect(&frame.paints, key).or_else(|| label_rect(&frame.overlay_paints, key))
    }

    #[test]
    fn rtl_mirrors_extrude_property_pane() {
        let size = layout_size(1600.0, 900.0);
        let doc = sample_document();
        let mode = Mode::Extrude(ExtrudeArming::profile(SketchId::default()));
        let ltr = render_phase2(
            size,
            &crate::strings::make_strings(Locale::EnUs),
            &doc,
            &mode,
            None,
            |_| {},
        )
        .0;
        let rtl = render_phase2(
            size,
            &crate::strings::make_strings(Locale::ArXb),
            &doc,
            &mode,
            None,
            |_| {},
        )
        .0;
        let key = strings::PROPERTY_ROW_EXTRUDE_DEPTH;
        let ltr_rect =
            any_label_rect(&ltr, key).unwrap_or_else(|| panic!("ltr extrude depth row missing"));
        let rtl_rect =
            any_label_rect(&rtl, key).unwrap_or_else(|| panic!("rtl extrude depth row missing"));
        let half = size.width.value() * 0.5;
        assert!(
            ltr_rect.origin.x.value() < half,
            "extrude depth row sits on the left half under ltr, got x={}",
            ltr_rect.origin.x.value(),
        );
        assert!(
            rtl_rect.origin.x.value() > half,
            "extrude depth row mirrors to the right half under rtl, got x={}",
            rtl_rect.origin.x.value(),
        );
    }

    #[test]
    fn rtl_mirrors_file_menu_import_item() {
        let size = layout_size(1600.0, 900.0);
        let doc = sample_document();
        let open_file = |s: &mut Shell| s.state.menu_bar.open = Some(s.ids.menu_file);
        let ltr = render_phase2(
            size,
            &crate::strings::make_strings(Locale::EnUs),
            &doc,
            &Mode::Idle,
            None,
            open_file,
        )
        .0;
        let rtl = render_phase2(
            size,
            &crate::strings::make_strings(Locale::ArXb),
            &doc,
            &Mode::Idle,
            None,
            open_file,
        )
        .0;
        let key = strings::MENU_FILE_IMPORT;
        let ltr_rect =
            any_label_rect(&ltr, key).unwrap_or_else(|| panic!("ltr file import item missing"));
        let rtl_rect =
            any_label_rect(&rtl, key).unwrap_or_else(|| panic!("rtl file import item missing"));
        let half = size.width.value() * 0.5;
        assert!(
            ltr_rect.origin.x.value() < half,
            "file import item opens on the left under ltr, got x={}",
            ltr_rect.origin.x.value(),
        );
        assert!(
            rtl_rect.origin.x.value() > half,
            "file import item mirrors to the right under rtl, got x={}",
            rtl_rect.origin.x.value(),
        );
    }

    fn cube_group_x0(update: &accesskit::TreeUpdate, cube: WidgetId) -> f64 {
        let nid = bone_ui::a11y::widget_node_id(cube);
        update
            .nodes
            .iter()
            .find_map(|(id, node)| (*id == nid).then(|| node.bounds()).flatten())
            .unwrap_or_else(|| panic!("view cube group must render"))
            .x0
    }

    #[test]
    fn rtl_flips_view_cube_with_the_viewport() {
        let size = layout_size(1600.0, 900.0);
        let doc = sample_document();
        let cube = Shell::new().ids.view_cube;
        let (_, ltr) = render_phase2(
            size,
            &crate::strings::make_strings(Locale::EnUs),
            &doc,
            &Mode::Idle,
            Some(isometric_camera()),
            |_| {},
        );
        let (_, rtl) = render_phase2(
            size,
            &crate::strings::make_strings(Locale::ArXb),
            &doc,
            &Mode::Idle,
            Some(isometric_camera()),
            |_| {},
        );
        let ltr_x0 = cube_group_x0(&ltr, cube);
        let rtl_x0 = cube_group_x0(&rtl, cube);
        assert!(
            ltr_x0 > rtl_x0,
            "view cube follows the viewport across rtl: ltr x0={ltr_x0}, rtl x0={rtl_x0}",
        );
    }

    fn extrude_property_rows() -> Vec<(WidgetId, &'static str)> {
        [
            "end",
            "depth",
            "draft",
            "draft_angle",
            "direction_two",
            "thin",
            "merge",
        ]
        .into_iter()
        .map(|key| {
            (
                WidgetId::ROOT
                    .child(WidgetKey::new("props.extrude"))
                    .child(WidgetKey::new(key)),
                key,
            )
        })
        .collect()
    }

    fn visible_cube_faces(ids: &ShellIds) -> Vec<(WidgetId, &'static str)> {
        use crate::view_cube::{CubeCell, cell_widget_id};
        [
            (bone_types::StandardView::Top, "view_cube.top"),
            (bone_types::StandardView::Right, "view_cube.right"),
            (bone_types::StandardView::Back, "view_cube.back"),
        ]
        .into_iter()
        .map(|(view, name)| {
            let cell = CubeCell::all()
                .into_iter()
                .find(|c| c.standard_view() == Some(view))
                .unwrap_or_else(|| panic!("{name} names a cube face"));
            (cell_widget_id(ids.view_cube, cell), name)
        })
        .collect()
    }

    #[test]
    fn a11y_smoke_post_extrude_surface_is_reachable_and_named() {
        let table = crate::strings::make_strings(Locale::EnUs);
        let canvas = layout_size(3600.0, 900.0);
        let ids = Shell::new().ids;

        let (doc, extrude_id) = document_with_extrude();

        let (_, tree_update) = render_phase2(
            canvas,
            &table,
            &doc,
            &Mode::Idle,
            Some(isometric_camera()),
            |s| {
                s.state.left_pane = LeftPane::Tree;
                s.state.ribbon_active_tab = Some(features_tab_id(s.ids.ribbon));
            },
        );

        let (_, pane_update) = render_phase2(
            canvas,
            &table,
            &doc,
            &Mode::Extrude(ExtrudeArming::profile(SketchId::default())),
            Some(isometric_camera()),
            |_| {},
        );

        let (_, menu_update) = render_phase2(canvas, &table, &doc, &Mode::Idle, None, |s| {
            s.state.menu_bar.open = Some(s.ids.menu_file);
            s.state.menu_bar.menu.open_submenu = Some(s.ids.menu_file_export);
        });

        let (reachable, nodes) =
            union_reachable([&tree_update, &pane_update, &menu_update].into_iter());

        let ribbon_buttons = FeatureTool::ALL
            .iter()
            .map(|t| (feature_tool_widget_id(ids.ribbon, *t), feature_tool_key(*t)));
        let extrude_row =
            std::iter::once((extrude_widget(&ids, extrude_id), "feature_tree.extrude"));
        let file_menu_items = [
            (ids.menu_file_import, "menu.file.import"),
            (ids.menu_file_export, "menu.file.export"),
            (ids.menu_file_export_step, "menu.file.export.step"),
        ];

        let expected = ribbon_buttons
            .chain(extrude_row)
            .chain(extrude_property_rows())
            .chain(visible_cube_faces(&ids))
            .chain(file_menu_items);
        assert_widgets_reachable_and_labeled(expected, &reachable, &nodes);
    }
}
