//! xfig's attribute (indicator) panel. Ported from xfig's `w_indpanel.c`
//! and the mode masks in `w_modepanel.c` / `w_indpanel.h` (xfig licence,
//! see LICENSE-xfig): one button per attribute, in xfig's order, each with
//! its label and a picture of the current value; only the attributes the
//! current mode uses are shown. Left click opens a choice panel, the middle
//! button decreases and the right button increases the value (xfig's
//! "Menu / Dec/Prev / Inc/Next").

use crate::theme::Tokens;
use crate::App;
use egui::{pos2, vec2, Color32, Painter, Pos2, Rect, Sense, Stroke, Ui};
use rustfig_engine::{arrow_choice, arrow_choice_of, AngleGeom, Indicator, LinkMode, SnapMode, ToolKind, UpdateFlag};
use rustfig_model::{area_fill_color, CapStyle, ColorRef, Document, JoinStyle, Justify, LineKind, Rgb};
use serde_json::{json, Value};

/// The attribute buttons, in xfig's order (`ind_switches` in `w_indpanel.c`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attr {
    Zoom,
    Grid,
    PointPosn,
    Depth,
    RotnAngle,
    NumSides,
    NumCopies,
    NumXCopies,
    NumYCopies,
    VAlign,
    HAlign,
    AngleGeom,
    PenColor,
    FillColor,
    FillStyle,
    ArcType,
    SmartLinks,
    LineWidth,
    LineStyle,
    TangentLength,
    JoinStyle,
    CapStyle,
    ArrowMode,
    ArrowType,
    ArrowSize,
    BoxRadius,
    DimLine,
    TextJust,
    TextAngle,
    TextFlags,
    FontSize,
    TextStep,
    Font,
}

impl Attr {
    pub const ORDER: [Attr; 33] = [
        Attr::Zoom,
        Attr::Grid,
        Attr::PointPosn,
        Attr::Depth,
        Attr::RotnAngle,
        Attr::NumSides,
        Attr::NumCopies,
        Attr::NumXCopies,
        Attr::NumYCopies,
        Attr::VAlign,
        Attr::HAlign,
        Attr::AngleGeom,
        Attr::PenColor,
        Attr::FillColor,
        Attr::FillStyle,
        Attr::ArcType,
        Attr::SmartLinks,
        Attr::LineWidth,
        Attr::LineStyle,
        Attr::TangentLength,
        Attr::JoinStyle,
        Attr::CapStyle,
        Attr::ArrowMode,
        Attr::ArrowType,
        Attr::ArrowSize,
        Attr::BoxRadius,
        Attr::DimLine,
        Attr::TextJust,
        Attr::TextAngle,
        Attr::TextFlags,
        Attr::FontSize,
        Attr::TextStep,
        Attr::Font,
    ];

    /// The button's label, one or two lines, as in xfig.
    pub fn label(self) -> (&'static str, &'static str) {
        match self {
            Attr::Zoom => ("Zoom", ""),
            Attr::Grid => ("Grid", "Mode"),
            Attr::PointPosn => ("Point", "Posn"),
            Attr::Depth => ("Depth", ""),
            Attr::RotnAngle => ("Rotn", "Angle"),
            Attr::NumSides => ("Num", "Sides"),
            Attr::NumCopies => ("Num", "Copies"),
            Attr::NumXCopies => ("Num X", "Copies"),
            Attr::NumYCopies => ("Num Y", "Copies"),
            Attr::DimLine => ("Dimension line", ""),
            Attr::VAlign => ("Vert", "Align"),
            Attr::HAlign => ("Horiz", "Align"),
            Attr::AngleGeom => ("Angle", "Geom"),
            Attr::PenColor => ("PenColor", ""),
            Attr::FillColor => ("FillColor", ""),
            Attr::FillStyle => ("Fill", "Style"),
            Attr::ArcType => ("Arc", "Type"),
            Attr::SmartLinks => ("Smart", "Links"),
            Attr::LineWidth => ("Line", "Width"),
            Attr::LineStyle => ("Line", "Style"),
            Attr::TangentLength => ("Tangent/Norm", "Length"),
            Attr::JoinStyle => ("Join", "Style"),
            Attr::CapStyle => ("Cap", "Style"),
            Attr::ArrowMode => ("Arrow", "Mode"),
            Attr::ArrowType => ("Arrow", "Type"),
            Attr::ArrowSize => ("Arrow Size", ""),
            Attr::BoxRadius => ("Box", "Curve"),
            Attr::TextJust => ("Text", "Just"),
            Attr::TextAngle => ("Text/Ellipse", "Angle"),
            Attr::TextFlags => ("Text Flags", ""),
            Attr::FontSize => ("Text Size", ""),
            Attr::TextStep => ("Text", "Step"),
            Attr::Font => ("Text", "Font"),
        }
    }

    /// The update flag this button carries in Update mode (xfig's
    /// `I_UPDATEMASK` buttons).
    pub fn update_flag(self) -> Option<UpdateFlag> {
        Some(match self {
            Attr::Depth => UpdateFlag::Depth,
            Attr::PenColor => UpdateFlag::PenColor,
            Attr::FillColor => UpdateFlag::FillColor,
            Attr::FillStyle => UpdateFlag::FillStyle,
            Attr::ArcType => UpdateFlag::ArcType,
            Attr::LineWidth => UpdateFlag::LineWidth,
            Attr::LineStyle => UpdateFlag::LineStyle,
            Attr::JoinStyle => UpdateFlag::JoinStyle,
            Attr::CapStyle => UpdateFlag::CapStyle,
            Attr::ArrowMode => UpdateFlag::ArrowMode,
            Attr::ArrowType => UpdateFlag::ArrowType,
            Attr::ArrowSize => UpdateFlag::ArrowSize,
            Attr::BoxRadius => UpdateFlag::BoxRadius,
            Attr::TextJust => UpdateFlag::TextJust,
            Attr::TextAngle => UpdateFlag::TextAngle,
            Attr::TextFlags => UpdateFlag::TextFlags,
            Attr::FontSize => UpdateFlag::FontSize,
            Attr::Font => UpdateFlag::Font,
            _ => return None,
        })
    }

    /// The manual topic (file name) that explains this button (F1).
    pub fn help_topic(self) -> &'static str {
        match self {
            Attr::Zoom => "view.md",
            Attr::Grid | Attr::PointPosn => "snap-grid.md",
            Attr::NumSides => "draw-shapes.md",
            Attr::RotnAngle | Attr::NumCopies | Attr::NumXCopies | Attr::NumYCopies => "transform.md",
            Attr::VAlign | Attr::HAlign => "transform.md",
            Attr::AngleGeom => "angle-geom.md",
            Attr::SmartLinks => "smart-links.md",
            Attr::LineStyle => "line-styles.md",
            Attr::TangentLength => "tangent-normal.md",
            Attr::DimLine => "measure.md",
            _ => "attributes.md",
        }
    }

    /// One-line name for menus, tooltips and accessibility.
    pub fn name(self) -> String {
        match self.label() {
            (a, "") => a.to_string(),
            (a, b) => format!("{a} {b}"),
        }
    }

    /// Button width: xfig's NARROW/DEF/WIDE/XWIDE/FONT widths (58, 76, 90,
    /// 104, 170), narrowed where the label and the value need less (W19).
    fn width(self) -> f32 {
        match self {
            Attr::Zoom
            | Attr::Depth
            | Attr::NumSides
            | Attr::NumCopies
            | Attr::NumXCopies
            | Attr::NumYCopies
            | Attr::TextStep => 58.0,
            Attr::DimLine => 104.0,
            // A two-line label beside a 32-point picture.
            Attr::Grid
            | Attr::PointPosn
            | Attr::VAlign
            | Attr::HAlign
            | Attr::AngleGeom
            | Attr::FillStyle
            | Attr::ArcType
            | Attr::SmartLinks
            | Attr::LineStyle
            | Attr::JoinStyle
            | Attr::CapStyle
            | Attr::ArrowMode
            | Attr::ArrowType
            | Attr::TextJust => 68.0,
            Attr::BoxRadius => 62.0,
            Attr::LineWidth => 72.0,
            Attr::RotnAngle => 76.0,
            Attr::PenColor | Attr::FillColor => 82.0,
            // A one-line label, the value on the line below.
            Attr::FontSize => 64.0,
            Attr::ArrowSize => 76.0,
            Attr::TextFlags | Attr::TangentLength => 90.0,
            Attr::TextAngle => 104.0,
            Attr::Font => 170.0,
        }
    }

    /// Whether the value is picked from a grid (the panel closes on a click)
    /// rather than entered (it stays open until a click outside).
    fn is_choice(self) -> bool {
        !matches!(
            self,
            Attr::Zoom
                | Attr::Depth
                | Attr::RotnAngle
                | Attr::NumSides
                | Attr::NumCopies
                | Attr::NumXCopies
                | Attr::NumYCopies
                | Attr::LineWidth
                | Attr::LineStyle
                | Attr::ArrowSize
                | Attr::BoxRadius
                | Attr::TextAngle
                | Attr::TextFlags
                | Attr::FontSize
                | Attr::Font
                | Attr::TextStep
                | Attr::TangentLength
        )
    }
}

