use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use bone_document::{
    DimensionKind, DimensionValue, Document, Sketch, SketchDimension, SketchEdit, SketchEntity,
    SketchRelation, SolverError, UndoStack,
};
use bone_render::{
    Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem,
    PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene,
    Style, SurfaceContext, ViewportExtent, ViewportPx,
};
use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchId, Vec2};
use bone_ui::a11y::AccessTreeBuilder;
use bone_ui::focus::FocusManager;
use bone_ui::frame::FrameCtx;
use bone_ui::gallery::{GALLERY_CANVAS, GalleryState, render};
use bone_ui::hit_test::{HitFrame, HitState, resolve};
use bone_ui::hotkey::{ActionId, HotkeyBinding, HotkeyScope, HotkeyScopes, HotkeyTable, KeyChord};
use bone_ui::input::{
    FrameInstant, InputSnapshot, KeyChar, KeyCode as UiKeyCode, KeyEvent as UiKeyEvent,
    ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample,
};
use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use bone_ui::raster::{PngError, encode_png, rasterize};
use bone_ui::strings::StringTable;
use bone_ui::theme::{Theme, ThemeMode};
use bone_ui::{SdfAtlas, SdfAtlasParams, Shaper};
use swash::FontRef;
use tracing_subscriber::EnvFilter;
use uom::si::length::millimeter;
use winit::{
    application::ApplicationHandler,
    dpi::{PhysicalPosition, PhysicalSize},
    event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
    keyboard::{Key, KeyCode, ModifiersState, NamedKey as WinitNamed, PhysicalKey},
    window::{Window, WindowId},
};

mod chrome;
mod dimension_editor;
mod relation_tools;
mod selection;
mod shell;
mod sketch_mode;
mod smart_dimension;
mod snap;
mod strings;
mod tools;

use dimension_editor::{DimensionEditorAction, DimensionEditorOutcome, DimensionEditorState};
use selection::Selection;
use sketch_mode::{
    ClickAnchor, DimensionFlow, DragPins, DragSession, Mode, Pending, PendingDimension, Plane,
    SketchTool,
};
use snap::{Anchor, SnapHit};

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("event loop: {0}")]
    EventLoop(#[from] winit::error::EventLoopError),
    #[error("missing argument for {flag}")]
    MissingArg { flag: &'static str },
    #[error("unknown argument: {0}")]
    UnknownArg(String),
    #[error("encode png ({mode}): {source}")]
    GalleryEncode {
        mode: ThemeMode,
        #[source]
        source: PngError,
    },
    #[error("write {path}: {source}")]
    GalleryWrite {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("create directory {path}: {source}")]
    GalleryDir {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
}

const DEFAULT_LOG_FILTER: &str = "bone_app=info,bone_render=info,bone_document=info,bone_kernel=info,bone_types=info,wgpu_core=warn,wgpu_hal=warn";
const ZOOM_STEP_PER_LINE: f64 = 1.1;
const ZOOM_STEP_PER_PIXEL: f64 = 1.0025;
const ZOOM_KEY_STEP: f64 = 1.25;
const ZOOM_MIN: f64 = 0.01;
const ZOOM_MAX: f64 = 1.0e5;
const INITIAL_ZOOM_PX_PER_MM: f64 = 12.0;
const PAN_STEP_PX: f64 = 40.0;
const PAN_FAST_MULTIPLIER: f64 = 5.0;
const ZOOM_FIT_MARGIN: f64 = 0.9;
const UNDO_CAPACITY: usize = 256;
const SNAP_TOLERANCE_PX: f64 = 8.0;
const SNAP_TOLERANCE_MAX_MM: f64 = 5.0;
const REDRAW_KICKS_AFTER_INTERACTION: u8 = 2;

struct RenderState {
    surface: SurfaceContext,
    renderer: SketchRenderer,
    chrome_pipeline: ChromePipeline,
    text_pipeline: ChromeTextPipeline,
    sdf_atlas: SdfAtlas,
    chrome_shaper: Shaper,
    sans_font: FontRef<'static>,
    mono_font: FontRef<'static>,
    scene: SketchScene,
    camera: Camera2,
    style: Style,
    theme: Arc<Theme>,
    shell: shell::Shell,
    document: Document,
    plane_sketches: BTreeMap<Plane, SketchId>,
    mode: Mode,
    focus: FocusManager,
    hit_state: HitState,
    hotkeys: HotkeyTable,
    strings: StringTable,
    viewport_rect: LayoutRect,
    undo: UndoStack,
    selection: Selection,
    dim_editor: DimensionEditorState,
    dim_editor_bounds: Option<LayoutRect>,
    pending_exit: bool,
    redraw_kicks: u8,
}

#[derive(Default)]
struct InputState {
    cursor_px: Option<PhysicalPosition<f64>>,
    left_pan: bool,
    middle_pan: bool,
    modifiers: ModifiersState,
    pending_pressed: PointerButtonMask,
    pending_released: PointerButtonMask,
    pending_keys: Vec<UiKeyEvent>,
    pending_text: String,
}

impl InputState {
    fn panning(&self) -> bool {
        self.middle_pan || (self.left_pan && self.modifiers.shift_key())
    }

    fn pan_step_px(&self) -> f64 {
        if self.modifiers.shift_key() {
            PAN_STEP_PX * PAN_FAST_MULTIPLIER
        } else {
            PAN_STEP_PX
        }
    }

    fn modifier_mask(&self) -> ModifierMask {
        let bits = [
            (self.modifiers.control_key(), ModifierMask::CTRL),
            (self.modifiers.shift_key(), ModifierMask::SHIFT),
            (self.modifiers.alt_key(), ModifierMask::ALT),
            (self.modifiers.super_key(), ModifierMask::META),
        ];
        bits.iter()
            .filter(|(active, _)| *active)
            .fold(ModifierMask::NONE, |acc, (_, m)| acc.union(*m))
    }

    fn pointer_sample(&self) -> Option<PointerSample> {
        self.cursor_px
            .map(physical_to_layout_pos)
            .map(PointerSample::new)
    }

    fn cursor_in(&self, rect: LayoutRect) -> bool {
        self.cursor_px
            .map(physical_to_layout_pos)
            .is_some_and(|p| rect.contains(p))
    }

    fn drain_snapshot(&mut self) -> InputSnapshot {
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.pointer = self.pointer_sample();
        snap.buttons_pressed =
            core::mem::replace(&mut self.pending_pressed, PointerButtonMask::EMPTY);
        snap.buttons_released =
            core::mem::replace(&mut self.pending_released, PointerButtonMask::EMPTY);
        snap.keys_pressed = core::mem::take(&mut self.pending_keys);
        snap.text_committed = core::mem::take(&mut self.pending_text);
        snap.modifiers = self.modifier_mask();
        snap
    }

    fn forget_pan_state(&mut self) {
        self.cursor_px = None;
        self.left_pan = false;
        self.middle_pan = false;
        self.modifiers = ModifiersState::empty();
    }
}

#[allow(
    clippy::cast_possible_truncation,
    reason = "winit logical px (f64) collapses to LayoutPx (f32) at the sub-pixel limit"
)]
fn physical_to_layout_pos(p: PhysicalPosition<f64>) -> LayoutPos {
    LayoutPos::new(
        LayoutPx::saturating(p.x as f32),
        LayoutPx::saturating(p.y as f32),
    )
}

struct App {
    window: Option<Arc<Window>>,
    render: Option<RenderState>,
    input: InputState,
}

fn default_sketch() -> Sketch {
    let sketch = Sketch::new(Plane::Xy.basis());
    let (sketch, p0) = tools::add_point(sketch, Point2::from_mm(-20.0, -12.5));
    let (sketch, p1) = tools::add_point(sketch, Point2::from_mm(20.0, -12.5));
    let (sketch, p2) = tools::add_point(sketch, Point2::from_mm(20.0, 12.5));
    let (sketch, p3) = tools::add_point(sketch, Point2::from_mm(-20.0, 12.5));
    let (sketch, _) = tools::add_line(sketch, p0, p1, false);
    let (sketch, _) = tools::add_line(sketch, p1, p2, false);
    let (sketch, _) = tools::add_line(sketch, p2, p3, false);
    let (sketch, _) = tools::add_line(sketch, p3, p0, false);
    let (sketch, origin) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
    let (sketch, _) = tools::add_circle(sketch, origin, Length::new::<millimeter>(5.0), false);
    let (sketch, arc_center) = tools::add_point(sketch, Point2::from_mm(-12.0, 0.0));
    let (sketch, arc_start) = tools::add_point(sketch, Point2::from_mm(-8.0, 0.0));
    let (sketch, arc_end) = tools::add_point(sketch, Point2::from_mm(-12.0, 4.0));
    let (sketch, _) = tools::add_arc(sketch, arc_center, arc_start, arc_end, false);
    sketch
}

fn initial_document(sketch: Sketch) -> (Document, SketchId) {
    let mut document = Document::new(DocumentId::default(), "Untitled".to_owned());
    let sketch_id = SketchId::default();
    document.insert_sketch(sketch_id, "Sketch1".to_owned(), sketch);
    (document, sketch_id)
}

fn viewport_extent(size: PhysicalSize<u32>) -> ViewportExtent {
    ViewportExtent::new(
        ViewportPx::new(size.width.max(1)),
        ViewportPx::new(size.height.max(1)),
    )
}

