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

use bone_document::{
    ContourSelection, DatumFeature, DimensionKind, DimensionValue, Direction2, Document,
    DraftAngle, DraftDirection, DraftMagnitude, ExtrudeEndCondition, ExtrudeFeature,
    ExtrudeOperation, ExtrudeSense, FeatureEdge, FeatureNode, FeatureScope, FromCondition,
    MergeResult, PrincipalPlane, RevolveAngle, RevolveAxis, RevolveDirection2, RevolveEndCondition,
    RevolveFeature, Sketch, Sketch3Version, SketchDimension, SketchEntity, SketchRelation,
    SketchStatusReport, SweepAlignment, SweepDirection, SweepFeature, SweepOrientation, SweepPath,
    SweepProfileKind, SweepTangency, SweepTwist, ThinWall, ThinWallDirection, TwistUnit,
};
use bone_types::{
    Angle, BodyOrdinal, BodyRef, Camera3, DatumId, ExtrudeId, FaceLabel, FeatureId, IconId, Length,
    Point2, PositiveLength, RevolveId, RollbackMarker, SketchDimensionId, SketchEntityId, SketchId,
    SweepId, VertexLabel,
};
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, AngleUnit, 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, ModeExit, Plane, ReferenceKind,
    RevolveArming, SketchTool, SweepArming, 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,
    menu_sketch_convert_entities: WidgetId,
    menu_sketch_toggle_construction: WidgetId,
    menu_sketch_toggle_driven: 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")),
            menu_sketch_convert_entities: menu_sketch.child(WidgetKey::new("convert_entities")),
            menu_sketch_toggle_construction: menu_sketch
                .child(WidgetKey::new("toggle_construction")),
            menu_sketch_toggle_driven: menu_sketch.child(WidgetKey::new("toggle_driven")),
            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),
            (
                self.menu_sketch_convert_entities,
                MenuAction::ConvertEntities,
            ),
            (
                self.menu_sketch_toggle_construction,
                MenuAction::ToggleConstruction,
            ),
            (self.menu_sketch_toggle_driven, MenuAction::ToggleDriven),
        ]
        .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,
    ConvertEntities,
    ToggleConstruction,
    ToggleDriven,
    TogglePlanes,
}

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

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FeatureCommand {
    Suppress(FeatureId),
    Unsuppress(FeatureId),
    RollbackToHere(FeatureId),
    TogglePlaneHidden(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: LabelText,
    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 revolve_property: Option<RevolvePropertyEditor>,
    pub sweep_property: Option<SweepPropertyEditor>,
    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_fully_armed: 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<(Sketch3Version, 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, Eq)]
pub enum FromKind {
    SketchPlane,
    Offset,
    Surface,
    Vertex,
}

impl FromKind {
    pub const SUPPORTED: &'static [Self] =
        &[Self::SketchPlane, Self::Offset, Self::Surface, Self::Vertex];

    #[must_use]
    fn of(from: &FromCondition) -> Self {
        match from {
            FromCondition::SketchPlane => Self::SketchPlane,
            FromCondition::Offset { .. } => Self::Offset,
            FromCondition::Surface { .. } => Self::Surface,
            FromCondition::Vertex { .. } => Self::Vertex,
        }
    }

    #[must_use]
    fn reference_kind(self) -> Option<ReferenceKind> {
        match self {
            Self::Surface => Some(ReferenceKind::Face),
            Self::Vertex => Some(ReferenceKind::Vertex),
            Self::SketchPlane | Self::Offset => None,
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ReferenceSlot {
    Direction1(EndConditionKind),
    Direction2(EndConditionKind),
    From(FromKind),
}

impl ReferenceSlot {
    #[must_use]
    pub fn reference_kind(self) -> Option<ReferenceKind> {
        match self {
            Self::Direction1(kind) | Self::Direction2(kind) => kind.reference_kind(),
            Self::From(kind) => kind.reference_kind(),
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CapturedReference {
    Face(FaceLabel),
    Vertex(VertexLabel),
    Body(FeatureId),
}

const DEFAULT_OFFSET_FROM_SURFACE_MM: f64 = 10.0;

#[must_use]
fn reference_end_condition(
    kind: EndConditionKind,
    captured: CapturedReference,
) -> Option<ExtrudeEndCondition> {
    match (kind, captured) {
        (EndConditionKind::UpToVertex, CapturedReference::Vertex(vertex)) => {
            Some(ExtrudeEndCondition::UpToVertex { vertex })
        }
        (EndConditionKind::UpToSurface, CapturedReference::Face(surface)) => {
            Some(ExtrudeEndCondition::UpToSurface { surface })
        }
        (EndConditionKind::OffsetFromSurface, CapturedReference::Face(surface)) => {
            Some(ExtrudeEndCondition::OffsetFromSurface {
                surface,
                offset: Length::new::<millimeter>(DEFAULT_OFFSET_FROM_SURFACE_MM),
                reverse_offset: false,
                translate_surface: false,
            })
        }
        (EndConditionKind::UpToBody, CapturedReference::Body(body)) => {
            Some(ExtrudeEndCondition::UpToBody { body })
        }
        _ => None,
    }
}

#[must_use]
fn reference_from_condition(kind: FromKind, captured: CapturedReference) -> Option<FromCondition> {
    match (kind, captured) {
        (FromKind::Surface, CapturedReference::Face(surface)) => {
            Some(FromCondition::Surface { surface })
        }
        (FromKind::Vertex, CapturedReference::Vertex(vertex)) => {
            Some(FromCondition::Vertex { vertex })
        }
        _ => None,
    }
}

#[must_use]
pub fn apply_extrude_reference(
    feature: ExtrudeFeature,
    slot: ReferenceSlot,
    captured: CapturedReference,
) -> ExtrudeFeature {
    match slot {
        ReferenceSlot::Direction1(kind) => match reference_end_condition(kind, captured) {
            Some(end_condition) => ExtrudeFeature {
                end_condition,
                ..feature
            },
            None => feature,
        },
        ReferenceSlot::Direction2(kind) => match reference_end_condition(kind, captured) {
            Some(end_condition) => ExtrudeFeature {
                direction2: Some(Direction2 {
                    end_condition,
                    draft: feature.direction2.and_then(|d| d.draft),
                }),
                ..feature
            },
            None => feature,
        },
        ReferenceSlot::From(kind) => match reference_from_condition(kind, captured) {
            Some(from) => ExtrudeFeature { from, ..feature },
            None => feature,
        },
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ExtrudeEdit {
    EndCondition(EndConditionKind),
    Depth(PositiveLength),
    Offset(Length),
    ReverseOffset(bool),
    Draft(Option<DraftAngle>),
    Direction2(Option<Direction2>),
    Thin(Option<ThinWall>),
    From(FromCondition),
    FlipSide(bool),
    Scope(FeatureScope),
    Merge(MergeResult),
    ArmReference(ReferenceSlot),
}

#[must_use]
fn rebuild_end_condition(
    kind: EndConditionKind,
    current: &ExtrudeEndCondition,
) -> ExtrudeEndCondition {
    match kind {
        EndConditionKind::Blind | EndConditionKind::MidPlane => {
            let depth = end_condition_depth(current).unwrap_or_else(default_extrude_depth);
            kind.with_depth(depth)
        }
        EndConditionKind::ThroughAll => ExtrudeEndCondition::ThroughAll,
        EndConditionKind::ThroughAllBoth => ExtrudeEndCondition::ThroughAllBoth,
        EndConditionKind::UpToSurface => match current {
            ExtrudeEndCondition::OffsetFromSurface { surface, .. }
            | ExtrudeEndCondition::UpToSurface { surface } => {
                ExtrudeEndCondition::UpToSurface { surface: *surface }
            }
            _ => *current,
        },
        EndConditionKind::OffsetFromSurface => match current {
            ExtrudeEndCondition::UpToSurface { surface }
            | ExtrudeEndCondition::OffsetFromSurface { surface, .. } => {
                ExtrudeEndCondition::OffsetFromSurface {
                    surface: *surface,
                    offset: offset_of(current).unwrap_or_else(|| {
                        Length::new::<millimeter>(DEFAULT_OFFSET_FROM_SURFACE_MM)
                    }),
                    reverse_offset: reverse_offset_of(current),
                    translate_surface: false,
                }
            }
            _ => *current,
        },
        EndConditionKind::UpToVertex | EndConditionKind::UpToBody => *current,
    }
}

#[must_use]
fn offset_of(condition: &ExtrudeEndCondition) -> Option<Length> {
    match condition {
        ExtrudeEndCondition::OffsetFromSurface { offset, .. } => Some(*offset),
        _ => None,
    }
}

#[must_use]
fn reverse_offset_of(condition: &ExtrudeEndCondition) -> bool {
    matches!(
        condition,
        ExtrudeEndCondition::OffsetFromSurface {
            reverse_offset: true,
            ..
        }
    )
}

impl ExtrudeEdit {
    #[must_use]
    pub fn apply(self, feature: ExtrudeFeature) -> ExtrudeFeature {
        match self {
            Self::EndCondition(kind) => ExtrudeFeature {
                end_condition: rebuild_end_condition(kind, &feature.end_condition),
                ..feature
            },
            Self::Depth(depth) => match EndConditionKind::of(&feature.end_condition) {
                Some(kind) if kind.needs_depth() => ExtrudeFeature {
                    end_condition: kind.with_depth(depth),
                    ..feature
                },
                _ => feature,
            },
            Self::Offset(offset) => match feature.end_condition {
                ExtrudeEndCondition::OffsetFromSurface {
                    surface,
                    reverse_offset,
                    translate_surface,
                    ..
                } => ExtrudeFeature {
                    end_condition: ExtrudeEndCondition::OffsetFromSurface {
                        surface,
                        offset,
                        reverse_offset,
                        translate_surface,
                    },
                    ..feature
                },
                _ => feature,
            },
            Self::ReverseOffset(reverse_offset) => match feature.end_condition {
                ExtrudeEndCondition::OffsetFromSurface {
                    surface,
                    offset,
                    translate_surface,
                    ..
                } => ExtrudeFeature {
                    end_condition: ExtrudeEndCondition::OffsetFromSurface {
                        surface,
                        offset,
                        reverse_offset,
                        translate_surface,
                    },
                    ..feature
                },
                _ => feature,
            },
            Self::Draft(draft) => ExtrudeFeature { draft, ..feature },
            Self::Direction2(direction2) => ExtrudeFeature {
                direction2,
                ..feature
            },
            Self::Thin(thin_wall) => ExtrudeFeature {
                thin_wall,
                ..feature
            },
            Self::From(from) => ExtrudeFeature { from, ..feature },
            Self::FlipSide(flip_side) => ExtrudeFeature {
                flip_side,
                ..feature
            },
            Self::Scope(scope) => ExtrudeFeature { scope, ..feature },
            Self::Merge(merge) => match feature.operation {
                ExtrudeOperation::Boss(_) => ExtrudeFeature {
                    operation: ExtrudeOperation::Boss(merge),
                    ..feature
                },
                ExtrudeOperation::Cut => feature,
            },
            Self::ArmReference(_) => feature,
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RevolveKind {
    Blind,
    MidPlane,
}

impl RevolveKind {
    const SUPPORTED: &'static [Self] = &[Self::Blind, Self::MidPlane];

    #[must_use]
    fn of(condition: &RevolveEndCondition) -> Self {
        match condition {
            RevolveEndCondition::MidPlane { .. } => Self::MidPlane,
            _ => Self::Blind,
        }
    }

    #[must_use]
    fn label(self) -> StringKey {
        match self {
            Self::Blind => strings::EXTRUDE_END_BLIND,
            Self::MidPlane => strings::EXTRUDE_END_MIDPLANE,
        }
    }
}

fn revolve_kind_options() -> Vec<PropertyOption> {
    RevolveKind::SUPPORTED
        .iter()
        .map(|kind| PropertyOption {
            label: kind.label(),
        })
        .collect()
}

fn revolve_kind_index(condition: &RevolveEndCondition) -> usize {
    let kind = RevolveKind::of(condition);
    RevolveKind::SUPPORTED
        .iter()
        .position(|candidate| *candidate == kind)
        .unwrap_or(0)
}

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

fn revolve_angle_value(condition: &RevolveEndCondition) -> Angle {
    match condition {
        RevolveEndCondition::Blind { angle } | RevolveEndCondition::MidPlane { angle } => {
            angle.get()
        }
        _ => RevolveAngle::full().get(),
    }
}

fn revolve_dir2_angle(direction2: Option<RevolveDirection2>) -> Angle {
    direction2.map_or_else(
        || RevolveAngle::full().get(),
        |second| revolve_angle_value(&second.end_condition),
    )
}

fn revolve_merge_on(feature: &RevolveFeature) -> bool {
    matches!(
        feature.operation,
        ExtrudeOperation::Boss(MergeResult::Merge)
    )
}

fn with_revolve_angle(kind: RevolveKind, angle: RevolveAngle) -> RevolveEndCondition {
    match kind {
        RevolveKind::Blind => RevolveEndCondition::Blind { angle },
        RevolveKind::MidPlane => RevolveEndCondition::MidPlane { angle },
    }
}

fn rebuild_revolve_condition(
    kind: RevolveKind,
    current: &RevolveEndCondition,
) -> RevolveEndCondition {
    let angle =
        RevolveAngle::new(revolve_angle_value(current)).unwrap_or_else(|_| RevolveAngle::full());
    with_revolve_angle(kind, angle)
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum RevolveEdit {
    Kind(RevolveKind),
    Angle(Angle),
    Reverse(bool),
    DirectionTwo(Option<RevolveAngle>),
    Thin(Option<ThinWall>),
    Scope(FeatureScope),
    Merge(MergeResult),
    Axis(RevolveAxis),
    Contour(ContourSelection),
}

impl RevolveEdit {
    #[must_use]
    pub fn apply(self, feature: &RevolveFeature) -> RevolveFeature {
        match self {
            Self::Kind(kind) => RevolveFeature {
                end_condition: rebuild_revolve_condition(kind, &feature.end_condition),
                direction2: if matches!(kind, RevolveKind::MidPlane) {
                    None
                } else {
                    feature.direction2
                },
                ..*feature
            },
            Self::Angle(angle) => match RevolveAngle::new(angle) {
                Ok(angle) => {
                    let kind = RevolveKind::of(&feature.end_condition);
                    RevolveFeature {
                        end_condition: with_revolve_angle(kind, angle),
                        ..*feature
                    }
                }
                Err(_) => *feature,
            },
            Self::Reverse(reverse) => RevolveFeature {
                sense: if reverse {
                    ExtrudeSense::Reverse
                } else {
                    ExtrudeSense::Forward
                },
                ..*feature
            },
            Self::DirectionTwo(angle) => RevolveFeature {
                direction2: angle.map(RevolveDirection2::blind),
                ..*feature
            },
            Self::Thin(thin_wall) => RevolveFeature {
                thin_wall,
                ..*feature
            },
            Self::Scope(scope) => RevolveFeature { scope, ..*feature },
            Self::Axis(axis) => RevolveFeature { axis, ..*feature },
            Self::Contour(contours) => RevolveFeature {
                contours,
                ..*feature
            },
            Self::Merge(merge) => match feature.operation {
                ExtrudeOperation::Boss(_) => RevolveFeature {
                    operation: ExtrudeOperation::Boss(merge),
                    ..*feature
                },
                ExtrudeOperation::Cut => *feature,
            },
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SweepGuideSlot {
    One,
    Two,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum SweepEdit {
    Path(SweepPath),
    Guide {
        slot: SweepGuideSlot,
        guide: Option<SweepPath>,
    },
    Orientation {
        orientation: SweepOrientation,
        twist: Option<SweepTwist>,
    },
    Direction(SweepDirection),
    Tangency {
        start: SweepTangency,
        end: SweepTangency,
    },
    Alignment(SweepAlignment),
    ProfileKind(SweepProfileKind),
    Thin(Option<ThinWall>),
    FlipSide(bool),
    Scope(FeatureScope),
    Merge(MergeResult),
}

impl SweepEdit {
    #[must_use]
    pub fn apply(self, feature: &SweepFeature) -> SweepFeature {
        let base = feature.clone();
        match self {
            Self::Path(path) => SweepFeature { path, ..base },
            Self::Guide { slot, guide } => {
                let kept = |index: usize| base.guides.get(index).copied();
                let (one, two) = match slot {
                    SweepGuideSlot::One => (guide, kept(1)),
                    SweepGuideSlot::Two => (kept(0), guide),
                };
                SweepFeature {
                    guides: [one, two].into_iter().flatten().collect(),
                    ..base
                }
            }
            Self::Orientation { orientation, twist } => {
                let alignment = if matches!(orientation, SweepOrientation::FollowPath) {
                    base.alignment
                } else {
                    SweepAlignment::default()
                };
                SweepFeature {
                    orientation,
                    twist,
                    alignment,
                    ..base
                }
            }
            Self::Direction(direction) => SweepFeature { direction, ..base },
            Self::Tangency { start, end } => SweepFeature {
                start_tangency: start,
                end_tangency: end,
                ..base
            },
            Self::Alignment(alignment) => SweepFeature { alignment, ..base },
            Self::ProfileKind(profile_kind) => {
                let scope = match profile_kind {
                    SweepProfileKind::Solid { .. } => FeatureScope::AllBodies,
                    SweepProfileKind::Sketch | SweepProfileKind::Circular { .. } => base.scope,
                };
                SweepFeature {
                    profile_kind,
                    scope,
                    ..base
                }
            }
            Self::Thin(thin_wall) => SweepFeature { thin_wall, ..base },
            Self::FlipSide(flip_side) => SweepFeature { flip_side, ..base },
            Self::Scope(scope) => SweepFeature { scope, ..base },
            Self::Merge(merge) => match base.operation {
                ExtrudeOperation::Boss(_) => SweepFeature {
                    operation: ExtrudeOperation::Boss(merge),
                    ..base
                },
                ExtrudeOperation::Cut => base,
            },
        }
    }
}

pub struct RevolvePropertyEditor {
    sketch: SketchId,
    kind: SelectionEditor,
    angle: AngleEditor,
    reverse: BoolEditor,
    axis: SelectionEditor,
    direction_two: BoolEditor,
    dir2_angle: AngleEditor,
    thin: BoolEditor,
    thin_direction: SelectionEditor,
    thickness: LengthEditor,
    thickness2: LengthEditor,
    scope: SelectionEditor,
    contours: SelectionEditor,
    merge: BoolEditor,
    is_cut: bool,
}

impl RevolvePropertyEditor {
    fn new(feature: &RevolveFeature) -> Self {
        Self {
            sketch: feature.sketch,
            kind: SelectionEditor::new(
                revolve_kind_options(),
                Some(revolve_kind_index(&feature.end_condition)),
            ),
            angle: AngleEditor::new(revolve_angle_value(&feature.end_condition)),
            reverse: BoolEditor::new(matches!(feature.sense, ExtrudeSense::Reverse)),
            axis: revolve_axis_editor(feature.axis),
            direction_two: BoolEditor::new(feature.direction2.is_some()),
            dir2_angle: AngleEditor::new(revolve_dir2_angle(feature.direction2)),
            thin: BoolEditor::new(feature.thin_wall.is_some()),
            thin_direction: thin_direction_editor(feature.thin_wall),
            thickness: LengthEditor::new(thin_thickness_value(feature.thin_wall)),
            thickness2: LengthEditor::new(thin_thickness2_value(feature.thin_wall)),
            scope: SelectionEditor::new(scope_options(), Some(scope_index(feature.scope))),
            contours: revolve_contour_editor(feature.contours),
            merge: BoolEditor::new(revolve_merge_on(feature)),
            is_cut: feature.operation.is_cut(),
        }
    }

    fn synced(mut self, feature: &RevolveFeature) -> Self {
        self.kind.current = Some(revolve_kind_index(&feature.end_condition));
        self.angle.value = revolve_angle_value(&feature.end_condition);
        self.reverse.value = matches!(feature.sense, ExtrudeSense::Reverse);
        self.axis = revolve_axis_editor(feature.axis);
        self.direction_two.value = feature.direction2.is_some();
        self.dir2_angle.value = revolve_dir2_angle(feature.direction2);
        self.thin.value = feature.thin_wall.is_some();
        self.thin_direction.current = Some(thin_direction_index(feature.thin_wall));
        self.thickness.value = thin_thickness_value(feature.thin_wall);
        self.thickness2.value = thin_thickness2_value(feature.thin_wall);
        self.scope.current = Some(scope_index(feature.scope));
        self.contours = revolve_contour_editor(feature.contours);
        self.merge.value = revolve_merge_on(feature);
        self.is_cut = feature.operation.is_cut();
        self
    }
}

const DEFAULT_SWEEP_DIAMETER_MM: f64 = 10.0;

fn default_sweep_diameter() -> PositiveLength {
    let Ok(diameter) = PositiveLength::new(Length::new::<millimeter>(DEFAULT_SWEEP_DIAMETER_MM))
    else {
        unreachable!("constant sweep diameter is positive and finite")
    };
    diameter
}

fn sweep_merge_on(feature: &SweepFeature) -> bool {
    matches!(
        feature.operation,
        ExtrudeOperation::Boss(MergeResult::Merge)
    )
}

fn sweep_profile_display_editor() -> SelectionEditor {
    SelectionEditor::new(
        vec![PropertyOption {
            label: strings::SWEEP_PROFILE_SKETCH,
        }],
        Some(0),
    )
}

fn sweep_path_label(path: SweepPath) -> StringKey {
    match path {
        SweepPath::Sketch(sketch) if sketch == SketchId::default() => strings::SWEEP_PATH_SELECT,
        SweepPath::Sketch(_) => strings::SWEEP_PATH_SKETCH,
        SweepPath::Edge(_) => strings::SWEEP_PATH_EDGE,
    }
}

fn sweep_path_editor(path: SweepPath) -> SelectionEditor {
    SelectionEditor::new(
        vec![
            PropertyOption {
                label: sweep_path_label(path),
            },
            PropertyOption {
                label: strings::SWEEP_PATH_PICK,
            },
        ],
        Some(0),
    )
}

fn sweep_twist_type_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::SWEEP_ORIENTATION_FOLLOW,
        },
        PropertyOption {
            label: strings::SWEEP_ORIENTATION_KEEP_NORMAL,
        },
        PropertyOption {
            label: strings::SWEEP_ORIENTATION_TWIST,
        },
        PropertyOption {
            label: strings::SWEEP_ORIENTATION_TWIST_NORMAL,
        },
        PropertyOption {
            label: strings::SWEEP_ORIENTATION_FIRST_GUIDE,
        },
        PropertyOption {
            label: strings::SWEEP_ORIENTATION_BOTH_GUIDES,
        },
    ]
}

fn sweep_twist_type_index(orientation: SweepOrientation, twisted: bool) -> usize {
    match (orientation, twisted) {
        (SweepOrientation::FollowPath, false) => 0,
        (SweepOrientation::KeepNormalConstant, false) => 1,
        (SweepOrientation::FollowPath, true) => 2,
        (SweepOrientation::KeepNormalConstant, true) => 3,
        (SweepOrientation::FollowPathAndFirstGuide, _) => 4,
        (SweepOrientation::FollowFirstAndSecondGuide, _) => 5,
    }
}

fn sweep_orientation_from_twist_type(index: Option<usize>) -> SweepOrientation {
    match index {
        Some(1 | 3) => SweepOrientation::KeepNormalConstant,
        Some(4) => SweepOrientation::FollowPathAndFirstGuide,
        Some(5) => SweepOrientation::FollowFirstAndSecondGuide,
        _ => SweepOrientation::FollowPath,
    }
}

fn sweep_twist_type_is_twisted(index: Option<usize>) -> bool {
    matches!(index, Some(2 | 3))
}

fn default_sweep_twist_angle() -> Angle {
    Angle::new::<degree>(90.0)
}

fn sweep_twist_value(twist: Option<SweepTwist>) -> Angle {
    twist.map_or_else(default_sweep_twist_angle, |twist| twist.angle)
}

fn sweep_twist_unit_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::SWEEP_TWIST_UNIT_DEGREES,
        },
        PropertyOption {
            label: strings::SWEEP_TWIST_UNIT_RADIANS,
        },
        PropertyOption {
            label: strings::SWEEP_TWIST_UNIT_REVOLUTIONS,
        },
    ]
}

fn sweep_twist_unit_index(twist: Option<SweepTwist>) -> usize {
    match twist.map(|twist| twist.unit) {
        Some(TwistUnit::Radians) => 1,
        Some(TwistUnit::Revolutions) => 2,
        _ => 0,
    }
}

fn sweep_twist_unit_from_index(index: Option<usize>) -> TwistUnit {
    match index {
        Some(1) => TwistUnit::Radians,
        Some(2) => TwistUnit::Revolutions,
        _ => TwistUnit::Degrees,
    }
}

fn twist_unit_to_angle_unit(unit: TwistUnit) -> AngleUnit {
    match unit {
        TwistUnit::Degrees => AngleUnit::Degrees,
        TwistUnit::Radians => AngleUnit::Radians,
        TwistUnit::Revolutions => AngleUnit::Revolutions,
    }
}

fn sweep_twist_angle_unit(twist: Option<SweepTwist>) -> AngleUnit {
    twist.map_or(AngleUnit::Degrees, |twist| {
        twist_unit_to_angle_unit(twist.unit)
    })
}

fn current_twist_from_sweep_editor(editor: &SweepPropertyEditor) -> Option<SweepTwist> {
    sweep_twist_type_is_twisted(editor.orientation.current).then(|| {
        SweepTwist::new(
            editor.twist_value.value,
            sweep_twist_unit_from_index(editor.twist_unit.current),
        )
    })
}

fn sweep_alignment_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::SWEEP_ALIGNMENT_MINIMUM_TWIST,
        },
        PropertyOption {
            label: strings::SWEEP_ALIGNMENT_NONE,
        },
        PropertyOption {
            label: strings::SWEEP_ALIGNMENT_DIRECTION,
        },
        PropertyOption {
            label: strings::SWEEP_ALIGNMENT_ALL_FACES,
        },
    ]
}

fn sweep_alignment_index(alignment: SweepAlignment) -> usize {
    match alignment {
        SweepAlignment::MinimumTwist => 0,
        SweepAlignment::NonePlanar => 1,
        SweepAlignment::DirectionVector { .. } => 2,
        SweepAlignment::AllFaces => 3,
    }
}

fn sweep_alignment_from_index(index: Option<usize>, current: SweepAlignment) -> SweepAlignment {
    match index {
        Some(1) => SweepAlignment::NonePlanar,
        Some(2) => match current {
            SweepAlignment::DirectionVector { .. } => current,
            _ => SweepAlignment::DirectionVector {
                axis: RevolveAxis::SketchLine(SketchEntityId::default()),
            },
        },
        Some(3) => SweepAlignment::AllFaces,
        _ => SweepAlignment::MinimumTwist,
    }
}

fn sweep_alignment_axis_editor(alignment: SweepAlignment) -> SelectionEditor {
    let label = match alignment {
        SweepAlignment::DirectionVector { axis } => revolve_axis_label(axis),
        _ => strings::REVOLVE_AXIS_SELECT,
    };
    SelectionEditor::new(
        vec![
            PropertyOption { label },
            PropertyOption {
                label: strings::REVOLVE_AXIS_SELECT,
            },
        ],
        Some(0),
    )
}

fn sweep_tangency_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::SWEEP_TANGENCY_NONE,
        },
        PropertyOption {
            label: strings::SWEEP_TANGENCY_PATH,
        },
    ]
}

fn sweep_tangency_index(tangency: SweepTangency) -> usize {
    match tangency {
        SweepTangency::None => 0,
        SweepTangency::PathTangent => 1,
    }
}

fn sweep_tangency_from_index(index: Option<usize>) -> SweepTangency {
    match index {
        Some(1) => SweepTangency::PathTangent,
        _ => SweepTangency::None,
    }
}

fn sweep_guide_label(guide: Option<SweepPath>) -> StringKey {
    match guide {
        None => strings::SWEEP_GUIDE_NONE,
        Some(SweepPath::Sketch(_)) => strings::SWEEP_GUIDE_SKETCH,
        Some(SweepPath::Edge(_)) => strings::SWEEP_GUIDE_EDGE,
    }
}

fn sweep_guide_editor(guide: Option<SweepPath>) -> SelectionEditor {
    let options = core::iter::once(PropertyOption {
        label: sweep_guide_label(guide),
    })
    .chain(core::iter::once(PropertyOption {
        label: strings::SWEEP_GUIDE_SELECT,
    }))
    .chain(guide.is_some().then_some(PropertyOption {
        label: strings::SWEEP_GUIDE_CLEAR,
    }))
    .collect();
    SelectionEditor::new(options, Some(0))
}

fn sweep_direction_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::SWEEP_DIRECTION_ONE,
        },
        PropertyOption {
            label: strings::SWEEP_DIRECTION_TWO,
        },
        PropertyOption {
            label: strings::SWEEP_DIRECTION_BIDIRECTIONAL,
        },
    ]
}

fn sweep_direction_index(direction: SweepDirection) -> usize {
    match direction {
        SweepDirection::DirectionOne => 0,
        SweepDirection::DirectionTwo => 1,
        SweepDirection::Bidirectional => 2,
    }
}

fn sweep_direction_from_index(index: Option<usize>) -> SweepDirection {
    match index {
        Some(1) => SweepDirection::DirectionTwo,
        Some(2) => SweepDirection::Bidirectional,
        _ => SweepDirection::DirectionOne,
    }
}

fn sweep_profile_kind_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::SWEEP_PROFILE_KIND_SKETCH,
        },
        PropertyOption {
            label: strings::SWEEP_PROFILE_KIND_CIRCULAR,
        },
        PropertyOption {
            label: strings::SWEEP_PROFILE_KIND_SOLID,
        },
    ]
}