/// The attributes a mode shows (xfig's per-mode masks: `I_ELLIPSE`,
/// `I_CLOSED`, `I_OPEN`, `I_LINE`, `I_BOX`, … in `w_indpanel.h`). Zoom and
/// Grid Mode are always shown. Attributes whose feature RustFig doesn't
/// have yet (copy counts, dimension lines) are left out until it does.
pub fn attributes_for(tool: ToolKind) -> Vec<Attr> {
    use Attr::*;
    const MIN2: &[Attr] = &[PointPosn];
    // `I_CLOSED` / `I_OPEN` / `I_LINE` / `I_ADDMOVPT` carry Angle Geom.
    const GEOM: &[Attr] = &[AngleGeom];
    const LINE0: &[Attr] = &[FillStyle, LineStyle, LineWidth, PenColor, FillColor, Depth];
    const LINE1: &[Attr] = &[
        FillStyle, LineStyle, JoinStyle, LineWidth, PenColor, FillColor, Depth, CapStyle,
    ];
    const ARROWS: &[Attr] = &[ArrowMode, ArrowType, ArrowSize];
    const TEXT0: &[Attr] = &[TextJust, Font, FontSize, PenColor, Depth, TextAngle, TextFlags];
    let parts: &[&[Attr]] = match tool {
        ToolKind::CircleRadius | ToolKind::CircleDiameter | ToolKind::EllipseRadii | ToolKind::EllipseDiameter => {
            &[MIN2, LINE0, &[TextAngle]]
        }
        ToolKind::SplineApproxClosed | ToolKind::SplineInterpClosed | ToolKind::XSplineClosed | ToolKind::Polygon => {
            &[MIN2, GEOM, LINE1]
        }
        ToolKind::SplineApprox | ToolKind::SplineInterp | ToolKind::XSpline => &[MIN2, GEOM, LINE1, ARROWS],
        ToolKind::Polyline => &[MIN2, GEOM, LINE1, ARROWS, &[DimLine]],
        ToolKind::Box => &[MIN2, LINE1],
        ToolKind::ArcBox => &[MIN2, LINE1, &[BoxRadius]],
        ToolKind::RegularPolygon => &[MIN2, LINE1, &[NumSides]],
        ToolKind::Arc => &[MIN2, LINE1, ARROWS, &[ArcType]],
        ToolKind::Picture => &[MIN2, &[Depth, PenColor]],
        // E12: the curve's line and the font of the numbers and labels.
        ToolKind::Plot => &[MIN2, &[LineStyle, LineWidth, PenColor, Depth, Font, FontSize]],
        ToolKind::Text => &[MIN2, &[TextStep], TEXT0],
        ToolKind::Glue => &[MIN2],
        // D14: smart links follow every geometric operation, so the button is here too.
        ToolKind::Scale | ToolKind::FlipV | ToolKind::FlipH => &[MIN2, &[SmartLinks]],
        ToolKind::MovePoint => &[MIN2, GEOM, &[SmartLinks]],
        ToolKind::AddPoint => &[MIN2, GEOM],
        ToolKind::Move => &[MIN2, &[SmartLinks]],
        // `I_COPY`: array copies (Copy middle button).
        ToolKind::Copy => &[MIN2, &[NumXCopies, NumYCopies, SmartLinks]],
        ToolKind::Align => &[&[HAlign, VAlign, SmartLinks]],
        ToolKind::Update => &[TEXT0, LINE1, ARROWS, &[BoxRadius, ArcType]],
        // RustFig's own Select tool edits with the same attributes as Update.
        ToolKind::RotateCcw | ToolKind::RotateCw => &[MIN2, &[RotnAngle, NumCopies, SmartLinks]],
        ToolKind::Arrow => &[&[LineWidth, ArrowType, ArrowSize]],
        // xfig's `I_TANGENT`: the line attributes, the arrows and the length.
        ToolKind::Tangent => &[MIN2, LINE1, ARROWS, &[TangentLength]],
        // `I_MIN2` only (`w_modepanel.c`).
        ToolKind::AngleMeasure | ToolKind::LengthMeasure | ToolKind::AreaMeasure => &[MIN2],
        // xfig's `I_CHOP` is `I_ARCTYPE`: chopped arcs become open or pie
        // pieces.
        ToolKind::Chop => &[&[ArcType]],
        // RustFig's Trim (D16): the split point snaps (Point Posn), cut arcs
        // as in Chop.
        ToolKind::Trim => &[MIN2, &[ArcType]],
        ToolKind::None
        | ToolKind::Break
        | ToolKind::OpenCompound
        | ToolKind::JoinSplit
        | ToolKind::Delete
        | ToolKind::DeletePoint
        | ToolKind::Edit
        | ToolKind::Convert
        | ToolKind::OpenClose => &[],
    };
    let mut set: Vec<Attr> = parts.concat();
    set.extend([Zoom, Grid]);
    Attr::ORDER.iter().copied().filter(|a| set.contains(a)).collect()
}

/// Mouse hints while the pointer is over an attribute button (xfig's
/// `draw_mousefun_ind`).
pub const BUTTON_HINTS: (&str, &str, &str) = ("Menu", "Dec/Prev", "Inc/Next");

const BUTTON_HEIGHT: f32 = 34.0;

/// The attribute panel: one row of buttons, wrapping when the window is narrow.
pub fn attribute_panel(app: &mut App, ui: &mut Ui, tk: &Tokens) {
    let attrs = attributes_for(app.engine.tool());
    let mut hovered = false;
    ui.add_space(2.0);
    let update_mode = app.engine.tool() == ToolKind::Update;
    ui.horizontal_wrapped(|ui| {
        ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
        if update_mode {
            update_control(app, ui, tk);
        }
        for a in attrs {
            hovered |= button(app, ui, tk, a);
        }
    });
    ui.add_space(2.0);
    crate::dimline_panel::show(app, ui.ctx());
    crate::point_entry::show(app, ui.ctx());
    app.hint_override = hovered.then_some(BUTTON_HINTS);
}

/// xfig's "Update Control" box, shown in Update mode only: a caption and
/// three small buttons that set, clear and toggle every update flag.
fn update_control(app: &mut App, ui: &mut Ui, tk: &Tokens) {
    let (rect, _) = ui.allocate_exact_size(vec2(76.0, BUTTON_HEIGHT), Sense::hover());
    crate::bevel::paint(ui.painter(), rect, tk, crate::bevel::State::Normal);
    let text = ui.visuals().text_color();
    ui.painter().text(
        rect.center_top() + vec2(0.0, 2.0),
        egui::Align2::CENTER_TOP,
        "Update Control",
        egui::FontId::proportional(10.0),
        text,
    );
    let buttons = [
        ("Sets all update flags", json!(true), 2),
        ("Clears all update flags", json!(false), 0),
        ("Toggles all update flags", json!("toggle"), 1),
    ];
    for (i, (tip, all, kind)) in buttons.into_iter().enumerate() {
        let r = Rect::from_min_size(
            pos2(rect.left() + 12.0 + 20.0 * i as f32, rect.top() + 16.0),
            vec2(14.0, 14.0),
        );
        let resp = ui.interact(r, ui.id().with(("update_control", i)), Sense::click());
        crate::bevel::paint(ui.painter(), r, tk, crate::bevel::State::of(&resp, false, false));
        let inner = r.shrink(3.0);
        let red = Color32::from_rgb(220, 30, 30);
        let p = ui.painter();
        match kind {
            2 => {
                p.rect_filled(inner, 0.0, red);
            }
            1 => {
                p.rect_filled(inner, 0.0, red);
                p.add(egui::Shape::convex_polygon(
                    vec![inner.right_top(), inner.right_bottom(), inner.left_bottom()],
                    ui.visuals().extreme_bg_color,
                    Stroke::NONE,
                ));
            }
            _ => {}
        }
        p.rect_stroke(inner, 0.0, Stroke::new(1.0_f32, text), egui::StrokeKind::Inside);
        if resp.clicked() {
            app.run("indicator.update_flags", json!({ "all": all }));
        }
        resp.on_hover_text(tip)
            .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
    }
}

/// The small update box in the upper-right corner of an attribute button
/// (Update mode): red when the attribute is copied by Update.
fn update_box(app: &mut App, ui: &mut Ui, button: Rect, a: Attr, flag: UpdateFlag) -> bool {
    let r = Rect::from_min_size(pos2(button.right() - 12.0, button.top() + 2.0), vec2(10.0, 10.0));
    let resp = ui.interact(r, ui.id().with(("update_flag", a as u8)), Sense::click());
    let on = app.engine.indicator.updates(flag);
    let ink = ui.visuals().text_color();
    let p = ui.painter();
    p.rect_filled(
        r,
        0.0,
        if on {
            Color32::from_rgb(220, 30, 30)
        } else {
            ui.visuals().extreme_bg_color
        },
    );
    p.rect_stroke(r, 0.0, Stroke::new(1.0_f32, ink), egui::StrokeKind::Inside);
    if resp.clicked() {
        app.run("indicator.set_update_flag", json!({ "attr": flag.name(), "on": !on }));
    }
    let label = format!("Update {}", a.name());
    let clicked = resp.clicked();
    resp.on_hover_text(format!("{label}: {}", if on { "on" } else { "off" }))
        .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, label.clone()));
    clicked
}

/// One attribute button; returns whether the pointer is over it.
fn button(app: &mut App, ui: &mut Ui, tk: &Tokens, a: Attr) -> bool {
    let (rect, resp) = ui.allocate_exact_size(vec2(a.width(), BUTTON_HEIGHT), Sense::click());
    let open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp));
    if resp.hovered() {
        app.context_help.hovered = Some(a.help_topic());
    }
    crate::bevel::paint(ui.painter(), rect, tk, crate::bevel::State::of(&resp, false, open));
    let p = ui.painter();
    let text = ui.visuals().text_color();
    let font = egui::FontId::proportional(11.0);
    let (l1, l2) = a.label();
    p.text(
        rect.left_top() + vec2(4.0, 3.0),
        egui::Align2::LEFT_TOP,
        l1,
        font.clone(),
        text,
    );
    if !l2.is_empty() {
        p.text(
            rect.left_top() + vec2(4.0, 17.0),
            egui::Align2::LEFT_TOP,
            l2,
            font,
            text,
        );
    }
    // The value picture fills the right part of the button.
    let label_w = [l1, l2]
        .iter()
        .map(|s| {
            p.layout_no_wrap(s.to_string(), egui::FontId::proportional(11.0), text)
                .size()
                .x
        })
        .fold(0.0_f32, f32::max)
        .min(a.width() - 24.0);
    let value_rect = Rect::from_min_max(
        pos2(rect.left() + label_w + 8.0, rect.top() + 3.0),
        pos2(rect.right() - 3.0, rect.bottom() - 3.0),
    );
    let one_line = l2.is_empty();
    // A one-line label leaves the second line for the value.
    let value_rect = if one_line {
        Rect::from_min_max(
            pos2(rect.left() + 4.0, rect.top() + 16.0),
            rect.right_bottom() - vec2(3.0, 2.0),
        )
    } else {
        value_rect
    };
    draw_value(app, ui, p, value_rect, rect, tk, a);
    // In Update mode the small upper-right box toggles the attribute's
    // update flag; when it is clicked it owns the click, so the button's
    // pop-up and wheel/right/middle actions stay closed and unchanged.
    let flag_clicked = if app.engine.tool() == ToolKind::Update {
        match a.update_flag() {
            Some(flag) => update_box(app, ui, rect, a, flag),
            None => false,
        }
    } else {
        false
    };
    let summary = format!("{}: {}", a.name(), value_text(app, a));
    // xfig: Alt + right button is the middle button (`sel_ind_but`).
    if !flag_clicked {
        if resp.secondary_clicked() {
            step(app, a, if ui.input(|i| i.modifiers.alt) { -1 } else { 1 });
        } else if resp.middle_clicked() {
            step(app, a, -1);
        } else if resp.hovered() {
            // xfig's wheel (`Fig*ind_panel*Command.translations`): up is the
            // middle button, down the right one; one step per wheel notch.
            let dy = ui.input(|i| i.raw_scroll_delta.y);
            if dy != 0.0 {
                let id = resp.id.with("wheel");
                let acc = ui.data(|d| d.get_temp::<f32>(id).unwrap_or(0.0)) + dy;
                let notch = 40.0;
                let steps = (acc / notch).trunc();
                ui.data_mut(|d| d.insert_temp(id, acc - steps * notch));
                if steps > 0.0 {
                    step(app, a, -1);
                } else if steps < 0.0 {
                    step(app, a, 1);
                }
            }
        }
        let close = if a.is_choice() {
            egui::PopupCloseBehavior::CloseOnClick
        } else {
            egui::PopupCloseBehavior::CloseOnClickOutside
        };
        if a == Attr::DimLine {
            // xfig's `popup_dimline_panel`: a window of its own.
            if resp.clicked() {
                crate::dimline_panel::open(ui.ctx());
            }
        } else {
            egui::Popup::from_toggle_button_response(&resp)
                .align(egui::RectAlign::TOP_START)
                .close_behavior(close)
                .show(|ui| choice_panel(app, ui, tk, a));
        }
    }
    let hovered = resp.hovered();
    resp.on_hover_text(format!(
        "{summary}\nClick: choose · middle button or wheel up: previous/decrease · right button or wheel down: next/increase"
    ))
    .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, summary.clone()));
    hovered
}

