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

use bone_document::{
    DimensionKind, DimensionValue, Document, Sketch, SketchDimension, SketchEntity, SketchRelation,
};
use bone_types::{Length, Point2, SketchDimensionId, SketchEntityId, SketchId};
use bone_ui::a11y::{AccessNode, Role};
use bone_ui::frame::{FrameCtx, InteractDeclaration};
use bone_ui::hit_test::Sense;
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::GlyphMark;
use bone_ui::widgets::{
    AngleEditor, Clipboard, Dialog, DialogButton, HotkeyCapture, HotkeyCaptureState, LabelText,
    LengthEditor, MemoryClipboard, MenuBar, MenuBarEntry, MenuBarState, MenuItem, PropertyCell,
    PropertyEditor, PropertyGrid, PropertyRow, RenameCommit, Ribbon, RibbonGroup, RibbonIconSize,
    RibbonTab, Slider, SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Tabs,
    TabsOrientation, ToolbarItem, TreeNode, TreeView, TreeViewState, WidgetPaint, show_dialog,
    show_hotkey_capture, show_menu_bar, show_property_grid, show_ribbon, show_slider,
    show_status_bar, show_tabs, show_tree_view,
};
use bone_ui::{WidgetId, WidgetKey};
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::{Mode, Plane, SketchTool};
use crate::smart_dimension;
use crate::strings;

const RIBBON_GROUP_PADDING_PX: f32 = 8.0;
const RIBBON_TOOLBAR_GAP_PX: f32 = 4.0;
const RIBBON_LABEL_HORIZONTAL_PADDING_PX: f32 = 12.0;
const RIBBON_LABEL_AVG_ADVANCE_RATIO: f32 = 0.6;
const STATUS_MODE_WIDTH: LayoutPx = LayoutPx::new(220.0);
const STATUS_UNITS_WIDTH: LayoutPx = LayoutPx::new(80.0);
const STATUS_COORDS_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,
    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_quit: WidgetId,
    menu_edit_undo: WidgetId,
    menu_edit_redo: WidgetId,
    menu_view_zoom_fit: WidgetId,
    menu_tools_options: WidgetId,
    menu_tools_keyboard: WidgetId,
    menu_sketch_exit: WidgetId,
    settings_dialog: WidgetId,
    settings_aperture_slider: WidgetId,
    settings_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")),
            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_quit: menu_file.child(WidgetKey::new("quit")),
            menu_edit_undo: menu_edit.child(WidgetKey::new("undo")),
            menu_edit_redo: menu_edit.child(WidgetKey::new("redo")),
            menu_view_zoom_fit: menu_view.child(WidgetKey::new("zoom_fit")),
            menu_tools_options: menu_tools.child(WidgetKey::new("options")),
            menu_tools_keyboard: menu_tools.child(WidgetKey::new("keyboard")),
            menu_sketch_exit: menu_sketch.child(WidgetKey::new("exit")),
            settings_dialog,
            settings_aperture_slider: settings_dialog.child(WidgetKey::new("aperture.slider")),
            settings_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_quit, MenuAction::Quit),
            (self.menu_edit_undo, MenuAction::Undo),
            (self.menu_edit_redo, MenuAction::Redo),
            (self.menu_view_zoom_fit, MenuAction::ZoomFit),
            (self.menu_tools_options, MenuAction::OpenSettings),
            (self.menu_tools_keyboard, MenuAction::OpenKeyboardCustomize),
            (self.menu_sketch_exit, MenuAction::ExitSketch),
        ]
        .iter()
        .copied()
        .find_map(|(menu_id, action)| (menu_id == id).then_some(action))
    }
}

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

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

#[derive(Default)]
pub struct ShellState {
    pub feature_tree: TreeViewState,
    pub clipboard: MemoryClipboard,
    pub menu_bar: MenuBarState,
    pub dim_property: Option<DimPropertyEditor>,
    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,
}

#[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(Clone, Debug, PartialEq)]
pub struct ShellFrame {
    pub paints: Vec<WidgetPaint>,
    pub overlay_paints: Vec<WidgetPaint>,
    pub viewport_rect: LayoutRect,
    pub activated_tool: Option<SketchTool>,
    pub activated_relation: Option<SketchRelation>,
    pub activated_dimension: Option<PendingDimension>,
    pub dimension_edit: Option<DimensionEdit>,
    pub plane_picked: Option<Plane>,
    pub sketch_activated: Option<SketchId>,
    pub sketch_rename: Option<SketchRenameRequest>,
    pub exit_sketch: bool,
    pub confirm_action: Option<ConfirmAction>,
    pub menu_action: Option<MenuAction>,
    pub settings_change: Option<crate::settings::Settings>,
}

