feat(table): add TABLEDIT point picking, and consolidate InputKind

- Implement `TABLEDIT` command supporting viewport point-picking on cell interiors
- Support overlapping table hit testing with selection cycling and top-table resolution
- Support `/n` and `\n` multiline paragraph breaks (`\P`) in table cell text editing
- Unify text input modes via `InputKind` (`Point`, `SingleToken`, `FreeText`) on `CadCommand`
- Migrate 15+ commands across annotate, draw, insert, layout, and view modules
- Preserve text casing, literal spaces, and bypass arithmetic eval during FreeText input
- Add dedicated `tabledit_tests` and `free_text_entry_tests` test suites
This commit is contained in:
Karim Jerbi committed 2026-09-02 14:55:49 +01:00
1 parent bde9cc2715
commit d6a6dd6a7d
23 files changed
+860 -254

No files matched your search

+9 -8
View File
@@ -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<CmdResult> {
if self.awaiting_text {
let t = text.trim();
+7 -7
View File
@@ -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<CmdOption> {
+7 -7
View File
@@ -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<CmdOption> {
let Step::DimLine(selection) = self.step else {
return Vec::new();
+7 -7
View File
@@ -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<CmdOption> {
if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle {
vec![
+7 -7
View File
@@ -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<CmdOption> {
if matches!(self.step, Step::DimLine { .. })
&& !self.awaiting_text
+9 -8
View File
@@ -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<CmdResult> {
if self.awaiting_text {
let t = text.trim();
+7 -7
View File
@@ -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 {
+7 -7
View File
@@ -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 {
+7 -7
View File
@@ -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<CmdOption> {
if matches!(self.step, Step::LeaderEndpoint { .. })
&& !self.awaiting_text
+7 -7
View File
@@ -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<CmdOption> {
if matches!(self.step, Step::DimLine(_)) && !self.awaiting_text && !self.awaiting_angle {
vec![
+283 -8
View File
@@ -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<CmdResult> {
@@ -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<TableCellHit> {
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"),
}
}
}