fn set(app: &mut App, v: Value) {
    app.run("indicator.set", v);
}

fn units(app: &App) -> rustfig_model::Units {
    app.engine.doc().settings.units
}

/// Colours offered: Default, the 32 standard colours, then user colours.
pub(crate) fn colors(doc: &Document) -> Vec<i32> {
    let mut v: Vec<i32> = (-1..32).collect();
    v.extend(doc.colors.iter().map(|c| c.number));
    v
}

pub(crate) fn color_name(c: i32) -> String {
    if c < 0 {
        "Default".to_string()
    } else if let Some(n) = usize::try_from(c)
        .ok()
        .and_then(|i| rustfig_model::STANDARD_COLOR_NAMES.get(i))
    {
        (*n).to_string()
    } else {
        format!("User {c}")
    }
}

pub(crate) fn color32(doc: &Document, c: i32) -> Color32 {
    let rgb = doc.rgb(ColorRef(c));
    Color32::from_rgb(rgb.r, rgb.g, rgb.b)
}

/// xfig's arrow type choices: type 0, then types 1–14 hollow and filled
/// (`arrowtype_choices` in `w_indpanel.c`, built with `ARROWS4TO14`).
const ARROW_CHOICES: i32 = 29;

/// Arrow mode in xfig's order: none, forward, both, backward.
fn arrow_mode(ind: &Indicator) -> usize {
    match (ind.forward_arrow, ind.backward_arrow) {
        (false, false) => 0,
        (true, false) => 1,
        (true, true) => 2,
        (false, true) => 3,
    }
}

fn set_arrow_mode(app: &mut App, m: usize) {
    let (f, b) = match m % 4 {
        1 => (true, false),
        2 => (true, true),
        3 => (false, true),
        _ => (false, false),
    };
    set(app, json!({"forward_arrow": f, "backward_arrow": b}));
}

/// xfig's seven align choices (`halign_choices`, `valign_choices`).
const H_ALIGN: [&str; 7] = [
    "none",
    "left",
    "center",
    "right",
    "distribute_centres",
    "distribute_edges",
    "abut",
];
const V_ALIGN: [&str; 7] = [
    "none",
    "top",
    "center",
    "bottom",
    "distribute_centres",
    "distribute_edges",
    "abut",
];
const JUSTIFY: [Justify; 3] = [Justify::Left, Justify::Center, Justify::Right];
const JOINS: [JoinStyle; 3] = [JoinStyle::Miter, JoinStyle::Round, JoinStyle::Bevel];
const CAPS: [CapStyle; 3] = [CapStyle::Butt, CapStyle::Round, CapStyle::Projecting];

/// Grid choices (xfig's): none, then grid steps 1–4.
fn grid_choices() -> Vec<(Option<SnapMode>, bool)> {
    std::iter::once((None, false))
        .chain(SnapMode::ALL.iter().skip(2).map(|m| (Some(*m), false)))
        .chain(SnapMode::ALL.iter().skip(2).map(|m| (Some(*m), true)))
        .collect()
}

fn cycle<T: PartialEq + Copy>(list: &[T], cur: T, dir: i32) -> Option<T> {
    let n = list.len() as i32;
    if n == 0 {
        return None;
    }
    let i = list.iter().position(|x| *x == cur).map_or(0, |i| i as i32);
    list.get((i + dir).rem_euclid(n) as usize).copied()
}

/// The value as text (tooltips, accessibility, numeric buttons).
fn value_text(app: &App, a: Attr) -> String {
    let ind = &app.engine.indicator;
    let u = units(app);
    match a {
        Attr::Zoom => format!("{:.2}", app.engine.view.zoom),
        Attr::Grid => grid_label(ind.grid, ind.grid_iso, u),
        Attr::PointPosn => ind.snap.label(u).to_string(),
        Attr::Depth => ind.depth.to_string(),
        Attr::RotnAngle => format!("{:.1}", ind.rotate_angle),
        Attr::NumSides => ind.sides.to_string(),
        Attr::NumCopies => ind.num_copies.to_string(),
        Attr::NumXCopies => ind.num_x_copies.to_string(),
        Attr::NumYCopies => ind.num_y_copies.to_string(),
        Attr::DimLine => "settings".to_string(),
        Attr::VAlign => ind.align_v.clone(),
        Attr::HAlign => ind.align_h.clone(),
        Attr::PenColor => color_name(ind.pen_color),
        Attr::FillColor => color_name(ind.fill_color),
        Attr::FillStyle => match ind.area_fill {
            -1 => "none".into(),
            f @ 0..=20 => format!("shade {f}"),
            f @ 21..=40 => format!("tint {f}"),
            f => format!("pattern {f}"),
        },
        Attr::ArcType => if ind.pie_arc { "pie wedge" } else { "open" }.into(),
        Attr::AngleGeom => ind.angle_geom.label().into(),
        Attr::SmartLinks => link_name(ind.linkmode).into(),
        Attr::LineWidth => ind.thickness.to_string(),
        Attr::LineStyle => ind.line.name().into(),
        Attr::JoinStyle => format!("{:?}", ind.join).to_lowercase(),
        Attr::CapStyle => format!("{:?}", ind.cap).to_lowercase(),
        Attr::ArrowMode => ["none", "forward", "both", "backward"]
            .get(arrow_mode(ind))
            .copied()
            .unwrap_or("none")
            .into(),
        Attr::ArrowType => {
            let a = ind.arrow;
            if a.kind == 0 {
                "type 0".into()
            } else {
                format!("{} type {}", if a.style == 1 { "solid" } else { "hollow" }, a.kind)
            }
        }
        Attr::ArrowSize => {
            // Thickness, width and length: absolute, or as multiples of the line width.
            let (t, w, h) = if ind.arrow_absolute {
                let u = rustfig_geom::FIG_PER_STYLE_UNIT;
                (ind.arrow.thickness, ind.arrow.width / u, ind.arrow.height / u)
            } else {
                ind.arrow_mult
            };
            format!(
                "{} {} {}{}",
                fmt_g(t),
                fmt_g(w),
                fmt_g(h),
                if ind.arrow_absolute { "" } else { " ×" }
            )
        }
        Attr::BoxRadius => ind.box_radius.to_string(),
        Attr::TextJust => format!("{:?}", ind.justify).to_lowercase(),
        Attr::TextAngle => format!("{:.1}", ind.text_angle),
        // xfig's `show_flags`: one flag at a time, "hidden=off".
        Attr::TextFlags => {
            let on = |b: bool| if b { "on" } else { "off" };
            match app.flag_shown % 3 {
                0 => format!("hidden={}", on(ind.hidden_text)),
                1 => format!("TeX={}", on(ind.special_text)),
                _ => format!("rigid={}", on(ind.rigid_text)),
            }
        }
        Attr::FontSize => format!("{:.2}", ind.font_size),
        Attr::TextStep => format!("{:.1}", ind.text_step),
        Attr::TangentLength => format!("{:.2}", ind.tangent_length),
        Attr::Font => match &ind.local_font {
            Some(n) => n.clone(),
            None => font_name(ind.font, ind.postscript),
        },
    }
}

pub(crate) fn font_name(font: i32, postscript: bool) -> String {
    if postscript {
        rustfig_text::ps_font_display_name(font).to_string()
    } else {
        format!("LaTeX {}", rustfig_text::latex_font_name(font))
    }
}

/// Middle button (dir −1) and right button (dir +1).
fn step(app: &mut App, a: Attr, dir: i32) {
    let ind = app.engine.indicator.clone();
    match a {
        Attr::Zoom => {
            app.run(if dir > 0 { "view.zoom_in" } else { "view.zoom_out" }, json!({}));
        }
        Attr::Grid => {
            if let Some((g, iso)) = cycle(&grid_choices(), (ind.grid, ind.grid_iso), dir) {
                set(app, json!({ "grid": g, "grid_iso": iso }));
            }
        }
        Attr::PointPosn => {
            if let Some(m) = cycle(&SnapMode::ALL, ind.snap, dir) {
                set(app, json!({ "snap": m }));
            }
        }
        Attr::Depth => set(
            app,
            json!({"depth": (ind.depth + dir).clamp(0, rustfig_model::MAX_DEPTH)}),
        ),
        Attr::RotnAngle => set(app, json!({"rotate_angle": step_rotn(ind.rotate_angle, dir)})),
        Attr::NumSides => set(app, json!({"sides": (ind.sides as i64 + i64::from(dir)).clamp(3, 200)})),
        Attr::NumCopies => set(
            app,
            json!({"num_copies": (i64::from(ind.num_copies) + i64::from(dir)).clamp(1, 99)}),
        ),
        Attr::NumXCopies => set(
            app,
            json!({"num_x_copies": (i64::from(ind.num_x_copies) + i64::from(dir)).clamp(0, 99)}),
        ),
        Attr::NumYCopies => set(
            app,
            json!({"num_y_copies": (i64::from(ind.num_y_copies) + i64::from(dir)).clamp(0, 99)}),
        ),
        Attr::DimLine => {}
        Attr::VAlign => {
            if let Some(v) = cycle(&V_ALIGN, ind.align_v.as_str(), dir) {
                set(app, json!({ "align_v": v }));
            }
        }
        Attr::HAlign => {
            if let Some(v) = cycle(&H_ALIGN, ind.align_h.as_str(), dir) {
                set(app, json!({ "align_h": v }));
            }
        }
        Attr::PenColor | Attr::FillColor => {
            let field = if a == Attr::PenColor { "pen_color" } else { "fill_color" };
            let cur = if a == Attr::PenColor {
                ind.pen_color
            } else {
                ind.fill_color
            };
            if let Some(c) = cycle(&colors(app.engine.doc()), cur, dir) {
                let mut m = serde_json::Map::new();
                m.insert(field.into(), json!(c));
                set(app, Value::Object(m));
            }
        }
        // xfig darkens and lightens the fill one step at a time.
        Attr::FillStyle => {
            let plain = matches!(ind.fill_color, -1 | 0 | 7);
            set(app, json!({"area_fill": step_fill(ind.area_fill, plain, dir)}));
        }
        Attr::ArcType => set(app, json!({"pie_arc": !ind.pie_arc})),
        Attr::AngleGeom => {
            if let Some(m) = cycle(&AngleGeom::ALL, ind.angle_geom, dir) {
                set(app, json!({ "angle_geom": m }));
            }
        }
        Attr::SmartLinks => {
            if let Some(m) = cycle(&LinkMode::ALL, ind.linkmode, dir) {
                set(app, json!({ "linkmode": m }));
            }
        }
        Attr::LineWidth => set(app, json!({"thickness": (ind.thickness + dir).clamp(0, 500)})),
        Attr::LineStyle => {
            if let Some(k) = cycle(&LineKind::ALL, ind.line, dir) {
                set(app, json!({ "line": k }));
            }
        }
        Attr::JoinStyle => {
            if let Some(j) = cycle(&JOINS, ind.join, dir) {
                set(app, json!({ "join": j }));
            }
        }
        Attr::CapStyle => {
            if let Some(c) = cycle(&CAPS, ind.cap, dir) {
                set(app, json!({ "cap": c }));
            }
        }
        Attr::ArrowMode => set_arrow_mode(app, (arrow_mode(&ind) as i32 + dir).rem_euclid(4) as usize),
        Attr::ArrowType => {
            let n = (arrow_choice_of(ind.arrow.kind, ind.arrow.style) + dir).rem_euclid(ARROW_CHOICES);
            set_arrow_type(app, &ind, n);
        }
        Attr::BoxRadius => set(app, json!({"box_radius": (ind.box_radius + dir).clamp(0, 1000)})),
        Attr::TextJust => {
            if let Some(j) = cycle(&JUSTIFY, ind.justify, dir) {
                set(app, json!({ "justify": j }));
            }
        }
        Attr::TextAngle => set(app, json!({"text_angle": step_angle(ind.text_angle, dir)})),
        Attr::FontSize => set(app, json!({"font_size": step_font_size(ind.font_size, dir)})),
        Attr::TextStep => set(app, json!({"text_step": step_text_step(ind.text_step, dir)})),
        // `inc_tangnormlen` / `dec_tangnormlen`: 0.1, within 0..20.
        Attr::TangentLength => {
            let v = ((ind.tangent_length + 0.1 * f64::from(dir)) * 100.0).round() / 100.0;
            set(app, json!({"tangent_length": v.clamp(0.0, 20.0)}));
        }
        Attr::Font => {
            let count = if ind.postscript {
                rustfig_text::PS_FONT_COUNT
            } else {
                rustfig_text::LATEX_FONT_COUNT
            };
            set(app, json!({"font": (ind.font + dir).rem_euclid(count.max(1))}));
        }
        // `inc_flags` / `dec_flags`: show the next / previous flag.
        Attr::TextFlags => app.flag_shown = (app.flag_shown + 3).wrapping_add_signed(dir as isize) % 3,
        // Opens a panel only.
        Attr::ArrowSize => {}
    }
}

