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

use bone_document::{
    DimensionKind, DimensionValue, Document, DocumentFolder, EditOutcome, LineData, 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, SketchItemId, 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, HotkeyScope, HotkeyScopes, HotkeyTable};
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::{MaskAtlas, MaskAtlasParams, Shaper};
use swash::FontRef;
use tracing_subscriber::EnvFilter;
use uom::si::length::millimeter;
use winit::{
    application::ApplicationHandler,
    dpi::{PhysicalPosition, PhysicalSize},
    event::{ElementState, 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 event;
mod file_menu;
mod hotkeys;
mod native_picker;
mod redraw;
mod relation_tools;
mod selection;
mod settings;
mod shell;
mod shortcut_bar;
mod sketch_mode;
mod smart_dimension;
mod snap;
mod status_badge;
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;

struct RenderState {
    surface: SurfaceContext,
    renderer: SketchRenderer,
    chrome_pipeline: ChromePipeline,
    text_pipeline: ChromeTextPipeline,
    sdf_atlas: MaskAtlas,
    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,
    settings: settings::Settings,
    dim_editor: DimensionEditorState,
    dim_editor_bounds: Option<LayoutRect>,
    pending_exit: bool,
    current_folder: Option<DocumentFolder>,
    documents_root: PathBuf,
    file_picker: Option<file_menu::FilePickerSession>,
    native_picker: Option<native_picker::PendingHandle>,
    pending_overwrite: Option<DocumentFolder>,
    last_saved: Option<Document>,
    pending_discard: Option<PendingDiscard>,
    notification: Option<Notification>,
    shortcut_bar: Option<shortcut_bar::ShortcutBarState>,
}

#[derive(Clone, Debug, PartialEq)]
enum PendingDiscard {
    New,
    Open(PathBuf),
}

fn modal_active(state: &RenderState) -> bool {
    state.file_picker.is_some()
        || state.native_picker.is_some()
        || state.pending_overwrite.is_some()
        || state.pending_discard.is_some()
        || state.shortcut_bar.is_some()
        || state.shell.state.ribbon_overflow_open.values().any(|v| *v)
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum NotificationKind {
    Info,
    Error,
}

#[derive(Clone, Debug, PartialEq)]
struct Notification {
    kind: NotificationKind,
    headline: bone_ui::strings::StringKey,
    detail: Option<String>,
}

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,
    start: std::time::Instant,
}

impl Default for InputState {
    fn default() -> Self {
        Self {
            cursor_px: None,
            left_pan: false,
            middle_pan: false,
            modifiers: ModifiersState::empty(),
            pending_pressed: PointerButtonMask::EMPTY,
            pending_released: PointerButtonMask::EMPTY,
            pending_keys: Vec::new(),
            pending_text: String::new(),
            start: std::time::Instant::now(),
        }
    }
}

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::from_duration(self.start.elapsed()));
        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 {
    redraw: Option<redraw::Scheduler>,
    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))
        .with_aperture(state.settings.pick_aperture);
    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 item = picked.map(selection::picked_to_item);
    state.selection = std::mem::take(&mut state.selection).picked(item, additive);
    if additive || !state.mode.is_sketch() {
        return;
    }
    let Some(SketchItemId::Entity(entity_id)) = item 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 drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option<Sketch> {
    let pins = drag.pins.to_targets(drag.press, world);
    sketch
        .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS)
        .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch"))
        .ok()
}

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) else {
        return;
    };
    let Some(next) = drag_resolved(sketch, drag, world) else {
        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)
}

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::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 Ok(table) = hotkeys::compose_table(&hotkeys::HotkeyOverrides::default()) else {
        unreachable!("default 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,
    exit_sketch_requested: bool,
    plane_sketches: &BTreeMap<Plane, SketchId>,
) -> Mode {
    let plane_pick = frame
        .plane_picked
        .filter(|_| !mode.is_sketch())
        .and_then(|plane| plane_sketches.get(&plane).copied());
    let sketch_pick = frame.sketch_activated.filter(|_| !mode.is_sketch());
    let pick = sketch_pick.or(plane_pick);
    let after_pick = pick.map_or(mode, Mode::enter_sketch);
    let after_escape = if exit_sketch_requested {
        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.redraw.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 = MaskAtlas::new(MaskAtlasParams::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 = shell::Shell::new();
        let (document, sketch_id) = initial_document(sketch);
        let last_saved_baseline = document.clone();
        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");
        };
        let loaded_settings = settings::load();
        let initial_hotkeys = match hotkeys::compose_table(&loaded_settings.hotkey_overrides) {
            Ok(table) => table,
            Err(e) => {
                tracing::warn!(error = %e, "stored hotkey overrides conflict, using defaults");
                build_hotkey_table()
            }
        };
        window.request_redraw();
        self.redraw = Some(redraw::Scheduler::new(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: initial_hotkeys,
            strings,
            viewport_rect,
            undo: UndoStack::with_capacity(undo_capacity),
            selection: Selection::default(),
            settings: loaded_settings,
            dim_editor: DimensionEditorState::default(),
            dim_editor_bounds: None,
            pending_exit: false,
            current_folder: None,
            documents_root: file_menu::documents_root(),
            file_picker: None,
            native_picker: None,
            pending_overwrite: None,
            last_saved: Some(last_saved_baseline),
            pending_discard: None,
            notification: None,
            shortcut_bar: None,
        });
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
        match event::AppEvent::from_winit(event) {
            event::AppEvent::Input(input) => {
                let ack = self.dispatch_input(event_loop, input);
                if let Some(scheduler) = self.redraw.as_mut() {
                    scheduler.schedule(ack);
                }
            }
            event::AppEvent::Redraw => self.dispatch_redraw(event_loop),
            event::AppEvent::Close => event_loop.exit(),
            event::AppEvent::Ignored => {}
        }
    }

    fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: winit::event::StartCause) {
        if matches!(cause, winit::event::StartCause::ResumeTimeReached { .. })
            && let Some(scheduler) = self.redraw.as_mut()
        {
            scheduler.kick();
            scheduler.window().request_redraw();
        }
    }
}

impl App {
    fn dispatch_input(
        &mut self,
        event_loop: &ActiveEventLoop,
        input: event::InputEvent,
    ) -> redraw::InputDispatched {
        let ack = redraw::InputDispatched::after_input();
        let Some(state) = self.render.as_mut() else {
            return ack;
        };
        match input {
            event::InputEvent::Resize(size) => {
                let extent = viewport_extent(size);
                state.surface.resize(extent);
                state.camera = state.camera.with_extent(extent);
                state.viewport_rect = empty_rect();
            }
            event::InputEvent::Focus(focused) => {
                if !focused {
                    self.input.forget_pan_state();
                }
            }
            event::InputEvent::Modifier(mods) => {
                self.input.modifiers = mods.state();
            }
            event::InputEvent::CursorMove(position) => {
                let prev = self.input.cursor_px;
                self.input.cursor_px = Some(position);
                let modal = modal_active(state);
                if !modal
                    && self.input.panning()
                    && let Some(p) = prev
                {
                    state.camera = pan_by_px(state.camera, position.x - p.x, position.y - p.y);
                } else if !modal
                    && dragging_in_sketch(&state.mode)
                    && let Some(world) = cursor_to_world(state.camera, position)
                {
                    try_drag_to(state, world);
                }
            }
            event::InputEvent::CursorLeft => {
                self.input.cursor_px = None;
            }
            event::InputEvent::CursorEntered => {}
            event::InputEvent::Pointer {
                button,
                state: btn_state,
            } => {
                self.dispatch_pointer_button(button, btn_state);
            }
            event::InputEvent::Wheel(delta) => {
                if !modal_active(state) && self.input.cursor_in(state.viewport_rect) {
                    state.camera =
                        zoom_about(state.camera, self.input.cursor_px, zoom_factor(delta));
                }
            }
            event::InputEvent::KeyDown {
                physical_key,
                logical_key,
                text,
            } => {
                self.dispatch_keydown(event_loop, physical_key, &logical_key, text.as_ref());
            }
        }
        ack
    }

