//! View menu, preferences and tools. View state is per document and drivable like everything else. use figcraft_doc::*; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; use crate::tools::ToolId; use crate::{EngineError, Result, Session, ViewState, bad, spec}; fn view(s: &mut Session) -> Result<&mut ViewState> { Ok(&mut s.ds_mut()?.view) } fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Result { let v = view(s)?; let slot = f(v); *slot = boolean(p, "on").unwrap_or(!*slot); Ok(json!({"on": *slot})) } /// Pixel preview: toggles, or turns on at a resolution of 1x or 2x. fn pixel_preview(s: &mut Session, p: &Value) -> Result { let scale = match p.get("scale") { None | Some(Value::Null) => None, Some(v) => match v.as_u64() { Some(k @ (1 | 2)) => Some(k as u8), _ => return Err(bad("view.pixelPreview", "`scale` must be 1 or 2")), }, }; let v = view(s)?; if let Some(k) = scale { v.pixel_scale = k; } v.pixel_preview = boolean(p, "on").unwrap_or(scale.is_some() || !v.pixel_preview); Ok(json!({"on": v.pixel_preview, "scale": v.pixel_scale})) } fn zoom_to(s: &mut Session, z: f64) -> Result { let v = view(s)?; let c = (v.cx, v.cy); v.set_zoom_at(z, c); Ok(json!({"zoom": v.zoom})) } /// Figma's zoom steps. const STEPS: &[f64] = &[0.02, 0.03, 0.06, 0.13, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0]; fn step(z: f64, up: bool) -> f64 { if up { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(256.0) } else { STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.02) } } /// The zoom tool: a click zooms a step in (or out) about the clicked point, a dragged area fills /// the view. fn zoom_at(s: &mut Session, p: &Value) -> Result { let (Some(x), Some(y)) = (num(p, "x").filter(|v| v.is_finite()), num(p, "y").filter(|v| v.is_finite())) else { return Err(bad("view.zoomAt", "`x` and `y` (page coordinates) are required")); }; let v = view(s)?; match (num(p, "width"), num(p, "height")) { (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => { v.fit(figcraft_geom::Rect::new(x, y, x + w, y + h), ViewState::MAX_ZOOM); } _ => { let z = step(v.zoom, !boolean(p, "out").unwrap_or(false)); v.set_zoom_at(z, (x, y)); } } Ok(json!({"zoom": v.zoom, "cx": v.cx, "cy": v.cy})) } fn zoom_frame(s: &mut Session, dir: i64) -> Result { let ds = s.ds()?; let frames = ds.doc.top_frames(ds.page); if frames.is_empty() { return ok(); } let cur = ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)).and_then(|f| frames.iter().position(|x| *x == f)); let n = frames.len() as i64; let next = match cur { Some(c) => (c as i64 + dir).rem_euclid(n) as usize, None => 0, }; let Some(f) = frames.get(next).copied() else { return ok() }; let b = ds.doc.world_bounds(f).unwrap_or_default(); let ds = s.ds_mut()?; ds.selection = vec![f]; ds.view.fit(b, 256.0); Ok(json!({"frame": f})) } fn page_step(s: &mut Session, dir: i64) -> Result { let ds = s.ds()?; let pages = &ds.doc.pages; let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64; let n = pages.len() as i64; let next = (i + dir).clamp(0, n - 1) as usize; let Some(pg) = pages.get(next).copied() else { return ok() }; let ds = s.ds_mut()?; ds.page = pg; ds.selection.clear(); ds.zoom_to_fit(); Ok(json!({"page": pg})) } fn ui(s: &mut Session, kind: &str) -> Result { s.request_ui(kind, json!({})); ok() } /// Move the keyboard `step` regions on (toolbar, layers, canvas, properties): the UI does it. fn region(s: &mut Session, step: i64) -> Result { s.request_ui("focus.region", json!({"step": step})); ok() } fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result { let slot = f(&mut s.prefs); *slot = boolean(p, "on").unwrap_or(!*slot); Ok(json!({"on": *slot})) } fn tool(s: &mut Session, t: ToolId) -> Result { // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) { return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into())); } s.set_tool(t); Ok(json!({"tool": t.id()})) } pub fn specs() -> Vec { vec![ spec!("view.zoomIn", "Zoom in", "{}", false, |s, _| { let z = view(s)?.zoom; zoom_to(s, step(z, true)) }), spec!("view.zoomOut", "Zoom out", "{}", false, |s, _| { let z = view(s)?.zoom; zoom_to(s, step(z, false)) }), spec!("view.zoom100", "Zoom to 100%", "{}", false, |s, _| zoom_to(s, 1.0)), spec!("view.zoom50", "Zoom to 50%", "{}", false, |s, _| zoom_to(s, 0.5)), spec!("view.zoom200", "Zoom to 200%", "{}", false, |s, _| zoom_to(s, 2.0)), spec!("view.zoomFit", "Zoom to fit", "{}", false, |s, _| { let ds = s.ds_mut()?; if let Some(b) = ds.doc.page_content_bounds(ds.page) { ds.view.fit(b, 256.0); } Ok(json!({"zoom": ds.view.zoom})) }), spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| { let ds = s.ds_mut()?; if let Some(b) = ds.doc.bounds_of(&ds.selection) { ds.view.fit(b, 256.0); } Ok(json!({"zoom": ds.view.zoom})) }), spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| { let v = view(s)?; if let Some(z) = num(p, "zoom") { v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM); } v.cx = num_or(p, "cx", v.cx); v.cy = num_or(p, "cy", v.cy); if let Some(w) = num(p, "vw") { v.vw = w.clamp(10.0, 20000.0); } if let Some(h) = num(p, "vh") { v.vh = h.clamp(10.0, 20000.0); } Ok(serde_json::to_value(&*v).unwrap_or(Value::Null)) }), spec!( "view.zoomAt", "Zoom tool", "{x, y (page point kept in place), out? (zoom out a step), width?, height? (an area to fit instead)}", false, zoom_at ), spec!("view.get", "Get view", "{}", false, |s, _| Ok(serde_json::to_value(&s.ds()?.view).unwrap_or(Value::Null))), spec!("view.prevFrame", "Zoom to previous frame", "{}", false, |s, _| zoom_frame(s, -1)), spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)), spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)), spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)), spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)), spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)), spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { flag(s, p, |v| &mut v.outlines_selection) }), spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)), spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")), spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")), spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")), spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")), spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")), spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")), spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")), spec!("view.nextRegion", "Focus next region", "{}", false, |s, _| region(s, 1)), spec!("view.previousRegion", "Focus previous region", "{}", false, |s, _| region(s, -1)), spec!("view.themeLight", "Light theme", "{}", false, |s, _| { s.prefs.theme = "light".into(); ok() }), spec!("view.themeDark", "Dark theme", "{}", false, |s, _| { s.prefs.theme = "dark".into(); ok() }), spec!("view.themeSystem", "Follow system theme", "{}", false, |s, _| { s.prefs.theme = "system".into(); ok() }), spec!("pref.keepTool", "Keep tool selected after use", "{on?}", false, |s, p| pref(s, p, |x| &mut x.keep_tool)), spec!("pref.highlightHover", "Highlight layers on hover", "{on?}", false, |s, p| pref(s, p, |x| &mut x.highlight_on_hover)), spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)), spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big})) }), spec!("pref.userName", "Your name in comments", "{name (blank: the default, \"You\")}", false, |s, p| { let name = req_string("pref.userName", p, "name")?; let name: String = name.trim().chars().filter(|c| !c.is_control()).take(64).collect(); s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name }; Ok(json!({"name": s.prefs.user_name})) }), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), spec!("help.about", "About FigCraft", "{}", false, |s, _| ui(s, "dialog.about")), spec!("help.page", "Help page", "{}", false, |s, _| ui(s, "open.help")), spec!("libraries.open", "Libraries", "{}", false, |s, _| ui(s, "panel.libraries")), spec!( "tool.set", "Choose tool", "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}", false, |s, p| { let t = req_string("tool.set", p, "tool")?; let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?; tool(s, t) } ), spec!( "tool.scaleAnchor", "Scale anchor", "{anchor?: tl|t|tr|l|c|r|bl|b|br} (the point of the selection that stays put when the Scale tool scales; omit to read it)", false, |s, p| { if let Some(a) = string(p, "anchor") { s.tool.scale_anchor = crate::tools::anchor_of(&a).ok_or_else(|| bad("tool.scaleAnchor", format!("unknown anchor `{a}`")))?; if let Ok(ds) = s.ds_mut() { ds.revision += 1; } } Ok(json!({"anchor": crate::tools::anchor_name(s.tool.scale_anchor)})) } ), spec!("tool.move", "Move", "{}", false, |s, _| tool(s, ToolId::Move)), spec!("tool.scale", "Scale", "{}", false, |s, _| tool(s, ToolId::Scale)), spec!("tool.hand", "Hand", "{}", false, |s, _| tool(s, ToolId::Hand)), spec!("tool.frame", "Frame", "{}", false, |s, _| tool(s, ToolId::Frame)), spec!("tool.section", "Section", "{}", false, |s, _| tool(s, ToolId::Section)), spec!("tool.slice", "Slice", "{}", false, |s, _| tool(s, ToolId::Slice)), spec!("tool.rectangle", "Rectangle", "{}", false, |s, _| tool(s, ToolId::Rectangle)), spec!("tool.line", "Line", "{}", false, |s, _| tool(s, ToolId::Line)), spec!("tool.arrow", "Arrow", "{}", false, |s, _| tool(s, ToolId::Arrow)), spec!("tool.ellipse", "Ellipse", "{}", false, |s, _| tool(s, ToolId::Ellipse)), spec!("tool.polygon", "Polygon", "{}", false, |s, _| tool(s, ToolId::Polygon)), spec!("tool.star", "Star", "{}", false, |s, _| tool(s, ToolId::Star)), spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)), spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)), spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)), ] } #[allow(dead_code)] fn unused(_: NodeId) {}