diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 150fd1381..713996c18 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -750,6 +750,88 @@ impl OpenCADStudio { task } + /// Resolve the cell under `click` on table `handle` (or any table in the drawing + /// if `handle` is `Handle::NULL`), arm the cell indicator, and launch the cell + /// editor unless the cell's content is locked. Shared by the double-click shortcut + /// and the TABLEDIT point pick so both agree on grid coordinates and editor setup. + pub(crate) fn begin_table_cell_edit( + &mut self, + i: usize, + handle: acadrust::Handle, + click: glam::DVec3, + ) -> crate::modules::annotate::table_cmd::TableCellEditStart { + use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; + let target = if handle != acadrust::Handle::NULL { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::Table(table) => Some((handle, table.clone())), + _ => None, + }) + } else { + // Find all tables whose grid contains `click`. + // Iterating document.entities() visits entities in draw order; + // taking the last match selects the table on top if they overlap. + let mut matched = None; + for entity in self.tabs[i].scene.document.entities() { + if let acadrust::EntityType::Table(table) = entity { + let h = table.common.handle; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + if table_cell_at(table, style, click).is_some() { + matched = Some((h, table.clone())); + } + } + } + matched + }; + let Some((handle, table)) = target else { + return TableCellEditStart::NoCell; + }; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + let Some(hit) = table_cell_at(&table, style, click) else { + return TableCellEditStart::NoCell; + }; + // Arm the Properties cell indicator whether or not the cell turns + // out to be editable (matches the double-click behavior). + let index = hit.index(&table); + self.tabs[i].properties.prop_vertex = index; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::entities::table::set_prop_current_cell(index); + self.refresh_properties(); + if hit.locked { + return TableCellEditStart::LockedCell; + } + let command = crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, &table, hit.row, hit.column, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + TableCellEditStart::Started + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; match result { @@ -2392,6 +2474,59 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); let _ = self.dispatch_command(&cmd); } + CmdResult::EditTableCell { handle, point } => { + // TABLEDIT's pick: end the pick phase and hand (table, point) + // to the shared cell-edit launcher. A miss or a locked cell + // re-prompts, matching AutoCAD's select-until-valid loop. + use crate::modules::annotate::table_cmd::{table_cell_at, TableCellEditStart}; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + + if handle == acadrust::Handle::NULL && self.selection_cycling { + let mut candidates = Vec::new(); + for entity in self.tabs[i].scene.document.entities() { + if let acadrust::EntityType::Table(table) = entity { + let h = table.common.handle; + let style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => Some(style), + _ => None, + }) + }); + if table_cell_at(table, style, point).is_some() { + candidates.push(h); + } + } + } + if candidates.len() >= 2 { + self.cycle_candidates = Some((self.quick_properties_anchor, candidates)); + return Task::none(); + } + } + + match self.begin_table_cell_edit(i, handle, point) { + TableCellEditStart::Started => {} + TableCellEditStart::LockedCell => { + self.command_line.push_error( + crate::t!("The cell is content locked.").as_ref(), + ); + let _ = self.dispatch_command("TABLEDIT"); + } + TableCellEditStart::NoCell => { + self.command_line.push_error( + crate::t!("No editable table cell picked.").as_ref(), + ); + let _ = self.dispatch_command("TABLEDIT"); + } + } + } CmdResult::MatchEntityLayer { dest, src } => { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0561d3e96..b697b71df 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -666,6 +666,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } + "TABLEDIT" => { + use crate::modules::annotate::table_cmd::TableditCommand; + let cmd = TableditCommand::new(); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + "DIMCONTINUE" => { use crate::modules::annotate::dim_continue::DimContinueCommand; let scene = &self.tabs[i].scene; diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 02dc22220..dc90c07ea 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1285,8 +1285,12 @@ impl CadCommand for DrawOrderCommand { } } - fn wants_text_input(&self) -> bool { - !matches!(self.step, DrawOrderStep::SelectObjects) + fn input_kind(&self) -> crate::command::InputKind { + if matches!(self.step, DrawOrderStep::SelectObjects) { + crate::command::InputKind::Point + } else { + crate::command::InputKind::SingleToken + } } fn is_selection_gathering(&self) -> bool { @@ -2116,7 +2120,7 @@ mod tests { // 1. Shortcut 'F' -> Front let mut cmd = DrawOrderCommand::new(vec![h_hatch]); - assert_eq!(cmd.wants_text_input(), true); + assert_eq!(cmd.input_kind().wants_text(), true); let res = cmd.on_text_input("F"); match res { Some(crate::command::CmdResult::Relaunch(c, handles)) => { @@ -2242,14 +2246,14 @@ mod tests { // Start command with no pre-selection let mut cmd = DrawOrderCommand::new(vec![]); assert!(cmd.is_selection_gathering()); - assert!(!cmd.wants_text_input()); + assert!(!cmd.input_kind().wants_text()); // Gather selection let _ = cmd.on_selection_complete(vec![h_hatch]); let enter_res = cmd.on_enter(); assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint)); assert!(!cmd.is_selection_gathering()); - assert!(cmd.wants_text_input()); + assert!(cmd.input_kind().wants_text()); // Choose verb Front let res = cmd.on_text_input("F"); diff --git a/src/app/mod.rs b/src/app/mod.rs index 173ac41a8..d31ec464c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -323,6 +323,21 @@ pub enum StartSection { Supporters, } +/// What the Space / Enter keys currently mean at the command line. One +/// decision point (`OpenCADStudio::text_entry_mode`) for every keyboard +/// route that used to re-derive the answer from editor state. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum TextEntryMode { + /// Normal command line: Space submits, Enter finalises. + Command, + /// MText preview pane: every key is literal content, Enter is a line break. + MTextPreview, + /// Free-form text prompt (table cell content, TEXT / MTEXT bodies): + /// Space is literal, typed case is preserved, Enter finishes the edit, + /// Shift+Enter inserts a line break. + FreeText, +} + pub(super) struct OpenCADStudio { start: Instant, tabs: Vec, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5e8e5689d..a1eb51675 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -8,6 +8,7 @@ use crate::app::helpers::{ CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; +use crate::app::TextEntryMode; use crate::modules::ModuleEvent; use crate::scene::pick::grip::{ find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, @@ -199,8 +200,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .get_or_insert_with(String::new) .push_str(&s); } else { - // Command-line entry is shown uppercase. - self.command_line.input.push_str(&s.to_uppercase()); + // Command-line entry is shown uppercase — except in + // free-form text prompts, where the typed case is the + // content (matches the CommandInput route). + if self.is_free_text_active() { + self.command_line.input.push_str(&s); + } else { + self.command_line.input.push_str(&s.to_uppercase()); + } self.command_line.cancel_history_navigation(); // Live incremental search for INSERT/MINSERT (see CommandInput) let live = self.command_line.input.clone(); @@ -263,7 +270,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.close_history(); // A leading `>` was only a "literal spaces" typing hint (see // CommandSpace) — drop it before the input is interpreted. - if self.command_line.input.starts_with('>') { + // Free-form text prompts keep it: there it is content, not a + // hint, so a cell value like `>Note` commits verbatim. + if !self.is_free_text_active() && self.command_line.input.starts_with('>') { self.command_line.input.remove(0); } // Grip-menu value prompt — consume the typed number and @@ -516,7 +525,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let wants_spaces = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input() && c.wants_text_with_spaces()) + .map(|c| c.is_free_text_step()) .unwrap_or(false); let raw = self.command_line.input.clone(); let toks: Vec = raw.split_whitespace().map(String::from).collect(); @@ -542,7 +551,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } if self.tabs[i].active_cmd.is_some() { - let text = crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + // Free-form text is the content itself: no expression + // evaluation, or a cell value like `5*2` would commit as + // `10`. Single-token prompts keep the calculator behavior. + let free_text = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.is_free_text_step()) + .unwrap_or(false); + let raw = self.command_line.input.trim().to_string(); + let text = if free_text { + raw + } else { + crate::app::expr_eval::eval_to_string(&raw) + }; self.command_line.input.clear(); // Offer the typed text to the command's option handler @@ -559,16 +581,23 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false) { - self.push_ucs_to_cmd(i); - if let Some(result) = self.tabs[i] - .active_cmd - .as_mut() - .and_then(|c| c.on_text_input(&text)) - { - return self.apply_cmd_result(result); + // An empty submit must not be handed to on_text_input — + // a text step would commit the empty string (wiping a + // table cell, clearing an attribute). It falls through + // to the Enter handling below, which ends the step the + // same way a bare Enter does: unchanged. + if !text.is_empty() { + self.push_ucs_to_cmd(i); + if let Some(result) = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|c| c.on_text_input(&text)) + { + return self.apply_cmd_result(result); + } } } @@ -679,9 +708,36 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } + /// The active command's current step collects free-form prose from the + /// command line. Single decision point for the routes that type into + /// the buffer (CommandInput, Shift+Enter, the view's on_submit wiring). + pub(crate) fn is_free_text_active(&self) -> bool { + self.tabs[self.active_tab] + .active_cmd + .as_ref() + .is_some_and(|c| c.is_free_text_step()) + } + + /// What Space / Enter currently mean. Previously every key handler + /// re-derived this from the MText editor and the active command, and + /// the MText preview case was known only to `CommandSpace` — so Enter + /// behaved differently depending on which route carried the key. + pub(crate) fn text_entry_mode(&self) -> crate::app::TextEntryMode { + if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { + return TextEntryMode::MTextPreview; + } + if self.is_free_text_active() { + return TextEntryMode::FreeText; + } + TextEntryMode::Command + } + pub(super) fn on_command_finalize(&mut self) -> Task { - // In the MText preview, Enter inserts a line break. - if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { + // In the MText preview, Enter inserts a line break. The + // free-text prompt case falls through deliberately: for it + // Enter *finishes* the edit (Shift+Enter breaks the line, + // handled by the SHIFT+ENTER shortcut route). + if self.text_entry_mode() == TextEntryMode::MTextPreview { self.mtext_type("\n"); return Task::none(); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 27f97734a..1e232cf7e 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -126,7 +126,7 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false); // A point step that also accepts keyword letters (PLINE A/L/C…) keeps // its polar boxes: only letters reach the command line, digits stay @@ -604,7 +604,7 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false); if !wants_text { if let Some(text) = self.tabs[i] @@ -645,7 +645,7 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| { - (c.wants_text_input() && !c.point_step_accepts_keywords()) + (c.input_kind().wants_text() && !c.point_step_accepts_keywords()) || c.dyn_commit_as_text() }) .unwrap_or(false); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9ca1cdb1c..c41f99477 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1,4 +1,4 @@ -use super::{ArrowKey, Message, OpenCADStudio}; +use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode}; use crate::scene::VIEWCUBE_DRAW_PX; use crate::ui::PropertiesPanel; use iced::time::Instant; @@ -1478,14 +1478,23 @@ impl OpenCADStudio { // string, a path, `UCS Z 90` as one line) can be typed; the `>` // is stripped on submit. (Unfocused Space repeats the last // command via CommandSpace.) - // Command-line entry is shown uppercase. - let s = s.to_uppercase(); + // Command-line entry is shown uppercase — except while the + // active command collects free-form text (cell text, TEXT / + // MTEXT bodies), where the typed case is the content and + // Space must stay in the buffer. + let text_with_spaces = self.is_free_text_active(); + let s = if text_with_spaces { s } else { s.to_uppercase() }; // A space submits (acts like Enter). The whole value is handed to // the submit path, which tokenises multi-token lines — so a typed // token, a pasted `LINE 0,0 10,10`, or API-fed text all run their - // spaces as step separators. A leading `>` keeps spaces literal. + // spaces as step separators. A leading `>` keeps spaces literal, + // as does an active free-form text prompt. let sweep = self.sync_active_field_if_any(); - if !self.command_line.literal_spaces && !s.starts_with('>') && s.contains(' ') { + if !text_with_spaces + && !self.command_line.literal_spaces + && !s.starts_with('>') + && s.contains(' ') + { self.command_line.input = s; return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]); } @@ -1817,21 +1826,35 @@ impl OpenCADStudio { Message::CommandSubmit => self.on_command_submit(), Message::CommandSpace => { - // Space is a literal space inside the MText preview; otherwise - // it finalises the active command like Enter. - if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { - self.mtext_type(" "); - return Task::none(); + // What Space means is decided in one place: the MText preview + // and free-form text prompts take it as a literal character, + // everything else finalises the active command like Enter. + match self.text_entry_mode() { + TextEntryMode::MTextPreview => { + self.mtext_type(" "); + Task::none() + } + // A free-form text prompt (table cell content, TEXT / + // MTEXT bodies) keeps Space in the buffer as a literal + // character. + TextEntryMode::FreeText => { + self.command_line.input.push(' '); + Task::none() + } + TextEntryMode::Command => { + // A leading `>` (or the persistent `>` toggle) puts + // the command line in "literal space" mode so the user + // can type arguments that contain spaces. The typed + // `>` is stripped on submit. + if self.command_line.literal_spaces + || self.command_line.input.starts_with('>') + { + self.command_line.input.push(' '); + return Task::none(); + } + self.update(Message::CommandFinalize) + } } - // A leading `>` (or the persistent `>` toggle) puts the command - // line in "literal space" mode so the user can type arguments - // that contain spaces; otherwise Space works like Enter. The - // typed `>` is stripped on submit. - if self.command_line.literal_spaces || self.command_line.input.starts_with('>') { - self.command_line.input.push(' '); - return Task::none(); - } - return self.update(Message::CommandFinalize); } Message::CommandFinalize => self.on_command_finalize(), @@ -8201,3 +8224,131 @@ mod prop_pointer_tests { assert!(task.units() > 0); } } + +#[cfg(test)] +mod free_text_entry_tests { + //! The free-form text prompts (table cell content, TEXT / MTEXT bodies) + //! change what Space / Enter / typing mean at the command line. These + //! tests pin the routing through `text_entry_mode` so the three key + //! routes can't drift apart again. + use super::Message; + use crate::app::{OpenCADStudio, TextEntryMode}; + use crate::modules::annotate::table_cmd::TableCellEditCommand; + use acadrust::entities::Table; + use acadrust::types::Vector3; + use acadrust::{EntityType, Handle}; + + /// A test drawing with a 2×2 table and the cell editor active on [0,0], + /// seeded with `cell_text`. + fn app_with_cell_command(cell_text: &str) -> (OpenCADStudio, Handle) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let mut table = Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2); + table.cell_mut(0, 0).unwrap().set_text(cell_text); + let handle = app.tabs[0] + .scene + .document + .add_entity(EntityType::Table(table.clone())) + .unwrap(); + app.set_active_command(0, Box::new(TableCellEditCommand::new(handle, &table, 0, 0))); + (app, handle) + } + + fn cell_text(app: &OpenCADStudio, _handle: Handle) -> String { + // ReplaceMany commits by erasing the old handle and re-adding the + // entity under a new one, so look the (single) table up by type. + app.tabs[0] + .scene + .document + .entities() + .find_map(|e| match e { + EntityType::Table(table) => Some(table.cell_text(0, 0).unwrap_or("").to_string()), + _ => None, + }) + .expect("table entity missing") + } + + #[test] + fn cell_editor_switches_to_free_text_mode() { + let (mut app, _) = app_with_cell_command(""); + assert_eq!(app.text_entry_mode(), TextEntryMode::FreeText); + assert!(app.is_free_text_active()); + app.tabs[0].active_cmd = None; + assert_eq!(app.text_entry_mode(), TextEntryMode::Command); + assert!(!app.is_free_text_active()); + } + + #[test] + fn cell_edit_typed_space_stays_in_buffer_with_case() { + let (mut app, handle) = app_with_cell_command(""); + // A typed buffer containing a Space must not auto-submit. + let _ = app.update(Message::CommandInput("Foo Bar".into())); + assert_eq!(app.command_line.input, "Foo Bar"); + assert!(app.tabs[0].active_cmd.is_some(), "Space must not submit"); + // The Space key binding inserts a literal space too. + let _ = app.update(Message::CommandSpace); + assert_eq!(app.command_line.input, "Foo Bar "); + // Enter finishes the edit and commits the content, case intact. + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Foo Bar"); + } + + #[test] + fn cell_edit_slash_n_inserts_line_break() { + let (mut app, handle) = app_with_cell_command(""); + let _ = app.update(Message::CommandInput("Foo/nBar".into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Foo\\PBar"); + } + + #[test] + fn cell_edit_unfocused_typing_preserves_case() { + // The CommandAppendChar route (typing while the command line is not + // focused) must not uppercase free-form text either. + let (mut app, handle) = app_with_cell_command(""); + for ch in ["F", "o", "o"] { + let _ = app.update(Message::CommandAppendChar(ch.into())); + } + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Foo"); + } + + #[test] + fn cell_edit_skips_expression_evaluation() { + let (mut app, handle) = app_with_cell_command(""); + let _ = app.update(Message::CommandInput("5*2".into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "5*2"); + } + + #[test] + fn cell_edit_leading_greater_than_is_content() { + // `>` is only a literal-space hint for command prompts; in free + // text it is content and must not be stripped on submit. + let (mut app, handle) = app_with_cell_command(""); + let _ = app.update(Message::CommandInput(">Note".into())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), ">Note"); + } + + #[test] + fn cell_edit_empty_enter_ends_edit_without_wiping() { + let (mut app, handle) = app_with_cell_command("Keep me"); + let _ = app.update(Message::CommandSubmit); + assert_eq!(cell_text(&app, handle), "Keep me"); + assert!(app.tabs[0].active_cmd.is_none(), "Enter ends the edit"); + } + + #[test] + fn normal_commands_still_uppercase_and_submit_on_space() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + // No free-text command active: entry is uppercased… + let _ = app.update(Message::CommandInput("lin".into())); + assert_eq!(app.command_line.input, "LIN"); + // …and a pasted multi-token line runs as a command, not as text. + let _ = app.update(Message::CommandInput("LINE 0,0 10,10".into())); + assert!(app.command_line.input.is_empty(), "Space submitted the line"); + assert_eq!(app.text_entry_mode(), TextEntryMode::Command); + } +} diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 55164a91b..f6341ce79 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3602,7 +3602,7 @@ impl OpenCADStudio { let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.input_kind().wants_text()) .unwrap_or(false); if wants_text { if let Some(text) = self.tabs[i] @@ -4369,102 +4369,14 @@ impl OpenCADStudio { ).as_ref()); return Task::none(); } - let table_editor = self.tabs[i] - .scene - .document - .get_entity(handle) - .and_then(|entity| match entity { - AcadEntityType::Table(table) => { - let horizontal = glam::DVec3::new( - table.horizontal_direction.x, - table.horizontal_direction.y, - table.horizontal_direction.z, - ) - .normalize_or(glam::DVec3::X); - let normal = glam::DVec3::new( - table.normal.x, - table.normal.y, - table.normal.z, - ) - .normalize_or(glam::DVec3::Z); - let mut down = horizontal - .cross(normal) - .normalize_or(glam::DVec3::NEG_Y); - let table_style = table.table_style_handle.and_then(|style_handle| { - self.tabs[i] - .scene - .document - .objects - .get(&style_handle) - .and_then(|object| match object { - acadrust::objects::ObjectType::TableStyle(style) => { - Some(style) - } - _ => None, - }) - }); - let flows_up = crate::entities::table::resolved_flow_up( - table, - table_style, - ); - if flows_up { - down = -down; - } - let origin = glam::DVec3::new( - table.insertion_point.x, - table.insertion_point.y, - table.insertion_point.z, - ); - let relative = click_world - origin; - let x = relative.dot(horizontal); - let y = relative.dot(down); - if x < 0.0 || y < 0.0 { - return None; - } - let column = table - .columns - .iter() - .scan(0.0, |offset, column| { - *offset += column.width; - Some(*offset) - }) - .position(|end| x <= end)?; - let row = table - .rows - .iter() - .scan(0.0, |offset, row| { - *offset += row.height; - Some(*offset) - }) - .position(|end| y <= end)?; - let locked = table.cell(row, column).is_some_and(|cell| { - use acadrust::entities::table::CellStateFlags; - cell.state.intersects( - CellStateFlags::CONTENT_LOCKED - | CellStateFlags::CONTENT_READ_ONLY, - ) - }); - Some(( - (!locked).then(|| { - crate::modules::annotate::table_cmd::TableCellEditCommand::new( - handle, table, row, column, - ) - }), - row * table.column_count() + column, - )) - } - _ => None, - }); - if let Some((command, cell_index)) = table_editor { - self.tabs[i].properties.prop_vertex = cell_index; - self.tabs[i].properties.prop_vertex_indicator_active = true; - crate::entities::table::set_prop_current_cell(cell_index); - self.refresh_properties(); - if let Some(command) = command { - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); - } + // Table double-click: shared TABLEDIT editor setup. A + // grid miss returns NoCell and falls through to the + // other text/entity editors below; an editable cell or + // a locked cell (indicator armed, no editor) ends the + // double-click handling here. + if self.begin_table_cell_edit(i, handle, click_world) + != crate::modules::annotate::table_cmd::TableCellEditStart::NoCell + { return Task::none(); } // Any text-bearing entity opens its in-place editor diff --git a/src/command.rs b/src/command.rs index 64bb12501..536152687 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1240,6 +1240,12 @@ pub enum CmdResult { }, /// Cancel: discard any preview and end the command. Cancel, + /// End the command and begin in-place editing of the table cell under + /// `point` on table `handle` (TABLEDIT). The host resolves the cell — + /// it owns the document needed for the table-style lookup — honors + /// content locks, and launches the cell editor, re-prompting the + /// command when the pick misses a cell. + EditTableCell { handle: Handle, point: DVec3 }, /// Cancel because the active drawing space changed. Cleanup is identical /// to `Cancel`, but the host reports the context change explicitly. CancelForSpaceChange, @@ -1678,6 +1684,38 @@ impl CmdOption { } } +/// What kind of input the command's current step expects from the command line. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum InputKind { + /// Normal point-picking / viewport interaction or keyword options. + /// The command line does not await dedicated text input. + #[default] + Point, + /// Single-token command-line text input (option letters, numeric radius, + /// layer name, block name, numeric angle). Space acts as a submit delimiter, + /// characters are automatically uppercased, and expressions like `5*2` + /// are evaluated. + SingleToken, + /// Free-form text prose (table cell contents, TEXT / MTEXT bodies, + /// dimension text overrides, attribute prompt defaults). Space is a + /// literal character, typed letter casing is preserved, math expression + /// evaluation is bypassed, Enter finishes the edit, and Shift+Enter + /// inserts a line break. + FreeText, +} + +impl InputKind { + /// Returns `true` when the command is waiting for typed text input (`SingleToken` or `FreeText`). + pub fn wants_text(self) -> bool { + matches!(self, InputKind::SingleToken | InputKind::FreeText) + } + + /// Returns `true` when the current prompt collects free-form prose. + pub fn is_free_text(self) -> bool { + self == InputKind::FreeText + } +} + pub trait CadCommand: Send { /// Short name shown in the command line prompt, e.g. `"LINE"`. #[allow(dead_code)] @@ -1904,7 +1942,21 @@ pub trait CadCommand: Send { &[] } + /// What kind of input the current step expects from the command line. + /// Default is [`InputKind::Point`]. + fn input_kind(&self) -> InputKind { + #[allow(deprecated)] + if self.wants_text_with_spaces() { + InputKind::FreeText + } else if self.wants_text_input() { + InputKind::SingleToken + } else { + InputKind::Point + } + } + /// Returns `true` when the command is waiting for text typed in the command line. + #[deprecated(note = "Use `input_kind().wants_text()` instead")] fn wants_text_input(&self) -> bool { false } @@ -1931,17 +1983,19 @@ pub trait CadCommand: Send { /// Returns `true` when the active text prompt expects free-form prose /// that can legitimately contain whitespace (the body of a TEXT / - /// MTEXT / DDEDIT entity, an attribute default value, etc.). For - /// these prompts the command-line input must let `Space` be typed as - /// a literal character; for every other prompt `Space` submits the - /// input the same way `Enter` does. - /// - /// Default `false` — single-token prompts (option letters, numeric - /// radius, block name) do not embed spaces. + /// MTEXT / DDEDIT entity, a table cell, an attribute default value). + #[deprecated(note = "Use `input_kind().is_free_text()` instead")] fn wants_text_with_spaces(&self) -> bool { false } + /// The current step collects free-form prose from the command line: + /// Space is a literal character, typed case is preserved, Enter + /// finishes the edit and Shift+Enter inserts a line break. + fn is_free_text_step(&self) -> bool { + self.input_kind().is_free_text() + } + /// Called when the user submits text via the command line while `wants_text_input` is true. fn on_text_input(&mut self, _text: &str) -> Option { None diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 9ecfa7c70..e4dd5d475 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -5,7 +5,9 @@ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::DVec3; -use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane}; +use crate::command::{ + CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -159,8 +161,12 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { @@ -169,11 +175,6 @@ impl CadCommand for AlignedDimensionCommand { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - // The override text may contain spaces. - self.awaiting_text - } - fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_text { let t = text.trim(); diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index f23e3b555..811dc0602 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -8,7 +8,7 @@ use cadkernel::geom2d::{ use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, - DimensionAssociationSource, WorkingPlane, + DimensionAssociationSource, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -486,12 +486,12 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true - } - - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn options(&self) -> Vec { diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index bbdfaf412..6bfb98a8c 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -6,7 +6,7 @@ use glam::DVec3; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, - DimensionAssociationSource, WorkingPlane, + DimensionAssociationSource, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::dimension_assoc::{ @@ -350,18 +350,18 @@ impl CadCommand for ArcLengthDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { let Step::DimLine(selection) = self.step else { return Vec::new(); diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 19dfcb1ca..5077dae15 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -4,7 +4,7 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -136,18 +136,18 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle { vec![ diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 9a517c968..8c3f219cf 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -5,7 +5,7 @@ use glam::{DVec3, Vec3}; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, - WorkingPlane, + InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::creation_style::DimensionCreationDefaults; @@ -227,18 +227,18 @@ impl CadCommand for JoggedRadiusDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::DimLine { .. }) && !self.awaiting_text diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 53d09b08e..68bb4d133 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -5,7 +5,9 @@ use cadkernel::geom2d::{ closest_point, Circle as KernelCircle, Curve, Line as KernelLine, }; -use crate::command::{CadCommand, CmdResult, DimensionAssociationInput, WorkingPlane}; +use crate::command::{ + CadCommand, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; @@ -219,8 +221,12 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { @@ -229,11 +235,6 @@ impl CadCommand for LinearDimensionCommand { !self.awaiting_text && !self.awaiting_angle && !self.awaiting_rotation } - fn wants_text_with_spaces(&self) -> bool { - // The override text may contain spaces. - self.awaiting_text - } - fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_text { let t = text.trim(); diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 1214d45d7..c10f4e04a 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -5,7 +5,7 @@ use acadrust::types::Vector3; use acadrust::EntityType; use glam::{DVec3, Mat4, Vec3}; -use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -469,12 +469,12 @@ impl CadCommand for MLeaderCommand { Some(preview) } - fn wants_text_input(&self) -> bool { - true - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, Step::Layer | Step::PreEnterText) + fn input_kind(&self) -> InputKind { + if matches!(self.step, Step::Layer | Step::PreEnterText) { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index 6af690398..24aaaf77e 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -3,7 +3,7 @@ use acadrust::types::Vector3; use acadrust::MText; use glam::DVec3; -use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -243,12 +243,12 @@ impl CadCommand for MTextCommand { matches!(self.step, Step::FirstCorner | Step::OppositeCorner) } - fn wants_text_input(&self) -> bool { - true - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, Step::Style) + fn input_kind(&self) -> InputKind { + if matches!(self.step, Step::Style) { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn dyn_field(&self) -> DynField { diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 1437ba953..ea5b94c8c 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -4,7 +4,7 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -139,18 +139,18 @@ impl CadCommand for OrdinateDimCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::LeaderEndpoint { .. }) && !self.awaiting_text diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 4cb6acc9d..f68c26f6b 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -3,7 +3,7 @@ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -134,18 +134,18 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - true + fn input_kind(&self) -> InputKind { + if self.awaiting_text { + InputKind::FreeText + } else { + InputKind::SingleToken + } } fn point_step_accepts_keywords(&self) -> bool { !self.awaiting_text && !self.awaiting_angle } - fn wants_text_with_spaces(&self) -> bool { - self.awaiting_text - } - fn options(&self) -> Vec { if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle { vec![ diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 0e597cc98..2ffb34878 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -10,7 +10,7 @@ use acadrust::EntityType; use glam::DVec3; use std::sync::{Mutex, OnceLock}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -303,15 +303,19 @@ impl CadCommand for TableCommand { } } - fn wants_text_input(&self) -> bool { - matches!( + fn input_kind(&self) -> InputKind { + if matches!( self.step, Step::Columns | Step::DataRows { .. } | Step::ColumnWidth { .. } | Step::RowHeight { .. } | Step::InsertionMode { .. } - ) + ) { + InputKind::SingleToken + } else { + InputKind::Point + } } fn on_text_input(&mut self, text: &str) -> Option { @@ -479,6 +483,147 @@ impl CadCommand for TableCommand { inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); +/// A table cell resolved from a pick: grid coordinates plus whether its +/// content is locked against editing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TableCellHit { + pub row: usize, + pub column: usize, + pub locked: bool, +} + +/// Outcome of launching the table cell editor for a pick (double-click or +/// TABLEDIT). Distinguishes "cell found but locked" — the caller should +/// stop, the indicator is armed — from "nothing to edit" — the caller may +/// fall through to other handlers or re-prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TableCellEditStart { + /// Cell editor launched. + Started, + /// Cell resolved but content-locked/read-only: indicator armed, no + /// editor. + LockedCell, + /// Not a table, or the pick missed the grid. + NoCell, +} + +impl TableCellHit { + /// Flat cell index, matching the Properties palette ordering. + pub fn index(&self, table: &acadrust::entities::Table) -> usize { + self.row * table.column_count() + self.column + } +} + +/// Resolve the table cell under `click_world`. Shared by the double-click +/// shortcut and the TABLEDIT command so both agree on grid coordinates, +/// flow direction and locked-cell detection. `table_style` is the style +/// object the table references, when the caller has the document at hand; +/// the table's embedded base style covers the common fallbacks. +pub(crate) fn table_cell_at( + table: &acadrust::entities::Table, + table_style: Option<&acadrust::objects::TableStyle>, + click_world: DVec3, +) -> Option { + let horizontal = glam::DVec3::new( + table.horizontal_direction.x, + table.horizontal_direction.y, + table.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(table.normal.x, table.normal.y, table.normal.z) + .normalize_or(glam::DVec3::Z); + let mut down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + if crate::entities::table::resolved_flow_up(table, table_style) { + down = -down; + } + let origin = glam::DVec3::new( + table.insertion_point.x, + table.insertion_point.y, + table.insertion_point.z, + ); + let relative = click_world - origin; + let x = relative.dot(horizontal); + let y = relative.dot(down); + if x < 0.0 || y < 0.0 { + return None; + } + let column = table + .columns + .iter() + .scan(0.0, |offset, column| { + *offset += column.width; + Some(*offset) + }) + .position(|end| x <= end)?; + let row = table + .rows + .iter() + .scan(0.0, |offset, row| { + *offset += row.height; + Some(*offset) + }) + .position(|end| y <= end)?; + Some(TableCellHit { + row, + column, + locked: cell_locked(table, row, column), + }) +} + +/// True when the cell's content may not be edited (locked or read-only). +pub(crate) fn cell_locked(table: &acadrust::entities::Table, row: usize, column: usize) -> bool { + use acadrust::entities::table::CellStateFlags; + table.cell(row, column).is_some_and(|cell| { + cell.state + .intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY) + }) +} + +/// TABLEDIT — edit a table cell's text by picking it. The pick only +/// collects (table, point); the host resolves the cell and launches +/// `TableCellEditCommand`, so the command-first flow and the double-click +/// shortcut share one resolver and one editor setup. +pub struct TableditCommand; + +impl TableditCommand { + pub fn new() -> Self { + Self + } +} + +impl Default for TableditCommand { + fn default() -> Self { + Self::new() + } +} + +impl CadCommand for TableditCommand { + fn name(&self) -> &'static str { + "TABLEDIT" + } + + fn prompt(&self) -> String { + t!("TABLEDIT Select a table cell:").into_owned() + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + CmdResult::EditTableCell { + handle: acadrust::Handle::NULL, + point, + } + } + + // A bare Enter at the pick prompt ends the command (AutoCAD leaves the + // select-until-valid loop on Enter). + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["TABLEDIT"] +}); + pub struct TableCellEditCommand { handle: acadrust::Handle, table: acadrust::entities::Table, @@ -508,7 +653,11 @@ impl CadCommand for TableCellEditCommand { } fn prompt(&self) -> String { - let current = self.table.cell_text(self.row, self.column).unwrap_or(""); + let current = self + .table + .cell_text(self.row, self.column) + .unwrap_or("") + .replace("\\P", "/n"); t!( "TABLE CELL Enter text for [%{row},%{column}] <%{current}>:", row = self.row, @@ -518,8 +667,11 @@ impl CadCommand for TableCellEditCommand { .into_owned() } - fn wants_text_input(&self) -> bool { - true + // Cell content is free-form prose ("Foo Bar"): Space must type a + // literal space instead of submitting, Enter finishes, and + // /n or \n inserts a line break. + fn input_kind(&self) -> InputKind { + InputKind::FreeText } fn on_point(&mut self, _point: DVec3) -> CmdResult { @@ -535,7 +687,11 @@ impl CadCommand for TableCellEditCommand { { return Some(CmdResult::Cancel); } - cell.set_text(text); + let formatted = text + .replace("/n", "\\P") + .replace("\\n", "\\P") + .replace('\n', "\\P"); + cell.set_text(&formatted); self.table.block_record_handle = None; Some(CmdResult::ReplaceMany( vec![(self.handle, vec![EntityType::Table(self.table.clone())])], @@ -543,7 +699,126 @@ impl CadCommand for TableCellEditCommand { )) } + // Bare Enter (empty buffer) ends the edit *without* writing — the + // host routes an empty submit here instead of on_text_input(""), so + // pressing Enter on an untouched cell keeps its current content. fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } } + +#[cfg(test)] +mod tabledit_tests { + //! `table_cell_at` is the one resolver shared by the double-click + //! shortcut and the TABLEDIT pick, so its grid math is pinned here. + //! `Table::new` seeds 2.5-unit columns and 0.25-unit rows with the + //! default orientation (horizontal +X, flow down). + use super::{table_cell_at, TableCellEditCommand, TableCellHit, TableditCommand}; + use crate::command::{CadCommand, CmdResult}; + use acadrust::entities::Table; + use acadrust::entities::table::CellStateFlags; + use acadrust::types::Vector3; + use glam::DVec3; + + fn table_2x2() -> Table { + Table::new(Vector3::new(0.0, 0.0, 0.0), 2, 2) + } + + #[test] + fn resolves_row_and_column_from_point() { + let table = table_2x2(); + // Centers of the four cells. + assert_eq!( + table_cell_at(&table, None, DVec3::new(1.25, -0.125, 0.0)), + Some(TableCellHit { + row: 0, + column: 0, + locked: false + }) + ); + assert_eq!( + table_cell_at(&table, None, DVec3::new(3.75, -0.125, 0.0)), + Some(TableCellHit { + row: 0, + column: 1, + locked: false + }) + ); + assert_eq!( + table_cell_at(&table, None, DVec3::new(1.25, -0.375, 0.0)), + Some(TableCellHit { + row: 1, + column: 0, + locked: false + }) + ); + } + + #[test] + fn misses_outside_the_grid() { + let table = table_2x2(); + // Before the insertion point, beyond the last column/row, and a + // point in the plane's empty space all miss. + assert_eq!( + table_cell_at(&table, None, DVec3::new(-0.1, -0.1, 0.0)), + None + ); + assert_eq!(table_cell_at(&table, None, DVec3::new(6.0, -0.1, 0.0)), None); + assert_eq!(table_cell_at(&table, None, DVec3::new(1.0, -9.0, 0.0)), None); + assert_eq!(table_cell_at(&table, None, DVec3::new(1.0, 1.0, 0.0)), None); + } + + #[test] + fn reports_locked_cells() { + let mut table = table_2x2(); + if let Some(cell) = table.cell_mut(0, 1) { + cell.state |= CellStateFlags::CONTENT_LOCKED; + } + let hit = table_cell_at(&table, None, DVec3::new(3.75, -0.125, 0.0)) + .expect("locked cell still resolves"); + assert!(hit.locked); + // Its neighbour stays editable through the same pick path. + assert!(!table_cell_at(&table, None, DVec3::new(1.25, -0.125, 0.0)) + .expect("neighbour resolves") + .locked); + } + + #[test] + fn tabledit_point_hands_point_to_host() { + let mut cmd = TableditCommand::new(); + assert!(!cmd.needs_entity_pick()); + match cmd.on_point(DVec3::new(1.0, 2.0, 0.0)) { + CmdResult::EditTableCell { + handle, + point, + } => { + assert_eq!(handle, acadrust::Handle::NULL); + assert_eq!(point, DVec3::new(1.0, 2.0, 0.0)); + } + _ => panic!("expected EditTableCell"), + } + // A bare Enter at the pick prompt ends the command. + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn table_cell_edit_formats_newlines() { + let table = table_2x2(); + let handle = acadrust::Handle::new(42); + let mut edit_cmd = TableCellEditCommand::new(handle, &table, 0, 0); + let result = edit_cmd + .on_text_input("Line1/nLine2\\nLine3\nLine4") + .expect("cell edit commits"); + match result { + CmdResult::ReplaceMany(repl, _) => { + assert_eq!(repl.len(), 1); + if let acadrust::EntityType::Table(t) = &repl[0].1[0] { + assert_eq!(t.cell_text(0, 0).unwrap(), "Line1\\PLine2\\PLine3\\PLine4"); + } else { + panic!("expected Table entity"); + } + } + _ => panic!("expected ReplaceMany"), + } + } +} diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 5d6c46db4..bba45cb11 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -12,7 +12,7 @@ use acadrust::EntityType; use glam::DVec3; use crate::t; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::scene::model::wire_model::WireModel; enum Step { @@ -88,14 +88,12 @@ impl CadCommand for AttdefCommand { } } - fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::Insertion { .. }) - } - - fn wants_text_with_spaces(&self) -> bool { - // Tag must be a single token (so disallow spaces there); the - // Prompt and Default value steps are free-form text. - matches!(self.step, Step::Prompt { .. } | Step::Default { .. }) + fn input_kind(&self) -> InputKind { + match self.step { + Step::Insertion { .. } => InputKind::Point, + Step::Tag => InputKind::SingleToken, + Step::Prompt { .. } | Step::Default { .. } => InputKind::FreeText, + } } fn on_text_input(&mut self, text: &str) -> Option { diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index e54671c98..3f65f0d80 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -4,7 +4,7 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::t; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -249,11 +249,11 @@ impl CadCommand for InsertBlockCommand { } } - fn wants_text_input(&self) -> bool { - matches!( - self.step, - Step::Name | Step::FillAttr { .. } | Step::Point { .. } - ) + fn input_kind(&self) -> InputKind { + match self.step { + Step::FillAttr { .. } => InputKind::FreeText, + Step::Name | Step::Point { .. } => InputKind::SingleToken, + } } fn point_step_accepts_keywords(&self) -> bool { @@ -263,11 +263,6 @@ impl CadCommand for InsertBlockCommand { matches!(self.step, Step::Point { .. }) && self.awaiting.is_none() } - fn wants_text_with_spaces(&self) -> bool { - // Block names don't embed whitespace, but attribute values do. - matches!(self.step, Step::FillAttr { .. }) - } - fn on_text_input(&mut self, text: &str) -> Option { match &self.step { Step::Name => { diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index 1f09cf384..8bef33f11 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -6,7 +6,7 @@ use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use crate::t; -use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::draw::draw::polyline::{ arc_sample_points, compute_bulge, seg_exit_tangent, update_tangent_after_arc, }; @@ -464,14 +464,16 @@ impl CadCommand for MviewCommand { } } - fn wants_text_input(&self) -> bool { - self.step == Step::RectangleFirst - || self.step == Step::ChooseView + fn input_kind(&self) -> InputKind { + if self.step == Step::ChooseView { + InputKind::FreeText + } else if self.step == Step::RectangleFirst || (self.step == Step::Polygon && !self.polygon.is_empty()) - } - - fn wants_text_with_spaces(&self) -> bool { - self.step == Step::ChooseView + { + InputKind::SingleToken + } else { + InputKind::Point + } } fn point_step_accepts_keywords(&self) -> bool {