    fn dispatch_pointer_button(&mut self, button: MouseButton, btn_state: ElementState) {
        let Some(state) = self.render.as_mut() else {
            return;
        };
        let modal = modal_active(state);
        match 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 = !modal && in_viewport && !over_dim_editor && !dim_active;
                    self.input.pending_pressed =
                        self.input.pending_pressed.with(PointerButton::Primary);
                    if !modal
                        && 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);
                }
            }
            MouseButton::Right => {
                if btn_state == ElementState::Pressed {
                    self.input.pending_pressed =
                        self.input.pending_pressed.with(PointerButton::Secondary);
                    if !modal && 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);
                }
            }
            MouseButton::Middle => {
                if btn_state == ElementState::Pressed {
                    self.input.middle_pan = !modal && 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);
                }
            }
            MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => {}
        }
    }

    fn dispatch_keydown(
        &mut self,
        event_loop: &ActiveEventLoop,
        physical_key: PhysicalKey,
        logical_key: &Key,
        text: Option<&winit::keyboard::SmolStr>,
    ) {
        let Some(state) = self.render.as_mut() else {
            return;
        };
        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 {
            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) || state.focus.focused().is_some();
        if matches!(named, Some(NamedKey::Escape)) && state.notification.is_some() {
            state.notification = None;
        }
        if let Some(code) = physical_code
            && !suppress_camera
            && let Some(next) = keyboard_camera(code, &self.input, state)
        {
            state.camera = next;
        }
        let _ = event_loop;
    }

    fn dispatch_redraw(&mut self, event_loop: &ActiveEventLoop) {
        let (Some(state), Some(scheduler)) = (self.render.as_mut(), self.redraw.as_mut()) else {
            return;
        };
        render_frame(state, scheduler, &mut self.input);
        if state.pending_exit {
            event_loop.exit();
            return;
        }
        if let Some(deadline) = next_wake_deadline(state, &self.input) {
            event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
        } else {
            event_loop.set_control_flow(ControlFlow::Wait);
        }
    }
}

fn next_wake_deadline(state: &RenderState, input: &InputState) -> Option<std::time::Instant> {
    let now = std::time::Instant::now();
    let native_poll = state
        .native_picker
        .is_some()
        .then(|| now + std::time::Duration::from_millis(40));
    let rename_deadline = state
        .shell
        .state
        .feature_tree
        .pending_rename
        .map(|pending| {
            let window = bone_ui::input::DoubleClickWindow::DEFAULT.duration();
            let slack = std::time::Duration::from_millis(8);
            input.start + pending.at.duration() + window + slack
        });
    [native_poll, rename_deadline].into_iter().flatten().min()
}

#[allow(
    clippy::cast_precision_loss,
    reason = "viewport pixel counts at any realistic display size fit f32 mantissa"
)]
#[allow(
    clippy::too_many_lines,
    reason = "splitting hides the per-outcome dispatch table"
)]
fn render_frame(
    state: &mut RenderState,
    scheduler: &mut redraw::Scheduler,
    input_state: &mut InputState,
) {
    poll_native_picker(state);
    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 chrome_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 FrameOutcomes {
        mut frame,
        hotkey_actions,
        dim: dim_outcome,
        dim_conflict: conflict_outcome,
        picker: picker_outcome,
        overwrite: overwrite_outcome,
        discard: discard_outcome,
        notification: notification_outcome,
        shortcut_bar: shortcut_bar_outcome,
    } = run_frame_ui(
        state,
        theme,
        &mut input,
        &mut hits,
        &mut a11y,
        &scopes,
        layout_size,
        chrome_cursor_world,
    );
    state.dim_editor_bounds = apply_popup_overlays(
        &mut frame.overlay_paints,
        dim_outcome.as_ref(),
        conflict_outcome.as_ref(),
        picker_outcome.as_ref(),
        overwrite_outcome.as_ref(),
        discard_outcome.as_ref(),
        notification_outcome.as_ref(),
        shortcut_bar_outcome.as_ref(),
    );
    apply_shortcut_bar_outcome(state, shortcut_bar_outcome.as_ref());
    if let Some(cmd) = picker_outcome.and_then(|o| o.command) {
        apply_picker_command(state, cmd);
    }
    apply_overwrite_outcome(state, overwrite_outcome);
    apply_discard_outcome(state, discard_outcome);
    apply_notification_outcome(state, notification_outcome);
    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, scheduler, &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) => {}
        }
    }
    let exit_sketch_requested = hotkey_actions.contains(&sketch_mode::EXIT_SKETCH_ACTION);
    state.mode = next_mode(
        core::mem::take(&mut state.mode),
        &frame,
        exit_sketch_requested,
        &state.plane_sketches,
    );
    apply_dimension_outcome(state, dim_outcome);
    apply_dim_conflict_outcome(state, conflict_outcome);
    apply_dimension_request(state, frame.activated_dimension);
    let dimension_edit = match frame.confirm_action {
        Some(shell::ConfirmAction::Cancel) => None,
        _ => frame.dimension_edit,
    };
    apply_dimension_edit(state, dimension_edit);
    let cursor_layout = input_state.cursor_px.map(physical_to_layout_pos);
    apply_hotkey_actions(state, &hotkey_actions, cursor_layout);
    apply_menu_action(state, frame.menu_action);
    apply_settings_change(state, frame.settings_change);
    apply_relation_action(state, frame.activated_relation);
    apply_sketch_rename(state, frame.sketch_rename.clone());
    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);
    let pre_present = || scheduler.window().pre_present_notify();
    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);
        },
        pre_present,
    );
    scheduler.consume_kick();
}

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,
    scheduler: &mut redraw::Scheduler,
    hits: &HitFrame,
    input: &InputSnapshot,
) {
    state.hit_state = resolve(&state.hit_state, hits, input, state.focus.focused());
    if any_actionable_interaction(&state.hit_state) {
        scheduler.kick();
    }
}

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_hotkey_actions(
    state: &mut RenderState,
    actions: &[ActionId],
    cursor_layout: Option<LayoutPos>,
) {
    actions
        .iter()
        .filter_map(|a| hotkeys::command_for_action(*a))
        .for_each(|cmd| dispatch_hotkey_command(state, cmd, cursor_layout));
}

fn dispatch_hotkey_command(
    state: &mut RenderState,
    cmd: hotkeys::HotkeyCommand,
    cursor_layout: Option<LayoutPos>,
) {
    use hotkeys::HotkeyCommand as C;
    match cmd {
        C::Undo => {
            if state.undo.undo(&mut state.document) {
                refresh_active_scene(state);
            }
        }
        C::Redo => {
            if state.undo.redo(&mut state.document) {
                refresh_active_scene(state);
            }
        }
        C::NewDocument => apply_menu_action(state, Some(shell::MenuAction::NewDocument)),
        C::OpenDocument => apply_menu_action(state, Some(shell::MenuAction::OpenDocument)),
        C::SaveDocument => apply_menu_action(state, Some(shell::MenuAction::SaveDocument)),
        C::ZoomFit => apply_menu_action(state, Some(shell::MenuAction::ZoomFit)),
        C::Quit => {
            state.pending_exit = true;
        }
        C::OpenShortcutBar => {
            if state.shortcut_bar.is_none() {
                let anchor = cursor_layout.unwrap_or(LayoutPos::ORIGIN);
                state.shortcut_bar = Some(shortcut_bar::ShortcutBarState { anchor });
            }
        }
        C::ToggleConstruction => apply_construction_toggle(state),
        C::Mirror => apply_mirror(state),
        C::SelectAll
        | C::DeleteSelection
        | C::EnterSketch
        | C::SmartDimension
        | C::Trim
        | C::Extend => notify_stub(state, hotkeys::label_for_command(cmd)),
    }
}

fn apply_construction_toggle(state: &mut RenderState) {
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec();
    if entity_ids.is_empty() {
        return;
    }
    let Some(sketch) = state.document.sketch(sketch_id) else {
        return;
    };
    let pivot = entity_ids
        .iter()
        .find_map(|id| match sketch.entities().get(*id)? {
            SketchEntity::Line(l) => Some(l.for_construction()),
            SketchEntity::Arc(a) => Some(a.for_construction()),
            SketchEntity::Circle(c) => Some(c.for_construction()),
            SketchEntity::Point(_) => None,
        });
    let Some(current) = pivot else {
        return;
    };
    let target = !current;
    let snapshot = state.document.clone();
    let result =
        entity_ids
            .iter()
            .try_fold(sketch.clone(), |acc, id| match acc.entities().get(*id) {
                Some(SketchEntity::Point(_)) | None => Ok(acc),
                Some(_) => acc
                    .apply(bone_document::SketchEdit::SetConstruction {
                        id: *id,
                        for_construction: target,
                    })
                    .map(|(s, _)| s),
            });
    match result {
        Ok(next) => {
            state.undo.record(snapshot);
            state.document.replace_sketch(sketch_id, next);
            refresh_active_scene(state);
        }
        Err(e) => tracing::warn!(error = %e, "construction toggle failed"),
    }
}