fn sweep_profile_kind_index(kind: SweepProfileKind) -> usize {
    match kind {
        SweepProfileKind::Sketch => 0,
        SweepProfileKind::Circular { .. } => SWEEP_CIRCULAR_INDEX,
        SweepProfileKind::Solid { .. } => SWEEP_SOLID_INDEX,
    }
}

fn sweep_diameter_value(kind: SweepProfileKind) -> Length {
    match kind {
        SweepProfileKind::Circular { diameter } => diameter.get(),
        SweepProfileKind::Sketch | SweepProfileKind::Solid { .. } => default_sweep_diameter().get(),
    }
}

fn sweep_tool_label(kind: SweepProfileKind) -> StringKey {
    match kind {
        SweepProfileKind::Solid { .. } => strings::SWEEP_TOOL_BODY,
        SweepProfileKind::Sketch | SweepProfileKind::Circular { .. } => strings::SWEEP_TOOL_SELECT,
    }
}

fn sweep_tool_editor(kind: SweepProfileKind) -> SelectionEditor {
    SelectionEditor::new(
        vec![
            PropertyOption {
                label: sweep_tool_label(kind),
            },
            PropertyOption {
                label: strings::SWEEP_TOOL_SELECT,
            },
        ],
        Some(0),
    )
}

pub struct SweepPropertyEditor {
    profile: SketchId,
    profile_display: SelectionEditor,
    path: SelectionEditor,
    current_path: SweepPath,
    direction: SelectionEditor,
    guide_one: SelectionEditor,
    guide_two: SelectionEditor,
    guide_one_filled: bool,
    start_tangency: SelectionEditor,
    end_tangency: SelectionEditor,
    alignment: SelectionEditor,
    alignment_axis: SelectionEditor,
    current_alignment: SweepAlignment,
    orientation: SelectionEditor,
    twist_value: AngleEditor,
    twist_unit: SelectionEditor,
    profile_kind: SelectionEditor,
    current_profile_kind: SweepProfileKind,
    diameter: LengthEditor,
    tool_display: SelectionEditor,
    thin: BoolEditor,
    thin_direction: SelectionEditor,
    thickness: LengthEditor,
    thickness2: LengthEditor,
    flip_side: BoolEditor,
    scope: SelectionEditor,
    merge: BoolEditor,
    is_cut: bool,
}

impl SweepPropertyEditor {
    fn new(feature: &SweepFeature) -> Self {
        Self {
            profile: feature.profile,
            profile_display: sweep_profile_display_editor(),
            path: sweep_path_editor(feature.path),
            current_path: feature.path,
            direction: SelectionEditor::new(
                sweep_direction_options(),
                Some(sweep_direction_index(feature.direction)),
            ),
            guide_one: sweep_guide_editor(feature.guides.first().copied()),
            guide_two: sweep_guide_editor(feature.guides.get(1).copied()),
            guide_one_filled: !feature.guides.is_empty(),
            start_tangency: SelectionEditor::new(
                sweep_tangency_options(),
                Some(sweep_tangency_index(feature.start_tangency)),
            ),
            end_tangency: SelectionEditor::new(
                sweep_tangency_options(),
                Some(sweep_tangency_index(feature.end_tangency)),
            ),
            alignment: SelectionEditor::new(
                sweep_alignment_options(),
                Some(sweep_alignment_index(feature.alignment)),
            ),
            alignment_axis: sweep_alignment_axis_editor(feature.alignment),
            current_alignment: feature.alignment,
            orientation: SelectionEditor::new(
                sweep_twist_type_options(),
                Some(sweep_twist_type_index(
                    feature.orientation,
                    feature.twist.is_some(),
                )),
            ),
            twist_value: AngleEditor::new_in(
                sweep_twist_value(feature.twist),
                sweep_twist_angle_unit(feature.twist),
            ),
            twist_unit: SelectionEditor::new(
                sweep_twist_unit_options(),
                Some(sweep_twist_unit_index(feature.twist)),
            ),
            profile_kind: SelectionEditor::new(
                sweep_profile_kind_options(),
                Some(sweep_profile_kind_index(feature.profile_kind)),
            ),
            current_profile_kind: feature.profile_kind,
            diameter: LengthEditor::new(sweep_diameter_value(feature.profile_kind)),
            tool_display: sweep_tool_editor(feature.profile_kind),
            thin: BoolEditor::new(feature.thin_wall.is_some()),
            thin_direction: thin_direction_editor(feature.thin_wall),
            thickness: LengthEditor::new(thin_thickness_value(feature.thin_wall)),
            thickness2: LengthEditor::new(thin_thickness2_value(feature.thin_wall)),
            flip_side: BoolEditor::new(feature.flip_side),
            scope: SelectionEditor::new(scope_options(), Some(scope_index(feature.scope))),
            merge: BoolEditor::new(sweep_merge_on(feature)),
            is_cut: feature.operation.is_cut(),
        }
    }

    fn synced(mut self, feature: &SweepFeature) -> Self {
        if feature.path != self.current_path {
            self.path = sweep_path_editor(feature.path);
            self.current_path = feature.path;
        }
        self.direction.current = Some(sweep_direction_index(feature.direction));
        self.guide_one = sweep_guide_editor(feature.guides.first().copied());
        self.guide_two = sweep_guide_editor(feature.guides.get(1).copied());
        self.guide_one_filled = !feature.guides.is_empty();
        self.start_tangency.current = Some(sweep_tangency_index(feature.start_tangency));
        self.end_tangency.current = Some(sweep_tangency_index(feature.end_tangency));
        self.alignment.current = Some(sweep_alignment_index(feature.alignment));
        self.alignment_axis = sweep_alignment_axis_editor(feature.alignment);
        self.current_alignment = feature.alignment;
        self.orientation.current = Some(sweep_twist_type_index(
            feature.orientation,
            feature.twist.is_some(),
        ));
        self.twist_value.value = sweep_twist_value(feature.twist);
        self.twist_value.unit = sweep_twist_angle_unit(feature.twist);
        self.twist_unit.current = Some(sweep_twist_unit_index(feature.twist));
        self.profile_kind.current = Some(sweep_profile_kind_index(feature.profile_kind));
        self.current_profile_kind = feature.profile_kind;
        self.diameter.value = sweep_diameter_value(feature.profile_kind);
        self.tool_display = sweep_tool_editor(feature.profile_kind);
        self.thin.value = feature.thin_wall.is_some();
        self.thin_direction.current = Some(thin_direction_index(feature.thin_wall));
        self.thickness.value = thin_thickness_value(feature.thin_wall);
        self.thickness2.value = thin_thickness2_value(feature.thin_wall);
        self.flip_side.value = feature.flip_side;
        self.scope.current = Some(scope_index(feature.scope));
        self.merge.value = sweep_merge_on(feature);
        self.is_cut = feature.operation.is_cut();
        self
    }
}

const REVOLVE_PICK_INDEX: usize = 1;

fn revolve_axis_label(axis: RevolveAxis) -> StringKey {
    match axis {
        RevolveAxis::SketchLine(_) => strings::REVOLVE_AXIS_SKETCH_LINE,
        RevolveAxis::Edge(_) => strings::REVOLVE_AXIS_EDGE,
        RevolveAxis::Datum(_) => strings::REVOLVE_AXIS_DATUM,
        RevolveAxis::Sketch3Line { .. } => strings::REVOLVE_AXIS_3D_SKETCH_LINE,
    }
}

fn revolve_axis_editor(axis: RevolveAxis) -> SelectionEditor {
    SelectionEditor::new(
        vec![
            PropertyOption {
                label: revolve_axis_label(axis),
            },
            PropertyOption {
                label: strings::REVOLVE_AXIS_SELECT,
            },
        ],
        Some(0),
    )
}

fn revolve_contour_label(contours: ContourSelection) -> StringKey {
    match contours {
        ContourSelection::All => strings::REVOLVE_CONTOUR_ALL,
        ContourSelection::Contour(_) => strings::REVOLVE_CONTOUR_ONE,
    }
}

fn revolve_contour_editor(contours: ContourSelection) -> SelectionEditor {
    SelectionEditor::new(
        vec![
            PropertyOption {
                label: revolve_contour_label(contours),
            },
            PropertyOption {
                label: strings::REVOLVE_CONTOUR_SELECT,
            },
        ],
        Some(0),
    )
}

fn resolve_revolve_arm(editor: &RevolvePropertyEditor, changed: &[WidgetId]) -> (bool, bool) {
    let arm_axis = changed.contains(&revolve_row_id("axis"))
        && editor.axis.current == Some(REVOLVE_PICK_INDEX);
    let arm_contour = changed.contains(&revolve_row_id("contours"))
        && editor.contours.current == Some(REVOLVE_PICK_INDEX);
    (arm_axis, arm_contour)
}

pub struct ExtrudePropertyEditor {
    sketch: SketchId,
    end_condition: SelectionEditor,
    depth: LengthEditor,
    direction1_reference: SelectionEditor,
    offset: LengthEditor,
    reverse_offset: BoolEditor,
    draft_enabled: BoolEditor,
    draft_angle: AngleEditor,
    draft_direction: SelectionEditor,
    direction_two: BoolEditor,
    dir2_end_condition: SelectionEditor,
    dir2_depth: LengthEditor,
    dir2_reference: SelectionEditor,
    from_kind: SelectionEditor,
    from_distance: LengthEditor,
    from_reference: SelectionEditor,
    scope: SelectionEditor,
    flip_side: BoolEditor,
    thin: BoolEditor,
    thin_direction: SelectionEditor,
    thickness: LengthEditor,
    thickness2: LengthEditor,
    cap_ends: BoolEditor,
    merge: BoolEditor,
    is_cut: bool,
}

fn merge_on(feature: &ExtrudeFeature) -> bool {
    matches!(
        feature.operation,
        ExtrudeOperation::Boss(MergeResult::Merge)
    )
}

const DEFAULT_THIN_THICKNESS_MM: f64 = 1.0;

#[must_use]
fn default_thin_thickness() -> PositiveLength {
    let Ok(thickness) = PositiveLength::new(Length::new::<millimeter>(DEFAULT_THIN_THICKNESS_MM))
    else {
        unreachable!("constant default thin thickness is positive and finite");
    };
    thickness
}

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.end_condition)),
            direction1_reference: reference_editor(reference_label_of(&feature.end_condition)),
            offset: LengthEditor::new(current_offset(&feature.end_condition)),
            reverse_offset: BoolEditor::new(reverse_offset_of(&feature.end_condition)),
            draft_enabled: BoolEditor::new(feature.draft.is_some()),
            draft_angle: AngleEditor::new(current_draft_angle(feature.draft)),
            draft_direction: draft_direction_editor(feature.draft),
            direction_two: BoolEditor::new(feature.direction2.is_some()),
            dir2_end_condition: SelectionEditor::new(
                end_condition_options(),
                Some(dir2_kind_index(feature.direction2)),
            ),
            dir2_depth: LengthEditor::new(dir2_depth_value(feature.direction2)),
            dir2_reference: reference_editor(dir2_reference_label(feature.direction2)),
            from_kind: SelectionEditor::new(from_options(), Some(from_index(&feature.from))),
            from_distance: LengthEditor::new(from_distance_value(&feature.from)),
            from_reference: reference_editor(from_reference_label(&feature.from)),
            scope: SelectionEditor::new(scope_options(), Some(scope_index(feature.scope))),
            flip_side: BoolEditor::new(feature.flip_side),
            thin: BoolEditor::new(feature.thin_wall.is_some()),
            thin_direction: thin_direction_editor(feature.thin_wall),
            thickness: LengthEditor::new(thin_thickness_value(feature.thin_wall)),
            thickness2: LengthEditor::new(thin_thickness2_value(feature.thin_wall)),
            cap_ends: BoolEditor::new(thin_cap_ends(feature.thin_wall)),
            merge: BoolEditor::new(merge_on(feature)),
            is_cut: feature.operation.is_cut(),
        }
    }

    fn synced(mut self, feature: &ExtrudeFeature) -> Self {
        self.end_condition.current = Some(kind_index(&feature.end_condition));
        self.depth.value = current_depth(&feature.end_condition);
        self.direction1_reference = reference_editor(reference_label_of(&feature.end_condition));
        self.offset.value = current_offset(&feature.end_condition);
        self.reverse_offset.value = reverse_offset_of(&feature.end_condition);
        self.draft_enabled.value = feature.draft.is_some();
        self.draft_angle.value = current_draft_angle(feature.draft);
        self.draft_direction.current = Some(draft_direction_index(feature.draft));
        self.direction_two.value = feature.direction2.is_some();
        self.dir2_end_condition.current = Some(dir2_kind_index(feature.direction2));
        self.dir2_depth.value = dir2_depth_value(feature.direction2);
        self.dir2_reference = reference_editor(dir2_reference_label(feature.direction2));
        self.from_kind.current = Some(from_index(&feature.from));
        self.from_distance.value = from_distance_value(&feature.from);
        self.from_reference = reference_editor(from_reference_label(&feature.from));
        self.scope.current = Some(scope_index(feature.scope));
        self.flip_side.value = feature.flip_side;
        self.thin.value = feature.thin_wall.is_some();
        self.thin_direction.current = Some(thin_direction_index(feature.thin_wall));
        self.thickness.value = thin_thickness_value(feature.thin_wall);
        self.thickness2.value = thin_thickness2_value(feature.thin_wall);
        self.cap_ends.value = thin_cap_ends(feature.thin_wall);
        self.merge.value = merge_on(feature);
        self.is_cut = feature.operation.is_cut();
        self
    }
}

const REFERENCE_PICK_INDEX: usize = 1;

fn reference_editor(label: StringKey) -> SelectionEditor {
    SelectionEditor::new(
        vec![
            PropertyOption { label },
            PropertyOption {
                label: strings::EXTRUDE_REFERENCE_SELECT,
            },
        ],
        Some(0),
    )
}