#[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_relation: None,
            activated_dimension: None,
            dimension_edit: None,
            plane_picked: None,
            sketch_activated: None,
            sketch_rename: None,
            exit_sketch: false,
            confirm_action: None,
            menu_action: None,
            settings_change: 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 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,
            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>,
    ) -> 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 activated_widget = render_ribbon(
            ctx,
            RibbonInputs {
                rect: ribbon_rect,
                ribbon: self.ids.ribbon,
                ribbon_smart_dimension: self.ids.ribbon_smart_dimension,
                mode,
                sketch: active_sketch,
                selection: entity_ids,
            },
            &mut paints,
        );
        let active_tool = match mode {
            Mode::Sketch { session, .. } => session.tool,
            Mode::Idle => None,
        };
        update_left_pane_auto(&mut self.state, selection, active_tool);
        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,
            tree_rect,
            self.ids.feature_tree,
            self.ids.feature_part,
            &mut self.state.feature_tree,
            document,
            &mut paints,
        );
        let dimension_edit = render_property_pane(
            ctx,
            property_rect,
            self.ids.property_pane,
            &mut self.state.clipboard,
            &mut self.state.dim_property,
            PropertyState {
                mode,
                sketch: active_sketch,
                selection,
            },
            &mut paints,
        );
        render_doc_tabs(ctx, doc_tabs_rect, &self.ids, &mut paints);
        render_status_bar(
            ctx,
            status_rect,
            self.ids.status_bar,
            mode,
            document,
            cursor_world,
            &mut paints,
        );
        let confirm =
            render_confirm_corner(ctx, viewport_rect, &self.ids, mode.is_sketch(), &mut paints);
        let confirm_action = confirm;
        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_relation = resolve_activated_relation(
            activated_widget,
            &self.relation_index,
            active_sketch,
            entity_ids,
        );
        let activated_dimension = resolve_activated_dimension(
            activated_widget,
            self.ids.ribbon_smart_dimension,
            active_sketch,
            entity_ids,
        );
        let plane_picked = feature_tree
            .double_activated
            .and_then(|id| self.ids.plane_for(id));
        let sketch_activated = feature_tree.sketch_activated;
        let sketch_rename = feature_tree.sketch_rename;
        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_relation,
            activated_dimension,
            dimension_edit,
            plane_picked,
            sketch_activated,
            sketch_rename,
            exit_sketch,
            confirm_action,
            menu_action,
            settings_change,
        }
    }
}

const SETTINGS_DIALOG_WIDTH: f32 = 420.0;
const SETTINGS_DIALOG_HEIGHT: f32 = 220.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_LABEL_TO_HINT_GAP: f32 = 6.0;
const SETTINGS_HINT_TO_SLIDER_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 (response, slider_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,
                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 {
            pick_aperture: PickAperture::DEFAULT,
            hotkey_overrides: settings.hotkey_overrides.clone(),
        });
    }
    slider_change
}

fn settings_dialog_body(
    ctx: &mut FrameCtx<'_>,
    body_rect: LayoutRect,
    aperture_slider_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);
    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),
            hotkey_overrides: settings.hotkey_overrides.clone(),
        }
    })
}

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

fn settings_hint_rect(body: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER),
            LayoutPx::new(
                body.origin.y.value()
                    + SETTINGS_DIALOG_GUTTER
                    + SETTINGS_LABEL_HEIGHT
                    + SETTINGS_LABEL_TO_HINT_GAP,
            ),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER),
            LayoutPx::new(SETTINGS_HINT_HEIGHT),
        ),
    )
}

fn settings_slider_rect(body: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(body.origin.x.value() + SETTINGS_DIALOG_GUTTER),
            LayoutPx::new(
                body.origin.y.value()
                    + SETTINGS_DIALOG_GUTTER
                    + SETTINGS_LABEL_HEIGHT
                    + SETTINGS_LABEL_TO_HINT_GAP
                    + SETTINGS_HINT_HEIGHT
                    + SETTINGS_HINT_TO_SLIDER_GAP,
            ),
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(body.size.width.value() - 2.0 * SETTINGS_DIALOG_GUTTER),
            LayoutPx::new(SETTINGS_SLIDER_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 {
            pick_aperture: settings.pick_aperture,
            hotkey_overrides: crate::hotkeys::HotkeyOverrides::default(),
        });
    }
    next_overrides.map(|overrides| Settings {
        pick_aperture: settings.pick_aperture,
        hotkey_overrides: overrides,
    })
}

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,
                    });
                }
                other => main.push(other),
            }
            (main, overlay)
        },
    )
}

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

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

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

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