fn apply_mirror(state: &mut RenderState) {
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let entity_ids: Vec<bone_types::SketchEntityId> = state.selection.entity_ids().to_vec();
    if entity_ids.is_empty() {
        return;
    }
    let Some(sketch) = state.document.sketch(sketch_id) else {
        return;
    };
    let axis_lines: Vec<(bone_types::SketchEntityId, LineData)> = entity_ids
        .iter()
        .filter_map(|id| match sketch.entities().get(*id)? {
            SketchEntity::Line(l) => Some((*id, *l)),
            _ => None,
        })
        .collect();
    let [(axis_id, axis_line)] = axis_lines.as_slice() else {
        notify_mirror_hint(state);
        return;
    };
    let axis_id = *axis_id;
    let Some(pa) = lookup_point(sketch, axis_line.a()) else {
        return;
    };
    let Some(pb) = lookup_point(sketch, axis_line.b()) else {
        return;
    };
    let axis = MirrorAxis::from_points(pa, pb);
    if axis.is_degenerate() {
        notify_mirror_hint(state);
        return;
    }
    let source_ids: std::collections::BTreeSet<bone_types::SketchEntityId> = entity_ids
        .iter()
        .copied()
        .filter(|id| *id != axis_id)
        .collect();
    if source_ids.is_empty() {
        notify_mirror_hint(state);
        return;
    }
    let snapshot = state.document.clone();
    let result = mirror_targets(sketch.clone(), &source_ids, axis_id, &axis);
    match result {
        Ok(next) => {
            state.undo.record(snapshot);
            state.document.replace_sketch(sketch_id, next);
            state.selection = Selection::default();
            refresh_active_scene(state);
        }
        Err(e) => tracing::warn!(error = %e, "mirror failed"),
    }
}

fn notify_mirror_hint(state: &mut RenderState) {
    state.notification = Some(Notification {
        kind: NotificationKind::Info,
        headline: strings::HOTKEY_LABEL_MIRROR,
        detail: Some(
            state
                .strings
                .resolve(strings::NOTIFY_MIRROR_SELECTION_HINT)
                .to_owned(),
        ),
    });
}

#[derive(Copy, Clone, Debug)]
struct MirrorAxis {
    anchor_x: f64,
    anchor_y: f64,
    direction_x: f64,
    direction_y: f64,
    length_sq: f64,
}

impl MirrorAxis {
    fn from_points(a: Point2, b: Point2) -> Self {
        let (ax, ay) = a.coords_mm();
        let (bx, by) = b.coords_mm();
        let dx = bx - ax;
        let dy = by - ay;
        Self {
            anchor_x: ax,
            anchor_y: ay,
            direction_x: dx,
            direction_y: dy,
            length_sq: dx * dx + dy * dy,
        }
    }

    fn is_degenerate(self) -> bool {
        !self.length_sq.is_finite() || self.length_sq <= f64::EPSILON
    }

    fn reflect(self, p: Point2) -> Point2 {
        let (px, py) = p.coords_mm();
        let vx = px - self.anchor_x;
        let vy = py - self.anchor_y;
        let t = (vx * self.direction_x + vy * self.direction_y) / self.length_sq;
        let foot_x = self.anchor_x + t * self.direction_x;
        let foot_y = self.anchor_y + t * self.direction_y;
        Point2::from_mm(2.0 * foot_x - px, 2.0 * foot_y - py)
    }

    fn is_on_axis(self, p: Point2) -> bool {
        let (px, py) = p.coords_mm();
        let vx = px - self.anchor_x;
        let vy = py - self.anchor_y;
        let cross = vx * self.direction_y - vy * self.direction_x;
        let perp_dist_sq = cross * cross / self.length_sq;
        perp_dist_sq < ON_AXIS_TOLERANCE_MM_SQ
    }
}

const ON_AXIS_TOLERANCE_MM_SQ: f64 = 1e-12;

struct MirrorBuilder {
    sketch: Sketch,
    point_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
    entity_map: std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
}

impl MirrorBuilder {
    fn new(sketch: Sketch) -> Self {
        Self {
            sketch,
            point_map: std::collections::BTreeMap::new(),
            entity_map: std::collections::BTreeMap::new(),
        }
    }

    fn mirror_point(
        &mut self,
        id: bone_types::SketchEntityId,
        axis: &MirrorAxis,
    ) -> Result<bone_types::SketchEntityId, bone_document::SketchEditError> {
        if let Some(&existing) = self.point_map.get(&id) {
            return Ok(existing);
        }
        let pos = require_point(&self.sketch, id)?;
        if axis.is_on_axis(pos) {
            self.point_map.insert(id, id);
            self.entity_map.insert(id, id);
            return Ok(id);
        }
        let (next, new_id) = add_point(self.sketch.clone(), axis.reflect(pos))?;
        self.sketch = next;
        self.point_map.insert(id, new_id);
        self.entity_map.insert(id, new_id);
        Ok(new_id)
    }

    fn mirror_entity(
        &mut self,
        id: bone_types::SketchEntityId,
        axis: &MirrorAxis,
    ) -> Result<(), bone_document::SketchEditError> {
        if self.entity_map.contains_key(&id) {
            return Ok(());
        }
        let entity = self
            .sketch
            .entities()
            .get(id)
            .copied()
            .ok_or(bone_document::SketchEditError::EntityNotFound(id))?;
        match entity {
            SketchEntity::Point(_) => {
                self.mirror_point(id, axis)?;
            }
            SketchEntity::Line(l) => {
                let new_a = self.mirror_point(l.a(), axis)?;
                let new_b = self.mirror_point(l.b(), axis)?;
                let (next, outcome) =
                    self.sketch
                        .clone()
                        .apply(bone_document::SketchEdit::AddEntity(SketchEntity::line(
                            new_a,
                            new_b,
                            l.for_construction(),
                        )))?;
                let EditOutcome::Entity(new_id) = outcome else {
                    unreachable!("AddEntity yields Entity outcome")
                };
                self.sketch = next;
                self.entity_map.insert(id, new_id);
            }
            SketchEntity::Arc(a) => {
                let new_center = self.mirror_point(a.center(), axis)?;
                let new_start = self.mirror_point(a.start(), axis)?;
                let new_end = self.mirror_point(a.end(), axis)?;
                let (next, outcome) =
                    self.sketch
                        .clone()
                        .apply(bone_document::SketchEdit::AddEntity(SketchEntity::arc(
                            new_center,
                            new_end,
                            new_start,
                            a.for_construction(),
                        )))?;
                let EditOutcome::Entity(new_id) = outcome else {
                    unreachable!("AddEntity yields Entity outcome")
                };
                self.sketch = next;
                self.entity_map.insert(id, new_id);
            }
            SketchEntity::Circle(c) => {
                let new_center = self.mirror_point(c.center(), axis)?;
                let (next, outcome) =
                    self.sketch
                        .clone()
                        .apply(bone_document::SketchEdit::AddEntity(SketchEntity::circle(
                            new_center,
                            c.radius(),
                            c.for_construction(),
                        )))?;
                let EditOutcome::Entity(new_id) = outcome else {
                    unreachable!("AddEntity yields Entity outcome")
                };
                self.sketch = next;
                self.entity_map.insert(id, new_id);
            }
        }
        Ok(())
    }
}

fn mirror_targets(
    sketch: Sketch,
    source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
    axis: &MirrorAxis,
) -> Result<Sketch, bone_document::SketchEditError> {
    let mut builder = MirrorBuilder::new(sketch);
    source_ids
        .iter()
        .try_for_each(|id| builder.mirror_entity(*id, axis))?;
    builder.entity_map.insert(axis_id, axis_id);
    builder.sketch = symmetric_relations_for_pairs(builder.sketch, &builder.point_map, axis_id)?;
    builder.sketch = copy_relations(builder.sketch, source_ids, axis_id, &builder.entity_map)?;
    builder.sketch = copy_dimensions(builder.sketch, source_ids, axis_id, &builder.entity_map)?;
    Ok(builder.sketch)
}

fn symmetric_relations_for_pairs(
    sketch: Sketch,
    point_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
) -> Result<Sketch, bone_document::SketchEditError> {
    point_map
        .iter()
        .filter(|(source, mirrored)| source != mirrored)
        .try_fold(sketch, |acc, (&source, &mirrored)| {
            let (next, _) = acc.apply(bone_document::SketchEdit::AddRelation(
                SketchRelation::Symmetric {
                    a: source,
                    b: mirrored,
                    axis: axis_id,
                },
            ))?;
            Ok(next)
        })
}