/// xfig's `inc_rotnangle`/`dec_rotnangle`: the preset angles 15, 30, 45, 60,
/// 90, 120, 180, wrapping.
fn step_rotn(cur: f64, dir: i32) -> f64 {
    const P: [f64; 7] = [15.0, 30.0, 45.0, 60.0, 90.0, 120.0, 180.0];
    if dir > 0 {
        P.iter().copied().find(|p| *p > cur).unwrap_or(15.0)
    } else {
        P.iter().rev().copied().find(|p| *p < cur).unwrap_or(180.0)
    }
}

/// xfig's `inc_elltextangle`/`dec_elltextangle`: the next multiple of 15
/// degrees, within -360..360.
fn step_angle(cur: f64, dir: i32) -> f64 {
    let v = if dir > 0 {
        ((cur + 15.0 + 1e-9) / 15.0).floor() * 15.0
    } else {
        ((cur - 15.0 - 1e-9) / 15.0).ceil() * 15.0
    };
    v.clamp(-360.0, 360.0)
}

/// xfig's `inc_fontsize`/`dec_fontsize`: steps of 1, 2 (from 24), 4 (from
/// 64) and 10 (from 120), kept within 1..500.
fn step_font_size(cur: f64, dir: i32) -> f64 {
    let v = if dir > 0 {
        if cur >= 120.0 {
            (cur / 10.0).floor() * 10.0 + 10.0
        } else if cur >= 64.0 {
            (cur / 4.0).floor() * 4.0 + 4.0
        } else if cur >= 24.0 {
            (cur / 2.0).floor() * 2.0 + 2.0
        } else {
            cur.floor() + 1.0
        }
    } else if cur > 120.0 {
        (cur / 10.0).ceil() * 10.0 - 10.0
    } else if cur > 64.0 {
        (cur / 4.0).ceil() * 4.0 - 4.0
    } else if cur > 24.0 {
        (cur / 2.0).ceil() * 2.0 - 2.0
    } else {
        cur.ceil() - 1.0
    };
    v.clamp(1.0, 500.0)
}

/// xfig's `inc_textstep`/`dec_textstep`: 0.1 below 2, 0.2 up to 5, 0.5 up to
/// 10, then 1; rounded to 0.1, within 0..1000.
fn step_text_step(cur: f64, dir: i32) -> f64 {
    let v = if dir > 0 {
        if cur >= 10.0 {
            cur.trunc() + 1.0
        } else if cur >= 5.0 {
            ((cur * 2.0 + 0.01).trunc()) / 2.0 + 0.5
        } else if cur >= 2.0 {
            ((cur * 5.0 + 0.01).trunc()) / 5.0 + 0.2
        } else {
            cur + 0.1
        }
    } else if cur > 10.0 {
        cur.trunc() - 1.0
    } else if cur > 5.0 {
        ((cur * 2.0 + 0.01).trunc()) / 2.0 - 0.5
    } else if cur > 2.0 {
        ((cur * 5.0 + 0.01).trunc()) / 5.0 - 0.2
    } else if cur > 0.4 {
        cur - 0.1
    } else {
        cur
    };
    ((v * 10.0).round() / 10.0).clamp(0.0, 1000.0)
}

/// xfig's `darken_fill`/`lighten_fill`: -1 (none) to 62, wrapping; the tints
/// 21..=40 are skipped for black, white and default fill colours.
fn step_fill(cur: i32, plain: bool, dir: i32) -> i32 {
    let mut v = cur + dir;
    if v > 62 {
        v = -1;
    } else if v < -1 {
        v = 62;
    }
    if plain && (21..=40).contains(&v) {
        v = if dir > 0 { 41 } else { 20 };
    }
    v
}

fn set_arrow_type(app: &mut App, ind: &Indicator, n: i32) {
    let (kind, style) = arrow_choice(n);
    let mut a = ind.arrow;
    a.kind = kind;
    a.style = style;
    set(app, json!({ "arrow": a }));
}

// ---- xfig's pictures (w_icons.c) -------------------------------------------

use crate::vector_icons::VIcon;
use crate::xfig_icons::{self as xi, Xbm};

fn arrow_type_icon(n: i32) -> &'static Xbm {
    const T: [&Xbm; 29] = [
        &xi::ARROW0,
        &xi::ARROW1O,
        &xi::ARROW1F,
        &xi::ARROW2O,
        &xi::ARROW2F,
        &xi::ARROW3O,
        &xi::ARROW3F,
        &xi::ARROW4O,
        &xi::ARROW4F,
        &xi::ARROW5O,
        &xi::ARROW5F,
        &xi::ARROW6O,
        &xi::ARROW6F,
        &xi::ARROW7O,
        &xi::ARROW7F,
        &xi::ARROW8O,
        &xi::ARROW8F,
        &xi::ARROW9A,
        &xi::ARROW9B,
        &xi::ARROW10O,
        &xi::ARROW10F,
        &xi::ARROW11O,
        &xi::ARROW11F,
        &xi::ARROW12O,
        &xi::ARROW12F,
        &xi::ARROW13A,
        &xi::ARROW13B,
        &xi::ARROW14A,
        &xi::ARROW14B,
    ];
    usize::try_from(n)
        .ok()
        .and_then(|i| T.get(i))
        .copied()
        .unwrap_or(&xi::ARROW0)
}

fn line_icon(k: LineKind) -> &'static Xbm {
    match k {
        LineKind::Dashed => &xi::DASHLINE,
        LineKind::Dotted => &xi::DOTTEDLINE,
        LineKind::DashDotted => &xi::DASHDOTLINE,
        LineKind::DashDoubleDotted => &xi::DASH2DOTSLINE,
        LineKind::DashTripleDotted => &xi::DASH3DOTSLINE,
        LineKind::Solid | LineKind::Default => &xi::SOLIDLINE,
    }
}

/// xfig's Smart Links pictures (`smartoff`, `smartmove`, `smartslide`).
/// xfig's Angle Geom pictures (`unconstrained`, `latexline`, `latexarrow`,
/// `mounthattan`, `manhattan`, `mountain`).
fn geom_icon(m: AngleGeom) -> &'static Xbm {
    match m {
        AngleGeom::Unconstrained => &xi::UNCONSTRAINED,
        AngleGeom::LatexLine => &xi::LATEXLINE,
        AngleGeom::LatexArrow => &xi::LATEXARROW,
        AngleGeom::MountHattan => &xi::MOUNTHATTAN,
        AngleGeom::Manhattan => &xi::MANHATTAN,
        AngleGeom::Mountain => &xi::MOUNTAIN,
    }
}

fn link_icon(m: LinkMode) -> &'static Xbm {
    match m {
        LinkMode::Off => &xi::SMARTOFF,
        LinkMode::Move => &xi::SMARTMOVE,
        LinkMode::Slide => &xi::SMARTSLIDE,
    }
}

fn link_name(m: LinkMode) -> &'static str {
    match m {
        LinkMode::Off => "off",
        LinkMode::Move => "move",
        LinkMode::Slide => "slide",
    }
}

fn join_icon(j: JoinStyle) -> &'static Xbm {
    match j {
        JoinStyle::Miter => &xi::JOINMITER,
        JoinStyle::Round => &xi::JOINROUND,
        JoinStyle::Bevel => &xi::JOINBEVEL,
    }
}

fn cap_icon(c: CapStyle) -> &'static Xbm {
    match c {
        CapStyle::Butt => &xi::CAPBUTT,
        CapStyle::Round => &xi::CAPROUND,
        CapStyle::Projecting => &xi::CAPPROJECT,
    }
}

const ARROW_MODE_ICONS: [&Xbm; 4] = [&xi::NOARROWS, &xi::FARROWS, &xi::FBARROWS, &xi::BARROWS];

fn just_icon(j: Justify) -> &'static Xbm {
    match j {
        Justify::Left => &xi::TEXTL,
        Justify::Center => &xi::TEXTC,
        Justify::Right => &xi::TEXTR,
    }
}

/// xfig's point positioning pictures: any, magnet, grid 1–4.
fn posn_icon(m: SnapMode) -> &'static Xbm {
    match m {
        SnapMode::Any => &xi::ANY,
        SnapMode::Fine => &xi::FINE_GRID,
        SnapMode::Small => &xi::GRID1,
        SnapMode::Medium => &xi::GRID2,
        SnapMode::Large => &xi::GRID3,
        SnapMode::Coarse => &xi::GRID4,
    }
}

fn grid_icon(g: Option<SnapMode>, iso: bool) -> &'static Xbm {
    let Some(m) = g else { return &xi::NONE };
    if !iso {
        return posn_icon(m);
    }
    match m {
        SnapMode::Small => &xi::GRID_ISO1,
        SnapMode::Medium => &xi::GRID_ISO2,
        SnapMode::Large => &xi::GRID_ISO3,
        _ => &xi::GRID_ISO4,
    }
}