fn reference_label_of(condition: &ExtrudeEndCondition) -> StringKey {
    match condition {
        ExtrudeEndCondition::UpToVertex { .. }
        | ExtrudeEndCondition::UpToSurface { .. }
        | ExtrudeEndCondition::OffsetFromSurface { .. }
        | ExtrudeEndCondition::UpToBody { .. } => strings::EXTRUDE_REFERENCE_SET,
        _ => strings::EXTRUDE_REFERENCE_NONE,
    }
}

fn dir2_reference_label(direction2: Option<Direction2>) -> StringKey {
    direction2.map_or(strings::EXTRUDE_REFERENCE_NONE, |d| {
        reference_label_of(&d.end_condition)
    })
}

fn from_reference_label(from: &FromCondition) -> StringKey {
    match from {
        FromCondition::Surface { .. } | FromCondition::Vertex { .. } => {
            strings::EXTRUDE_REFERENCE_SET
        }
        _ => strings::EXTRUDE_REFERENCE_NONE,
    }
}

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::ThroughAll => strings::EXTRUDE_END_THROUGH_ALL,
        EndConditionKind::ThroughAllBoth => strings::EXTRUDE_END_THROUGH_ALL_BOTH,
        EndConditionKind::MidPlane => strings::EXTRUDE_END_MIDPLANE,
        EndConditionKind::UpToVertex => strings::EXTRUDE_END_UP_TO_VERTEX,
        EndConditionKind::UpToSurface => strings::EXTRUDE_END_UP_TO_SURFACE,
        EndConditionKind::OffsetFromSurface => strings::EXTRUDE_END_OFFSET_FROM_SURFACE,
        EndConditionKind::UpToBody => strings::EXTRUDE_END_UP_TO_BODY,
    }
}

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 dir2_kind_index(direction2: Option<Direction2>) -> usize {
    direction2.map_or(0, |d| kind_index(&d.end_condition))
}

fn dir2_depth_value(direction2: Option<Direction2>) -> Length {
    direction2.map_or_else(
        || default_extrude_depth().get(),
        |d| current_depth(&d.end_condition),
    )
}

fn current_depth(condition: &ExtrudeEndCondition) -> Length {
    end_condition_depth(condition)
        .map_or_else(|| default_extrude_depth().get(), PositiveLength::get)
}

fn current_offset(condition: &ExtrudeEndCondition) -> Length {
    offset_of(condition)
        .unwrap_or_else(|| Length::new::<millimeter>(DEFAULT_OFFSET_FROM_SURFACE_MM))
}

fn current_draft_angle(draft: Option<DraftAngle>) -> Angle {
    draft.map_or_else(|| Angle::new::<degree>(0.0), |d| d.angle().get())
}

fn draft_direction_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::EXTRUDE_DRAFT_OUTWARD,
        },
        PropertyOption {
            label: strings::EXTRUDE_DRAFT_INWARD,
        },
    ]
}

fn draft_direction_index(draft: Option<DraftAngle>) -> usize {
    match draft.map(DraftAngle::direction) {
        Some(DraftDirection::Inward) => 1,
        _ => 0,
    }
}

fn draft_direction_editor(draft: Option<DraftAngle>) -> SelectionEditor {
    SelectionEditor::new(
        draft_direction_options(),
        Some(draft_direction_index(draft)),
    )
}

fn from_options() -> Vec<PropertyOption> {
    FromKind::SUPPORTED
        .iter()
        .map(|kind| PropertyOption {
            label: from_kind_label(*kind),
        })
        .collect()
}

fn from_kind_label(kind: FromKind) -> StringKey {
    match kind {
        FromKind::SketchPlane => strings::EXTRUDE_FROM_SKETCH_PLANE,
        FromKind::Offset => strings::EXTRUDE_FROM_OFFSET,
        FromKind::Surface => strings::EXTRUDE_FROM_SURFACE,
        FromKind::Vertex => strings::EXTRUDE_FROM_VERTEX,
    }
}

fn from_index(from: &FromCondition) -> usize {
    let kind = FromKind::of(from);
    FromKind::SUPPORTED
        .iter()
        .position(|candidate| *candidate == kind)
        .unwrap_or(0)
}

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

const DEFAULT_FROM_OFFSET_MM: f64 = 10.0;

fn from_distance_value(from: &FromCondition) -> Length {
    match from {
        FromCondition::Offset { distance } => *distance,
        _ => Length::new::<millimeter>(DEFAULT_FROM_OFFSET_MM),
    }
}

fn scope_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::EXTRUDE_SCOPE_AUTO,
        },
        PropertyOption {
            label: strings::EXTRUDE_SCOPE_ALL,
        },
    ]
}

fn scope_index(scope: FeatureScope) -> usize {
    match scope {
        FeatureScope::AutoSelect => 0,
        FeatureScope::AllBodies => 1,
    }
}

fn scope_from_index(index: Option<usize>) -> FeatureScope {
    match index {
        Some(1) => FeatureScope::AllBodies,
        _ => FeatureScope::AutoSelect,
    }
}

fn thin_direction_options() -> Vec<PropertyOption> {
    vec![
        PropertyOption {
            label: strings::EXTRUDE_THIN_INWARD,
        },
        PropertyOption {
            label: strings::EXTRUDE_THIN_OUTWARD,
        },
        PropertyOption {
            label: strings::EXTRUDE_THIN_MIDPLANE,
        },
        PropertyOption {
            label: strings::EXTRUDE_THIN_TWO_DIRECTION,
        },
    ]
}

fn thin_direction_index(thin: Option<ThinWall>) -> usize {
    match thin.map(|t| t.direction) {
        Some(ThinWallDirection::Outward) => 1,
        Some(ThinWallDirection::MidPlane) => 2,
        Some(ThinWallDirection::TwoDirection) => 3,
        Some(ThinWallDirection::Inward) | None => 0,
    }
}

fn thin_direction_from_index(index: Option<usize>) -> ThinWallDirection {
    match index {
        Some(1) => ThinWallDirection::Outward,
        Some(2) => ThinWallDirection::MidPlane,
        Some(3) => ThinWallDirection::TwoDirection,
        _ => ThinWallDirection::Inward,
    }
}

fn thin_direction_editor(thin: Option<ThinWall>) -> SelectionEditor {
    SelectionEditor::new(thin_direction_options(), Some(thin_direction_index(thin)))
}

fn thin_thickness_value(thin: Option<ThinWall>) -> Length {
    thin.map_or_else(|| default_thin_thickness().get(), |t| t.thickness.get())
}

fn thin_thickness2_value(thin: Option<ThinWall>) -> Length {
    thin.and_then(|t| t.thickness2)
        .map_or_else(|| default_thin_thickness().get(), PositiveLength::get)
}

fn thin_cap_ends(thin: Option<ThinWall>) -> bool {
    thin.is_some_and(|t| t.cap_ends)
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FeatureArm {
    RevolveAxis,
    RevolveContour,
    SweepPath,
    SweepGuide(SweepGuideSlot),
    SweepAlignmentAxis,
    SweepToolBody,
}

#[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 extrude_arm_reference: Option<ReferenceSlot>,
    pub revolve_edit: Option<RevolveEdit>,
    pub sweep_edit: Option<SweepEdit>,
    pub feature_arm: Option<FeatureArm>,
    pub plane_picked: Option<Plane>,
    pub datum_sketch_target: Option<FeatureId>,
    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,
            extrude_arm_reference: None,
            revolve_edit: None,
            sweep_edit: None,
            feature_arm: None,
            plane_picked: None,
            datum_sketch_target: 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>,
        body_rows: &[BodyRow],
        needs_rebuild: bool,
        whats_wrong: &[WhatsWrong],
        hidden_planes: &BTreeSet<FeatureId>,
    ) -> 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();
        let datum_plane_ready = !entity_ids.is_empty()
            && crate::app::normal_to_curve_datum(document, mode, entity_ids).is_some();
        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.as_ref(),
                selection: entity_ids,
                datum_plane_ready,
            },
            &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() || mode.is_revolve() || mode.is_sweep() || mode.is_datum(),
            is_armed_dialog(mode),
        );
        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,
                body_rows,
                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,
            hidden_planes,
            &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,
                revolve: &mut self.state.revolve_property,
                sweep: &mut self.state.sweep_property,
                groups: &mut self.state.property_groups,
            },
            PropertyState {
                mode,
                sketch: active_sketch.as_ref(),
                selection,
            },
            &mut paints,
        );
        let dimension_edit = pane.dimension_edit;
        let extrude_edit = pane.extrude_edit;
        let extrude_arm_reference = pane.extrude_arm_reference;
        let revolve_edit = pane.revolve_edit;
        let sweep_edit = pane.sweep_edit;
        let feature_arm = pane.feature_arm;
        render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints);
        let status_report: Option<&SketchStatusReport> = if let Some(s3) =
            mode.sketch_id().and_then(|id| document.sketch3(id))
        {
            let v = s3.version();
            if self
                .state
                .status_cache
                .as_ref()
                .is_none_or(|(cv, _)| *cv != v)
            {
                let report = crate::sketch3_project::project_status(s3, s3.plane(), &s3.status());
                self.state.status_cache = Some((v, report));
            }
            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() && r.redundant().is_empty()) {
            self.state.status_panel_open = false;
        }
        if self.state.status_panel_open {
            if let (Some(report), Some(sketch)) = (status_report, active_sketch.as_ref()) {
                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 = match mode.lifecycle().exit {
            ModeExit::Toggle => true,
            ModeExit::Dialog => is_armed_dialog(mode),
            ModeExit::None => false,
        };
        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.as_ref(),
            entity_ids,
        );
        let activated_dimension = resolve_activated_dimension(
            activated_widget,
            self.ids.ribbon_smart_dimension,
            active_sketch.as_ref(),
            entity_ids,
        );
        let plane_picked = feature_tree
            .double_activated
            .and_then(|id| self.ids.plane_for(id));
        let datum_sketch_target = feature_tree.datum_activated;
        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,
            extrude_arm_reference,
            revolve_edit,
            sweep_edit,
            feature_arm,
            plane_picked,
            datum_sketch_target,
            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(document: &Document, mode: &Mode) -> Option<Sketch> {
    mode.sketch_id()
        .and_then(|id| document.sketch3(id))
        .map(|sketch| crate::sketch3_project::project_sketch(sketch, sketch.plane()))
}

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],
) -> ToolbarItem {
    let item = ToolbarItem::new(id, strings::TOOL_SMART_DIMENSION)
        .with_icon(RibbonIconSize::Large.slot(IconId::SmartDimension));
    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_convert_entities,
                    strings::MENU_SKETCH_CONVERT_ENTITIES,
                    Some(crate::hotkeys::CONVERT_ENTITIES_ACTION),
                ),
                action_with_accel(
                    ids.menu_sketch_toggle_construction,
                    strings::MENU_SKETCH_TOGGLE_CONSTRUCTION,
                    Some(crate::hotkeys::TOGGLE_CONSTRUCTION_ACTION),
                ),
                action_with_accel(
                    ids.menu_sketch_toggle_driven,
                    strings::MENU_SKETCH_TOGGLE_DRIVEN,
                    None,
                ),
                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],
    datum_plane_ready: bool,
}

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() || mode.is_sketch3();
    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,
        datum_plane_ready,
    } = inputs;
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    let active_tool = mode.active_tool();
    let sketch3_tool = mode.sketch3_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| {
            let state = entity_tool_state(mode, t, active_tool, sketch3_tool, tools_disabled);
            size_item(
                ToolbarItem::new(tool_widget_id(ribbon, t), tool_label(t))
                    .with_icon(RibbonIconSize::Large.slot(tool_icon(t)))
                    .active(state.active)
                    .disabled(state.disabled),
                large_min,
            )
        })
        .collect();
    let dimension_items = vec![size_item(
        smart_dimension_tool_item(ribbon_smart_dimension, sketch, selection),
        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 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 = feature_ribbon_groups(
        ctx,
        ribbon,
        mode,
        datum_plane_ready,
        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),
        },
    ]
}

struct FeatureGroupItems {
    extrude: Vec<ToolbarItem>,
    revolve: Vec<ToolbarItem>,
    sweep: Vec<ToolbarItem>,
    datum: Vec<ToolbarItem>,
    sketch3: Vec<ToolbarItem>,
}

fn build_feature_groups(
    ribbon: WidgetId,
    items: FeatureGroupItems,
    large_min: LayoutPx,
    overflow_open: &BTreeMap<WidgetId, bool>,
) -> Vec<RibbonGroup> {
    let feature_group = |key: &'static str, label: StringKey, items: Vec<ToolbarItem>| {
        let id = ribbon.child(WidgetKey::new(key));
        RibbonGroup {
            id,
            label,
            min_width: group_min_width(large_min, items.len()),
            width: group_width_for(&items, large_min, RibbonIconSize::Large.rows()),
            items,
            icon_size: RibbonIconSize::Large,
            overflow_open: overflow_open.get(&id).copied().unwrap_or(false),
            overflow_label: Some(strings::TOOLBAR_OVERFLOW),
        }
    };
    vec![
        feature_group(
            "group.extrude",
            strings::RIBBON_GROUP_EXTRUDE,
            items.extrude,
        ),
        feature_group(
            "group.revolve",
            strings::RIBBON_GROUP_REVOLVE,
            items.revolve,
        ),
        feature_group("group.sweep", strings::RIBBON_GROUP_SWEEP, items.sweep),
        feature_group("group.datum", strings::RIBBON_GROUP_DATUM, items.datum),
        feature_group(
            "group.sketch3",
            strings::RIBBON_GROUP_SKETCH3,
            items.sketch3,
        ),
    ]
}

fn feature_ribbon_groups(
    ctx: &FrameCtx<'_>,
    ribbon: WidgetId,
    mode: &Mode,
    datum_plane_ready: bool,
    large_min: LayoutPx,
    overflow_open: &BTreeMap<WidgetId, bool>,
) -> Vec<RibbonGroup> {
    let items = FeatureGroupItems {
        extrude: feature_tool_items(ctx, ribbon, mode, FeatureTool::EXTRUDE, false, large_min),
        revolve: feature_tool_items(ctx, ribbon, mode, FeatureTool::REVOLVE, false, large_min),
        sweep: feature_tool_items(ctx, ribbon, mode, FeatureTool::SWEEP, false, large_min),
        datum: feature_tool_items(
            ctx,
            ribbon,
            mode,
            FeatureTool::DATUM,
            datum_plane_ready,
            large_min,
        ),
        sketch3: feature_tool_items(ctx, ribbon, mode, FeatureTool::SKETCH3, false, large_min),
    };
    build_feature_groups(ribbon, items, large_min, overflow_open)
}

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>,
    datum_activated: Option<FeatureId>,
    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 sketch_parents(document: &Document, feature: FeatureId) -> Vec<SketchId> {
    let tree = document.feature_tree();
    tree.parents(feature)
        .into_iter()
        .filter_map(|parent| match tree.node(parent) {
            Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id),
            _ => None,
        })
        .collect()
}

fn nested_sketch_rows(
    document: &Document,
    part_id: WidgetId,
    feature: Option<FeatureId>,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> Vec<TreeNode> {
    feature
        .map(|feature| sketch_parents(document, feature))
        .unwrap_or_default()
        .into_iter()
        .map(|sketch_id| sketch_leaf(document, part_id, sketch_id, badges))
        .collect()
}

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::PathToSweep { path, .. } => match tree.node(*path) {
                Some(FeatureNode::Sketch(sketch_id)) => Some(sketch_id),
                _ => None,
            },
            FeatureEdge::FaceToSketch { .. }
            | FeatureEdge::ReferenceToDatum { .. }
            | FeatureEdge::AxisToRevolve { .. }
            | FeatureEdge::AxisToSweep { .. }
            | FeatureEdge::PierceToSketch { .. } => None,
        })
        .collect()
}

fn feature_tree_children(
    document: &Document,
    part_id: WidgetId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
    body_rows: &[BodyRow],
) -> 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),
        solid_bodies_node(part_id, body_rows),
        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 solid_bodies_node(part_id: WidgetId, body_rows: &[BodyRow]) -> TreeNode {
    let folder_id = part_id.child(WidgetKey::new("solid_bodies"));
    if body_rows.is_empty() {
        return TreeNode::leaf(folder_id, strings::FEATURE_SOLID_BODIES)
            .with_icon(IconId::TreeFeature)
            .disabled(true);
    }
    let children: Vec<TreeNode> = body_rows
        .iter()
        .enumerate()
        .map(|(index, row)| {
            let id = folder_id.child_indexed(
                WidgetKey::new("body"),
                u64::try_from(index).unwrap_or(u64::MAX),
            );
            TreeNode::leaf_owned(id, row.label.clone()).with_icon(IconId::TreeFeature)
        })
        .collect();
    TreeNode::parent(folder_id, strings::FEATURE_SOLID_BODIES, children)
        .with_icon(IconId::TreeFeature)
}

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 sketch_datum_host(document: &Document, sketch_id: SketchId) -> Option<FeatureId> {
    let feature = document.sketch_plane_binding(sketch_id)?.source_feature();
    matches!(
        document.feature_tree().node(feature)?,
        FeatureNode::Datum(_)
    )
    .then_some(feature)
}

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::Revolve(revolve_id) => {
                Some(revolve_feature_node(document, part_id, revolve_id, badges))
            }
            FeatureNode::Sweep(sweep_id) => {
                Some(sweep_feature_node(document, part_id, sweep_id, badges))
            }
            FeatureNode::Datum(datum_id) => Some(datum_feature_node(
                document, part_id, datum_id, badges, &consumed,
            )),
            FeatureNode::Sketch(sketch_id)
                if !consumed.contains(&sketch_id)
                    && sketch_datum_host(document, sketch_id).is_none() =>
            {
                Some(sketch_leaf(document, part_id, sketch_id, badges))
            }
            _ => None,
        })
        .collect()
}

fn datum_widget_id(part_id: WidgetId, datum_id: DatumId) -> WidgetId {
    part_id.child_indexed(WidgetKey::new("datum"), datum_id.as_u64())
}

fn revolve_widget_id(part_id: WidgetId, revolve_id: RevolveId) -> WidgetId {
    part_id.child_indexed(WidgetKey::new("revolve"), revolve_id.as_u64())
}

fn revolve_icon(feature: Option<&RevolveFeature>) -> IconId {
    match feature.map(|feature| feature.operation) {
        Some(ExtrudeOperation::Cut) => IconId::RevolvedCut,
        _ => IconId::RevolvedBossBase,
    }
}

fn revolve_feature_node(
    document: &Document,
    part_id: WidgetId,
    revolve_id: RevolveId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> TreeNode {
    let widget_id = revolve_widget_id(part_id, revolve_id);
    let label = document.revolve_label(revolve_id).unwrap_or("").to_owned();
    let feature = document.feature_tree().feature_of_revolve(revolve_id);
    let nested = nested_sketch_rows(document, part_id, feature, badges);
    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(revolve_icon(document.revolve(revolve_id)))
        .with_badge(badge)
        .disabled(rolled_back)
}

fn sweep_widget_id(part_id: WidgetId, sweep_id: SweepId) -> WidgetId {
    part_id.child_indexed(WidgetKey::new("sweep"), sweep_id.as_u64())
}

fn sweep_icon(feature: Option<&SweepFeature>) -> IconId {
    match feature.map(|feature| feature.operation) {
        Some(ExtrudeOperation::Cut) => IconId::SweptCut,
        _ => IconId::SweptBossBase,
    }
}

fn sweep_feature_node(
    document: &Document,
    part_id: WidgetId,
    sweep_id: SweepId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
) -> TreeNode {
    let widget_id = sweep_widget_id(part_id, sweep_id);
    let label = document.sweep_label(sweep_id).unwrap_or("").to_owned();
    let feature = document.feature_tree().feature_of_sweep(sweep_id);
    let nested = nested_sketch_rows(document, part_id, feature, badges);
    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(sweep_icon(document.sweep(sweep_id)))
        .with_badge(badge)
        .disabled(rolled_back)
}

fn datum_icon(feature: Option<&DatumFeature>) -> IconId {
    match feature {
        Some(DatumFeature::Plane(_)) => IconId::TreePlane,
        Some(DatumFeature::Axis(_)) => IconId::Line,
        Some(DatumFeature::Point(_)) => IconId::Point,
        Some(DatumFeature::CoordinateSystem(_)) | None => IconId::TreeOrigin,
    }
}

fn datum_hosted_sketches(
    document: &Document,
    datum_feature: FeatureId,
    consumed: &BTreeSet<SketchId>,
) -> Vec<SketchId> {
    document
        .sketches()
        .filter_map(|(sketch_id, _)| {
            (!consumed.contains(&sketch_id)
                && sketch_datum_host(document, sketch_id) == Some(datum_feature))
            .then_some(sketch_id)
        })
        .collect()
}

fn datum_feature_node(
    document: &Document,
    part_id: WidgetId,
    datum_id: DatumId,
    badges: &BTreeMap<FeatureId, TreeBadge>,
    consumed: &BTreeSet<SketchId>,
) -> TreeNode {
    let widget_id = datum_widget_id(part_id, datum_id);
    let label = document.datum_label(datum_id).unwrap_or("").to_owned();
    let feature = document.feature_tree().feature_of_datum(datum_id);
    let badge = feature.and_then(|feature| badges.get(&feature).copied());
    let rolled_back = feature.is_some_and(|feature| document.is_rolled_back(feature));
    let children: Vec<TreeNode> = feature
        .map(|feature| datum_hosted_sketches(document, feature, consumed))
        .unwrap_or_default()
        .into_iter()
        .map(|sketch_id| sketch_leaf(document, part_id, sketch_id, badges))
        .collect();
    let icon = datum_icon(document.datum(datum_id));
    if children.is_empty() {
        TreeNode::leaf_owned(widget_id, label)
            .with_icon(icon)
            .with_badge(badge)
            .disabled(rolled_back)
    } else {
        TreeNode::parent_owned(widget_id, label, children)
            .with_icon(icon)
            .with_badge(badge)
            .disabled(rolled_back)
    }
}

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

fn datum_widget_ids(document: &Document, part_id: WidgetId) -> Vec<(DatumId, WidgetId)> {
    document
        .feature_tree()
        .iter()
        .filter_map(|(_, node)| match node {
            FeatureNode::Datum(datum_id) => Some((datum_id, datum_widget_id(part_id, datum_id))),
            _ => None,
        })
        .collect()
}

fn principal_plane_widget_features(
    document: &Document,
    part_id: WidgetId,
) -> Vec<(WidgetId, FeatureId)> {
    [
        ("plane.xy", PrincipalPlane::Xy),
        ("plane.yz", PrincipalPlane::Yz),
        ("plane.zx", PrincipalPlane::Zx),
    ]
    .into_iter()
    .filter_map(|(key, kind)| {
        let feature = document.feature_tree().principal_plane_feature(kind)?;
        Some((part_id.child(WidgetKey::new(key)), feature))
    })
    .collect()
}

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

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

impl FeatureTreeOutcome {
    fn empty() -> Self {
        Self {
            double_activated: None,
            datum_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,
        body_rows,
        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, body_rows);
    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 widget_to_datum: BTreeMap<WidgetId, DatumId> = datum_widget_ids(document, part_id)
        .into_iter()
        .map(|(d, w)| (w, d))
        .collect();
    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 datum_activated = response
        .double_activated
        .and_then(|id| widget_to_datum.get(&id).copied())
        .and_then(|datum_id| document.feature_tree().feature_of_datum(datum_id));
    let (sketch_rename, extrude_rename) = resolve_renames(
        response.rename_committed.as_ref(),
        &widget_to_sketch,
        &widget_to_extrude,
    );
    let widget_to_plane: BTreeMap<WidgetId, FeatureId> =
        principal_plane_widget_features(document, part_id)
            .into_iter()
            .collect();
    let context_menu = resolve_context_menu_target(
        response.context_menu,
        &widget_to_sketch,
        &widget_to_extrude,
        &widget_to_datum,
        &widget_to_plane,
    );
    FeatureTreeOutcome {
        double_activated: response.double_activated,
        datum_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>,
    widget_to_datum: &BTreeMap<WidgetId, DatumId>,
    widget_to_plane: &BTreeMap<WidgetId, FeatureId>,
) -> 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))
        })
        .or_else(|| {
            widget_to_datum
                .get(&target)
                .map(|datum_id| FeatureTarget::Datum(*datum_id))
        })
        .or_else(|| {
            widget_to_plane
                .get(&target)
                .map(|feature| FeatureTarget::Plane(*feature))
        })
        .map(|feature_target| (feature_target, at))
}