#[allow(
    clippy::cast_precision_loss,
    reason = "viewport pixel counts at any realistic display size fit f32 mantissa"
)]
fn layout_size_from_extent(extent: ViewportExtent) -> LayoutSize {
    LayoutSize::new(
        LayoutPx::new(extent.width().value() as f32),
        LayoutPx::new(extent.height().value() as f32),
    )
}

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

fn zoom_factor(delta: MouseScrollDelta) -> f64 {
    match delta {
        MouseScrollDelta::LineDelta(_, y) => ZOOM_STEP_PER_LINE.powf(f64::from(y)),
        MouseScrollDelta::PixelDelta(p) => ZOOM_STEP_PER_PIXEL.powf(p.y),
    }
}

fn zoom_about(camera: Camera2, cursor: Option<PhysicalPosition<f64>>, factor: f64) -> Camera2 {
    if !factor.is_finite() || factor <= 0.0 {
        return camera;
    }
    let zoom_before = camera.zoom().value();
    let zoom_after = (zoom_before * factor).clamp(ZOOM_MIN, ZOOM_MAX);
    if !zoom_after.is_finite() {
        return camera;
    }
    let extent = camera.extent();
    let w = f64::from(extent.width().value());
    let h = f64::from(extent.height().value());
    let (pan_x, pan_y) = camera.pan_mm().coords_mm();
    let (cursor_x, cursor_y) = cursor.map_or((w * 0.5, h * 0.5), |c| (c.x, c.y));
    let horizontal_px = cursor_x - w * 0.5;
    let vertical_px = h * 0.5 - cursor_y;
    let world_x = pan_x + horizontal_px / zoom_before;
    let world_y = pan_y + vertical_px / zoom_before;
    let new_pan_x = world_x - horizontal_px / zoom_after;
    let new_pan_y = world_y - vertical_px / zoom_after;
    camera
        .with_zoom(PixelsPerMm::new(zoom_after))
        .with_pan(Vec2::from_mm(new_pan_x, new_pan_y))
}

fn armed_sketch_tool(mode: &Mode) -> Option<SketchTool> {
    match mode {
        Mode::Sketch { session, .. } => session.tool,
        Mode::Idle => None,
    }
}

fn dragging_in_sketch(mode: &Mode) -> bool {
    matches!(
        mode,
        Mode::Sketch { session, .. } if session.drag.is_some()
    )
}

fn dim_flow_active(mode: &Mode) -> bool {
    matches!(
        mode,
        Mode::Sketch { session, .. } if session.dim_flow.is_some()
    )
}

fn cursor_to_world(camera: Camera2, cursor: PhysicalPosition<f64>) -> Option<Point2> {
    let extent = camera.extent();
    let w = f64::from(extent.width().value());
    let h = f64::from(extent.height().value());
    let zoom = camera.zoom().value();
    if w <= 0.0 || h <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
        return None;
    }
    let (pan_x, pan_y) = camera.pan_mm().coords_mm();
    Some(Point2::from_mm(
        pan_x + (cursor.x - w * 0.5) / zoom,
        pan_y + (h * 0.5 - cursor.y) / zoom,
    ))
}

fn try_place(state: &mut RenderState, world: Point2) {
    let Mode::Sketch { sketch_id, session } = &state.mode else {
        return;
    };
    let Some(tool) = session.tool else {
        return;
    };
    let prev_pending = session.pending;
    let sketch_id = *sketch_id;
    let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
        return;
    };
    let snap = match tool {
        SketchTool::Line => {
            compute_snap(&sketch, &state.camera, world, latest_anchor(prev_pending))
        }
        _ => compute_endpoint_snap(&sketch, &state.camera, world),
    };
    let (next_sketch, new_pending) = tools::place(sketch, tool, world, prev_pending, snap);
    if let Some(next) = next_sketch {
        state.undo.record(state.document.clone());
        state.document.replace_sketch(sketch_id, next);
        let Some(stored) = state.document.sketch(sketch_id) else {
            return;
        };
        match SketchScene::extract(stored) {
            Ok(scene) => state.scene = scene,
            Err(e) => tracing::warn!(error = %e, "scene extract after place failed"),
        }
    }
    if let Mode::Sketch {
        ref mut session, ..
    } = state.mode
    {
        session.pending = new_pending;
    }
}

const fn latest_anchor(pending: Option<Pending>) -> Option<ClickAnchor> {
    match pending {
        None => None,
        Some(Pending::First(a) | Pending::Second(_, a)) => Some(a),
    }
}

fn pick_at(state: &RenderState, cursor: PhysicalPosition<f64>) -> Option<PickedItem> {
    if !cursor.x.is_finite() || !cursor.y.is_finite() || cursor.x < 0.0 || cursor.y < 0.0 {
        return None;
    }
    let extent = state.surface.extent();
    #[allow(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "cursor px is bounds-checked against surface extent before the cast"
    )]
    let qx = cursor.x.round() as u32;
    #[allow(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "cursor px is bounds-checked against surface extent before the cast"
    )]
    let qy = cursor.y.round() as u32;
    if qx >= extent.width().value() || qy >= extent.height().value() {
        return None;
    }
    let index = match state.scene.pick_index() {
        Ok(i) => i,
        Err(e) => {
            tracing::warn!(error = %e, "build pick index");
            return None;
        }
    };
    let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy));
    match state.surface.picker(index).at(query) {
        Ok(item) => item,
        Err(e) => {
            tracing::warn!(error = %e, "pick failed");
            None
        }
    }
}

fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>, additive: bool) {
    let picked = pick_at(state, cursor);
    let picked_id = match picked {
        Some(
            PickedItem::Point(id)
            | PickedItem::Line(id)
            | PickedItem::Arc(id)
            | PickedItem::Circle(id),
        ) => Some(id),
        Some(_) | None => None,
    };
    state.selection = std::mem::take(&mut state.selection).picked(picked_id, additive);
    if additive || !state.mode.is_sketch() {
        return;
    }
    let Some(entity_id) = picked_id else {
        return;
    };
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let Some(sketch) = state.document.sketch(sketch_id) else {
        return;
    };
    let Some(world) = cursor_to_world(state.camera, cursor) else {
        return;
    };
    let Some(pins) = DragPins::from_sketch_entity(sketch, entity_id) else {
        return;
    };
    let drag = DragSession {
        entity: entity_id,
        press: world,
        pins,
    };
    state.undo.record(state.document.clone());
    state.mode = core::mem::take(&mut state.mode).start_drag(drag);
}

fn try_drag_to(state: &mut RenderState, world: Point2) {
    let Mode::Sketch { sketch_id, session } = &state.mode else {
        return;
    };
    let Some(drag) = session.drag else {
        return;
    };
    let sketch_id = *sketch_id;
    let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
        return;
    };
    let pins = drag.pins.to_targets(drag.press, world);
    let next = match sketch.solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) {
        Ok(solved) => solved,
        Err(e) => {
            tracing::trace!(error = %e, "drag solve failed; falling back to raw move");
            let Some(baseline) = state.document.sketch(sketch_id).cloned() else {
                return;
            };
            let folded = pins.iter().try_fold(baseline, |acc, (id, target)| {
                acc.apply(SketchEdit::MovePoint {
                    id: *id,
                    position: *target,
                })
                .map(|(s, _)| s)
            });
            match folded {
                Ok(s) => s,
                Err(_) => return,
            }
        }
    };
    state.document.replace_sketch(sketch_id, next);
    refresh_active_scene(state);
}

fn refresh_active_scene(state: &mut RenderState) {
    let Some(active_id) = active_sketch_id(&state.mode, &state.plane_sketches) else {
        return;
    };
    let Some(sketch) = state.document.sketch(active_id) else {
        return;
    };
    match SketchScene::extract(sketch) {
        Ok(scene) => state.scene = scene,
        Err(e) => tracing::warn!(error = %e, "refresh active scene failed"),
    }
}

fn active_sketch_id(mode: &Mode, plane_sketches: &BTreeMap<Plane, SketchId>) -> Option<SketchId> {
    match mode {
        Mode::Sketch { sketch_id, .. } => Some(*sketch_id),
        Mode::Idle => plane_sketches.get(&Plane::Xy).copied(),
    }
}

fn build_preview(
    mode: &Mode,
    document: &Document,
    cursor_world: Option<Point2>,
    camera: &Camera2,
) -> SketchPreview {
    let Mode::Sketch { sketch_id, session } = mode else {
        return SketchPreview::empty();
    };
    if session.drag.is_some() || session.dim_flow.is_some() {
        return SketchPreview::empty();
    }
    let Some(tool) = session.tool else {
        return SketchPreview::empty();
    };
    let pending = session.pending;
    let Some(sketch) = document.sketch(*sketch_id) else {
        return SketchPreview::empty();
    };
    let Some(cursor) = cursor_world else {
        return tools::preview_anchors_only(sketch, pending);
    };
    let snap = match tool {
        SketchTool::Line => compute_snap(sketch, camera, cursor, latest_anchor(pending)),
        _ => compute_endpoint_snap(sketch, camera, cursor),
    };
    tools::preview(sketch, tool, cursor, pending, snap)
}

fn snap_tolerance(camera: &Camera2) -> Option<Length> {
    let mm_per_px = camera.world_mm_per_pixel();
    if !mm_per_px.is_finite() || mm_per_px <= 0.0 {
        return None;
    }
    let tol_mm = (SNAP_TOLERANCE_PX * mm_per_px).min(SNAP_TOLERANCE_MAX_MM);
    Some(Length::new::<millimeter>(tol_mm))
}

