mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 02:39:42 +00:00
Guides dragged out of the rulers could not be touched again: no tool hit-tested
them, so a click on a guide went to the art under it.
- The Selection, Direct Selection and Group Selection tools pick a ruler guide
within the selection tolerance (over the art; Direct Selection takes an
anchor on it first) through a shared tools::rulerguide::GuideEdit. A drag
moves the selected guides in one undo step (Alt copies them, Shift snaps the
dragged one to the ruler's ticks, otherwise it snaps to pixels, the grid or,
with Smart Guides, the art's edges, centres and anchors); dropped off the
canvas onto its ruler, the guide is deleted. Hidden or locked guides can't be
picked; the pointer shows which way a guide moves.
- Selection::guides holds the selected guides (on their own, like slices); the
canvas draws them in the selection colour. Delete/Backspace (edit.clear)
delete them, the arrow keys (object.nudge) nudge them, Lock Guides and Hide
Guides deselect them.
- Commands for agents: guide.list, guide.select {indexes, toggle?},
guide.move {dx?, dy?, copy?} (besides {index, pos}) and guide.remove {index?};
guide positions out of range are refused. Documented in docs/mcp.md.
- Shift while dragging a new guide out of a ruler snaps it to the ticks too
(Unit::ruler_step, shared with the rulers' drawing).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
16 KiB
Rust
391 lines
16 KiB
Rust
//! 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,
|
||
/// View → Show Guides: the ruler guides show (and, unlocked, can be picked).
|
||
pub 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,
|
||
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,
|
||
guides: view.guides && !self.menu.guides_locked,
|
||
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)
|
||
}
|
||
}
|