fn grid_label(g: Option<SnapMode>, iso: bool, u: rustfig_model::Units) -> String {
    match g {
        None => "none".to_string(),
        Some(m) if iso => format!("{} isometric", m.label(u)),
        Some(m) => m.label(u).to_string(),
    }
}

fn align_icon(v: &str, horizontal: bool) -> &'static Xbm {
    match (v, horizontal) {
        ("left", true) => &xi::HALIGNL,
        ("center", true) => &xi::HALIGNC,
        ("distribute_centres", true) => &xi::HALIGNDC,
        ("distribute_edges", true) => &xi::HALIGNDE,
        ("abut", true) => &xi::HALIGNA,
        ("distribute_centres", false) => &xi::VALIGNDC,
        ("distribute_edges", false) => &xi::VALIGNDE,
        ("abut", false) => &xi::VALIGNA,
        ("right", true) => &xi::HALIGNR,
        ("top", false) => &xi::VALIGNT,
        ("center", false) => &xi::VALIGNC,
        ("bottom", false) => &xi::VALIGNB,
        _ => &xi::NONE,
    }
}

/// The picture of arc type `pie`.
fn arc_pic(pie: bool) -> Pic {
    let b = if pie { &xi::PIE_WEDGE_ARC } else { &xi::OPEN_ARC };
    Pic::of(b, VIcon::Arc { pie })
}

/// The picture of arrow mode `m` (xfig's order: none, forward, both, backward).
fn arrow_mode_pic(m: usize) -> Pic {
    let (forward, backward) = match m {
        1 => (true, false),
        2 => (true, true),
        3 => (false, true),
        _ => (false, false),
    };
    let b = ARROW_MODE_ICONS.get(m).copied().unwrap_or(&xi::NOARROWS);
    Pic::of(b, VIcon::ArrowMode { backward, forward })
}

/// The picture of arrow choice `n`.
fn arrow_type_pic(n: i32) -> Pic {
    let (kind, style) = arrow_choice(n);
    Pic::of(arrow_type_icon(n), VIcon::Arrow(kind, style))
}

/// The picture xfig shows on an attribute button, if it shows one.
fn value_icon(app: &App, a: Attr) -> Option<Pic> {
    let ind = &app.engine.indicator;
    Some(match a {
        Attr::Grid => Pic::traced(grid_icon(ind.grid, ind.grid_iso)),
        Attr::PointPosn => Pic::traced(posn_icon(ind.snap)),
        Attr::HAlign => Pic::traced(align_icon(&ind.align_h, true)),
        Attr::VAlign => Pic::traced(align_icon(&ind.align_v, false)),
        Attr::ArcType => arc_pic(ind.pie_arc),
        Attr::LineStyle => Pic::of(line_icon(ind.line), VIcon::Line(ind.line)),
        Attr::JoinStyle => Pic::of(join_icon(ind.join), VIcon::Join(ind.join)),
        Attr::AngleGeom => Pic::traced(geom_icon(ind.angle_geom)),
        Attr::SmartLinks => Pic::traced(link_icon(ind.linkmode)),
        Attr::CapStyle => Pic::of(cap_icon(ind.cap), VIcon::Cap(ind.cap)),
        Attr::ArrowMode => arrow_mode_pic(arrow_mode(ind)),
        Attr::ArrowType => arrow_type_pic(arrow_choice_of(ind.arrow.kind, ind.arrow.style)),
        Attr::TextJust => Pic::of(just_icon(ind.justify), VIcon::Justify(ind.justify)),
        _ => return None,
    })
}

/// A row (wrapping every `per_row`) of xfig pictures to choose from;
/// returns the index clicked.
/// A picture: xfig's bitmap, or the same drawn as a vector (W9).
#[derive(Clone, Copy)]
enum Pic {
    Bitmap(&'static Xbm),
    Vector(VIcon),
}

impl Pic {
    /// The vector picture (the bitmap stays the reference it was traced from).
    fn of(_bitmap: &'static Xbm, vector: VIcon) -> Pic {
        Pic::Vector(vector)
    }

    /// The traced vector drawing of xfig's bitmap, or the bitmap itself
    /// where it has not been traced.
    fn traced(bitmap: &'static Xbm) -> Pic {
        if crate::mode_icons::traced(bitmap.name) {
            Pic::Vector(VIcon::Traced(bitmap.name))
        } else {
            Pic::Bitmap(bitmap)
        }
    }

    fn paint(self, ui: &Ui, r: Rect, tint: Color32) {
        match self {
            Pic::Bitmap(b) => crate::xfig_icons::paint(ui, b, r, tint),
            Pic::Vector(v) => crate::vector_icons::paint(ui, v, r, tint),
        }
    }
}

fn icon_choices(ui: &mut Ui, id: &str, items: &[(bool, String, Pic)], per_row: usize) -> Option<usize> {
    let mut chosen = None;
    egui::Grid::new(("icon_choices", id))
        .spacing(vec2(2.0, 2.0))
        .show(ui, |ui| {
            for (i, (selected, name, icon)) in items.iter().enumerate() {
                let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click());
                let v = ui.style().interact_selectable(&resp, *selected);
                ui.painter().rect_filled(r, 2.0, v.weak_bg_fill);
                icon.paint(ui, r, ui.visuals().text_color());
                if *selected {
                    ui.painter().rect_stroke(
                        r,
                        2.0,
                        Stroke::new(2.0_f32, ui.visuals().selection.stroke.color),
                        egui::StrokeKind::Inside,
                    );
                }
                if resp.clicked() {
                    chosen = Some(i);
                }
                resp.on_hover_text(name.as_str()).widget_info(|| {
                    egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, *selected, name)
                });
                if per_row > 0 && i % per_row == per_row - 1 {
                    ui.end_row();
                }
            }
        });
    chosen
}

/// A pictorial menu (W10-21): a button showing the picture of the current
/// value; its pop-up lists every value as a picture (the same pictures and
/// grid as the indicator panel's choice panel). Returns the index chosen.
fn pic_menu(
    ui: &mut Ui,
    id: &str,
    name: &str,
    current: Pic,
    items: &[(bool, String, Pic)],
    per_row: usize,
) -> Option<usize> {
    let mut chosen = None;
    // One framed picture button; its pop-up is the indicator's choice grid.
    let (r, resp) = ui.allocate_exact_size(vec2(PIC_BTN * 1.5, PIC_BTN), Sense::click());
    let v = ui.style().interact(&resp);
    ui.painter()
        .rect(r, 3.0, v.weak_bg_fill, v.bg_stroke, egui::StrokeKind::Inside);
    current.paint(ui, r.shrink(2.0), ui.visuals().text_color());
    resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name));
    let resp = resp.on_hover_text(name);
    egui::Popup::from_toggle_button_response(&resp)
        .close_behavior(egui::PopupCloseBehavior::CloseOnClick)
        .show(|ui| {
            chosen = icon_choices(ui, id, items, per_row);
        });
    chosen
}

/// Height of a pictorial button and of every row of the edit panel.
pub(crate) const PIC_BTN: f32 = 28.0;

/// Line style menu with pictures (edit panel).
pub(crate) fn line_menu(ui: &mut Ui, id: &str, cur: LineKind) -> Option<LineKind> {
    let items: Vec<_> = LineKind::ALL
        .iter()
        .map(|k| (cur == *k, k.name().to_string(), Pic::of(line_icon(*k), VIcon::Line(*k))))
        .collect();
    let pic = Pic::of(line_icon(cur), VIcon::Line(cur));
    pic_menu(ui, id, cur.name(), pic, &items, 6).and_then(|i| LineKind::ALL.get(i).copied())
}

/// Cap style menu with pictures (edit panel).
pub(crate) fn cap_menu(ui: &mut Ui, id: &str, cur: CapStyle) -> Option<CapStyle> {
    let items: Vec<_> = CAPS
        .iter()
        .map(|c| {
            (
                cur == *c,
                format!("{c:?}").to_lowercase(),
                Pic::of(cap_icon(*c), VIcon::Cap(*c)),
            )
        })
        .collect();
    let pic = Pic::of(cap_icon(cur), VIcon::Cap(cur));
    pic_menu(ui, id, &format!("{cur:?}").to_lowercase(), pic, &items, 3).and_then(|i| CAPS.get(i).copied())
}

/// Join style menu with pictures (edit panel).
pub(crate) fn join_menu(ui: &mut Ui, id: &str, cur: JoinStyle) -> Option<JoinStyle> {
    let items: Vec<_> = JOINS
        .iter()
        .map(|j| {
            (
                cur == *j,
                format!("{j:?}").to_lowercase(),
                Pic::of(join_icon(*j), VIcon::Join(*j)),
            )
        })
        .collect();
    let pic = Pic::of(join_icon(cur), VIcon::Join(cur));
    pic_menu(ui, id, &format!("{cur:?}").to_lowercase(), pic, &items, 3).and_then(|i| JOINS.get(i).copied())
}

/// Arrow type menu with pictures (edit panel): xfig's 29 choices.
pub(crate) fn arrow_menu(ui: &mut Ui, id: &str, cur: i32) -> Option<i32> {
    let items: Vec<_> = (0..ARROW_CHOICES)
        .map(|n| (n == cur, crate::dialogs::arrow_choice_label(n), arrow_type_pic(n)))
        .collect();
    pic_menu(
        ui,
        id,
        &crate::dialogs::arrow_choice_label(cur),
        arrow_type_pic(cur),
        &items,
        9,
    )
    .map(|i| i as i32)
}

// ---- value pictures ------------------------------------------------------

fn draw_value(app: &App, ui: &Ui, p: &Painter, r: Rect, button: Rect, tk: &Tokens, a: Attr) {
    let ind = &app.engine.indicator;
    let doc = app.engine.doc();
    let ink = ui.visuals().text_color();
    let small = egui::FontId::proportional(11.0);
    let text_right = |s: &str| {
        p.text(r.right_center(), egui::Align2::RIGHT_CENTER, s, small.clone(), ink);
    };
    if let Some(icon) = value_icon(app, a) {
        // xfig's 32×32 picture, the full height of the button.
        let tall = Rect::from_min_max(
            pos2(r.left(), button.top() + 1.0),
            pos2(r.right(), button.bottom() - 1.0),
        );
        icon.paint(ui, tall, ink);
        return;
    }
    match a {
        Attr::PenColor | Attr::FillColor => {
            let c = if a == Attr::PenColor {
                ind.pen_color
            } else {
                ind.fill_color
            };
            let sw = Rect::from_min_size(pos2(r.right() - 16.0, r.center().y - 8.0), vec2(16.0, 16.0));
            p.rect_filled(sw, 1.0, color32(doc, c));
            p.rect_stroke(sw, 1.0, Stroke::new(1.0_f32, tk.muted), egui::StrokeKind::Inside);
            // The name goes on the label's second line, like xfig.
            p.text(
                button.left_top() + vec2(4.0, 17.0),
                egui::Align2::LEFT_TOP,
                color_name(c),
                small,
                ink,
            );
        }
        Attr::FillStyle => {
            let sw = Rect::from_center_size(r.center(), vec2(r.height().min(26.0), r.height().min(26.0)));
            fill_swatch(p, sw, doc, (ind.pen_color, ind.fill_color), ind.area_fill, ink, tk);
        }
        Attr::LineWidth => {
            text_right(&ind.thickness.to_string());
            let y = r.center().y;
            let w = (ind.thickness as f32).clamp(0.5, 8.0);
            p.line_segment([pos2(r.left(), y), pos2(r.right() - 16.0, y)], Stroke::new(w, ink));
        }
        _ => text_right(&value_text(app, a)),
    }
}