fn copy_relations(
    sketch: Sketch,
    source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
    entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Result<Sketch, bone_document::SketchEditError> {
    let relations: Vec<SketchRelation> = sketch
        .relations()
        .iter()
        .map(|(_, r)| *r)
        .filter(|r| relation_is_mirrorable(r, source_ids, axis_id))
        .filter_map(|r| remap_relation(r, entity_map))
        .collect();
    relations.into_iter().try_fold(sketch, |acc, rel| {
        match acc
            .clone()
            .apply(bone_document::SketchEdit::AddRelation(rel))
        {
            Ok((next, _)) => Ok(next),
            Err(e) => {
                tracing::warn!(error = %e, relation = ?rel, "mirror: skipped relation");
                Ok(acc)
            }
        }
    })
}

fn relation_is_mirrorable(
    rel: &SketchRelation,
    source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
) -> bool {
    let refs: Vec<_> = rel.references().into_iter().collect();
    let touches_source = refs.iter().any(|id| source_ids.contains(id));
    let all_known = refs
        .iter()
        .all(|id| source_ids.contains(id) || *id == axis_id);
    touches_source && all_known
}

fn remap_relation(
    rel: SketchRelation,
    entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Option<SketchRelation> {
    let get = |id| entity_map.get(&id).copied();
    match rel {
        SketchRelation::Coincident(a, b) => Some(SketchRelation::Coincident(get(a)?, get(b)?)),
        SketchRelation::Horizontal(a) => Some(SketchRelation::Horizontal(get(a)?)),
        SketchRelation::Vertical(a) => Some(SketchRelation::Vertical(get(a)?)),
        SketchRelation::Parallel(a, b) => Some(SketchRelation::Parallel(get(a)?, get(b)?)),
        SketchRelation::Perpendicular(a, b) => {
            Some(SketchRelation::Perpendicular(get(a)?, get(b)?))
        }
        SketchRelation::Tangent(a, b) => Some(SketchRelation::Tangent(get(a)?, get(b)?)),
        SketchRelation::Equal(a, b) => Some(SketchRelation::Equal(get(a)?, get(b)?)),
        SketchRelation::Concentric(a, b) => Some(SketchRelation::Concentric(get(a)?, get(b)?)),
        SketchRelation::Midpoint { point, line } => Some(SketchRelation::Midpoint {
            point: get(point)?,
            line: get(line)?,
        }),
        SketchRelation::Symmetric { a, b, axis } => Some(SketchRelation::Symmetric {
            a: get(a)?,
            b: get(b)?,
            axis: get(axis)?,
        }),
        SketchRelation::Fix(a) => Some(SketchRelation::Fix(get(a)?)),
    }
}

fn copy_dimensions(
    sketch: Sketch,
    source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
    entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Result<Sketch, bone_document::SketchEditError> {
    let dims: Vec<SketchDimension> = sketch
        .dimensions()
        .iter()
        .map(|(_, d)| *d)
        .filter(|d| dimension_is_mirrorable(d, source_ids, axis_id))
        .filter_map(|d| remap_dimension(d, entity_map))
        .collect();
    dims.into_iter().try_fold(sketch, |acc, dim| {
        match acc
            .clone()
            .apply(bone_document::SketchEdit::AddDimension(dim))
        {
            Ok((next, _)) => Ok(next),
            Err(e) => {
                tracing::warn!(error = %e, dimension = ?dim, "mirror: skipped dimension");
                Ok(acc)
            }
        }
    })
}

fn dimension_is_mirrorable(
    dim: &SketchDimension,
    source_ids: &std::collections::BTreeSet<bone_types::SketchEntityId>,
    axis_id: bone_types::SketchEntityId,
) -> bool {
    let refs: Vec<_> = dim.references().into_iter().collect();
    let touches_source = refs.iter().any(|id| source_ids.contains(id));
    let all_known = refs
        .iter()
        .all(|id| source_ids.contains(id) || *id == axis_id);
    touches_source && all_known
}

fn remap_dimension(
    dim: SketchDimension,
    entity_map: &std::collections::BTreeMap<bone_types::SketchEntityId, bone_types::SketchEntityId>,
) -> Option<SketchDimension> {
    let get = |id| entity_map.get(&id).copied();
    match dim {
        SketchDimension::Linear { a, b, value, kind } => Some(SketchDimension::Linear {
            a: get(a)?,
            b: get(b)?,
            value,
            kind,
        }),
        SketchDimension::Radius {
            target,
            value,
            kind,
        } => Some(SketchDimension::Radius {
            target: get(target)?,
            value,
            kind,
        }),
        SketchDimension::Diameter {
            target,
            value,
            kind,
        } => Some(SketchDimension::Diameter {
            target: get(target)?,
            value,
            kind,
        }),
        SketchDimension::Angular { a, b, value, kind } => Some(SketchDimension::Angular {
            a: get(a)?,
            b: get(b)?,
            value,
            kind,
        }),
    }
}

fn add_point(
    sketch: Sketch,
    at: Point2,
) -> Result<(Sketch, bone_types::SketchEntityId), bone_document::SketchEditError> {
    let (next, outcome) = sketch.apply(bone_document::SketchEdit::AddEntity(
        SketchEntity::point(at),
    ))?;
    let EditOutcome::Entity(id) = outcome else {
        unreachable!("AddEntity must yield Entity outcome")
    };
    Ok((next, id))
}

fn require_point(
    sketch: &Sketch,
    id: bone_types::SketchEntityId,
) -> Result<Point2, bone_document::SketchEditError> {
    match sketch
        .entities()
        .get(id)
        .ok_or(bone_document::SketchEditError::EntityNotFound(id))?
    {
        SketchEntity::Point(p) => Ok(p.at()),
        _ => Err(bone_document::SketchEditError::ExpectedPoint(id)),
    }
}

fn lookup_point(sketch: &Sketch, id: bone_types::SketchEntityId) -> Option<Point2> {
    match sketch.entities().get(id)? {
        SketchEntity::Point(p) => Some(p.at()),
        _ => None,
    }
}

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,
        dimension_edit: None,
        plane_picked: None,
        sketch_activated: None,
        sketch_rename: None,
        exit_sketch: false,
        confirm_action: None,
        menu_action: None,
        settings_change: None,
    }
}

#[allow(
    clippy::too_many_arguments,
    reason = "popup overlay dispatch threads every transient surface"
)]
fn apply_popup_overlays(
    overlay: &mut Vec<bone_ui::widgets::WidgetPaint>,
    dim_outcome: Option<&DimensionEditorOutcome>,
    conflict_outcome: Option<&DimConflictOutcome>,
    picker_outcome: Option<&file_menu::PickerModalOutcome>,
    overwrite_outcome: Option<&OverwriteOutcome>,
    discard_outcome: Option<&DiscardOutcome>,
    notification_outcome: Option<&NotificationOutcome>,
    shortcut_bar_outcome: Option<&shortcut_bar::ShortcutBarOutcome>,
) -> 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,
    );
    let picker_closing = picker_outcome.and_then(|o| o.command.as_ref()).is_some();
    extend_when_open(
        overlay,
        picker_outcome.map(|o| o.paints.as_slice()),
        picker_closing,
    );
    let overwrite_closing = matches!(
        overwrite_outcome.map(|o| o.action),
        Some(OverwriteAction::Replace | OverwriteAction::Cancel),
    );
    extend_when_open(
        overlay,
        overwrite_outcome.map(|o| o.paints.as_slice()),
        overwrite_closing,
    );
    let discard_closing = matches!(
        discard_outcome.map(|o| o.action),
        Some(DiscardAction::Confirm | DiscardAction::Cancel),
    );
    extend_when_open(
        overlay,
        discard_outcome.map(|o| o.paints.as_slice()),
        discard_closing,
    );
    if let Some(notification) = notification_outcome {
        overlay.extend(notification.paints.iter().cloned());
    }
    let bar_closing = shortcut_bar_outcome.is_some_and(|o| o.dismissed || o.activated.is_some());
    extend_when_open(
        overlay,
        shortcut_bar_outcome.map(|o| o.paints.as_slice()),
        bar_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());
    }
}

struct FrameOutcomes {
    frame: shell::ShellFrame,
    hotkey_actions: Vec<ActionId>,
    dim: Option<DimensionEditorOutcome>,
    dim_conflict: Option<DimConflictOutcome>,
    picker: Option<file_menu::PickerModalOutcome>,
    overwrite: Option<OverwriteOutcome>,
    discard: Option<DiscardOutcome>,
    notification: Option<NotificationOutcome>,
    shortcut_bar: Option<shortcut_bar::ShortcutBarOutcome>,
}

fn strip_plain_letter_chords(input: &mut InputSnapshot) {
    input.keys_pressed.retain(|event| {
        !matches!(event.code, bone_ui::input::KeyCode::Char(_))
            || event.modifiers != ModifierMask::NONE
    });
}

