Files
vectorcraft/crates/engine/src/tooling.rs
T
bflatasticandClaude Opus 5.5 47bed05ff4 Part of #394: Preferences that act on selection, the wheel, tool tips and the grid
Fifteen Preferences options were stored but read by nothing. They now act,
through the existing plumbing (ToolContext for the tools, the prefs read by the
canvas for the view), and reach agents through prefs.set:

- General: Show Tool Tips (off: no widget's tool tip comes due), Double Click
  To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer,
  Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise
  Cursors (Pen, Eyedropper, Slice and Blend pointers become a crosshair).
- Selection & Anchor Display: Tolerance (objects, anchors and handles, in
  place of the fixed 3 and 4 px), Object Selection by Path Only (compound
  paths too), Command Click to Select Objects Behind (cycles down the objects
  under the pointer; not when Cmd only borrows the selection tool), Zoom to
  Selection (Zoom In/Out centre the selection), anchor Size, Highlight anchors
  on mouse over (Direct Selection).
- Type: Number of Recent Fonts (up to 15 Recent Fonts slots).
- Guides & Grid: guide and grid Color and Style (lines or dots), Grids In Back.

The canvas's context menu, panel drops and hover highlight pick objects like
the selection tools. The control channel gets ui.wheel (with modifiers held
like ui.click) so agents can turn the wheel. The rest of #394 stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:22:24 +07:00

387 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Hosting the active tool: pointer/key events → actions → commands.
use serde_json::{Map, Value};
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::widget::WidgetPlace;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use crate::{EngineError, Prefs, Result, Session};
/// View state the tools need from the frontend.
#[derive(Clone, Copy, Debug)]
pub struct ViewInfo {
pub zoom: f64,
pub outline: bool,
pub smart_guides: bool,
pub snap_to_grid: bool,
pub show_bbox: bool,
/// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi).
pub snap_to_pixel: bool,
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
pub snap_to_point: bool,
/// View → Show Corner Widget.
pub corner_widgets: bool,
/// The document window (none headless): screen-fixed widgets sit in it.
pub screen: Option<vectorcraft_tools::ScreenFrame>,
}
impl Default for ViewInfo {
fn default() -> Self {
Self {
zoom: 1.0,
outline: false,
smart_guides: true,
snap_to_grid: false,
show_bbox: true,
snap_to_pixel: false,
snap_to_point: true,
corner_widgets: true,
screen: None,
}
}
}
/// Requests from tools that only the frontend can fulfil.
#[derive(Clone, Debug, PartialEq)]
pub enum UiRequest {
Dialog(String, Value),
SwitchTool(String),
/// A message for the status bar: the `warning` of a command the tool ran (Liquify skipping
/// type under the brush).
Status(String),
}
impl Session {
pub fn tool_id(&self) -> &'static str {
self.tool.id()
}
/// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first.
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
// Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it
// again, the tool staying chosen.
if matches!(id, "perspectiveGrid" | "perspectiveSelection")
&& self.active().is_some_and(|d| !vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc).visible)
{
crate::cmd::distortcmds::silent(self, |g| g.visible = true)?;
}
self.switch_tool(id, view)
}
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
/// perspective grid stays hidden), finishing any pending tool work first.
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
if self.tool.id() == id {
return Ok(());
}
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.keep_tool_settings();
self.tool = self.make_tool(id);
Ok(())
}
/// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this
/// session or a saved one.
pub(crate) fn make_tool(&self, id: &str) -> Box<dyn Tool> {
let mut t = vectorcraft_tools::create(id);
Self::restore_tool_settings(&self.prefs, t.as_mut());
t
}
/// Give `t` the persistent options stored for it in `prefs`.
fn restore_tool_settings(prefs: &Prefs, t: &mut dyn Tool) {
for (store, keys) in settings::stores(t.id()) {
let Some(saved) = prefs.tool_settings.get(store) else { continue };
for k in keys {
if let Some(v) = saved.get(*k) {
t.set_option(k, v);
}
}
}
}
/// Store the persistent options of `t` in `prefs` (so they are saved with them too).
fn store_tool_settings(prefs: &mut Prefs, t: &dyn Tool) {
let opts = t.options();
for (store, keys) in settings::stores(t.id()) {
let saved = prefs.tool_settings.entry(store.to_string()).or_default();
for k in keys {
if let Some(v) = opts.get(*k) {
saved.insert(k.to_string(), v.clone());
}
}
}
}
/// Keep the active tool's persistent options for the next time it, or a tool sharing them, is
/// made.
pub(crate) fn keep_tool_settings(&mut self) {
Self::store_tool_settings(&mut self.prefs, self.tool.as_ref());
}
pub(crate) fn with_tool_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn vectorcraft_tools::Tool, &ToolContext) -> R) -> R
where
R: Default,
{
let Some(st) = self.active.and_then(|i| self.docs.get(i)) else { return R::default() };
let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit());
let cx = ToolContext {
doc: &st.doc,
selection: &st.selection,
zoom: view.zoom,
isolation: st.isolation,
paint: &self.paint,
outline: view.outline,
smart_guides: view.smart_guides,
snap_to_grid: view.snap_to_grid,
show_bbox: view.show_bbox,
snap_to_pixel: view.snap_to_pixel,
snap_to_point: view.snap_to_point,
corner_widgets: view.corner_widgets,
fill_active: self.fill_active,
gradient_stop: self.selected_stop(),
appearance_item: self.appearance_item(),
constrain_angle: self.prefs.constrain_angle,
freeform_point: self.selected_freeform_point(),
raster_sample: self.prefs.eyedropper.sample_size,
preview_bounds: self.prefs.use_preview_bounds,
unit,
stroke_unit,
paste_plain_text: self.prefs.paste_text_formatting == "plain",
slices_hidden: self.menu.slices_hidden,
slices_locked: self.menu.slices_locked,
auto_add_delete: !self.prefs.disable_auto_add_delete,
selection_tolerance: self.prefs.selection_tolerance,
path_only: self.prefs.object_selection_by_path_only,
double_click_isolate: self.prefs.double_click_to_isolate,
select_behind: self.prefs.ctrl_click_selects_behind,
highlight_anchors: self.prefs.highlight_anchors_on_hover,
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
let tool = &mut self.tool;
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
Ok(r) => r,
Err(msg) => {
// A bug in the tool: start it afresh and drop its half-done drag.
let id = self.tool.id();
self.tool = self.make_tool(id);
let _ = self.cancel_interaction();
self.tool_panic = Some(EngineError::Internal { cmd: format!("tool `{id}`"), msg });
R::default()
}
}
}
/// The error of a tool that panicked in the last [`Self::with_tool_cx`] call, if any.
fn take_tool_panic(&mut self) -> Result<()> {
self.tool_panic.take().map_or(Ok(()), Err)
}
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.last_view = view;
// The Plane Switching Widget takes its clicks whatever the tool.
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.take_tool_panic()?;
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
if ev.kind == PointerKind::Up {
self.keep_tool_settings();
}
self.apply_actions(acts)
}
/// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the
/// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event.
pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> {
if !self.tool.wants_ticks() {
return Ok(vec![]);
}
let acts = self.with_tool_cx(view, |t, cx| t.tick(cx, dt));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// Does the active tool want [`Session::tool_tick`]s now?
pub fn tool_wants_ticks(&self) -> bool {
self.tool.wants_ticks()
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
// 1–4 pick the active perspective plane while the grid is shown.
if let Some(plane) = self.plane_key(key) {
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
return Ok(vec![]);
}
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// Typed text for the active tool (Type tool).
pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text));
self.take_tool_panic()?;
self.apply_actions(acts)
}
pub fn tool_wants_text(&self) -> bool {
self.tool.wants_text()
}
/// IME marked text for the active tool (see `Tool::ime_preedit`; `active_chars` counts
/// characters).
pub fn tool_preedit(&mut self, text: &str, active_chars: Option<std::ops::Range<usize>>, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.ime_preedit(cx, text, active_chars));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// Is the active tool showing uncommitted IME text?
pub fn tool_composing(&self) -> bool {
self.tool.composing()
}
/// The caret line (document space) the IME candidate window follows.
pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(Point, Point)> {
self.with_tool_cx(view, |t, cx| t.ime_caret(cx))
}
pub fn tool_busy(&self) -> bool {
self.tool.busy()
}
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
if let Some(d) = self.active() {
let w = self.prefs.perspective_widget;
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place));
}
v
}
/// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing
/// tools' a crosshair, [`Cursor::precise`]).
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods));
if self.prefs.use_precise_cursors { c.precise() } else { c }
}
pub fn tool_options(&self) -> Value {
self.tool.options()
}
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
self.tool.set_option(key, v);
self.keep_tool_settings();
}
/// The options of tool `id`: the active tool's, or those it would have if chosen now.
pub fn tool_options_of(&self, id: &str) -> Value {
if id == self.tool.id() { self.tool.options() } else { self.make_tool(id).options() }
}
/// Set options of tool `id` (default: the active tool). Another tool's persistent options are
/// stored for when it is chosen, and the active tool takes those it shares with it (the
/// Liquify tools' Global Brush Dimensions). Returns the tool's options.
pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map<String, Value>) -> Value {
let active = id.is_none_or(|id| id == self.tool.id());
let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default()));
let t = other.as_deref_mut().unwrap_or(self.tool.as_mut());
for (k, v) in values {
t.set_option(k, v);
}
Self::store_tool_settings(&mut self.prefs, t);
let out = t.options();
if other.is_some() {
Self::restore_tool_settings(&self.prefs, self.tool.as_mut());
}
out
}
/// `tool.setOption {tool?, key?, value?, values?}` (a command of the desktop app and of the
/// headless host): set one option (`key`, `value`) or several (`values`) of `tool` (default:
/// the active tool) → its options.
pub fn set_tool_option_cmd(&mut self, p: &Value) -> std::result::Result<Value, String> {
let tool = p.get("tool").and_then(Value::as_str);
if let Some(t) = tool
&& vectorcraft_tools::tool_info(t).is_none()
{
return Err(format!("unknown tool `{t}`"));
}
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
if let Some(k) = p.get("key").and_then(Value::as_str) {
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
}
Ok(self.set_tool_options(tool, &values))
}
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
/// the Type tool stops editing text that is no longer selected. Only while it edits text.
pub(crate) fn after_command(&mut self) {
if self.in_tool_actions || !self.tool.wants_text() {
return;
}
let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx));
// The command itself succeeded: a failure here only leaves the edit as it was.
if let Err(e) = self.apply_actions(acts) {
log::warn!("ending the text edit: {e}");
}
}
/// Apply tool actions. Errors from previews are reported but keep the interaction alive.
pub fn apply_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
let outer = std::mem::replace(&mut self.in_tool_actions, true);
let r = self.apply_tool_actions(acts);
self.in_tool_actions = outer;
r
}
fn apply_tool_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
for a in acts {
match a {
Action::Begin(label) => self.begin_interaction(&label)?,
Action::Preview(cmd, p) => match self.preview(&cmd, &p) {
Ok(v) => {
if let Some(w) = v.get("warning").and_then(Value::as_str)
&& !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w))
{
ui.push(UiRequest::Status(w.to_string()));
}
}
Err(e) => log::warn!("preview {cmd}: {e}"),
},
// The drag is over: the dabs of a Liquify stroke have no further use.
Action::Commit => {
self.liquify_stroke = None;
self.commit_interaction()?
}
Action::Cancel => {
self.liquify_stroke = None;
self.cancel_interaction()?
}
Action::Exec(cmd, p) => {
self.execute(&cmd, &p)?;
}
Action::Dialog(k, p) => ui.push(UiRequest::Dialog(k, p)),
Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)),
Action::Notify(what) => {
let view = self.last_view;
self.with_tool_cx(view, |t, cx| t.notify(cx, &what));
}
}
}
Ok(ui)
}
}