/// A swatch of an area fill: none, a shade or tint of the fill colour, or a
/// pattern drawn in the pen colour over the fill colour.
/// A fill-style box as xfig's panel draws it: shades and tints in the fill
/// colour, patterns in the pen colour on the fill colour (as fig2dev fills
/// them), at a scale where the lines stay apart.
fn fill_swatch(
    p: &Painter,
    r: Rect,
    doc: &Document,
    (pen_color, fill_color): (i32, i32),
    area_fill: i32,
    ink: Color32,
    tk: &Tokens,
) {
    let stroke = Stroke::new(1.0_f32, tk.muted);
    match area_fill {
        -1 => {
            p.text(
                r.center(),
                egui::Align2::CENTER_CENTER,
                "NONE",
                egui::FontId::proportional(9.0),
                ink,
            );
        }
        f @ 0..=40 => {
            let base = doc.rgb(ColorRef(fill_color));
            let rgb = area_fill_color(base, fill_color < 1, fill_color == 7, f).unwrap_or(base);
            p.rect_filled(r, 0.0, Color32::from_rgb(rgb.r, rgb.g, rgb.b));
        }
        kind => {
            let bg: Rgb = doc.rgb(ColorRef(fill_color));
            p.rect_filled(r, 0.0, Color32::from_rgb(bg.r, bg.g, bg.b));
            if let Some(t) = rustfig_render::pattern::tile(kind) {
                // Pattern space has y up; 0.7 points per pattern unit keeps
                // the lines of the dense patterns apart.
                let (tw, th) = (t.width.max(1.0), t.height.max(1.0));
                let s = 0.7;
                let pen = doc.rgb(ColorRef(pen_color.max(0)));
                let pen = Color32::from_rgb(pen.r, pen.g, pen.b);
                let painter = p.with_clip_rect(r);
                let ny = (f64::from(r.height()) / (th * s)).ceil() as i32 + 1;
                let nx = (f64::from(r.width()) / (tw * s)).ceil() as i32 + 1;
                for ty in 0..ny.clamp(1, 64) {
                    for tx in 0..nx.clamp(1, 64) {
                        for l in &t.lines {
                            let pts: Vec<Pos2> = l
                                .iter()
                                .map(|&(x, y)| {
                                    pos2(
                                        r.left() + ((x + f64::from(tx) * tw) * s) as f32,
                                        r.top() + ((th - y + f64::from(ty) * th) * s) as f32,
                                    )
                                })
                                .collect();
                            painter.add(egui::Shape::line(pts, Stroke::new(0.8_f32, pen)));
                        }
                    }
                }
            }
        }
    }
    p.rect_stroke(r, 0.0, stroke, egui::StrokeKind::Inside);
}

// ---- choice panels ---------------------------------------------------------

/// A clickable cell with a picture; returns whether it was clicked.
fn cell(ui: &mut Ui, size: egui::Vec2, selected: bool, name: &str, paint: impl FnOnce(&Painter, Rect)) -> bool {
    let (r, resp) = ui.allocate_exact_size(size, Sense::click());
    let v = ui.style().interact_selectable(&resp, selected);
    ui.painter().rect_filled(r, 2.0, v.weak_bg_fill);
    paint(ui.painter(), r.shrink(3.0));
    if selected {
        ui.painter().rect_stroke(
            r,
            2.0,
            Stroke::new(2.0_f32, ui.visuals().selection.stroke.color),
            egui::StrokeKind::Inside,
        );
    }
    let clicked = resp.clicked();
    resp.on_hover_text(name)
        .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, name));
    clicked
}

/// The Fill Style choice panel's swatches (shared by the indicator and the edit
/// panel): NONE, shades and tints of the fill colour (no tints for black,
/// white and default), then the patterns in the pen colour.
pub(crate) fn fill_choices_grid(
    ui: &mut Ui,
    doc: &Document,
    (pen, fill): (i32, i32),
    current: i32,
    tk: &Tokens,
) -> Option<i32> {
    let mut chosen = None;
    let ink = ui.visuals().text_color();
    ui.label("Fill Style");
    egui::Grid::new("fill_shades").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
        let plain = matches!(fill, -1 | 0 | 7);
        for (i, f) in (-1..=40).filter(|f| !(plain && (21..=40).contains(f))).enumerate() {
            let name = if f < 0 { "none".to_string() } else { format!("{f}") };
            if cell(ui, vec2(32.0, 32.0), current == f, &name, |p, r| {
                fill_swatch(p, r, doc, (pen, fill), f, ink, tk)
            }) {
                chosen = Some(f);
            }
            if i % 11 == 10 {
                ui.end_row();
            }
        }
    });
    ui.label("Patterns");
    egui::Grid::new("fill_patterns").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
        for (i, f) in (41..=62).enumerate() {
            let name = rustfig_render::pattern::PATTERN_NAMES
                .get(i)
                .copied()
                .unwrap_or("pattern");
            if cell(ui, vec2(32.0, 32.0), current == f, name, |p, r| {
                fill_swatch(p, r, doc, (pen, fill), f, ink, tk)
            }) {
                chosen = Some(f);
            }
            if i % 11 == 10 {
                ui.end_row();
            }
        }
    });
    chosen
}

/// The edit panel's fill style: a button showing the current fill that opens
/// the indicator's choice panel, and xfig's words for it beside it.
pub(crate) fn fill_menu(ui: &mut Ui, doc: &Document, (pen, fill): (i32, i32), current: i32) -> Option<i32> {
    let tk = Tokens::for_dark(ui.visuals().dark_mode);
    let mut chosen = None;
    ui.horizontal(|ui| {
        let (r, resp) = ui.allocate_exact_size(vec2(PIC_BTN * 1.5, PIC_BTN), Sense::click());
        fill_swatch(
            ui.painter(),
            r,
            doc,
            (pen, fill),
            current,
            ui.visuals().text_color(),
            &tk,
        );
        resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill style choices"));
        egui::Popup::from_toggle_button_response(&resp)
            .close_behavior(egui::PopupCloseBehavior::CloseOnClick)
            .show(|ui| {
                chosen = fill_choices_grid(ui, doc, (pen, fill), current, &tk);
            });
        ui.label(fill_words(current));
    });
    chosen
}

/// xfig's words for an area fill: "No fill", "Filled 50 %", "Pattern 3".
pub(crate) fn fill_words(area: i32) -> String {
    match area {
        ..=-1 => "No fill".into(),
        f @ 0..=40 => format!("Filled {} %", f * 5),
        f => format!("Pattern {}", f - 41),
    }
}

