fix(sketch): move and copy editable text as a whole source (#715)

Dragging a text outline now moves the whole editable text, and Move/Copy shifts or rotates it as one piece (a copy stays independently editable). Content, font, height and outline identities are kept; a drag is one undo step and Esc restores the start.
This commit is contained in:
selune authored and GitHub committed 2026-10-13 10:29:02 -04:00
1 parent 2c4d197bf4
commit 11084a5963
14 files changed
+469 -9

No files matched your search

+12 -2
View File
@@ -247,6 +247,7 @@ pub enum Kind {
Part(crate::dialogs_parts::Pt),
/// Edit Text: a sketch text's content and placement (`sketch_text`).
Text(crate::sketch_text::Tx),
TextMove(crate::sketch_text_move::TextMove),
}
/// The rest of the Hole dialog: placement, extents, tap type and drill point.
@@ -503,7 +504,8 @@ impl Dialog {
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id))
.or_else(|| crate::dialogs_parts::start(app, id))
.or_else(|| crate::sketch_text::start(app, id))?;
.or_else(|| crate::sketch_text::start(app, id))
.or_else(|| crate::sketch_text_move::start(app, id))?;
Dialog::new(kind, inputs)
}
};
@@ -933,6 +935,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Motion(k) => k.title(),
Kind::Part(_) => "INSERT PART",
Kind::Text(_) => "EDIT TEXT",
Kind::TextMove(_) => "MOVE/COPY TEXT",
}
}
@@ -1354,6 +1357,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
let _language = crate::i18n::scope(&app.preferences.language);
if let Kind::TextMove(k) = &d.kind
&& !crate::sketch_text_move::valid(app, k)
{
return;
}
auto_operation(app, &mut d);
update_measure(app, &mut d);
update_fill_cells(app, &mut d);
@@ -2061,6 +2069,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k),
Kind::TextMove(k) => enter |= crate::sketch_text_move::rows(ui, k),
Kind::ConfirmDelete { with, fail, .. } => {
if !with.is_empty() {
row_label(ui, "Also deletes");
@@ -2171,7 +2180,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
crate::dialogs_motion::closed(app, &mut d, applied);
}
// The edited text's outlines are new curves: the old selection would name stale ids.
if applied && matches!(d.kind, Kind::Text(_)) {
if applied && matches!(d.kind, Kind::Text(_) | Kind::TextMove(_)) {
let _ = app.session.execute("select.clear", &json!({}));
}
if keep && !cancel {
@@ -3067,6 +3076,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
Kind::Part(k) => return crate::dialogs_parts::commands(app, k, &d.inputs),
Kind::Text(k) => return crate::sketch_text::commands(app, k),
Kind::TextMove(k) => return crate::sketch_text_move::commands(app, k),
};
let mut params = params;
if let Value::Object(m) = &mut params {
+2
View File
@@ -80,6 +80,7 @@ fn put_view(app: &mut SolveApp, v: DocView) {
/// Put `slot` into the app and return what was there.
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
// Whatever was going on in the design leaves with it.
crate::sketch_text_move::cancel_drag(app);
crate::dialogs::cancel(app);
crate::dim_view::cancel();
app.tool = None;
@@ -196,6 +197,7 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
app.docs.closing = None;
if n <= 1 {
// The last design: a blank one takes its place.
crate::sketch_text_move::cancel_drag(app);
let rev = app.session.revision;
crate::dialogs::cancel(app);
app.tool = None;
+4
View File
@@ -601,6 +601,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
cancel_capture(app);
// Pointer release may have happened during review; never finalize it against
// the report's last cursor position when CAD interaction resumes.
if app.viewport.text_drag.is_some() {
let _ = app.session.execute("sketch.drag_end", &serde_json::json!({}));
crate::sketch_text_move::end_drag(app);
}
app.viewport.point_drag = None;
app.viewport.boxsel = None;
app.viewport.axis_drag = None;
+6 -2
View File
@@ -103,6 +103,7 @@ mod sketch_edit_tests;
mod sketch_families;
pub mod sketch_palette;
pub(crate) mod sketch_text;
mod sketch_text_move;
pub mod sketch_tools;
pub mod stand_up;
pub mod theme;
@@ -526,7 +527,8 @@ impl SolveApp {
return;
}
// Another command ends Edit Text (its dialog is not a feature edit to keep).
if sketch_text::editing(self) {
sketch_text_move::cancel_drag(self);
if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) {
dialogs::cancel(self);
}
// A component moved without Capture Position: ask first (Capture, Revert or Cancel).
@@ -712,7 +714,8 @@ impl SolveApp {
/// Finish the active sketch: stop editing, restore the history marker Edit Sketch moved and
/// return to the view from before the sketch.
pub fn finish_sketch(&mut self) {
if sketch_text::editing(self) {
sketch_text_move::cancel_drag(self);
if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) {
dialogs::cancel(self);
}
dim_view::cancel();
@@ -737,6 +740,7 @@ impl SolveApp {
v.extend(d.items());
}
v.extend(sketch_text::highlighted(self));
v.extend(sketch_text_move::highlighted(self));
v
}
@@ -6,6 +6,52 @@ use solidcraft_engine::geom::Vec2;
use crate::scenario::Harness;
#[test]
fn text_move_drag_is_one_editable_unit_and_escape_restores_the_gesture() {
for cancel in [false, true] {
let mut h = Harness::new();
h.app.home.open = false;
run(&mut h, "sketch.create", json!({"plane":"XY"}));
run(&mut h, "sketch.text", json!({"text":"II","at":[0,0],"height":10}));
run(&mut h, "sketch.line", json!({"points":[[15,0],[15,10]],"connect":false}));
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 35.0;
h.frames(3);
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let sk = h.app.session.doc.sketch(1).unwrap();
let line = sk.curves.last().unwrap().id.clone();
let neighbor = point(&h, &format!("{line}.start"));
let polyline = sk.polyline(0);
let at = (polyline[0] + polyline[1]) * 0.5;
let a = screen(&mut h, at.x, at.y);
let b = screen(&mut h, at.x + 3.0, at.y + 2.0);
let mut path = vec![a];
path.extend(steps(a, b, 6));
press_and_move(&mut h, &path);
assert!(h.app.viewport.text_drag.is_some());
assert_ne!(h.app.session.doc.to_json(), before);
if cancel {
h.step(&json!({"key":"Escape"})).unwrap();
}
release(&mut h, b);
assert!(h.app.viewport.text_drag.is_none());
assert_eq!(h.app.session.active_sketch, Some(1));
assert!(point(&h, &format!("{line}.start")).dist(neighbor) < 1e-9);
if cancel {
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
} else {
assert_eq!(h.app.session.undo.len(), undo + 1);
let sk = h.app.session.doc.sketch(1).unwrap();
let solidcraft_engine::sketch::LinkSource::Text { at, .. } = &sk.links[0].source else { panic!() };
assert!(at.dist(Vec2::new(3.0, 2.0)) < 0.02, "{at:?}");
run(&mut h, "edit.undo", json!({}));
assert_eq!(h.app.session.doc.to_json(), before);
}
}
}
fn run(h: &mut Harness, id: &str, p: Value) {
if let Err(e) = h.app.run(id, p.clone()) {
panic!("{id} {p}: {e}");
+187
View File
@@ -0,0 +1,187 @@
//! Move/Copy changes the text source, never independent generated outlines.
use crate::{SolveApp, dialogs::Kind, selection::SelInput};
use serde_json::{Value, json};
use solidcraft_engine::{Sel, doc::expr::Kind as ValueKind, sketch::LinkKind};
#[derive(Clone, Debug)]
pub struct TextMove {
sketch: u64,
link: String,
values: [String; 3],
copy: bool,
}
pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
if id != "sketch.move" {
return None;
}
let entity = crate::sketch_text::selected_entity(app)?;
let sketch = app.session.active_sketch?;
let sk = app.session.doc.sketch(sketch).ok()?;
let link = sk.curves.iter().find(|c| c.id == entity)?.link.clone()?;
let (_, unit) = crate::prefs::unit_scale(&app.session.doc.units);
Some((Kind::TextMove(TextMove { sketch, link, values: [format!("0 {unit}"), format!("0 {unit}"), "0 deg".into()], copy: false }), vec![]))
}
pub(crate) fn valid(app: &SolveApp, k: &TextMove) -> bool {
app.session.active_sketch == Some(k.sketch)
&& app.session.doc.sketch(k.sketch).ok().and_then(|sk| sk.link(&k.link)).is_some_and(|l| l.kind == LinkKind::Text)
}
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut TextMove) -> bool {
let mut enter = false;
for (label, value) in ["Distance X", "Distance Y", "Angle"].iter().zip(&mut k.values) {
crate::dialogs::row_label(ui, label);
enter |= crate::dialogs::field(ui, value);
ui.end_row();
}
crate::dialogs::row_label(ui, "Create Copy");
let response = ui.checkbox(&mut k.copy, "");
crate::scenario::publish_handle("dialog:Create Copy", response.rect.center());
ui.end_row();
enter
}
pub(crate) fn commands(app: &SolveApp, k: &TextMove) -> Result<Vec<(String, Value)>, String> {
if !valid(app, k) {
return Err("The text is no longer in the active sketch".into());
}
let doc = &app.session.doc;
let mut values = [0.0; 3];
for (i, (input, value)) in k.values.iter().zip(&mut values).enumerate() {
let kind = if i == 2 { ValueKind::Angle } else { ValueKind::Length };
*value = doc.eval(&doc.with_design_unit(input.trim(), kind), kind).map_err(|e| e.to_string())?;
if !value.is_finite() {
return Err("Move values must be finite".into());
}
}
let sk = doc.sketch(k.sketch).map_err(|e| e.to_string())?;
let curve = sk.curves.iter().find(|c| c.link.as_deref() == Some(&k.link)).ok_or("The text has no outlines")?;
let Some(solidcraft_engine::sketch::LinkSource::Text { at, .. }) = sk.link(&k.link).map(|l| &l.source) else {
return Err("The source is no longer text".into());
};
Ok(vec![(
"sketch.move".into(),
json!({"entities":[curve.id],"translate":[values[0],values[1]],
"angle":values[2].to_degrees(),"center":[at.x,at.y],"copy":k.copy}),
)])
}
pub(crate) fn highlighted(app: &SolveApp) -> Vec<Sel> {
let link = match app.dialog.as_ref().map(|d| &d.kind) {
Some(Kind::TextMove(k)) => Some(k.link.as_str()),
_ => app.viewport.text_drag.as_ref().map(|(_, link)| link.as_str()),
};
let Some(link) = link else {
return vec![];
};
let Some(sk) = app.session.active_sketch.and_then(|sid| app.session.doc.sketch(sid).ok()) else {
return vec![];
};
sk.curves.iter().filter(|c| c.link.as_deref() == Some(link)).map(|c| Sel::SketchCurve { id: c.id.clone() }).collect()
}
/// Esc restores the document and both history stacks to the start of this gesture.
pub(crate) fn cancel_drag(app: &mut SolveApp) -> bool {
let Some((checkpoint, _)) = app.viewport.text_drag.take() else {
return false;
};
let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint}));
app.viewport.point_drag = None;
crate::drag_snap::clear();
true
}
pub(crate) fn end_drag(app: &mut SolveApp) {
if let Some((checkpoint, _)) = app.viewport.text_drag.take() {
let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint,"forget":true}));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::Harness;
use solidcraft_engine::sketch::LinkSource;
fn setup() -> (Harness, String) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let result = h.app.run("sketch.text", json!({"text":"II","at":[3,4],"height":10})).unwrap();
h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }];
h.app.start("sketch.move");
h.frames(3);
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::TextMove(_))));
(h, result["link"].as_str().unwrap().into())
}
fn replace(h: &mut Harness, field: &str, value: &str) {
h.step(&json!({"click":{"handle":field}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":value})).unwrap();
}
#[test]
fn text_move_dialog_uses_units_and_one_undo_and_preserves_cancel() {
let (mut h, link) = setup();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
replace(&mut h, "dialog:Distance X", "1 in");
replace(&mut h, "dialog:Distance Y", "-2 mm");
replace(&mut h, "dialog:Angle", "90 deg");
assert_eq!(h.app.session.doc.to_json(), before);
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.frames(2);
assert!(h.app.dialog.is_none());
let sk = h.app.session.doc.sketch(1).unwrap();
let LinkSource::Text { at, angle, text, .. } = &sk.link(&link).unwrap().source else { panic!() };
assert!(at.dist(solidcraft_engine::geom::Vec2::new(28.4, 2.0)) < 1e-8);
assert!((*angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
assert_eq!(text, "II");
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
let curve = h.app.session.doc.sketch(1).unwrap().curves[0].id.clone();
h.app.session.selection = vec![Sel::SketchCurve { id: curve }];
h.app.start("sketch.move");
h.frames(3);
replace(&mut h, "dialog:Distance X", "17");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn text_move_dialog_copies_an_editable_source() {
let (mut h, link) = setup();
let original = h.app.session.doc.sketch(1).unwrap().link(&link).unwrap().source.clone();
replace(&mut h, "dialog:Distance X", "20");
h.step(&json!({"click":{"handle":"dialog:Create Copy"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.frames(2);
let sk = h.app.session.doc.sketch(1).unwrap();
assert_eq!(sk.links.len(), 2);
assert_eq!(sk.link(&link).unwrap().source, original);
let new_link = sk.links.iter().find(|l| l.id != link).unwrap().id.clone();
h.app.run("sketch.edit_text", json!({"link":new_link,"text":"O"})).unwrap();
assert_eq!(h.app.session.doc.sketch(1).unwrap().link(&link).unwrap().source, original);
}
#[test]
fn text_move_cancels_before_switching_documents() {
let (mut h, link) = setup();
crate::dialogs::cancel(&mut h.app);
let before = h.app.session.doc.to_json();
let checkpoint = h.app.session.execute("edit.checkpoint", &json!({})).unwrap()["checkpoint"].as_u64().unwrap();
let curve = h.app.session.doc.sketch(1).unwrap().curves[0].id.clone();
h.app.session.execute("sketch.drag", &json!({"entity":curve,"from":[0,0],"to":[2,3],"live":true})).unwrap();
h.app.viewport.text_drag = Some((checkpoint, link));
h.app.viewport.point_drag = Some((curve, 0, None));
crate::documents::open_tab(&mut h.app, solidcraft_engine::Session::default()).unwrap();
assert!(h.app.viewport.text_drag.is_none());
assert!(h.app.viewport.point_drag.is_none());
crate::documents::switch(&mut h.app, 0);
assert_eq!(h.app.session.doc.to_json(), before);
}
}
+3
View File
@@ -1187,6 +1187,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
match k {
Key::Escape => {
app.esc_handled = true;
if crate::sketch_text_move::cancel_drag(app) {
continue;
}
if crate::select_other::is_open(app) {
crate::select_other::close(app);
} else if app.tool.is_some() {
+26
View File
@@ -97,6 +97,8 @@ pub struct ViewportState {
/// A sketch point or curve being dragged (its id, the undo depth when the drag began: the
/// whole drag is one undo step, and for a curve where it was last under the cursor).
pub point_drag: Option<(String, usize, Option<Vec2>)>,
/// Checkpoint and source for a cancellable whole-text drag.
pub(crate) text_drag: Option<(u64, String)>,
}
/// Something under the cursor.
@@ -2202,6 +2204,28 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
_ => {}
}
if let Some((id, _, _)) = &app.viewport.point_drag {
let link = app.session.active_sketch.and_then(|sid| app.session.doc.sketch(sid).ok()).and_then(|sk| {
let link = sk
.curve_index(id)
.and_then(|i| sk.curves.get(i))
.and_then(|c| c.link.as_deref())
.or_else(|| sk.resolve_point(id).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?;
sk.link(link).filter(|l| l.kind == solidcraft_engine::sketch::LinkKind::Text).map(|l| l.id.clone())
});
if let Some(link) = link
&& let Some(q) = on_plane(app, a)
{
if let Some((_, _, grab)) = &mut app.viewport.point_drag {
*grab = Some(q);
}
if let Ok(result) = app.session.execute("edit.checkpoint", &json!({}))
&& let Some(checkpoint) = result["checkpoint"].as_u64()
{
app.viewport.text_drag = Some((checkpoint, link));
}
}
}
}
if let Some((id, depth, grab)) = app.viewport.point_drag.clone()
&& resp.dragged_by(egui::PointerButton::Primary)
@@ -2252,6 +2276,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
crate::drag_snap::clear();
app.viewport.point_drag = None;
crate::sketch_text_move::end_drag(app);
crate::dim_view::drag_end();
crate::sketch3d::drag_end();
}
@@ -2264,6 +2289,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.viewport.point_drag = None;
crate::drag_snap::clear();
let _ = app.session.execute("sketch.drag_end", &json!({}));
crate::sketch_text_move::end_drag(app);
}
// Box selection: a primary drag on the model when no navigation mode is on.
if app.tool.is_none()