fn compute_snap(
    sketch: &Sketch,
    camera: &Camera2,
    cursor_world: Point2,
    click: Option<ClickAnchor>,
) -> Option<SnapHit> {
    snap::detect(
        cursor_world,
        click.and_then(|c| resolve_anchor(Some(sketch), c)),
        sketch,
        snap_tolerance(camera)?,
    )
}

fn compute_endpoint_snap(
    sketch: &Sketch,
    camera: &Camera2,
    cursor_world: Point2,
) -> Option<SnapHit> {
    snap::detect_endpoint_only(cursor_world, sketch, snap_tolerance(camera)?)
}

fn resolve_anchor(sketch: Option<&Sketch>, click: ClickAnchor) -> Option<Anchor> {
    match click {
        ClickAnchor::Position(pos) | ClickAnchor::MidpointOf { position: pos, .. } => {
            Some(Anchor { pos, id: None })
        }
        ClickAnchor::Endpoint(id) => match sketch?.entities().get(id)? {
            SketchEntity::Point(p) => Some(Anchor {
                pos: p.at(),
                id: Some(id),
            }),
            _ => None,
        },
    }
}

fn pan_by_px(camera: Camera2, horizontal_px: f64, vertical_px: f64) -> Camera2 {
    let mm_per_px = camera.world_mm_per_pixel();
    let (pan_x, pan_y) = camera.pan_mm().coords_mm();
    camera.with_pan(Vec2::from_mm(
        pan_x - horizontal_px * mm_per_px,
        pan_y + vertical_px * mm_per_px,
    ))
}

fn zoom_fit(camera: Camera2, scene: &SketchScene, viewport: LayoutRect) -> Camera2 {
    let Some(aabb) = scene.aabb() else {
        return camera;
    };
    let (mnx, mny) = aabb.min().coords_mm();
    let (mxx, mxy) = aabb.max().coords_mm();
    let center_x = (mnx + mxx) * 0.5;
    let center_y = (mny + mxy) * 0.5;
    let world_w = mxx - mnx;
    let world_h = mxy - mny;
    let v_w = f64::from(viewport.size.width.value());
    let v_h = f64::from(viewport.size.height.value());
    if v_w <= 0.0 || v_h <= 0.0 {
        return camera;
    }
    let axis_zoom = |pixels: f64, world: f64| {
        if world > 0.0 {
            pixels / world
        } else {
            f64::INFINITY
        }
    };
    let raw_zoom = axis_zoom(v_w, world_w).min(axis_zoom(v_h, world_h)) * ZOOM_FIT_MARGIN;
    let zoom = raw_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
    let pan = pan_centering((center_x, center_y), camera.extent(), viewport, zoom);
    camera.with_zoom(PixelsPerMm::new(zoom)).with_pan(pan)
}

fn pan_centering(
    target_world: (f64, f64),
    window: ViewportExtent,
    viewport: LayoutRect,
    zoom: f64,
) -> Vec2 {
    let v_w = f64::from(viewport.size.width.value());
    let v_h = f64::from(viewport.size.height.value());
    let viewport_center_x = f64::from(viewport.origin.x.value()) + v_w * 0.5;
    let viewport_center_y = f64::from(viewport.origin.y.value()) + v_h * 0.5;
    let window_center_x = f64::from(window.width().value()) * 0.5;
    let window_center_y = f64::from(window.height().value()) * 0.5;
    let pan_x = target_world.0 - (viewport_center_x - window_center_x) / zoom;
    let pan_y = target_world.1 + (viewport_center_y - window_center_y) / zoom;
    Vec2::from_mm(pan_x, pan_y)
}

enum KeyAction {
    Exit,
    Camera(Camera2),
}

fn keyboard_action(code: KeyCode, input: &InputState, state: &RenderState) -> Option<KeyAction> {
    match (code, input.modifiers.control_key()) {
        (KeyCode::KeyQ, true) => Some(KeyAction::Exit),
        _ => keyboard_camera(code, input, state).map(KeyAction::Camera),
    }
}

fn keyboard_camera(code: KeyCode, input: &InputState, state: &RenderState) -> Option<Camera2> {
    if input.modifiers.control_key() || input.modifiers.super_key() {
        return None;
    }
    let camera = state.camera;
    let step = input.pan_step_px();
    let shift = input.modifiers.shift_key();
    match code {
        KeyCode::ArrowLeft => Some(pan_by_px(camera, step, 0.0)),
        KeyCode::ArrowRight => Some(pan_by_px(camera, -step, 0.0)),
        KeyCode::ArrowUp => Some(pan_by_px(camera, 0.0, step)),
        KeyCode::ArrowDown => Some(pan_by_px(camera, 0.0, -step)),
        KeyCode::KeyF => Some(zoom_fit(camera, &state.scene, state.viewport_rect)),
        KeyCode::KeyZ => Some(zoom_about(
            camera,
            input.cursor_px,
            if shift {
                1.0 / ZOOM_KEY_STEP
            } else {
                ZOOM_KEY_STEP
            },
        )),
        KeyCode::Equal => Some(zoom_about(camera, input.cursor_px, ZOOM_KEY_STEP)),
        KeyCode::Minus => Some(zoom_about(camera, input.cursor_px, 1.0 / ZOOM_KEY_STEP)),
        _ => None,
    }
}

fn build_hotkey_table() -> HotkeyTable {
    let bindings = vec![
        HotkeyBinding::new(
            KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE),
            HotkeyScope::Sketch,
            sketch_mode::EXIT_SKETCH_ACTION,
        ),
        HotkeyBinding::new(
            KeyChord::new(UiKeyCode::Char(KeyChar::from_char('z')), ModifierMask::CTRL),
            HotkeyScope::Global,
            sketch_mode::UNDO_ACTION,
        ),
        HotkeyBinding::new(
            KeyChord::new(
                UiKeyCode::Char(KeyChar::from_char('z')),
                ModifierMask::CTRL.union(ModifierMask::SHIFT),
            ),
            HotkeyScope::Global,
            sketch_mode::REDO_ACTION,
        ),
        HotkeyBinding::new(
            KeyChord::new(UiKeyCode::Char(KeyChar::from_char('y')), ModifierMask::CTRL),
            HotkeyScope::Global,
            sketch_mode::REDO_ACTION,
        ),
    ];
    let Ok(table) = HotkeyTable::try_from_bindings(bindings) else {
        unreachable!("hotkey bindings are conflict-free");
    };
    table
}

fn scopes_for_mode(mode: &Mode) -> HotkeyScopes {
    let mut scopes = HotkeyScopes::from_outer_to_inner([HotkeyScope::Global]);
    if mode.is_sketch() {
        scopes.push(HotkeyScope::Sketch);
    }
    scopes
}

fn next_mode(
    mode: Mode,
    frame: &shell::ShellFrame,
    actions: &[ActionId],
    plane_sketches: &BTreeMap<Plane, SketchId>,
) -> Mode {
    let pick = frame
        .plane_picked
        .filter(|_| !mode.is_sketch())
        .and_then(|plane| plane_sketches.get(&plane).copied());
    let after_pick = pick.map_or(mode, Mode::enter_sketch);
    let escape = actions.contains(&sketch_mode::EXIT_SKETCH_ACTION);
    let after_escape = if escape {
        cancel_pending_or_exit(after_pick)
    } else {
        after_pick
    };
    let after_exit = if frame.exit_sketch {
        Mode::Idle
    } else {
        after_escape
    };
    match frame.activated_tool {
        Some(t) => toggle_or_arm(after_exit, t),
        None => after_exit,
    }
}

fn toggle_or_arm(mode: Mode, tool: SketchTool) -> Mode {
    let already_active = matches!(
        &mode,
        Mode::Sketch { session, .. } if session.tool == Some(tool)
    );
    if already_active {
        mode.disarm_tool()
    } else {
        mode.arm_tool(tool)
    }
}

fn cancel_pending_or_exit(mode: Mode) -> Mode {
    match &mode {
        Mode::Sketch { session, .. } if session.pending.is_some() => mode.clear_pending(),
        Mode::Sketch { session, .. } if session.tool.is_some() => mode.disarm_tool(),
        Mode::Sketch { .. } | Mode::Idle => Mode::Idle,
    }
}

impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        if self.window.is_some() {
            return;
        }
        let attrs = Window::default_attributes().with_title("bone");
        let window = match event_loop.create_window(attrs) {
            Ok(w) => Arc::new(w),
            Err(e) => {
                tracing::error!(error = %e, "create_window failed");
                event_loop.exit();
                return;
            }
        };
        let extent = viewport_extent(window.inner_size());
        let surface = match pollster::block_on(SurfaceContext::new(window.clone(), extent)) {
            Ok(s) => s,
            Err(e) => {
                tracing::error!(error = %e, "SurfaceContext::new failed");
                event_loop.exit();
                return;
            }
        };
        let renderer = SketchRenderer::new(surface.gpu(), surface.color_format());
        let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format());
        let sdf_atlas = SdfAtlas::new(SdfAtlasParams::STANDARD);
        let text_pipeline =
            ChromeTextPipeline::new(surface.gpu(), surface.color_format(), sdf_atlas.extent());
        let chrome_shaper = Shaper::new();
        let sans_font = bone_text::load_font(bone_text::FontFace::Sans);
        let mono_font = bone_text::load_font(bone_text::FontFace::Mono);
        let sketch = default_sketch();
        let scene = match SketchScene::extract(&sketch) {
            Ok(s) => s,
            Err(e) => {
                tracing::error!(error = %e, "scene extract failed");
                event_loop.exit();
                return;
            }
        };
        let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM));
        let style = Style::default();
        let theme = Arc::new(Theme::light());
        let shell = match shell::Shell::new() {
            Ok(s) => s,
            Err(e) => {
                tracing::error!(error = %e, "Shell::new failed");
                event_loop.exit();
                return;
            }
        };
        let (document, sketch_id) = initial_document(sketch);
        let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]);
        let strings = strings::make_strings(bone_ui::strings::Locale::EnUs);
        let viewport_rect = empty_rect();
        let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else {
            unreachable!("UNDO_CAPACITY constant is non-zero");
        };
        window.request_redraw();
        self.window = Some(window);
        self.render = Some(RenderState {
            surface,
            renderer,
            chrome_pipeline,
            text_pipeline,
            sdf_atlas,
            chrome_shaper,
            sans_font,
            mono_font,
            scene,
            camera,
            style,
            theme,
            shell,
            document,
            plane_sketches,
            mode: Mode::Idle,
            focus: FocusManager::new(),
            hit_state: HitState::new(),
            hotkeys: build_hotkey_table(),
            strings,
            viewport_rect,
            undo: UndoStack::with_capacity(undo_capacity),
            selection: Selection::default(),
            dim_editor: DimensionEditorState::default(),
            dim_editor_bounds: None,
            pending_exit: false,
            redraw_kicks: 0,
        });
    }

    #[allow(
        clippy::too_many_lines,
        reason = "winit event dispatch is a flat match by event variant; splitting obscures the dispatch table"
    )]
    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
        let (Some(state), Some(window)) = (self.render.as_mut(), self.window.as_ref()) else {
            return;
        };
        match event {
            WindowEvent::CloseRequested => event_loop.exit(),
            WindowEvent::Resized(size) => {
                let extent = viewport_extent(size);
                state.surface.resize(extent);
                state.camera = state.camera.with_extent(extent);
                state.viewport_rect = empty_rect();
                window.request_redraw();
            }
            WindowEvent::RedrawRequested => {
                render_frame(state, window, &mut self.input);
                if state.pending_exit {
                    event_loop.exit();
                }
            }
            WindowEvent::Focused(false) => self.input.forget_pan_state(),
            WindowEvent::ModifiersChanged(mods) => {
                self.input.modifiers = mods.state();
            }
            WindowEvent::CursorMoved { position, .. } => {
                let prev = self.input.cursor_px;
                self.input.cursor_px = Some(position);
                if self.input.panning()
                    && let Some(p) = prev
                {
                    state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y);
                } else if dragging_in_sketch(&state.mode)
                    && let Some(world) = cursor_to_world(state.camera, position)
                {
                    try_drag_to(state, world);
                }
                window.request_redraw();
            }
            WindowEvent::CursorLeft { .. } => {
                self.input.cursor_px = None;
            }
            WindowEvent::MouseInput {
                state: btn_state,
                button: MouseButton::Left,
                ..
            } => {
                if btn_state == ElementState::Pressed {
                    let in_viewport = self.input.cursor_in(state.viewport_rect);
                    let over_dim_editor = state
                        .dim_editor_bounds
                        .is_some_and(|r| self.input.cursor_in(r));
                    let dim_active = dim_flow_active(&state.mode);
                    self.input.left_pan = in_viewport && !over_dim_editor && !dim_active;
                    self.input.pending_pressed =
                        self.input.pending_pressed.with(PointerButton::Primary);
                    if in_viewport
                        && !over_dim_editor
                        && !dim_active
                        && !self.input.modifiers.shift_key()
                        && let Some(cursor) = self.input.cursor_px
                    {
                        if armed_sketch_tool(&state.mode).is_some() {
                            if let Some(world) = cursor_to_world(state.camera, cursor) {
                                try_place(state, world);
                            }
                        } else {
                            let additive = self.input.modifiers.control_key()
                                || self.input.modifiers.super_key();
                            handle_viewport_click(state, cursor, additive);
                        }
                    }
                } else {
                    self.input.left_pan = false;
                    state.mode = core::mem::take(&mut state.mode).end_drag();
                    self.input.pending_released =
                        self.input.pending_released.with(PointerButton::Primary);
                }
                window.request_redraw();
            }
            WindowEvent::MouseInput {
                state: btn_state,
                button: MouseButton::Right,
                ..
            } => {
                if btn_state == ElementState::Pressed {
                    self.input.pending_pressed =
                        self.input.pending_pressed.with(PointerButton::Secondary);
                    if self.input.cursor_in(state.viewport_rect) {
                        state.mode = core::mem::take(&mut state.mode).clear_pending();
                    }
                } else {
                    self.input.pending_released =
                        self.input.pending_released.with(PointerButton::Secondary);
                }
                window.request_redraw();
            }
            WindowEvent::MouseInput {
                state: btn_state,
                button: MouseButton::Middle,
                ..
            } => {
                if btn_state == ElementState::Pressed {
                    self.input.middle_pan = self.input.cursor_in(state.viewport_rect);
                    self.input.pending_pressed =
                        self.input.pending_pressed.with(PointerButton::Middle);
                } else {
                    self.input.middle_pan = false;
                    self.input.pending_released =
                        self.input.pending_released.with(PointerButton::Middle);
                }
            }
            WindowEvent::MouseWheel { delta, .. } if self.input.cursor_in(state.viewport_rect) => {
                state.camera = zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta));
                window.request_redraw();
            }
            WindowEvent::KeyboardInput {
                event:
                    KeyEvent {
                        physical_key,
                        logical_key,
                        state: ElementState::Pressed,
                        text,
                        ..
                    },
                ..
            } => {
                let physical_code = match physical_key {
                    PhysicalKey::Code(c) => Some(c),
                    PhysicalKey::Unidentified(_) => None,
                };
                let logical_named = match &logical_key {
                    Key::Named(nk) => winit_named_to_ui(*nk),
                    _ => None,
                };
                let physical_named = physical_code.and_then(keycode_to_named);
                let named = logical_named.or(physical_named);
                let mods = self.input.modifier_mask();
                if let Some(named) = named {
                    self.input
                        .pending_keys
                        .push(UiKeyEvent::new(UiKeyCode::Named(named), mods));
                } else if let Some(c) = physical_code.and_then(keycode_to_char) {
                    self.input.pending_keys.push(UiKeyEvent::new(
                        UiKeyCode::Char(KeyChar::from_char(c)),
                        mods,
                    ));
                }
                if let Some(typed) = text.as_ref() {
                    let filtered: String = typed.chars().filter(|c| !c.is_control()).collect();
                    if !filtered.is_empty() {
                        self.input.pending_text.push_str(&filtered);
                    }
                }
                let suppress_camera = dim_flow_active(&state.mode);
                if let Some(code) = physical_code {
                    match keyboard_action(code, &self.input, state) {
                        Some(KeyAction::Exit) => event_loop.exit(),
                        Some(KeyAction::Camera(next)) if !suppress_camera => {
                            state.camera = next;
                        }
                        Some(KeyAction::Camera(_)) | None => {}
                    }
                }
                window.request_redraw();
            }
            _ => {}
        }
    }
}

#[allow(
    clippy::cast_precision_loss,
    reason = "viewport pixel counts at any realistic display size fit f32 mantissa"
)]
fn render_frame(state: &mut RenderState, window: &Window, input_state: &mut InputState) {
    let extent = state.surface.extent();
    let layout_size = layout_size_from_extent(extent);
    let theme = Arc::clone(&state.theme);
    let mut input = input_state.drain_snapshot();
    let mut hits = HitFrame::new();
    let mut a11y = AccessTreeBuilder::new();
    let scopes = scopes_for_mode(&state.mode);
    let (mut frame, hotkey_actions, dim_outcome, conflict_outcome) = run_frame_ui(
        state,
        theme,
        &mut input,
        &mut hits,
        &mut a11y,
        &scopes,
        layout_size,
    );
    state.dim_editor_bounds = apply_popup_overlays(
        &mut frame.overlay_paints,
        dim_outcome.as_ref(),
        conflict_outcome.as_ref(),
    );
    let claimed_pointer = dim_outcome.as_ref().is_some_and(|o| o.claimed_pointer);
    let frame = if claimed_pointer {
        suppress_pointer_activations(frame)
    } else {
        frame
    };
    state.viewport_rect = frame.viewport_rect;
    apply_resolve_and_kick_redraws(state, &hits, &input);
    if let Some(plane) = frame.plane_picked {
        match (
            state.mode.is_sketch(),
            state.plane_sketches.contains_key(&plane),
        ) {
            (true, _) => {
                tracing::debug!(?plane, "plane pick ignored: already in sketch mode");
            }
            (false, false) => {
                tracing::debug!(?plane, "plane pick ignored: no sketch on this plane");
            }
            (false, true) => {}
        }
    }
    state.mode = next_mode(
        core::mem::take(&mut state.mode),
        &frame,
        &hotkey_actions,
        &state.plane_sketches,
    );
    apply_dimension_outcome(state, dim_outcome);
    apply_dim_conflict_outcome(state, conflict_outcome);
    apply_dimension_request(state, frame.activated_dimension);
    apply_undo_actions(state, &hotkey_actions);
    apply_menu_action(state, frame.menu_action);
    apply_relation_action(state, frame.activated_relation);
    let cursor_world = input_state
        .cursor_px
        .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c)))
        .and_then(|c| cursor_to_world(state.camera, c));
    let preview = build_preview(&state.mode, &state.document, cursor_world, &state.camera);
    let main_layer = build_chrome_layer(state, &frame.paints);
    let overlay_layer = build_chrome_layer(state, &frame.overlay_paints);
    let atlas_pixels = state.sdf_atlas.pixels();
    let atlas_version = state.sdf_atlas.version();
    let viewport_px = [
        extent.width().value() as f32,
        extent.height().value() as f32,
    ];
    let surface = &mut state.surface;
    let renderer = &mut state.renderer;
    let mut chrome_stage = ChromeStage {
        chrome: &mut state.chrome_pipeline,
        text: &mut state.text_pipeline,
        atlas_pixels,
        atlas_version,
        viewport_px,
    };
    let scene = &state.scene;
    let camera = state.camera;
    let style = &state.style;
    renderer.prepare(scene, style);
    surface.render(
        |encoder, color, pick| {
            renderer.encode_passes(
                encoder,
                RenderTargets::new(color, pick),
                scene,
                &preview,
                camera,
                style,
            );
            chrome_stage.encode_layered(encoder, color, &main_layer, &overlay_layer);
        },
        || window.pre_present_notify(),
    );
    if state.redraw_kicks > 0 {
        state.redraw_kicks -= 1;
        window.request_redraw();
    }
}