fn choice_panel(app: &mut App, ui: &mut Ui, tk: &Tokens, a: Attr) {
    // xfig's Fill Style panel names its own sections ("Fill Style",
    // "Patterns").
    if a != Attr::FillStyle {
        ui.label(egui::RichText::new(a.name()).strong());
    }
    let ind = app.engine.indicator.clone();
    match a {
        Attr::Zoom => {
            // xfig's number pop-up with the Integer zoom check box.
            let mut z = nval_draft(ui, a, app.engine.view.zoom);
            if ui
                .add(
                    egui::DragValue::new(&mut z)
                        .range(0.01..=128.0)
                        .speed(0.01)
                        .fixed_decimals(2),
                )
                .changed()
            {
                nval_store(ui, a, z);
            }
            let mut int_zoom = app.engine.view.integer_zoom;
            if ui.checkbox(&mut int_zoom, "Integer zoom").changed() {
                app.run("view.set_integer_zoom", json!({ "on": int_zoom }));
            }
            ui.horizontal(|ui| {
                if ui.button("Fit to canvas").clicked() {
                    app.run("view.zoom_fit", json!({}));
                    nval_forget(ui, a);
                }
                if ui.button("1.00").clicked() {
                    app.run("view.zoom_set", json!({ "zoom": 1.0 }));
                    nval_forget(ui, a);
                }
            });
            let (set_it, cancel) = nval_buttons(ui);
            if set_it {
                app.run("view.zoom_set", json!({ "zoom": z }));
            }
            if set_it || cancel {
                nval_forget(ui, a);
                ui.close();
            }
        }
        Attr::Grid => {
            let u = units(app);
            let list = grid_choices();
            let items: Vec<_> = list
                .iter()
                .map(|(g, iso)| {
                    (
                        ind.grid == *g && (ind.grid_iso == *iso || g.is_none()),
                        grid_label(*g, *iso, u),
                        Pic::traced(grid_icon(*g, *iso)),
                    )
                })
                .collect();
            if let Some((g, iso)) = icon_choices(ui, "grid", &items, 5).and_then(|i| list.get(i)) {
                set(app, json!({ "grid": g, "grid_iso": iso }));
            }
        }
        Attr::PointPosn => {
            let u = units(app);
            let items: Vec<_> = SnapMode::ALL
                .iter()
                .map(|m| (ind.snap == *m, m.label(u).to_string(), Pic::traced(posn_icon(*m))))
                .collect();
            if let Some(m) = icon_choices(ui, "posn", &items, 6).and_then(|i| SnapMode::ALL.get(i)) {
                set(app, json!({ "snap": m }));
            }
        }
        Attr::VAlign | Attr::HAlign => {
            let horizontal = a == Attr::HAlign;
            let (list, cur, field) = if horizontal {
                (H_ALIGN, ind.align_h.as_str(), "align_h")
            } else {
                (V_ALIGN, ind.align_v.as_str(), "align_v")
            };
            let items: Vec<_> = list
                .iter()
                .map(|v| (cur == *v, (*v).to_string(), Pic::traced(align_icon(v, horizontal))))
                .collect();
            if let Some(v) = icon_choices(ui, field, &items, 4).and_then(|i| list.get(i)) {
                let mut m = serde_json::Map::new();
                m.insert(field.into(), json!(v));
                set(app, Value::Object(m));
            }
        }
        Attr::PenColor | Attr::FillColor => {
            let field = if a == Attr::PenColor { "pen_color" } else { "fill_color" };
            let cur = if a == Attr::PenColor {
                ind.pen_color
            } else {
                ind.fill_color
            };
            let doc_colors = colors(app.engine.doc());
            let mut chosen = None;
            egui::Grid::new(("colors", field))
                .spacing(vec2(2.0, 2.0))
                .show(ui, |ui| {
                    for (i, c) in doc_colors.iter().enumerate() {
                        let col = color32(app.engine.doc(), *c);
                        let name = color_name(*c);
                        if cell(ui, vec2(34.0, 20.0), *c == cur, &name, |p, r| {
                            p.rect_filled(r, 1.0, col);
                            if *c < 0 {
                                p.text(
                                    r.center(),
                                    egui::Align2::CENTER_CENTER,
                                    "Def",
                                    egui::FontId::proportional(9.0),
                                    Color32::GRAY,
                                );
                            }
                        }) {
                            chosen = Some(*c);
                        }
                        if i % 7 == 6 {
                            ui.end_row();
                        }
                    }
                });
            // The user's own colours (E1): picking one adds it to the drawing.
            let user = app.engine.prefs.rustfig.user_colors.clone();
            if !user.is_empty() {
                ui.label(egui::RichText::new("Your colours").small());
                egui::Grid::new(("user_colors", field))
                    .spacing(vec2(2.0, 2.0))
                    .show(ui, |ui| {
                        for (i, uc) in user.iter().enumerate() {
                            let rgb = rustfig_model::Rgb::from_hex(&uc.rgb).unwrap_or(rustfig_model::Rgb::BLACK);
                            let col = Color32::from_rgb(rgb.r, rgb.g, rgb.b);
                            if cell(ui, vec2(34.0, 20.0), false, &uc.name, |p, r| {
                                p.rect_filled(r, 1.0, col);
                            }) {
                                if let Some(r) = app.run("color.add", json!({ "rgb": uc.rgb })) {
                                    chosen = r
                                        .get("color")
                                        .and_then(Value::as_i64)
                                        .and_then(|c| i32::try_from(c).ok());
                                }
                            }
                            if i % 7 == 6 {
                                ui.end_row();
                            }
                        }
                    });
            }
            // Colours used lately (E4, W10-36), kept in the preferences.
            let recent: Vec<rustfig_model::Rgb> = app
                .engine
                .prefs
                .rustfig
                .recent_colors
                .iter()
                .filter_map(|h| rustfig_model::Rgb::from_hex(h))
                .collect();
            if !recent.is_empty() {
                ui.label(egui::RichText::new("Recent").small());
                ui.horizontal(|ui| {
                    for rgb in recent {
                        let hex = rgb.to_hex();
                        let col = Color32::from_rgb(rgb.r, rgb.g, rgb.b);
                        if cell(ui, vec2(22.0, 20.0), false, &format!("Recent {hex}"), |p, r| {
                            p.rect_filled(r, 1.0, col);
                        }) {
                            if let Some(r) = app.run("color.add", json!({ "rgb": hex })) {
                                chosen = r
                                    .get("color")
                                    .and_then(Value::as_i64)
                                    .and_then(|c| i32::try_from(c).ok());
                            }
                        }
                    }
                });
            }
            ui.horizontal(|ui| {
                if ui.button("More…").clicked() {
                    crate::color_dialog::open(app, cur);
                }
            });
            if let Some(c) = chosen {
                app.run("color.use", json!({ "color": c }));
                let mut m = serde_json::Map::new();
                m.insert(field.into(), json!(c));
                set(app, Value::Object(m));
            }
        }
        Attr::FillStyle => {
            let chosen = fill_choices_grid(ui, app.engine.doc(), (ind.pen_color, ind.fill_color), ind.area_fill, tk);
            // xfig's Cancel: any click closes the panel; this one sets nothing.
            let _ = ui.button("Cancel");
            if let Some(f) = chosen {
                set(app, json!({ "area_fill": f }));
            }
        }
        Attr::ArcType => {
            let items = [
                (!ind.pie_arc, "open".to_string(), arc_pic(false)),
                (ind.pie_arc, "pie wedge".to_string(), arc_pic(true)),
            ];
            if let Some(i) = icon_choices(ui, "arc", &items, 2) {
                set(app, json!({ "pie_arc": i == 1 }));
            }
        }
        Attr::AngleGeom => {
            let items: Vec<_> = AngleGeom::ALL
                .iter()
                .map(|m| (ind.angle_geom == *m, m.label().to_string(), Pic::traced(geom_icon(*m))))
                .collect();
            if let Some(m) = icon_choices(ui, "angle_geom", &items, 3).and_then(|i| AngleGeom::ALL.get(i)) {
                set(app, json!({ "angle_geom": m }));
            }
        }
        Attr::SmartLinks => {
            let modes = [LinkMode::Off, LinkMode::Move, LinkMode::Slide];
            let items: Vec<_> = modes
                .iter()
                .map(|m| {
                    (
                        ind.linkmode == *m,
                        link_name(*m).to_string(),
                        Pic::traced(link_icon(*m)),
                    )
                })
                .collect();
            if let Some(m) = icon_choices(ui, "smart_links", &items, 3).and_then(|i| modes.get(i)) {
                set(app, json!({ "linkmode": m }));
            }
        }
        Attr::LineStyle => {
            let items: Vec<_> = LineKind::ALL
                .iter()
                .map(|k| {
                    (
                        ind.line == *k,
                        k.name().to_string(),
                        Pic::of(line_icon(*k), VIcon::Line(*k)),
                    )
                })
                .collect();
            if let Some(k) = icon_choices(ui, "line", &items, 6).and_then(|i| LineKind::ALL.get(i)) {
                set(app, json!({ "line": k }));
            }
            // xfig: "Default dash length" and "Default dot gap" (0..10000,
            // both shown; a value of 0 or less means the default).
            let mut dash = ind.style_val;
            let mut gap = ind.dot_gap;
            let mut changed = false;
            egui::Grid::new("dash_gap").show(ui, |ui| {
                ui.label("Default dash length")
                    .on_hover_text(crate::dialogs::LINE_STYLE_VALUE_HELP);
                changed |= ui
                    .add(
                        egui::DragValue::new(&mut dash)
                            .range(0.0..=10000.0)
                            .speed(0.1)
                            .max_decimals(1),
                    )
                    .changed();
                ui.end_row();
                ui.label("Default dot gap")
                    .on_hover_text(crate::dialogs::LINE_STYLE_VALUE_HELP);
                changed |= ui
                    .add(
                        egui::DragValue::new(&mut gap)
                            .range(0.0..=10000.0)
                            .speed(0.1)
                            .max_decimals(1),
                    )
                    .changed();
                ui.end_row();
            });
            if changed {
                set(
                    app,
                    json!({ "style_val": if dash <= 0.0 { 4.0 } else { dash }, "dot_gap": if gap <= 0.0 { 3.0 } else { gap } }),
                );
            }
        }
        Attr::JoinStyle => {
            let items: Vec<_> = JOINS
                .iter()
                .map(|j| {
                    (
                        ind.join == *j,
                        format!("{j:?}").to_lowercase(),
                        Pic::of(join_icon(*j), VIcon::Join(*j)),
                    )
                })
                .collect();
            if let Some(j) = icon_choices(ui, "join", &items, 3).and_then(|i| JOINS.get(i)) {
                set(app, json!({ "join": j }));
            }
        }
        Attr::CapStyle => {
            let items: Vec<_> = CAPS
                .iter()
                .map(|c| {
                    (
                        ind.cap == *c,
                        format!("{c:?}").to_lowercase(),
                        Pic::of(cap_icon(*c), VIcon::Cap(*c)),
                    )
                })
                .collect();
            if let Some(c) = icon_choices(ui, "cap", &items, 3).and_then(|i| CAPS.get(i)) {
                set(app, json!({ "cap": c }));
            }
        }
        Attr::ArrowMode => {
            let names = ["no arrows", "forward", "both", "backward"];
            let items: Vec<_> = names
                .iter()
                .enumerate()
                .map(|(m, n)| (arrow_mode(&ind) == m, (*n).to_string(), arrow_mode_pic(m)))
                .collect();
            if let Some(m) = icon_choices(ui, "arrow_mode", &items, 4) {
                set_arrow_mode(app, m);
            }
        }
        Attr::ArrowType => {
            let cur = arrow_choice_of(ind.arrow.kind, ind.arrow.style);
            let items: Vec<_> = (0..ARROW_CHOICES)
                .map(|n| {
                    let (kind, style) = arrow_choice(n);
                    let name = if kind == 0 {
                        "type 0".to_string()
                    } else {
                        format!("{} type {kind}", if style == 1 { "solid" } else { "hollow" })
                    };
                    (n == cur, name, arrow_type_pic(n))
                })
                .collect();
            if let Some(n) = icon_choices(ui, "arrow_type", &items, 9) {
                set_arrow_type(app, &ind, n as i32);
            }
        }
        Attr::ArrowSize => {
            // xfig's `popup_arrowsize_panel`: Absolute Values or Multiple of
            // Line Width, each with Thickness, Width and Length; Cancel and Set.
            let unit = rustfig_geom::FIG_PER_STYLE_UNIT;
            let id = egui::Id::new("arrow_size_draft");
            let frame = ui.ctx().cumulative_frame_nr();
            let start = (
                ind.arrow_absolute,
                [ind.arrow.thickness, ind.arrow.width / unit, ind.arrow.height / unit],
                [ind.arrow_mult.0, ind.arrow_mult.1, ind.arrow_mult.2],
            );
            let (mut absolute, mut abs, mut mult) = ui
                .data(|d| d.get_temp::<((bool, [f64; 3], [f64; 3]), u64)>(id))
                .filter(|(_, last)| frame <= last + 2)
                .map_or(start, |(v, _)| v);
            egui::Grid::new("arrow_size").show(ui, |ui| {
                ui.radio_value(&mut absolute, true, "Absolute Values");
                ui.end_row();
                for (i, name) in ["Thickness", "Width", "Length"].iter().enumerate() {
                    ui.label(format!("   {name}"));
                    if let Some(v) = abs.get_mut(i) {
                        ui.add_enabled(
                            absolute,
                            egui::DragValue::new(v).range(0.1..=10_000.0).speed(0.1).max_decimals(2),
                        )
                        .widget_info(|| {
                            egui::WidgetInfo::labeled(egui::WidgetType::DragValue, absolute, format!("Absolute {name}"))
                        });
                    }
                    ui.end_row();
                }
                ui.radio_value(&mut absolute, false, "Multiple of Line Width");
                ui.end_row();
                for (i, name) in ["Thickness", "Width", "Length"].iter().enumerate() {
                    ui.label(format!("   {name}"));
                    if let Some(v) = mult.get_mut(i) {
                        ui.add_enabled(
                            !absolute,
                            egui::DragValue::new(v).range(0.1..=10_000.0).speed(0.1).max_decimals(2),
                        )
                        .widget_info(|| {
                            egui::WidgetInfo::labeled(
                                egui::WidgetType::DragValue,
                                !absolute,
                                format!("Multiple {name}"),
                            )
                        });
                    }
                    ui.end_row();
                }
            });
            ui.data_mut(|d| d.insert_temp(id, ((absolute, abs, mult), frame)));
            let (set_it, cancel) = nval_buttons(ui);
            if set_it {
                let mut arrow = ind.arrow;
                arrow.thickness = abs[0];
                arrow.width = abs[1] * unit;
                arrow.height = abs[2] * unit;
                set(
                    app,
                    json!({ "arrow": arrow, "arrow_absolute": absolute, "arrow_mult": mult }),
                );
            }
            if set_it || cancel {
                ui.data_mut(|d| d.remove::<((bool, [f64; 3], [f64; 3]), u64)>(id));
                ui.close();
            }
        }
        Attr::TextJust => {
            let items: Vec<_> = JUSTIFY
                .iter()
                .map(|j| {
                    (
                        ind.justify == *j,
                        format!("{j:?}").to_lowercase(),
                        Pic::of(just_icon(*j), VIcon::Justify(*j)),
                    )
                })
                .collect();
            if let Some(j) = icon_choices(ui, "just", &items, 3).and_then(|i| JUSTIFY.get(i)) {
                set(app, json!({ "justify": j }));
            }
        }
        Attr::TextFlags => {
            // xfig's Text Flags panel: Hidden, Rigid and TeX (the special flag).
            for (field, name, on) in [
                ("hidden_text", "Hidden", ind.hidden_text),
                ("rigid_text", "Rigid", ind.rigid_text),
                ("special_text", "TeX", ind.special_text),
            ] {
                let mut v = on;
                if ui.checkbox(&mut v, name).changed() {
                    let mut m = serde_json::Map::new();
                    m.insert(field.into(), json!(v));
                    set(app, Value::Object(m));
                }
            }
        }
        Attr::Font => {
            // E11: the font chooser (names in their fonts, search, size, preview).
            ui.set_min_width(260.0);
            let out = crate::font_chooser::show(
                ui,
                "indicator_font",
                ind.font,
                ind.postscript,
                ind.local_font.as_deref(),
                Some(ind.font_size),
            );
            if let Some((f, ps)) = out.font {
                set(
                    app,
                    json!({"font": f, "postscript": ps, "local_font": out.local.flatten()}),
                );
                ui.close();
            }
            if let Some(s) = out.size {
                set(app, json!({"font_size": s}));
            }
        }
        Attr::Depth
        | Attr::RotnAngle
        | Attr::NumSides
        | Attr::NumCopies
        | Attr::NumXCopies
        | Attr::NumYCopies
        | Attr::LineWidth
        | Attr::BoxRadius
        | Attr::TextAngle
        | Attr::FontSize
        | Attr::TangentLength
        | Attr::TextStep => value_entry(app, ui, &ind, a),
        // Opens its own window instead of a pop-up (`dimline_panel`).
        Attr::DimLine => {}
    }
}