struct FeatureMenuIds {
    root: WidgetId,
    edit_feature: WidgetId,
    edit_sketch: WidgetId,
    suppress: WidgetId,
    unsuppress: WidgetId,
    rollback: WidgetId,
    delete: WidgetId,
    hide_show: 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")),
            hide_show: root.child(WidgetKey::new("hide_show")),
            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),
        FeatureTarget::Datum(datum_id) => tree.feature_of_datum(datum_id),
        FeatureTarget::Plane(feature) => Some(feature),
    }
}

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),
        FeatureTarget::Datum(_) | FeatureTarget::Plane(_) => None,
    }
}

fn feature_menu_items(
    ids: &FeatureMenuIds,
    target: FeatureTarget,
    suppressed: bool,
    has_sketch: bool,
    plane_hidden: Option<bool>,
) -> Vec<MenuItem> {
    let action = |id, label| MenuItem::Action {
        id,
        label,
        shortcut: None,
        disabled: false,
    };
    let hide_show = |hidden: bool| {
        let label = if hidden {
            strings::FEATURE_CTX_SHOW
        } else {
            strings::FEATURE_CTX_HIDE
        };
        action(ids.hide_show, label)
    };
    if let FeatureTarget::Plane(_) = target {
        return vec![hide_show(plane_hidden.unwrap_or(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 visibility = plane_hidden.map(hide_show);
    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(visibility)
        .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.hide_show {
        Some(FeatureMenuOutcome::Command(
            FeatureCommand::TogglePlaneHidden(feature),
        ))
    } else if activated == ids.edit_feature {
        match target {
            FeatureTarget::Extrude(extrude_id) => Some(FeatureMenuOutcome::EditExtrude(extrude_id)),
            FeatureTarget::Sketch(_) | FeatureTarget::Datum(_) | FeatureTarget::Plane(_) => 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,
    hidden_planes: &BTreeSet<FeatureId>,
    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 plane_hidden = matches!(target, FeatureTarget::Plane(_) | FeatureTarget::Datum(_))
        .then(|| feature_id_of(document, target).map(|feature| hidden_planes.contains(&feature)))
        .flatten();
    let items = feature_menu_items(&ids, target, suppressed, has_sketch, plane_hidden);
    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 = 360.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())
        }
        FeatureNode::Datum(datum_id) => {
            Some(document.datum_label(datum_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.78 - REL_PAD_PX),
            LayoutPx::new(REL_ROW_PX),
        ),
    );
    paints.push(WidgetPaint::AlignedLabel {
        rect: message_rect,
        text: entry.message.clone(),
        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.78),
            LayoutPx::new(top),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() * 0.22 - 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>,
    extrude_arm_reference: Option<ReferenceSlot>,
    revolve_edit: Option<RevolveEdit>,
    sweep_edit: Option<SweepEdit>,
    feature_arm: Option<FeatureArm>,
    confirm: Option<ConfirmAction>,
}

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

fn clear_inactive_pane_editors(mode: &Mode, editors: &mut PaneEditors<'_>, keep_dim: bool) {
    if !keep_dim {
        *editors.dim = None;
    }
    if !matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. })) {
        *editors.extrude = None;
    }
    if !matches!(mode, Mode::Revolve(RevolveArming::Profile { .. })) {
        *editors.revolve = None;
    }
    if !matches!(mode, Mode::Sweep(SweepArming::Profile { .. })) {
        *editors.sweep = None;
    }
}

const PROMPT_PAD: f32 = 8.0;

fn awaiting_sketch_prompt(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    prompt: StringKey,
    paints: &mut Vec<WidgetPaint>,
) -> PropertyPaneOutcome {
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::Form).with_label(prompt));
    let style = ctx.theme().typography.body;
    let color = ctx.theme().colors.text_secondary();
    let text = ctx.strings.resolve(prompt).to_owned();
    let max_width = (rect.size.width.value() - 2.0 * PROMPT_PAD).max(1.0);
    let height = bone_ui::text::wrapped_height_px(ctx.shaper, style, &text, max_width);
    let box_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(rect.origin.x.value() + PROMPT_PAD),
            LayoutPx::new(rect.origin.y.value() + PROMPT_PAD),
        ),
        LayoutSize::new(LayoutPx::new(max_width), LayoutPx::new(height)),
    );
    paints.push(WidgetPaint::Paragraph {
        rect: box_rect,
        text: LabelText::Key(prompt),
        color,
        role: style,
        align: HorizontalAlign::Start,
    });
    PropertyPaneOutcome::default()
}

fn arming_property_pane(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    editors: &mut PaneEditors<'_>,
    mode: &Mode,
    paints: &mut Vec<WidgetPaint>,
) -> Option<PropertyPaneOutcome> {
    match mode {
        Mode::Extrude(ExtrudeArming::Profile { feature, .. }) => {
            let outcome = render_extrude_rows(ctx, rect, id, clipboard, editors, feature, paints);
            Some(PropertyPaneOutcome {
                extrude_edit: outcome.edit,
                extrude_arm_reference: outcome.arm_reference,
                confirm: outcome.confirm,
                ..PropertyPaneOutcome::default()
            })
        }
        Mode::Revolve(RevolveArming::Profile { feature, .. }) => Some(
            render_revolve_rows(ctx, rect, id, clipboard, editors, feature, paints).into_pane(),
        ),
        Mode::Sweep(SweepArming::Profile { feature, .. }) => {
            Some(render_sweep_rows(ctx, rect, id, clipboard, editors, feature, paints).into_pane())
        }
        Mode::Extrude(ExtrudeArming::AwaitingSketch(_))
        | Mode::Revolve(RevolveArming::AwaitingSketch(_)) => Some(awaiting_sketch_prompt(
            ctx,
            rect,
            id,
            strings::EXTRUDE_PROMPT_SELECT_SKETCH,
            paints,
        )),
        Mode::Sweep(SweepArming::AwaitingSketch(_)) => Some(awaiting_sketch_prompt(
            ctx,
            rect,
            id,
            strings::SWEEP_PROMPT_SELECT_PROFILE,
            paints,
        )),
        Mode::Idle | Mode::Sketch { .. } | Mode::Datum(_) => None,
    }
}

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)));
    let keep_dim = matches!(resolved, Some((_, SelectionTarget::Dimension(_, _))));
    clear_inactive_pane_editors(state.mode, editors, keep_dim);
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return PropertyPaneOutcome::default();
    }
    if let Mode::Datum(arming) = state.mode {
        let confirm = render_datum_rows(ctx, rect, id, clipboard, &arming.feature, paints);
        return PropertyPaneOutcome {
            confirm,
            ..PropertyPaneOutcome::default()
        };
    }
    if let Some(outcome) =
        arming_property_pane(ctx, rect, id, clipboard, editors, state.mode, paints)
    {
        return outcome;
    }
    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,
                ),
                ..PropertyPaneOutcome::default()
            }
        }
        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>(
    slot: &'slot mut Option<ExtrudePropertyEditor>,
    feature: &ExtrudeFeature,
) -> &'slot 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>,
    arm_reference: Option<ReferenceSlot>,
    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,
    end_kind: EndConditionKind,
) -> Vec<PropertyRow<'_>> {
    let draft_on = editor.draft_enabled.value;
    let ExtrudePropertyEditor {
        end_condition,
        depth,
        direction1_reference,
        offset,
        reverse_offset,
        draft_enabled,
        draft_angle,
        draft_direction,
        ..
    } = editor;
    let mut rows = vec![PropertyRow {
        id: extrude_row_id("end"),
        label: strings::PROPERTY_ROW_EXTRUDE_END,
        editor: end_condition,
        read_only: false,
    }];
    if end_kind.needs_depth() {
        rows.push(PropertyRow {
            id: extrude_row_id("depth"),
            label: strings::PROPERTY_ROW_EXTRUDE_DEPTH,
            editor: depth,
            read_only: false,
        });
    }
    if end_kind.needs_reference() {
        rows.push(PropertyRow {
            id: extrude_row_id("d1_reference"),
            label: strings::PROPERTY_ROW_EXTRUDE_REFERENCE,
            editor: direction1_reference,
            read_only: false,
        });
    }
    if matches!(end_kind, EndConditionKind::OffsetFromSurface) {
        rows.push(PropertyRow {
            id: extrude_row_id("offset"),
            label: strings::PROPERTY_ROW_EXTRUDE_OFFSET,
            editor: offset,
            read_only: false,
        });
        rows.push(PropertyRow {
            id: extrude_row_id("reverse_offset"),
            label: strings::PROPERTY_ROW_EXTRUDE_REVERSE_OFFSET,
            editor: reverse_offset,
            read_only: false,
        });
    }
    rows.push(PropertyRow {
        id: extrude_row_id("draft"),
        label: strings::PROPERTY_ROW_EXTRUDE_DRAFT,
        editor: draft_enabled,
        read_only: false,
    });
    if draft_on {
        rows.push(PropertyRow {
            id: extrude_row_id("draft_angle"),
            label: strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE,
            editor: draft_angle,
            read_only: false,
        });
        rows.push(PropertyRow {
            id: extrude_row_id("draft_direction"),
            label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION,
            editor: draft_direction,
            read_only: false,
        });
    }
    rows
}

fn direction_two_group_rows(
    editor: &mut ExtrudePropertyEditor,
    dir2_kind: EndConditionKind,
) -> Vec<PropertyRow<'_>> {
    let direction_two_on = editor.direction_two.value;
    let ExtrudePropertyEditor {
        direction_two,
        dir2_end_condition,
        dir2_depth,
        dir2_reference,
        ..
    } = editor;
    let mut rows = vec![PropertyRow {
        id: extrude_row_id("direction_two"),
        label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO,
        editor: direction_two,
        read_only: false,
    }];
    if direction_two_on {
        rows.push(PropertyRow {
            id: extrude_row_id("d2_end"),
            label: strings::PROPERTY_ROW_EXTRUDE_END,
            editor: dir2_end_condition,
            read_only: false,
        });
        if dir2_kind.needs_depth() {
            rows.push(PropertyRow {
                id: extrude_row_id("d2_depth"),
                label: strings::PROPERTY_ROW_EXTRUDE_DEPTH,
                editor: dir2_depth,
                read_only: false,
            });
        }
        if dir2_kind.needs_reference() {
            rows.push(PropertyRow {
                id: extrude_row_id("d2_reference"),
                label: strings::PROPERTY_ROW_EXTRUDE_REFERENCE,
                editor: dir2_reference,
                read_only: false,
            });
        }
    }
    rows
}

fn from_group_rows(
    editor: &mut ExtrudePropertyEditor,
    from_kind: FromKind,
) -> Vec<PropertyRow<'_>> {
    let ExtrudePropertyEditor {
        from_kind: from_kind_editor,
        from_distance,
        from_reference,
        ..
    } = editor;
    let mut rows = vec![PropertyRow {
        id: extrude_row_id("from_kind"),
        label: strings::PROPERTY_ROW_EXTRUDE_FROM,
        editor: from_kind_editor,
        read_only: false,
    }];
    if matches!(from_kind, FromKind::Offset) {
        rows.push(PropertyRow {
            id: extrude_row_id("from_distance"),
            label: strings::PROPERTY_ROW_EXTRUDE_FROM_DISTANCE,
            editor: from_distance,
            read_only: false,
        });
    }
    if from_kind.reference_kind().is_some() {
        rows.push(PropertyRow {
            id: extrude_row_id("from_reference"),
            label: strings::PROPERTY_ROW_EXTRUDE_REFERENCE,
            editor: from_reference,
            read_only: false,
        });
    }
    rows
}

fn scope_group_rows(editor: &mut ExtrudePropertyEditor) -> Vec<PropertyRow<'_>> {
    let is_cut = editor.is_cut;
    let thin_on = editor.thin.value;
    let two_direction = matches!(
        thin_direction_from_index(editor.thin_direction.current),
        ThinWallDirection::TwoDirection
    );
    let ExtrudePropertyEditor {
        scope,
        flip_side,
        thin,
        thin_direction,
        thickness,
        thickness2,
        cap_ends,
        merge,
        ..
    } = editor;
    let mut rows = vec![PropertyRow {
        id: extrude_row_id("scope"),
        label: strings::PROPERTY_ROW_EXTRUDE_SCOPE,
        editor: scope,
        read_only: false,
    }];
    if is_cut {
        rows.push(PropertyRow {
            id: extrude_row_id("flip_side"),
            label: strings::PROPERTY_ROW_EXTRUDE_FLIP_SIDE,
            editor: flip_side,
            read_only: false,
        });
    }
    rows.push(PropertyRow {
        id: extrude_row_id("thin"),
        label: strings::PROPERTY_ROW_EXTRUDE_THIN,
        editor: thin,
        read_only: false,
    });
    if thin_on {
        rows.push(PropertyRow {
            id: extrude_row_id("thin_direction"),
            label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION,
            editor: thin_direction,
            read_only: false,
        });
        rows.push(PropertyRow {
            id: extrude_row_id("thickness"),
            label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS,
            editor: thickness,
            read_only: false,
        });
        if two_direction {
            rows.push(PropertyRow {
                id: extrude_row_id("thickness2"),
                label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO,
                editor: thickness2,
                read_only: false,
            });
        }
        rows.push(PropertyRow {
            id: extrude_row_id("cap_ends"),
            label: strings::PROPERTY_ROW_EXTRUDE_CAP_ENDS,
            editor: cap_ends,
            read_only: false,
        });
    }
    if !is_cut {
        rows.push(PropertyRow {
            id: extrude_row_id("merge"),
            label: strings::PROPERTY_ROW_EXTRUDE_MERGE,
            editor: merge,
            read_only: false,
        });
    }
    rows
}

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 sync_revolve_editor<'slot>(
    slot: &'slot mut Option<RevolvePropertyEditor>,
    feature: &RevolveFeature,
) -> &'slot mut RevolvePropertyEditor {
    let editor = match slot.take() {
        Some(editor) if editor.sketch == feature.sketch => editor.synced(feature),
        _ => RevolvePropertyEditor::new(feature),
    };
    slot.insert(editor)
}

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

struct RevolveRowsOutcome {
    edit: Option<RevolveEdit>,
    arm_axis: bool,
    arm_contour: bool,
    confirm: Option<ConfirmAction>,
}

impl RevolveRowsOutcome {
    fn into_pane(self) -> PropertyPaneOutcome {
        let feature_arm = if self.arm_axis {
            Some(FeatureArm::RevolveAxis)
        } else if self.arm_contour {
            Some(FeatureArm::RevolveContour)
        } else {
            None
        };
        PropertyPaneOutcome {
            revolve_edit: self.edit,
            feature_arm,
            confirm: self.confirm,
            ..PropertyPaneOutcome::default()
        }
    }
}

fn revolve_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_REVOLVE_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 revolve_direction_group_rows(editor: &mut RevolvePropertyEditor) -> Vec<PropertyRow<'_>> {
    let RevolvePropertyEditor {
        kind,
        angle,
        reverse,
        axis,
        ..
    } = editor;
    vec![
        PropertyRow {
            id: revolve_row_id("axis"),
            label: strings::PROPERTY_ROW_REVOLVE_AXIS,
            editor: axis,
            read_only: false,
        },
        PropertyRow {
            id: revolve_row_id("kind"),
            label: strings::PROPERTY_ROW_REVOLVE_TYPE,
            editor: kind,
            read_only: false,
        },
        PropertyRow {
            id: revolve_row_id("angle"),
            label: strings::PROPERTY_ROW_REVOLVE_ANGLE,
            editor: angle,
            read_only: false,
        },
        PropertyRow {
            id: revolve_row_id("reverse"),
            label: strings::PROPERTY_ROW_REVOLVE_REVERSE,
            editor: reverse,
            read_only: false,
        },
    ]
}

fn revolve_direction_two_group_rows(editor: &mut RevolvePropertyEditor) -> Vec<PropertyRow<'_>> {
    let direction_two_on = editor.direction_two.value;
    let RevolvePropertyEditor {
        direction_two,
        dir2_angle,
        ..
    } = editor;
    let mut rows = vec![PropertyRow {
        id: revolve_row_id("direction_two"),
        label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO,
        editor: direction_two,
        read_only: false,
    }];
    if direction_two_on {
        rows.push(PropertyRow {
            id: revolve_row_id("dir2_angle"),
            label: strings::PROPERTY_ROW_REVOLVE_ANGLE,
            editor: dir2_angle,
            read_only: false,
        });
    }
    rows
}

fn revolve_scope_group_rows(editor: &mut RevolvePropertyEditor) -> Vec<PropertyRow<'_>> {
    let is_cut = editor.is_cut;
    let thin_on = editor.thin.value;
    let two_direction = matches!(
        thin_direction_from_index(editor.thin_direction.current),
        ThinWallDirection::TwoDirection
    );
    let RevolvePropertyEditor {
        scope,
        contours,
        thin,
        thin_direction,
        thickness,
        thickness2,
        merge,
        ..
    } = editor;
    let mut rows = vec![
        PropertyRow {
            id: revolve_row_id("contours"),
            label: strings::PROPERTY_ROW_REVOLVE_CONTOURS,
            editor: contours,
            read_only: false,
        },
        PropertyRow {
            id: revolve_row_id("scope"),
            label: strings::PROPERTY_ROW_EXTRUDE_SCOPE,
            editor: scope,
            read_only: false,
        },
        PropertyRow {
            id: revolve_row_id("thin"),
            label: strings::PROPERTY_ROW_EXTRUDE_THIN,
            editor: thin,
            read_only: false,
        },
    ];
    if thin_on {
        rows.push(PropertyRow {
            id: revolve_row_id("thin_direction"),
            label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION,
            editor: thin_direction,
            read_only: false,
        });
        rows.push(PropertyRow {
            id: revolve_row_id("thickness"),
            label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS,
            editor: thickness,
            read_only: false,
        });
        if two_direction {
            rows.push(PropertyRow {
                id: revolve_row_id("thickness2"),
                label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO,
                editor: thickness2,
                read_only: false,
            });
        }
    }
    if !is_cut {
        rows.push(PropertyRow {
            id: revolve_row_id("merge"),
            label: strings::PROPERTY_ROW_EXTRUDE_MERGE,
            editor: merge,
            read_only: false,
        });
    }
    rows
}

fn current_thin_from_revolve_editor(editor: &RevolvePropertyEditor) -> Option<ThinWall> {
    if !editor.thin.value {
        return None;
    }
    let thickness = PositiveLength::new(editor.thickness.value).ok()?;
    let direction = thin_direction_from_index(editor.thin_direction.current);
    let thickness2 = match direction {
        ThinWallDirection::TwoDirection => PositiveLength::new(editor.thickness2.value).ok(),
        _ => None,
    };
    Some(ThinWall {
        thickness,
        direction,
        thickness2,
        cap_ends: false,
        auto_fillet: None,
    })
}

