diff --git a/ROADMAP.md b/ROADMAP.md index 8e895741..2d84c32a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 0d2175ed..2b1a62e2 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -642,6 +642,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { return v; } let ents: Vec = 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 }))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5459a785..27739eaa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, 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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4d44f018..1daf67fc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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!({})); diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs new file mode 100644 index 00000000..8e00920e --- /dev/null +++ b/crates/ui-egui/src/sketch_text.rs @@ -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 { + 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 { + 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)> { + 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, 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"); + } +} diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 96d079c3..77a3d9f1 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -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 { "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 { "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; diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 3561281e..83a69d73 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -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