From 2627d0622453cf25870da376619cf4006401ae23 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 31 May 2026 14:44:18 +0300 Subject: [PATCH] feat(text): in-place editors for all text entities Route every text-bearing entity to an in-place editor instead of the command line: a plain text box for single-line text (Text, attributes, dimension override, tolerance) and the rich MText editor for MText / MultiLeader. A Leader resolves to the entity it annotates. - TEXT places a plain text box at the click; double-click edits in place. - DDEDIT and double-click on any text entity open the matching editor. - MLEADER places the leader then opens the MText editor on its content. - LEADER follows the classic flow: points, Enter, then a linked empty MText annotation opens in the editor; the leader's annotation_handle links the pair so they edit/erase as a unit. Adds shared field read/write helpers and a begin_text_edit router, plus CommitAndEditText / CommitManyAndEditText results that commit an entity and open its editor (commit_entity now returns the new handle). Co-Authored-By: Claude Opus 4.8 --- src/app/cmd_result.rs | 81 +++++++++++ src/app/commands.rs | 28 ++-- src/app/mod.rs | 9 ++ src/app/mtext_editor.rs | 52 ++++--- src/app/properties.rs | 23 ++- src/app/text_inline.rs | 209 ++++++++++++++++++++++++++++ src/app/update.rs | 55 ++++---- src/app/view.rs | 38 ++++- src/command/mod.rs | 22 +++ src/modules/annotate/ddedit.rs | 83 ++--------- src/modules/annotate/leader_cmd.rs | 182 +++++++----------------- src/modules/annotate/mleader_cmd.rs | 77 +++------- src/modules/annotate/text.rs | 38 +---- 13 files changed, 551 insertions(+), 346 deletions(-) create mode 100644 src/app/text_inline.rs diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 35539a0d3..00e7e1c1c 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -125,6 +125,57 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); } + CmdResult::CommitAndEditText(entity) => { + let label = self.history_label_from_active_cmd(i, "ENTITY"); + self.push_undo_snapshot(i, label); + let handle = self.commit_entity_handle(entity); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + if let Some(h) = handle { + return self.begin_text_edit(h); + } + } + CmdResult::CommitManyAndEditText { + entities, + edit_index, + } => { + let label = self.history_label_from_active_cmd(i, "ENTITY"); + self.push_undo_snapshot(i, label); + let mut edit_handle = None; + let mut leader_handle = None; + for (idx, entity) in entities.into_iter().enumerate() { + let is_leader = matches!(entity, acadrust::EntityType::Leader(_)); + let h = self.commit_entity_handle(entity); + if idx == edit_index { + edit_handle = h; + } + if is_leader { + leader_handle = h; + } + } + // Link the leader to its annotation so the pair edits as a unit + // (double-click on the leader resolves to the text entity). + if let (Some(lh), Some(ah)) = (leader_handle, edit_handle) { + if let Some(acadrust::EntityType::Leader(l)) = + self.tabs[i].scene.document.get_entity_mut(lh) + { + l.annotation_handle = ah; + } + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + if let Some(h) = edit_handle { + return self.begin_text_edit(h); + } + } CmdResult::CreateBlock { handles, name, @@ -1506,6 +1557,30 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.open_mtext_editor(pos, handle, &initial, height); } + CmdResult::OpenTextEditor { + pos, + handle, + initial, + height, + } => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.open_text_inline( + pos, + handle, + &initial, + height, + super::text_inline::TextEntityField::Text, + ); + } + CmdResult::EditTextEntity { handle } => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + return self.begin_text_edit(handle); + } CmdResult::DdeditEntity { handle, new_text } => { let mut updated = false; if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { @@ -1556,6 +1631,12 @@ impl OpenCADStudio { if self.tabs[i].active_cmd.is_none() { self.ribbon.deactivate_tool(); } + // The in-place TEXT editor needs keyboard focus on its own field. + if self.text_inline.is_some() { + return iced::widget::operation::focus(iced::widget::Id::new( + super::view::TEXT_INLINE_ID, + )); + } self.focus_cmd_input() } diff --git a/src/app/commands.rs b/src/app/commands.rs index 0d80e399a..ca7aa51cf 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1196,22 +1196,22 @@ impl OpenCADStudio { } "DDEDIT" | "ED" => { - use crate::modules::annotate::ddedit::{entity_text, DdeditCommand}; - // If a single text/mtext entity is already selected, skip the pick step. + use crate::modules::annotate::ddedit::DdeditCommand; + // A single text entity already selected opens its in-place + // editor directly; otherwise prompt for a pick. let sel = self.tabs[i].scene.selected_entities(); + let editable = (sel.len() == 1).then(|| sel[0].0).filter(|h| { + self.tabs[i].scene.document.get_entity(*h).is_some_and(|e| { + super::text_inline::read_text_field(e).is_some() + || matches!(e, acadrust::EntityType::Leader(_)) + }) + }); + if let Some(h) = editable { + return self.begin_text_edit(h); + } if sel.len() == 1 { - let (h, _) = sel[0]; - if let Some(e) = self.tabs[i].scene.document.get_entity(h) { - if let Some(cur) = entity_text(e) { - let cmd = DdeditCommand::with_handle(h, cur.clone()); - self.command_line - .push_info(&format!("DDEDIT Enter new text <{cur}>:")); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } else { - self.command_line - .push_error("DDEDIT: selected entity is not text."); - } - } + self.command_line + .push_error("DDEDIT: selected entity is not text."); } else { let cmd = DdeditCommand::new(); self.command_line.push_info(&cmd.prompt()); diff --git a/src/app/mod.rs b/src/app/mod.rs index 9e2399984..d1bd75ad8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,6 +6,7 @@ mod history; mod layers; mod mtext_editor; mod properties; +mod text_inline; mod update; mod view; @@ -253,6 +254,8 @@ pub(super) struct OpenCADStudio { shift_down: bool, /// Open in-place MText editor (toolbar + text area + live preview), if any. mtext_editor: Option, + /// Open in-place single-line TEXT editor (plain text-entry box), if any. + text_inline: Option, /// Which layout tab has its context menu open (None = closed). layout_context_menu: Option, /// Inline rename state: (original_name, current_edit_value). @@ -755,6 +758,11 @@ pub enum Message { MTextOk, /// Discard the editor without creating / changing the entity. MTextCancel, + // ── In-place single-line TEXT editor ──────────────────────────────── + /// Text-field input changed. + TextInlineInput(String), + /// Commit the editor: create or update the TEXT entity. + TextInlineOk, // ── Draw Order context menu ───────────────────────────────────────── /// Toggle the Draw Order sub-items in the viewport context menu. DrawOrderSubmenuToggle, @@ -996,6 +1004,7 @@ impl OpenCADStudio { clipboard_centroid: glam::Vec3::ZERO, shift_down: false, mtext_editor: None, + text_inline: None, layout_context_menu: None, layout_rename_state: None, last_vp_click_time: None, diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index 16ce96737..d7ee0d8c6 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -330,6 +330,8 @@ use std::sync::Arc; impl super::OpenCADStudio { /// Open the in-place editor for a new (`handle = None`) or existing MText. + /// Open the rich MText editor for a new or existing MText / MultiLeader. + /// The committed slot is chosen by the edited entity's type. pub(super) fn open_mtext_editor( &mut self, pos: Vec3, @@ -341,17 +343,26 @@ impl super::OpenCADStudio { if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { state.screen_anchor = p; } - // Seed attachment / line-spacing from the entity being edited. + // Seed attachment / line-spacing / box width from the entity being edited. if let Some(h) = handle { - if let Some(EntityType::MText(m)) = self.tabs[self.active_tab].scene.document.get_entity(h) { - state.attachment = m.attachment_point; - state.line_spacing = m.line_spacing_factor as f32; - if !m.style.trim().is_empty() { - state.style = m.style.clone(); + match self.tabs[self.active_tab].scene.document.get_entity(h) { + Some(EntityType::MText(m)) => { + state.attachment = m.attachment_point; + state.line_spacing = m.line_spacing_factor as f32; + if !m.style.trim().is_empty() { + state.style = m.style.clone(); + } + if m.rectangle_width > 0.0 { + state.rect_width = m.rectangle_width; + } } - if m.rectangle_width > 0.0 { - state.rect_width = m.rectangle_width; + Some(EntityType::MultiLeader(ml)) => { + state.line_spacing = ml.context.line_spacing_factor as f32; + if ml.context.text_width > 0.0 { + state.rect_width = ml.context.text_width; + } } + _ => {} } } self.mtext_editor = Some(state); @@ -616,14 +627,23 @@ impl super::OpenCADStudio { } if let Some(h) = ed.editing { self.push_undo_snapshot(i, "MTEXT"); - if let Some(EntityType::MText(t)) = - self.tabs[i].scene.document.get_entity_mut(h) - { - t.value = mt.value; - t.height = mt.height; - t.attachment_point = mt.attachment_point; - t.line_spacing_factor = mt.line_spacing_factor; - t.rectangle_width = mt.rectangle_width; + match self.tabs[i].scene.document.get_entity_mut(h) { + Some(EntityType::MText(t)) => { + t.value = mt.value; + t.height = mt.height; + t.attachment_point = mt.attachment_point; + t.line_spacing_factor = mt.line_spacing_factor; + t.rectangle_width = mt.rectangle_width; + } + Some(EntityType::MultiLeader(ml)) => { + ml.context.text_string = mt.value; + ml.context.text_height = mt.height; + ml.context.line_spacing_factor = mt.line_spacing_factor; + if mt.rectangle_width > 0.0 { + ml.context.text_width = mt.rectangle_width; + } + } + _ => {} } self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; diff --git a/src/app/properties.rs b/src/app/properties.rs index 8aeaba4f3..edc029e69 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -434,7 +434,17 @@ impl OpenCADStudio { } /// Add an entity to the correct space (model or paper space layout). - pub(super) fn commit_entity(&mut self, mut entity: acadrust::EntityType) { + pub(super) fn commit_entity(&mut self, entity: acadrust::EntityType) { + let _ = self.commit_entity_handle(entity); + } + + /// Like [`commit_entity`] but returns the handle the new entity was given + /// (or `None` if it could not be added). Lets callers follow up — e.g. + /// open the in-place text editor on a freshly created MultiLeader. + pub(super) fn commit_entity_handle( + &mut self, + mut entity: acadrust::EntityType, + ) -> Option { let i = self.active_tab; let layer = &self.tabs[i].active_layer; if layer != "0" || entity.as_entity().layer().is_empty() { @@ -531,13 +541,16 @@ impl OpenCADStudio { { Ok(new_handle) => { self.tabs[i].scene.auto_fit_viewport(new_handle); + Some(new_handle) + } + Err(e) => { + self.command_line + .push_error(&format!("Viewport could not be added: {e}")); + None } - Err(e) => self - .command_line - .push_error(&format!("Viewport could not be added: {e}")), } } else { - self.tabs[i].scene.add_entity(entity); + Some(self.tabs[i].scene.add_entity(entity)) } } } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs new file mode 100644 index 000000000..5420f738e --- /dev/null +++ b/src/app/text_inline.rs @@ -0,0 +1,209 @@ +// In-place single-line TEXT editor: a plain text-entry box anchored at the +// insertion-point click. Unlike MText, plain-text entities carry no inline +// formatting, so there is no toolbar or rich preview — just a field the user +// types into, committed on Enter. +// +// This module also hosts `begin_text_edit`, the shared router that opens the +// right in-place editor (this plain box, or the rich MText editor) for any +// text-bearing entity, plus the field read/write helpers both editors use to +// commit back to the correct entity slot. + +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle, Text}; +use glam::Vec3; + +/// Which text slot of which entity an editor session reads from and writes to. +/// `Text`/`AttDef`/`AttEnt`/`Dim`/`Tolerance` are plain (single-line box); +/// `MText`/`MLeader` are rich (MText editor). +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TextEntityField { + /// `Text.value`. + Text, + /// `AttributeDefinition.default_value`. + AttDef, + /// `AttributeEntity` value. + AttEnt, + /// `Dimension` text override (`base.text`). + Dim, + /// `Tolerance.text` (feature-control-frame string). + Tolerance, + /// `MText.value`. + MText, + /// `MultiLeader.context.text_string`. + MLeader, +} + +impl TextEntityField { + /// True for fields edited with the rich MText editor. + pub fn is_rich(self) -> bool { + matches!(self, TextEntityField::MText | TextEntityField::MLeader) + } +} + +/// Read the current text of an entity's editable slot, or `None` if the entity +/// carries no editable text. +pub fn read_text_field(entity: &EntityType) -> Option<(String, TextEntityField)> { + match entity { + EntityType::Text(t) => Some((t.value.clone(), TextEntityField::Text)), + EntityType::MText(m) => Some((m.value.clone(), TextEntityField::MText)), + EntityType::AttributeDefinition(a) => { + Some((a.default_value.clone(), TextEntityField::AttDef)) + } + EntityType::AttributeEntity(a) => { + Some((a.get_value().to_string(), TextEntityField::AttEnt)) + } + EntityType::Dimension(d) => Some((d.base().text.clone(), TextEntityField::Dim)), + EntityType::Tolerance(t) => Some((t.text.clone(), TextEntityField::Tolerance)), + EntityType::MultiLeader(ml) => { + Some((ml.context.text_string.clone(), TextEntityField::MLeader)) + } + _ => None, + } +} + +/// Write `value` into an entity's editable slot. Returns true on a match. +pub fn write_text_field(entity: &mut EntityType, field: TextEntityField, value: String) -> bool { + match (entity, field) { + (EntityType::Text(t), TextEntityField::Text) => t.value = value, + (EntityType::MText(m), TextEntityField::MText) => m.value = value, + (EntityType::AttributeDefinition(a), TextEntityField::AttDef) => a.default_value = value, + (EntityType::AttributeEntity(a), TextEntityField::AttEnt) => a.set_value(value), + (EntityType::Dimension(d), TextEntityField::Dim) => d.base_mut().text = value, + (EntityType::Tolerance(t), TextEntityField::Tolerance) => t.text = value, + (EntityType::MultiLeader(ml), TextEntityField::MLeader) => { + ml.context.text_string = value; + } + _ => return false, + } + true +} + +fn vec3(v: Vector3) -> Vec3 { + Vec3::new(v.x as f32, v.y as f32, v.z as f32) +} + +/// Live state of the open in-place TEXT editor. Absent (`None`) when no editor +/// is up. +pub struct TextInlineState { + /// World insertion point (WCS, same convention the committed entity uses). + pub pos: Vec3, + /// The plain text being entered. + pub value: String, + /// Text height (drawing units), used when creating a new TEXT entity. + pub height: f64, + /// `Some` when editing an existing entity; `None` for a fresh TEXT. + pub editing: Option, + /// Which entity slot this session writes to on commit. + pub field: TextEntityField, + /// Canvas-space anchor where the field is drawn (the insertion-point click). + pub screen_anchor: iced::Point, +} + +impl super::OpenCADStudio { + /// Open the right in-place editor for `handle`: the plain box for single- + /// line text entities, the rich MText editor for MText / MultiLeader. A + /// Leader resolves to the entity it annotates. Returns the focus task for + /// the plain box (the rich editor needs no field focus). + pub(super) fn begin_text_edit(&mut self, handle: Handle) -> iced::Task { + let i = self.active_tab; + // Resolve a Leader chain to the annotated entity. + let mut target = handle; + for _ in 0..8 { + match self.tabs[i].scene.document.get_entity(target) { + Some(EntityType::Leader(l)) => { + let ann = l.annotation_handle; + if ann.is_null() || ann == target { + return iced::Task::none(); + } + target = ann; + } + _ => break, + } + } + // Snapshot what we need before borrowing `self` mutably to open. + let Some(entity) = self.tabs[i].scene.document.get_entity(target) else { + return iced::Task::none(); + }; + let Some((value, field)) = read_text_field(entity) else { + return iced::Task::none(); + }; + let (pos, height) = match entity { + EntityType::Text(t) => (vec3(t.insertion_point), t.height), + EntityType::MText(m) => (vec3(m.insertion_point), m.height), + EntityType::AttributeDefinition(a) => (vec3(a.insertion_point), a.height), + EntityType::AttributeEntity(a) => (vec3(a.insertion_point), a.height), + EntityType::Dimension(d) => (vec3(d.base().insertion_point), 0.25), + EntityType::Tolerance(t) => (vec3(t.insertion_point), t.text_height), + EntityType::MultiLeader(ml) => { + (vec3(ml.context.text_location), ml.context.text_height) + } + _ => (Vec3::ZERO, 0.25), + }; + + if field.is_rich() { + self.open_mtext_editor(pos, Some(target), &value, height); + iced::Task::none() + } else { + self.open_text_inline(pos, Some(target), &value, height, field); + iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) + } + } + + /// Open the in-place plain-text editor at `pos`, writing to `field` on + /// commit. `handle` is `Some` when editing an existing entity. + pub(super) fn open_text_inline( + &mut self, + pos: Vec3, + handle: Option, + initial: &str, + height: f64, + field: TextEntityField, + ) { + let mut state = TextInlineState { + pos, + value: initial.to_string(), + height: if height > 0.0 { height } else { 0.25 }, + editing: handle, + field, + screen_anchor: iced::Point::new(60.0, 90.0), + }; + if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { + state.screen_anchor = p; + } + self.text_inline = Some(state); + } + + /// Commit the editor: create a new TEXT entity or update the edited slot. + /// Empty content drops a new entity and leaves an edited one untouched. + pub(super) fn text_inline_commit(&mut self) { + let i = self.active_tab; + let Some(ed) = self.text_inline.take() else { return }; + if ed.value.trim().is_empty() && ed.editing.is_none() { + self.refresh_properties(); + return; + } + if let Some(h) = ed.editing { + self.push_undo_snapshot(i, "TEXT"); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(h) { + write_text_field(entity, ed.field, ed.value.clone()); + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } else { + let t = Text::with_value( + &ed.value, + Vector3::new(ed.pos.x as f64, ed.pos.y as f64, ed.pos.z as f64), + ) + .with_height(ed.height); + self.push_undo_snapshot(i, "TEXT"); + self.commit_entity(EntityType::Text(t)); + self.tabs[i].dirty = true; + } + self.refresh_properties(); + } + + /// Discard the editor without changing the drawing. + pub(super) fn text_inline_cancel(&mut self) { + self.text_inline = None; + } +} diff --git a/src/app/update.rs b/src/app/update.rs index e7e94d48e..95c217f0e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1260,6 +1260,11 @@ impl OpenCADStudio { self.mtext_cancel(); return Task::none(); } + // The in-place TEXT editor likewise cancels on Escape. + if self.text_inline.is_some() { + self.text_inline_cancel(); + return Task::none(); + } // Grip popup intercepts Escape — dismisses the menu // without doing anything else. if self.grip_popup.take().is_some() { @@ -2959,31 +2964,20 @@ impl OpenCADStudio { let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { - // MText opens the in-place editor; other text types - // keep the command-line DDEDIT path. - if let Some(AcadEntityType::MText(m)) = - self.tabs[i].scene.document.get_entity(handle) - { - let pos = glam::Vec3::new( - m.insertion_point.x as f32, - m.insertion_point.y as f32, - m.insertion_point.z as f32, - ); - let (val, h) = (m.value.clone(), m.height); - self.open_mtext_editor(pos, Some(handle), &val, h); - return Task::none(); - } - if let Some(entity) = self.tabs[i].scene.document.get_entity(handle) { - use crate::modules::annotate::ddedit::{ - entity_text, DdeditCommand, - }; - if let Some(cur) = entity_text(entity) { - let cmd = DdeditCommand::with_handle(handle, cur.clone()); - self.command_line - .push_info(&format!("DDEDIT Enter new text <{cur}>:")); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - return self.focus_cmd_input(); - } + // Any text-bearing entity opens its in-place editor + // (plain box or rich MText editor, per type). A + // Leader resolves to the entity it annotates. + let is_editable_text = self + .tabs[i] + .scene + .document + .get_entity(handle) + .is_some_and(|e| { + super::text_inline::read_text_field(e).is_some() + || matches!(e, AcadEntityType::Leader(_)) + }); + if is_editable_text { + return self.begin_text_edit(handle); } } } @@ -3621,6 +3615,17 @@ impl OpenCADStudio { Task::none() } + Message::TextInlineInput(s) => { + if let Some(ed) = self.text_inline.as_mut() { + ed.value = s; + } + Task::none() + } + Message::TextInlineOk => { + self.text_inline_commit(); + Task::none() + } + Message::DrawOrderSubmenuToggle => { let i = self.active_tab; let mut sel = self.tabs[i].scene.selection.borrow_mut(); diff --git a/src/app/view.rs b/src/app/view.rs index 8804776bb..7100e83ff 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -807,6 +807,9 @@ impl OpenCADStudio { .collect(); viewport_stack = viewport_stack.push(mtext_editor_overlay(ed, styles)); } + if let Some(ed) = &self.text_inline { + viewport_stack = viewport_stack.push(text_inline_overlay(ed)); + } } // Properties / layers panels carry no useful state on the Start tab. @@ -833,7 +836,8 @@ impl OpenCADStudio { let dyn_capturing = (self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty()) - || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview); + || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) + || self.text_inline.is_some(); let command_line_overlay = iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing)) .width(Fill) @@ -1361,6 +1365,38 @@ fn position_canvas_overlay<'a>( /// Widget id for the MText editor's text area (focused when Edit mode opens). pub(super) const MTEXT_TEXT_ID: &str = "mtext_editor_text"; +/// Widget id for the in-place TEXT editor's input (focused when it opens). +pub(super) const TEXT_INLINE_ID: &str = "text_inline_input"; + +/// In-place single-line TEXT editor: a plain text-entry box (no formatting +/// toolbar), anchored at the insertion-point click. Enter commits; Esc cancels. +fn text_inline_overlay(ed: &super::text_inline::TextInlineState) -> Element<'_, Message> { + const PANEL_BG: Color = Color { r: 0.16, g: 0.16, b: 0.16, a: 0.98 }; + const BORDER: Color = Color { r: 0.40, g: 0.40, b: 0.40, a: 1.0 }; + + let field = text_input("Text", &ed.value) + .id(iced::widget::Id::new(TEXT_INLINE_ID)) + .on_input(Message::TextInlineInput) + .on_submit(Message::TextInlineOk) + .padding(6) + .size(13) + .width(iced::Length::Fixed(240.0)); + + let panel = container(field) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { color: BORDER, width: 1.0, radius: 5.0.into() }, + ..Default::default() + }) + .padding(4); + + let anchor = iced::Point::new( + (ed.screen_anchor.x - 6.0).max(0.0), + (ed.screen_anchor.y - 18.0).max(0.0), + ); + position_canvas_overlay(anchor, panel.into()) +} + // Stroke-font families the renderer ships (LibreCAD LFF; see scene/lff.rs). const MTEXT_FONTS: [&str; 10] = [ "[Style default]", diff --git a/src/command/mod.rs b/src/command/mod.rs index c77655180..cda056748 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -51,6 +51,16 @@ pub enum CmdResult { CommitEntity(EntityType), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), + /// Commit an acadrust entity, end the command, and open the in-place text + /// editor on it (used by MLEADER to type the annotation after placement). + CommitAndEditText(EntityType), + /// Commit several entities, end the command, and open the in-place text + /// editor on the one at `edit_index` (used by LEADER to place the leader + /// line plus an empty MText annotation, then type into the MText). + CommitManyAndEditText { + entities: Vec, + edit_index: usize, + }, /// Create a block definition from existing entities and insert one reference. CreateBlock { handles: Vec, @@ -126,6 +136,9 @@ pub enum CmdResult { SetPlotWindow { p1: Vec3, p2: Vec3 }, /// Replace the text content of a Text/MText entity in-place. DdeditEntity { handle: Handle, new_text: String }, + /// Open the in-place editor (plain box or rich MText editor, per type) for + /// a text-bearing entity picked by a command such as DDEDIT. + EditTextEntity { handle: Handle }, /// Open the in-place MText editor (formatting toolbar + multi-line text /// area with live viewport preview). `handle` is `Some` when editing an /// existing MText, `None` when creating a new one at `pos`. @@ -135,6 +148,15 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Open the in-place single-line TEXT editor (a plain text-entry box, no + /// formatting toolbar). `handle` is `Some` when editing an existing Text, + /// `None` when creating a new one at `pos`. + OpenTextEditor { + pos: Vec3, + handle: Option, + initial: String, + height: f64, + }, /// Apply new pattern/scale/angle to an existing hatch entity. HatcheditApply { handle: Handle, diff --git a/src/modules/annotate/ddedit.rs b/src/modules/annotate/ddedit.rs index 645b15374..cf76a16ad 100644 --- a/src/modules/annotate/ddedit.rs +++ b/src/modules/annotate/ddedit.rs @@ -1,10 +1,13 @@ -// DDEDIT — edit text content of a Text or MText entity in-place. +// DDEDIT — edit the text content of a text-bearing entity in-place. // // Workflow: -// 1. Pick a Text or MText entity (or fire from double-click with handle pre-set). -// 2. Enter new text. Press Enter to commit, Escape to cancel. +// 1. Pick a text entity (or fire from double-click / a current selection). +// 2. The picked entity opens its in-place editor: a plain text box for +// single-line text (Text, attributes, dimension override, tolerance) or +// the rich MText editor for MText / MultiLeader. A Leader resolves to the +// entity it annotates. -use acadrust::{EntityType, Handle}; +use acadrust::Handle; use glam::Vec3; use crate::command::{CadCommand, CmdResult}; @@ -21,27 +24,11 @@ pub fn tool() -> ToolDef { } } -enum DdeditStep { - PickEntity, - EnterText { handle: Handle, current: String }, -} - -pub struct DdeditCommand { - step: DdeditStep, -} +pub struct DdeditCommand; impl DdeditCommand { pub fn new() -> Self { - Self { - step: DdeditStep::PickEntity, - } - } - - /// Start with a pre-picked entity (for double-click use). - pub fn with_handle(handle: Handle, current: String) -> Self { - Self { - step: DdeditStep::EnterText { handle, current }, - } + Self } } @@ -51,51 +38,20 @@ impl CadCommand for DdeditCommand { } fn prompt(&self) -> String { - match &self.step { - DdeditStep::PickEntity => "DDEDIT Select text entity:".into(), - DdeditStep::EnterText { current, .. } => { - format!("DDEDIT Enter new text <{current}>:") - } - } + "DDEDIT Select text entity:".into() } fn needs_entity_pick(&self) -> bool { - matches!(self.step, DdeditStep::PickEntity) + true } fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - // The current value will be filled in by the caller (commands.rs dispatch) - // via on_text_input once the entity is known. Store handle here. - self.step = DdeditStep::EnterText { - handle, - current: String::new(), - }; - CmdResult::NeedPoint - } - - fn wants_text_input(&self) -> bool { - matches!(self.step, DdeditStep::EnterText { .. }) - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, DdeditStep::EnterText { .. }) - } - - fn on_text_input(&mut self, text: &str) -> Option { - let (handle, current) = match &self.step { - DdeditStep::EnterText { handle, current } => (*handle, current.clone()), - _ => return None, - }; - // Empty input → keep existing text - let new_text = if text.trim().is_empty() { - current - } else { - text.to_string() - }; - Some(CmdResult::DdeditEntity { handle, new_text }) + // Hand the picked entity to the in-place editor (plain box or rich + // MText editor, chosen by the entity's type). + CmdResult::EditTextEntity { handle } } fn on_point(&mut self, _pt: Vec3) -> CmdResult { @@ -108,14 +64,3 @@ impl CadCommand for DdeditCommand { CmdResult::Cancel } } - -/// Extract the text content from a Text or MText entity. -pub fn entity_text(entity: &EntityType) -> Option { - match entity { - EntityType::Text(t) => Some(t.value.clone()), - EntityType::MText(t) => Some(t.value.clone()), - EntityType::AttributeDefinition(a) => Some(a.default_value.clone()), - EntityType::AttributeEntity(a) => Some(a.get_value().to_string()), - _ => None, - } -} diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index 80ff8df7b..db03c967a 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -1,12 +1,16 @@ // LEADER command // -// Flow: -// 1. CollectPoints — click arrowhead, then bend points; Enter (≥2) to finish -// 2. AskCreationType— wants_text_input; N/T/B/TL → default Text on blank Enter -// 3. AskAnnotation — text string (Text) or block name (Block); blank = skip -// → commit Leader [+ MText | + Insert] +// Flow (matching the classic AutoCAD LEADER): +// 1. Click the arrowhead point, then one or more bend points. +// 2. Enter (≥2 points) places the leader line plus a linked, empty MText +// annotation at the landing, and opens the in-place MText editor so the +// user types the annotation. Escape leaves the leader without text. +// +// The MText is a separate entity referenced by the leader's annotation_handle +// (DXF group 340); editing/erasing them stays in sync via that link. -use acadrust::entities::{Insert, Leader, LeaderCreationType, MText}; +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::entities::{Leader, LeaderCreationType, MText}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::Vec3; @@ -26,22 +30,13 @@ pub fn tool() -> ToolDef { } } -enum Step { - CollectPoints { verts: Vec }, - AskCreationType { verts: Vec }, - AskText { verts: Vec }, - AskBlock { verts: Vec }, -} - pub struct LeaderCommand { - step: Step, + verts: Vec, } impl LeaderCommand { pub fn new() -> Self { - Self { - step: Step::CollectPoints { verts: Vec::new() }, - } + Self { verts: Vec::new() } } } @@ -51,102 +46,33 @@ impl CadCommand for LeaderCommand { } fn prompt(&self) -> String { - match &self.step { - Step::CollectPoints { verts } if verts.is_empty() => { - "LEADER Specify arrowhead point:".into() - } - Step::CollectPoints { verts } => format!( - "LEADER Specify next point [{} pts — Enter to finish]:", - verts.len() - ), - Step::AskCreationType { .. } => { - "LEADER Annotation type [None/Text/Block/Tolerance] :".into() - } - Step::AskText { verts } => format!( - "LEADER Annotation text [{} pts — blank = skip]:", - verts.len() - ), - Step::AskBlock { verts } => { - format!("LEADER Block name [{} pts — blank = skip]:", verts.len()) - } + if self.verts.is_empty() { + "LEADER Specify arrowhead point:".into() + } else { + format!( + "LEADER Specify next point [{} pts — Enter to place text]:", + self.verts.len() + ) } } - fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::CollectPoints { .. }) - } - fn on_point(&mut self, pt: Vec3) -> CmdResult { - if let Step::CollectPoints { verts } = &mut self.step { - verts.push(pt); - } + self.verts.push(pt); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - if let Step::CollectPoints { verts } = &self.step { - if verts.len() < 2 { - return CmdResult::Cancel; - } - let verts = verts.clone(); - self.step = Step::AskCreationType { verts }; - CmdResult::NeedPoint - } else { - CmdResult::Cancel + if self.verts.len() < 2 { + return CmdResult::Cancel; } - } - - fn on_text_input(&mut self, raw: &str) -> Option { - let text = raw.trim(); - match &self.step { - Step::AskCreationType { verts } => { - let verts = verts.clone(); - match parse_ct(text) { - LeaderCreationType::NoAnnotation | LeaderCreationType::WithTolerance => { - let ct = parse_ct(text); - Some(CmdResult::CommitAndExit(EntityType::Leader(build_leader( - &verts, ct, - )))) - } - LeaderCreationType::WithBlock => { - self.step = Step::AskBlock { verts }; - Some(CmdResult::NeedPoint) - } - LeaderCreationType::WithText => { - self.step = Step::AskText { verts }; - Some(CmdResult::NeedPoint) - } - } - } - Step::AskText { verts } => { - let verts = verts.clone(); - let leader = build_leader(&verts, LeaderCreationType::WithText); - if text.is_empty() { - return Some(CmdResult::CommitAndExit(EntityType::Leader(leader))); - } - let mtext = build_mtext( - text, - landing_pt(&verts, leader.text_height), - leader.text_height, - ); - Some(CmdResult::ReplaceMany( - vec![], - vec![EntityType::Leader(leader), EntityType::MText(mtext)], - )) - } - Step::AskBlock { verts } => { - let verts = verts.clone(); - let leader = build_leader(&verts, LeaderCreationType::WithBlock); - if text.is_empty() { - return Some(CmdResult::CommitAndExit(EntityType::Leader(leader))); - } - let ins = Insert::new(text, v3(landing_pt(&verts, leader.text_height))); - Some(CmdResult::ReplaceMany( - vec![], - vec![EntityType::Leader(leader), EntityType::Insert(ins)], - )) - } - Step::CollectPoints { .. } => None, + // Place the leader plus an empty MText annotation, link them, then open + // the in-place MText editor so the user types the annotation text. + let leader = build_leader(&self.verts); + let (anchor, attach) = annotation_anchor(&self.verts, leader.text_height); + let mtext = build_mtext("", anchor, leader.text_height, attach); + CmdResult::CommitManyAndEditText { + entities: vec![EntityType::Leader(leader), EntityType::MText(mtext)], + edit_index: 1, } } @@ -155,53 +81,51 @@ impl CadCommand for LeaderCommand { } fn on_mouse_move(&mut self, pt: Vec3) -> Option { - if let Step::CollectPoints { verts } = &self.step { - if verts.is_empty() { - return None; - } - let mut pts = verts.clone(); - pts.push(pt); - Some(preview_wire(&pts)) - } else { - None + if self.verts.is_empty() { + return None; } + let mut pts = self.verts.clone(); + pts.push(pt); + Some(preview_wire(&pts)) } } // ── Helpers ───────────────────────────────────────────────────────────────── -fn parse_ct(s: &str) -> LeaderCreationType { - match s.to_ascii_uppercase().as_str() { - "N" | "NONE" => LeaderCreationType::NoAnnotation, - "B" | "BLOCK" => LeaderCreationType::WithBlock, - "TL" | "TOLERANCE" => LeaderCreationType::WithTolerance, - _ => LeaderCreationType::WithText, - } -} - fn v3(p: Vec3) -> Vector3 { Vector3::new(p.x as f64, p.y as f64, p.z as f64) } -fn build_leader(verts: &[Vec3], ct: LeaderCreationType) -> Leader { +fn build_leader(verts: &[Vec3]) -> Leader { let mut l = Leader::from_vertices(verts.iter().map(|p| v3(*p)).collect()); - l.creation_type = ct; - l.hookline_enabled = !matches!(ct, LeaderCreationType::NoAnnotation); + l.creation_type = LeaderCreationType::WithText; + l.hookline_enabled = true; l } -fn landing_pt(verts: &[Vec3], text_height: f64) -> Vec3 { +/// Text anchor at the end of the landing line, and the attachment point that +/// keeps the text reading away from the leader (text to the right of a +/// left-pointing landing, to the left of a right-pointing one). +fn annotation_anchor(verts: &[Vec3], text_height: f64) -> (Vec3, AttachmentPoint) { let last = *verts.last().unwrap(); let prev = verts[verts.len() - 2]; - let sign = if last.x >= prev.x { 1.0_f32 } else { -1.0_f32 }; - Vec3::new(last.x + sign * text_height as f32 * 1.5, last.y, last.z) + let to_right = last.x >= prev.x; + let sign = if to_right { 1.0_f32 } else { -1.0_f32 }; + let anchor = Vec3::new(last.x + sign * text_height as f32 * 1.5, last.y, last.z); + let attach = if to_right { + AttachmentPoint::MiddleLeft + } else { + AttachmentPoint::MiddleRight + }; + (anchor, attach) } -fn build_mtext(text: &str, pos: Vec3, height: f64) -> MText { +fn build_mtext(text: &str, pos: Vec3, height: f64, attach: AttachmentPoint) -> MText { let mut m = MText::new(); m.value = text.to_string(); m.insertion_point = v3(pos); m.height = height; + m.attachment_point = attach; m } diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 3885f9443..7a408ad9f 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -25,20 +25,13 @@ pub fn tool() -> ToolDef { } } -enum Step { - CollectPoints { verts: Vec }, - AskText { verts: Vec }, -} - pub struct MLeaderCommand { - step: Step, + verts: Vec, } impl MLeaderCommand { pub fn new() -> Self { - Self { - step: Step::CollectPoints { verts: Vec::new() }, - } + Self { verts: Vec::new() } } } @@ -48,53 +41,29 @@ impl CadCommand for MLeaderCommand { } fn prompt(&self) -> String { - match &self.step { - Step::CollectPoints { verts } if verts.is_empty() => { - "MLEADER Specify arrowhead point:".into() - } - Step::CollectPoints { verts } => format!( - "MLEADER Specify next point [{} pts — Enter to finish]:", - verts.len() - ), - Step::AskText { verts } => format!( - "MLEADER Enter annotation text [{} pts — blank = no text]:", - verts.len() - ), + if self.verts.is_empty() { + "MLEADER Specify arrowhead point:".into() + } else { + format!( + "MLEADER Specify next point [{} pts — Enter to place text]:", + self.verts.len() + ) } } - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::AskText { .. }) - } - fn on_point(&mut self, pt: Vec3) -> CmdResult { - if let Step::CollectPoints { verts } = &mut self.step { - verts.push(pt); - } + self.verts.push(pt); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - if let Step::CollectPoints { verts } = &self.step { - if verts.len() < 2 { - return CmdResult::Cancel; - } - let verts = verts.clone(); - self.step = Step::AskText { verts }; - CmdResult::NeedPoint - } else { - CmdResult::Cancel - } - } - - fn on_text_input(&mut self, raw: &str) -> Option { - if let Step::AskText { verts } = &self.step { - let text = raw.trim(); - let ml = build_mleader(text, verts); - Some(CmdResult::CommitAndExit(EntityType::MultiLeader(ml))) - } else { - None + if self.verts.len() < 2 { + return CmdResult::Cancel; } + // Place the leader with empty text, then open the in-place MText editor + // so the user types the annotation into the rich editor. + let ml = build_mleader("", &self.verts); + CmdResult::CommitAndEditText(EntityType::MultiLeader(ml)) } fn on_escape(&mut self) -> CmdResult { @@ -102,16 +71,12 @@ impl CadCommand for MLeaderCommand { } fn on_mouse_move(&mut self, pt: Vec3) -> Option { - if let Step::CollectPoints { verts } = &self.step { - if verts.is_empty() { - return None; - } - let mut pts = verts.clone(); - pts.push(pt); - Some(preview_wire(&pts)) - } else { - None + if self.verts.is_empty() { + return None; } + let mut pts = self.verts.clone(); + pts.push(pt); + Some(preview_wire(&pts)) } } diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index 61e41d73a..cecbb3991 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -1,6 +1,3 @@ -use acadrust::types::Vector3; -use acadrust::{EntityType, Text}; - use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::wire_model::WireModel; @@ -19,7 +16,6 @@ pub fn tool() -> ToolDef { enum Step { InsertPoint, - WaitText(Vec3), } pub struct TextCommand { @@ -42,16 +38,17 @@ impl CadCommand for TextCommand { fn prompt(&self) -> String { match &self.step { Step::InsertPoint => "TEXT Specify insertion point:".into(), - Step::WaitText(pos) => format!( - "TEXT Type text, press Enter [at {:.2},{:.2}]:", - pos.x, pos.z - ), } } fn on_point(&mut self, pt: Vec3) -> CmdResult { - self.step = Step::WaitText(pt); - CmdResult::NeedPoint + // Hand off to the in-place plain-text editor anchored at the click. + CmdResult::OpenTextEditor { + pos: pt, + handle: None, + initial: String::new(), + height: 0.25, + } } fn on_enter(&mut self) -> CmdResult { @@ -61,27 +58,6 @@ impl CadCommand for TextCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::WaitText(_)) - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, Step::WaitText(_)) - } - - fn on_text_input(&mut self, text: &str) -> Option { - if let Step::WaitText(pos) = &self.step { - if text.trim().is_empty() { - return Some(CmdResult::Cancel); - } - let t = Text::with_value(text, Vector3::new(pos.x as f64, pos.y as f64, pos.z as f64)) - .with_height(0.25); - Some(CmdResult::CommitEntity(EntityType::Text(t))) - } else { - None - } - } - fn on_mouse_move(&mut self, _pt: Vec3) -> Option { None }