//! View commands: zoom, pan, grid, snap, page border. use super::{always, num, num_or, opt_bool, opt_num, opt_str, CmdResult, Command}; use crate::{Engine, EngineError, SnapMode}; use rustfig_geom::Point; use serde_json::{json, Value}; pub(super) fn commands() -> Vec { vec![ Command { id: "view.zoom_in", label: "Zoom In", menu: "View", shortcut: "Ctrl+=", params: "{}", mutates: false, enabled: always, run: |e, _| zoom_by(e, true), }, Command { id: "view.zoom_out", label: "Zoom Out", menu: "View", shortcut: "Ctrl+-", params: "{}", mutates: false, enabled: always, run: |e, _| zoom_by(e, false), }, Command { id: "view.zoom_fit", label: "Zoom to Fit", menu: "View", shortcut: "Ctrl+0", params: "{}", mutates: false, enabled: always, run: |e, _| { let bb = e.fit_bounds(); if !bb.is_empty() { e.view.fit(&bb); e.view.apply_integer_zoom(true); } Ok(json!({"zoom": e.view.zoom})) }, }, Command { id: "view.zoom_actual", label: "Actual Size", menu: "View", shortcut: "Ctrl+1", params: "{}", mutates: false, enabled: always, run: |e, _| { let c = Point::new(e.view.size.0 / 2.0, e.view.size.1 / 2.0); e.view.zoom_about(1.0, c); Ok(json!({"zoom": 1.0})) }, }, Command { id: "view.zoom_box", label: "Zoom to box", menu: "", shortcut: "", params: "{\"x1\", \"y1\", \"x2\", \"y2\": two opposite corners of the box (Fig units)} — zoom so that the box fills the canvas, its top-left corner at the canvas's top-left (xfig's zoom box: Ctrl+left click, then drag or click the opposite corner; on macOS Cmd+click). A box with no width or no height is ignored", mutates: false, enabled: always, run: |e, p| { let a = Point::new(num(p, "x1")?, num(p, "y1")?); let b = Point::new(num(p, "x2")?, num(p, "y2")?); if !(a.is_finite() && b.is_finite()) { return Err(EngineError::bad_params("box corners must be finite")); } let (w, h) = ((a.x - b.x).abs(), (a.y - b.y).abs()); if w >= 1.0 && h >= 1.0 { let px = (e.view.size.0.max(10.0) / w).min(e.view.size.1.max(10.0) / h); e.view.zoom = (px / crate::view::BASE_PX_PER_FIG).clamp(crate::view::MIN_ZOOM, crate::view::MAX_ZOOM); e.view.origin = Point::new(a.x.min(b.x), a.y.min(b.y)); } Ok(json!({"zoom": e.view.zoom})) }, }, Command { id: "view.zoom_set", label: "Set zoom", menu: "", shortcut: "", params: "{\"zoom\": factor (1 = 80 px/inch), \"x\"?, \"y\"?: screen anchor}", mutates: false, enabled: always, run: |e, p| { let z = num(p, "zoom")?; if z <= 0.0 { return Err(EngineError::bad_params("zoom must be positive")); } let a = Point::new( num_or(p, "x", e.view.size.0 / 2.0)?, num_or(p, "y", e.view.size.1 / 2.0)?, ); let z = if e.view.integer_zoom && z > 1.99 { z.round() } else { z }; e.view.zoom_about(z, a); Ok(json!({"zoom": e.view.zoom})) }, }, Command { id: "view.set_integer_zoom", label: "Integer zoom", menu: "", shortcut: "", params: "{\"on\"?: bool (default: toggle)} — xfig's Integer zoom check box in the Zoom pop-up: zoom values above 1 are whole numbers (zoom_set rounds, zoom_fit truncates)", mutates: false, enabled: always, run: |e, p| { e.view.integer_zoom = opt_bool(p, "on")?.unwrap_or(!e.view.integer_zoom); Ok(json!({"integer_zoom": e.view.integer_zoom})) }, }, Command { id: "view.pan", label: "Pan", menu: "", shortcut: "", params: "{\"dx\", \"dy\"} — screen pixels", mutates: false, enabled: always, run: |e, p| { e.view.pan_pixels(num(p, "dx")?, num(p, "dy")?); Ok(json!({})) }, }, Command { id: "view.set_scale", label: "Unit/Scale settings", menu: "", shortcut: "", params: "{\"decimal\"?: bool (imperial rulers in tenths), \"unit\"?: figure unit name (empty: the ruler unit), \"scale\"?: figure units per ruler unit}", mutates: false, enabled: always, run: |e, p| { let decimal = opt_bool(p, "decimal")?; let unit = opt_str(p, "unit")?; let scale = opt_num(p, "scale")?; if let Some(u) = unit { if u.chars().count() > crate::view::MAX_UNIT_NAME || u.chars().any(char::is_control) { return Err(EngineError::bad_params("unit name too long or not printable")); } } if let Some(s) = scale { if !(s.is_finite() && (1e-6..=1e6).contains(&s)) { return Err(EngineError::bad_params("scale must be between 0.000001 and 1000000")); } } if let Some(d) = decimal { e.unit_scale.decimal_inches = d; } if let Some(u) = unit { e.unit_scale.unit = u.trim().to_string(); } if let Some(s) = scale { e.unit_scale.factor = s; } Ok(json!({"label": e.unit_label()})) }, }, Command { id: "view.pan_origin", label: "Pan to Origin", menu: "View", shortcut: "", params: "{}", mutates: false, enabled: always, run: |e, _| { let s = e.view.px_per_fig(); e.view.origin = Point::new(-40.0 / s, -40.0 / s); Ok(json!({})) }, }, Command { id: "view.set_size", label: "Set canvas size", menu: "", shortcut: "", params: "{\"width\", \"height\"} — pixels (set by the UI)", mutates: false, enabled: always, run: |e, p| { let (w, h) = (num(p, "width")?, num(p, "height")?); e.view.size = (w.clamp(1.0, 100_000.0), h.clamp(1.0, 100_000.0)); Ok(json!({})) }, }, Command { id: "view.toggle_grid", label: "Show Grid", menu: "View", shortcut: "Ctrl+'", params: "{}", mutates: false, enabled: always, run: |e, _| { e.indicator.grid = match e.indicator.grid { Some(_) => None, None => Some(match e.indicator.snap { SnapMode::Any => SnapMode::Medium, s => s, }), }; Ok(json!({"grid": e.indicator.grid.is_some()})) }, }, Command { id: "view.set_grid", label: "Set grid", menu: "", shortcut: "", params: "{\"grid\": none|fine|small|medium|large|coarse}", mutates: false, enabled: always, run: |e, p| { e.indicator.grid = match opt_str(p, "grid")?.unwrap_or("none") { "none" | "off" => None, s => Some(parse_mode(s)?), }; Ok(json!({})) }, }, Command { id: "view.set_snap", label: "Set point positioning", menu: "", shortcut: "", params: "{\"snap\": any|fine|small|medium|large|coarse}", mutates: false, enabled: always, run: |e, p| { e.indicator.snap = parse_mode(opt_str(p, "snap")?.unwrap_or("any"))?; Ok(json!({})) }, }, Command { id: "view.toggle_lengths", label: "Show Lengths", menu: "", shortcut: "", params: "{\"on\"?: bool} — while drawing, show the dx / dy triangle of the segment with its two sides' lengths (xfig's Show Lengths; the message panel always shows length, dx, dy and angle)", mutates: false, enabled: always, run: |e, p| { e.show_lengths = super::opt_bool(p, "on")?.unwrap_or(!e.show_lengths); Ok(json!({"show_lengths": e.show_lengths})) }, }, Command { id: "view.toggle_crosshair", label: "Show Crosshair", menu: "", shortcut: "", params: "{\"on\"?: bool}: draw full-window red lines through the pointer while drawing or editing (xfig's `crosshair` resource, option -crosshair)", mutates: false, enabled: always, run: |e, p| { e.crosshair = super::opt_bool(p, "on")?.unwrap_or(!e.crosshair); Ok(json!({"crosshair": e.crosshair})) }, }, Command { id: "view.toggle_vertex_numbers", label: "Show vertex numbers", menu: "", shortcut: "", params: "{\"on\"?: bool} — number the points of lines, polygons and splines on the canvas (xfig's Show vertex numbers; resource shownums)", mutates: false, enabled: always, run: |e, p| { let on = super::opt_bool(p, "on")?.unwrap_or(!e.show_vertex_numbers()); e.prefs .set("shownums", toml::Value::Boolean(on)) .map_err(EngineError::bad_params)?; e.prefs_dirty = true; Ok(json!({"show_vertex_numbers": on})) }, }, Command { id: "view.toggle_latex_preview", label: "Typeset LaTeX Text", menu: "View", shortcut: "", params: r#"{"on": bool (optional)}"#, mutates: false, enabled: always, run: |e, p| { let on = p .get("on") .and_then(Value::as_bool) .unwrap_or(!e.prefs.rustfig.latex_preview.unwrap_or(true)); e.prefs.rustfig.latex_preview = Some(on); e.prefs_dirty = true; e.prefs.apply_latex(e); e.bump_revision(); Ok(json!({"latex_preview": on, "tex": e.latex.program().map(|p| p.display().to_string())})) }, }, Command { id: "view.set_latex_program", label: "LaTeX Program", menu: "", shortcut: "", params: r#"{"program": string (name or path; empty looks for pdflatex, then lualatex)}"#, mutates: false, enabled: always, run: |e, p| { let program = p .get("program") .and_then(Value::as_str) .ok_or_else(|| EngineError::bad_params("program: a string is required"))?; if program.len() > 1024 || program.contains('\0') { return Err(EngineError::bad_params("program: not a program name")); } e.prefs.rustfig.latex_program = Some(program.trim().to_string()); e.prefs_dirty = true; e.prefs.apply_latex(e); e.bump_revision(); Ok(json!({"program": program.trim(), "found": e.latex.program().map(|p| p.display().to_string())})) }, }, Command { id: "view.set_latex_preamble", label: "LaTeX Preamble", menu: "", shortcut: "", params: r#"{"preamble": string (added to the preamble of typeset texts)}"#, mutates: false, enabled: always, run: |e, p| { let pre = p .get("preamble") .and_then(Value::as_str) .ok_or_else(|| EngineError::bad_params("preamble: a string is required"))?; if pre.len() > crate::latex_preview::MAX_PREAMBLE_BYTES { return Err(EngineError::bad_params("preamble: too long")); } e.prefs.rustfig.latex_preamble = Some(pre.to_string()); e.prefs_dirty = true; e.prefs.apply_latex(e); e.bump_revision(); Ok(json!({"preamble": pre})) }, }, Command { id: "view.toggle_page_border", label: "Show Page Border", menu: "View", shortcut: "Ctrl+B", params: "{}", mutates: false, enabled: always, run: |e, _| { e.show_page_border = !e.show_page_border; Ok(json!({"page_border": e.show_page_border})) }, }, ] } fn parse_mode(s: &str) -> Result { serde_json::from_value(Value::String(s.to_string())) .map_err(|_| EngineError::bad_params(format!("unknown positioning mode {s:?}"))) } fn zoom_by(e: &mut Engine, up: bool) -> CmdResult { let c = Point::new(e.view.size.0 / 2.0, e.view.size.1 / 2.0); let z = crate::view::View::zoom_step(e.view.zoom, up); e.view.zoom_about(z, c); e.view.apply_integer_zoom(false); Ok(json!({"zoom": e.view.zoom})) } .