struct ChromeStage<'a> {
    chrome: &'a mut ChromePipeline,
    text: &'a mut ChromeTextPipeline,
    atlas_pixels: &'a [u8],
    atlas_version: u64,
    viewport_px: [f32; 2],
}

impl ChromeStage<'_> {
    fn encode_layered(
        &mut self,
        encoder: &mut wgpu::CommandEncoder,
        color: &wgpu::TextureView,
        main: &ChromeLayer,
        overlay: &ChromeLayer,
    ) {
        let combined_chrome: Vec<ChromeInstance> = main
            .chrome
            .iter()
            .chain(overlay.chrome.iter())
            .copied()
            .collect();
        let combined_glyphs: Vec<SdfGlyphInstance> = main
            .glyphs
            .iter()
            .chain(overlay.glyphs.iter())
            .copied()
            .collect();
        let Ok(main_chrome) = u32::try_from(main.chrome.len()) else {
            unreachable!("chrome instance count fits in u32")
        };
        let Ok(total_chrome) = u32::try_from(combined_chrome.len()) else {
            unreachable!("chrome instance count fits in u32")
        };
        let Ok(main_glyphs) = u32::try_from(main.glyphs.len()) else {
            unreachable!("glyph instance count fits in u32")
        };
        let Ok(total_glyphs) = u32::try_from(combined_glyphs.len()) else {
            unreachable!("glyph instance count fits in u32")
        };
        self.chrome.upload(self.viewport_px, &combined_chrome);
        self.text.upload(
            self.viewport_px,
            self.atlas_pixels,
            self.atlas_version,
            &combined_glyphs,
        );
        self.chrome.draw_range(encoder, color, 0..main_chrome);
        self.text.draw_range(encoder, color, 0..main_glyphs);
        self.chrome
            .draw_range(encoder, color, main_chrome..total_chrome);
        self.text
            .draw_range(encoder, color, main_glyphs..total_glyphs);
    }
}

fn apply_resolve_and_kick_redraws(state: &mut RenderState, hits: &HitFrame, input: &InputSnapshot) {
    state.hit_state = resolve(&state.hit_state, hits, input, state.focus.focused());
    if any_actionable_interaction(&state.hit_state) {
        state.redraw_kicks = state.redraw_kicks.max(REDRAW_KICKS_AFTER_INTERACTION);
    }
}

fn any_actionable_interaction(hit_state: &HitState) -> bool {
    use bone_ui::hit_test::InteractionState;
    hit_state.interactions.values().any(|i| {
        i.state.contains(InteractionState::CLICK)
            || i.state.contains(InteractionState::DOUBLE_CLICK)
            || i.state.contains(InteractionState::DRAG_START)
            || i.state.contains(InteractionState::DRAG_RELEASE)
    })
}

struct ChromeLayer {
    chrome: Vec<ChromeInstance>,
    glyphs: Vec<SdfGlyphInstance>,
}

fn build_chrome_layer(
    state: &mut RenderState,
    paints: &[bone_ui::widgets::WidgetPaint],
) -> ChromeLayer {
    let chrome = chrome::paint_to_instances(&state.theme, paints);
    let spans = chrome::paint_to_text_spans(paints, &state.strings);
    let glyphs = chrome::build_glyph_instances(
        &spans,
        &mut state.sdf_atlas,
        &mut state.chrome_shaper,
        &state.sans_font,
        &state.mono_font,
    );
    ChromeLayer { chrome, glyphs }
}

fn apply_undo_actions(state: &mut RenderState, actions: &[ActionId]) {
    if actions.contains(&sketch_mode::UNDO_ACTION) && state.undo.undo(&mut state.document) {
        refresh_active_scene(state);
    }
    if actions.contains(&sketch_mode::REDO_ACTION) && state.undo.redo(&mut state.document) {
        refresh_active_scene(state);
    }
}

fn suppress_pointer_activations(frame: shell::ShellFrame) -> shell::ShellFrame {
    shell::ShellFrame {
        paints: frame.paints,
        overlay_paints: frame.overlay_paints,
        viewport_rect: frame.viewport_rect,
        activated_tool: None,
        activated_relation: None,
        activated_dimension: None,
        plane_picked: None,
        exit_sketch: false,
        menu_action: None,
    }
}

fn apply_popup_overlays(
    overlay: &mut Vec<bone_ui::widgets::WidgetPaint>,
    dim_outcome: Option<&DimensionEditorOutcome>,
    conflict_outcome: Option<&DimConflictOutcome>,
) -> Option<LayoutRect> {
    let dim_closing = matches!(
        dim_outcome.map(|o| &o.action),
        Some(DimensionEditorAction::Commit(_) | DimensionEditorAction::Cancel),
    );
    extend_when_open(
        overlay,
        dim_outcome.map(|o| o.paints.as_slice()),
        dim_closing,
    );
    let conflict_closing = matches!(
        conflict_outcome.map(|o| o.action),
        Some(DimConflictAction::MakeDriven | DimConflictAction::Cancel),
    );
    extend_when_open(
        overlay,
        conflict_outcome.map(|o| o.paints.as_slice()),
        conflict_closing,
    );
    if dim_closing {
        None
    } else {
        dim_outcome.map(|o| o.bounds)
    }
}

fn extend_when_open(
    overlay: &mut Vec<bone_ui::widgets::WidgetPaint>,
    paints: Option<&[bone_ui::widgets::WidgetPaint]>,
    closing: bool,
) {
    if let Some(p) = paints
        && !closing
    {
        overlay.extend(p.iter().cloned());
    }
}

fn run_frame_ui(
    state: &mut RenderState,
    theme: Arc<Theme>,
    input: &mut InputSnapshot,
    hits: &mut HitFrame,
    a11y: &mut AccessTreeBuilder,
    scopes: &HotkeyScopes,
    layout_size: LayoutSize,
) -> (
    shell::ShellFrame,
    Vec<ActionId>,
    Option<DimensionEditorOutcome>,
    Option<DimConflictOutcome>,
) {
    let mut ctx = FrameCtx::new(
        theme,
        input,
        &mut state.focus,
        &state.hotkeys,
        &state.strings,
        hits,
        &state.hit_state,
        a11y,
        &mut state.chrome_shaper,
    );
    let frame = state.shell.render(
        &mut ctx,
        &state.document,
        &state.mode,
        state.selection.ids(),
        layout_size,
    );
    let dim_outcome = pending_dim(&state.mode).map(|pending| {
        let live_anchor = match state.mode {
            Mode::Sketch { sketch_id, .. } => state
                .document
                .sketch(sketch_id)
                .and_then(|s| smart_dimension::live_anchor(s, pending.proto))
                .unwrap_or(pending.anchor),
            Mode::Idle => pending.anchor,
        };
        dimension_editor::render(
            &mut ctx,
            pending,
            live_anchor,
            &state.camera,
            frame.viewport_rect,
            &mut state.dim_editor,
        )
    });
    let conflict_outcome =
        dim_conflict_pending(&state.mode).map(|_| render_dim_conflict_modal(&mut ctx, layout_size));
    let actions = if conflict_outcome.is_some() || dim_outcome.is_some() {
        Vec::new()
    } else {
        ctx.dispatch_hotkeys(scopes)
    };
    (frame, actions, dim_outcome, conflict_outcome)
}

fn dim_conflict_pending(mode: &Mode) -> Option<PendingDimension> {
    match mode {
        Mode::Sketch { session, .. } => match session.dim_flow {
            Some(DimensionFlow::Conflict(p)) => Some(p),
            Some(DimensionFlow::Editing(_)) | None => None,
        },
        Mode::Idle => None,
    }
}

