Files
vectorcraft/crates/engine/src/tooling.rs
T
imachusandClaude Opus 5.5 66fa0a998a Type: input method composition (Japanese IME) in the Type tool
Typing with an input method (the macOS Japanese IME and the like) used to
insert the raw keys (romaji came in as ASCII): egui's Ime events weren't
handled.

- Tools gain `ime_preedit` / `composing` / `ime_caret`; the Type tool lays
  the marked text out in place inside its typing session, underlined (the
  clause being converted thicker), and commits it through `text_input`.
  Character ranges from the IME convert to byte ranges in one place. A
  cancelled composition leaves no empty undo step; an empty clear with
  nothing marked never deletes the selection.
- The UI sends Text and Ime events to the tool in order, sends IMEOutput
  every frame while text is edited (what lets egui-winit enable the IME
  and place its candidate window at the caret, or the converting clause),
  and holds back keys, Enter/Escape, clipboard, single-key shortcuts and
  menu commands (Undo, Cut, Select All… also from the native menu) while
  composing. A click, a tool switch or a style change ends the
  composition with the marked text kept as typed; on macOS the IME is
  told to discard it (NSTextInputContext.discardMarkedText) so it doesn't
  come back in the next input.
- Undo during a typing session also ends the Type tool's session, so its
  caret and marked text follow the document.
- Tests: tools (char → byte ranges, underlines and candidate position),
  engine (compose → commit is one step, cancel leaves no step, a click
  commits, Undo mid-composition is safe), ui-egui (synthetic Ime events:
  compose, commit, ⌘Z held back, IME output, click and app switch).

Checked by hand on macOS 27 with the Japanese IME (romaji input, convert,
commit, cancel, Undo/Redo after commit, click away, Latin mode, app
switch). Not checked: Windows and Linux IMEs, the web build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:50 +09:00

365 lines
14 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()
}
/// Switch tools (finishing any pending tool work first).
pub fn select_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,
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), self.tool.id(), place));
}
v
}
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods))
}
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)
}
}