#[allow(
    clippy::too_many_arguments,
    reason = "run_frame_ui threads every per-frame UI subsystem"
)]
fn run_frame_ui(
    state: &mut RenderState,
    theme: Arc<Theme>,
    input: &mut InputSnapshot,
    hits: &mut HitFrame,
    a11y: &mut AccessTreeBuilder,
    scopes: &HotkeyScopes,
    layout_size: LayoutSize,
    cursor_world: Option<Point2>,
) -> FrameOutcomes {
    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,
        &state.settings,
        layout_size,
        cursor_world,
    );
    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 picker_outcome = state
        .file_picker
        .as_mut()
        .map(|session| file_menu::render(&mut ctx, session, layout_size));
    let overwrite_outcome = state
        .pending_overwrite
        .is_some()
        .then(|| render_overwrite_modal(&mut ctx, layout_size));
    let discard_outcome = state
        .pending_discard
        .is_some()
        .then(|| render_discard_modal(&mut ctx, layout_size));
    let notification_outcome = state
        .notification
        .as_ref()
        .map(|notification| render_notification_toast(&mut ctx, layout_size, notification));
    let is_sketch = state.mode.is_sketch();
    let shortcut_bar_outcome = state
        .shortcut_bar
        .map(|bar_state| shortcut_bar::render(&mut ctx, bar_state, layout_size, is_sketch));
    let any_modal_open = state.shell.state.keyboard_dialog_open
        || conflict_outcome.is_some()
        || dim_outcome.is_some()
        || picker_outcome.is_some()
        || overwrite_outcome.is_some()
        || discard_outcome.is_some()
        || shortcut_bar_outcome.is_some();
    if !any_modal_open && ctx.focus.is_text_input_focused() {
        strip_plain_letter_chords(ctx.input);
    }
    let mut actions = if any_modal_open {
        Vec::new()
    } else {
        ctx.dispatch_hotkeys(scopes)
    };
    if let Some(activated) = shortcut_bar_outcome.as_ref().and_then(|o| o.activated) {
        actions.push(activated);
    }
    FrameOutcomes {
        frame,
        hotkey_actions: actions,
        dim: dim_outcome,
        dim_conflict: conflict_outcome,
        picker: picker_outcome,
        overwrite: overwrite_outcome,
        discard: discard_outcome,
        notification: notification_outcome,
        shortcut_bar: shortcut_bar_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,
    }
}

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

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

fn render_overwrite_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> OverwriteOutcome {
    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("file.overwrite"));
    let response = show_confirmation(
        ctx,
        ConfirmationDialog {
            id,
            viewport,
            size: dialog_size,
            title: strings::FILE_OVERWRITE_TITLE,
            message: strings::FILE_OVERWRITE_MESSAGE,
            confirm_label: strings::FILE_OVERWRITE_REPLACE,
            cancel_label: strings::FILE_OVERWRITE_CANCEL,
            destructive: true,
        },
    );
    let action = match response.outcome {
        Some(ConfirmationOutcome::Confirm) => OverwriteAction::Replace,
        Some(ConfirmationOutcome::Cancel) => OverwriteAction::Cancel,
        None => OverwriteAction::Idle,
    };
    OverwriteOutcome {
        paints: response.paint,
        action,
    }
}

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

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

fn render_discard_modal(ctx: &mut FrameCtx<'_>, layout_size: LayoutSize) -> DiscardOutcome {
    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(460.0), LayoutPx::new(190.0));
    let id = WidgetId::ROOT.child(WidgetKey::new("file.discard"));
    let response = show_confirmation(
        ctx,
        ConfirmationDialog {
            id,
            viewport,
            size: dialog_size,
            title: strings::FILE_DISCARD_TITLE,
            message: strings::FILE_DISCARD_MESSAGE,
            confirm_label: strings::FILE_DISCARD_CONFIRM,
            cancel_label: strings::FILE_DISCARD_CANCEL,
            destructive: true,
        },
    );
    let action = match response.outcome {
        Some(ConfirmationOutcome::Confirm) => DiscardAction::Confirm,
        Some(ConfirmationOutcome::Cancel) => DiscardAction::Cancel,
        None => DiscardAction::Idle,
    };
    DiscardOutcome {
        paints: response.paint,
        action,
    }
}

fn apply_discard_outcome(state: &mut RenderState, outcome: Option<DiscardOutcome>) {
    let Some(outcome) = outcome else { return };
    match outcome.action {
        DiscardAction::Idle => {}
        DiscardAction::Cancel => {
            state.pending_discard = None;
        }
        DiscardAction::Confirm => {
            let Some(pending) = state.pending_discard.take() else {
                return;
            };
            match pending {
                PendingDiscard::New => apply_new_document(state),
                PendingDiscard::Open(path) => apply_open_folder(state, path),
            }
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
struct NotificationOutcome {
    paints: Vec<bone_ui::widgets::WidgetPaint>,
    dismissed: bool,
}

fn render_notification_toast(
    ctx: &mut FrameCtx<'_>,
    layout_size: LayoutSize,
    notification: &Notification,
) -> NotificationOutcome {
    use bone_ui::widgets::{Button, ButtonVariant, WidgetPaint, show_button};
    use bone_ui::{WidgetId, WidgetKey};
    let theme = ctx.theme();
    let bg = match notification.kind {
        NotificationKind::Info => theme.colors.surface(bone_ui::theme::SurfaceLevel::L2),
        NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::SUBTLE_BG),
    };
    let fg = match notification.kind {
        NotificationKind::Info => theme.colors.text_primary(),
        NotificationKind::Error => theme.colors.danger.step(bone_ui::theme::Step12::TEXT_HIGH),
    };
    let toast_width = layout_size.width.value().clamp(280.0, 420.0);
    let toast_height = if notification.detail.is_some() {
        72.0
    } else {
        44.0
    };
    let margin = 24.0;
    let toast_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(margin),
            LayoutPx::new(layout_size.height.value() - toast_height - margin),
        ),
        LayoutSize::new(LayoutPx::new(toast_width), LayoutPx::new(toast_height)),
    );
    let id = WidgetId::ROOT.child(WidgetKey::new("notification.toast"));
    let mut paints = vec![WidgetPaint::Surface {
        rect: toast_rect,
        fill: bg,
        border: Some(bone_ui::theme::Border {
            width: bone_ui::theme::StrokeWidth::HAIRLINE,
            color: theme.colors.neutral.step(bone_ui::theme::Step12::BORDER),
        }),
        radius: theme.radius.sm,
        elevation: Some(theme.elevation.level1),
    }];
    let headline_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(toast_rect.origin.x.value() + 16.0),
            LayoutPx::new(toast_rect.origin.y.value() + 12.0),
        ),
        LayoutSize::new(LayoutPx::new(toast_width - 120.0), LayoutPx::new(20.0)),
    );
    paints.push(WidgetPaint::Label {
        rect: headline_rect,
        text: bone_ui::widgets::LabelText::Key(notification.headline),
        color: fg,
        role: theme.typography.label,
    });
    if let Some(detail) = &notification.detail {
        let detail_rect = LayoutRect::new(
            LayoutPos::new(
                LayoutPx::new(toast_rect.origin.x.value() + 16.0),
                LayoutPx::new(toast_rect.origin.y.value() + 36.0),
            ),
            LayoutSize::new(LayoutPx::new(toast_width - 32.0), LayoutPx::new(24.0)),
        );
        paints.push(WidgetPaint::Label {
            rect: detail_rect,
            text: bone_ui::widgets::LabelText::Owned(detail.clone()),
            color: theme.colors.text_secondary(),
            role: theme.typography.caption,
        });
    }
    let dismiss_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(toast_rect.origin.x.value() + toast_width - 96.0),
            LayoutPx::new(toast_rect.origin.y.value() + 8.0),
        ),
        LayoutSize::new(LayoutPx::new(84.0), LayoutPx::new(28.0)),
    );
    let dismiss_id = id.child(WidgetKey::new("dismiss"));
    let response = show_button(
        ctx,
        Button::new(
            dismiss_id,
            dismiss_rect,
            strings::NOTIFY_DISMISS,
            ButtonVariant::Secondary,
        ),
    );
    paints.extend(response.paint);
    NotificationOutcome {
        paints,
        dismissed: response.activated,
    }
}

fn apply_notification_outcome(state: &mut RenderState, outcome: Option<NotificationOutcome>) {
    let Some(outcome) = outcome else { return };
    if outcome.dismissed {
        state.notification = None;
    }
}