#[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_trailing_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_quit,
                    strings::MENU_FILE_QUIT,
                    Some(crate::hotkeys::QUIT_ACTION),
                ),
            ],
        },
        MenuBarEntry {
            id: ids.menu_edit,
            label: strings::MENU_EDIT,
            items: vec![
                action_with_accel(
                    ids.menu_edit_undo,
                    strings::MENU_EDIT_UNDO,
                    Some(crate::sketch_mode::UNDO_ACTION),
                ),
                action_with_accel(
                    ids.menu_edit_redo,
                    strings::MENU_EDIT_REDO,
                    Some(crate::sketch_mode::REDO_ACTION),
                ),
            ],
        },
        MenuBarEntry {
            id: ids.menu_view,
            label: strings::MENU_VIEW,
            items: vec![action_with_accel(
                ids.menu_view_zoom_fit,
                strings::MENU_VIEW_ZOOM_FIT,
                Some(crate::hotkeys::ZOOM_FIT_ACTION),
            )],
        },
        MenuBarEntry {
            id: ids.menu_insert,
            label: strings::MENU_INSERT,
            items: vec![placeholder(ids.menu_insert, "soon")],
        },
        MenuBarEntry {
            id: ids.menu_tools,
            label: strings::MENU_TOOLS,
            items: vec![
                action_with_accel(ids.menu_tools_options, strings::MENU_TOOLS_OPTIONS, None),
                action_with_accel(ids.menu_tools_keyboard, strings::MENU_TOOLS_KEYBOARD, None),
            ],
        },
    ];
    if is_sketch {
        entries.push(MenuBarEntry {
            id: ids.menu_sketch,
            label: strings::MENU_SKETCH,
            items: vec![action_with_accel(
                ids.menu_sketch_exit,
                strings::MENU_SKETCH_EXIT,
                Some(crate::sketch_mode::EXIT_SKETCH_ACTION),
            )],
        });
    }
    entries.extend([
        MenuBarEntry {
            id: ids.menu_window,
            label: strings::MENU_WINDOW,
            items: vec![placeholder(ids.menu_window, "soon")],
        },
        MenuBarEntry {
            id: ids.menu_help,
            label: strings::MENU_HELP,
            items: vec![placeholder(ids.menu_help, "soon")],
        },
    ]);
    entries
}

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

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

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

struct FeatureTreeOutcome {
    double_activated: Option<WidgetId>,
    sketch_activated: Option<SketchId>,
    sketch_rename: Option<SketchRenameRequest>,
}

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

fn render_feature_tree(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    tree_id: WidgetId,
    part_id: WidgetId,
    state: &mut TreeViewState,
    document: &Document,
    paints: &mut Vec<WidgetPaint>,
) -> FeatureTreeOutcome {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return FeatureTreeOutcome {
            double_activated: None,
            sketch_activated: None,
            sketch_rename: None,
        };
    }
    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_glyph(GlyphMark::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_glyph(GlyphMark::TreePlane);
    let sketch_rows: Vec<(SketchId, WidgetId, TreeNode)> = document
        .sketches()
        .map(|(sketch_id, _)| {
            let widget_id = sketch_widget_id(part_id, sketch_id);
            let label = document.sketch_label(sketch_id).unwrap_or("").to_owned();
            let node = TreeNode::leaf_owned(widget_id, label).with_glyph(GlyphMark::TreeSketch);
            (sketch_id, widget_id, node)
        })
        .collect();
    let renamable: Vec<WidgetId> = sketch_rows.iter().map(|(_, w, _)| *w).collect();
    let widget_to_sketch: BTreeMap<WidgetId, SketchId> =
        sketch_rows.iter().map(|(s, w, _)| (*w, *s)).collect();
    let children: Vec<TreeNode> = [
        placeholder("history", strings::FEATURE_HISTORY),
        placeholder("sensors", strings::FEATURE_SENSORS),
        placeholder("annotations", strings::FEATURE_ANNOTATIONS),
        placeholder("solid_bodies", strings::FEATURE_SOLID_BODIES),
        placeholder("material", strings::FEATURE_MATERIAL),
        plane_leaf("plane.xy", strings::FEATURE_PLANE_XY),
        plane_leaf("plane.yz", strings::FEATURE_PLANE_YZ),
        plane_leaf("plane.zx", strings::FEATURE_PLANE_ZX),
        leaf("origin", strings::FEATURE_ORIGIN).with_glyph(GlyphMark::RadioDot),
    ]
    .into_iter()
    .chain(sketch_rows.into_iter().map(|(_, _, node)| node))
    .collect();
    let part = TreeNode::parent_owned(part_id, document.name().to_owned(), children);
    let roots = [part];
    let response = show_tree_view(
        ctx,
        TreeView::new(tree_id, rect, strings::FEATURE_TREE_LABEL, &roots, state)
            .renamable(&renamable),
    );
    paints.extend(response.paint);
    let sketch_activated = response
        .double_activated
        .and_then(|id| widget_to_sketch.get(&id).copied());
    let sketch_rename = response
        .rename_committed
        .and_then(|RenameCommit { id, text }| {
            widget_to_sketch
                .get(&id)
                .copied()
                .map(|sketch_id| SketchRenameRequest {
                    id: sketch_id,
                    label: text,
                })
        });
    FeatureTreeOutcome {
        double_activated: response.double_activated,
        sketch_activated,
        sketch_rename,
    }
}

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