fn resolve_revolve_edit(
    editor: &RevolvePropertyEditor,
    changed: &[WidgetId],
) -> Option<RevolveEdit> {
    let on = |key: &'static str| changed.contains(&revolve_row_id(key));
    if on("kind") {
        return revolve_kind_from_index(editor.kind.current).map(RevolveEdit::Kind);
    }
    if on("angle") {
        return Some(RevolveEdit::Angle(editor.angle.value));
    }
    if on("reverse") {
        return Some(RevolveEdit::Reverse(editor.reverse.value));
    }
    if on("direction_two") || on("dir2_angle") {
        let angle = editor
            .direction_two
            .value
            .then(|| RevolveAngle::new(editor.dir2_angle.value).ok())
            .flatten();
        if editor.direction_two.value && angle.is_none() {
            return None;
        }
        return Some(RevolveEdit::DirectionTwo(angle));
    }
    if on("thin") || on("thin_direction") || on("thickness") || on("thickness2") {
        return Some(RevolveEdit::Thin(current_thin_from_revolve_editor(editor)));
    }
    if on("scope") {
        return Some(RevolveEdit::Scope(scope_from_index(editor.scope.current)));
    }
    if on("merge") {
        return Some(RevolveEdit::Merge(if editor.merge.value {
            MergeResult::Merge
        } else {
            MergeResult::Separate
        }));
    }
    None
}

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

    let groups = &mut *editors.groups;
    let editor = sync_revolve_editor(editors.revolve, feature);
    let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP);
    let mut changed: Vec<WidgetId> = Vec::new();
    let mut render_group = |ctx: &mut FrameCtx<'_>,
                            rows: &mut Vec<PropertyRow<'_>>,
                            key: &'static str,
                            title: StringKey,
                            top: LayoutPx| {
        let (next_y, ch) = render_property_group(
            ctx,
            clipboard,
            groups,
            PropertyGroupSpec {
                id: id.child(WidgetKey::new(key)),
                title,
                top_left: LayoutPos::new(rect.origin.x, top),
                width: rect.size.width,
            },
            rows,
            paints,
        );
        changed.extend(ch);
        next_y
    };
    let dir2_top = {
        let mut rows = revolve_direction_group_rows(editor);
        render_group(
            ctx,
            &mut rows,
            "group.direction1",
            strings::PROPERTY_GROUP_DIRECTION_1,
            groups_top,
        )
    };
    let scope_top = {
        let mut rows = revolve_direction_two_group_rows(editor);
        render_group(
            ctx,
            &mut rows,
            "group.direction2",
            strings::PROPERTY_GROUP_DIRECTION_2,
            dir2_top,
        )
    };
    {
        let mut rows = revolve_scope_group_rows(editor);
        let _ = render_group(
            ctx,
            &mut rows,
            "group.feature_scope",
            strings::PROPERTY_GROUP_FEATURE_SCOPE,
            scope_top,
        );
    }

    let edit = resolve_revolve_edit(editor, &changed);
    let (arm_axis, arm_contour) = resolve_revolve_arm(editor, &changed);
    RevolveRowsOutcome {
        edit,
        arm_axis,
        arm_contour,
        confirm,
    }
}

const SWEEP_PICK_INDEX: usize = 1;
const SWEEP_GUIDE_CLEAR_INDEX: usize = 2;
const SWEEP_CIRCULAR_INDEX: usize = 1;
const SWEEP_SOLID_INDEX: usize = 2;
const SWEEP_ALIGNMENT_DIRECTION_INDEX: usize = 2;

fn sync_sweep_editor<'slot>(
    slot: &'slot mut Option<SweepPropertyEditor>,
    feature: &SweepFeature,
) -> &'slot mut SweepPropertyEditor {
    let editor = match slot.take() {
        Some(editor) if editor.profile == feature.profile => editor.synced(feature),
        _ => SweepPropertyEditor::new(feature),
    };
    slot.insert(editor)
}

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

struct SweepRowsOutcome {
    edit: Option<SweepEdit>,
    arm_path: bool,
    arm_guide: Option<SweepGuideSlot>,
    arm_alignment: bool,
    arm_tool: bool,
    confirm: Option<ConfirmAction>,
}

impl SweepRowsOutcome {
    fn into_pane(self) -> PropertyPaneOutcome {
        PropertyPaneOutcome {
            sweep_edit: self.edit,
            feature_arm: self
                .arm_path
                .then_some(FeatureArm::SweepPath)
                .or(self.arm_guide.map(FeatureArm::SweepGuide))
                .or(self.arm_alignment.then_some(FeatureArm::SweepAlignmentAxis))
                .or(self.arm_tool.then_some(FeatureArm::SweepToolBody)),
            confirm: self.confirm,
            ..PropertyPaneOutcome::default()
        }
    }
}

fn sweep_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_SWEEP_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 sweep_profile_path_group_rows(editor: &mut SweepPropertyEditor) -> Vec<PropertyRow<'_>> {
    let SweepPropertyEditor {
        profile_display,
        path,
        direction,
        ..
    } = editor;
    vec![
        PropertyRow {
            id: sweep_row_id("profile"),
            label: strings::PROPERTY_ROW_SWEEP_PROFILE,
            editor: profile_display,
            read_only: true,
        },
        PropertyRow {
            id: sweep_row_id("path"),
            label: strings::PROPERTY_ROW_SWEEP_PATH,
            editor: path,
            read_only: false,
        },
        PropertyRow {
            id: sweep_row_id("direction"),
            label: strings::PROPERTY_ROW_SWEEP_DIRECTION,
            editor: direction,
            read_only: false,
        },
    ]
}

fn sweep_options_group_rows(editor: &mut SweepPropertyEditor) -> Vec<PropertyRow<'_>> {
    let show_diameter = editor.profile_kind.current == Some(SWEEP_CIRCULAR_INDEX);
    let show_tool = editor.profile_kind.current == Some(SWEEP_SOLID_INDEX);
    let show_twist = sweep_twist_type_is_twisted(editor.orientation.current);
    let show_alignment = matches!(editor.orientation.current, Some(0 | 2));
    let show_alignment_axis =
        show_alignment && editor.alignment.current == Some(SWEEP_ALIGNMENT_DIRECTION_INDEX);
    let SweepPropertyEditor {
        orientation,
        twist_value,
        twist_unit,
        start_tangency,
        end_tangency,
        alignment,
        alignment_axis,
        profile_kind,
        diameter,
        tool_display,
        ..
    } = editor;
    let mut rows = vec![PropertyRow {
        id: sweep_row_id("orientation"),
        label: strings::PROPERTY_ROW_SWEEP_ORIENTATION,
        editor: orientation,
        read_only: false,
    }];
    if show_twist {
        rows.push(PropertyRow {
            id: sweep_row_id("twist_value"),
            label: strings::PROPERTY_ROW_SWEEP_TWIST,
            editor: twist_value,
            read_only: false,
        });
        rows.push(PropertyRow {
            id: sweep_row_id("twist_unit"),
            label: strings::PROPERTY_ROW_SWEEP_TWIST_UNIT,
            editor: twist_unit,
            read_only: false,
        });
    }
    rows.push(PropertyRow {
        id: sweep_row_id("start_tangency"),
        label: strings::PROPERTY_ROW_SWEEP_START_TANGENCY,
        editor: start_tangency,
        read_only: false,
    });
    rows.push(PropertyRow {
        id: sweep_row_id("end_tangency"),
        label: strings::PROPERTY_ROW_SWEEP_END_TANGENCY,
        editor: end_tangency,
        read_only: false,
    });
    if show_alignment {
        rows.push(PropertyRow {
            id: sweep_row_id("alignment"),
            label: strings::PROPERTY_ROW_SWEEP_ALIGNMENT,
            editor: alignment,
            read_only: false,
        });
    }
    if show_alignment_axis {
        rows.push(PropertyRow {
            id: sweep_row_id("alignment_axis"),
            label: strings::PROPERTY_ROW_SWEEP_ALIGNMENT_AXIS,
            editor: alignment_axis,
            read_only: false,
        });
    }
    rows.push(PropertyRow {
        id: sweep_row_id("profile_kind"),
        label: strings::PROPERTY_ROW_SWEEP_PROFILE_KIND,
        editor: profile_kind,
        read_only: false,
    });
    if show_diameter {
        rows.push(PropertyRow {
            id: sweep_row_id("diameter"),
            label: strings::PROPERTY_ROW_SWEEP_DIAMETER,
            editor: diameter,
            read_only: false,
        });
    }
    if show_tool {
        rows.push(PropertyRow {
            id: sweep_row_id("tool"),
            label: strings::PROPERTY_ROW_SWEEP_TOOL,
            editor: tool_display,
            read_only: false,
        });
    }
    rows
}

fn sweep_guides_group_rows(editor: &mut SweepPropertyEditor) -> Vec<PropertyRow<'_>> {
    let guide_one_filled = editor.guide_one_filled;
    let SweepPropertyEditor {
        guide_one,
        guide_two,
        ..
    } = editor;
    vec![
        PropertyRow {
            id: sweep_row_id("guide_one"),
            label: strings::PROPERTY_ROW_SWEEP_GUIDE_ONE,
            editor: guide_one,
            read_only: false,
        },
        PropertyRow {
            id: sweep_row_id("guide_two"),
            label: strings::PROPERTY_ROW_SWEEP_GUIDE_TWO,
            editor: guide_two,
            read_only: !guide_one_filled,
        },
    ]
}

fn sweep_scope_group_rows(editor: &mut SweepPropertyEditor) -> Vec<PropertyRow<'_>> {
    let is_cut = editor.is_cut;
    let thin_on = editor.thin.value;
    let two_direction = matches!(
        thin_direction_from_index(editor.thin_direction.current),
        ThinWallDirection::TwoDirection
    );
    let SweepPropertyEditor {
        scope,
        thin,
        thin_direction,
        thickness,
        thickness2,
        flip_side,
        merge,
        ..
    } = editor;
    let mut rows = vec![
        PropertyRow {
            id: sweep_row_id("scope"),
            label: strings::PROPERTY_ROW_EXTRUDE_SCOPE,
            editor: scope,
            read_only: false,
        },
        PropertyRow {
            id: sweep_row_id("thin"),
            label: strings::PROPERTY_ROW_EXTRUDE_THIN,
            editor: thin,
            read_only: false,
        },
    ];
    if thin_on {
        rows.push(PropertyRow {
            id: sweep_row_id("thin_direction"),
            label: strings::PROPERTY_ROW_EXTRUDE_DIRECTION,
            editor: thin_direction,
            read_only: false,
        });
        rows.push(PropertyRow {
            id: sweep_row_id("thickness"),
            label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS,
            editor: thickness,
            read_only: false,
        });
        if two_direction {
            rows.push(PropertyRow {
                id: sweep_row_id("thickness2"),
                label: strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO,
                editor: thickness2,
                read_only: false,
            });
        }
    }
    if is_cut {
        rows.push(PropertyRow {
            id: sweep_row_id("flip_side"),
            label: strings::PROPERTY_ROW_EXTRUDE_FLIP_SIDE,
            editor: flip_side,
            read_only: false,
        });
    } else {
        rows.push(PropertyRow {
            id: sweep_row_id("merge"),
            label: strings::PROPERTY_ROW_EXTRUDE_MERGE,
            editor: merge,
            read_only: false,
        });
    }
    rows
}

fn current_thin_from_sweep_editor(editor: &SweepPropertyEditor) -> Option<ThinWall> {
    if !editor.thin.value {
        return None;
    }
    let thickness = PositiveLength::new(editor.thickness.value).ok()?;
    let direction = thin_direction_from_index(editor.thin_direction.current);
    let thickness2 = match direction {
        ThinWallDirection::TwoDirection => PositiveLength::new(editor.thickness2.value).ok(),
        _ => None,
    };
    Some(ThinWall {
        thickness,
        direction,
        thickness2,
        cap_ends: false,
        auto_fillet: None,
    })
}

fn sweep_profile_kind_from_index(
    index: Option<usize>,
    diameter: Length,
    current: SweepProfileKind,
) -> SweepProfileKind {
    match index {
        Some(SWEEP_CIRCULAR_INDEX) => SweepProfileKind::Circular {
            diameter: PositiveLength::new(diameter).unwrap_or_else(|_| default_sweep_diameter()),
        },
        Some(SWEEP_SOLID_INDEX) => SweepProfileKind::Solid {
            tool: match current {
                SweepProfileKind::Solid { tool } => tool,
                SweepProfileKind::Sketch | SweepProfileKind::Circular { .. } => {
                    BodyRef::new(FeatureId::default(), BodyOrdinal::origin())
                }
            },
        },
        _ => SweepProfileKind::Sketch,
    }
}

fn resolve_sweep_edit(editor: &SweepPropertyEditor, changed: &[WidgetId]) -> Option<SweepEdit> {
    let on = |key: &'static str| changed.contains(&sweep_row_id(key));
    if on("orientation") || on("twist_value") || on("twist_unit") {
        return Some(SweepEdit::Orientation {
            orientation: sweep_orientation_from_twist_type(editor.orientation.current),
            twist: current_twist_from_sweep_editor(editor),
        });
    }
    if on("direction") {
        return Some(SweepEdit::Direction(sweep_direction_from_index(
            editor.direction.current,
        )));
    }
    let cleared = |key: &'static str, row: &SelectionEditor| {
        changed.contains(&sweep_row_id(key)) && row.current == Some(SWEEP_GUIDE_CLEAR_INDEX)
    };
    if cleared("guide_one", &editor.guide_one) {
        return Some(SweepEdit::Guide {
            slot: SweepGuideSlot::One,
            guide: None,
        });
    }
    if cleared("guide_two", &editor.guide_two) {
        return Some(SweepEdit::Guide {
            slot: SweepGuideSlot::Two,
            guide: None,
        });
    }
    if on("start_tangency") || on("end_tangency") {
        return Some(SweepEdit::Tangency {
            start: sweep_tangency_from_index(editor.start_tangency.current),
            end: sweep_tangency_from_index(editor.end_tangency.current),
        });
    }
    if on("alignment") {
        return Some(SweepEdit::Alignment(sweep_alignment_from_index(
            editor.alignment.current,
            editor.current_alignment,
        )));
    }
    if on("profile_kind") || on("diameter") {
        return Some(SweepEdit::ProfileKind(sweep_profile_kind_from_index(
            editor.profile_kind.current,
            editor.diameter.value,
            editor.current_profile_kind,
        )));
    }
    if on("thin") || on("thin_direction") || on("thickness") || on("thickness2") {
        return Some(SweepEdit::Thin(current_thin_from_sweep_editor(editor)));
    }
    if on("flip_side") {
        return Some(SweepEdit::FlipSide(editor.flip_side.value));
    }
    if on("scope") {
        return Some(SweepEdit::Scope(scope_from_index(editor.scope.current)));
    }
    if on("merge") {
        return Some(SweepEdit::Merge(if editor.merge.value {
            MergeResult::Merge
        } else {
            MergeResult::Separate
        }));
    }
    None
}

fn resolve_sweep_arm(editor: &SweepPropertyEditor, changed: &[WidgetId]) -> bool {
    changed.contains(&sweep_row_id("path")) && editor.path.current == Some(SWEEP_PICK_INDEX)
}

fn resolve_sweep_alignment_arm(editor: &SweepPropertyEditor, changed: &[WidgetId]) -> bool {
    changed.contains(&sweep_row_id("alignment_axis"))
        && editor.alignment_axis.current == Some(SWEEP_PICK_INDEX)
}

fn resolve_sweep_tool_arm(editor: &SweepPropertyEditor, changed: &[WidgetId]) -> bool {
    changed.contains(&sweep_row_id("tool")) && editor.tool_display.current == Some(SWEEP_PICK_INDEX)
}

fn resolve_sweep_guide_arm(
    editor: &SweepPropertyEditor,
    changed: &[WidgetId],
) -> Option<SweepGuideSlot> {
    let armed = |key: &'static str, row: &SelectionEditor| {
        changed.contains(&sweep_row_id(key)) && row.current == Some(SWEEP_PICK_INDEX)
    };
    if armed("guide_one", &editor.guide_one) {
        return Some(SweepGuideSlot::One);
    }
    if armed("guide_two", &editor.guide_two) {
        return Some(SweepGuideSlot::Two);
    }
    None
}

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

    let groups = &mut *editors.groups;
    let editor = sync_sweep_editor(editors.sweep, feature);
    let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP);
    let mut changed: Vec<WidgetId> = Vec::new();
    let mut render_group = |ctx: &mut FrameCtx<'_>,
                            rows: &mut Vec<PropertyRow<'_>>,
                            key: &'static str,
                            title: StringKey,
                            top: LayoutPx| {
        let (next_y, ch) = render_property_group(
            ctx,
            clipboard,
            groups,
            PropertyGroupSpec {
                id: id.child(WidgetKey::new(key)),
                title,
                top_left: LayoutPos::new(rect.origin.x, top),
                width: rect.size.width,
            },
            rows,
            paints,
        );
        changed.extend(ch);
        next_y
    };
    let options_top = {
        let mut rows = sweep_profile_path_group_rows(editor);
        render_group(
            ctx,
            &mut rows,
            "group.profile_path",
            strings::PROPERTY_GROUP_SWEEP_PROFILE_PATH,
            groups_top,
        )
    };
    let guides_top = {
        let mut rows = sweep_options_group_rows(editor);
        render_group(
            ctx,
            &mut rows,
            "group.options",
            strings::PROPERTY_GROUP_SWEEP_OPTIONS,
            options_top,
        )
    };
    let scope_top = {
        let mut rows = sweep_guides_group_rows(editor);
        render_group(
            ctx,
            &mut rows,
            "group.guides",
            strings::PROPERTY_GROUP_SWEEP_GUIDES,
            guides_top,
        )
    };
    {
        let mut rows = sweep_scope_group_rows(editor);
        let _ = render_group(
            ctx,
            &mut rows,
            "group.feature_scope",
            strings::PROPERTY_GROUP_FEATURE_SCOPE,
            scope_top,
        );
    }

    let edit = resolve_sweep_edit(editor, &changed);
    let arm_path = resolve_sweep_arm(editor, &changed);
    let arm_guide = resolve_sweep_guide_arm(editor, &changed);
    let arm_alignment = resolve_sweep_alignment_arm(editor, &changed);
    let arm_tool = resolve_sweep_tool_arm(editor, &changed);
    SweepRowsOutcome {
        edit,
        arm_path,
        arm_guide,
        arm_alignment,
        arm_tool,
        confirm,
    }
}

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 groups = &mut *editors.groups;
    let editor = sync_extrude_editor(editors.extrude, feature);
    let end_kind = kind_from_index(editor.end_condition.current).unwrap_or(EndConditionKind::Blind);
    let dir2_kind =
        kind_from_index(editor.dir2_end_condition.current).unwrap_or(EndConditionKind::Blind);
    let from_kind = from_kind_from_index(editor.from_kind.current).unwrap_or(FromKind::SketchPlane);
    let groups_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP);
    let mut changed: Vec<WidgetId> = Vec::new();
    let mut render_group = |ctx: &mut FrameCtx<'_>,
                            rows: &mut Vec<PropertyRow<'_>>,
                            key: &'static str,
                            title: StringKey,
                            top: LayoutPx| {
        let (next_y, ch) = render_property_group(
            ctx,
            clipboard,
            groups,
            PropertyGroupSpec {
                id: id.child(WidgetKey::new(key)),
                title,
                top_left: LayoutPos::new(rect.origin.x, top),
                width: rect.size.width,
            },
            rows,
            paints,
        );
        changed.extend(ch);
        next_y
    };
    let dir2_top = {
        let mut rows = direction_group_rows(editor, end_kind);
        render_group(
            ctx,
            &mut rows,
            "group.direction1",
            strings::PROPERTY_GROUP_DIRECTION_1,
            groups_top,
        )
    };
    let from_top = {
        let mut rows = direction_two_group_rows(editor, dir2_kind);
        render_group(
            ctx,
            &mut rows,
            "group.direction2",
            strings::PROPERTY_GROUP_DIRECTION_2,
            dir2_top,
        )
    };
    let scope_top = {
        let mut rows = from_group_rows(editor, from_kind);
        render_group(
            ctx,
            &mut rows,
            "group.from",
            strings::PROPERTY_GROUP_FROM,
            from_top,
        )
    };
    {
        let mut rows = scope_group_rows(editor);
        let _ = render_group(
            ctx,
            &mut rows,
            "group.feature_scope",
            strings::PROPERTY_GROUP_FEATURE_SCOPE,
            scope_top,
        );
    }

    let edit = resolve_extrude_edit(editor, &changed, from_kind);
    let arm_reference = resolve_arm_reference(editor, &changed, end_kind, dir2_kind, from_kind);
    ExtrudeRowsOutcome {
        edit,
        arm_reference,
        confirm,
    }
}

fn resolve_extrude_edit(
    editor: &ExtrudePropertyEditor,
    changed: &[WidgetId],
    from_kind: FromKind,
) -> Option<ExtrudeEdit> {
    let on = |key: &'static str| changed.contains(&extrude_row_id(key));
    if on("end") {
        return kind_from_index(editor.end_condition.current).map(ExtrudeEdit::EndCondition);
    }
    if on("depth") {
        return PositiveLength::new(editor.depth.value)
            .ok()
            .map(ExtrudeEdit::Depth);
    }
    if on("offset") {
        return Some(ExtrudeEdit::Offset(editor.offset.value));
    }
    if on("reverse_offset") {
        return Some(ExtrudeEdit::ReverseOffset(editor.reverse_offset.value));
    }
    if on("draft") || on("draft_angle") || on("draft_direction") {
        return Some(ExtrudeEdit::Draft(current_draft_from_editor(editor)));
    }
    if on("direction_two") || on("d2_end") || on("d2_depth") {
        return Some(ExtrudeEdit::Direction2(current_direction2_from_editor(
            editor,
        )));
    }
    if on("from_distance") {
        return Some(ExtrudeEdit::From(FromCondition::Offset {
            distance: editor.from_distance.value,
        }));
    }
    if on("from_kind") {
        return from_condition_for_kind(editor, from_kind).map(ExtrudeEdit::From);
    }
    if on("scope") {
        return Some(ExtrudeEdit::Scope(scope_from_index(editor.scope.current)));
    }
    if on("flip_side") {
        return Some(ExtrudeEdit::FlipSide(editor.flip_side.value));
    }
    if on("thin") || on("thin_direction") || on("thickness") || on("thickness2") || on("cap_ends") {
        return Some(ExtrudeEdit::Thin(current_thin_from_editor(editor)));
    }
    if on("merge") {
        return Some(ExtrudeEdit::Merge(if editor.merge.value {
            MergeResult::Merge
        } else {
            MergeResult::Separate
        }));
    }
    None
}