fn apply_overwrite_outcome(state: &mut RenderState, outcome: Option<OverwriteOutcome>) {
    let Some(outcome) = outcome else { return };
    match outcome.action {
        OverwriteAction::Idle => {}
        OverwriteAction::Cancel => {
            state.pending_overwrite = None;
        }
        OverwriteAction::Replace => {
            if let Some(folder) = state.pending_overwrite.take() {
                perform_save_to(state, folder);
            }
        }
    }
}

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

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, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) {
        Ok((next, EditOutcome::Dimension(id))) => (next, id),
        Ok(_) => {
            tracing::warn!(?proto, "add dimension produced unexpected outcome");
            return;
        }
        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.selection = Selection::Dimension(dim_id);
    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, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) {
        Ok((next, EditOutcome::Dimension(id))) => (next, id),
        Ok(_) => {
            tracing::warn!(
                ?driven_proto,
                "add driven dimension produced unexpected outcome"
            );
            state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict();
            return;
        }
        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.selection = Selection::Dimension(dim_id);
    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_dimension_edit(state: &mut RenderState, edit: Option<shell::DimensionEdit>) {
    let Some(edit) = edit else { return };
    let Mode::Sketch { sketch_id, .. } = state.mode else {
        return;
    };
    let Some(sketch) = state.document.sketch(sketch_id).cloned() else {
        return;
    };
    let after = match sketch.apply(SketchEdit::UpdateDimensionValue {
        id: edit.id,
        value: edit.value,
    }) {
        Ok((next, _)) => next,
        Err(e) => {
            tracing::warn!(error = %e, ?edit, "update dimension value failed");
            return;
        }
    };
    let solved = match after.solve() {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(error = %e, ?edit, "solve after dimension edit failed");
            return;
        }
    };
    state.undo.record(state.document.clone());
    state.document.replace_sketch(sketch_id, solved);
    refresh_active_scene(state);
}

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::OpenSettings) => {
            state.shell.state.settings_dialog_open = true;
        }
        Some(shell::MenuAction::OpenKeyboardCustomize) => {
            state.shell.state.keyboard_dialog_open = true;
        }
        Some(shell::MenuAction::NewDocument) => {
            request_new_document(state);
        }
        Some(shell::MenuAction::OpenDocument) => {
            open_picker(state, bone_ui::widgets::FilePickerMode::Open, None);
        }
        Some(shell::MenuAction::SaveDocument) => {
            apply_save_in_place(state);
        }
        Some(shell::MenuAction::SaveDocumentAs) => {
            let seed = state.document.name().to_owned();
            open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed));
        }
        Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch)
        | None => {}
    }
}

fn request_new_document(state: &mut RenderState) {
    if is_dirty(state) {
        state.pending_discard = Some(PendingDiscard::New);
    } else {
        apply_new_document(state);
    }
}

fn request_open_folder(state: &mut RenderState, path: PathBuf) {
    if is_dirty(state) {
        state.pending_discard = Some(PendingDiscard::Open(path));
    } else {
        apply_open_folder(state, path);
    }
}

fn is_dirty(state: &RenderState) -> bool {
    state.last_saved.as_ref() != Some(&state.document)
}

fn apply_new_document(state: &mut RenderState) {
    let sketch = default_sketch();
    let scene = match SketchScene::extract(&sketch) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(error = %e, "scene extract failed on new document");
            notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string());
            return;
        }
    };
    let (document, sketch_id) = initial_document(sketch);
    state.last_saved = Some(document.clone());
    state.document = document;
    state.plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]);
    state.scene = scene;
    state.mode = Mode::Idle;
    state.selection = Selection::default();
    state.current_folder = None;
    state.pending_overwrite = None;
    let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else {
        unreachable!("UNDO_CAPACITY constant is non-zero");
    };
    state.undo = UndoStack::with_capacity(undo_capacity);
    state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect);
}

fn open_picker(
    state: &mut RenderState,
    mode: bone_ui::widgets::FilePickerMode,
    seed_filename: Option<String>,
) {
    if state.file_picker.is_some() || state.native_picker.is_some() {
        return;
    }
    let starting_folder = state
        .current_folder
        .as_ref()
        .map(|f| f.path().to_owned())
        .or_else(|| {
            state
                .documents_root
                .is_dir()
                .then(|| state.documents_root.clone())
        });
    let title_key = match mode {
        bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN,
        bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS,
    };
    let accept_key = match mode {
        bone_ui::widgets::FilePickerMode::Open => strings::FILE_PICKER_OPEN,
        bone_ui::widgets::FilePickerMode::Save => strings::FILE_PICKER_SAVE,
    };
    let title = state.strings.resolve(title_key).to_owned();
    let accept_label = state.strings.resolve(accept_key).to_owned();
    let native_req = native_picker::Request {
        mode,
        title: title.as_str(),
        accept_label: accept_label.as_str(),
        seed_filename: seed_filename.as_deref(),
        current_folder: starting_folder.as_deref(),
    };
    match native_picker::spawn(native_req) {
        Ok(handle) => {
            state.native_picker = Some(handle);
            return;
        }
        Err(native_picker::SpawnError::Unsupported) => {
            tracing::debug!("native picker unavailable, falling back to custom picker");
        }
    }
    open_custom_picker(state, mode, seed_filename);
}

fn open_custom_picker(
    state: &mut RenderState,
    mode: bone_ui::widgets::FilePickerMode,
    seed_filename: Option<String>,
) {
    let entries = match file_menu::scan_document_folders(&state.documents_root) {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(error = %e, path = %state.documents_root.display(), "scan documents root failed");
            notify_error(state, strings::NOTIFY_SCAN_FAILED, e.to_string());
            Vec::new()
        }
    };
    state.file_picker = Some(file_menu::FilePickerSession::open(
        state.documents_root.clone(),
        mode,
        seed_filename,
        entries,
    ));
}

fn poll_native_picker(state: &mut RenderState) {
    let Some(handle) = state.native_picker.as_ref() else {
        return;
    };
    let outcome = match handle.poll() {
        native_picker::PollState::Pending => return,
        native_picker::PollState::Ready(o) => o,
    };
    let mode = handle.mode;
    state.native_picker = None;
    match outcome {
        native_picker::NativeOutcome::Path(path) => match mode {
            bone_ui::widgets::FilePickerMode::Open => request_open_folder(state, path),
            bone_ui::widgets::FilePickerMode::Save => apply_save_as(state, path),
        },
        native_picker::NativeOutcome::Cancelled => {}
        native_picker::NativeOutcome::Error(message) => {
            tracing::warn!(error = %message, "native picker errored, falling back to custom picker");
            let seed = matches!(mode, bone_ui::widgets::FilePickerMode::Save)
                .then(|| state.document.name().to_owned());
            open_custom_picker(state, mode, seed);
        }
    }
}

fn apply_save_in_place(state: &mut RenderState) {
    let Some(folder) = state.current_folder.clone() else {
        let seed = state.document.name().to_owned();
        open_picker(state, bone_ui::widgets::FilePickerMode::Save, Some(seed));
        return;
    };
    if let Err(e) = bone_document::save(&state.document, &folder) {
        tracing::warn!(error = %e, path = %folder.path().display(), "save failed");
        notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string());
        return;
    }
    state.last_saved = Some(state.document.clone());
    notify_info(state, strings::NOTIFY_SAVED, None);
}

fn apply_picker_command(state: &mut RenderState, command: file_menu::PickerCommand) {
    state.file_picker = None;
    match command {
        file_menu::PickerCommand::Cancel => {}
        file_menu::PickerCommand::OpenFolder(path) => request_open_folder(state, path),
        file_menu::PickerCommand::SaveAs(path) => apply_save_as(state, path),
    }
}

fn apply_open_folder(state: &mut RenderState, path: PathBuf) {
    let folder = DocumentFolder::new(path);
    let document = match bone_document::load(&folder) {
        Ok(d) => d,
        Err(e) => {
            tracing::warn!(error = %e, path = %folder.path().display(), "load failed");
            notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string());
            return;
        }
    };
    install_loaded_document(state, document, Some(folder));
}

fn apply_save_as(state: &mut RenderState, path: PathBuf) {
    let folder = DocumentFolder::new(path);
    let in_place = state
        .current_folder
        .as_ref()
        .is_some_and(|current| same_folder(current.path(), folder.path()));
    if folder.document_file().is_file() && !in_place {
        state.pending_overwrite = Some(folder);
        return;
    }
    perform_save_to(state, folder);
}

fn perform_save_to(state: &mut RenderState, folder: DocumentFolder) {
    let prior_name = state.document.name().to_owned();
    state
        .document
        .set_name(folder_display_name(&folder, &state.strings));
    if let Err(e) = bone_document::save(&state.document, &folder) {
        tracing::warn!(error = %e, path = %folder.path().display(), "save as failed");
        state.document.set_name(prior_name);
        notify_error(state, strings::NOTIFY_SAVE_FAILED, e.to_string());
        return;
    }
    state.current_folder = Some(folder);
    state.last_saved = Some(state.document.clone());
    notify_info(state, strings::NOTIFY_SAVED, None);
}

fn folder_display_name(folder: &DocumentFolder, string_table: &StringTable) -> String {
    folder.path().file_name().map_or_else(
        || {
            string_table
                .resolve(strings::DEFAULT_DOCUMENT_NAME)
                .to_owned()
        },
        |s| s.to_string_lossy().into_owned(),
    )
}

fn same_folder(a: &Path, b: &Path) -> bool {
    match (resolve_path(a), resolve_path(b)) {
        (Some(x), Some(y)) => x == y,
        _ => false,
    }
}