fn render_property_pane(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    dim_property: &mut Option<DimPropertyEditor>,
    state: PropertyState<'_>,
    paints: &mut Vec<WidgetPaint>,
) -> Option<DimensionEdit> {
    let in_sketch = matches!(state.mode, Mode::Sketch { .. });
    let active_sketch_id = match state.mode {
        Mode::Sketch { sketch_id, .. } => Some(*sketch_id),
        Mode::Idle => None,
    };
    let resolved = state
        .sketch
        .filter(|_| in_sketch)
        .and_then(|s| resolve_selection_target(s, state.selection).map(|t| (s, t)));
    if !matches!(resolved, Some((_, SelectionTarget::Dimension(_, _)))) {
        *dim_property = None;
    }
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return None;
    }
    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);
            None
        }
        Some((sketch, SelectionTarget::Relation(rel))) => {
            let mut editors = relation_editors(ctx.strings, rel, sketch);
            render_static_rows(ctx, rect, id, clipboard, &mut editors, paints);
            None
        }
        Some((sketch, SelectionTarget::Dimension(dim_id, dim))) => {
            let sketch_id = active_sketch_id?;
            render_dimension_rows(
                ctx,
                rect,
                id,
                clipboard,
                dim_property,
                sketch_id,
                dim_id,
                dim,
                sketch,
                paints,
            )
        }
        None => {
            let mut editors = vec![row_editor(strings::PROPERTY_PANE_NO_SELECTION, "")];
            render_static_rows(ctx, rect, id, clipboard, &mut editors, paints);
            None
        }
    }
}

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

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