fn resolve_arm_reference(
    editor: &ExtrudePropertyEditor,
    changed: &[WidgetId],
    end_kind: EndConditionKind,
    dir2_kind: EndConditionKind,
    from_kind: FromKind,
) -> Option<ReferenceSlot> {
    let picked = |key: &'static str, current: Option<usize>| {
        changed.contains(&extrude_row_id(key)) && current == Some(REFERENCE_PICK_INDEX)
    };
    if picked("d1_reference", editor.direction1_reference.current) {
        return Some(ReferenceSlot::Direction1(end_kind));
    }
    if picked("d2_reference", editor.dir2_reference.current) {
        return Some(ReferenceSlot::Direction2(dir2_kind));
    }
    if picked("from_reference", editor.from_reference.current) {
        return Some(ReferenceSlot::From(from_kind));
    }
    None
}

fn current_draft_from_editor(editor: &ExtrudePropertyEditor) -> Option<DraftAngle> {
    if !editor.draft_enabled.value {
        return None;
    }
    let direction = match editor.draft_direction.current {
        Some(1) => DraftDirection::Inward,
        _ => DraftDirection::Outward,
    };
    DraftMagnitude::new(editor.draft_angle.value)
        .ok()
        .map(|magnitude| DraftAngle::new(magnitude, direction))
}

fn current_direction2_from_editor(editor: &ExtrudePropertyEditor) -> Option<Direction2> {
    if !editor.direction_two.value {
        return None;
    }
    let kind = kind_from_index(editor.dir2_end_condition.current)?;
    let end_condition = match kind {
        EndConditionKind::Blind | EndConditionKind::MidPlane => {
            let depth = PositiveLength::new(editor.dir2_depth.value).ok()?;
            kind.with_depth(depth)
        }
        EndConditionKind::ThroughAll => ExtrudeEndCondition::ThroughAll,
        EndConditionKind::ThroughAllBoth => ExtrudeEndCondition::ThroughAllBoth,
        EndConditionKind::UpToVertex
        | EndConditionKind::UpToSurface
        | EndConditionKind::OffsetFromSurface
        | EndConditionKind::UpToBody => return None,
    };
    Some(Direction2 {
        end_condition,
        draft: None,
    })
}

fn from_condition_for_kind(
    editor: &ExtrudePropertyEditor,
    from_kind: FromKind,
) -> Option<FromCondition> {
    match from_kind {
        FromKind::SketchPlane => Some(FromCondition::SketchPlane),
        FromKind::Offset => Some(FromCondition::Offset {
            distance: editor.from_distance.value,
        }),
        FromKind::Surface | FromKind::Vertex => None,
    }
}

fn current_thin_from_editor(editor: &ExtrudePropertyEditor) -> Option<ThinWall> {
    if !editor.thin.value {
        return None;
    }
    let thickness = PositiveLength::new(editor.thickness.value).ok()?;
    let direction = thin_direction_from_index(editor.thin_direction.current);
    let thickness2 = match direction {
        ThinWallDirection::TwoDirection => PositiveLength::new(editor.thickness2.value).ok(),
        _ => None,
    };
    Some(ThinWall {
        thickness,
        direction,
        thickness2,
        cap_ends: editor.cap_ends.value,
        auto_fillet: None,
    })
}

fn datum_pane_title(feature: &DatumFeature) -> StringKey {
    match feature {
        DatumFeature::Plane(_) => strings::PROPERTY_PANE_DATUM_PLANE_TITLE,
        DatumFeature::Axis(_) => strings::PROPERTY_PANE_DATUM_AXIS_TITLE,
        DatumFeature::Point(_) => strings::PROPERTY_PANE_DATUM_POINT_TITLE,
        DatumFeature::CoordinateSystem(_) => strings::PROPERTY_PANE_DATUM_CSYS_TITLE,
    }
}

fn datum_definition_summary(feature: &DatumFeature) -> &'static str {
    use bone_document::{AxisRecipe, PlaneRecipe, PointRecipe};
    match feature {
        DatumFeature::Plane(recipe) => match recipe.as_ref() {
            PlaneRecipe::Coincident { .. } => "Coincident",
            PlaneRecipe::Offset { .. } => "Offset distance",
            PlaneRecipe::AtAngle { .. } => "At angle",
            PlaneRecipe::ThreePoints { .. } => "Through three points",
            PlaneRecipe::NormalToCurve { .. } => "Normal to curve",
            PlaneRecipe::Parallel { .. } => "Parallel",
            PlaneRecipe::MidPlane { .. } => "Mid plane",
            PlaneRecipe::TangentToSurface { .. } => "Tangent",
            PlaneRecipe::ParallelToScreen => "Parallel to screen",
        },
        DatumFeature::Axis(recipe) => match recipe.as_ref() {
            AxisRecipe::OneLine { .. } => "One line / edge / axis",
            AxisRecipe::TwoPlanes { .. } => "Two planes",
            AxisRecipe::TwoPoints { .. } => "Two points",
            AxisRecipe::CylindricalFace { .. } => "Cylindrical / conical face",
            AxisRecipe::PointAndFace { .. } => "Point and face / plane",
        },
        DatumFeature::Point(recipe) => match recipe.as_ref() {
            PointRecipe::OnPoint { .. } => "On point",
            PointRecipe::Intersection { .. } => "Intersection",
            PointRecipe::Projection { .. } => "Projection",
            PointRecipe::ArcCenter { .. } => "Arc center",
            PointRecipe::CenterOfFace { .. } => "Center of face",
            PointRecipe::AlongCurve { .. } => "Along curve",
        },
        DatumFeature::CoordinateSystem(_) => "Origin and axes",
    }
}

fn render_datum_rows(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    feature: &DatumFeature,
    paints: &mut Vec<WidgetPaint>,
) -> Option<ConfirmAction> {
    ctx.a11y.push(
        id,
        rect,
        AccessNode::new(Role::Form).with_label(strings::PROPERTY_PANE_LABEL),
    );
    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: datum_pane_title(feature),
            accept_id: header_id.child(WidgetKey::new("accept")),
            cancel_id: header_id.child(WidgetKey::new("cancel")),
        },
    );
    paints.extend(header.paint);
    let confirm = match header.action {
        Some(PropertyPaneAction::Accept) => Some(ConfirmAction::Accept),
        Some(PropertyPaneAction::Cancel) => Some(ConfirmAction::Cancel),
        None => None,
    };
    let body_top = LayoutPx::new(rect.origin.y.value() + PM_HEADER_HEIGHT + PM_GROUP_GAP);
    let body_height = (rect.size.height.value() - PM_HEADER_HEIGHT - PM_GROUP_GAP).max(0.0);
    let body_rect = LayoutRect::new(
        LayoutPos::new(rect.origin.x, body_top),
        LayoutSize::new(rect.size.width, LayoutPx::new(body_height)),
    );
    let mut specs = vec![row_editor(
        strings::PROPERTY_ROW_DATUM_DEFINITION,
        datum_definition_summary(feature),
    )];
    render_static_rows(
        ctx,
        body_rect,
        id.child(WidgetKey::new("body")),
        clipboard,
        &mut specs,
        paints,
    );
    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)
                .cloned()
                .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(),
        Some(SketchEntity::Ellipse(_)) => strings_table
            .resolve(strings::PROPERTY_KIND_ELLIPSE)
            .to_owned(),
        Some(SketchEntity::Spline(_)) => strings_table
            .resolve(strings::PROPERTY_KIND_SPLINE)
            .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 kind_row(strings_table: &StringTable, kind: StringKey) -> PropertyRowSpec {
    row_editor(
        strings::PROPERTY_ROW_KIND,
        strings_table.resolve(kind).to_owned(),
    )
}

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![
                kind_row(strings_table, strings::PROPERTY_KIND_POINT),
                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![
                kind_row(strings_table, strings::PROPERTY_KIND_LINE),
                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![
                kind_row(strings_table, strings::PROPERTY_KIND_ARC),
                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![
                kind_row(strings_table, strings::PROPERTY_KIND_CIRCLE),
                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()),
                ),
            ]
        }
        SketchEntity::Ellipse(e) => {
            let center = endpoint_or_entity_label(strings_table, sketch, e.center());
            vec![
                kind_row(strings_table, strings::PROPERTY_KIND_ELLIPSE),
                row_editor(strings::PROPERTY_ROW_CENTER, center),
                row_editor(
                    strings::PROPERTY_ROW_CONSTRUCTION,
                    yes_no(e.for_construction()),
                ),
            ]
        }
        SketchEntity::Spline(s) => {
            vec![
                kind_row(strings_table, strings::PROPERTY_KIND_SPLINE),
                row_editor(
                    strings::PROPERTY_ROW_CONSTRUCTION,
                    yes_no(s.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::Revolve(_) => LabelText::Key(strings::STATUS_REVOLVE_ACTIVE),
        Mode::Sweep(_) => LabelText::Key(strings::STATUS_SWEEP_ACTIVE),
        Mode::Datum(_) => LabelText::Key(strings::STATUS_DATUM_ACTIVE),
        Mode::Sketch { session, .. } if session.spatial.is_some() => {
            LabelText::Key(strings::STATUS_SKETCH3_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()))
}

struct ToolButtonState {
    active: bool,
    disabled: bool,
}

fn entity_tool_state(
    mode: &Mode,
    tool: SketchTool,
    active_tool: Option<SketchTool>,
    sketch3_tool: Option<SketchTool>,
    tools_disabled: bool,
) -> ToolButtonState {
    if mode.is_sketch3() {
        return ToolButtonState {
            active: sketch3_tool == Some(tool),
            disabled: false,
        };
    }
    ToolButtonState {
        active: active_tool == Some(tool),
        disabled: tools_disabled,
    }
}

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::Centerline => "tool.centerline",
        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::Ellipse => "tool.ellipse",
        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",
        SketchTool::Spline => "tool.spline",
    }
}

fn tool_label(tool: SketchTool) -> StringKey {
    match tool {
        SketchTool::Point => strings::TOOL_POINT,
        SketchTool::Line => strings::TOOL_LINE,
        SketchTool::Centerline => strings::TOOL_CENTERLINE,
        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::Ellipse => strings::TOOL_ELLIPSE,
        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,
        SketchTool::Spline => strings::TOOL_SPLINE,
    }
}

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

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",
        FeatureTool::RevolvedBossBase => "tool.revolved_boss_base",
        FeatureTool::RevolvedCut => "tool.revolved_cut",
        FeatureTool::SweptBossBase => "tool.swept_boss_base",
        FeatureTool::SweptCut => "tool.swept_cut",
        FeatureTool::DatumPlane => "tool.datum_plane",
        FeatureTool::DatumAxis => "tool.datum_axis",
        FeatureTool::DatumPoint => "tool.datum_point",
        FeatureTool::DatumCoordinateSystem => "tool.datum_coordinate_system",
        FeatureTool::Sketch3D => "tool.sketch3",
    }
}

fn feature_tool_label(tool: FeatureTool) -> StringKey {
    match tool {
        FeatureTool::ExtrudedBossBase => strings::TOOL_EXTRUDED_BOSS_BASE,
        FeatureTool::ExtrudedCut => strings::TOOL_EXTRUDED_CUT,
        FeatureTool::RevolvedBossBase => strings::TOOL_REVOLVED_BOSS_BASE,
        FeatureTool::RevolvedCut => strings::TOOL_REVOLVED_CUT,
        FeatureTool::SweptBossBase => strings::TOOL_SWEPT_BOSS_BASE,
        FeatureTool::SweptCut => strings::TOOL_SWEPT_CUT,
        FeatureTool::DatumPlane => strings::TOOL_DATUM_PLANE,
        FeatureTool::DatumAxis => strings::TOOL_DATUM_AXIS,
        FeatureTool::DatumPoint => strings::TOOL_DATUM_POINT,
        FeatureTool::DatumCoordinateSystem => strings::TOOL_DATUM_COORDINATE_SYSTEM,
        FeatureTool::Sketch3D => strings::TOOL_SKETCH3,
    }
}

const fn feature_tool_icon(tool: FeatureTool) -> IconId {
    match tool {
        FeatureTool::ExtrudedBossBase => IconId::ExtrudedBossBase,
        FeatureTool::ExtrudedCut => IconId::ExtrudedCut,
        FeatureTool::RevolvedBossBase => IconId::RevolvedBossBase,
        FeatureTool::RevolvedCut => IconId::RevolvedCut,
        FeatureTool::SweptBossBase => IconId::SweptBossBase,
        FeatureTool::SweptCut => IconId::SweptCut,
        FeatureTool::DatumPlane => IconId::TreePlane,
        FeatureTool::DatumAxis => IconId::Line,
        FeatureTool::DatumPoint => IconId::Point,
        FeatureTool::DatumCoordinateSystem => IconId::TreeOrigin,
        FeatureTool::Sketch3D => IconId::TreeSketch,
    }
}