fn resolve_path(path: &Path) -> Option<PathBuf> {
    if let Ok(canon) = std::fs::canonicalize(path) {
        return Some(canon);
    }
    let parent = path.parent()?;
    let file_name = path.file_name()?;
    let parent_canon = std::fs::canonicalize(parent).ok()?;
    Some(parent_canon.join(file_name))
}

fn notify_error(state: &mut RenderState, headline: bone_ui::strings::StringKey, detail: String) {
    state.notification = Some(Notification {
        kind: NotificationKind::Error,
        headline,
        detail: Some(detail),
    });
}

fn notify_info(
    state: &mut RenderState,
    headline: bone_ui::strings::StringKey,
    detail: Option<String>,
) {
    state.notification = Some(Notification {
        kind: NotificationKind::Info,
        headline,
        detail,
    });
}

fn notify_stub(state: &mut RenderState, label: bone_ui::strings::StringKey) {
    let detail = state.strings.resolve(label).to_owned();
    tracing::info!(label = %detail, "hotkey action stub");
    state.notification = Some(Notification {
        kind: NotificationKind::Info,
        headline: strings::NOTIFY_COMING_SOON,
        detail: Some(detail),
    });
}

fn apply_shortcut_bar_outcome(
    state: &mut RenderState,
    outcome: Option<&shortcut_bar::ShortcutBarOutcome>,
) {
    let Some(outcome) = outcome else { return };
    if outcome.dismissed || outcome.activated.is_some() {
        state.shortcut_bar = None;
    }
}

fn install_loaded_document(
    state: &mut RenderState,
    document: Document,
    folder: Option<DocumentFolder>,
) {
    let plane_sketches = plane_sketches_from(&document);
    let active_sketch_id = plane_sketches.get(&Plane::Xy).copied();
    state.last_saved = Some(document.clone());
    state.document = document;
    state.plane_sketches = plane_sketches;
    state.mode = Mode::Idle;
    state.selection = Selection::default();
    state.current_folder = folder;
    state.pending_overwrite = None;
    let Some(undo_capacity) = NonZeroUsize::new(UNDO_CAPACITY) else {
        unreachable!("UNDO_CAPACITY constant is non-zero");
    };
    state.undo = UndoStack::with_capacity(undo_capacity);
    let scene_attempt = active_sketch_id
        .and_then(|id| state.document.sketch(id))
        .map(SketchScene::extract);
    state.scene = match scene_attempt {
        None => SketchScene::empty(),
        Some(Ok(scene)) => scene,
        Some(Err(e)) => {
            tracing::warn!(error = %e, "scene extract on load failed");
            notify_error(state, strings::NOTIFY_LOAD_FAILED, e.to_string());
            SketchScene::empty()
        }
    };
    state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect);
}

fn plane_sketches_from(document: &Document) -> BTreeMap<Plane, SketchId> {
    document
        .sketches()
        .map(|(id, _)| (Plane::Xy, id))
        .take(1)
        .collect()
}

fn persist_settings(state: &RenderState) {
    settings::save(&state.settings);
}

fn apply_settings_change(state: &mut RenderState, change: Option<settings::Settings>) {
    let Some(next) = change else { return };
    let overrides_changed = next.hotkey_overrides != state.settings.hotkey_overrides;
    if !overrides_changed {
        state.settings = next;
        persist_settings(state);
        return;
    }
    match hotkeys::compose_table(&next.hotkey_overrides) {
        Ok(table) => {
            state.hotkeys = table;
            state.settings = next;
            persist_settings(state);
        }
        Err(error) => {
            tracing::warn!(?error, "hotkey override rejected, retaining prior settings");
            state.shell.state.hotkey_capture.clear();
            notify_error(state, strings::NOTIFY_HOTKEY_CONFLICT, format!("{error}"));
        }
    }
}

fn apply_sketch_rename(state: &mut RenderState, request: Option<shell::SketchRenameRequest>) {
    let Some(req) = request else { return };
    apply_sketch_rename_into(&mut state.document, &mut state.undo, req);
}

