Edit sketch text content and placement from its outlines (#253)

* Expose interactive editing and placement of sketch text

* Respect document units when editing sketch text

* Route Edit Text through the shared feature dialog

The sketch text editor is now a Kind::Text dialog opened through
Dialog::for_command (frame.rs docking, standard OK/Cancel, Enter/Esc).
Its fields are ordinary dialog fields: they take expressions, parameters
and units, and bare lengths are in the design's units. Untouched fields
keep their exact saved values. The dialog stays open across unrelated
design changes and closes only when its text is gone, the sketch ends,
or another command starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 10:08:15 -04:00
1 parent 62847173f4
commit f77da83c93
7 files changed
+461 -2

No files matched your search

+5
View File
@@ -63,6 +63,11 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Dimensioning and error reporting
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
+3
View File
@@ -642,6 +642,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
return v;
}
let ents: Vec<String> = curves.iter().chain(points.iter()).cloned().collect();
if crate::sketch_text::selected_entity(app).is_some() {
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
}
v.push(cmd(app, "sketch.move", "Move/Copy").key("M"));
if !curves.is_empty() {
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
+17 -1
View File
@@ -236,6 +236,8 @@ pub enum Kind {
Motion(crate::dialogs_motion::Mo),
/// Insert Part from the standard parts library (`dialogs_parts`).
Part(crate::dialogs_parts::Pt),
/// Edit Text: a sketch text's content and placement (`sketch_text`).
Text(crate::sketch_text::Tx),
}
/// The rest of the Hole dialog: placement, extents, tap type and drill point.
@@ -459,7 +461,8 @@ impl Dialog {
.or_else(|| crate::dialogs_plastic::start(app, id))
.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::dialogs_parts::start(app, id))
.or_else(|| crate::sketch_text::start(app, id))?;
Dialog::new(kind, inputs)
}
};
@@ -859,6 +862,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
Kind::Part(_) => "INSERT PART",
Kind::Text(_) => "EDIT TEXT",
}
}
@@ -1273,6 +1277,12 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.dialog_rect = None;
let Some(mut d) = app.dialog.take() else { return };
// Edit Text lasts as long as its text: other design changes leave it open.
if let Kind::Text(k) = &mut d.kind
&& !crate::sketch_text::refresh(app, k)
{
return;
}
let _language = crate::i18n::scope(&app.preferences.language);
auto_operation(app, &mut d);
update_measure(app, &mut d);
@@ -1920,6 +1930,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
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::ConfirmDelete { with, fail, .. } => {
if !with.is_empty() {
row_label(ui, "Also deletes");
@@ -2029,6 +2040,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if !keep || cancel {
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(_)) {
let _ = app.session.execute("select.clear", &json!({}));
}
if keep && !cancel {
app.dialog = Some(d);
} else if let Some((_, marker)) = d.editing {
@@ -2856,6 +2871,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
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),
};
let mut params = params;
if let Value::Object(m) = &mut params {
+8
View File
@@ -95,6 +95,7 @@ mod sketch_display_ui_tests;
#[cfg(test)]
mod sketch_edit_tests;
pub mod sketch_palette;
pub(crate) mod sketch_text;
pub mod sketch_tools;
pub mod stand_up;
pub mod theme;
@@ -483,6 +484,10 @@ impl SolveApp {
/// Start a command the way a toolbar click does: interactive tools and dialogs for commands
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
// Another command ends Edit Text (its dialog is not a feature edit to keep).
if sketch_text::editing(self) {
dialogs::cancel(self);
}
// A component moved without Capture Position: ask first (Capture, Revert or Cancel).
if browser::needs_capture(self, id) && !(id == "sketch.dimension" && self.session.active_sketch.is_some()) {
self.tree.capture_prompt = Some(id.to_string());
@@ -663,6 +668,9 @@ 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) {
dialogs::cancel(self);
}
dim_view::cancel();
self.tool = None;
let _ = self.run("sketch.finish", json!({}));
+413
View File
@@ -0,0 +1,413 @@
//! Edit Text: the shared feature dialog (OK/Cancel, expression fields) for the source of a
//! sketch text's outlines; OK runs `sketch.edit_text`.
use serde_json::{Value, json};
use solvecraft_engine::{
Sel,
doc::expr::Kind as ValueKind,
sketch::{LinkKind, LinkSource},
};
use crate::SolveApp;
use crate::dialogs::{Dialog, Kind};
use crate::selection::SelInput;
/// Field rows after the text: position, height (lengths) and angle.
const LABELS: [&str; 4] = ["X", "Y", "Height", "Angle"];
const KINDS: [ValueKind; 4] = [ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle];
/// The Edit Text dialog's state: the text being edited and its four values as expressions.
#[derive(Clone, Debug)]
pub struct Tx {
sketch: u64,
pub(crate) link: String,
pub(crate) text: String,
initial_text: String,
values: [String; 4],
/// The fields as first shown; an untouched field keeps its exact saved value.
initial: [String; 4],
/// The saved source: x, y, height (mm) and angle (rad).
original: [f64; 4],
pub(crate) unit: &'static str,
focus: bool,
}
/// Only a complete selection belonging to one text source can seed the editor.
pub(crate) fn selected_entity(app: &SolveApp) -> Option<String> {
let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?;
let mut selected = None;
let mut link = None;
for selection in &app.session.selection {
let Sel::SketchCurve { id } = selection else { return None };
let curve = sk.curves.iter().find(|curve| &curve.id == id)?;
let source = sk.link(curve.link.as_deref()?)?;
if source.kind != LinkKind::Text || link.is_some_and(|old| old != source.id.as_str()) {
return None;
}
link = Some(source.id.as_str());
selected = Some(id.clone());
}
selected
}
/// A value shown in a field: rounded so binary noise does not show, with its unit.
fn shown(value: f64, unit: &str) -> String {
let text = format!("{value:.6}");
let text = text.trim_end_matches('0').trim_end_matches('.');
let text = if text == "-0" { "0" } else { text };
format!("{text} {unit}")
}
/// The saved text source behind `link` in the active sketch.
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<(String, [f64; 4])> {
if app.session.active_sketch != Some(sketch) {
return None;
}
let sk = app.session.doc.sketch(sketch).ok()?;
let source = sk.link(link).filter(|source| source.kind == LinkKind::Text)?;
let LinkSource::Text { text, at, height, angle } = &source.source else { return None };
Some((text.clone(), [at.x, at.y, *height, *angle]))
}
fn fields(original: &[f64; 4], unit: &'static str, scale: f64) -> [String; 4] {
std::array::from_fn(|i| {
let value = original.get(i).copied().unwrap_or_default();
if i == 3 { shown(value.to_degrees(), "deg") } else { shown(value * scale, unit) }
})
}
/// The dialog's kind for the text that `entity` (an outline curve) belongs to.
fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
let sketch = app.session.active_sketch?;
let sk = app.session.doc.sketch(sketch).ok()?;
let link = sk.curves.iter().find(|curve| curve.id == entity).and_then(|curve| curve.link.clone())?;
let (text, original) = source(app, sketch, &link)?;
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let values = fields(&original, unit, scale);
Some(Kind::Text(Tx { sketch, link, initial_text: text.clone(), text, initial: values.clone(), values, original, unit, focus: true }))
}
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
if id != "sketch.edit_text" {
return None;
}
Some((kind_for(app, &selected_entity(app)?)?, Vec::new()))
}
/// Open the dialog for the text an outline (picked with the Edit Text tool) belongs to.
pub(crate) fn open(app: &mut SolveApp, entity: &str) {
match kind_for(app, entity) {
Some(kind) => {
app.dialog = Some(Dialog::new(kind, Vec::new()));
app.tool = None;
}
None => app.set_status("Choose an outline belonging to sketch text", true),
}
}
/// Is the Edit Text dialog open?
pub(crate) fn editing(app: &SolveApp) -> bool {
matches!(app.dialog.as_ref().map(|d| &d.kind), Some(Kind::Text(_)))
}
/// Keep the dialog in step with the design: false once its text is gone (sketch finished, the
/// text deleted or undone). Other design changes leave it open; when the saved source itself
/// changed (undo, a script), fields the user has not touched follow it.
pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
let Some((text, original)) = source(app, k.sketch, &k.link) else { return false };
if original != k.original {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let fresh = fields(&original, unit, scale);
for ((value, initial), new) in k.values.iter_mut().zip(k.initial.iter_mut()).zip(fresh) {
if value == initial {
*value = new.clone();
}
*initial = new;
}
k.original = original;
k.unit = unit;
}
if text != k.initial_text {
if k.text == k.initial_text {
k.text = text.clone();
}
k.initial_text = text;
}
true
}
/// The dialog's rows; true when Enter was pressed in a value field.
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
crate::dialogs::row_label(ui, "Text");
let width = ui.spacing().text_edit_width;
let response = ui.add(egui::TextEdit::multiline(&mut k.text).id_salt("sketch_text_content").desired_rows(3).desired_width(width));
crate::scenario::publish_handle("dialog:Text", response.rect.center());
if k.focus {
response.request_focus();
k.focus = false;
}
ui.end_row();
let mut enter = false;
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
crate::dialogs::row_label(ui, label);
enter |= crate::dialogs::field(ui, value);
ui.end_row();
}
enter
}
/// The command OK runs. Fields take expressions with units and parameters; a bare number is
/// in the design's units (lengths) or degrees (angle).
pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, String> {
let doc = &app.session.doc;
let mut out = [0.0; 4];
for (i, slot) in out.iter_mut().enumerate() {
let (Some(value), Some(initial), Some(original), Some(kind), Some(label)) =
(k.values.get(i), k.initial.get(i), k.original.get(i), KINDS.get(i), LABELS.get(i))
else {
return Err("missing value".into());
};
*slot = if value == initial {
*original
} else {
let v = doc.eval(&doc.with_design_unit(value.trim(), *kind), *kind).map_err(|e| format!("{label}: {e}"))?;
if !v.is_finite() {
return Err(format!("{label}: not a finite number"));
}
v
};
}
let [x, y, height, angle] = out;
let mut params = json!({"link": k.link, "text": k.text, "at": [x, y], "height": height});
// Omitting an unchanged angle keeps its exact saved radians.
if k.values.get(3) != k.initial.get(3) {
params["angle"] = json!(angle.to_degrees());
}
Ok(vec![("sketch.edit_text".into(), params)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::Harness;
fn setup() -> (Harness, String, 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":"I","at":[0,0],"height":10})).unwrap();
let link = result["link"].as_str().unwrap().to_owned();
let curve = result["curves"][0].as_str().unwrap().to_owned();
h.app.session.selection = vec![Sel::SketchCurve { id: curve.clone() }];
h.frames(3);
(h, link, curve)
}
fn tx(app: &SolveApp) -> &Tx {
match app.dialog.as_ref().map(|d| &d.kind) {
Some(Kind::Text(k)) => k,
_ => panic!("Edit Text is not open"),
}
}
fn ok(h: &mut Harness) {
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
h.frames(2);
}
fn replace(h: &mut Harness, handle: &str, value: &str) {
h.step(&json!({"click":{"handle":handle}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":value})).unwrap();
}
#[test]
fn selected_text_menu_edits_source_position_and_size_with_one_undo_and_reopen() {
let (mut h, link, _) = setup();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let item = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport)
.into_iter()
.find(|item| item.id == "sketch.edit_text")
.unwrap();
crate::context_menu::run_item(&mut h.app, &item, egui::Pos2::ZERO);
h.frames(3);
assert!(editing(&h.app));
for (field, value) in [("dialog:Text", "O"), ("dialog:X", "25"), ("dialog:Y", "12"), ("dialog:Height", "20"), ("dialog:Angle", "90")] {
replace(&mut h, field, value);
}
assert_eq!(h.app.session.doc.to_json(), before);
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(h.app.session.undo.len(), undo + 1);
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap();
let expected = LinkSource::Text {
text: "O".into(),
at: solvecraft_engine::geom::Vec2::new(25.0, 12.0),
height: 20.0,
angle: std::f64::consts::FRAC_PI_2,
};
assert_eq!(sk.link(&link).unwrap().source, expected);
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
let changed = h.app.session.doc.to_json();
let reopened = solvecraft_engine::Session::new(solvecraft_engine::doc::Document::from_json(&changed).unwrap());
assert_eq!(reopened.doc.sketch(sid).unwrap().link(&link).unwrap().source, expected);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), changed);
}
#[test]
fn text_editor_converts_document_units_and_preserves_untouched_source_values() {
let (mut h, link, _) = setup();
h.app.run("document.units", json!({"units":"in"})).unwrap();
h.app.run("sketch.edit_text", json!({"link":link,"at":[3.123456789,7.987654321],"height":9.123456789,"angle":17.123456789})).unwrap();
let sid = h.app.session.active_sketch.unwrap();
let original = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone();
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).unit, "in");
replace(&mut h, "dialog:Text", "O");
ok(&mut h);
let LinkSource::Text { at, height, angle, .. } = original else { panic!("text source") };
assert_eq!(h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source, LinkSource::Text { text: "O".into(), at, height, angle });
h.app.session.selection = vec![Sel::SketchCurve { id: h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone() }];
h.app.start("sketch.edit_text");
h.frames(3);
let undo = h.app.session.undo.len();
replace(&mut h, "dialog:X", "1.2345");
replace(&mut h, "dialog:Height", "0.25");
ok(&mut h);
let LinkSource::Text { at: updated, height: size, angle: rotation, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source
else {
panic!("text source")
};
assert!((updated.x - 31.3563).abs() < 1e-12);
assert_eq!(updated.y, at.y);
assert!((size - 6.35).abs() < 1e-12);
assert_eq!(rotation, angle);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn invalid_text_values_and_cancel_leave_document_and_history_unchanged() {
let (mut h, _, _) = setup();
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
h.app.start("sketch.edit_text");
h.frames(3);
for value in ["NaN", "inf", "-5", "0"] {
replace(&mut h, "dialog:Height", value);
ok(&mut h);
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
}
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(!editing(&h.app));
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
}
#[test]
fn text_editor_rejects_mixed_sources_and_leaves_with_document_or_command() {
let (mut h, _, curve) = setup();
let other = h.app.run("sketch.text", json!({"text":"O","at":[30,0],"height":10})).unwrap();
h.app.session.selection.push(Sel::SketchCurve { id: other["curves"][0].as_str().unwrap().into() });
assert!(selected_entity(&h.app).is_none());
assert!(!crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport).iter().any(|item| item.id == "sketch.edit_text"));
h.app.session.selection = vec![Sel::SketchCurve { id: curve }];
h.app.start("sketch.edit_text");
assert!(editing(&h.app));
h.app.start("sketch.line");
assert!(!editing(&h.app));
h.app.start("sketch.edit_text");
assert!(editing(&h.app));
crate::documents::open_tab(&mut h.app, solvecraft_engine::Session::default()).unwrap();
assert!(!editing(&h.app));
}
#[test]
fn edit_text_without_preselection_picks_a_real_outline() {
let (mut h, link, _) = setup();
h.app.session.selection.clear();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 40.0;
h.frames(3);
let sid = h.app.session.active_sketch.unwrap();
let polyline = h.app.session.doc.sketch(sid).unwrap().polyline(0);
let midpoint = (polyline[0] + polyline[1]) * 0.5;
h.step(&json!({"start":"sketch.edit_text"})).unwrap();
assert!(h.app.tool.is_some());
h.step(&json!({"click":{"sketch":[midpoint.x,midpoint.y]}})).unwrap();
assert_eq!(tx(&h.app).link, link);
assert!(h.app.tool.is_none());
h.step(&json!({"widget":"Cancel"})).unwrap();
}
#[test]
fn issue_report_preserves_text_edit_and_owns_keyboard_input() {
let (mut h, _, _) = setup();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Text", "Pending text");
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
h.app.help.menu = true;
h.frames(2);
h.step(&json!({"click":{"handle":"menu:report"}})).unwrap();
h.frames(3);
let token = h.app.shot_token;
crate::issue_report::receive(&mut h.app, token, std::sync::Arc::new(egui::ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
h.frames(3);
let focus = egui::Id::new("issue_report_title");
h.ctx.memory_mut(|memory| memory.request_focus(focus));
h.frames(2);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(focus));
h.step(&json!({"text":"report-only-marker"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(tx(&h.app).text, "Pending text");
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
h.step(&json!({"widget":"Close draft"})).unwrap();
ok(&mut h);
assert!(!editing(&h.app));
assert_eq!(h.app.session.undo.len(), before.1 + 1);
}
#[test]
fn fields_take_expressions_with_units_and_parameters() {
let (mut h, link, _) = setup();
h.app.run("parameters.add", json!({"name":"gap","expression":"4 mm","unit":"mm"})).unwrap();
h.app.run("document.units", json!({"units":"in"})).unwrap();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:X", "10 mm * 2 + 0.5 in");
replace(&mut h, "dialog:Y", "gap * 3");
replace(&mut h, "dialog:Height", "0.5");
replace(&mut h, "dialog:Angle", "45 + 45");
ok(&mut h);
assert!(!editing(&h.app), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
let sid = h.app.session.active_sketch.unwrap();
let LinkSource::Text { at, height, angle, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else {
panic!("text source")
};
assert!((at.x - 32.7).abs() < 1e-9);
assert!((at.y - 12.0).abs() < 1e-9);
assert!((height - 12.7).abs() < 1e-9);
assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
}
#[test]
fn unrelated_design_changes_keep_the_dialog_open() {
let (mut h, _, _) = setup();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Text", "Kept");
h.app.run("sketch.line", json!({"points":[[50,0],[60,0]]})).unwrap();
h.frames(3);
assert!(editing(&h.app));
assert_eq!(tx(&h.app).text, "Kept");
h.app.run("edit.undo", json!({})).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
h.frames(3);
assert!(!editing(&h.app), "the text it edits is gone");
}
}
+12
View File
@@ -125,6 +125,12 @@ fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
/// Commands that start with a step of their own: Canvas and Decal ask for the image first,
/// Edit Canvas opens the canvas panel. False when the command should not go on.
pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
if id == "sketch.edit_text"
&& let Some(dialog) = crate::dialogs::Dialog::for_command(app, id)
{
app.dialog = Some(dialog);
return false;
}
// Surface analyses toggle: a second click turns the same one off.
use solvecraft_engine::SurfaceAnalysis as A;
let same = match (id, app.session.analysis) {
@@ -349,6 +355,7 @@ fn mode(id: &str) -> Option<Mode> {
"sketch.slot.arc_three_point" | "sketch.slot.arc_center" => Mode::Points(4),
"sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5" => Mode::Points(0),
"sketch.text" => Mode::Points(1),
"sketch.edit_text" => Mode::Entities(1),
"sketch.blend_curve" => Mode::Points(2),
"sketch.offset" => Mode::Entities(1),
"sketch.fillet" | "sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => Mode::Entities(1),
@@ -406,6 +413,7 @@ pub fn hint(id: &str) -> Option<String> {
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
"sketch.text" => "Text: click where the text starts",
"sketch.edit_text" => "Edit Text: click an outline of the text to edit",
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
"sketch.offset" => "Offset: click a curve (its chain is offset), then the side and distance",
"sketch.fillet" => "Fillet: click the corner of two lines",
@@ -730,6 +738,10 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
return;
}
let Some((id, _)) = sketch_entity(app, &hits) else { return };
if cmd == "sketch.edit_text" {
crate::sketch_text::open(app, &id);
return;
}
if cmd == "sketch.centerline" {
let _ = app.run(cmd, json!({"curves": [id]}));
return;
+3 -1
View File
@@ -222,7 +222,9 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
}
}
}
app.tool = Some(tool);
if !crate::sketch_text::editing(app) {
app.tool = Some(tool);
}
}
/// Put down the next point of the shape being drawn; the last point runs the command (with