fn feature_tool_items(
    ctx: &FrameCtx<'_>,
    ribbon: WidgetId,
    mode: &Mode,
    tools: &[FeatureTool],
    datum_plane_ready: bool,
    large_min: LayoutPx,
) -> Vec<ToolbarItem> {
    let active = mode.lifecycle().button;
    let font = ctx.theme().typography.caption.size.as_px_f32();
    tools
        .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 is_plane = t == FeatureTool::DatumPlane;
            let item = match (mode.is_sketch(), is_plane, datum_plane_ready) {
                (true, true, true) => base.with_tooltip(strings::FEATURE_HINT_PLANE_AT_ENDPOINT),
                (true, true, false) => base
                    .disabled(true)
                    .with_tooltip(strings::FEATURE_HINT_PLANE_NEEDS_ENDPOINT),
                (true, false, _) => base
                    .disabled(true)
                    .with_tooltip(strings::FEATURE_HINT_EXIT_SKETCH),
                (false, _, _) => 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 is_armed_dialog(mode: &Mode) -> bool {
    matches!(mode, Mode::Extrude(ExtrudeArming::Profile { .. }))
        || matches!(mode, Mode::Revolve(RevolveArming::Profile { .. }))
        || matches!(mode, Mode::Sweep(SweepArming::Profile { .. }))
        || matches!(mode, Mode::Datum(_))
}

fn update_left_pane_auto(
    state: &mut ShellState,
    selection: &Selection,
    active_tool: Option<SketchTool>,
    armed_feature: bool,
    fully_armed: bool,
) {
    let interesting = !selection.is_empty() || active_tool.is_some() || armed_feature;
    let newly_armed = fully_armed && !state.last_fully_armed;
    let newly_interesting = interesting && !state.last_left_pane_interesting;
    if newly_armed || newly_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;
    state.last_fully_armed = fully_armed;
}

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 with_ctx<R>(theme: Theme, strings: &StringTable, f: impl FnOnce(&mut FrameCtx) -> R) -> R {
        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,
        );
        f(&mut ctx)
    }

    fn render_with_strings(
        shell: &mut Shell,
        theme: Theme,
        size: LayoutSize,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        strings: &StringTable,
    ) -> ShellFrame {
        with_ctx(theme, strings, |ctx| {
            shell.render(
                ctx,
                document,
                mode,
                selection,
                &Settings::default(),
                size,
                None,
                None,
                None,
                &mut ViewUi::default(),
                &BTreeMap::new(),
                &[],
                false,
                &[],
                &BTreeSet::new(),
            )
        })
    }

    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),
                ..
            }
            | WidgetPaint::Paragraph {
                rect,
                text: LabelText::Key(k),
                ..
            } if *k == target => Some(*rect),
            _ => None,
        })
    }

    fn blind_extrude(sketch: SketchId) -> ExtrudeFeature {
        let Ok(depth) = PositiveLength::new(Length::new::<bone_types::millimeter>(10.0)) else {
            panic!("ten millimeters is a positive depth");
        };
        ExtrudeFeature {
            operation: ExtrudeOperation::Boss(MergeResult::Separate),
            ..ExtrudeFeature::blind(sketch, depth)
        }
    }

    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(),
            bone_document::Sketch3::on_plane(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_consumed_sketches() -> Result<(), Box<dyn std::error::Error>> {
        use bone_document::{
            DatumFeature, PlaneRecipe, PrincipalPlane, Sketch3, SweepFeature, SweepPath,
        };
        use bone_types::{DatumTarget, Length};
        let part_id = test_part_id();

        let mut chain = sample_document();
        let (sketch_a, extrude_a) = push_plain_chain(&mut chain, "Sketch1");
        let (sketch_b, extrude_b) = push_plain_chain(&mut chain, "Sketch2");
        let rows = feature_rows(&chain, 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);

        let mut swept = sample_document();
        let profile_id = swept.allocate_sketch();
        swept.insert_sketch(
            profile_id,
            "Profile".to_owned(),
            Sketch3::on_plane(Plane::Zx.basis()),
        );
        let path_id = swept.allocate_sketch();
        swept.insert_sketch(
            path_id,
            "Path".to_owned(),
            Sketch3::on_plane(Plane::Xy.basis()),
        );
        let sweep_id =
            swept.commit_sweep(SweepFeature::boss(profile_id, SweepPath::Sketch(path_id)));
        let rows = feature_rows(&swept, part_id, &BTreeMap::new());
        let sweep_row = rows
            .iter()
            .find(|node| node.id == sweep_widget_id(part_id, sweep_id))
            .ok_or("the sweep appears in the feature tree")?;
        assert_eq!(
            sweep_row.children.len(),
            2,
            "the sweep exposes its profile and path sketches as children",
        );
        assert_eq!(
            sweep_row.children[0].id,
            sketch_widget_id(part_id, profile_id),
            "the profile nests first",
        );
        assert_eq!(
            sweep_row.children[1].id,
            sketch_widget_id(part_id, path_id),
            "the path nests second",
        );
        assert!(
            !rows
                .iter()
                .any(|node| node.id == sketch_widget_id(part_id, profile_id)
                    || node.id == sketch_widget_id(part_id, path_id)),
            "the consumed sketches do not also sit at the top level",
        );

        let mut loose = sample_document();
        let loose_id = loose.allocate_sketch();
        loose.insert_sketch(
            loose_id,
            "Sketch1".to_owned(),
            Sketch3::on_plane(Plane::Xy.basis()),
        );
        let rows = feature_rows(&loose, part_id, &BTreeMap::new());
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].id, sketch_widget_id(part_id, loose_id));
        assert!(
            rows[0].children.is_empty(),
            "an unconsumed sketch has no nested feature"
        );

        let mut datum_doc = sample_document();
        let xy = datum_doc
            .feature_tree()
            .principal_plane_feature(PrincipalPlane::Xy)
            .ok_or("a seeded document owns the XY plane")?;
        let datum_id = datum_doc.commit_datum(DatumFeature::plane(PlaneRecipe::Offset {
            from: DatumTarget::Feature(xy),
            distance: Length::new::<bone_types::millimeter>(25.0),
            flip: false,
        }));
        let datum_feature = datum_doc
            .feature_tree()
            .feature_of_datum(datum_id)
            .ok_or("the datum has a feature id")?;
        let datum_sketch = datum_doc.allocate_sketch();
        datum_doc.insert_sketch(
            datum_sketch,
            "Sketch1".to_owned(),
            Sketch3::on_plane(Plane::Xy.basis()),
        );
        datum_doc.bind_sketch_to_datum_plane(datum_sketch, datum_feature)?;
        let rows = feature_rows(&datum_doc, part_id, &BTreeMap::new());
        assert_eq!(
            rows.len(),
            1,
            "the bound sketch nests under its datum, never at the top level",
        );
        let datum_row = &rows[0];
        assert_eq!(datum_row.id, datum_widget_id(part_id, datum_id));
        assert_eq!(
            datum_row.children.len(),
            1,
            "the datum plane hosts its sketch as a child row",
        );
        assert_eq!(
            datum_row.children[0].id,
            sketch_widget_id(part_id, datum_sketch)
        );
        assert_eq!(
            datum_widget_ids(&datum_doc, part_id),
            vec![(datum_id, datum_widget_id(part_id, datum_id))],
            "the datum row maps back to its id for double-click routing",
        );
        Ok(())
    }

    #[test]
    fn solid_bodies_folder_lists_each_live_body() {
        let part_id = test_part_id();
        let empty = solid_bodies_node(part_id, &[]);
        assert!(
            empty.children.is_empty(),
            "no bodies leaves the folder empty"
        );
        assert!(
            empty.disabled,
            "an empty solid bodies folder stays disabled"
        );
        let rows = [
            BodyRow {
                label: "Revolve1[1]".to_owned(),
            },
            BodyRow {
                label: "Revolve1[2]".to_owned(),
            },
        ];
        let folder = solid_bodies_node(part_id, &rows);
        assert!(!folder.disabled, "live bodies enable the folder");
        assert_eq!(
            folder.children.len(),
            2,
            "a two-body feature shows two body rows"
        );
    }

    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_reflect_target_and_suppression() -> Result<(), Box<dyn std::error::Error>>
    {
        let ids = FeatureMenuIds::new(test_part_id());
        let extrude = menu_labels(&feature_menu_items(
            &ids,
            FeatureTarget::Extrude(ExtrudeId::default()),
            false,
            true,
            None,
        ));
        assert!(extrude.contains(&strings::FEATURE_CTX_EDIT_FEATURE));
        assert!(extrude.contains(&strings::FEATURE_CTX_EDIT_SKETCH));
        assert!(extrude.contains(&strings::FEATURE_CTX_SUPPRESS));
        assert!(!extrude.contains(&strings::FEATURE_CTX_UNSUPPRESS));
        assert!(extrude.contains(&strings::FEATURE_CTX_DELETE));
        assert!(extrude.contains(&strings::FEATURE_CTX_RELATIONSHIPS));

        let suppressed = menu_labels(&feature_menu_items(
            &ids,
            FeatureTarget::Sketch(SketchId::default()),
            true,
            true,
            None,
        ));
        assert!(!suppressed.contains(&strings::FEATURE_CTX_EDIT_FEATURE));
        assert!(suppressed.contains(&strings::FEATURE_CTX_UNSUPPRESS));
        assert!(!suppressed.contains(&strings::FEATURE_CTX_SUPPRESS));

        let mut document = sample_document();
        let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let target = FeatureTarget::Extrude(extrude);
        let feature = feature_id_of(&document, target).ok_or("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)),
        );
        Ok(())
    }

    #[test]
    fn rollback_target_change_and_disabled_node() -> Result<(), Box<dyn std::error::Error>> {
        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 feature = document
            .feature_tree()
            .feature_of_extrude(extrude)
            .ok_or("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)),
        );
        let rows = feature_rows(&document, part_id, &BTreeMap::new());
        assert!(
            rows[0].disabled,
            "a rolled-back feature is greyed and inert"
        );
        Ok(())
    }

    #[test]
    fn extrude_node_badge_rollup() -> Result<(), Box<dyn std::error::Error>> {
        let mut document = sample_document();
        let (sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let part_id = test_part_id();
        let sketch_feature = document
            .feature_tree()
            .feature_of_sketch(sketch)
            .ok_or("the sketch resolves to a feature id")?;
        let extrude_feature = document
            .feature_tree()
            .feature_of_extrude(extrude)
            .ok_or("the extrude resolves to a feature id")?;

        let own = feature_rows(
            &document,
            part_id,
            &BTreeMap::from([(extrude_feature, TreeBadge::Error)]),
        );
        assert_eq!(own.len(), 1);
        assert_eq!(
            own[0].badge,
            Some(TreeBadge::Error),
            "the extrude shows its own error badge"
        );

        let rolled = feature_rows(
            &document,
            part_id,
            &BTreeMap::from([(sketch_feature, TreeBadge::Warning)]),
        );
        assert_eq!(
            rolled[0].badge,
            Some(TreeBadge::Warning),
            "the extrude rolls up its nested sketch's warning",
        );
        assert_eq!(rolled[0].children[0].badge, Some(TreeBadge::Warning));

        let ranked = feature_rows(
            &document,
            part_id,
            &BTreeMap::from([
                (extrude_feature, TreeBadge::Warning),
                (sketch_feature, TreeBadge::Error),
            ]),
        );
        assert_eq!(
            ranked[0].badge,
            Some(TreeBadge::Error),
            "an error on the nested sketch outranks the extrude's own warning",
        );
        Ok(())
    }

    #[test]
    fn drop_to_reorder_accepts_legal_move_and_rejects_illegal()
    -> Result<(), Box<dyn std::error::Error>> {
        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 reorder = drop_to_reorder(&document, &w2s, &w2e, src, after_b)
            .ok_or("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",
        );
        Ok(())
    }

    #[test]
    fn relationship_rows_list_the_extrude_parent_sketch_and_no_children()
    -> Result<(), Box<dyn std::error::Error>> {
        let mut document = sample_document();
        let (_sketch, extrude) = push_plain_chain(&mut document, "Sketch1");
        let feature = feature_id_of(&document, FeatureTarget::Extrude(extrude))
            .ok_or("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");
        Ok(())
    }

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

    #[test]
    fn file_menu_actions_are_enabled() -> Result<(), Box<dyn std::error::Error>> {
        let entries = build_menu_entries(
            &ShellIds::standard(),
            false,
            &crate::hotkeys::HotkeyOverrides::default(),
        );
        let file_menu = entries
            .iter()
            .find(|e| e.label == strings::MENU_FILE)
            .ok_or("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)],
        );
        Ok(())
    }

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

        let mut shell = Shell::new();
        shell.state.settings_dialog_open = true;
        let open = render_into_shell(
            &mut shell,
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
            &Selection::default(),
        );
        let has_title = open.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 = open.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 idle_shell_render_layout_and_signals() {
        let frame = render_with(
            Theme::light(),
            layout_size(1280.0, 800.0),
            &sample_document(),
            &Mode::Idle,
        );
        assert!(!frame.paints.is_empty());
        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");
        let center = LayoutPos::new(
            LayoutPx::new(v.min_x().value() + v.size.width.value() * 0.5),
            LayoutPx::new(v.min_y().value() + v.size.height.value() * 0.5),
        );
        assert!(
            !frame.paints.iter().any(|p| matches!(
                p,
                WidgetPaint::Surface { rect, .. } if rect.contains(center)
            )),
            "the viewport panel body is not painted",
        );
        assert!(
            !frame.paints.iter().any(is_confirm_glyph),
            "idle omits the confirm corner"
        );
        assert_eq!(frame.plane_picked, None);
        assert!(!frame.exit_sketch);
        assert!(frame.activated_tool.is_none());
    }

    #[test]
    fn shell_seeds_part_node_and_maps_plane_ids() {
        let shell = Shell::new();
        assert!(
            shell
                .state
                .feature_tree
                .expanded
                .contains(&shell.ids.feature_part)
        );
        let ids = &shell.ids;
        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 tool_indices_round_trip() {
        let ids = ShellIds::standard();
        let tool_index = build_tool_index(ids.ribbon);
        SketchTool::ENTITIES.iter().copied().for_each(|t| {
            assert_eq!(
                tool_index.get(&tool_widget_id(ids.ribbon, t)).copied(),
                Some(t)
            );
        });
        assert!(
            !tool_index.contains_key(&ids.ribbon_smart_dimension),
            "smart dimension is excluded from the entity tool index",
        );
        let feature_index = build_feature_tool_index(ids.ribbon);
        FeatureTool::ALL.iter().copied().for_each(|t| {
            assert_eq!(
                feature_index
                    .get(&feature_tool_widget_id(ids.ribbon, t))
                    .copied(),
                Some(t),
            );
        });
    }

    #[test]
    fn features_tab_renders_extrude_group_only_when_selected() {
        let document = sample_document();
        let size = layout_size(1920.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",
        );
    }

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

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

        let feature = profile_feature();
        let before = end_condition_depth(&feature.end_condition);
        let mid = ExtrudeEdit::EndCondition(EndConditionKind::MidPlane).apply(feature);
        assert!(matches!(
            mid.end_condition,
            ExtrudeEndCondition::MidPlane { .. }
        ));
        assert_eq!(end_condition_depth(&mid.end_condition), before);

        let merged = ExtrudeEdit::Merge(MergeResult::Separate).apply(profile_feature());
        assert_eq!(
            merged.operation,
            ExtrudeOperation::Boss(MergeResult::Separate)
        );

        assert_eq!(
            ExtrudeEdit::EndCondition(EndConditionKind::ThroughAll)
                .apply(profile_feature())
                .end_condition,
            ExtrudeEndCondition::ThroughAll,
        );
        assert_eq!(
            ExtrudeEdit::EndCondition(EndConditionKind::ThroughAllBoth)
                .apply(profile_feature())
                .end_condition,
            ExtrudeEndCondition::ThroughAllBoth,
        );

        let Ok(magnitude) = DraftMagnitude::new(Angle::new::<degree>(5.0)) else {
            unreachable!("5 deg is a valid draft");
        };
        let draft = DraftAngle::new(magnitude, DraftDirection::Inward);
        let with_draft = ExtrudeEdit::Draft(Some(draft)).apply(profile_feature());
        assert_eq!(with_draft.draft, Some(draft));
        assert_eq!(ExtrudeEdit::Draft(None).apply(with_draft).draft, None);

        let Ok(thickness) = PositiveLength::new(Length::new::<millimeter>(2.0)) else {
            unreachable!("2 mm is positive");
        };
        let thin = ThinWall {
            thickness,
            direction: ThinWallDirection::MidPlane,
            thickness2: None,
            cap_ends: true,
            auto_fillet: None,
        };
        assert_eq!(
            ExtrudeEdit::Thin(Some(thin))
                .apply(profile_feature())
                .thin_wall,
            Some(thin),
        );

        assert_eq!(
            ExtrudeEdit::Scope(FeatureScope::AllBodies)
                .apply(profile_feature())
                .scope,
            FeatureScope::AllBodies,
        );
        assert!(ExtrudeEdit::FlipSide(true).apply(cut_feature()).flip_side);

        let distance = Length::new::<millimeter>(4.0);
        let from = ExtrudeEdit::From(FromCondition::Offset { distance }).apply(profile_feature());
        assert!(matches!(
            from.from,
            FromCondition::Offset { distance: d } if d == distance
        ));

        let Ok(depth2) = PositiveLength::new(Length::new::<millimeter>(6.0)) else {
            unreachable!("6 mm is positive");
        };
        let direction2 = Direction2 {
            end_condition: ExtrudeEndCondition::Blind { depth: depth2 },
            draft: None,
        };
        assert_eq!(
            ExtrudeEdit::Direction2(Some(direction2))
                .apply(profile_feature())
                .direction2,
            Some(direction2),
        );

        let surface = FaceLabel {
            feature: FeatureId::default(),
            role: bone_types::FaceRole::StartCap,
        };
        let base = ExtrudeFeature {
            end_condition: ExtrudeEndCondition::OffsetFromSurface {
                surface,
                offset: Length::new::<millimeter>(3.0),
                reverse_offset: false,
                translate_surface: false,
            },
            ..profile_feature()
        };
        let moved = ExtrudeEdit::Offset(Length::new::<millimeter>(8.0)).apply(base);
        assert!(matches!(
            moved.end_condition,
            ExtrudeEndCondition::OffsetFromSurface { surface: s, offset, .. }
                if s == surface && offset == Length::new::<millimeter>(8.0)
        ));
        let reversed = ExtrudeEdit::ReverseOffset(true).apply(moved);
        assert!(matches!(
            reversed.end_condition,
            ExtrudeEndCondition::OffsetFromSurface { surface: s, reverse_offset: true, .. }
                if s == surface
        ));

        let up_to = ExtrudeFeature {
            end_condition: ExtrudeEndCondition::UpToSurface { surface },
            ..profile_feature()
        };
        let to_offset = ExtrudeEdit::EndCondition(EndConditionKind::OffsetFromSurface).apply(up_to);
        assert!(matches!(
            to_offset.end_condition,
            ExtrudeEndCondition::OffsetFromSurface { surface: s, .. } if s == surface
        ));
        let back = ExtrudeEdit::EndCondition(EndConditionKind::UpToSurface).apply(to_offset);
        assert!(matches!(
            back.end_condition,
            ExtrudeEndCondition::UpToSurface { surface: s } if s == surface
        ));
    }

    fn cut_feature() -> ExtrudeFeature {
        ExtrudeFeature {
            operation: ExtrudeOperation::Cut,
            ..profile_feature()
        }
    }

    #[test]
    fn revolve_edit_apply_covers_thin_scope_axis_and_contour() {
        let feature = RevolveFeature::blind(
            SketchId::default(),
            RevolveAxis::SketchLine(SketchEntityId::default()),
            RevolveAngle::full(),
        );

        let Ok(thickness) = PositiveLength::new(Length::new::<millimeter>(2.0)) else {
            unreachable!("2 mm is positive");
        };
        let thin = ThinWall {
            thickness,
            direction: ThinWallDirection::MidPlane,
            thickness2: None,
            cap_ends: false,
            auto_fillet: None,
        };
        let thinned = RevolveEdit::Thin(Some(thin)).apply(&feature);
        assert_eq!(thinned.thin_wall, Some(thin));
        assert!(RevolveEdit::Thin(None).apply(&thinned).thin_wall.is_none());

        assert_eq!(feature.scope, FeatureScope::AutoSelect);
        assert_eq!(
            RevolveEdit::Scope(FeatureScope::AllBodies)
                .apply(&feature)
                .scope,
            FeatureScope::AllBodies,
        );

        let datum = FeatureId::default();
        assert_eq!(
            RevolveEdit::Axis(RevolveAxis::Datum(datum))
                .apply(&feature)
                .axis,
            RevolveAxis::Datum(datum),
        );

        assert_eq!(feature.contours, ContourSelection::All);
        let seed = SketchEntityId::default();
        assert_eq!(
            RevolveEdit::Contour(ContourSelection::Contour(seed))
                .apply(&feature)
                .contours,
            ContourSelection::Contour(seed),
        );
    }

    fn sweep_boss() -> SweepFeature {
        SweepFeature::boss(SketchId::default(), SweepPath::Sketch(SketchId::default()))
    }

    #[test]
    fn sweep_property_editor_reflects_twist_and_edits_round_trip() {
        let feature =
            SweepFeature::boss(SketchId::default(), SweepPath::Sketch(SketchId::default()));
        let mut editor = SweepPropertyEditor::new(&feature);
        editor.path.state.open = true;
        let editor = editor.synced(&feature);
        assert!(
            editor.path.state.open,
            "an unchanged path keeps its open dropdown instead of insta-closing every frame",
        );

        let modes = [
            (SweepOrientation::FollowPath, false, 0),
            (SweepOrientation::KeepNormalConstant, false, 1),
            (SweepOrientation::FollowPath, true, 2),
            (SweepOrientation::KeepNormalConstant, true, 3),
        ];
        modes.iter().for_each(|&(orientation, twisted, index)| {
            assert_eq!(sweep_twist_type_index(orientation, twisted), index);
            assert_eq!(sweep_orientation_from_twist_type(Some(index)), orientation);
            assert_eq!(sweep_twist_type_is_twisted(Some(index)), twisted);
        });

        let in_rev = SweepFeature {
            twist: Some(SweepTwist::new(
                Angle::new::<degree>(90.0),
                TwistUnit::Revolutions,
            )),
            ..sweep_boss()
        };
        let rev_editor = SweepPropertyEditor::new(&in_rev);
        assert_eq!(rev_editor.twist_value.unit, AngleUnit::Revolutions);
        assert!((rev_editor.twist_value.value.get::<degree>() - 90.0).abs() < 1e-9);
        assert!(
            rev_editor.twist_value.buffer.text.contains("rev")
                && !rev_editor.twist_value.buffer.text.contains("deg"),
            "the value box reads in revolutions, not degrees: {:?}",
            rev_editor.twist_value.buffer.text
        );

        let twist = SweepTwist::new(Angle::new::<degree>(45.0), TwistUnit::Revolutions);
        let twisted = SweepEdit::Orientation {
            orientation: SweepOrientation::KeepNormalConstant,
            twist: Some(twist),
        }
        .apply(&sweep_boss());
        assert_eq!(twisted.orientation, SweepOrientation::KeepNormalConstant);
        assert_eq!(twisted.twist, Some(twist));
        let cleared = SweepEdit::Orientation {
            orientation: SweepOrientation::FollowPath,
            twist: None,
        }
        .apply(&twisted);
        assert_eq!(cleared.orientation, SweepOrientation::FollowPath);
        assert!(cleared.twist.is_none());
    }

    #[test]
    fn extrude_reference_apply_builds_and_guards() {
        let surface = FaceLabel {
            feature: FeatureId::default(),
            role: bone_types::FaceRole::StartCap,
        };
        let vertex = VertexLabel {
            feature: FeatureId::default(),
            role: bone_types::VertexRole::Imported {
                ordinal: bone_types::ImportOrdinal::new(0),
            },
        };

        let feature = profile_feature();
        let armed =
            ExtrudeEdit::ArmReference(ReferenceSlot::Direction1(EndConditionKind::UpToSurface))
                .apply(feature);
        assert_eq!(armed, feature, "arming leaves the feature untouched");

        let up_to = apply_extrude_reference(
            profile_feature(),
            ReferenceSlot::Direction1(EndConditionKind::UpToSurface),
            CapturedReference::Face(surface),
        );
        assert!(matches!(
            up_to.end_condition,
            ExtrudeEndCondition::UpToSurface { surface: s } if s == surface
        ));

        let from_vertex = apply_extrude_reference(
            profile_feature(),
            ReferenceSlot::From(FromKind::Vertex),
            CapturedReference::Vertex(vertex),
        );
        assert!(matches!(
            from_vertex.from,
            FromCondition::Vertex { vertex: v } if v == vertex
        ));

        let mismatched = apply_extrude_reference(
            feature,
            ReferenceSlot::Direction1(EndConditionKind::UpToSurface),
            CapturedReference::Vertex(vertex),
        );
        assert_eq!(mismatched, feature, "a mismatched capture kind is refused");
    }

    #[test]
    fn extrude_property_pane_shows_mode_specific_rows() {
        let size = layout_size(1280.0, 800.0);
        let doc = sample_document();

        let awaiting = render_with(
            Theme::light(),
            size,
            &doc,
            &Mode::Extrude(ExtrudeArming::AwaitingSketch(ExtrudeOperation::default())),
        );
        assert!(
            label_rect(&awaiting.paints, strings::EXTRUDE_PROMPT_SELECT_SKETCH).is_some(),
            "awaiting-sketch arm prompts via the property pane",
        );

        let profiled = render_with(
            Theme::light(),
            size,
            &doc,
            &Mode::Extrude(ExtrudeArming::profile(SketchId::default())),
        );
        [
            strings::PROPERTY_ROW_EXTRUDE_END,
            strings::PROPERTY_ROW_EXTRUDE_DEPTH,
            strings::PROPERTY_ROW_EXTRUDE_MERGE,
            strings::PROPERTY_ROW_EXTRUDE_DIRECTION_TWO,
            strings::PROPERTY_ROW_EXTRUDE_FROM,
            strings::PROPERTY_ROW_EXTRUDE_SCOPE,
            strings::PROPERTY_ROW_EXTRUDE_THIN,
        ]
        .into_iter()
        .for_each(|key| {
            assert!(
                label_rect(&profiled.paints, key).is_some(),
                "extrude property pane shows {key:?}",
            );
        });

        let Ok(magnitude) = DraftMagnitude::new(Angle::new::<degree>(3.0)) else {
            unreachable!("3 deg is a valid draft");
        };
        let Ok(thickness) = PositiveLength::new(Length::new::<millimeter>(2.0)) else {
            unreachable!("2 mm is positive");
        };
        let enriched = ExtrudeFeature {
            draft: Some(DraftAngle::new(magnitude, DraftDirection::Outward)),
            thin_wall: Some(ThinWall {
                thickness,
                direction: ThinWallDirection::TwoDirection,
                thickness2: Some(thickness),
                cap_ends: false,
                auto_fillet: None,
            }),
            ..profile_feature()
        };
        let draft_thin = render_with(
            Theme::light(),
            size,
            &doc,
            &Mode::Extrude(ExtrudeArming::Profile {
                feature: Box::new(enriched),
                target: None,
            }),
        );
        [
            strings::PROPERTY_ROW_EXTRUDE_DRAFT_ANGLE,
            strings::PROPERTY_ROW_EXTRUDE_DIRECTION,
            strings::PROPERTY_ROW_EXTRUDE_THICKNESS,
            strings::PROPERTY_ROW_EXTRUDE_THICKNESS_TWO,
            strings::PROPERTY_ROW_EXTRUDE_CAP_ENDS,
        ]
        .into_iter()
        .for_each(|key| {
            assert!(
                label_rect(&draft_thin.paints, key).is_some(),
                "draft and thin pane shows {key:?}",
            );
        });

        let cut = render_with(
            Theme::light(),
            size,
            &doc,
            &Mode::Extrude(ExtrudeArming::Profile {
                feature: Box::new(cut_feature()),
                target: None,
            }),
        );
        assert!(
            label_rect(&cut.paints, strings::PROPERTY_ROW_EXTRUDE_FLIP_SIDE).is_some(),
            "a cut extrude offers Flip Side To Cut",
        );
        assert!(
            label_rect(&cut.paints, strings::PROPERTY_ROW_EXTRUDE_MERGE).is_none(),
            "a cut extrude hides Merge Result",
        );
    }

    #[test]
    fn feature_tools_disabled_while_sketching() {
        with_ctx(Theme::light(), StringTable::empty(), |ctx| {
            let ribbon = ShellIds::standard().ribbon;
            let large = RibbonIconSize::Large.item_px();

            let sketching = feature_tool_items(
                ctx,
                ribbon,
                &Mode::enter_sketch(SketchId::default()),
                FeatureTool::ALL,
                false,
                large,
            );
            assert!(
                sketching.iter().all(|item| item.disabled),
                "feature tools disable mid-sketch so a ribbon click can't drop the session",
            );
            let plane_id = feature_tool_widget_id(ribbon, FeatureTool::DatumPlane);
            assert!(
                sketching.iter().all(|item| {
                    let expected = if item.id == plane_id {
                        strings::FEATURE_HINT_PLANE_NEEDS_ENDPOINT
                    } else {
                        strings::FEATURE_HINT_EXIT_SKETCH
                    };
                    item.tooltip == Some(expected)
                }),
                "disabled feature tools explain why through a tooltip; the plane points at its endpoint prerequisite",
            );

            let plane_ready = feature_tool_items(
                ctx,
                ribbon,
                &Mode::enter_sketch(SketchId::default()),
                FeatureTool::DATUM,
                true,
                large,
            );
            let Some(plane) = plane_ready
                .iter()
                .find(|item| item.id == feature_tool_widget_id(ribbon, FeatureTool::DatumPlane))
            else {
                panic!("the datum group carries a plane button");
            };
            assert!(
                !plane.disabled && plane.tooltip == Some(strings::FEATURE_HINT_PLANE_AT_ENDPOINT),
                "an eligible endpoint selection lights up the plane button mid-sketch",
            );
            assert!(
                plane_ready
                    .iter()
                    .filter(|item| {
                        item.id != feature_tool_widget_id(ribbon, FeatureTool::DatumPlane)
                    })
                    .all(|item| item.disabled),
                "readiness lights only the plane, not the rest of the datum group",
            );

            let idle = feature_tool_items(ctx, ribbon, &Mode::Idle, FeatureTool::ALL, false, 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 ribbon_tab_sync_follows_the_sketch_rising_edge() {
        let ids = ShellIds::standard();
        let features = Some(features_tab_id(ids.ribbon));

        let mut rising = ShellState {
            ribbon_active_tab: features,
            ..ShellState::default()
        };
        sync_ribbon_tab_to_mode(
            &mut rising,
            ids.ribbon,
            &Mode::enter_sketch(SketchId::default()),
        );
        assert_eq!(
            rising.ribbon_active_tab,
            Some(sketch_tab_id(ids.ribbon)),
            "entering sketch snaps the ribbon to the sketch tab",
        );

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

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

    fn datum_plane_mode() -> Mode {
        Mode::Datum(crate::sketch_mode::DatumArming::new(
            crate::sketch_mode::default_datum_plane(bone_types::FeatureId::default()),
        ))
    }

    #[test]
    fn non_idle_modes_show_confirm_corner() {
        let size = layout_size(1280.0, 800.0);
        let sketch = render_with(
            Theme::light(),
            size,
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
        );
        assert!(
            sketch.paints.iter().any(is_confirm_glyph),
            "sketch mode shows the confirm corner"
        );

        let datum = render_with(
            Theme::light(),
            size,
            &sample_document(),
            &datum_plane_mode(),
        );
        assert!(
            datum.paints.iter().any(|paint| matches!(
                paint,
                WidgetPaint::Icon { icon: IconId::Check, rect, .. }
                    if rect.origin.x.value() > size.width.value() * 0.5
            )),
            "the datum tool shows the viewport confirm corner like extrude/revolve/sweep",
        );
    }

    #[test]
    fn becoming_fully_armed_reveals_the_options_pane_after_a_tree_detour() {
        let mut state = ShellState::default();
        let selection = Selection::default();
        update_left_pane_auto(&mut state, &selection, None, true, false);
        assert_eq!(
            state.left_pane,
            LeftPane::Properties,
            "arming an awaiting-sketch feature opens the options pane",
        );
        state.left_pane = LeftPane::Tree;
        update_left_pane_auto(&mut state, &selection, None, true, true);
        assert_eq!(
            state.left_pane,
            LeftPane::Properties,
            "picking the profile in the tree flips back to the options once fully armed",
        );
        state.left_pane = LeftPane::Tree;
        update_left_pane_auto(&mut state, &selection, None, true, true);
        assert_eq!(
            state.left_pane,
            LeftPane::Tree,
            "a deliberate tree switch while already armed is respected, no fighting the user",
        );
    }

    #[test]
    fn relation_tool_item_reflects_eligibility() {
        let ids = ShellIds::standard();
        let empty = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());

        let no_sketch = relation_tool_item(ids.ribbon, RelationKind::Horizontal, None, &[], false);
        assert!(no_sketch.disabled);
        assert!(
            no_sketch.tooltip.is_none(),
            "no sketch, no per-relation reason"
        );

        let flag_disabled = relation_tool_item(
            ids.ribbon,
            RelationKind::Horizontal,
            Some(&empty),
            &[],
            true,
        );
        assert!(flag_disabled.disabled);

        let ineligible = relation_tool_item(
            ids.ribbon,
            RelationKind::Horizontal,
            Some(&empty),
            &[],
            false,
        );
        assert!(ineligible.disabled);
        assert_eq!(ineligible.tooltip, Some(strings::REL_HINT_ONE_LINE));

        let (sketch, line) = sample_sketch_with_line();
        let eligible = relation_tool_item(
            ids.ribbon,
            RelationKind::Horizontal,
            Some(&sketch),
            &[line],
            false,
        );
        assert!(!eligible.disabled);
        assert!(eligible.tooltip.is_none());
    }

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

        let (sketch, line) = sample_sketch_with_line();
        let horizontal = relation_widget_id(ids.ribbon, RelationKind::Horizontal);
        assert_eq!(
            resolve_activated_relation(Some(horizontal), &index, Some(&sketch), &[line]),
            Some(SketchRelation::Horizontal(line)),
        );

        let empty = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        assert_eq!(
            resolve_activated_relation(Some(horizontal), &index, Some(&empty), &[]),
            None,
        );

        let (two, l1, l2) = sample_sketch_with_two_lines();
        let parallel = relation_widget_id(ids.ribbon, RelationKind::Parallel);
        assert_eq!(
            resolve_activated_relation(Some(parallel), &index, Some(&two), &[l1, l2]),
            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,
        })
    }

    fn paints_smart_dimension_label(frame: &ShellFrame) -> bool {
        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
                )
            })
    }

    #[test]
    fn smart_dimension_stays_reachable_in_the_ribbon() {
        let table = crate::strings::make_strings(Locale::EnUs);
        let mut shell = Shell::new();
        let typical = render_with_strings(
            &mut shell,
            Theme::light(),
            layout_size(1600.0, 900.0),
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
            &Selection::default(),
            &table,
        );
        assert!(
            paints_smart_dimension_label(&typical),
            "Smart Dimension paints at a typical window with real strings",
        );

        let mut narrow_shell = Shell::new();
        let dimensions_group = narrow_shell
            .ids
            .ribbon
            .child(WidgetKey::new("group.dimensions"));
        narrow_shell
            .state
            .ribbon_overflow_open
            .insert(dimensions_group, true);
        let narrow = render_into_shell(
            &mut narrow_shell,
            Theme::light(),
            layout_size(800.0, 600.0),
            &sample_document(),
            &Mode::enter_sketch(SketchId::default()),
            &Selection::default(),
        );
        assert!(
            paints_smart_dimension_label(&narrow),
            "Smart Dimension must stay reachable on a narrow ribbon, inline or via its group overflow",
        );
    }

    #[test]
    fn smart_dimension_item_reflects_selection() {
        let ids = ShellIds::standard();
        let empty = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());

        let no_sketch = smart_dimension_tool_item(ids.ribbon_smart_dimension, None, &[]);
        assert!(no_sketch.disabled);

        let no_selection = smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&empty), &[]);
        assert!(no_selection.disabled);
        assert_eq!(no_selection.tooltip, Some(strings::DIM_HINT_GENERIC));

        let (sketch, line) = sample_sketch_with_line();
        let eligible =
            smart_dimension_tool_item(ids.ribbon_smart_dimension, Some(&sketch), &[line]);
        assert!(!eligible.disabled);
        assert!(eligible.tooltip.is_none());
    }

    #[test]
    fn resolve_activated_dimension_maps_eligible_selection()
    -> Result<(), Box<dyn std::error::Error>> {
        let ids = ShellIds::standard();
        let (sketch, line) = sample_sketch_with_line();
        let dim_id = ids.ribbon_smart_dimension;

        let req = resolve_activated_dimension(Some(dim_id), dim_id, Some(&sketch), &[line])
            .ok_or("expected eligible request")?;
        assert!(matches!(
            req.proto,
            bone_document::SketchDimension::Linear { .. }
        ));

        let other = relation_widget_id(ids.ribbon, RelationKind::Horizontal);
        assert_eq!(
            resolve_activated_dimension(Some(other), dim_id, Some(&sketch), &[line]),
            None,
        );

        let empty = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        assert_eq!(
            resolve_activated_dimension(Some(dim_id), dim_id, Some(&empty), &[]),
            None,
        );
        Ok(())
    }

    #[test]
    fn partition_overlay_lifts_tooltips_and_popups() {
        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 surface = |fill| WidgetPaint::Surface {
            rect,
            fill,
            border: None,
            radius: theme.radius.none,
            elevation: None,
        };

        let (tt_main, tt_overlay) = partition_overlay(
            vec![
                surface(theme.colors.surface(theme.elevation.level1.surface)),
                WidgetPaint::Tooltip {
                    rect,
                    text: LabelText::Owned("hint".to_owned()),
                    anchor: WidgetId::ROOT,
                    elevation: theme.elevation.level2,
                },
            ],
            &theme,
        );
        assert_eq!(tt_main.len(), 1, "non-tooltip stays in main");
        assert!(matches!(tt_main[0], WidgetPaint::Surface { .. }));
        assert_eq!(tt_overlay.len(), 2, "tooltip expands to surface + label");
        assert!(matches!(tt_overlay[0], WidgetPaint::Surface { .. }));
        assert!(matches!(tt_overlay[1], WidgetPaint::Label { .. }));

        let (pop_main, pop_overlay) = partition_overlay(
            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,
                        },
                    ],
                },
            ],
            &theme,
        );
        assert_eq!(
            pop_main.len(),
            1,
            "the row surface beneath the popup stays in main"
        );
        assert_eq!(
            pop_overlay.len(),
            2,
            "the popup's surface + label float into the overlay so they draw on top",
        );
        assert!(matches!(pop_overlay[0], WidgetPaint::Surface { .. }));
        assert!(matches!(pop_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(),
            crate::sketch3_project::embed_sketch(sketch),
        );
        (doc, id)
    }

    #[test]
    fn dim_property_editor_populates_and_drops() {
        use bone_document::{EditOutcome, SketchEdit};
        use bone_types::Point2;
        let size = layout_size(1280.0, 800.0);

        let (driving, driving_id) = sketch_with_dim(DimensionKind::Driving);
        let (driving_doc, driving_sketch) = document_with_sketch(&driving);
        let mut shell = Shell::new();
        let frame = render_into_shell(
            &mut shell,
            Theme::light(),
            size,
            &driving_doc,
            &Mode::enter_sketch(driving_sketch),
            &Selection::Dimension(driving_id),
        );
        let Some(DimPropertyEditor::Length { id, editor, .. }) = &shell.state.dim_property else {
            panic!("expected length editor populated");
        };
        assert_eq!(*id, driving_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");

        let _ = render_into_shell(
            &mut shell,
            Theme::light(),
            size,
            &driving_doc,
            &Mode::enter_sketch(driving_sketch),
            &Selection::default(),
        );
        assert!(
            shell.state.dim_property.is_none(),
            "selecting off the dimension drops its editor"
        );

        let (driven, driven_id) = sketch_with_dim(DimensionKind::Driven);
        let (driven_doc, driven_sketch) = document_with_sketch(&driven);
        let mut driven_shell = Shell::new();
        let _ = render_into_shell(
            &mut driven_shell,
            Theme::light(),
            size,
            &driven_doc,
            &Mode::enter_sketch(driven_sketch),
            &Selection::Dimension(driven_id),
        );
        let Some(DimPropertyEditor::Length { id, .. }) = &driven_shell.state.dim_property else {
            panic!("expected length editor populated for the driven dim");
        };
        assert_eq!(*id, driven_id);

        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 linear = |a, b| {
            SketchEdit::AddDimension(SketchDimension::Linear {
                a,
                b,
                value: Length::new::<millimeter>(5.0),
                kind: DimensionKind::Driving,
            })
        };
        let Ok((s, EditOutcome::Dimension(dim_a))) = s.apply(linear(a, b)) else {
            panic!("expected first Dimension outcome");
        };
        let Ok((s, EditOutcome::Dimension(dim_b))) = s.apply(linear(a, c)) else {
            panic!("expected second Dimension outcome");
        };
        assert_ne!(dim_a, dim_b);
        let (swap_doc, swap_sketch) = document_with_sketch(&s);
        let mut swap_shell = Shell::new();
        [dim_a, dim_b].into_iter().for_each(|dim| {
            let _ = render_into_shell(
                &mut swap_shell,
                Theme::light(),
                size,
                &swap_doc,
                &Mode::enter_sketch(swap_sketch),
                &Selection::Dimension(dim),
            );
        });
        let Some(DimPropertyEditor::Length { id, .. }) = &swap_shell.state.dim_property else {
            panic!("expected length editor for the second dim");
        };
        assert_eq!(*id, dim_b, "the editor tracks the newly selected dimension");
    }

    #[test]
    fn property_pane_renders_relation_kind_label() -> Result<(), Box<dyn std::error::Error>> {
        let (sketch, _rel_id, _line) = sketch_with_relation();
        let (doc, sketch_id) = document_with_sketch(&sketch);
        let base = doc.sketch3(sketch_id).ok_or("expected inserted sketch")?;
        let projected = crate::sketch3_project::project_sketch(base, base.plane());
        let rel_id = projected
            .relation_order()
            .first()
            .copied()
            .ok_or("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"
        );
        Ok(())
    }

    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,
                &[],
                &BTreeSet::new(),
            )
        };
        *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)
    }

    fn hit_rect(hits: &HitFrame, widget: WidgetId) -> LayoutRect {
        hits.items()
            .iter()
            .find(|item| item.id == widget)
            .map_or_else(
                || panic!("widget must register a hit item"),
                |item| item.rect,
            )
    }

    fn rect_center(rect: LayoutRect) -> LayoutPos {
        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),
        )
    }

    fn warm_drive(
        shell: &mut Shell,
        document: &Document,
        mode: &Mode,
        focus: &mut FocusManager,
        prev: &mut HitState,
    ) -> HitFrame {
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        shell_drive(
            shell,
            document,
            mode,
            &Selection::default(),
            focus,
            prev,
            &mut snap,
        )
        .1
    }

    fn primary_click(
        shell: &mut Shell,
        document: &Document,
        mode: &Mode,
        focus: &mut FocusManager,
        prev: &mut HitState,
        center: LayoutPos,
    ) {
        use bone_ui::input::{PointerButton, PointerButtonMask, PointerSample};
        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,
            &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);
        let _ = drive_with_snap(
            shell,
            document,
            mode,
            &Selection::default(),
            focus,
            prev,
            release,
        );
    }

    fn hover_settle(
        shell: &mut Shell,
        document: &Document,
        mode: &Mode,
        focus: &mut FocusManager,
        prev: &mut HitState,
        center: LayoutPos,
    ) -> ShellFrame {
        use bone_ui::input::PointerSample;
        let mut idle = InputSnapshot::idle(FrameInstant::ZERO);
        idle.pointer = Some(PointerSample::new(center));
        drive_with_snap(
            shell,
            document,
            mode,
            &Selection::default(),
            focus,
            prev,
            idle,
        )
        .0
    }

    fn press_f2(
        shell: &mut Shell,
        document: &Document,
        focus: &mut FocusManager,
        prev: &mut HitState,
    ) {
        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(
            shell,
            document,
            &Mode::Idle,
            &Selection::default(),
            focus,
            prev,
            &mut f2,
        );
    }

    fn f2_rename_case(document: &Document, widget_of: impl Fn(&ShellIds) -> WidgetId) {
        let mut shell = Shell::new();
        let widget = widget_of(&shell.ids);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let _ = warm_drive(&mut shell, document, &Mode::Idle, &mut focus, &mut prev);
        focus.request_focus(widget);
        let _ = warm_drive(&mut shell, document, &Mode::Idle, &mut focus, &mut prev);
        assert_eq!(
            focus.focused(),
            Some(widget),
            "row must be focusable and focused after the second render",
        );
        press_f2(&mut shell, document, &mut focus, &mut prev);
        assert_eq!(
            shell.state.feature_tree.renaming,
            Some(widget),
            "F2 with the row focused must enter rename",
        );
    }

    #[test]
    fn feature_row_enters_rename_on_f2() {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (sketch_doc, sketch_id) = document_with_sketch(&sketch);
        f2_rename_case(&sketch_doc, |ids| sketch_widget(ids, sketch_id));
        let (extrude_doc, extrude_id) = document_with_extrude();
        f2_rename_case(&extrude_doc, |ids| extrude_widget(ids, extrude_id));

        let mut shell = Shell::new();
        let widget = sketch_widget(&shell.ids, sketch_id);
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let hits = warm_drive(&mut shell, &sketch_doc, &Mode::Idle, &mut focus, &mut prev);
        let center = rect_center(hit_rect(&hits, widget));
        primary_click(
            &mut shell,
            &sketch_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        let _ = hover_settle(
            &mut shell,
            &sketch_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        assert_eq!(
            focus.focused(),
            Some(widget),
            "clicking a sketch row focuses it before F2 is pressed",
        );
        press_f2(&mut shell, &sketch_doc, &mut focus, &mut prev);
        assert_eq!(
            shell.state.feature_tree.renaming,
            Some(widget),
            "click-then-F2 must enter rename mode on the sketch row",
        );
    }

    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()
    -> Result<(), Box<dyn std::error::Error>> {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (mut document, sketch_id) = document_with_sketch(&sketch);
        document.rename_sketch(sketch_id, "Profile")?;
        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:?}",
        );
        Ok(())
    }

    #[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 row_rect = hit_rect(&hits, widget);
        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() {
        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 hits = warm_drive(&mut shell, &document, &mode, &mut focus, &mut prev);
        let center = rect_center(hit_rect(&hits, accept_id));
        let _ = hover_settle(&mut shell, &document, &mode, &mut focus, &mut prev, center);
        primary_click(&mut shell, &document, &mode, &mut focus, &mut prev, center);
        let frame = hover_settle(&mut shell, &document, &mode, &mut focus, &mut prev, center);
        assert_eq!(
            frame.confirm_action,
            Some(ConfirmAction::Accept),
            "clicking the property header check must accept like the confirm corner",
        );
    }

    #[test]
    fn double_click_feature_row_emits_activation() {
        let sketch = bone_document::Sketch::new(crate::sketch_mode::Plane::Xy.basis());
        let (sketch_doc, 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 hits = warm_drive(&mut shell, &sketch_doc, &Mode::Idle, &mut focus, &mut prev);
        let center = rect_center(hit_rect(&hits, widget));
        primary_click(
            &mut shell,
            &sketch_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        primary_click(
            &mut shell,
            &sketch_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        let frame = hover_settle(
            &mut shell,
            &sketch_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        assert_eq!(
            frame.sketch_activated,
            Some(sketch_id),
            "double-click on a sketch row emits sketch_activated for that sketch",
        );

        let (extrude_doc, 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 hits = warm_drive(&mut shell, &extrude_doc, &Mode::Idle, &mut focus, &mut prev);
        let center = rect_center(hit_rect(&hits, widget));
        primary_click(
            &mut shell,
            &extrude_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        primary_click(
            &mut shell,
            &extrude_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        let frame = hover_settle(
            &mut shell,
            &extrude_doc,
            &Mode::Idle,
            &mut focus,
            &mut prev,
            center,
        );
        assert_eq!(
            frame.extrude_activated,
            Some(extrude_id),
            "double-click on an extrude row emits extrude_activated for that extrude",
        );
    }

    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_and_keeps_chrome() {
        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",
        );

        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_chrome_labels() {
        CHROME_BAND_KEYS
            .into_iter()
            .for_each(assert_chrome_label_mirrors_under_rtl);
    }

    fn render_capture(
        size: LayoutSize,
        strings: &StringTable,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        camera3: Option<Camera3>,
        configure: impl FnOnce(&mut Shell),
    ) -> (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,
                &Settings::default(),
                size,
                None,
                camera3,
                None,
                &mut view,
                &BTreeMap::new(),
                &[],
                false,
                &[],
                &BTreeSet::new(),
            )
        };
        let update = a11y.build(strings, focus.focused());
        (shell, frame, update)
    }

    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, _, update) = render_capture(
            canvas,
            table,
            doc,
            &Mode::enter_sketch(sketch_id),
            selection,
            None,
            configure,
        );
        (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_convert_entities,
                        "menu.sketch.convert_entities",
                    ),
                    (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 (_, frame, update) = render_capture(
            size,
            strings,
            document,
            mode,
            &Selection::default(),
            camera3,
            configure,
        );
        (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))
    }

    fn assert_frame_mirror(ltr: &ShellFrame, rtl: &ShellFrame, key: StringKey, half: f32) {
        let ltr_rect =
            any_label_rect(ltr, key).unwrap_or_else(|| panic!("ltr paint missing for {key}"));
        let rtl_rect =
            any_label_rect(rtl, key).unwrap_or_else(|| panic!("rtl paint missing for {key}"));
        assert!(
            ltr_rect.origin.x.value() < half,
            "{key} sits on the left half under ltr, got x={}",
            ltr_rect.origin.x.value(),
        );
        assert!(
            rtl_rect.origin.x.value() > half,
            "{key} mirrors to the right half under rtl, got x={}",
            rtl_rect.origin.x.value(),
        );
    }

    #[test]
    fn rtl_mirrors_property_pane_and_file_menu() {
        let size = layout_size(1600.0, 900.0);
        let half = size.width.value() * 0.5;
        let doc = sample_document();

        let extrude_mode = Mode::Extrude(ExtrudeArming::profile(SketchId::default()));
        let extrude_ltr = render_phase2(
            size,
            &crate::strings::make_strings(Locale::EnUs),
            &doc,
            &extrude_mode,
            None,
            |_| {},
        )
        .0;
        let extrude_rtl = render_phase2(
            size,
            &crate::strings::make_strings(Locale::ArXb),
            &doc,
            &extrude_mode,
            None,
            |_| {},
        )
        .0;
        assert_frame_mirror(
            &extrude_ltr,
            &extrude_rtl,
            strings::PROPERTY_ROW_EXTRUDE_DEPTH,
            half,
        );

        let open_file = |s: &mut Shell| s.state.menu_bar.open = Some(s.ids.menu_file);
        let menu_ltr = render_phase2(
            size,
            &crate::strings::make_strings(Locale::EnUs),
            &doc,
            &Mode::Idle,
            None,
            open_file,
        )
        .0;
        let menu_rtl = render_phase2(
            size,
            &crate::strings::make_strings(Locale::ArXb),
            &doc,
            &Mode::Idle,
            None,
            open_file,
        )
        .0;
        assert_frame_mirror(&menu_ltr, &menu_rtl, strings::MENU_FILE_IMPORT, half);
    }

    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",
            "direction_two",
            "from_kind",
            "scope",
            "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);
    }
}