#[derive(Clone, Debug, PartialEq)]
struct DimConflictOutcome {
    paints: Vec<bone_ui::widgets::WidgetPaint>,
    action: DimConflictAction,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum DimConflictAction {
    Idle,
    MakeDriven,
    Cancel,
}

fn render_dim_conflict_modal(
    ctx: &mut FrameCtx<'_>,
    layout_size: LayoutSize,
) -> DimConflictOutcome {
    use bone_ui::widgets::{ConfirmationDialog, ConfirmationOutcome, show_confirmation};
    use bone_ui::{WidgetId, WidgetKey};
    let viewport = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), layout_size);
    let dialog_size = LayoutSize::new(LayoutPx::new(440.0), LayoutPx::new(180.0));
    let id = WidgetId::ROOT.child(WidgetKey::new("dim.conflict"));
    let response = show_confirmation(
        ctx,
        ConfirmationDialog {
            id,
            viewport,
            size: dialog_size,
            title: strings::DIM_CONFLICT_TITLE,
            message: strings::DIM_CONFLICT_MESSAGE,
            confirm_label: strings::DIM_CONFLICT_MAKE_DRIVEN,
            cancel_label: strings::DIM_CONFLICT_CANCEL,
            destructive: false,
        },
    );
    let action = match response.outcome {
        Some(ConfirmationOutcome::Confirm) => DimConflictAction::MakeDriven,
        Some(ConfirmationOutcome::Cancel) => DimConflictAction::Cancel,
        None => DimConflictAction::Idle,
    };
    DimConflictOutcome {
        paints: response.paint,
        action,
    }
}

fn pending_dim(mode: &Mode) -> Option<PendingDimension> {
    match mode {
        Mode::Sketch { session, .. } => match session.dim_flow {
            Some(DimensionFlow::Editing(p)) => Some(p),
            Some(DimensionFlow::Conflict(_)) | None => None,
        },
        Mode::Idle => None,
    }
}

fn apply_dimension_request(state: &mut RenderState, request: Option<PendingDimension>) {
    let Some(request) = request else { return };
    let Mode::Sketch { .. } = state.mode else {
        return;
    };
    state.mode = core::mem::take(&mut state.mode).start_dimension(request);
    state.selection = Selection::default();
}

fn apply_dimension_outcome(state: &mut RenderState, outcome: Option<DimensionEditorOutcome>) {
    let Some(outcome) = outcome else { return };
    let Some(pending) = pending_dim(&state.mode) else {
        return;
    };
    match outcome.action {
        DimensionEditorAction::Idle => {}
        DimensionEditorAction::Cancel => {
            state.mode = core::mem::take(&mut state.mode).cancel_dimension();
            state.dim_editor.close();
        }
        DimensionEditorAction::Swap(next_proto) => {
            state.mode = core::mem::take(&mut state.mode).start_dimension(PendingDimension {
                proto: next_proto,
                anchor: pending.anchor,
            });
        }
        DimensionEditorAction::Commit(value) => {
            commit_pending_dimension(state, pending, value);
        }
    }
}

fn apply_dim_conflict_outcome(state: &mut RenderState, outcome: Option<DimConflictOutcome>) {
    let Some(outcome) = outcome else { return };
    let Some(pending) = dim_conflict_pending(&state.mode) else {
        return;
    };
    match outcome.action {
        DimConflictAction::Idle => {}
        DimConflictAction::Cancel => {
            state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
        }
        DimConflictAction::MakeDriven => {
            confirm_dim_conflict_make_driven(state, pending);
        }
    }
}

fn commit_pending_dimension(
    state: &mut RenderState,
    pending: PendingDimension,
    value: DimensionValue,
) {
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
        return;
    };
    let proto = match pending.proto.with_value(value) {
        Ok(p) => p,
        Err(e) => {
            tracing::warn!(error = %e, ?pending.proto, ?value, "dimension value type mismatch");
            return;
        }
    };
    let after_add = match sketch.clone().apply(SketchEdit::AddDimension(proto)) {
        Ok((next, _)) => next,
        Err(e) => {
            tracing::warn!(error = %e, ?proto, "add dimension failed");
            return;
        }
    };
    let solved = match after_add.solve() {
        Ok(s) => s,
        Err(SolverError::OverDefined { .. }) => {
            state.mode = core::mem::take(&mut state.mode).start_dim_conflict(PendingDimension {
                proto,
                anchor: pending.anchor,
            });
            state.dim_editor.close();
            return;
        }
        Err(e) => {
            tracing::warn!(error = %e, "solve after add dim did not converge; rejecting");
            return;
        }
    };
    state.undo.record(state.document.clone());
    state.document.replace_sketch(sketch_id, solved);
    state.mode = core::mem::take(&mut state.mode).cancel_dimension();
    state.dim_editor.close();
    refresh_active_scene(state);
}

fn confirm_dim_conflict_make_driven(state: &mut RenderState, pending: PendingDimension) {
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
        return;
    };
    let Some(measured) = sketch.measure(pending.proto) else {
        tracing::warn!(?pending.proto, "measure failed for driven conversion; aborting");
        state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
        return;
    };
    let Some(driven_proto) = driven_with_value(pending.proto, measured) else {
        tracing::warn!(?pending.proto, "driven conversion failed");
        state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
        return;
    };
    let after_add = match sketch.apply(SketchEdit::AddDimension(driven_proto)) {
        Ok((next, _)) => next,
        Err(e) => {
            tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed");
            state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
            return;
        }
    };
    let solved = after_add.clone().solve().unwrap_or(after_add);
    state.undo.record(state.document.clone());
    state.document.replace_sketch(sketch_id, solved);
    state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
    refresh_active_scene(state);
}

fn driven_with_value(proto: SketchDimension, value: DimensionValue) -> Option<SketchDimension> {
    proto
        .with_kind(DimensionKind::Driven)
        .with_value(value)
        .ok()
}

fn apply_relation_action(state: &mut RenderState, relation: Option<SketchRelation>) {
    let Some(relation) = relation else { return };
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
        return;
    };
    let next = match sketch.apply(SketchEdit::AddRelation(relation)) {
        Ok((next, _)) => next,
        Err(e) => {
            tracing::warn!(error = %e, ?relation, "add relation failed");
            return;
        }
    };
    state.undo.record(state.document.clone());
    state.document.replace_sketch(sketch_id, next);
    state.selection = Selection::default();
    refresh_active_scene(state);
}

fn apply_menu_action(state: &mut RenderState, action: Option<shell::MenuAction>) {
    match action {
        Some(shell::MenuAction::Quit) => {
            state.pending_exit = true;
        }
        Some(shell::MenuAction::Undo) if state.undo.undo(&mut state.document) => {
            refresh_active_scene(state);
        }
        Some(shell::MenuAction::Redo) if state.undo.redo(&mut state.document) => {
            refresh_active_scene(state);
        }
        Some(shell::MenuAction::ZoomFit) => {
            state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect);
        }
        Some(shell::MenuAction::Undo | shell::MenuAction::Redo) | None => {}
    }
}

enum RunMode {
    Window,
    Gallery(PathBuf),
}

fn parse_mode(args: impl IntoIterator<Item = String>) -> Result<RunMode, AppError> {
    let collected: Vec<String> = args.into_iter().skip(1).collect();
    parse_args(&collected, RunMode::Window)
}

fn parse_args(args: &[String], acc: RunMode) -> Result<RunMode, AppError> {
    match args {
        [] => Ok(acc),
        [first, second, rest @ ..] if first == "--gallery" => {
            parse_args(rest, RunMode::Gallery(PathBuf::from(second)))
        }
        [only] if only == "--gallery" => Err(AppError::MissingArg {
            flag: "--gallery <out-dir>",
        }),
        [unknown, ..] => Err(AppError::UnknownArg(unknown.clone())),
    }
}

fn emit_gallery(out_dir: &Path) -> Result<(), AppError> {
    std::fs::create_dir_all(out_dir).map_err(|e| AppError::GalleryDir {
        path: out_dir.to_path_buf(),
        source: e,
    })?;
    [
        (ThemeMode::Light, Theme::light()),
        (ThemeMode::Dark, Theme::dark()),
    ]
    .into_iter()
    .try_for_each(|(mode, theme)| -> Result<(), AppError> {
        let mut state = GalleryState::new();
        let paint = render(Arc::new(theme.clone()), &mut state);
        let rgba = rasterize(&theme, &paint, GALLERY_CANVAS, StringTable::empty());
        let png = encode_png(&rgba, GALLERY_CANVAS)
            .map_err(|source| AppError::GalleryEncode { mode, source })?;
        let target = out_dir.join(format!("gallery_{mode}.png"));
        std::fs::write(&target, &png).map_err(|source| AppError::GalleryWrite {
            path: target.clone(),
            source,
        })?;
        tracing::info!(path = %target.display(), "gallery png written");
        Ok(())
    })
}

fn main() -> Result<(), AppError> {
    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)),
        )
        .init();
    match parse_mode(std::env::args())? {
        RunMode::Gallery(dir) => emit_gallery(&dir),
        RunMode::Window => run_window(),
    }
}

fn keycode_to_named(code: KeyCode) -> Option<NamedKey> {
    match code {
        KeyCode::Tab => Some(NamedKey::Tab),
        KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter),
        KeyCode::Escape => Some(NamedKey::Escape),
        KeyCode::Backspace => Some(NamedKey::Backspace),
        KeyCode::Delete => Some(NamedKey::Delete),
        KeyCode::Space => Some(NamedKey::Space),
        KeyCode::ArrowUp => Some(NamedKey::ArrowUp),
        KeyCode::ArrowDown => Some(NamedKey::ArrowDown),
        KeyCode::ArrowLeft => Some(NamedKey::ArrowLeft),
        KeyCode::ArrowRight => Some(NamedKey::ArrowRight),
        KeyCode::Home => Some(NamedKey::Home),
        KeyCode::End => Some(NamedKey::End),
        KeyCode::PageUp => Some(NamedKey::PageUp),
        KeyCode::PageDown => Some(NamedKey::PageDown),
        _ => None,
    }
}