fn apply_sketch_rename_into(
    document: &mut Document,
    undo: &mut UndoStack,
    request: shell::SketchRenameRequest,
) {
    let shell::SketchRenameRequest { id, label } = request;
    if document.sketch_label(id).is_some_and(|l| l == label.trim()) {
        return;
    }
    let snapshot = document.clone();
    match document.rename_sketch(id, &label) {
        Ok(()) => undo.record(snapshot),
        Err(e) => tracing::warn!(error = %e, ?id, "sketch rename rejected"),
    }
}

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),
        KeyCode::F2 => Some(NamedKey::F2),
        _ => 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),
        WinitNamed::F2 => Some(NamedKey::F2),
        _ => 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 {
        redraw: None,
        render: None,
        input: InputState::default(),
    };
    event_loop.run_app(&mut app)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sketch_mode::SketchSession;
    use bone_ui::hotkey::KeyChord;
    use bone_ui::input::{KeyChar, KeyCode, KeyEvent, NamedKey};

    #[test]
    fn strip_plain_letter_chords_removes_chars_with_no_modifiers() {
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let plain_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::NONE);
        let ctrl_s = KeyEvent::new(KeyCode::Char(KeyChar::from_char('s')), ModifierMask::CTRL);
        let esc = KeyEvent::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
        input.keys_pressed = vec![plain_s, ctrl_s, esc];
        strip_plain_letter_chords(&mut input);
        assert_eq!(
            input.keys_pressed,
            vec![ctrl_s, esc],
            "strip removes plain letters, keeps modified chords and named keys"
        );
    }

    #[test]
    fn strip_plain_letter_chords_is_idempotent_when_no_chars() {
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let enter = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE);
        input.keys_pressed = vec![enter];
        strip_plain_letter_chords(&mut input);
        assert_eq!(input.keys_pressed, vec![enter]);
    }

    #[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,
            dimension_edit: None,
            plane_picked: None,
            sketch_activated: None,
            sketch_rename: None,
            exit_sketch: false,
            confirm_action: None,
            menu_action: None,
            settings_change: 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, false, &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, false, &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, false, &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, false, &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(), true, &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(), true, &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(), true, &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, false, &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, false, &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, false, &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, false, &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, false, &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, false, &xy_only()) else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Line));
    }

    fn doc_with_default_sketch() -> (Document, SketchId) {
        let sketch = bone_document::Sketch::new(Plane::Xy.basis());
        let mut document = Document::new(DocumentId::default(), "Untitled".to_owned());
        let id = SketchId::default();
        document.insert_sketch(id, "Sketch1".to_owned(), sketch);
        (document, id)
    }

    #[test]
    fn apply_sketch_rename_into_writes_label_and_records_undo_on_change() {
        let (mut document, id) = doc_with_default_sketch();
        let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
        apply_sketch_rename_into(
            &mut document,
            &mut undo,
            shell::SketchRenameRequest {
                id,
                label: "Profile".to_owned(),
            },
        );
        assert_eq!(document.sketch_label(id), Some("Profile"));
        assert_eq!(
            undo.past_len(),
            1,
            "successful rename records one undo snapshot"
        );
    }

    #[test]
    fn apply_sketch_rename_into_drops_empty_label_without_undo() {
        let (mut document, id) = doc_with_default_sketch();
        let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
        apply_sketch_rename_into(
            &mut document,
            &mut undo,
            shell::SketchRenameRequest {
                id,
                label: "   ".to_owned(),
            },
        );
        assert_eq!(document.sketch_label(id), Some("Sketch1"));
        assert_eq!(undo.past_len(), 0);
    }

    #[test]
    fn apply_sketch_rename_into_skips_no_op_against_trimmed_match() {
        let (mut document, id) = doc_with_default_sketch();
        let mut undo = UndoStack::with_capacity(NonZeroUsize::MIN);
        apply_sketch_rename_into(
            &mut document,
            &mut undo,
            shell::SketchRenameRequest {
                id,
                label: "  Sketch1 ".to_owned(),
            },
        );
        assert_eq!(document.sketch_label(id), Some("Sketch1"));
        assert_eq!(
            undo.past_len(),
            0,
            "trimmed-equal rename must not record undo"
        );
    }

    #[test]
    fn sketch_activated_from_idle_enters_that_sketch_without_plane_map() {
        let sketch_id = SketchId::default();
        let frame = shell::ShellFrame {
            sketch_activated: Some(sketch_id),
            ..empty_frame()
        };
        let next = next_mode(Mode::Idle, &frame, false, &BTreeMap::new());
        assert_eq!(next, Mode::enter_sketch(sketch_id));
    }

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

    #[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, false, &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);
    }

    fn horizontal_line_fixture() -> (
        Sketch,
        bone_types::SketchEntityId,
        bone_types::SketchEntityId,
        bone_types::SketchEntityId,
    ) {
        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, line) = tools::add_line(sketch, a, b, false);
        let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line));
        let sketch = tools::add_relation(sketch, SketchRelation::Fix(a));
        (sketch, a, b, line)
    }

    fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 {
        let SketchEntity::Point(p) = sketch.entities()[id] else {
            panic!("expected point entity");
        };
        p.at()
    }

    #[test]
    fn drag_resolved_translates_endpoint_and_preserves_horizontal() {
        let (sketch, a, b, _) = horizontal_line_fixture();
        let drag = DragSession {
            entity: b,
            press: Point2::from_mm(10.0, 0.0),
            pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]),
        };
        let cursor = Point2::from_mm(13.0, 0.0);
        let Some(next) = drag_resolved(&sketch, drag, cursor) else {
            panic!("solve_with_drag_pins must converge on horizontal line")
        };
        let (bx, by) = point_at(&next, b).coords_mm();
        assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}");
        assert!(by.abs() < 1e-6, "horizontal preserved: by={by}");
        let (ax, ay) = point_at(&next, a).coords_mm();
        assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}");
        assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}");
    }

    #[test]
    fn mirror_axis_reflects_across_horizontal_x_axis() {
        let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
        let (rx, ry) = axis.reflect(Point2::from_mm(3.0, 4.0)).coords_mm();
        assert!((rx - 3.0).abs() < 1e-9, "x preserved across x-axis");
        assert!((ry - -4.0).abs() < 1e-9, "y inverted across x-axis");
    }

    #[test]
    fn mirror_axis_reflects_across_diagonal() {
        let axis = MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 1.0));
        let (rx, ry) = axis.reflect(Point2::from_mm(2.0, 0.0)).coords_mm();
        assert!((rx - 0.0).abs() < 1e-9, "x reflects to y on y=x diagonal");
        assert!((ry - 2.0).abs() < 1e-9, "y reflects to x on y=x diagonal");
    }

    #[test]
    fn mirror_axis_detects_degenerate_zero_length() {
        let axis = MirrorAxis::from_points(Point2::from_mm(1.0, 1.0), Point2::from_mm(1.0, 1.0));
        assert!(
            axis.is_degenerate(),
            "coincident endpoints must be degenerate"
        );
    }

    #[test]
    fn mirror_targets_creates_reflected_circle_with_symmetric_relations() {
        let (sketch, _, _, axis_line) = horizontal_line_fixture();
        let (sketch, center) = tools::add_point(sketch, Point2::from_mm(0.0, 3.0));
        let (sketch, circle_id) =
            tools::add_circle(sketch, center, Length::new::<millimeter>(1.0), false);
        let axis_geom =
            MirrorAxis::from_points(Point2::from_mm(0.0, 0.0), Point2::from_mm(1.0, 0.0));
        let source_ids: std::collections::BTreeSet<_> = [center, circle_id].into_iter().collect();
        let Ok(mirrored) = mirror_targets(sketch.clone(), &source_ids, axis_line, &axis_geom)
        else {
            panic!("circle mirror must succeed");
        };
        let new_circles: Vec<_> = mirrored
            .entities()
            .iter()
            .filter_map(|(_, e)| match *e {
                SketchEntity::Circle(c) => Some(c),
                _ => None,
            })
            .collect();
        assert_eq!(new_circles.len(), 2, "original + mirrored circle");
        let new_center_pos = new_circles
            .iter()
            .map(|c| {
                let SketchEntity::Point(p) = mirrored.entities()[c.center()] else {
                    panic!("circle center is a point");
                };
                p.at().coords_mm()
            })
            .find(|(_, y)| *y < 0.0);
        let Some((cx, cy)) = new_center_pos else {
            panic!("mirrored circle must lie below x-axis");
        };
        assert!(cx.abs() < 1e-9 && (cy + 3.0).abs() < 1e-9, "({cx}, {cy})");
        let symmetric_count = mirrored
            .relations()
            .iter()
            .filter(
                |(_, r)| matches!(r, SketchRelation::Symmetric { axis, .. } if *axis == axis_line),
            )
            .count();
        assert!(
            symmetric_count >= 1,
            "mirror must emit at least one Symmetric relation tied to the axis",
        );
    }

    #[test]
    fn mirror_copies_horizontal_relation_to_mirrored_line() {
        use bone_document::SketchEdit;
        let sketch = Sketch::new(Plane::Xy.basis());
        let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0));
        let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0));
        let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true);
        let (sketch, source_p0) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0));
        let (sketch, source_p1) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0));
        let (sketch, source_line) = tools::add_line(sketch, source_p0, source_p1, false);
        let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(
            source_line,
        ))) else {
            panic!("seed Horizontal must apply");
        };
        let axis_geom =
            MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
        let source_ids: std::collections::BTreeSet<_> =
            [source_p0, source_p1, source_line].into_iter().collect();
        let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else {
            panic!("mirror must succeed");
        };
        let horizontal_lines: Vec<_> = mirrored
            .relations()
            .iter()
            .filter_map(|(_, r)| match r {
                SketchRelation::Horizontal(id) => Some(*id),
                _ => None,
            })
            .collect();
        assert_eq!(
            horizontal_lines.len(),
            2,
            "original + mirrored horizontal must both exist"
        );
    }

    #[test]
    fn construction_toggle_flips_line_flag() {
        let (sketch, _, _, line) = horizontal_line_fixture();
        let before = match sketch.entities()[line] {
            SketchEntity::Line(l) => l.for_construction(),
            _ => panic!("line"),
        };
        let Ok((next, _)) = sketch.apply(SketchEdit::SetConstruction {
            id: line,
            for_construction: !before,
        }) else {
            panic!("set construction must succeed");
        };
        let after = match next.entities()[line] {
            SketchEntity::Line(l) => l.for_construction(),
            _ => panic!("line"),
        };
        assert_ne!(before, after);
    }

    #[test]
    fn mirror_axis_detects_on_axis_point() {
        let axis = MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
        assert!(axis.is_on_axis(Point2::from_mm(2.0, 0.0)));
        assert!(axis.is_on_axis(Point2::from_mm(-5.0, 0.0)));
        assert!(!axis.is_on_axis(Point2::from_mm(2.0, 0.5)));
    }

    #[test]
    fn mirror_on_axis_source_point_is_identity_no_self_symmetric() {
        let sketch = Sketch::new(Plane::Xy.basis());
        let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0));
        let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0));
        let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true);
        let (sketch, on_axis_point) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0));
        let axis_geom =
            MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
        let source_ids: std::collections::BTreeSet<_> = [on_axis_point].into_iter().collect();
        let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else {
            panic!("mirror must succeed");
        };
        let point_count = mirrored
            .entities()
            .iter()
            .filter(|(_, e)| matches!(e, SketchEntity::Point(_)))
            .count();
        assert_eq!(
            point_count, 3,
            "on-axis source must not produce a duplicate point: {point_count}"
        );
        let symmetric_count = mirrored
            .relations()
            .iter()
            .filter(|(_, r)| matches!(r, SketchRelation::Symmetric { .. }))
            .count();
        assert_eq!(
            symmetric_count, 0,
            "on-axis source must not emit a self-pair Symmetric relation"
        );
    }

    #[test]
    fn mirror_copies_relation_referencing_axis_line() {
        use bone_document::SketchEdit;
        let sketch = Sketch::new(Plane::Xy.basis());
        let (sketch, axis_a) = tools::add_point(sketch, Point2::from_mm(-5.0, 0.0));
        let (sketch, axis_b) = tools::add_point(sketch, Point2::from_mm(5.0, 0.0));
        let (sketch, axis_line) = tools::add_line(sketch, axis_a, axis_b, true);
        let (sketch, off_a) = tools::add_point(sketch, Point2::from_mm(-2.0, 3.0));
        let (sketch, off_b) = tools::add_point(sketch, Point2::from_mm(2.0, 3.0));
        let (sketch, off_line) = tools::add_line(sketch, off_a, off_b, false);
        let Ok((sketch, _)) = sketch.apply(SketchEdit::AddRelation(SketchRelation::Parallel(
            off_line, axis_line,
        ))) else {
            panic!("seed Parallel(off_line, axis_line) must apply");
        };
        let axis_geom =
            MirrorAxis::from_points(Point2::from_mm(-5.0, 0.0), Point2::from_mm(5.0, 0.0));
        let source_ids: std::collections::BTreeSet<_> =
            [off_a, off_b, off_line].into_iter().collect();
        let Ok(mirrored) = mirror_targets(sketch, &source_ids, axis_line, &axis_geom) else {
            panic!("mirror must succeed");
        };
        let parallel_count = mirrored
            .relations()
            .iter()
            .filter(|(_, r)| matches!(r, SketchRelation::Parallel(_, b) if *b == axis_line))
            .count();
        assert_eq!(
            parallel_count, 2,
            "original + mirrored Parallel(line, axis_line) both expected: {parallel_count}",
        );
    }
}