fn render_static_rows(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    clipboard: &mut MemoryClipboard,
    editors: &mut [PropertyRowSpec],
    paints: &mut Vec<WidgetPaint>,
) {
    let mut rows: Vec<PropertyRow<'_>> = editors
        .iter_mut()
        .map(|(row_id, label, editor)| PropertyRow {
            id: *row_id,
            label: *label,
            editor: editor.as_mut(),
            read_only: true,
        })
        .collect();
    let response = show_property_grid(
        ctx,
        PropertyGrid::new(id, rect, strings::PROPERTY_PANE_LABEL, &mut rows),
        clipboard,
    );
    paints.extend(response.paint);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

struct StaticTextEditor {
    value: String,
}

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

impl PropertyEditor for StaticTextEditor {
    fn render(
        &mut self,
        ctx: &mut FrameCtx<'_>,
        cell: PropertyCell,
        _clipboard: &mut dyn Clipboard,
        paint: &mut Vec<WidgetPaint>,
    ) -> bool {
        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);
}

fn render_status_bar(
    ctx: &mut FrameCtx<'_>,
    rect: LayoutRect,
    id: WidgetId,
    mode: &Mode,
    document: &Document,
    cursor_world: Option<Point2>,
    paints: &mut Vec<WidgetPaint>,
) {
    if rect.size.width.value() <= 0.0 || rect.size.height.value() <= 0.0 {
        return;
    }
    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 mut items: Vec<StatusItem> = vec![mode_item];
    if let Some(coords) = coords_item {
        items.push(coords);
    }
    items.push(units_item);
    let response = show_status_bar(
        ctx,
        StatusBar::new(id, rect, strings::STATUS_BAR_LABEL, &items),
    );
    paints.extend(response.paint);
}

fn mode_status_label(strings_table: &StringTable, mode: &Mode, document: &Document) -> LabelText {
    match mode {
        Mode::Idle => LabelText::Key(strings::STATUS_READY),
        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) -> LayoutPx {
    let total: f32 = items
        .iter()
        .enumerate()
        .map(|(i, it)| {
            it.width.unwrap_or(fallback_item_size).value()
                + if i == 0 { 0.0 } else { RIBBON_TOOLBAR_GAP_PX }
        })
        .sum();
    LayoutPx::new(total + 2.0 * RIBBON_GROUP_PADDING_PX)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

fn 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 = 96.0;
const DOC_TABS_HEIGHT_PX: f32 = 22.0;
const STATUS_BAR_HEIGHT_PX: f32 = 22.0;

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

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

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

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

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

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

const LEFT_PANE_TAB_STRIP_HEIGHT: f32 = 28.0;

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

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

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

const LEFT_PANE_TAB_WIDTH_PX: f32 = 28.0;

#[derive(Copy, Clone)]
struct LeftPaneTabSpec {
    id: WidgetId,
    label: StringKey,
    glyph: GlyphMark,
    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,
            glyph: GlyphMark::TabTree,
            target: Some(LeftPane::Tree),
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_properties,
            label: strings::PROPERTY_PANE_LABEL,
            glyph: GlyphMark::TabProperties,
            target: Some(LeftPane::Properties),
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_configuration,
            label: strings::LEFT_PANE_TAB_CONFIGURATION,
            glyph: GlyphMark::TabConfiguration,
            target: None,
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_dimension_expert,
            label: strings::LEFT_PANE_TAB_DIMENSION_EXPERT,
            glyph: GlyphMark::TabDimensionExpert,
            target: None,
        },
        LeftPaneTabSpec {
            id: ids.left_pane_tab_display,
            label: strings::LEFT_PANE_TAB_DISPLAY,
            glyph: GlyphMark::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_glyph(spec.glyph)
                    .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,
        GlyphMark::Checkmark,
        strings::CONFIRM_ACCEPT,
        ConfirmTone::Accept,
        paints,
    );
    let cancel_clicked = paint_confirm_button(
        ctx,
        ids.confirm_cancel,
        cancel_rect,
        GlyphMark::Close,
        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,
    glyph: GlyphMark,
    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::Mark {
        rect,
        kind: glyph,
        color: glyph_color,
    });
    interaction.click()
}

fn build_dock_main(panels: ShellPanels) -> DockNode {
    const LEFT_PANE_RATIO: SplitFraction = SplitFraction::clamped(0.22);
    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::StringTable;
    use bone_ui::theme::Theme;
    use bone_ui::widgets::LabelText;
    use std::sync::Arc;

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

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

    fn render_with(theme: Theme, size: LayoutSize, document: &Document, mode: &Mode) -> ShellFrame {
        let mut shell = Shell::new();
        render_into_shell(
            &mut shell,
            theme,
            size,
            document,
            mode,
            &Selection::default(),
        )
    }

    fn render_into_shell(
        shell: &mut Shell,
        theme: Theme,
        size: LayoutSize,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
    ) -> ShellFrame {
        let theme = Arc::new(theme);
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        shell.render(
            &mut ctx,
            document,
            mode,
            selection,
            &Settings::default(),
            size,
            None,
        )
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    fn is_confirm_glyph(paint: &WidgetPaint) -> bool {
        matches!(
            paint,
            WidgetPaint::Mark { kind, .. } if matches!(kind, bone_ui::widgets::GlyphMark::Checkmark | bone_ui::widgets::GlyphMark::Close)
        )
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    fn shell_drive(
        shell: &mut Shell,
        document: &Document,
        mode: &Mode,
        selection: &Selection,
        focus: &mut FocusManager,
        prev: &mut HitState,
        snap: &mut InputSnapshot,
    ) -> (ShellFrame, HitFrame) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = AccessTreeBuilder::new();
        let frame = {
            let mut ctx = FrameCtx::new(
                theme,
                snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            shell.render(
                &mut ctx,
                document,
                mode,
                selection,
                &Settings::default(),
                layout_size(1280.0, 800.0),
                None,
            )
        };
        *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())
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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