fn winit_named_to_ui(named: WinitNamed) -> Option<NamedKey> {
    match named {
        WinitNamed::Tab => Some(NamedKey::Tab),
        WinitNamed::Enter => Some(NamedKey::Enter),
        WinitNamed::Escape => Some(NamedKey::Escape),
        WinitNamed::Backspace => Some(NamedKey::Backspace),
        WinitNamed::Delete => Some(NamedKey::Delete),
        WinitNamed::Space => Some(NamedKey::Space),
        WinitNamed::ArrowUp => Some(NamedKey::ArrowUp),
        WinitNamed::ArrowDown => Some(NamedKey::ArrowDown),
        WinitNamed::ArrowLeft => Some(NamedKey::ArrowLeft),
        WinitNamed::ArrowRight => Some(NamedKey::ArrowRight),
        WinitNamed::Home => Some(NamedKey::Home),
        WinitNamed::End => Some(NamedKey::End),
        WinitNamed::PageUp => Some(NamedKey::PageUp),
        WinitNamed::PageDown => Some(NamedKey::PageDown),
        _ => None,
    }
}

fn keycode_to_char(code: KeyCode) -> Option<char> {
    match code {
        KeyCode::KeyA => Some('a'),
        KeyCode::KeyB => Some('b'),
        KeyCode::KeyC => Some('c'),
        KeyCode::KeyD => Some('d'),
        KeyCode::KeyE => Some('e'),
        KeyCode::KeyF => Some('f'),
        KeyCode::KeyG => Some('g'),
        KeyCode::KeyH => Some('h'),
        KeyCode::KeyI => Some('i'),
        KeyCode::KeyJ => Some('j'),
        KeyCode::KeyK => Some('k'),
        KeyCode::KeyL => Some('l'),
        KeyCode::KeyM => Some('m'),
        KeyCode::KeyN => Some('n'),
        KeyCode::KeyO => Some('o'),
        KeyCode::KeyP => Some('p'),
        KeyCode::KeyQ => Some('q'),
        KeyCode::KeyR => Some('r'),
        KeyCode::KeyS => Some('s'),
        KeyCode::KeyT => Some('t'),
        KeyCode::KeyU => Some('u'),
        KeyCode::KeyV => Some('v'),
        KeyCode::KeyW => Some('w'),
        KeyCode::KeyX => Some('x'),
        KeyCode::KeyY => Some('y'),
        KeyCode::KeyZ => Some('z'),
        KeyCode::Digit0 => Some('0'),
        KeyCode::Digit1 => Some('1'),
        KeyCode::Digit2 => Some('2'),
        KeyCode::Digit3 => Some('3'),
        KeyCode::Digit4 => Some('4'),
        KeyCode::Digit5 => Some('5'),
        KeyCode::Digit6 => Some('6'),
        KeyCode::Digit7 => Some('7'),
        KeyCode::Digit8 => Some('8'),
        KeyCode::Digit9 => Some('9'),
        _ => None,
    }
}