/// xfig's value panel: a number field.
fn value_entry(app: &mut App, ui: &mut Ui, ind: &Indicator, a: Attr) {
    let (field, mut v, lo, hi, speed) = match a {
        Attr::Depth => (
            "depth",
            f64::from(ind.depth),
            0.0,
            f64::from(rustfig_model::MAX_DEPTH),
            1.0,
        ),
        Attr::RotnAngle => ("rotate_angle", ind.rotate_angle, -360.0, 360.0, 1.0),
        Attr::NumSides => ("sides", ind.sides as f64, 3.0, 200.0, 1.0),
        Attr::NumCopies => ("num_copies", f64::from(ind.num_copies), 1.0, 99.0, 1.0),
        Attr::NumXCopies => ("num_x_copies", f64::from(ind.num_x_copies), 0.0, 99.0, 1.0),
        Attr::NumYCopies => ("num_y_copies", f64::from(ind.num_y_copies), 0.0, 99.0, 1.0),
        Attr::LineWidth => ("thickness", f64::from(ind.thickness), 0.0, 500.0, 1.0),
        Attr::BoxRadius => ("box_radius", f64::from(ind.box_radius), 0.0, 1000.0, 1.0),
        Attr::TextAngle => ("text_angle", ind.text_angle, -360.0, 360.0, 1.0),
        Attr::FontSize => ("font_size", ind.font_size, 1.0, 500.0, 0.5),
        Attr::TextStep => ("text_step", ind.text_step, 0.0, 100.0, 0.05),
        Attr::TangentLength => ("tangent_length", ind.tangent_length, 0.0, 20.0, 0.05),
        _ => return,
    };
    let integer = matches!(
        a,
        Attr::Depth
            | Attr::NumSides
            | Attr::NumCopies
            | Attr::NumXCopies
            | Attr::NumYCopies
            | Attr::LineWidth
            | Attr::BoxRadius
    );
    // xfig's number pop-up (`popup_nval_panel`): the value is edited here and
    // only Set (or Return) applies it; Cancel closes without a change.
    v = nval_draft(ui, a, v);
    let mut dv = egui::DragValue::new(&mut v).range(lo..=hi).speed(speed);
    if integer {
        dv = dv.fixed_decimals(0);
    }
    // xfig's panel: a "Value" label before the field.
    let resp = ui
        .horizontal(|ui| {
            ui.label("Value");
            ui.add(dv)
        })
        .inner;
    let mut changed = resp.changed();
    if resp.hovered() {
        // xfig's spinners (`Fig*spinner_form*Text.translations`): wheel up
        // decreases, wheel down increases, one step per notch.
        let dy = ui.input(|i| i.raw_scroll_delta.y);
        if dy != 0.0 {
            let id = resp.id.with("wheel");
            let acc = ui.data(|d| d.get_temp::<f32>(id).unwrap_or(0.0)) + dy;
            let notches = (acc / 40.0).trunc();
            ui.data_mut(|d| d.insert_temp(id, acc - notches * 40.0));
            if notches != 0.0 {
                let step = if matches!(a, Attr::TextStep | Attr::TangentLength) {
                    0.1
                } else {
                    1.0
                };
                v = (v - f64::from(notches) * step).clamp(lo, hi);
                changed = true;
            }
        }
    }
    if changed {
        nval_store(ui, a, v);
    }
    let (set_it, cancel) = nval_buttons(ui);
    if set_it {
        let value = if integer { json!(v.round() as i64) } else { json!(v) };
        let mut m = serde_json::Map::new();
        m.insert(field.into(), value);
        set(app, Value::Object(m));
    }
    if set_it || cancel {
        nval_forget(ui, a);
        ui.close();
    }
}

/// A number without trailing zeros ("4", "0.5").
fn fmt_g(v: f64) -> String {
    let s = format!("{v:.1}");
    s.strip_suffix(".0").map_or(s.clone(), str::to_string)
}

fn nval_id(a: Attr) -> egui::Id {
    egui::Id::new(("nval_draft", format!("{a:?}")))
}

/// The value being edited in a number pop-up: `current` when the pop-up has
/// just opened (it was not shown in the last frames), else the draft.
fn nval_draft(ui: &Ui, a: Attr, current: f64) -> f64 {
    let frame = ui.ctx().cumulative_frame_nr();
    let kept = ui
        .data(|d| d.get_temp::<(f64, u64)>(nval_id(a)))
        .filter(|(_, last)| frame <= last + 2);
    let v = kept.map_or(current, |(v, _)| v);
    ui.data_mut(|d| d.insert_temp(nval_id(a), (v, frame)));
    v
}

fn nval_store(ui: &Ui, a: Attr, v: f64) {
    let frame = ui.ctx().cumulative_frame_nr();
    ui.data_mut(|d| d.insert_temp(nval_id(a), (v, frame)));
}

fn nval_forget(ui: &Ui, a: Attr) {
    ui.data_mut(|d| d.remove::<(f64, u64)>(nval_id(a)));
}

/// Cancel and Set; Return sets too. Returns (set, cancel).
fn nval_buttons(ui: &mut Ui) -> (bool, bool) {
    let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
    let mut out = (enter, false);
    ui.horizontal(|ui| {
        out.1 = ui.button("Cancel").clicked();
        out.0 |= ui.button("Set").clicked();
    });
    out
}

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

    #[test]
    fn every_attribute_button_has_an_existing_topic() {
        for a in Attr::ORDER {
            assert!(crate::help::find_topic(a.help_topic()).is_some(), "{}", a.name());
        }
    }

    #[test]
    fn angle_geom_shows_in_xfigs_modes() {
        // I_LINE, I_OPEN, I_CLOSED and I_ADDMOVPT carry I_ANGLEGEOM; boxes
        // and circles do not.
        for t in [
            ToolKind::Polyline,
            ToolKind::Polygon,
            ToolKind::SplineApprox,
            ToolKind::SplineInterpClosed,
            ToolKind::XSpline,
            ToolKind::MovePoint,
            ToolKind::AddPoint,
        ] {
            assert!(attributes_for(t).contains(&Attr::AngleGeom), "{t:?}");
        }
        for t in [ToolKind::Box, ToolKind::CircleRadius, ToolKind::Arc, ToolKind::Move] {
            assert!(!attributes_for(t).contains(&Attr::AngleGeom), "{t:?}");
        }
        // Between Horiz Align and PenColor, as in xfig's panel.
        let a = attributes_for(ToolKind::Polyline);
        let i = a.iter().position(|x| *x == Attr::AngleGeom).unwrap();
        assert_eq!(a.get(i + 1), Some(&Attr::PenColor));
    }

    #[test]
    fn rotation_steps_through_xfigs_presets() {
        assert_eq!(step_rotn(90.0, 1), 120.0);
        assert_eq!(step_rotn(180.0, 1), 15.0);
        assert_eq!(step_rotn(90.0, -1), 60.0);
        assert_eq!(step_rotn(15.0, -1), 180.0);
        assert_eq!(step_rotn(0.0, 1), 15.0);
    }

    #[test]
    fn angle_steps_to_multiples_of_fifteen() {
        assert_eq!(step_angle(0.0, 1), 15.0);
        assert_eq!(step_angle(20.0, 1), 30.0);
        assert_eq!(step_angle(20.0, -1), 15.0);
        assert_eq!(step_angle(-20.0, -1), -30.0);
        assert_eq!(step_angle(360.0, 1), 360.0);
    }

    #[test]
    fn font_size_steps_grow_with_size() {
        assert_eq!(step_font_size(12.0, 1), 13.0);
        assert_eq!(step_font_size(24.0, 1), 26.0);
        assert_eq!(step_font_size(64.0, 1), 68.0);
        assert_eq!(step_font_size(120.0, 1), 130.0);
        assert_eq!(step_font_size(130.0, -1), 120.0);
        assert_eq!(step_font_size(1.0, -1), 1.0);
    }

    #[test]
    fn text_step_steps() {
        assert_eq!(step_text_step(1.0, 1), 1.1);
        assert_eq!(step_text_step(2.0, 1), 2.2);
        assert_eq!(step_text_step(5.0, 1), 5.5);
        assert_eq!(step_text_step(10.0, 1), 11.0);
        assert_eq!(step_text_step(0.4, -1), 0.4);
    }

    #[test]
    fn fill_steps_wrap_and_skip_tints_for_black() {
        assert_eq!(step_fill(62, false, 1), -1);
        assert_eq!(step_fill(-1, false, -1), 62);
        assert_eq!(step_fill(20, true, 1), 41);
        assert_eq!(step_fill(41, true, -1), 20);
        assert_eq!(step_fill(20, false, 1), 21);
    }
}
