//! 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 { 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 { 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 = 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::(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 { let mut v: Vec = (-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, 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(list: &[T], cur: T, dir: i32) -> Option { 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, 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, 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = 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 { 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 { 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 = 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::(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); } } .