fn run_window() -> Result<(), AppError> {
    let event_loop = EventLoop::new()?;
    event_loop.set_control_flow(ControlFlow::Wait);
    let mut app = App {
        window: None,
        render: None,
        input: InputState::default(),
    };
    event_loop.run_app(&mut app)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sketch_mode::SketchSession;

    #[test]
    fn cursor_to_world_at_window_center_equals_camera_pan() {
        let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100));
        let camera = Camera2::new(extent)
            .with_pan(Vec2::from_mm(7.0, -3.0))
            .with_zoom(PixelsPerMm::new(5.0));
        let Some(world) = cursor_to_world(camera, PhysicalPosition::new(100.0, 50.0)) else {
            panic!("center maps");
        };
        let (x, y) = world.coords_mm();
        assert!((x - 7.0).abs() < 1e-9);
        assert!((y - -3.0).abs() < 1e-9);
    }

    #[test]
    fn cursor_to_world_inverts_y_so_up_in_window_is_up_in_world() {
        let extent = ViewportExtent::new(ViewportPx::new(200), ViewportPx::new(100));
        let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(10.0));
        let Some(above) = cursor_to_world(camera, PhysicalPosition::new(100.0, 0.0)) else {
            panic!("top");
        };
        let Some(below) = cursor_to_world(camera, PhysicalPosition::new(100.0, 100.0)) else {
            panic!("bottom");
        };
        let (_, ya) = above.coords_mm();
        let (_, yb) = below.coords_mm();
        assert!(ya > yb);
    }

    #[test]
    fn cursor_to_world_rejects_zero_extent() {
        let extent = ViewportExtent::new(ViewportPx::new(0), ViewportPx::new(100));
        let camera = Camera2::new(extent);
        assert!(cursor_to_world(camera, PhysicalPosition::new(0.0, 0.0)).is_none());
    }

    fn empty_frame() -> shell::ShellFrame {
        shell::ShellFrame {
            paints: Vec::new(),
            overlay_paints: Vec::new(),
            viewport_rect: empty_rect(),
            activated_tool: None,
            activated_relation: None,
            activated_dimension: None,
            plane_picked: None,
            exit_sketch: false,
            menu_action: None,
        }
    }

    fn xy_only() -> BTreeMap<Plane, SketchId> {
        BTreeMap::from([(Plane::Xy, SketchId::default())])
    }

    #[test]
    fn plane_pick_from_idle_enters_sketch_for_known_plane() {
        let frame = shell::ShellFrame {
            plane_picked: Some(Plane::Xy),
            ..empty_frame()
        };
        let next = next_mode(Mode::Idle, &frame, &[], &xy_only());
        assert_eq!(next, Mode::enter_sketch(SketchId::default()));
    }

    #[test]
    fn plane_pick_from_idle_with_no_sketch_for_plane_stays_idle() {
        let frame = shell::ShellFrame {
            plane_picked: Some(Plane::Yz),
            ..empty_frame()
        };
        let next = next_mode(Mode::Idle, &frame, &[], &xy_only());
        assert_eq!(next, Mode::Idle);
    }

    #[test]
    fn plane_pick_while_in_sketch_keeps_current_mode() {
        let prev = Mode::enter_sketch(SketchId::default());
        let frame = shell::ShellFrame {
            plane_picked: Some(Plane::Xy),
            ..empty_frame()
        };
        assert_eq!(next_mode(prev.clone(), &frame, &[], &xy_only()), prev);
    }

    #[test]
    fn ribbon_exit_returns_idle() {
        let prev = Mode::enter_sketch(SketchId::default());
        let frame = shell::ShellFrame {
            exit_sketch: true,
            ..empty_frame()
        };
        assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle);
    }

    #[test]
    fn exit_sketch_action_returns_idle() {
        let prev = Mode::enter_sketch(SketchId::default());
        assert_eq!(
            next_mode(
                prev,
                &empty_frame(),
                &[sketch_mode::EXIT_SKETCH_ACTION],
                &xy_only()
            ),
            Mode::Idle
        );
    }

    #[test]
    fn escape_with_pending_clears_pending_keeps_sketch_and_tool() {
        let prev = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
                    1.0, 2.0,
                )))),
                ..SketchSession::default()
            }),
        };
        let next = next_mode(
            prev,
            &empty_frame(),
            &[sketch_mode::EXIT_SKETCH_ACTION],
            &xy_only(),
        );
        let Mode::Sketch { session, .. } = next else {
            panic!("escape with pending must keep sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Line));
        assert_eq!(session.pending, None);
    }

    fn far_camera() -> Camera2 {
        Camera2::new(ViewportExtent::new(
            ViewportPx::new(800),
            ViewportPx::new(600),
        ))
        .with_zoom(PixelsPerMm::new(1_000_000.0))
    }

    #[test]
    fn build_preview_in_idle_is_empty() {
        let document = Document::new(DocumentId::default(), "doc".to_owned());
        let preview = build_preview(
            &Mode::Idle,
            &document,
            Some(Point2::from_mm(1.0, 1.0)),
            &far_camera(),
        );
        assert!(preview.is_empty());
    }

    #[test]
    fn build_preview_without_armed_tool_is_empty() {
        let document = Document::new(DocumentId::default(), "doc".to_owned());
        let mode = Mode::enter_sketch(SketchId::default());
        let preview = build_preview(
            &mode,
            &document,
            Some(Point2::from_mm(0.0, 0.0)),
            &far_camera(),
        );
        assert!(preview.is_empty());
    }

    #[test]
    fn build_preview_with_position_pending_emits_anchor_and_segment() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (document, sketch_id) = initial_document(sketch);
        let anchor = Point2::from_mm(2.0, 3.0);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Position(anchor))),
                ..SketchSession::default()
            }),
        };
        let cursor = Point2::from_mm(5.0, 7.0);
        let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
        assert_eq!(preview.anchors, vec![anchor]);
        assert_eq!(preview.segments, vec![(anchor, cursor)]);
    }

    #[test]
    fn build_preview_with_endpoint_pending_resolves_via_document() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let target = Point2::from_mm(-4.0, 6.0);
        let (sketch, endpoint) = tools::add_point(sketch, target);
        let (document, sketch_id) = initial_document(sketch);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Endpoint(endpoint))),
                ..SketchSession::default()
            }),
        };
        let cursor = Point2::from_mm(0.0, 0.0);
        let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
        assert_eq!(preview.anchors, vec![target]);
        assert_eq!(preview.segments, vec![(target, cursor)]);
    }

    #[test]
    fn build_preview_keeps_anchor_when_cursor_outside_viewport() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (document, sketch_id) = initial_document(sketch);
        let anchor = Point2::from_mm(1.0, 1.0);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Position(anchor))),
                ..SketchSession::default()
            }),
        };
        let preview = build_preview(&mode, &document, None, &far_camera());
        assert_eq!(preview.anchors, vec![anchor]);
        assert!(preview.segments.is_empty());
    }

    #[test]
    fn build_preview_during_drag_is_empty() {
        let document = Document::new(DocumentId::default(), "doc".to_owned());
        let mode = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
                    0.0, 0.0,
                )))),
                drag: Some(DragSession {
                    entity: bone_types::SketchEntityId::default(),
                    press: Point2::origin(),
                    pins: DragPins::from_array([
                        Some((bone_types::SketchEntityId::default(), Point2::origin())),
                        None,
                        None,
                    ]),
                }),
                ..SketchSession::default()
            }),
        };
        let preview = build_preview(
            &mode,
            &document,
            Some(Point2::from_mm(1.0, 1.0)),
            &far_camera(),
        );
        assert!(preview.is_empty());
    }

    #[test]
    fn build_preview_circle_emits_ghost_circle() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (document, sketch_id) = initial_document(sketch);
        let center = Point2::from_mm(0.0, 0.0);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Circle),
                pending: Some(Pending::First(ClickAnchor::Position(center))),
                ..SketchSession::default()
            }),
        };
        let cursor = Point2::from_mm(3.0, 4.0);
        let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
        assert_eq!(preview.anchors, vec![center]);
        assert_eq!(preview.circles.len(), 1);
        let r = preview.circles[0].radius.get::<millimeter>();
        assert!((r - 5.0).abs() < 1e-9, "r={r}");
    }

    #[test]
    fn build_preview_corner_rectangle_emits_four_segments() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (document, sketch_id) = initial_document(sketch);
        let corner = Point2::from_mm(0.0, 0.0);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::CornerRectangle),
                pending: Some(Pending::First(ClickAnchor::Position(corner))),
                ..SketchSession::default()
            }),
        };
        let cursor = Point2::from_mm(5.0, 3.0);
        let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
        assert_eq!(preview.anchors, vec![corner]);
        assert_eq!(preview.segments.len(), 4);
    }

    #[test]
    fn build_preview_tangent_arc_emits_ghost_arc_after_endpoint_click() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
        let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0));
        let (sketch, _) = tools::add_line(sketch, a, b, false);
        let (document, sketch_id) = initial_document(sketch);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::TangentArc),
                pending: Some(Pending::First(ClickAnchor::Endpoint(b))),
                ..SketchSession::default()
            }),
        };
        let cursor = Point2::from_mm(10.0, 6.0);
        let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
        assert_eq!(preview.anchors.len(), 1, "start anchor visible");
        assert_eq!(preview.arcs.len(), 1, "ghost arc emitted");
    }

    #[test]
    fn build_preview_centerpoint_arc_emits_ghost_arc_after_two_clicks() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (document, sketch_id) = initial_document(sketch);
        let center = Point2::from_mm(0.0, 0.0);
        let start = Point2::from_mm(5.0, 0.0);
        let mode = Mode::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                tool: Some(SketchTool::CenterpointArc),
                pending: Some(Pending::Second(
                    ClickAnchor::Position(center),
                    ClickAnchor::Position(start),
                )),
                ..SketchSession::default()
            }),
        };
        let cursor = Point2::from_mm(0.0, 5.0);
        let preview = build_preview(&mode, &document, Some(cursor), &far_camera());
        assert_eq!(preview.arcs.len(), 1);
        assert_eq!(preview.anchors.len(), 2);
    }

    #[test]
    fn escape_with_armed_tool_no_pending_disarms_tool() {
        let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
        let next = next_mode(
            prev,
            &empty_frame(),
            &[sketch_mode::EXIT_SKETCH_ACTION],
            &xy_only(),
        );
        let Mode::Sketch { session, .. } = next else {
            panic!("escape with armed tool must keep sketch mode");
        };
        assert_eq!(session.tool, None);
        assert_eq!(session.pending, None);
    }

    #[test]
    fn clicking_active_tool_disarms_it() {
        let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
        let frame = shell::ShellFrame {
            activated_tool: Some(SketchTool::Line),
            ..empty_frame()
        };
        let next = next_mode(prev, &frame, &[], &xy_only());
        let Mode::Sketch { session, .. } = next else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, None);
    }

    #[test]
    fn clicking_different_tool_swaps() {
        let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
        let frame = shell::ShellFrame {
            activated_tool: Some(SketchTool::Point),
            ..empty_frame()
        };
        let next = next_mode(prev, &frame, &[], &xy_only());
        let Mode::Sketch { session, .. } = next else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Point));
    }

    #[test]
    fn ribbon_exit_overrides_pending_chain() {
        let prev = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
                    0.0, 0.0,
                )))),
                ..SketchSession::default()
            }),
        };
        let frame = shell::ShellFrame {
            exit_sketch: true,
            ..empty_frame()
        };
        assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle);
    }

    #[test]
    fn tool_in_idle_does_not_promote_to_sketch() {
        let frame = shell::ShellFrame {
            activated_tool: Some(SketchTool::Line),
            ..empty_frame()
        };
        assert_eq!(next_mode(Mode::Idle, &frame, &[], &xy_only()), Mode::Idle);
    }

    #[test]
    fn tool_in_sketch_arms_session() {
        let prev = Mode::enter_sketch(SketchId::default());
        let frame = shell::ShellFrame {
            activated_tool: Some(SketchTool::Line),
            ..empty_frame()
        };
        let Mode::Sketch { session, .. } = next_mode(prev, &frame, &[], &xy_only()) else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Line));
    }

    #[test]
    fn plane_pick_then_tool_enters_and_arms_in_one_frame() {
        let frame = shell::ShellFrame {
            plane_picked: Some(Plane::Xy),
            activated_tool: Some(SketchTool::Line),
            ..empty_frame()
        };
        let Mode::Sketch { session, .. } = next_mode(Mode::Idle, &frame, &[], &xy_only()) else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Line));
    }

    #[test]
    fn exit_action_wins_over_pending_tool() {
        let prev = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
        let frame = shell::ShellFrame {
            exit_sketch: true,
            ..empty_frame()
        };
        assert_eq!(next_mode(prev, &frame, &[], &xy_only()), Mode::Idle);
    }

    #[test]
    fn idle_scopes_omit_sketch_scope() {
        let scopes = scopes_for_mode(&Mode::Idle);
        let collected: Vec<_> = scopes.innermost_first().copied().collect();
        assert!(!collected.contains(&HotkeyScope::Sketch));
        assert!(collected.contains(&HotkeyScope::Global));
    }

    #[test]
    fn sketch_scopes_include_sketch_scope() {
        let scopes = scopes_for_mode(&Mode::enter_sketch(SketchId::default()));
        let collected: Vec<_> = scopes.innermost_first().copied().collect();
        assert!(collected.contains(&HotkeyScope::Sketch));
        assert!(collected.contains(&HotkeyScope::Global));
    }

    #[test]
    fn hotkey_table_binds_escape_to_exit_under_sketch_scope() {
        let table = build_hotkey_table();
        let chord = KeyChord::new(UiKeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
        let in_sketch = scopes_for_mode(&Mode::enter_sketch(SketchId::default()));
        assert_eq!(
            table.dispatch(chord, &in_sketch),
            Some(sketch_mode::EXIT_SKETCH_ACTION)
        );
        let in_idle = scopes_for_mode(&Mode::Idle);
        assert_eq!(table.dispatch(chord, &in_idle), None);
    }

    #[test]
    fn driven_with_value_promotes_kind_and_overwrites_value() {
        let proto = SketchDimension::Linear {
            a: bone_types::SketchEntityId::default(),
            b: bone_types::SketchEntityId::default(),
            value: Length::new::<millimeter>(8.0),
            kind: DimensionKind::Driving,
        };
        let measured = DimensionValue::Length(Length::new::<millimeter>(10.0));
        let Some(driven) = driven_with_value(proto, measured) else {
            panic!("driven_with_value rejected matched-kind value");
        };
        assert_eq!(driven.kind(), DimensionKind::Driven);
        let DimensionValue::Length(length) = driven.value() else {
            panic!("expected Length");
        };
        assert!((length.get::<millimeter>() - 10.0).abs() < 1e-9);
    }

    #[test]
    fn driven_with_value_rejects_kind_mismatch() {
        let proto = SketchDimension::Linear {
            a: bone_types::SketchEntityId::default(),
            b: bone_types::SketchEntityId::default(),
            value: Length::new::<millimeter>(1.0),
            kind: DimensionKind::Driving,
        };
        let bad = DimensionValue::Angle(bone_types::Angle::new::<uom::si::angle::radian>(1.0));
        assert!(driven_with_value(proto, bad).is_none());
    }

    #[test]
    fn dim_conflict_pending_returns_proto_when_set() {
        let proto = SketchDimension::Linear {
            a: bone_types::SketchEntityId::default(),
            b: bone_types::SketchEntityId::default(),
            value: Length::new::<millimeter>(2.0),
            kind: DimensionKind::Driving,
        };
        let pending = PendingDimension {
            proto,
            anchor: Point2::origin(),
        };
        let mode = Mode::enter_sketch(SketchId::default()).start_dim_conflict(pending);
        assert_eq!(dim_conflict_pending(&mode), Some(pending));
    }

    #[test]
    fn dim_conflict_pending_returns_none_in_idle() {
        assert_eq!(dim_conflict_pending(&Mode::Idle), None);
    }
}
